This commit is contained in:
2026-08-07 20:07:37 +08:00
parent 28d0b81c5a
commit 99413ee155
27 changed files with 1065 additions and 595 deletions
@@ -2,25 +2,28 @@ import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getCustomerDimensionConfig,
updateCustomerDimensionConfig,
type CustomerDimensionConfig,
} from '@/modules/dimension-config'
import { getCustomerDimensionConfig, updateCustomerDimensionConfig, type CustomerDimensionConfig } from '@/modules/dimension-config'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import CustomerBillingRulesPanel from './CustomerBillingRulesPanel.vue'
vi.mock('@/modules/dimension-config', () => ({
getCustomerDimensionConfig: vi.fn(),
updateCustomerDimensionConfig: vi.fn(),
}))
vi.mock('@/modules/dimension-config', () => ({ getCustomerDimensionConfig: vi.fn(), updateCustomerDimensionConfig: vi.fn() }))
const rule: CustomerDimensionConfig = {
const boxes = [
['SMALL', '小箱', 30, 20, 20, '标准小箱'],
['MEDIUM', '中箱', 40, 30, 30, '标准中箱'],
['LARGE', '大箱', 50, 40, 40, '标准大箱'],
['EXTRA_LARGE', '特大箱', 60, 50, 50, '特大箱'],
] as const
const config: CustomerDimensionConfig = {
tenantId: '8',
edgeAdditionCm: 2,
version: 3,
updatedAt: '2026-08-07T10:20:00',
rules: boxes.map(([boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, remark], index) => ({
id: String(100 + index),
boxType, boxTypeName, innerLengthCm, innerWidthCm, innerHeightCm, edgeAdditionCm: 2,
outerLengthCm: innerLengthCm + 4, outerWidthCm: innerWidthCm + 4, outerHeightCm: innerHeightCm + 4,
volumeCbm: Number(((innerLengthCm + 4) * (innerWidthCm + 4) * (innerHeightCm + 4) / 1_000_000).toFixed(3)),
remark, version: 3, createdAt: '2026-08-07T10:00:00', updatedAt: '2026-08-07T10:20:00',
})),
}
enableAutoUnmount(afterEach)
@@ -29,79 +32,47 @@ describe('CustomerBillingRulesPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = ['firstleg:dimension-config:view', 'firstleg:dimension-config:edit']
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = ['firstleg:dimension-config:view', 'firstleg:dimension-config:edit']
useAccessPreviewStore().initializeAfterLogin()
vi.mocked(getCustomerDimensionConfig).mockResolvedValue(rule)
vi.mocked(updateCustomerDimensionConfig).mockResolvedValue({ ...rule, version: 4 })
vi.mocked(getCustomerDimensionConfig).mockResolvedValue(config)
vi.mocked(updateCustomerDimensionConfig).mockResolvedValue(config)
})
it('shows one customer dimension rule instead of the old pricing versions', async () => {
mount(CustomerBillingRulesPanel, {
attachTo: document.body,
props: { customerId: '8', customerName: '柏悦物流', customerCode: 'TEN8' },
global: { plugins: [ElementPlus] },
})
it('renders the four fixed carton dimension rows and calculated values', async () => {
mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(getCustomerDimensionConfig).toHaveBeenCalledWith('8')
expect(document.body.textContent).toContain('尺寸计算规则')
expect(document.body.textContent).toContain('当前每侧加宽')
expect(document.body.textContent).toContain('+2cm')
expect(document.body.textContent).toContain('34 × 24 × 24 cm')
expect(document.body.textContent).toContain('0.020 CBM')
expect(document.body.textContent).not.toContain('规则版本')
expect(document.body.textContent).not.toContain('小箱')
expect(document.body.textContent).toContain('按箱规尺寸计算')
expect(document.body.textContent).toContain('小箱')
expect(document.body.textContent).toContain('特大箱')
expect(document.body.textContent).toContain('34 × 24 × 24')
expect(document.body.textContent).toContain('0.020')
expect(document.body.textContent).toContain('标准中箱')
})
it('edits the single value and submits the optimistic lock version', async () => {
mount(CustomerBillingRulesPanel, {
attachTo: document.body,
props: { customerId: '8', customerName: '柏悦物流' },
global: { plugins: [ElementPlus] },
})
it('submits all four rows in one update request', async () => {
mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const editButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '编辑规则')
editButton?.click()
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '编辑规则')?.click()
await flushPromises()
expect(document.body.textContent).toContain('编辑尺寸计算规则')
expect(document.body.textContent).toContain('实际内径 30 × 20 × 20 cm')
const saveButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '保存规则')
saveButton?.click()
await flushPromises()
expect(updateCustomerDimensionConfig).toHaveBeenCalledWith('8', {
edgeAdditionCm: 2,
version: 3,
})
const saveButton = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '保存规则')
saveButton?.click(); await flushPromises()
expect(updateCustomerDimensionConfig).toHaveBeenCalledWith('8', { rules: expect.arrayContaining([expect.objectContaining({ boxType: 'SMALL', version: 3 }), expect.objectContaining({ boxType: 'EXTRA_LARGE', version: 3 })]) })
const submittedRules = vi.mocked(updateCustomerDimensionConfig).mock.calls[0][1].rules
expect(submittedRules).toHaveLength(4)
expect(Object.keys(submittedRules[0]).sort()).toEqual([
'boxType', 'edgeAdditionCm', 'innerHeightCm', 'innerLengthCm', 'innerWidthCm', 'remark', 'version',
])
})
it('keeps the editor open when the versioned save fails', async () => {
vi.mocked(updateCustomerDimensionConfig).mockRejectedValueOnce(new Error('规则已被其他人更新'))
mount(CustomerBillingRulesPanel, {
attachTo: document.body,
props: { customerId: '8', customerName: '柏悦物流' },
global: { plugins: [ElementPlus] },
})
it('keeps the editor open when the whole-set save fails', async () => {
vi.mocked(updateCustomerDimensionConfig).mockRejectedValueOnce(new Error('规则版本冲突'))
mount(CustomerBillingRulesPanel, { attachTo: document.body, props: { customerId: '8', customerName: '柏悦物流' }, global: { plugins: [ElementPlus] } })
await flushPromises()
const editButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '编辑规则')
editButton?.click()
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '编辑规则')?.click(); await flushPromises()
;[...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent?.trim() === '保存规则')?.click()
await flushPromises()
const saveButton = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.trim() === '保存规则')
saveButton?.click()
await flushPromises()
expect(document.body.textContent).toContain('编辑尺寸计算规则')
expect(updateCustomerDimensionConfig).toHaveBeenCalledWith('8', expect.objectContaining({ version: 3 }))
expect(document.body.textContent).toContain('编辑箱规尺寸')
})
})
@@ -1,64 +1,52 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { Calculator, Pencil, RefreshCw, Ruler } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Pencil, Ruler } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import {
getCustomerDimensionConfig,
updateCustomerDimensionConfig,
type CustomerDimensionBoxType,
type CustomerDimensionConfig,
type CustomerDimensionConfigFormModel,
type CustomerDimensionRule,
} from '@/modules/dimension-config'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
const props = defineProps<{
customerId: string
customerName: string
customerCode?: string
}>()
const props = defineProps<{ customerId: string; customerName: string; customerCode?: string }>()
const access = useAccessPreviewStore()
const canView = computed(() => access.can('firstleg:dimension-config:view'))
const canEdit = computed(() => canView.value && access.can('firstleg:dimension-config:edit'))
const rule = ref<CustomerDimensionConfig | null>(null)
const config = ref<CustomerDimensionConfig | null>(null)
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const editorVisible = ref(false)
const editorSaving = ref(false)
const editorDirty = ref(false)
const editorForm = ref<FormInstance>()
const form = reactive<CustomerDimensionConfigFormModel>({ edgeAdditionCm: 2 })
const form = reactive<CustomerDimensionConfigFormModel>({ rules: [] })
const formRules: FormRules<CustomerDimensionConfigFormModel> = {
edgeAdditionCm: [{
required: true,
type: 'number',
min: 0,
max: 100,
message: '请输入 0 至 100 之间的每侧加宽值',
trigger: ['blur', 'change'],
}],
const BOX_ORDER: readonly CustomerDimensionBoxType[] = ['SMALL', 'MEDIUM', 'LARGE', 'EXTRA_LARGE']
const BOX_NAMES: Record<CustomerDimensionBoxType, string> = {
SMALL: '小箱', MEDIUM: '中箱', LARGE: '大箱', EXTRA_LARGE: '特大箱',
}
const preview = computed(() => {
const addition = Number.isFinite(form.edgeAdditionCm) ? form.edgeAdditionCm : 0
const length = 30 + addition * 2
const width = 20 + addition * 2
const height = 20 + addition * 2
return {
length,
width,
height,
volume: (length * width * height / 1_000_000).toFixed(3),
}
})
const columns: DataTableColumn[] = [
{ key: 'boxTypeName', label: '箱规类型', minWidth: 110, slotName: 'boxTypeName' },
{ key: 'inner', label: '内径尺寸(长×宽×高 cm)', minWidth: 210, slotName: 'inner' },
{ key: 'edgeAdditionCm', label: '外边加宽(cm)', width: 150, slotName: 'edgeAdditionCm' },
{ key: 'outer', label: '外径尺寸(长×宽×高 cm)', minWidth: 210, slotName: 'outer' },
{ key: 'volumeCbm', label: '体积(CBM)', width: 130, slotName: 'volumeCbm' },
{ key: 'remark', label: '备注', minWidth: 140, prop: 'remark' },
]
const { captureBaseline } = useUnsavedWorkRegistration({
key: `customer-dimension-rule-${props.customerId}`,
label: computed(() => `${props.customerName}的尺寸计算规则`),
key: `customer-dimension-config-${props.customerId}`,
label: computed(() => `${props.customerName}的箱规尺寸规则`),
isOpen: editorVisible,
model: form,
requiredPermission: computed(() => 'firstleg:dimension-config:edit'),
@@ -70,26 +58,36 @@ watch(form, () => {
watch(() => props.customerId, () => {
editorVisible.value = false
void loadRule()
void loadConfig()
})
async function loadRule() {
function sortRules(rules: CustomerDimensionRule[]) {
return [...rules].sort((a, b) => BOX_ORDER.indexOf(a.boxType) - BOX_ORDER.indexOf(b.boxType))
.map((rule) => ({ ...rule, boxTypeName: BOX_NAMES[rule.boxType] || rule.boxTypeName }))
}
async function loadConfig() {
if (!canView.value) return
loading.value = true
loadError.value = null
try {
rule.value = await getCustomerDimensionConfig(props.customerId)
config.value = await getCustomerDimensionConfig(props.customerId)
config.value.rules = sortRules(config.value.rules)
} catch (error) {
rule.value = null
loadError.value = getApiErrorDetails(error, '尺寸计算规则加载失败,请稍后重试')
config.value = null
loadError.value = getApiErrorDetails(error, '箱规尺寸规则加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function cloneRules(rules: CustomerDimensionRule[]) {
return sortRules(rules).map((rule) => ({ ...rule }))
}
async function openEditor() {
if (!rule.value) return
form.edgeAdditionCm = rule.value.edgeAdditionCm
if (!config.value) return
form.rules = cloneRules(config.value.rules)
await nextTick()
editorDirty.value = false
editorVisible.value = true
@@ -100,10 +98,8 @@ async function openEditor() {
async function confirmAbandon(): Promise<boolean> {
try {
await ElMessageBox.confirm('每侧加宽值尚未保存,确定放弃修改吗?', '放弃编辑', {
confirmButtonText: '放弃',
cancelButtonText: '继续编辑',
type: 'warning',
await ElMessageBox.confirm('箱规尺寸尚未保存,确定放弃修改吗?', '放弃编辑', {
confirmButtonText: '放弃', cancelButtonText: '继续编辑', type: 'warning',
})
return true
} catch {
@@ -122,178 +118,111 @@ async function closeEditor() {
editorVisible.value = false
}
async function saveRule() {
if (!rule.value || !await editorForm.value?.validate()) return
function validateRules() {
if (form.rules.length !== BOX_ORDER.length || form.rules.some((rule) => !BOX_ORDER.includes(rule.boxType))) {
ElMessage.error('必须完整保存小箱、中箱、大箱、特大箱四条规则')
return false
}
if (new Set(form.rules.map((rule) => rule.boxType)).size !== BOX_ORDER.length) {
ElMessage.error('箱规类型不能重复')
return false
}
if (form.rules.some((rule) => ![rule.innerLengthCm, rule.innerWidthCm, rule.innerHeightCm].every((value) => Number.isFinite(value) && value > 0))) {
ElMessage.error('内径长、宽、高必须大于 0')
return false
}
if (form.rules.some((rule) => !Number.isFinite(rule.edgeAdditionCm) || rule.edgeAdditionCm < 0.001 || rule.edgeAdditionCm > 100)) {
ElMessage.error('外边加宽必须在 0.001 至 100cm 之间')
return false
}
if (form.rules.some((rule) => (rule.remark || '').length > 500)) {
ElMessage.error('备注不能超过 500 个字符')
return false
}
return true
}
async function saveConfig() {
if (!config.value || !validateRules()) return
editorSaving.value = true
try {
rule.value = await updateCustomerDimensionConfig(props.customerId, {
edgeAdditionCm: form.edgeAdditionCm,
version: rule.value.version,
})
const rules = form.rules.map((rule) => ({
boxType: rule.boxType,
innerLengthCm: rule.innerLengthCm,
innerWidthCm: rule.innerWidthCm,
innerHeightCm: rule.innerHeightCm,
edgeAdditionCm: rule.edgeAdditionCm,
remark: rule.remark,
version: rule.version,
}))
config.value = await updateCustomerDimensionConfig(props.customerId, { rules })
config.value.rules = sortRules(config.value.rules)
editorDirty.value = false
editorVisible.value = false
ElMessage.success('尺寸计算规则已保存')
ElMessage.success('箱规尺寸规则已保存')
} catch (error) {
ElMessage.error(getApiErrorDetails(error, '尺寸计算规则保存失败,请刷新后重试').message)
ElMessage.error(getApiErrorDetails(error, '箱规尺寸规则保存失败,请刷新后重试').message)
} finally {
editorSaving.value = false
}
}
function formatCm(value: number) {
return Number(value.toFixed(2)).toString()
function formatDimension(...values: number[]) {
return values.map((value) => Number(value.toFixed(2)).toString()).join(' × ')
}
function formatDate(value: string | null) {
return value ? value.replace('T', ' ').slice(0, 16) : '暂无记录'
}
onMounted(loadRule)
onMounted(loadConfig)
</script>
<template>
<div class="dimension-rule-panel">
<header class="rule-heading">
<div>
<h2>尺寸计算规则</h2>
<p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的外箱尺寸加边配置</p>
</div>
<div class="heading-actions">
<el-button v-if="canView" :loading="loading" aria-label="刷新尺寸计算规则" @click="loadRule">
<RefreshCw :size="16" />刷新
</el-button>
<el-button v-if="canEdit && rule" type="primary" :disabled="loading" @click="openEditor">
<Pencil :size="16" />编辑规则
</el-button>
</div>
</header>
<div v-if="!canView" class="rule-state">
<Ruler :size="30" />
<strong>暂无计费规则查看权限</strong>
<p>需要权限 firstleg:dimension-config:view。</p>
<div v-if="!canView || loadError" class="panel-title rule-heading">
<div><h2>按箱规尺寸计算</h2><p>{{ customerName }}{{ customerCode ? `(${customerCode})` : '' }}的尺寸计算规则</p></div>
</div>
<div v-else-if="loadError" class="rule-error" role="alert">
<div>
<strong>尺寸计算规则加载失败</strong>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
</div>
<el-button type="primary" plain :loading="loading" @click="loadRule">重新加载</el-button>
</div>
<section v-else v-loading="loading" class="rule-content" aria-live="polite">
<div class="formula-band">
<div><span>外径计算</span><strong>实际内径 + 每侧加宽 × 2</strong></div>
<div><span>体积计算</span><strong>外径长 × 外径宽 × 外径高 ÷ 1,000,000 = CBM</strong></div>
</div>
<div v-if="rule" class="rule-summary">
<div class="addition-value">
<span>当前每侧加宽</span>
<strong>+{{ formatCm(rule.edgeAdditionCm) }}<small>cm</small></strong>
<p>长度、宽度和高度的两侧均使用该加宽值。</p>
</div>
<div class="example-preview">
<div class="example-title"><Calculator :size="18" /><strong>计算示例</strong></div>
<dl>
<div><dt>客户填写的实际内径</dt><dd>30 × 20 × 20 cm</dd></div>
<div><dt>计算后的外径</dt><dd>{{ formatCm(30 + rule.edgeAdditionCm * 2) }} × {{ formatCm(20 + rule.edgeAdditionCm * 2) }} × {{ formatCm(20 + rule.edgeAdditionCm * 2) }} cm</dd></div>
<div><dt>外箱体积</dt><dd>{{ (((30 + rule.edgeAdditionCm * 2) * (20 + rule.edgeAdditionCm * 2) * (20 + rule.edgeAdditionCm * 2)) / 1_000_000).toFixed(3) }} CBM</dd></div>
</dl>
</div>
</div>
<p v-if="rule" class="updated-at">最后更新:{{ formatDate(rule.updatedAt) }}</p>
<div v-if="!canView" class="rule-state"><Ruler :size="30" /><strong>暂无尺寸计算规则查看权限</strong><p>需要权限 firstleg:dimension-config:view。</p></div>
<div v-else-if="loadError" class="rule-error" role="alert"><div><strong>箱规尺寸规则加载失败</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" plain :loading="loading" @click="loadConfig">重新加载</el-button></div>
<section v-else-if="config" class="rule-card">
<header class="rule-card-heading"><h2>按箱规尺寸计算</h2><el-button v-if="canEdit" type="primary" :disabled="loading" @click="openEditor"><Pencil :size="16" />编辑规则</el-button></header>
<div class="formula-band"><span>计算方式:<strong>内径尺寸 + 外边加宽 = 外径尺寸</strong></span><span>体积公式:外径长 × 外径宽 × 外径高 ÷ 1,000,000 = CBM</span><span>默认加边:2cm(每侧)</span></div>
<DataTable :data="config.rules" :columns="columns" :loading="loading" row-key="boxType" empty-text="当前客户暂无箱规尺寸规则">
<template #boxTypeName="{ row }"><strong>{{ row.boxTypeName }}</strong></template>
<template #inner="{ row }">{{ formatDimension(row.innerLengthCm, row.innerWidthCm, row.innerHeightCm) }}</template>
<template #edgeAdditionCm="{ row }"><el-tag effect="light">+{{ row.edgeAdditionCm }}cm</el-tag></template>
<template #outer="{ row }">{{ formatDimension(row.outerLengthCm, row.outerWidthCm, row.outerHeightCm) }}</template>
<template #volumeCbm="{ row }"><strong class="volume-value">{{ Number(row.volumeCbm).toFixed(3) }}</strong></template>
<template #empty><div class="rule-state"><Ruler :size="28" /><strong>当前客户暂无箱规尺寸规则</strong></div></template>
</DataTable>
</section>
<div v-else v-loading="loading" class="loading-state" />
<AppDialog
v-model="editorVisible"
title="编辑尺寸计算规则"
width="min(720px, calc(100vw - 28px))"
:confirm-loading="editorSaving"
:show-cancel="false"
:before-close="beforeEditorClose"
@confirm="saveRule"
>
<AppDialog v-model="editorVisible" title="编辑箱规尺寸" width="min(1100px, calc(100vw - 28px))" :confirm-loading="editorSaving" :show-cancel="false" :before-close="beforeEditorClose" @confirm="saveConfig">
<div class="editor-content">
<div class="customer-context"><span>适用客户</span><strong>{{ customerName }}</strong><small>{{ customerCode || customerId }}</small></div>
<p class="editor-formula">外径 = 实际内径 + 每侧加宽 × 2;体积 = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
<el-form ref="editorForm" :model="form" :rules="formRules" label-position="top">
<el-form-item label="每侧加宽(cm)" prop="edgeAdditionCm">
<el-input-number v-model="form.edgeAdditionCm" :min="0" :max="100" :precision="2" :step="0.5" controls-position="right" />
</el-form-item>
<p class="editor-formula">计算公式:外径 = 内径 + 加宽 × 2(每侧加宽),体积(CBM) = 外径长 × 外径宽 × 外径高 ÷ 1,000,000</p>
<el-form ref="editorForm" :model="form" label-position="top">
<div class="editor-table-wrap">
<table class="editor-table"><thead><tr><th>箱规类型</th><th>内径长(cm)</th><th>内径宽(cm)</th><th>内径高(cm)</th><th>外边加宽(cm)</th><th>备注</th></tr></thead><tbody>
<tr v-for="row in form.rules" :key="row.boxType"><th scope="row">{{ row.boxTypeName }}</th><td><el-input-number v-model="row.innerLengthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerWidthCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.innerHeightCm" :min="0.001" :precision="3" :controls="false" /></td><td><el-input-number v-model="row.edgeAdditionCm" :min="0.001" :max="100" :precision="3" :controls="false" /></td><td><el-input v-model="row.remark" maxlength="500" /></td></tr>
</tbody></table>
</div>
</el-form>
<div class="live-preview">
<span>示例:实际内径 30 × 20 × 20 cm</span>
<strong>外径 {{ formatCm(preview.length) }} × {{ formatCm(preview.width) }} × {{ formatCm(preview.height) }} cm</strong>
<small>体积 {{ preview.volume }} CBM</small>
</div>
</div>
<template #footer>
<div class="dialog-actions">
<el-button :disabled="editorSaving" @click="closeEditor">取消</el-button>
<el-button type="primary" :loading="editorSaving" @click="saveRule">保存规则</el-button>
</div>
</template>
<template #footer><div class="dialog-actions"><el-button :disabled="editorSaving" @click="closeEditor">取消</el-button><el-button type="primary" :loading="editorSaving" @click="saveConfig">保存规则</el-button></div></template>
</AppDialog>
</div>
</template>
<style scoped>
.dimension-rule-panel { min-width: 0; }
.rule-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding-bottom: 18px; border-bottom: 1px solid var(--cc-border); }
.rule-heading h2 { margin: 0; color: var(--cc-text); font-size: 18px; }
.rule-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 13px; }
.heading-actions { display: flex; align-items: center; gap: 8px; }
.heading-actions :deep(.el-button) { margin-left: 0; }
.rule-content { min-height: 260px; }
.formula-band { display: grid; grid-template-columns: minmax(220px, .8fr) minmax(360px, 1.2fr); gap: 1px; margin: 0 -20px; background: var(--cc-border); border-bottom: 1px solid var(--cc-border); }
.formula-band > div { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 14px 20px; background: #f8fafc; }
.formula-band span { color: var(--cc-muted); font-size: 12px; }
.formula-band strong { color: #344054; font-size: 13px; font-weight: 600; }
.rule-summary { display: grid; grid-template-columns: minmax(220px, .7fr) minmax(320px, 1.3fr); gap: 32px; padding: 28px 0 22px; }
.addition-value { display: flex; min-width: 0; flex-direction: column; justify-content: center; padding: 6px 24px; border-right: 1px solid var(--cc-border); }
.addition-value > span { color: var(--cc-muted); font-size: 13px; }
.addition-value > strong { margin-top: 8px; color: #6941c6; font-size: 38px; line-height: 1; }
.addition-value small { margin-left: 5px; font-size: 15px; font-weight: 600; }
.addition-value p { margin: 12px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.7; }
.example-preview { min-width: 0; }
.example-title { display: flex; align-items: center; gap: 8px; color: #344054; }
.example-preview dl { display: grid; gap: 12px; margin: 18px 0 0; }
.example-preview dl > div { display: grid; grid-template-columns: minmax(140px, .8fr) minmax(180px, 1.2fr); gap: 16px; }
.example-preview dt { color: var(--cc-muted); font-size: 12px; }
.example-preview dd { margin: 0; color: var(--cc-text); font-weight: 600; overflow-wrap: anywhere; }
.updated-at { margin: 0; padding-top: 16px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
.rule-state { display: flex; min-height: 260px; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: #8797b2; text-align: center; }
.rule-state strong { color: #475467; font-size: 13px; }
.rule-state p { margin: 0; color: var(--cc-muted); font-size: 12px; }
.rule-error { display: flex; min-height: 180px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px; background: #fff4f3; }
.rule-error strong { color: #7a271a; }
.rule-error p { margin: 5px 0; color: #b42318; font-size: 12px; }
.editor-content { display: grid; gap: 20px; }
.customer-context { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 8px; background: #f5f7ff; }
.customer-context span,.customer-context small { color: var(--cc-muted); font-size: 12px; }
.customer-context strong { color: #25324a; }
.editor-formula { margin: 0; padding: 13px 15px; background: #f8fafc; color: #475467; font-size: 13px; line-height: 1.7; }
.editor-content :deep(.el-input-number) { width: min(260px, 100%); }
.live-preview { display: grid; gap: 6px; padding: 16px; border: 1px solid #d9d6fe; border-radius: 8px; background: #f8f7ff; }
.live-preview span,.live-preview small { color: var(--cc-muted); font-size: 12px; }
.live-preview strong { color: #42307d; }
.dialog-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.dialog-actions :deep(.el-button) { margin-left: 0; }
@media (max-width: 760px) {
.rule-heading { align-items: stretch; flex-direction: column; }
.heading-actions { width: 100%; }
.heading-actions :deep(.el-button) { min-height: 44px; flex: 1; }
.formula-band,.rule-summary { grid-template-columns: 1fr; }
.formula-band { margin: 0; }
.addition-value { padding: 4px 0 20px; border-right: 0; border-bottom: 1px solid var(--cc-border); }
.rule-summary { gap: 22px; }
.rule-error { align-items: stretch; flex-direction: column; }
.customer-context { grid-template-columns: 1fr; gap: 4px; }
.example-preview dl > div { grid-template-columns: 1fr; gap: 4px; }
.dialog-actions :deep(.el-button) { min-height: 44px; flex: 1; }
}
.rule-heading { align-items: center; }
.rule-heading h2 { margin: 0; color: var(--cc-text); font-size: 18px; }.rule-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 13px; }
.heading-actions,.dialog-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }.heading-actions :deep(.el-button),.dialog-actions :deep(.el-button) { margin-left: 0; }
.rule-card { min-width: 0; }.rule-card-heading { display: flex; min-height: 68px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); }.rule-card-heading h2 { margin: 0; color: var(--cc-text); font-size: 18px; }.formula-band { display: flex; min-height: 52px; align-items: center; flex-wrap: wrap; gap: 12px 30px; padding: 12px 18px; border-bottom: 1px solid var(--cc-border); background: #f8fafc; color: var(--cc-muted); font-size: 13px; }.formula-band strong { color: #344054; }.loading-state { min-height: 280px; }
.rule-state { display: flex; min-height: 220px; align-items: center; justify-content: center; flex-direction: column; gap: 8px; color: #8797b2; text-align: center; }.rule-state strong { color: #475467; font-size: 13px; }.rule-state p { margin: 0; color: var(--cc-muted); font-size: 12px; }
.rule-error { display: flex; min-height: 180px; align-items: center; justify-content: space-between; gap: 18px; padding: 20px; background: #fff4f3; }.rule-error strong { color: #7a271a; }.rule-error p { margin: 5px 0; color: #b42318; font-size: 12px; }
.volume-value { color: #1570ef; }.editor-content { display: grid; gap: 20px; }.editor-formula { margin: 0; padding: 13px 15px; color: #475467; font-size: 13px; line-height: 1.7; }.editor-table-wrap { overflow-x: auto; }.editor-table { width: 100%; min-width: 920px; border-collapse: collapse; }.editor-table th,.editor-table td { padding: 10px 12px; border-bottom: 1px solid var(--cc-border); text-align: left; white-space: nowrap; }.editor-table thead th { background: #eef0fa; color: #475467; font-size: 12px; }.editor-table tbody th { color: var(--cc-text); font-size: 13px; }.editor-table :deep(.el-input-number),.editor-table :deep(.el-input) { width: 140px; }.editor-table :deep(.el-input-number .el-input__wrapper) { width: 100%; }
@media (max-width: 760px) { .rule-heading { align-items: stretch; flex-direction: column; }.rule-card-heading { align-items: stretch; flex-direction: column; }.rule-card-heading :deep(.el-button) { min-height: 44px; }.formula-band { align-items: flex-start; flex-direction: column; gap: 8px; }.rule-error { align-items: stretch; flex-direction: column; }.dialog-actions { width: 100%; }.dialog-actions :deep(.el-button) { min-height: 44px; flex: 1; } }
</style>
@@ -15,11 +15,19 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import CustomerDetailView from './CustomerDetailView.vue'
const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
const { routerPush, routeControl } = vi.hoisted(() => ({
routerPush: vi.fn(),
routeControl: { setTenantId: (_tenantId: string) => {} },
}))
vi.mock('vue-router', async (importOriginal) => ({
...await importOriginal<typeof import('vue-router')>(),
useRoute: () => ({ params: { tenantId: '8' } }),
...await (async () => {
const { reactive } = await import('vue')
const route = reactive({ params: { tenantId: '8' } })
routeControl.setTenantId = (tenantId: string) => { route.params.tenantId = tenantId }
return { useRoute: () => route }
})(),
useRouter: () => ({ push: routerPush }),
}))
@@ -59,6 +67,7 @@ describe('CustomerDetailView', () => {
beforeEach(() => {
vi.clearAllMocks()
routeControl.setTenantId('8')
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() })
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:qualification-preview') })
@@ -121,7 +130,7 @@ describe('CustomerDetailView', () => {
.find((button) => button.textContent?.trim() === '计费规则')
billingTab?.click()
await flushPromises()
expect(document.body.textContent).toContain('暂无计费规则查看权限')
expect(document.body.textContent).toContain('暂无尺寸计算规则查看权限')
expect(document.body.textContent).not.toContain('计费规则尚未接入')
})
@@ -159,4 +168,28 @@ describe('CustomerDetailView', () => {
expect(listPlatformFirstLegOrders).toHaveBeenCalledTimes(3)
expect(listPlatformFirstLegOrders).toHaveBeenLastCalledWith({ tenantId: tenant.id, page: 1, size: 10 })
})
it('reloads a reused detail view and ignores the previous customer response', async () => {
const secondTenant = { ...tenant, id: '9', tenantCode: 'TEN9', tenantName: '远航供应链', legalName: '远航供应链有限公司' }
let resolveFirstTenant!: (value: PlatformTenantResource) => void
const firstTenantRequest = new Promise<PlatformTenantResource>((resolve) => { resolveFirstTenant = resolve })
vi.mocked(getPlatformTenant).mockImplementation((tenantId) => tenantId === '8' ? firstTenantRequest : Promise.resolve(secondTenant))
vi.mocked(listTenantQualifications).mockResolvedValue([])
vi.mocked(listPlatformTenantMembers).mockResolvedValue([])
vi.mocked(listPlatformTenantOrderCounts).mockImplementation(async (tenantIds) => [{ tenantId: tenantIds[0], orderCount: 0 }])
vi.mocked(listPlatformFirstLegOrders).mockResolvedValue({ records: [], page: 1, size: 10, total: 0, pages: 0 })
mount(CustomerDetailView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
routeControl.setTenantId('9')
await flushPromises()
expect(getPlatformTenant).toHaveBeenCalledWith('9')
expect(document.body.textContent).toContain('远航供应链')
resolveFirstTenant(tenant)
await flushPromises()
expect(document.body.textContent).toContain('远航供应链')
expect(document.body.textContent).not.toContain('柏悦物流')
})
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
ArrowLeft, BarChart3, Box, Building2, CircleDollarSign, ClipboardList, Eye,
@@ -54,6 +54,9 @@ const orderPageSize = ref(10)
const customerOrdersLoading = ref(false)
const customerOrdersError = ref<ApiErrorDetails | null>(null)
let skipNextOrderPageLoad = false
let detailRequestSerial = 0
let orderRequestSerial = 0
let disposed = false
const previews = ref<Record<string, PreviewState>>({})
const previewOpen = ref(false)
const previewName = ref('')
@@ -124,20 +127,23 @@ function transportLabel(order: PlatformFirstLegOrderResource) {
}
function formattedDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
async function loadCustomerOrders(page = orderPage.value) {
async function loadCustomerOrders(page = orderPage.value, requestedTenantId = tenantId.value) {
if (!access.can('platform:order:list')) return
const serial = ++orderRequestSerial
customerOrdersLoading.value = true
customerOrdersError.value = null
try {
const result = await listPlatformFirstLegOrders({ tenantId: tenantId.value, page, size: orderPageSize.value })
const result = await listPlatformFirstLegOrders({ tenantId: requestedTenantId, page, size: orderPageSize.value })
if (disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId.value) return
customerOrders.value = result.records
customerOrderTotal.value = result.total
if (orderCount.value === null) orderCount.value = result.total
} catch (error) {
if (disposed || serial !== orderRequestSerial || requestedTenantId !== tenantId.value) return
customerOrders.value = []
customerOrdersError.value = getApiErrorDetails(error, '运单记录加载失败,请稍后重试')
} finally {
customerOrdersLoading.value = false
if (!disposed && serial === orderRequestSerial) customerOrdersLoading.value = false
}
}
@@ -178,15 +184,25 @@ async function openPreview(item: PlatformTenantQualification) {
}
async function loadDetail() {
const requestedTenantId = tenantId.value
const serial = ++detailRequestSerial
loading.value = true
loadError.value = null
clearPreviews()
customer.value = null
qualifications.value = []
members.value = []
orderCount.value = null
customerOrders.value = []
customerOrderTotal.value = 0
customerOrdersError.value = null
try {
const [tenantResult, qualificationResult, memberResult] = await Promise.all([
getPlatformTenant(tenantId.value),
listTenantQualifications(tenantId.value),
listPlatformTenantMembers(tenantId.value),
getPlatformTenant(requestedTenantId),
listTenantQualifications(requestedTenantId),
listPlatformTenantMembers(requestedTenantId),
])
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
customer.value = tenantResult
qualifications.value = qualificationResult
members.value = memberResult
@@ -198,12 +214,13 @@ async function loadDetail() {
if (access.can('platform:order:list')) {
if (orderPage.value !== 1) skipNextOrderPageLoad = true
orderPage.value = 1
customerOrders.value = []
customerOrderTotal.value = 0
optional.push(listPlatformTenantOrderCounts([tenantId.value]).then((records) => {
orderCount.value = Number(records.find((item) => String(item.tenantId) === tenantId.value)?.orderCount ?? 0)
}).catch(() => { orderCount.value = null }))
optional.push(loadCustomerOrders(1))
optional.push(listPlatformTenantOrderCounts([requestedTenantId]).then((records) => {
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
orderCount.value = Number(records.find((item) => String(item.tenantId) === requestedTenantId)?.orderCount ?? 0)
}).catch(() => {
if (!disposed && serial === detailRequestSerial && requestedTenantId === tenantId.value) orderCount.value = null
}))
optional.push(loadCustomerOrders(1, requestedTenantId))
} else {
customerOrdersLoading.value = false
customerOrdersError.value = null
@@ -212,11 +229,14 @@ async function loadDetail() {
}
await Promise.all(optional)
} catch (error) {
if (disposed || serial !== detailRequestSerial || requestedTenantId !== tenantId.value) return
customer.value = null
qualifications.value = []
members.value = []
loadError.value = getApiErrorDetails(error, '客户详情加载失败,请稍后重试')
} finally { loading.value = false }
} finally {
if (!disposed && serial === detailRequestSerial) loading.value = false
}
}
function openOrder(order: PlatformFirstLegOrderResource) {
@@ -253,8 +273,25 @@ function handleTabKeydown(event: KeyboardEvent, key: DetailTab) {
button?.focus()
selectTab(tabs[next].key, button)
}
onMounted(loadDetail)
onBeforeUnmount(clearPreviews)
watch(tenantId, (value) => {
activeTab.value = 'basic'
if (value) void loadDetail()
else {
detailRequestSerial += 1
orderRequestSerial += 1
loading.value = false
customer.value = null
qualifications.value = []
members.value = []
customerOrders.value = []
}
}, { immediate: true })
onBeforeUnmount(() => {
disposed = true
detailRequestSerial += 1
orderRequestSerial += 1
clearPreviews()
})
watch(orderPage, (page, previousPage) => {
if (skipNextOrderPageLoad) {
skipNextOrderPageLoad = false
@@ -23,11 +23,23 @@ import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import CustomersView from './CustomersView.vue'
const { routerPush, routerReplace } = vi.hoisted(() => ({ routerPush: vi.fn(), routerReplace: vi.fn() }))
const { routerPush, routerReplace, routeControl } = vi.hoisted(() => ({
routerPush: vi.fn(),
routerReplace: vi.fn(),
routeControl: { setEditId: (_editId: string) => {} },
}))
vi.mock('vue-router', async (importOriginal) => ({
...await importOriginal<typeof import('vue-router')>(),
useRoute: () => ({ query: {} }),
...await (async () => {
const { reactive } = await import('vue')
const route = reactive<{ query: { edit?: string } }>({ query: {} })
routeControl.setEditId = (editId: string) => {
if (editId) route.query.edit = editId
else delete route.query.edit
}
return { useRoute: () => route }
})(),
useRouter: () => ({ push: routerPush, replace: routerReplace }),
}))
@@ -89,6 +101,7 @@ describe('CustomersView', () => {
beforeEach(() => {
vi.clearAllMocks()
routeControl.setEditId('')
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:qualification-preview') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
@@ -208,6 +221,18 @@ describe('CustomersView', () => {
expect(getPlatformTenant).not.toHaveBeenCalled()
})
it('opens the requested editor when a cached list receives an edit query', async () => {
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
routeControl.setEditId(String(fullTenant.id))
await flushPromises()
expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id)
expect(document.querySelector<HTMLInputElement>('input[placeholder="请输入手机号或固定电话"]')?.value).toBe(fullTenant.contactPhone)
expect(routerReplace).toHaveBeenCalledWith({ name: 'customers' })
})
it('saves both required files and the credit code through one package action', async () => {
vi.mocked(listTenantQualifications).mockResolvedValue([{
id: 'qualification-legal', tenantId: fullTenant.id, qualificationType: 'LEGAL_PERSON_ID',
+17 -12
View File
@@ -652,7 +652,23 @@ async function exportCustomers() {
if (openDownloadCenter) await router.push({ name: 'download-center' })
}
async function openRouteEditor(editId: string) {
if (!editId || !access.can('platform:customer:edit')) return
const row = result.value.records.find((item) => String(item.id) === editId)
try {
await openEdit(row ?? await getPlatformTenant(editId))
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '客户资料加载失败'))
}
if (route.query.edit === editId) void router.replace({ name: 'customers' })
}
watch([page, pageSize], () => { void loadCustomers() })
watch(
() => typeof route.query.edit === 'string' ? route.query.edit : '',
(editId) => { if (editId) void openRouteEditor(editId) },
{ immediate: true },
)
watch(editorOpen, (open) => {
if (!open) {
editorLoadSequence += 1
@@ -666,18 +682,7 @@ watch(
},
)
onBeforeUnmount(clearQualificationPreviews)
onMounted(async () => {
await loadCustomers()
const editId = typeof route.query.edit === 'string' ? route.query.edit : ''
if (!editId || !access.can('platform:customer:edit')) return
const row = result.value.records.find((item) => String(item.id) === editId)
if (row) await openEdit(row)
else {
try { await openEdit(await getPlatformTenant(editId)) }
catch (error) { ElMessage.error(getApiErrorMessage(error, '客户资料加载失败')) }
}
void router.replace({ name: 'customers' })
})
onMounted(() => { void loadCustomers() })
</script>
<template>