bug修改

This commit is contained in:
18631081161 2026-03-05 17:36:18 +08:00
parent b6011d3c16
commit 658bf0675b
9 changed files with 412 additions and 98 deletions

View File

@ -34,6 +34,9 @@ export function updateOrderStatus(id: number, data: {
shippingCompany?: string
shippingNo?: string
receivedAt?: string
cancelReason?: string
refundProof?: string
refundTime?: string
}) {
return http.put(`/admin/orders/${id}/status`, data)
}

View File

@ -57,12 +57,40 @@
</el-dialog>
<!-- 筛选配置弹窗 -->
<el-dialog v-model="filterDialogVisible" :title="`筛选配置 - ${filterCategoryName}`" width="600px">
<div v-for="(f, idx) in filterList" :key="idx" class="filter-item">
<div class="filter-item__label">{{ f.filterName }}</div>
<div class="tag-input-wrap">
<el-tag v-for="(opt, oi) in f.options" :key="oi" closable size="small" @close="f.options.splice(oi, 1)" style="margin: 0 4px 4px 0">{{ opt }}</el-tag>
<el-input v-model="f._input" size="small" placeholder="输入后回车添加" style="width:140px" @keyup.enter="addFilterOption(f)" />
<el-dialog v-model="filterDialogVisible" :title="`价格区间配置 - ${filterCategoryName}`" width="600px">
<div class="price-config">
<div class="price-config__tip">配置价格筛选区间1000以下1000-29993000以上</div>
<div class="price-config__list">
<div v-for="(item, idx) in priceOptions" :key="idx" class="price-config__item">
<el-tag closable @close="priceOptions.splice(idx, 1)" style="margin: 0 6px 6px 0">{{ item }}</el-tag>
</div>
</div>
<div class="price-config__add">
<el-select v-model="priceType" size="small" style="width:120px;margin-right:8px">
<el-option label="以下" value="below" />
<el-option label="区间" value="range" />
<el-option label="以上" value="above" />
</el-select>
<template v-if="priceType === 'below'">
<el-input-number v-model="priceVal1" :min="0" size="small" controls-position="right" style="width:120px" />
<span style="margin:0 8px;color:#999">以下</span>
</template>
<template v-else-if="priceType === 'range'">
<el-input-number v-model="priceVal1" :min="0" size="small" controls-position="right" style="width:120px" />
<span style="margin:0 8px;color:#999">-</span>
<el-input-number v-model="priceVal2" :min="0" size="small" controls-position="right" style="width:120px" />
</template>
<template v-else>
<el-input-number v-model="priceVal1" :min="0" size="small" controls-position="right" style="width:120px" />
<span style="margin:0 8px;color:#999">以上</span>
</template>
<el-button size="small" type="primary" @click="addPriceOption" style="margin-left:8px">添加</el-button>
</div>
<div class="price-config__note">
<div style="margin-top:16px;padding:12px;background:#f0f9ff;border-radius:8px;font-size:13px;color:#666">
<div style="font-weight:600;margin-bottom:4px;color:#333">提示</div>
成色主石副石手寸筛选项会自动从该分类下商品的规格数据中提取无需手动配置
</div>
</div>
</div>
<template #footer>
@ -130,58 +158,60 @@ async function handleDelete(id: number) {
const filterDialogVisible = ref(false)
const filterCategoryName = ref('')
const filterCategoryId = ref(0)
const filterList = ref<any[]>([])
const filterSaving = ref(false)
const FIXED_FILTERS = [
{ filterName: '成色', filterKey: 'fineness' },
{ filterName: '副石', filterKey: 'side_stone' },
{ filterName: '款式', filterKey: 'style' },
{ filterName: '镶口', filterKey: 'setting' },
{ filterName: '价格', filterKey: 'price' },
]
const priceOptions = ref<string[]>([])
const priceType = ref('range')
const priceVal1 = ref(0)
const priceVal2 = ref(0)
async function openFilterDialog(cat: any) {
filterCategoryId.value = cat.id
filterCategoryName.value = cat.name
filterDialogVisible.value = true
let savedMap: Record<string, string[]> = {}
priceOptions.value = []
priceType.value = 'range'
priceVal1.value = 0
priceVal2.value = 0
try {
const res: any = await http.get(`/admin/categories/${cat.id}/filters`)
for (const f of (res.data || [])) {
const key = f.filter_key || f.filterKey
const opts = typeof f.options === 'string' ? JSON.parse(f.options) : (f.options || [])
savedMap[key] = opts
if (key === 'price') {
const opts = typeof f.options === 'string' ? JSON.parse(f.options) : (f.options || [])
priceOptions.value = opts
}
}
} catch { /* ignore */ }
filterList.value = FIXED_FILTERS.map(f => ({
filterName: f.filterName,
filterKey: f.filterKey,
options: savedMap[f.filterKey] || [],
_input: '',
}))
}
function addFilter() {
// no longer needed
}
function addFilterOption(f: any) {
const val = (f._input || '').trim()
if (val && !f.options.includes(val)) {
f.options.push(val)
function addPriceOption() {
let label = ''
if (priceType.value === 'below') {
label = `${priceVal1.value}以下`
} else if (priceType.value === 'above') {
label = `${priceVal1.value}以上`
} else {
if (priceVal2.value <= priceVal1.value) {
ElMessage.warning('区间上限需大于下限')
return
}
label = `${priceVal1.value}-${priceVal2.value}`
}
f._input = ''
if (!priceOptions.value.includes(label)) {
priceOptions.value.push(label)
}
priceVal1.value = 0
priceVal2.value = 0
}
async function saveFilters() {
filterSaving.value = true
try {
const data = filterList.value.map((f: any, i: number) => ({
filterName: f.filterName,
filterKey: f.filterKey || `filter_${i}`,
options: f.options,
}))
const data = [{
filterName: '价格',
filterKey: 'price',
options: priceOptions.value,
}]
await http.post(`/admin/categories/${filterCategoryId.value}/filters`, { filters: data })
ElMessage.success('保存成功')
filterDialogVisible.value = false
@ -200,4 +230,7 @@ onMounted(loadCategories)
.filter-item { margin-bottom: 16px; padding: 12px 16px; background: #fafafa; border-radius: 8px; }
.filter-item__label { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 8px; }
.tag-input-wrap { display: flex; flex-wrap: wrap; align-items: center; }
.price-config__tip { font-size: 13px; color: #999; margin-bottom: 12px; }
.price-config__list { display: flex; flex-wrap: wrap; margin-bottom: 12px; }
.price-config__add { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
</style>

View File

@ -30,7 +30,36 @@
ref="orderTableRef">
<el-table-column type="expand">
<template #default="{ row }">
<div v-if="row.items && row.items.length" style="padding:12px 20px">
<div v-if="row.items?.length || row.cancel_reason || row.refund_proof" style="padding:12px 20px">
<!-- 支付信息 -->
<div v-if="row.payment_time || row.payment_proof" style="margin-bottom:16px;padding:12px 16px;background:#f9f9f9;border-radius:8px">
<div style="font-size:14px;font-weight:600;color:#333;margin-bottom:8px">支付信息</div>
<div style="display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap">
<div v-if="row.payment_time" style="font-size:13px;color:#666">
<span style="color:#999">支付时间</span>{{ formatTime(row.payment_time) }}
</div>
<div v-if="row.payment_proof">
<div style="font-size:13px;color:#999;margin-bottom:4px">支付凭证</div>
<el-image :src="row.payment_proof" style="width:120px;height:120px;border-radius:6px" fit="cover" :preview-src-list="[row.payment_proof]" preview-teleported />
</div>
</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>
<div v-if="row.cancel_reason" style="font-size:13px;color:#666;margin-bottom:8px">
<span style="color:#999">取消原因</span>{{ row.cancel_reason }}
</div>
<div style="display:flex;align-items:flex-start;gap:16px;flex-wrap:wrap">
<div v-if="row.refund_time" style="font-size:13px;color:#666">
<span style="color:#999">退款时间</span>{{ formatTime(row.refund_time) }}
</div>
<div v-if="row.refund_proof">
<div style="font-size:13px;color:#999;margin-bottom:4px">退款凭证</div>
<el-image :src="row.refund_proof" style="width:120px;height:120px;border-radius:6px" fit="cover" :preview-src-list="[row.refund_proof]" preview-teleported />
</div>
</div>
</div>
<div v-for="item in row.items" :key="item.id" style="display:flex;align-items:center;gap:12px;padding:8px 0;border-bottom:1px solid #f5f5f5">
<el-image :src="getItemThumb(item)" style="width:60px;height:60px;border-radius:6px;flex-shrink:0" fit="cover">
<template #error><div style="width:60px;height:60px;background:#f5f5f5;border-radius:6px"></div></template>
@ -75,9 +104,30 @@
<span style="color: #e4393c; font-weight: 600">¥{{ Number(row.total_price).toFixed(2) }}</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100" align="center">
<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>
<div v-if="row.status === 'cancelled' && row.cancel_reason" style="font-size:11px;color:#e4393c;margin-top:4px;line-height:1.4;word-break:break-all">
{{ row.cancel_reason }}
</div>
</template>
</el-table-column>
<el-table-column label="支付信息" width="160" align="center">
<template #default="{ row }">
<div v-if="row.payment_proof || row.payment_time" style="display:flex;flex-direction:column;align-items:center;gap:4px">
<el-image v-if="row.payment_proof" :src="row.payment_proof" style="width:48px;height:48px;border-radius:4px" fit="cover" :preview-src-list="[row.payment_proof]" preview-teleported />
<span v-if="row.payment_time" style="font-size:11px;color:#999">{{ formatTime(row.payment_time) }}</span>
</div>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column label="退款信息" width="160" align="center">
<template #default="{ row }">
<div v-if="row.refund_proof || row.refund_time" style="display:flex;flex-direction:column;align-items:center;gap:4px">
<el-image v-if="row.refund_proof" :src="row.refund_proof" style="width:48px;height:48px;border-radius:4px" fit="cover" :preview-src-list="[row.refund_proof]" preview-teleported />
<span v-if="row.refund_time" style="font-size:11px;color:#999">{{ formatTime(row.refund_time) }}</span>
</div>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" align="center">
@ -85,11 +135,12 @@
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<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'" 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>
</template>
</el-table-column>
@ -107,7 +158,7 @@
</el-card>
<!-- Create Order Dialog -->
<el-dialog v-model="showCreateDialog" title="手动创建订单" width="600px" @close="resetCreateForm">
<el-dialog v-model="showCreateDialog" title="手动创建订单" width="680px" @close="resetCreateForm">
<el-form :model="createForm" label-width="100px">
<el-form-item label="用户 ID" required>
<el-input-number v-model="createForm.userId" :min="1" />
@ -122,13 +173,19 @@
<el-input v-model="createForm.receiverAddress" />
</el-form-item>
<el-divider>商品列表</el-divider>
<div v-for="(item, idx) in createForm.items" :key="idx" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center">
<el-input-number v-model="item.productId" :min="1" placeholder="商品ID" style="width: 130px" />
<el-input-number v-model="item.specDataId" :min="1" placeholder="规格ID" style="width: 130px" />
<el-input-number v-model="item.quantity" :min="1" placeholder="数量" style="width: 110px" />
<el-button text type="danger" @click="createForm.items.splice(idx, 1)" :disabled="createForm.items.length <= 1">删除</el-button>
<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 })">添加商品</el-button>
<el-button @click="createForm.items.push({ productId: 0, specDataId: 0, quantity: 1, _specList: [] })">+ 添加商品</el-button>
</el-form>
<template #footer>
<el-button @click="showCreateDialog = false">取消</el-button>
@ -137,7 +194,7 @@
</el-dialog>
<!-- Edit Order Dialog -->
<el-dialog v-model="showEditDialog" title="修改订单" width="600px">
<el-dialog v-model="showEditDialog" title="修改订单" width="680px">
<el-form :model="editForm" label-width="100px">
<el-form-item label="收货人">
<el-input v-model="editForm.receiverName" />
@ -149,13 +206,19 @@
<el-input v-model="editForm.receiverAddress" />
</el-form-item>
<el-divider>商品列表修改后自动重算价格</el-divider>
<div v-for="(item, idx) in editForm.items" :key="idx" style="display: flex; gap: 8px; margin-bottom: 8px; align-items: center">
<el-input-number v-model="item.productId" :min="1" placeholder="商品ID" style="width: 130px" />
<el-input-number v-model="item.specDataId" :min="1" placeholder="规格ID" style="width: 130px" />
<el-input-number v-model="item.quantity" :min="1" placeholder="数量" style="width: 110px" />
<el-button text type="danger" @click="editForm.items.splice(idx, 1)" :disabled="editForm.items.length <= 1">删除</el-button>
<div v-for="(item, idx) in editForm.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, 'edit')">
<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="editForm.items.splice(idx, 1)" :disabled="editForm.items.length <= 1">删除</el-button>
</div>
</div>
<el-button @click="editForm.items.push({ productId: 0, specDataId: 0, quantity: 1 })">添加商品</el-button>
<el-button @click="editForm.items.push({ productId: 0, specDataId: 0, quantity: 1, _specList: [] })">+ 添加商品</el-button>
</el-form>
<template #footer>
<el-button @click="showEditDialog = false">取消</el-button>
@ -218,6 +281,36 @@
<el-button type="primary" :loading="updatingStatus" @click="confirmShip">确认</el-button>
</template>
</el-dialog>
<!-- Cancel Order Dialog -->
<el-dialog v-model="showCancelDialog" title="取消订单" width="500px">
<el-form :model="cancelForm" label-width="100px">
<el-form-item label="取消原因" required>
<el-input v-model="cancelForm.cancelReason" type="textarea" :rows="3" placeholder="请填写取消原因" />
</el-form-item>
<el-form-item label="退款凭证">
<el-upload
:action="uploadUrl"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleRefundProofSuccess"
accept="image/*"
>
<el-button>上传退款凭证</el-button>
</el-upload>
<div v-if="cancelForm.refundProof" style="margin-top: 8px">
<el-image :src="cancelForm.refundProof" style="width: 120px; height: 120px" fit="cover" />
</div>
</el-form-item>
<el-form-item label="退款时间">
<el-date-picker v-model="cancelForm.refundTime" type="datetime" placeholder="选择退款时间" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCancelDialog = false">取消</el-button>
<el-button type="danger" :loading="updatingStatus" @click="confirmCancel">确认取消订单</el-button>
</template>
</el-dialog>
</div>
</template>
@ -225,6 +318,7 @@
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getOrders, getOrderDetail, createOrder, updateOrder, updateOrderStatus } from '../../api/order'
import { getProducts, getSpecDataList } from '../../api/product'
import { getUploadUrl } from '../../api/request'
const orders = ref<any[]>([])
@ -236,6 +330,37 @@ const pageSize = 10
const total = ref(0)
const orderTableRef = ref<any>(null)
// All products for select
const allProducts = ref<any[]>([])
async function loadAllProducts() {
try {
const res: any = await getProducts({ page: 1, pageSize: 9999 })
allProducts.value = res.data.list || []
} catch { /* ignore */ }
}
function formatSpecLabel(s: any): string {
const parts: string[] = []
if (s.model_name) parts.push(`型号:${s.model_name}`)
if (s.fineness) parts.push(`成色:${s.fineness}`)
if (s.main_stone) parts.push(`主石:${s.main_stone}`)
if (s.sub_stone) parts.push(`副石:${s.sub_stone}`)
if (s.ring_size) parts.push(`手寸:${s.ring_size}`)
if (s.total_price) parts.push(`¥${Number(s.total_price).toFixed(2)}`)
return parts.join(' | ') || `规格#${s.id}`
}
async function onProductChange(item: any, _mode: string) {
item.specDataId = 0
item._specList = []
if (!item.productId) return
try {
const res: any = await getSpecDataList(item.productId)
item._specList = res.data || []
} catch { /* ignore */ }
}
// Create dialog
const showCreateDialog = ref(false)
const creating = ref(false)
@ -244,7 +369,7 @@ const createForm = ref({
receiverName: '',
receiverPhone: '',
receiverAddress: '',
items: [{ productId: 0, specDataId: 0, quantity: 1 }],
items: [{ productId: 0, specDataId: 0, quantity: 1, _specList: [] as any[] }],
})
// Edit dialog
@ -255,7 +380,7 @@ const editForm = ref({
receiverName: '',
receiverPhone: '',
receiverAddress: '',
items: [] as { productId: number; specDataId: number; quantity: number }[],
items: [] as { productId: number; specDataId: number; quantity: number; _specList: any[] }[],
})
// Payment dialog
@ -268,6 +393,11 @@ const showShipDialog = ref(false)
const shipOrderId = ref(0)
const shipForm = ref({ shippingCompany: '', shippingNo: '' })
// Cancel dialog
const showCancelDialog = ref(false)
const cancelOrderId = ref(0)
const cancelForm = ref({ cancelReason: '', refundProof: '', refundTime: '' })
// Receive dialog
const showReceiveDialog = ref(false)
const receiveOrderId = ref(0)
@ -326,14 +456,18 @@ function resetCreateForm() {
receiverName: '',
receiverPhone: '',
receiverAddress: '',
items: [{ productId: 0, specDataId: 0, quantity: 1 }],
items: [{ productId: 0, specDataId: 0, quantity: 1, _specList: [] }],
}
}
async function handleCreate() {
creating.value = true
try {
await createOrder(createForm.value)
const payload = {
...createForm.value,
items: createForm.value.items.map(({ _specList, ...rest }) => rest),
}
await createOrder(payload)
ElMessage.success('订单创建成功')
showCreateDialog.value = false
fetchOrders()
@ -344,21 +478,51 @@ async function handleCreate() {
}
}
function handleEdit(row: any) {
async function handleEdit(row: any) {
editingOrderId.value = row.id
editForm.value = {
receiverName: row.receiver_name || '',
receiverPhone: row.receiver_phone || '',
receiverAddress: row.receiver_address || '',
items: [{ productId: 0, specDataId: 0, quantity: 1 }],
items: [],
}
showEditDialog.value = true
try {
const res: any = await getOrderDetail(row.id)
const detail = res.data
if (detail.items && detail.items.length) {
const items: any[] = []
for (const it of detail.items) {
const item: any = {
productId: it.product_id,
specDataId: it.spec_data_id,
quantity: it.quantity,
_specList: [],
}
// Pre-load spec list for this product
try {
const specRes: any = await getSpecDataList(it.product_id)
item._specList = specRes.data || []
} catch { /* ignore */ }
items.push(item)
}
editForm.value.items = items
} else {
editForm.value.items = [{ productId: 0, specDataId: 0, quantity: 1, _specList: [] }]
}
} catch {
ElMessage.error('获取订单详情失败')
}
}
async function handleUpdate() {
editing.value = true
try {
await updateOrder(editingOrderId.value, editForm.value)
const payload = {
...editForm.value,
items: editForm.value.items.map(({ _specList, ...rest }) => rest),
}
await updateOrder(editingOrderId.value, payload)
ElMessage.success('订单更新成功')
showEditDialog.value = false
fetchOrders()
@ -431,6 +595,41 @@ async function confirmShip() {
}
}
function handleCancel(row: any) {
cancelOrderId.value = row.id
cancelForm.value = { cancelReason: '', refundProof: '', refundTime: '' }
showCancelDialog.value = true
}
function handleRefundProofSuccess(response: any) {
if (response.code === 0) {
cancelForm.value.refundProof = response.data.url
}
}
async function confirmCancel() {
if (!cancelForm.value.cancelReason.trim()) {
ElMessage.warning('请填写取消原因')
return
}
updatingStatus.value = true
try {
await updateOrderStatus(cancelOrderId.value, {
status: 'cancelled',
cancelReason: cancelForm.value.cancelReason.trim(),
refundProof: cancelForm.value.refundProof || undefined,
refundTime: cancelForm.value.refundTime ? new Date(cancelForm.value.refundTime).toISOString() : undefined,
})
ElMessage.success('订单已取消')
showCancelDialog.value = false
fetchOrders()
} catch {
ElMessage.error('取消订单失败')
} finally {
updatingStatus.value = false
}
}
function handleReceive(row: any) {
receiveOrderId.value = row.id
receiveForm.value = { receivedAt: '' }
@ -474,7 +673,10 @@ function handleRowClick(row: any, _column: any, event: Event) {
orderTableRef.value?.toggleRowExpansion(row)
}
onMounted(fetchOrders)
onMounted(() => {
fetchOrders()
loadAllProducts()
})
</script>
<style scoped>
@ -488,4 +690,7 @@ onMounted(fetchOrders)
:deep(.el-table th.el-table__cell) { font-size: 13px; }
:deep(.el-table td.el-table__cell) { font-size: 13px; }
:deep(.el-pagination) { margin-top: 20px; justify-content: flex-end; }
.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; }
</style>

View File

@ -71,7 +71,7 @@
</el-col>
</el-row>
<!-- 筛选属性 -->
<!-- 筛选属性暂时隐藏
<div class="section-title">筛选属性</div>
<el-row :gutter="32">
<el-col :span="8">
@ -90,6 +90,7 @@
</el-form-item>
</el-col>
</el-row>
-->
<!-- 媒体资源 -->
<div class="section-title">媒体资源</div>
@ -234,7 +235,7 @@
</div>
</div>
<el-table v-if="specDataRows.length" :data="specDataRows" size="small" stripe
<el-table v-if="specDataRows.length" :data="specDataRows" size="small" stripe border
style="width: 100%; margin-bottom: 20px" max-height="500"
:header-cell-style="{ background: '#f0f0ff', color: '#1d1e3a', fontWeight: 600, fontSize: '13px', padding: '10px 0' }"
:cell-style="{ padding: '8px 0', fontSize: '13px' }">

View File

@ -341,7 +341,7 @@ onShow(() => loadOrders())
/* 物流信息 */
.order-card__shipping {
padding: 16rpx 24rpx;
background: #fafafa;
background: #FFFFFF;
font-size: 24rpx;
color: #666;
display: flex;

View File

@ -483,6 +483,14 @@ onMounted(() => {
.product-item {
display: flex;
gap: 20rpx;
padding-bottom: 24rpx;
margin-bottom: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.product-item:last-child {
padding-bottom: 0;
margin-bottom: 0;
border-bottom: none;
}
.product-item__img {
width: 160rpx;

View File

@ -1,6 +1,6 @@
// 手动切换后端地址,部署时改成线上域名即可
// const BASE_URL = 'http://localhost:3000'
const BASE_URL = 'http://115.190.188.216:2850'
const BASE_URL = 'http://localhost:3000'
// const BASE_URL = 'http://115.190.188.216:2850'
export { BASE_URL }

View File

@ -66,7 +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, o.status,
o.total_price, o.receiver_name, o.receiver_phone, o.receiver_address,
o.payment_time, o.shipping_company, o.shipping_no,
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
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
@ -84,8 +85,8 @@ export async function adminGetOrders(req: Request, res: Response): Promise<void>
p.name as product_name, p.thumb, p.banner_images,
sd.model_name, sd.fineness, sd.main_stone, sd.sub_stone, sd.ring_size
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN spec_data sd ON oi.spec_data_id = sd.id
LEFT JOIN products p ON oi.product_id = p.id
LEFT JOIN spec_data sd ON oi.spec_data_id = sd.id
WHERE oi.order_id IN (${placeholders})`,
orderIds
)
@ -129,8 +130,8 @@ export async function adminGetOrderDetail(req: Request, res: Response): Promise<
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
FROM order_items oi
JOIN products p ON oi.product_id = p.id
JOIN spec_data sd ON oi.spec_data_id = sd.id
LEFT JOIN products p ON oi.product_id = p.id
LEFT JOIN spec_data sd ON oi.spec_data_id = sd.id
WHERE oi.order_id = ?`,
[id]
)
@ -290,7 +291,7 @@ export async function adminUpdateOrder(req: Request, res: Response): Promise<voi
export async function adminUpdateOrderStatus(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params
const { status, paymentTime, paymentProof, shippingCompany, shippingNo, receivedAt } = req.body
const { status, paymentTime, paymentProof, shippingCompany, shippingNo, receivedAt, cancelReason, refundProof, refundTime } = req.body
const [orderRows] = await pool.execute<RowDataPacket[]>(
'SELECT id, status FROM orders WHERE id = ?',
@ -306,7 +307,7 @@ export async function adminUpdateOrderStatus(req: Request, res: Response): Promi
// Validate status transitions
const validTransitions: Record<string, string[]> = {
pending: ['paid', 'cancelled'],
paid: ['shipped'],
paid: ['shipped', 'cancelled'],
shipped: ['received'],
}
@ -341,6 +342,23 @@ export async function adminUpdateOrderStatus(req: Request, res: Response): Promi
params.push(toMySQLDatetime(receivedAt || new Date()))
}
if (status === 'cancelled') {
if (!cancelReason) {
res.status(400).json({ code: 400, message: '请填写取消原因' })
return
}
updates.push('cancel_reason = ?')
params.push(cancelReason)
if (refundProof) {
updates.push('refund_proof = ?')
params.push(refundProof)
}
if (refundTime) {
updates.push('refund_time = ?')
params.push(toMySQLDatetime(refundTime))
}
}
params.push(id)
await pool.execute(`UPDATE orders SET ${updates.join(', ')} WHERE id = ?`, params)

View File

@ -13,9 +13,9 @@ export async function getProducts(req: Request, res: Response): Promise<void> {
// Filter params
const fineness = req.query.fineness as string | undefined
const sideStone = req.query.side_stone as string | undefined
const style = req.query.style as string | undefined
const setting = req.query.setting as string | undefined
const mainStone = req.query.mainStone as string | undefined
const subStone = req.query.subStone as string | undefined
const ringSize = req.query.ringSize as string | undefined
const price = req.query.price as string | undefined
let where = "WHERE p.status = 'on'"
@ -33,25 +33,26 @@ export async function getProducts(req: Request, res: Response): Promise<void> {
params.push(kw, kw)
}
// fineness: match against spec_data table
// spec_data filters
if (fineness) {
needJoinSpec = true
where += ' AND sd.fineness = ?'
params.push(fineness)
}
// side_stone, style, setting: match against products columns
if (sideStone) {
where += ' AND p.side_stone = ?'
params.push(sideStone)
if (mainStone) {
needJoinSpec = true
where += ' AND sd.main_stone = ?'
params.push(mainStone)
}
if (style) {
where += ' AND p.style = ?'
params.push(style)
if (subStone) {
needJoinSpec = true
where += ' AND sd.sub_stone = ?'
params.push(subStone)
}
if (setting) {
where += ' AND p.setting = ?'
params.push(setting)
if (ringSize) {
needJoinSpec = true
where += ' AND sd.ring_size = ?'
params.push(ringSize)
}
// price: parse range string like "1000以下", "1000-1499", "3000以上"
@ -220,16 +221,61 @@ export async function getCategories(_req: Request, res: Response): Promise<void>
export async function getCategoryFilters(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params
const [rows] = await pool.execute<RowDataPacket[]>(
'SELECT id, filter_name AS filterName, filter_key AS filterKey, options, sort FROM category_filters WHERE category_id = ? ORDER BY sort ASC, id ASC',
// 1. 自动从该分类下商品的 spec_data 提取成色/主石/副石/手寸
const [fRows] = await pool.execute<RowDataPacket[]>(
`SELECT DISTINCT sd.fineness FROM spec_data sd
JOIN products p ON sd.product_id = p.id
WHERE JSON_CONTAINS(p.category_id, ?) AND sd.fineness != '' AND p.status = 'on'
ORDER BY sd.fineness`,
[JSON.stringify(Number(id))]
)
const [mRows] = await pool.execute<RowDataPacket[]>(
`SELECT DISTINCT sd.main_stone FROM spec_data sd
JOIN products p ON sd.product_id = p.id
WHERE JSON_CONTAINS(p.category_id, ?) AND sd.main_stone != '' AND p.status = 'on'
ORDER BY sd.main_stone`,
[JSON.stringify(Number(id))]
)
const [sRows] = await pool.execute<RowDataPacket[]>(
`SELECT DISTINCT sd.sub_stone FROM spec_data sd
JOIN products p ON sd.product_id = p.id
WHERE JSON_CONTAINS(p.category_id, ?) AND sd.sub_stone != '' AND p.status = 'on'
ORDER BY sd.sub_stone`,
[JSON.stringify(Number(id))]
)
const [rRows] = await pool.execute<RowDataPacket[]>(
`SELECT DISTINCT sd.ring_size FROM spec_data sd
JOIN products p ON sd.product_id = p.id
WHERE JSON_CONTAINS(p.category_id, ?) AND sd.ring_size != '' AND p.status = 'on'
ORDER BY sd.ring_size`,
[JSON.stringify(Number(id))]
)
const autoFilters: any[] = []
const fineness = fRows.map((r: any) => r.fineness)
const mainStone = mRows.map((r: any) => r.main_stone)
const subStone = sRows.map((r: any) => r.sub_stone)
const ringSize = rRows.map((r: any) => r.ring_size)
if (fineness.length) autoFilters.push({ filterName: '成色', filterKey: 'fineness', options: fineness })
if (mainStone.length) autoFilters.push({ filterName: '主石', filterKey: 'mainStone', options: mainStone })
if (subStone.length) autoFilters.push({ filterName: '副石', filterKey: 'subStone', options: subStone })
if (ringSize.length) autoFilters.push({ filterName: '手寸', filterKey: 'ringSize', options: ringSize })
// 2. 从 category_filters 读取价格配置
const [priceRows] = await pool.execute<RowDataPacket[]>(
"SELECT options FROM category_filters WHERE category_id = ? AND filter_key = 'price'",
[id]
)
// Parse options JSON
const data = rows.map((r: any) => ({
...r,
options: typeof r.options === 'string' ? JSON.parse(r.options) : r.options,
}))
res.json({ code: 0, data })
if (priceRows.length > 0) {
const opts = typeof priceRows[0].options === 'string' ? JSON.parse(priceRows[0].options) : priceRows[0].options
if (opts && opts.length) {
autoFilters.push({ filterName: '价格', filterKey: 'price', options: opts })
}
}
res.json({ code: 0, data: autoFilters })
} catch (err) {
console.error('getCategoryFilters error:', err)
res.status(500).json({ code: 500, message: '获取筛选配置失败' })