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(/\/$/, '')
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> => {
const token = readAdminSession()?.token
const response = await fetch(`${normalizeBase(API_BASE)}/${name}`, {
@@ -1,23 +1,111 @@
<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>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
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 }>()
type AfterSale = { serviceOrderId: string; serviceState?: number; serviceType?: number; isSigned?: boolean; serviceTypeText?: string; serviceStateText?: string; reason?: string; applyTime?: 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: '' })
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 reset = () => { supplierId.value = ''; records.value = []; queried.value = 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 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 : '处理失败') } }
const canReceive = (row: AfterSale) => props.scene === 'process' && Number(row.serviceState) === 3
const canShip = (row: AfterSale) => props.scene === 'process' && Number(row.serviceType) === 5 && (Number(row.serviceState) === 4 || (row.isSigned === false && Number(row.serviceState) === 1))
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 : '操作失败') } }
type AfterSaleProduct = { id?: number; sku: string; name: string; number: number; price: string; image?: string }
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 serviceOrderId = ref('')
const auditing = ref(false)
const currentStatusText = (row: AfterSale) => {
if (row.serviceState === 1 && row.logisticsState === 1) return '审核通过,待用户寄回'
if (row.serviceState === 3) return '用户已寄回,待商家收货'
if (row.serviceState === 4 && row.serviceType === 5) return '商家已收货,待换货发货'
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 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>
<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">
<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-item index="/reconciliation"><el-icon><Tickets /></el-icon><template #title>订单对账</template></el-menu-item>
<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="/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/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>
</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: [
{ 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/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/process', name: 'AfterSaleProcess', component: () => import('@/views/after-sales/process.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', redirect: '/orders/after-sales/audit' },
{ 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: '商品管理' } }
] }]
+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 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 .el-form-item { display: flex; flex-direction: column; align-items: stretch; 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__content { min-height: 36px; }
.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; 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-width: 0; min-height: 36px; }
.query-form .el-input, .query-form .el-select { width: 208px; max-width: 100%; }
.query-form .el-button { min-width: 76px; 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-select, .el-dialog .el-input-number { width: 100%; }
.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) {
.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>
@@ -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>
<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>
<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 }">
<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>
</el-image>
<div class="order-product__info">
@@ -13,7 +13,7 @@
</div>
</div>
<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-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">
import { computed, onMounted, reactive, ref } from 'vue'
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 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 query = reactive<{ supplierId: string; orderId: string; userId: string; orderState?: number; pageNumber: number; pageSize: number }>({ supplierId: '', orderId: '', userId: '', 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 query = reactive<{ orderId: string; userNickname: string; orderState?: number; pageNumber: number; pageSize: number }>({ orderId: '', userNickname: '', pageNumber: 1, pageSize: 10 })
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 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 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 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 } }
@@ -84,7 +82,7 @@ const repushSplit = async (order: OrderItem) => {
repushingOrderId.value = ''
}
}
onMounted(() => { loadSuppliers(); loadOrders() })
onMounted(loadOrders)
</script>
<style scoped lang="scss">
+48 -11
View File
@@ -1,32 +1,69 @@
<template>
<section class="page-card">
<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-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-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="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>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
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 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 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 }
const query = reactive({ keyword: '', categoryId: '' })
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; tag: string; sales: string; detail: string; number: number; taxRate: number }
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 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 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 reset = () => { query.keyword = ''; query.categoryId = ''; load() }
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 search = () => { query.pageNumber = 1; return load() }
const reset = () => { query.keyword = ''; query.categoryId = ''; return search() }
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 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 : '删除失败') } }
onMounted(load)
</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">
<div class="page-title"><div><h2>订单对账</h2><p>查看 ZHGH 回传的子订单对账结果,并处理异常订单。</p></div></div>
<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-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-table :data="items" v-loading="loading" border>
<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="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.supplierTotalPrice || '-' }}</template></el-table-column>
<el-table-column prop="reconciliationBatchNo" label="对账批次" min-width="150" show-overflow-tooltip />
<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>
<el-pagination v-model:current-page="query.pageNumber" :page-size="query.pageSize" :total="totalCount" layout="total, prev, pager, next, jumper" @current-change="load" />
</section>
<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>
@@ -29,12 +26,13 @@
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
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 }
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 loading = 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 load = async () => {
if (!query.supplierId.trim()) return ElMessage.warning('请输入供应商 ID')
loading.value = true
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 || []
totalCount.value = data.totalCount || 0
loaded.value = true
} 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 save = async () => {
if (!editing.value) return
@@ -64,4 +63,5 @@ const save = async () => {
ElMessage.success('保存成功'); dialogVisible.value = false; await load()
} catch (error) { ElMessage.error(error instanceof Error ? error.message : '保存失败') } finally { saving.value = false }
}
onMounted(load)
</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 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 = {}) => {
const url = new URL(`${normalizeBase(API_BASE)}/${name}`)
Object.entries(params).forEach(([key, value]) => {
@@ -18,25 +31,34 @@ export const getFunction = async (name, params = {}) => {
throw new Error(message)
}
return payload.data
return resolveFileUrls(payload.data)
}
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',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify(body)
})
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) {
const message = payload.error?.message || payload.msg || '请求失败'
throw new Error(message)
}
return payload.data
return resolveFileUrls(payload.data)
}
export const uploadImageFunction = async (file, folder = 'after-sale') => {
+23 -3
View File
@@ -1,4 +1,7 @@
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 JoyfulSelectionLayout from '@/view/joyful-selection/layout.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 CuteSelection from '@/view/cute-selection/index.vue'
export default createRouter({
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: SupplierHome },
{ path: '/joyful-selection/merchant/login', component: () => import('@/view/joyful-selection/merchant/login.vue') },
{
path: '/joyful-selection',
component: JoyfulSelectionLayout,
@@ -54,8 +58,8 @@ export default createRouter({
{ path: 'after-sale/select', component: JoyfulSelectionAfterSaleSelect },
{ path: 'after-sale/return', component: JoyfulSelectionAfterSaleReturn },
{ path: 'after-sale/exchange', component: JoyfulSelectionAfterSaleExchange },
{ path: 'merchant', component: JoyfulSelectionMerchant },
{ path: 'merchant/after-sale-detail', component: JoyfulSelectionMerchantAfterSaleDetail },
{ path: 'merchant', component: JoyfulSelectionMerchant, meta: { merchantAuth: true } },
{ path: 'merchant/after-sale-detail', component: JoyfulSelectionMerchantAfterSaleDetail, meta: { merchantAuth: true } },
{ path: 'supplier-logs', component: JoyfulSelectionSupplierLogs },
{ path: 'profile', component: JoyfulSelectionProfile },
{ path: 'points', component: JoyfulSelectionPoints }
@@ -67,3 +71,19 @@ export default createRouter({
{ 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(useUserStore, ['hydrateFromRoute', 'fetchBridgeToken']),
async prepareUserSession() {
if (this.$route.meta.merchantAuth) {
this.sessionLoading = false
return
}
this.sessionLoading = true
try {
await this.hydrateFromRoute(this.$route.query)
@@ -336,7 +336,7 @@ export default {
this.loadDetail()
},
methods: {
...mapActions(useUserStore, ['fetchBridgeToken']),
...mapActions(useUserStore, ['fetchSupplierInfo']),
normalizeSteps(steps) {
const serviceState = Number(this.afterSale.serviceState)
return steps.map((step, index) => ({
@@ -359,9 +359,9 @@ export default {
async loadDetail() {
try {
this.loading = true
await this.fetchBridgeToken()
if (!this.supplierId && !this.$route.query.supplierId) await this.fetchSupplierInfo()
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 || '')
})
@@ -407,7 +407,7 @@ export default {
message: auditStatus === 1 ? '通过后用户可寄回商品。' : '拒绝后该售后申请将结束。'
})
await postFunction('merchant-after-sale-audit', {
supplierId: this.supplierId,
supplierId: this.afterSale.supplierId || this.supplierId,
serviceOrderId: this.afterSale.serviceOrderId,
auditStatus,
auditRemark: auditStatus === 1 ? '审核通过' : '商家拒绝'
@@ -421,7 +421,7 @@ export default {
message: '确认后退货售后会进入完成,换货售后会进入待商家寄货。'
})
await postFunction('merchant-after-sale-receive', {
supplierId: this.supplierId,
supplierId: this.afterSale.supplierId || this.supplierId,
serviceOrderId: this.afterSale.serviceOrderId
})
Toast('已确认收货')
@@ -441,7 +441,7 @@ export default {
}
await postFunction('merchant-after-sale-ship', {
supplierId: this.supplierId,
supplierId: this.afterSale.supplierId || this.supplierId,
serviceOrderId: this.afterSale.serviceOrderId,
logisticsCompany: this.exchangeShipForm.logisticsCompany,
logisticsNo: this.exchangeShipForm.logisticsNo
@@ -252,7 +252,7 @@ export default {
this.prepareSupplier().then(() => this.loadAllTabs())
},
methods: {
...mapActions(useUserStore, ['fetchSupplierInfo', 'fetchBridgeToken']),
...mapActions(useUserStore, ['fetchSupplierInfo']),
...mapActions(useUiStore, ['loadUi', 'setMerchantTab']),
skuText(item = {}) {
const sku = String(item.sku || item.skuText || '').trim()
@@ -273,7 +273,6 @@ export default {
return count > 0 ? count : ''
},
async prepareSupplier() {
await this.fetchBridgeToken()
if (!this.supplierId) {
await this.fetchSupplierInfo()
}
@@ -353,7 +352,8 @@ export default {
this.$router.push({
path: '/joyful-selection/merchant/after-sale-detail',
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="price-stack">
<div><b>¥{{ item.price }}</b></div>
<div><span>已售{{ item.sales }}</span></div>
<div><span>已售{{ formatSales(item.sales) }}件</span></div>
</div>
<button type="button" class="cart-button" @click.stop="openSku(item)">
<van-icon name="cart-o" />
@@ -96,6 +96,11 @@ export default {
this.loadProducts()
},
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() {
this.loading = true
try {
@@ -15,7 +15,7 @@
src="https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg"
/>
<div>
<b>张三</b>
<b>{{ nickname || '-' }}</b>
<p>欢迎来到悦享优选</p>
</div>
</div>
@@ -103,7 +103,7 @@ export default {
}
}),
computed: {
...mapState(useUserStore, ['pointsBalance', 'supplierId', 'userId']),
...mapState(useUserStore, ['pointsBalance', 'supplierId', 'userId', 'nickname']),
formattedPoints() {
return Number(this.pointsBalance || 0).toLocaleString('zh-CN', {
maximumFractionDigits: 2
@@ -40,7 +40,19 @@ export const uploadController = withErrorBoundary(async (request: Request, _cont
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) => {
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'])
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'])
if (methodResponse) return methodResponse
const body = await readJson<{ keyword?: string; categoryId?: string }>(request)
return toHttpResponse(await productService.listManageProducts(String(body.keyword || '').trim(), String(body.categoryId || '').trim()))
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(), body.pageNumber, body.pageSize))
})
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 = {
...(supplierId ? { supplierId } : {}),
deleted: 0,
...(Number.isInteger(orderState) ? { orderState } : {}),
...(orderId ? { OR: [{ orderId: { contains: orderId } }, { orderNumberId: { contains: orderId } }] } : {}),
...(userId ? { userId: { contains: userId } } : {}),
...(matchingUserIds !== undefined ? { AND: [{ userId: { in: matchingUserIds } }] } : {}),
...(excludedOrderIds.length ? { orderId: { notIn: excludedOrderIds } } : {})
}
const [orders, totalCount] = await prisma.$transaction([
@@ -201,12 +202,13 @@ export const orderDao = {
return { orders, totalCount }
},
findMerchantAfterSales(supplierId: string, serviceStates?: number[]) {
findMerchantAfterSales(supplierId: string, serviceStates?: number[], serviceOrderId = '') {
return prisma.supplierAfterSale.findMany({
where: {
...(supplierId ? { supplierId } : {}),
deleted: 0,
...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {})
...(serviceStates?.length ? { serviceState: { in: serviceStates } } : {}),
...(serviceOrderId ? { serviceOrderId: { contains: serviceOrderId } } : {})
},
orderBy: [
{ createTime: 'desc' },
+16 -12
View File
@@ -35,18 +35,22 @@ export const productDao = {
})
},
findManageProducts(keyword: string, categoryId: string) {
return prisma.supplierProduct.findMany({
where: {
deleted: 0,
...(keyword ? { OR: [{ name: { contains: keyword } }, { sku: { contains: keyword } }] } : {}),
...(categoryId ? { categoryId } : {})
},
orderBy: [
{ sortOrder: 'asc' },
{ createdAt: 'desc' }
]
})
async findManageProducts(keyword: string, categoryId: string, pageNumber = 1, pageSize = 10) {
const where = {
deleted: 0,
...(keyword ? { OR: [{ name: { contains: keyword } }, { sku: { contains: keyword } }] } : {}),
...(categoryId ? { categoryId } : {})
}
const [products, totalCount] = await prisma.$transaction([
prisma.supplierProduct.findMany({
where,
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']) {
@@ -31,6 +31,11 @@ export const suppliedUserDao = {
if (!user) throw new Error('获取供应商用户信息失败')
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[]) {
if (!virtualIds.length) return []
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,
uploadController,
uploadPreviewController,
fileController,
userInfoController
} from './controllers/basic.controller.js'
import {
@@ -78,7 +79,11 @@ const app = new Koa()
const router = new Router()
const port = Number(process.env.PORT || 8888)
import { merchantInvoiceListController, merchantInvoiceFileController } from './controllers/merchant-invoice.controller.js'
const handlers: Record<string, FunctionHandler> = {
'merchant-invoice-list': merchantInvoiceListController,
'merchant-invoice-file': merchantInvoiceFileController,
'address-default': addressDefaultController,
'address-delete': addressDeleteController,
'address-list': addressListController,
@@ -132,6 +137,7 @@ const handlers: Record<string, FunctionHandler> = {
'supplier-token': supplierTokenController,
upload: uploadController,
'upload-preview': uploadPreviewController,
file: fileController,
'user-info': userInfoController
}
@@ -176,6 +182,11 @@ const writeResponse = async (ctx: Koa.Context, response: Response, wrapBa7 = fal
ctx.status = response.status
Object.entries(corsHeaders).forEach(([key, value]) => 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() : ''
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: '接口不存在' } }
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), {})
await writeResponse(ctx, response, ctx.path.startsWith('/supplierOpenApi/'))
}
@@ -1,3 +1,4 @@
import { randomBytes } from 'node:crypto'
import {
AFTER_SALE_LOGISTICS_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())}`
}
const createServiceOrderId = (subOrderId: string, serviceType: number) => {
return `AS${serviceType}_${subOrderId}_${Date.now()}`
const createServiceOrderId = (serviceType: number) => {
return `AS${serviceType}_${randomBytes(8).toString('hex')}`
}
const isOrderSigned = (orderState?: number | null) => signedOrderStates.has(Number(orderState))
@@ -323,7 +324,7 @@ export const afterSaleService = {
const now = new Date()
const amount = Number(input.amount || subOrder.totalPrice || 0)
const serviceOrderId = createServiceOrderId(subOrderId, serviceType)
const serviceOrderId = createServiceOrderId(serviceType)
const { isSigned: _isSigned, ...requestBody } = input
await orderDao.transaction(async tx => {
@@ -490,7 +491,7 @@ export const afterSaleService = {
const subOrders = 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 isExchange = record.serviceType === ORDER_EVENT_TYPE.EXCHANGE_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
pageNumber?: number
pageSize?: number
userNickname?: string
orderId?: string
userId?: string
}
@@ -34,6 +35,7 @@ export type MerchantShipInput = {
export type MerchantAfterSaleListInput = {
supplierId?: string
scene?: string
serviceOrderId?: string
}
const serviceStateText = (serviceState: number) => {
@@ -76,7 +78,7 @@ const firstImage = (picInfo: unknown) => {
}
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') {
return [
AFTER_SALE_SERVICE_STATE.APPROVED,
@@ -123,7 +125,9 @@ export const merchantService = {
const pagination = input.pageNumber !== undefined || input.pageSize !== undefined ? { pageNumber, pageSize } : undefined
const afterSales = await orderDao.findMerchantAfterSales(supplierId, pendingShipExcludeAfterSaleStates)
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 users = await suppliedUserDao.findByVirtualIds(virtualIds)
const nicknames = new Map(users.map(user => [user.virtualId, user.nickname]))
@@ -224,13 +228,12 @@ export const merchantService = {
const scene = String(input.scene || '').trim()
logger.info('merchant after sale list query', { supplierId, scene })
if (!supplierId) {
return serviceFail('供应商不能为空')
}
const records = await orderDao.findMerchantAfterSales(supplierId, afterSaleStatesByScene(scene))
const subOrderIds = records.map(item => item.subOrderId).filter(Boolean) as string[]
const subOrders = subOrderIds.length ? await orderDao.findOpenApiSubOrders(supplierId, subOrderIds) : []
const records = await orderDao.findMerchantAfterSales(supplierId, afterSaleStatesByScene(scene), String(input.serviceOrderId || '').trim())
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[]
return subOrderIds.length ? orderDao.findOpenApiSubOrders(recordSupplierId, subOrderIds) : []
}))).flat()
const subOrderMap = new Map(subOrders.map(item => [item.orderId, item]))
const afterSaleItems = records.map(record => {
const subOrder = record.subOrderId ? subOrderMap.get(record.subOrderId) : null
@@ -246,7 +249,7 @@ export const merchantService = {
serviceState: record.serviceState,
serviceStateText: serviceStateText(record.serviceState),
logisticsState: record.logisticsState,
isSigned: isOrderSigned(subOrder?.orderState),
isSigned: Boolean(record.pickupMethod || record.pickupName || record.pickupMobile || record.pickupAddress) || isOrderSigned(subOrder?.orderState),
reason: record.reason || '',
problemDescription: record.problemDescription || '',
pickupMethod: record.pickupMethod || '',
@@ -1,4 +1,5 @@
import crypto from 'node:crypto'
import { prisma } from '../utils/prisma.js'
import { serviceFail, serviceOk } from '../types/service-result.js'
export type UploadInput = {
@@ -36,9 +37,9 @@ const signingKey = (date: string) => {
return hmac(serviceKey, 'aws4_request')
}
const objectUrl = (objectName: string) => {
const objectUrl = (objectName: string, bucket = minioConfig.bucket) => {
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
}
@@ -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 presign = (method: 'GET', objectName: string, expires = 3600) => {
const presign = (method: 'GET', objectName: string, expires = 3600, bucket = minioConfig.bucket) => {
const now = new Date()
const date = shortDate(now)
const fullDate = amzDate(now)
const credentialScope = `${date}/${minioConfig.region}/s3/aws4_request`
const endpoint = objectUrl(objectName)
const endpoint = objectUrl(objectName, bucket)
const params = new URLSearchParams({
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${minioConfig.accessKey}/${credentialScope}`,
@@ -147,8 +148,15 @@ export const minioUploadService = {
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)
return serviceOk({
fileId,
fileUrl,
objectName,
fileName,
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) {
if (!ensureConfig()) {
return serviceFail('MinIO配置不完整', 500, 'MINIO_CONFIG_MISSING')
@@ -204,9 +204,9 @@ export const orderActionService = {
}
const payTime = new Date()
await orderDao.transaction(async tx => {
await tx.supplierOrderMain.update({
where: { orderId },
const paid = await orderDao.transaction(async tx => {
const changed = await tx.supplierOrderMain.updateMany({
where: { orderId, supplierId, deleted: 0, orderState: ORDER_STATE.PENDING_PAY },
data: {
orderState: ORDER_STATE.FINISH_PAY,
isSplit: ORDER_SPLIT_STATE.NOT_SPLIT,
@@ -217,11 +217,38 @@ export const orderActionService = {
updateTime: payTime
}
})
if (changed.count !== 1) return false
await tx.supplierOrderSub.updateMany({
where: { mainOrderId: orderId, deleted: 0 },
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', {
orderId,
@@ -34,7 +34,7 @@ const toProductItem = (product: ProductRecord) => {
categoryId: product.categoryId || '',
category: product.category || '',
tag: product.tag || '',
sales: product.sales || '',
sales: product.sales?.trim() || '0',
stock: product.stock,
sortOrder: product.sortOrder,
enabled: product.enabled
@@ -68,9 +68,11 @@ export const productService = {
return serviceOk(toProductItem(product))
},
async listManageProducts(keyword: string, categoryId: string) {
const products = await productDao.findManageProducts(keyword, categoryId)
return serviceOk({ categories, products: products.map(toProductItem) })
async listManageProducts(keyword: string, categoryId: string, requestedPageNumber = 1, requestedPageSize = 10) {
const pageNumber = Number.isInteger(Number(requestedPageNumber)) && Number(requestedPageNumber) > 0 ? Number(requestedPageNumber) : 1
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: {
@@ -99,6 +101,13 @@ export const productService = {
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 = {
sku,
name,
@@ -108,17 +117,22 @@ export const productService = {
taxRate: Number(input.taxRate ?? 0.13),
categoryId: String(input.categoryId || '').trim() || null,
category: String(input.category || '').trim() || null,
imageUrl: String(input.imageUrl || '').trim() || null,
imageUrl: imageUrl || null,
description: String(input.description || '').trim() || null,
detail: String(input.detail || '').trim() || null,
originPrice: input.originPrice === undefined || input.originPrice === null ? null : Number(input.originPrice),
tag: String(input.tag || '').trim() || null,
sales: String(input.sales || '').trim() || null,
sales: '0',
sortOrder: Math.max(Number(input.sortOrder || 0), 0),
enabled: input.enabled !== false
}
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))
},
@@ -32,6 +32,8 @@ export type ReconciliationResultCallbackInput = {
}
export type ReconciliationResultListInput = {
pageNumber?: number
pageSize?: number
supplierId?: string
reconciliationStatus?: number
orderId?: string
@@ -189,26 +191,34 @@ export const reconciliationResultService = {
async list(input: ReconciliationResultListInput) {
const supplierId = String(input.supplierId || '').trim()
if (!supplierId) return serviceFail('供应商不能为空')
const reconciliationStatus = input.reconciliationStatus === undefined ? undefined : Number(input.reconciliationStatus)
if (reconciliationStatus !== undefined && ![0, 1, 2].includes(reconciliationStatus)) {
return serviceFail('对账状态不正确')
}
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[]>`
SELECT supplierId, orderId, mainOrderId, userId, reconciliationStatus, diffType, diffReason,
platformTotalPrice, platformCashAmount, platformPointsPrice, platformRefundPrice,
supplierTotalPrice, supplierCashAmount, supplierPointsPrice, supplierRefundPrice,
reconciliationBatchNo, reconciliationTime, updateAt
FROM supplier_reconciliation_order
WHERE supplierId = ${supplierId}
AND deleted = 0
${reconciliationStatus === undefined ? Prisma.empty : Prisma.sql`AND reconciliationStatus = ${reconciliationStatus}`}
${orderId ? Prisma.sql`AND orderId LIKE ${`%${orderId}%`}` : Prisma.empty}
ORDER BY updateAt DESC
${condition}
ORDER BY updateAt DESC, supplierId, orderId
LIMIT ${pageSize} OFFSET ${(pageNumber - 1) * pageSize}
`
return serviceOk({ reconciliationItems: records.map(resultItem) })
return serviceOk({ reconciliationItems: records.map(resultItem), totalCount: Number(totals[0]?.totalCount || 0), pageNumber, pageSize })
},
async update(input: ReconciliationResultUpdateInput) {
@@ -44,6 +44,15 @@ export const supplierFileService = {
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) {
const file = await prisma.supplierFile.findFirst({ where: { fileId, supplierId, deleted: 0 } })
if (!file) return null
@@ -9,6 +9,7 @@ import { logger } from '../utils/logger.js'
import { sm4CbcDecrypt, verifySupplierOpenSignature } from '../utils/supplier-open-crypto.js'
import { generateMockInvoicePdf } from '../../tools/mock-invoice-pdf.js'
import { supplierFileService } from './supplier-file.service.js'
import { minioUploadService } from './minio-upload.service.js'
export type SupplierOrderQueryRequest = {
supplierId?: string
@@ -123,7 +124,13 @@ const storedRequest = (value: unknown) => value && typeof value === 'object' &&
: {}
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.startsWith('data:')) {
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 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')
text(title, (PAGE_WIDTH - title.length * 21) / 2, 546, 21, 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
text(label, x, 382, 9, labelColor)
})
wrappedText(`*商品*${String(invoice.invoiceContent).length > 50 ? '订单商品汇总(详见后附明细)' : invoice.invoiceContent}`, 50, 360, 20, 9, 14)
text('项', 344, 360, 9)
fitText(invoice.orderNum, 398, 360, 32, 9)
fitText(money(invoice.naked / invoice.orderNum), 445, 360, 60, 9)
fitText(money(invoice.naked), 526, 360, 73, 9)
fitText(`${Number((invoice.taxRate * 100).toFixed(4))}%`, 627, 360, 60, 9)
fitText(money(invoice.tax), 722, 360, 70, 9)
invoiceItems.slice(itemOffset, itemOffset + itemsPerPage).forEach((item, index) => {
const y = 360 - index * 23
fitText(`*商品*${item.name || item.sku || '商品'}`, 50, y, 184, 9)
fitText(item.spec || item.skuText || '', 247, y, 69, 8)
fitText(item.unit || '件', 337, y, 40, 9)
fitText(item.number, 391, y, 40, 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)
fitText(`¥${money(invoice.naked)}`, 526, 230, 73, 10)
fitText(`¥${money(invoice.tax)}`, 722, 230, 70, 10)
} else {
text('项目明细续下页,合计见末页', 92, 230, 10, labelColor)
}
line(235, 183, 235, 217, 0.7, 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)
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)
text('备', 51, 153, 10, labelColor)
@@ -384,7 +418,9 @@ export const generateMockInvoicePdf = (invoiceInput, outputPath, _options = {})
text('开票人:', 90, 73, 10, labelColor)
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 name = Array.from(String(product.name || product.sku || '商品'))
const lines = []