Files
medical-mall/pages/mall/admin/product/product-management/edit.uvue

729 lines
22 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="page-header">
<view class="back-link" @click="goBack">
<text class="arrow"><</text>
<text class="back-txt">返回</text>
</view>
<text class="header-title">编辑商品</text>
</view>
<!-- 步骤层 -->
<view class="steps-card">
<view class="step-items">
<view v-for="(step, index) in steps" :key="index" class="step-item" :class="{ active: activeStep === index }">
<text class="step-txt">{{ step }}</text>
<view v-if="index < steps.length - 1" class="step-line"></view>
</view>
</view>
</view>
<!-- 表单内容 -->
<view class="form-card">
<view class="form-item align-center">
<view class="label"><text class="required">*</text><text>商品类型:</text></view>
<view class="input-wrap">
<view class="radio-group">
<view class="radio-item active">
<text class="radio-circle on"></text>
<view class="radio-txt">
<text class="main">普通商品</text>
<text class="sub">(物流发货)</text>
</view>
</view>
</view>
</view>
</view>
<view class="form-item align-center">
<view class="label"><text class="required">*</text><text>商品名称:</text></view>
<view class="input-wrap">
<view class="input-box">
<input class="real-input" v-model="formData.name" placeholder="请输入商品名称" />
</view>
</view>
</view>
<view class="form-item align-center">
<view class="label"><text class="required">*</text><text>单位:</text></view>
<view class="input-wrap">
<view class="input-box small">
<input class="real-input" v-model="formData.attributes.unit" placeholder="例: 件" />
</view>
</view>
</view>
<view class="form-item multi-line">
<view class="label"><text class="required">*</text><text>商品轮播图:</text></view>
<view class="input-wrap">
<view class="image-uploader">
<view v-for="(img, i) in formData.image_urls" :key="i" class="img-item">
<image :src="img" mode="aspectFill" />
<view v-if="i === 0" class="main-tag">主图</view>
<view class="img-close" @click="removeImage(i)">×</view>
</view>
<view class="upload-btn" @click="uploadImage" v-if="formData.image_urls.length < 10">
<text class="icon">+</text>
<text class="upload-txt">添加图片</text>
</view>
</view>
<text class="tip">建议尺寸800*800默认首张图为主图最多上传10张</text>
</view>
</view>
<view class="form-item multi-line">
<view class="label"><text>添加视频:</text></view>
<view class="input-wrap">
<view class="image-uploader">
<view v-for="(vid, i) in formData.video_urls" :key="i" class="img-item video-item">
<video :src="vid" style="width: 100%; height: 100%; border-radius: 4px;" :controls="false"></video>
<view class="img-close" @click="removeVideo(i)">×</view>
</view>
<view class="upload-btn v-btn" @click="uploadVideo" v-if="formData.video_urls.length < 1">
<text class="v-icon">📹</text>
<text class="upload-txt">上传视频</text>
</view>
</view>
<text class="tip">建议时长9~30秒视频宽高比16:9</text>
</view>
</view>
<view class="form-item align-center">
<view class="label"><text class="required">*</text><text>商品分类:</text></view>
<view class="input-wrap">
<picker :range="categories" @change="onCategoryChange">
<view class="mock-btn-select">
<text>{{ categoryName || '请选择分类' }}</text>
</view>
</picker>
</view>
</view>
<view class="form-item align-center">
<view class="label"><text class="required">*</text><text>商品售价:</text></view>
<view class="input-wrap">
<view class="input-box small">
<input class="real-input" type="number" v-model="formData.base_price" placeholder="请输入售价" />
<text class="unit">元</text>
</view>
</view>
</view>
<view class="form-item align-center">
<view class="label"><text class="required">*</text><text>商品库存:</text></view>
<view class="input-wrap">
<view class="input-box small">
<input class="real-input" type="number" v-model="formData.available_stock" placeholder="请输入库存" />
<text class="unit">件</text>
</view>
</view>
</view>
<view class="form-item align-center">
<view class="label"><text>商品标签:</text></view>
<view class="input-wrap">
<view class="tag-selector">
<view v-for="(tg, index) in formData.tags" :key="index" class="tag-item">
<text>{{ tg }}</text>
<text class="close" @click="removeTag(index)">×</text>
</view>
<text class="add-link" @click="addTag">+添加标签</text>
</view>
</view>
</view>
<view class="form-item align-center">
<view class="label"><text>商品状态:</text></view>
<view class="input-wrap">
<view class="radio-group-simple">
<view class="radio-simple" :class="{ on: formData.status === 1 }" @click="formData.status = 1">
<text class="dot"></text>
<text>上架</text>
</view>
<view class="radio-simple" :class="{ on: formData.status === 2 }" @click="formData.status = 2">
<text class="dot"></text>
<text>下架</text>
</view>
</view>
</view>
</view>
</view>
<view class="footer-btns">
<button class="btn-save" @click="saveDraftProduct">草稿箱</button>
<button class="btn-next" @click="saveAndNext">下一步</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance'
import { SUPA_URL } from '@/ak/config.uts'
const activeStep = ref(0)
const steps = ['基础信息', '规格库存', '商品详情', '物流设置', '会员价/佣金', '营销设置', '其他设置']
interface CategoryOption {
id: string
name: string
}
const categoryOptions = ref<CategoryOption[]>([])
const categories = ref<string[]>([])
const categoryName = ref('')
const formData = ref({
id: '',
merchant_id: '',
category_id: '',
name: '',
base_price: 0,
available_stock: 0,
total_stock: 0,
main_image_url: '',
image_urls: [] as string[],
video_urls: [] as string[],
status: 1, // 1上架 2下架 3草稿 4删除
tags: [] as string[],
attributes: { unit: '件' } as Record<string, any>,
published_at: null as string | null
})
onMounted(async () => {
await ensureSupabaseReady()
const mId = supa.getSession().user?.getString('id') ?? null
if (!mId) {
uni.showToast({ title: '商家未获取到信息,请重新登录', icon: 'none' })
return
}
formData.value.merchant_id = mId
// 加载真实分类
await loadCategoryOptions()
const editId = uni.getStorageSync('edit_product_id') as string | null
if (editId) {
uni.removeStorageSync('edit_product_id')
await fetchProductDetail(editId, mId)
}
})
async function loadCategoryOptions() {
try {
const res = await supa.from('ml_categories')
.select('id, name')
.eq('is_active', true)
.order('sort_order', { ascending: true })
.execute()
if (res.data != null) {
const data = res.data as Array<UTSJSONObject>
categoryOptions.value = data.map((item: UTSJSONObject): CategoryOption => ({
id: item.get('id') as string,
name: item.get('name') as string
}))
categories.value = categoryOptions.value.map((item: CategoryOption): string => item.name)
}
} catch (e) {
console.error('加载分类失败', e)
}
}
async function fetchProductDetail(id: string, mId: string) {
try {
const res = await supa
.from('ml_products')
.select('*')
.eq('id', id)
.eq('merchant_id', mId)
.single()
.execute()
if (res.error != null) {
console.error('[ProductEdit] 查询失败:', res.error)
uni.showToast({ title: '获取商品信息失败,或者无权限编辑', icon: 'none' })
return
}
// .single() 返回时 data 可能是 UTSJSONObject 或包含一条记录的数组,兼容处理
let row : UTSJSONObject | null = null
if (res.data != null) {
const raw = res.data
if (Array.isArray(raw)) {
const arr = raw as Array<UTSJSONObject>
if (arr.length > 0) row = arr[0] as UTSJSONObject
} else {
row = raw as UTSJSONObject
}
}
if (row == null) {
uni.showToast({ title: '未找到该商品', icon: 'none' })
return
}
formData.value.id = row.getString('id') ?? ''
formData.value.name = row.getString('name') ?? ''
formData.value.base_price = row.getNumber('base_price') ?? 0
formData.value.available_stock = row.getNumber('available_stock') ?? 0
formData.value.total_stock = row.getNumber('total_stock') ?? row.getNumber('available_stock') ?? 0
formData.value.status = row.getNumber('status') ?? 1
formData.value.main_image_url = row.getString('main_image_url') ?? ''
formData.value.category_id = row.getString('category_id') ?? ''
// image_urls / video_urls / tags 是 JSONB 数组,用 .get() 取原始值再强转
const imgRaw = row.get('image_urls')
if (imgRaw != null && Array.isArray(imgRaw)) {
formData.value.image_urls = imgRaw as string[]
}
const vidRaw = row.get('video_urls')
if (vidRaw != null && Array.isArray(vidRaw)) {
formData.value.video_urls = vidRaw as string[]
}
const tagsRaw = row.get('tags')
if (tagsRaw != null && Array.isArray(tagsRaw)) {
formData.value.tags = tagsRaw as string[]
}
// attributes JSONB 是对象,用 getJSON 取
const attrsRaw = row.getJSON('attributes')
if (attrsRaw != null) {
formData.value.attributes.unit = attrsRaw.getString('unit') ?? '件'
}
// 同步分类名称
if (formData.value.category_id) {
const cat = categoryOptions.value.find((c: CategoryOption): boolean => c.id === formData.value.category_id)
categoryName.value = cat ? cat.name : ''
}
console.log('[ProductEdit] 加载成功id=', formData.value.id, 'name=', formData.value.name)
} catch (e : any) {
console.error('[ProductEdit] 获取详情异常:', e)
uni.showToast({ title: '获取商品信息失败', icon: 'none' })
}
}
function onCategoryChange(e: any) {
const v = e.detail.value as number
const selected = categoryOptions.value[v]
categoryName.value = selected.name
formData.value.category_id = selected.id
}
function addTag() {
uni.showModal({
title: '添加标签',
editable: true,
success: (res) => {
if (res.confirm && res.content) {
formData.value.tags.push(res.content)
}
}
})
}
function removeTag(index: number) {
formData.value.tags.splice(index, 1)
}
function uploadImage() {
const maxCount = 10 - formData.value.image_urls.length
if (maxCount <= 0) return
uni.chooseImage({
count: maxCount,
success: (res) => {
const paths = res.tempFilePaths as string[]
formData.value.image_urls.push(...paths)
}
})
}
function removeImage(index: number) {
formData.value.image_urls.splice(index, 1)
}
function uploadVideo() {
uni.chooseVideo({
sourceType: ['camera', 'album'],
success: (res) => {
formData.value.video_urls.push(res.tempFilePath as string)
}
})
}
function removeVideo(index: number) {
formData.value.video_urls.splice(index, 1)
}
function goBack() {
openRoute('product_productList')
}
// 通用上传方法
async function uploadToSupabase(filePath: string): Promise<string> {
if (filePath.startsWith('http') && !filePath.startsWith('blob:')) {
return filePath
}
const extMatch = filePath.match(/\.(\w+)$/)
const ext = extMatch ? extMatch[1] : 'png'
const uuid = Math.random().toString(36).substring(2, 10)
const remotePath = `products/${formData.value.merchant_id}_${Date.now()}_${uuid}.${ext}`
uni.showLoading({ title: '上传中...' })
try {
const res = await supa.storage.from('zhipao').upload(remotePath, filePath, {})
if (res.error != null) {
throw res.error
}
return `${SUPA_URL}/storage/v1/object/public/zhipao/${remotePath}`
} catch (e: any) {
console.error('上传文件失败:', e)
throw new Error(e.message || '上传异常')
} finally {
uni.hideLoading()
}
}
async function uploadAllCurrentFiles() {
const newImages = [] as string[]
for (const p of formData.value.image_urls) {
const u = await uploadToSupabase(p)
if(u) newImages.push(u)
}
formData.value.image_urls = newImages
const newVideos = [] as string[]
for (const p of formData.value.video_urls) {
const u = await uploadToSupabase(p)
if(u) newVideos.push(u)
}
formData.value.video_urls = newVideos
}
async function doSaveProduct(targetStatus: number) {
if (!formData.value.name) return uni.showToast({ title: '请输入商品名称', icon: 'none' })
if (!formData.value.category_id) return uni.showToast({ title: '请选择商品分类', icon: 'none' })
if (formData.value.image_urls.length === 0) return uni.showToast({ title: '请至少上传一张商品图', icon: 'none' })
// 必须获取正确的 merchant_id并且存在
if (!formData.value.merchant_id || formData.value.merchant_id.startsWith('merchant_123')) {
const mId = supa.getSession().user?.getString('id') ?? null
if (!mId) {
uni.showToast({ title: '未获取到商家信息,请重新登录', icon: 'none' })
return
}
formData.value.merchant_id = mId
}
uni.showLoading({ title: '保存中...' })
try {
// 1. 上传文件拿到正式 URL
await uploadAllCurrentFiles()
// 2. 组装 Payload
// 根据 CRMEB: 1:上架 2:下架 3:草稿 4:删除
formData.value.status = targetStatus
if (targetStatus === 1) {
formData.value.published_at = new Date().toISOString()
} else if (targetStatus === 3) {
formData.value.published_at = null
}
const payload : UTSJSONObject = {
merchant_id: formData.value.merchant_id,
name: formData.value.name,
category_id: formData.value.category_id,
base_price: Number(formData.value.base_price),
main_image_url: formData.value.image_urls.length > 0 ? formData.value.image_urls[0] : '', // 首图同步
image_urls: formData.value.image_urls,
video_urls: formData.value.video_urls,
available_stock: Number(formData.value.available_stock),
total_stock: Number(formData.value.total_stock) || Number(formData.value.available_stock),
status: formData.value.status,
tags: formData.value.tags,
attributes: formData.value.attributes,
published_at: formData.value.published_at
}
// 新建商品时自动生成唯一的 product_code
if (!formData.value.id) {
const uid = Math.random().toString(36).substring(2, 10).toUpperCase()
payload['product_code'] = `P${Date.now()}${uid}`
}
let result;
if (formData.value.id) {
// 必须加上 merchant_id 检查,防止越权串数据
result = await supa.from('ml_products')
.update(payload)
.eq('id', formData.value.id)
.eq('merchant_id', formData.value.merchant_id)
.execute()
} else {
result = await supa.from('ml_products').insert([payload]).execute()
}
uni.hideLoading()
if (result && result.error) {
console.error('Save Product DB Error:', result.error)
uni.showToast({ title: '保存失败: ' + result.error.message, icon: 'none' })
} else {
uni.showToast({ title: '保存成功', icon: 'success' })
uni.$emit('REFRESH_PRODUCT_LIST')
setTimeout(() => goBack(), 1000)
}
} catch (error: any) {
uni.hideLoading()
console.error('保存报错:', error)
uni.showToast({ title: '发生错误: ' + (error.message || ''), icon: 'none' })
}
}
// 草稿箱
function saveDraftProduct() {
doSaveProduct(3) // 3 表示草稿
}
// 下一步/发布
function saveAndNext() {
// 这里如果原本要求点击下一步是发布上架,则传入 1
doSaveProduct(1)
}
</script>
<style scoped lang="scss">
.product-edit-page {
padding: 0;
background-color: transparent;
min-height: auto;
}
.page-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
margin-bottom: 20px;
.back-link {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
color: #666;
cursor: pointer;
.arrow { font-size: 14px; }
.back-txt { font-size: 14px; }
}
.header-title { font-size: 16px; font-weight: bold; color: #333; }
}
.steps-card {
background: #fff;
padding: 20px;
border-radius: 4px;
margin-bottom: 16px;
}
.step-items {
display: flex;
flex-direction: row;
align-items: center;
}
.step-item {
display: flex;
flex-direction: row;
align-items: center;
.step-txt {
font-size: 14px;
color: #999;
padding: 0 16px;
}
&.active .step-txt {
color: #1890ff;
font-weight: bold;
border-bottom: 2px solid #1890ff;
padding-bottom: 4px;
}
.step-line {
width: 20px;
height: 1px;
background: #e8e8e8;
}
}
.form-card {
background: #fff;
padding: 40px;
border-radius: 4px;
}
.form-item {
display: flex;
flex-direction: row;
margin-bottom: 30px;
&.align-center {
align-items: center;
}
&.multi-line {
align-items: flex-start;
}
.label {
width: 120px;
text-align: right;
font-size: 14px;
color: #333;
margin-right: 20px;
line-height: 20px;
.required { color: #f5222d; margin-right: 4px; }
}
&.multi-line .label {
padding-top: 8px; /* 顶部对齐缓冲 */
}
.input-wrap { flex: 1; }
}
.radio-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
border: 1px solid #1890ff;
border-radius: 4px;
padding: 10px 16px;
width: 160px;
position: relative;
.radio-circle {
width: 14px; height: 14px; border: 1px solid #d9d9d9; border-radius: 50%;
&.on { border-color: #1890ff; background: #1890ff; }
}
.radio-txt {
display: flex;
flex-direction: column;
.main { font-size: 14px; color: #333; }
.sub { font-size: 12px; color: #999; }
}
&::after {
content: '✓';
position: absolute;
right: 0; bottom: 0;
background: #1890ff; color: #fff; font-size: 10px; padding: 0 2px;
}
}
.input-box {
border: 1px solid #d9d9d9;
border-radius: 4px;
display: flex;
flex-direction: row;
align-items: center;
padding: 0 12px;
width: 400px;
height: 36px;
&.small { width: 150px; }
.real-input { flex: 1; font-size: 14px; color: #333; }
.count, .unit { font-size: 14px; color: #666; margin-left: 8px; }
}
.image-uploader {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
.img-item {
width: 100px; height: 100px; position: relative; border-radius: 4px; overflow: visible;
border: 1px solid #e8e8e8;
background-color: #f9f9f9;
image { width: 100%; height: 100%; border-radius: 4px; }
.img-close {
position: absolute; right: -8px; top: -8px; width: 20px; height: 20px;
background: rgba(0,0,0,0.5); color: #fff; border-radius: 50%;
display: flex; align-items: center; justify-content: center; font-size: 12px;
cursor: pointer; z-index: 2;
}
.main-tag {
position: absolute; left: 0; bottom: 0; background: rgba(24,144,255,0.8);
color: #fff; font-size: 12px; padding: 2px 6px; border-top-right-radius: 4px; border-bottom-left-radius: 4px;
}
}
.video-item {
width: 150px; height: 100px;
}
}
.upload-btn {
width: 100px; height: 100px; border: 1px dashed #d9d9d9; border-radius: 4px;
display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: pointer;
background-color: #fafafa;
&:hover { border-color: #1890ff; }
.icon { font-size: 28px; color: #999; margin-bottom: 4px; }
.upload-txt { font-size: 12px; color: #666; }
&.v-btn {
width: 150px; height: 100px;
.v-icon { font-size: 24px; color: #999; margin-bottom: 4px; }
}
}
.tip { font-size: 12px; color: #999; }
.tag-selector {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
align-items: center;
.tag-item {
background: #f5f5f5; border: 1px solid #d9d9d9; padding: 4px 10px; border-radius: 4px;
display: flex; flex-direction: row; align-items: center; gap: 6px;
font-size: 14px; color: #666;
.close { color: #999; cursor: pointer; font-size: 16px; margin-left: 2px; }
}
.add-link { font-size: 14px; color: #1890ff; cursor: pointer; padding: 4px 0; }
}
.mock-btn-select {
border: 1px solid #d9d9d9; border-radius: 4px; padding: 0 16px; height: 36px;
font-size: 14px; color: #333; display: flex; align-items: center; cursor: pointer;
}
.radio-group-simple {
display: flex;
flex-direction: row;
gap: 20px;
.radio-simple {
display: flex; flex-direction: row; align-items: center; gap: 6px; font-size: 14px; color: #666; cursor: pointer;
.dot { width: 14px; height: 14px; border: 1px solid #d9d9d9; border-radius: 50%; position: relative; }
&.on {
color: #1890ff;
.dot { border-color: #1890ff; }
.dot::after {
content: ''; position: absolute; left: 3px; top: 3px; width: 6px; height: 6px;
background: #1890ff; border-radius: 50%;
}
}
}
}
.footer-btns {
margin-top: 24px;
display: flex;
flex-direction: row;
justify-content: center;
gap: 16px;
padding-bottom: 40px;
.btn-next { background: #1890ff; color: #fff; border: none; padding: 0 24px; height: 40px; border-radius: 4px; cursor: pointer; font-size: 14px; }
.btn-save { background: #fff; color: #333; border: 1px solid #d9d9d9; padding: 0 24px; height: 40px; border-radius: 4px; cursor: pointer; font-size: 14px; }
.btn-save:hover { color: #1890ff; border-color: #1890ff; }
}
</style>