Merge remote-tracking branch 'origin/release/test' into dev
This commit is contained in:
@@ -56,7 +56,7 @@ function saveRequest(row?: OverseasContainerFeeSource): OverseasContainerFeeSave
|
||||
async function load() {
|
||||
if (!canView.value) return
|
||||
loading.value = true; error.value = null
|
||||
try { [rows.value, customers.value, suppliers.value] = await Promise.all([listOverseasContainerFees(props.containerBookingId), listOverseasContainerCustomers(props.containerBookingId), props.readonly ? Promise.resolve([]) : listContainerFeeSupplierOptions(null)]) }
|
||||
try { [rows.value, customers.value, suppliers.value] = await Promise.all([listOverseasContainerFees(props.containerBookingId), listOverseasContainerCustomers(props.containerBookingId), canEdit.value && access.can('platform:supplier:list') ? listContainerFeeSupplierOptions(null) : Promise.resolve([])]) }
|
||||
catch (cause) { error.value = getApiErrorDetails(cause, '尾程计费加载失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
@@ -108,7 +108,7 @@ onMounted(load)
|
||||
<template>
|
||||
<div class="last-leg-fees">
|
||||
<header class="last-leg-fees__header"><div><h2>{{ title }}</h2><p>按真实货柜展示港口、堆存、修箱、吊机和码头拖车费用,金额按币种分别列示。</p></div><el-button v-if="canEdit" type="primary" :disabled="!containerNo || customers.length === 0" @click="openCreate">新增费用</el-button></header>
|
||||
<el-alert v-if="readonly" type="info" :closable="false" title="当前路由仅查看海外段费用,写操作请从清关提柜详情进入。" />
|
||||
<el-alert v-if="canView && !canEdit && !canReview" type="info" :closable="false" title="当前账号仅有海外段费用查看权限。" />
|
||||
<el-alert v-if="!containerNo" type="warning" :closable="false" title="当前货柜尚未补录真实集装箱号,暂不能新增尾程费用。" />
|
||||
<el-alert v-if="!canView" type="warning" :closable="false" title="当前账号无权查看尾程计费。" />
|
||||
<LoadErrorState v-else-if="error" title="尾程计费加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
|
||||
+18
-2
@@ -20,8 +20,8 @@ const overview = { container: { id: '9001', version: 1 }, loadingPlanId: null, d
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
async function mountDetail() {
|
||||
const wrapper = mount(ContainerDetailView, { props: { workbench: 'OVERSEAS' }, attachTo: document.body, global: { plugins: [createPinia(), ElementPlus], stubs: { ContainerOverviewPanel: true, ContainerCustomsPanel: true, ContainerLastLegFeesPanel: true } } })
|
||||
async function mountDetail(workbench: 'DOMESTIC' | 'OVERSEAS' = 'OVERSEAS') {
|
||||
const wrapper = mount(ContainerDetailView, { props: { workbench }, attachTo: document.body, global: { plugins: [createPinia(), ElementPlus], stubs: { ContainerOverviewPanel: true, ContainerCustomsPanel: true, ContainerLastLegFeesPanel: true } } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
@@ -34,6 +34,22 @@ describe('ContainerDetailView empty return', () => {
|
||||
vi.mocked(recordTerminalDrayageEmptyReturn).mockReset().mockResolvedValue({ ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } as never)
|
||||
})
|
||||
|
||||
it.each(['DOMESTIC', 'OVERSEAS'] as const)('gates fee tabs by granted permissions in %s', async (workbench) => {
|
||||
for (const [firstLeg, lastLeg] of [[true, false], [false, true], [true, true], [false, false]]) {
|
||||
canAccess.mockImplementation((code) => code === 'firstleg:inbound:fee-detail:view' ? firstLeg! : code === 'platform:finance:customer-arap:list' ? lastLeg! : false)
|
||||
const wrapper = await mountDetail(workbench)
|
||||
const labels = wrapper.findAll('[role="tab"]').map((tab) => tab.text())
|
||||
expect(labels.includes('国内段费用(头程)')).toBe(firstLeg)
|
||||
expect(labels.includes('目的港及海外段费用(尾程)')).toBe(lastLeg)
|
||||
if (lastLeg) {
|
||||
await wrapper.findAll('[role="tab"]').find((tab) => tab.text() === '目的港及海外段费用(尾程)')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.findComponent({ name: 'ContainerLastLegFeesPanel' }).props('readonly')).toBe(false)
|
||||
}
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the overseas stage, submits one versioned command and refreshes to returned state', async () => {
|
||||
vi.mocked(getContainer).mockResolvedValueOnce(base as never).mockResolvedValue({ ...base, workbenchStage: 'EMPTY_RETURNED', statusSummary: { terminalDrayageStatus: 'EMPTY_RETURNED' }, allowedActions: [], terminalDrayage: { ...base.terminalDrayage, status: 'EMPTY_RETURNED', version: 4, actualEmptyReturnAt: '2026-09-09T12:00:00', allowedActions: [] } } as never)
|
||||
const wrapper = await mountDetail()
|
||||
|
||||
@@ -70,13 +70,13 @@ describe('ContainerDetailView customs integration', () => {
|
||||
it('separates domestic first-leg fees and connects the overseas fee lifecycle lazily', () => {
|
||||
expect(source).toContain('label="国内段费用(头程)" name="first-leg-fees"')
|
||||
expect(source).toContain('label="目的港及海外段费用(尾程)" name="last-leg-fees"')
|
||||
expect(source).toContain('v-if="firstLegFeesOpened"')
|
||||
expect(source).toContain('v-if="canViewFirstLegFees && firstLegFeesOpened"')
|
||||
expect(source).toContain('v-show="activeTab === \'first-leg-fees\'"')
|
||||
expect(source).toContain('title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id"')
|
||||
expect(source).toContain(':readonly="isOverseas"')
|
||||
expect(source).toContain('v-if="lastLegFeesOpened"')
|
||||
expect(source).not.toContain(':readonly="isOverseas"')
|
||||
expect(source).toContain('v-if="canViewLastLegFees && lastLegFeesOpened"')
|
||||
expect(source).toContain('title="目的港及海外段费用(尾程)"')
|
||||
expect(source).toContain(':readonly="isDomestic"')
|
||||
expect(source).not.toContain(':readonly="isDomestic"')
|
||||
expect(source).toContain("if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)")
|
||||
expect(source).toContain("const needsLoadingDetail = tabName === 'first-leg-fees' || (isDomestic.value && tabName === 'loading-list')")
|
||||
expect(lastLegFeesSource.indexOf('label="操作"')).toBeLessThan(lastLegFeesSource.indexOf('label="客户"'))
|
||||
@@ -84,7 +84,7 @@ describe('ContainerDetailView customs integration', () => {
|
||||
expect(lastLegFeesSource).toContain('尾程计费加载失败')
|
||||
expect(lastLegFeesSource).toContain('empty-text="当前货柜暂无尾程费用"')
|
||||
expect(lastLegFeesSource).toContain('v-loading="loading"')
|
||||
expect(lastLegFeesSource).toContain('props.readonly ? Promise.resolve([]) : listContainerFeeSupplierOptions(null)')
|
||||
expect(lastLegFeesSource).toContain("canEdit.value && access.can('platform:supplier:list') ? listContainerFeeSupplierOptions(null) : Promise.resolve([])")
|
||||
expect(lastLegFeesSource).toContain("sourceType: 'OVERSEAS_CONTAINER_FEE', businessType: 'OVERSEAS_CONTAINER'")
|
||||
expect(lastLegFeesSource).toContain("form.settlementMode === 'DIRECT_PAID'")
|
||||
expect(lastLegFeesSource).toContain('uploadContainerFeePaymentVoucher(form.customerId, file.raw)')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, FileUp, Link2 } from '@lucide/vue'
|
||||
import { ArrowLeft, ClipboardList, FileUp, Link2, Package, Receipt, Wallet } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
@@ -46,6 +46,8 @@ const containerId = computed(() => String(route.params.containerId || ''))
|
||||
const isDomestic = computed(() => props.workbench === 'DOMESTIC')
|
||||
const isOverseas = computed(() => props.workbench === 'OVERSEAS')
|
||||
const activeTab = ref(route.query.tab === 'loading-list' ? 'loading-list' : 'overview')
|
||||
const canViewFirstLegFees = computed(() => access.can('firstleg:inbound:fee-detail:view'))
|
||||
const canViewLastLegFees = computed(() => access.can('platform:finance:customer-arap:list'))
|
||||
const firstLegFeesOpened = ref(false)
|
||||
const lastLegFeesOpened = ref(false)
|
||||
const feeCustomers = computed(() => {
|
||||
@@ -65,6 +67,10 @@ const firstLegFeesEmptyReason = computed(() => {
|
||||
return '当前柜次暂无可用费用归属明细。'
|
||||
})
|
||||
async function rememberWorkbenchTab(tabName: string | number) {
|
||||
if ((tabName === 'first-leg-fees' && !canViewFirstLegFees.value) || (tabName === 'last-leg-fees' && !canViewLastLegFees.value)) {
|
||||
activeTab.value = 'overview'
|
||||
return
|
||||
}
|
||||
if (tabName === 'first-leg-fees') firstLegFeesOpened.value = true
|
||||
if (tabName === 'last-leg-fees') lastLegFeesOpened.value = true
|
||||
const needsLoadingDetail = tabName === 'first-leg-fees' || (isDomestic.value && tabName === 'loading-list')
|
||||
@@ -86,6 +92,11 @@ const canViewOutboundDispatch = computed(() => access.can('platform:firstleg:out
|
||||
const canConfirmOutboundDispatch = computed(() => access.can('platform:firstleg:outbound-dispatch:confirm'))
|
||||
const canViewPreload = computed(() => access.can('platform:firstleg:preload:list'))
|
||||
const canViewLoadingList = computed(() => access.can('platform:firstleg:loading-list:list'))
|
||||
const canViewCurrentLoadingList = computed(() => {
|
||||
if (!isDomestic.value || !detail.value) return false
|
||||
if (detail.value.planStatus === 'COMPLETED') return canViewLoadingList.value
|
||||
return ['WAIT_LOADING', 'EDITING', 'LOADING'].includes(detail.value.planStatus) && canViewPreload.value
|
||||
})
|
||||
const canLinkOceanBooking = computed(() => Boolean(canEdit.value
|
||||
&& detail.value?.sourceType === 'SIMULATION'
|
||||
&& !detail.value.oceanBookingId
|
||||
@@ -261,7 +272,7 @@ async function load() {
|
||||
try {
|
||||
const [resource, aggregate] = await Promise.all([getContainer(containerId.value, props.workbench), getContainerOverview(containerId.value)])
|
||||
workbenchDetail.value = resource; detail.value = resource; overview.value = aggregate
|
||||
if (activeTab.value === 'loading-list' && resource.planStatus !== 'COMPLETED') activeTab.value = 'overview'
|
||||
if (activeTab.value === 'loading-list' && !canViewCurrentLoadingList.value) activeTab.value = 'overview'
|
||||
if (isDomestic.value && activeTab.value === 'loading-list') await loadLoadingDetail(resource, aggregate)
|
||||
if (isDomestic.value && resource.planStatus === 'COMPLETED' && canViewOutboundDispatch.value) await loadOutboundDispatch()
|
||||
} catch (cause) { error.value = getApiErrorDetails(cause, '货柜详情加载失败') }
|
||||
@@ -477,7 +488,12 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
</section>
|
||||
<LoadErrorState v-if="error" title="货柜详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<template v-else-if="detail">
|
||||
<el-tabs v-model="activeTab" class="workbench-tabs" @tab-change="rememberWorkbenchTab"><el-tab-pane :label="isDomestic ? '送柜报关' : '清关提柜'" name="overview" /><el-tab-pane v-if="isDomestic && detail.planStatus === 'COMPLETED' && canViewLoadingList" label="装柜清单" name="loading-list" /><el-tab-pane label="国内段费用(头程)" name="first-leg-fees" /><el-tab-pane label="目的港及海外段费用(尾程)" name="last-leg-fees" /></el-tabs>
|
||||
<el-tabs v-model="activeTab" class="workbench-tabs" @tab-change="rememberWorkbenchTab">
|
||||
<el-tab-pane :label="isDomestic ? '送柜报关' : '清关提柜'" name="overview"><template #label><span class="workbench-tab-label"><ClipboardList :size="18" aria-hidden="true" /><span>{{ isDomestic ? '送柜报关' : '清关提柜' }}</span></span></template></el-tab-pane>
|
||||
<el-tab-pane v-if="canViewCurrentLoadingList" label="装柜清单" name="loading-list"><template #label><span class="workbench-tab-label"><Package :size="18" aria-hidden="true" /><span>装柜清单</span></span></template></el-tab-pane>
|
||||
<el-tab-pane v-if="canViewFirstLegFees" label="国内段费用(头程)" name="first-leg-fees"><template #label><span class="workbench-tab-label"><Receipt :size="18" aria-hidden="true" /><span>国内段费用(头程)</span></span></template></el-tab-pane>
|
||||
<el-tab-pane v-if="canViewLastLegFees" label="目的港及海外段费用(尾程)" name="last-leg-fees"><template #label><span class="workbench-tab-label"><Wallet :size="18" aria-hidden="true" /><span>目的港及海外段费用(尾程)</span></span></template></el-tab-pane>
|
||||
</el-tabs>
|
||||
<LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false" />
|
||||
<section v-show="activeTab === 'overview'" class="detail-section surface-panel" aria-labelledby="container-information-heading">
|
||||
<h2 id="container-information-heading">货柜信息</h2>
|
||||
@@ -531,14 +547,14 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
<LoadingListDetailDrawer mode="confirmed" :loading="loadingDetailLoading" :detail="loadingDetail" :error="loadingDetailError" :can-edit="false" @back="activeTab = 'overview'" @retry="overview && loadLoadingDetail(detail, overview)" />
|
||||
</section>
|
||||
|
||||
<section v-if="firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
|
||||
<section v-if="canViewFirstLegFees && firstLegFeesOpened" v-show="activeTab === 'first-leg-fees'" v-loading="loadingDetailLoading" class="detail-section surface-panel" aria-label="头程计费">
|
||||
<LoadErrorState v-if="loadingDetailError" compact title="头程计费归属明细加载失败" :message="loadingDetailError.message" :loading="loadingDetailLoading" @retry="overview && loadLoadingDetail(detail, overview)"><template #details><ApiErrorMeta :details="loadingDetailError" /></template></LoadErrorState>
|
||||
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" :readonly="isOverseas" />
|
||||
<ContainerFeesPanel v-else-if="overview?.loadingPlanId && loadingDetail" title="国内段费用(头程)" :plan-id="overview.loadingPlanId" :container-booking-id="detail.id" :customers="feeCustomers" :business-references="feeBusinessReferences" />
|
||||
<el-empty v-else-if="!loadingDetailLoading" :description="firstLegFeesEmptyReason" :image-size="72" />
|
||||
</section>
|
||||
|
||||
<section v-if="lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section surface-panel" aria-label="尾程计费">
|
||||
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :container-no="detail.containerNo" :readonly="isDomestic" />
|
||||
<section v-if="canViewLastLegFees && lastLegFeesOpened" v-show="activeTab === 'last-leg-fees'" class="detail-section surface-panel" aria-label="尾程计费">
|
||||
<ContainerLastLegFeesPanel title="目的港及海外段费用(尾程)" :container-booking-id="detail.id" :container-no="detail.containerNo" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -550,6 +566,14 @@ onMounted(async () => { await load(); if (isOverseas.value && route.query.action
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench-tabs { min-width:0; overflow:hidden; border-bottom:1px solid rgba(228,228,231,.5); border-radius:20px 20px 0 0; background:rgba(255,255,255,.95); box-shadow:0 -4px 16px rgba(0,0,0,.04); }
|
||||
.workbench-tabs :deep(.el-tabs__header) { margin:0; }
|
||||
.workbench-tabs :deep(.el-tabs__nav-wrap::after), .workbench-tabs :deep(.el-tabs__active-bar), .workbench-tabs :deep(.el-tabs__content) { display:none; }
|
||||
.workbench-tabs :deep(.el-tabs__item) { height:auto; padding:16px 28px !important; border-bottom:3px solid transparent; color:#71717a; font-size:15px; font-weight:600; transition:background-color .2s, color .2s, border-color .2s; }
|
||||
.workbench-tabs :deep(.el-tabs__item:hover) { background:rgba(102,126,234,.05); color:#667eea; }
|
||||
.workbench-tabs :deep(.el-tabs__item.is-active) { border-bottom-color:#667eea; background:rgba(102,126,234,.08); color:#667eea; font-weight:800; }
|
||||
.workbench-tabs :deep(.el-tabs__item:focus-visible) { outline:2px solid #667eea; outline-offset:-4px; }
|
||||
.workbench-tab-label { display:inline-flex; align-items:center; justify-content:center; gap:8px; white-space:nowrap; }
|
||||
.container-detail-page { display:grid; min-width:0; gap:12px; }.container-overview { overflow:hidden; }.container-overview__header { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:14px; padding:10px 16px; }.container-overview__header h1 { margin:0; font-size:18px; }.container-overview__header p { margin:2px 0 0; color:var(--cc-muted); font-size:12px; }.container-overview__summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); border-top:1px solid var(--cc-border); }.container-overview__summary>div { display:flex; min-width:0; flex-direction:column; gap:3px; padding:8px 14px; border-right:1px solid var(--cc-border); }.container-overview__summary>div:last-child { border-right:0; }.container-overview__summary span,dt { color:var(--cc-muted); font-size:12px; }.container-overview__summary strong,dd { overflow:hidden; margin:0; text-overflow:ellipsis; white-space:nowrap; }.detail-section{display:grid;min-width:0;gap:12px;padding:12px 16px}.detail-section>h2{margin:0;font-size:15px}.detail-fields { padding:0; }.detail-fields dl { display:grid; grid-template-columns:repeat(6,minmax(0,1fr)); gap:9px 16px; margin:0; }.detail-fields dl>div { min-width:0; }.detail-fields dd { margin-top:3px; font-size:13px; }.outbound-panel{padding:12px 0 0;border-top:1px solid var(--cc-border)}.outbound-panel__header{display:flex;align-items:center;justify-content:space-between;gap:12px}.outbound-panel__header h2{margin:0;font-size:15px}.outbound-panel__header p,.outbound-panel__hint{margin:3px 0 0;color:var(--cc-muted);font-size:12px}.outbound-panel__hint--success{color:var(--el-color-success)}.outbound-panel__actions{display:flex;align-items:center;gap:8px}.outbound-panel__loading{padding:8px 0}.gate-summary{display:flex;align-items:center;gap:14px;margin-top:8px;color:var(--cc-muted);font-size:12px}.gate-summary__passed strong{color:var(--el-color-success)}.gate-summary__blocked strong{color:var(--el-color-danger)}.gate-summary__reference{margin-left:auto;padding-left:10px;border-left:1px solid var(--cc-border)}.gate-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 12px;margin-top:7px}.gate-row{display:grid;grid-template-columns:auto minmax(130px,.9fr) minmax(100px,1fr);align-items:center;gap:6px 8px;min-width:0;padding:6px 8px;border:1px solid var(--cc-border);border-radius:6px;background:var(--cc-surface-muted)}.gate-row__actual,.gate-row small{overflow:hidden;color:var(--cc-muted);font-size:12px;text-overflow:ellipsis;white-space:nowrap}.gate-row small{grid-column:2/-1}.gate-row__coverage{display:flex;grid-column:2/-1;min-width:0;align-items:center;gap:5px;overflow:hidden}.gate-row__coverage>span{flex:0 0 auto;color:var(--cc-muted);font-size:11px}.gate-row__coverage :deep(.el-tag){max-width:120px;overflow:hidden;text-overflow:ellipsis}.carrier-panel{padding-top:4px}@media(max-width:1200px){.detail-fields dl{grid-template-columns:repeat(4,minmax(0,1fr))}.gate-list{grid-template-columns:1fr}}@media(max-width:720px){.container-overview__header{grid-template-columns:auto minmax(0,1fr)}.container-overview__header>:last-child{grid-column:2}.container-overview__summary,.detail-fields dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outbound-panel__header{align-items:flex-start;flex-direction:column}.outbound-panel__actions,.gate-summary{flex-wrap:wrap}.gate-summary__reference{width:100%;margin-left:0;padding-left:0;border-left:0}.gate-row{grid-template-columns:auto minmax(0,1fr)}.gate-row__actual,.gate-row small,.gate-row__coverage{grid-column:2}}
|
||||
.detail-section__heading { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.detail-section__heading h2 { margin:0; font-size:15px; }
|
||||
|
||||
Reference in New Issue
Block a user