Merge branch 'release/test' of http://8.163.30.128:10082/html/admin-web into release/test

This commit is contained in:
2026-10-19 15:59:17 +08:00
5 changed files with 256 additions and 89 deletions
@@ -60,3 +60,16 @@
| 本土散板 | 列表 | 原 `page-overseas-looseboard` 独立列表与左侧操作 | 展示两端联系信息、商品名、预计托数及取送时间;内部未知安排显示“—”;池来源保持承运摘要。待人工截图确认 |
本次不增加详情、审核、删除或文件预览操作;不更改现有承运安排只读弹框。
## Admin / Client 统一运输下单表单(2026-10-19)
依据:当前对话明确要求“最多 Admin 添加客户选项,Client 从登录客户下单”,覆盖此前两端独立排版结论。保留应用壳层、正式路由及权限;业务表单统一为同一字段清单、排列、控件、默认值、校验及提交转换。
| 页面 | 操作 | 承载与现有依据 | 本轮统一结论 | 验收 |
|---|---|---|---|---|
| 本土卡车 | Client 新增、Admin 新增/编辑 | 既有独立页面;原客户端截图及当前统一确认 | 运输信息双列,车辆必选;两端联系人/电话及详细地址左右对应;货物件重体;费用币种与两个分钟时间;备注;底部取消/提交。Admin 新增前置客户选择 | 自动合同及行为测试;待人工 1920×1080、100% 截图 |
| 本土散板 | Client 新增、Admin 新增/编辑 | 既有独立页面;当前统一确认 | 与卡车共用表单;保留商品名、预计托数和件重体,移除车型;其余相同。Admin 新增前置客户选择 | 同上 |
| 两类业务 | 列表、详情、承运安排 | 既有独立路由及只读承运摘要 | 本轮不改变承载;Client 不新增编辑路由 | 既有行为回归 |
统一表单组件和转换逻辑各自位于应用内部,运行时不跨应用引用。两端业务组件源码一致性另作定向检查;下单数据不允许混入客户标识,Admin 在 API 参数显式选择客户,Client 由认证会话确定客户。
@@ -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>
@@ -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' })) })
@@ -281,7 +305,7 @@ describe('overseas business mounted pages', () => {
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' })
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()
@@ -297,9 +321,9 @@ describe('overseas business mounted pages', () => {
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.findAll('input[placeholder="城市 / 州 / 详细地址"]')[1]; 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('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'); 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.findAll('input[placeholder="城市 / 州 / 详细地址"]')[0]; const destination = wrapper.findAll('input[placeholder="城市 / 州 / 详细地址"]')[1]; 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'
@@ -18,8 +18,10 @@ import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRe
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, 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)
@@ -30,45 +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; productName: string; estimatedPalletCount: number
origin: string; destination: string; originAddress: string | null; destinationAddress: 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
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: '', productName: '', estimatedPalletCount: 0, origin: '', destination: '', originAddress: null, destinationAddress: null, vehicleType: '', originContactName: '', originContactPhone: '', destinationContactName: '', destinationContactPhone: '', pickupAt: '', deliveryAt: '', packageCount: 0, totalWeightKg: isLooseboard.value ? '0' : null, totalVolumeCbm: isLooseboard.value ? '0' : null, budgetAmount: null, currency: 'USD', 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 demandRequired(label: string, trigger = 'blur') {
return { required: true, validator: (_rule: unknown, value: unknown, callback: (error?: Error) => void) => callback(String(value || '').trim() ? undefined : new Error(`请填写${label}`)), trigger }
}
const rules: FormRules<TransportFormModel> = {
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: [{ required: true, validator: (_rule, value, callback) => callback(isLooseboard.value || value ? undefined : new Error('请选择车辆类型')), trigger: 'change' }],
originContactName: [demandRequired('起运地联系人')], originContactPhone: [demandRequired('起运地联系电话')],
destinationContactName: [demandRequired('目的地联系人')], destinationContactPhone: [demandRequired('目的地联系电话')],
pickupAt: [{ required: true, validator: (_rule, value, callback) => callback(!value ? new Error('请选择取货时间') : undefined), trigger: 'change' }],
estimatedPalletCount: [{ type: 'number', min: 0, required: true, message: '预计托数不能小于 0', trigger: 'change' }],
deliveryAt: [demandRequired('送货时间', 'change'), {
validator: (_rule, value, callback) => callback(value && form.pickupAt && value < form.pickupAt ? new Error('送货时间不能早于取货时间') : undefined), trigger: 'change',
}],
budgetAmount: [{ pattern: /^\d+(\.\d{1,2})?$/, message: '预算总价最多保留 2 位小数', trigger: 'blur' }],
}
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 = ''
@@ -85,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, 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: detail.totalWeightKg, totalVolumeCbm: detail.totalVolumeCbm, budgetAmount: detail.budgetAmount, currency: detail.currency, pickupAt: detail.pickupAt || '', deliveryAt: detail.deliveryAt || '', 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 }
@@ -97,20 +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.pickupAt && form.deliveryAt && form.deliveryAt < form.pickupAt) { submitError.value = { message: '送达时间不能早于取货时间' }; return }
for (const [value, label] of [[form.originContactName, '起运地联系人'], [form.originContactPhone, '起运地联系电话'], [form.destinationContactName, '目的地联系人'], [form.destinationContactPhone, '目的地联系电话'], [form.pickupAt, '取货时间'], [form.deliveryAt, '送货时间']]) {
if (!value.trim()) { submitError.value = { message: `请填写${label}` }; 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, productName: optional(form.productName), estimatedPalletCount: form.estimatedPalletCount, originContactName: form.originContactName.trim(), originContactPhone: form.originContactPhone.trim(), destinationContactName: form.destinationContactName.trim(), destinationContactPhone: form.destinationContactPhone.trim(), pickupAt: form.pickupAt, deliveryAt: form.deliveryAt }
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)
}
@@ -126,52 +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="客户" 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>
<section v-else v-loading="loading" class="form-panel">
<el-alert v-if="editing && !editableStatus" type="warning" :closable="false" show-icon title="当前状态不可普通编辑,请返回列表查看" />
<template v-if="isLooseboard">
<FormSection class="density-form-section" title="运输信息">
<div class="form-grid two">
<el-form-item label="取货地" prop="origin"><el-input v-model.trim="form.origin" maxlength="255" placeholder="城市 / 州 / 详细地址" /></el-form-item>
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="form.destination" maxlength="255" placeholder="城市 / 州 / 详细地址" /></el-form-item>
<el-form-item label="起运地联系人" prop="originContactName" required><el-input v-model.trim="form.originContactName" maxlength="128" placeholder="请输入起运地联系人" /></el-form-item>
<el-form-item label="起运地联系电话" prop="originContactPhone" required><el-input v-model.trim="form.originContactPhone" maxlength="128" placeholder="请输入起运地联系电话" type="tel" /></el-form-item>
<el-form-item label="目的地联系人" prop="destinationContactName" required><el-input v-model.trim="form.destinationContactName" maxlength="128" placeholder="请输入目的地联系人" /></el-form-item>
<el-form-item 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 four">
<el-form-item label="商品名称"><el-input v-model.trim="form.productName" maxlength="255" placeholder="请输入商品名称(选填)" /></el-form-item>
<el-form-item label="预计托数" prop="estimatedPalletCount"><el-input-number v-model="form.estimatedPalletCount" :min="0" :precision="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><el-select v-model="form.currency" aria-label="预算币种" class="budget-currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></template></el-input></el-form-item>
<div></div>
<el-form-item 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 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>
</div></FormSection>
<el-collapse><el-collapse-item title="补充信息(选填)" name="extra"><div class="form-grid two">
<el-form-item label="件数" prop="packageCount"><el-input-number v-model="form.packageCount" :min="0" :precision="0" controls-position="right" placeholder="请输入件数" /></el-form-item>
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" 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>
</div></el-collapse-item></el-collapse>
</template>
<template v-else>
<FormSection class="density-form-section" title="运输信息"><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="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 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="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>
</template>
</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)); }
.overseas-business-editor .form-grid .budget-currency { width: 85px; }
.span-2 { grid-column: 1 / -1; }
.transport-customer-section { padding: 20px 24px 0; border-bottom: 1px solid #e7ebf1; }
</style>
@@ -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 }
}