851 lines
36 KiB
Vue
851 lines
36 KiB
Vue
<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>
|