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

658 lines
20 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'
const activeStep = ref(0)
const steps = ['基础信息', '规格库存', '商品详情', '物流设置', '会员价/佣金', '营销设置', '其他设置']
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
})
const categories = ref(['361度', '特步', '匹克', '生活家居'])
const categoryName = ref('')
onMounted(async () => {
await ensureSupabaseReady()
const mId = uni.getStorageSync('merchant_id') as string | null
if (!mId) {
uni.showToast({ title: '商家未获取到信息,请重新登录', icon: 'none' })
return
}
formData.value.merchant_id = mId
const editId = uni.getStorageSync('edit_product_id') as string | null
if (editId) {
uni.removeStorageSync('edit_product_id')
await fetchProductDetail(editId, mId)
}
})
async function fetchProductDetail(id: string, mId: string) {
try {
const { data, error } = await supa
.from('ml_products')
.select('*')
.eq('id', id)
.eq('merchant_id', mId)
.single()
if (error) throw error
if (data) {
formData.value.id = data.id
formData.value.name = data.name || ''
formData.value.base_price = data.base_price || 0
formData.value.available_stock = data.available_stock || 0
formData.value.total_stock = data.total_stock || data.available_stock || 0
formData.value.status = data.status || 1
formData.value.main_image_url = data.main_image_url || ''
formData.value.image_urls = data.image_urls || []
formData.value.video_urls = data.video_urls || []
formData.value.tags = data.tags || []
if (data.attributes && typeof data.attributes === 'object') {
const attrs = data.attributes as Record<string, any>
formData.value.attributes.unit = attrs['unit'] || '件'
}
// Try to map category
formData.value.category_id = data.category_id || ''
categoryName.value = data.category_id ? '已绑定分类' : ''
}
} catch (e) {
console.error('获取详情失败', e)
uni.showToast({ title: '获取商品信息失败,或者无权限编辑', icon: 'none' })
}
}
function onCategoryChange(e: any) {
const v = e.detail.value as number
categoryName.value = categories.value[v]
// In a real project, this maps to an actual category ID, but for now we use a mock one
formData.value.category_id = `cat_${v}`
}
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 { data, error } = await supa.storage.from('zhipao').upload(remotePath, filePath, {})
if (error) {
throw error
}
const urlKey = typeof data === 'object' ? (data as any)['Key'] || (data as any)['path'] : ''
// fallback logic, generally Supabase uses 'storage/v1/object/public/bucket/' + path
if (urlKey) {
return `https://ak3.oulog.com/storage/v1/object/public/${urlKey}`
}
return ''
} 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 = uni.getStorageSync('merchant_id') as string | 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 = {
merchant_id: formData.value.merchant_id,
name: formData.value.name,
category_id: formData.value.category_id || null,
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
}
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)
} else {
result = await supa.from('ml_products').insert([payload])
}
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>