优化
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,54 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const http = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
}))
|
||||
const http = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }))
|
||||
vi.mock('@/shared/api/http', () => ({ http, unwrapApiResponse: <T>(response: { data: T }) => response.data }))
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http,
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
import { getCustomerDimensionConfig, updateCustomerDimensionConfig } from './api'
|
||||
import type { CustomerDimensionRule, UpdateCustomerDimensionRule } from './types'
|
||||
|
||||
import {
|
||||
getCustomerDimensionConfig,
|
||||
updateCustomerDimensionConfig,
|
||||
} from './api'
|
||||
|
||||
const rawConfig = {
|
||||
tenantId: 9007199254740993n.toString(),
|
||||
edgeAdditionCm: 2,
|
||||
version: 3,
|
||||
updatedAt: '2026-08-07T10:20:00',
|
||||
}
|
||||
const rawRule = {
|
||||
id: '9007199254740995',
|
||||
boxType: 'SMALL', boxTypeName: '小箱', innerLengthCm: 30, innerWidthCm: 20, innerHeightCm: 20,
|
||||
edgeAdditionCm: 2, outerLengthCm: 34, outerWidthCm: 24, outerHeightCm: 24, volumeCbm: 0.02,
|
||||
remark: '标准小箱', version: 3, createdAt: '2026-08-07T10:00:00', updatedAt: '2026-08-07T10:20:00',
|
||||
} satisfies CustomerDimensionRule
|
||||
|
||||
describe('customer dimension config api', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('loads the current customer config and keeps long ids as strings', async () => {
|
||||
http.get.mockResolvedValue({ data: rawConfig })
|
||||
|
||||
it('normalizes the wrapped config and keeps the customer id as a string', async () => {
|
||||
http.get.mockResolvedValue({ data: { tenantId: 9007199254740993n.toString(), rules: [rawRule] } })
|
||||
const result = await getCustomerDimensionConfig('9007199254740993')
|
||||
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/customer-dimension-config', {
|
||||
params: { tenantId: '9007199254740993' },
|
||||
})
|
||||
expect(result).toEqual(rawConfig)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/customer-dimension-config', { params: { tenantId: '9007199254740993' } })
|
||||
expect(result).toEqual({ tenantId: '9007199254740993', rules: [rawRule] })
|
||||
expect(result.rules[0].id).toBe('9007199254740995')
|
||||
})
|
||||
|
||||
it('sends the current version when updating the edge addition', async () => {
|
||||
http.put.mockResolvedValue({ data: { ...rawConfig, edgeAdditionCm: 3, version: 4 } })
|
||||
|
||||
const result = await updateCustomerDimensionConfig('9007199254740993', {
|
||||
edgeAdditionCm: 3,
|
||||
version: 3,
|
||||
})
|
||||
|
||||
expect(http.put).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/customer-dimension-config',
|
||||
{ edgeAdditionCm: 3, version: 3 },
|
||||
{ params: { tenantId: '9007199254740993' } },
|
||||
)
|
||||
expect(result).toMatchObject({ tenantId: '9007199254740993', edgeAdditionCm: 3, version: 4 })
|
||||
it('submits the complete rule set in one update request', async () => {
|
||||
const updateRule = {
|
||||
boxType: rawRule.boxType,
|
||||
innerLengthCm: rawRule.innerLengthCm,
|
||||
innerWidthCm: rawRule.innerWidthCm,
|
||||
innerHeightCm: rawRule.innerHeightCm,
|
||||
edgeAdditionCm: rawRule.edgeAdditionCm,
|
||||
remark: rawRule.remark,
|
||||
version: rawRule.version,
|
||||
} satisfies UpdateCustomerDimensionRule
|
||||
http.put.mockResolvedValue({ data: { tenantId: '8', rules: [rawRule] } })
|
||||
await updateCustomerDimensionConfig('8', { rules: [updateRule] })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/first-leg/customer-dimension-config', { rules: [updateRule] }, { params: { tenantId: '8' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type {
|
||||
CustomerDimensionConfig,
|
||||
CustomerDimensionRule,
|
||||
UpdateCustomerDimensionConfigRequest,
|
||||
} from './types'
|
||||
|
||||
const dimensionConfigPath = '/platform/v1/first-leg/customer-dimension-config'
|
||||
|
||||
type RawDimensionConfig = Omit<CustomerDimensionConfig, 'tenantId'> & {
|
||||
type RawRule = Omit<CustomerDimensionRule, 'id' | 'version'> & {
|
||||
id: string | number
|
||||
version: string | number
|
||||
}
|
||||
type RawConfig = Omit<CustomerDimensionConfig, 'tenantId' | 'rules'> & {
|
||||
tenantId: string | number
|
||||
rules: RawRule[]
|
||||
}
|
||||
|
||||
function normalizeConfig(config: RawDimensionConfig): CustomerDimensionConfig {
|
||||
function normalizeRule(rule: RawRule): CustomerDimensionRule {
|
||||
return { ...rule, id: String(rule.id), version: Number(rule.version) }
|
||||
}
|
||||
|
||||
function normalizeConfig(config: RawConfig): CustomerDimensionConfig {
|
||||
return {
|
||||
...config,
|
||||
tenantId: String(config.tenantId),
|
||||
rules: config.rules.map(normalizeRule),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCustomerDimensionConfig(tenantId: string): Promise<CustomerDimensionConfig> {
|
||||
const response = await http.get<never, ApiEnvelope<RawDimensionConfig>>(dimensionConfigPath, {
|
||||
const response = await http.get<never, ApiEnvelope<RawConfig>>(dimensionConfigPath, {
|
||||
params: { tenantId },
|
||||
})
|
||||
return normalizeConfig(unwrapApiResponse(response))
|
||||
@@ -28,7 +38,7 @@ export async function updateCustomerDimensionConfig(
|
||||
tenantId: string,
|
||||
request: UpdateCustomerDimensionConfigRequest,
|
||||
): Promise<CustomerDimensionConfig> {
|
||||
const response = await http.put<never, ApiEnvelope<RawDimensionConfig>>(dimensionConfigPath, request, {
|
||||
const response = await http.put<never, ApiEnvelope<RawConfig>>(dimensionConfigPath, request, {
|
||||
params: { tenantId },
|
||||
})
|
||||
return normalizeConfig(unwrapApiResponse(response))
|
||||
|
||||
@@ -4,7 +4,10 @@ export {
|
||||
} from './api'
|
||||
|
||||
export type {
|
||||
CustomerDimensionBoxType,
|
||||
CustomerDimensionConfig,
|
||||
CustomerDimensionConfigFormModel,
|
||||
CustomerDimensionRule,
|
||||
UpdateCustomerDimensionConfigRequest,
|
||||
UpdateCustomerDimensionRule,
|
||||
} from './types'
|
||||
|
||||
@@ -1,15 +1,42 @@
|
||||
export interface CustomerDimensionConfig {
|
||||
tenantId: string
|
||||
export type CustomerDimensionBoxType = 'SMALL' | 'MEDIUM' | 'LARGE' | 'EXTRA_LARGE'
|
||||
|
||||
export interface CustomerDimensionRule {
|
||||
id: string
|
||||
boxType: CustomerDimensionBoxType
|
||||
boxTypeName: string
|
||||
innerLengthCm: number
|
||||
innerWidthCm: number
|
||||
innerHeightCm: number
|
||||
edgeAdditionCm: number
|
||||
outerLengthCm: number
|
||||
outerWidthCm: number
|
||||
outerHeightCm: number
|
||||
volumeCbm: number
|
||||
remark: string | null
|
||||
version: number
|
||||
createdAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface CustomerDimensionConfig {
|
||||
tenantId: string
|
||||
rules: CustomerDimensionRule[]
|
||||
}
|
||||
|
||||
export type UpdateCustomerDimensionRule = Pick<CustomerDimensionRule,
|
||||
| 'boxType'
|
||||
| 'innerLengthCm'
|
||||
| 'innerWidthCm'
|
||||
| 'innerHeightCm'
|
||||
| 'edgeAdditionCm'
|
||||
| 'remark'
|
||||
| 'version'
|
||||
>
|
||||
|
||||
export interface UpdateCustomerDimensionConfigRequest {
|
||||
edgeAdditionCm: number
|
||||
version: number
|
||||
rules: UpdateCustomerDimensionRule[]
|
||||
}
|
||||
|
||||
export interface CustomerDimensionConfigFormModel {
|
||||
edgeAdditionCm: number
|
||||
rules: CustomerDimensionRule[]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
confirmWeightResult,
|
||||
correctWeightResult,
|
||||
createWeightResult,
|
||||
deleteWeightResult,
|
||||
downloadWeightImage,
|
||||
downloadWeightThumbnail,
|
||||
getWeightOcrRawText,
|
||||
getWeightWorkspace,
|
||||
normalizeWeightConfidence,
|
||||
@@ -13,7 +15,7 @@ import {
|
||||
} from './weightOcr'
|
||||
|
||||
vi.mock('@/shared/api/http', () => ({
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn() },
|
||||
http: { delete: vi.fn(), get: vi.fn(), post: vi.fn(), put: vi.fn() },
|
||||
ApiResponseError: class ApiResponseError extends Error {},
|
||||
unwrapApiResponse: <T>(response: { data: T }) => response.data,
|
||||
}))
|
||||
@@ -21,6 +23,7 @@ vi.mock('@/shared/api/http', () => ({
|
||||
describe('inbound weight OCR API', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
vi.mocked(http.delete).mockReset()
|
||||
vi.mocked(http.post).mockReset()
|
||||
vi.mocked(http.put).mockReset()
|
||||
})
|
||||
@@ -136,4 +139,22 @@ describe('inbound weight OCR API', () => {
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
})
|
||||
|
||||
it('downloads a thumbnail and deletes a result without converting long IDs', async () => {
|
||||
const image = new Blob(['jpg'], { type: 'image/jpeg' })
|
||||
vi.mocked(http.get).mockResolvedValue(image)
|
||||
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
|
||||
|
||||
await expect(downloadWeightThumbnail('10001', '20001', '9007199254740995')).resolves.toBe(image)
|
||||
expect(http.get).toHaveBeenCalledWith(
|
||||
'/platform/v1/files/measurements/10001/20001/9007199254740995/thumbnail',
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
|
||||
await deleteWeightResult('10001', '20001', '9007199254740997', 3)
|
||||
expect(http.delete).toHaveBeenCalledWith(
|
||||
'/platform/v1/first-leg/inbounds/20001/weight-results/9007199254740997',
|
||||
{ params: { tenantId: '10001', version: 3 } },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -94,6 +94,24 @@ export async function downloadWeightImage(tenantId: string, inboundId: string, f
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadWeightThumbnail(tenantId: string, inboundId: string, fileId: string) {
|
||||
try {
|
||||
const blob = await http.get<Blob, Blob>(
|
||||
`${imageBasePath}/${segment(tenantId)}/${segment(inboundId)}/${segment(fileId)}/thumbnail`,
|
||||
{ responseType: 'blob' },
|
||||
)
|
||||
const responseError = await parseBlobError(blob, 200)
|
||||
if (responseError) throw responseError
|
||||
return blob
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.data instanceof Blob) {
|
||||
const responseError = await parseBlobError(error.response.data, error.response.status)
|
||||
if (responseError) throw responseError
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWeightResult(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
@@ -138,3 +156,16 @@ export async function confirmWeightResult(
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function deleteWeightResult(
|
||||
tenantId: string,
|
||||
inboundId: string,
|
||||
resultId: string,
|
||||
version: number,
|
||||
) {
|
||||
const response = await http.delete<ApiEnvelope<null>, ApiEnvelope<null>>(
|
||||
`${measurementBasePath}/${segment(inboundId)}/weight-results/${segment(resultId)}`,
|
||||
{ params: { tenantId, version } },
|
||||
)
|
||||
unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { downloadWeightThumbnail } from '../api/weightOcr'
|
||||
import WeightImageThumbnail from './WeightImageThumbnail.vue'
|
||||
|
||||
vi.mock('../api/weightOcr', () => ({
|
||||
downloadWeightThumbnail: vi.fn(),
|
||||
}))
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('WeightImageThumbnail', () => {
|
||||
const createObjectURL = vi.fn(() => 'blob:thumbnail')
|
||||
const revokeObjectURL = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
|
||||
})
|
||||
|
||||
it('loads an authenticated thumbnail and releases its object URL', async () => {
|
||||
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' }))
|
||||
const wrapper = mount(WeightImageThumbnail, {
|
||||
props: { customerId: '10001', inboundId: '20001', fileId: '30001', alt: '称重图片 1' },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(downloadWeightThumbnail).toHaveBeenCalledWith('10001', '20001', '30001')
|
||||
expect(wrapper.get('img').attributes('src')).toBe('blob:thumbnail')
|
||||
await wrapper.unmount()
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:thumbnail')
|
||||
})
|
||||
|
||||
it('shows a stable failure state when the thumbnail cannot be read', async () => {
|
||||
vi.mocked(downloadWeightThumbnail).mockRejectedValue(new Error('network'))
|
||||
const wrapper = mount(WeightImageThumbnail, {
|
||||
props: { customerId: '10001', inboundId: '20001', fileId: '30001', alt: '称重图片 1' },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('图片加载失败')
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ImageOff } from '@lucide/vue'
|
||||
import { downloadWeightThumbnail } from '../api/weightOcr'
|
||||
|
||||
const props = defineProps<{
|
||||
customerId: string
|
||||
inboundId: string
|
||||
fileId: string
|
||||
alt: string
|
||||
}>()
|
||||
|
||||
defineEmits<{ open: [] }>()
|
||||
|
||||
const imageUrl = ref('')
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
let requestId = 0
|
||||
|
||||
function releaseImage() {
|
||||
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
|
||||
imageUrl.value = ''
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const currentRequest = ++requestId
|
||||
releaseImage()
|
||||
failed.value = false
|
||||
loading.value = true
|
||||
try {
|
||||
const blob = await downloadWeightThumbnail(props.customerId, props.inboundId, props.fileId)
|
||||
if (currentRequest !== requestId) return
|
||||
imageUrl.value = URL.createObjectURL(blob)
|
||||
} catch {
|
||||
if (currentRequest === requestId) failed.value = true
|
||||
} finally {
|
||||
if (currentRequest === requestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => [props.customerId, props.inboundId, props.fileId], load, { immediate: true })
|
||||
onBeforeUnmount(() => {
|
||||
requestId += 1
|
||||
releaseImage()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="weight-thumb" type="button" :aria-label="`查看${alt}`" @click="$emit('open')">
|
||||
<img v-if="imageUrl" :src="imageUrl" :alt="alt" loading="lazy" />
|
||||
<span v-else class="weight-thumb__state" :class="{ 'is-loading': loading }">
|
||||
<ImageOff v-if="failed" :size="22" aria-hidden="true" />
|
||||
<span>{{ failed ? '图片加载失败' : '图片加载中' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.weight-thumb { display: block; width: 100%; aspect-ratio: 4 / 3; overflow: hidden; padding: 0; border: 0; border-radius: 6px 6px 0 0; background: #f2f4f7; cursor: zoom-in; }
|
||||
.weight-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
.weight-thumb__state { display: grid; width: 100%; height: 100%; place-items: center; align-content: center; gap: 7px; color: #667085; font-size: 12px; }
|
||||
.weight-thumb__state.is-loading { color: #98a2b3; }
|
||||
.weight-thumb:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 2px; }
|
||||
</style>
|
||||
@@ -1,13 +1,19 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import {
|
||||
confirmWeightResult,
|
||||
correctWeightResult,
|
||||
createWeightResult,
|
||||
deleteWeightResult,
|
||||
downloadWeightImage,
|
||||
downloadWeightThumbnail,
|
||||
getWeightWorkspace,
|
||||
uploadWeightImage,
|
||||
} from '../api/weightOcr'
|
||||
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||
import WeightOcrPanel from './WeightOcrPanel.vue'
|
||||
@@ -18,7 +24,12 @@ vi.mock('../api/weightOcr', async () => {
|
||||
...actual,
|
||||
confirmWeightResult: vi.fn(),
|
||||
correctWeightResult: vi.fn(),
|
||||
createWeightResult: vi.fn(),
|
||||
deleteWeightResult: vi.fn(),
|
||||
downloadWeightImage: vi.fn(),
|
||||
downloadWeightThumbnail: vi.fn(),
|
||||
getWeightWorkspace: vi.fn(),
|
||||
uploadWeightImage: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -72,11 +83,40 @@ function mountPanel(permissions = allWeightPermissions) {
|
||||
})
|
||||
}
|
||||
|
||||
async function openWeightDialog(wrapper: VueWrapper) {
|
||||
await wrapper.get('button[aria-label="查看称重图片 1"]').trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function dialogWeightInput() {
|
||||
const input = document.body.querySelector<HTMLInputElement>('input[aria-label="当前图片确认重量"]')
|
||||
if (!input) throw new Error('weight confirmation input is missing')
|
||||
return new DOMWrapper(input)
|
||||
}
|
||||
|
||||
function dialogUnit(index: number) {
|
||||
const option = document.body.querySelectorAll<HTMLElement>('.el-dialog .el-segmented__item').item(index)
|
||||
return new DOMWrapper(option)
|
||||
}
|
||||
|
||||
function dialogConfirmButton() {
|
||||
const button = [...document.body.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')]
|
||||
.find((candidate) => candidate.textContent?.trim() === '确认重量')
|
||||
if (!button) throw new Error('weight confirmation button is missing')
|
||||
return new DOMWrapper(button)
|
||||
}
|
||||
|
||||
describe('WeightOcrPanel confirmation behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue(workspace)
|
||||
vi.mocked(downloadWeightImage).mockResolvedValue(new Blob(['image'], { type: 'image/jpeg' }))
|
||||
vi.mocked(downloadWeightThumbnail).mockResolvedValue(new Blob(['thumb'], { type: 'image/jpeg' }))
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: vi.fn(() => 'blob:test-image'),
|
||||
revokeObjectURL: vi.fn(),
|
||||
})
|
||||
vi.mocked(confirmWeightResult).mockResolvedValue({
|
||||
...pendingWeight,
|
||||
recognitionStatus: 'CONFIRMED',
|
||||
@@ -85,11 +125,79 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('confirms an unchanged OCR value with one request', async () => {
|
||||
it('opens the editable confirmation dialog immediately after upload', async () => {
|
||||
vi.mocked(uploadWeightImage).mockResolvedValue({
|
||||
fileId: '40002',
|
||||
fileName: 'weight.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
sizeBytes: 100,
|
||||
recognitionStatus: 'SUCCESS',
|
||||
weightValue: 12.5,
|
||||
weightUnit: 'KG',
|
||||
confidence: 0.98,
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(createWeightResult).mockResolvedValue({
|
||||
...pendingWeight,
|
||||
id: '30002',
|
||||
fileId: '40002',
|
||||
ocrWeightValue: 12.5,
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
const file = new File(['image'], 'weight.jpg', { type: 'image/jpeg' })
|
||||
Object.defineProperty(input.element, 'files', { configurable: true, value: [file] })
|
||||
|
||||
await input.trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadWeightImage).toHaveBeenCalledWith('10001', '20001', file)
|
||||
expect(document.body.textContent).toContain('确认称重图片')
|
||||
expect(document.body.textContent).toContain('识别重量 12.500 KG')
|
||||
expect(document.body.querySelector('input[aria-label="当前图片确认重量"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders thirty compact image cards without technical OCR details', async () => {
|
||||
vi.mocked(getWeightWorkspace).mockResolvedValue({
|
||||
...workspace,
|
||||
weightResults: Array.from({ length: 30 }, (_, index) => ({
|
||||
...pendingWeight,
|
||||
id: String(30001 + index),
|
||||
fileId: String(40001 + index),
|
||||
ocrRawText: `raw-${index}`,
|
||||
})),
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
expect(wrapper.findAll('.weight-card')).toHaveLength(30)
|
||||
expect(wrapper.findAll('.weight-thumb')).toHaveLength(30)
|
||||
expect(wrapper.text()).not.toContain('置信度')
|
||||
expect(wrapper.text()).not.toContain('OCR 原文')
|
||||
expect(wrapper.text()).not.toContain('FILE_WEIGHT_OCR')
|
||||
expect(wrapper.text()).not.toContain('raw-0')
|
||||
})
|
||||
|
||||
it('deletes a pending image with its optimistic-lock version', async () => {
|
||||
vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
|
||||
vi.mocked(deleteWeightResult).mockResolvedValue(undefined)
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('button[aria-label="删除称重图片 1"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(deleteWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', 0)
|
||||
})
|
||||
|
||||
it('confirms an unchanged OCR value with one request', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
expect(document.body.textContent).not.toContain('识别重量 285.500 KG')
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledTimes(1)
|
||||
@@ -100,9 +208,10 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
it('sends the adjusted value and a specific audit reason in the same confirmation request', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('286.125')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await dialogWeightInput().setValue('286.125')
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledTimes(1)
|
||||
@@ -118,10 +227,11 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
it('sends an LB adjustment atomically after switching units', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('10.125')
|
||||
await wrapper.findAll('.el-segmented__item').at(1)?.trigger('click')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await dialogWeightInput().setValue('10.125')
|
||||
await dialogUnit(1).trigger('click')
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
@@ -136,21 +246,23 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
it('rejects an LB value that would round to zero kilograms', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('0.001')
|
||||
await wrapper.findAll('.el-segmented__item').at(1)?.trigger('click')
|
||||
await dialogWeightInput().setValue('0.001')
|
||||
await dialogUnit(1).trigger('click')
|
||||
|
||||
expect(wrapper.get('.weight-row__editor .el-button--success').attributes('disabled')).toBeDefined()
|
||||
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
|
||||
expect(confirmWeightResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts the smallest LB value that remains representable in kilograms', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('0.002')
|
||||
await wrapper.findAll('.el-segmented__item').at(1)?.trigger('click')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await dialogWeightInput().setValue('0.002')
|
||||
await dialogUnit(1).trigger('click')
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
@@ -164,11 +276,12 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
it('clamps a value outside the safe three-decimal JavaScript range', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
const input = wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]')
|
||||
await openWeightDialog(wrapper)
|
||||
const input = dialogWeightInput()
|
||||
|
||||
await input.setValue('9000000000001')
|
||||
expect((input.element as HTMLInputElement).value).toBe('9000000000000.000')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
@@ -182,11 +295,12 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
it('rounds an input with more than three decimals before confirmation', async () => {
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
const input = wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]')
|
||||
await openWeightDialog(wrapper)
|
||||
const input = dialogWeightInput()
|
||||
|
||||
await input.setValue('1.2345')
|
||||
expect((input.element as HTMLInputElement).value).toBe('1.235')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
@@ -210,8 +324,9 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 1 })
|
||||
@@ -224,9 +339,10 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
'platform:firstleg:inbound:weight:confirm',
|
||||
])
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
expect(wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
expect(dialogWeightInput().attributes('disabled')).toBeDefined()
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 })
|
||||
@@ -238,8 +354,11 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
'platform:firstleg:inbound:weight:edit',
|
||||
])
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
expect(wrapper.find('.weight-row__editor .el-button--success').exists()).toBe(false)
|
||||
const confirmButtons = [...document.body.querySelectorAll<HTMLButtonElement>('.el-dialog__footer button')]
|
||||
.filter((button) => button.textContent?.trim() === '确认重量')
|
||||
expect(confirmButtons).toHaveLength(0)
|
||||
expect(confirmWeightResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -256,10 +375,11 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
expect(wrapper.get('.weight-row__editor .el-button--success').attributes('disabled')).toBeDefined()
|
||||
await wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]').setValue('1.500')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
expect(dialogConfirmButton().attributes('disabled')).toBeDefined()
|
||||
await dialogWeightInput().setValue('1.500')
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
@@ -277,8 +397,9 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', { version: 0 })
|
||||
@@ -291,8 +412,9 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
@@ -310,8 +432,9 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
})
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
await openWeightDialog(wrapper)
|
||||
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirmWeightResult).toHaveBeenCalledWith('10001', '20001', '30001', {
|
||||
@@ -326,10 +449,11 @@ describe('WeightOcrPanel confirmation behavior', () => {
|
||||
vi.mocked(confirmWeightResult).mockRejectedValueOnce(new Error('version conflict'))
|
||||
const wrapper = mountPanel()
|
||||
await flushPromises()
|
||||
const input = wrapper.get('input[aria-label="称重图片序号 1 的确认重量"]')
|
||||
await openWeightDialog(wrapper)
|
||||
const input = dialogWeightInput()
|
||||
|
||||
await input.setValue('286.125')
|
||||
await wrapper.get('.weight-row__editor .el-button--success').trigger('click')
|
||||
await dialogConfirmButton().trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect((input.element as HTMLInputElement).value).toBe('286.125')
|
||||
|
||||
@@ -14,29 +14,29 @@ describe('WeightOcrPanel contract', () => {
|
||||
expect(template).not.toContain('租户')
|
||||
})
|
||||
|
||||
it('covers upload, preview, OCR retry, inline correction, confirmation, and server totals', () => {
|
||||
it('covers upload, immediate preview, inline correction, deletion, confirmation, and server totals', () => {
|
||||
expect(source).toContain('uploadWeightImage')
|
||||
expect(source).toContain('downloadWeightImage')
|
||||
expect(source).toContain('retryWeightRecognition')
|
||||
expect(source).toContain('correctWeightResult')
|
||||
expect(source).toContain('await openPreview(created, URL.createObjectURL(file))')
|
||||
expect(source).toContain('previewShowsRecognition.value = Boolean(localUrl)')
|
||||
expect(source).toContain('deleteWeightResult')
|
||||
expect(source).toContain('confirmWeightResult')
|
||||
expect(source).toContain('实际总毛重(服务端汇总)')
|
||||
expect(source).toContain('称重图片序号 #{{ index + 1 }}')
|
||||
expect(source).toContain('workspace.value?.actualGrossWeightKg')
|
||||
expect(source).toContain('v-model="weightDrafts[row.id].value"')
|
||||
expect(source).toContain('识别值已自动填入,可修改后直接确认保存。')
|
||||
expect(source).toContain('class="weight-card__value"')
|
||||
expect(source).toContain('v-model="weightDrafts[previewResult.id].value"')
|
||||
expect(source).toContain('识别重量')
|
||||
expect(source).toContain('payload.adjustedWeightValue = Number(draft.value)')
|
||||
expect(source).toContain('payload.adjustmentReason = buildAdjustmentReason(row)')
|
||||
expect(source).not.toContain('version = corrected.version')
|
||||
expect(source).not.toContain('title="人工修正重量"')
|
||||
})
|
||||
|
||||
it('matches backend image limits and preserves missing OCR text honestly', () => {
|
||||
it('matches backend image limits and keeps technical OCR data out of the list', () => {
|
||||
expect(source).toContain("['image/jpeg', 'image/png', 'image/bmp']")
|
||||
expect(source).toContain('5 * 1024 * 1024')
|
||||
expect(source).toContain('图片服务未返回 OCR 原文')
|
||||
expect(source).toContain('ocrRawText: getWeightOcrRawText(upload)')
|
||||
expect(source).toContain("adjustmentReason: 'OCR 重试识别结果'")
|
||||
const template = source.match(/<template>([\s\S]*?)<\/template>/)?.[1] || ''
|
||||
expect(template).not.toContain('置信度')
|
||||
expect(template).not.toContain('OCR 原文')
|
||||
expect(template).not.toContain('提供方')
|
||||
expect(template).not.toContain('文件 ID')
|
||||
})
|
||||
|
||||
it('gates every action with the implemented permissions and editable statuses', () => {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
FileImage,
|
||||
ImagePlus,
|
||||
RefreshCw,
|
||||
Scale,
|
||||
Trash2,
|
||||
} from '@lucide/vue'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -16,15 +13,15 @@ import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import {
|
||||
confirmWeightResult,
|
||||
correctWeightResult,
|
||||
createWeightResult,
|
||||
deleteWeightResult,
|
||||
downloadWeightImage,
|
||||
getWeightOcrRawText,
|
||||
getWeightWorkspace,
|
||||
retryWeightRecognition,
|
||||
uploadWeightImage,
|
||||
} from '../api/weightOcr'
|
||||
import type { WeightResultResource, WeightWorkspaceResource } from '../types'
|
||||
import WeightImageThumbnail from './WeightImageThumbnail.vue'
|
||||
|
||||
const props = defineProps<{ customerId?: string; tenantId?: string; inboundId: string }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
@@ -41,6 +38,9 @@ const previewOpen = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref<ApiErrorDetails | null>(null)
|
||||
const previewUrl = ref('')
|
||||
const previewShowsRecognition = ref(false)
|
||||
const previewResultId = ref('')
|
||||
const previewSnapshot = ref<WeightResultResource | null>(null)
|
||||
const weightDrafts = ref<Record<string, { value: number | null; unit: 'KG' | 'LB' }>>({})
|
||||
let loadSequence = 0
|
||||
|
||||
@@ -53,29 +53,13 @@ const canConfirm = computed(() => access.can('platform:firstleg:inbound:weight:c
|
||||
const canView = computed(() => access.can('platform:firstleg:inbound:measure:view'))
|
||||
const editable = computed(() => Boolean(workspace.value && editableStatuses.has(workspace.value.processStatus)))
|
||||
const rows = computed(() => workspace.value?.weightResults ?? [])
|
||||
const confirmedCount = computed(() => rows.value.filter((row) => row.recognitionStatus === 'CONFIRMED').length)
|
||||
const pendingCount = computed(() => rows.value.length - confirmedCount.value)
|
||||
const confirmedTotal = computed(() => workspace.value?.actualGrossWeightKg ?? null)
|
||||
const previewResult = computed(() => rows.value.find((row) => row.id === previewResultId.value)
|
||||
|| previewSnapshot.value)
|
||||
|
||||
function formatWeight(value: number | null, unit = 'KG') {
|
||||
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
|
||||
}
|
||||
|
||||
function formatConfidence(value: number | null) {
|
||||
return value == null ? '未返回' : `${(Number(value) * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
return value?.replace('T', ' ').slice(0, 16) || '—'
|
||||
}
|
||||
|
||||
function statusMeta(status: string) {
|
||||
if (status === 'CONFIRMED') return { label: '已确认', type: 'success' as const }
|
||||
if (status === 'PENDING_CONFIRM') return { label: '待确认', type: 'warning' as const }
|
||||
if (status === 'NEEDS_MANUAL') return { label: '需人工处理', type: 'danger' as const }
|
||||
return { label: status || '未知', type: 'info' as const }
|
||||
}
|
||||
|
||||
function actionKey(action: string, id: string) {
|
||||
return `${action}:${id}`
|
||||
}
|
||||
@@ -99,6 +83,13 @@ function syncWeightDrafts(results: WeightResultResource[]) {
|
||||
}]))
|
||||
}
|
||||
|
||||
function ensureWeightDraft(row: WeightResultResource) {
|
||||
weightDrafts.value[row.id] ??= {
|
||||
value: row.adjustedWeightValue ?? row.ocrWeightValue,
|
||||
unit: normalizeUnit(row.adjustedWeightUnit ?? row.ocrWeightUnit),
|
||||
}
|
||||
}
|
||||
|
||||
function isWeightDraftValid(row: WeightResultResource) {
|
||||
const draft = weightDrafts.value[row.id]
|
||||
const value = Number(draft?.value)
|
||||
@@ -108,6 +99,12 @@ function isWeightDraftValid(row: WeightResultResource) {
|
||||
&& value <= maximumSafeWeight)
|
||||
}
|
||||
|
||||
function cardWeight(row: WeightResultResource) {
|
||||
if (row.recognitionStatus === 'CONFIRMED') return formatWeight(row.finalWeightKg)
|
||||
const draft = weightDrafts.value[row.id]
|
||||
return formatWeight(draft?.value ?? null, draft?.unit ?? 'KG')
|
||||
}
|
||||
|
||||
function hasWeightDraftChanged(row: WeightResultResource) {
|
||||
const draft = weightDrafts.value[row.id]
|
||||
if (!draft) return false
|
||||
@@ -144,7 +141,6 @@ async function loadWorkspace() {
|
||||
}
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
workspace.value = null
|
||||
loadError.value = getApiErrorDetails(error, '称重与 OCR 数据加载失败,请稍后重试')
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
@@ -179,7 +175,7 @@ async function handleFileChange(event: Event) {
|
||||
actionError.value = null
|
||||
try {
|
||||
const upload = await uploadWeightImage(customerId.value, props.inboundId, file)
|
||||
await createWeightResult(customerId.value, props.inboundId, {
|
||||
const created = await createWeightResult(customerId.value, props.inboundId, {
|
||||
fileId: String(upload.fileId),
|
||||
ocrProvider: 'FILE_WEIGHT_OCR',
|
||||
ocrRawText: getWeightOcrRawText(upload),
|
||||
@@ -187,10 +183,10 @@ async function handleFileChange(event: Event) {
|
||||
ocrWeightUnit: upload.weightUnit,
|
||||
confidence: upload.confidence,
|
||||
})
|
||||
ElMessage.success(upload.recognitionStatus === 'FAILED'
|
||||
? '图片已保存,OCR 未识别出重量,请人工修正或重试'
|
||||
: '称重图片已上传并完成识别')
|
||||
ensureWeightDraft(created)
|
||||
await openPreview(created, URL.createObjectURL(file))
|
||||
await loadWorkspace()
|
||||
ensureWeightDraft(created)
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '称重图片上传失败,请检查后重试')
|
||||
@@ -199,7 +195,7 @@ async function handleFileChange(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmResult(row: WeightResultResource) {
|
||||
async function confirmResult(row: WeightResultResource, closeAfterSuccess = false) {
|
||||
const draft = weightDrafts.value[row.id]
|
||||
if (!draft || !isWeightDraftValid(row)) {
|
||||
ElMessage.warning('请输入大于 0 的有效重量')
|
||||
@@ -220,39 +216,17 @@ async function confirmResult(row: WeightResultResource) {
|
||||
payload.adjustmentReason = buildAdjustmentReason(row)
|
||||
}
|
||||
await confirmWeightResult(customerId.value, props.inboundId, row.id, payload)
|
||||
ElMessage.success('重量已确认并计入实际总重量')
|
||||
ElMessage.success('重量已确认')
|
||||
await loadWorkspace()
|
||||
if (closeAfterSuccess) previewOpen.value = false
|
||||
emit('changed')
|
||||
return true
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '重量确认失败,请刷新后重试')
|
||||
} finally {
|
||||
activeAction.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function retryOcr(row: WeightResultResource) {
|
||||
const key = actionKey('retry', row.id)
|
||||
activeAction.value = key
|
||||
actionError.value = null
|
||||
try {
|
||||
const result = await retryWeightRecognition(customerId.value, props.inboundId, row.fileId)
|
||||
if (result.weightValue == null || !result.weightUnit) {
|
||||
throw new Error(result.error || 'OCR 仍未识别出有效重量,可改用人工修正')
|
||||
}
|
||||
await correctWeightResult(customerId.value, props.inboundId, row.id, {
|
||||
version: row.version,
|
||||
adjustedWeightValue: result.weightValue,
|
||||
adjustedWeightUnit: result.weightUnit.toUpperCase().startsWith('LB') ? 'LB' : 'KG',
|
||||
adjustmentReason: 'OCR 重试识别结果',
|
||||
})
|
||||
ElMessage.success('OCR 重试成功,请核对并确认重量')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, 'OCR 重试失败,可稍后重试或人工修正')
|
||||
} finally {
|
||||
activeAction.value = ''
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function releasePreview() {
|
||||
@@ -260,10 +234,18 @@ function releasePreview() {
|
||||
previewUrl.value = ''
|
||||
}
|
||||
|
||||
async function openPreview(row: WeightResultResource) {
|
||||
async function openPreview(row: WeightResultResource, localUrl = '') {
|
||||
releasePreview()
|
||||
previewResultId.value = row.id
|
||||
previewSnapshot.value = row
|
||||
previewShowsRecognition.value = Boolean(localUrl)
|
||||
previewError.value = null
|
||||
previewOpen.value = true
|
||||
if (localUrl) {
|
||||
previewUrl.value = localUrl
|
||||
previewLoading.value = false
|
||||
return
|
||||
}
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const blob = await downloadWeightImage(customerId.value, props.inboundId, row.fileId)
|
||||
@@ -275,6 +257,34 @@ async function openPreview(row: WeightResultResource) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeResult(row: WeightResultResource) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
row.recognitionStatus === 'CONFIRMED'
|
||||
? '删除后将重新计算实际总毛重,确认删除这张称重图片吗?'
|
||||
: '确认删除这张称重图片吗?',
|
||||
'删除称重图片',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const key = actionKey('delete', row.id)
|
||||
activeAction.value = key
|
||||
actionError.value = null
|
||||
try {
|
||||
await deleteWeightResult(customerId.value, props.inboundId, row.id, row.version)
|
||||
if (previewResultId.value === row.id) previewOpen.value = false
|
||||
ElMessage.success('称重图片已删除')
|
||||
await loadWorkspace()
|
||||
emit('changed')
|
||||
} catch (error) {
|
||||
actionError.value = getApiErrorDetails(error, '称重图片删除失败,请刷新后重试')
|
||||
} finally {
|
||||
activeAction.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [customerId.value, props.inboundId, canView.value] as const,
|
||||
() => {
|
||||
@@ -300,8 +310,8 @@ defineExpose({ reload: loadWorkspace })
|
||||
<section class="weight-workspace" aria-labelledby="weight-ocr-title">
|
||||
<header class="weight-heading">
|
||||
<div>
|
||||
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片与 OCR</h3>
|
||||
<p>逐张核对识别值并确认,服务端汇总所有已确认图片的实际总重量。</p>
|
||||
<h3 id="weight-ocr-title"><Scale :size="18" aria-hidden="true" />称重图片</h3>
|
||||
<p>上传后直接核对重量,确认结果自动计入实际总毛重。</p>
|
||||
</div>
|
||||
<div class="weight-heading__actions">
|
||||
<input
|
||||
@@ -339,12 +349,6 @@ defineExpose({ reload: loadWorkspace })
|
||||
</LoadErrorState>
|
||||
|
||||
<template v-else>
|
||||
<div class="weight-metrics" aria-live="polite" :aria-busy="loading">
|
||||
<div><span>实际总毛重(服务端汇总)</span><strong>{{ formatWeight(confirmedTotal) }}</strong></div>
|
||||
<div><span>已确认图片</span><strong>{{ confirmedCount }}</strong></div>
|
||||
<div><span>待处理图片</span><strong :class="{ 'is-warning': pendingCount > 0 }">{{ pendingCount }}</strong></div>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="workspace && !editable" type="info" :closable="false" show-icon class="weight-state-note">
|
||||
当前入库状态为 {{ workspace.processStatus }},称重数据仅供查看。
|
||||
</el-alert>
|
||||
@@ -362,97 +366,55 @@ defineExpose({ reload: loadWorkspace })
|
||||
|
||||
<div v-loading="loading" class="weight-list" aria-live="polite">
|
||||
<div v-if="!loading && rows.length === 0" class="weight-empty">
|
||||
<FileImage :size="28" aria-hidden="true" />
|
||||
<strong>尚未上传称重图片</strong>
|
||||
<p>上传 JPG、PNG 或 BMP 图片,单张不超过 5MB。</p>
|
||||
</div>
|
||||
|
||||
<article v-for="(row, index) in rows" :key="row.id" class="weight-row">
|
||||
<div class="weight-row__identity">
|
||||
<span class="weight-row__icon"><FileImage :size="18" aria-hidden="true" /></span>
|
||||
<div>
|
||||
<strong>称重图片序号 #{{ index + 1 }}</strong>
|
||||
<small>文件 ID {{ row.fileId }} · {{ row.ocrProvider || 'OCR 服务未标识' }}</small>
|
||||
<div v-else class="weight-grid">
|
||||
<article v-for="(row, index) in rows" :key="row.id" class="weight-card">
|
||||
<WeightImageThumbnail
|
||||
:customer-id="customerId"
|
||||
:inbound-id="inboundId"
|
||||
:file-id="row.fileId"
|
||||
:alt="`称重图片 ${index + 1}`"
|
||||
@open="openPreview(row)"
|
||||
/>
|
||||
<el-tooltip v-if="canEdit && editable" content="删除图片" placement="top">
|
||||
<el-button
|
||||
text
|
||||
type="danger"
|
||||
class="weight-delete"
|
||||
:aria-label="`删除称重图片 ${index + 1}`"
|
||||
:loading="isActionLoading('delete', row.id)"
|
||||
:disabled="Boolean(activeAction)"
|
||||
@click="removeResult(row)"
|
||||
><Trash2 :size="16" /></el-button>
|
||||
</el-tooltip>
|
||||
<div class="weight-card__body">
|
||||
<button
|
||||
type="button"
|
||||
class="weight-card__value"
|
||||
:aria-label="`查看称重图片 ${index + 1},重量 ${cardWeight(row)}`"
|
||||
@click="openPreview(row)"
|
||||
><strong>{{ cardWeight(row) }}</strong></button>
|
||||
</div>
|
||||
<el-tag :type="statusMeta(row.recognitionStatus).type" effect="light">
|
||||
{{ statusMeta(row.recognitionStatus).label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<dl class="weight-row__facts">
|
||||
<div><dt>OCR 原值</dt><dd>{{ formatWeight(row.ocrWeightValue, row.ocrWeightUnit || '') }}</dd></div>
|
||||
<div><dt>置信度</dt><dd>{{ formatConfidence(row.confidence) }}</dd></div>
|
||||
<div><dt>人工修正</dt><dd>{{ formatWeight(row.adjustedWeightValue, row.adjustedWeightUnit || '') }}</dd></div>
|
||||
<div><dt>最终计入</dt><dd class="weight-final">{{ formatWeight(row.finalWeightKg) }}</dd></div>
|
||||
</dl>
|
||||
|
||||
<div class="weight-row__raw">
|
||||
<span>OCR 原文</span>
|
||||
<p>{{ row.ocrRawText || '图片服务未返回 OCR 原文' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="row.adjustmentReason || row.confirmedAt" class="weight-row__audit">
|
||||
<span v-if="row.adjustmentReason">修正原因:{{ row.adjustmentReason }}</span>
|
||||
<span v-if="row.confirmedAt">确认时间:{{ formatTime(row.confirmedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="row.recognitionStatus !== 'CONFIRMED' && weightDrafts[row.id]"
|
||||
class="weight-row__editor"
|
||||
>
|
||||
<label>
|
||||
<span>确认重量</span>
|
||||
<el-input-number
|
||||
v-model="weightDrafts[row.id].value"
|
||||
:aria-label="`称重图片序号 ${index + 1} 的确认重量`"
|
||||
:min="minimumWeight[weightDrafts[row.id].unit]"
|
||||
:max="maximumSafeWeight"
|
||||
:precision="3"
|
||||
:controls="false"
|
||||
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>单位</span>
|
||||
<el-segmented
|
||||
v-model="weightDrafts[row.id].unit"
|
||||
:aria-label="`称重图片序号 ${index + 1} 的重量单位`"
|
||||
:options="['KG', 'LB']"
|
||||
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
||||
/>
|
||||
</label>
|
||||
<el-button
|
||||
v-if="canConfirm && editable"
|
||||
type="success"
|
||||
:loading="isActionLoading('confirm', row.id)"
|
||||
:disabled="Boolean(activeAction) || !isWeightDraftValid(row) || (hasWeightDraftChanged(row) && !canEdit)"
|
||||
@click="confirmResult(row)"
|
||||
><CheckCircle2 :size="16" />确认并保存</el-button>
|
||||
<small>识别值已自动填入,可修改后直接确认保存。</small>
|
||||
</div>
|
||||
|
||||
<footer class="weight-row__actions">
|
||||
<el-button link type="primary" @click="openPreview(row)"><Eye :size="15" />查看图片</el-button>
|
||||
<el-button
|
||||
v-if="canEdit && editable && row.recognitionStatus !== 'CONFIRMED'"
|
||||
link
|
||||
type="primary"
|
||||
:loading="isActionLoading('retry', row.id)"
|
||||
:disabled="Boolean(activeAction)"
|
||||
@click="retryOcr(row)"
|
||||
><RefreshCw :size="15" />重试 OCR</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<AppDialog
|
||||
v-model="previewOpen"
|
||||
title="称重图片"
|
||||
title="确认称重图片"
|
||||
:loading="previewLoading"
|
||||
:show-footer="false"
|
||||
width="min(760px, calc(100vw - 28px))"
|
||||
:confirm-loading="Boolean(previewResult && isActionLoading('confirm', previewResult.id))"
|
||||
:confirm-disabled="!previewResult || !isWeightDraftValid(previewResult) || Boolean(activeAction)"
|
||||
:show-confirm="Boolean(previewResult && previewResult.recognitionStatus !== 'CONFIRMED' && canConfirm && editable)"
|
||||
confirm-text="确认重量"
|
||||
confirm-type="success"
|
||||
width="min(680px, calc(100vw - 28px))"
|
||||
@confirm="previewResult && confirmResult(previewResult, true)"
|
||||
>
|
||||
<LoadErrorState
|
||||
v-if="previewError"
|
||||
@@ -463,7 +425,35 @@ defineExpose({ reload: loadWorkspace })
|
||||
>
|
||||
<template #details><ApiErrorMeta :details="previewError" /></template>
|
||||
</LoadErrorState>
|
||||
<img v-else-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
|
||||
<template v-else-if="previewResult">
|
||||
<img v-if="previewUrl" class="weight-preview" :src="previewUrl" alt="当前入库单的称重图片" />
|
||||
<div v-if="previewResult.recognitionStatus === 'CONFIRMED'" class="preview-weight is-confirmed">
|
||||
<span>确认重量</span><strong>{{ formatWeight(previewResult.finalWeightKg) }}</strong>
|
||||
</div>
|
||||
<div v-else-if="weightDrafts[previewResult.id]" class="preview-weight">
|
||||
<span v-if="previewShowsRecognition" class="preview-weight__recognized">
|
||||
识别重量 {{ formatWeight(previewResult.ocrWeightValue, previewResult.ocrWeightUnit || '') }}
|
||||
</span>
|
||||
<label>
|
||||
<span>确认重量</span>
|
||||
<el-input-number
|
||||
v-model="weightDrafts[previewResult.id].value"
|
||||
aria-label="当前图片确认重量"
|
||||
:min="minimumWeight[weightDrafts[previewResult.id].unit]"
|
||||
:max="maximumSafeWeight"
|
||||
:precision="3"
|
||||
:controls="false"
|
||||
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
||||
/>
|
||||
</label>
|
||||
<el-segmented
|
||||
v-model="weightDrafts[previewResult.id].unit"
|
||||
aria-label="当前图片重量单位"
|
||||
:options="['KG', 'LB']"
|
||||
:disabled="Boolean(activeAction) || !editable || !canEdit || !canConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</section>
|
||||
</template>
|
||||
@@ -476,61 +466,36 @@ defineExpose({ reload: loadWorkspace })
|
||||
.weight-heading p { margin: 6px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; }
|
||||
.weight-heading__actions { flex: 0 0 auto; }
|
||||
.visually-hidden-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||
.weight-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 18px 0; border: 1px solid var(--cc-border); border-radius: 8px; background: #f8fafc; }
|
||||
.weight-metrics > div { min-width: 0; padding: 15px 18px; border-right: 1px solid var(--cc-border); }
|
||||
.weight-metrics > div:last-child { border-right: 0; }
|
||||
.weight-metrics span { display: block; margin-bottom: 5px; color: var(--cc-muted); font-size: 11px; }
|
||||
.weight-metrics strong { font-size: 20px; line-height: 1.2; }
|
||||
.weight-metrics strong.is-warning { color: #b54708; }
|
||||
.weight-state-note, .weight-action-error { margin-bottom: 14px; }
|
||||
.weight-list { min-height: 120px; }
|
||||
.weight-state-note, .weight-action-error { margin-bottom: 14px; }
|
||||
.weight-list { min-height: 120px; margin-top: 16px; }
|
||||
.weight-empty { display: grid; min-height: 180px; place-items: center; align-content: center; gap: 8px; padding: 24px; color: var(--cc-muted); text-align: center; }
|
||||
.weight-empty svg { color: #98a2b3; }
|
||||
.weight-empty strong { color: #344054; font-size: 14px; }
|
||||
.weight-empty p { margin: 0; font-size: 12px; }
|
||||
.weight-row { padding: 18px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.weight-row:last-child { border-bottom: 0; }
|
||||
.weight-row__identity { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; align-items: center; gap: 10px; }
|
||||
.weight-row__icon { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 7px; background: #eef4ff; color: var(--cc-primary); }
|
||||
.weight-row__identity strong, .weight-row__identity small { display: block; min-width: 0; overflow-wrap: anywhere; }
|
||||
.weight-row__identity strong { font-size: 13px; }
|
||||
.weight-row__identity small { margin-top: 3px; color: var(--cc-muted); font-size: 11px; }
|
||||
.weight-row__facts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: 14px 0 0; background: var(--cc-border); }
|
||||
.weight-row__facts > div { min-width: 0; padding: 11px 12px; background: #f8fafc; }
|
||||
.weight-row__facts dt { color: var(--cc-muted); font-size: 10px; }
|
||||
.weight-row__facts dd { margin: 4px 0 0; font-size: 12px; font-weight: 650; overflow-wrap: anywhere; }
|
||||
.weight-row__facts .weight-final { color: #067647; }
|
||||
.weight-row__raw { display: grid; grid-template-columns: 78px minmax(0, 1fr); gap: 10px; margin-top: 12px; font-size: 11px; line-height: 1.55; }
|
||||
.weight-row__raw span { color: var(--cc-muted); }
|
||||
.weight-row__raw p { margin: 0; color: #475467; overflow-wrap: anywhere; white-space: pre-wrap; }
|
||||
.weight-row__audit { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
|
||||
.weight-row__editor { display: grid; grid-template-columns: minmax(180px, 1fr) 150px auto; align-items: end; gap: 12px; margin-top: 14px; padding: 14px; border: 1px solid #b7dfca; border-radius: 7px; background: #f2fbf6; }
|
||||
.weight-row__editor label { display: grid; min-width: 0; gap: 6px; color: #475467; font-size: 11px; }
|
||||
.weight-row__editor :deep(.el-input-number), .weight-row__editor :deep(.el-segmented) { width: 100%; }
|
||||
.weight-row__editor > small { grid-column: 1 / -1; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
|
||||
.weight-row__actions { display: flex; flex-wrap: wrap; gap: 2px 12px; margin-top: 12px; }
|
||||
.weight-row__actions :deep(.el-button) { margin-left: 0; }
|
||||
.weight-preview { display: block; max-width: 100%; max-height: min(68vh, 720px); margin: 0 auto; object-fit: contain; }
|
||||
.weight-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
|
||||
.weight-card { position: relative; min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
|
||||
.weight-card__body { padding: 12px; text-align: center; }
|
||||
.weight-delete { position: absolute; z-index: 2; top: 8px; right: 8px; width: 30px; height: 30px; padding: 0; border: 1px solid rgb(255 255 255 / 82%); background: rgb(255 255 255 / 92%); box-shadow: 0 1px 3px rgb(16 24 40 / 16%); }
|
||||
.weight-card__value { width: 100%; padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; text-align: center; }
|
||||
.weight-card__value strong { color: #101828; font-size: 17px; }
|
||||
.weight-card__value:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: 4px; }
|
||||
.weight-preview { display: block; width: 100%; max-height: min(58vh, 560px); object-fit: contain; border-radius: 6px; background: #f2f4f7; }
|
||||
.preview-weight { display: grid; grid-template-columns: minmax(0, 1fr) 120px; align-items: end; gap: 12px; margin-top: 14px; }
|
||||
.preview-weight__recognized { grid-column: 1 / -1; color: var(--cc-muted); font-size: 12px; }
|
||||
.preview-weight label { display: grid; gap: 6px; color: #475467; font-size: 12px; }
|
||||
.preview-weight :deep(.el-input-number), .preview-weight :deep(.el-segmented) { width: 100%; }
|
||||
.preview-weight.is-confirmed { display: flex; align-items: baseline; justify-content: space-between; padding: 12px 0 0; }
|
||||
.preview-weight.is-confirmed span { color: var(--cc-muted); font-size: 12px; }
|
||||
.preview-weight.is-confirmed strong { color: #067647; font-size: 20px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.weight-heading { flex-direction: column; }
|
||||
.weight-heading__actions, .weight-heading__actions :deep(.el-button) { width: 100%; }
|
||||
.weight-heading__actions :deep(.el-button), .weight-row__actions :deep(.el-button) { min-height: 44px; }
|
||||
.weight-metrics { grid-template-columns: 1fr; }
|
||||
.weight-metrics > div { border-right: 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.weight-metrics > div:last-child { border-bottom: 0; }
|
||||
.weight-row__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.weight-row__raw { grid-template-columns: 1fr; gap: 4px; }
|
||||
.weight-row__editor { grid-template-columns: minmax(0, 1fr) 132px; }
|
||||
.weight-row__editor > .el-button { grid-column: 1 / -1; width: 100%; }
|
||||
.weight-row__actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.weight-row__actions :deep(.el-button) { width: 100%; justify-content: flex-start; }
|
||||
.weight-heading__actions, .weight-heading__actions :deep(.el-button) { width: 100%; min-height: 44px; }
|
||||
.weight-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.weight-row__identity { grid-template-columns: 32px minmax(0, 1fr); }
|
||||
.weight-row__identity > .el-tag { grid-column: 2; justify-self: start; }
|
||||
.weight-row__facts, .weight-row__editor, .weight-row__actions { grid-template-columns: 1fr; }
|
||||
.weight-row__editor > .el-button { grid-column: 1; }
|
||||
.weight-grid { grid-template-columns: 1fr; }
|
||||
.preview-weight { grid-template-columns: 1fr; }
|
||||
.preview-weight__recognized { grid-column: 1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -188,4 +188,31 @@ describe('PartnersView real API integration', () => {
|
||||
expect(wrapper.text()).toContain('重新加载')
|
||||
expect(wrapper.text()).not.toContain('深圳示例贸易有限公司')
|
||||
})
|
||||
|
||||
it('keeps the latest detail when an older partner request finishes last', async () => {
|
||||
const secondPartner = { ...partner, id: '19002', partnerCode: 'CUS-002', partnerName: '上海示例贸易有限公司' }
|
||||
let resolveFirst!: (value: PlatformPartnerResource) => void
|
||||
let resolveSecond!: (value: PlatformPartnerResource) => void
|
||||
vi.mocked(getPlatformPartner).mockImplementation((partnerId) => new Promise((resolve) => {
|
||||
if (String(partnerId) === String(partner.id)) resolveFirst = resolve
|
||||
else resolveSecond = resolve
|
||||
}))
|
||||
const wrapper = await mountView()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
detailOpen: boolean
|
||||
detail: PlatformPartnerResource | null
|
||||
openDetail(row: PlatformPartnerResource): Promise<void>
|
||||
}
|
||||
|
||||
void vm.openDetail(partner)
|
||||
vm.detailOpen = false
|
||||
await wrapper.vm.$nextTick()
|
||||
void vm.openDetail(secondPartner)
|
||||
resolveSecond(secondPartner)
|
||||
await flushPromises()
|
||||
resolveFirst(partner)
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.detail?.partnerCode).toBe('CUS-002')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,6 +40,7 @@ const total = ref(0)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const statusLoadingIds = ref(new Set<string>())
|
||||
let loadSequence = 0
|
||||
let detailSequence = 0
|
||||
|
||||
type PartnerFilters = QueryModel & {
|
||||
keyword: string
|
||||
@@ -261,17 +262,21 @@ function savePayload(): SavePlatformPartnerRequest {
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformPartnerResource) {
|
||||
const sequence = ++detailSequence
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
detail.value = await getPlatformPartner(row.id)
|
||||
const result = await getPlatformPartner(row.id)
|
||||
if (sequence !== detailSequence || !detailOpen.value) return
|
||||
detail.value = result
|
||||
} catch (error) {
|
||||
if (sequence !== detailSequence) return
|
||||
const meta = getApiErrorDetails(error, '往来单位详情加载失败')
|
||||
ElMessage.error(meta.requestId ? `${meta.message}(requestId: ${meta.requestId})` : meta.message)
|
||||
detailOpen.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
if (sequence === detailSequence) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +340,12 @@ function formatUpdatedAt(value?: string | null) {
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => { void loadPartners() })
|
||||
watch(detailOpen, (open) => {
|
||||
if (open) return
|
||||
detailSequence += 1
|
||||
detailLoading.value = false
|
||||
detail.value = null
|
||||
})
|
||||
onMounted(() => {
|
||||
void loadPartners()
|
||||
})
|
||||
|
||||
@@ -2,13 +2,14 @@ import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { listPlatformExpressCompanies, type PlatformExpressCompanyResource } from '@/modules/master-data/express-companies/api/platformExpressCompanies'
|
||||
import { getPlatformExpressCompany, listPlatformExpressCompanies, type PlatformExpressCompanyResource } from '@/modules/master-data/express-companies/api/platformExpressCompanies'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import ExpressCompaniesView from './ExpressCompaniesView.vue'
|
||||
|
||||
vi.mock('@/modules/master-data/express-companies/api/platformExpressCompanies', () => ({
|
||||
listPlatformExpressCompanies: vi.fn(),
|
||||
getPlatformExpressCompany: vi.fn(),
|
||||
createPlatformExpressCompany: vi.fn(),
|
||||
downloadPlatformExpressCompanyImportTemplate: vi.fn(),
|
||||
importPlatformExpressCompanies: vi.fn(),
|
||||
@@ -74,6 +75,7 @@ describe('ExpressCompaniesView query and table migration', () => {
|
||||
vi.mocked(listPlatformExpressCompanies).mockReset().mockResolvedValue({
|
||||
records: [company], page: 1, size: 20, total: 1, pages: 1,
|
||||
})
|
||||
vi.mocked(getPlatformExpressCompany).mockReset().mockResolvedValue(company)
|
||||
})
|
||||
|
||||
it('renders the shared toolbar and data table without a summary strip', async () => {
|
||||
@@ -110,4 +112,28 @@ describe('ExpressCompaniesView query and table migration', () => {
|
||||
expect(document.body.textContent).toContain('新增快递公司')
|
||||
expect(document.body.textContent).not.toContain('请先在筛选条件中选择所属企业')
|
||||
})
|
||||
|
||||
it('keeps the latest express company selected for editing', async () => {
|
||||
const secondCompany = { ...company, id: '19002', companyCode: 'JD', companyName: '京东物流' }
|
||||
let resolveFirst!: (value: PlatformExpressCompanyResource) => void
|
||||
let resolveSecond!: (value: PlatformExpressCompanyResource) => void
|
||||
vi.mocked(getPlatformExpressCompany).mockImplementation((companyId) => new Promise((resolve) => {
|
||||
if (String(companyId) === String(company.id)) resolveFirst = resolve
|
||||
else resolveSecond = resolve
|
||||
}))
|
||||
const wrapper = await mountView()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
form: { companyCode: string; companyName: string }
|
||||
openEdit(row: PlatformExpressCompanyResource): Promise<void>
|
||||
}
|
||||
|
||||
void vm.openEdit(company)
|
||||
void vm.openEdit(secondCompany)
|
||||
resolveSecond(secondCompany)
|
||||
await flushPromises()
|
||||
resolveFirst(company)
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.form).toMatchObject({ companyCode: 'JD', companyName: '京东物流' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,6 +56,7 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
},
|
||||
])
|
||||
let loadSequence = 0
|
||||
let editorLoadSequence = 0
|
||||
|
||||
const emptyForm = (): SavePlatformExpressCompanyRequest & { id: string } => ({
|
||||
id: '',
|
||||
@@ -161,6 +162,7 @@ function applyFilters(model: QueryModel) {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editorLoadSequence += 1
|
||||
Object.assign(form, emptyForm())
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
@@ -184,13 +186,17 @@ function assignEditor(row: PlatformExpressCompanyResource) {
|
||||
}
|
||||
|
||||
async function openEdit(row: PlatformExpressCompanyResource) {
|
||||
const sequence = ++editorLoadSequence
|
||||
const id = String(row.id)
|
||||
const pending = new Set(editLoadingIds.value)
|
||||
pending.add(id)
|
||||
editLoadingIds.value = pending
|
||||
try {
|
||||
assignEditor(await getPlatformExpressCompany(row.id))
|
||||
const result = await getPlatformExpressCompany(row.id)
|
||||
if (sequence !== editorLoadSequence) return
|
||||
assignEditor(result)
|
||||
} catch (error) {
|
||||
if (sequence !== editorLoadSequence) return
|
||||
const details = getApiErrorDetails(error, '快递公司详情加载失败,请稍后重试')
|
||||
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
|
||||
} finally {
|
||||
|
||||
@@ -76,6 +76,7 @@ describe('UnifiedOrdersView contract', () => {
|
||||
|
||||
it('keeps only confirmed business actions as explicit boundaries', () => {
|
||||
expect(source).toContain('nextActionBoundary')
|
||||
expect(source).not.toContain('class="action-boundary"')
|
||||
expect(source).toContain("can('platform:firstleg:inbound:view')")
|
||||
expect(source).toContain('openMeasurement')
|
||||
expect(source).not.toContain('待接入:智能分柜')
|
||||
@@ -128,6 +129,8 @@ describe('UnifiedOrdersView contract', () => {
|
||||
expect(source).toContain("{ key: 'measurement', label: '测量数据'")
|
||||
expect(source).not.toContain('<AppDrawer')
|
||||
expect(source).not.toContain('detailOpen')
|
||||
expect(source).toContain('watch(detailRouteKey,')
|
||||
expect(source).toContain('requestKey !== detailRouteKey.value')
|
||||
})
|
||||
|
||||
it('keeps the basic information compact and maps available order fields', () => {
|
||||
|
||||
@@ -89,9 +89,12 @@ const measurementLoadedKey = ref('')
|
||||
const measurementImageUrls = ref<string[]>([])
|
||||
const measurementCollapsed = reactive({ customer: false, outer: false, actual: false })
|
||||
let detailLoadingKey = ''
|
||||
let detailRequestSerial = 0
|
||||
const showAdvancedFilters = ref(false)
|
||||
const filters = ref<FilterModel>(emptyFilters())
|
||||
const orderId = computed(() => typeof route.params.orderId === 'string' ? route.params.orderId : '')
|
||||
const detailTenantId = computed(() => typeof route.query.tenantId === 'string' ? route.query.tenantId : '')
|
||||
const detailRouteKey = computed(() => orderId.value ? `${detailTenantId.value}:${orderId.value}` : '')
|
||||
|
||||
const detailTabs = [
|
||||
{ key: 'basic', label: '基本信息', icon: Box },
|
||||
@@ -700,13 +703,14 @@ function closeDetail() {
|
||||
|
||||
async function loadDetail(force = false) {
|
||||
if (!orderId.value) return
|
||||
const tenantId = typeof route.query.tenantId === 'string' ? route.query.tenantId : ''
|
||||
const tenantId = detailTenantId.value
|
||||
if (!tenantId) {
|
||||
detailError.value = { message: '订单详情缺少客户标识,请从订单列表重新打开' }
|
||||
return
|
||||
}
|
||||
const requestKey = `${tenantId}:${orderId.value}`
|
||||
if (!force && (detailLoadingKey === requestKey || detail.value?.summary.orderId === orderId.value && String(detail.value.summary.tenantId) === tenantId)) return
|
||||
const serial = ++detailRequestSerial
|
||||
detailLoadingKey = requestKey
|
||||
feeDetails.value = []
|
||||
feeLoadedKey.value = ''
|
||||
@@ -716,21 +720,31 @@ async function loadDetail(force = false) {
|
||||
detailLoading.value = true
|
||||
detailError.value = null
|
||||
try {
|
||||
detail.value = await getPlatformUnifiedOrder(tenantId, orderId.value)
|
||||
const response = await getPlatformUnifiedOrder(tenantId, orderId.value)
|
||||
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
|
||||
detail.value = response
|
||||
} catch (error) {
|
||||
if (serial !== detailRequestSerial || requestKey !== detailRouteKey.value) return
|
||||
detail.value = null
|
||||
detailError.value = getApiErrorDetails(error, '统一订单详情加载失败,请检查 C02 统一详情接口和订单权限')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
if (detailLoadingKey === requestKey) detailLoadingKey = ''
|
||||
if (serial === detailRequestSerial) detailLoading.value = false
|
||||
if (serial === detailRequestSerial && detailLoadingKey === requestKey) detailLoadingKey = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => { void loadOrders() })
|
||||
watch(orderId, (value) => {
|
||||
watch(detailRouteKey, (value) => {
|
||||
if (value) {
|
||||
activeDetailTab.value = 'basic'
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
void loadDetail()
|
||||
} else {
|
||||
detailRequestSerial += 1
|
||||
detail.value = null
|
||||
detailError.value = null
|
||||
detailLoading.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
onMounted(() => {
|
||||
@@ -994,7 +1008,6 @@ onMounted(() => {
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<span v-if="nextActionBoundary(row as UnifiedOrderResource)" class="action-boundary">{{ nextActionBoundary(row as UnifiedOrderResource) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #orderStatus="{ row }"><StatusBadge :status="orderStatusLabel((row as UnifiedOrderResource).internalOrderStatus || (row as UnifiedOrderResource).mainStatus)" /></template>
|
||||
|
||||
@@ -336,4 +336,33 @@ describe('LocationsView', () => {
|
||||
status: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the latest detail when an older location request finishes last', async () => {
|
||||
const secondLocation = { ...availableLocation, id: '9223372036854775800', locationCode: 'A1-03' }
|
||||
const firstDetail = { location: availableLocation, warehouseCode: warehouse.warehouseCode, warehouseName: warehouse.warehouseName }
|
||||
const secondDetail = { location: secondLocation, warehouseCode: warehouse.warehouseCode, warehouseName: warehouse.warehouseName }
|
||||
let resolveFirst!: (value: typeof firstDetail) => void
|
||||
let resolveSecond!: (value: typeof secondDetail) => void
|
||||
vi.mocked(getPlatformLocation).mockImplementation((_warehouseId, locationId) => new Promise((resolve) => {
|
||||
if (String(locationId) === String(availableLocation.id)) resolveFirst = resolve
|
||||
else resolveSecond = resolve
|
||||
}))
|
||||
const wrapper = await mountView()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
drawerOpen: boolean
|
||||
detail: typeof firstDetail | null
|
||||
loadDetail(row: PlatformLocationResource, mode: 'view' | 'edit'): Promise<void>
|
||||
}
|
||||
|
||||
void vm.loadDetail(availableLocation, 'view')
|
||||
vm.drawerOpen = false
|
||||
await wrapper.vm.$nextTick()
|
||||
void vm.loadDetail(secondLocation, 'view')
|
||||
resolveSecond(secondDetail)
|
||||
await flushPromises()
|
||||
resolveFirst(firstDetail)
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.detail?.location.locationCode).toBe('A1-03')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,6 +61,7 @@ const actionLoadingIds = ref(new Set<string>())
|
||||
const formRef = ref<FormInstance>()
|
||||
let warehouseLoadSequence = 0
|
||||
let locationLoadSequence = 0
|
||||
let drawerLoadSequence = 0
|
||||
|
||||
const filters = ref<LocationFilters>({ warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
|
||||
const appliedFilters = ref<AppliedLocationFilters>({ warehouseId: '', zoneCode: '', locationCode: '', locationType: '', status: '' })
|
||||
@@ -335,6 +336,7 @@ async function loadDetail(row: PlatformLocationResource, mode: 'view' | 'edit')
|
||||
ElMessage.warning('仅支持编辑国内仓库位')
|
||||
return
|
||||
}
|
||||
const sequence = ++drawerLoadSequence
|
||||
const scope = recordScope(row)
|
||||
drawerMode.value = mode
|
||||
drawerOpen.value = true
|
||||
@@ -343,6 +345,7 @@ async function loadDetail(row: PlatformLocationResource, mode: 'view' | 'edit')
|
||||
detail.value = null
|
||||
try {
|
||||
const result = await getPlatformLocation(scope.warehouseId, row.id)
|
||||
if (sequence !== drawerLoadSequence || !drawerOpen.value) return
|
||||
detail.value = result
|
||||
if (mode === 'edit') {
|
||||
assignForm(result.location)
|
||||
@@ -350,9 +353,10 @@ async function loadDetail(row: PlatformLocationResource, mode: 'view' | 'edit')
|
||||
void formRef.value?.clearValidate()
|
||||
}
|
||||
} catch (error) {
|
||||
if (sequence !== drawerLoadSequence) return
|
||||
drawerError.value = getApiErrorDetails(error, '库位详情加载失败,请稍后重试')
|
||||
} finally {
|
||||
drawerLoading.value = false
|
||||
if (sequence === drawerLoadSequence) drawerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +479,13 @@ watch(
|
||||
)
|
||||
|
||||
watch([page, pageSize], () => { void loadLocations() })
|
||||
watch(drawerOpen, (open) => {
|
||||
if (open) return
|
||||
drawerLoadSequence += 1
|
||||
drawerLoading.value = false
|
||||
drawerError.value = null
|
||||
detail.value = null
|
||||
})
|
||||
watch(() => form.warehouseId, (warehouseId) => {
|
||||
if (drawerMode.value !== 'create') return
|
||||
const warehouse = warehouses.value.find((item) => String(item.id) === warehouseId)
|
||||
|
||||
@@ -333,4 +333,55 @@ describe('PalletsView', () => {
|
||||
expect(document.body.textContent).not.toContain('托盘状态')
|
||||
expect(document.body.textContent).not.toContain('最大承重')
|
||||
})
|
||||
|
||||
it('keeps the latest detail when an older pallet request finishes last', async () => {
|
||||
const secondPallet = { ...pallet, id: '20002', palletCode: 'TP-002' }
|
||||
let resolveFirst!: (value: PlatformPalletResource) => void
|
||||
let resolveSecond!: (value: PlatformPalletResource) => void
|
||||
vi.mocked(getPlatformPallet).mockImplementation((_warehouseId, palletId) => new Promise((resolve) => {
|
||||
if (String(palletId) === String(pallet.id)) resolveFirst = resolve
|
||||
else resolveSecond = resolve
|
||||
}))
|
||||
const wrapper = await mountView()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
detailOpen: boolean
|
||||
detail: PlatformPalletResource | null
|
||||
openDetail(row: PlatformPalletResource): Promise<void>
|
||||
}
|
||||
|
||||
void vm.openDetail(pallet)
|
||||
vm.detailOpen = false
|
||||
await wrapper.vm.$nextTick()
|
||||
void vm.openDetail(secondPallet)
|
||||
resolveSecond(secondPallet)
|
||||
await flushPromises()
|
||||
resolveFirst(pallet)
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.detail?.palletCode).toBe('TP-002')
|
||||
})
|
||||
|
||||
it('keeps the latest pallet selected for editing', async () => {
|
||||
const secondPallet = { ...pallet, id: '20002', palletCode: 'TP-002', color: '蓝色' }
|
||||
let resolveFirst!: (value: PlatformPalletResource) => void
|
||||
let resolveSecond!: (value: PlatformPalletResource) => void
|
||||
vi.mocked(getPlatformPallet).mockImplementation((_warehouseId, palletId) => new Promise((resolve) => {
|
||||
if (String(palletId) === String(pallet.id)) resolveFirst = resolve
|
||||
else resolveSecond = resolve
|
||||
}))
|
||||
const wrapper = await mountView()
|
||||
const vm = wrapper.vm as unknown as {
|
||||
form: { palletCode: string; color: string }
|
||||
openEdit(row: PlatformPalletResource): Promise<void>
|
||||
}
|
||||
|
||||
void vm.openEdit(pallet)
|
||||
void vm.openEdit(secondPallet)
|
||||
resolveSecond(secondPallet)
|
||||
await flushPromises()
|
||||
resolveFirst(pallet)
|
||||
await flushPromises()
|
||||
|
||||
expect(vm.form).toMatchObject({ palletCode: 'TP-002', color: '蓝色' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,6 +38,8 @@ const deletingIds = ref(new Set<string>())
|
||||
const formRef = ref<FormInstance>()
|
||||
let warehouseSequence = 0
|
||||
let palletSequence = 0
|
||||
let detailSequence = 0
|
||||
let editorSequence = 0
|
||||
|
||||
type PalletFilters = QueryModel & { warehouseId: string; palletCode: string; palletType: string }
|
||||
type AppliedPalletFilters = Pick<PalletFilters, 'warehouseId' | 'palletCode' | 'palletType'>
|
||||
@@ -226,6 +228,7 @@ function resetFilters() {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editorSequence += 1
|
||||
Object.assign(form, emptyForm())
|
||||
form.warehouseId = isActiveDomesticWarehouse(filters.value.warehouseId) ? filters.value.warehouseId : ''
|
||||
editorOpen.value = true
|
||||
@@ -254,16 +257,20 @@ async function fetchDetail(row: PlatformPalletResource) {
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformPalletResource) {
|
||||
const sequence = ++detailSequence
|
||||
detailLoading.value = true
|
||||
detailOpen.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
detail.value = await fetchDetail(row)
|
||||
const result = await fetchDetail(row)
|
||||
if (sequence !== detailSequence || !detailOpen.value) return
|
||||
detail.value = result
|
||||
} catch (error) {
|
||||
if (sequence !== detailSequence) return
|
||||
detailOpen.value = false
|
||||
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘详情加载失败,请稍后重试')))
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
if (sequence === detailSequence) detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,9 +279,13 @@ async function openEdit(row: PlatformPalletResource) {
|
||||
ElMessage.warning('仅支持编辑国内仓托盘')
|
||||
return
|
||||
}
|
||||
const sequence = ++editorSequence
|
||||
try {
|
||||
assignForm(await fetchDetail(row))
|
||||
const result = await fetchDetail(row)
|
||||
if (sequence !== editorSequence) return
|
||||
assignForm(result)
|
||||
} catch (error) {
|
||||
if (sequence !== editorSequence) return
|
||||
ElMessage.error(errorMessage(getApiErrorDetails(error, '托盘详情加载失败,请稍后重试')))
|
||||
}
|
||||
}
|
||||
@@ -341,6 +352,12 @@ watch([page, pageSize], ([nextPage, nextSize], [previousPage, previousSize]) =>
|
||||
if (nextPage === previousPage && nextSize === previousSize) return
|
||||
void loadPallets()
|
||||
})
|
||||
watch(detailOpen, (open) => {
|
||||
if (open) return
|
||||
detailSequence += 1
|
||||
detailLoading.value = false
|
||||
detail.value = null
|
||||
})
|
||||
watch(() => access.can(editorPermission.value), (canWrite) => {
|
||||
if (!canWrite && editorOpen.value) editorOpen.value = false
|
||||
}, { flush: 'sync' })
|
||||
|
||||
Reference in New Issue
Block a user