管理后台

This commit is contained in:
18631081161 2026-03-28 18:30:52 +08:00
parent 53ce69a1f9
commit 3983f4ad75
15 changed files with 1185 additions and 175 deletions

View File

@ -11,3 +11,15 @@ export function getLatestGoldPrice() {
export function setGoldPrice(price: number) {
return http.post('/admin/gold-price', { price })
}
export function getPlatinumPriceLogs() {
return http.get('/admin/platinum-price')
}
export function getLatestPlatinumPrice() {
return http.get('/admin/platinum-price/latest')
}
export function setPlatinumPrice(price: number) {
return http.post('/admin/platinum-price', { price })
}

View File

@ -10,7 +10,8 @@ export function getOrderDetail(id: number) {
export function createOrder(data: {
userId: number
items: { productId: number; specDataId: number; quantity: number }[]
source?: string
items: { barcode: string; quantity: number }[]
receiverName?: string
receiverPhone?: string
receiverAddress?: string
@ -40,3 +41,16 @@ export function updateOrderStatus(id: number, data: {
}) {
return http.put(`/admin/orders/${id}/status`, data)
}
export function returnOrder(id: number, data: {
returnReason: string
refundProof?: string
refundTime?: string
items: { orderItemId: number; quantity: number }[]
}) {
return http.post(`/admin/orders/${id}/return`, data)
}
export function getOrderReturns(id: number) {
return http.get(`/admin/orders/${id}/returns`)
}

View File

@ -55,3 +55,7 @@ export function deleteSpecData(productId: number, specId: number) {
export function updateSpecData(productId: number, specId: number, data: any) {
return http.put(`/admin/products/${productId}/spec-data/${specId}`, data)
}
export function lookupBarcodes(barcodes: string[]) {
return http.post('/admin/spec-data/lookup', { barcodes })
}

View File

@ -21,6 +21,7 @@
<el-option label="待收货" value="shipped" />
<el-option label="已收货" value="received" />
<el-option label="已取消" value="cancelled" />
<el-option label="已退货" value="returned" />
</el-select>
</div>
@ -105,6 +106,11 @@
<span style="color: #e4393c; font-weight: 600">¥{{ Number(row.total_price).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column prop="source" label="订单来源" width="100" align="center">
<template #default="{ row }">
<el-tag :type="sourceTagType(row.source)" size="small" round effect="plain">{{ sourceLabel(row.source) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="140" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small" round effect="light">{{ statusLabel(row.status) }}</el-tag>
@ -143,6 +149,8 @@
<el-button v-if="row.status === 'paid'" text type="warning" size="small" @click="handleShip(row)">确认发货</el-button>
<el-button v-if="row.status === 'paid'" text type="danger" size="small" @click="handleCancel(row)">取消订单</el-button>
<el-button v-if="row.status === 'shipped'" text type="success" size="small" @click="handleReceive(row)">确认收货</el-button>
<el-button v-if="row.status === 'received'" text type="warning" size="small" @click="handleReturn(row)">退货</el-button>
<el-button v-if="row.status === 'received' || row.status === 'returned'" text type="info" size="small" @click="handleViewReturns(row)">退货记录</el-button>
</template>
</el-table-column>
</el-table>
@ -159,38 +167,111 @@
</el-card>
<!-- Create Order Dialog -->
<el-dialog v-model="showCreateDialog" title="手动创建订单" width="680px" @close="resetCreateForm">
<el-dialog v-model="showCreateDialog" title="手动创建订单" width="960px" top="5vh" @close="resetCreateForm">
<el-form :model="createForm" label-width="100px">
<el-form-item label="用户 ID" required>
<el-input-number v-model="createForm.userId" :min="1" />
</el-form-item>
<el-form-item label="收货人">
<el-input v-model="createForm.receiverName" />
</el-form-item>
<el-form-item label="联系电话">
<el-input v-model="createForm.receiverPhone" />
</el-form-item>
<el-form-item label="收货地址">
<el-input v-model="createForm.receiverAddress" />
</el-form-item>
<el-divider>商品列表</el-divider>
<div v-for="(item, idx) in createForm.items" :key="idx" class="order-item-row">
<div class="order-item-row__main">
<el-select v-model="item.productId" filterable placeholder="选择商品" style="width:220px" @change="onProductChange(item, 'create')">
<el-option v-for="p in allProducts" :key="p.id" :label="`${p.name}${p.style_no}`" :value="p.id" />
</el-select>
<el-select v-model="item.specDataId" filterable placeholder="选择规格" style="width:260px" :disabled="!item.productId">
<el-option v-for="s in (item._specList || [])" :key="s.id" :label="formatSpecLabel(s)" :value="s.id" />
</el-select>
<el-input-number v-model="item.quantity" :min="1" style="width:100px" />
<el-button text type="danger" @click="createForm.items.splice(idx, 1)" :disabled="createForm.items.length <= 1">删除</el-button>
</div>
</div>
<el-button @click="createForm.items.push({ productId: 0, specDataId: 0, quantity: 1, _specList: [] })">+ 添加商品</el-button>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="订单来源" required>
<el-select v-model="createForm.source" placeholder="请选择" style="width:100%">
<el-option label="小程序" value="miniapp" />
<el-option label="线下门店" value="offline" />
<el-option label="淘宝" value="taobao" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="用户 ID" required>
<el-input-number v-model="createForm.userId" :min="1" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="收货人">
<el-input v-model="createForm.receiverName" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="联系电话">
<el-input v-model="createForm.receiverPhone" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="收货地址">
<el-input v-model="createForm.receiverAddress" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-divider>商品列表</el-divider>
<div class="create-toolbar">
<div class="create-toolbar__left">
<el-input v-model="manualBarcode" placeholder="输入条形码后回车添加" style="width:240px"
@keyup.enter="handleAddBarcode" />
<el-button type="primary" plain @click="handleAddBarcode" :disabled="!manualBarcode.trim()">添加</el-button>
</div>
<div class="create-toolbar__right">
<el-upload :show-file-list="false" accept=".csv,.txt" :before-upload="handleCsvImport">
<el-button type="success" plain><el-icon><Upload /></el-icon> CSV</el-button>
</el-upload>
</div>
</div>
<el-table v-if="previewItems.length" :data="previewItems" size="small" stripe border
style="width:100%;margin-top:12px" max-height="400"
:header-cell-style="{ background:'#f5f5ff', fontWeight:600, fontSize:'12px', padding:'8px 0' }"
:cell-style="{ padding:'6px 0', fontSize:'12px' }">
<el-table-column type="index" label="#" width="40" align="center" />
<el-table-column prop="barcode" label="条形码" width="130" show-overflow-tooltip />
<el-table-column prop="productName" label="商品名称" width="140" show-overflow-tooltip />
<el-table-column prop="styleNo" label="款号" width="100" show-overflow-tooltip />
<el-table-column label="规格信息" min-width="240">
<template #default="{ row }">
<div style="line-height:1.6;font-size:12px">
<span v-if="row.modelName">型号:{{ row.modelName }}</span>
<span v-if="row.fineness" style="margin-left:8px">成色:{{ row.fineness }}</span>
<span v-if="row.mainStone" style="margin-left:8px">主石:{{ row.mainStone }}</span>
<span v-if="row.subStone" style="margin-left:8px">副石:{{ row.subStone }}</span>
<span v-if="row.ringSize" style="margin-left:8px">手寸:{{ row.ringSize }}</span>
</div>
<div style="color:#999;margin-top:2px">
<span v-if="row.goldTotalWeight">总重:{{ row.goldTotalWeight }}</span>
<span v-if="row.goldNetWeight" style="margin-left:8px">净重:{{ row.goldNetWeight }}</span>
<span v-if="row.goldValue" style="margin-left:8px">金值:¥{{ Number(row.goldValue).toFixed(2) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="数量" width="100" align="center">
<template #default="{ row }">
<el-input-number v-model="row.quantity" :min="1" size="small" controls-position="right" style="width:80px" />
</template>
</el-table-column>
<el-table-column prop="totalPrice" label="单价" width="90" align="right">
<template #default="{ row }">
<span style="color:#e4393c;font-weight:600">¥{{ Number(row.totalPrice).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="previewItems.splice($index, 1)">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-else description="请导入 CSV 或手动添加条形码" :image-size="50" style="padding:20px 0" />
<div v-if="previewItems.length" style="text-align:right;margin-top:12px;font-size:14px">
<b>{{ previewItems.length }}</b> 件商品合计
<span style="color:#e4393c;font-weight:700;font-size:16px">
¥{{ previewItems.reduce((s, i) => s + Number(i.totalPrice) * i.quantity, 0).toFixed(2) }}
</span>
</div>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="handleCreate">创建</el-button>
<el-button type="primary" :loading="creating" :disabled="previewItems.length === 0" @click="handleCreate">确认创建订单</el-button>
</template>
</el-dialog>
@ -312,14 +393,126 @@
<el-button type="danger" :loading="updatingStatus" @click="confirmCancel">确认取消订单</el-button>
</template>
</el-dialog>
<!-- Return Order Dialog -->
<el-dialog v-model="showReturnDialog" title="退货处理" width="780px" top="5vh">
<el-alert v-if="returnOrder_" :title="`订单号: ${returnOrder_.order_no}`" type="info" :closable="false" show-icon style="margin-bottom:16px" />
<el-table :data="returnItems" size="small" stripe border max-height="320"
:header-cell-style="{ background:'#fafafa', fontWeight:600 }"
:cell-style="{ padding:'8px 0' }">
<el-table-column width="50" align="center">
<template #default="{ row }">
<el-checkbox v-model="row.checked" :disabled="row.maxReturnQty <= 0" />
</template>
</el-table-column>
<el-table-column label="商品" min-width="200">
<template #default="{ row }">
<div style="font-weight:600">{{ row.productName }}</div>
<div style="font-size:12px;color:#999;margin-top:2px">
<span v-if="row.modelName">型号:{{ row.modelName }}</span>
<span v-if="row.fineness" style="margin-left:8px">成色:{{ row.fineness }}</span>
<span v-if="row.mainStone" style="margin-left:8px">主石:{{ row.mainStone }}</span>
<span v-if="row.subStone" style="margin-left:8px">副石:{{ row.subStone }}</span>
<span v-if="row.ringSize" style="margin-left:8px">手寸:{{ row.ringSize }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="单价" width="100" align="right">
<template #default="{ row }">
<span style="color:#e4393c">¥{{ Number(row.unitPrice).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column label="购买数量" width="80" align="center">
<template #default="{ row }">{{ row.quantity }}</template>
</el-table-column>
<el-table-column label="已退" width="60" align="center">
<template #default="{ row }">
<span :style="{ color: row.returnedQty > 0 ? '#e4393c' : '#ccc' }">{{ row.returnedQty }}</span>
</template>
</el-table-column>
<el-table-column label="退货数量" width="120" align="center">
<template #default="{ row }">
<el-input-number v-model="row.returnQty" :min="0" :max="row.maxReturnQty" :disabled="!row.checked || row.maxReturnQty <= 0" size="small" controls-position="right" style="width:90px" />
</template>
</el-table-column>
</el-table>
<div v-if="returnCheckedItems.length" style="text-align:right;margin-top:8px;font-size:13px;color:#666">
退货 <b>{{ returnCheckedItems.reduce((s, i) => s + i.returnQty, 0) }}</b> 退款金额
<span style="color:#e4393c;font-weight:700;font-size:15px">
¥{{ returnCheckedItems.reduce((s, i) => s + i.returnQty * Number(i.unitPrice), 0).toFixed(2) }}
</span>
</div>
<el-form label-width="100px" style="margin-top:16px">
<el-form-item label="退货原因" required>
<el-input v-model="returnForm.returnReason" type="textarea" :rows="2" placeholder="请填写退货原因" />
</el-form-item>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="退款凭证">
<el-upload :action="uploadUrl" :headers="uploadHeaders" :show-file-list="false" :on-success="handleReturnProofSuccess" accept="image/*">
<el-button size="small">上传凭证</el-button>
</el-upload>
<div v-if="returnForm.refundProof" style="margin-top:6px">
<el-image :src="returnForm.refundProof" style="width:100px;height:100px;border-radius:6px" fit="cover" :preview-src-list="[returnForm.refundProof]" preview-teleported />
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="退款时间">
<el-date-picker v-model="returnForm.refundTime" type="datetime" placeholder="选择退款时间" style="width:100%" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="showReturnDialog = false">取消</el-button>
<el-button type="warning" :loading="submittingReturn" :disabled="returnCheckedItems.length === 0" @click="confirmReturn">确认退货</el-button>
</template>
</el-dialog>
<!-- Return Records Dialog -->
<el-dialog v-model="showReturnRecordsDialog" title="退货记录" width="700px" top="5vh">
<div v-if="returnRecordsLoading" v-loading="true" style="min-height:120px"></div>
<div v-else-if="returnRecords.length === 0" style="text-align:center;padding:40px;color:#999">暂无退货记录</div>
<div v-else>
<div v-for="(record, idx) in returnRecords" :key="record.id" class="return-record-card">
<div class="return-record-card__header">
<span style="font-weight:600;color:#333">退货 #{{ idx + 1 }}</span>
<span style="color:#999;font-size:12px">{{ formatTime(record.created_at) }}</span>
</div>
<div style="font-size:13px;color:#666;margin:6px 0">
<span style="color:#999">退货原因</span>{{ record.return_reason }}
</div>
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:6px 0">
<span style="color:#e4393c;font-weight:600;font-size:14px">退款 ¥{{ Number(record.refund_amount).toFixed(2) }}</span>
<span v-if="record.refund_time" style="font-size:12px;color:#999">退款时间: {{ formatTime(record.refund_time) }}</span>
<el-image v-if="record.refund_proof" :src="record.refund_proof" style="width:48px;height:48px;border-radius:4px" fit="cover" :preview-src-list="[record.refund_proof]" preview-teleported />
</div>
<el-table :data="record.items" size="small" stripe style="margin-top:8px">
<el-table-column prop="productName" label="商品" min-width="140" />
<el-table-column prop="modelName" label="规格名称" min-width="120" />
<el-table-column prop="quantity" label="退货数量" width="80" align="center" />
<el-table-column label="退款小计" width="100" align="right">
<template #default="{ row }">
<span style="color:#e4393c">¥{{ (row.quantity * Number(row.unitPrice)).toFixed(2) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { getOrders, getOrderDetail, createOrder, updateOrder, updateOrderStatus } from '../../api/order'
import { getProducts, getSpecDataList } from '../../api/product'
import { Upload, Delete, Plus, Search } from '@element-plus/icons-vue'
import { getOrders, getOrderDetail, createOrder, updateOrder, updateOrderStatus, returnOrder, getOrderReturns } from '../../api/order'
import { getProducts, getSpecDataList, lookupBarcodes } from '../../api/product'
import { getUploadUrl } from '../../api/request'
const orders = ref<any[]>([])
@ -365,12 +558,14 @@ async function onProductChange(item: any, _mode: string) {
// Create dialog
const showCreateDialog = ref(false)
const creating = ref(false)
const manualBarcode = ref('')
const previewItems = ref<any[]>([])
const createForm = ref({
source: 'miniapp' as string,
userId: 1,
receiverName: '',
receiverPhone: '',
receiverAddress: '',
items: [{ productId: 0, specDataId: 0, quantity: 1, _specList: [] as any[] }],
})
// Edit dialog
@ -411,13 +606,23 @@ const uploadHeaders = computed(() => ({
Authorization: `Bearer ${localStorage.getItem('admin_token') || ''}`,
}))
function sourceLabel(s: string) {
const map: Record<string, string> = { miniapp: '小程序', offline: '线下门店', taobao: '淘宝' }
return map[s] || '-'
}
function sourceTagType(s: string) {
const map: Record<string, string> = { miniapp: 'success', offline: 'warning', taobao: '' }
return map[s] || 'info'
}
function statusLabel(s: string) {
const map: Record<string, string> = { pending: '未付款', paid: '待发货', shipped: '待收货', received: '已收货', cancelled: '已取消' }
const map: Record<string, string> = { pending: '未付款', paid: '待发货', shipped: '待收货', received: '已收货', cancelled: '已取消', returned: '已退货' }
return map[s] || s
}
function statusTagType(s: string) {
const map: Record<string, string> = { pending: 'danger', paid: 'warning', shipped: '', received: 'success', cancelled: 'info' }
const map: Record<string, string> = { pending: 'danger', paid: 'warning', shipped: '', received: 'success', cancelled: 'info', returned: 'danger' }
return map[s] || ''
}
@ -453,27 +658,91 @@ function handlePageChange(p: number) {
function resetCreateForm() {
createForm.value = {
source: 'miniapp',
userId: 1,
receiverName: '',
receiverPhone: '',
receiverAddress: '',
items: [{ productId: 0, specDataId: 0, quantity: 1, _specList: [] }],
}
previewItems.value = []
manualBarcode.value = ''
}
async function resolveBarcodes(barcodes: string[]) {
const existingBarcodes = new Set(previewItems.value.map((i: any) => i.barcode))
const newBarcodes = barcodes.filter(b => b && !existingBarcodes.has(b))
if (newBarcodes.length === 0) {
ElMessage.info('没有新的条形码需要添加')
return
}
try {
const res: any = await lookupBarcodes(newBarcodes)
const { items, notFound } = res.data
for (const barcode of newBarcodes) {
if (items[barcode]) {
previewItems.value.push({ ...items[barcode], quantity: 1 })
}
}
if (notFound?.length) {
ElMessage.warning(`以下条形码未找到: ${notFound.join(', ')}`)
}
} catch {
ElMessage.error('查询条形码失败')
}
}
async function handleAddBarcode() {
const barcode = manualBarcode.value.trim()
if (!barcode) return
await resolveBarcodes([barcode])
manualBarcode.value = ''
}
function handleCsvImport(file: File) {
const reader = new FileReader()
reader.onload = async (e) => {
const text = (e.target?.result as string) || ''
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean)
const barcodes: string[] = []
for (const line of lines) {
const val = line.split(',')[0].trim()
if (!val) continue
if (/条[型形]码/i.test(val)) continue
if (val === 'barcode') continue
barcodes.push(val)
}
if (barcodes.length === 0) {
ElMessage.warning('CSV 中未找到有效的条形码')
return
}
await resolveBarcodes(barcodes)
ElMessage.success(`已解析 ${barcodes.length} 个条形码`)
}
reader.readAsText(file, 'utf-8')
return false
}
async function handleCreate() {
if (previewItems.value.length === 0) {
ElMessage.warning('请添加至少一个商品')
return
}
if (!createForm.value.source) {
ElMessage.warning('请选择订单来源')
return
}
creating.value = true
try {
const payload = {
...createForm.value,
items: createForm.value.items.map(({ _specList, ...rest }) => rest),
items: previewItems.value.map((i: any) => ({ barcode: i.barcode, quantity: i.quantity })),
}
await createOrder(payload)
ElMessage.success('订单创建成功')
showCreateDialog.value = false
fetchOrders()
} catch {
ElMessage.error('创建订单失败')
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '创建订单失败')
} finally {
creating.value = false
}
@ -662,6 +931,111 @@ async function confirmReceive() {
}
}
// Return dialog
const showReturnDialog = ref(false)
const submittingReturn = ref(false)
const returnOrder_ = ref<any>(null)
const returnItems = ref<any[]>([])
const returnForm = ref({ returnReason: '', refundProof: '', refundTime: '' })
const returnCheckedItems = computed(() => returnItems.value.filter(i => i.checked && i.returnQty > 0))
// Return records dialog
const showReturnRecordsDialog = ref(false)
const returnRecordsLoading = ref(false)
const returnRecords = ref<any[]>([])
async function handleReturn(row: any) {
returnOrder_.value = row
returnForm.value = { returnReason: '', refundProof: '', refundTime: '' }
try {
const detailRes: any = await getOrderDetail(row.id)
const detail = detailRes.data
let returnsRes: any = { data: [] }
try {
returnsRes = await getOrderReturns(row.id)
} catch { /* no returns yet */ }
const returnedMap: Record<number, number> = {}
for (const r of returnsRes.data || []) {
for (const ri of r.items || []) {
returnedMap[ri.orderItemId] = (returnedMap[ri.orderItemId] || 0) + ri.quantity
}
}
returnItems.value = (detail.items || []).map((it: any) => {
const returnedQty = returnedMap[it.id] || 0
const maxReturnQty = it.quantity - returnedQty
return {
orderItemId: it.id,
productName: it.product_name,
modelName: it.model_name,
fineness: it.fineness,
mainStone: it.main_stone,
subStone: it.sub_stone,
ringSize: it.ring_size,
unitPrice: it.unit_price,
quantity: it.quantity,
returnedQty,
maxReturnQty,
returnQty: maxReturnQty > 0 ? maxReturnQty : 0,
checked: maxReturnQty > 0,
}
})
showReturnDialog.value = true
} catch {
ElMessage.error('获取订单详情失败')
}
}
function handleReturnProofSuccess(response: any) {
if (response.code === 0) {
returnForm.value.refundProof = response.data.url
}
}
async function confirmReturn() {
const checkedItems = returnCheckedItems.value
if (checkedItems.length === 0) {
ElMessage.warning('请选择退货商品')
return
}
if (!returnForm.value.returnReason.trim()) {
ElMessage.warning('请填写退货原因')
return
}
submittingReturn.value = true
try {
const res: any = await returnOrder(returnOrder_.value.id, {
returnReason: returnForm.value.returnReason.trim(),
refundProof: returnForm.value.refundProof || undefined,
refundTime: returnForm.value.refundTime ? new Date(returnForm.value.refundTime).toISOString() : undefined,
items: checkedItems.map(i => ({ orderItemId: i.orderItemId, quantity: i.returnQty })),
})
ElMessage.success(res.message || '退货成功')
showReturnDialog.value = false
fetchOrders()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '退货处理失败')
} finally {
submittingReturn.value = false
}
}
async function handleViewReturns(row: any) {
showReturnRecordsDialog.value = true
returnRecordsLoading.value = true
try {
const res: any = await getOrderReturns(row.id)
returnRecords.value = res.data || []
} catch {
ElMessage.error('获取退货记录失败')
returnRecords.value = []
} finally {
returnRecordsLoading.value = false
}
}
function getItemThumb(item: any): string {
if (item.banner_images) {
try {
@ -698,4 +1072,10 @@ onMounted(() => {
.order-item-row { padding: 8px 0; border-bottom: 1px dashed #eee; }
.order-item-row:last-child { border-bottom: none; }
.order-item-row__main { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.create-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.create-toolbar__left { display: flex; align-items: center; gap: 8px; }
.create-toolbar__right { display: flex; align-items: center; gap: 8px; }
.return-record-card { padding: 16px; margin-bottom: 12px; background: #fafafa; border-radius: 8px; border: 1px solid #f0f0f0; }
.return-record-card:last-child { margin-bottom: 0; }
.return-record-card__header { display: flex; justify-content: space-between; align-items: center; }
</style>

View File

@ -26,11 +26,6 @@
</el-col>
</el-row>
<el-row :gutter="32">
<el-col :span="12">
<el-form-item label="基础价格" prop="basePrice">
<el-input-number v-model="form.basePrice" :min="0" :precision="2" controls-position="right" style="width:100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="分类" prop="categoryId">
<el-select v-model="form.categoryId" placeholder="选择分类" clearable multiple style="width:100%">
@ -38,28 +33,18 @@
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="32">
<el-col :span="12">
<el-form-item label="库存" prop="stock">
<el-input-number v-model="form.stock" :min="0" 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="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-row>
<el-row :gutter="32">
<el-col :span="12">
<el-form-item label="状态">
<el-switch v-model="form.status" active-value="on" inactive-value="off" active-text="上架" inactive-text="下架" />
@ -297,7 +282,16 @@
<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-input v-model="specForm.fineness" /></el-form-item></el-col>
<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>
@ -314,7 +308,7 @@
</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">读取自金价配置</div></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>
@ -366,7 +360,7 @@ 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 } from '../../api/goldPrice'
import { getLatestGoldPrice, getLatestPlatinumPrice } from '../../api/goldPrice'
import http, { getUploadUrl } from '../../api/request'
const route = useRoute()
@ -424,9 +418,7 @@ const detailFileList = ref<UploadFile[]>([])
const rules = {
name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
basePrice: [{ required: true, message: '请输入基础价格', trigger: 'blur' }],
styleNo: [{ required: true, message: '请输入款号', trigger: 'blur' }],
stock: [{ required: true, message: '请输入库存', trigger: 'blur' }],
loss: [{ required: true, message: '请输入损耗', trigger: 'blur' }],
laborCost: [{ required: true, message: '请输入工费', trigger: 'blur' }],
@ -440,6 +432,7 @@ 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,
@ -475,6 +468,10 @@ function recalcSpec() {
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,
@ -702,7 +699,13 @@ async function handleExport() {
function handleImportSuccess(response: any) {
if (response.code === 0) {
ElMessage.success(`导入成功,共 ${response.data.imported}`)
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 || '导入失败')
@ -719,8 +722,9 @@ onMounted(async () => {
categories.value = res.data
} catch { /* ignore */ }
try {
const gRes: any = await getLatestGoldPrice()
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()

View File

@ -17,16 +17,48 @@
</el-input>
</div>
<el-table :data="products" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="商品名称" min-width="150" />
<el-table-column prop="style_no" label="款号" width="120" />
<el-table-column prop="base_price" label="基础价格" width="110">
<el-table :data="products" v-loading="loading" stripe ref="productTableRef"
@expand-change="handleExpandChange">
<el-table-column type="expand">
<template #default="{ row }">
<span style="color: #e4393c; font-weight: 600">¥{{ row.base_price }}</span>
<div v-if="specGroupsMap[row.id]" style="padding:8px 20px 16px">
<el-table :data="specGroupsMap[row.id]" size="small" border stripe
:header-cell-style="{ background:'#f5f5ff', fontWeight:600, fontSize:'12px', padding:'6px 0' }"
:cell-style="{ padding:'6px 0', fontSize:'12px' }">
<el-table-column type="index" label="#" width="40" align="center" />
<el-table-column prop="fineness" label="成色" width="80" align="center" />
<el-table-column prop="mainStone" label="主石" width="80" align="center" />
<el-table-column prop="subStone" label="副石" width="80" align="center" />
<el-table-column prop="ringSize" label="手寸" width="80" align="center" />
<el-table-column prop="modelNames" label="规格名称" min-width="200">
<template #default="{ row: g }">
<el-tag v-for="name in g.modelNames" :key="name" size="small" effect="plain"
style="margin:0 4px 4px 0">{{ name }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="count" label="规格总数" width="90" align="center">
<template #default="{ row: g }">
<span style="font-weight:700;color:#7c5cfc">{{ g.count }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div v-else-if="specLoadingMap[row.id]" style="text-align:center;padding:20px;color:#999">加载中...</div>
<div v-else style="text-align:center;padding:20px;color:#ccc">暂无规格数据</div>
</template>
</el-table-column>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column label="商品名称" min-width="200">
<template #default="{ row }">
<span style="font-weight:600">{{ row.name }}</span>
<span v-if="row.style_no" style="margin-left:8px;color:#999;font-size:12px">{{ row.style_no }}</span>
</template>
</el-table-column>
<el-table-column prop="spec_count" label="库存总额" width="100" align="center">
<template #default="{ row }">
<span style="font-weight:600">{{ row.spec_count }}</span>
</template>
</el-table-column>
<el-table-column prop="stock" label="库存" width="80" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'on' ? 'success' : 'info'" size="small" round>
@ -54,9 +86,9 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getProducts, deleteProduct } from '../../api/product'
import { getProducts, deleteProduct, getSpecDataList } from '../../api/product'
const products = ref<any[]>([])
const loading = ref(false)
@ -64,6 +96,48 @@ const keyword = ref('')
const page = ref(1)
const pageSize = 10
const total = ref(0)
const productTableRef = ref<any>(null)
const specGroupsMap = reactive<Record<number, any[]>>({})
const specLoadingMap = reactive<Record<number, boolean>>({})
async function handleExpandChange(row: any, expandedRows: any[]) {
const isExpanded = expandedRows.some((r: any) => r.id === row.id)
if (!isExpanded || specGroupsMap[row.id]) return
specLoadingMap[row.id] = true
try {
const res: any = await getSpecDataList(row.id)
const specs: any[] = res.data || []
const groupMap = new Map<string, { fineness: string; mainStone: string; subStone: string; ringSize: string; modelNames: Set<string>; count: number }>()
for (const s of specs) {
const key = `${s.fineness || '-'}|${s.mainStone || '-'}|${s.subStone || '-'}|${s.ringSize || '-'}`
if (!groupMap.has(key)) {
groupMap.set(key, {
fineness: s.fineness || '-',
mainStone: s.mainStone || '-',
subStone: s.subStone || '-',
ringSize: s.ringSize || '-',
modelNames: new Set(),
count: 0,
})
}
const g = groupMap.get(key)!
if (s.modelName) g.modelNames.add(s.modelName)
g.count++
}
specGroupsMap[row.id] = Array.from(groupMap.values()).map(g => ({
...g,
modelNames: Array.from(g.modelNames),
}))
} catch {
specGroupsMap[row.id] = []
} finally {
specLoadingMap[row.id] = false
}
}
async function fetchProducts() {
loading.value = true

View File

@ -1,9 +1,11 @@
<template>
<div>
<!-- 金价配置 -->
<el-card shadow="never" class="page-card">
<template #header>
<div class="page-card__header">
<span class="page-card__title">金价配置</span>
<el-tag type="info" size="small" effect="plain">适用于18k白 / 18k黄 / 18k玫瑰金</el-tag>
</div>
</template>
@ -15,11 +17,9 @@
</div>
<div class="price-input">
<el-input-number v-model="newPrice" :precision="2" :min="0.01" :step="10" controls-position="right" placeholder="输入新金价" style="width: 240px" />
<el-button type="primary" :loading="saving" @click="handleSave" style="margin-left: 12px">
更新金价
</el-button>
<el-button type="primary" :loading="saving" @click="handleSave" style="margin-left: 12px">更新金价</el-button>
</div>
<div class="price-tip">更新金价后系统将自动重算所有商品规格的金值和总</div>
<div class="price-tip">更新金价后系统将自动重算成色为18k白 / 18k黄 / 18k玫瑰金的所有规格价格</div>
</div>
<el-divider>修改记录</el-divider>
@ -33,10 +33,46 @@
</template>
</el-table-column>
<el-table-column prop="created_at" label="修改时间" min-width="200" align="center">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
</el-table>
</el-card>
<!-- 铂金价格配置 -->
<el-card shadow="never" class="page-card" style="margin-top: 20px">
<template #header>
<div class="page-card__header">
<span class="page-card__title">铂金价格配置</span>
<el-tag type="warning" size="small" effect="plain">适用于铂金PT950</el-tag>
</div>
</template>
<div class="gold-price-form">
<div class="current-price">
<span class="current-price__label">当前铂金价格</span>
<span class="current-price__value platinum" v-if="currentPlatinumPrice > 0">¥{{ currentPlatinumPrice.toFixed(2) }} / </span>
<span class="current-price__empty" v-else>暂未配置</span>
</div>
<div class="price-input">
<el-input-number v-model="newPlatinumPrice" :precision="2" :min="0.01" :step="10" controls-position="right" placeholder="输入新铂金价格" style="width: 240px" />
<el-button type="warning" :loading="savingPlatinum" @click="handleSavePlatinum" style="margin-left: 12px">更新铂金价格</el-button>
</div>
<div class="price-tip">更新铂金价格后系统将自动重算成色为铂金PT950的所有规格价格</div>
</div>
<el-divider>修改记录</el-divider>
<el-table :data="platinumLogs" v-loading="loadingPlatinum" stripe size="small"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }">
<el-table-column type="index" label="#" width="60" align="center" />
<el-table-column prop="price" label="铂金价格(元/克)" width="180" align="center">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
<span style="color: #e6a23c; font-weight: 600; font-size: 15px">¥{{ Number(row.price).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="修改时间" min-width="200" align="center">
<template #default="{ row }">{{ formatTime(row.created_at) }}</template>
</el-table-column>
</el-table>
</el-card>
</div>
@ -45,7 +81,7 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getGoldPriceLogs, getLatestGoldPrice, setGoldPrice } from '../../api/goldPrice'
import { getGoldPriceLogs, getLatestGoldPrice, setGoldPrice, getPlatinumPriceLogs, getLatestPlatinumPrice, setPlatinumPrice } from '../../api/goldPrice'
const currentPrice = ref(0)
const newPrice = ref(0)
@ -53,6 +89,12 @@ const logs = ref<any[]>([])
const loading = ref(false)
const saving = ref(false)
const currentPlatinumPrice = ref(0)
const newPlatinumPrice = ref(0)
const platinumLogs = ref<any[]>([])
const loadingPlatinum = ref(false)
const savingPlatinum = ref(false)
function formatTime(iso: string): string {
if (!iso) return '-'
const d = new Date(iso)
@ -65,7 +107,7 @@ function formatTime(iso: string): string {
return `${Y}-${M}-${D} ${h}:${m}:${s}`
}
async function fetchData() {
async function fetchGoldData() {
loading.value = true
try {
const [latestRes, logsRes]: any[] = await Promise.all([getLatestGoldPrice(), getGoldPriceLogs()])
@ -79,6 +121,20 @@ async function fetchData() {
}
}
async function fetchPlatinumData() {
loadingPlatinum.value = true
try {
const [latestRes, logsRes]: any[] = await Promise.all([getLatestPlatinumPrice(), getPlatinumPriceLogs()])
currentPlatinumPrice.value = latestRes.data?.price || 0
newPlatinumPrice.value = currentPlatinumPrice.value || 0
platinumLogs.value = logsRes.data || []
} catch {
ElMessage.error('获取铂金价格数据失败')
} finally {
loadingPlatinum.value = false
}
}
async function handleSave() {
if (!newPrice.value || newPrice.value <= 0) {
ElMessage.warning('请输入有效的金价')
@ -86,7 +142,7 @@ async function handleSave() {
}
try {
await ElMessageBox.confirm(
`确认将金价更新为 ¥${newPrice.value.toFixed(2)}/克?\n系统将自动重算所有商品规格价格。`,
`确认将金价更新为 ¥${newPrice.value.toFixed(2)}/克?\n系统将自动重算18k白/18k黄/18k玫瑰金的规格价格。`,
'确认更新金价',
{ type: 'warning' }
)
@ -96,7 +152,7 @@ async function handleSave() {
try {
const res: any = await setGoldPrice(newPrice.value)
ElMessage.success(res.message || '金价更新成功')
fetchData()
fetchGoldData()
} catch {
ElMessage.error('更新金价失败')
} finally {
@ -104,7 +160,35 @@ async function handleSave() {
}
}
onMounted(fetchData)
async function handleSavePlatinum() {
if (!newPlatinumPrice.value || newPlatinumPrice.value <= 0) {
ElMessage.warning('请输入有效的铂金价格')
return
}
try {
await ElMessageBox.confirm(
`确认将铂金价格更新为 ¥${newPlatinumPrice.value.toFixed(2)}/克?\n系统将自动重算铂金PT950的规格价格。`,
'确认更新铂金价格',
{ type: 'warning' }
)
} catch { return }
savingPlatinum.value = true
try {
const res: any = await setPlatinumPrice(newPlatinumPrice.value)
ElMessage.success(res.message || '铂金价格更新成功')
fetchPlatinumData()
} catch {
ElMessage.error('更新铂金价格失败')
} finally {
savingPlatinum.value = false
}
}
onMounted(() => {
fetchGoldData()
fetchPlatinumData()
})
</script>
<style scoped>
@ -116,6 +200,7 @@ onMounted(fetchData)
.current-price { margin-bottom: 16px; }
.current-price__label { font-size: 14px; color: #666; }
.current-price__value { font-size: 24px; font-weight: 700; color: #e4393c; }
.current-price__value.platinum { color: #e6a23c; }
.current-price__empty { font-size: 14px; color: #999; }
.price-input { display: flex; align-items: center; margin-bottom: 8px; }
.price-tip { font-size: 12px; color: #999; }

View File

@ -0,0 +1,2 @@
-- 订单表增加来源字段
ALTER TABLE orders ADD COLUMN source VARCHAR(20) DEFAULT NULL COMMENT '订单来源: miniapp/offline/taobao' AFTER receiver_address;

View File

@ -0,0 +1,25 @@
-- 扩展订单状态枚举,增加 returned
ALTER TABLE orders MODIFY COLUMN status ENUM('pending','paid','shipped','received','cancelled','returned') NOT NULL DEFAULT 'pending';
-- 退货记录表
CREATE TABLE IF NOT EXISTS order_returns (
id INT AUTO_INCREMENT PRIMARY KEY,
order_id INT NOT NULL,
return_reason VARCHAR(512) NOT NULL DEFAULT '',
refund_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
refund_proof VARCHAR(512) DEFAULT NULL,
refund_time DATETIME DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 退货商品明细表
CREATE TABLE IF NOT EXISTS order_return_items (
id INT AUTO_INCREMENT PRIMARY KEY,
return_id INT NOT NULL,
order_item_id INT NOT NULL,
quantity INT NOT NULL DEFAULT 1,
unit_price DECIMAL(12,2) NOT NULL DEFAULT 0,
FOREIGN KEY (return_id) REFERENCES order_returns(id) ON DELETE CASCADE,
FOREIGN KEY (order_item_id) REFERENCES order_items(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -0,0 +1,6 @@
-- 铂金价格记录表
CREATE TABLE IF NOT EXISTS platinum_price_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
price DECIMAL(12,2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -52,7 +52,7 @@ export async function adminGetOrders(req: Request, res: Response): Promise<void>
const kw = `%${keyword.trim()}%`
params.push(kw, kw)
}
if (status && ['pending', 'paid', 'shipped', 'received', 'cancelled'].includes(status)) {
if (status && ['pending', 'paid', 'shipped', 'received', 'cancelled', 'returned'].includes(status)) {
where += ' AND o.status = ?'
params.push(status)
}
@ -68,7 +68,7 @@ export async function adminGetOrders(req: Request, res: Response): Promise<void>
o.total_price, o.receiver_name, o.receiver_phone, o.receiver_address,
o.payment_time, o.payment_proof, o.shipping_company, o.shipping_no,
o.cancel_reason, o.refund_proof, o.refund_time,
o.created_at, o.updated_at
o.source, o.created_at, o.updated_at
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
${where}
@ -147,7 +147,7 @@ export async function adminGetOrderDetail(req: Request, res: Response): Promise<
export async function adminCreateOrder(req: Request, res: Response): Promise<void> {
const conn = await pool.getConnection()
try {
const { userId, items, receiverName, receiverPhone, receiverAddress } = req.body
const { userId, items, receiverName, receiverPhone, receiverAddress, source } = req.body
if (!userId) {
res.status(400).json({ code: 400, message: '用户 ID 不能为空' })
@ -158,36 +158,62 @@ export async function adminCreateOrder(req: Request, res: Response): Promise<voi
return
}
const validSources = ['miniapp', 'offline', 'taobao']
const orderSource = validSources.includes(source) ? source : null
await conn.beginTransaction()
const orderItems: { productId: number; specDataId: number; quantity: number; unitPrice: number }[] = []
for (const item of items) {
const [specRows] = await conn.execute<RowDataPacket[]>(
'SELECT total_price FROM spec_data WHERE id = ?',
[item.specDataId]
)
if (specRows.length === 0) {
// 支持通过条形码查找规格数据
if (item.barcode) {
const [specRows] = await conn.execute<RowDataPacket[]>(
'SELECT id, product_id, total_price FROM spec_data WHERE barcode = ?',
[item.barcode]
)
if (specRows.length === 0) {
await conn.rollback()
res.status(400).json({ code: 400, message: `条形码 "${item.barcode}" 未找到对应规格数据` })
return
}
const spec = specRows[0]
orderItems.push({
productId: spec.product_id,
specDataId: spec.id,
quantity: item.quantity || 1,
unitPrice: spec.total_price,
})
} else if (item.specDataId) {
const [specRows] = await conn.execute<RowDataPacket[]>(
'SELECT total_price FROM spec_data WHERE id = ?',
[item.specDataId]
)
if (specRows.length === 0) {
await conn.rollback()
res.status(400).json({ code: 400, message: `规格数据 ${item.specDataId} 不存在` })
return
}
orderItems.push({
productId: item.productId,
specDataId: item.specDataId,
quantity: item.quantity || 1,
unitPrice: specRows[0].total_price,
})
} else {
await conn.rollback()
res.status(400).json({ code: 400, message: `规格数据 ${item.specDataId} 不存在` })
res.status(400).json({ code: 400, message: '请提供条形码或规格数据ID' })
return
}
const unitPrice = specRows[0].total_price
orderItems.push({
productId: item.productId,
specDataId: item.specDataId,
quantity: item.quantity || 1,
unitPrice,
})
}
const totalPrice = recalculateOrderTotal(orderItems)
const orderNo = generateOrderNo()
const [orderResult] = await conn.execute<ResultSetHeader>(
`INSERT INTO orders (order_no, user_id, status, total_price, receiver_name, receiver_phone, receiver_address)
VALUES (?, ?, 'pending', ?, ?, ?, ?)`,
[orderNo, userId, totalPrice, receiverName || '', receiverPhone || '', receiverAddress || '']
`INSERT INTO orders (order_no, user_id, status, total_price, receiver_name, receiver_phone, receiver_address, source)
VALUES (?, ?, 'pending', ?, ?, ?, ?, ?)`,
[orderNo, userId, totalPrice, receiverName || '', receiverPhone || '', receiverAddress || '', orderSource]
)
const orderId = orderResult.insertId
@ -287,6 +313,156 @@ export async function adminUpdateOrder(req: Request, res: Response): Promise<voi
}
}
// POST /api/admin/orders/:id/return - 退货(支持部分/全部)
export async function adminReturnOrder(req: Request, res: Response): Promise<void> {
const conn = await pool.getConnection()
try {
const { id } = req.params
const { returnReason, refundProof, refundTime, items } = req.body
if (!returnReason || !returnReason.trim()) {
res.status(400).json({ code: 400, message: '请填写退货原因' })
return
}
if (!items || !Array.isArray(items) || items.length === 0) {
res.status(400).json({ code: 400, message: '请选择退货商品' })
return
}
const [orderRows] = await conn.execute<RowDataPacket[]>(
'SELECT id, status FROM orders WHERE id = ?', [id]
)
if (orderRows.length === 0) {
res.status(404).json({ code: 404, message: '订单不存在' })
return
}
if (orderRows[0].status !== 'received') {
res.status(400).json({ code: 400, message: '只有已收货的订单才能退货' })
return
}
// 查询订单商品及已退数量
const [orderItemRows] = await conn.execute<RowDataPacket[]>(
`SELECT oi.id, oi.quantity, oi.unit_price,
IFNULL(SUM(ori.quantity), 0) AS returned_qty
FROM order_items oi
LEFT JOIN order_return_items ori ON ori.order_item_id = oi.id
WHERE oi.order_id = ?
GROUP BY oi.id`,
[id]
)
const itemMap: Record<number, { quantity: number; unitPrice: number; returnedQty: number }> = {}
for (const r of orderItemRows) {
itemMap[r.id] = { quantity: r.quantity, unitPrice: Number(r.unit_price), returnedQty: Number(r.returned_qty) }
}
// 校验退货数量
let refundAmount = 0
const returnItems: { orderItemId: number; quantity: number; unitPrice: number }[] = []
for (const item of items) {
const info = itemMap[item.orderItemId]
if (!info) {
await conn.rollback()
res.status(400).json({ code: 400, message: `订单商品 ${item.orderItemId} 不存在` })
return
}
const maxReturnQty = info.quantity - info.returnedQty
if (item.quantity <= 0 || item.quantity > maxReturnQty) {
await conn.rollback()
res.status(400).json({ code: 400, message: `商品 ${item.orderItemId} 最多可退 ${maxReturnQty}` })
return
}
returnItems.push({
orderItemId: item.orderItemId,
quantity: item.quantity,
unitPrice: info.unitPrice,
})
refundAmount += info.unitPrice * item.quantity
}
await conn.beginTransaction()
const [returnResult] = await conn.execute<ResultSetHeader>(
`INSERT INTO order_returns (order_id, return_reason, refund_amount, refund_proof, refund_time)
VALUES (?, ?, ?, ?, ?)`,
[id, returnReason.trim(), refundAmount, refundProof || null, refundTime ? toMySQLDatetime(refundTime) : null]
)
const returnId = returnResult.insertId
for (const ri of returnItems) {
await conn.execute(
'INSERT INTO order_return_items (return_id, order_item_id, quantity, unit_price) VALUES (?, ?, ?, ?)',
[returnId, ri.orderItemId, ri.quantity, ri.unitPrice]
)
}
// 判断是否全部退完 → 更新订单状态
const [checkRows] = await conn.execute<RowDataPacket[]>(
`SELECT oi.id, oi.quantity, IFNULL(SUM(ori.quantity), 0) AS returned_qty
FROM order_items oi
LEFT JOIN order_return_items ori ON ori.order_item_id = oi.id
WHERE oi.order_id = ?
GROUP BY oi.id`,
[id]
)
const allReturned = checkRows.every((r: any) => Number(r.returned_qty) >= r.quantity)
if (allReturned) {
await conn.execute("UPDATE orders SET status = 'returned' WHERE id = ?", [id])
}
await conn.commit()
res.json({
code: 0,
data: { returnId, refundAmount: +refundAmount.toFixed(2), allReturned },
message: allReturned ? '全部退货完成,订单已标记为已退货' : '部分退货成功',
})
} catch (err) {
await conn.rollback()
console.error('adminReturnOrder error:', err)
res.status(500).json({ code: 500, message: '退货处理失败' })
} finally {
conn.release()
}
}
// GET /api/admin/orders/:id/returns - 查询订单退货记录
export async function adminGetOrderReturns(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params
const [returns] = await pool.execute<RowDataPacket[]>(
`SELECT r.id, r.return_reason, r.refund_amount, r.refund_proof, r.refund_time, r.created_at,
JSON_ARRAYAGG(
JSON_OBJECT(
'orderItemId', ri.order_item_id,
'quantity', ri.quantity,
'unitPrice', ri.unit_price,
'productName', IFNULL(p.name, ''),
'modelName', IFNULL(sd.model_name, '')
)
) AS items
FROM order_returns r
LEFT JOIN order_return_items ri ON ri.return_id = r.id
LEFT JOIN order_items oi ON ri.order_item_id = oi.id
LEFT JOIN products p ON oi.product_id = p.id
LEFT JOIN spec_data sd ON oi.spec_data_id = sd.id
WHERE r.order_id = ?
GROUP BY r.id
ORDER BY r.id DESC`,
[id]
)
for (const r of returns as any[]) {
if (typeof r.items === 'string') r.items = JSON.parse(r.items)
}
res.json({ code: 0, data: returns })
} catch (err) {
console.error('adminGetOrderReturns error:', err)
res.status(500).json({ code: 500, message: '获取退货记录失败' })
}
}
// PUT /api/admin/orders/:id/status - 更新订单状态
export async function adminUpdateOrderStatus(req: Request, res: Response): Promise<void> {
try {

View File

@ -15,25 +15,28 @@ export async function adminGetProducts(req: Request, res: Response): Promise<voi
const params: any[] = []
if (keyword && keyword.trim()) {
where += ' AND (name LIKE ? OR style_no LIKE ?)'
where += ' AND (p.name LIKE ? OR p.style_no LIKE ?)'
const kw = `%${keyword.trim()}%`
params.push(kw, kw)
}
if (categoryId) {
where += ' AND JSON_CONTAINS(category_id, ?)'
where += ' AND JSON_CONTAINS(p.category_id, ?)'
params.push(JSON.stringify(categoryId))
}
const [countRows] = await pool.execute<RowDataPacket[]>(
`SELECT COUNT(*) as total FROM products ${where}`,
`SELECT COUNT(*) as total FROM products p ${where}`,
params
)
const total = countRows[0].total
const [rows] = await pool.execute<RowDataPacket[]>(
`SELECT id, name, base_price, style_no, stock, total_stock, status, created_at
FROM products ${where}
ORDER BY id DESC LIMIT ? OFFSET ?`,
`SELECT p.id, p.name, p.style_no, p.stock, p.status, p.created_at,
IFNULL(sc.spec_count, 0) AS spec_count
FROM products p
LEFT JOIN (SELECT product_id, COUNT(*) AS spec_count FROM spec_data GROUP BY product_id) sc ON sc.product_id = p.id
${where}
ORDER BY p.id DESC LIMIT ? OFFSET ?`,
[...params, String(pageSize), String(offset)]
)

View File

@ -2,7 +2,22 @@ import { Request, Response } from 'express'
import pool from '../utils/db'
import { RowDataPacket, ResultSetHeader } from 'mysql2'
// GET /api/admin/gold-price - 获取金价历史记录
const PLATINUM_FINENESS = '铂金PT950'
const RECALC_SQL = `
gold_net_weight = GREATEST(gold_total_weight - main_stone_weight * 0.2 - side_stone_weight * 0.2, 0),
gold_loss = GREATEST(gold_total_weight - main_stone_weight * 0.2 - side_stone_weight * 0.2, 0) * loss,
gold_value = GREATEST(gold_total_weight - main_stone_weight * 0.2 - side_stone_weight * 0.2, 0) * loss * gold_price,
main_stone_amount = main_stone_weight * main_stone_unit_price,
side_stone_amount = side_stone_weight * side_stone_unit_price,
total_labor_cost = accessory_amount + processing_fee + setting_fee,
total_price = GREATEST(gold_total_weight - main_stone_weight * 0.2 - side_stone_weight * 0.2, 0) * loss * gold_price
+ main_stone_weight * main_stone_unit_price
+ side_stone_weight * side_stone_unit_price
+ accessory_amount + processing_fee + setting_fee`
// ============ 金价18k白 / 18k黄 / 18k玫瑰金 ============
export async function getGoldPriceLogs(req: Request, res: Response): Promise<void> {
try {
const [rows] = await pool.execute<RowDataPacket[]>(
@ -15,7 +30,6 @@ export async function getGoldPriceLogs(req: Request, res: Response): Promise<voi
}
}
// GET /api/admin/gold-price/latest - 获取最新金价
export async function getLatestGoldPrice(req: Request, res: Response): Promise<void> {
try {
const [rows] = await pool.execute<RowDataPacket[]>(
@ -29,7 +43,6 @@ export async function getLatestGoldPrice(req: Request, res: Response): Promise<v
}
}
// POST /api/admin/gold-price - 设置新金价并重算所有规格
export async function setGoldPrice(req: Request, res: Response): Promise<void> {
const conn = await pool.getConnection()
try {
@ -42,37 +55,20 @@ export async function setGoldPrice(req: Request, res: Response): Promise<void> {
await conn.beginTransaction()
// 记录金价
await conn.execute<ResultSetHeader>(
'INSERT INTO gold_price_logs (price) VALUES (?)', [newPrice]
)
// 重算所有规格数据
// 公式: 净重 = 总重 - 主石重*0.2 - 副石重*0.2
// 金耗 = 净重 * 损耗
// 金值 = 金耗 * 金价
// 主石金额 = 主石重 * 主石单价
// 副石金额 = 副石重 * 副石单价
// 总工费 = 配件 + 加工 + 镶石
// 总价 = 金值 + 主石金额 + 副石金额 + 总工费
// 只更新非铂金规格
await conn.execute(
`UPDATE spec_data SET
gold_price = ?,
gold_net_weight = GREATEST(gold_total_weight - main_stone_weight * 0.2 - side_stone_weight * 0.2, 0),
gold_loss = GREATEST(gold_total_weight - main_stone_weight * 0.2 - side_stone_weight * 0.2, 0) * loss,
gold_value = GREATEST(gold_total_weight - main_stone_weight * 0.2 - side_stone_weight * 0.2, 0) * loss * ?,
main_stone_amount = main_stone_weight * main_stone_unit_price,
side_stone_amount = side_stone_weight * side_stone_unit_price,
total_labor_cost = accessory_amount + processing_fee + setting_fee,
total_price = GREATEST(gold_total_weight - main_stone_weight * 0.2 - side_stone_weight * 0.2, 0) * loss * ?
+ main_stone_weight * main_stone_unit_price
+ side_stone_weight * side_stone_unit_price
+ accessory_amount + processing_fee + setting_fee`,
[newPrice, newPrice, newPrice]
`UPDATE spec_data SET gold_price = ?, ${RECALC_SQL}
WHERE fineness != ?`,
[newPrice, PLATINUM_FINENESS]
)
// 统计更新了多少条
const [countRows] = await conn.execute<RowDataPacket[]>('SELECT COUNT(*) as cnt FROM spec_data')
const [countRows] = await conn.execute<RowDataPacket[]>(
'SELECT COUNT(*) as cnt FROM spec_data WHERE fineness != ?', [PLATINUM_FINENESS]
)
const updated = countRows[0].cnt
await conn.commit()
@ -85,3 +81,69 @@ export async function setGoldPrice(req: Request, res: Response): Promise<void> {
conn.release()
}
}
// ============ 铂金价格 ============
export async function getPlatinumPriceLogs(req: Request, res: Response): Promise<void> {
try {
const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT id, price, created_at FROM platinum_price_logs ORDER BY id DESC LIMIT 50'
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('getPlatinumPriceLogs error:', err)
res.status(500).json({ code: 500, message: '获取铂金价格记录失败' })
}
}
export async function getLatestPlatinumPrice(req: Request, res: Response): Promise<void> {
try {
const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT price FROM platinum_price_logs ORDER BY id DESC LIMIT 1'
)
const price = rows.length > 0 ? Number(rows[0].price) : 0
res.json({ code: 0, data: { price } })
} catch (err) {
console.error('getLatestPlatinumPrice error:', err)
res.status(500).json({ code: 500, message: '获取最新铂金价格失败' })
}
}
export async function setPlatinumPrice(req: Request, res: Response): Promise<void> {
const conn = await pool.getConnection()
try {
const { price } = req.body
if (!price || Number(price) <= 0) {
res.status(400).json({ code: 400, message: '请输入有效的铂金价格' })
return
}
const newPrice = Number(price)
await conn.beginTransaction()
await conn.execute<ResultSetHeader>(
'INSERT INTO platinum_price_logs (price) VALUES (?)', [newPrice]
)
// 只更新铂金规格
await conn.execute(
`UPDATE spec_data SET gold_price = ?, ${RECALC_SQL}
WHERE fineness = ?`,
[newPrice, PLATINUM_FINENESS]
)
const [countRows] = await conn.execute<RowDataPacket[]>(
'SELECT COUNT(*) as cnt FROM spec_data WHERE fineness = ?', [PLATINUM_FINENESS]
)
const updated = countRows[0].cnt
await conn.commit()
res.json({ code: 0, message: `铂金价格已更新为 ${newPrice},已重算 ${updated} 条铂金规格数据` })
} catch (err) {
await conn.rollback()
console.error('setPlatinumPrice error:', err)
res.status(500).json({ code: 500, message: '设置铂金价格失败' })
} finally {
conn.release()
}
}

View File

@ -2,8 +2,8 @@ import { Request, Response } from 'express'
import pool from '../utils/db'
import { RowDataPacket, ResultSetHeader } from 'mysql2'
// CSV column headers matching spec_data table fields
const CSV_HEADERS = [
// 所有字段(用于数据库读写)
const ALL_DB_HEADERS = [
'model_name', 'barcode', 'fineness', 'main_stone', 'sub_stone', 'ring_size',
'gold_total_weight', 'gold_net_weight', 'loss', 'gold_loss',
'gold_price', 'gold_value',
@ -12,8 +12,19 @@ const CSV_HEADERS = [
'accessory_amount', 'processing_fee', 'setting_fee', 'total_labor_cost', 'total_price',
]
// 中文表头映射(导出用)
// CSV 手动输入列(不含自动计算字段)
const CSV_INPUT_HEADERS = [
'style_no',
'model_name', 'barcode', 'fineness', 'main_stone', 'sub_stone', 'ring_size',
'gold_total_weight', 'loss',
'main_stone_count', 'main_stone_weight', 'main_stone_unit_price',
'side_stone_count', 'side_stone_weight', 'side_stone_unit_price',
'accessory_amount', 'processing_fee', 'setting_fee',
]
// 全部中文表头映射
const CSV_HEADERS_CN: Record<string, string> = {
style_no: '款号',
model_name: '规格名称', barcode: '条型码', fineness: '成色', main_stone: '主石', sub_stone: '副石', ring_size: '手寸',
gold_total_weight: '金料总重', gold_net_weight: '金料净重', loss: '损耗', gold_loss: '金损',
gold_price: '金价', gold_value: '金料价值',
@ -27,6 +38,28 @@ const CN_TO_EN: Record<string, string> = Object.fromEntries(
Object.entries(CSV_HEADERS_CN).map(([en, cn]) => [cn, en])
)
// 根据手动输入字段自动计算派生字段
function calcSpecFields(row: Record<string, number>) {
const n = (v: any) => Number(v) || 0
const goldNetWeight = +(n(row.gold_total_weight) - n(row.main_stone_weight) * 0.2 - n(row.side_stone_weight) * 0.2).toFixed(4)
const safeGoldNetWeight = goldNetWeight < 0 ? 0 : goldNetWeight
const goldLoss = +(safeGoldNetWeight * n(row.loss)).toFixed(4)
const goldValue = +(goldLoss * n(row.gold_price)).toFixed(2)
const mainStoneAmount = +(n(row.main_stone_weight) * n(row.main_stone_unit_price)).toFixed(2)
const sideStoneAmount = +(n(row.side_stone_weight) * n(row.side_stone_unit_price)).toFixed(2)
const totalLaborCost = +(n(row.accessory_amount) + n(row.processing_fee) + n(row.setting_fee)).toFixed(2)
const totalPrice = +(goldValue + mainStoneAmount + sideStoneAmount + totalLaborCost).toFixed(2)
return {
gold_net_weight: safeGoldNetWeight,
gold_loss: goldLoss,
gold_value: goldValue,
main_stone_amount: mainStoneAmount,
side_stone_amount: sideStoneAmount,
total_labor_cost: totalLaborCost,
total_price: totalPrice,
}
}
function escapeCSVField(value: string | number): string {
const str = String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
@ -89,10 +122,10 @@ function parseCSVLine(line: string): string[] {
return result
}
export function generateCSV(rows: Record<string, any>[]): string {
const headerLine = CSV_HEADERS.map((h) => escapeCSVField(CSV_HEADERS_CN[h] || h)).join(',')
export function generateCSV(rows: Record<string, any>[], headers: string[] = CSV_INPUT_HEADERS): string {
const headerLine = headers.map((h) => escapeCSVField(CSV_HEADERS_CN[h] || h)).join(',')
const dataLines = rows.map((row) =>
CSV_HEADERS.map((h) => escapeCSVField(row[h] ?? '')).join(',')
headers.map((h) => escapeCSVField(row[h] ?? '')).join(',')
)
return [headerLine, ...dataLines].join('\n')
}
@ -198,15 +231,21 @@ export async function adminUpdateSpecData(req: Request, res: Response): Promise<
export async function exportSpecData(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params
const [productRows] = await pool.execute<RowDataPacket[]>(
'SELECT style_no FROM products WHERE id = ?', [id]
)
const styleNo = productRows.length > 0 ? productRows[0].style_no : ''
const inputDbCols = CSV_INPUT_HEADERS.filter(h => h !== 'style_no')
const [rows] = await pool.execute<RowDataPacket[]>(
`SELECT ${CSV_HEADERS.join(', ')} FROM spec_data WHERE product_id = ?`,
`SELECT ${inputDbCols.join(', ')} FROM spec_data WHERE product_id = ?`,
[id]
)
const rowsWithStyleNo = rows.map(r => ({ style_no: styleNo, ...r }))
const csv = generateCSV(rows)
const csv = generateCSV(rowsWithStyleNo, CSV_INPUT_HEADERS)
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.setHeader('Content-Disposition', `attachment; filename=spec_data_${id}.csv`)
// Add BOM for Excel compatibility
res.send('\uFEFF' + csv)
} catch (err) {
console.error('exportSpecData error:', err)
@ -214,24 +253,70 @@ export async function exportSpecData(req: Request, res: Response): Promise<void>
}
}
// POST /api/admin/spec-data/lookup - 批量条形码查询商品+规格信息
export async function lookupByBarcodes(req: Request, res: Response): Promise<void> {
try {
const { barcodes } = req.body
if (!barcodes || !Array.isArray(barcodes) || barcodes.length === 0) {
res.status(400).json({ code: 400, message: '请提供条形码列表' })
return
}
const uniqueBarcodes = [...new Set(barcodes.map((b: string) => b.trim()).filter(Boolean))]
if (uniqueBarcodes.length === 0) {
res.status(400).json({ code: 400, message: '条形码列表为空' })
return
}
const placeholders = uniqueBarcodes.map(() => '?').join(',')
const [rows] = await pool.execute<RowDataPacket[]>(
`SELECT sd.id AS specDataId, sd.product_id AS productId, sd.barcode,
sd.model_name AS modelName, sd.fineness, sd.main_stone AS mainStone,
sd.sub_stone AS subStone, sd.ring_size AS ringSize,
sd.gold_total_weight AS goldTotalWeight, sd.gold_net_weight AS goldNetWeight,
sd.loss, sd.gold_loss AS goldLoss, sd.gold_price AS goldPrice, sd.gold_value AS goldValue,
sd.main_stone_count AS mainStoneCount, sd.main_stone_weight AS mainStoneWeight,
sd.main_stone_unit_price AS mainStoneUnitPrice, sd.main_stone_amount AS mainStoneAmount,
sd.side_stone_count AS sideStoneCount, sd.side_stone_weight AS sideStoneWeight,
sd.side_stone_unit_price AS sideStoneUnitPrice, sd.side_stone_amount AS sideStoneAmount,
sd.accessory_amount AS accessoryAmount, sd.processing_fee AS processingFee,
sd.setting_fee AS settingFee, sd.total_labor_cost AS totalLaborCost,
sd.total_price AS totalPrice,
p.name AS productName, p.style_no AS styleNo, p.thumb
FROM spec_data sd
LEFT JOIN products p ON sd.product_id = p.id
WHERE sd.barcode IN (${placeholders})`,
uniqueBarcodes
)
const itemsMap: Record<string, any> = {}
for (const row of rows) {
itemsMap[row.barcode] = row
}
const notFound = uniqueBarcodes.filter(b => !itemsMap[b])
res.json({ code: 0, data: { items: itemsMap, notFound } })
} catch (err) {
console.error('lookupByBarcodes error:', err)
res.status(500).json({ code: 500, message: '条形码查询失败' })
}
}
// POST /api/admin/products/:id/spec-data/import
export async function importSpecData(req: Request, res: Response): Promise<void> {
const conn = await pool.getConnection()
try {
const { id } = req.params
if (!req.file) {
res.status(400).json({ code: 400, message: '请上传 CSV 文件' })
return
}
let content = req.file.buffer.toString('utf-8')
// Remove BOM if present
if (content.charCodeAt(0) === 0xFEFF) {
content = content.slice(1)
}
// 如果检测到乱码(中文表头变成乱码),尝试用 GBK 解码
if (content.includes('<27>') || content.includes('\ufffd')) {
try {
const iconv = require('iconv-lite')
@ -239,7 +324,7 @@ export async function importSpecData(req: Request, res: Response): Promise<void>
if (content.charCodeAt(0) === 0xFEFF) {
content = content.slice(1)
}
} catch { /* iconv-lite not available, continue with utf-8 */ }
} catch { /* fallback utf-8 */ }
}
const rows = parseCSV(content)
@ -248,51 +333,117 @@ export async function importSpecData(req: Request, res: Response): Promise<void>
return
}
// Validate required headers
// 校验必填的手动输入列
const firstRow = rows[0]
const missingHeaders = CSV_HEADERS.filter((h) => !(h in firstRow))
const requiredHeaders = CSV_INPUT_HEADERS
const missingHeaders = requiredHeaders.filter((h) => !(h in firstRow))
if (missingHeaders.length > 0) {
const missingCN = missingHeaders.map(h => CSV_HEADERS_CN[h] || h).join(', ')
res.status(400).json({ code: 400, message: `缺少必填列: ${missingCN}。请确保CSV表头为中文(款号,成色,主石...)且文件编码为UTF-8` })
res.status(400).json({ code: 400, message: `缺少必填列: ${missingCN}。请确保CSV表头包含:款号、规格名称、条型码等,编码为UTF-8` })
return
}
await conn.beginTransaction()
// 获取最新金价和铂金价格
const [gpRows] = await pool.execute<RowDataPacket[]>(
'SELECT price FROM gold_price_logs ORDER BY id DESC LIMIT 1'
)
const goldPrice = gpRows.length > 0 ? Number(gpRows[0].price) : 0
const [ppRows] = await pool.execute<RowDataPacket[]>(
'SELECT price FROM platinum_price_logs ORDER BY id DESC LIMIT 1'
)
const platinumPrice = ppRows.length > 0 ? Number(ppRows[0].price) : 0
// Clear existing spec data for this product
await conn.execute('DELETE FROM spec_data WHERE product_id = ?', [id])
// 按款号分组,批量查询对应的 product_id
const styleNos = [...new Set(rows.map(r => (r.style_no || '').trim()).filter(Boolean))]
if (styleNos.length === 0) {
res.status(400).json({ code: 400, message: 'CSV 中未找到有效的"款号"列数据' })
return
}
const numericFields = CSV_HEADERS.filter((h) => h !== 'model_name' && h !== 'barcode' && h !== 'fineness' && h !== 'main_stone' && h !== 'sub_stone' && h !== 'ring_size')
const [productRows] = await pool.execute<RowDataPacket[]>(
`SELECT id, style_no FROM products WHERE style_no IN (${styleNos.map(() => '?').join(',')})`,
styleNos
)
const styleNoToProductId: Record<string, number> = {}
for (const p of productRows) {
styleNoToProductId[p.style_no] = p.id
}
const notFoundStyleNos = styleNos.filter(s => !styleNoToProductId[s])
if (notFoundStyleNos.length > 0) {
res.status(400).json({ code: 400, message: `以下款号未找到对应商品: ${notFoundStyleNos.join(', ')}` })
return
}
const textFields = ['model_name', 'barcode', 'fineness', 'main_stone', 'sub_stone', 'ring_size']
const numericInputFields = CSV_INPUT_HEADERS.filter(h => h !== 'style_no' && !textFields.includes(h))
const errors: string[] = []
let imported = 0
let skipped = 0
await conn.beginTransaction()
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
const values = CSV_HEADERS.map((h) => {
if (numericFields.includes(h)) {
const num = Number(row[h])
if (isNaN(num)) {
errors.push(`${i + 2}${h} 列不是有效数字`)
return 0
}
return num
const styleNo = (row.style_no || '').trim()
const productId = styleNoToProductId[styleNo]
if (!productId) {
errors.push(`${i + 2} 行:款号 "${styleNo}" 未找到对应商品,已跳过`)
skipped++
continue
}
// 条型码去重:如果已存在则跳过
const barcode = (row.barcode || '').trim()
if (barcode) {
const [dup] = await conn.execute<RowDataPacket[]>(
'SELECT id FROM spec_data WHERE barcode = ?', [barcode]
)
if (dup.length > 0) {
errors.push(`${i + 2} 行:条型码 "${barcode}" 已存在,已跳过`)
skipped++
continue
}
return row[h] || ''
}
// 根据成色决定使用金价还是铂金价格
const fineness = (row.fineness || '').trim()
const rowPrice = fineness === '铂金PT950' ? platinumPrice : goldPrice
// 解析手动输入的数值字段
const numericVals: Record<string, number> = { gold_price: rowPrice }
for (const h of numericInputFields) {
const num = Number(row[h])
if (row[h] !== '' && isNaN(num)) {
errors.push(`${i + 2} 行 "${CSV_HEADERS_CN[h] || h}" 列不是有效数字`)
}
numericVals[h] = isNaN(num) ? 0 : num
}
// 自动计算派生字段
const calc = calcSpecFields(numericVals)
const insertCols = ALL_DB_HEADERS
const insertVals = insertCols.map(h => {
if (textFields.includes(h)) return (row[h] || '').trim()
if (h in calc) return (calc as any)[h]
if (h === 'gold_price') return rowPrice
return numericVals[h] ?? 0
})
await conn.execute(
`INSERT INTO spec_data (product_id, ${CSV_HEADERS.join(', ')})
VALUES (?, ${CSV_HEADERS.map(() => '?').join(', ')})`,
[id, ...values]
`INSERT INTO spec_data (product_id, ${insertCols.join(', ')})
VALUES (?, ${insertCols.map(() => '?').join(', ')})`,
[productId, ...insertVals]
)
imported++
}
await conn.commit()
if (errors.length > 0) {
res.json({ code: 0, data: { imported: rows.length, warnings: errors } })
} else {
res.json({ code: 0, data: { imported: rows.length } })
}
res.json({
code: 0,
data: { imported, skipped, warnings: errors.length > 0 ? errors : undefined },
})
} catch (err) {
await conn.rollback()
console.error('importSpecData error:', err)

View File

@ -9,7 +9,7 @@ import {
adminUpdateProduct,
adminDeleteProduct,
} from '../controllers/adminProduct'
import { exportSpecData, importSpecData, adminGetSpecData, adminCreateSpecData, adminDeleteSpecData, adminUpdateSpecData } from '../controllers/specDataIO'
import { exportSpecData, importSpecData, adminGetSpecData, adminCreateSpecData, adminDeleteSpecData, adminUpdateSpecData, lookupByBarcodes } from '../controllers/specDataIO'
import { getStockAlerts } from '../controllers/stockAlert'
import {
adminGetOrders,
@ -17,6 +17,8 @@ import {
adminCreateOrder,
adminUpdateOrder,
adminUpdateOrderStatus,
adminReturnOrder,
adminGetOrderReturns,
} from '../controllers/adminOrder'
import {
adminGetMolds,
@ -33,7 +35,7 @@ import {
} from '../controllers/adminCategory'
import { adminGetConfigs, adminUpdateConfig } from '../controllers/config'
import { adminGetUsers } from '../controllers/adminUser'
import { getGoldPriceLogs, getLatestGoldPrice, setGoldPrice } from '../controllers/goldPrice'
import { getGoldPriceLogs, getLatestGoldPrice, setGoldPrice, getPlatinumPriceLogs, getLatestPlatinumPrice, setPlatinumPrice } from '../controllers/goldPrice'
const csvUpload = multer({ storage: multer.memoryStorage() })
@ -63,6 +65,9 @@ adminRoutes.post('/products/:id/spec-data', adminCreateSpecData)
adminRoutes.delete('/products/:productId/spec-data/:specId', adminDeleteSpecData)
adminRoutes.put('/products/:productId/spec-data/:specId', adminUpdateSpecData)
// Spec data barcode lookup
adminRoutes.post('/spec-data/lookup', lookupByBarcodes)
// Stock alerts
adminRoutes.get('/stock-alerts', getStockAlerts)
@ -72,6 +77,8 @@ adminRoutes.get('/orders/:id', adminGetOrderDetail)
adminRoutes.post('/orders', adminCreateOrder)
adminRoutes.put('/orders/:id', adminUpdateOrder)
adminRoutes.put('/orders/:id/status', adminUpdateOrderStatus)
adminRoutes.post('/orders/:id/return', adminReturnOrder)
adminRoutes.get('/orders/:id/returns', adminGetOrderReturns)
// Mold management
adminRoutes.get('/molds', adminGetMolds)
@ -97,3 +104,8 @@ adminRoutes.get('/users', adminGetUsers)
adminRoutes.get('/gold-price', getGoldPriceLogs)
adminRoutes.get('/gold-price/latest', getLatestGoldPrice)
adminRoutes.post('/gold-price', setGoldPrice)
// Platinum price management
adminRoutes.get('/platinum-price', getPlatinumPriceLogs)
adminRoutes.get('/platinum-price/latest', getLatestPlatinumPrice)
adminRoutes.post('/platinum-price', setPlatinumPrice)