完成代码路径重构

This commit is contained in:
2026-03-18 08:36:49 +08:00
parent 4041933e42
commit c2cd6dcd95
290 changed files with 866 additions and 38459 deletions

View File

@@ -0,0 +1,728 @@
<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>