From b47323edf352e2d1d15dca97e022fd962dbb88de Mon Sep 17 00:00:00 2001
From: wujinliang <2318309077@qq.com>
Date: Thu, 27 Aug 2026 21:48:15 +0800
Subject: [PATCH] 1
---
.idea/.gitignore | 8 ++
.idea/admin-web.iml | 9 ++
.idea/misc.xml | 6 +
.idea/modules.xml | 8 ++
.idea/vcs.xml | 6 +
docker-compose.test.yml | 4 +-
src/app/DomesticOutboundListStates.test.ts | 2 +-
src/app/layouts/AppLayout.test.ts | 2 +-
src/app/layouts/AppLayout.vue | 6 +-
src/app/router/index.test.ts | 1 +
src/deployment/nginxConfig.test.ts | 2 +
.../pages/GlobalSmartLoadingView.test.ts | 10 ++
.../pages/GlobalSmartLoadingView.vue | 9 +-
.../pages/SmartLoadingWorkbenchView.test.ts | 10 ++
.../pages/SmartLoadingWorkbenchView.vue | 9 +-
.../customers/overseasStorageFee.test.ts | 22 +++-
src/modules/customers/overseasStorageFee.ts | 3 +-
src/modules/finance/api/finance.test.ts | 22 ++++
src/modules/finance/api/finance.ts | 8 +-
.../pages/FinanceCustomerArapView.test.ts | 7 ++
.../finance/pages/FinanceCustomerArapView.vue | 17 +--
.../FinanceCustomerStatementsView.test.ts | 29 +++++
.../pages/FinanceCustomerStatementsView.vue | 23 ++--
.../FinanceFormalSettlementFields.test.ts | 4 +
.../pages/FinanceFormalSettlementPage.vue | 27 +++--
.../finance/pages/FinanceG3G4Views.test.ts | 34 ++++++
.../finance/pages/FinancePresentation.test.ts | 4 +-
src/modules/finance/status.test.ts | 24 +++-
src/modules/finance/status.ts | 13 +--
src/modules/finance/types.ts | 3 +-
.../components/InboundTaskList.test.ts | 2 +
.../inbounds/components/InboundTaskList.vue | 2 +-
src/modules/inbounds/review/types.ts | 2 +
.../components/ContainerFeesPanel.test.ts | 31 +++++
.../components/ContainerFeesPanel.vue | 33 +++++-
.../api/notificationCenter.test.ts | 25 ++++
.../notifications/api/notificationCenter.ts | 29 ++++-
.../components/MessageComposerDialog.vue | 19 ++-
.../notifications/notifications.test.ts | 7 ++
.../pages/PlatformNotificationsView.vue | 110 ++++++++++++++++++
src/modules/notifications/routes.ts | 6 +
src/modules/notifications/types.ts | 4 +
.../pages/OceanBookingDetailView.test.ts | 5 +
.../pages/OceanBookingDetailView.vue | 1 +
.../components/TruckAppointmentForm.test.ts | 10 ++
.../components/TruckAppointmentForm.vue | 29 ++++-
.../pages/TruckAppointmentDetailView.test.ts | 6 +
.../pages/TruckAppointmentDetailView.vue | 12 +-
.../pages/UnifiedOrdersView.test.ts | 6 +
.../pages/UnifiedOrdersView.vue | 7 +-
src/shared/api/http.test.ts | 7 ++
src/shared/api/http.ts | 13 ++-
src/shared/components/QueryToolbar.vue | 8 +-
53 files changed, 624 insertions(+), 82 deletions(-)
create mode 100644 .idea/.gitignore
create mode 100644 .idea/admin-web.iml
create mode 100644 .idea/misc.xml
create mode 100644 .idea/modules.xml
create mode 100644 .idea/vcs.xml
create mode 100644 src/modules/notifications/pages/PlatformNotificationsView.vue
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 00000000..13566b81
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/admin-web.iml b/.idea/admin-web.iml
new file mode 100644
index 00000000..d6ebd480
--- /dev/null
+++ b/.idea/admin-web.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 00000000..07115cdf
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 00000000..e8ace242
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 00000000..35eb1ddf
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docker-compose.test.yml b/docker-compose.test.yml
index 6a5933bb..c8c35400 100644
--- a/docker-compose.test.yml
+++ b/docker-compose.test.yml
@@ -12,6 +12,8 @@ services:
ports:
- "${HOST_PORT:-9001}:80"
environment:
- CLIENT_WEB_URL: "${CLIENT_WEB_URL:-}"
+ # The test client is exposed on the same host through port 9002. Allow
+ # deployments to override this, but never fall back to the server IP.
+ CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://test-admin.buggoglobal.com:9002}"
API_UPSTREAM: "http://test-api-cn.buggoglobal.com"
OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"
diff --git a/src/app/DomesticOutboundListStates.test.ts b/src/app/DomesticOutboundListStates.test.ts
index 470fa9e5..738e665a 100644
--- a/src/app/DomesticOutboundListStates.test.ts
+++ b/src/app/DomesticOutboundListStates.test.ts
@@ -30,7 +30,7 @@ describe('domestic outbound list state contracts', () => {
const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue')
expect(layout).toContain('')
- expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute))"')
+ expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + \':\' + JSON.stringify(renderedRoute.params)"')
expect(oceanDetail).toContain("router.push('/ocean-bookings')")
expect(truckingDetail).toContain("router.push({ name: 'trucking' })")
})
diff --git a/src/app/layouts/AppLayout.test.ts b/src/app/layouts/AppLayout.test.ts
index 1eed235a..8a3f58f6 100644
--- a/src/app/layouts/AppLayout.test.ts
+++ b/src/app/layouts/AppLayout.test.ts
@@ -125,7 +125,7 @@ describe('AppLayout', () => {
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
expect(wrapper.text()).toContain('消息管理')
expect(wrapper.text()).toContain('模板管理')
- expect(wrapper.text()).not.toContain('我的消息')
+ expect(wrapper.text()).toContain('我的消息')
const notificationTrigger = wrapper.get('.notification-trigger')
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
})
diff --git a/src/app/layouts/AppLayout.vue b/src/app/layouts/AppLayout.vue
index ff3b690a..39bfcbff 100644
--- a/src/app/layouts/AppLayout.vue
+++ b/src/app/layouts/AppLayout.vue
@@ -37,7 +37,8 @@ const menuExpanded = computed(() => (
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
))
const canOpenNotificationManagement = computed(() => (
- access.can('platform:notification:task:list')
+ access.can('platform:notification:view')
+ || access.can('platform:notification:task:list')
|| access.can('platform:notification:template:list')
))
@@ -177,6 +178,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
消息中心
+ 我的消息
消息管理
模板管理
@@ -193,7 +195,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
diff --git a/src/app/router/index.test.ts b/src/app/router/index.test.ts
index b4a68aeb..b316b27c 100644
--- a/src/app/router/index.test.ts
+++ b/src/app/router/index.test.ts
@@ -110,6 +110,7 @@ describe('admin router access metadata', () => {
it('registers message-center routes with the frozen IAM permissions', () => {
const expected = [
+ ['notification-inbox', '/notifications', 'platform:notification:view'],
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
]
diff --git a/src/deployment/nginxConfig.test.ts b/src/deployment/nginxConfig.test.ts
index ce5339d3..a687f321 100644
--- a/src/deployment/nginxConfig.test.ts
+++ b/src/deployment/nginxConfig.test.ts
@@ -38,6 +38,8 @@ describe('admin nginx deployment config', () => {
it('declares an explicit overseas gateway upstream in container deployments', () => {
expect(testCompose).toContain('API_UPSTREAM: "http://test-api-cn.buggoglobal.com"')
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://test-api-global.buggoglobal.com"')
+ expect(testCompose).toContain('CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://test-admin.buggoglobal.com:9002}"')
+ expect(testCompose).not.toContain('CLIENT_WEB_URL: "http://8.163.')
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"')
expect(config).toContain('proxy_set_header Host $proxy_host;')
})
diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts
index d31afa41..8f94a453 100644
--- a/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts
+++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.test.ts
@@ -193,6 +193,12 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('assignForm.packageCount = candidate.availablePackageCount')
})
+ it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
+ const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/GlobalSmartLoadingView.vue'), 'utf8')
+ expect(source).toContain('
{
vi.mocked(getSmartLoadingPlanByBooking).mockResolvedValue({
...plan,
@@ -496,6 +502,10 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('智能推荐')
expect(source).toContain('推送预装')
expect(source).toContain('推送预装的流程说明已由操作确认框承载')
+ expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?')
+ expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认')
+ expect(source).not.toContain('关联并放舱真实订舱')
+ expect(source).not.toContain('订舱放舱')
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo")
})
})
diff --git a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue
index a0381e8d..fa3828db 100644
--- a/src/modules/container-loading/pages/GlobalSmartLoadingView.vue
+++ b/src/modules/container-loading/pages/GlobalSmartLoadingView.vue
@@ -370,13 +370,13 @@ async function removeAll() {
}
async function pushPreload() {
if (!plan.value) return
- const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', {
+ const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', {
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
- ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认')
+ ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认')
}, '推送预装清单失败')
}
@@ -540,7 +540,7 @@ onMounted(() => { void load(); void loadCustomers() })
保存移除整票多规格;改箱数请移除后重加已拣 {{ row.pickedPackageCount }} 箱
-
+
{{ formatNumber(Number(row.grossWeightKg || 0), 1) }} kg
{{ formatNumber(Number(row.volumeCbm || 0)) }} m³
@@ -603,7 +603,8 @@ onMounted(() => { void load(); void loadCustomers() })
.readonly-status-card strong { font-size: 16px; }
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
.meter-title { justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
-.plan-pane :deep(.el-input-number) { width: 120px; }
+.package-count-editor { display: flex; min-width: 0; }
+.plan-pane :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
@media (max-width: 1180px) {
diff --git a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts
index 2621cc3c..bff9d0eb 100644
--- a/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts
+++ b/src/modules/container-loading/pages/SmartLoadingWorkbenchView.test.ts
@@ -99,6 +99,10 @@ describe('SmartLoadingWorkbenchView', () => {
expect(source).toContain("canPush && plan?.status === 'EDITING'")
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo")
expect(source).toContain('推送后生成预装清单,不会直接开放 PDA')
+ expect(source).toContain('后续还需补集装箱号、确认预装。确认继续?')
+ expect(source).toContain('预装清单已生成,请继续完成集装箱号和预装确认')
+ expect(source).not.toContain('关联并放舱真实订舱')
+ expect(source).not.toContain('订舱放舱')
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
expect(source).not.toContain("label: '目的仓'")
expect(source).not.toContain('plan.planNo')
@@ -107,4 +111,10 @@ describe('SmartLoadingWorkbenchView', () => {
expect(source).toContain('集装箱号')
expect(source).not.toContain('当前柜号')
})
+
+ it('keeps the package count editor inside its table cell without overflow ellipsis', () => {
+ const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue'), 'utf8')
+ expect(source).toContain(' false)
+ const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
- try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认') }
+ try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号和预装确认') }
catch (error) { await handleMutationError(error, '推送失败') }
finally { mutating.value = false }
}
@@ -268,7 +268,7 @@ onMounted(load)
保存箱数移除整票含 {{ row.detailCount }} 个规格;改箱数请移除后重加已拣 {{ row.pickedPackageCount }} 箱
{{ row.inboundCode || '—' }}
{{ row.productNameZh || '未命名货物' }}{{ row.skuCode || '无 SKU' }}
-
+
{{ formatNumber(Number(row.volumeCbm || 0)) }} m³{{ formatNumber(Number(row.grossWeightKg || 0),1) }} kg
@@ -306,7 +306,8 @@ onMounted(load)
.readonly-status-card p { margin: 0; color: var(--cc-muted); }
.meter-title { justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 13px; }
.row-actions { min-width: 150px; }
-.workbench :deep(.el-input-number) { width: 130px; }
+.package-count-editor { display: flex; min-width: 0; }
+.workbench :deep(.package-count-editor .el-input-number) { width: 100%; min-width: 0; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
diff --git a/src/modules/customers/overseasStorageFee.test.ts b/src/modules/customers/overseasStorageFee.test.ts
index 5bc622d1..0cae8f3d 100644
--- a/src/modules/customers/overseasStorageFee.test.ts
+++ b/src/modules/customers/overseasStorageFee.test.ts
@@ -9,7 +9,14 @@ const overseasPanelSource = readFileSync(resolve(process.cwd(), 'src/modules/cus
vi.mock('@/shared/api/http', () => ({
http: { get: vi.fn(), put: vi.fn() },
- unwrapApiResponse: (response: { data: T }) => response.data,
+ unwrapApiResponse: (response: { data: T }) => {
+ const value = response.data as T & { code?: string; message?: string; data?: unknown }
+ if (value && typeof value === 'object' && 'code' in value) {
+ if (value.code !== '200') throw new Error(value.message || 'request failed')
+ return value.data as T
+ }
+ return value
+ },
}))
const config = { id: 12, customerId: 9007199254740993n.toString(), customerName: '客户', currency: 'USD', tiers: [], version: 0, createdAt: null, updatedAt: null }
@@ -46,6 +53,19 @@ describe('customer overseas storage fee api', () => {
expect(result.version).toBe(1)
})
+ it('rejects a business error returned by the update endpoint', async () => {
+ vi.mocked(http.put).mockResolvedValue({ data: { code: 'A0400', message: '璇锋眰鏃犳晥', data: null } })
+
+ await expect(updateCustomerOverseasStorageFees(config.customerId, {
+ customerId: config.customerId,
+ customerName: '瀹㈡埛',
+ currency: 'USD',
+ version: 0,
+ tiers: [{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 1, endDay: null, unitPrice: 0 }],
+ })).rejects.toThrow('璇锋眰鏃犳晥')
+ expect(http.get).not.toHaveBeenCalled()
+ })
+
it('normalizes an omitted end day as an open-ended tier', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { ...config, tiers: [{ id: 1, startDay: 91, unitPrice: 2, sortNo: 5, feeType: 'STORAGE', billingUnit: 'CBM' }] } })
const result = await getCustomerOverseasStorageFees(config.customerId)
diff --git a/src/modules/customers/overseasStorageFee.ts b/src/modules/customers/overseasStorageFee.ts
index f1816c2d..88e590d1 100644
--- a/src/modules/customers/overseasStorageFee.ts
+++ b/src/modules/customers/overseasStorageFee.ts
@@ -55,7 +55,8 @@ export async function getCustomerOverseasStorageFees(tenantId: string) {
}
export async function updateCustomerOverseasStorageFees(tenantId: string, request: OverseasStorageFeeUpdateRequest) {
- await http.put>(path(tenantId), request)
+ const response = await http.put>(path(tenantId), request)
+ unwrapApiResponse(response)
return getCustomerOverseasStorageFees(tenantId)
}
diff --git a/src/modules/finance/api/finance.test.ts b/src/modules/finance/api/finance.test.ts
index 3e53ae0a..54d30f27 100644
--- a/src/modules/finance/api/finance.test.ts
+++ b/src/modules/finance/api/finance.test.ts
@@ -56,6 +56,28 @@ describe('finance api', () => {
])
})
+ it('includes a supplier filter only for payable fee-pool requests', async () => {
+ const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [] })
+
+ await listFinanceFeePool('10001', 'PAYABLE', '301')
+
+ expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-pool', {
+ params: { customerId: '10001', direction: 'PAYABLE', supplierId: '301' },
+ })
+ })
+
+ it('preserves settlement mode from fee-pool candidates for client-side eligibility checks', async () => {
+ vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{
+ poolItemId: 'PAYABLE:42', customerId: 10001, direction: 'PAYABLE', sourceType: 'INBOUND_FEE', sourceId: 42,
+ sourceVersion: 1, feeItemName: '进仓费', amount: 33, currency: 'CNY', settlementMode: 'DIRECT_PAID',
+ status: 'AVAILABLE', createdAt: '2026-08-26T00:00:00',
+ }] })
+
+ const [candidate] = await listFinanceFeePool('10001', 'PAYABLE', '301')
+
+ expect(candidate.settlementMode).toBe('DIRECT_PAID')
+ })
+
it('uses the formal G1 account list path and fixed page DTO', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } })
diff --git a/src/modules/finance/api/finance.ts b/src/modules/finance/api/finance.ts
index 368c4bf9..15ece978 100644
--- a/src/modules/finance/api/finance.ts
+++ b/src/modules/finance/api/finance.ts
@@ -111,12 +111,14 @@ export async function getFinanceCustomerAccountSummary(customerId: string): Prom
}
}
-export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE'): Promise {
- const response = await http.get, ApiEnvelope>(`${basePath}/fee-pool`, { params: { customerId: customerId?.trim() || undefined, direction } })
+export async function listFinanceFeePool(customerId?: string, direction?: 'RECEIVABLE' | 'PAYABLE', supplierId?: string): Promise {
+ const params: Record = { customerId: customerId?.trim() || undefined, direction }
+ if (supplierId?.trim()) params.supplierId = supplierId.trim()
+ const response = await http.get, ApiEnvelope>(`${basePath}/fee-pool`, { params })
return unwrapApiResponse(response).map((item) => {
const poolItemId = stringValue(item.poolItemId)
const feeSourceId = stringValue(item.sourceType) === 'MANUAL_COST' ? null : (poolItemId.match(/^[A-Z_]+:(\d+)$/)?.[1] ?? null)
- return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
+ return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', settlementMode: item.settlementMode == null ? null : stringValue(item.settlementMode), status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
})
}
diff --git a/src/modules/finance/pages/FinanceCustomerArapView.test.ts b/src/modules/finance/pages/FinanceCustomerArapView.test.ts
index 307e0bc5..3d3b20b7 100644
--- a/src/modules/finance/pages/FinanceCustomerArapView.test.ts
+++ b/src/modules/finance/pages/FinanceCustomerArapView.test.ts
@@ -129,6 +129,13 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(adjustFinanceFeeSource).not.toHaveBeenCalled()
})
+ it('keeps the customer dropdown open while its option list is scrolled', async () => {
+ const source = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerArapView.vue'), 'utf8')
+ expect(source).not.toContain("document.addEventListener('scroll', closeCustomerSelectOnScroll, true)")
+ expect(source).not.toContain('input?.blur()')
+ expect(source).toContain("class: 'finance-customer-select'")
+ })
+
it('uses sourceSystem to suppress Firstleg editing when the source type is not in the legacy fallback list', async () => {
const firstlegOtherType = { ...feeSummary, sourceType: 'ORDER_FEE', reviewStatus: 'DRAFT' as const }
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [firstlegOtherType], total: 1, pages: 1 })
diff --git a/src/modules/finance/pages/FinanceCustomerArapView.vue b/src/modules/finance/pages/FinanceCustomerArapView.vue
index 512fa642..8dac9f43 100644
--- a/src/modules/finance/pages/FinanceCustomerArapView.vue
+++ b/src/modules/finance/pages/FinanceCustomerArapView.vue
@@ -1,5 +1,5 @@
+
+
+
+
+
+
+
+
+ 全部标为已读
+ 共 {{ result.total }} 条,未读 {{ unreadCount }} 条
+ 查看标为已读
+ {{ notificationCategoryLabel((row as NotificationInboxItem).category) }}
+ {{ formatDate((row as NotificationInboxItem).sentAt) }}
+ {{ statusLabel((row as NotificationInboxItem).status) }}
+
+
+
+
+
+ {{ detail.title }}
{{ statusLabel(detail.status) }}{{ notificationCategoryLabel(detail.category) }} · {{ formatDate(detail.sentAt) }}
{{ detail.body || '暂无消息内容' }}
+
+
+
+
+
diff --git a/src/modules/notifications/routes.ts b/src/modules/notifications/routes.ts
index 3b5a4e7e..f2f4dea6 100644
--- a/src/modules/notifications/routes.ts
+++ b/src/modules/notifications/routes.ts
@@ -1,6 +1,12 @@
import type { RouteRecordRaw } from 'vue-router'
export const notificationRoutes = [
+ {
+ path: 'notifications',
+ name: 'notification-inbox',
+ component: () => import('@/modules/notifications/pages/PlatformNotificationsView.vue'),
+ meta: { title: '我的消息', description: '查看当前平台账号的站内消息', permission: 'platform:notification:view' },
+ },
{
path: 'notifications/messages',
name: 'notification-messages',
diff --git a/src/modules/notifications/types.ts b/src/modules/notifications/types.ts
index efe3b2f7..99dfc57a 100644
--- a/src/modules/notifications/types.ts
+++ b/src/modules/notifications/types.ts
@@ -8,6 +8,10 @@ export interface NotificationTask { id: string; taskNo: string; category: string
export interface NotificationTaskRecipient { id: string; displayName: string; appCode: string; status: string; failureReason: string | null; deliveredAt: string | null }
export interface NotificationTaskDetail extends Omit { attemptCount: number; lastError: string | null; recipients: NotificationTaskRecipient[] }
+export type NotificationInboxStatus = 'UNREAD' | 'READ'
+export interface NotificationInboxItem { id: string; category: string; title: string; summary: string; status: NotificationInboxStatus; sentAt: string; readAt: string | null }
+export interface NotificationInboxDetail extends NotificationInboxItem { body: string; sourceType: string | null; sourceId: string | null }
+
export interface CreateNotificationRequest { tenantId: string; templateCode: string; recipientType: RecipientType; recipientIds?: string[]; roleCode?: string; variables: Record; scheduledAt: string | null; idempotencyKey: string; sourceEventId?: string | null }
export interface CreateTemplateRequest { templateCode: string; templateName: string; category: string; titleTemplate: string; bodyTemplate: string; variableWhitelist: string[] }
export interface UpdateTemplateRequest extends Omit { version: number }
diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts
index ea5f1b8a..c6db7ba5 100644
--- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts
+++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.test.ts
@@ -24,6 +24,11 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).not.toContain('cancelOceanBooking')
})
+ it('invalidates the cached booking list after deleting a draft', () => {
+ expect(source).toContain("workspace.bumpCacheEpoch('ocean-bookings')")
+ expect(source).toContain("await router.replace('/ocean-bookings')")
+ })
+
it('saves incomplete DRAFT data and sends null for blank nullable fields', () => {
expect(source).toContain('保存草稿')
expect(source).toContain('channelCode: form.data.channelCode?.trim() || null')
diff --git a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue
index 9732cbc2..699b6022 100644
--- a/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue
+++ b/src/modules/ocean-bookings/pages/OceanBookingDetailView.vue
@@ -316,6 +316,7 @@ async function removeDraft() {
actionSaving.value = true
await deleteOceanBookingDraft(id.value, detail.value.version)
ElMessage.success('订舱草稿已删除')
+ workspace.bumpCacheEpoch('ocean-bookings')
await router.replace('/ocean-bookings')
} catch (cause) {
if (cause !== 'cancel' && !(cause instanceof Error && cause.message === 'cancel')) actionError.value = getApiErrorDetails(cause)
diff --git a/src/modules/trucking/components/TruckAppointmentForm.test.ts b/src/modules/trucking/components/TruckAppointmentForm.test.ts
index ea62944e..c3c9d5a5 100644
--- a/src/modules/trucking/components/TruckAppointmentForm.test.ts
+++ b/src/modules/trucking/components/TruckAppointmentForm.test.ts
@@ -47,6 +47,16 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).not.toContain('const relationText = computed')
})
+ it('loads readonly detail options and preserves relation snapshots outside the first page', () => {
+ expect(source).toContain('onMounted(() => { void loadOptions() })')
+ expect(source).toContain('const selectedContainerFallback = computed')
+ expect(source).toContain('const selectedBookingFallback = computed')
+ expect(source).toContain('const selectedWarehouseFallback = computed')
+ expect(source).toContain('v-if="selectedContainerFallback"')
+ expect(source).toContain('v-if="selectedBookingFallback"')
+ expect(source).toContain('v-if="selectedWarehouseFallback"')
+ })
+
it('keeps booking, cabinet and loading plan optional and only uses them for completion hints', () => {
expect(source).toContain('订舱、柜次和装柜计划均为可选关联,不影响独立开立拖车预约')
expect(source).toContain('class="relation-note"')
diff --git a/src/modules/trucking/components/TruckAppointmentForm.vue b/src/modules/trucking/components/TruckAppointmentForm.vue
index 4e970e6e..3374eeda 100644
--- a/src/modules/trucking/components/TruckAppointmentForm.vue
+++ b/src/modules/trucking/components/TruckAppointmentForm.vue
@@ -53,6 +53,27 @@ const selectableCarriers = computed(() => carriers.value.filter((item) => (
item.status === 'ACTIVE'
)))
const selectedReleasedBooking = computed(() => bookings.value.find((item) => item.id === draft.oceanBookingId && item.status === 'RELEASED') || null)
+const selectedContainerFallback = computed(() => {
+ const id = draft.containerBookingId
+ const snapshot = props.resource
+ if (!id || containers.value.some((item) => String(item.id) === String(id)) || String(snapshot?.containerBookingId) !== String(id)) return null
+ return {
+ id: String(id),
+ label: `柜次号:${snapshot?.cabinetNo || '待生成'} / 集装箱号:${snapshot?.containerNo || '待录入'}`,
+ }
+})
+const selectedBookingFallback = computed(() => {
+ const id = draft.oceanBookingId
+ const snapshot = props.resource
+ if (!id || bookings.value.some((item) => String(item.id) === String(id)) || String(snapshot?.oceanBookingId) !== String(id)) return null
+ return { id: String(id), label: snapshot?.oceanBookingNo || '订舱号缺失' }
+})
+const selectedWarehouseFallback = computed(() => {
+ const id = draft.warehouseId
+ const snapshot = props.resource
+ if (!id || selectableWarehouses.value.some((item) => String(item.id) === String(id)) || String(snapshot?.warehouseId) !== String(id)) return null
+ return { id: String(id), label: snapshot?.warehouseName || '当前仓库(需重新选择)' }
+})
function customerLabel(customer: PlatformTenantResource) { return customer.tenantName }
function selectCustomer(customerId: string) { const customer = selectableCustomers.value.find((item) => String(item.id) === customerId); draft.customerId = customer?.id ? String(customer.id) : null; draft.customerName = customer ? customerLabel(customer) : null; derivedCustomerSource.value = null; relationWarning.value = '' }
function selectCarrier(carrierId: string) { const carrier = selectableCarriers.value.find((item) => String(item.id) === carrierId); draft.carrierId = carrier?.id || null; draft.carrierName = carrier?.name || null }
@@ -115,7 +136,7 @@ async function loadOptions() {
async function validate() { return formRef.value?.validate() }
async function submitForm() { if (await formRef.value?.validate()) emit('submit') }
defineExpose({ validate })
-onMounted(() => { if (!props.readonly) void loadOptions() })
+onMounted(() => { void loadOptions() })
watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.length) void loadOptions() })
@@ -131,9 +152,9 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
已从放舱订舱回填并锁定
可选关联
-
-
-
+
+
+
运输与时间计划
diff --git a/src/modules/trucking/pages/TruckAppointmentDetailView.test.ts b/src/modules/trucking/pages/TruckAppointmentDetailView.test.ts
index 6ba8a2b6..0b075f8b 100644
--- a/src/modules/trucking/pages/TruckAppointmentDetailView.test.ts
+++ b/src/modules/trucking/pages/TruckAppointmentDetailView.test.ts
@@ -52,4 +52,10 @@ describe('TruckAppointmentDetailView status contract', () => {
expect(source).toContain('主详情数据不受影响')
expect(source).toContain('v-if="relatedError"')
})
+
+ it('reloads a cached detail after an error without showing stale resource data', () => {
+ expect(source).toContain("resource.value = null; history.value = []; relatedError.value = ''")
+ expect(source).toContain('onActivated(() => {')
+ expect(source).toContain('if (error.value || resource.value?.id !== id.value) void load()')
+ })
})
diff --git a/src/modules/trucking/pages/TruckAppointmentDetailView.vue b/src/modules/trucking/pages/TruckAppointmentDetailView.vue
index 809d30ab..7538db56 100644
--- a/src/modules/trucking/pages/TruckAppointmentDetailView.vue
+++ b/src/modules/trucking/pages/TruckAppointmentDetailView.vue
@@ -1,5 +1,5 @@
diff --git a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
index 58542937..dfc317e5 100644
--- a/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
+++ b/src/modules/unified-orders/pages/UnifiedOrdersView.test.ts
@@ -133,6 +133,7 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain("{ key: 'customerRemark', prop: 'customerRemark', label: '客户备注'")
expect(source).toContain("{ key: 'latestInternalNote', prop: 'latestInternalNote', label: '内部备注'")
expect(source.match(/ {
@@ -237,6 +238,11 @@ describe('UnifiedOrdersView contract', () => {
expect(source).not.toContain("value.replaceAll('_', ' ')")
})
+ it('uses the inbound measurement girth formula for cargo dimensions', () => {
+ expect(source).toContain('formatNumber(length + 2 * (width + height))')
+ expect(source).not.toContain('formatNumber(2 * (width + height))')
+ })
+
it('binds dimension and weight evidence to their formal server records without mixing galleries', () => {
expect(source).toContain(', key: string) {
const value = cargo[key]
if (key === 'circumferenceCm') {
+ const length = Number(cargo.lengthCm)
const width = Number(cargo.widthCm)
const height = Number(cargo.heightCm)
- return Number.isFinite(width) && Number.isFinite(height) ? formatNumber(2 * (width + height)) : '—'
+ return Number.isFinite(length) && Number.isFinite(width) && Number.isFinite(height)
+ ? formatNumber(length + 2 * (width + height))
+ : '—'
}
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'number') return formatNumber(value)
@@ -1017,7 +1020,7 @@ onBeforeUnmount(() => {
PC 端下单文件{{ detail.summary.pcOrderFiles?.join('、') || '—' }}
快递公司{{ detail.summary.expressCompany || detail.order.order?.expressCompany || '—' }}
快递单号{{ detail.summary.expressTrackingNo || detail.order.order?.expressTrackingNo || '—' }}
- 客服内部备注{{ detail.summary.internalRemark || '—' }}
+ 客服内部备注{{ detail.summary.internalRemark || detail.summary.latestInternalNote || '—' }}
创建人{{ detail.summary.createdBy || '—' }}
下一步{{ canApprove(detail.summary) ? '审核订单' : nextActionBoundary(detail.summary) || '—' }}
diff --git a/src/shared/api/http.test.ts b/src/shared/api/http.test.ts
index c20b2b88..871ac892 100644
--- a/src/shared/api/http.test.ts
+++ b/src/shared/api/http.test.ts
@@ -66,6 +66,13 @@ describe('API error diagnostics', () => {
}
})
+ it('translates notification recipient error codes into actionable Chinese messages', () => {
+ expect(getApiErrorDetails(new ApiResponseError('NOTIFICATION_RECIPIENT_FORBIDDEN', 'A0400')).message)
+ .toBe('角色编码不存在或当前账号无权使用该角色,请检查后重试')
+ expect(getApiErrorDetails(new ApiResponseError('NOTIFICATION_RECIPIENT_EMPTY', 'A0400')).message)
+ .toBe('该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员')
+ })
+
it('preserves a backend credential error for an explicit login request', () => {
const error = new ApiResponseError(
'用户名或密码错误',
diff --git a/src/shared/api/http.ts b/src/shared/api/http.ts
index c70cfed7..15e9d239 100644
--- a/src/shared/api/http.ts
+++ b/src/shared/api/http.ts
@@ -53,6 +53,15 @@ let authRefreshHandler: AuthRefreshHandler | null = null
let authSessionExpiredHandler: AuthSessionExpiredHandler | null = null
let activeRefresh: Promise | null = null
+const businessErrorMessages: Record = {
+ NOTIFICATION_RECIPIENT_FORBIDDEN: '角色编码不存在或当前账号无权使用该角色,请检查后重试',
+ NOTIFICATION_RECIPIENT_EMPTY: '该角色下暂无可接收消息的有效用户,请先为角色分配启用中的成员',
+}
+
+function friendlyBusinessMessage(message?: string | null) {
+ return (message && businessErrorMessages[message]) || message || undefined
+}
+
export function registerAuthRefreshHandler(handler: AuthRefreshHandler) {
authRefreshHandler = handler
}
@@ -134,7 +143,7 @@ export function getApiErrorDetails(
? '当前账号无权访问该数据,请联系管理员确认权限'
: error.code.startsWith('S05')
? '服务暂时不可用,请稍后重试'
- : error.message
+ : friendlyBusinessMessage(error.message) || '接口请求失败'
return { message, code: error.code, requestId: error.requestId, traceId: error.traceId }
}
if (axios.isAxiosError(error)) {
@@ -156,7 +165,7 @@ export function getApiErrorDetails(
? '服务暂时不可用,请稍后重试'
: undefined
return {
- message: statusMessage || response?.message || response?.error || fallback,
+ message: statusMessage || friendlyBusinessMessage(response?.message || response?.error) || fallback,
code: response?.code || (error.response?.status ? `HTTP_${error.response.status}` : undefined),
requestId: response?.requestId || undefined,
traceId: response?.traceId || undefined,
diff --git a/src/shared/components/QueryToolbar.vue b/src/shared/components/QueryToolbar.vue
index 811f9d02..bebb8e5b 100644
--- a/src/shared/components/QueryToolbar.vue
+++ b/src/shared/components/QueryToolbar.vue
@@ -40,11 +40,13 @@ const props = withDefaults(defineProps<{
searchText?: string
resetText?: string
searchOnReset?: boolean
+ searchOnClear?: boolean
loading?: boolean
}>(), {
searchText: '查询',
resetText: '重置',
searchOnReset: true,
+ searchOnClear: true,
loading: false,
})
@@ -146,6 +148,10 @@ function search() {
emit('search', model.value)
}
+function clearInput() {
+ if (props.searchOnClear) search()
+}
+
function reset() {
if (props.loading) return
for (const field of props.fields) model.value[field.key] = resetValue(field)
@@ -182,7 +188,7 @@ defineExpose({ search, reset })
clearable
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
- @clear="search"
+ @clear="clearInput"
@keyup.enter.exact="searchFromTextInput"
>