1013 lines
34 KiB
Plaintext
1013 lines
34 KiB
Plaintext
<!-- 机构端 - 服务/商品编辑页面 -->
|
||
<template>
|
||
<view class="product-edit-page">
|
||
<!-- #ifdef MP-WEIXIN -->
|
||
<view style="padding-top: var(--status-bar-height); background-color: #ffffff; display: flex; flex-direction: row; align-items: flex-end; border-bottom: 1rpx solid #eeeeee; box-sizing: border-box; height: calc(88rpx + var(--status-bar-height));">
|
||
<view style="display: flex; flex-direction: row; align-items: center; padding: 0 30rpx; height: 88rpx;" @click="uni.navigateBack()">
|
||
<text style="font-size: 44rpx; color: #333333; line-height: 1; margin-right: 6rpx;">‹</text>
|
||
<text style="font-size: 28rpx; color: #333333;">返回</text>
|
||
</view>
|
||
</view>
|
||
<!-- #endif -->
|
||
<!-- 服务基本信息 -->
|
||
<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>
|
||
|
||
<!-- 商品图片 -->
|
||
<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">长者关怀价/专属补贴</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">关怀价折扣率</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="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="#09C39D"
|
||
/>
|
||
</view>
|
||
|
||
<view class="switch-item">
|
||
<text class="label">新增服务</text>
|
||
<switch
|
||
:checked="product.is_new"
|
||
@change="product.is_new = !product.is_new"
|
||
color="#09C39D"
|
||
/>
|
||
</view>
|
||
|
||
<view class="switch-item">
|
||
<text class="label">推荐服务</text>
|
||
<switch
|
||
:checked="product.is_featured"
|
||
@change="product.is_featured = !product.is_featured"
|
||
color="#09C39D"
|
||
/>
|
||
</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'
|
||
import { SUPA_URL, logSupaConfig } from '@/ak/config.uts'
|
||
|
||
// 严格对齐 categories.sql 真实字段,禁止自行添加或兼容不存在的字段
|
||
type CategoryType = {
|
||
id: string
|
||
name: string
|
||
icon: string
|
||
description: string
|
||
color: string
|
||
created_at: string
|
||
}
|
||
|
||
export default {
|
||
data() {
|
||
return {
|
||
productId: '',
|
||
isEdit: false,
|
||
categories: [] as CategoryType[],
|
||
categoryIndex: -1,
|
||
selectedCategory: null as CategoryType | null,
|
||
product: {
|
||
name: '',
|
||
subtitle: '',
|
||
category_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: ''
|
||
}
|
||
},
|
||
|
||
async 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) {}
|
||
}
|
||
}
|
||
|
||
// ── 启动诊断日志 ──
|
||
console.log('[product-edit] onLoad start')
|
||
console.log('[product-edit] mode:', productId ? '编辑' : '新增')
|
||
console.log('[product-edit] productId:', productId || '(none)')
|
||
logSupaConfig()
|
||
|
||
if (productId && productId !== '') {
|
||
this.productId = productId
|
||
this.isEdit = true
|
||
uni.setNavigationBarTitle({ title: '编辑服务' })
|
||
} else {
|
||
uni.setNavigationBarTitle({ title: '发布服务' })
|
||
}
|
||
this.initMerchantId()
|
||
|
||
// ── 先探活,通过后再加载业务数据 ──
|
||
const endpointOk = await this.checkSupabaseEndpoint()
|
||
if (!endpointOk) {
|
||
console.error('[product-edit] Supabase 端点不可用,跳过业务数据加载')
|
||
return
|
||
}
|
||
|
||
await this.loadCategories()
|
||
if (this.isEdit && productId !== '') {
|
||
this.loadProductDetail(productId)
|
||
}
|
||
},
|
||
|
||
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)
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Supabase 端点联通检查:直接用真实业务表 categories 做 limit(1) 探测。
|
||
* - 200:端点正常,anon 有读权限
|
||
* - 401/403:端点可达,但权限不足(打日志,仍返回 true 表示端点在线)
|
||
* - HTML/非 JSON:SUPA_URL 指向了非 PostgREST 服务 → false
|
||
* - 网络错误:服务不可达 → false
|
||
*/
|
||
async checkSupabaseEndpoint() : Promise<boolean> {
|
||
const baseUrl = supa.baseUrl
|
||
console.log('[supa-check] SUPA_URL:', baseUrl)
|
||
try {
|
||
const response = await supa
|
||
.from('categories')
|
||
.select('*')
|
||
.limit(1)
|
||
.execute()
|
||
|
||
const status = response.status
|
||
console.log('[supa-check] categories probe status:', status)
|
||
|
||
// ak-req 遇到非 JSON 响应会包装 { raw: '...' }
|
||
const d = response.data as any
|
||
const rawText = (d != null && typeof d === 'object' && d['raw'] != null)
|
||
? String(d['raw'])
|
||
: null
|
||
|
||
if (rawText != null) {
|
||
const looksHtml = rawText.trimStart().startsWith('<')
|
||
console.error('[supa-check] ✗ 收到非 JSON 响应,SUPA_URL 可能指向了错误服务(反向代理/Studio UI)')
|
||
console.error('[supa-check] content looks like html:', looksHtml)
|
||
console.error('[supa-check] raw preview:', rawText.substring(0, 250))
|
||
uni.showToast({ title: 'Supabase连接失败,查看日志', icon: 'none', duration: 4000 })
|
||
return false
|
||
}
|
||
|
||
if (status === 200) {
|
||
const arr = response.data as any[]
|
||
console.log('[supa-check] ✓ categories 可读,行数:', arr != null ? arr.length : 0)
|
||
return true
|
||
}
|
||
if (status === 401 || status === 403) {
|
||
console.error('[supa-check] ⚠ 端点可达但 categories 查询返回', status)
|
||
console.error('[supa-check] ★ 这是认证层问题,不是 categories 表字段问题')
|
||
console.error('[supa-check] 请运维核查 self-hosted 实例配置:')
|
||
console.error('[supa-check] 1. SUPA_KEY 是否属于 SUPA_URL 对应的 Supabase 实例(9126 Kong API 网关)')
|
||
console.error('[supa-check] 2. 9126 和 9127 是否属于同一套 docker-compose / 同一实例')
|
||
console.error('[supa-check] 3. Kong 是否已 reload 最新 consumer 配置(ANON_KEY 与 SUPA_KEY 一致)')
|
||
console.error('[supa-check] 4. categories 表是否有 RLS policy 允许 anon SELECT(认证通过后再查)')
|
||
console.error('[supa-check] 5. SUPABASE_PUBLIC_URL 是否仍是 localhost:8000(Connect 显示值不代表前端请求地址)')
|
||
console.error('[supa-check] error:', JSON.stringify(response.error))
|
||
uni.showToast({ title: '认证失败(401),请运维核查 Supabase 配置', icon: 'none', duration: 4000 })
|
||
// 401 = 认证不过,暂停后续请求避免日志污染
|
||
return false
|
||
}
|
||
if (status === 404) {
|
||
console.error('[supa-check] ⚠ categories 表不存在(404)')
|
||
console.error('[supa-check] 请在 Supabase Studio 确认表名是否为 categories,schema 是否为 public')
|
||
return true // PostgREST 在线,但表名/schema 可能有误
|
||
}
|
||
|
||
console.log('[supa-check] status:', status, '— PostgREST 在线')
|
||
return true
|
||
} catch (e) {
|
||
console.error('[supa-check] ✗ 异常:', e)
|
||
uni.showToast({ title: 'Supabase连接失败', icon: 'none', duration: 3000 })
|
||
return false
|
||
}
|
||
},
|
||
|
||
async loadMemberLevels() {
|
||
this.memberLevels = []
|
||
try {
|
||
console.log('[member-levels] request start, table: ml_member_levels')
|
||
const response = await supa
|
||
.from('ml_member_levels')
|
||
.select('*')
|
||
.eq('is_active', true)
|
||
.order('level_rank', { ascending: true })
|
||
.execute()
|
||
|
||
console.log('[member-levels] response status:', response.status)
|
||
|
||
if (response.error != null) {
|
||
console.error('[member-levels] query error:', response.error)
|
||
console.error('[member-levels] status:', response.status)
|
||
const d = response.data as any
|
||
if (d != null && typeof d === 'object' && d['raw'] != null) {
|
||
console.error('[member-levels] raw preview:', String(d['raw']).substring(0, 200))
|
||
}
|
||
return
|
||
}
|
||
|
||
const rawData = response.data as any[]
|
||
console.log('[member-levels] raw length:', rawData != null ? rawData.length : 0)
|
||
if (rawData == null) return
|
||
|
||
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)
|
||
}
|
||
console.log('[member-levels] loaded:', this.memberLevels.length)
|
||
|
||
// 如果是编辑模式,还需要加载已有的会员价
|
||
if (this.isEdit) {
|
||
this.loadMemberPrices()
|
||
}
|
||
} catch (e) {
|
||
console.error('[member-levels] exception:', e)
|
||
}
|
||
},
|
||
|
||
// 严格对齐 categories.sql 真实字段:id, name, icon, description, color, created_at
|
||
async loadCategories() {
|
||
this.categories = []
|
||
try {
|
||
console.log('[categories] 开始从 categories 表读取分类(严格字段映射)...')
|
||
const response = await supa
|
||
.from('categories')
|
||
.select('id,name,icon,description,color,created_at')
|
||
.limit(100)
|
||
.execute()
|
||
|
||
console.log('[categories] response status:', response.status)
|
||
|
||
if (response.error != null) {
|
||
console.error('[categories] 查询失败:', response.error)
|
||
uni.showToast({ title: '分类加载失败', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
const rawData = response.data as any[]
|
||
console.log('[categories] raw length:', rawData != null ? rawData.length : 0)
|
||
|
||
if (rawData == null || rawData.length === 0) {
|
||
console.error('[categories] 返回数据为空,请检查:表名/RLS策略/anon key')
|
||
uni.showToast({ title: '分类加载失败:数据为空', icon: 'none' })
|
||
return
|
||
}
|
||
|
||
// 严格字段映射:仅接受 categories.sql 定义的 6 个字段
|
||
// 不再做 category_id/uuid/category_name/title/label 等兼容回退
|
||
for (let i = 0; i < rawData.length; i++) {
|
||
const item = rawData[i] as any
|
||
|
||
const catId = item['id'] != null ? String(item['id']) : ''
|
||
const catName = item['name'] != null ? String(item['name']) : ''
|
||
|
||
// id 或 name 缺失则跳过(categories.sql 中两者均为 NOT NULL)
|
||
if (catId === '' || catName === '') continue
|
||
|
||
this.categories.push({
|
||
id: catId,
|
||
name: catName,
|
||
icon: item['icon'] != null ? String(item['icon']) : '',
|
||
description: item['description'] != null ? String(item['description']) : '',
|
||
color: item['color'] != null ? String(item['color']) : '',
|
||
created_at: item['created_at'] != null ? String(item['created_at']) : ''
|
||
} as CategoryType)
|
||
}
|
||
|
||
console.log('[categories] 严格映射完成,共加载:', this.categories.length, '条')
|
||
|
||
// 编辑模式:分类加载完成后尝试回填
|
||
if (this.isEdit && this.product.category_id) {
|
||
this.tryCategoryBackfill()
|
||
}
|
||
} catch (e) {
|
||
console.error('[categories] 获取分类异常:', e)
|
||
uni.showToast({ title: '分类加载异常', icon: 'none' })
|
||
}
|
||
},
|
||
|
||
// 编辑态分类回填:categories 加载完成 AND product.category_id 已就位时调用
|
||
tryCategoryBackfill() {
|
||
if (!this.product.category_id || this.categories.length === 0) return
|
||
const idx = this.categories.findIndex(c => c.id === this.product.category_id)
|
||
if (idx >= 0) {
|
||
this.categoryIndex = idx
|
||
this.selectedCategory = this.categories[idx]
|
||
console.log('[categories] 编辑态回填成功:', this.selectedCategory.name)
|
||
} else {
|
||
console.warn('[categories] 编辑态回填未找到匹配分类, category_id=', this.product.category_id)
|
||
}
|
||
},
|
||
|
||
async loadBrands() {
|
||
this.brands = []
|
||
try {
|
||
console.log('[brands] request start, table: ml_brands')
|
||
const response = await supa
|
||
.from('ml_brands')
|
||
.select('id, name, logo_url')
|
||
.eq('is_active', true)
|
||
.order('name', { ascending: true })
|
||
.execute()
|
||
|
||
console.log('[brands] response status:', response.status)
|
||
|
||
if (response.error != null) {
|
||
console.error('[brands] query error:', response.error)
|
||
console.error('[brands] status:', response.status)
|
||
// 检查是否是非 JSON 响应包裹
|
||
const d = response.data as any
|
||
if (d != null && typeof d === 'object' && d['raw'] != null) {
|
||
console.error('[brands] raw preview:', String(d['raw']).substring(0, 200))
|
||
}
|
||
return
|
||
}
|
||
|
||
const rawData = response.data as any[]
|
||
console.log('[brands] raw length:', rawData != null ? rawData.length : 0)
|
||
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)
|
||
}
|
||
console.log('[brands] loaded:', this.brands.length)
|
||
} catch (e) {
|
||
console.error('[brands] exception:', 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.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')
|
||
|
||
// 尝试回填分类(若此时 categories 已加载则立即生效;若尚未完成则由 loadCategories 结尾再次触发)
|
||
if (this.product.category_id) {
|
||
this.tryCategoryBackfill()
|
||
}
|
||
} 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
|
||
},
|
||
|
||
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,
|
||
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
|
||
}
|
||
|
||
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;
|
||
padding-left: 16rpx;
|
||
border-left: 6rpx solid #09C39D;
|
||
}
|
||
|
||
.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;
|
||
flex-direction: row;
|
||
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;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
}
|
||
|
||
.picker-value {
|
||
font-size: 28rpx;
|
||
color: #333;
|
||
}
|
||
|
||
.switch-item {
|
||
display: flex;
|
||
flex-direction: row;
|
||
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-direction: row;
|
||
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;
|
||
flex-direction: row;
|
||
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;
|
||
flex-direction: row;
|
||
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, #A6F1E4 0%, #69DFC2 100%);
|
||
color: #fff;
|
||
}
|
||
</style>
|
||
|