管理后台
This commit is contained in:
parent
53ce69a1f9
commit
3983f4ad75
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
2
server/migrations/002_add_order_source.sql
Normal file
2
server/migrations/002_add_order_source.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
-- 订单表增加来源字段
|
||||
ALTER TABLE orders ADD COLUMN source VARCHAR(20) DEFAULT NULL COMMENT '订单来源: miniapp/offline/taobao' AFTER receiver_address;
|
||||
25
server/migrations/003_add_order_returns.sql
Normal file
25
server/migrations/003_add_order_returns.sql
Normal 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;
|
||||
6
server/migrations/004_add_platinum_price.sql
Normal file
6
server/migrations/004_add_platinum_price.sql
Normal 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;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user