管理后台修改

This commit is contained in:
18631081161 2026-03-29 19:58:26 +08:00
parent d3a7e96094
commit 4ed0d66757
8 changed files with 205 additions and 116 deletions

View File

@ -28,6 +28,10 @@ export function getStockAlerts() {
return http.get('/admin/stock-alerts')
}
export function getSpecDataCount() {
return http.get('/admin/spec-data-count')
}
export function exportSpecData(productId: number) {
return http.get(`/admin/products/${productId}/spec-data/export`, { responseType: 'blob' } as any)
}

View File

@ -32,74 +32,38 @@
<el-col :span="6">
<div class="stat-card stat-card--orange">
<div class="stat-card__info">
<div class="stat-card__value">{{ stats.alertCount }}</div>
<div class="stat-card__label">库存预警</div>
<div class="stat-card__value">{{ stats.specCount }}</div>
<div class="stat-card__label">规格总数</div>
</div>
<el-icon class="stat-card__icon"><WarningFilled /></el-icon>
<el-icon class="stat-card__icon"><Collection /></el-icon>
</div>
</el-col>
</el-row>
<!-- 库存预警 -->
<el-card class="dashboard-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-header__title">
<el-icon style="color: #e6a23c; margin-right: 8px"><WarningFilled /></el-icon>
库存预警
</span>
<el-tag type="danger" size="small" v-if="alerts.length">{{ alerts.length }} </el-tag>
</div>
</template>
<el-table :data="alerts" v-loading="loading" empty-text="暂无库存预警" 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="stock" label="库存" width="100">
<template #default="{ row }">
<el-tag :type="row.stock <= 5 ? 'danger' : 'warning'" size="small">{{ row.stock }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="$router.push(`/products/${row.id}/edit`)">
补货
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getStockAlerts, getProducts, getCategories } from '../api/product'
import { getProducts, getCategories, getSpecDataCount } from '../api/product'
import { getOrders } from '../api/order'
const alerts = ref<any[]>([])
const loading = ref(false)
const stats = reactive({ productCount: 0, orderCount: 0, categoryCount: 0, alertCount: 0 })
const stats = reactive({ productCount: 0, orderCount: 0, categoryCount: 0, specCount: 0 })
async function fetchData() {
loading.value = true
try {
const [alertRes, productRes, orderRes, catRes]: any[] = await Promise.all([
getStockAlerts(),
const [productRes, orderRes, catRes, specRes]: any[] = await Promise.all([
getProducts({ page: 1, pageSize: 1 }),
getOrders({ page: 1, pageSize: 1 }),
getCategories(),
getSpecDataCount(),
])
alerts.value = alertRes.data || []
stats.alertCount = alerts.value.length
stats.productCount = productRes.data?.total || 0
stats.orderCount = orderRes.data?.total || 0
stats.categoryCount = (catRes.data || []).length
stats.specCount = specRes.data || 0
} catch {
ElMessage.error('加载数据失败')
} finally {
loading.value = false
}
}
@ -107,8 +71,6 @@ onMounted(fetchData)
</script>
<style scoped>
.dashboard { }
.stat-row { margin-bottom: 20px; }
.stat-card {
@ -128,25 +90,4 @@ onMounted(fetchData)
.stat-card__value { font-size: 32px; font-weight: 700; line-height: 1.2; }
.stat-card__label { font-size: 14px; opacity: 0.85; margin-top: 4px; }
.stat-card__icon { font-size: 48px; opacity: 0.3; }
.dashboard-card {
border-radius: 12px;
border: none;
}
.dashboard-card :deep(.el-card__header) {
border-bottom: 1px solid #f0f0f0;
padding: 16px 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-header__title {
display: flex;
align-items: center;
font-size: 16px;
font-weight: 600;
color: #333;
}
</style>

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="partial_returned" />
<el-option label="已退货" value="returned" />
</el-select>
</div>
@ -45,6 +46,22 @@
</div>
</div>
</div>
<!-- 发货信息 -->
<div v-if="row.shipping_company || row.shipping_no" style="margin-bottom:16px;padding:12px 16px;background:#f0f9ff;border-radius:8px">
<div style="font-size:14px;font-weight:600;color:#409eff;margin-bottom:8px">发货信息</div>
<div style="display:flex;align-items:flex-start;gap:24px;flex-wrap:wrap;font-size:13px;color:#666">
<div v-if="row.shipping_company"><span style="color:#999">物流公司</span>{{ row.shipping_company }}</div>
<div v-if="row.shipping_no"><span style="color:#999">物流单号</span>{{ row.shipping_no }}</div>
<div v-if="row.shipped_at"><span style="color:#999">发货时间</span>{{ formatTime(row.shipped_at) }}</div>
</div>
</div>
<!-- 收货信息 -->
<div v-if="row.received_at" style="margin-bottom:16px;padding:12px 16px;background:#f0f9eb;border-radius:8px">
<div style="font-size:14px;font-weight:600;color:#67c23a;margin-bottom:8px">收货信息</div>
<div style="font-size:13px;color:#666">
<span style="color:#999">收货时间</span>{{ formatTime(row.received_at) }}
</div>
</div>
<!-- 取消/退款信息 -->
<div v-if="row.cancel_reason || row.refund_proof || row.refund_time" style="margin-bottom:16px;padding:12px 16px;background:#fff2f0;border-radius:8px">
<div style="font-size:14px;font-weight:600;color:#e4393c;margin-bottom:8px">取消/退款信息</div>
@ -68,6 +85,10 @@
<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:600;color:#333">{{ item.product_name }}</div>
<div style="font-size:12px;color:#999;margin-top:4px">
<span v-if="item.style_no" style="margin-right:12px">款号:{{ item.style_no }}</span>
<span v-if="item.barcode" style="margin-right:12px">条形码:{{ item.barcode }}</span>
</div>
<div style="font-size:12px;color:#999;margin-top:2px">
<span v-if="item.model_name">型号:{{ item.model_name }}</span>
<span v-if="item.fineness" style="margin-left:12px">成色:{{ item.fineness }}</span>
<span v-if="item.main_stone" style="margin-left:12px">主石:{{ item.main_stone }}</span>
@ -77,7 +98,6 @@
</div>
<div style="text-align:right;flex-shrink:0">
<div style="color:#e4393c;font-weight:600">¥{{ Number(item.unit_price).toFixed(2) }}</div>
<div style="font-size:12px;color:#999">×{{ item.quantity }}</div>
</div>
</div>
</div>
@ -144,13 +164,13 @@
</el-table-column>
<el-table-column label="操作" width="280" fixed="right" align="center">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="row.status === 'pending' || row.status === 'paid'" text type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
<el-button v-if="row.status === 'pending'" text type="danger" size="small" @click="handlePayment(row)">确认支付</el-button>
<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>
<el-button v-if="row.status === 'received' || row.status === 'partial_returned'" text type="warning" size="small" @click="handleReturn(row)">退货</el-button>
<el-button v-if="row.status === 'received' || row.status === 'returned' || row.status === 'partial_returned'" text type="info" size="small" @click="handleViewReturns(row)">退货记录</el-button>
</template>
</el-table-column>
</el-table>
@ -212,6 +232,7 @@
<el-button type="primary" plain @click="handleAddBarcode" :disabled="!manualBarcode.trim()">添加</el-button>
</div>
<div class="create-toolbar__right">
<el-button type="info" plain @click="downloadCsvTemplate"><el-icon><Download /></el-icon> </el-button>
<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>
@ -242,11 +263,6 @@
</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>
@ -265,7 +281,7 @@
<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) }}
¥{{ previewItems.reduce((s, i) => s + Number(i.totalPrice), 0).toFixed(2) }}
</span>
</div>
@ -296,7 +312,6 @@
<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="editForm.items.splice(idx, 1)" :disabled="editForm.items.length <= 1">删除</el-button>
</div>
</div>
@ -422,25 +437,12 @@
<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> 退款金额
退货 <b>{{ returnCheckedItems.length }}</b> 退款金额
<span style="color:#e4393c;font-weight:700;font-size:15px">
¥{{ returnCheckedItems.reduce((s, i) => s + i.returnQty * Number(i.unitPrice), 0).toFixed(2) }}
¥{{ returnCheckedItems.reduce((s, i) => s + Number(i.unitPrice), 0).toFixed(2) }}
</span>
</div>
@ -510,7 +512,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { Upload, Delete, Plus, Search } from '@element-plus/icons-vue'
import { Upload, Delete, Plus, Search, Download } 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'
@ -617,12 +619,12 @@ function sourceTagType(s: string) {
}
function statusLabel(s: string) {
const map: Record<string, string> = { pending: '未付款', paid: '待发货', shipped: '待收货', received: '已收货', cancelled: '已取消', returned: '已退货' }
const map: Record<string, string> = { pending: '未付款', paid: '待发货', shipped: '待收货', received: '已收货', cancelled: '已取消', returned: '已退货', partial_returned: '部分退货' }
return map[s] || s
}
function statusTagType(s: string) {
const map: Record<string, string> = { pending: 'danger', paid: 'warning', shipped: '', received: 'success', cancelled: 'info', returned: 'danger' }
const map: Record<string, string> = { pending: 'danger', paid: 'warning', shipped: '', received: 'success', cancelled: 'info', returned: 'danger', partial_returned: 'warning' }
return map[s] || ''
}
@ -698,6 +700,18 @@ async function handleAddBarcode() {
manualBarcode.value = ''
}
function downloadCsvTemplate() {
const header = '\uFEFF条形码\n'
const example = 'ABC123456\nDEF789012\n'
const blob = new Blob([header + example], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '订单导入模板.csv'
a.click()
URL.revokeObjectURL(url)
}
function handleCsvImport(file: File) {
const reader = new FileReader()
reader.onload = async (e) => {
@ -735,7 +749,7 @@ async function handleCreate() {
try {
const payload = {
...createForm.value,
items: previewItems.value.map((i: any) => ({ barcode: i.barcode, quantity: i.quantity })),
items: previewItems.value.map((i: any) => ({ barcode: i.barcode })),
}
await createOrder(payload)
ElMessage.success('订单创建成功')
@ -937,7 +951,7 @@ 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))
const returnCheckedItems = computed(() => returnItems.value.filter(i => i.checked))
// Return records dialog
const showReturnRecordsDialog = ref(false)
@ -1010,7 +1024,7 @@ async function confirmReturn() {
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 })),
items: checkedItems.map(i => ({ orderItemId: i.orderItemId, quantity: 1 })),
})
ElMessage.success(res.message || '退货成功')
showReturnDialog.value = false

View File

@ -222,7 +222,7 @@
:cell-style="{ padding: '8px 0', fontSize: '13px' }">
<el-table-column type="index" label="#" width="50" fixed align="center" />
<el-table-column prop="modelName" label="规格名称" min-width="120" fixed show-overflow-tooltip />
<el-table-column prop="barcode" label="条码" width="130" fixed show-overflow-tooltip />
<el-table-column prop="barcode" label="条码" width="130" fixed show-overflow-tooltip />
<el-table-column label="基本参数" align="center">
<el-table-column prop="fineness" label="成色" width="70" align="center" />
<el-table-column prop="mainStone" label="主石" width="70" align="center" />
@ -279,7 +279,7 @@
<div class="dialog-section">基本信息</div>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="规格名称"><el-input v-model="specForm.modelName" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="条码"><el-input v-model="specForm.barcode" /></el-form-item></el-col>
<el-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">

View File

@ -0,0 +1,27 @@
-- 给 order_items 添加 spec_snapshot 字段,用于保存付款时的规格数据快照
ALTER TABLE order_items ADD COLUMN spec_snapshot JSON DEFAULT NULL;
-- 去掉 order_items 的 spec_data_id 外键约束(避免删除 spec_data 时级联删除订单商品)
-- 先查找并删除外键
SET @fk_name = (
SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'order_items'
AND COLUMN_NAME = 'spec_data_id' AND REFERENCED_TABLE_NAME = 'spec_data'
LIMIT 1
);
SET @sql = IF(@fk_name IS NOT NULL, CONCAT('ALTER TABLE order_items DROP FOREIGN KEY ', @fk_name), 'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 去掉 cart_items 的 spec_data_id 外键约束
SET @fk_name2 = (
SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'cart_items'
AND COLUMN_NAME = 'spec_data_id' AND REFERENCED_TABLE_NAME = 'spec_data'
LIMIT 1
);
SET @sql2 = IF(@fk_name2 IS NOT NULL, CONCAT('ALTER TABLE cart_items DROP FOREIGN KEY ', @fk_name2), 'SELECT 1');
PREPARE stmt2 FROM @sql2;
EXECUTE stmt2;
DEALLOCATE PREPARE stmt2;

View File

@ -0,0 +1 @@
ALTER TABLE orders ADD COLUMN shipped_at DATETIME DEFAULT NULL;

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', 'returned'].includes(status)) {
if (status && ['pending', 'paid', 'shipped', 'received', 'cancelled', 'returned', 'partial_returned'].includes(status)) {
where += ' AND o.status = ?'
params.push(status)
}
@ -66,8 +66,8 @@ export async function adminGetOrders(req: Request, res: Response): Promise<void>
const [rows] = await pool.execute<RowDataPacket[]>(
`SELECT o.id, o.order_no, o.user_id, u.nickname as user_nickname, u.uid as user_uid, o.status,
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.payment_time, o.payment_proof, o.shipping_company, o.shipping_no, o.shipped_at,
o.cancel_reason, o.refund_proof, o.refund_time, o.received_at,
o.source, o.created_at, o.updated_at
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
@ -82,8 +82,8 @@ export async function adminGetOrders(req: Request, res: Response): Promise<void>
const placeholders = orderIds.map(() => '?').join(',')
const [itemRows] = await pool.execute<RowDataPacket[]>(
`SELECT oi.order_id, oi.id, oi.product_id, oi.spec_data_id, oi.quantity, oi.unit_price,
p.name as product_name, p.thumb, p.banner_images,
sd.model_name, sd.fineness, sd.main_stone, sd.sub_stone, sd.ring_size
p.name as product_name, p.thumb, p.banner_images, p.style_no,
sd.model_name, sd.fineness, sd.main_stone, sd.sub_stone, sd.ring_size, sd.barcode
FROM order_items oi
LEFT JOIN products p ON oi.product_id = p.id
LEFT JOIN spec_data sd ON oi.spec_data_id = sd.id
@ -127,8 +127,8 @@ export async function adminGetOrderDetail(req: Request, res: Response): Promise<
const [itemRows] = await pool.execute<RowDataPacket[]>(
`SELECT oi.id, oi.product_id, oi.spec_data_id, oi.quantity, oi.unit_price,
p.name as product_name, p.banner_images,
sd.model_name, sd.fineness, sd.main_stone, sd.sub_stone, sd.ring_size, sd.total_price as spec_total_price
p.name as product_name, p.banner_images, p.style_no,
sd.model_name, sd.fineness, sd.main_stone, sd.sub_stone, sd.ring_size, sd.barcode, sd.total_price as spec_total_price
FROM order_items oi
LEFT JOIN products p ON oi.product_id = p.id
LEFT JOIN spec_data sd ON oi.spec_data_id = sd.id
@ -336,8 +336,8 @@ export async function adminReturnOrder(req: Request, res: Response): Promise<voi
res.status(404).json({ code: 404, message: '订单不存在' })
return
}
if (orderRows[0].status !== 'received') {
res.status(400).json({ code: 400, message: '只有已收货的订单才能退货' })
if (orderRows[0].status !== 'received' && orderRows[0].status !== 'partial_returned') {
res.status(400).json({ code: 400, message: '只有已收货或部分退货的订单才能退货' })
return
}
@ -395,6 +395,34 @@ export async function adminReturnOrder(req: Request, res: Response): Promise<voi
'INSERT INTO order_return_items (return_id, order_item_id, quantity, unit_price) VALUES (?, ?, ?, ?)',
[returnId, ri.orderItemId, ri.quantity, ri.unitPrice]
)
// 退货时从快照恢复 spec_data
const [snapRows] = await conn.execute<RowDataPacket[]>(
'SELECT spec_data_id, spec_snapshot FROM order_items WHERE id = ?',
[ri.orderItemId]
)
if (snapRows.length > 0 && snapRows[0].spec_snapshot) {
const snap = typeof snapRows[0].spec_snapshot === 'string' ? JSON.parse(snapRows[0].spec_snapshot) : snapRows[0].spec_snapshot
const specId = snap.id || snapRows[0].spec_data_id
// 检查是否已存在
const [existing] = await conn.execute<RowDataPacket[]>('SELECT id FROM spec_data WHERE id = ?', [specId])
if (existing.length === 0) {
await conn.execute(
`INSERT INTO spec_data (id, product_id, model_name, barcode, fineness, main_stone, sub_stone, ring_size,
gold_total_weight, gold_net_weight, loss, gold_loss, gold_price, gold_value,
main_stone_count, main_stone_weight, main_stone_unit_price, main_stone_amount,
side_stone_count, side_stone_weight, side_stone_unit_price, side_stone_amount,
accessory_amount, processing_fee, setting_fee, total_labor_cost, total_price)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[specId, snap.product_id, snap.model_name, snap.barcode || null, snap.fineness, snap.main_stone, snap.sub_stone || '',
snap.ring_size, snap.gold_total_weight, snap.gold_net_weight, snap.loss, snap.gold_loss,
snap.gold_price, snap.gold_value, snap.main_stone_count, snap.main_stone_weight,
snap.main_stone_unit_price, snap.main_stone_amount, snap.side_stone_count, snap.side_stone_weight,
snap.side_stone_unit_price, snap.side_stone_amount, snap.accessory_amount, snap.processing_fee,
snap.setting_fee, snap.total_labor_cost, snap.total_price]
)
}
}
}
// 判断是否全部退完 → 更新订单状态
@ -409,6 +437,8 @@ export async function adminReturnOrder(req: Request, res: Response): Promise<voi
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])
} else {
await conn.execute("UPDATE orders SET status = 'partial_returned' WHERE id = ?", [id])
}
await conn.commit()
@ -465,11 +495,12 @@ export async function adminGetOrderReturns(req: Request, res: Response): Promise
// PUT /api/admin/orders/:id/status - 更新订单状态
export async function adminUpdateOrderStatus(req: Request, res: Response): Promise<void> {
const conn = await pool.getConnection()
try {
const { id } = req.params
const { status, paymentTime, paymentProof, shippingCompany, shippingNo, receivedAt, cancelReason, refundProof, refundTime } = req.body
const [orderRows] = await pool.execute<RowDataPacket[]>(
const [orderRows] = await conn.execute<RowDataPacket[]>(
'SELECT id, status FROM orders WHERE id = ?',
[id]
)
@ -509,8 +540,8 @@ export async function adminUpdateOrderStatus(req: Request, res: Response): Promi
res.status(400).json({ code: 400, message: '请填写物流公司和物流单号' })
return
}
updates.push('shipping_company = ?', 'shipping_no = ?')
params.push(shippingCompany, shippingNo)
updates.push('shipping_company = ?', 'shipping_no = ?', 'shipped_at = ?')
params.push(shippingCompany, shippingNo, toMySQLDatetime(new Date()))
}
if (status === 'received') {
@ -537,12 +568,71 @@ export async function adminUpdateOrderStatus(req: Request, res: Response): Promi
}
}
params.push(id)
await pool.execute(`UPDATE orders SET ${updates.join(', ')} WHERE id = ?`, params)
await conn.beginTransaction()
params.push(id)
await conn.execute(`UPDATE orders SET ${updates.join(', ')} WHERE id = ?`, params)
// 付款时:保存规格快照 → 删除 spec_data → 清理购物车
if (status === 'paid') {
const [items] = await conn.execute<RowDataPacket[]>(
'SELECT oi.id as oi_id, oi.spec_data_id, sd.* FROM order_items oi LEFT JOIN spec_data sd ON oi.spec_data_id = sd.id WHERE oi.order_id = ?',
[id]
)
for (const item of items) {
if (item.spec_data_id) {
// 保存快照(排除 oi_id 和 spec_data_id 这两个非 spec_data 字段)
const { oi_id, spec_data_id, ...specFields } = item
await conn.execute(
'UPDATE order_items SET spec_snapshot = ? WHERE id = ?',
[JSON.stringify(specFields), oi_id]
)
// 清理购物车中引用该 spec_data 的记录
await conn.execute('DELETE FROM cart_items WHERE spec_data_id = ?', [spec_data_id])
// 删除 spec_data
await conn.execute('DELETE FROM spec_data WHERE id = ?', [spec_data_id])
}
}
}
// 取消已付款订单时:从快照恢复 spec_data
if (status === 'cancelled' && currentStatus === 'paid') {
const [items] = await conn.execute<RowDataPacket[]>(
'SELECT id, spec_data_id, spec_snapshot FROM order_items WHERE order_id = ?',
[id]
)
for (const item of items) {
if (item.spec_snapshot) {
const snap = typeof item.spec_snapshot === 'string' ? JSON.parse(item.spec_snapshot) : item.spec_snapshot
// 检查是否已存在(避免重复恢复)
const [existing] = await conn.execute<RowDataPacket[]>('SELECT id FROM spec_data WHERE id = ?', [snap.id])
if (existing.length === 0) {
await conn.execute(
`INSERT INTO spec_data (id, product_id, model_name, barcode, fineness, main_stone, sub_stone, ring_size,
gold_total_weight, gold_net_weight, loss, gold_loss, gold_price, gold_value,
main_stone_count, main_stone_weight, main_stone_unit_price, main_stone_amount,
side_stone_count, side_stone_weight, side_stone_unit_price, side_stone_amount,
accessory_amount, processing_fee, setting_fee, total_labor_cost, total_price)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[snap.id, snap.product_id, snap.model_name, snap.barcode || null, snap.fineness, snap.main_stone, snap.sub_stone || '',
snap.ring_size, snap.gold_total_weight, snap.gold_net_weight, snap.loss, snap.gold_loss,
snap.gold_price, snap.gold_value, snap.main_stone_count, snap.main_stone_weight,
snap.main_stone_unit_price, snap.main_stone_amount, snap.side_stone_count, snap.side_stone_weight,
snap.side_stone_unit_price, snap.side_stone_amount, snap.accessory_amount, snap.processing_fee,
snap.setting_fee, snap.total_labor_cost, snap.total_price]
)
}
}
}
}
await conn.commit()
res.json({ code: 0, message: '状态更新成功' })
} catch (err) {
await conn.rollback()
console.error('adminUpdateOrderStatus error:', err)
res.status(500).json({ code: 500, message: '更新订单状态失败' })
} finally {
conn.release()
}
}

View File

@ -1,5 +1,7 @@
import { Router } from 'express'
import multer from 'multer'
import { RowDataPacket } from 'mysql2'
import pool from '../utils/db'
import { upload, uploadFile } from '../controllers/upload'
import { adminLogin, verifyAdmin } from '../controllers/adminAuth'
import {
@ -71,6 +73,16 @@ adminRoutes.post('/spec-data/lookup', lookupByBarcodes)
// Stock alerts
adminRoutes.get('/stock-alerts', getStockAlerts)
// Spec data count
adminRoutes.get('/spec-data-count', async (_req, res) => {
try {
const [rows] = await pool.execute<RowDataPacket[]>('SELECT COUNT(*) as total FROM spec_data')
res.json({ code: 0, data: (rows as any[])[0].total })
} catch {
res.status(500).json({ code: 500, message: '获取规格总数失败' })
}
})
// Order management
adminRoutes.get('/orders', adminGetOrders)
adminRoutes.get('/orders/:id', adminGetOrderDetail)