JewelryMall/admin/src/views/product/ProductForm.vue
2026-03-29 19:58:26 +08:00

851 lines
36 KiB
Vue
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>
<div class="product-form-page">
<el-card shadow="never" class="form-card">
<template #header>
<div class="form-card__header">
<span class="form-card__title">{{ isEdit ? '编辑商品' : '新增商品' }}</span>
<el-button text @click="$router.back()">
<el-icon><Back /></el-icon>
</el-button>
</div>
</template>
<el-form :model="form" :rules="rules" ref="formRef" label-width="110px" label-position="right">
<!-- 基本信息 -->
<div class="section-title">基本信息</div>
<el-row :gutter="32">
<el-col :span="12">
<el-form-item label="商品名称" prop="name">
<el-input v-model="form.name" placeholder="请输入商品名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="款号" prop="styleNo">
<el-input v-model="form.styleNo" placeholder="请输入款号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="32">
<el-col :span="12">
<el-form-item label="分类" prop="categoryId">
<el-select v-model="form.categoryId" placeholder="选择分类" clearable multiple style="width:100%">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="损耗" prop="loss">
<el-input-number v-model="form.loss" :min="0" :precision="4" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="32">
<el-col :span="12">
<el-form-item label="工费" prop="laborCost">
<el-input-number v-model="form.laborCost" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-switch v-model="form.status" active-value="on" inactive-value="off" active-text="上架" inactive-text="下架" />
</el-form-item>
</el-col>
</el-row>
<!-- 筛选属性(暂时隐藏)
<div class="section-title">筛选属性</div>
<el-row :gutter="32">
<el-col :span="8">
<el-form-item label="副石">
<el-input v-model="form.sideStone" placeholder="如:有副石、无副石" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="款式">
<el-input v-model="form.style" placeholder="如:经典、时尚" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="镶口">
<el-input v-model="form.setting" placeholder="如:四爪、六爪" />
</el-form-item>
</el-col>
</el-row>
-->
<!-- 媒体资源 -->
<div class="section-title">媒体资源</div>
<el-row :gutter="32">
<el-col :span="12">
<el-form-item label="列表展示图" prop="thumb">
<div>
<el-upload
:action="uploadUrl"
:headers="uploadHeaders"
list-type="picture-card"
:file-list="thumbFileList"
:limit="1"
:on-success="handleThumbSuccess"
:on-remove="handleThumbRemove"
accept="image/*"
:class="{ 'hide-upload': thumbFileList.length >= 1 }"
>
<el-icon :size="24" color="#999"><Plus /></el-icon>
</el-upload>
<div class="upload-tip">用于商品列表页展示的缩略图</div>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="Banner 图片" prop="bannerImages">
<el-upload
:action="uploadUrl"
:headers="uploadHeaders"
list-type="picture-card"
:file-list="bannerFileList"
:on-success="handleBannerSuccess"
:on-remove="handleBannerRemove"
accept="image/*"
>
<el-icon :size="24" color="#999"><Plus /></el-icon>
</el-upload>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="32">
<el-col :span="24">
<el-form-item label="Banner 视频" class="video-form-item">
<div class="video-upload-wrap">
<el-upload
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleVideoSuccess"
accept="video/*"
>
<el-button type="primary" plain><el-icon><Upload /></el-icon> 上传视频</el-button>
</el-upload>
<div v-for="(v, i) in form.bannerVideo" :key="i" class="video-item">
<el-icon color="#7c5cfc"><VideoCamera /></el-icon>
<span class="video-item__name">视频{{ i + 1 }}{{ v.split('/').pop() }}</span>
<el-button type="danger" text size="small" @click="form.bannerVideo.splice(i, 1)"><el-icon><Delete /></el-icon></el-button>
</div>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="32">
<el-col :span="24">
<el-form-item label="详情图片">
<el-upload
:action="uploadUrl"
:headers="uploadHeaders"
list-type="picture-card"
:file-list="detailFileList"
:on-success="handleDetailSuccess"
:on-remove="handleDetailRemove"
accept="image/*"
>
<el-icon :size="24" color="#999"><Plus /></el-icon>
</el-upload>
<div class="upload-tip">用于商品详情页底部展示的大图</div>
</el-form-item>
</el-col>
</el-row>
<!-- 详细参数配置(暂时隐藏)
<div class="section-title">详细参数配置</div>
<el-row :gutter="32">
<el-col :span="6">
<el-form-item label="成色选项">
<div class="tag-input-wrap">
<el-tag v-for="(tag, i) in detailParams.fineness" :key="tag" closable type="warning" effect="light" @close="detailParams.fineness.splice(i, 1)" style="margin: 0 6px 6px 0">{{ tag }}</el-tag>
<el-input v-model="tagInputs.fineness" size="small" placeholder="回车添加" @keyup.enter="addTag('fineness')" style="width:120px" />
</div>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="主石选项">
<div class="tag-input-wrap">
<el-tag v-for="(tag, i) in detailParams.mainStone" :key="tag" closable type="success" effect="light" @close="detailParams.mainStone.splice(i, 1)" style="margin: 0 6px 6px 0">{{ tag }}</el-tag>
<el-input v-model="tagInputs.mainStone" size="small" placeholder="回车添加" @keyup.enter="addTag('mainStone')" style="width:120px" />
</div>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="副石选项">
<div class="tag-input-wrap">
<el-tag v-for="(tag, i) in detailParams.subStone" :key="tag" closable type="danger" effect="light" @close="detailParams.subStone.splice(i, 1)" style="margin: 0 6px 6px 0">{{ tag }}</el-tag>
<el-input v-model="tagInputs.subStone" size="small" placeholder="回车添加" @keyup.enter="addTag('subStone')" style="width:120px" />
</div>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="手寸选项">
<div class="tag-input-wrap">
<el-tag v-for="(tag, i) in detailParams.ringSize" :key="tag" closable type="info" effect="light" @close="detailParams.ringSize.splice(i, 1)" style="margin: 0 6px 6px 0">{{ tag }}</el-tag>
<el-input v-model="tagInputs.ringSize" size="small" placeholder="回车添加" @keyup.enter="addTag('ringSize')" style="width:120px" />
</div>
</el-form-item>
</el-col>
</el-row>
-->
<!-- 规格数据管理 -->
<div class="section-title">
规格数据管理
<div class="section-actions">
<template v-if="isEdit">
<el-button size="small" @click="handleExport"><el-icon><Download /></el-icon> 导出 CSV</el-button>
<el-upload
:action="`${apiBaseUrl}/admin/products/${route.params.id}/spec-data/import`"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleImportSuccess"
:on-error="handleImportError"
accept=".csv"
style="display: inline-block"
>
<el-button size="small" type="success"><el-icon><Upload /></el-icon> 导入 CSV</el-button>
</el-upload>
</template>
<template v-else>
<span class="upload-tip" style="margin-right:8px">CSV 导入导出需先保存商品</span>
</template>
<el-button size="small" type="primary" @click="specDialogVisible = true"><el-icon><Plus /></el-icon> 新增规格</el-button>
</div>
</div>
<el-table v-if="specDataRows.length" :data="specDataRows" size="small" stripe border
style="width: 100%; margin-bottom: 20px" max-height="500"
:header-cell-style="{ background: '#f0f0ff', color: '#1d1e3a', fontWeight: 600, fontSize: '13px', padding: '10px 0' }"
:cell-style="{ padding: '8px 0', fontSize: '13px' }">
<el-table-column type="index" label="#" width="50" fixed align="center" />
<el-table-column prop="modelName" label="规格名称" min-width="120" fixed show-overflow-tooltip />
<el-table-column prop="barcode" label="条形码" width="130" fixed show-overflow-tooltip />
<el-table-column label="基本参数" align="center">
<el-table-column prop="fineness" label="成色" width="70" align="center" />
<el-table-column prop="mainStone" label="主石" width="70" align="center" />
<el-table-column prop="subStone" label="副石" width="70" align="center" />
<el-table-column prop="ringSize" label="手寸" width="70" align="center" />
</el-table-column>
<el-table-column label="金料信息" align="center">
<el-table-column prop="goldTotalWeight" label="总重" width="80" align="center" />
<el-table-column prop="goldNetWeight" label="净重" width="80" align="center" />
<el-table-column prop="loss" label="损耗" width="70" align="center" />
<el-table-column prop="goldLoss" label="金耗" width="70" align="center" />
<el-table-column prop="goldPrice" label="金价" width="80" align="center" />
<el-table-column prop="goldValue" label="金值" width="80" align="center" />
</el-table-column>
<el-table-column label="主石信息" align="center">
<el-table-column prop="mainStoneCount" label="数量" width="60" align="center" />
<el-table-column prop="mainStoneWeight" label="石重" width="75" align="center" />
<el-table-column prop="mainStoneUnitPrice" label="单价" width="75" align="center" />
<el-table-column prop="mainStoneAmount" label="金额" width="75" align="center" />
</el-table-column>
<el-table-column label="副石信息" align="center">
<el-table-column prop="sideStoneCount" label="数量" width="60" align="center" />
<el-table-column prop="sideStoneWeight" label="石重" width="75" align="center" />
<el-table-column prop="sideStoneUnitPrice" label="单价" width="75" align="center" />
<el-table-column prop="sideStoneAmount" label="金额" width="75" align="center" />
</el-table-column>
<el-table-column label="工费信息" align="center">
<el-table-column prop="accessoryAmount" label="配件" width="70" align="center" />
<el-table-column prop="processingFee" label="加工" width="70" align="center" />
<el-table-column prop="settingFee" label="镶石" width="70" align="center" />
<el-table-column prop="totalLaborCost" label="总工费" width="75" align="center" />
</el-table-column>
<el-table-column prop="totalPrice" label="总价" width="90" fixed="right" align="center">
<template #default="{ row }">
<span style="color:#e4393c;font-weight:700;font-size:14px">{{ row.totalPrice }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="handleEditSpec(row)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button type="danger" link size="small" @click="handleDeleteSpec(row.id || row._tempId)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无规格数据" :image-size="60" style="margin-bottom:20px" />
<!-- 新增规格弹窗 -->
<el-dialog v-model="specDialogVisible" :title="editingSpecId ? '编辑规格数据' : '新增规格数据'" width="720px" @close="resetSpecForm" destroy-on-close>
<el-form :model="specForm" label-width="90px" size="small">
<div class="dialog-section">基本信息</div>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="规格名称"><el-input v-model="specForm.modelName" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="条形码"><el-input v-model="specForm.barcode" /></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="成色">
<el-select v-model="specForm.fineness" placeholder="请选择成色" style="width:100%" @change="onFinenesChange">
<el-option label="18k白" value="18k白" />
<el-option label="18k黄" value="18k黄" />
<el-option label="18k玫瑰金" value="18k玫瑰金" />
<el-option label="铂金PT950" value="铂金PT950" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="主石"><el-input v-model="specForm.mainStone" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="副石"><el-input v-model="specForm.subStone" /></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="手寸"><el-input v-model="specForm.ringSize" /></el-form-item></el-col>
</el-row>
<div class="dialog-section">金料信息</div>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="金料总重"><el-input-number v-model="specForm.goldTotalWeight" :precision="4" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="金料净重"><el-input-number v-model="specForm.goldNetWeight" :precision="4" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="损耗"><el-input-number v-model="specForm.loss" :precision="4" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="金耗"><el-input-number v-model="specForm.goldLoss" :precision="4" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="金价"><el-input-number v-model="specForm.goldPrice" :precision="2" disabled controls-position="right" style="width:100%" /><div style="font-size:11px;color:#999;margin-top:2px">{{ specForm.fineness === '铂金PT950' ? '铂金价格' : '金价' }},根据成色自动切换</div></el-form-item></el-col>
<el-col :span="8"><el-form-item label="金值"><el-input-number v-model="specForm.goldValue" :precision="2" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
<div class="dialog-section">主石信息</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="数量"><el-input-number v-model="specForm.mainStoneCount" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="石重"><el-input-number v-model="specForm.mainStoneWeight" :precision="4" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="单价"><el-input-number v-model="specForm.mainStoneUnitPrice" :precision="2" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="金额"><el-input-number v-model="specForm.mainStoneAmount" :precision="2" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
<div class="dialog-section">副石信息</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="数量"><el-input-number v-model="specForm.sideStoneCount" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="石重"><el-input-number v-model="specForm.sideStoneWeight" :precision="4" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="单价"><el-input-number v-model="specForm.sideStoneUnitPrice" :precision="2" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="金额"><el-input-number v-model="specForm.sideStoneAmount" :precision="2" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
<div class="dialog-section">工费信息</div>
<el-row :gutter="16">
<el-col :span="6"><el-form-item label="配件金额"><el-input-number v-model="specForm.accessoryAmount" :precision="2" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="加工工费"><el-input-number v-model="specForm.processingFee" :precision="2" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="镶石工费"><el-input-number v-model="specForm.settingFee" :precision="2" :min="0" controls-position="right" style="width:100%" /></el-form-item></el-col>
<el-col :span="6"><el-form-item label="总工费"><el-input-number v-model="specForm.totalLaborCost" :precision="2" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="总价"><el-input-number v-model="specForm.totalPrice" :precision="2" disabled controls-position="right" style="width:100%" /></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="specDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="specSaving" @click="handleCreateSpec">确定</el-button>
</template>
</el-dialog>
<!-- 底部操作栏 -->
<div class="form-footer">
<el-button type="primary" size="large" :loading="saving" @click="handleSubmit">
<el-icon><Check /></el-icon> 保存
</el-button>
<el-button size="large" @click="$router.back()">取消</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type UploadFile } from 'element-plus'
import { Back, Plus, Upload, Download, Delete, Check, VideoCamera, Edit } from '@element-plus/icons-vue'
import { getProductDetail, createProduct, updateProduct, getCategories, exportSpecData, getSpecDataList, createSpecData, deleteSpecData, updateSpecData } from '../../api/product'
import { getLatestGoldPrice, getLatestPlatinumPrice } from '../../api/goldPrice'
import http, { getUploadUrl } from '../../api/request'
const route = useRoute()
const router = useRouter()
const formRef = ref<FormInstance>()
const saving = ref(false)
const categories = ref<any[]>([])
const isEdit = computed(() => !!route.params.id)
const uploadUrl = getUploadUrl()
const apiBaseUrl = uploadUrl.replace('/admin/upload', '')
const uploadHeaders = computed(() => ({
Authorization: `Bearer ${localStorage.getItem('admin_token') || ''}`,
}))
const form = reactive({
name: '',
basePrice: 0,
styleNo: '',
stock: 0,
totalStock: 0,
loss: 0,
laborCost: 0,
categoryId: [] as number[],
status: 'on' as 'on' | 'off',
bannerImages: [] as string[],
bannerVideo: [] as string[],
thumb: '',
detailImages: [] as string[],
sideStone: '',
style: '',
setting: '',
})
const detailParams = reactive({
fineness: [] as string[],
mainStone: [] as string[],
subStone: [] as string[],
ringSize: [] as string[],
})
const tagInputs = reactive({ fineness: '', mainStone: '', subStone: '', ringSize: '' })
function addTag(key: 'fineness' | 'mainStone' | 'subStone' | 'ringSize') {
const val = tagInputs[key].trim()
if (val && !detailParams[key].includes(val)) {
detailParams[key].push(val)
}
tagInputs[key] = ''
}
const bannerFileList = ref<UploadFile[]>([])
const thumbFileList = ref<UploadFile[]>([])
const detailFileList = ref<UploadFile[]>([])
const rules = {
name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
styleNo: [{ required: true, message: '请输入款号', trigger: 'blur' }],
loss: [{ required: true, message: '请输入损耗', trigger: 'blur' }],
laborCost: [{ required: true, message: '请输入工费', trigger: 'blur' }],
categoryId: [{ required: true, validator: (_r: any, _v: any, cb: any) => form.categoryId.length > 0 ? cb() : cb(new Error('请选择分类')), trigger: 'change' }],
thumb: [{ required: true, validator: (_r: any, _v: any, cb: any) => form.thumb ? cb() : cb(new Error('请上传列表展示图')), trigger: 'change' }],
bannerImages: [{ required: true, validator: (_r: any, _v: any, cb: any) => form.bannerImages.length > 0 ? cb() : cb(new Error('请上传至少一张 Banner 图片')), trigger: 'change' }],
}
const specDataRows = ref<any[]>([])
const specDialogVisible = ref(false)
const specSaving = ref(false)
const editingSpecId = ref<number | null>(null)
const globalGoldPrice = ref(0)
const globalPlatinumPrice = ref(0)
const specForm = reactive({
modelName: '', barcode: '', fineness: '', mainStone: '', subStone: '', ringSize: '',
goldTotalWeight: 0, goldNetWeight: 0, loss: 0, goldLoss: 0,
goldPrice: 0, goldValue: 0,
mainStoneCount: 0, mainStoneWeight: 0, mainStoneUnitPrice: 0, mainStoneAmount: 0,
sideStoneCount: 0, sideStoneWeight: 0, sideStoneUnitPrice: 0, sideStoneAmount: 0,
accessoryAmount: 0, processingFee: 0, settingFee: 0, totalLaborCost: 0, totalPrice: 0,
})
function resetSpecForm() {
editingSpecId.value = null
Object.assign(specForm, {
modelName: '', barcode: '', fineness: '', mainStone: '', subStone: '', ringSize: '',
goldTotalWeight: 0, goldNetWeight: 0, loss: 0, goldLoss: 0,
goldPrice: globalGoldPrice.value, goldValue: 0,
mainStoneCount: 0, mainStoneWeight: 0, mainStoneUnitPrice: 0, mainStoneAmount: 0,
sideStoneCount: 0, sideStoneWeight: 0, sideStoneUnitPrice: 0, sideStoneAmount: 0,
accessoryAmount: 0, processingFee: 0, settingFee: 0, totalLaborCost: 0, totalPrice: 0,
})
}
// 自动计算规格数据
function recalcSpec() {
const f = specForm
const n = (v: any) => Number(v) || 0
f.goldNetWeight = +(n(f.goldTotalWeight) - n(f.mainStoneWeight) * 0.2 - n(f.sideStoneWeight) * 0.2).toFixed(4)
if (f.goldNetWeight < 0) f.goldNetWeight = 0
f.goldLoss = +(f.goldNetWeight * n(f.loss)).toFixed(4)
f.goldValue = +(f.goldLoss * n(f.goldPrice)).toFixed(2)
f.mainStoneAmount = +(n(f.mainStoneWeight) * n(f.mainStoneUnitPrice)).toFixed(2)
f.sideStoneAmount = +(n(f.sideStoneWeight) * n(f.sideStoneUnitPrice)).toFixed(2)
f.totalLaborCost = +(n(f.accessoryAmount) + n(f.processingFee) + n(f.settingFee)).toFixed(2)
f.totalPrice = +(f.goldValue + f.mainStoneAmount + f.sideStoneAmount + f.totalLaborCost).toFixed(2)
}
function onFinenesChange(val: string) {
specForm.goldPrice = val === '铂金PT950' ? globalPlatinumPrice.value : globalGoldPrice.value
}
watch(
() => [
specForm.goldTotalWeight, specForm.loss, specForm.goldPrice,
specForm.mainStoneWeight, specForm.mainStoneUnitPrice,
specForm.sideStoneWeight, specForm.sideStoneUnitPrice,
specForm.accessoryAmount, specForm.processingFee, specForm.settingFee,
],
recalcSpec,
)
async function loadSpecData() {
if (!isEdit.value) return
try {
const res: any = await getSpecDataList(Number(route.params.id))
specDataRows.value = res.data
} catch { /* ignore */ }
}
function handleEditSpec(row: any) {
editingSpecId.value = row.id || row._tempId
Object.assign(specForm, {
modelName: row.modelName, barcode: row.barcode || '', fineness: row.fineness, mainStone: row.mainStone, subStone: row.subStone || '', ringSize: row.ringSize,
goldTotalWeight: row.goldTotalWeight, goldNetWeight: row.goldNetWeight, loss: row.loss, goldLoss: row.goldLoss,
goldPrice: row.goldPrice, goldValue: row.goldValue,
mainStoneCount: row.mainStoneCount, mainStoneWeight: row.mainStoneWeight, mainStoneUnitPrice: row.mainStoneUnitPrice, mainStoneAmount: row.mainStoneAmount,
sideStoneCount: row.sideStoneCount, sideStoneWeight: row.sideStoneWeight, sideStoneUnitPrice: row.sideStoneUnitPrice, sideStoneAmount: row.sideStoneAmount,
accessoryAmount: row.accessoryAmount, processingFee: row.processingFee, settingFee: row.settingFee, totalLaborCost: row.totalLaborCost, totalPrice: row.totalPrice,
})
specDialogVisible.value = true
}
async function handleCreateSpec() {
specSaving.value = true
try {
if (editingSpecId.value) {
// 编辑模式
if (isEdit.value) {
await updateSpecData(Number(route.params.id), editingSpecId.value, { ...specForm })
ElMessage.success('更新成功')
loadSpecData()
} else {
const idx = specDataRows.value.findIndex((r: any) => (r.id || r._tempId) === editingSpecId.value)
if (idx !== -1) Object.assign(specDataRows.value[idx], { ...specForm })
ElMessage.success('已更新')
}
} else if (isEdit.value) {
await createSpecData(Number(route.params.id), { ...specForm })
ElMessage.success('新增成功')
loadSpecData()
} else {
specDataRows.value.push({ ...specForm, _tempId: Date.now() })
ElMessage.success('已添加,保存商品时将一并提交')
}
specDialogVisible.value = false
resetSpecForm()
} catch (e: any) {
const msg = e?.response?.data?.message || '操作失败'
ElMessage.error(msg)
}
finally { specSaving.value = false }
}
async function handleDeleteSpec(specId: number) {
try {
await ElMessageBox.confirm('确定删除该规格数据?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
if (isEdit.value) {
await deleteSpecData(Number(route.params.id), specId)
ElMessage.success('删除成功')
loadSpecData()
} else {
specDataRows.value = specDataRows.value.filter((r: any) => (r.id || r._tempId) !== specId)
ElMessage.success('已删除')
}
} catch (err: any) {
if (err === 'cancel') return
ElMessage.error('删除失败')
}
}
function handleBannerSuccess(response: any) {
if (response.code === 0) {
form.bannerImages.push(response.data.url)
formRef.value?.validateField('bannerImages')
}
}
function handleBannerRemove(_file: UploadFile, fileList: UploadFile[]) {
form.bannerImages = fileList
.filter((f) => f.response || f.url)
.map((f: any) => f.response?.data?.url || f.url)
formRef.value?.validateField('bannerImages')
}
function handleThumbSuccess(response: any, file: any) {
if (response.code === 0) {
form.thumb = response.data.url
thumbFileList.value = [{ name: file.name, url: response.data.url }] as any[]
formRef.value?.validateField('thumb')
}
}
function handleThumbRemove() {
form.thumb = ''
thumbFileList.value = []
formRef.value?.validateField('thumb')
}
function handleVideoSuccess(response: any) {
if (response.code === 0) {
form.bannerVideo.push(response.data.url)
}
}
function handleDetailSuccess(response: any) {
if (response.code === 0) {
form.detailImages.push(response.data.url)
}
}
function handleDetailRemove(_file: UploadFile, fileList: UploadFile[]) {
form.detailImages = fileList
.filter((f) => f.response || f.url)
.map((f: any) => f.response?.data?.url || f.url)
}
async function loadProduct() {
if (!isEdit.value) return
try {
const res: any = await getProductDetail(Number(route.params.id))
const p = res.data
Object.assign(form, {
name: p.name,
basePrice: Number(p.base_price),
styleNo: p.style_no,
stock: p.stock,
totalStock: p.total_stock,
loss: Number(p.loss),
laborCost: Number(p.labor_cost),
categoryId: (() => {
const cid = p.category_id
if (Array.isArray(cid)) return cid
if (typeof cid === 'string') { try { const arr = JSON.parse(cid); return Array.isArray(arr) ? arr : [arr] } catch { return cid ? [Number(cid)] : [] } }
return cid ? [cid] : []
})(),
status: p.status,
bannerImages: p.banner_images || [],
bannerVideo: p.banner_video || [],
thumb: p.thumb || '',
detailImages: p.detail_images || [],
sideStone: p.side_stone || '',
style: p.style || '',
setting: p.setting || '',
})
bannerFileList.value = (p.banner_images || []).map((url: string, i: number) => ({
name: `image-${i}`,
url,
}))
thumbFileList.value = p.thumb ? [{ name: 'thumb', url: p.thumb }] as UploadFile[] : []
detailFileList.value = (p.detail_images || []).map((url: string, i: number) => ({
name: `detail-${i}`,
url,
}))
const specRes: any = await http.get(`/products/${route.params.id}/specs`)
if (specRes.data) {
detailParams.fineness = specRes.data.fineness || []
detailParams.mainStone = specRes.data.mainStone || specRes.data.main_stone || []
detailParams.subStone = specRes.data.subStone || specRes.data.sub_stone || []
detailParams.ringSize = specRes.data.ringSize || specRes.data.ring_size || []
}
} catch {
ElMessage.error('加载商品信息失败')
}
}
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const data = {
...form,
detailParams: {
fineness: detailParams.fineness,
mainStone: detailParams.mainStone,
subStone: detailParams.subStone,
ringSize: detailParams.ringSize,
},
}
if (isEdit.value) {
await updateProduct(Number(route.params.id), data)
ElMessage.success('更新成功')
} else {
const createData = { ...data, specDataList: specDataRows.value }
const res: any = await createProduct(createData)
ElMessage.success('创建成功')
// 新建后跳转到编辑页,方便继续操作
if (res?.data?.id) {
router.replace(`/products/${res.data.id}/edit`)
}
}
} catch (e: any) {
const msg = e?.response?.data?.message || '保存失败'
ElMessage.error(msg)
} finally {
saving.value = false
}
}
async function handleExport() {
try {
const res: any = await exportSpecData(Number(route.params.id))
const blob = new Blob([res], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `spec_data_${route.params.id}.csv`
a.click()
URL.revokeObjectURL(url)
} catch {
ElMessage.error('导出失败')
}
}
function handleImportSuccess(response: any) {
if (response.code === 0) {
const { imported, skipped, warnings } = response.data
let msg = `导入成功,新增 ${imported}`
if (skipped) msg += `,跳过 ${skipped}`
ElMessage.success(msg)
if (warnings?.length) {
setTimeout(() => ElMessage.warning(warnings.join('')), 500)
}
loadSpecData()
} else {
ElMessage.error(response.message || '导入失败')
}
}
function handleImportError() {
ElMessage.error('导入失败')
}
onMounted(async () => {
try {
const res: any = await getCategories()
categories.value = res.data
} catch { /* ignore */ }
try {
const [gRes, pRes]: any[] = await Promise.all([getLatestGoldPrice(), getLatestPlatinumPrice()])
globalGoldPrice.value = gRes.data?.price || 0
globalPlatinumPrice.value = pRes.data?.price || 0
specForm.goldPrice = globalGoldPrice.value
} catch { /* ignore */ }
loadProduct()
loadSpecData()
})
</script>
<style scoped>
.product-form-page {
padding: 0;
}
.form-card {
border-radius: 12px;
border: none;
}
.form-card :deep(.el-card__header) {
border-bottom: 1px solid #f0f0f0;
padding: 16px 24px;
}
.form-card :deep(.el-card__body) {
padding: 24px 32px;
}
.form-card__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.form-card__title {
font-size: 18px;
font-weight: 600;
color: #1d1e3a;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #1d1e3a;
margin: 8px 0 18px;
padding-left: 10px;
border-left: 3px solid #7c5cfc;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-actions {
display: flex;
align-items: center;
gap: 8px;
font-weight: 400;
}
.dialog-section {
font-size: 13px;
font-weight: 600;
color: #666;
margin: 12px 0 8px;
padding-left: 8px;
border-left: 2px solid #7c5cfc;
}
.upload-tip {
color: #999;
font-size: 12px;
line-height: 1.4;
}
.tag-input-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.form-footer {
margin-top: 12px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
display: flex;
gap: 12px;
}
.form-card :deep(.el-form-item__label) {
white-space: nowrap;
}
.hide-upload :deep(.el-upload--picture-card) {
display: none;
}
.video-upload-wrap {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.video-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #f5f7fa;
border-radius: 6px;
border: 1px solid #e4e7ed;
}
.video-item__name {
flex: 1;
font-size: 13px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 规格数据表格边框加深 */
:deep(.el-table--border .el-table__cell) {
border-right: 1px solid #ccc !important;
}
:deep(.el-table--border th.el-table__cell) {
border-bottom: 1px solid #ccc !important;
border-right: 1px solid #ccc !important;
}
:deep(.el-table--border) {
border: 1px solid #ccc !important;
}
:deep(.el-table--border::after),
:deep(.el-table--border::before) {
background-color: #ccc !important;
}
:deep(.el-table__inner-wrapper::before) {
background-color: #ccc !important;
}
</style>