feat: 积分商城-供应商-对账-发票4

This commit is contained in:
2026-09-08 20:12:40 +08:00
parent f42ea5b270
commit ff77ba16cc
39 changed files with 705 additions and 136 deletions
+4
View File
@@ -4,6 +4,10 @@ const API_BASE = import.meta.env.VITE_SUPPLIED_ADMIN_API_BASE || 'http://127.0.0
const normalizeBase = (base: string) => base.replace(/\/$/, '') const normalizeBase = (base: string) => base.replace(/\/$/, '')
export const resolveImageUrl = (value: string) => /^\/api\/(?:file|upload-preview)\?/.test(value)
? `${normalizeBase(API_BASE).replace(/\/api\/admin$/, '')}${value.replace('/api/upload-preview?', '/api/file?')}`
: value
export const postAdmin = async <T>(name: string, body: Record<string, unknown>): Promise<T> => { export const postAdmin = async <T>(name: string, body: Record<string, unknown>): Promise<T> => {
const token = readAdminSession()?.token const token = readAdminSession()?.token
const response = await fetch(`${normalizeBase(API_BASE)}/${name}`, { const response = await fetch(`${normalizeBase(API_BASE)}/${name}`, {
@@ -1,23 +1,111 @@
<template> <template>
<section class="page-card"><div class="page-title"><div><h2>{{ title }}</h2><p>{{ description }}</p></div></div><el-form :inline="true" class="query-form"><el-form-item label="供应商 ID"><el-input v-model="supplierId" placeholder="请输入供应商 ID" clearable /></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="loadRecords">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form><el-table v-loading="loading" :data="records" border><el-table-column prop="serviceOrderId" label="售后单号" min-width="170" /><el-table-column prop="serviceTypeText" label="售后类型" width="110" /><el-table-column prop="serviceStateText" label="状态" width="130" /><el-table-column prop="reason" label="原因" min-width="160" /><el-table-column prop="applyTime" label="申请时间" min-width="170" /><el-table-column label="操作" min-width="160" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button v-if="scene === 'audit'" link type="success" @click="audit(row, 1)">通过</el-button><el-button v-if="scene === 'audit'" link type="danger" @click="audit(row, 2)">拒绝</el-button><el-button v-if="canReceive(row)" link type="success" @click="receive(row)">确认收货</el-button><el-button v-if="canShip(row)" link type="primary" @click="openShipDialog(row)">换货发货</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无售后记录" /></template></el-table><el-dialog v-model="detailVisible" title="售后详情" width="620px"><pre class="detail-json">{{ JSON.stringify(detail, null, 2) }}</pre></el-dialog><el-dialog v-model="shipVisible" title="换货发货" width="460px"><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.logisticsCompany" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsNo" /></el-form-item></el-form><template #footer><el-button @click="shipVisible = false">取消</el-button><el-button type="primary" @click="shipExchange">确认发货</el-button></template></el-dialog></section> <section class="page-card"><div class="page-title"><div><h2>{{ title }}</h2><p>{{ description }}</p></div></div><el-form :inline="true" class="query-form" @submit.prevent="loadRecords"><el-form-item label="售后单号"><el-input v-model="serviceOrderId" placeholder="请输入售后单号" clearable /></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="loadRecords">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form><div v-if="scene !== 'process'" v-loading="loading" class="audit-list">
<article v-for="row in records" :key="row.serviceOrderId" class="audit-card">
<header class="audit-card-header"><strong>{{ row.serviceOrderId }}</strong><el-tag type="warning">{{ row.serviceTypeText || '-' }}</el-tag></header>
<div v-for="product in row.products || []" :key="product.id || product.sku" class="audit-product">
<el-image :src="resolveImageUrl(product.image || '')" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported fit="cover"><template #error><span class="image-empty">暂无图片</span></template></el-image>
<div class="audit-product-info"><strong>{{ product.name }}</strong><span>{{ specText(product.sku) }}</span><b>¥{{ money(product.price) }}</b></div><span class="audit-quantity">×{{ product.number }}</span>
</div>
<div class="audit-summary"><dl><dt>原因</dt><dd>{{ row.reason || '-' }}</dd><dt>描述</dt><dd>{{ row.problemDescription || '-' }}</dd></dl><el-tag :type="row.isSigned === true ? 'success' : 'info'">{{ row.isSigned === true ? '已签收' : row.isSigned === false ? '未签收' : '签收状态未知' }}</el-tag></div>
<div v-if="scene === 'audit'" class="after-sale-status"><el-tag :type="row.serviceState === 0 ? 'warning' : 'primary'">{{ currentStatusText(row) }}</el-tag></div>
<div v-if="scene === 'history'" class="history-result"><span class="result-stamp" :class="{ approved: row.serviceState === 5, rejected: row.serviceState === 2 }">{{ historyResultText(row) }}</span></div>
<footer class="audit-card-footer"><span>{{ row.applyTime || '-' }}</span><div><el-button @click="openDetail(row)">查看详情</el-button><el-button v-if="scene === 'audit' && row.serviceState === 0" type="danger" plain :disabled="auditing" @click="audit(row, 2)">拒绝</el-button><el-button v-if="scene === 'audit' && row.serviceState === 0" type="primary" :disabled="auditing" @click="audit(row, 1)">通过</el-button><el-button v-if="canReceive(row)" type="primary" :disabled="auditing" @click="receive(row)">确认收货</el-button><el-button v-if="canShip(row)" type="primary" :disabled="auditing" @click="openShipDialog(row)">换货发货</el-button></div></footer>
</article>
<el-empty v-if="!loading && !records.length" :description="scene === 'audit' ? '暂无待处理售后' : '暂无售后记录'" />
</div><el-table v-else v-loading="loading" :data="records" border><el-table-column prop="serviceOrderId" label="售后单号" min-width="170" /><el-table-column prop="serviceTypeText" label="售后类型" width="110" /><el-table-column prop="serviceStateText" label="状态" width="130" /><el-table-column prop="reason" label="原因" min-width="160" /><el-table-column prop="applyTime" label="申请时间" min-width="170" /><el-table-column label="操作" min-width="160" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button v-if="scene === 'audit'" link type="success" @click="audit(row, 1)">通过</el-button><el-button v-if="scene === 'audit'" link type="danger" @click="audit(row, 2)">拒绝</el-button><el-button v-if="canReceive(row)" link type="success" @click="receive(row)">确认收货</el-button><el-button v-if="canShip(row)" link type="primary" @click="openShipDialog(row)">换货发货</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无售后记录" /></template></el-table><el-dialog v-model="detailVisible" title="售后详情" :width="scene !== 'process' ? '1080px' : '760px'">
<div v-if="scene !== 'process' && detail.afterSale" class="audit-detail">
<section class="audit-detail-section audit-progress"><span v-if="scene === 'history'" class="result-stamp detail-result-stamp" :class="{ approved: detail.afterSale.serviceState === 5, rejected: detail.afterSale.serviceState === 2 }" aria-label="处理结果">{{ historyResultText(detail.afterSale) }}</span><h3>售后进度</h3><el-steps align-center>
<el-step title="提交申请" status="success"><template #description><p>用户提交售后申请</p><p>{{ detail.afterSale.applyTime || '-' }}</p></template></el-step>
<el-step title="商家审核" :status="detail.afterSale.auditTime ? (detail.afterSale.auditStatus === 2 ? 'error' : 'success') : detail.afterSale.serviceState === 6 ? 'wait' : 'process'"><template #description><p>{{ detail.afterSale.auditRemark || (detail.afterSale.serviceState === 6 ? '申请已撤销' : '待商家审核') }}</p><p>{{ detail.afterSale.auditTime || '-' }}</p></template></el-step>
<el-step v-if="detail.afterSale.isSigned && detail.afterSale.auditStatus === 1" title="商品寄回" :status="detail.afterSale.userShipTime ? 'success' : 'process'"><template #description><p>{{ detail.afterSale.userShipTime ? '用户已寄回商品' : '等待用户填写寄回物流' }}</p><p>{{ detail.afterSale.userShipTime || '-' }}</p></template></el-step>
<el-step v-if="detail.afterSale.isSigned && detail.afterSale.auditStatus === 1" title="商家收货" :status="detail.afterSale.merchantReceiveTime ? 'success' : detail.afterSale.userShipTime ? 'process' : 'wait'"><template #description><p>{{ detail.afterSale.merchantReceiveTime ? '商家已确认收货' : '等待商家收到商品' }}</p><p>{{ detail.afterSale.merchantReceiveTime || '-' }}</p></template></el-step>
<el-step :title="detail.afterSale.serviceState === 6 ? '已撤销' : detail.afterSale.serviceState === 2 ? '审核未通过' : '售后完成'" :status="detail.afterSale.serviceState === 5 ? 'success' : 'wait'"><template #description><p>{{ detail.afterSale.serviceState === 5 ? '售后已完成' : detail.afterSale.serviceState === 2 ? '审核未通过,售后已结束' : detail.afterSale.serviceState === 6 ? '用户已撤销售后申请' : '等待售后处理完成' }}</p><p>{{ detail.afterSale.finishTime || '-' }}</p></template></el-step>
</el-steps></section>
<section class="audit-detail-section"><h3>售后商品</h3><div v-for="product in detail.afterSale.products || []" :key="product.id || product.sku" class="audit-product"><el-image :src="resolveImageUrl(product.image || '')" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported fit="cover"><template #error><span class="image-empty">暂无图片</span></template></el-image><div class="audit-product-info"><strong>{{ product.name }}</strong><span>{{ specText(product.sku) }}</span><b>¥{{ money(product.price) }}</b></div><span class="audit-quantity">×{{ product.number }}</span></div></section>
<section class="audit-detail-section"><h3>售后信息</h3><el-descriptions :column="1" border>
<el-descriptions-item label="售后单号">{{ detail.afterSale.serviceOrderId }}</el-descriptions-item><el-descriptions-item label="子订单号">{{ detail.afterSale.orderNumberId || detail.afterSale.subOrderId || '-' }}</el-descriptions-item><el-descriptions-item label="售后类型"><el-tag type="warning">{{ detail.afterSale.serviceTypeText || '-' }}</el-tag></el-descriptions-item><el-descriptions-item label="签收状态"><el-tag :type="detail.afterSale.isSigned === true ? 'success' : 'info'">{{ detail.afterSale.isSigned === true ? '已签收' : detail.afterSale.isSigned === false ? '未签收' : '-' }}</el-tag></el-descriptions-item><el-descriptions-item label="当前状态"><el-tag>{{ currentStatusText(detail.afterSale) }}</el-tag></el-descriptions-item><el-descriptions-item v-if="detail.afterSale.userLogisticsNo" label="寄回物流">{{ detail.afterSale.userLogisticsCompany || '-' }} / {{ detail.afterSale.userLogisticsNo }}</el-descriptions-item><el-descriptions-item v-if="detail.afterSale.merchantReceiveTime" label="商家收货时间">{{ detail.afterSale.merchantReceiveTime }}</el-descriptions-item><el-descriptions-item v-if="detail.afterSale.merchantLogisticsNo" label="换货物流">{{ detail.afterSale.merchantLogisticsCompany || '-' }} / {{ detail.afterSale.merchantLogisticsNo }}</el-descriptions-item><el-descriptions-item label="售后原因">{{ detail.afterSale.reason || '-' }}</el-descriptions-item><el-descriptions-item label="问题描述">{{ detail.afterSale.problemDescription || '-' }}</el-descriptions-item><el-descriptions-item label="申请时间">{{ detail.afterSale.applyTime || '-' }}</el-descriptions-item>
</el-descriptions><div v-if="detailImages.length" class="audit-evidence"><el-image v-for="url in detailImages" :key="url" :src="url" :preview-src-list="detailImages" preview-teleported fit="cover" /></div></section>
</div><pre v-else class="detail-json">{{ JSON.stringify(detail, null, 2) }}</pre>
<template #footer><el-button @click="detailVisible = false">关闭</el-button><template v-if="scene === 'audit' && detail.afterSale?.serviceState === 0"><el-button type="danger" plain :disabled="auditing" @click="audit(detail.afterSale, 2)">拒绝</el-button><el-button type="primary" :disabled="auditing" @click="audit(detail.afterSale, 1)">通过</el-button></template><el-button v-if="detail.afterSale && canReceive(detail.afterSale)" type="primary" :disabled="auditing" @click="receive(detail.afterSale)">确认收货</el-button><el-button v-if="detail.afterSale && canShip(detail.afterSale)" type="primary" :disabled="auditing" @click="openShipDialog(detail.afterSale)">换货发货</el-button></template>
</el-dialog><el-dialog v-model="shipVisible" title="换货发货" width="460px" append-to-body><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.logisticsCompany" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsNo" /></el-form-item></el-form><template #footer><el-button @click="shipVisible = false">取消</el-button><el-button type="primary" :loading="auditing" @click="shipExchange">确认发货</el-button></template></el-dialog></section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { postAdmin } from '@/api/admin' import { postAdmin, resolveImageUrl } from '@/api/admin'
const props = defineProps<{ scene: 'audit' | 'process' | 'history'; title: string; description: string }>() const props = defineProps<{ scene: 'audit' | 'process' | 'history'; title: string; description: string }>()
type AfterSale = { serviceOrderId: string; serviceState?: number; serviceType?: number; isSigned?: boolean; serviceTypeText?: string; serviceStateText?: string; reason?: string; applyTime?: string } type AfterSaleProduct = { id?: number; sku: string; name: string; number: number; price: string; image?: string }
const supplierId = ref(''); const records = ref<AfterSale[]>([]); const loading = ref(false); const queried = ref(false); const detailVisible = ref(false); const detail = ref<unknown>({}); const shipVisible = ref(false); const current = ref<AfterSale>(); const shipForm = ref({ logisticsCompany: '', logisticsNo: '' }) type AfterSale = { serviceOrderId: string; supplierId: string; serviceState?: number; logisticsState?: number; serviceType?: number; isSigned?: boolean; serviceTypeText?: string; serviceStateText?: string; reason?: string; applyTime?: string; problemDescription?: string; products?: AfterSaleProduct[]; subOrderId?: string; orderNumberId?: string; auditStatus?: number; auditTime?: string; auditRemark?: string; finishTime?: string; userLogisticsNo?: string; userLogisticsCompany?: string; merchantLogisticsNo?: string; merchantLogisticsCompany?: string; userShipTime?: string; merchantReceiveTime?: string; merchantShipTime?: string; images?: Array<string | { url?: string; previewUrl?: string; fileUrl?: string }> }
const loadRecords = async () => { if (!supplierId.value.trim()) return ElMessage.warning('请输入供应商 ID'); loading.value = true; try { const data = await postAdmin<{ afterSaleItems?: AfterSale[] }>('merchant-after-sale-list', { supplierId: supplierId.value.trim(), scene: props.scene }); records.value = data.afterSaleItems || []; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后查询失败') } finally { loading.value = false } } const serviceOrderId = ref('')
const reset = () => { supplierId.value = ''; records.value = []; queried.value = false } const auditing = ref(false)
const openDetail = async (row: AfterSale) => { try { detail.value = await postAdmin('merchant-after-sale-detail', { supplierId: supplierId.value.trim(), serviceOrderId: row.serviceOrderId }); detailVisible.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后详情查询失败') } } const currentStatusText = (row: AfterSale) => {
const audit = async (row: AfterSale, auditStatus: number) => { try { await ElMessageBox.confirm(auditStatus === 1 ? '确认通过该售后申请?' : '确认拒绝该售后申请?', '售后审核'); await postAdmin('merchant-after-sale-audit', { supplierId: supplierId.value.trim(), serviceOrderId: row.serviceOrderId, auditStatus, auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝' }); ElMessage.success('处理成功'); await loadRecords() } catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '处理失败') } } if (row.serviceState === 1 && row.logisticsState === 1) return '审核通过,待用户寄回'
const canReceive = (row: AfterSale) => props.scene === 'process' && Number(row.serviceState) === 3 if (row.serviceState === 3) return '用户已寄回,待商家收货'
const canShip = (row: AfterSale) => props.scene === 'process' && Number(row.serviceType) === 5 && (Number(row.serviceState) === 4 || (row.isSigned === false && Number(row.serviceState) === 1)) if (row.serviceState === 4 && row.serviceType === 5) return '商家已收货,待换货发货'
const receive = async (row: AfterSale) => { try { await ElMessageBox.confirm('确认已收到寄回商品?', '售后处理'); await postAdmin('merchant-after-sale-receive', { supplierId: supplierId.value.trim(), serviceOrderId: row.serviceOrderId }); ElMessage.success('已确认收货'); await loadRecords() } catch (error) { if (error !== 'cancel') ElMessage.error(error instanceof Error ? error.message : '操作失败') } } if (row.serviceState === 1 && row.serviceType === 5 && row.isSigned === false) return '审核通过,待换货发货'
return row.serviceStateText || '-'
}
const historyResultText = (row: AfterSale) => row.serviceState === 5 ? '审核通过' : row.serviceStateText || '-'
const money = (value: string) => Number(value || 0).toFixed(2)
const specText = (sku: string) => {
const labels: Record<string, string> = { cream: '奶油白', blue: '雾霾蓝', orange: '暖橙色', standard: '标准款', plus: '升级款' }
const parts = String(sku || '').split('-').slice(-2)
return parts.some(part => labels[part]) ? parts.map(part => labels[part] || part).join(' | ') : '-'
}
const detailImages = computed(() => (detail.value.afterSale?.images || []).map(item => resolveImageUrl(typeof item === 'string' ? item : item.fileUrl || item.url || item.previewUrl || '')).filter(Boolean))
const records = ref<AfterSale[]>([]); const loading = ref(false); const queried = ref(false); const detailVisible = ref(false); const detail = ref<{ afterSale?: AfterSale }>({}); const shipVisible = ref(false); const current = ref<AfterSale>(); const shipForm = ref({ logisticsCompany: '', logisticsNo: '' })
const loadRecords = async () => { loading.value = true; try { const data = await postAdmin<{ afterSaleItems?: AfterSale[] }>('merchant-after-sale-list', { scene: props.scene, serviceOrderId: serviceOrderId.value.trim() }); records.value = data.afterSaleItems || []; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后查询失败') } finally { loading.value = false } }
const reset = () => { serviceOrderId.value = ''; return loadRecords() }
const openDetail = async (row: AfterSale) => { try { detail.value = await postAdmin<{ afterSale: AfterSale }>('merchant-after-sale-detail', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId }); detailVisible.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '售后详情查询失败') } }
const audit = async (row: AfterSale, auditStatus: number) => { if (auditing.value) return; auditing.value = true; try { await ElMessageBox.confirm(auditStatus === 1 ? '确认通过该售后申请?' : '确认拒绝该售后申请?', '售后审核'); await postAdmin('merchant-after-sale-audit', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId, auditStatus, auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝' }); ElMessage.success('处理成功'); detailVisible.value = false; await loadRecords() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '处理失败') } finally { auditing.value = false } }
const canReceive = (row: AfterSale) => props.scene !== 'history' && Number(row.serviceState) === 3
const canShip = (row: AfterSale) => props.scene !== 'history' && Number(row.serviceType) === 5 && (Number(row.serviceState) === 4 || (row.isSigned === false && Number(row.serviceState) === 1))
const receive = async (row: AfterSale) => { if (auditing.value) return; auditing.value = true; try { await ElMessageBox.confirm('确认已收到寄回商品?', '售后处理'); await postAdmin('merchant-after-sale-receive', { supplierId: row.supplierId, serviceOrderId: row.serviceOrderId }); ElMessage.success('已确认收货'); await loadRecords(); if (detailVisible.value) await openDetail(row) } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '操作失败') } finally { auditing.value = false } }
const openShipDialog = (row: AfterSale) => { current.value = row; shipForm.value = { logisticsCompany: '', logisticsNo: '' }; shipVisible.value = true } const openShipDialog = (row: AfterSale) => { current.value = row; shipForm.value = { logisticsCompany: '', logisticsNo: '' }; shipVisible.value = true }
const shipExchange = async () => { if (!current.value || !shipForm.value.logisticsCompany || !shipForm.value.logisticsNo) return ElMessage.warning('请填写完整发货信息'); try { await postAdmin('merchant-after-sale-ship', { supplierId: supplierId.value.trim(), serviceOrderId: current.value.serviceOrderId, ...shipForm.value }); ElMessage.success('换货已发货'); shipVisible.value = false; await loadRecords() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } } const shipExchange = async () => { if (auditing.value) return; if (!current.value || !shipForm.value.logisticsCompany || !shipForm.value.logisticsNo) return ElMessage.warning('请填写完整发货信息'); auditing.value = true; try { await postAdmin('merchant-after-sale-ship', { supplierId: current.value.supplierId, serviceOrderId: current.value.serviceOrderId, ...shipForm.value }); ElMessage.success('换货已发货'); shipVisible.value = false; await loadRecords(); if (detailVisible.value) await openDetail(current.value) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } finally { auditing.value = false } }
onMounted(loadRecords)
</script> </script>
<style scoped>.detail-json { max-height: 420px; overflow: auto; padding: 12px; border-radius: 4px; background: #f5f7fa; white-space: pre-wrap; word-break: break-all; }</style> <style scoped>
.after-sale-status { margin-top: 16px; }
.audit-card-footer > div { display: flex; flex-wrap: wrap; gap: 8px; }
.audit-card-footer > div .el-button { margin-left: 0; }
.history-result { display: flex; justify-content: flex-end; margin-top: 16px; padding: 8px 6px; }
.result-stamp { position: relative; display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 112px; height: 56px; margin: 6px 0; border: 2px solid currentColor; border-radius: 50%; color: #909399; font-size: 16px; font-weight: 700; letter-spacing: 2px; line-height: 1; transform: rotate(-14deg); }
.result-stamp::after { position: absolute; inset: 4px; border: 1px dashed currentColor; border-radius: 50%; content: ""; }
.result-stamp.approved { color: #389e63; }
.result-stamp.rejected { color: #d9534f; }
.audit-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 390px), 1fr)); gap: 20px; }
.audit-list > .el-empty { grid-column: 1 / -1; }
.audit-card { padding: 20px; border: 1px solid #e9eef5; border-radius: 12px; background: #fff; }
.audit-card-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
.audit-card-header strong { min-width: 0; overflow-wrap: anywhere; line-height: 22px; }
.audit-card-header .el-tag { flex-shrink: 0; }
.audit-product { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
.audit-product > .el-image { width: 72px; height: 72px; flex-shrink: 0; border-radius: 8px; }
.audit-product-info { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 6px; }
.audit-product-info strong { overflow-wrap: anywhere; }
.audit-product-info span, .audit-card-footer > span { color: #8793a5; font-size: 12px; }
.audit-product-info b { color: #ff6b35; }
.audit-quantity { flex-shrink: 0; font-weight: 600; }
.image-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; background: #f5f7fa; color: #8793a5; font-size: 12px; }
.audit-summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.audit-summary dl { display: grid; grid-template-columns: 36px minmax(0, 1fr); gap: 10px 12px; flex: 1; min-width: 0; margin: 0; font-size: 13px; }
.audit-summary dt { color: #8793a5; }
.audit-summary dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
.audit-card-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 14px; margin-top: 20px; padding-top: 16px; border-top: 1px solid #edf0f5; }
.audit-detail { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); align-items: start; gap: 20px; }
.audit-progress { position: relative; grid-column: 1 / -1; }
.detail-result-stamp { position: absolute; z-index: 10; top: -18px; right: 8px; pointer-events: none; width: 128px; height: 128px; margin: 0; border-width: 3px; font-size: 20px; }
.detail-result-stamp::after { inset: 7px; border-width: 2px; }
.audit-detail-section { min-width: 0; padding: 20px; border: 1px solid #e9eef5; border-radius: 10px; }
@media (max-width: 767px) { .audit-detail { grid-template-columns: minmax(0, 1fr); } }
.audit-detail-section h3 { margin: 0 0 18px; font-size: 15px; }
.audit-progress :deep(.el-step__description) { overflow-wrap: anywhere; }
.audit-detail-section p { margin: 6px 0 0; color: #8793a5; font-size: 12px; }
.audit-detail :deep(.el-descriptions__label) { width: 100px; white-space: nowrap; }
.audit-detail :deep(.el-descriptions__content) { overflow-wrap: anywhere; white-space: pre-wrap; }
.audit-evidence { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 16px; }
.audit-evidence .el-image { width: 80px; height: 80px; border-radius: 6px; }
.detail-json { max-height: 420px; overflow: auto; padding: 12px; border-radius: 4px; background: #f5f7fa; white-space: pre-wrap; word-break: break-all; }</style>
+2 -2
View File
@@ -3,8 +3,8 @@
<el-aside :width="appStore.sidebarCollapsed ? '64px' : '224px'" class="sidebar"> <el-aside :width="appStore.sidebarCollapsed ? '64px' : '224px'" class="sidebar">
<div class="brand"><el-icon><ShoppingBag /></el-icon><span v-show="!appStore.sidebarCollapsed">供应商管理</span></div> <div class="brand"><el-icon><ShoppingBag /></el-icon><span v-show="!appStore.sidebarCollapsed">供应商管理</span></div>
<el-menu :collapse="appStore.sidebarCollapsed" :default-active="$route.path" background-color="#152238" text-color="#afbed3" active-text-color="#ffffff" router> <el-menu :collapse="appStore.sidebarCollapsed" :default-active="$route.path" background-color="#152238" text-color="#afbed3" active-text-color="#ffffff" router>
<el-menu-item index="/reconciliation"><el-icon><Tickets /></el-icon><template #title>订单对账</template></el-menu-item> <el-sub-menu index="/billing"><template #title><el-icon><Tickets /></el-icon><span>对账开票</span></template><el-menu-item index="/reconciliation">订单对账</el-menu-item><el-menu-item index="/invoices">发票管理</el-menu-item></el-sub-menu>
<el-sub-menu index="/orders"><template #title><el-icon><List /></el-icon><span>订单管理</span></template><el-menu-item index="/orders/list">订单列表</el-menu-item><el-menu-item index="/orders/after-sales/audit">售后审核</el-menu-item><el-menu-item index="/orders/after-sales/process">售后处理</el-menu-item><el-menu-item index="/orders/after-sales/history">售后记录</el-menu-item></el-sub-menu> <el-sub-menu index="/orders"><template #title><el-icon><List /></el-icon><span>订单管理</span></template><el-menu-item index="/orders/list">订单列表</el-menu-item><el-menu-item index="/orders/after-sales/audit">订单售后</el-menu-item><el-menu-item index="/orders/after-sales/history">售后记录</el-menu-item></el-sub-menu>
<el-menu-item index="/products"><el-icon><Goods /></el-icon><template #title>商品管理</template></el-menu-item> <el-menu-item index="/products"><el-icon><Goods /></el-icon><template #title>商品管理</template></el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
+3 -2
View File
@@ -6,10 +6,11 @@ import { clearAdminSession, readAdminSession } from '@/utils/auth'
const routes: RouteRecordRaw[] = [{ path: '/login', name: 'Login', component: () => import('@/views/login/index.vue'), meta: { title: '登录' } }, { path: '/', component: AdminLayout, redirect: '/reconciliation', children: [ const routes: RouteRecordRaw[] = [{ path: '/login', name: 'Login', component: () => import('@/views/login/index.vue'), meta: { title: '登录' } }, { path: '/', component: AdminLayout, redirect: '/reconciliation', children: [
{ path: 'reconciliation', name: 'Reconciliation', component: () => import('@/views/reconciliation/index.vue'), meta: { title: '订单对账' } }, { path: 'reconciliation', name: 'Reconciliation', component: () => import('@/views/reconciliation/index.vue'), meta: { title: '订单对账' } },
{ path: 'invoices', name: 'Invoices', component: () => import('@/views/invoices/index.vue'), meta: { title: '发票管理' } },
{ path: 'orders', redirect: '/orders/list' }, { path: 'orders', redirect: '/orders/list' },
{ path: 'orders/list', name: 'Orders', component: () => import('@/views/orders/index.vue'), meta: { title: '订单列表' } }, { path: 'orders/list', name: 'Orders', component: () => import('@/views/orders/index.vue'), meta: { title: '订单列表' } },
{ path: 'orders/after-sales/audit', name: 'AfterSaleAudit', component: () => import('@/views/after-sales/audit.vue'), meta: { title: '售后审核' } }, { path: 'orders/after-sales/audit', name: 'AfterSaleAudit', component: () => import('@/views/after-sales/audit.vue'), meta: { title: '订单售后' } },
{ path: 'orders/after-sales/process', name: 'AfterSaleProcess', component: () => import('@/views/after-sales/process.vue'), meta: { title: '售后处理' } }, { path: 'orders/after-sales/process', name: 'AfterSaleProcess', redirect: '/orders/after-sales/audit' },
{ path: 'orders/after-sales/history', name: 'AfterSaleHistory', component: () => import('@/views/after-sales/history.vue'), meta: { title: '售后记录' } }, { path: 'orders/after-sales/history', name: 'AfterSaleHistory', component: () => import('@/views/after-sales/history.vue'), meta: { title: '售后记录' } },
{ path: 'products', name: 'Products', component: () => import('@/views/products/index.vue'), meta: { title: '商品管理' } } { path: 'products', name: 'Products', component: () => import('@/views/products/index.vue'), meta: { title: '商品管理' } }
] }] ] }]
+4 -4
View File
@@ -34,9 +34,9 @@ button, input, textarea { font: inherit; }
.page-title h2 { margin: 0 0 8px; color: #202e45; font-size: 22px; font-weight: 600; letter-spacing: .5px; } .page-title h2 { margin: 0 0 8px; color: #202e45; font-size: 22px; font-weight: 600; letter-spacing: .5px; }
.page-title p { margin: 0; color: #78859a; font-size: 13px; line-height: 1.7; } .page-title p { margin: 0; color: #78859a; font-size: 13px; line-height: 1.7; }
.query-form.el-form--inline { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px 20px; padding: 20px; margin-bottom: 24px; border: 1px solid #e9eef5; border-radius: 10px; background: #f8fafc; } .query-form.el-form--inline { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 16px 20px; padding: 20px; margin-bottom: 24px; border: 1px solid #e9eef5; border-radius: 10px; background: #f8fafc; }
.query-form.el-form--inline .el-form-item { display: flex; flex-direction: column; align-items: stretch; margin: 0; } .query-form.el-form--inline .el-form-item { display: flex; flex-direction: row; align-items: center; margin: 0; }
.query-form .el-form-item__label { justify-content: flex-start; height: auto; padding: 0 0 8px; color: #526078; font-size: 13px; line-height: 18px; } .query-form .el-form-item__label { justify-content: flex-start; flex-shrink: 0; height: 36px; padding: 0 12px 0 0; color: #526078; font-size: 13px; line-height: 36px; white-space: nowrap; }
.query-form .el-form-item__content { min-height: 36px; } .query-form .el-form-item__content { min-width: 0; min-height: 36px; }
.query-form .el-input, .query-form .el-select { width: 208px; max-width: 100%; } .query-form .el-input, .query-form .el-select { width: 208px; max-width: 100%; }
.query-form .el-button { min-width: 76px; height: 36px; } .query-form .el-button { min-width: 76px; height: 36px; }
.el-input__wrapper, .el-select__wrapper { min-height: 36px; } .el-input__wrapper, .el-select__wrapper { min-height: 36px; }
@@ -78,7 +78,7 @@ button, input, textarea { font: inherit; }
.el-dialog .el-form-item:last-child { margin-bottom: 0; } .el-dialog .el-form-item:last-child { margin-bottom: 0; }
.el-dialog .el-select, .el-dialog .el-input-number { width: 100%; } .el-dialog .el-select, .el-dialog .el-input-number { width: 100%; }
.el-dialog__footer .el-button { min-width: 80px; height: 36px; } .el-dialog__footer .el-button { min-width: 80px; height: 36px; }
.el-message-box { max-width: calc(100vw - 32px); padding: 20px; border-radius: 12px; } .el-message-box { width: 360px; max-width: calc(100vw - 32px); padding: 20px; border-radius: 12px; }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.page-card { padding: 22px; } .page-card { padding: 22px; }
@@ -1,2 +1,2 @@
<template><MerchantAfterSaleTable scene="audit" title="售后审核" description="审核用户提交的退货、换货申请。" /></template> <template><MerchantAfterSaleTable scene="audit" title="订单售后" description="审核退换货申请,处理寄回收货和换货发货。" /></template>
<script setup lang="ts">import MerchantAfterSaleTable from '@/components/MerchantAfterSaleTable.vue'</script> <script setup lang="ts">import MerchantAfterSaleTable from '@/components/MerchantAfterSaleTable.vue'</script>
@@ -0,0 +1,88 @@
<template>
<section class="page-card">
<div class="page-title"><div><h2>发票管理</h2><p>查看开票结果、关联订单及电子发票。</p></div></div>
<el-form inline class="query-form" @submit.prevent="search">
<el-form-item label="结算单号"><el-input v-model="query.settlementId" placeholder="请输入结算单号" clearable /></el-form-item>
<el-form-item label="开票状态"><el-select v-model="query.invoiceStatus" placeholder="全部" clearable><el-option v-for="(name, value) in statuses" :key="value" :label="name" :value="Number(value)" /></el-select></el-form-item>
<el-form-item><el-button type="primary" :loading="loading" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<el-table :data="items" v-loading="loading" border>
<el-table-column prop="settlementId" label="结算单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="invoiceIds" label="发票号码" min-width="190" show-overflow-tooltip />
<el-table-column label="开票状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.invoiceStatus)">{{ statuses[row.invoiceStatus] || '-' }}</el-tag></template></el-table-column>
<el-table-column label="价税合计" width="120"><template #default="{ row }">¥{{ row.invoiceAmount }}</template></el-table-column>
<el-table-column label="开票日期" width="130"><template #default="{ row }">{{ dateText(row.invoiceDate) }}</template></el-table-column>
<el-table-column prop="failureMessage" label="说明" min-width="180" show-overflow-tooltip><template #default="{ row }">{{ row.failureMessage || '-' }}</template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openDetail(row)">详情</el-button><el-button v-if="row.hasFile" link type="primary" :disabled="fileLoading" @click="openFile(row)">预览</el-button><el-button v-if="row.hasFile" link type="primary" :disabled="fileLoading" @click="openFile(row, true)">下载</el-button></template></el-table-column>
<template #empty><el-empty :image-size="80" description="暂无发票记录" /></template>
</el-table>
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
<el-dialog v-model="detailVisible" title="发票详情" width="880px">
<template v-if="current"><el-descriptions :column="2" border class="invoice-detail">
<el-descriptions-item label="结算单号">{{ current.settlementId }}</el-descriptions-item><el-descriptions-item label="开票状态"><el-tag :type="statusType(current.invoiceStatus)">{{ statuses[current.invoiceStatus] || '-' }}</el-tag></el-descriptions-item>
<el-descriptions-item label="发票号码">{{ current.invoiceIds || '-' }}</el-descriptions-item><el-descriptions-item label="发票代码">{{ current.invoiceCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="开票日期">{{ dateText(current.invoiceDate) }}</el-descriptions-item><el-descriptions-item label="发票类型">{{ current.invoiceType === 9 ? '全电专票' : current.invoiceType === 10 ? '全电普票' : '-' }}</el-descriptions-item>
<el-descriptions-item label="不含税金额">¥{{ current.invoiceNakeAmount }}</el-descriptions-item><el-descriptions-item label="税率">{{ current.invoiceTaxRate ? `${Number(current.invoiceTaxRate) * 100}%` : '-' }}</el-descriptions-item>
<el-descriptions-item label="税额">¥{{ current.invoiceTaxAmount }}</el-descriptions-item><el-descriptions-item label="价税合计">¥{{ current.invoiceAmount }}</el-descriptions-item>
<el-descriptions-item label="纳税人识别号">{{ current.taxIdNumber || '-' }}</el-descriptions-item><el-descriptions-item label="校验码">{{ current.checkCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="说明" :span="2">{{ current.failureMessage || '-' }}</el-descriptions-item>
</el-descriptions><h3 class="orders-title">关联订单</h3><div class="invoice-orders"><el-tag v-for="orderId in current.orderIds.split(',').filter(Boolean)" :key="orderId" type="info">{{ orderId }}</el-tag><span v-if="!current.orderIds">暂无关联订单</span></div></template>
<template #footer><el-button @click="detailVisible = false">关闭</el-button><template v-if="current?.hasFile"><el-button :disabled="fileLoading" @click="openFile(current, true)">下载PDF</el-button><el-button type="primary" :disabled="fileLoading" @click="openFile(current)">预览PDF</el-button></template></template>
</el-dialog>
<el-dialog v-model="previewVisible" title="发票预览" width="1000px" append-to-body @closed="previewUrl = ''"><iframe v-if="previewUrl" :src="previewUrl" title="电子发票" class="invoice-preview" /></el-dialog>
</section>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { postAdmin } from '@/api/admin'
type Invoice = { id: number; settlementId: string; invoiceIds: string; invoiceCode: string; invoiceStatus: number; invoiceDate: string | null; invoiceType: number | null; invoiceAmount: string; invoiceNakeAmount: string; invoiceTaxAmount: string; invoiceTaxRate: string; taxIdNumber: string; checkCode: string; orderIds: string; failureMessage: string; hasFile: boolean }
const statuses: Record<number, string> = { 0: '未开票', 1: '开票中', 2: '成功', 3: '失败' }
const statusType = (status: number) => status === 2 ? 'success' : status === 3 ? 'danger' : status === 1 ? 'warning' : 'info'
const dateText = (value: string | null) => value ? new Date(value).toLocaleDateString('sv-SE') : '-'
const query = reactive<{ settlementId: string; invoiceStatus?: number; pageNumber: number; pageSize: number }>({ settlementId: '', pageNumber: 1, pageSize: 10 })
const items = ref<Invoice[]>([])
const totalCount = ref(0)
const loading = ref(false)
const current = ref<Invoice>()
const detailVisible = ref(false)
const fileLoading = ref(false)
const previewVisible = ref(false)
const previewUrl = ref('')
const load = async () => {
loading.value = true
try {
const data = await postAdmin<{ invoiceItems: Invoice[]; totalCount: number }>('merchant-invoice-list', query)
items.value = data.invoiceItems || []
totalCount.value = data.totalCount || 0
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '发票查询失败') } finally { loading.value = false }
}
const search = () => { query.pageNumber = 1; return load() }
const reset = () => { query.settlementId = ''; query.invoiceStatus = undefined; return search() }
const openDetail = (row: Invoice) => { current.value = row; detailVisible.value = true }
const openFile = async (row: Invoice, download = false) => {
if (fileLoading.value) return
fileLoading.value = true
try {
const data = await postAdmin<{ url: string }>('merchant-invoice-file', { id: row.id, download })
if (download) {
const link = document.createElement('a')
link.href = data.url
link.download = `${row.invoiceIds || 'invoice'}.pdf`
document.body.appendChild(link)
link.click()
link.remove()
} else { previewUrl.value = data.url; previewVisible.value = true }
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '获取发票文件失败') } finally { fileLoading.value = false }
}
onMounted(load)
</script>
<style scoped>
.invoice-detail :deep(.el-descriptions__content) { overflow-wrap: anywhere; }
.invoice-detail :deep(.el-descriptions__label) { width: 110px; }
.orders-title { margin: 24px 0 12px; font-size: 15px; }
.invoice-orders { display: flex; flex-wrap: wrap; gap: 10px; }
.invoice-preview { display: block; width: 100%; height: 60vh; border: 0; }
</style>
+8 -10
View File
@@ -1,10 +1,10 @@
<template> <template>
<section class="page-card"> <section class="page-card">
<div class="page-title"><div><h2>订单列表</h2><p>查询订单并对待发货订单确认发货。</p></div><el-button type="primary" :loading="batchShipping" :disabled="loading || submitting || shipDialogVisible || Boolean(repushingOrderId) || !pendingShipOrders.length" @click="quickShipAll">一键发货</el-button></div> <div class="page-title"><div><h2>订单列表</h2><p>查询订单并对待发货订单确认发货。</p></div><el-button type="primary" :loading="batchShipping" :disabled="loading || submitting || shipDialogVisible || Boolean(repushingOrderId) || !pendingShipOrders.length" @click="quickShipAll">一键发货</el-button></div>
<el-form :inline="true" class="query-form"><el-form-item label="供应商"><el-select v-model="query.supplierId" placeholder="请选择供应商" filterable clearable><el-option v-for="supplier in suppliers" :key="supplier.supplierId" :label="`${supplier.supplierName}(${supplier.supplierId})`" :value="supplier.supplierId" /></el-select></el-form-item><el-form-item label="订单号"><el-input v-model="query.orderId" placeholder="订单编号或子订单号" clearable /></el-form-item><el-form-item label="工号"><el-input v-model="query.userId" placeholder="请输入工号" clearable /></el-form-item><el-form-item label="订单状态"><el-select v-model="query.orderState" placeholder="全部" clearable style="width: 160px"><el-option v-for="status in orderStates" :key="status.value" :label="status.label" :value="status.value" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="searchOrders">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form> <el-form :inline="true" class="query-form"><el-form-item label="订单号"><el-input v-model="query.orderId" placeholder="订单编号" clearable /></el-form-item><el-form-item label="用户昵称"><el-input v-model="query.userNickname" placeholder="请输入用户昵称" clearable /></el-form-item><el-form-item label="订单状态"><el-select v-model="query.orderState" placeholder="全部" clearable style="width: 160px"><el-option v-for="status in orderStates" :key="status.value" :label="status.label" :value="status.value" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="searchOrders">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
<el-table v-loading="loading" :data="orders" border><el-table-column prop="orderNumberId" label="订单编号" min-width="200" show-overflow-tooltip /><el-table-column label="用户昵称" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.userNickname || '-' }}</template></el-table-column><el-table-column label="商品信息" min-width="300"><template #default="{ row }"> <el-table v-loading="loading" :data="orders" border><el-table-column prop="orderNumberId" label="订单编号" min-width="200" show-overflow-tooltip /><el-table-column label="用户昵称" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.userNickname || '-' }}</template></el-table-column><el-table-column label="商品信息" min-width="300"><template #default="{ row }">
<div v-for="(product, index) in row.products || []" :key="product.id || index" class="order-product"> <div v-for="(product, index) in row.products || []" :key="product.id || index" class="order-product">
<el-image class="order-product__image" :src="product.image" fit="cover" :preview-src-list="product.image ? [product.image] : []" preview-teleported> <el-image class="order-product__image" :src="resolveImageUrl(product.image)" fit="cover" :preview-src-list="product.image ? [resolveImageUrl(product.image)] : []" preview-teleported>
<template #error><span class="order-product__placeholder">暂无图片</span></template> <template #error><span class="order-product__placeholder">暂无图片</span></template>
</el-image> </el-image>
<div class="order-product__info"> <div class="order-product__info">
@@ -13,7 +13,7 @@
</div> </div>
</div> </div>
<span v-if="!row.products?.length">-</span> <span v-if="!row.products?.length">-</span>
</template></el-table-column><el-table-column label="订单金额" width="120" align="right"><template #default="{ row }"><span class="order-amount">{{ formatPrice(row.amount) }}</span></template></el-table-column><el-table-column label="订单状态" width="110"><template #default="{ row }"><el-tag :type="[4, 5].includes(row.orderState) ? 'success' : [0, 1, 6].includes(row.orderState) ? 'warning' : row.orderState === 2 ? 'primary' : 'info'">{{ stateText(row.orderState) }}</el-tag></template></el-table-column><el-table-column prop="createTime" label="下单时间" min-width="170" /><el-table-column label="操作" width="240" fixed="right"><template #default="{ row }"><el-button v-if="row.orderState === 1" link type="primary" :disabled="batchShipping" @click="openShipDialog(row)">确认发货</el-button><span v-else>-</span><el-button v-if="row.orderState === 1" link type="warning" :loading="repushingOrderId === row.orderId" :disabled="batchShipping || Boolean(repushingOrderId)" @click="repushSplit(row)">重新推送拆单</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无订单" /></template></el-table> </template></el-table-column><el-table-column label="订单金额" width="120" align="right"><template #default="{ row }"><span class="order-amount">{{ formatPrice(row.amount) }}</span></template></el-table-column><el-table-column prop="createTime" label="下单时间" min-width="170" /><el-table-column label="订单状态" width="110"><template #default="{ row }"><el-tag :type="[4, 5].includes(row.orderState) ? 'success' : [0, 1, 6].includes(row.orderState) ? 'warning' : row.orderState === 2 ? 'primary' : 'info'">{{ stateText(row.orderState) }}</el-tag></template></el-table-column><el-table-column label="操作" width="240" fixed="right"><template #default="{ row }"><el-button v-if="row.orderState === 1" link type="primary" :disabled="batchShipping" @click="openShipDialog(row)">确认发货</el-button><span v-else>-</span><el-button v-if="row.orderState === 1" link type="warning" :loading="repushingOrderId === row.orderId" :disabled="batchShipping || Boolean(repushingOrderId)" @click="repushSplit(row)">重新推送拆单</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无订单" /></template></el-table>
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="loadOrders" /> <el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="loadOrders" />
<el-dialog v-model="shipDialogVisible" title="确认发货" width="460px"><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.crrgBsnNm" placeholder="请输入快递公司" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsOrderId" placeholder="请输入快递单号" /></el-form-item></el-form><template #footer><el-button @click="shipDialogVisible = false">取消</el-button><el-button type="primary" :loading="submitting" @click="submitShip">确认发货</el-button></template></el-dialog> <el-dialog v-model="shipDialogVisible" title="确认发货" width="460px"><el-form label-width="90px"><el-form-item label="快递公司"><el-input v-model="shipForm.crrgBsnNm" placeholder="请输入快递公司" /></el-form-item><el-form-item label="快递单号"><el-input v-model="shipForm.logisticsOrderId" placeholder="请输入快递单号" /></el-form-item></el-form><template #footer><el-button @click="shipDialogVisible = false">取消</el-button><el-button type="primary" :loading="submitting" @click="submitShip">确认发货</el-button></template></el-dialog>
@@ -23,20 +23,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { postAdmin } from '@/api/admin' import { postAdmin, resolveImageUrl } from '@/api/admin'
type OrderProduct = { id?: number; name?: string; image?: string; price?: string | number; number?: number } type OrderProduct = { id?: number; name?: string; image?: string; price?: string | number; number?: number }
type OrderItem = { orderId: string; supplierId: string; orderNumberId?: string; userId?: string; userNickname?: string; orderState: number; createTime?: string; crrgBsnNm?: string; logisticsOrderId?: string; amount?: string | number; products?: OrderProduct[] } type OrderItem = { orderId: string; supplierId: string; orderNumberId?: string; userId?: string; userNickname?: string; orderState: number; createTime?: string; crrgBsnNm?: string; logisticsOrderId?: string; amount?: string | number; products?: OrderProduct[] }
type SupplierItem = { supplierId: string; supplierName: string }
const orderStates = [{ label: '待付款', value: 0 }, { label: '待发货', value: 1 }, { label: '已发货', value: 2 }, { label: '已取消', value: 3 }, { label: '已签收', value: 4 }, { label: '已完成', value: 5 }, { label: '换货', value: 6 }, { label: '已退款', value: 7 }] const orderStates = [{ label: '待付款', value: 0 }, { label: '待发货', value: 1 }, { label: '已发货', value: 2 }, { label: '已取消', value: 3 }, { label: '已签收', value: 4 }, { label: '已完成', value: 5 }, { label: '换货', value: 6 }, { label: '已退款', value: 7 }]
const query = reactive<{ supplierId: string; orderId: string; userId: string; orderState?: number; pageNumber: number; pageSize: number }>({ supplierId: '', orderId: '', userId: '', pageNumber: 1, pageSize: 10 }) const query = reactive<{ orderId: string; userNickname: string; orderState?: number; pageNumber: number; pageSize: number }>({ orderId: '', userNickname: '', pageNumber: 1, pageSize: 10 })
const suppliers = ref<SupplierItem[]>([]); const orders = ref<OrderItem[]>([]); const loading = ref(false); const queried = ref(false); const submitting = ref(false); const shipDialogVisible = ref(false); const currentOrder = ref<OrderItem>(); const shipForm = ref({ crrgBsnNm: '', logisticsOrderId: '' }) const orders = ref<OrderItem[]>([]); const loading = ref(false); const queried = ref(false); const submitting = ref(false); const shipDialogVisible = ref(false); const currentOrder = ref<OrderItem>(); const shipForm = ref({ crrgBsnNm: '', logisticsOrderId: '' })
const totalCount = ref(0) const totalCount = ref(0)
const stateText = (state: number) => orderStates.find(item => item.value === state)?.label || '未知' const stateText = (state: number) => orderStates.find(item => item.value === state)?.label || '未知'
const loadSuppliers = async () => { try { const data = await postAdmin<{ suppliers?: SupplierItem[] }>('merchant-supplier-list', {}); suppliers.value = data.suppliers || [] } catch (error) { ElMessage.error(error instanceof Error ? error.message : '供应商加载失败') } }
const loadOrders = async () => { loading.value = true; try { const data = await postAdmin<{ orderItems?: OrderItem[]; totalCount?: number }>('merchant-order-list', query); orders.value = data.orderItems || []; totalCount.value = data.totalCount || 0; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '订单查询失败') } finally { loading.value = false } } const loadOrders = async () => { loading.value = true; try { const data = await postAdmin<{ orderItems?: OrderItem[]; totalCount?: number }>('merchant-order-list', query); orders.value = data.orderItems || []; totalCount.value = data.totalCount || 0; queried.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '订单查询失败') } finally { loading.value = false } }
const searchOrders = () => { query.pageNumber = 1; return loadOrders() } const searchOrders = () => { query.pageNumber = 1; return loadOrders() }
const reset = () => { query.supplierId = ''; query.orderId = ''; query.userId = ''; query.orderState = undefined; searchOrders() } const reset = () => { query.orderId = ''; query.userNickname = ''; query.orderState = undefined; searchOrders() }
const formatPrice = (value?: string | number) => value !== undefined && value !== null && value !== '' && Number.isFinite(Number(value)) ? `¥${Number(value).toFixed(2)}` : '-' const formatPrice = (value?: string | number) => value !== undefined && value !== null && value !== '' && Number.isFinite(Number(value)) ? `¥${Number(value).toFixed(2)}` : '-'
const openShipDialog = (order: OrderItem) => { currentOrder.value = order; shipForm.value = { crrgBsnNm: '韵达快递', logisticsOrderId: `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}` }; shipDialogVisible.value = true } const openShipDialog = (order: OrderItem) => { currentOrder.value = order; shipForm.value = { crrgBsnNm: '韵达快递', logisticsOrderId: `YT${Date.now()}${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}` }; shipDialogVisible.value = true }
const submitShip = async () => { if (!currentOrder.value || !shipForm.value.crrgBsnNm || !shipForm.value.logisticsOrderId) return ElMessage.warning('请填写完整发货信息'); submitting.value = true; try { await postAdmin('merchant-order-ship', { supplierId: currentOrder.value.supplierId, orderId: currentOrder.value.orderId, userId: currentOrder.value.userId, ...shipForm.value }); ElMessage.success('发货成功'); shipDialogVisible.value = false; await loadOrders() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } finally { submitting.value = false } } const submitShip = async () => { if (!currentOrder.value || !shipForm.value.crrgBsnNm || !shipForm.value.logisticsOrderId) return ElMessage.warning('请填写完整发货信息'); submitting.value = true; try { await postAdmin('merchant-order-ship', { supplierId: currentOrder.value.supplierId, orderId: currentOrder.value.orderId, userId: currentOrder.value.userId, ...shipForm.value }); ElMessage.success('发货成功'); shipDialogVisible.value = false; await loadOrders() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '发货失败') } finally { submitting.value = false } }
@@ -84,7 +82,7 @@ const repushSplit = async (order: OrderItem) => {
repushingOrderId.value = '' repushingOrderId.value = ''
} }
} }
onMounted(() => { loadSuppliers(); loadOrders() }) onMounted(loadOrders)
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
+48 -11
View File
@@ -1,32 +1,69 @@
<template> <template>
<section class="page-card"> <section class="page-card">
<div class="page-title"><div><h2>商品管理</h2><p>管理供应商商品的展示、库存和上下架状态。</p></div><el-button type="primary" @click="openCreate">新增商品</el-button></div> <div class="page-title"><div><h2>商品管理</h2><p>管理供应商商品的展示、库存和上下架状态。</p></div><el-button type="primary" @click="openCreate">新增商品</el-button></div>
<el-form inline class="query-form" @submit.prevent><el-form-item label="商品"><el-input v-model="query.keyword" placeholder="商品名称或 SKU" clearable /></el-form-item><el-form-item label="分类"><el-select v-model="query.categoryId" placeholder="全部" clearable><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form> <el-form inline class="query-form" @submit.prevent><el-form-item label="商品"><el-input v-model="query.keyword" placeholder="商品名称或 SKU" clearable /></el-form-item><el-form-item label="分类"><el-select v-model="query.categoryId" placeholder="全部" clearable><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item><el-button type="primary" :loading="loading" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item></el-form>
<el-table :data="products" v-loading="loading" border><el-table-column prop="sku" label="SKU" min-width="140" /><el-table-column prop="name" label="商品名称" min-width="180" /><el-table-column prop="category" label="分类" width="120" /><el-table-column prop="price" label="售价" width="100" /><el-table-column prop="stock" label="库存" width="90" /><el-table-column prop="sortOrder" label="排序" width="80" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '上架' : '下架' }}</el-tag></template></el-table-column><el-table-column label="操作" width="130" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无商品" /></template></el-table> <el-table :data="products" v-loading="loading" border><el-table-column prop="sku" label="SKU" min-width="140" /><el-table-column prop="name" label="商品名称" min-width="180" /><el-table-column prop="category" label="分类" width="120" /><el-table-column prop="price" label="售价" width="100" /><el-table-column prop="stock" label="库存" width="90" /><el-table-column prop="sortOrder" label="排序" width="80" /><el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '上架' : '下架' }}</el-tag></template></el-table-column><el-table-column label="操作" width="130" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link type="danger" @click="remove(row)">删除</el-button></template></el-table-column><template #empty><el-empty :image-size="80" description="暂无商品" /></template></el-table>
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
<el-dialog v-model="visible" :title="form.id ? '编辑商品' : '新增商品'" width="680px" destroy-on-close><el-form :model="form" label-width="90px"><el-row :gutter="16"><el-col :span="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" /></el-form-item></el-col><el-col :span="12"><el-form-item label="商品名称" required><el-input v-model="form.name" /></el-form-item></el-col><el-col :span="12"><el-form-item label="售价" required><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item></el-col><el-col :span="12"><el-form-item label="原价"><el-input-number v-model="form.originPrice" :min="0" :precision="2" /></el-form-item></el-col><el-col :span="12"><el-form-item label="库存"><el-input-number v-model="form.stock" :min="0" :precision="0" /></el-form-item></el-col><el-col :span="12"><el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" :precision="0" /></el-form-item></el-col><el-col :span="12"><el-form-item label="分类"><el-select v-model="form.categoryId" @change="setCategory"><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></el-col><el-col :span="12"><el-form-item label="状态"><el-switch v-model="form.enabled" active-text="上架" inactive-text="下架" /></el-form-item></el-col></el-row><el-form-item label="图片地址"><el-input v-model="form.imageUrl" /></el-form-item><el-form-item label="商品描述"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item></el-form><template #footer><el-button @click="visible = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template></el-dialog> <el-dialog v-model="visible" :title="form.id ? '编辑商品' : '新增商品'" width="760px" destroy-on-close><el-form class="product-form" :model="form" label-width="90px"><el-row :gutter="24"><el-col :xs="24" :sm="12"><el-form-item label="商品SKU" required><el-input v-model="form.sku" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="商品名称" required><el-input v-model="form.name" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="售价" required><el-input-number v-model="form.price" :min="0" :precision="2" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="原价"><el-input-number v-model="form.originPrice" :min="0" :precision="2" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="库存"><el-input-number v-model="form.stock" :min="0" :precision="0" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" :precision="0" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="分类"><el-select v-model="form.categoryId" @change="setCategory"><el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="状态"><el-switch v-model="form.enabled" active-text="上架" inactive-text="下架" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="商品标签"><el-input v-model="form.tag" placeholder="如:赠品、积分兑换" clearable /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="已售数量"><el-input v-model="form.sales" disabled placeholder="0" clearable /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="商品数量"><el-input-number v-model="form.number" :min="0" :precision="0" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="税率"><el-input-number v-model="form.taxRate" :min="0" :max="1" :step="0.01" :precision="4" /><span class="product-field-hint">按小数填写,例如 0.13 表示 13%</span></el-form-item></el-col></el-row><el-form-item label="商品图片"><el-upload class="product-image-upload" list-type="picture-card" accept="image/*" :auto-upload="false" :show-file-list="false" :disabled="uploading" :on-change="uploadProductImage" v-loading="uploading" :aria-label="form.imageUrl ? '更换商品图片' : '上传商品图片'"><template v-if="form.imageUrl"><img :src="resolveImageUrl(form.imageUrl)" alt="商品图片" class="product-image-preview" /><span class="product-image-replace">点击更换</span></template><div v-else class="product-image-placeholder"><span class="product-image-plus" aria-hidden="true">+</span><span>上传图片</span></div></el-upload></el-form-item><el-form-item label="商品描述"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item><el-form-item label="商品详情"><el-input v-model="form.detail" type="textarea" :rows="4" /></el-form-item></el-form><template #footer><el-button @click="visible = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="uploading" @click="save">保存</el-button></template></el-dialog>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { postAdmin } from '@/api/admin' import { postAdmin, resolveImageUrl } from '@/api/admin'
import type { UploadFile } from 'element-plus'
type Category = { id: string; name: string } type Category = { id: string; name: string }
type Product = { id: number; sku: string; name: string; categoryId: string; category: string; price: string; originPrice: string; stock: number; sortOrder: number; enabled: boolean; imageUrl: string; description: string } type Product = { id: number; sku: string; name: string; categoryId: string; category: string; price: string; originPrice: string; stock: number; sortOrder: number; enabled: boolean; imageUrl: string; description: string; tag: string; sales: string; detail: string; number: number; taxRate: number }
type ProductForm = { id?: number; sku: string; name: string; price: number; originPrice?: number; stock: number; sortOrder: number; categoryId: string; category: string; imageUrl: string; description: string; enabled: boolean } type ProductForm = { id?: number; sku: string; name: string; price: number; originPrice?: number; stock: number; sortOrder: number; categoryId: string; category: string; imageUrl: string; description: string; enabled: boolean; tag: string; sales: string; detail: string; number: number; taxRate: number }
const query = reactive({ keyword: '', categoryId: '' }) const query = reactive({ keyword: '', categoryId: '', pageNumber: 1, pageSize: 10 })
const totalCount = ref(0)
const uploading = ref(false)
const uploadProductImage = async (file: UploadFile) => {
if (!file.raw || uploading.value) return
if (!file.raw.type.startsWith('image/')) return ElMessage.warning('请选择图片文件')
uploading.value = true
try {
const contentBase64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(new Error('图片读取失败'))
reader.readAsDataURL(file.raw!)
})
const data = await postAdmin<{ fileUrl: string }>('upload', { fileName: file.name, contentType: file.raw.type, contentBase64, folder: 'supplied/product' })
form.imageUrl = data.fileUrl
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '图片上传失败') } finally { uploading.value = false }
}
const categories = ref<Category[]>([]); const products = ref<Product[]>([]); const loading = ref(false); const loaded = ref(false); const visible = ref(false); const saving = ref(false) const categories = ref<Category[]>([]); const products = ref<Product[]>([]); const loading = ref(false); const loaded = ref(false); const visible = ref(false); const saving = ref(false)
const emptyForm = (): ProductForm => ({ sku: '', name: '', price: 0, stock: 0, sortOrder: 0, categoryId: '', category: '', imageUrl: '', description: '', enabled: true }) const emptyForm = (): ProductForm => ({ sku: '', name: '', price: 0, stock: 0, sortOrder: 0, categoryId: '', category: '', imageUrl: '', description: '', enabled: true, tag: '', sales: '0', detail: '', number: 0, taxRate: 0.13 })
const form = reactive<ProductForm>(emptyForm()) const form = reactive<ProductForm>(emptyForm())
const setForm = (value: ProductForm) => Object.assign(form, value) const setForm = (value: ProductForm) => Object.assign(form, value)
const load = async () => { loading.value = true; try { const data = await postAdmin<{ categories: Category[]; products: Product[] }>('merchant-product-list', query); categories.value = data.categories || []; products.value = data.products || []; loaded.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '商品查询失败') } finally { loading.value = false } } const load = async () => { loading.value = true; try { const data = await postAdmin<{ categories: Category[]; products: Product[]; totalCount: number }>('merchant-product-list', query); categories.value = data.categories || []; products.value = data.products || []; totalCount.value = data.totalCount || 0; loaded.value = true } catch (error) { ElMessage.error(error instanceof Error ? error.message : '商品查询失败') } finally { loading.value = false } }
const reset = () => { query.keyword = ''; query.categoryId = ''; load() } const search = () => { query.pageNumber = 1; return load() }
const reset = () => { query.keyword = ''; query.categoryId = ''; return search() }
const openCreate = () => { setForm(emptyForm()); visible.value = true } const openCreate = () => { setForm(emptyForm()); visible.value = true }
const openEdit = (product: Product) => { setForm({ id: product.id, sku: product.sku, name: product.name, price: Number(product.price), originPrice: product.originPrice ? Number(product.originPrice) : undefined, stock: product.stock, sortOrder: product.sortOrder, categoryId: product.categoryId, category: product.category, imageUrl: product.imageUrl, description: product.description, enabled: product.enabled }); visible.value = true } const openEdit = (product: Product) => { setForm({ id: product.id, sku: product.sku, name: product.name, price: Number(product.price), originPrice: product.originPrice ? Number(product.originPrice) : undefined, stock: product.stock, sortOrder: product.sortOrder, categoryId: product.categoryId, category: product.category, imageUrl: product.imageUrl, description: product.description || '', enabled: product.enabled, tag: product.tag || '', sales: product.sales?.trim() || '0', detail: product.detail || '', number: product.number ?? 0, taxRate: product.taxRate ?? 0.13 }); visible.value = true }
const setCategory = (categoryId: string) => { form.category = categories.value.find(item => item.id === categoryId)?.name || '' } const setCategory = (categoryId: string) => { form.category = categories.value.find(item => item.id === categoryId)?.name || '' }
const save = async () => { saving.value = true; try { await postAdmin('merchant-product-save', form); ElMessage.success('保存成功'); visible.value = false; await load() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败') } finally { saving.value = false } } const save = async () => { saving.value = true; try { await postAdmin('merchant-product-save', form); ElMessage.success('保存成功'); visible.value = false; await load() } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败') } finally { saving.value = false } }
const remove = async (product: Product) => { try { await ElMessageBox.confirm(`确认删除商品“${product.name}”?`, '删除商品', { type: 'warning' }); await postAdmin('merchant-product-delete', { id: product.id }); ElMessage.success('删除成功'); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败') } } const remove = async (product: Product) => { try { await ElMessageBox.confirm(`确认删除商品“${product.name}”?`, '删除商品', { type: 'warning' }); await postAdmin('merchant-product-delete', { id: product.id }); ElMessage.success('删除成功'); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.error(error instanceof Error ? error.message : '删除失败') } }
onMounted(load) onMounted(load)
</script> </script>
<style scoped>
.product-field-hint { margin-top: 6px; color: #8793a5; font-size: 12px; line-height: 18px; }
.product-image-upload :deep(.el-upload--picture-card) { position: relative; width: 128px; height: 128px; overflow: hidden; border-radius: 8px; }
.product-image-preview { display: block; width: 100%; height: 100%; object-fit: cover; }
.product-image-placeholder { display: flex; flex-direction: column; align-items: center; gap: 8px; color: #8793a5; font-size: 13px; }
.product-image-plus { font-size: 32px; line-height: 32px; }
.product-image-replace { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 12px; background: rgba(0, 0, 0, .4); color: #fff; font-size: 13px; line-height: 20px; opacity: 0; }
.product-image-upload :deep(.el-upload--picture-card:hover) .product-image-replace,
.product-image-upload :deep(.el-upload--picture-card:focus-within) .product-image-replace { opacity: 1; }
@media (hover: none) { .product-image-replace { top: auto; padding: 6px 12px; opacity: 1; } }
.product-form :deep(.el-row .el-form-item) { margin-bottom: 24px; }
.product-form :deep(.el-form-item__content) { min-width: 0; }
.product-form :deep(.el-input),
.product-form :deep(.el-select),
.product-form :deep(.el-input-number) { width: 100%; }
.product-form :deep(.el-form-item__label) { line-height: 36px; }
</style>
@@ -3,23 +3,20 @@
<section class="page-card"> <section class="page-card">
<div class="page-title"><div><h2>订单对账</h2><p>查看 ZHGH 回传的子订单对账结果,并处理异常订单。</p></div></div> <div class="page-title"><div><h2>订单对账</h2><p>查看 ZHGH 回传的子订单对账结果,并处理异常订单。</p></div></div>
<el-form inline class="query-form" @submit.prevent> <el-form inline class="query-form" @submit.prevent>
<el-form-item label="供应商"><el-input v-model="query.supplierId" placeholder="供应商 ID" clearable /></el-form-item>
<el-form-item label="子订单"><el-input v-model="query.orderId" placeholder="子订单 ID" clearable /></el-form-item> <el-form-item label="子订单"><el-input v-model="query.orderId" placeholder="子订单 ID" clearable /></el-form-item>
<el-form-item label="对账状态"><el-select v-model="query.reconciliationStatus" clearable placeholder="全部"><el-option label="未对账" :value="0" /><el-option label="对账成功" :value="1" /><el-option label="对账异常" :value="2" /></el-select></el-form-item> <el-form-item label="对账状态"><el-select v-model="query.reconciliationStatus" clearable placeholder="全部"><el-option label="未对账" :value="0" /><el-option label="对账成功" :value="1" /><el-option label="对账异常" :value="2" /></el-select></el-form-item>
<el-form-item><el-button type="primary" :loading="loading" @click="load">查询</el-button><el-button @click="reset">重置</el-button></el-form-item> <el-form-item><el-button type="primary" :loading="loading" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form> </el-form>
<el-table :data="items" v-loading="loading" border> <el-table :data="items" v-loading="loading" border>
<el-table-column prop="orderId" label="子订单ID" min-width="180" /> <el-table-column prop="orderId" label="子订单ID" min-width="180" />
<el-table-column prop="mainOrderId" label="主订单ID" min-width="180" />
<el-table-column prop="reconciliationStatus" label="对账状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.reconciliationStatus)">{{ statusText(row.reconciliationStatus) }}</el-tag></template></el-table-column> <el-table-column prop="reconciliationStatus" label="对账状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.reconciliationStatus)">{{ statusText(row.reconciliationStatus) }}</el-tag></template></el-table-column>
<el-table-column prop="diffReason" label="差异说明" min-width="220" show-overflow-tooltip /> <el-table-column prop="diffReason" label="差异说明" min-width="220" show-overflow-tooltip />
<el-table-column label="平台金额" min-width="120"><template #default="{ row }">{{ row.platformTotalPrice || '-' }}</template></el-table-column> <el-table-column label="平台金额" min-width="120"><template #default="{ row }">{{ row.platformTotalPrice || '-' }}</template></el-table-column>
<el-table-column label="供应商金额" min-width="120"><template #default="{ row }">{{ row.supplierTotalPrice || '-' }}</template></el-table-column> <el-table-column label="供应商金额" min-width="120"><template #default="{ row }">{{ row.supplierTotalPrice || '-' }}</template></el-table-column>
<el-table-column prop="reconciliationBatchNo" label="对账批次" min-width="150" show-overflow-tooltip /> <el-table-column prop="reconciliationBatchNo" label="对账批次" min-width="150" show-overflow-tooltip />
<el-table-column prop="reconciliationTime" label="对账时间" min-width="170" /> <el-table-column prop="reconciliationTime" label="对账时间" min-width="170" />
<el-table-column label="操作" width="90" fixed="right"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">处理</el-button></template></el-table-column>
<template #empty><el-empty :image-size="80" description="暂无对账结果" /></template></el-table> <template #empty><el-empty :image-size="80" description="暂无对账结果" /></template></el-table>
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
</section> </section>
<el-dialog v-model="dialogVisible" title="处理对账结果" width="460px"> <el-dialog v-model="dialogVisible" title="处理对账结果" width="460px">
<el-form label-width="90px"><el-form-item label="子订单"><span>{{ editing?.orderId }}</span></el-form-item><el-form-item label="对账状态"><el-select v-model="form.reconciliationStatus"><el-option label="未对账" :value="0" /><el-option label="对账成功" :value="1" /><el-option label="对账异常" :value="2" /></el-select></el-form-item><el-form-item label="差异类型"><el-input-number v-model="form.diffType" :min="0" :precision="0" /></el-form-item><el-form-item label="差异说明"><el-input v-model="form.diffReason" type="textarea" :rows="3" /></el-form-item></el-form> <el-form label-width="90px"><el-form-item label="子订单"><span>{{ editing?.orderId }}</span></el-form-item><el-form-item label="对账状态"><el-select v-model="form.reconciliationStatus"><el-option label="未对账" :value="0" /><el-option label="对账成功" :value="1" /><el-option label="对账异常" :value="2" /></el-select></el-form-item><el-form-item label="差异类型"><el-input-number v-model="form.diffType" :min="0" :precision="0" /></el-form-item><el-form-item label="差异说明"><el-input v-model="form.diffReason" type="textarea" :rows="3" /></el-form-item></el-form>
@@ -29,12 +26,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { postAdmin } from '@/api/admin' import { postAdmin } from '@/api/admin'
type ReconciliationItem = { supplierId: string; orderId: string; mainOrderId: string; reconciliationStatus: number; diffType?: number; diffReason: string; platformTotalPrice: string; supplierTotalPrice: string; reconciliationBatchNo: string; reconciliationTime: string } type ReconciliationItem = { supplierId: string; orderId: string; mainOrderId: string; reconciliationStatus: number; diffType?: number; diffReason: string; platformTotalPrice: string; supplierTotalPrice: string; reconciliationBatchNo: string; reconciliationTime: string }
const query = reactive<{ supplierId: string; orderId: string; reconciliationStatus?: number }>({ supplierId: '', orderId: '' }) const query = reactive<{ orderId: string; reconciliationStatus?: number; pageNumber: number; pageSize: number }>({ orderId: '', pageNumber: 1, pageSize: 10 })
const totalCount = ref(0)
const items = ref<ReconciliationItem[]>([]) const items = ref<ReconciliationItem[]>([])
const loading = ref(false) const loading = ref(false)
const loaded = ref(false) const loaded = ref(false)
@@ -46,15 +44,16 @@ const statusText = (status: number) => ['未对账', '对账成功', '对账异
const statusType = (status: number) => status === 1 ? 'success' : status === 2 ? 'danger' : 'info' const statusType = (status: number) => status === 1 ? 'success' : status === 2 ? 'danger' : 'info'
const load = async () => { const load = async () => {
if (!query.supplierId.trim()) return ElMessage.warning('请输入供应商 ID')
loading.value = true loading.value = true
try { try {
const data = await postAdmin<{ reconciliationItems: ReconciliationItem[] }>('merchant-reconciliation-list', query) const data = await postAdmin<{ reconciliationItems: ReconciliationItem[]; totalCount: number }>('merchant-reconciliation-list', query)
items.value = data.reconciliationItems || [] items.value = data.reconciliationItems || []
totalCount.value = data.totalCount || 0
loaded.value = true loaded.value = true
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '查询失败') } finally { loading.value = false } } catch (error) { ElMessage.error(error instanceof Error ? error.message : '查询失败') } finally { loading.value = false }
} }
const reset = () => { query.supplierId = ''; query.orderId = ''; query.reconciliationStatus = undefined; items.value = []; loaded.value = false } const search = () => { query.pageNumber = 1; return load() }
const reset = () => { query.orderId = ''; query.reconciliationStatus = undefined; return search() }
const openEdit = (row: ReconciliationItem) => { editing.value = row; form.reconciliationStatus = row.reconciliationStatus; form.diffType = row.diffType; form.diffReason = row.diffReason || ''; dialogVisible.value = true } const openEdit = (row: ReconciliationItem) => { editing.value = row; form.reconciliationStatus = row.reconciliationStatus; form.diffType = row.diffType; form.diffReason = row.diffReason || ''; dialogVisible.value = true }
const save = async () => { const save = async () => {
if (!editing.value) return if (!editing.value) return
@@ -64,4 +63,5 @@ const save = async () => {
ElMessage.success('保存成功'); dialogVisible.value = false; await load() ElMessage.success('保存成功'); dialogVisible.value = false; await load()
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败') } finally { saving.value = false } } catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败') } finally { saving.value = false }
} }
onMounted(load)
</script> </script>
+15
View File
@@ -0,0 +1,15 @@
const STORAGE_KEY = 'supplied-h5-merchant-session'
export const clearMerchantSession = () => sessionStorage.removeItem(STORAGE_KEY)
export const saveMerchantSession = session => sessionStorage.setItem(STORAGE_KEY, JSON.stringify(session))
export const readMerchantSession = () => {
try {
const session = JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null')
if (session?.token && session?.user && Date.parse(session.expiresAt) > Date.now()) return session
} catch { /* 损坏的会话按未登录处理。 */ }
clearMerchantSession()
return null
}
export const merchantLoginLocation = () => {
const redirect = window.location.hash.slice(1) || '/joyful-selection/merchant'
return `#/joyful-selection/merchant/login?redirect=${encodeURIComponent(redirect)}`
}
+26 -4
View File
@@ -1,7 +1,20 @@
import { readMerchantSession, clearMerchantSession, merchantLoginLocation } from './merchant-session'
const API_BASE = process.env.VUE_APP_SUPPLIED_API_BASE || 'http://localhost:8888/api' const API_BASE = process.env.VUE_APP_SUPPLIED_API_BASE || 'http://localhost:8888/api'
const normalizeBase = base => base.replace(/\/$/, '') const normalizeBase = base => base.replace(/\/$/, '')
const resolveFileUrls = value => {
if (typeof value === 'string' && /^\/api\/(?:file|upload-preview)\?/.test(value)) {
return `${normalizeBase(API_BASE).replace(/\/api$/, '')}${value.replace('/api/upload-preview?', '/api/file?')}`
}
if (Array.isArray(value)) return value.map(resolveFileUrls)
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveFileUrls(item)]))
}
return value
}
export const getFunction = async (name, params = {}) => { export const getFunction = async (name, params = {}) => {
const url = new URL(`${normalizeBase(API_BASE)}/${name}`) const url = new URL(`${normalizeBase(API_BASE)}/${name}`)
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
@@ -18,25 +31,34 @@ export const getFunction = async (name, params = {}) => {
throw new Error(message) throw new Error(message)
} }
return payload.data return resolveFileUrls(payload.data)
} }
export const postFunction = async (name, body = {}) => { export const postFunction = async (name, body = {}) => {
const response = await fetch(`${normalizeBase(API_BASE)}/${name}`, { const merchantRequest = name.startsWith('merchant-') || ['login', 'admin-session', 'admin-logout'].includes(name)
const token = merchantRequest ? readMerchantSession()?.token : ''
const response = await fetch(`${normalizeBase(API_BASE)}${merchantRequest ? '/admin' : ''}/${name}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
}, },
body: JSON.stringify(body) body: JSON.stringify(body)
}) })
const payload = await response.json().catch(() => ({})) const payload = await response.json().catch(() => ({}))
if (merchantRequest && name !== 'login' && (response.status === 401 || payload.error?.code === 'UNAUTHORIZED')) {
clearMerchantSession()
if (name !== 'admin-session' && !window.location.hash.startsWith('#/joyful-selection/merchant/login')) {
window.location.replace(merchantLoginLocation())
}
}
if (!response.ok || payload.success === false) { if (!response.ok || payload.success === false) {
const message = payload.error?.message || payload.msg || '请求失败' const message = payload.error?.message || payload.msg || '请求失败'
throw new Error(message) throw new Error(message)
} }
return payload.data return resolveFileUrls(payload.data)
} }
export const uploadImageFunction = async (file, folder = 'after-sale') => { export const uploadImageFunction = async (file, folder = 'after-sale') => {
+23 -3
View File
@@ -1,4 +1,7 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import { Toast } from 'vant'
import { postFunction } from '@/api/supplied-service'
import { readMerchantSession, clearMerchantSession } from '@/api/merchant-session'
import SupplierHome from '@/view/index.vue' import SupplierHome from '@/view/index.vue'
import JoyfulSelectionLayout from '@/view/joyful-selection/layout.vue' import JoyfulSelectionLayout from '@/view/joyful-selection/layout.vue'
import JoyfulSelectionHome from '@/view/joyful-selection/index.vue' import JoyfulSelectionHome from '@/view/joyful-selection/index.vue'
@@ -28,10 +31,11 @@ import HappyShopping from '@/view/happy-shopping/index.vue'
import FreshFruit from '@/view/fresh-fruit/index.vue' import FreshFruit from '@/view/fresh-fruit/index.vue'
import CuteSelection from '@/view/cute-selection/index.vue' import CuteSelection from '@/view/cute-selection/index.vue'
export default createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
routes: [ routes: [
{ path: '/', component: SupplierHome }, { path: '/', component: SupplierHome },
{ path: '/joyful-selection/merchant/login', component: () => import('@/view/joyful-selection/merchant/login.vue') },
{ {
path: '/joyful-selection', path: '/joyful-selection',
component: JoyfulSelectionLayout, component: JoyfulSelectionLayout,
@@ -54,8 +58,8 @@ export default createRouter({
{ path: 'after-sale/select', component: JoyfulSelectionAfterSaleSelect }, { path: 'after-sale/select', component: JoyfulSelectionAfterSaleSelect },
{ path: 'after-sale/return', component: JoyfulSelectionAfterSaleReturn }, { path: 'after-sale/return', component: JoyfulSelectionAfterSaleReturn },
{ path: 'after-sale/exchange', component: JoyfulSelectionAfterSaleExchange }, { path: 'after-sale/exchange', component: JoyfulSelectionAfterSaleExchange },
{ path: 'merchant', component: JoyfulSelectionMerchant }, { path: 'merchant', component: JoyfulSelectionMerchant, meta: { merchantAuth: true } },
{ path: 'merchant/after-sale-detail', component: JoyfulSelectionMerchantAfterSaleDetail }, { path: 'merchant/after-sale-detail', component: JoyfulSelectionMerchantAfterSaleDetail, meta: { merchantAuth: true } },
{ path: 'supplier-logs', component: JoyfulSelectionSupplierLogs }, { path: 'supplier-logs', component: JoyfulSelectionSupplierLogs },
{ path: 'profile', component: JoyfulSelectionProfile }, { path: 'profile', component: JoyfulSelectionProfile },
{ path: 'points', component: JoyfulSelectionPoints } { path: 'points', component: JoyfulSelectionPoints }
@@ -67,3 +71,19 @@ export default createRouter({
{ path: '/cute-selection', component: CuteSelection } { path: '/cute-selection', component: CuteSelection }
] ]
}) })
router.beforeEach(async to => {
if (!to.meta.merchantAuth) return true
const login = { path: '/joyful-selection/merchant/login', query: { redirect: to.fullPath } }
if (!readMerchantSession()) return login
try {
await postFunction('admin-session')
return true
} catch (error) {
clearMerchantSession()
Toast(error.message || '登录已失效,请重新登录')
return login
}
})
export default router
@@ -50,6 +50,10 @@ export default {
...mapActions(useCartStore, ['loadCart']), ...mapActions(useCartStore, ['loadCart']),
...mapActions(useUserStore, ['hydrateFromRoute', 'fetchBridgeToken']), ...mapActions(useUserStore, ['hydrateFromRoute', 'fetchBridgeToken']),
async prepareUserSession() { async prepareUserSession() {
if (this.$route.meta.merchantAuth) {
this.sessionLoading = false
return
}
this.sessionLoading = true this.sessionLoading = true
try { try {
await this.hydrateFromRoute(this.$route.query) await this.hydrateFromRoute(this.$route.query)
@@ -336,7 +336,7 @@ export default {
this.loadDetail() this.loadDetail()
}, },
methods: { methods: {
...mapActions(useUserStore, ['fetchBridgeToken']), ...mapActions(useUserStore, ['fetchSupplierInfo']),
normalizeSteps(steps) { normalizeSteps(steps) {
const serviceState = Number(this.afterSale.serviceState) const serviceState = Number(this.afterSale.serviceState)
return steps.map((step, index) => ({ return steps.map((step, index) => ({
@@ -359,9 +359,9 @@ export default {
async loadDetail() { async loadDetail() {
try { try {
this.loading = true this.loading = true
await this.fetchBridgeToken() if (!this.supplierId && !this.$route.query.supplierId) await this.fetchSupplierInfo()
const data = await postFunction('merchant-after-sale-detail', { const data = await postFunction('merchant-after-sale-detail', {
supplierId: this.supplierId, supplierId: String(this.$route.query.supplierId || this.supplierId),
serviceOrderId: String(this.$route.query.serviceOrderId || '') serviceOrderId: String(this.$route.query.serviceOrderId || '')
}) })
@@ -407,7 +407,7 @@ export default {
message: auditStatus === 1 ? '通过后用户可寄回商品。' : '拒绝后该售后申请将结束。' message: auditStatus === 1 ? '通过后用户可寄回商品。' : '拒绝后该售后申请将结束。'
}) })
await postFunction('merchant-after-sale-audit', { await postFunction('merchant-after-sale-audit', {
supplierId: this.supplierId, supplierId: this.afterSale.supplierId || this.supplierId,
serviceOrderId: this.afterSale.serviceOrderId, serviceOrderId: this.afterSale.serviceOrderId,
auditStatus, auditStatus,
auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝' auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝'
@@ -421,7 +421,7 @@ export default {
message: '确认后退货售后会进入完成,换货售后会进入待商家寄货。' message: '确认后退货售后会进入完成,换货售后会进入待商家寄货。'
}) })
await postFunction('merchant-after-sale-receive', { await postFunction('merchant-after-sale-receive', {
supplierId: this.supplierId, supplierId: this.afterSale.supplierId || this.supplierId,
serviceOrderId: this.afterSale.serviceOrderId serviceOrderId: this.afterSale.serviceOrderId
}) })
Toast('已确认收货') Toast('已确认收货')
@@ -441,7 +441,7 @@ export default {
} }
await postFunction('merchant-after-sale-ship', { await postFunction('merchant-after-sale-ship', {
supplierId: this.supplierId, supplierId: this.afterSale.supplierId || this.supplierId,
serviceOrderId: this.afterSale.serviceOrderId, serviceOrderId: this.afterSale.serviceOrderId,
logisticsCompany: this.exchangeShipForm.logisticsCompany, logisticsCompany: this.exchangeShipForm.logisticsCompany,
logisticsNo: this.exchangeShipForm.logisticsNo logisticsNo: this.exchangeShipForm.logisticsNo
@@ -252,7 +252,7 @@ export default {
this.prepareSupplier().then(() => this.loadAllTabs()) this.prepareSupplier().then(() => this.loadAllTabs())
}, },
methods: { methods: {
...mapActions(useUserStore, ['fetchSupplierInfo', 'fetchBridgeToken']), ...mapActions(useUserStore, ['fetchSupplierInfo']),
...mapActions(useUiStore, ['loadUi', 'setMerchantTab']), ...mapActions(useUiStore, ['loadUi', 'setMerchantTab']),
skuText(item = {}) { skuText(item = {}) {
const sku = String(item.sku || item.skuText || '').trim() const sku = String(item.sku || item.skuText || '').trim()
@@ -273,7 +273,6 @@ export default {
return count > 0 ? count : '' return count > 0 ? count : ''
}, },
async prepareSupplier() { async prepareSupplier() {
await this.fetchBridgeToken()
if (!this.supplierId) { if (!this.supplierId) {
await this.fetchSupplierInfo() await this.fetchSupplierInfo()
} }
@@ -353,7 +352,8 @@ export default {
this.$router.push({ this.$router.push({
path: '/joyful-selection/merchant/after-sale-detail', path: '/joyful-selection/merchant/after-sale-detail',
query: { query: {
serviceOrderId: item.serviceOrderId serviceOrderId: item.serviceOrderId,
supplierId: item.supplierId || this.supplierId
} }
}) })
}, },
@@ -0,0 +1,56 @@
<template>
<main class="merchant-login-page">
<van-nav-bar title="商家登录" left-arrow @click-left="$router.replace('/joyful-selection/home')" />
<section class="merchant-login-card">
<div class="login-icon"><van-icon name="shop-o" size="36" /></div>
<h1>商家处理台</h1><p>使用供应商管理账号登录</p>
<van-form @submit="submit">
<van-cell-group inset>
<van-field v-model="username" name="username" label="账号" placeholder="请输入账号" autocomplete="username" :rules="[{ required: true, message: '请输入账号' }]" />
<van-field v-model="password" name="password" type="password" label="密码" placeholder="请输入密码" autocomplete="current-password" :rules="[{ required: true, message: '请输入密码' }]" />
</van-cell-group>
<van-button block round type="primary" native-type="submit" :loading="submitting" :disabled="submitting" color="#ff5b20" class="login-submit">登录</van-button>
</van-form>
</section>
</main>
</template>
<script>
import { Toast } from 'vant'
import { postFunction } from '@/api/supplied-service'
import { saveMerchantSession, clearMerchantSession } from '@/api/merchant-session'
export default {
name: 'MerchantLogin',
data: () => ({ username: '', password: '', submitting: false }),
methods: {
async submit() {
if (this.submitting) return
this.submitting = true
try {
const session = await postFunction('login', { username: this.username.trim(), password: this.password })
saveMerchantSession(session)
await postFunction('admin-session')
this.password = ''
const target = typeof this.$route.query.redirect === 'string' ? this.$route.query.redirect : ''
const resolved = this.$router.resolve(target || '/joyful-selection/merchant')
await this.$router.replace(resolved.meta.merchantAuth ? resolved.fullPath : '/joyful-selection/merchant')
} catch (error) {
clearMerchantSession()
Toast(error.message || '登录失败')
} finally { this.submitting = false }
}
}
}
</script>
<style scoped>
.merchant-login-page { min-height: 100vh; background: #f7f8fa; }
.merchant-login-card { max-width: 420px; margin: 48px auto; padding: 28px 20px; background: #fff; border-radius: 16px; }
.login-icon { display: flex; align-items: center; justify-content: center; width: 72px; height: 72px; margin: 0 auto 20px; border-radius: 20px; color: #ff5b20; background: #fff1e9; }
.merchant-login-card h1 { margin: 0; text-align: center; font-size: 23px; }
.merchant-login-card > p { margin: 12px 0 28px; text-align: center; color: #8793a5; font-size: 14px; }
.merchant-login-card :deep(.van-cell-group--inset) { margin: 0; }
.login-submit { margin-top: 28px; }
@media (max-width: 480px) { .merchant-login-card { margin: 32px 16px; } }
</style>
@@ -43,7 +43,7 @@
<div class="goods-bottom"> <div class="goods-bottom">
<div class="price-stack"> <div class="price-stack">
<div><b>¥{{ item.price }}</b></div> <div><b>¥{{ item.price }}</b></div>
<div><span>已售{{ item.sales }}</span></div> <div><span>已售{{ formatSales(item.sales) }}件</span></div>
</div> </div>
<button type="button" class="cart-button" @click.stop="openSku(item)"> <button type="button" class="cart-button" @click.stop="openSku(item)">
<van-icon name="cart-o" /> <van-icon name="cart-o" />
@@ -96,6 +96,11 @@ export default {
this.loadProducts() this.loadProducts()
}, },
methods: { methods: {
formatSales(value) {
const number = Number(value)
const count = Number.isFinite(number) ? Math.max(0, Math.floor(number)) : 0
return count >= 10000 ? `${Math.floor(count / 10000)}万` : String(count)
},
async loadProducts() { async loadProducts() {
this.loading = true this.loading = true
try { try {
@@ -15,7 +15,7 @@
src="https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg" src="https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg"
/> />
<div> <div>
<b>张三</b> <b>{{ nickname || '-' }}</b>
<p>欢迎来到悦享优选</p> <p>欢迎来到悦享优选</p>
</div> </div>
</div> </div>
@@ -103,7 +103,7 @@ export default {
} }
}), }),
computed: { computed: {
...mapState(useUserStore, ['pointsBalance', 'supplierId', 'userId']), ...mapState(useUserStore, ['pointsBalance', 'supplierId', 'userId', 'nickname']),
formattedPoints() { formattedPoints() {
return Number(this.pointsBalance || 0).toLocaleString('zh-CN', { return Number(this.pointsBalance || 0).toLocaleString('zh-CN', {
maximumFractionDigits: 2 maximumFractionDigits: 2
@@ -40,7 +40,19 @@ export const uploadController = withErrorBoundary(async (request: Request, _cont
return toHttpResponse(await minioUploadService.upload(await readJson<UploadInput>(request))) return toHttpResponse(await minioUploadService.upload(await readJson<UploadInput>(request)))
}) })
export const fileController = withErrorBoundary(async (request: Request, _context: Context) => {
const methodResponse = guardMethod(request, ['GET'])
if (methodResponse) return methodResponse
const result = await minioUploadService.imageUrl(new URL(request.url).searchParams.get('fid')?.trim() || '')
if (!result.success) return toHttpResponse(result)
return new Response(null, { status: 302, headers: { Location: result.data.url, 'Cache-Control': 'no-store' } })
})
export const uploadPreviewController = withErrorBoundary(async (request: Request, _context: Context) => { export const uploadPreviewController = withErrorBoundary(async (request: Request, _context: Context) => {
if (request.method === 'GET') {
const fid = new URL(request.url).searchParams.get('fid') || ''
return new Response(null, { status: 302, headers: { Location: `/api/file?fid=${encodeURIComponent(fid)}`, 'Cache-Control': 'no-store' } })
}
const methodResponse = guardMethod(request, ['POST']) const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse if (methodResponse) return methodResponse
@@ -0,0 +1,21 @@
import { withErrorBoundary } from '../utils/handler.js'
import { guardMethod } from '../utils/method.js'
import { getBearerToken, readJson } from '../utils/request.js'
import { adminAuthService } from '../services/admin-auth.service.js'
import { serviceFail } from '../types/service-result.js'
import { merchantInvoiceService, type MerchantInvoiceListInput, type MerchantInvoiceFileInput } from '../services/merchant-invoice.service.js'
import { toHttpResponse } from './controller-response.js'
export const merchantInvoiceListController = withErrorBoundary(async request => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
if (!await adminAuthService.findSession(getBearerToken(request))) return toHttpResponse(serviceFail('登录已失效,请重新登录', 401, 'UNAUTHORIZED'))
return toHttpResponse(await merchantInvoiceService.list(await readJson<MerchantInvoiceListInput>(request)))
})
export const merchantInvoiceFileController = withErrorBoundary(async request => {
const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse
if (!await adminAuthService.findSession(getBearerToken(request))) return toHttpResponse(serviceFail('登录已失效,请重新登录', 401, 'UNAUTHORIZED'))
return toHttpResponse(await merchantInvoiceService.file(await readJson<MerchantInvoiceFileInput>(request)))
})
@@ -31,8 +31,8 @@ export const merchantProductListController = withErrorBoundary(async (request: R
const methodResponse = guardMethod(request, ['POST']) const methodResponse = guardMethod(request, ['POST'])
if (methodResponse) return methodResponse if (methodResponse) return methodResponse
const body = await readJson<{ keyword?: string; categoryId?: string }>(request) const body = await readJson<{ keyword?: string; categoryId?: string; pageNumber?: number; pageSize?: number }>(request)
return toHttpResponse(await productService.listManageProducts(String(body.keyword || '').trim(), String(body.categoryId || '').trim())) return toHttpResponse(await productService.listManageProducts(String(body.keyword || '').trim(), String(body.categoryId || '').trim(), body.pageNumber, body.pageSize))
}) })
export const merchantProductSaveController = withErrorBoundary(async (request: Request, _context: Context) => { export const merchantProductSaveController = withErrorBoundary(async (request: Request, _context: Context) => {
+5 -3
View File
@@ -171,13 +171,14 @@ export const orderDao = {
}) })
}, },
async findMerchantSubOrders(supplierId: string, orderState?: number, orderId?: string, userId?: string, excludedOrderIds: string[] = [], pagination?: { pageNumber: number; pageSize: number }) { async findMerchantSubOrders(supplierId: string, orderState?: number, orderId?: string, userId?: string, excludedOrderIds: string[] = [], pagination?: { pageNumber: number; pageSize: number }, matchingUserIds?: string[]) {
const where: Prisma.SupplierOrderSubWhereInput = { const where: Prisma.SupplierOrderSubWhereInput = {
...(supplierId ? { supplierId } : {}), ...(supplierId ? { supplierId } : {}),
deleted: 0, deleted: 0,
...(Number.isInteger(orderState) ? { orderState } : {}), ...(Number.isInteger(orderState) ? { orderState } : {}),
...(orderId ? { OR: [{ orderId: { contains: orderId } }, { orderNumberId: { contains: orderId } }] } : {}), ...(orderId ? { OR: [{ orderId: { contains: orderId } }, { orderNumberId: { contains: orderId } }] } : {}),
...(userId ? { userId: { contains: userId } } : {}), ...(userId ? { userId: { contains: userId } } : {}),
...(matchingUserIds !== undefined ? { AND: [{ userId: { in: matchingUserIds } }] } : {}),
...(excludedOrderIds.length ? { orderId: { notIn: excludedOrderIds } } : {}) ...(excludedOrderIds.length ? { orderId: { notIn: excludedOrderIds } } : {})
} }
const [orders, totalCount] = await prisma.$transaction([ const [orders, totalCount] = await prisma.$transaction([
@@ -201,12 +202,13 @@ export const orderDao = {
return { orders, totalCount } return { orders, totalCount }
}, },
findMerchantAfterSales(supplierId: string, serviceStates?: number[]) { findMerchantAfterSales(supplierId: string, serviceStates?: number[], serviceOrderId = '') {
return prisma.supplierAfterSale.findMany({ return prisma.supplierAfterSale.findMany({
where: { where: {
...(supplierId ? { supplierId } : {}), ...(supplierId ? { supplierId } : {}),
deleted: 0, deleted: 0,
...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {}) ...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {}),
...(serviceOrderId ? { serviceOrderId: { contains: serviceOrderId } } : {})
}, },
orderBy: [ orderBy: [
{ createTime: 'desc' }, { createTime: 'desc' },
+16 -12
View File
@@ -35,18 +35,22 @@ export const productDao = {
}) })
}, },
findManageProducts(keyword: string, categoryId: string) { async findManageProducts(keyword: string, categoryId: string, pageNumber = 1, pageSize = 10) {
return prisma.supplierProduct.findMany({ const where = {
where: { deleted: 0,
deleted: 0, ...(keyword ? { OR: [{ name: { contains: keyword } }, { sku: { contains: keyword } }] } : {}),
...(keyword ? { OR: [{ name: { contains: keyword } }, { sku: { contains: keyword } }] } : {}), ...(categoryId ? { categoryId } : {})
...(categoryId ? { categoryId } : {}) }
}, const [products, totalCount] = await prisma.$transaction([
orderBy: [ prisma.supplierProduct.findMany({
{ sortOrder: 'asc' }, where,
{ createdAt: 'desc' } orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }, { id: 'desc' }],
] skip: (pageNumber - 1) * pageSize,
}) take: pageSize
}),
prisma.supplierProduct.count({ where })
])
return { products, totalCount }
}, },
createProduct(data: Parameters<typeof prisma.supplierProduct.create>[0]['data']) { createProduct(data: Parameters<typeof prisma.supplierProduct.create>[0]['data']) {
@@ -31,6 +31,11 @@ export const suppliedUserDao = {
if (!user) throw new Error('获取供应商用户信息失败') if (!user) throw new Error('获取供应商用户信息失败')
return user return user
}, },
async findVirtualIdsByNickname(nickname: string) {
return prisma.$queryRaw<Array<{ virtualId: string }>>`
SELECT virtualId FROM supplied_user
WHERE virtualId IS NOT NULL AND LOCATE(${nickname}, nickname) > 0`
},
async findByVirtualIds(virtualIds: string[]) { async findByVirtualIds(virtualIds: string[]) {
if (!virtualIds.length) return [] if (!virtualIds.length) return []
return prisma.$queryRaw<SuppliedCustomer[]>` return prisma.$queryRaw<SuppliedCustomer[]>`
+1
View File
@@ -0,0 +1 @@
export { fileController as default } from '../controllers/basic.controller.js'
@@ -0,0 +1 @@
export { merchantInvoiceFileController as default } from '../controllers/merchant-invoice.controller.js'
@@ -0,0 +1 @@
export { merchantInvoiceListController as default } from '../controllers/merchant-invoice.controller.js'
+19
View File
@@ -18,6 +18,7 @@ import {
loginController, loginController,
uploadController, uploadController,
uploadPreviewController, uploadPreviewController,
fileController,
userInfoController userInfoController
} from './controllers/basic.controller.js' } from './controllers/basic.controller.js'
import { import {
@@ -78,7 +79,11 @@ const app = new Koa()
const router = new Router() const router = new Router()
const port = Number(process.env.PORT || 8888) const port = Number(process.env.PORT || 8888)
import { merchantInvoiceListController, merchantInvoiceFileController } from './controllers/merchant-invoice.controller.js'
const handlers: Record<string, FunctionHandler> = { const handlers: Record<string, FunctionHandler> = {
'merchant-invoice-list': merchantInvoiceListController,
'merchant-invoice-file': merchantInvoiceFileController,
'address-default': addressDefaultController, 'address-default': addressDefaultController,
'address-delete': addressDeleteController, 'address-delete': addressDeleteController,
'address-list': addressListController, 'address-list': addressListController,
@@ -132,6 +137,7 @@ const handlers: Record<string, FunctionHandler> = {
'supplier-token': supplierTokenController, 'supplier-token': supplierTokenController,
upload: uploadController, upload: uploadController,
'upload-preview': uploadPreviewController, 'upload-preview': uploadPreviewController,
file: fileController,
'user-info': userInfoController 'user-info': userInfoController
} }
@@ -176,6 +182,11 @@ const writeResponse = async (ctx: Koa.Context, response: Response, wrapBa7 = fal
ctx.status = response.status ctx.status = response.status
Object.entries(corsHeaders).forEach(([key, value]) => ctx.set(key, value)) Object.entries(corsHeaders).forEach(([key, value]) => ctx.set(key, value))
response.headers.forEach((value, key) => ctx.set(key, value)) response.headers.forEach((value, key) => ctx.set(key, value))
if (response.status >= 300 && response.status < 400 && response.headers.has('Location')) {
ctx.body = ''
ctx.status = response.status
return
}
const responseText = response.body ? await response.text() : '' const responseText = response.body ? await response.text() : ''
ctx.body = wrapBa7 ? ba7ResponseBody(responseText) : responseText || undefined ctx.body = wrapBa7 ? ba7ResponseBody(responseText) : responseText || undefined
} }
@@ -199,6 +210,14 @@ const dispatch = (name: string) => async (ctx: Koa.Context) => {
ctx.body = { success: false, error: { code: 'NOT_FOUND', message: '接口不存在' } } ctx.body = { success: false, error: { code: 'NOT_FOUND', message: '接口不存在' } }
return return
} }
if (name.startsWith('merchant-')) {
const token = ctx.get('Authorization').match(/^Bearer\s+(.+)$/i)?.[1] || ''
if (!await adminAuthService.findSession(token)) {
ctx.status = 401
ctx.body = { success: false, error: { code: 'UNAUTHORIZED', message: '登录已失效,请重新登录' } }
return
}
}
const response = await handler(toRequest(ctx), {}) const response = await handler(toRequest(ctx), {})
await writeResponse(ctx, response, ctx.path.startsWith('/supplierOpenApi/')) await writeResponse(ctx, response, ctx.path.startsWith('/supplierOpenApi/'))
} }
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto'
import { import {
AFTER_SALE_LOGISTICS_STATE, AFTER_SALE_LOGISTICS_STATE,
AFTER_SALE_SERVICE_STATE, AFTER_SALE_SERVICE_STATE,
@@ -132,8 +133,8 @@ const toDateTimeText = (date?: Date | null) => {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}` return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
} }
const createServiceOrderId = (subOrderId: string, serviceType: number) => { const createServiceOrderId = (serviceType: number) => {
return `AS${serviceType}_${subOrderId}_${Date.now()}` return `AS${serviceType}_${randomBytes(8).toString('hex')}`
} }
const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Number(orderState)) const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Number(orderState))
@@ -323,7 +324,7 @@ export const afterSaleService = {
const now = new Date() const now = new Date()
const amount = Number(input.amount || subOrder.totalPrice || 0) const amount = Number(input.amount || subOrder.totalPrice || 0)
const serviceOrderId = createServiceOrderId(subOrderId, serviceType) const serviceOrderId = createServiceOrderId(serviceType)
const { isSigned: _isSigned, ...requestBody } = input const { isSigned: _isSigned, ...requestBody } = input
await orderDao.transaction(async tx => { await orderDao.transaction(async tx => {
@@ -490,7 +491,7 @@ export const afterSaleService = {
const subOrders = record.subOrderId const subOrders = record.subOrderId
? await orderDao.findOpenApiSubOrders(supplierId, [record.subOrderId]) ? await orderDao.findOpenApiSubOrders(supplierId, [record.subOrderId])
: [] : []
const isSigned = isOrderSigned(subOrders[0]?.orderState) const isSigned = Boolean(record.pickupMethod || record.pickupName || record.pickupMobile || record.pickupAddress) || isOrderSigned(subOrders[0]?.orderState)
const approved = auditStatus === 1 const approved = auditStatus === 1
const isExchange = record.serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER const isExchange = record.serviceType === ORDER_EVENT_TYPE.EXCHANGE_ORDER
const finishUnsignedReturn = approved && !isSigned && record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER const finishUnsignedReturn = approved && !isSigned && record.serviceType === ORDER_EVENT_TYPE.RETURN_ORDER
@@ -0,0 +1,40 @@
import { prisma } from '../utils/prisma.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
import { supplierFileService } from './supplier-file.service.js'
export type MerchantInvoiceListInput = { settlementId?: string; invoiceStatus?: number; pageNumber?: number; pageSize?: number }
export type MerchantInvoiceFileInput = { id?: number; download?: boolean }
export const merchantInvoiceService = {
async list(input: MerchantInvoiceListInput) {
const settlementId = String(input.settlementId || '').trim()
const invoiceStatus = input.invoiceStatus === undefined || input.invoiceStatus === null ? undefined : Number(input.invoiceStatus)
if (invoiceStatus !== undefined && ![0, 1, 2, 3].includes(invoiceStatus)) return serviceFail('开票状态不正确')
const pageNumber = Number.isInteger(Number(input.pageNumber)) && Number(input.pageNumber) > 0 ? Number(input.pageNumber) : 1
const pageSize = Number.isInteger(Number(input.pageSize)) && Number(input.pageSize) > 0 ? Math.min(Number(input.pageSize), 100) : 10
const where = { deleted: 0, ...(settlementId ? { settlementId: { contains: settlementId } } : {}), ...(invoiceStatus === undefined ? {} : { invoiceStatus }) }
const [records, totalCount] = await prisma.$transaction([
prisma.supplierInvoiceInfo.findMany({ where, orderBy: [{ createTime: 'desc' }, { id: 'desc' }], skip: (pageNumber - 1) * pageSize, take: pageSize }),
prisma.supplierInvoiceInfo.count({ where })
])
const invoiceItems = records.map(record => ({
id: record.id, settlementId: record.settlementId || record.invoiceBizId, invoiceIds: record.invoiceIds || '',
invoiceCode: record.invoiceCode || '', invoiceStatus: record.invoiceStatus, invoiceDate: record.invoiceDate,
invoiceType: record.invoiceType, invoiceAmount: record.invoiceAmount?.toFixed(2) || '0.00',
invoiceNakeAmount: record.invoiceNakeAmount?.toFixed(2) || '0.00', invoiceTaxAmount: record.invoiceTaxAmount?.toFixed(2) || '0.00',
invoiceTaxRate: record.invoiceTaxRate?.toString() || '', taxIdNumber: record.taxIdNumber || '', checkCode: record.checkCode || '',
orderIds: record.orderIds || '', failureMessage: record.failureMessage || '', createTime: record.createTime,
hasFile: record.invoiceStatus === 2 && Boolean(record.fileId)
}))
return serviceOk({ invoiceItems, totalCount, pageNumber, pageSize })
},
async file(input: MerchantInvoiceFileInput) {
const id = Number(input.id)
if (!Number.isInteger(id) || id <= 0) return serviceFail('发票参数不正确')
const invoice = await prisma.supplierInvoiceInfo.findFirst({ where: { id, deleted: 0, invoiceStatus: 2 } })
if (!invoice?.fileId) return serviceFail('发票文件不存在', 404, 'INVOICE_FILE_NOT_FOUND')
const url = await supplierFileService.pdfUrl(invoice.fileId, invoice.supplierId, input.download === true)
if (!url) return serviceFail('发票文件不存在', 404, 'INVOICE_FILE_NOT_FOUND')
return serviceOk({ url })
}
}
@@ -19,6 +19,7 @@ export type MerchantOrderListInput = {
orderState?: number orderState?: number
pageNumber?: number pageNumber?: number
pageSize?: number pageSize?: number
userNickname?: string
orderId?: string orderId?: string
userId?: string userId?: string
} }
@@ -34,6 +35,7 @@ export type MerchantShipInput = {
export type MerchantAfterSaleListInput = { export type MerchantAfterSaleListInput = {
supplierId?: string supplierId?: string
scene?: string scene?: string
serviceOrderId?: string
} }
const serviceStateText = (serviceState: number) => { const serviceStateText = (serviceState: number) => {
@@ -76,7 +78,7 @@ const firstImage = (picInfo: unknown) => {
} }
const afterSaleStatesByScene = (scene: string) => { const afterSaleStatesByScene = (scene: string) => {
if (scene === 'audit') return [AFTER_SALE_SERVICE_STATE.PENDING_AUDIT] if (scene === 'audit') return [AFTER_SALE_SERVICE_STATE.PENDING_AUDIT, AFTER_SALE_SERVICE_STATE.APPROVED, AFTER_SALE_SERVICE_STATE.USER_SHIPPED, AFTER_SALE_SERVICE_STATE.RECEIVED]
if (scene === 'process') { if (scene === 'process') {
return [ return [
AFTER_SALE_SERVICE_STATE.APPROVED, AFTER_SALE_SERVICE_STATE.APPROVED,
@@ -123,7 +125,9 @@ export const merchantService = {
const pagination = input.pageNumber !== undefined || input.pageSize !== undefined ? { pageNumber, pageSize } : undefined const pagination = input.pageNumber !== undefined || input.pageSize !== undefined ? { pageNumber, pageSize } : undefined
const afterSales = await orderDao.findMerchantAfterSales(supplierId, pendingShipExcludeAfterSaleStates) const afterSales = await orderDao.findMerchantAfterSales(supplierId, pendingShipExcludeAfterSaleStates)
const afterSaleSubOrderIds = afterSales.map(item => item.subOrderId).filter((id): id is string => Boolean(id)) const afterSaleSubOrderIds = afterSales.map(item => item.subOrderId).filter((id): id is string => Boolean(id))
const { orders, totalCount } = await orderDao.findMerchantSubOrders(supplierId, orderState, orderId, userId, afterSaleSubOrderIds, pagination) const userNickname = String(input.userNickname || '').trim()
const matchingUserIds = userNickname ? (await suppliedUserDao.findVirtualIdsByNickname(userNickname)).map(user => user.virtualId) : undefined
const { orders, totalCount } = await orderDao.findMerchantSubOrders(supplierId, orderState, orderId, userId, afterSaleSubOrderIds, pagination, matchingUserIds)
const virtualIds = [...new Set(orders.map(order => order.userId).filter((id): id is string => Boolean(id)))] const virtualIds = [...new Set(orders.map(order => order.userId).filter((id): id is string => Boolean(id)))]
const users = await suppliedUserDao.findByVirtualIds(virtualIds) const users = await suppliedUserDao.findByVirtualIds(virtualIds)
const nicknames = new Map(users.map(user => [user.virtualId, user.nickname])) const nicknames = new Map(users.map(user => [user.virtualId, user.nickname]))
@@ -224,13 +228,12 @@ export const merchantService = {
const scene = String(input.scene || '').trim() const scene = String(input.scene || '').trim()
logger.info('merchant after sale list query', { supplierId, scene }) logger.info('merchant after sale list query', { supplierId, scene })
if (!supplierId) { const records = await orderDao.findMerchantAfterSales(supplierId, afterSaleStatesByScene(scene), String(input.serviceOrderId || '').trim())
return serviceFail('供应商不能为空') const supplierIds = [...new Set(records.map(item => item.supplierId))]
} const subOrders = (await Promise.all(supplierIds.map(recordSupplierId => {
const subOrderIds = records.filter(item => item.supplierId === recordSupplierId).map(item => item.subOrderId).filter(Boolean) as string[]
const records = await orderDao.findMerchantAfterSales(supplierId, afterSaleStatesByScene(scene)) return subOrderIds.length ? orderDao.findOpenApiSubOrders(recordSupplierId, subOrderIds) : []
const subOrderIds = records.map(item => item.subOrderId).filter(Boolean) as string[] }))).flat()
const subOrders = subOrderIds.length ? await orderDao.findOpenApiSubOrders(supplierId, subOrderIds) : []
const subOrderMap = new Map(subOrders.map(item => [item.orderId, item])) const subOrderMap = new Map(subOrders.map(item => [item.orderId, item]))
const afterSaleItems = records.map(record => { const afterSaleItems = records.map(record => {
const subOrder = record.subOrderId ? subOrderMap.get(record.subOrderId) : null const subOrder = record.subOrderId ? subOrderMap.get(record.subOrderId) : null
@@ -246,7 +249,7 @@ export const merchantService = {
serviceState: record.serviceState, serviceState: record.serviceState,
serviceStateText: serviceStateText(record.serviceState), serviceStateText: serviceStateText(record.serviceState),
logisticsState: record.logisticsState, logisticsState: record.logisticsState,
isSigned: isOrderSigned(subOrder?.orderState), isSigned: Boolean(record.pickupMethod || record.pickupName || record.pickupMobile || record.pickupAddress) || isOrderSigned(subOrder?.orderState),
reason: record.reason || '', reason: record.reason || '',
problemDescription: record.problemDescription || '', problemDescription: record.problemDescription || '',
pickupMethod: record.pickupMethod || '', pickupMethod: record.pickupMethod || '',
@@ -1,4 +1,5 @@
import crypto from 'node:crypto' import crypto from 'node:crypto'
import { prisma } from '../utils/prisma.js'
import { serviceFail, serviceOk } from '../types/service-result.js' import { serviceFail, serviceOk } from '../types/service-result.js'
export type UploadInput = { export type UploadInput = {
@@ -36,9 +37,9 @@ const signingKey = (date: string) => {
return hmac(serviceKey, 'aws4_request') return hmac(serviceKey, 'aws4_request')
} }
const objectUrl = (objectName: string) => { const objectUrl = (objectName: string, bucket = minioConfig.bucket) => {
const endpoint = new URL(minioConfig.endPoint) const endpoint = new URL(minioConfig.endPoint)
endpoint.pathname = `${endpoint.pathname.replace(/\/$/, '')}/${minioConfig.bucket}/${encodePath(objectName)}` endpoint.pathname = `${endpoint.pathname.replace(/\/$/, '')}/${bucket}/${encodePath(objectName)}`
return endpoint return endpoint
} }
@@ -49,12 +50,12 @@ const extName = (fileName: string) => {
const safeFolder = (folder?: string) => String(folder || 'after-sale').replace(/[^a-zA-Z0-9/_-]/g, '').replace(/^\/+|\/+$/g, '') || 'after-sale' const safeFolder = (folder?: string) => String(folder || 'after-sale').replace(/[^a-zA-Z0-9/_-]/g, '').replace(/^\/+|\/+$/g, '') || 'after-sale'
const presign = (method: 'GET', objectName: string, expires = 3600) => { const presign = (method: 'GET', objectName: string, expires = 3600, bucket = minioConfig.bucket) => {
const now = new Date() const now = new Date()
const date = shortDate(now) const date = shortDate(now)
const fullDate = amzDate(now) const fullDate = amzDate(now)
const credentialScope = `${date}/${minioConfig.region}/s3/aws4_request` const credentialScope = `${date}/${minioConfig.region}/s3/aws4_request`
const endpoint = objectUrl(objectName) const endpoint = objectUrl(objectName, bucket)
const params = new URLSearchParams({ const params = new URLSearchParams({
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256', 'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${minioConfig.accessKey}/${credentialScope}`, 'X-Amz-Credential': `${minioConfig.accessKey}/${credentialScope}`,
@@ -147,8 +148,15 @@ export const minioUploadService = {
return serviceFail(`图片上传失败:${response.status}`, 500, 'MINIO_UPLOAD_FAILED') return serviceFail(`图片上传失败:${response.status}`, 500, 'MINIO_UPLOAD_FAILED')
} }
const fileId = crypto.randomUUID()
await prisma.supplierFile.create({
data: { fileId, fileName, objectName, bucketName: minioConfig.bucket, contentType, fileSize: body.length }
})
const fileUrl = `/api/file?fid=${encodeURIComponent(fileId)}`
const previewUrl = presign('GET', objectName) const previewUrl = presign('GET', objectName)
return serviceOk({ return serviceOk({
fileId,
fileUrl,
objectName, objectName,
fileName, fileName,
contentType, contentType,
@@ -158,6 +166,16 @@ export const minioUploadService = {
}) })
}, },
async imageUrl(fileId: string) {
if (!ensureConfig()) return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
if (!fileId) return serviceFail('文件ID不能为空', 400, 'FILE_ID_REQUIRED')
const file = await prisma.supplierFile.findFirst({ where: { fileId, deleted: 0 } })
if (!file || !file.contentType?.startsWith('image/')) {
return serviceFail('图片不存在', 404, 'IMAGE_NOT_FOUND')
}
return serviceOk({ url: presign('GET', file.objectName, 3600, file.bucketName) })
},
preview(input: PreviewInput) { preview(input: PreviewInput) {
if (!ensureConfig()) { if (!ensureConfig()) {
return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING') return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
@@ -204,9 +204,9 @@ export const orderActionService = {
} }
const payTime = new Date() const payTime = new Date()
await orderDao.transaction(async tx => { const paid = await orderDao.transaction(async tx => {
await tx.supplierOrderMain.update({ const changed = await tx.supplierOrderMain.updateMany({
where: { orderId }, where: { orderId, supplierId, deleted: 0, orderState: ORDER_STATE.PENDING_PAY },
data: { data: {
orderState: ORDER_STATE.FINISH_PAY, orderState: ORDER_STATE.FINISH_PAY,
isSplit: ORDER_SPLIT_STATE.NOT_SPLIT, isSplit: ORDER_SPLIT_STATE.NOT_SPLIT,
@@ -217,11 +217,38 @@ export const orderActionService = {
updateTime: payTime updateTime: payTime
} }
}) })
if (changed.count !== 1) return false
await tx.supplierOrderSub.updateMany({ await tx.supplierOrderSub.updateMany({
where: { mainOrderId: orderId, deleted: 0 }, where: { mainOrderId: orderId, deleted: 0 },
data: { orderState: ORDER_STATE.FINISH_PAY, payTime, updateTime: payTime } data: { orderState: ORDER_STATE.FINISH_PAY, payTime, updateTime: payTime }
}) })
const products = await tx.supplierOrderProduct.findMany({
where: { deleted: 0, subOrder: { mainOrderId: orderId, supplierId, deleted: 0 } },
select: { sku: true, number: true }
})
const quantities = new Map<string, number>()
products.forEach(product => {
if (product.number > 0) quantities.set(product.sku, (quantities.get(product.sku) || 0) + product.number)
})
// 锁定商品行后累加销量,与首次支付状态变更一起提交,避免并发丢失或重复累计。
for (const sku of [...quantities.keys()].sort()) {
const rows = await tx.$queryRaw<{ id: number; sales: string | null }[]>`
SELECT id, sales FROM supplier_product WHERE sku = ${sku} FOR UPDATE
`
const product = rows[0]
if (!product) continue
const text = String(product.sales || '').trim().replace(/,/g, '')
const match = text.match(/^(\d+(?:\.\d+)?)\s*([万千])?(?:件)?\+?$/)
if (text && !match) throw new Error(`商品销量格式不正确:${sku}`)
const sales = match ? Math.floor(Number(match[1]) * (match[2] === '万' ? 10000 : match[2] === '千' ? 1000 : 1)) : 0
await tx.supplierProduct.update({
where: { id: product.id },
data: { sales: String(sales + quantities.get(sku)!) }
})
}
return true
}) })
if (!paid) return serviceFail('当前订单状态不可支付,请刷新订单状态', 400, 'ORDER_NOT_PAYABLE')
const confirm = await bridgeFetchWithAuth<BridgeOrderResponse>(supplierId, '/pointOrder/confirmPurchase', { const confirm = await bridgeFetchWithAuth<BridgeOrderResponse>(supplierId, '/pointOrder/confirmPurchase', {
orderId, orderId,
@@ -34,7 +34,7 @@ const toProductItem = (product: ProductRecord) => {
categoryId: product.categoryId || '', categoryId: product.categoryId || '',
category: product.category || '', category: product.category || '',
tag: product.tag || '', tag: product.tag || '',
sales: product.sales || '', sales: product.sales?.trim() || '0',
stock: product.stock, stock: product.stock,
sortOrder: product.sortOrder, sortOrder: product.sortOrder,
enabled: product.enabled enabled: product.enabled
@@ -68,9 +68,11 @@ export const productService = {
return serviceOk(toProductItem(product)) return serviceOk(toProductItem(product))
}, },
async listManageProducts(keyword: string, categoryId: string) { async listManageProducts(keyword: string, categoryId: string, requestedPageNumber = 1, requestedPageSize = 10) {
const products = await productDao.findManageProducts(keyword, categoryId) const pageNumber = Number.isInteger(Number(requestedPageNumber)) && Number(requestedPageNumber) > 0 ? Number(requestedPageNumber) : 1
return serviceOk({ categories, products: products.map(toProductItem) }) const pageSize = Number.isInteger(Number(requestedPageSize)) && Number(requestedPageSize) > 0 ? Math.min(Number(requestedPageSize), 100) : 10
const { products, totalCount } = await productDao.findManageProducts(keyword, categoryId, pageNumber, pageSize)
return serviceOk({ categories, products: products.map(product => ({ ...toProductItem(product), description: product.description || '', detail: product.detail || '' })), totalCount, pageNumber, pageSize })
}, },
async saveManageProduct(input: { async saveManageProduct(input: {
@@ -99,6 +101,13 @@ export const productService = {
return serviceFail('商品SKU、名称和价格不能为空') return serviceFail('商品SKU、名称和价格不能为空')
} }
let imageUrl = String(input.imageUrl || '').trim()
if (imageUrl) {
const image = new URL(imageUrl, 'http://supplier.local')
if ((image.pathname.endsWith('/api/file') || image.pathname.endsWith('/api/upload-preview')) && image.searchParams.get('fid')) {
imageUrl = `/api/file?fid=${encodeURIComponent(image.searchParams.get('fid')!)}`
}
}
const data = { const data = {
sku, sku,
name, name,
@@ -108,17 +117,22 @@ export const productService = {
taxRate: Number(input.taxRate ?? 0.13), taxRate: Number(input.taxRate ?? 0.13),
categoryId: String(input.categoryId || '').trim() || null, categoryId: String(input.categoryId || '').trim() || null,
category: String(input.category || '').trim() || null, category: String(input.category || '').trim() || null,
imageUrl: String(input.imageUrl || '').trim() || null, imageUrl: imageUrl || null,
description: String(input.description || '').trim() || null, description: String(input.description || '').trim() || null,
detail: String(input.detail || '').trim() || null, detail: String(input.detail || '').trim() || null,
originPrice: input.originPrice === undefined || input.originPrice === null ? null : Number(input.originPrice), originPrice: input.originPrice === undefined || input.originPrice === null ? null : Number(input.originPrice),
tag: String(input.tag || '').trim() || null, tag: String(input.tag || '').trim() || null,
sales: String(input.sales || '').trim() || null, sales: '0',
sortOrder: Math.max(Number(input.sortOrder || 0), 0), sortOrder: Math.max(Number(input.sortOrder || 0), 0),
enabled: input.enabled !== false enabled: input.enabled !== false
} }
const id = Number(input.id || 0) const id = Number(input.id || 0)
const product = id > 0 ? await productDao.updateProduct(id, data) : await productDao.createProduct(data) const updateData: Partial<typeof data> = { ...data }
for (const field of Object.keys(data) as Array<keyof typeof data>) {
if (input[field] === undefined) delete updateData[field]
}
delete updateData.sales
const product = id > 0 ? await productDao.updateProduct(id, updateData) : await productDao.createProduct(data)
return serviceOk(toProductItem(product)) return serviceOk(toProductItem(product))
}, },
@@ -32,6 +32,8 @@ export type ReconciliationResultCallbackInput = {
} }
export type ReconciliationResultListInput = { export type ReconciliationResultListInput = {
pageNumber?: number
pageSize?: number
supplierId?: string supplierId?: string
reconciliationStatus?: number reconciliationStatus?: number
orderId?: string orderId?: string
@@ -189,26 +191,34 @@ export const reconciliationResultService = {
async list(input: ReconciliationResultListInput) { async list(input: ReconciliationResultListInput) {
const supplierId = String(input.supplierId || '').trim() const supplierId = String(input.supplierId || '').trim()
if (!supplierId) return serviceFail('供应商不能为空')
const reconciliationStatus = input.reconciliationStatus === undefined ? undefined : Number(input.reconciliationStatus) const reconciliationStatus = input.reconciliationStatus === undefined ? undefined : Number(input.reconciliationStatus)
if (reconciliationStatus !== undefined && ![0, 1, 2].includes(reconciliationStatus)) { if (reconciliationStatus !== undefined && ![0, 1, 2].includes(reconciliationStatus)) {
return serviceFail('对账状态不正确') return serviceFail('对账状态不正确')
} }
const orderId = String(input.orderId || '').trim() const orderId = String(input.orderId || '').trim()
const pageNumber = Number.isInteger(Number(input.pageNumber)) && Number(input.pageNumber) > 0 ? Number(input.pageNumber) : 1
const pageSize = Number.isInteger(Number(input.pageSize)) && Number(input.pageSize) > 0 ? Math.min(Number(input.pageSize), 100) : 10
const condition = Prisma.sql`
WHERE deleted = 0
${supplierId ? Prisma.sql`AND supplierId = ${supplierId}` : Prisma.empty}
${reconciliationStatus === undefined ? Prisma.empty : Prisma.sql`AND reconciliationStatus = ${reconciliationStatus}`}
${orderId ? Prisma.sql`AND orderId LIKE ${`%${orderId}%`}` : Prisma.empty}
`
const totals = await prisma.$queryRaw<{ totalCount: bigint }[]>`
SELECT COUNT(*) AS totalCount FROM supplier_reconciliation_order ${condition}
`
const records = await prisma.$queryRaw<SupplierReconciliationOrderRecord[]>` const records = await prisma.$queryRaw<SupplierReconciliationOrderRecord[]>`
SELECT supplierId, orderId, mainOrderId, userId, reconciliationStatus, diffType, diffReason, SELECT supplierId, orderId, mainOrderId, userId, reconciliationStatus, diffType, diffReason,
platformTotalPrice, platformCashAmount, platformPointsPrice, platformRefundPrice, platformTotalPrice, platformCashAmount, platformPointsPrice, platformRefundPrice,
supplierTotalPrice, supplierCashAmount, supplierPointsPrice, supplierRefundPrice, supplierTotalPrice, supplierCashAmount, supplierPointsPrice, supplierRefundPrice,
reconciliationBatchNo, reconciliationTime, updateAt reconciliationBatchNo, reconciliationTime, updateAt
FROM supplier_reconciliation_order FROM supplier_reconciliation_order
WHERE supplierId = ${supplierId} ${condition}
AND deleted = 0 ORDER BY updateAt DESC, supplierId, orderId
${reconciliationStatus === undefined ? Prisma.empty : Prisma.sql`AND reconciliationStatus = ${reconciliationStatus}`} LIMIT ${pageSize} OFFSET ${(pageNumber - 1) * pageSize}
${orderId ? Prisma.sql`AND orderId LIKE ${`%${orderId}%`}` : Prisma.empty}
ORDER BY updateAt DESC
` `
return serviceOk({ reconciliationItems: records.map(resultItem) }) return serviceOk({ reconciliationItems: records.map(resultItem), totalCount: Number(totals[0]?.totalCount || 0), pageNumber, pageSize })
}, },
async update(input: ReconciliationResultUpdateInput) { async update(input: ReconciliationResultUpdateInput) {
@@ -44,6 +44,15 @@ export const supplierFileService = {
return { fileId, objectName } return { fileId, objectName }
}, },
async pdfUrl(fileId: string, supplierId: string, download = false) {
const file = await prisma.supplierFile.findFirst({ where: { fileId, supplierId, deleted: 0, contentType: 'application/pdf' } })
if (!file) return null
return client.presignedGetObject(file.bucketName, file.objectName, 3600, {
'response-content-type': 'application/pdf',
'response-content-disposition': `${download ? 'attachment' : 'inline'}; filename*=UTF-8''${encodeURIComponent(file.fileName)}`
})
},
async read(fileId: string, supplierId: string) { async read(fileId: string, supplierId: string) {
const file = await prisma.supplierFile.findFirst({ where: { fileId, supplierId, deleted: 0 } }) const file = await prisma.supplierFile.findFirst({ where: { fileId, supplierId, deleted: 0 } })
if (!file) return null if (!file) return null
@@ -9,6 +9,7 @@ import { logger } from '../utils/logger.js'
import { sm4CbcDecrypt, verifySupplierOpenSignature } from '../utils/supplier-open-crypto.js' import { sm4CbcDecrypt, verifySupplierOpenSignature } from '../utils/supplier-open-crypto.js'
import { generateMockInvoicePdf } from '../../tools/mock-invoice-pdf.js' import { generateMockInvoicePdf } from '../../tools/mock-invoice-pdf.js'
import { supplierFileService } from './supplier-file.service.js' import { supplierFileService } from './supplier-file.service.js'
import { minioUploadService } from './minio-upload.service.js'
export type SupplierOrderQueryRequest = { export type SupplierOrderQueryRequest = {
supplierId?: string supplierId?: string
@@ -123,7 +124,13 @@ const storedRequest = (value: unknown) => value && typeof value === 'object' &&
: {} : {}
const encodeImage = async (source?: string | null) => { const encodeImage = async (source?: string | null) => {
const imageSource = String(source || '').trim() let imageSource = String(source || '').trim()
if (/^\/api\/(?:file|upload-preview)\?/.test(imageSource)) {
const fileId = new URL(imageSource, 'http://supplier.local').searchParams.get('fid') || ''
const result = await minioUploadService.imageUrl(fileId)
if (!result.success) return ''
imageSource = result.data.url
}
if (!imageSource) return '' if (!imageSource) return ''
if (imageSource.startsWith('data:')) { if (imageSource.startsWith('data:')) {
return imageSource.split(',')[1] || '' return imageSource.split(',')[1] || ''
+46 -10
View File
@@ -324,6 +324,32 @@ export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {})
const invoiceDate = dateValue.replace(/^(\d{4})-(\d{2})-(\d{2}).*$/, '$1年$2月$3日') const invoiceDate = dateValue.replace(/^(\d{4})-(\d{2})-(\d{2}).*$/, '$1年$2月$3日')
const title = Number(invoice.invoiceType) === 9 ? '电子发票(增值税专用发票)' : '电子发票(普通发票)' const title = Number(invoice.invoiceType) === 9 ? '电子发票(增值税专用发票)' : '电子发票(普通发票)'
const products = Array.isArray(invoice.orderProducts) && invoice.orderProducts.length
? invoice.orderProducts
: [{ name: invoice.invoiceContent, number: invoice.orderNum, price: invoice.total / invoice.orderNum, unit: '项' }]
const weights = products.map(product => Math.max(Number(product.price || 0) * Number(product.number || 1), 0))
const totalWeight = weights.reduce((sum, value) => sum + value, 0)
const nakedCents = Math.round(invoice.naked * 100)
const taxCents = Math.round(invoice.tax * 100)
let accumulatedWeight = 0
let allocatedNaked = 0
let allocatedTax = 0
// 按商品金额分摊票面金额,累计取整以消除逐行计算产生的分位尾差。
const invoiceItems = products.map((product, index) => {
accumulatedWeight += weights[index]
const ratio = totalWeight > 0 ? accumulatedWeight / totalWeight : (index + 1) / products.length
const nextNaked = index === products.length - 1 ? nakedCents : Math.round(nakedCents * ratio)
const nextTax = index === products.length - 1 ? taxCents : Math.round(taxCents * ratio)
const item = { ...product, number: Number(product.number || 1), naked: (nextNaked - allocatedNaked) / 100, tax: (nextTax - allocatedTax) / 100 }
allocatedNaked = nextNaked
allocatedTax = nextTax
return item
})
const pages = []
const itemsPerPage = 5
for (let itemOffset = 0; itemOffset < invoiceItems.length; itemOffset += itemsPerPage) {
commands = []
const lastItemPage = itemOffset + itemsPerPage >= invoiceItems.length
commands.push('1 1 1 rg 0 0 842 595 re f') commands.push('1 1 1 rg 0 0 842 595 re f')
text(title, (PAGE_WIDTH - title.length * 21) / 2, 546, 21, labelColor) text(title, (PAGE_WIDTH - title.length * 21) / 2, 546, 21, labelColor)
line(245, 531, 590, 531, 0.8, labelColor) line(245, 531, 590, 531, 0.8, labelColor)
@@ -358,22 +384,30 @@ export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {})
const x = (columns[index] + columns[index + 1] - label.length * 9) / 2 const x = (columns[index] + columns[index + 1] - label.length * 9) / 2
text(label, x, 382, 9, labelColor) text(label, x, 382, 9, labelColor)
}) })
wrappedText(`*商品*${String(invoice.invoiceContent).length > 50 ? '订单商品汇总(详见后附明细)' : invoice.invoiceContent}`, 50, 360, 20, 9, 14) invoiceItems.slice(itemOffset, itemOffset + itemsPerPage).forEach((item, index) => {
text('项', 344, 360, 9) const y = 360 - index * 23
fitText(invoice.orderNum, 398, 360, 32, 9) fitText(`*商品*${item.name || item.sku || '商品'}`, 50, y, 184, 9)
fitText(money(invoice.naked / invoice.orderNum), 445, 360, 60, 9) fitText(item.spec || item.skuText || '', 247, y, 69, 8)
fitText(money(invoice.naked), 526, 360, 73, 9) fitText(item.unit || '件', 337, y, 40, 9)
fitText(`${Number((invoice.taxRate * 100).toFixed(4))}%`, 627, 360, 60, 9) fitText(item.number, 391, y, 40, 9)
fitText(money(invoice.tax), 722, 360, 70, 9) fitText(Number((item.naked / item.number).toFixed(6)).toString(), 445, y, 60, 9)
fitText(money(item.naked), 526, y, 73, 9)
fitText(`${Number((invoice.taxRate * 100).toFixed(4))}%`, 627, y, 60, 9)
fitText(money(item.tax), 722, y, 70, 9)
})
if (lastItemPage) {
text('合 计', 92, 230, 10, labelColor) text('合 计', 92, 230, 10, labelColor)
fitText(`¥${money(invoice.naked)}`, 526, 230, 73, 10) fitText(`¥${money(invoice.naked)}`, 526, 230, 73, 10)
fitText(`¥${money(invoice.tax)}`, 722, 230, 70, 10) fitText(`¥${money(invoice.tax)}`, 722, 230, 70, 10)
} else {
text('项目明细续下页,合计见末页', 92, 230, 10, labelColor)
}
line(235, 183, 235, 217, 0.7, labelColor) line(235, 183, 235, 217, 0.7, labelColor)
text('价税合计(大写)', 88, 196, 10, labelColor) text('价税合计(大写)', 88, 196, 10, labelColor)
fitText(chineseAmount(invoice.total), 245, 196, 340, 11) fitText(lastItemPage ? chineseAmount(invoice.total) : '详见项目明细末页', 245, 196, 340, 11)
text('(小写)', 603, 196, 10, labelColor) text('(小写)', 603, 196, 10, labelColor)
fitText(`¥${money(invoice.total)}`, 657, 196, 133, 11) if (lastItemPage) fitText(`¥${money(invoice.total)}`, 657, 196, 133, 11)
line(70, 100, 70, 183, 0.7, labelColor) line(70, 100, 70, 183, 0.7, labelColor)
text('备', 51, 153, 10, labelColor) text('备', 51, 153, 10, labelColor)
@@ -384,7 +418,9 @@ export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {})
text('开票人:', 90, 73, 10, labelColor) text('开票人:', 90, 73, 10, labelColor)
fitText(invoice.biller || '模拟开票员', 140, 73, 250, 10) fitText(invoice.biller || '模拟开票员', 140, 73, 250, 10)
const pages = [Buffer.from(commands.join('\n'), 'ascii')] text(`项目明细第 ${pages.length + 1} / ${Math.ceil(invoiceItems.length / itemsPerPage)} 页`, 620, 73, 9, labelColor)
pages.push(Buffer.from(commands.join('\n'), 'ascii'))
}
const rows = (Array.isArray(invoice.orderProducts) ? invoice.orderProducts : []).flatMap(product => { const rows = (Array.isArray(invoice.orderProducts) ? invoice.orderProducts : []).flatMap(product => {
const name = Array.from(String(product.name || product.sku || '商品')) const name = Array.from(String(product.name || product.sku || '商品'))
const lines = [] const lines = []