This commit is contained in:
2026-08-17 18:32:41 +08:00
parent d14d0dcb61
commit 822b285312
17 changed files with 133 additions and 53 deletions
@@ -187,14 +187,15 @@ describe('ContainersView', () => {
expect(source).not.toContain('@reset=')
})
it('uses the frozen prototype modal contract for create and edit while retaining the detail drawer', () => {
it('uses centered dialogs for container detail, create and edit operations', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
expect(source).toContain('<AppDrawer v-model="detailOpen"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog"')
expect(source).toContain('<AppDialog v-model="detailOpen" class="container-detail-dialog"')
expect(source).toContain('width="min(820px, calc(100vw - 28px))"')
expect(source).toContain('<AppDialog v-model="editorOpen" class="container-editor-dialog"')
expect(source).toContain('max-height: 90vh')
expect(source).toContain('overflow-y: auto')
expect(source).toContain('grid-template-columns: minmax(0,1fr)')
expect(source).not.toContain('AppDrawer')
expect(source).not.toContain('<AppDrawer v-model="editorOpen"')
})
@@ -13,7 +13,6 @@ import { uploadLoadingDeclaration } from '@/modules/loading-lists'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -614,10 +613,10 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
</section>
</ListPageLayout>
<AppDrawer v-model="detailOpen" title="货柜详情" size="min(760px, 94vw)" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
<AppDialog v-model="detailOpen" class="container-detail-dialog" title="货柜详情" width="min(820px, calc(100vw - 28px))" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="真实订舱">{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '待录入' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
</AppDrawer>
</AppDialog>
<AppDialog v-model="clearanceDialogOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :confirm-loading="clearanceActionLoading" :confirm-disabled="!clearanceFile" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><el-form v-else label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="clearanceError" compact title="操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
@@ -81,6 +81,23 @@ describe('GlobalSmartLoadingView', () => {
expect(wrapper.text()).not.toContain('ORD-2')
})
it('uses the top-level container channel snapshot without expecting a nested snapshot', async () => {
vi.mocked(listContainers).mockResolvedValue({
records: [{ ...container, channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁' }],
page: 1,
size: 100,
total: 1,
pages: 1,
})
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([{ accountId: '1', inboundId: '5', inboundDetailId: '6', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-2', warehouseId: '8', channelCode: 'EXX_RAIL_NY', channelName: '纽约EXX海铁', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '另一企业', goodsName: '鞋类', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 }])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('纽约EXX海铁')
expect(wrapper.text()).not.toContain('缺少渠道快照')
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
})
it('shows one user-facing container number and hides internal manual or split references', async () => {
vi.mocked(listContainers).mockResolvedValue({
records: [{ ...container, cabinetNo: 'CAB-20260814-01', containerNo: 'SPLIT-814022117898chain1' }],
@@ -249,7 +266,34 @@ describe('GlobalSmartLoadingView', () => {
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
expect(wrapper.text()).not.toContain('CB-3')
expect(wrapper.text()).not.toContain('装柜完成')
expect(wrapper.text()).toContain('当前柜次已完成装柜,不可继续编辑')
expect(wrapper.text()).not.toContain('缺少渠道快照')
})
it('isolates a missing-channel row without hiding another valid container', async () => {
const missingChannel = { ...container, id: '9007199254740997', cabinetNo: 'CB-MISSING', channelCode: null, channelName: null }
vi.mocked(listContainers).mockResolvedValue({ records: [missingChannel, container], page: 1, size: 100, total: 2, pages: 1 })
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.findAll('.container-option')).toHaveLength(1)
expect(wrapper.text()).toContain('CB-1')
expect(wrapper.text()).not.toContain('CB-MISSING')
expect(wrapper.text()).not.toContain('缺少渠道快照')
expect(getSmartLoadingPlanByBooking).toHaveBeenCalledWith(container.id)
})
it('matches candidate goods by their own formal top-level channel code', async () => {
vi.mocked(listSmartLoadingCandidates).mockResolvedValue([
{ accountId: '1', inboundId: '5', inboundDetailId: '6', orderId: '7', orderNo: 'ORD-2', inboundCode: 'IN-MATCH', warehouseId: '8', channelCode: 'ZBYHK', channelName: '美森正班海卡包税', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '匹配企业', goodsName: '匹配货物', availablePackageCount: 12, unitWeightKg: 10, unitVolumeCbm: 0.2 },
{ accountId: '2', inboundId: '8', inboundDetailId: '9', orderId: '10', orderNo: 'ORD-3', inboundCode: 'IN-OTHER', warehouseId: '8', channelCode: 'OTHER', channelName: '其他渠道', destinationWarehouseId: '9', destinationWarehouseCode: 'LAX9', destinationWarehouseName: 'LAX9', customerName: '其他企业', goodsName: '其他货物', availablePackageCount: 8, unitWeightKg: 10, unitVolumeCbm: 0.2 },
])
const wrapper = mount(GlobalSmartLoadingView, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('IN-MATCH')
expect(wrapper.text()).not.toContain('IN-OTHER')
expect(wrapper.text()).not.toContain('其他货物')
})
it('refreshes the latest plan after A0409 without losing the selected container', async () => {
@@ -60,10 +60,13 @@ const canAssign = computed(() => access.can('platform:firstleg:smart-loading:ass
const canDelete = computed(() => access.can('platform:firstleg:smart-loading:delete'))
const canPush = computed(() => access.can('platform:firstleg:smart-loading:push'))
const selectedContainer = computed(() => containers.value.find((item) => item.id === selectedContainerId.value) || null)
const orchestratableContainers = computed(() => containers.value.filter((item) => ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus)))
const completedContainerCount = computed(() => containers.value.filter((item) => item.planStatus === 'COMPLETED').length)
const missingChannelContainerCount = computed(() => orchestratableContainers.value.filter((item) => !item.channelCode).length)
const channelTabs = computed(() => {
const channels = new Map<string, { code: string; name: string }>()
for (const item of containers.value) {
if (!['WAIT_LOADING', 'EDITING', 'LOADING'].includes(item.planStatus) || !item.channelCode) continue
for (const item of orchestratableContainers.value) {
if (!item.channelCode) continue
if (!channels.has(item.channelCode)) channels.set(item.channelCode, {
code: item.channelCode,
name: item.channelName || item.channelCode,
@@ -98,7 +101,9 @@ const filteredContainers = computed(() => containers.value.filter((item) => (
const filteredCandidates = computed(() => {
const customerId = String(appliedQuery.value.customerId || '')
const inboundCode = String(appliedQuery.value.inboundCode || '').trim().toLowerCase()
const channelCode = selectedContainer.value?.channelCode || activeChannelCode.value
return candidates.value.filter((item) => {
if (!channelCode || item.channelCode !== channelCode) return false
if (customerId && item.accountId !== customerId) return false
return !inboundCode || String(item.inboundCode || '').toLowerCase().includes(inboundCode)
})
@@ -404,7 +409,8 @@ onMounted(() => { void load(); void loadCustomers() })
<div><dt>总体积</dt><dd><strong>{{ formatNumber(totalVolume) }}</strong> m³</dd></div>
</dl>
</div>
<el-alert v-else-if="containers.length" type="warning" :closable="false" show-icon title="当前柜次缺少渠道快照,暂时不能参与智能装柜" />
<el-alert v-else-if="missingChannelContainerCount" type="warning" :closable="false" show-icon :title="`${missingChannelContainerCount} 个可编排柜次缺少渠道快照,已隔离;请补齐后再参与智能装柜`" />
<el-alert v-else-if="completedContainerCount" type="success" :closable="false" show-icon title="当前柜次已完成装柜,不可继续编辑" />
<QueryToolbar
v-model="query"
ariaLabel="智能装柜筛选"
+1 -1
View File
@@ -273,7 +273,7 @@ defineExpose({ reload: loadFee })
</el-alert>
<el-empty v-else-if="notFound" :image-size="64" description="尚未生成费用快照">
<p class="empty-note">测量审核完成后,系统会自动生成并发布费用。</p>
<p class="empty-note">测量审核完成后,系统会自动生成初算费用;人工确认后才发布。</p>
</el-empty>
<div v-else-if="snapshot" class="fee-content">
+1
View File
@@ -2,5 +2,6 @@ export { getInboundMeasurement } from './measurement/api'
export { default as InboundMeasurementPanel } from './measurement/InboundMeasurementPanel.vue'
export { default as InboundMeasurementDialog } from './measurement/InboundMeasurementDialog.vue'
export { default as MeasurementEvidenceThumbnail } from './measurement/components/MeasurementEvidenceThumbnail.vue'
export { default as InboundFeePanel } from './fee/InboundFeePanel.vue'
export { downloadWeightImage } from './weight/api/weightOcr'
export type { InboundMeasurementDetail } from './measurement/types'
@@ -14,6 +14,9 @@ describe('InboundMeasurementDialog', () => {
expect(source).toContain('height: 92vh')
expect(source).toContain('<WeightOcrPanel')
expect(source).toContain('<InboundMeasurementPanel')
expect(source).toContain('class="measurement-dialog-weight"')
expect(source).not.toContain('class="surface-panel measurement-dialog-weight"')
expect(source).toContain('border-bottom: 1px solid var(--cc-border)')
expect(source).toContain('@summary-changed="handleWeightSummaryChanged"')
expect(source).toContain('measurementPanelRef.value?.updateActualGrossWeight(actualGrossWeightKg)')
})
@@ -72,7 +72,7 @@ function handleSubmitted() {
@closed="emit('closed')"
>
<div class="measurement-dialog-workspace">
<section class="surface-panel measurement-dialog-weight" aria-label="称重图片、OCR 与托盘核对">
<section class="measurement-dialog-weight" aria-label="称重图片、OCR 与托盘核对">
<WeightOcrPanel
:customer-id="customerId"
:inbound-id="inboundId"
@@ -113,12 +113,12 @@ function handleSubmitted() {
</template>
<style scoped>
.measurement-dialog-workspace { display: grid; min-width: 0; gap: 20px; }
.measurement-dialog-weight { min-width: 0; padding: 20px; }
.measurement-dialog-workspace { display: grid; min-width: 0; }
.measurement-dialog-weight { min-width: 0; padding: 0 0 16px; border-bottom: 1px solid var(--cc-border); }
.measurement-dialog-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.measurement-dialog-footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) {
.measurement-dialog-weight { padding: 14px; }
.measurement-dialog-weight { padding-bottom: 14px; }
.measurement-dialog-footer { display: grid; grid-template-columns: 1fr; }
.measurement-dialog-footer :deep(.el-button) { width: 100%; min-height: 44px; }
}
@@ -193,7 +193,7 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.text()).not.toContain('LEGACY_DEVICE')
})
it('keeps the first view focused on key totals and moves secondary fields behind disclosure', async () => {
it('keeps key totals compact and shows declaration information without another disclosure card', async () => {
const wrapper = await mountPanel()
const text = wrapper.text()
@@ -210,12 +210,13 @@ describe('InboundMeasurementPanel', () => {
expect(wrapper.find('.measurement-item').exists()).toBe(false)
expect(wrapper.find('.measurement-table').text()).not.toContain('周长')
const source = await import('./InboundMeasurementPanel.vue?raw').then((module) => module.default)
expect(source).toContain('<el-collapse-item name="declaration" title="申报信息">')
expect(source).toContain('<section class="measurement-declaration"')
expect(source).not.toContain('<el-collapse-item name="declaration"')
expect(source).toContain('grid-template-columns: repeat(4, minmax(0, 1fr))')
expect(source).toContain('.measurement-form-grid, .measurement-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));')
expect(source).toContain('.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr));')
expect(source).toContain('.measurement-table { width: 100%; font-size: 12px; }')
expect(source).toContain('.measurement-data-grid :deep(.el-input__wrapper)')
expect(source).toContain('defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit })')
expect(source).toContain('defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain, maySubmit, saving, submitting, canEdit, canSubmit, isDirty })')
})
it('updates the server weight summary without discarding unsaved measurement edits', async () => {
@@ -544,17 +544,16 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
<div><dt>材积重</dt><dd>{{ formatNumber(detail.volumeWeightKg) }} <small>kg</small></dd></div>
</dl>
<el-collapse class="measurement-more">
<el-collapse-item name="declaration" title="申报信息">
<div class="measurement-data-grid">
<label><span>申报箱数</span><el-input :model-value="formatNumber(expectedPackageCount, 0)" readonly /></label>
<label><span>申报毛重(kg)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" controls-position="right" /></label>
<label><span>申报体积(m³)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" controls-position="right" /></label>
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="128" /></label>
<label><span>原单据号码</span><el-input v-model="form.originalDocumentNo" placeholder="请输入原单据号码" maxlength="128" /></label>
</div>
</el-collapse-item>
</el-collapse>
<section class="measurement-declaration" aria-labelledby="measurement-declaration-title">
<h4 id="measurement-declaration-title">申报信息</h4>
<div class="measurement-data-grid">
<label><span>申报箱数</span><el-input :model-value="formatNumber(expectedPackageCount, 0)" readonly /></label>
<label><span>申报毛重(kg)</span><el-input-number v-model="form.originalGrossWeightKg" :min="0" :precision="3" controls-position="right" /></label>
<label><span>申报体积(m³)</span><el-input-number v-model="form.originalVolumeCbm" :min="0" :precision="4" controls-position="right" /></label>
<label><span>实际品名</span><el-input v-model="form.actualGoodsName" placeholder="请输入实际品名" maxlength="128" /></label>
<label><span>原单据号码</span><el-input v-model="form.originalDocumentNo" placeholder="请输入原单据号码" maxlength="128" /></label>
</div>
</section>
</section>
<section class="measurement-section measurement-items-section">
@@ -694,31 +693,28 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
.measurement-alert ul { margin: 7px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.65; }
.measurement-alert .el-button { margin-top: 9px; }
.measurement-form fieldset { min-width: 0; margin: 0; padding: 0; border: 0; }
.measurement-section { padding: 18px 20px; border-top: 1px solid var(--cc-border); }
.measurement-summary-section { display: grid; gap: 16px; border-top: 0; }
.measurement-section { padding: 16px 20px; border-top: 1px solid var(--cc-border); }
.measurement-summary-section { display: grid; gap: 12px; border-top: 0; }
.measurement-summary-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.measurement-summary-header > div:first-child { display: grid; min-width: 0; gap: 3px; }
.measurement-summary-header span { color: var(--cc-muted); font-size: 11px; }
.measurement-summary-header strong { overflow-wrap: anywhere; color: var(--cc-text); font-size: 20px; line-height: 1.3; }
.measurement-summary-header p { margin: 0; color: var(--cc-muted); font-size: 12px; }
.measurement-summary-status { display: flex; flex: 0 0 auto; align-items: center; gap: 9px; }
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; }
.measurement-metrics > div { display: grid; min-width: 0; min-height: 78px; align-content: center; gap: 6px; padding: 13px 16px; border-right: 1px solid var(--cc-border); }
.measurement-metrics > div:last-child { border-right: 0; }
.measurement-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 2px 0; }
.measurement-metrics > div { display: grid; min-width: 0; min-height: 58px; align-content: center; gap: 4px; padding: 5px 18px; border-left: 1px solid var(--cc-border); }
.measurement-metrics > div:first-child { padding-left: 0; border-left: 0; }
.measurement-metrics dt { color: var(--cc-muted); font-size: 12px; }
.measurement-metrics dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 19px; font-weight: 700; overflow-wrap: anywhere; }
.measurement-metrics dd { min-width: 0; margin: 0; color: var(--cc-text); font-size: 18px; font-weight: 700; overflow-wrap: anywhere; }
.measurement-metrics small { color: var(--cc-muted); font-size: 11px; font-weight: 500; }
.measurement-metric--primary { background: #f0f7ff; }
.measurement-metric--primary dd { color: var(--cc-primary); font-size: 23px; }
.measurement-metric--warning { background: var(--el-color-warning-light-9); }
.measurement-metric--primary dd { color: var(--cc-primary); font-size: 20px; }
.measurement-metric--warning dd { color: var(--el-color-warning-dark-2); }
.measurement-more { border-top: 0; border-bottom: 0; }
.measurement-more :deep(.el-collapse-item__header) { height: 38px; color: var(--cc-muted); font-size: 12px; }
.measurement-more :deep(.el-collapse-item__wrap) { border-bottom: 0; }
.measurement-more :deep(.el-collapse-item__content) { padding: 4px 0 2px; }
.measurement-declaration { padding-top: 12px; border-top: 1px solid var(--cc-border); }
.measurement-declaration h4 { margin: 0 0 10px; color: #344054; font-size: 12px; font-weight: 700; }
.measurement-section__heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; margin-bottom: 16px; }
.measurement-section__heading > div { min-width: 0; }
.measurement-form-grid, .measurement-data-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; }
.measurement-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 24px; }
.measurement-data-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; }
.measurement-form label { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #344054; font-size: 12px; font-weight: 650; }
.measurement-form :deep(.el-input-number), .measurement-form :deep(.el-input) { width: 100%; }
.measurement-data-grid label { display: flex; min-width: 0; flex-direction: column; gap: 5px; color: #344054; font-size: 12px; font-weight: 600; }
@@ -747,10 +743,10 @@ defineExpose({ saveDraft, submitForReview, updateActualGrossWeight, mayMaintain,
@media (max-width: 960px) {
.measurement-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-data-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.measurement-row-details { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.measurement-metrics > div:nth-child(2) { border-right: 0; }
.measurement-metrics > div:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); }
.measurement-metrics > div:nth-child(3) { padding-left: 0; border-left: 0; }
}
@media (max-width: 720px) {
@@ -160,6 +160,7 @@ describe('WeightOcrPanel confirmation behavior', () => {
const wrapper = mountPanel()
await flushPromises()
await wrapper.find('.pallet-picker .el-select').trigger('click')
expect(document.body.textContent).toContain('木托盘 · 皮重 12.000 KG · 容量 1.2 m³')
await new DOMWrapper(document.body).find('.el-select-dropdown__item').trigger('click')
const input = wrapper.get('input[type="file"]')
const file = new File(['image'], 'weight.jpg', { type: 'image/jpeg' })
@@ -53,6 +53,13 @@ describe('WeightOcrPanel contract', () => {
expect(source).toContain("option.palletType || '未记录类型'")
})
it('keeps pallet type, tare weight, and capacity in one select line', () => {
expect(source).toContain(':label="palletOptionLabel(option)"')
expect(source).toContain("`${option.palletType || '未记录类型'} · 皮重 ${formatWeight(option.tareWeightKg)} · 容量 ${option.capacityCbm} m³`")
expect(source).not.toContain('<small v-if="selectedPallet">')
expect(source).toContain('width: clamp(360px, 36vw, 520px)')
})
it('gates every action with the implemented permissions and editable statuses', () => {
expect(source).toContain("platform:firstleg:inbound:measure:view")
expect(source).toContain("platform:firstleg:inbound:weight:edit")
@@ -61,7 +61,6 @@ 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 selectedPallet = computed(() => palletOptions.value.find((option) => option.palletId === selectedPalletId.value) ?? null)
const previewResult = computed(() => rows.value.find((row) => row.id === previewResultId.value)
|| previewSnapshot.value)
const previewConfirmDisabled = computed(() => {
@@ -75,6 +74,10 @@ function formatWeight(value: number | null, unit = 'KG') {
return value == null ? '—' : `${Number(value).toFixed(3)} ${unit}`
}
function palletOptionLabel(option: WeightPalletOption) {
return `${option.palletType || '未记录类型'} · 皮重 ${formatWeight(option.tareWeightKg)} · 容量 ${option.capacityCbm} m³`
}
function actionKey(action: string, id: string) {
return `${action}:${id}`
}
@@ -400,11 +403,10 @@ defineExpose({ reload: loadWorkspace })
<el-option
v-for="option in palletOptions"
:key="option.palletId"
:label="option.palletType || '未记录类型'"
:label="palletOptionLabel(option)"
:value="option.palletId"
/>
</el-select>
<small v-if="selectedPallet">皮重 {{ formatWeight(selectedPallet.tareWeightKg) }} · 容量 {{ selectedPallet.capacityCbm }} m³</small>
</div>
<input
ref="fileInput"
@@ -570,15 +572,15 @@ defineExpose({ reload: loadWorkspace })
.weight-heading h3 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 15px; }
.weight-heading h3 svg { color: var(--cc-primary); }
.weight-heading__actions { display: flex; flex: 0 0 auto; align-items: center; gap: 16px; }
.pallet-picker { display: grid; min-width: 260px; gap: 4px; }
.pallet-picker small { color: var(--cc-muted); font-size: 11px; line-height: 1.4; }
.pallet-picker { width: clamp(360px, 36vw, 520px); min-width: 0; }
.pallet-picker :deep(.el-select) { width: 100%; }
.weight-summary { display: grid; gap: 2px; text-align: right; }
.weight-summary span { color: var(--cc-muted); font-size: 12px; }
.weight-summary strong { color: #067647; font-size: 18px; }
.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-state-note, .weight-action-error { margin-bottom: 14px; }
.weight-list { min-height: 0; margin-top: 14px; }
.weight-empty { display: flex; min-height: 0; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 4px; background: var(--cc-surface-muted); color: var(--cc-muted); }
.weight-empty { display: flex; min-height: 0; align-items: center; gap: 10px; padding: 4px 0 0; color: var(--cc-muted); }
.weight-empty strong { color: #344054; font-size: 14px; }
.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; }
@@ -115,6 +115,13 @@ describe('InboundFeeDetailManager', () => {
expect(source).toContain('<el-table :data="rows" border stripe')
})
it('spins only the refresh icon while fee rows are loading', () => {
expect(source).toContain(':aria-busy="loading" :disabled="loading"')
expect(source).toContain(`<RefreshCw :size="16" :class="{ 'is-spinning': loading }" />`)
expect(source).not.toMatch(/aria-label="刷新费用明细"[^>]*:loading=/)
expect(source).toContain('@keyframes fee-refresh-spin')
})
it('loads RECEIVABLE fee items without requesting counterparties', async () => {
const wrapper = mountManager(['firstleg:inbound:fee-detail:view', 'firstleg:inbound:fee-detail:edit', 'firstleg:fee-item:view'])
await flushPromises()
@@ -182,7 +182,7 @@ onMounted(() => void load())
<div class="fee-toolbar">
<span>共 {{ rows.length }} 条应收应付费用明细</span>
<div>
<el-tooltip content="刷新费用明细" placement="top"><el-button circle aria-label="刷新费用明细" :loading="loading" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-tooltip content="刷新费用明细" placement="top"><el-button circle aria-label="刷新费用明细" :aria-busy="loading" :disabled="loading" @click="load"><RefreshCw :size="16" :class="{ 'is-spinning': loading }" /></el-button></el-tooltip>
<el-button v-if="access.can('firstleg:inbound:fee-detail:edit') && canViewFeeItems" type="primary" @click="openEditor('create')"><Plus :size="16" />新增费用</el-button>
</div>
</div>
@@ -255,10 +255,12 @@ onMounted(() => void load())
.fee-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--el-text-color-secondary); font-size: 13px; }
.fee-toolbar > div { display: flex; flex-wrap: wrap; gap: 8px; }
.fee-toolbar :deep(.el-button + .el-button) { margin-left: 0; }
.fee-toolbar .is-spinning { animation: fee-refresh-spin 0.8s linear infinite; }
.fee-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 14px; }
.fee-form :deep(.el-select), .fee-form :deep(.el-date-editor), .fee-form :deep(.el-input-number) { width: 100%; }
.fee-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.fee-table-shell { min-width: 0; max-width: 100%; overflow: hidden; }
.fee-table-shell :deep(.el-table) { width: 100%; max-width: 100%; }
@keyframes fee-refresh-spin { to { transform: rotate(360deg); } }
@media (max-width: 720px) { .fee-toolbar { align-items: flex-start; flex-direction: column; } .fee-form { grid-template-columns: 1fr; } .fee-form :deep(.el-form-item:last-child) { grid-column: auto; } }
</style>
@@ -286,6 +286,9 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain("partyValue(detail.order.parties, 'CONSIGNEE'")
expect(source).toContain("cargoTotal(detail.order.cargoes, 'netWeightKg')")
expect(source).toContain('InboundFeeDetailManager')
expect(source).toContain('InboundFeePanel')
expect(source).toContain(':customer-id="String(detail.summary.tenantId)"')
expect(source).toContain('费用明细与审核')
expect(source).toContain('@changed="loadDetail(true)"')
expect(source).toContain('firstLegTransportModeLabel(detail.order.order?.transportMode)')
expect(source).toContain('firstLegPickupMethodLabel(detail.order.order?.pickupMethod)')
@@ -51,6 +51,7 @@ import {
import {
downloadWeightImage,
getInboundMeasurement,
InboundFeePanel,
InboundMeasurementDialog,
MeasurementEvidenceThumbnail,
type InboundMeasurementDetail,
@@ -977,6 +978,12 @@ onBeforeUnmount(() => {
<template v-else-if="activeDetailTab === 'fees'">
<div class="panel-title"><div><h2>费用信息</h2><p>维护运单与入库单应收应付费用</p></div><WalletCards :size="19" /></div>
<InboundFeePanel
:customer-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
@changed="loadDetail(true)"
/>
<el-divider content-position="left">费用明细与审核</el-divider>
<InboundFeeDetailManager
:account-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"