feat(client): show inbound business relationships

This commit is contained in:
2026-08-30 17:35:52 +08:00
parent 545a0f23ed
commit da92b75ae1
11 changed files with 288 additions and 30 deletions
@@ -240,7 +240,7 @@ function formatSize(bytes: number) {
<header class="customs-summary">
<div>
<span>柜次</span>
<strong>{{ detail.containerNo || detail.cabinetNo || detail.planNo || detail.loadingPlanId }}</strong>
<strong>{{ detail.containerNo || detail.cabinetNo || detail.planNo || '—' }}</strong>
</div>
<div>
<span>资料覆盖</span>
@@ -281,7 +281,7 @@ function formatSize(bytes: number) {
<div>
<strong>{{ item.source.fileName }}</strong>
<span>版本 V{{ item.source.version }} · {{ formatSize(item.source.sizeBytes) }} · {{ formatDateTime(item.source.uploadedAt) }}</span>
<small>关联入仓码:{{ item.inboundCodes.join('、') || '-' }}</small>
<small>关联入仓码:{{ item.inboundCodes.join('、') || '—' }}</small>
</div>
</div>
<div class="customs-document-actions">
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./InboundCodeAssociationDialog.vue', import.meta.url), 'utf8')
test('loads only the customer-scoped server association and fails closed', () => {
assert.match(source, /getMerchantInboundCodeAssociation\(inboundCode\)/)
assert.match(source, /association\.value = null/)
assert.match(source, /关联摘要加载失败/)
assert.match(source, /@click="loadAssociation"/)
assert.doesNotMatch(source, /orderId\)|customerId\)|destinationWarehouseId\)/)
})
test('renders business identifiers and names without internal long identifiers', () => {
for (const label of ['入仓码', '订单号', '订单状态', '客户名称', '目的国家', '目的仓']) {
assert.match(source, new RegExp(label))
}
assert.match(source, /association\.inboundCode/)
assert.match(source, /association\.orderNo/)
assert.match(source, /association\.customerName/)
assert.match(source, /destinationWarehouseCode/)
assert.match(source, /destinationWarehouseName/)
assert.doesNotMatch(source, /association\.(?:orderId|customerId|destinationWarehouseId)/)
assert.match(source, /\|\| '—'/)
})
test('uses a bounded centered summary dialog suitable for the desktop detail flow', () => {
assert.match(source, /<el-dialog[^>]*width="620px"/)
assert.match(source, /grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/)
assert.match(source, /min-height: 190px/)
})
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { AlertTriangle, Link2, RefreshCw } from '@lucide/vue'
import { FIRST_LEG_ORDER_STATUSES } from '@chongchong/shared'
import {
getMerchantInboundCodeAssociation,
type MerchantInboundCodeAssociation,
} from '@/modules/first-leg/api/inboundCodeAssociations'
const props = defineProps<{
modelValue: boolean
inboundCode: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
const loading = ref(false)
const error = ref('')
const association = ref<MerchantInboundCodeAssociation | null>(null)
let requestSequence = 0
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
function displayValue(value: string | null | undefined) {
return value?.trim() || '—'
}
function statusLabel(value: string) {
return FIRST_LEG_ORDER_STATUSES.find((item) => item.code === value)?.label ?? displayValue(value)
}
function destinationLabel(value: MerchantInboundCodeAssociation) {
return [value.destinationWarehouseCode, value.destinationWarehouseName].filter(Boolean).join(' · ') || '—'
}
async function loadAssociation() {
const inboundCode = props.inboundCode.trim()
const sequence = ++requestSequence
association.value = null
error.value = ''
loading.value = false
if (!inboundCode) {
error.value = '当前订单暂无可查询的入仓码'
return
}
loading.value = true
try {
const result = await getMerchantInboundCodeAssociation(inboundCode)
if (sequence === requestSequence && props.modelValue) association.value = result
} catch (reason) {
if (sequence === requestSequence && props.modelValue) {
error.value = reason instanceof Error ? reason.message : '入仓码关联查询失败'
}
} finally {
if (sequence === requestSequence) loading.value = false
}
}
watch(() => [props.modelValue, props.inboundCode] as const, ([open]) => {
if (open) void loadAssociation()
else {
requestSequence += 1
loading.value = false
error.value = ''
association.value = null
}
}, { immediate: true })
</script>
<template>
<el-dialog v-model="visible" class="inbound-association-dialog" title="入仓码关联摘要" width="620px" append-to-body destroy-on-close>
<div v-loading="loading" class="inbound-association-content">
<div v-if="error" class="inbound-association-error" role="alert">
<AlertTriangle :size="22" />
<div><strong>关联摘要加载失败</strong><span>{{ error }}</span></div>
<el-button :loading="loading" @click="loadAssociation"><RefreshCw :size="15" />重新加载</el-button>
</div>
<template v-else-if="association">
<header class="inbound-association-heading">
<Link2 :size="21" />
<div><span>入仓码</span><strong>{{ displayValue(association.inboundCode) }}</strong></div>
</header>
<dl class="inbound-association-grid">
<div><dt>订单号</dt><dd>{{ displayValue(association.orderNo) }}</dd></div>
<div><dt>订单状态</dt><dd>{{ statusLabel(association.orderStatus) }}</dd></div>
<div><dt>客户名称</dt><dd>{{ displayValue(association.customerName) }}</dd></div>
<div><dt>目的国家</dt><dd>{{ displayValue(association.destinationCountryCode) }}</dd></div>
<div class="is-wide"><dt>目的仓</dt><dd>{{ destinationLabel(association) }}</dd></div>
</dl>
</template>
</div>
<template #footer><el-button @click="visible = false">关闭</el-button></template>
</el-dialog>
</template>
<style scoped>
.inbound-association-content { min-height: 190px; }
.inbound-association-error { display: grid; min-height: 170px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 13px; padding: 20px; border: 1px solid #efc1bc; background: #fff8f7; color: #a33b32; }
.inbound-association-error > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.inbound-association-error span { overflow-wrap: anywhere; font-size: 12px; }
.inbound-association-heading { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 12px; padding: 15px 16px; background: #f2f7f6; color: var(--primary); }
.inbound-association-heading > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.inbound-association-heading span { color: var(--muted); font-size: 11px; }
.inbound-association-heading strong { overflow-wrap: anywhere; color: var(--navy); font-size: 16px; }
.inbound-association-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 14px 0 0; border-top: 1px solid var(--border); border-left: 1px solid var(--border); }
.inbound-association-grid > div { min-width: 0; padding: 13px 14px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.inbound-association-grid .is-wide { grid-column: 1 / -1; }
.inbound-association-grid dt { color: var(--muted); font-size: 11px; }
.inbound-association-grid dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 13px; font-weight: 650; }
</style>