Files
medical-mall/pages/mall/admin/product/product-management/edit.uvue
2026-02-13 17:29:50 +08:00

850 lines
24 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">
<!-- Step 0: 基础信息 -->
<view v-if="activeStep === 0" class="step-content">
<view class="form-item">
<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">
<view class="label"><text class="required">*</text><text>商品名称:</text></view>
<view class="input-wrap">
<view class="input-box">
<input class="real-input" v-model="form.name" maxlength="80" placeholder="请输入商品名称" />
<text class="count">{{ form.name.length }}/80</text>
</view>
</view>
</view>
<view class="form-item">
<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="form.unit" maxlength="5" placeholder="件" />
<text class="count">{{ form.unit.length }}/5</text>
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text class="required">*</text><text>商品轮播图:</text></view>
<view class="input-wrap">
<view class="image-uploader">
<view v-for="(img, i) in form.image_urls" :key="i" class="img-item">
<image :src="img" mode="aspectFill" />
<view class="img-close" @click="removeImage(i)">×</view>
</view>
<view v-if="form.image_urls.length < 10" class="upload-btn" @click="onUploadCarousel">
<text class="icon">+</text>
</view>
</view>
<text class="tip">建议尺寸800*800默认首张图为主图最多上传10张</text>
</view>
</view>
<view class="form-item">
<view class="label"><text class="required">*</text><text>商品分类:</text></view>
<view class="input-wrap">
<picker :value="categoryIndex" :range="categoryLabels" @change="onCategoryChange">
<view class="tag-selector">
<view v-if="selectedCategoryName" class="tag-item">
<text>{{ selectedCategoryName }}</text>
</view>
<text class="add-link">{{ selectedCategoryName ? '点击切换分类' : '选择分类' }}</text>
</view>
</picker>
</view>
</view>
<view class="form-item">
<view class="label"><text>商品标签:</text></view>
<view class="input-wrap">
<view class="tag-selector">
<view v-for="(tag, tidx) in form.tags" :key="tidx" class="tag-item">
<text>{{ tag }}</text>
<text class="close" @click="removeTag(tag)">×</text>
</view>
<text class="add-link" @click="showLabelModal = true">选择标签</text>
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text>商品状态:</text></view>
<view class="input-wrap">
<view class="radio-group-simple">
<view class="radio-simple" :class="{ on: form.status === 1 }" @click="form.status = 1">
<text class="dot"></text>
<text>上架</text>
</view>
<view class="radio-simple" :class="{ on: form.status === 2 }" @click="form.status = 2">
<text class="dot"></text>
<text>下架</text>
</view>
</view>
</view>
</view>
</view>
<!-- Step 1: 规格库存 -->
<view v-if="activeStep === 1" class="step-content">
<view class="form-item">
<view class="label"><text>基础售价:</text></view>
<view class="input-wrap">
<view class="input-box small">
<input class="real-input" type="digit" v-model="form.price" placeholder="0.00" />
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text>库存总数:</text></view>
<view class="input-wrap">
<view class="input-box small">
<input class="real-input" type="number" v-model="form.stock" placeholder="0" />
</view>
</view>
</view>
<view class="form-item">
<view class="label"><text>规格模板:</text></view>
<view class="input-wrap">
<picker :range="specTemplateLabels" @change="onSpecTemplateChange">
<button class="btn-white small-btn">快速导入模板</button>
</picker>
</view>
</view>
<!-- SKU 表格区域 -->
<view class="sku-table-wrap">
<view class="sku-table-header">
<text class="sku-th flex-2">规格</text>
<text class="sku-th flex-1">价格</text>
<text class="sku-th flex-1">库存</text>
<text class="sku-th flex-1">编号</text>
</view>
<view v-for="(sku, idx) in skus" :key="idx" class="sku-tr">
<text class="sku-td flex-2">{{ formatSpecs(sku.specifications) }}</text>
<view class="sku-td flex-1">
<input class="sku-input" type="digit" v-model="sku.price" />
</view>
<view class="sku-td flex-1">
<input class="sku-input" type="number" v-model="sku.stock" />
</view>
<view class="sku-td flex-1">
<input class="sku-input" v-model="sku.sku_code" />
</view>
</view>
<view v-if="skus.length === 0" class="empty-sku">
<text>请先选择规格或添加属性</text>
</view>
</view>
</view>
<!-- Step 2: 商品详情 -->
<view v-if="activeStep === 2" class="step-content">
<view class="form-item row-align-start">
<view class="label"><text class="required">*</text><text>商品详情:</text></view>
<view class="input-wrap">
<textarea class="textarea-box" v-model="form.description" placeholder="请输入商品详细描述内容..." />
</view>
</view>
</view>
<!-- 其他步骤占位 -->
<view v-if="activeStep > 2" class="step-content">
<view class="placeholder-step">
<text>步骤 {{ steps[activeStep] }} 逻辑接入中...</text>
<text class="sub-tip">该模块涉及物流模板、会员等级定价等,将在后续阶段补齐</text>
</view>
</view>
</view>
<view class="footer-btns">
<button v-if="activeStep > 0" class="btn-prev" @click="prevStep">上一步</button>
<button v-if="activeStep < steps.length - 1" class="btn-next" @click="nextStep">下一步</button>
<button v-if="activeStep === steps.length - 1" class="btn-save" @click="handleSave">保存</button>
</view>
<!-- 标签选择弹窗 -->
<view v-if="showLabelModal" class="popup-mask" @click="showLabelModal = false">
<view class="popup-card" @click.stop>
<view class="popup-header">
<text class="popup-title">选择商品标签</text>
<text class="popup-close" @click="showLabelModal = false">×</text>
</view>
<scroll-view class="popup-body" scroll-y="true" style="height: 300px;">
<view class="label-grid">
<view
v-for="label in labelList"
:key="label.id"
class="label-check-item"
:class="{ selected: form.tags.includes(label.name) }"
@click="toggleTag(label.name)"
>
<text class="label-check-txt">{{ label.name }}</text>
</view>
</view>
</scroll-view>
<view class="popup-footer">
<button class="btn primary" @click="showLabelModal = false">完成</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, reactive, onMounted } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
import {
fetchAdminProductDetail,
saveAdminProduct,
AdminProduct,
AdminProductSku
} from '@/services/admin/productService.uts'
import { fetchAdminCategoryList, AdminCategory } from '@/services/admin/productCategoryService.uts'
import { fetchLabels, ProductLabel } from '@/services/admin/productLabelService.uts'
import { fetchSpecTemplates, fetchParamTemplates, ProductSpecTemplate, ProductParamTemplate } from '@/services/admin/productSpecParamService.uts'
// --- 状态定义 ---
const activeStep = ref(0)
const steps = ['基础信息', '规格库存', '商品详情', '物流设置', '会员价/佣金', '营销设置', '其他设置']
const isLoading = ref(false)
const productId = ref<string | null>(null)
// 响应式表单数据
const form = reactive({
name: '',
subtitle: '',
unit: '件',
category_id: '',
image_urls: [] as string[],
video_urls: [] as string[],
tags: [] as string[],
status: 1, // 1:上架, 2:下架
description: '',
price: 0,
stock: 0,
product_code: '',
attributes: {} as any
})
const skus = ref<AdminProductSku[]>([])
// 基础资料列表
const categories = ref<AdminCategory[]>([])
const labelList = ref<ProductLabel[]>([])
const specTemplates = ref<ProductSpecTemplate[]>([])
const paramTemplates = ref<ProductParamTemplate[]>([])
// --- UI 控制状态 ---
const showLabelModal = ref(false)
const categoryIndex = ref(0)
// 计算属性:分类名称列表供 picker 使用
const categoryLabels = computed((): string[] => {
return categories.value.map(c => c.name)
})
// 计算属性:当前选中分类名称
const selectedCategoryName = computed((): string => {
const cate = categories.value.find(c => c.id === form.category_id)
return cate != null ? cate.name : ''
})
// 计算属性:规格模板名称列表
const specTemplateLabels = computed((): string[] => {
return specTemplates.value.map(t => t.name)
})
onMounted(() => {
// 1. 获取商品 ID
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options as Record<string, string | undefined>
productId.value = options['id'] ?? null
// 2. 初始化所有基础资料
initBaseData()
// 3. 如果是编辑模式,加载详情
if (productId.value != null) {
loadProductDetail(productId.value!)
}
})
async function initBaseData() {
try {
const [cateRes, labelRes, specRes, paramRes] = await Promise.all([
fetchAdminCategoryList({ isActive: true }),
fetchLabels(),
fetchSpecTemplates(),
fetchParamTemplates()
])
categories.value = cateRes
labelList.value = labelRes
specTemplates.value = specRes
paramTemplates.value = paramRes
} catch (e) {
console.error('加载基础资料失败:', e)
}
}
async function loadProductDetail(id: string) {
isLoading.value = true
try {
const res = await fetchAdminProductDetail(id)
if (res.product != null) {
const p = res.product!
form.name = p.name
form.subtitle = p.subtitle ?? ''
form.unit = p.unit ?? '件'
form.category_id = p.category_id
form.image_urls = p.image_urls ?? []
form.video_urls = p.video_urls ?? []
form.tags = p.tags ?? []
form.status = p.status
form.description = p.description ?? ''
form.price = p.price
form.stock = p.stock
form.product_code = p.product_code ?? ''
form.attributes = p.attributes ?? {}
skus.value = res.skus
}
} catch (e) {
uni.showToast({ title: '加载详情失败', icon: 'none' })
} finally {
isLoading.value = false
}
}
// --- 交互逻辑 ---
function goBack() {
openRoute('product_productList')
}
function nextStep() {
if (activeStep.value < steps.length - 1) {
activeStep.value++
}
}
function prevStep() {
if (activeStep.value > 0) {
activeStep.value--
}
}
async function handleSave() {
isLoading.value = true
try {
const productData: Partial<AdminProduct> = {
id: productId.value ?? undefined,
...form
}
const success = await saveAdminProduct(productData, skus.value)
if (success) {
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => goBack(), 1500)
} else {
uni.showToast({ title: '保存失败', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '系统异常', icon: 'none' })
} finally {
isLoading.value = false
}
}
// 模拟图片上传
function onUploadCarousel() {
uni.chooseImage({
count: 10 - form.image_urls.length,
success: (res) => {
form.image_urls.push(...res.tempFilePaths)
}
})
}
function removeImage(index: number) {
form.image_urls.splice(index, 1)
}
function onCategoryChange(e: any) {
const index = parseInt(String(e.detail.value))
categoryIndex.value = index
form.category_id = categories.value[index].id
}
function toggleTag(name: string) {
const index = form.tags.indexOf(name)
if (index > -1) {
form.tags.splice(index, 1)
} else {
form.tags.push(name)
}
}
function removeTag(name: string) {
const index = form.tags.indexOf(name)
if (index > -1) {
form.tags.splice(index, 1)
}
}
// --- 规格与 SKU 核心逻辑 ---
function formatSpecs(specs: any): string {
if (specs == null) return '默认规格'
if (typeof specs === 'object') {
const vals = Object.values(specs as Record<string, any>)
return vals.join(',')
}
return String(specs)
}
function onSpecTemplateChange(e: any) {
const index = parseInt(String(e.detail.value))
const tpl = specTemplates.value[index]
if (tpl == null) return
// 解析规格字符串,例如 "颜色:红色,蓝色;尺寸:S,M"
const specGroups = tpl.specs.split(';')
const dimensions: Array<Array<{key: string, value: string}>> = []
specGroups.forEach(group => {
const parts = group.split(':')
if (parts.length === 2) {
const key = parts[0].trim()
const values = parts[1].split(',')
dimensions.push(values.map(v => ({ key, value: v.trim() })))
}
})
if (dimensions.length === 0) return
// 笛卡尔积生成组合
const combinations = cartesianProduct(dimensions)
// 转换为 SKU 列表
skus.value = combinations.map(combo => {
const specObj: Record<string, string> = {}
combo.forEach(item => {
specObj[item.key] = item.value
})
return {
sku_code: '',
specifications: specObj,
price: form.price || 0,
stock: 0,
status: 1,
image_url: form.image_urls.length > 0 ? form.image_urls[0] : null
} as AdminProductSku
})
uni.showToast({ title: `生成了 ${skus.value.length} 个规格` })
}
/**
* 笛卡尔积辅助函数
*/
function cartesianProduct<T>(arrays: T[][]): T[][] {
const result: T[][] = [[]]
for (const array of arrays) {
const temp: T[][] = []
for (const res of result) {
for (const item of array) {
temp.push([...res, item])
}
}
if (temp.length > 0) result.length = 0, result.push(...temp)
}
return result
}
</script>
<style scoped lang="scss">
.product-edit-page {
padding: 20px;
background-color: #f5f7f9;
min-height: 100vh;
}
.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;
.label {
width: 120px;
text-align: right;
font-size: 14px;
color: #333;
padding-top: 8px;
margin-right: 20px;
.required { color: #f5222d; margin-right: 4px; }
}
.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 { font-size: 12px; color: #bfbfbf; }
}
.image-uploader {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 8px;
.img-item {
width: 80px; height: 80px; position: relative;
image { width: 100%; height: 100%; border-radius: 4px; }
.img-close {
position: absolute; right: -6px; top: -6px; width: 16px; height: 16px;
background: rgba(0,0,0,0.5); color: #fff; border-radius: 50%;
display: flex; align-items: center; justify-content: center; font-size: 12px;
}
}
}
.upload-btn {
width: 80px; height: 80px; border: 1px dashed #d9d9d9; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
.icon { font-size: 24px; color: #999; }
&.v-btn { width: 64px; height: 64px; margin-bottom: 8px; .v-icon { font-size: 24px; } }
}
.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: 2px 10px; border-radius: 4px;
display: flex; flex-direction: row; align-items: center; gap: 6px;
font-size: 14px; color: #666;
.close { color: #999; cursor: pointer; }
}
.add-link { font-size: 14px; color: #1890ff; cursor: pointer; }
}
.mock-btn-select {
border: 1px solid #d9d9d9; border-radius: 4px; padding: 6px 16px;
font-size: 14px; color: #666; display: inline-block;
}
.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;
.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-prev { background: #fff; color: #666; border: 1px solid #d9d9d9; padding: 0 24px; height: 40px; border-radius: 4px; }
.btn-next { background: #1890ff; color: #fff; border: none; padding: 0 24px; height: 40px; border-radius: 4px; }
.btn-save { background: #52c41a; color: #fff; border: none; padding: 0 24px; height: 40px; border-radius: 4px; }
}
/* SKU 表格样式 */
.sku-table-wrap {
margin-top: 20px;
border: 1px solid #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.sku-table-header {
display: flex;
flex-direction: row;
background-color: #fafafa;
height: 44px;
align-items: center;
border-bottom: 1px solid #f0f0f0;
}
.sku-th {
font-size: 14px;
font-weight: bold;
color: #333;
padding: 0 12px;
text-align: center;
}
.sku-tr {
display: flex;
flex-direction: row;
height: 54px;
align-items: center;
border-bottom: 1px solid #f0f0f0;
}
.sku-td {
padding: 0 12px;
font-size: 14px;
color: #666;
text-align: center;
}
.sku-input {
border: 1px solid #dcdfe6;
border-radius: 4px;
height: 32px;
width: 90%;
font-size: 13px;
text-align: center;
}
.empty-sku {
padding: 40px 0;
text-align: center;
color: #999;
font-size: 14px;
}
.textarea-box {
width: 100%;
height: 300px;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 12px;
font-size: 14px;
line-height: 1.6;
}
.placeholder-step {
padding: 100px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: #999;
font-size: 16px;
.sub-tip { font-size: 13px; color: #ccc; }
}
.small-btn {
height: 32px;
line-height: 32px;
padding: 0 12px;
font-size: 13px;
border: 1px solid #dcdfe6;
background: #fff;
border-radius: 4px;
}
.row-align-start {
align-items: flex-start !important;
}
/* 弹窗与标签选择样式 */
.popup-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.popup-card {
width: 500px;
background-color: #fff;
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.popup-header {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.popup-title { font-size: 16px; font-weight: bold; }
.popup-close { font-size: 20px; color: #999; cursor: pointer; }
.popup-body { padding: 20px; }
.label-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 12px;
}
.label-check-item {
padding: 6px 16px;
background-color: #f5f5f5;
border: 1px solid #d9d9d9;
border-radius: 4px;
&.selected {
background-color: #e6f7ff;
border-color: #1890ff;
.label-check-txt { color: #1890ff; }
}
}
.label-check-txt { font-size: 13px; color: #666; }
.popup-footer {
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
}
.btn.primary {
background-color: #1890ff;
color: #fff;
border: none;
padding: 0 20px;
height: 32px;
border-radius: 4px;
font-size: 14px;
}
.flex-1 { flex: 1; }
.flex-2 { flex: 2; }
</style>