Files
medical-mall/pages/mall/merchant/product-edit.uvue
2026-03-20 17:30:30 +08:00

993 lines
32 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 商家端 - 商品编辑页面 -->
<template>
<view class="product-edit-page">
<!-- 商品基本信息 -->
<view class="section">
<view class="section-title">基本信息</view>
<view class="form-item">
<text class="label">商品名称 *</text>
<input
class="input"
v-model="product.name"
placeholder="请输入商品名称"
maxlength="100"
/>
</view>
<view class="form-item">
<text class="label">商品副标题</text>
<input
class="input"
v-model="product.subtitle"
placeholder="请输入商品副标题"
maxlength="200"
/>
</view>
<view class="form-item">
<text class="label">商品分类 *</text>
<picker
class="picker"
:range="categories"
range-key="name"
:value="categoryIndex"
@change="onCategoryChange"
>
<view class="picker-value">
{{ selectedCategory?.name || '请选择分类' }}
</view>
</picker>
</view>
<view class="form-item">
<text class="label">商品品牌</text>
<picker
class="picker"
:range="brands"
range-key="name"
:value="brandIndex"
@change="onBrandChange"
>
<view class="picker-value">
{{ selectedBrand?.name || '请选择品牌' }}
</view>
</picker>
</view>
</view>
<!-- 商品图片 -->
<view class="section">
<view class="section-title">商品图片</view>
<view class="image-section">
<text class="label">主图 *</text>
<view class="image-grid">
<view class="image-item main-image" @click="chooseMainImage">
<image v-if="product.main_image_url" :src="product.main_image_url" mode="aspectFill" class="preview-image"/>
<view v-else class="add-image">+</view>
</view>
</view>
</view>
<view class="image-section">
<text class="label">轮播图</text>
<view class="image-grid">
<view
v-for="(img, index) in product.imageList"
:key="index"
class="image-item"
>
<image :src="img" mode="aspectFill" class="preview-image"/>
<view class="delete-btn" @click="removeImage(index)">×</view>
</view>
<view class="image-item add-image" @click="chooseImages" v-if="product.imageList.length < 5">
+
</view>
</view>
</view>
</view>
<!-- 价格库存 -->
<view class="section">
<view class="section-title">价格库存</view>
<view class="form-item">
<text class="label">销售价 *</text>
<view class="price-input">
<text class="unit">¥</text>
<input
class="input"
type="digit"
v-model="product.base_price"
placeholder="0.00"
/>
</view>
</view>
<view class="form-item">
<text class="label">市场价</text>
<view class="price-input">
<text class="unit">¥</text>
<input
class="input"
type="digit"
v-model="product.market_price"
placeholder="0.00"
/>
</view>
</view>
<view class="form-item">
<text class="label">成本价</text>
<view class="price-input">
<text class="unit">¥</text>
<input
class="input"
type="digit"
v-model="product.cost_price"
placeholder="0.00"
/>
</view>
</view>
<view class="form-item">
<text class="label">VIP独立折扣</text>
<switch :checked="product.is_vip_discount" @change="e => { product.is_vip_discount = e.detail.value as boolean }" />
</view>
<view class="form-item" v-if="product.is_vip_discount">
<text class="label">VIP折扣率</text>
<input
class="input"
type="digit"
v-model="product.vip_discount_rate"
placeholder="如 0.85 代表 85 折(空代表采用全局)"
/>
</view>
<view class="form-item">
<text class="label">总库存 *</text>
<input
class="input"
type="number"
v-model="product.total_stock"
placeholder="0"
/>
</view>
<view class="form-item">
<text class="label">库存预警</text>
<input
class="input"
type="number"
v-model="product.warning_stock"
placeholder="库存低于此值提醒"
/>
</view>
</view>
<!-- 会员阶梯价 -->
<view class="section">
<view class="section-title">会员等级价格 (选填)</view>
<view class="section-desc">若不填写则按照商品销售价或默认折扣计算</view>
<view v-for="(level, index) in memberLevels" :key="index" class="form-item">
<text class="label">{{ level.name }}价格</text>
<view class="price-input">
<text class="unit">¥</text>
<input
class="input"
v-model="level.price"
type="digit"
placeholder="专属折扣价"
/>
</view>
</view>
</view>
<!-- 商品属性 -->
<view class="section">
<view class="section-title">商品属性</view>
<view class="form-item">
<text class="label">商品单位</text>
<input
class="input"
v-model="product.unit"
placeholder="如: 件, 盒, 箱"
/>
</view>
<view class="switch-item">
<text class="label">热卖商品</text>
<switch
:checked="product.is_hot"
@change="product.is_hot = !product.is_hot"
color="#007AFF"
/>
</view>
<view class="switch-item">
<text class="label">新品上架</text>
<switch
:checked="product.is_new"
@change="product.is_new = !product.is_new"
color="#007AFF"
/>
</view>
<view class="switch-item">
<text class="label">推荐商品</text>
<switch
:checked="product.is_featured"
@change="product.is_featured = !product.is_featured"
color="#007AFF"
/>
</view>
</view>
<!-- 商品详情 -->
<view class="section">
<view class="section-title">商品详情</view>
<view class="form-item">
<text class="label">商品描述</text>
<textarea
class="textarea"
v-model="product.description"
placeholder="请输入商品详细描述"
:maxlength="2000"
/>
</view>
</view>
<!-- 提交按钮 -->
<view class="submit-bar">
<view class="submit-btn primary" @click="saveProduct">
{{ isEdit ? '保存修改' : '发布商品' }}
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type CategoryType = {
id: string
name: string
}
type BrandType = {
id: string
name: string
logo_url: string
}
type MemberLevelType = {
id: string
name: string
level_rank: number
discount_rate: number
price: string // 绑定输入框用
}
export default {
data() {
return {
productId: '',
isEdit: false,
categories: [] as CategoryType[],
categoryIndex: -1,
selectedCategory: null as CategoryType | null,
brands: [] as BrandType[],
brandIndex: -1,
selectedBrand: null as BrandType | null,
memberLevels: [] as MemberLevelType[],
product: {
name: '',
subtitle: '',
category_id: '',
brand_id: '',
main_image_url: '',
imageList: [] as string[],
base_price: '',
market_price: '',
cost_price: '',
total_stock: '',
warning_stock: '10',
unit: '件',
is_hot: false,
is_new: false,
is_featured: false,
is_vip_discount: true,
vip_discount_rate: '',
description: ''
},
merchantId: ''
}
},
onLoad(options: any) {
let productId = ''
if (options) {
const keys = Object.keys(options as object)
for (let i = 0; i < keys.length; i++) {
if (keys[i] === 'productId') {
productId = String((options as Record<string, any>)[keys[i]])
}
}
if (!productId && options['productId']) {
productId = String(options['productId'])
}
// 兼容某些平台
if (!productId) {
try {
const optsStr = JSON.stringify(options)
const optsObj = JSON.parse(optsStr) as Record<string, any>
if (optsObj['productId']) {
productId = String(optsObj['productId'])
}
} catch(e) {}
}
}
if (productId && productId !== '') {
this.productId = productId
this.isEdit = true
uni.setNavigationBarTitle({ title: '编辑商品' })
this.loadProductDetail(productId)
} else {
uni.setNavigationBarTitle({ title: '添加商品' })
}
this.initMerchantId()
this.loadCategories()
this.loadBrands()
this.loadMemberLevels()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
if (session != null && session.user != null) {
this.merchantId = (session.user as any)['id'] != null ? String((session.user as any)['id']) : ''
}
if (!this.merchantId) {
this.merchantId = uni.getStorageSync('user_id') || ''
}
} catch (e) {
console.error('获取商户ID失败:', e)
}
},
async loadMemberLevels() {
try {
const response = await supa
.from('ml_member_levels')
.select('*')
.eq('is_active', true)
.order('level_rank', { ascending: true })
.execute()
if (response.error != null) {
console.error('获取会员等级失败:', response.error)
return
}
const rawData = response.data as any[]
if (rawData == null) return
this.memberLevels = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as any
this.memberLevels.push({
id: item['id'] != null ? String(item['id']) : '',
name: item['name'] != null ? String(item['name']) : '',
level_rank: item['level_rank'] != null ? parseInt(String(item['level_rank'])) : 0,
discount_rate: item['discount_rate'] != null ? parseFloat(String(item['discount_rate'])) : 1.0,
price: ''
} as MemberLevelType)
}
// 如果是编辑模式,还需要加载已有的会员价
if (this.isEdit) {
this.loadMemberPrices()
}
} catch (e) {
console.error('获取会员等级异常:', e)
}
},
async loadMemberPrices() {
try {
const response = await supa
.from('ml_product_member_prices')
.select('*')
.eq('product_id', this.productId)
.execute()
if (response.error != null) {
console.error('获取会员价失败:', response.error)
return
}
const rawData = response.data as any[]
if (rawData == null || rawData.length == 0) return
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as any
const levelId = String(item['level_id'])
const price = String(item['member_price'])
const index = this.memberLevels.findIndex(lv => lv.id === levelId)
if (index >= 0) {
this.memberLevels[index].price = price
}
}
} catch (e) {
console.error('获取会员价异常:', e)
}
},
async loadCategories() {
try {
const response = await supa
.from('ml_categories')
.select('id, name')
.eq('is_active', true)
.order('sort_order', { ascending: true })
.execute()
if (response.error != null) {
console.error('获取分类失败:', response.error)
return
}
const rawData = response.data as any[]
if (rawData == null) return
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as any
this.categories.push({
id: item['id'] != null ? String(item['id']) : '',
name: item['name'] != null ? String(item['name']) : ''
} as CategoryType)
}
} catch (e) {
console.error('获取分类异常:', e)
}
},
async loadBrands() {
try {
const response = await supa
.from('ml_brands')
.select('id, name, logo_url')
.eq('is_active', true)
.order('name', { ascending: true })
.execute()
if (response.error != null) {
console.error('获取品牌失败:', response.error)
return
}
const rawData = response.data as any[]
if (rawData == null) return
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as any
this.brands.push({
id: item['id'] != null ? String(item['id']) : '',
name: item['name'] != null ? String(item['name']) : '',
logo_url: item['logo_url'] != null ? String(item['logo_url']) : ''
} as BrandType)
}
} catch (e) {
console.error('获取品牌异常:', e)
}
},
async loadProductDetail(productId: string) {
try {
uni.showLoading({ title: '加载商品中...' })
const response = await supa
.from('ml_products')
.select('*')
.eq('id', productId)
.single()
.execute()
uni.hideLoading()
if (response.error != null) {
console.error('获取详情失败:', response.error)
uni.showToast({ title: '没有找到该商品', icon: 'none' })
return
}
let rawData = response.data as any
if (rawData == null) return
// 防止Supabase某些版本把single()仍返回数组的坑
if (Array.isArray(rawData) && rawData.length > 0) {
rawData = rawData[0]
}
const getStr = (key: string): string => { try { return rawData[key] != null ? String(rawData[key]) : '' } catch(e){ return '' } }
const getBool = (key: string): boolean => { try { return rawData[key] === true || rawData[key] === 'true' } catch(e){ return false } }
this.product.name = getStr('name')
this.product.subtitle = getStr('subtitle')
this.product.category_id = getStr('category_id')
this.product.brand_id = getStr('brand_id')
this.product.main_image_url = getStr('main_image_url')
this.product.imageList = this.parseImageUrls(getStr('image_urls'))
this.product.base_price = getStr('base_price')
this.product.market_price = getStr('market_price')
this.product.cost_price = getStr('cost_price')
this.product.total_stock = getStr('total_stock')
this.product.warning_stock = getStr('warning_stock') || '10'
this.product.unit = getStr('unit') || '件'
this.product.is_hot = getBool('is_hot')
this.product.is_new = getBool('is_new')
this.product.is_featured = getBool('is_featured')
const _isVip = rawData['is_vip_discount']
this.product.is_vip_discount = _isVip == null ? true : getBool('is_vip_discount')
this.product.vip_discount_rate = getStr('vip_discount_rate')
this.product.description = getStr('description')
if (this.product.category_id) {
this.categoryIndex = this.categories.findIndex(c => c.id === this.product.category_id)
if (this.categoryIndex >= 0) {
this.selectedCategory = this.categories[this.categoryIndex]
}
}
if (this.product.brand_id) {
this.brandIndex = this.brands.findIndex(b => b.id === this.product.brand_id)
if (this.brandIndex >= 0) {
this.selectedBrand = this.brands[this.brandIndex]
}
}
} catch (e) {
uni.hideLoading()
console.error('获取商品详情异常:', e)
uni.showToast({ title: '加载异常: ' + String(e), icon: 'none', duration: 3000 })
}
},
parseImageUrls(urlsStr: string): string[] {
if (!urlsStr) return []
try {
const arr = JSON.parse(urlsStr)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
},
onCategoryChange(e: any) {
const index = e.detail.value as number
this.categoryIndex = index
this.selectedCategory = this.categories[index]
this.product.category_id = this.selectedCategory.id
},
onBrandChange(e: any) {
const index = e.detail.value as number
this.brandIndex = index
this.selectedBrand = this.brands[index]
this.product.brand_id = this.selectedBrand.id
},
chooseMainImage() {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
success: (res) => {
this.product.main_image_url = res.tempFilePaths[0]
}
})
},
chooseImages() {
const remainCount = 5 - this.product.imageList.length
uni.chooseImage({
count: remainCount,
sizeType: ['compressed'],
success: (res) => {
this.product.imageList = [...this.product.imageList, ...res.tempFilePaths]
}
})
},
removeImage(index: number) {
this.product.imageList.splice(index, 1)
},
async uploadImageToSupa(localPath: string): Promise<string> {
if (localPath.startsWith('http://') || localPath.startsWith('https://')) {
return localPath
}
let ext = '.jpg'
const dotIndex = localPath.lastIndexOf('.')
if (dotIndex > -1) {
ext = localPath.substring(dotIndex).toLowerCase()
}
const uuid = Date.now().toString() + '_' + Math.floor(Math.random() * 1000)
const remotePath = `products/${this.merchantId}_${uuid}${ext}`
try {
const uploadResult = await supa.storage.from('zhipao').upload(remotePath, localPath, {})
if (uploadResult.error != null) {
console.error('上传图片失败:', uploadResult.error)
return localPath
}
return supa.storage.getPublicUrl('zhipao', remotePath)
} catch (e) {
console.error('上传图片异常:', e)
return localPath
}
},
async saveProduct() {
if (!this.product.name) {
uni.showToast({ title: '请输入商品名称', icon: 'none' })
return
}
if (!this.product.category_id) {
uni.showToast({ title: '请选择商品分类', icon: 'none' })
return
}
if (!this.product.base_price) {
uni.showToast({ title: '请输入销售价', icon: 'none' })
return
}
if (!this.product.total_stock) {
uni.showToast({ title: '请输入总库存', icon: 'none' })
return
}
if (this.product.is_vip_discount && this.product.vip_discount_rate !== '') {
const rate = parseFloat(this.product.vip_discount_rate)
if (isNaN(rate) || rate <= 0 || rate > 1) {
uni.showToast({ title: 'VIP折扣率需在0~1之间', icon: 'none' })
return
}
}
uni.showLoading({ title: '保存中...' })
try {
let finalMainImage = this.product.main_image_url
if (finalMainImage != '') {
finalMainImage = await this.uploadImageToSupa(finalMainImage)
}
const finalImageList = [] as string[]
for (let i = 0; i < this.product.imageList.length; i++) {
const img = await this.uploadImageToSupa(this.product.imageList[i])
finalImageList.push(img)
}
const imageUrlsStr = JSON.stringify(finalImageList)
const productData = {
merchant_id: this.merchantId,
name: this.product.name,
subtitle: this.product.subtitle,
category_id: this.product.category_id,
brand_id: this.product.brand_id || null,
main_image_url: finalMainImage,
image_urls: imageUrlsStr,
base_price: this.product.base_price ? parseFloat(this.product.base_price) : 0,
market_price: this.product.market_price ? parseFloat(this.product.market_price) : null,
cost_price: this.product.cost_price ? parseFloat(this.product.cost_price) : null,
total_stock: parseInt(this.product.total_stock),
available_stock: parseInt(this.product.total_stock),
is_hot: this.product.is_hot,
is_new: this.product.is_new,
is_featured: this.product.is_featured,
is_vip_discount: this.product.is_vip_discount,
vip_discount_rate: this.product.vip_discount_rate ? parseFloat(this.product.vip_discount_rate) : null,
description: this.product.description,
status: 1,
updated_at: new Date().toISOString()
} as UTSJSONObject
let response : any = null
if (this.isEdit) {
const updateData = {} as UTSJSONObject
const keys = UTSJSONObject.keys(productData)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
if (key != 'status') {
updateData[key] = productData[key]
}
}
console.log('执行产品更新, ID:', this.productId)
const updateResponse = await supa
.from('ml_products')
.update(updateData)
.eq('id', this.productId)
.execute()
if (updateResponse.error != null) {
throw new Error('产品更新失败: ' + String(updateResponse.error!.message))
}
response = updateResponse
} else {
productData['created_at'] = new Date().toISOString()
productData['product_code'] = 'P' + Date.now().toString()
console.log('执行新产品插入')
const insertResponse = await supa
.from('ml_products')
.insert(productData)
.execute()
if (insertResponse.error != null) {
throw new Error('产品发布失败: ' + String(insertResponse.error!.message))
}
response = insertResponse
}
// 保存会员价
let targetProductId = this.isEdit ? this.productId : ''
if (response != null && response.data != null) {
const responseData = response.data
if (Array.isArray(responseData)) {
const dataArr = responseData as any[]
if (dataArr.length > 0) {
const firstRow = dataArr[0] as UTSJSONObject
if (firstRow['id'] != null) {
targetProductId = String(firstRow['id'])
}
}
} else if (responseData instanceof UTSJSONObject) {
const dataObj = responseData as UTSJSONObject
if (dataObj['id'] != null) {
targetProductId = String(dataObj['id'])
}
}
}
console.log('最终目标产品ID:', targetProductId)
if (targetProductId && targetProductId !== '' && targetProductId !== 'undefined') {
// 1. 先删除旧的会员价
if (this.isEdit) {
console.log('删除旧会员价:', targetProductId)
await supa.from('ml_product_member_prices').delete().eq('product_id', targetProductId).execute()
}
// 2. 插入新的会员价
for (let i = 0; i < this.memberLevels.length; i++) {
const level = this.memberLevels[i]
if (level.price && level.price > 0) {
const memberPriceData = {
product_id: targetProductId,
level_id: level.id,
member_price: level.price,
created_at: new Date().toISOString()
} as UTSJSONObject
const insertRes = await supa
.from('ml_product_member_prices')
.insert(memberPriceData)
.execute()
if (insertRes.error != null) {
console.error('插入会员价失败', insertRes.error)
}
}
}
}
uni.hideLoading()
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (e) {
uni.hideLoading()
console.error('保存商品异常:', e)
uni.showToast({ title: '保存异常: ' + String(e), icon: 'none' })
}
}
}
}
</script>
<style>
.product-edit-page {
background-color: #f5f5f5;
min-height: 100vh;
padding-bottom: 160rpx;
}
.section {
background-color: #fff;
margin-bottom: 20rpx;
padding: 30rpx;
}
.section-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 30rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f5f5f5;
}
.section-desc {
font-size: 24rpx;
color: #999;
margin-top: -20rpx;
margin-bottom: 30rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.label {
font-size: 28rpx;
color: #333;
display: block;
margin-bottom: 16rpx;
}
.input {
height: 72rpx;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 0 20rpx;
font-size: 28rpx;
}
.price-input {
display: flex;
align-items: center;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 0 20rpx;
}
.price-input .unit {
font-size: 28rpx;
color: #666;
margin-right: 10rpx;
}
.price-input .input {
flex: 1;
border: none;
padding: 0;
}
.picker {
height: 72rpx;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 0 20rpx;
display: flex;
align-items: center;
}
.picker-value {
font-size: 28rpx;
color: #333;
}
.switch-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
.switch-item:last-child {
border-bottom: none;
}
.textarea {
width: 100%;
height: 200rpx;
border: 1rpx solid #e5e5e5;
border-radius: 8rpx;
padding: 20rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.image-section {
margin-bottom: 30rpx;
}
.image-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.image-item {
position: relative;
width: 150rpx;
height: 150rpx;
border-radius: 8rpx;
overflow: hidden;
}
.main-image {
width: 200rpx;
height: 200rpx;
}
.preview-image {
width: 100%;
height: 100%;
}
.add-image {
width: 100%;
height: 100%;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
font-size: 60rpx;
color: #999;
border: 2rpx dashed #ddd;
border-radius: 8rpx;
}
.delete-btn {
position: absolute;
top: 5rpx;
right: 5rpx;
width: 36rpx;
height: 36rpx;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 28rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.submit-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background-color: #fff;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
}
.submit-btn {
height: 88rpx;
line-height: 88rpx;
text-align: center;
font-size: 32rpx;
font-weight: bold;
border-radius: 44rpx;
}
.submit-btn.primary {
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
color: #fff;
}
</style>