Compare commits

...
17 changed files with 647 additions and 149 deletions
+5 -4
View File
@@ -36,11 +36,11 @@ describe('adminNavigation', () => {
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心'] },
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(13)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(56)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(57)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
@@ -196,12 +196,13 @@ describe('adminNavigation', () => {
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
})
it('retains the supplier route permission while omitting its entry from the latest sidebar', () => {
it('shows the restored supplier entry under general only with its list permission', () => {
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
const missing = filterAdminNavigation(adminNavigation, new Set())
expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list')
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard'])
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
expect(visible.find(item => item.title === '通用')?.children?.map(item => item.path)).toEqual(['/suppliers'])
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
})
+1 -1
View File
@@ -204,7 +204,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' },
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list', hidden: true },
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true },
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true },
{ key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true },
@@ -46,4 +46,30 @@
| 本土卡车 | 新增、编辑 | 上表 `page-overseas-truck-add`、`page-overseas-truck-edit`;本轮用户确认同步后台 | 保持独立页面及现有分区;起运地和目的地两列对应联系人 | 删除旧需求字段,增加车型及两端联系、取送时间;待人工截图确认 |
| 本土卡车 | 列表 | 上表 `page-overseas-truck`;本轮确认 | 保持列表、操作第一列及现有承运安排 | 对应展示车型、两端联系及取送时间;待人工截图确认 |
后台不新增详情路由;池来源仍只读展示承运安排,美本土散板不变。
后台不新增详情路由;池来源仍只读展示承运安排,散板最新范围见下节。
## 本土散板简化下单
字段规则唯一见[海外仓业务 README](../../../../../docs/海外仓业务/README.md#本土散板简化下单)。依据用户截图 `codex-clipboard-71e44fad-9790-48c0-bf94-fa80ddce1445.png` 和本轮确认,覆盖此前散板新增/编辑字段结论。
| 页面 | 操作 | 原型证据及布局 | 当前实现与结论 |
|---|---|---|---|
| 本土散板 | 新增 | 用户截图:独立页面;自送/我司提货;地址与联系两列;货物四列;取送时间两列;底部取消/提交 | 保持独立路由,顶部补客户;货物为商品名、预计托数、重量、体积;显示币种;件数/地址补充/备注收起;移除运输方式;两端四项联系信息和取送时间必填。待人工 1920×1080、100% 截图确认 |
| 本土散板 | 编辑 | 现有独立编辑路由;本轮确认的新需求合同 | 同新增字段并回显保存值;只允许待接单非池来源;版本冲突保留内容。待人工截图确认 |
| 本土散板 | 列表 | 原 `page-overseas-looseboard` 独立列表与左侧操作 | 展示两端联系信息、商品名、预计托数及取送时间;内部未知安排显示“—”;池来源保持承运摘要。待人工截图确认 |
本次不增加详情、审核、删除或文件预览操作;不更改现有承运安排只读弹框。
## Admin / Client 统一运输下单表单(2026-10-19)
依据:当前对话明确要求“最多 Admin 添加客户选项,Client 从登录客户下单”,覆盖此前两端独立排版结论。保留应用壳层、正式路由及权限;业务表单统一为同一字段清单、排列、控件、默认值、校验及提交转换。
| 页面 | 操作 | 承载与现有依据 | 本轮统一结论 | 验收 |
|---|---|---|---|---|
| 本土卡车 | Client 新增、Admin 新增/编辑 | 既有独立页面;原客户端截图及当前统一确认 | 运输信息双列,车辆必选;两端联系人/电话及详细地址左右对应;货物件重体;费用币种与两个分钟时间;备注;底部取消/提交。Admin 新增前置客户选择 | 自动合同及行为测试;待人工 1920×1080、100% 截图 |
| 本土散板 | Client 新增、Admin 新增/编辑 | 既有独立页面;当前统一确认 | 与卡车共用表单;保留商品名、预计托数和件重体,移除车型;其余相同。Admin 新增前置客户选择 | 同上 |
| 两类业务 | 列表、详情、承运安排 | 既有独立路由及只读承运摘要 | 本轮不改变承载;Client 不新增编辑路由 | 既有行为回归 |
统一表单组件和转换逻辑各自位于应用内部,运行时不跨应用引用。两端业务组件源码一致性另作定向检查;下单数据不允许混入客户标识,Admin 在 API 参数显式选择客户,Client 由认证会话确定客户。
@@ -26,9 +26,9 @@ const truckSummary = {
pickupAt: '2026-08-20T09:30:00', deliveryAt: '2026-08-20T15:45:00', status: 'PENDING_REVIEW', version: 2, createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-02T00:00:00',
}
const looseboardSummary = {
id: '9007199254741005', customerId: '10003', orderNo: 'LB-1', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
origin: 'LA', destination: 'ONT', containerType: null, packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2,
budgetAmount: 50, currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0,
id: '9007199254741005', customerId: '10003', orderNo: 'LB-1', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 2, packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2,
budgetAmount: 50, currency: 'USD', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', status: 'PENDING_ACCEPTANCE', version: 0,
createdAt: '2026-08-01T00:00:00', updatedAt: '2026-08-01T00:00:00',
}
@@ -111,7 +111,7 @@ describe('platform overseas business API adapter', () => {
overseas.get.mockResolvedValueOnce(envelope({ records: [looseboardSummary], page: 1, size: 20, total: 1, pages: 1 })).mockResolvedValueOnce(envelope(detail))
expect((await listLocalLooseboardOrders({ status: 'PENDING_ACCEPTANCE' })).records[0]?.id).toBe('9007199254741005')
expect((await getLocalLooseboardOrder('LB-1', '10003')).status).toBe('PENDING_ACCEPTANCE')
const create = { transportMode: 'SHARED_TRUCK' as const, palletRequired: 'YES' as const, transportType: 'WAREHOUSE_TO_WAREHOUSE' as const, origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'HTTP-20260824-MARKER-looseboard' }
const create = { productName: '家居用品', estimatedPalletCount: 2, originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', origin: 'LA', destination: 'ONT', packageCount: 8, totalWeightKg: 80.5, totalVolumeCbm: 3.2, idempotencyKey: 'HTTP-20260824-MARKER-looseboard' }
overseas.post.mockResolvedValue(envelope(detail)); await createLocalLooseboardOrder('9007199254742001', create)
expect(overseas.post).toHaveBeenCalledWith('/platform/v1/last-leg/local-looseboard-orders', create, { params: { customerId: '9007199254742001' } })
const { idempotencyKey: _looseKey, ...looseboardFields } = create
@@ -133,11 +133,11 @@ describe('platform overseas business API adapter', () => {
expect(overseas.put).not.toHaveBeenCalled()
})
it('preserves unknown pool pallet service and container type in list and detail responses', async () => {
const summary = { ...looseboardSummary, palletRequired: null, containerType: null }
it('preserves unknown pool pallet service and product name in list and detail responses', async () => {
const summary = { ...looseboardSummary, palletRequired: null, productName: null }
overseas.get.mockResolvedValueOnce(envelope({ records: [summary], total: 1, page: 1, size: 20, pages: 1 })).mockResolvedValueOnce(envelope({ summary, histories: [] }))
expect((await listLocalLooseboardOrders({})).records[0]).toMatchObject({ palletRequired: null, containerType: null })
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ palletRequired: null, containerType: null })
expect((await listLocalLooseboardOrders({})).records[0]).toMatchObject({ palletRequired: null, productName: null })
expect(await getLocalLooseboardOrder('LB-1', '10003')).toMatchObject({ palletRequired: null, productName: null })
expect(overseas.get).toHaveBeenCalledTimes(2)
})
})
@@ -131,7 +131,7 @@ export async function updateUsTruckOrder(orderNo: string, customerId: string, pa
return truckDetail(unwrapApiResponse(response))
}
export async function listLocalLooseboardOrders(query: LocalLooseboardQuery = {}) {
const response = await http.get<ApiEnvelope<PagePayload<LooseboardSummaryPayload>>, ApiEnvelope<PagePayload<LooseboardSummaryPayload>>>(looseboardBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined, transportMode: query.transportMode || undefined } })
const response = await http.get<ApiEnvelope<PagePayload<LooseboardSummaryPayload>>, ApiEnvelope<PagePayload<LooseboardSummaryPayload>>>(looseboardBasePath, { params: { ...query, customerId: query.customerId || undefined, orderNo: query.orderNo?.trim() || undefined, routeKeyword: query.routeKeyword?.trim() || undefined, status: query.status || undefined } })
return normalizePage(unwrapApiResponse(response), looseboardSummary)
}
export async function getLocalLooseboardOrder(orderNo: string, customerId: string) {
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { FormInstance } from 'element-plus'
import { transportDemandRules, type TransportDemandForm, type TransportDemandKind } from '../transportDemandForm'
const props = withDefaults(defineProps<{
model: TransportDemandForm
kind: TransportDemandKind
disabled?: boolean
submitting?: boolean
canSubmit?: boolean
editing?: boolean
}>(), { disabled: false, submitting: false, canSubmit: true, editing: false })
const emit = defineEmits<{ submit: []; cancel: [] }>()
const formRef = ref<FormInstance>()
const rules = computed(() => transportDemandRules(props.kind, props.model))
defineExpose({
validate: () => formRef.value?.validate() ?? Promise.resolve(false),
clearValidate: (fields?: string | string[]) => formRef.value?.clearValidate(fields),
})
</script>
<template>
<el-form ref="formRef" class="transport-demand-form" :model="model" :rules="rules" label-position="top" :disabled="disabled || submitting">
<slot name="customer" />
<section class="transport-demand-section">
<h3>运输信息</h3>
<div class="transport-demand-grid">
<el-form-item v-if="kind === 'truck'" class="transport-demand-wide" label="车辆类型" prop="vehicleType">
<el-select v-model="model.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select>
</el-form-item>
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="model.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item>
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="model.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item>
<el-form-item label="起运地联系人" prop="originContactName"><el-input v-model.trim="model.originContactName" maxlength="128" placeholder="请输入起运地联系人" /></el-form-item>
<el-form-item label="目的地联系人" prop="destinationContactName"><el-input v-model.trim="model.destinationContactName" maxlength="128" placeholder="请输入目的地联系人" /></el-form-item>
<el-form-item label="起运地联系电话" prop="originContactPhone"><el-input v-model.trim="model.originContactPhone" type="tel" maxlength="128" placeholder="请输入起运地联系电话" /></el-form-item>
<el-form-item label="目的地联系电话" prop="destinationContactPhone"><el-input v-model.trim="model.destinationContactPhone" type="tel" maxlength="128" placeholder="请输入目的地联系电话" /></el-form-item>
<el-form-item label="起运详细地址"><el-input v-model.trim="model.originAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
<el-form-item label="目的详细地址"><el-input v-model.trim="model.destinationAddress" maxlength="500" placeholder="街道 / 门牌等补充信息(选填)" /></el-form-item>
</div>
</section>
<section class="transport-demand-section">
<h3>货物信息</h3>
<div class="transport-demand-grid" :class="kind === 'looseboard' ? 'transport-demand-cargo-four' : 'transport-demand-cargo-three'">
<el-form-item v-if="kind === 'looseboard'" label="商品名称"><el-input v-model.trim="model.productName" maxlength="255" placeholder="请输入商品名称(选填)" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="model.estimatedPalletCount" :min="0" :precision="0" controls-position="right" placeholder="请输入预计托数" /></el-form-item>
<el-form-item v-if="kind === 'truck'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" placeholder="请输入件数" /></el-form-item>
<el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input-number v-model="model.totalWeightKg" :min="0" :precision="3" controls-position="right" placeholder="请输入总重量" /></el-form-item>
<el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input-number v-model="model.totalVolumeCbm" :min="0" :precision="6" controls-position="right" placeholder="请输入总体积" /></el-form-item>
<el-form-item v-if="kind === 'looseboard'" label="件数" prop="packageCount"><el-input-number v-model="model.packageCount" :min="0" :precision="0" controls-position="right" placeholder="请输入件数" /></el-form-item>
</div>
</section>
<section class="transport-demand-section">
<h3>费用与时效</h3>
<div class="transport-demand-grid">
<el-form-item label="预算总价" prop="budgetAmount"><div class="transport-demand-budget">
<el-input-number v-model="model.budgetAmount" :min="0" :precision="2" controls-position="right" placeholder="请输入预算总价(选填)" />
<el-select v-model="model.currency" aria-label="预算币种" placeholder="请选择预算币种"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select>
</div></el-form-item>
<div />
<el-form-item label="取货时间" prop="pickupAt"><el-date-picker v-model="model.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货日期和时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item>
<el-form-item label="送货时间" prop="deliveryAt"><el-date-picker v-model="model.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货日期和时间" /></el-form-item>
<el-form-item class="transport-demand-wide" label="备注"><el-input v-model="model.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入其他要求(选填)" /></el-form-item>
</div>
</section>
<footer class="transport-demand-actions">
<el-button :disabled="submitting" @click="emit('cancel')">取消</el-button>
<el-button v-if="canSubmit" type="primary" :loading="submitting" :disabled="disabled" @click="emit('submit')">{{ editing ? '保存修改' : '提交订单' }}</el-button>
</footer>
</el-form>
</template>
<style scoped>
.transport-demand-form { --el-component-size: 36px; width: 100%; max-width: 1180px; margin: 0; background: #fff; }
.transport-demand-section { padding: 20px 24px 0; border-bottom: 1px solid #e7ebf1; }
.transport-demand-section h3 { margin: 0 0 16px; color: #27364d; font-size: 14px; font-weight: 600; }
.transport-demand-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 24px; }
.transport-demand-cargo-three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.transport-demand-cargo-four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.transport-demand-wide { grid-column: 1 / -1; }
.transport-demand-form :deep(.el-form-item) { min-width: 0; margin-bottom: 18px; }
.transport-demand-form :deep(.el-form-item__label) { height: auto; padding-bottom: 6px; font-size: 13px; line-height: 20px; }
.transport-demand-form :deep(.el-input), .transport-demand-form :deep(.el-input-number), .transport-demand-form :deep(.el-select), .transport-demand-form :deep(.el-date-editor) { width: 100%; }
.transport-demand-budget { display: grid; width: 100%; grid-template-columns: minmax(0, 1fr) 100px; gap: 10px; }
.transport-demand-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 24px; }
.transport-demand-actions :deep(.el-button + .el-button) { margin-left: 0; }
</style>
@@ -57,7 +57,7 @@ const procurementDetail = (): PlatformProcurement => ({
})
const truck: LocalTransportOrder = { fleetArrangement: null, id: '9007199254740995', customerId: '10002', orderNo: 'TK-200', vehicleType: 'PALLET_15', origin: 'Long Beach', destination: 'Ontario', palletCount: 2, packageCount: 20, receivedPalletCount: 0, allocatedPalletCount: 0, shippedPalletCount: 0, totalWeightKg: '1200.000', totalVolumeCbm: '16.500', budgetAmount: '850.00', currency: 'USD', originContactName: 'Bob', originContactPhone: '10010', destinationContactName: 'Alice', destinationContactPhone: '10020', pickupAt: '2026-08-28T09:30:00', deliveryAt: '2026-08-28T15:45:00', status: 'PENDING_REVIEW', version: 1, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-02T10:00:00' }
const truckDetail: LocalTransportDetail = { ...truck, originAddress: 'Pier A', destinationAddress: 'Warehouse B', remark: null, createdBy: '7', pallets: [], histories: [] }
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', containerType: '40HQ', packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', contactName: null, contactInfo: null, requestedDate: null, status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboard: LocalLooseboardOrder = { fleetArrangement: null, id: '9007199254740997', customerId: '10003', orderNo: 'LB-300', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 2, packageCount: 8, totalWeightKg: '80.5', totalVolumeCbm: '3.2', budgetAmount: '50.00', currency: 'USD', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00', status: 'PENDING_ACCEPTANCE', version: 0, createdAt: '2026-08-01T10:00:00', updatedAt: '2026-08-01T10:00:00' }
const looseboardDetail: LocalLooseboardDetail = { ...looseboard, originAddress: null, destinationAddress: null, remark: null, createdBy: '7', histories: [] }
const arrangement: FleetArrangement = { sourceType: 'FLEET_CAPACITY_OFFER', businessType: 'US_TRUCK', businessOrderNo: 'TR-200', bookingNo: 'FB-TEST', tripNo: 'TRIP-TEST', plannedDepartureAt: null, originWarehouseName: '我仓', destinationLabel: 'ONT8', deliveryMode: 'SELF_DELIVERY', deliveryInstructions: null, fulfillmentStage: 'WAITING_INBOUND', stageOccurredAt: null, agreedFreightAmount: null, currency: 'USD', incomingNo: null, dispatchNo: null, deliveryNo: null, signedPackages: null, unsignedPackages: null, returnedPackages: null, exceptionSummary: null, readOnlyReason: '池来源订单按实际运输履约推进' }
@@ -133,12 +133,11 @@ describe('overseas business mounted pages', () => {
expect(wrapper.find('form').exists()).toBe(false); expect(wrapper.findAll('button').some(b => b.text() === '保存修改')).toBe(false)
expect(updateUsTruckOrder).not.toHaveBeenCalled(); expect(getUsTruckOrder).toHaveBeenCalledOnce()
})
it('distinguishes unknown pool pallet service from explicit no and yes in actual list cells', async () => {
const records = ([null, 'NO', 'YES'] as const).map((palletRequired, index) => ({ ...looseboard, id: String(100 + index), orderNo: `LB-NULL-${index}`, palletRequired, containerType: null, fleetArrangement: { ...arrangement, businessType: 'LOCAL_LOOSEBOARD' as const } }))
vi.mocked(listLocalLooseboardOrders).mockResolvedValue({ records, total: 3, page: 1, size: 20, pages: 1 })
it('shows declared product and forecast pallets without exposing obsolete container selection', async () => {
const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises()
const rows = wrapper.findAll('.el-table__body-wrapper tbody .el-table__row')
expect(rows.map(row => row.findAll('td')[3]!.text())).toEqual(['—', '否', '是'])
expect(wrapper.text()).toContain('家居用品')
expect(wrapper.text()).toContain('预计托数')
expect(wrapper.text()).not.toContain('柜型')
expect(listLocalLooseboardOrders).toHaveBeenCalledOnce()
})
it('shows unknown pool vehicle type as a dash without inventing a capacity', async () => {
@@ -147,14 +146,15 @@ describe('overseas business mounted pages', () => {
expect(wrapper.find('.el-table__body-wrapper tbody .el-table__row').findAll('td')[2]!.text()).toBe('—')
expect(wrapper.find('.el-table__body-wrapper').text()).not.toContain('40HQ')
})
it('still rejects an ordinary looseboard edit when its required pallet service is absent', async () => {
it('allows standalone demand editing without inventing pallet service or transport arrangements', async () => {
routing.route.params = { orderId: looseboard.orderNo }; routing.route.query = { customerId: looseboard.customerId }
vi.mocked(getLocalLooseboardOrder).mockResolvedValue({ ...looseboardDetail, palletRequired: null, fleetArrangement: null })
vi.mocked(getLocalLooseboardOrder).mockResolvedValue({ ...looseboardDetail, palletRequired: null, transportType: null })
const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises()
expect(wrapper.findComponent({ name: 'ElForm' }).props('model').palletRequired).toBeNull()
expect(wrapper.text()).not.toContain('是否打盘')
expect(wrapper.text()).not.toContain('运输类型')
await wrapper.findAll('button').find(button => button.text().includes('保存修改'))!.trigger('click'); await flushPromises()
expect(updateLocalLooseboardOrder).not.toHaveBeenCalled()
expect(document.body.textContent).toContain('请选择是否打盘')
expect(updateLocalLooseboardOrder).toHaveBeenCalledWith(looseboard.orderNo, looseboard.customerId, expect.objectContaining({ productName: '家居用品', estimatedPalletCount: 2, pickupAt: '2026-10-20T09:30:00' }))
expect(updateLocalLooseboardOrder).toHaveBeenCalledOnce()
})
it('keeps all procurement facts in dense groups and business references in the heading', async () => {
routing.route.params = { purchaseId: 'internal-purchase-route' }; routing.route.query = { customerId: '10001' }
@@ -270,6 +270,30 @@ describe('overseas business mounted pages', () => {
expect(updateOverseasPurchase).toHaveBeenCalledWith('OP-100', '10001', { version: 3, remark: null })
expect(routing.push).toHaveBeenCalledWith({ name: 'overseas-business-purchase-detail', params: { purchaseId: 'OP-100' }, query: { customerId: '10001' } })
})
it.each(['truck', 'looseboard'] as const)('requires an explicit customer and sends only the unified business payload for %s', async domain => {
const wrapper = mountPage(TransportOrderFormView, { domain }); await flushPromises()
const model = wrapper.findComponent({ name: 'ElForm' }).props('model')
expect(model.totalWeightKg).toBe(0); expect(model.totalVolumeCbm).toBe(0)
expect(model.budgetAmount).toBeUndefined(); expect(model.currency).toBe('USD')
const labels = wrapper.findAll('.el-form-item__label').map(item => item.text())
expect(labels.slice(0, domain === 'truck' ? 10 : 9)).toEqual([
'客户', ...(domain === 'truck' ? ['车辆类型'] : []), '起运地', '目的地',
'起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话', '起运详细地址', '目的详细地址',
])
Object.assign(model, { vehicleType: 'PALLET_15', origin: ' LA ', destination: ' ONT ', originContactName: ' 发货人 ', originContactPhone: ' 1 ', destinationContactName: ' 收货人 ', destinationContactPhone: ' 2 ', pickupAt: '2026-10-20T09:30:45', deliveryAt: '2026-10-20T15:45:12', budgetAmount: 0, currency: 'CNY' })
const submit = wrapper.findAll('button').find(button => button.text() === '提交订单')!
await submit.trigger('click'); await flushPromises()
expect(createUsTruckOrder).not.toHaveBeenCalled(); expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
model.customerId = '10001'
await submit.trigger('click'); await flushPromises()
const command = domain === 'truck' ? vi.mocked(createUsTruckOrder) : vi.mocked(createLocalLooseboardOrder)
expect(command).toHaveBeenCalledOnce()
const [customer, payload] = command.mock.calls[0]!
expect(customer).toBe('10001')
expect(payload).toEqual(expect.objectContaining({ origin: 'LA', destination: 'ONT', originContactName: '发货人', originContactPhone: '1', destinationContactName: '收货人', destinationContactPhone: '2', totalWeightKg: 0, totalVolumeCbm: 0, budgetAmount: 0, currency: 'CNY', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:45:00' }))
for (const field of ['customerId', 'tenantId', 'orderNo', 'status', 'transportMode', 'containerType']) expect(payload).not.toHaveProperty(field)
})
it('mounts truck list with enabled create and supported types', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(listUsTruckOrders).toHaveBeenCalled(); expect(wrapper.text()).toContain('TK-200'); expect(wrapper.text()).toContain('15拖'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(wrapper.findAll('button').find((button) => button.text().includes('新增订单'))?.attributes('disabled')).toBeUndefined() })
it('renders truck load failure without samples', async () => { vi.mocked(listUsTruckOrders).mockRejectedValue(new Error('hidden')); const wrapper = mountPage(TransportOrdersView, { domain: 'truck' }); await flushPromises(); expect(wrapper.text()).toContain('本土卡车订单加载失败'); expect(wrapper.text()).not.toContain('TK-200') })
it('loads and submits pending-review truck edit', async () => { routing.route.params = { orderId: 'TK-200' }; routing.route.query = { customerId: '10002' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002'); expect(updateUsTruckOrder).toHaveBeenCalledWith('TK-200', '10002', expect.objectContaining({ version: 1, vehicleType: 'PALLET_15', originContactName: 'Bob', destinationContactName: 'Alice', pickupAt: '2026-08-28T09:30:00' })) })
@@ -277,10 +301,29 @@ describe('overseas business mounted pages', () => {
it('mounts truck create with explicit customer selector and enabled submit', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 200, status: 'ACTIVE' }); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves truck create input after retries fail', async () => { vi.mocked(createUsTruckOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'truck' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); selects[1].vm.$emit('update:modelValue', 'PALLET_30'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '+1-555-0100' : '联系人'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('100'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('2.5'); wrapper.findAllComponents({ name: 'ElDatePicker' })[0].vm.$emit('update:modelValue', '2026-08-31T09:30:00'); wrapper.findAllComponents({ name: 'ElDatePicker' })[1].vm.$emit('update:modelValue', '2026-08-31T15:45:00'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createUsTruckOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createUsTruckOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(document.body.textContent).toContain('服务暂时不可用'); expect(routing.push).not.toHaveBeenCalled() })
it('loads the real looseboard list', async () => { const wrapper = mountPage(TransportOrdersView, { domain: 'looseboard' }); await flushPromises(); expect(listLocalLooseboardOrders).toHaveBeenCalledWith({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '' }); expect(wrapper.text()).toContain('LB-300'); expect(wrapper.text()).toContain('待接单'); expect(wrapper.text()).toContain('2026-08-01 10:00'); expect(listUsTruckOrders).not.toHaveBeenCalled() })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, transportMode: 'SHARED_TRUCK', palletRequired: 'YES' })) })
it('loads and submits pending-acceptance looseboard edit with PUT adapter', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(getLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003'); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, estimatedPalletCount: 2 })) })
it('requires split contacts and both local times, rejecting reversed times', async () => {
const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises()
const model = wrapper.findComponent({ name: 'ElForm' }).props('model')
Object.assign(model, { customerId: '10001', originContactName: 'A', originContactPhone: '1', destinationContactName: 'B', destinationContactPhone: '2', origin: 'LA', destination: 'ONT', productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, totalWeightKg: 80, totalVolumeCbm: 1.8, deliveryAt: '2026-10-20T11:30:00' })
await wrapper.vm.$nextTick()
const save = wrapper.findAll('button').find(button => button.text().includes('提交订单'))!
await save.trigger('click'); await flushPromises()
expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
model.pickupAt = '2026-10-20T12:30:00'
await save.trigger('click'); await flushPromises()
expect(createLocalLooseboardOrder).not.toHaveBeenCalled()
model.deliveryAt = '2026-10-20T15:45:00'
await save.trigger('click'); await flushPromises()
expect(createLocalLooseboardOrder).toHaveBeenCalledWith('10001', expect.objectContaining({ productName: '家居用品', estimatedPalletCount: 3, packageCount: 8, pickupAt: '2026-10-20T12:30:00', deliveryAt: '2026-10-20T15:45:00' }))
const payload = vi.mocked(createLocalLooseboardOrder).mock.calls[0]![1]
expect(payload).not.toHaveProperty('containerType')
expect(payload).not.toHaveProperty('palletRequired')
expect(payload).not.toHaveProperty('transportType')
})
it('keeps looseboard edit form and version unchanged after a 409', async () => { routing.route.params = { orderId: 'LB-300' }; routing.route.query = { customerId: '10003' }; vi.mocked(updateLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('订单版本冲突', 'B0409', 409)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await destination.setValue('ONT-MARKER-EDIT'); await wrapper.findAll('button').find((button) => button.text().includes('保存修改'))?.trigger('click'); await flushPromises(); expect(updateLocalLooseboardOrder).toHaveBeenCalledWith('LB-300', '10003', expect.objectContaining({ version: 0, destination: 'ONT-MARKER-EDIT' })); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER-EDIT'); expect(document.body.textContent).toContain('订单版本冲突'); expect(routing.push).not.toHaveBeenCalled() })
it('mounts looseboard create without a detail route or pending notice', async () => { const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); expect(wrapper.text()).toContain('客户信息'); expect(wrapper.text()).toContain('请选择客户'); expect(wrapper.text()).not.toContain('接口能力待后端补齐'); expect(wrapper.findAll('button').find((button) => button.text().includes('提交订单'))?.attributes('disabled')).toBeUndefined() })
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); const groups = wrapper.findAllComponents({ name: 'ElRadioGroup' }); groups[0].vm.$emit('update:modelValue', 'SHARED_TRUCK'); groups[1].vm.$emit('update:modelValue', 'YES'); groups[2].vm.$emit('update:modelValue', 'WAREHOUSE_TO_WAREHOUSE'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() })
it('reuses one idempotency key and preserves looseboard create input after retries fail', async () => { vi.mocked(createLocalLooseboardOrder).mockRejectedValue(new ApiResponseError('temporary failure', 'S0500', 500)); const wrapper = mountPage(TransportOrderFormView, { domain: 'looseboard' }); await flushPromises(); const selects = wrapper.findAllComponents({ name: 'ElSelect' }); selects[0].vm.$emit('update:modelValue', '10001'); for (const label of ['起运地联系人', '目的地联系人', '起运地联系电话', '目的地联系电话']) await wrapper.findAll('.el-form-item').find(item => item.text().startsWith(label))!.find('input').setValue(label.includes('电话') ? '1' : '联系人'); const dates = wrapper.findAllComponents({ name: 'ElDatePicker' }); dates[0].vm.$emit('update:modelValue', '2026-10-20T09:30:00'); dates[1].vm.$emit('update:modelValue', '2026-10-20T15:45:00'); const origin = wrapper.find('input[placeholder="城市 / 港口 / 州"]'); const destination = wrapper.find('input[placeholder="城市 / 州"]'); await origin.setValue('LA-MARKER'); await destination.setValue('ONT-MARKER'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总重量'))!.find('input').setValue('80'); await wrapper.findAll('.el-form-item').find((item) => item.text().startsWith('总体积'))!.find('input').setValue('1.8'); await wrapper.vm.$nextTick(); const submit = wrapper.findAll('button').find((button) => button.text().includes('提交订单')); await submit?.trigger('click'); await flushPromises(); await submit?.trigger('click'); await flushPromises(); expect(createLocalLooseboardOrder).toHaveBeenCalledTimes(2); const keys = vi.mocked(createLocalLooseboardOrder).mock.calls.map((call) => call[1].idempotencyKey); expect(keys[0]).toBeTruthy(); expect(keys[1]).toBe(keys[0]); expect((origin.element as HTMLInputElement).value).toBe('LA-MARKER'); expect((destination.element as HTMLInputElement).value).toBe('ONT-MARKER'); expect(routing.push).not.toHaveBeenCalled() })
it.each([true, false])('opens only the matching pool order read-only arrangement: %s', matching => {
routing.route.query = { orderNo: truck.orderNo, fleetSource: 'inquiry', fleetOfferNo: 'FO-1', fleetBookingNo: matching ? arrangement.bookingNo : 'FB-OTHER' }
vi.mocked(listUsTruckOrders).mockResolvedValue({ records: [{ ...truck, fleetArrangement: arrangement }], page: 1, size: 20, total: 1, pages: 1 })
@@ -4,8 +4,8 @@ import { TransportRouteDialog, routeLocation } from '@/modules/overseas-master-d
import '../overseasBusinessDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Save } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowLeft } from '@lucide/vue'
import { ElMessage, type FormInstance } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -14,13 +14,14 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OperationErrorToast from '@/shared/components/OperationErrorToast.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE } from '@/shared/domain/containerSpecifications'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { createLocalLooseboardOrder, createUsTruckOrder, getLocalLooseboardOrder, getUsTruckOrder, updateLocalLooseboardOrder, updateUsTruckOrder } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import FleetArrangementSummary from '../components/FleetArrangementSummary.vue'
import TransportDemandForm from '../components/TransportDemandForm.vue'
import { createTransportDemandForm, transportDemandError, buildTransportDemand, type TransportDemandForm as DemandFormModel } from '../transportDemandForm'
import type { FleetArrangement } from '../fleetArrangement'
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, LocalTransportType, LooseboardTransportMode, PalletRequired, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest, TruckVehicleType } from '../types'
import type { CreateLocalLooseboardOrderRequest, CreateLocalTransportOrderRequest, LocalLooseboardStatus, LocalTransportStatus, UpdateLocalLooseboardOrderRequest, UpdateLocalTransportOrderRequest } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const routeOpen = ref(false)
@@ -31,48 +32,17 @@ const editing = computed(() => Boolean(orderId.value)); const isLooseboard = com
const domainLabel = computed(() => isLooseboard.value ? '本土散板' : '本土卡车'); const listRouteName = computed(() => isLooseboard.value ? 'overseas-business-looseboards' : 'overseas-business-trucks')
const requiredPermission = computed(() => isLooseboard.value ? (editing.value ? overseasBusinessPermissions.looseboardEdit : overseasBusinessPermissions.looseboardCreate) : (editing.value ? overseasBusinessPermissions.truckEdit : overseasBusinessPermissions.truckCreate))
type FormStatus = LocalTransportStatus | LocalLooseboardStatus
interface TransportFormModel {
customerId: string; orderNo: string; transportMode: LooseboardTransportMode | ''; palletRequired: PalletRequired | ''
transportType: LocalTransportType | ''; origin: string; destination: string; originAddress: string | null; destinationAddress: string | null
contactName: string | null; contactInfo: string | null; containerType: string | null; packageCount: number
vehicleType: TruckVehicleType | ''; originContactName: string; originContactPhone: string
destinationContactName: string; destinationContactPhone: string; pickupAt: string; deliveryAt: string
totalWeightKg: string | null; totalVolumeCbm: string | null; budgetAmount: string | null; currency: string
requestedDate: string | null; remark: string | null; status: FormStatus; version: number; idempotencyKey: string
interface TransportFormModel extends DemandFormModel {
customerId: string; orderNo: string; status: FormStatus; version: number; idempotencyKey: string
}
function newIdempotencyKey() { return globalThis.crypto?.randomUUID?.() || `admin-${Date.now()}-${Math.random().toString(36).slice(2)}` }
const initial = (): TransportFormModel => ({ customerId: '', orderNo: '', transportMode: '', palletRequired: '', transportType: '', origin: '', destination: '', originAddress: null, destinationAddress: null, contactName: null, contactInfo: null, vehicleType: '', originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '', pickupAt: '', deliveryAt: '', containerType: DEFAULT_CONTAINER_TYPE, packageCount: 0, totalWeightKg: null, totalVolumeCbm: null, budgetAmount: null, currency: 'USD', requestedDate: null, remark: null, status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() })
const initial = (): TransportFormModel => ({ ...createTransportDemandForm(), customerId: '', orderNo: '', status: isLooseboard.value ? 'PENDING_ACCEPTANCE' : 'PENDING_REVIEW', version: 0, idempotencyKey: newIdempotencyKey() })
const form = reactive<TransportFormModel>(initial()); const loading = ref(false); const saving = ref(false)
const fleetArrangement = ref<FleetArrangement | null>(null)
const loadError = ref<ApiErrorDetails | null>(null); const submitError = ref<ApiErrorDetails | null>(null)
const customers = ref<PlatformTenantResource[]>([]); const customersLoading = ref(false); const customersError = ref('')
const requiredDecimal = (label: string) => ({ validator: (_rule: unknown, value: string | null, callback: (error?: Error) => void) => value !== null && value !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 ? callback() : callback(new Error(`请输入有效的${label}`)), trigger: 'blur' })
function truckRequired(label: string, trigger = 'blur') {
return { validator: (_rule: unknown, value: unknown, callback: (error?: Error) => void) => callback(isLooseboard.value || String(value || '').trim() ? undefined : new Error(`请填写${label}`)), trigger }
}
const rules: FormRules<TransportFormModel> = {
transportMode: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择运输方式')), trigger: 'change' }],
palletRequired: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择是否打盘')), trigger: 'change' }],
transportType: [{ validator: (_rule, value, callback) => (!isLooseboard.value || value) ? callback() : callback(new Error('请选择运输类型')), trigger: 'change' }], origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }], destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
packageCount: [{ required: true, type: 'number', min: 0, message: '件数不能小于 0', trigger: 'change' }], totalWeightKg: [requiredDecimal('总重量')], totalVolumeCbm: [requiredDecimal('总体积')],
vehicleType: [truckRequired('车辆类型', 'change')],
originContactName: [truckRequired('起运地联系人')], originContactPhone: [truckRequired('起运地联系电话')],
destinationContactName: [truckRequired('目的地联系人')], destinationContactPhone: [truckRequired('目的地联系电话')],
pickupAt: [truckRequired('取货时间', 'change')],
deliveryAt: [truckRequired('送货时间', 'change'), {
validator: (_rule, value, callback) => callback(!isLooseboard.value && value && form.pickupAt && value < form.pickupAt ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change',
}],
budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
}
const transportTypes = [{ value: 'PORT_PICKUP', label: '⚓ 港口提柜' }, { value: 'WAREHOUSE_TO_WAREHOUSE', label: '🏭 仓到仓' }, { value: 'WAREHOUSE_TO_DOOR', label: '🚛 仓到门' }]
const transportModes = [{ value: 'SHARED_TRUCK', label: '🚐 拼车' }, { value: 'PRIORITY_CONSOLIDATION', label: '📦 优先拼载' }]
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' }
const statusLabel = computed(() => isLooseboard.value ? looseboardStatusLabels[form.status as LocalLooseboardStatus] : truckStatusLabels[form.status as LocalTransportStatus])
const editableStatus = computed(() => !fleetArrangement.value && (!editing.value || (isLooseboard.value ? form.status === 'PENDING_ACCEPTANCE' : form.status === 'PENDING_REVIEW')))
const { captureBaseline } = useUnsavedWorkRegistration({ key: `overseas-${props.domain}-editor`, label: computed(() => `${editing.value ? '编辑' : '新增'}${domainLabel.value}订单`), isOpen: computed(() => true), model: form, requiredPermission })
function optional(value: string | null) { return value?.trim() || null }
function amount(value: string | null) { return value === null || value === '' ? null : Number(value) }
async function loadCustomers() {
if (editing.value) return
customersLoading.value = true; customersError.value = ''
@@ -89,11 +59,11 @@ async function load() {
if (isLooseboard.value) {
const detail = await getLocalLooseboardOrder(orderId.value, routeCustomerId.value)
fleetArrangement.value = detail.fleetArrangement
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, transportMode: detail.transportMode, palletRequired: detail.palletRequired, transportType: detail.transportType, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, contactName: detail.contactName, contactInfo: detail.contactInfo, containerType: detail.containerType, packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, requestedDate: detail.requestedDate, remark: detail.remark, status: detail.status, version: detail.version })
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, productName: detail.productName || '', estimatedPalletCount: detail.estimatedPalletCount ?? 0, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', remark: detail.remark || '', status: detail.status, version: detail.version })
} else {
const detail = await getUsTruckOrder(orderId.value, routeCustomerId.value)
fleetArrangement.value = detail.fleetArrangement
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress, destinationAddress: detail.destinationAddress, vehicleType: detail.vehicleType || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', packageCount: detail.packageCount, totalWeightKg: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, remark: detail.remark, status: detail.status, version: detail.version })
Object.assign(form, { customerId: detail.customerId, orderNo: detail.orderNo, origin: detail.origin, destination: detail.destination, originAddress: detail.originAddress || '', destinationAddress: detail.destinationAddress || '', vehicleType: detail.vehicleType || '', originContactName: detail.originContactName || '', originContactPhone: detail.originContactPhone || '', destinationContactName: detail.destinationContactName || '', destinationContactPhone: detail.destinationContactPhone || '', pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', packageCount: detail.packageCount, totalWeightKg: Number(detail.totalWeightKg), totalVolumeCbm: Number(detail.totalVolumeCbm), budgetAmount: detail.budgetAmount == null ? undefined : Number(detail.budgetAmount), currency: detail.currency, remark: detail.remark || '', status: detail.status, version: detail.version })
}
captureBaseline()
} catch (error) { loadError.value = getApiErrorDetails(error, `${domainLabel.value}订单详情加载失败`) } finally { loading.value = false }
@@ -101,17 +71,17 @@ async function load() {
async function save() {
if (!editableStatus.value) { submitError.value = { message: `当前${domainLabel.value}订单状态不可编辑` }; return }
if (!editing.value && !form.customerId) { submitError.value = { message: '请选择客户' }; return }
if (isLooseboard.value && form.palletRequired !== 'YES' && form.palletRequired !== 'NO') { submitError.value = { message: '请选择是否打盘' }; return }
const failure = transportDemandError(isLooseboard.value ? 'looseboard' : 'truck', form)
if (failure) { submitError.value = { message: failure }; return }
if (!await formRef.value?.validate().catch(() => false)) return
const common = { origin: form.origin.trim(), originAddress: optional(form.originAddress), destination: form.destination.trim(), destinationAddress: optional(form.destinationAddress), packageCount: form.packageCount, totalWeightKg: Number(form.totalWeightKg), totalVolumeCbm: Number(form.totalVolumeCbm), budgetAmount: amount(form.budgetAmount), currency: form.currency, remark: optional(form.remark) }
saving.value = true; submitError.value = null
try {
if (isLooseboard.value) {
const loose = { ...common, transportType: form.transportType as LocalTransportType, containerType: optional(form.containerType), contactName: optional(form.contactName), contactInfo: optional(form.contactInfo), requestedDate: form.requestedDate, transportMode: form.transportMode as LooseboardTransportMode, palletRequired: form.palletRequired as PalletRequired }
const loose = buildTransportDemand('looseboard', form)
if (editing.value) await updateLocalLooseboardOrder(orderId.value, routeCustomerId.value, { ...loose, version: form.version } satisfies UpdateLocalLooseboardOrderRequest)
else await createLocalLooseboardOrder(form.customerId, { ...loose, idempotencyKey: form.idempotencyKey } satisfies CreateLocalLooseboardOrderRequest)
} else {
const truck = { ...common, vehicleType: form.vehicleType as TruckVehicleType, originContactName: form.originContactName.trim(), originContactPhone: form.originContactPhone.trim(), destinationContactName: form.destinationContactName.trim(), destinationContactPhone: form.destinationContactPhone.trim(), pickupAt: form.pickupAt, deliveryAt: form.deliveryAt }
const truck = buildTransportDemand('truck', form)
if (editing.value) await updateUsTruckOrder(orderId.value, routeCustomerId.value, { ...truck, version: form.version } satisfies UpdateLocalTransportOrderRequest)
else await createUsTruckOrder(form.customerId, { ...truck, idempotencyKey: form.idempotencyKey } satisfies CreateLocalTransportOrderRequest)
}
@@ -127,21 +97,18 @@ watch([orderId, routeCustomerId, isLooseboard], () => { void load(); void loadCu
<template #header><div class="density-detail-header"><div class="density-detail-heading"><el-button link class="density-back" @click="back"><ArrowLeft :size="16" />返回列表</el-button><h1 class="density-detail-title">{{ editing ? '编辑' : '新增' }}{{ domainLabel }}订单</h1><span v-if="editing" class="form-reference">{{ form.orderNo || '—' }}</span></div></div></template>
<LoadErrorState v-if="loadError" :title="`${domainLabel}订单暂时无法加载`" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<FleetArrangementSummary v-else-if="fleetArrangement" :arrangement="fleetArrangement" />
<section v-else v-loading="loading" class="form-panel"><el-form class="density-form" ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving || !editableStatus">
<FormSection class="density-form-section" v-if="!editing" title="客户信息"><el-form-item label="客户"><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
<section v-else v-loading="loading" class="form-panel">
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
<FormSection class="density-form-section" v-if="isLooseboard" title="散板方式"><div class="choice-block"><el-form-item label="运输方式" prop="transportMode"><el-radio-group v-model="form.transportMode"><el-radio-button v-for="item in transportModes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><el-form-item label="是否打盘" prop="palletRequired"><el-radio-group v-model="form.palletRequired"><el-radio-button value="YES">是</el-radio-button><el-radio-button value="NO">否</el-radio-button></el-radio-group></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="运输信息"><el-form-item v-if="isLooseboard" label="运输类型" prop="transportType"><el-radio-group v-model="form.transportType"><el-radio-button v-for="item in transportTypes" :key="item.value" :value="item.value">{{ item.label }}</el-radio-button></el-radio-group></el-form-item><el-form-item v-if="!isLooseboard" label="车辆类型" prop="vehicleType" required><el-select v-model="form.vehicleType" placeholder="请选择车辆类型"><el-option label="15拖" value="PALLET_15" /><el-option label="30拖" value="PALLET_30" /></el-select></el-form-item><div class="form-grid two"><el-form-item label="起运地" prop="origin"><el-input v-model="form.origin" maxlength="255" placeholder="城市 / 港口 / 州" /></el-form-item><el-form-item label="目的地" prop="destination"><el-input v-model="form.destination" maxlength="255" placeholder="城市 / 州" /></el-form-item><el-form-item label="详细地址"><el-input v-model="form.originAddress" maxlength="500" placeholder="街道 / 门牌" /></el-form-item><el-form-item label="详细地址 / 码头 / 仓号"><el-input v-model="form.destinationAddress" maxlength="500" placeholder="地址 / 码头 / 仓号" /></el-form-item><el-form-item v-if="isLooseboard" label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="128" placeholder="请输入联系人姓名" /></el-form-item><el-form-item v-if="isLooseboard" label="联系方式" prop="contactInfo"><el-input v-model="form.contactInfo" maxlength="128" placeholder="请输入联系方式" /></el-form-item><el-form-item v-if="!isLooseboard" label="起运地联系人" prop="originContactName" required><el-input v-model.trim="form.originContactName" maxlength="128" placeholder="请输入起运地联系人" /></el-form-item><el-form-item v-if="!isLooseboard" label="目的地联系人" prop="destinationContactName" required><el-input v-model.trim="form.destinationContactName" maxlength="128" placeholder="请输入目的地联系人" /></el-form-item><el-form-item v-if="!isLooseboard" label="起运地联系电话" prop="originContactPhone" required><el-input v-model.trim="form.originContactPhone" maxlength="128" placeholder="请输入起运地联系电话" type="tel" /></el-form-item><el-form-item v-if="!isLooseboard" label="目的地联系电话" prop="destinationContactPhone" required><el-input v-model.trim="form.destinationContactPhone" maxlength="128" placeholder="请输入目的地联系电话" type="tel" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="📦 货物信息"><div class="form-grid" :class="isLooseboard ? 'four' : 'three'"><el-form-item v-if="isLooseboard" label="柜型"><el-select v-model="form.containerType"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item><el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" controls-position="right" placeholder="请输入件数" /></el-form-item><el-form-item label="总重量 (kg)" prop="totalWeightKg"><el-input v-model="form.totalWeightKg" inputmode="decimal" placeholder="请输入总重量" /></el-form-item><el-form-item label="总体积 (m³)" prop="totalVolumeCbm"><el-input v-model="form.totalVolumeCbm" inputmode="decimal" placeholder="请输入总体积" /></el-form-item></div></FormSection>
<FormSection class="density-form-section" title="费用与时效"><div class="form-grid two"><el-form-item label="预算总价" prop="budgetAmount"><el-input v-model="form.budgetAmount" inputmode="decimal" placeholder="请输入预算金额" ><template #append>{{ form.currency }}</template></el-input></el-form-item><el-form-item v-if="isLooseboard" label="要求时间" prop="requestedDate"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" clearable /></el-form-item><el-form-item v-if="!isLooseboard" label="取货时间" prop="pickupAt" required><el-date-picker v-model="form.pickupAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择取货时间" @change="formRef?.validateField('deliveryAt').catch(() => undefined)" /></el-form-item><el-form-item v-if="!isLooseboard" label="送货时间" prop="deliveryAt" required><el-date-picker v-model="form.deliveryAt" type="datetime" format="YYYY-MM-DD HH:mm" time-format="HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择送货时间" /></el-form-item><el-form-item v-if="editing" label="状态"><el-input :model-value="statusLabel" disabled /></el-form-item><el-form-item class="span-2" label="备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></div></FormSection>
</el-form><FormSection v-if="!isLooseboard" class="density-form-section" title="车辆与派车"><el-button @click="routeOpen = true">查看路线</el-button></FormSection><footer class="form-actions"><el-button @click="back">取消</el-button><el-button type="primary" :loading="saving" :disabled="!editableStatus" @click="save"><Save :size="16" />{{ editing ? '保存修改' : '提交订单' }}</el-button></footer></section>
<TransportDemandForm ref="formRef" :model="form" :kind="isLooseboard ? 'looseboard' : 'truck'" :submitting="saving" :disabled="!editableStatus" :editing="editing" @submit="save" @cancel="back">
<template v-if="!editing" #customer>
<FormSection class="transport-customer-section" title="客户信息"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable :loading="customersLoading" :disabled="Boolean(customersError)" placeholder="请选择客户" style="width:100%"><el-option v-for="item in customers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select><small v-if="customersError" class="field-error">{{ customersError }}</small></el-form-item></FormSection>
</template>
</TransportDemandForm>
<FormSection v-if="!isLooseboard" class="density-form-section" title="车辆与派车"><el-button @click="routeOpen = true">查看路线</el-button></FormSection>
</section>
</DetailPageLayout><TransportRouteDialog v-model="routeOpen" :origin="routeLocation(form.origin, form.originAddress)" :destinations="[routeLocation(form.destination, form.destinationAddress)]" /></AdminPage>
</template>
<style scoped>
.form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.form-grid.four { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.form-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.choice-block { display: grid; grid-template-columns: 1fr 1fr; gap: var(--admin-space-5); }
.span-2 { grid-column: 1 / -1; }
.transport-customer-section { padding: 20px 24px 0; border-bottom: 1px solid #e7ebf1; }
</style>
@@ -17,7 +17,7 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listLocalLooseboardOrders, listUsTruckOrders } from '../api/platformOverseasBusiness'
import { overseasBusinessPermissions } from '../permissions'
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType, LooseboardTransportMode } from '../types'
import type { LocalLooseboardOrder, LocalLooseboardQuery, LocalLooseboardStatus, LocalTransportOrder, LocalTransportQuery, LocalTransportStatus, LocalTransportType } from '../types'
const props = defineProps<{ domain: 'truck' | 'looseboard' }>()
const routeOpen = ref(false)
@@ -40,7 +40,6 @@ const filters = ref<QueryModel>({ orderNo: String(route.query.orderNo || ''), st
const truckStatusLabels: Record<LocalTransportStatus, string> = { PENDING_REVIEW: '待审核', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PALLET_OVERAGE_REVIEW: '超托待审', PARTIALLY_ALLOCATED: '部分分配', ALLOCATED: '已分配', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', REJECTED: '已拒绝', CANCELLED: '已取消' }
const looseboardStatusLabels: Record<LocalLooseboardStatus, string> = { PENDING_ACCEPTANCE: '待接单', WAITING_INBOUND: '待入仓', RECEIVING: '收货中', WAITING_CONSOLIDATION: '待合托', PENDING_DISPATCH: '待派车', DISPATCHED: '已派车', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消', ...poolOrderStatusLabels }
const transportTypeLabels: Record<LocalTransportType, string> = { PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' }
const transportModeLabels: Record<LooseboardTransportMode, string> = { SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' }
const fields = computed<QueryField[]>(() => [
{ key: 'orderNo', type: 'input', label: '商业单号', placeholder: '请输入商业单号', width: 230 },
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 170, options: Object.entries(isLooseboard.value ? looseboardStatusLabels : truckStatusLabels).map(([value, label]) => ({ value, label })) },
@@ -60,12 +59,11 @@ const truckColumns: DataTableColumn[] = [
]
const looseboardColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'orderNo', prop: 'orderNo', label: '订单编号', minWidth: 170 },
{ key: 'transportMode', label: '运输方式', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportModeLabels[row.transportMode] }, { key: 'palletRequired', label: '是否打盘', width: 100, formatter: (row: LocalLooseboardOrder) => row.palletRequired === 'YES' ? '是' : row.palletRequired === 'NO' ? '否' : '—' },
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => transportTypeLabels[row.transportType] }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'contactName', prop: 'contactName', label: '联系人', minWidth: 110 },
{ key: 'contactInfo', prop: 'contactInfo', label: '联系方式', minWidth: 145 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
{ key: 'transportType', label: '运输类型', minWidth: 120, formatter: (row: LocalLooseboardOrder) => row.transportType ? transportTypeLabels[row.transportType] : '—' }, { key: 'origin', prop: 'origin', label: '起运地', minWidth: 145 },
{ key: 'destination', prop: 'destination', label: '目的地', minWidth: 145 }, { key: 'originContactName', prop: 'originContactName', label: '起运地联系人', minWidth: 125 }, { key: 'originContactPhone', prop: 'originContactPhone', label: '起运地联系电话', minWidth: 155 },
{ key: 'destinationContactName', prop: 'destinationContactName', label: '目的地联系人', minWidth: 125 }, { key: 'destinationContactPhone', prop: 'destinationContactPhone', label: '目的地联系电话', minWidth: 155 }, { key: 'packageCount', prop: 'packageCount', label: '件数', width: 80, align: 'right' },
{ key: 'totalWeightKg', prop: 'totalWeightKg', label: '总重量(kg)', width: 115, align: 'right' }, { key: 'budgetAmount', label: '预算总价', width: 115, align: 'right', formatter: (row: LocalLooseboardOrder) => row.budgetAmount == null ? '—' : `${row.budgetAmount} ${row.currency}` },
{ key: 'requestedDate', prop: 'requestedDate', label: '要求时间', width: 120 }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
{ key: 'productName', prop: 'productName', label: '商品名称', minWidth: 150 }, { key: 'estimatedPalletCount', prop: 'estimatedPalletCount', label: '预计托数', width: 100 }, { key: 'pickupAt', label: '取货时间', width: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.pickupAt) }, { key: 'deliveryAt', label: '送货时间', width: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.deliveryAt) }, { key: 'status', label: '状态', width: 105, formatter: (row: LocalLooseboardOrder) => looseboardStatusLabels[row.status] },
{ key: 'createdAt', prop: 'createdAt', label: '创建时间', minWidth: 165, formatter: (row: LocalLooseboardOrder) => formatDateTime(row.createdAt) },
]
const columns = computed(() => {
@@ -0,0 +1,106 @@
import type { FormRules } from 'element-plus'
export type TransportDemandKind = 'truck' | 'looseboard'
export interface TransportDemandForm {
vehicleType: '' | 'PALLET_15' | 'PALLET_30'
origin: string
destination: string
originAddress: string
destinationAddress: string
originContactName: string
originContactPhone: string
destinationContactName: string
destinationContactPhone: string
productName: string
estimatedPalletCount: number
packageCount: number
totalWeightKg: number
totalVolumeCbm: number
budgetAmount: number | undefined
currency: 'USD' | 'CNY'
pickupAt: string
deliveryAt: string
remark: string
}
export function createTransportDemandForm(): TransportDemandForm {
return {
vehicleType: '', origin: '', destination: '', originAddress: '', destinationAddress: '',
originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '',
productName: '', estimatedPalletCount: 0, packageCount: 0, totalWeightKg: 0, totalVolumeCbm: 0,
budgetAmount: undefined, currency: 'USD', pickupAt: '', deliveryAt: '', remark: '',
}
}
const requiredText = {
origin: '起运地', destination: '目的地', originContactName: '起运地联系人',
originContactPhone: '起运地联系电话', destinationContactName: '目的地联系人', destinationContactPhone: '目的地联系电话',
} as const
export function localMinuteInput(value: unknown) {
return typeof value === 'string' && value ? `${value.slice(0, 16)}:00` : ''
}
export function transportDemandRules(kind: TransportDemandKind, form: TransportDemandForm): FormRules<TransportDemandForm> {
const rules: FormRules<TransportDemandForm> = {}
for (const [field, label] of Object.entries(requiredText)) {
rules[field as keyof typeof requiredText] = [{ required: true, whitespace: true, message: `请输入${label}`, trigger: 'blur' }]
}
if (kind === 'truck') rules.vehicleType = [{ required: true, message: '请选择车辆类型', trigger: 'change' }]
for (const [field, label] of [['packageCount', '件数'], ['estimatedPalletCount', '预计托数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积']] as const) {
if (field === 'estimatedPalletCount' && kind !== 'looseboard') continue
rules[field] = [{ required: true, type: 'number', min: 0, message: `${label}须为不小于 0 的数字`, trigger: 'change' }]
}
rules.budgetAmount = [{ type: 'number', min: 0, message: '预算总价不能小于 0', trigger: 'change' }]
rules.pickupAt = [{ required: true, message: '请选择取货时间', trigger: 'change' }]
rules.deliveryAt = [{ required: true, message: '请选择送货时间', trigger: 'change' }, {
validator: (_rule, value, callback) => callback(value && form.pickupAt && localMinuteInput(value) < localMinuteInput(form.pickupAt) ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change',
}]
return rules
}
export function transportDemandError(kind: TransportDemandKind, form: TransportDemandForm): string | undefined {
if (kind === 'truck' && !['PALLET_15', 'PALLET_30'].includes(form.vehicleType)) return '请选择车辆类型'
for (const [field, label] of Object.entries(requiredText)) {
if (!String(form[field as keyof typeof requiredText] || '').trim()) return `请输入${label}`
}
for (const [field, label] of [['pickupAt', '取货时间'], ['deliveryAt', '送货时间']] as const) {
if (!form[field]) return `请选择${label}`
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(form[field])) return `请选择有效的${label}`
}
if (localMinuteInput(form.deliveryAt) < localMinuteInput(form.pickupAt)) return '送货时间不能早于取货时间'
for (const [field, label] of [['packageCount', '件数'], ['totalWeightKg', '总重量'], ['totalVolumeCbm', '总体积'], ...(kind === 'looseboard' ? [['estimatedPalletCount', '预计托数'] as const] : [])] as const) {
if (!Number.isFinite(form[field]) || form[field] < 0) return `${label}须为不小于 0 的数字`
}
if (!Number.isInteger(form.packageCount) || kind === 'looseboard' && !Number.isInteger(form.estimatedPalletCount)) return '件数和预计托数须为整数'
if (form.budgetAmount != null && (!Number.isFinite(form.budgetAmount) || form.budgetAmount < 0)) return '预算总价不能小于 0'
if (!['USD', 'CNY'].includes(form.currency)) return '请选择预算币种'
return undefined
}
function commonPayload(form: TransportDemandForm) {
return {
origin: form.origin.trim(), destination: form.destination.trim(),
originAddress: form.originAddress.trim() || undefined, destinationAddress: form.destinationAddress.trim() || undefined,
originContactName: form.originContactName.trim(), originContactPhone: form.originContactPhone.trim(),
destinationContactName: form.destinationContactName.trim(), destinationContactPhone: form.destinationContactPhone.trim(),
packageCount: form.packageCount, totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm,
budgetAmount: form.budgetAmount ?? undefined, currency: form.currency,
pickupAt: localMinuteInput(form.pickupAt), deliveryAt: localMinuteInput(form.deliveryAt), remark: form.remark.trim() || undefined,
}
}
type CommonPayload = ReturnType<typeof commonPayload>
type TruckPayload = CommonPayload & { vehicleType: 'PALLET_15' | 'PALLET_30' }
type LooseboardPayload = CommonPayload & { productName: string | undefined; estimatedPalletCount: number }
export function buildTransportDemand(kind: 'truck', form: TransportDemandForm): TruckPayload
export function buildTransportDemand(kind: 'looseboard', form: TransportDemandForm): LooseboardPayload
export function buildTransportDemand(kind: TransportDemandKind, form: TransportDemandForm) {
const failure = transportDemandError(kind, form)
if (failure) throw new Error(failure)
const common = commonPayload(form)
return kind === 'truck'
? { ...common, vehicleType: form.vehicleType as 'PALLET_15' | 'PALLET_30' }
: { ...common, productName: form.productName.trim() || undefined, estimatedPalletCount: form.estimatedPalletCount }
}
+9 -9
View File
@@ -63,19 +63,18 @@ export interface UpdateLocalTransportOrderRequest extends Omit<CreateLocalTransp
version: number; origin?: string; destination?: string; totalWeightKg?: number; totalVolumeCbm?: number
}
export type LooseboardTransportMode = 'SHARED_TRUCK' | 'PRIORITY_CONSOLIDATION'
export type PalletRequired = 'YES' | 'NO'
export type LocalLooseboardStatus = 'PENDING_ACCEPTANCE' | 'WAITING_INBOUND' | 'RECEIVING' | 'WAITING_CONSOLIDATION' | 'PENDING_DISPATCH' | 'DISPATCHED' | 'IN_TRANSIT' | 'COMPLETED' | 'CANCELLED'
export interface LocalLooseboardQuery {
page?: number; size?: number; customerId?: string; orderNo?: string; routeKeyword?: string
status?: LocalLooseboardStatus | ''; transportMode?: LooseboardTransportMode | ''; requestedFrom?: string; requestedTo?: string
status?: LocalLooseboardStatus | ''; requestedFrom?: string; requestedTo?: string
}
export interface LocalLooseboardOrder {
fleetArrangement: import('./fleetArrangement').FleetArrangement | null
id: string; customerId: string; orderNo: string; transportMode: LooseboardTransportMode; palletRequired: PalletRequired | null
transportType: LocalTransportType; origin: string; destination: string; containerType: string | null; packageCount: number
id: string; customerId: string; orderNo: string; palletRequired: PalletRequired | null
transportType: LocalTransportType | null; origin: string; destination: string; productName: string | null; estimatedPalletCount: number | null; packageCount: number
totalWeightKg: string; totalVolumeCbm: string; budgetAmount: string | null; currency: string
contactName: string | null; contactInfo: string | null; requestedDate: string | null; status: LocalLooseboardStatus
originContactName: string | null; originContactPhone: string | null; destinationContactName: string | null; destinationContactPhone: string | null; pickupAt: string | null; deliveryAt: string | null; status: LocalLooseboardStatus
version: number; createdAt: string; updatedAt: string
}
export interface LocalLooseboardDetail extends LocalLooseboardOrder {
@@ -83,10 +82,11 @@ export interface LocalLooseboardDetail extends LocalLooseboardOrder {
histories: OverseasBusinessHistory[]
}
export interface CreateLocalLooseboardOrderRequest {
transportMode: LooseboardTransportMode; palletRequired: PalletRequired; transportType: LocalTransportType
productName?: string | null; estimatedPalletCount: number
origin: string; originAddress?: string | null; destination: string; destinationAddress?: string | null
containerType?: string | null; packageCount: number; totalWeightKg: number; totalVolumeCbm: number
budgetAmount?: number | null; currency?: string; contactName?: string | null; contactInfo?: string | null
requestedDate?: string | null; remark?: string | null; idempotencyKey: string
packageCount: number; totalWeightKg: number; totalVolumeCbm: number
budgetAmount?: number | null; currency?: string
originContactName: string; originContactPhone: string; destinationContactName: string; destinationContactPhone: string
pickupAt: string; deliveryAt: string; remark?: string | null; idempotencyKey: string
}
export interface UpdateLocalLooseboardOrderRequest extends Omit<CreateLocalLooseboardOrderRequest, 'idempotencyKey'> { version: number }
@@ -89,6 +89,19 @@
以上仅为代码接入依据,真实界面验收仍待执行。
## 2026-10-19 出车记录统计与列表优化
依据:当前对话图1(现状)、图2(原型);用户确认优先在前端优化。沿当前实际发车口径与接口,不恢复原型手填发车事实。
| 页面 | 操作 | 原型承载/布局 | 当前实现 | 本轮结论 |
|---|---|---|---|---|
| 出车记录 | 统计与查询 | 图2上方独立四栏饼图,下方记录与筛选 | 图1统计位于列表筛选和表格之间,条形明细内部滚动 | 独立四栏饼图及彩色图例;用户追加确认只改饼图、保留现有日期/目的地统计分组;列表一套日期/关键词/货源/状态查询,同一响应更新统计与记录;币种切换独立展示,顶部均价明确为最近统计日值,不相加或简单平均每日均价;长图例与完整统计按需展开 |
| 出车记录 | 列表、导出 | 图2列表独立卡片,操作第一列,导出在右上 | integrated列表、字段设置、XLSX下载任务 | 保留公共连续列表,整理默认列;完整字段仍可通过字段设置显示;导出沿原筛选及权限 |
| 出车记录 | 详情、实测编辑、凭证预览 | 图2仅能确认列表编辑入口;当前操作以既有确认矩阵为准 | 详情独立路由;实测820px居中及按需凭证预览 | 不改变承载、读写权限或请求方式 |
| 出车记录 | 新增、审核、删除 | 图2有新增记录,未定义审核/删除 | 当前列表无新增/审核/删除 | 不新增业务动作,实际发车事实仍由既有作业链产生 |
本轮只调整前端;自动检查与人工视觉验收分别记录,修改后1920×1080、100%截图待用户确认。
## 新版报价与出车记录接入
| 入口 | 原型依据 | 当前承载 | 核对边界 |
@@ -0,0 +1,83 @@
import { afterEach, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import TripOperationsCharts from './TripOperationsCharts.vue'
import type { TripOperationsCharts as Charts, TripOperationsSummary } from '../api/tripOperations'
const wrappers: VueWrapper[] = []
const emptyCharts = (): Charts => ({ physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] })
const summary = (): TripOperationsSummary => ({ trips: 20, inTransit: 5, completed: 15, physicalPallets: 90, palletSpaces: 100, ownPackages: 50, externalPackages: 20, externalAgreedAmounts: [{ currency: 'CNY', amount: 390 }, { currency: 'USD', amount: 100 }], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } })
function render(charts: Charts, totals = summary()) {
const wrapper = mount(TripOperationsCharts, { props: { charts, summary: totals }, global: { stubs: { ElButton: { template: '<button><slot/></button>' } } } })
wrappers.push(wrapper)
return wrapper
}
afterEach(() => wrappers.splice(0).forEach(wrapper => wrapper.unmount()))
it('renders all four statistics as pies with matching legends and server totals', () => {
const charts = emptyCharts()
charts.physicalPallets = [{ label: '每日实运托盘', unit: '托', points: [{ label: '2026-10-02', value: 10 }] }]
charts.destinationTrips = [{ label: '目的地涉及车次', unit: '次', points: [{ label: 'ONT8', value: 3 }, { label: 'ABE2', value: 8 }] }]
charts.externalAgreedAmounts = [{ label: 'CNY', unit: 'CNY', points: [{ label: '2026-10-02', value: 390 }] }]
charts.externalPalletRates = [{ label: 'CNY', unit: 'CNY/标准托位', points: [{ label: '2026-10-07', value: 30 }, { label: '2026-10-02', value: 90 }] }]
const original = structuredClone(charts)
const wrapper = render(charts)
expect(wrapper.findAll('.pie-chart')).toHaveLength(4)
expect(wrapper.find('polyline').exists()).toBe(false)
expect(wrapper.find('.bar-track').exists()).toBe(false)
expect(wrapper.find('.chart--pallets .chart-metric').text()).toBe('90 托')
expect(wrapper.find('.chart--rates .chart-metric').text()).toContain('CNY/标准托位 30.00')
expect(wrapper.find('.chart--rates .series-caption').text()).toContain('2026-10-07')
expect(wrapper.find('.chart--rates').text()).toContain('顶部为最近统计日均价')
expect(wrapper.findAll('.chart--rates .pie-slice').map(slice => slice.attributes('aria-label'))).toEqual(['2026-10-02:90.00 CNY/标准托位', '2026-10-07:30.00 CNY/标准托位'])
expect(wrapper.find('.chart--destinations .chart-legend .legend-dot').attributes('style')).toContain('rgb(59, 130, 246)')
expect(wrapper.find('.chart--destinations .pie-slice').attributes('fill')).toBe('#3b82f6')
expect(charts).toEqual(original)
})
it('switches currency without combining monetary amounts or adding daily rates', async () => {
const charts = emptyCharts()
charts.externalAgreedAmounts = [{ label: 'CNY', unit: 'CNY', points: [{ label: '2026-10-02', value: 390 }] }, { label: 'USD', unit: 'USD', points: [{ label: '2026-10-03', value: 100 }] }]
const wrapper = render(charts)
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
expect(wrapper.find('.chart--amounts').findAll('svg')).toHaveLength(1)
const usd = wrapper.findAll('.chart--amounts .series-selector button').find(button => button.text() === 'USD')!
await usd.trigger('click')
expect(usd.attributes('aria-pressed')).toBe('true')
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('USD 100.00')
expect(wrapper.find('.chart--amounts .pie-slice').attributes('aria-label')).toBe('2026-10-03:100.00 USD')
await wrapper.setProps({ charts: { ...charts, externalAgreedAmounts: [charts.externalAgreedAmounts[0]!] } })
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('CNY 390.00')
})
it('includes every destination in its pie while collapsing long legends and preserving complete details', () => {
const charts = emptyCharts()
charts.destinationTrips = [{ label: '目的地涉及车次', unit: '次', points: Array.from({ length: 7 }, (_, index) => ({ label: `FBA-${index}`, value: index + 1 })) }]
const wrapper = render(charts)
const destination = wrapper.find('.chart--destinations')
expect(destination.find('.chart-metric').text()).toBe('7 个目的地')
expect(destination.findAll('.pie-slice')).toHaveLength(7)
expect(destination.find('.legend-details').attributes('open')).toBeUndefined()
expect(destination.find('.legend-details summary').text()).toBe('展开其余 2 项图例')
expect(wrapper.find('[aria-label="派送目的地明细"]').findAll('tbody tr')).toHaveLength(7)
expect(wrapper.find('[aria-label="派送目的地明细"]').text()).toContain('FBA-0')
})
it('draws a single positive value as a full pie and retains known zero values without invalid geometry', () => {
const charts = emptyCharts()
charts.physicalPallets = [{ label: '托盘', unit: '托', points: [{ label: '2026-10-01', value: 0 }, { label: '2026-10-02', value: 2 }] }]
charts.externalPalletRates = [{ label: 'USD', unit: 'USD/标准托位', points: [{ label: '2026-10-01', value: 0 }] }]
const wrapper = render(charts)
expect(wrapper.find('.chart--pallets .pie-slice').attributes('d')).toBe('M 80 8 A 72 72 0 1 1 80 152 A 72 72 0 1 1 80 8 Z')
expect(wrapper.find('.chart--rates .pie-empty').exists()).toBe(true)
expect(wrapper.find('.chart--rates .chart-metric').text()).toContain('0.00')
expect(wrapper.find('.chart--pallets .chart-legend').text()).toContain('2026-10-01')
expect(wrapper.html()).not.toMatch(/NaN|Infinity/)
})
it('shows missing amounts and rates as unknown and omits empty detail controls', () => {
const wrapper = render(emptyCharts(), { ...summary(), physicalPallets: 0, externalAgreedAmounts: [] })
expect(wrapper.findAll('.chart-empty')).toHaveLength(4)
expect(wrapper.find('.chart--amounts .chart-metric').text()).toBe('—')
expect(wrapper.find('.chart--rates .chart-metric').text()).toBe('—')
expect(wrapper.find('details').exists()).toBe(false)
})
@@ -1,28 +1,137 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { TripOperationsCharts, TripOperationsSeries } from '../api/tripOperations'
const props = defineProps<{ charts: TripOperationsCharts }>()
import { computed, ref } from 'vue'
import type { TripOperationsCharts, TripOperationsSeries, TripOperationsSummary } from '../api/tripOperations'
const props = defineProps<{ charts: TripOperationsCharts; summary: TripOperationsSummary }>()
const selectedSeries = ref<Record<string, string>>({})
const palette = ['#3b82f6', '#14b8a6', '#f59e0b', '#8b5cf6', '#f46565', '#06b6d4', '#ec4899', '#84cc16', '#6366f1', '#f97316']
const sections = computed(() => [
{ title: '实运托盘', note: '按实际发车日(UTC)汇总,物理托盘去重', series: props.charts.physicalPallets },
{ title: '派送目的地', note: '各目的地涉及的车次,同车多站可分别出现', series: props.charts.destinationTrips },
{ title: '外接约定金额', note: '按币种分别展示,不代表财务应收或实收', series: props.charts.externalAgreedAmounts },
{ title: '外接按托报价均价', note: '按确认计价托位加权,排除整单报价', series: props.charts.externalPalletRates },
])
function width(value: number, series: TripOperationsSeries) { const max = Math.max(...series.points.map(p => p.value), 0); return max ? value / max * 100 : 0 }
{ key: 'pallets', title: '实运托盘', note: '按实际发车日(UTC)分布,物理托盘去重', series: props.charts.physicalPallets, money: false },
{ key: 'destinations', title: '派送目的地', note: '按目的地涉及车次分布,同车多站可分别计入', series: props.charts.destinationTrips, money: false },
{ key: 'amounts', title: '外接约定金额', note: '按日期分布,各币种独立展示,不代表应收或实收', series: props.charts.externalAgreedAmounts, money: true },
{ key: 'rates', title: '外接按托报价均价', note: '按日均价数值对比,顶部为最近统计日均价', series: props.charts.externalPalletRates, money: true },
].map(section => {
const series = section.series.filter(series => series.points.length).map(series => ({
...series,
points: [...series.points].sort((a, b) => section.key === 'destinations' ? b.value - a.value || a.label.localeCompare(b.label) : a.label.localeCompare(b.label)),
}))
return { ...section, series, active: series.find(series => series.label === selectedSeries.value[section.key]) ?? series[0] ?? null }
}))
const destinationCount = computed(() => new Set(props.charts.destinationTrips.flatMap(series => series.points.map(point => point.label))).size)
const hasPoints = computed(() => sections.value.some(section => section.series.length))
const number = (value: number, money = false) => value.toLocaleString('zh-CN', { minimumFractionDigits: money ? 2 : 0, maximumFractionDigits: 2 })
const color = (index: number) => palette[index % palette.length]!
const seriesTitle = (series: TripOperationsSeries) => series.label === series.unit ? series.label : `${series.label}(${series.unit})`
const amount = (currency: string) => props.summary.externalAgreedAmounts.find(amount => amount.currency === currency)
function slices(series: TripOperationsSeries) {
const total = series.points.reduce((sum, point) => sum + Math.max(0, point.value), 0)
if (!total) return []
let angle = -Math.PI / 2
return series.points.flatMap((point, index) => {
if (point.value <= 0) return []
const end = angle + point.value / total * Math.PI * 2
const startX = 80 + 72 * Math.cos(angle), startY = 80 + 72 * Math.sin(angle)
const endX = 80 + 72 * Math.cos(end), endY = 80 + 72 * Math.sin(end)
const path = point.value === total
? 'M 80 8 A 72 72 0 1 1 80 152 A 72 72 0 1 1 80 8 Z'
: `M 80 80 L ${startX} ${startY} A 72 72 0 ${end - angle > Math.PI ? 1 : 0} 1 ${endX} ${endY} Z`
angle = end
return [{ ...point, color: color(index), path }]
})
}
</script>
<template><section class="operations-charts" aria-label="出车数据统计"><article v-for="section in sections" :key="section.title" class="chart">
<strong>{{ section.title }}</strong><p>{{ section.note }}</p>
<p v-if="!section.series.some(s => s.points.length)">暂无对应统计</p>
<div class="chart-values"><template v-for="(series, si) in section.series" :key="`${series.label}-${si}`"><div v-if="series.points.length"><b>{{ series.label }}({{ series.unit }})</b>
<div v-for="(point, pi) in series.points" :key="pi" class="point"><span>{{ point.label }}</span><div class="bar-track" aria-hidden="true"><div class="bar" :style="{ width: width(point.value, series) + '%' }" /></div><span>{{ point.value }}</span></div>
</div></template></div>
</article></section></template>
<template>
<div class="operations-visualizations">
<section class="operations-charts" aria-label="出车统计饼图">
<article v-for="section in sections" :key="section.key" class="chart" :class="`chart--${section.key}`" :aria-label="section.title">
<h3>{{ section.title }}</h3>
<div class="chart-metric">
<strong v-if="section.key === 'pallets'">{{ number(summary.physicalPallets) }} <small>托</small></strong>
<strong v-else-if="section.key === 'destinations'">{{ number(destinationCount) }} <small>个目的地</small></strong>
<template v-else-if="section.key === 'amounts'">
<strong v-if="section.active && amount(section.active.label)"><small>{{ section.active.label }}</small> {{ number(amount(section.active.label)!.amount, true) }}</strong>
<strong v-else>—</strong>
</template>
<template v-else>
<strong v-if="section.active"><small>{{ section.active.unit }}</small> {{ number(section.active.points[section.active.points.length - 1]!.value, true) }}</strong>
<strong v-else>—</strong>
</template>
</div>
<p class="chart-note">{{ section.note }}</p>
<div v-if="section.series.length > 1" class="series-selector" role="group" :aria-label="`${section.title}币种或系列`">
<el-button v-for="series in section.series" :key="series.label" size="small" :type="section.active?.label === series.label ? 'primary' : 'default'" :aria-pressed="section.active?.label === series.label" @click="selectedSeries[section.key] = series.label">{{ series.label }}</el-button>
</div>
<template v-if="section.active">
<p class="series-caption">{{ seriesTitle(section.active) }}<template v-if="section.key === 'rates'"> · {{ section.active.points[section.active.points.length - 1]!.label }}</template></p>
<svg class="pie-chart" viewBox="0 0 160 160" role="img" :aria-label="`${section.title} · ${seriesTitle(section.active)}饼图,完整数值见图例及统计明细`">
<circle v-if="!slices(section.active).length" cx="80" cy="80" r="72" class="pie-empty" />
<path v-for="slice in slices(section.active)" :key="slice.label" :d="slice.path" :fill="slice.color" class="pie-slice" tabindex="0" :aria-label="`${slice.label}:${number(slice.value, section.money)} ${section.active.unit}`"><title>{{ slice.label }}:{{ number(slice.value, section.money) }} {{ section.active.unit }}</title></path>
<text v-if="!slices(section.active).length" x="80" y="84" text-anchor="middle" class="pie-empty-label">暂无正值数据</text>
</svg>
<ul class="chart-legend" :aria-label="`${section.title}图例`">
<li v-for="(point, pointIndex) in section.active.points.slice(0, 5)" :key="point.label" :title="`${point.label}:${number(point.value, section.money)} ${section.active.unit}`"><span class="legend-dot" :style="{ background: color(pointIndex) }" aria-hidden="true" /><span>{{ point.label }}</span><strong>{{ number(point.value, section.money) }}</strong></li>
</ul>
<details v-if="section.active.points.length > 5" class="legend-details">
<summary>展开其余 {{ section.active.points.length - 5 }} 项图例</summary>
<ul class="chart-legend">
<li v-for="(point, pointIndex) in section.active.points.slice(5)" :key="point.label"><span class="legend-dot" :style="{ background: color(pointIndex + 5) }" aria-hidden="true" /><span>{{ point.label }}</span><strong>{{ number(point.value, section.money) }}</strong></li>
</ul>
</details>
</template>
<p v-else class="chart-empty">暂无对应统计</p>
</article>
</section>
<details v-if="hasPoints" class="chart-details">
<summary>查看完整统计明细</summary>
<div class="chart-details-grid">
<section v-for="section in sections" :key="section.key" :aria-label="`${section.title}明细`">
<h3>{{ section.title }}</h3>
<p v-if="section.key === 'rates'" class="chart-note">各日均价按确认计价托位加权,排除整单报价;不将每日均价相加或简单平均为期间均价。</p>
<p v-if="!section.series.length" class="chart-note">暂无对应统计</p>
<table v-for="(series, seriesIndex) in section.series" :key="`${series.label}-${seriesIndex}`" class="density-table">
<caption>{{ seriesTitle(series) }}</caption>
<thead><tr><th scope="col">{{ section.key === 'destinations' ? '目的地' : '日期(UTC)' }}</th><th scope="col">数值</th></tr></thead>
<tbody><tr v-for="point in series.points" :key="point.label"><td>{{ point.label }}</td><td>{{ number(point.value, section.money) }}</td></tr></tbody>
</table>
</section>
</div>
</details>
</div>
</template>
<style scoped>
.operations-charts { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--admin-space-3); }
.chart { display:grid; align-content:start; gap:var(--admin-space-2); min-width:0; padding:var(--admin-space-3); border:1px solid var(--cc-border); border-radius:var(--admin-radius); background:var(--admin-surface); }
.chart > strong { font-size:var(--admin-font-section); }
.chart p { margin:0; line-height:1.6; color:var(--cc-muted); font-size:var(--admin-font-caption); }
.chart-values { max-height:220px; overflow:auto; }
.point { display:grid; grid-template-columns:minmax(95px,1fr) minmax(45px,1fr) minmax(40px,auto); gap:var(--admin-space-2); align-items:center; margin:var(--admin-space-2) 0; }
.bar-track { height:10px; background:var(--el-fill-color-light); }.bar { height:100%; background:var(--el-color-primary); }
.operations-visualizations { display: grid; gap: var(--admin-space-3); min-width: 0; }
.operations-charts, .chart-details-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--admin-space-6); }
.chart { min-width: 0; padding-right: var(--admin-space-6); border-right: 1px solid var(--cc-border); color: var(--cc-text); text-align: center; }
.chart:last-child { padding-right: 0; border-right: 0; }
.chart h3, .chart-details h3 { margin: 0 0 var(--admin-space-2); font-size: var(--admin-font-section); }
.chart-metric { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: center; gap: var(--admin-space-1) var(--admin-space-5); margin-bottom: var(--admin-space-1); font-variant-numeric: tabular-nums; }
.chart-metric > strong { font-size: calc(var(--admin-font-title) * 1.25); overflow-wrap: anywhere; }
.chart-metric small { font-size: var(--admin-font-caption); font-weight: normal; color: var(--cc-muted); }
.chart-note, .chart-empty, .series-caption { margin: 0; font-size: var(--admin-font-caption); color: var(--cc-muted); line-height: 1.6; }
.chart-empty { display: grid; min-height: 136px; place-content: center; }
.series-caption { margin-top: var(--admin-space-2); }
.series-selector { display: flex; justify-content: center; flex-wrap: wrap; gap: var(--admin-space-2); margin-top: var(--admin-space-2); }
.series-selector :deep(.el-button + .el-button) { margin-left: 0; }
.pie-chart { display: block; width: 136px; height: 136px; max-width: 100%; margin: var(--admin-space-2) auto; overflow: visible; }
.pie-slice { stroke: var(--admin-surface); stroke-width: 2; stroke-linejoin: round; }
.pie-slice:hover, .pie-slice:focus-visible { stroke: var(--cc-text); stroke-width: 3; outline: none; }
.pie-empty { fill: var(--el-fill-color-light); }
.pie-empty-label { fill: var(--cc-muted); font-size: var(--admin-font-caption); }
.chart-legend { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--admin-space-1) var(--admin-space-3); padding: 0; margin: 0; list-style: none; font-size: var(--admin-font-caption); }
.chart-legend li { display: inline-flex; align-items: baseline; gap: var(--admin-space-1); min-width: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.legend-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; flex: 0 0 9px; }
.chart-legend strong { font-weight: normal; color: var(--cc-muted); }
.chart-details summary, .legend-details summary { width: fit-content; cursor: pointer; color: var(--el-color-primary); font-size: var(--admin-font-caption); }
.legend-details summary { margin: var(--admin-space-2) auto; }
.chart-details summary:focus-visible, .legend-details summary:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.chart-details-grid { margin-top: var(--admin-space-5); }
.chart-details-grid > section { min-width: 0; }
.chart-details table { width: 100%; table-layout: fixed; }
.chart-details caption { text-align: left; padding: var(--admin-space-2) 0; color: var(--cc-muted); font-size: var(--admin-font-caption); }
.chart-details td { overflow-wrap: anywhere; }
@media (max-width: 1100px) { .operations-charts, .chart-details-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .chart:nth-child(2n) { padding-right: 0; border-right: 0; } }
@media (max-width: 720px) { .operations-charts, .chart-details-grid { grid-template-columns: minmax(0, 1fr); } .chart { padding-right: 0; padding-bottom: var(--admin-space-5); border-right: 0; border-bottom: 1px solid var(--cc-border); } .chart:last-child { padding-bottom: 0; border-bottom: 0; } }
</style>
@@ -2,6 +2,8 @@ import { afterEach, expect, it, vi } from 'vitest'
import { shallowMount, flushPromises, type VueWrapper } from '@vue/test-utils'
import FleetQuotesView from './FleetQuotesView.vue'
import TripOperationsView from './TripOperationsView.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import TripOperationsCharts from '../components/TripOperationsCharts.vue'
import * as quotes from '../api/fleetQuotes'
import * as reports from '../api/tripOperations'
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (p: string) => p.endsWith(':list') }) }))
@@ -9,10 +11,10 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('../api/fleetQuotes', () => ({ listFleetQuotes: vi.fn(), setFleetQuoteStatus: vi.fn(), deleteFleetQuote: vi.fn(), exportFleetQuotes: vi.fn(), saveFleetCsv: vi.fn() }))
vi.mock('../api/tripOperations', () => ({ listTripOperations: vi.fn(), exportTripOperations: vi.fn() }))
const wrappers: VueWrapper[] = []
const layout = { template: '<div><slot/></div>' }
const layout = { template: '<div><slot name="metrics"/><slot name="state"/><slot/></div>' }
function mount(component: object) { const wrapper = shallowMount(component, { global: { stubs: { AdminPage: layout, ListPageLayout: layout, QueryToolbar: true,
DataTable: { name: 'DataTable', props: ['data', 'columns'], template: '<div><slot name="toolbar-start"/><slot name="toolbar-actions"/><slot name="filters"/><slot name="notices"/></div>' }, ElButton: { template: '<button><slot/></button>' },
LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
ElTooltip: true, LoadErrorState: true, FleetQuoteEditor: true, FleetQuoteImport: true, ApiErrorMeta: true } } }); wrappers.push(wrapper); return wrapper }
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.clearAllMocks() })
it('quotes read-only view fetches a page once and hides all write/export tools', async () => {
vi.mocked(quotes.listFleetQuotes).mockResolvedValue({ records: [], total: 0, page: 1, size: 20, pages: 0 })
@@ -24,4 +26,30 @@ it('operations uses server totals rather than displayed page size', async () =>
vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 21, page: 1, size: 20, pages: 2 }, summary: { trips: 21, inTransit: 12, completed: 9, physicalPallets: 40, palletSpaces: 40, ownPackages: 70, externalPackages: 10, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
const wrapper = mount(TripOperationsView); await flushPromises()
expect(reports.listTripOperations).toHaveBeenCalledTimes(1); expect(wrapper.text()).toContain('实际出车 21 次'); expect(wrapper.text()).not.toContain('导出CSV')
expect(wrapper.findComponent(TripOperationsCharts).props('summary').physicalPallets).toBe(40)
expect(wrapper.find('.operations-overview').element.compareDocumentPosition(wrapper.findComponent({ name: 'DataTable' }).element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(wrapper.findComponent({ name: 'DataTable' }).props('columns').find((column: { key: string }) => column.key === 'finance')).toMatchObject({ defaultVisible: false })
})
it('operations queries charts and records together and resets with only one request', async () => {
vi.mocked(reports.listTripOperations).mockResolvedValue({ page: { records: [], total: 0, page: 1, size: 20, pages: 0 }, summary: { trips: 0, inTransit: 0, completed: 0, physicalPallets: 0, palletSpaces: 0, ownPackages: 0, externalPackages: 0, externalAgreedAmounts: [], costs: { visible: false, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] } })
const wrapper = mount(TripOperationsView); await flushPromises()
const initial = vi.mocked(reports.listTripOperations).mock.calls[0]![0]
const toolbar = wrapper.findComponent(QueryToolbar)
toolbar.vm.$emit('update:modelValue', { from: '2026-10-01', to: '2026-10-07', keyword: 'TRIP', sourceType: 'EXTERNAL', status: 'IN_TRANSIT' })
toolbar.vm.$emit('search'); await flushPromises()
expect(reports.listTripOperations).toHaveBeenLastCalledWith(expect.objectContaining({ from: '2026-10-01T00:00:00Z', to: '2026-10-08T00:00:00.000Z', keyword: 'TRIP', sourceType: 'EXTERNAL', status: 'IN_TRANSIT' }))
expect(wrapper.text()).toContain('2026-10-01 至 2026-10-07(UTC)')
toolbar.vm.$emit('update:modelValue', { from: '2026-09-01', to: '2026-09-07' }); await flushPromises()
expect(wrapper.text()).toContain('2026-10-01 至 2026-10-07(UTC)')
toolbar.vm.$emit('reset'); toolbar.vm.$emit('search'); await flushPromises()
expect(reports.listTripOperations).toHaveBeenCalledTimes(3)
expect(reports.listTripOperations).toHaveBeenLastCalledWith(initial)
expect(toolbar.props('modelValue')).toMatchObject({ keyword: '', sourceType: '', status: '' })
})
it('operations shows query failures without leaving stale statistics', async () => {
vi.mocked(reports.listTripOperations).mockRejectedValue(new Error('统计暂不可用'))
const wrapper = mount(TripOperationsView); await flushPromises()
expect(wrapper.findComponent(TripOperationsCharts).exists()).toBe(false)
expect(wrapper.findComponent({ name: 'LoadErrorState' }).attributes('message')).toBe('运营记录加载失败')
expect(wrapper.text()).not.toContain('实际出车 0 次')
})
@@ -20,48 +20,79 @@ const router = useRouter(), access = useAccessPreviewStore()
const result = ref<TripOperationsResult | null>(null), page = ref(1), size = ref(20), loading = ref(false), exporting = ref(false), error = ref<ApiErrorDetails | null>(null)
const recordOpen = ref(false), selected = ref<TripOperationsRow | null>(null)
const today = new Date().toISOString().slice(0, 10), monthAgo = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10)
const filters = ref<QueryModel>({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
const defaultFilters = () => ({ from: monthAgo, to: today, keyword: '', sourceType: '', status: '' })
const filters = ref<QueryModel>(defaultFilters())
const displayedRange = ref('')
let applied: TripOperationsQuery = range(), sequence = 0
const fields: QueryField[] = [
{ key: 'from', label: '实际发车起日(UTC)', type: 'date', width: 185 }, { key: 'to', label: '实际发车止日(UTC)', type: 'date', width: 185 },
{ key: 'keyword', label: '关键词', type: 'input', width: 235, placeholder: '车次 / 发车车牌 / 司机 / 目的仓' },
{ key: 'sourceType', label: '货源', type: 'select', width: 145, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
{ key: 'status', label: '状态', type: 'select', width: 130, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
{ key: 'from', label: '实际发车起日(UTC)', placeholder: '发车起日(UTC)', type: 'date', width: 150, props: { 'aria-label': '实际发车起日(UTC)' } },
{ key: 'to', label: '实际发车止日(UTC)', placeholder: '发车止日(UTC)', type: 'date', width: 150, props: { 'aria-label': '实际发车止日(UTC)' } },
{ key: 'keyword', label: '关键词', type: 'input', width: 270, placeholder: '车次 / 车牌 / 司机 / 目的仓', props: { 'aria-label': '车次、车牌、司机或目的仓' } },
{ key: 'sourceType', label: '货源', placeholder: '全部货源', type: 'select', width: 140, props: { 'aria-label': '货源' }, options: [{ value: 'OWN', label: '含自有业务' }, { value: 'EXTERNAL', label: '含外接业务' }] },
{ key: 'status', label: '状态', placeholder: '全部状态', type: 'select', width: 140, props: { 'aria-label': '状态' }, options: [{ value: 'IN_TRANSIT', label: '在途' }, { value: 'COMPLETED', label: '货物已结案' }] },
]
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 185, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
const columns: DataTableColumn[] = [{ key: 'actions', label: '操作', width: 140, fixed: 'left', slotName: 'actions' }, { key: 'tripNo', label: '车次号', minWidth: 220 },
{ key: 'status', label: '状态', width: 125, slotName: 'status' }, { key: 'vehiclePlate', label: '发车车牌', minWidth: 125 }, { key: 'driverName', label: '发车司机', minWidth: 100 },
{ key: 'warehouseName', label: '起运仓', minWidth: 150 }, { key: 'destinations', label: '目的地', minWidth: 180, slotName: 'destinations' },
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'completedAt', label: '货物结案(UTC)', minWidth: 190 },
{ key: 'physicalPallets', label: '实物托盘', width: 100 }, { key: 'palletSpaces', label: '标准托位', width: 100 }, { key: 'ownPackages', label: '自有箱数', width: 100 }, { key: 'externalPackages', label: '外接箱数', width: 100 },
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' }, { key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance' }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel' }, { key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance' }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost' }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated' }]
{ key: 'departedAt', label: '实际发车(UTC)', minWidth: 190 }, { key: 'physicalPallets', label: '实物托盘', width: 100 },
{ key: 'agreed', label: '外接约定金额', minWidth: 170, slotName: 'agreed' },
{ key: 'completedAt', label: '货物结案(UTC)', minWidth: 190, defaultVisible: false },
{ key: 'palletSpaces', label: '标准托位', width: 100, defaultVisible: false }, { key: 'ownPackages', label: '自有箱数', width: 100, defaultVisible: false }, { key: 'externalPackages', label: '外接箱数', width: 100, defaultVisible: false },
{ key: 'distance', label: '实际里程(mile)', width: 140, slotName: 'distance', defaultVisible: false }, { key: 'fuel', label: '实际油耗(L)', width: 130, slotName: 'fuel', defaultVisible: false },
{ key: 'finance', label: '应收 / 实收', minWidth: 260, slotName: 'finance', defaultVisible: false }, { key: 'cost', label: '已登记直接成本', minWidth: 200, slotName: 'cost', defaultVisible: false }, { key: 'allocated', label: '自有 / 外接分摊', minWidth: 240, slotName: 'allocated', defaultVisible: false }]
function range(): TripOperationsQuery {
const from = String(filters.value.from || monthAgo), to = String(filters.value.to || today)
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to) || Number.isNaN(Date.parse(from)) || Number.isNaN(Date.parse(to))) throw new Error('请输入有效日期 YYYY-MM-DD')
return { from: `${from}T00:00:00Z`, to: new Date(Date.parse(`${to}T00:00:00Z`) + 86400000).toISOString(), keyword: String(filters.value.keyword || '') || undefined, sourceType: (filters.value.sourceType || undefined) as TripOperationsQuery['sourceType'], status: String(filters.value.status || '') || undefined }
}
async function load() { const current = ++sequence; loading.value = true; error.value = null; try { const data = await listTripOperations({ ...applied, page: page.value, size: size.value }); if (current === sequence) result.value = data } catch (e) { if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') } } finally { if (current === sequence) loading.value = false } }
async function load() {
const current = ++sequence, query = { ...applied, page: page.value, size: size.value }
loading.value = true; error.value = null
try {
const data = await listTripOperations(query)
if (current === sequence) {
result.value = data
displayedRange.value = `${query.from.slice(0, 10)} 至 ${new Date(Date.parse(query.to) - 86400000).toISOString().slice(0, 10)}(UTC)`
}
} catch (e) {
if (current === sequence) { result.value = null; error.value = getApiErrorDetails(e, '运营记录加载失败') }
} finally { if (current === sequence) loading.value = false }
}
function search() { try { applied = range(); if (page.value !== 1) page.value = 1; else void load() } catch (e) { error.value = getApiErrorDetails(e, '日期范围无效') } }
function resetFilters() { filters.value = defaultFilters() }
function openRecord(tripNo: unknown) { const row = result.value?.page.records.find(item => item.tripNo === tripNo); if (row) { selected.value = row; recordOpen.value = true } }
async function download() { exporting.value = true; try { await submitExport('TRIP_OPERATIONS_LIST', '出车记录', applied) } catch (e) { error.value = getApiErrorDetails(e, '导出失败') } finally { exporting.value = false } }
const money = (rows: TripOperationsMoney[]) => rows.length ? rows.map(r => `${r.currency} ${r.amount.toFixed(2)}`).join(' / ') : '—'
watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ })
</script>
<template><AdminPage class="admin-density"><ListPageLayout aria-label="出车记录">
<template #metrics>
<section class="operations-overview" aria-labelledby="operations-statistics-title" :aria-busy="loading">
<header class="operations-heading">
<h2 id="operations-statistics-title">出车数据统计</h2>
<span class="operations-note">{{ loading ? '正在更新统计与记录…' : displayedRange }} · 与下方列表筛选一致</span>
</header>
<template v-if="result">
<TripOperationsCharts :charts="result.charts" :summary="result.summary" />
<footer class="operations-footer">
<div class="operations-summary" aria-live="polite">
<span>实际出车 <strong>{{ result.summary.trips }}</strong> 次</span><span>在途 <strong>{{ result.summary.inTransit }}</strong> 次</span><span>货物结案 <strong>{{ result.summary.completed }}</strong> 次</span>
</div>
<el-tooltip placement="top" :show-after="200" content="按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。物理托盘去重;同车多站可分别计入目的地。外接约定金额不代表应收或实收,结案不代表车辆与司机已释放。">
<el-button link aria-label="查看统计口径">统计口径</el-button>
</el-tooltip>
</footer>
<p v-if="result.summary.costs.visible" class="operations-note operations-costs">已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips ?? '—' }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips ?? '—' }} 趟。</p>
</template>
<p v-else class="operations-placeholder operations-note" role="status">{{ loading ? '正在加载出车统计…' : '暂无统计数据' }}</p>
</section>
</template>
<template #state>
<LoadErrorState v-if="error" title="查询或导出失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
</template>
<DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="true" preference-key="fleet-operations-table" v-model:page="page" v-model:page-size="size" :data="result?.page.records || []" :columns="columns" :total="result?.page.total || 0" :loading="loading" row-key="tripNo" empty-text="该范围暂无实际出车记录">
<template #toolbar-start><h1>出车记录</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="运营记录查询" preference-key="fleet-operations-query" :loading="loading" @search="search" @reset="search" /></template>
<template #notices>
<div class="operations-overview">
<LoadErrorState v-if="error" title="查询或导出失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<TripOperationsCharts v-if="result" :charts="result.charts" />
<div v-if="result" class="operations-summary" aria-live="polite">
<span>实际出车 <strong>{{ result.summary.trips }}</strong> 次</span><span>在途 <strong>{{ result.summary.inTransit }}</strong> 次</span><span>货物结案 <strong>{{ result.summary.completed }}</strong> 次</span>
<span>实物托盘 <strong>{{ result.summary.physicalPallets }}</strong> 托</span><span>外接约定 {{ money(result.summary.externalAgreedAmounts) }}</span>
</div>
<p v-if="result?.summary.costs.visible" class="operations-note">已登记直接成本:{{ result.summary.costs.totals.length ? result.summary.costs.totals.map(c => `${c.currency} ${c.directCost.toFixed(2)}`).join(' / ') : '—' }};未登记 {{ result.summary.costs.unrecordedTrips }} 趟,已登记但未能分摊 {{ result.summary.costs.unallocatedTrips }} 趟。</p>
<p class="operations-note">按实际发车时间查询,每趟车只计一次;货源筛选展示含该类货物的整趟车。约定金额不代表应收或实收,结案不代表车辆与司机已释放。</p>
</div>
</template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="运营记录查询" preference-key="fleet-operations-query" :loading="loading" @search="search" @reset="resetFilters" /></template>
<template #toolbar-actions><el-button v-if="access.can('platform:fleet:operations:export') && access.can('platform:download:list')" :loading="exporting" @click="download">导出</el-button></template>
<template #actions="{ row }"><div class="density-actions row-actions admin-text-actions"><el-button v-if="access.can('platform:fleet:trip:list')" class="density-action" @click="router.push({ name: 'overseas-master-data-trip-detail', params: { tripNo: row.tripNo } })">详情</el-button><el-button class="density-action" @click="openRecord(row.tripNo)">实测记录</el-button></div></template>
<template #status="{ row }">{{ row.status === 'COMPLETED' ? '货物已结案' : '在途' }}</template><template #destinations="{ row }">{{ row.destinations.map((d: TripOperationsRow['destinations'][number]) => d.label).join(' / ') }}</template>
@@ -73,7 +104,12 @@ watch([page, size], load); onMounted(load); onBeforeUnmount(() => { sequence++ }
<TripOperationsRecordDialog v-model="recordOpen" :row="selected" :readonly="!access.can('platform:fleet:operations:edit')" @saved="load" />
</ListPageLayout></AdminPage></template>
<style scoped>
.operations-overview { display: grid; gap: var(--admin-space-3); min-width: 0; }
.operations-overview { display: grid; gap: var(--admin-space-3); min-width: 0; padding: var(--admin-space-5); background: var(--admin-surface); border: 1px solid var(--cc-border); border-radius: var(--admin-radius); }
.operations-heading, .operations-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--admin-space-3) var(--admin-space-6); }
.operations-heading h2 { margin: 0; font-size: var(--admin-font-title); }
.operations-footer { border-top: 1px solid var(--cc-border); padding-top: var(--admin-space-3); }
.operations-summary { display: flex; flex-wrap: wrap; gap: var(--admin-space-2) var(--admin-space-6); font-variant-numeric: tabular-nums; }
.operations-note { margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.6; }
.operations-placeholder { min-height: var(--admin-empty-height); display: grid; place-content: center; }
.operations-costs { padding-top: var(--admin-space-1); }
</style>
+1
View File
@@ -71,6 +71,7 @@ const generalCatalogConsumers = [
'overseas-master-data/components/CapacityInquiryTable.vue',
'overseas-master-data/components/CapacityOfferSettingsDialog.vue',
'overseas-master-data/pages/TripOperationsView.vue',
'overseas-master-data/components/TripOperationsCharts.vue',
'overseas-master-data/pages/TripDetailView.vue',
'overseas-master-data/components/TripPlanDialog.vue',
'overseas-master-data/pages/DriverDetailView.vue',