feat: connect plan-level preload confirmation

This commit is contained in:
2026-08-15 17:04:00 +08:00
parent fc80d09faa
commit 835d1d0216
26 changed files with 398 additions and 267 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ const pages = [
['src/modules/port-schedules/pages/PortSchedulesView.vue', "prop: 'schedule.scheduleNo'"],
['src/modules/trucking/pages/TruckAppointmentsView.vue', "key: 'appointmentNo'"],
['src/modules/customs/pages/CustomsCasesView.vue', "prop: 'customsNo'"],
['src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue', "prop: 'orderNo'"],
['src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue', "prop: 'inboundCode'"],
] as const
function source(path: string) {
+1
View File
@@ -1,2 +1,3 @@
export * from './api/platformContainerLoading'
export * from './types'
export { default as SmartLoadingDialog } from './components/SmartLoadingDialog.vue'
@@ -199,7 +199,7 @@ describe('ContainersView', () => {
it('keeps the prototype field sequence ahead of contract-only supplemental fields', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/container-loading/pages/ContainersView.vue'), 'utf8')
const labels = ['集装箱号', '柜号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
const labels = ['集装箱号', '柜次号', '装柜仓库', '提单号', '船名', '航次', '箱型(货柜类型)', '船公司', '起运港', '目的港', '目的仓', '货柜容量(m³)']
let previous = -1
for (const label of labels) {
const current = source.indexOf(`label="${label}"`, previous + 1)
@@ -235,7 +235,7 @@ describe('ContainersView', () => {
expect(source).toContain('新增模拟柜')
expect(source).toContain('createContainerSimulation({ ...simulationForm })')
expect(source).toContain("channelCode: '', warehouseId: '', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000")
expect(source).toContain('服务端会生成稳定的内部柜次')
expect(source).toContain('服务端会生成稳定的柜次号')
expect(source).not.toContain('openCreate')
})
@@ -244,7 +244,7 @@ describe('ContainersView', () => {
expect(source).toContain("!row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)")
expect(source).toContain("row.oceanBookingId && row.planStatus === 'WAIT_LOADING'")
expect(source).toContain('已预排货但尚未进入 PDA、取证或单证阶段的模拟柜仍可首次关联')
expect(source).toContain('内部柜次与装柜明细保持不变')
expect(source).toContain('柜次号与装柜明细保持不变')
expect(source).toContain('只有尚未排货的空柜可以解除')
expect(source).toContain('linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })')
expect(source).toContain('unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())')
@@ -164,14 +164,14 @@ const destinationWarehouseValue = computed(() => form.destinationWarehouseCode |
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', width: 220, placeholder: '全部客户', options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、柜号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '柜次号、集装箱号或提单号', props: { 'aria-label': '关键词', maxlength: 128 } },
{ key: 'containerType', type: 'select', label: '柜型', width: 140, options: ['20GP', '40GP', '40HQ', '45HQ'].map((value) => ({ label: value, value })), props: { 'aria-label': '柜型' } },
{ key: 'status', type: 'select', label: '排柜状态', width: 150, options: [{ label: '待装柜', value: 'WAIT_LOADING' }, { label: '预装柜中', value: 'EDITING' }, { label: '拣货中', value: 'LOADING' }, { label: '装柜完成', value: 'COMPLETED' }], props: { 'aria-label': '排柜状态' } },
{ key: 'sailingRange', type: 'daterange', label: '预计开船', width: 260, props: { 'aria-label': '预计开船日期范围' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' },
{ key: 'cabinetNo', label: '柜次', minWidth: 180, slotName: 'cabinet' },
{ key: 'cabinetNo', label: '柜次号', minWidth: 180, slotName: 'cabinet' },
{ key: 'oceanBooking', label: '真实订舱', minWidth: 170, slotName: 'oceanBooking' },
{ key: 'containerNo', label: '集装箱号', minWidth: 150, slotName: 'containerNo' },
{ key: 'planStatus', label: '状态', width: 110, slotName: 'status' },
@@ -387,7 +387,7 @@ async function saveLink() {
try {
await linkContainerOceanBooking(target.id, { oceanBookingId: candidate.oceanBookingId, requirementId: candidate.requirementId, expectedVersion: target.version })
linkOpen.value = false
ElMessage.success('已关联真实订舱柜,内部柜次与装柜明细保持不变')
ElMessage.success('已关联真实订舱柜,柜次号与装柜明细保持不变')
await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '关联真实订舱柜失败') }
finally { linkSaving.value = false }
@@ -406,7 +406,7 @@ async function saveUnlink() {
try {
await unlinkContainerOceanBooking(target.id, target.version, unlinkReason.value.trim())
unlinkOpen.value = false
ElMessage.success('真实订舱关联已解除,内部柜次保持不变')
ElMessage.success('真实订舱关联已解除,柜次号保持不变')
await loadRows()
} catch (error) { linkError.value = getApiErrorDetails(error, '解除真实订舱关联失败') }
finally { linkSaving.value = false }
@@ -597,8 +597,8 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :total="total" :loading="loading" stripe preference-key="container-loading-table" empty-text="暂无货柜">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button v-if="canView" size="small" type="primary" plain :aria-label="containerViewLabel(row as ContainerBookingResource)" @click="openDetail(row)"><Eye :size="15" />查看</el-button><el-button v-if="canView && canEdit && row.planStatus !== 'COMPLETED'" size="small" @click="openEdit(row)"><Pencil :size="15" />{{ row.planStatus === 'LOADING' ? (row.containerNo ? '修改集装箱号' : '录入集装箱号') : '编辑运输信息' }}</el-button><el-button v-if="row.planStatus === 'EDITING' && canSmartLoading" size="small" type="primary" @click="goContainerProcess(row)"><Sparkles :size="15" />排柜</el-button><el-button v-else-if="row.planStatus === 'LOADING' && canViewPreload" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />预装清单</el-button><el-button v-else-if="row.planStatus === 'COMPLETED' && canViewLoadingList" size="small" type="primary" plain @click="goContainerProcess(row)"><ListChecks :size="15" />装柜清单</el-button><el-dropdown v-if="canView" trigger="click" @command="handleMoreAction($event, row)"><el-button size="small" plain aria-label="更多货柜操作" title="更多货柜操作"><MoreHorizontal :size="16" /></el-button><template #dropdown><el-dropdown-menu><el-dropdown-item command="overview"><FileText :size="15" />轨迹与日志</el-dropdown-item><el-dropdown-item command="documents"><FileUp :size="15" />DO / PU 资料</el-dropdown-item><el-dropdown-item v-if="canEdit && !row.oceanBookingId && ['WAIT_LOADING','EDITING'].includes(row.planStatus)" command="link"><Link2 :size="15" />关联真实订舱柜</el-dropdown-item><el-dropdown-item v-if="canEdit && row.oceanBookingId && row.planStatus === 'WAIT_LOADING'" command="unlink"><Unlink :size="15" />解除订舱关联</el-dropdown-item><el-dropdown-item v-if="canEdit" command="exception" divided><TriangleAlert :size="15" />异常处理</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>内部柜次</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo || '真实柜号待录入' }}</small></span><span v-else>未关联</span></template>
<template #cabinet="{ row }"><strong>{{ row.cabinetNo }}</strong><small>系统生成</small></template>
<template #oceanBooking="{ row }"><span v-if="row.oceanBookingId"><strong>{{ row.billOfLadingNo || '已关联订舱' }}</strong><small>{{ row.containerNo ? `集装箱号:${row.containerNo}` : '集装箱号待录入' }}</small></span><span v-else>未关联</span></template>
<template #containerNo="{ row }">{{ row.containerNo || '待录入' }}</template>
<template #status="{ row }"><el-tag :type="row.planStatus === 'COMPLETED' ? 'success' : row.planStatus === 'LOADING' ? 'warning' : 'info'">{{ statusText(row.planStatus) }}</el-tag></template>
<template #route="{ row }">{{ row.departurePortName || row.departurePortCode || '—' }} → {{ row.destinationPortName || row.destinationPortCode || '—' }}</template>
@@ -615,7 +615,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<AppDrawer v-model="detailOpen" title="货柜详情" size="min(760px, 94vw)" :loading="detailLoading" :show-confirm="false" cancel-text="关闭">
<LoadErrorState v-if="detailError" title="货柜详情加载失败" :message="detailError.message" @retry="detailTarget && openDetail(detailTarget)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="内部柜次">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="真实订舱">{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</el-descriptions-item><el-descriptions-item label="真实集装箱号">{{ detail.containerNo || '待录入' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
<template v-else-if="detail"><el-descriptions :column="2" border class="details"><el-descriptions-item label="柜次号">{{ detail.cabinetNo }}</el-descriptions-item><el-descriptions-item label="真实订舱">{{ detail.oceanBookingId ? (detail.billOfLadingNo || '已关联') : '未关联' }}</el-descriptions-item><el-descriptions-item label="集装箱号">{{ detail.containerNo || '待录入' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusText(detail.planStatus) }}</el-descriptions-item><el-descriptions-item label="柜型">{{ detail.containerType }}</el-descriptions-item><el-descriptions-item label="装柜仓库">{{ detail.warehouseName || '—' }}</el-descriptions-item><el-descriptions-item label="目的仓">{{ detail.destinationWarehouseName || detail.destinationWarehouseCode || '—' }}</el-descriptions-item><el-descriptions-item label="船公司">{{ detail.shippingCompanyName || '—' }}</el-descriptions-item><el-descriptions-item label="船名/航次">{{ [detail.vesselName, detail.voyageNo].filter(Boolean).join(' / ') || '—' }}</el-descriptions-item><el-descriptions-item label="提单号">{{ detail.billOfLadingNo || '—' }}</el-descriptions-item><el-descriptions-item label="拖车公司">{{ detail.terminalTruckingCompany || '—' }}</el-descriptions-item><el-descriptions-item label="预计开船">{{ formatTime(detail.estimatedSailingAt) }}</el-descriptions-item><el-descriptions-item label="预计到港">{{ formatTime(detail.estimatedPortArrivalAt) }}</el-descriptions-item></el-descriptions><LoadErrorState v-if="overviewError" compact title="履约摘要加载失败" :message="overviewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="overviewError" /></template></LoadErrorState><ContainerOverviewPanel v-else-if="overview" :overview="overview" :can-manage-documents="canEdit" :can-manage-exception="canEdit" :document-busy="documentDownloading" :photo-busy-id="photoBusyId" :photo-error="photoError" @upload-document="openDocumentDialog" @download-document="downloadDocument" @manage-exception="openExceptionDialog" @preview-photo="previewPhoto" /></template>
</AppDrawer>
<AppDialog v-model="clearanceDialogOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :confirm-loading="clearanceActionLoading" :confirm-disabled="!clearanceFile" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`清关已完成:${clearanceDocument.fileName}`" /><el-form v-else label-position="top"><el-form-item label="清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ clearanceDocument ? '上传新文件将替换当前资料。' : '请选择需要提交的目的港清关资料。' }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="clearanceError" compact title="操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
@@ -625,7 +625,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<AppDialog v-model="exceptionDialogOpen" :title="exceptionDialogTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false"><template #details><ApiErrorMeta :details="exceptionError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
<el-alert title="模拟柜用于先排货,服务端会生成稳定的内部柜次;此处不填写真实柜号和船期。" type="info" :closable="false" />
<el-alert title="模拟柜用于先排货,服务端会生成稳定的柜次号;此处不填写集装箱号和船期。" type="info" :closable="false" />
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="物流渠道" prop="channelCode"><el-select v-model="simulationForm.channelCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="channel in simulationChannels" :key="channel.channelCode" :label="channel.channelName" :value="channel.channelCode" /></el-select></el-form-item>
@@ -637,14 +637,14 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
</AppDialog>
<AppDialog v-model="linkOpen" title="关联真实订舱柜" width="min(620px, calc(100vw - 28px))" confirm-text="确认关联" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!linkCandidateKey" @confirm="saveLink">
<el-alert title="关联只补充真实订舱关系,不会重建内部柜次、装柜计划或已排货明细。" type="info" :closable="false" />
<el-form label-position="top"><el-form-item label="内部柜次"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="可关联的真实订舱柜" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择同仓、同渠道、同规格的已确认订舱柜" no-data-text="暂无可关联的真实订舱柜"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item></el-form>
<el-alert title="关联只补充真实订舱关系,不会重建柜次号、装柜计划或已排货明细。" type="info" :closable="false" />
<el-form label-position="top"><el-form-item label="柜次号"><el-input :model-value="linkTarget?.cabinetNo || ''" disabled /></el-form-item><el-form-item label="可关联的真实订舱柜" required><el-select v-model="linkCandidateKey" filterable :loading="linkLoading" placeholder="请选择同仓、同渠道、同规格的已确认订舱柜" no-data-text="暂无可关联的真实订舱柜"><el-option v-for="candidate in linkCandidates" :key="candidate.key" :label="candidate.label" :value="candidate.key" /></el-select></el-form-item></el-form>
<p class="dialog-hint">已预排货但尚未进入 PDA、取证或单证阶段的模拟柜仍可首次关联;最终状态门禁以后端为准。</p>
<LoadErrorState v-if="linkError" compact title="关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="unlinkOpen" title="解除真实订舱关联" width="min(520px, calc(100vw - 28px))" confirm-text="确认解除" cancel-text="取消" :confirm-loading="linkSaving" :confirm-disabled="!unlinkReason.trim()" @confirm="saveUnlink">
<el-alert title="只有尚未排货的空柜可以解除;已有排货明细、PDA 或单证事实时服务端会拒绝。内部柜次不会被删除。" type="warning" :closable="false" />
<el-alert title="只有尚未排货的空柜可以解除;已有排货明细、PDA 或单证事实时服务端会拒绝。柜次号不会被删除。" type="warning" :closable="false" />
<el-form label-position="top"><el-form-item label="解除原因" required><el-input v-model="unlinkReason" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form>
<LoadErrorState v-if="linkError" compact title="解除关联失败" :message="linkError.message" :show-retry="false"><template #details><ApiErrorMeta :details="linkError" /></template></LoadErrorState>
</AppDialog>
@@ -656,7 +656,7 @@ onMounted(() => { void Promise.all([loadCustomers(), loadWarehouses(), loadBusin
<el-alert v-if="businessOptionsError && !transportOnly" type="warning" :closable="false" show-icon :title="businessOptionsError"><el-button link type="warning" :loading="businessOptionsLoading" @click="loadBusinessOptions">重新加载业务选项</el-button></el-alert>
<el-form v-if="!warehouseError || transportOnly" ref="formRef" :model="form" :rules="rules" label-position="top" class="form-grid">
<el-form-item label="集装箱号"><el-input v-model="form.containerNo" maxlength="64" placeholder="请输入集装箱号,开始装柜前必填" /></el-form-item>
<el-form-item label="柜号" prop="cabinetNo"><el-input v-model="form.cabinetNo" maxlength="64" placeholder="请输入柜号" :disabled="transportOnly" /></el-form-item>
<el-form-item label="柜次号" prop="cabinetNo"><el-input v-model="form.cabinetNo" maxlength="64" placeholder="请输入柜次号" :disabled="transportOnly" /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select v-model="form.warehouseId" filterable placeholder="请选择装柜仓库" :loading="warehouseLoading" loading-text="仓库加载中" no-data-text="暂无可用国内仓库" :disabled="transportOnly"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select></el-form-item>
<el-form-item label="提单号"><el-input v-model="form.billOfLadingNo" maxlength="128" placeholder="请输入提单号(可选)" :disabled="transportOnly" /></el-form-item>
<el-form-item label="船名"><el-input v-model="form.vesselName" maxlength="128" placeholder="请输入船名" :disabled="transportOnly" /></el-form-item>
@@ -115,12 +115,14 @@ describe('GlobalSmartLoadingView', () => {
const option = wrapper.find('.container-option')
expect(option.text()).toContain('模拟柜次')
expect(option.text()).toContain('模拟柜次:真实柜号待录入')
expect(option.text()).toContain('真实柜号:待录入')
expect(option.text()).toContain('柜次号:待生成')
expect(option.text()).toContain('集装箱号:待录入')
expect(option.text()).toContain('开始装柜前录入集装箱号')
expect(option.text()).toContain('渠道:美森正班海卡包税')
expect(option.text()).toContain('仓库:义乌国内仓')
expect(option.text()).toContain('柜型:40HQ')
expect(option.text()).not.toContain('2088475410134773762')
expect(wrapper.text()).not.toContain('真实柜号')
})
it('groups containers by channel and reloads candidates when the channel changes', async () => {
@@ -368,5 +370,7 @@ describe('GlobalSmartLoadingView', () => {
expect(source).toContain('class="capacity-grid"')
expect(source).toContain('智能推荐')
expect(source).toContain('推送预装')
expect(source).toContain('推送预装仅生成预装清单,不会直接开放 PDA')
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && selectedContainer?.containerNo")
})
})
@@ -138,21 +138,18 @@ function adjustmentMax(source: object) {
const remaining = candidates.value.find((candidate) => candidate.inboundDetailId === item.inboundDetailId)?.availablePackageCount || 0
return Math.max(1, item.packageCount + remaining)
}
function displayContainerNo(container: ContainerBookingResource | null | undefined) {
if (!container) return '柜号待生成'
const actual = String(container.containerNo || '').trim()
const internalReference = /^(?:MAN|SPLIT)-/i.test(actual)
if (actual && !internalReference) return actual
function displayCabinetNo(container: ContainerBookingResource | null | undefined) {
if (!container) return '待生成'
const cabinetReference = String(container.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetReference) ? '模拟柜次' : cabinetReference || '柜号待生成'
return /^\d{15,}$/.test(cabinetReference) ? '待生成' : cabinetReference || '待生成'
}
function hasRealContainerNo(container: ContainerBookingResource | null | undefined) {
if (!container) return false
const value = String(container.containerNo || '').trim()
return Boolean(value) && !/^(?:MAN|SPLIT)-/i.test(value)
}
function hasOpaqueCabinetReference(container: ContainerBookingResource | null | undefined) {
return Boolean(container && /^\d{15,}$/.test(String(container.cabinetNo || '').trim()))
function displayActualContainerNo(container: ContainerBookingResource | null | undefined) {
return hasRealContainerNo(container) ? String(container?.containerNo || '').trim() : '待录入'
}
function syncDrafts(next: SmartLoadingPlan, preserved: Record<string, number> = {}) {
for (const key of Object.keys(quantityDrafts)) delete quantityDrafts[key]
@@ -346,13 +343,13 @@ async function removeAll() {
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后当前方案进入装柜流程,不能继续调整。确认推送预装清单?', '推送预装清单', {
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', {
type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消',
}).catch(() => false)
if (!confirmed || !plan.value) return
await mutate(async () => {
acceptPlan(await pushSmartLoadingPreload(plan.value!.id, plan.value!.version))
ElMessage.success('已推送预装清单')
ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认')
}, '推送预装清单失败')
}
@@ -456,24 +453,23 @@ onMounted(() => { void load(); void loadCustomers() })
@click="selectContainer(item.id)"
>
<span class="container-option__top">
<strong>{{ hasRealContainerNo(item) ? displayContainerNo(item) : '模拟柜次' }}</strong>
<strong>{{ hasRealContainerNo(item) ? displayActualContainerNo(item) : '模拟柜次' }}</strong>
<el-tag size="small" :type="hasRealContainerNo(item) ? 'success' : 'info'">
{{ hasRealContainerNo(item) ? '已录入柜号' : '空柜' }}
{{ hasRealContainerNo(item) ? '已录入集装箱号' : '空柜' }}
</el-tag>
</span>
<span class="container-option__identity">
{{ hasRealContainerNo(item) ? `柜号:${displayContainerNo(item)}` : (hasOpaqueCabinetReference(item) ? '模拟柜次:真实柜号待录入' : `柜次参考:${displayContainerNo(item)}`) }}
</span>
<span class="container-option__identity">柜次号:{{ displayCabinetNo(item) }}</span>
<span>集装箱号:{{ displayActualContainerNo(item) }}</span>
<span>渠道:{{ item.channelName || item.channelCode || '渠道待补充' }}</span>
<span>仓库:{{ item.warehouseName || item.warehouseId || '仓库待补充' }} · 柜型:{{ item.containerType || '柜型待补充' }}</span>
<span v-if="!hasRealContainerNo(item)" class="container-option__hint">真实柜号:待录入</span>
<span v-if="!hasRealContainerNo(item)" class="container-option__hint">可先排货,开始装柜前录入集装箱号</span>
</button>
</div>
</aside>
<section class="dispatch-pane plan-pane">
<header class="pane-header plan-header">
<div><strong>{{ displayContainerNo(selectedContainer) }} 装柜方案</strong><small>{{ plan ? '当前柜号的装柜明细' : '尚未创建方案' }}</small></div>
<div><strong>{{ hasRealContainerNo(selectedContainer) ? displayActualContainerNo(selectedContainer) : displayCabinetNo(selectedContainer) }} 装柜方案</strong><small>{{ selectedContainer ? `柜次号:${displayCabinetNo(selectedContainer)} · 集装箱号:${displayActualContainerNo(selectedContainer)}` : '尚未选择柜次' }}</small></div>
<div class="plan-actions">
<el-tooltip content="刷新当前柜次" placement="top"><el-button aria-label="刷新当前柜次" :loading="selectionLoading" @click="loadSelectedData()"><RefreshCw :size="16" /></el-button></el-tooltip>
<el-button v-if="canAssign && editable" type="primary" plain :loading="mutating" @click="recommend"><Sparkles :size="16" />智能推荐</el-button>
@@ -496,6 +492,14 @@ onMounted(() => { void load(); void loadCustomers() })
<el-button v-if="canCreate" type="primary" :loading="mutating" @click="createPlan"><Plus :size="16" />创建装柜方案</el-button>
</el-empty>
<template v-else-if="plan">
<el-alert
class="preload-readiness-alert"
type="info"
:closable="false"
show-icon
title="推送预装仅生成预装清单,不会直接开放 PDA"
description="后续依次完成:补集装箱号、关联并放舱真实订舱、确认预装;全部满足后才生成 PDA 任务。"
/>
<div class="capacity-grid">
<div><div class="meter-title"><span>体积</span><strong>{{ formatNumber(totalVolume) }} / {{ formatNumber(volumeLimit) }} m³</strong></div><el-progress :percentage="Math.min(displayedVolumePercent, 100)" :status="volumePercent > 100 ? 'exception' : undefined" /></div>
<div><div class="meter-title"><span>重量</span><strong>{{ formatNumber(totalWeight, 1) }} / {{ formatNumber(weightLimit, 1) }} kg</strong></div><el-progress :percentage="Math.min(displayedWeightPercent, 100)" :status="weightPercent > 100 ? 'exception' : undefined" /></div>
@@ -561,6 +565,7 @@ onMounted(() => { void load(); void loadCustomers() })
.plan-actions,.row-actions { flex-wrap: wrap; }
.plan-actions :deep(.el-button + .el-button),.row-actions :deep(.el-button + .el-button) { margin-left: 0; }
.capacity-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; padding: 12px; border-bottom: 1px solid var(--cc-border); }
.preload-readiness-alert { border-radius: 0; border-right: 0; border-left: 0; }
.readonly-status-card { display: grid; align-content: start; justify-items: start; gap: 10px; padding: 28px 24px; }
.readonly-status-card strong { font-size: 16px; }
.readonly-status-card p { max-width: 460px; margin: 0; color: var(--cc-muted); line-height: 1.6; }
@@ -60,7 +60,7 @@ describe('SmartLoadingWorkbenchView', () => {
expect(getSmartLoadingPlanByBooking).not.toHaveBeenCalled()
expect(listSmartLoadingCandidates).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('该柜号已进入拣货流程')
expect(wrapper.text()).toContain('该柜次已进入拣货流程')
expect(wrapper.text()).toContain('查看预装清单')
expect(wrapper.text()).not.toContain('智能推荐')
expect(wrapper.text()).not.toContain('ORD-1')
@@ -92,9 +92,13 @@ describe('SmartLoadingWorkbenchView', () => {
expect(source).toContain('size="small" type="danger" plain')
expect(source).toContain("canPush && plan?.status === 'EDITING'")
expect(source).not.toContain("canPush && plan?.status === 'EDITING' && container?.containerNo")
expect(source).toContain('推送后生成预装清单,不会直接开放 PDA')
expect(source).not.toContain('link type="primary" :disabled="!routeMatches(row)')
expect(source).not.toContain("label: '目的仓'")
expect(source).not.toContain('plan.planNo')
expect(source.match(/<DataTable[^>]*\bstripe\b/g)).toHaveLength(2)
expect(source).toContain('<span>柜次号</span>')
expect(source).toContain('<span>集装箱号</span>')
expect(source).not.toContain('当前柜号')
})
})
@@ -82,11 +82,14 @@ function explainCandidate(source: object) {
const weightRoom = weightLimit.value ? Math.floor(Math.max(weightLimit.value - totalWeight.value, 0) / Math.max(weight, 0.000001)) : candidate.availablePackageCount
return `按 110% 上限,当前最多可装 ${Math.max(Math.min(candidate.availablePackageCount, volumeRoom, weightRoom), 0)} 箱`
}
function displayCabinetNo(value: ContainerBookingResource | null | undefined) {
if (!value) return '待生成'
const cabinetNo = String(value.cabinetNo || '').trim()
return /^\d{15,}$/.test(cabinetNo) ? '待生成' : cabinetNo || '待生成'
}
function displayContainerNo(value: ContainerBookingResource | null | undefined) {
if (!value) return '柜号待生成'
const actual = String(value.containerNo || '').trim()
const internalReference = /^(?:MAN|SPLIT)-/i.test(actual)
return actual && !internalReference ? actual : String(value.cabinetNo || '').trim() || '柜号待生成'
const containerNo = String(value?.containerNo || '').trim()
return containerNo && !/^(?:MAN|SPLIT)-/i.test(containerNo) ? containerNo : '待录入'
}
function syncDrafts() { for (const item of plan.value?.items || []) quantityDrafts[item.id] = item.packageCount }
function acceptPlan(next: SmartLoadingPlan) { plan.value = next; noDraft.value = false; syncDrafts() }
@@ -168,11 +171,11 @@ async function remove(source: object) {
}
async function pushPreload() {
if (!plan.value) return
const confirmed = await ElMessageBox.confirm('推送后计划进入装柜流程,不能继续调整排柜明细。确认推送预装清单?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
const confirmed = await ElMessageBox.confirm('推送后生成预装清单,不会直接开放 PDA。后续还需补集装箱号、关联并放舱真实订舱、确认预装。确认继续?', '推送预装清单', { type: 'warning', confirmButtonText: '确认推送', cancelButtonText: '取消' }).catch(() => false)
if (!confirmed || !plan.value) return
mutating.value = true
mutationError.value = null
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('已推送预装清单') }
try { acceptPlan(await pushSmartLoadingPreload(plan.value.id, plan.value.version)); ElMessage.success('预装清单已生成,请继续完成集装箱号、订舱放舱和预装确认') }
catch (error) { await handleMutationError(error, '推送失败') }
finally { mutating.value = false }
}
@@ -199,10 +202,16 @@ onMounted(load)
<AdminPage>
<section class="business-page workbench">
<section class="surface-panel workbench-command-bar" aria-label="排柜作业摘要">
<div class="workbench-identities">
<div class="workbench-identity">
<span>当前柜号</span>
<span>柜次号</span>
<strong>{{ loading ? '正在加载' : displayCabinetNo(container) }}</strong>
</div>
<div class="workbench-identity">
<span>集装箱号</span>
<strong>{{ loading ? '正在加载' : displayContainerNo(container) }}</strong>
</div>
</div>
<div class="header-actions">
<el-button text aria-label="返回货柜列表" @click="router.back()"><ArrowLeft :size="18" />返回</el-button>
<el-tooltip content="刷新" placement="top"><el-button :loading="loading" aria-label="刷新智能排柜" @click="load"><RefreshCw :size="16" /></el-button></el-tooltip>
@@ -220,7 +229,7 @@ onMounted(load)
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看装柜清单</el-button>
</section>
<section v-else-if="container?.planStatus === 'LOADING'" class="readonly-status-card" aria-label="已进入拣货流程">
<strong>该柜号已进入拣货流程</strong>
<strong>该柜次已进入拣货流程</strong>
<p>智能装柜仅处理待装柜和预装编辑中的空柜,请在预装清单继续查看。</p>
<el-button v-if="canViewLoadingList" type="primary" @click="openOperationalList"><ListChecks :size="16" />查看预装清单</el-button>
</section>
@@ -255,6 +264,7 @@ onMounted(load)
.workbench { display: grid; gap: 16px; min-width: 0; }
.workbench-command-bar,.header-actions,.row-actions,.meter-title { display: flex; align-items: center; }
.workbench-command-bar { justify-content: space-between; gap: 16px; padding: 14px 16px; }
.workbench-identities { display: flex; min-width: 0; align-items: center; gap: 32px; }
.workbench-identity { display: grid; min-width: 0; gap: 3px; }
.workbench-identity span { color: var(--cc-muted); font-size: 11px; }
.workbench-identity strong { overflow: hidden; font-size: 15px; text-overflow: ellipsis; white-space: nowrap; }
@@ -274,5 +284,5 @@ onMounted(load)
.workbench :deep(.el-input-number) { width: 130px; }
.assign-dialog p { margin: 0 0 10px; }
.assign-dialog :deep(.el-input-number) { width: 100%; }
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
@media (max-width: 760px) { .workbench-command-bar,.header-actions { width: 100%; flex-direction: column; align-items: stretch; } .workbench-identities { flex-direction: column; align-items: stretch; gap: 10px; } .summary-band,.capacity-grid { grid-template-columns: 1fr; } .meter-title { align-items: flex-start; flex-direction: column; } .header-actions .el-button { min-height: 44px; margin-left: 0; } }
</style>
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import {
getConfirmedLoadingList,
confirmPreloadPlan,
uploadLoadingDeclaration,
completeLoadingDeclaration,
getPreloadPlan,
@@ -15,7 +16,7 @@ const preloadSummary = {
containerBookingId: 1900000000000000004n as unknown as string,
preloadNo: 'PL-001',
containerNo: 'MSCU1234567',
internalContainerNo: 'YG-001',
cabinetNo: 'YG-001',
customers: [{ accountId: 1900000000000000002n as unknown as string, customerName: '测试客户' }],
warehouseId: 1900000000000000003n as unknown as string,
warehouseName: '义乌仓',
@@ -29,6 +30,20 @@ const preloadSummary = {
totalGrossWeightKg: 1200,
createdAt: '2026-08-10T10:00:00',
version: 3,
readiness: {
preloadPushed: true,
containerNoReady: true,
preloadLinkCount: 1,
confirmedPreloadCount: 0,
oceanBookingId: 1900000000000000005n as unknown as string,
oceanBookingStatus: 'RELEASED',
pdaTaskId: null,
pdaTaskStatus: null,
confirmationStatus: 'PENDING',
canConfirm: true,
readyForPda: false,
blockingReasons: [],
},
}
const detail = {
@@ -54,6 +69,7 @@ describe('platform loading lists API', () => {
expect(result.total).toBe(1)
expect(result.records[0]?.planId).toBe('1900000000000000001')
expect(result.records[0]?.containerBookingId).toBe('1900000000000000004')
expect(result.records[0]?.readiness.oceanBookingId).toBe('1900000000000000005')
expect(result.records[0]?.customers[0]?.accountId).toBe('1900000000000000002')
})
@@ -92,6 +108,42 @@ describe('platform loading lists API', () => {
expect(loaded.summary.status).toBe('COMPLETED')
})
it('confirms the whole preload plan with expectedVersion and the caller operation id', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({
code: '200', message: 'ok', data: {
planId: 1900000000000000001n,
containerBookingId: 1900000000000000004n,
confirmationStatus: 'CONFIRMED',
version: 4,
readiness: {
...preloadSummary.readiness,
confirmedPreloadCount: 1,
confirmationStatus: 'CONFIRMED',
canConfirm: false,
readyForPda: true,
pdaTaskId: 1900000000000000006n,
pdaTaskStatus: 'PENDING',
blockingReasons: ['PRELOAD_ALREADY_CONFIRMED'],
},
},
})
const result = await confirmPreloadPlan('1900000000000000001', 3, 'confirm-operation-1')
expect(post).toHaveBeenCalledWith(
'/platform/v1/first-leg/loading-plans/1900000000000000001/confirm',
{ expectedVersion: 3 },
{ headers: { 'Idempotency-Key': 'confirm-operation-1' } },
)
expect(result).toMatchObject({
planId: '1900000000000000001',
containerBookingId: '1900000000000000004',
confirmationStatus: 'CONFIRMED',
version: 4,
readiness: { pdaTaskId: '1900000000000000006', readyForPda: true },
})
})
it('uses stable plan and warehouse ids for customs document actions', async () => {
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'ok', data: { id: 6, documentType: 'CUSTOMS_DECLARATION', fileId: 9, fileName: 'declaration.pdf', version: 2 } })
const file = new File(['declaration'], 'declaration.pdf', { type: 'application/pdf' })
@@ -7,12 +7,38 @@ export interface LoadingCustomer {
customerName: string | null
}
export type PreloadConfirmationStatus = 'PENDING' | 'PARTIALLY_CONFIRMED' | 'CONFIRMED'
export type PreloadBlockingReason =
| 'PRELOAD_NOT_PUSHED'
| 'LOADING_PLAN_NOT_EDITING'
| 'CONTAINER_NO_MISSING'
| 'PRELOAD_LINK_MISSING'
| 'OCEAN_BOOKING_NOT_LINKED'
| 'OCEAN_BOOKING_NOT_RELEASED'
| 'PDA_TASK_ALREADY_CREATED'
| 'PRELOAD_ALREADY_CONFIRMED'
export interface PreloadReadiness {
preloadPushed: boolean
containerNoReady: boolean
preloadLinkCount: number
confirmedPreloadCount: number
oceanBookingId: string | null
oceanBookingStatus: 'CONFIRMED' | 'CHANGE_PENDING' | 'RELEASED' | 'CANCELLED' | null
pdaTaskId: string | null
pdaTaskStatus: string | null
confirmationStatus: PreloadConfirmationStatus
canConfirm: boolean
readyForPda: boolean
blockingReasons: PreloadBlockingReason[]
}
export interface PreloadSummary {
planId: string
containerBookingId: string
preloadNo: string
containerNo: string | null
internalContainerNo: string | null
cabinetNo: string | null
customers: LoadingCustomer[]
warehouseId: string | null
warehouseName: string | null
@@ -26,6 +52,7 @@ export interface PreloadSummary {
totalGrossWeightKg: number
createdAt: string
version: number
readiness: PreloadReadiness
}
export interface ConfirmedSummary {
@@ -35,7 +62,7 @@ export interface ConfirmedSummary {
warehouseId: string | null
warehouseName: string | null
containerNo: string | null
internalContainerNo: string | null
cabinetNo: string | null
status: string
totalPackageCount: string
totalGrossWeightKg: number
@@ -142,6 +169,14 @@ export interface TransportUpdateRequest {
operationReason: string | null
}
export interface PreloadConfirmationResponse {
planId: string
containerBookingId: string
confirmationStatus: 'CONFIRMED'
version: number
readiness: PreloadReadiness
}
type RawId = string | number | null | undefined
type RawPage<T extends LoadingListSummary> = Omit<LoadingListPage<T>, 'page' | 'size' | 'total' | 'pages'> & {
page: string | number
@@ -160,6 +195,15 @@ function optionalId(value: RawId) {
return value === null || value === undefined ? null : String(value)
}
function normalizePreloadReadiness(raw: PreloadReadiness): PreloadReadiness {
return {
...raw,
oceanBookingId: optionalId(raw.oceanBookingId),
pdaTaskId: optionalId(raw.pdaTaskId),
blockingReasons: raw.blockingReasons || [],
}
}
function normalizePreloadSummary(raw: PreloadSummary): PreloadSummary {
return {
...raw,
@@ -168,6 +212,7 @@ function normalizePreloadSummary(raw: PreloadSummary): PreloadSummary {
warehouseId: optionalId(raw.warehouseId),
skuCount: String(raw.skuCount ?? '0'),
totalPackageCount: String(raw.totalPackageCount ?? '0'),
readiness: normalizePreloadReadiness(raw.readiness),
customers: (raw.customers || []).map((customer) => ({
...customer,
accountId: requiredId(customer.accountId),
@@ -206,6 +251,7 @@ function normalizeHistory(raw: LoadingHistory): LoadingHistory {
operatorId: requiredId(raw.operatorId),
}
}
function normalizeDocument(raw: BusinessDocument | null | undefined): BusinessDocument | null {
if (!raw) return null
return { ...raw, id: raw.id == null ? null : requiredId(raw.id), fileId: requiredId(raw.fileId), uploadedBy: optionalId(raw.uploadedBy), completedBy: optionalId(raw.completedBy), version: Number(raw.version ?? 0) }
@@ -262,6 +308,21 @@ export async function updatePreloadTransport(planId: string, warehouseId: string
}
}
export async function confirmPreloadPlan(planId: string, expectedVersion: number, operationId: string) {
const response = await http.post<ApiEnvelope<PreloadConfirmationResponse>, ApiEnvelope<PreloadConfirmationResponse>>(
`${basePath}/loading-plans/${encodeURIComponent(planId)}/confirm`,
{ expectedVersion },
{ headers: { 'Idempotency-Key': operationId } },
)
const result = unwrapApiResponse(response)
return {
...result,
planId: requiredId(result.planId),
containerBookingId: requiredId(result.containerBookingId),
readiness: normalizePreloadReadiness(result.readiness),
}
}
export async function getLoadingDeclaration(planId: string, warehouseId: string) {
const response = await http.get<ApiEnvelope<BusinessDocument | null>, ApiEnvelope<BusinessDocument | null>>(`${basePath}/loading-lists/${encodeURIComponent(planId)}/customs`, { params: { warehouseId } })
return normalizeDocument(unwrapApiResponse(response))
@@ -17,11 +17,12 @@ const props = defineProps<{
detail: LoadingDetail | null
error: ApiErrorDetails | null
canEdit: boolean
confirmSaving?: boolean
canManageCustoms?: boolean
customsSaving?: boolean
}>()
const emit = defineEmits<{ retry: []; edit: []; attachCustoms: []; completeCustoms: [] }>()
const emit = defineEmits<{ retry: []; edit: []; confirmPreload: []; attachCustoms: []; completeCustoms: [] }>()
const itemColumns: DataTableColumn[] = [
{ key: 'sequence', prop: 'loadSequence', label: '装柜顺序', fixed: 'left', width: 92, hideable: false, movable: false },
@@ -45,6 +46,28 @@ const itemColumns: DataTableColumn[] = [
const summary = computed<LoadingListSummary | null>(() => props.detail?.summary || null)
const preloadSummary = computed(() => props.mode === 'preload' ? summary.value as PreloadSummary | null : null)
const blockingReasonLabels: Record<string, string> = {
PRELOAD_NOT_PUSHED: '尚未推送预装清单',
LOADING_PLAN_NOT_EDITING: '装柜计划已不在可确认的编辑状态',
CONTAINER_NO_MISSING: '尚未录入集装箱号',
PRELOAD_LINK_MISSING: '装柜明细尚未完整关联入库预装票据',
OCEAN_BOOKING_NOT_LINKED: '尚未关联真实订舱',
OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱',
PDA_TASK_ALREADY_CREATED: 'PDA 任务已创建,无需重复确认',
PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认',
}
const confirmationStatusLabels: Record<string, string> = {
PENDING: '待确认', PARTIALLY_CONFIRMED: '部分票据已确认', CONFIRMED: '已确认',
}
const oceanBookingStatusLabels: Record<string, string> = {
CONFIRMED: '已确认', CHANGE_PENDING: '改船处理中', RELEASED: '已放舱', CANCELLED: '已取消',
}
const pdaTaskStatusLabels: Record<string, string> = {
PENDING: '待领取', CLAIMED: '已领取', LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成',
}
const blockingReasonText = computed(() => (preloadSummary.value?.readiness.blockingReasons || [])
.map((reason) => blockingReasonLabels[reason] || '存在未满足的确认条件')
.join(';'))
const feeCustomers = computed(() => {
const customers = new Map<string, string | null>()
for (const item of props.detail?.items || []) if (item.accountId) customers.set(item.accountId, item.customerName)
@@ -62,6 +85,7 @@ function actionLabel(action: string) {
return ({
CREATE: '创建计划', ASSIGN: '分配货物', ADJUST: '调整货物', DELETE: '移除货物',
RECOMMEND: '智能排柜', PUSH_PRELOAD: '推送预装', UPDATE_TRANSPORT: '修改运输信息',
CONFIRM_PRELOAD: '确认预装',
START_LOADING: '开始拣货', COMPLETE: '完成装柜',
} as Record<string, string>)[action] || '状态更新'
}
@@ -86,13 +110,16 @@ function formatNumber(value?: number | null, digits = 3) {
<DetailPageLayout :ariaLabel="title">
<template #summary>
<section class="detail-summary" aria-label="清单基本信息">
<div><span>柜次号</span><strong>{{ summary.cabinetNo || '—' }}</strong></div>
<div><span>集装箱号</span><strong>{{ summary.containerNo || '待录入' }}</strong></div>
<div><span>仓库</span><strong>{{ summary.warehouseName || '—' }}</strong></div>
<div><span>集装箱号</span><strong>{{ summary.containerNo || '—' }}</strong></div>
<div><span>柜号</span><strong>{{ summary.internalContainerNo || '—' }}</strong></div>
<div><span>状态</span><StatusBadge :status="loadingListStatusLabel(summary.status)" /></div>
<template v-if="preloadSummary">
<div><span>船名 / 航次</span><strong>{{ preloadSummary.vesselName || '—' }} / {{ preloadSummary.voyageNo || '—' }}</strong></div>
<div><span>箱型</span><strong>{{ preloadSummary.containerType || '—' }}</strong></div>
<div><span>预装确认</span><StatusBadge :status="confirmationStatusLabels[preloadSummary.readiness.confirmationStatus] || '未知状态'" /></div>
<div><span>订舱状态</span><strong>{{ preloadSummary.readiness.oceanBookingStatus ? (oceanBookingStatusLabels[preloadSummary.readiness.oceanBookingStatus] || '未知状态') : '未关联' }}</strong></div>
<div><span>PDA 状态</span><strong>{{ preloadSummary.readiness.pdaTaskStatus ? (pdaTaskStatusLabels[preloadSummary.readiness.pdaTaskStatus] || '未知状态') : (preloadSummary.readiness.readyForPda ? '可下发' : '未就绪') }}</strong></div>
<div class="detail-summary__wide"><span>客户</span><strong>{{ preloadSummary.customers.map((item) => item.customerName || '客户信息缺失').join('、') || '客户信息缺失' }}</strong></div>
</template>
<div><span>创建时间</span><strong>{{ formatDateTime(summary.createdAt) }}</strong></div>
@@ -100,8 +127,11 @@ function formatNumber(value?: number | null, digits = 3) {
</template>
<div v-if="mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)" class="detail-actions">
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />编辑运输信息</el-button>
<el-button type="primary" @click="emit('edit')"><Pencil :size="16" />{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}</el-button>
<el-button type="success" :loading="confirmSaving" :disabled="!preloadSummary?.readiness.canConfirm" @click="emit('confirmPreload')"><Check :size="16" />确认预装</el-button>
</div>
<el-alert v-if="preloadSummary?.readiness.confirmationStatus === 'CONFIRMED'" type="success" :closable="false" show-icon title="整份预装计划已确认" :description="preloadSummary.readiness.pdaTaskStatus ? `PDA 任务状态:${pdaTaskStatusLabels[preloadSummary.readiness.pdaTaskStatus] || '未知状态'}` : 'PDA 前置条件已满足,等待服务端生成任务。'" />
<el-alert v-else-if="preloadSummary && !preloadSummary.readiness.canConfirm" type="warning" :closable="false" show-icon title="当前暂不能确认预装" :description="blockingReasonText || '请刷新详情获取最新确认条件。'" />
<div v-else-if="mode === 'confirmed'" class="detail-actions"><el-button v-if="canManageCustoms && !detail.customsDocument?.completedAt" @click="emit('attachCustoms')"><FileUp :size="16" />{{ detail.customsDocument ? '更换报关资料' : '上传报关资料' }}</el-button><el-button v-if="canManageCustoms && detail.customsDocument && !detail.customsDocument.completedAt" type="primary" :loading="customsSaving" @click="emit('completeCustoms')"><Check :size="16" />完成报关</el-button><StatusBadge v-if="detail.customsDocument?.completedAt" status="报关完成" /></div>
<section class="detail-section surface-panel">
@@ -27,22 +27,36 @@ describe('LoadingListsView', () => {
expect(source).not.toContain("key: 'warehouseId'")
expect(source).not.toContain('loadingWarehouses')
expect(source).not.toContain('warehouseId: warehouseId || undefined')
expect(source).toContain("selectedWarehouseId.value = String(row.warehouseId || '')")
expect(source).toContain('query: { warehouseId: String(row.warehouseId || \'\') }')
expect(source).not.toContain("ElMessage.warning('请先选择仓库')")
expect(source).not.toContain("'请先选择仓库后查询'")
expect(source).toContain('MISSING_WAREHOUSE_CONTEXT')
expect(preloadDetailSource).toContain('MISSING_WAREHOUSE_CONTEXT')
expect(confirmedDetailSource).toContain('MISSING_WAREHOUSE_CONTEXT')
})
it('uses the shared query, table, dialog and error components without a detail drawer', () => {
it('uses the shared query and table components while keeping details on route pages', () => {
expect(source).toContain('QueryToolbar')
expect(source).toContain('DataTable')
expect(source).toContain('AppDialog')
expect(source).toContain('LoadErrorState')
expect(source).toContain('ApiErrorMeta')
expect(preloadDetailSource).toContain('AppDialog')
expect(confirmedDetailSource).toContain('AppDialog')
expect(detailSource).not.toContain('AppDrawer')
expect(detailSource).toContain('ContainerFeesPanel')
})
it('distinguishes the generated cabinet reference from the physical container number', () => {
expect(source).toContain("label: '柜次号'")
expect(source).toContain("label: '集装箱号'")
expect(source).toContain('装柜清单号/柜次号/集装箱号')
expect(detailSource).toContain('<span>柜次号</span>')
expect(detailSource).toContain('<span>集装箱号</span>')
expect(source).not.toContain("label: '柜号'")
expect(detailSource).not.toContain('<span>柜号</span>')
expect(source).toContain("key: 'cabinetNo', prop: 'cabinetNo'")
expect(source).not.toContain('internalContainerNo')
})
it('opens both preload and confirmed details as independent route pages', () => {
expect(source).toContain("name: 'preload-plan-detail'")
expect(source).toContain("name: 'loading-list-detail'")
@@ -55,20 +69,19 @@ describe('LoadingListsView', () => {
expect(routesSource).toContain("name: 'loading-list-detail'")
})
it('keeps the real transport edit contract in a 620px centered modal', () => {
expect(source).toContain('class="loading-transport-dialog"')
expect(source).toContain('width="min(620px, calc(100vw - 28px))"')
it('keeps the real transport edit contract in a 620px centered modal on the detail page', () => {
expect(source).not.toContain('class="loading-transport-dialog"')
expect(preloadDetailSource).toContain('width="min(620px, calc(100vw - 28px))"')
expect(source).not.toContain('备注信息(仅自己可见)')
expect(source).not.toContain('备注信息(客户可见)')
expect(source).not.toContain('备注信息(仓库可见)')
expect(preloadDetailSource).not.toContain('备注信息(仅自己可见)')
expect(preloadDetailSource).not.toContain('备注信息(客户可见)')
expect(preloadDetailSource).not.toContain('备注信息(仓库可见)')
})
it('only exposes transport editing for LOADING preload details', () => {
expect(detailSource).toContain("mode === 'preload' && canEdit && ['EDITING', 'LOADING'].includes(summary.status)")
expect(detailSource).toContain('报关资料')
expect(detailSource).toContain('完成报关')
expect(source).toContain('expectedVersion: detail.value.summary.version')
expect(preloadDetailSource).toContain('expectedVersion: detail.value.summary.version')
})
it('renders business labels instead of raw loading-plan status codes', () => {
@@ -78,16 +91,17 @@ describe('LoadingListsView', () => {
expect(source).not.toContain('仅装柜中允许修改')
})
it('exposes a row edit action only for loading preload plans', () => {
expect(source).toContain("isPreload && canEdit && ['EDITING', 'LOADING'].includes(row.status)")
expect(source).toContain('openEditFromRow(row)')
expect(source).toContain('await loadDetail()')
it('keeps transport editing as a detail-page action', () => {
expect(source).not.toContain('openEditFromRow(row)')
expect(source).not.toContain('编辑运输信息')
expect(detailSource).toContain("{{ preloadSummary?.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号' }}")
expect(preloadDetailSource).toContain("const editDialogTitle = computed(() => detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号')")
})
it('refreshes the detail version on A0409 while preserving transport input', () => {
expect(source).toContain("details.code === 'A0409'")
expect(source).toContain('await loadDetail()')
expect(source).toContain('当前输入已保留,请核对后重新保存')
expect(preloadDetailSource).toContain("details.code === 'A0409'")
expect(preloadDetailSource).toContain('await loadDetail()')
expect(preloadDetailSource).toContain('当前输入已保留,请核对后重新保存')
})
it('opens a validated plan and warehouse deep link without mixing list modes', () => {
@@ -99,6 +113,16 @@ describe('LoadingListsView', () => {
expect(confirmedDetailSource).toContain('MODE_STATUS_MISMATCH')
})
it('uses backend readiness for whole-plan confirmation and translates blocking reasons', () => {
expect(detailSource).toContain(':disabled="!preloadSummary?.readiness.canConfirm"')
expect(detailSource).toContain("PRELOAD_NOT_PUSHED: '尚未推送预装清单'")
expect(detailSource).toContain("OCEAN_BOOKING_NOT_RELEASED: '关联订舱尚未放舱'")
expect(detailSource).toContain("PRELOAD_ALREADY_CONFIRMED: '整份预装计划已经确认'")
expect(detailSource).toContain("confirmationStatus === 'CONFIRMED'")
expect(detailSource).toContain("pdaTaskStatusLabels")
expect(preloadDetailSource).toContain('@confirm-preload="openConfirmPreload"')
})
it('passes translated status text directly to the badge', () => {
expect(source).toContain('<StatusBadge :status="loadingListStatusLabel(row.status)" />')
expect(source).not.toContain('<StatusBadge :status="row.status" />')
@@ -107,10 +131,8 @@ describe('LoadingListsView', () => {
it('uses bordered list actions with a stable 320px layout', () => {
expect(source).toContain('size="small" type="primary" plain')
expect(source).toContain('size="small" @click="openEditFromRow(row)"')
expect(source).toContain('编辑运输信息')
expect(source).not.toContain('<el-button v-if="canView" link')
expect(source).toContain("key: 'actions', label: '操作', fixed: 'left', width: 230")
expect(source).toContain("key: 'actions', label: '操作', fixed: 'left', width: 110")
expect(source).toContain('flex-wrap: wrap')
expect(source).toContain('@media (max-width: 720px)')
expect(source).toContain('.row-actions { gap: 4px; }')
@@ -119,6 +141,7 @@ describe('LoadingListsView', () => {
})
it('does not expose unknown history action codes', () => {
expect(detailSource).toContain("CONFIRM_PRELOAD: '确认预装'")
expect(detailSource).toContain("[action] || '状态更新'")
expect(detailSource).not.toContain('[action] || action')
})
@@ -1,14 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Eye, Pencil } from '@lucide/vue'
import { Eye } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -16,12 +14,9 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone
import StatusBadge from '@/shared/components/StatusBadge.vue'
import { loadingListStatusLabel } from '../status'
import {
getPreloadPlan,
listConfirmedLoadingLists,
listPreloadPlans,
updatePreloadTransport,
type ConfirmedSummary,
type LoadingDetail,
type LoadingListMode,
type LoadingListQuery,
type LoadingListSummary,
@@ -41,16 +36,6 @@ const total = ref(0)
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const query = reactive<QueryModel>({ customerId: '', keyword: '', createdRange: [] })
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const detail = ref<LoadingDetail | null>(null)
const selectedPlanId = ref('')
const selectedWarehouseId = ref('')
const editOpen = ref(false)
const editSaving = ref(false)
const editError = ref<ApiErrorDetails | null>(null)
const editFormRef = ref<FormInstance>()
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
const handledDeepLink = ref('')
const isPreload = computed(() => props.mode === 'preload')
@@ -58,18 +43,17 @@ const title = computed(() => isPreload.value ? '预装清单' : '装柜清单')
const listPermission = computed(() => isPreload.value ? 'platform:firstleg:preload:list' : 'platform:firstleg:loading-list:list')
const canList = computed(() => access.can(listPermission.value))
const canView = computed(() => canList.value)
const canEdit = computed(() => isPreload.value && access.can('platform:firstleg:preload:edit'))
const fields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户', placeholder: '全部客户', width: 220, options: customers.value.filter((customer) => customer.status === 'ACTIVE').map((customer) => ({ label: customer.tenantName, value: String(customer.id) })), props: { filterable: true, loading: customersLoading.value } },
{ key: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/集装箱号/柜号' : '装柜清单号/集装箱号/柜号', width: 250 },
{ key: 'keyword', type: 'input', label: '关键词', placeholder: isPreload.value ? '预装单号/柜次号/集装箱号' : '装柜清单号/柜次号/集装箱号', width: 270 },
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 260 },
])
const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
{ key: 'actions', label: '操作', fixed: 'left', width: 230, slotName: 'actions', hideable: false, movable: false },
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
{ key: 'preloadNo', prop: 'preloadNo', label: '预装单号', minWidth: 165 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
{ key: 'internalContainerNo', prop: 'internalContainerNo', label: '柜号', minWidth: 130 },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 },
{ key: 'customers', label: '客户', minWidth: 190, slotName: 'customers' },
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
{ key: 'vesselVoyage', label: '船名 / 航次', minWidth: 175, slotName: 'vesselVoyage' },
@@ -84,7 +68,7 @@ const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
{ key: 'actions', label: '操作', fixed: 'left', width: 110, slotName: 'actions', hideable: false, movable: false },
{ key: 'loadingListNo', prop: 'loadingListNo', label: '装柜清单号', minWidth: 175 },
{ key: 'containerNo', prop: 'containerNo', label: '集装箱号', minWidth: 145 },
{ key: 'internalContainerNo', prop: 'internalContainerNo', label: '柜号', minWidth: 130 },
{ key: 'cabinetNo', prop: 'cabinetNo', label: '柜次号', minWidth: 150 },
{ key: 'warehouseName', prop: 'warehouseName', label: '仓库', minWidth: 140 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'totalPackageCount', prop: 'totalPackageCount', label: '总箱数', width: 90, align: 'right' },
@@ -97,12 +81,6 @@ const columns = computed<DataTableColumn[]>(() => isPreload.value ? [
{ key: 'createdAt', label: '创建时间', minWidth: 155, slotName: 'createdAt' },
])
const editRules: FormRules = {
containerNo: [{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' }],
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }],
}
function formatDateTime(value?: string | null) {
return value ? value.replace('T', ' ').slice(0, 16) : '—'
}
@@ -165,27 +143,6 @@ function search() {
}
async function loadDetail() {
if (!canView.value) return
if (!selectedPlanId.value || !selectedWarehouseId.value) {
detail.value = null
detailError.value = {
message: '该清单缺少所属仓库信息,无法安全查询详情,请联系管理员核对数据。',
code: 'MISSING_WAREHOUSE_CONTEXT',
}
return
}
detailLoading.value = true
detailError.value = null
try {
detail.value = await getPreloadPlan(selectedPlanId.value, selectedWarehouseId.value)
} catch (cause) {
detailError.value = getApiErrorDetails(cause, '清单详情加载失败,请稍后重试')
} finally {
detailLoading.value = false
}
}
function openDetail(rowValue: object) {
const row = rowValue as LoadingListSummary
if (isPreload.value) {
@@ -195,59 +152,6 @@ function openDetail(rowValue: object) {
void router.push({ name: 'loading-list-detail', params: { planId: String(row.planId) }, query: { warehouseId: String(row.warehouseId || '') } })
}
async function openEditFromRow(rowValue: object) {
const row = rowValue as LoadingListSummary
if (!isPreload.value || !canEdit.value || !['EDITING', 'LOADING'].includes(row.status)) return
selectedPlanId.value = String(row.planId)
selectedWarehouseId.value = String(row.warehouseId || '')
detail.value = null
detailError.value = null
await loadDetail()
const loadedDetail = detail.value as LoadingDetail | null
if (loadedDetail && ['EDITING', 'LOADING'].includes(loadedDetail.summary.status)) openEdit()
}
function openEdit() {
if (!detail.value || !('preloadNo' in detail.value.summary) || !['EDITING', 'LOADING'].includes(detail.value.summary.status)) return
editForm.containerNo = detail.value.summary.containerNo || ''
editForm.voyageNo = detail.value.summary.voyageNo || ''
editForm.operationReason = ''
editError.value = null
editOpen.value = true
}
async function saveTransport() {
if (!detail.value || !('preloadNo' in detail.value.summary)) return
const valid = await editFormRef.value?.validate().catch(() => false)
if (!valid) return
editSaving.value = true
editError.value = null
try {
detail.value = await updatePreloadTransport(selectedPlanId.value, selectedWarehouseId.value, {
containerNo: editForm.containerNo.trim() || null,
voyageNo: editForm.voyageNo.trim() || null,
operationReason: editForm.operationReason.trim() || null,
expectedVersion: detail.value.summary.version,
})
editOpen.value = false
ElMessage.success('运输信息已更新')
await load()
} catch (cause) {
const details = getApiErrorDetails(cause, '运输信息保存失败,请刷新详情后重试')
if (details.code === 'A0409' || /版本|并发|已被其他人修改/.test(details.message)) {
await loadDetail()
editError.value = {
...details,
message: '运输信息已被其他人修改,系统已刷新最新版本;当前输入已保留,请核对后重新保存。',
}
} else {
editError.value = details
}
} finally {
editSaving.value = false
}
}
async function loadCustomers() {
customersLoading.value = true
try {
@@ -305,7 +209,6 @@ onMounted(() => {
<template #actions="{ row }">
<div class="row-actions">
<el-button v-if="canView" size="small" type="primary" plain @click="openDetail(row)"><Eye :size="15" />查看</el-button>
<el-button v-if="isPreload && canEdit && ['EDITING', 'LOADING'].includes(row.status)" size="small" @click="openEditFromRow(row)"><Pencil :size="15" />编辑运输信息</el-button>
</div>
</template>
<template #customers="{ row }">{{ (row as PreloadSummary).customers.map((item) => item.customerName || '客户信息缺失').join('、') || '—' }}</template>
@@ -319,26 +222,6 @@ onMounted(() => {
</section>
</ListPageLayout>
<AppDialog
v-model="editOpen"
class="loading-transport-dialog"
title="编辑预装运输信息"
width="min(620px, calc(100vw - 28px))"
confirm-text="保存"
:confirm-loading="editSaving"
:confirm-disabled="!canEdit"
@confirm="saveTransport"
>
<el-alert type="info" :closable="false" show-icon title="仅拣货中允许修改;保存时按详情版本执行乐观锁校验。" />
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
<el-form-item label="集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" show-word-limit placeholder="请输入集装箱号" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" show-word-limit placeholder="请输入航次" /></el-form-item>
<el-form-item label="修改原因" prop="operationReason"><el-input v-model="editForm.operationReason" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填,填写后将记录在操作历史中" /></el-form-item>
</el-form>
<LoadErrorState v-if="editError" compact title="保存失败" :message="editError.message" :show-retry="false">
<template #details><ApiErrorMeta :details="editError" /></template>
</LoadErrorState>
</AppDialog>
</div>
</AdminPage>
</template>
@@ -346,10 +229,7 @@ onMounted(() => {
<style scoped>
.loading-lists-page { display: grid; min-width: 0; gap: 16px; }
.table-panel { min-width: 0; overflow: hidden; border: 1px solid var(--cc-border); background: #fff; }
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; margin-top: 16px; }
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.row-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.row-actions :deep(.el-button) { margin-left: 0; }
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } .row-actions { gap: 4px; } }
@media (max-width: 720px) { .row-actions { gap: 4px; } }
</style>
@@ -25,4 +25,30 @@ describe('PreloadPlanDetailView prototype contract', () => {
expect(source).toContain('width="min(620px, calc(100vw - 28px))"')
expect(source).not.toContain('saveMfNote')
})
it('is the formal container-number entry and does not imply that preload directly opens PDA', () => {
expect(source).toContain("{ required: true, message: '请输入集装箱号'")
expect(source).toContain("detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号'")
expect(source).toContain('预装清单不会直接开放 PDA')
expect(source).toContain('订舱关联与放舱、预装确认后')
})
it('confirms the whole plan through readiness with stable idempotency and refreshes the detail', () => {
expect(source).toContain('confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)')
expect(source).toContain('summary?.readiness.canConfirm')
expect(source).toContain('confirmSaving.value')
expect(source).toContain('await loadDetail()')
expect(source).toContain('本操作将确认整份预装计划')
expect(source).toContain('全部入库预装票据')
expect(source).toContain('当前页面不会伪造 PDA 成功')
expect(source).not.toContain('/inbounds/')
})
it('keeps backend confirmation errors visible and supports narrow dialogs', () => {
expect(source).toContain("confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')")
expect(source).toContain(':message="confirmError.message"')
expect(source).toContain('width="min(520px, calc(100vw - 28px))"')
expect(source).toContain("label=\"柜次号\"")
expect(source).toContain("label=\"集装箱号\"")
})
})
@@ -10,7 +10,7 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import LoadingListDetailDrawer from '../components/LoadingListDetailDrawer.vue'
import { getPreloadPlan, updatePreloadTransport, type LoadingDetail, type PreloadSummary } from '../api/platformLoadingLists'
import { confirmPreloadPlan, getPreloadPlan, updatePreloadTransport, type LoadingDetail, type PreloadSummary } from '../api/platformLoadingLists'
const route = useRoute()
const router = useRouter()
@@ -21,13 +21,21 @@ const error = ref<ApiErrorDetails | null>(null)
const editOpen = ref(false)
const editSaving = ref(false)
const editError = ref<ApiErrorDetails | null>(null)
const confirmOpen = ref(false)
const confirmSaving = ref(false)
const confirmError = ref<ApiErrorDetails | null>(null)
const confirmOperationId = ref('')
const editFormRef = ref<FormInstance>()
const editForm = reactive({ containerNo: '', voyageNo: '', operationReason: '' })
const planId = computed(() => String(route.params.planId || ''))
const warehouseId = computed(() => typeof route.query.warehouseId === 'string' ? route.query.warehouseId : '')
const canEdit = computed(() => access.can('platform:firstleg:preload:edit'))
const editDialogTitle = computed(() => detail.value?.summary.containerNo ? '修改集装箱号 / 航次' : '录入集装箱号')
const editRules: FormRules = {
containerNo: [{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' }],
containerNo: [
{ required: true, message: '请输入集装箱号', trigger: 'blur' },
{ max: 64, message: '集装箱号不能超过 64 个字符', trigger: 'blur' },
],
voyageNo: [{ max: 64, message: '航次不能超过 64 个字符', trigger: 'blur' }],
operationReason: [{ max: 500, message: '修改原因不能超过 500 个字符', trigger: 'blur' }],
}
@@ -74,7 +82,7 @@ async function saveTransport() {
editError.value = null
try {
detail.value = await updatePreloadTransport(planId.value, warehouseId.value, {
containerNo: editForm.containerNo.trim() || null,
containerNo: editForm.containerNo.trim(),
voyageNo: editForm.voyageNo.trim() || null,
operationReason: editForm.operationReason.trim() || null,
expectedVersion: detail.value.summary.version,
@@ -92,6 +100,32 @@ async function saveTransport() {
}
}
function openConfirmPreload() {
const summary = detail.value?.summary
if (!canEdit.value || !summary?.readiness.canConfirm) return
confirmError.value = null
confirmOperationId.value = `admin-preload-confirm-${summary.planId}-${summary.version}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
confirmOpen.value = true
}
async function submitConfirmPreload() {
const summary = detail.value?.summary
if (!summary?.readiness.canConfirm || confirmSaving.value) return
confirmSaving.value = true
confirmError.value = null
try {
const result = await confirmPreloadPlan(summary.planId, summary.version, confirmOperationId.value)
await loadDetail()
confirmOpen.value = false
confirmOperationId.value = ''
ElMessage.success(result.readiness.readyForPda ? '预装已确认,PDA 任务已具备下发条件' : '预装已确认')
} catch (cause) {
confirmError.value = getApiErrorDetails(cause, '确认预装失败,请稍后重试')
} finally {
confirmSaving.value = false
}
}
onMounted(() => void loadDetail())
</script>
@@ -99,13 +133,21 @@ onMounted(() => void loadDetail())
<AdminPage>
<div class="preload-detail-page">
<el-button link type="primary" class="back-button" @click="goBack"><ArrowLeft :size="16" />返回列表</el-button>
<el-alert
v-if="detail"
type="info"
:closable="false"
show-icon
:title="detail.summary.containerNo ? '集装箱号已录入,继续完成订舱放舱与预装确认' : '下一步:录入集装箱号'"
description="预装清单不会直接开放 PDA;需完成集装箱号、订舱关联与放舱、预装确认后,才由服务端生成 PDA 任务。"
/>
<LoadErrorState v-if="error && !detail" title="预装清单详情加载失败" :message="error.message" :loading="loading" @retry="loadDetail">
<template #details><ApiErrorMeta :details="error" /></template>
</LoadErrorState>
<LoadingListDetailDrawer v-else :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" @retry="loadDetail" @edit="openEdit" />
<LoadingListDetailDrawer v-else :mode="'preload'" :loading="loading" :detail="detail" :error="error" :can-edit="canEdit" :confirm-saving="confirmSaving" @retry="loadDetail" @edit="openEdit" @confirm-preload="openConfirmPreload" />
</div>
<AppDialog v-model="editOpen" class="loading-transport-dialog" title="编辑预装运输信息" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
<AppDialog v-model="editOpen" class="loading-transport-dialog" :title="editDialogTitle" width="min(620px, calc(100vw - 28px))" confirm-text="保存" :confirm-loading="editSaving" :confirm-disabled="!canEdit" @confirm="saveTransport">
<el-form ref="editFormRef" class="transport-form" :model="editForm" :rules="editRules" label-position="top">
<el-form-item label="集装箱号" prop="containerNo"><el-input v-model="editForm.containerNo" maxlength="64" placeholder="请输入集装箱号" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="editForm.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
@@ -113,6 +155,18 @@ onMounted(() => void loadDetail())
</el-form>
<LoadErrorState v-if="editError" compact title="保存失败" :message="editError.message" :show-retry="false"><template #details><ApiErrorMeta :details="editError" /></template></LoadErrorState>
</AppDialog>
<AppDialog v-model="confirmOpen" class="preload-confirm-dialog" title="确认整份预装计划" width="min(520px, calc(100vw - 28px))" confirm-text="确认预装" :confirm-loading="confirmSaving" :confirm-disabled="!detail?.summary.readiness.canConfirm" @confirm="submitConfirmPreload">
<div class="confirm-content">
<el-alert type="warning" :closable="false" show-icon title="本操作将确认整份预装计划" description="系统会原子确认本计划包含的全部入库预装票据;成功后由服务端按真实条件创建唯一 PDA 任务,当前页面不会伪造 PDA 成功。" />
<el-descriptions v-if="detail" :column="1" border>
<el-descriptions-item label="预装单号">{{ detail.summary.preloadNo }}</el-descriptions-item>
<el-descriptions-item label="柜次号">{{ detail.summary.cabinetNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="集装箱号">{{ detail.summary.containerNo || '待录入' }}</el-descriptions-item>
</el-descriptions>
<LoadErrorState v-if="confirmError" compact title="确认预装失败" :message="confirmError.message" :show-retry="false"><template #details><ApiErrorMeta :details="confirmError" /></template></LoadErrorState>
</div>
</AppDialog>
</AdminPage>
</template>
@@ -121,6 +175,7 @@ onMounted(() => void loadDetail())
.back-button { width: fit-content; }
.transport-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.transport-form :deep(.el-form-item:last-child) { grid-column: 1 / -1; }
.confirm-content { display: grid; gap: 16px; }
:global(.loading-transport-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
@media (max-width: 720px) { .transport-form { grid-template-columns: 1fr; } .transport-form :deep(.el-form-item:last-child) { grid-column: auto; } }
</style>
+1
View File
@@ -7,6 +7,7 @@ describe('loading list routes', () => {
['loading-plans', 'platform:firstleg:preload:list'],
['loading-plans/:planId', 'platform:firstleg:preload:list'],
['loading-lists', 'platform:firstleg:loading-list:list'],
['loading-lists/:planId', 'platform:firstleg:loading-list:list'],
])
})
})
@@ -52,7 +52,7 @@ describe('OceanBookingDetailView interaction contract', () => {
expect(source).toContain("form.data.shippingCompanyName || '船公司名称缺失'")
expect(source).toContain("form.data.bookingAgentName || '订舱代理名称缺失'")
expect(source).toContain("from '@/modules/master-data'")
expect(source).toContain("from '@/modules/suppliers/api/platformSuppliers'")
expect(source).toContain("from '@/modules/suppliers'")
})
it('renders the translated status once in the detail heading', () => {
@@ -14,8 +14,7 @@ import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
import { listSuppliers } from '@/modules/suppliers/api/platformSuppliers'
import type { Supplier } from '@/modules/suppliers/types'
import { listSuppliers, type Supplier } from '@/modules/suppliers'
import { listPlatformWarehouses, type PlatformWarehouseResource } from '@/modules/warehouses'
import {
createOceanBooking,
@@ -45,4 +45,9 @@ describe('TruckAppointmentForm interaction contract', () => {
expect(source).toContain('placeholder="请输入目的门店地址"')
expect(source).toContain('placeholder="请输入拖车预约备注(可选)"')
})
it('labels the generated cabinet reference and physical container number separately', () => {
expect(source).toContain('柜次号:${item.cabinetNo')
expect(source).toContain('集装箱号:${item.containerNo')
})
})
@@ -87,7 +87,7 @@ watch(() => props.readonly, (readonly) => { if (!readonly && !customers.value.le
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError" class="relation-alert"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<el-row :gutter="16">
<el-col :xs="24"><el-divider content-position="left">关联资源</el-divider></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="请选择柜次" :loading="optionsLoading" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-for="item in containers" :key="item.id" :label="`${item.cabinetNo || item.bookingNo}${item.containerNo ? ` / ${item.containerNo}` : ''}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联柜次"><el-select :model-value="draft.containerBookingId" filterable clearable placeholder="请选择柜次" :loading="optionsLoading" style="width:100%" @update:model-value="selectContainer(String($event || ''))"><el-option v-for="item in containers" :key="item.id" :label="`柜次号:${item.cabinetNo || item.bookingNo || '待生成'} / 集装箱号:${item.containerNo || '待录入'}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="关联订舱"><el-select :model-value="draft.oceanBookingId" filterable clearable placeholder="请选择订舱单" :loading="optionsLoading" style="width:100%" @update:model-value="selectBooking(String($event || ''))"><el-option v-for="item in bookings" :key="item.id" :label="item.bookingNo || `订舱 ${item.id}`" :value="item.id" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="客户"><el-select :model-value="draft.customerId" filterable clearable placeholder="请选择客户" :loading="optionsLoading" style="width:100%" @update:model-value="selectCustomer(String($event || ''))"><el-option v-if="draft.customerId && !selectableCustomers.some((item) => String(item.id) === String(draft.customerId))" :label="draft.customerName || '当前客户'" :value="String(draft.customerId)" /><el-option v-for="item in selectableCustomers" :key="String(item.id)" :label="customerLabel(item)" :value="String(item.id)" /></el-select></el-form-item></el-col>
<el-col :xs="24" :sm="12"><el-form-item label="仓库"><el-select v-model="draft.warehouseId" filterable clearable placeholder="请选择仓库" :loading="optionsLoading" style="width:100%"><el-option v-for="item in selectableWarehouses" :key="String(item.id)" :label="`${item.warehouseName}(${item.warehouseCode})`" :value="String(item.id)" /></el-select></el-form-item></el-col>
@@ -40,7 +40,7 @@ import {
firstLegTransportModeLabel,
} from '@/shared/utils/firstLegBusinessLabels'
import { UnifiedOrderWorkflowPanel } from '@/modules/unified-orders/workflows'
import SmartLoadingDialog from '@/modules/container-loading/components/SmartLoadingDialog.vue'
import { SmartLoadingDialog } from '@/modules/container-loading'
import {
unifiedOrderHistoryActionLabel,
unifiedOrderLoadingStatusLabel,
@@ -5,7 +5,7 @@
真实接口均使用 `/api/platform/v1/inbounds/{inboundId}`:
- `POST /split/smart`:智能分柜/保存预装清单
- `GET /preload`、`POST /preload/confirming`、`POST /preload/confirm`:预装清单查询与确认
- `GET /preload`:单票预装状态与明细只读查询;整份计划的确认统一在预装清单真实 `loadingPlan` 详情完成
- `GET/POST /loading/picking`、`/loading/picking/start`、`/loading/picking/complete`:拣货
- `POST /platform/v1/files/loading-evidence/{tenantId}/{inboundId}`:上传真实装柜证据文件
- `GET/POST /loading/evidence`:查询并登记五类装柜证据及锁码
@@ -4,7 +4,6 @@ import {
adjustSmartLoadingItem,
assignSmartLoadingItem,
completeLoading,
confirmingPreload,
deleteSmartLoadingItem,
downloadEvidenceFile,
getContainerBooking,
@@ -62,15 +61,6 @@ describe('unified order workflow API contract', () => {
await expect(getPreload('2', '8')).rejects.toThrow('无权限')
})
it('supports the intermediate confirming preload action', async () => {
vi.mocked(http.post).mockResolvedValue({ data: { mainStatus: 'WAIT_SPLIT', preloadPlan: null } })
await confirmingPreload('2', '8', { expectedVersion: 1 })
expect(http.post).toHaveBeenCalledWith(
'/platform/v1/inbounds/8/preload/confirming', { expectedVersion: 1 },
expect.objectContaining({ params: { tenantId: '2' } }),
)
})
it('uploads a real evidence file and voids evidence through audited endpoints', async () => {
vi.mocked(http.post)
.mockResolvedValueOnce({ data: { fileId: '88', fileName: 'empty.jpg' } })
@@ -186,10 +186,6 @@ export interface SmartSplitRequest {
remark?: string
}
export interface ConfirmPreloadRequest {
expectedVersion: number
}
export interface UpsertEvidenceRequest {
expectedVersion: number
evidenceType: EvidenceType
@@ -446,30 +442,6 @@ export async function smartSplit(
return normalizeSplitAction(unwrapApiResponse(response))
}
export async function confirmingPreload(
tenantId: string | number,
inboundId: string | number,
request: ConfirmPreloadRequest,
) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${inboundBasePath}/${inboundId}/preload/confirming`, request,
writeOptions('preload-confirming', inboundId, tenantId),
)
return normalizeSplitAction(unwrapApiResponse(response))
}
export async function confirmPreload(
tenantId: string | number,
inboundId: string | number,
request: ConfirmPreloadRequest,
) {
const response = await http.post<ApiEnvelope<Record<string, unknown>>, ApiEnvelope<Record<string, unknown>>>(
`${inboundBasePath}/${inboundId}/preload/confirm`, request,
writeOptions('preload-confirm', inboundId, tenantId),
)
return normalizeSplitAction(unwrapApiResponse(response))
}
export async function getPicking(tenantId: string | number, inboundId: string | number) {
const response = await http.get<ApiEnvelope<PickingTask>, ApiEnvelope<PickingTask>>(
`${inboundBasePath}/${inboundId}/loading/picking`, { params: query(tenantId) },
@@ -83,6 +83,20 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
expect(wrapper.find('button[aria-label="编辑第 1 条预装明细"]').exists()).toBe(true)
})
it('keeps single-order preload confirmation read-only and links to the plan list', async () => {
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'platform:firstleg:preload:list'])
await flushPromises()
expect(wrapper.text()).toContain('计划级预装确认')
expect(wrapper.text()).toContain('从实际预装计划详情确认整份计划')
expect(wrapper.text()).toContain('前往预装清单')
expect(wrapper.text()).not.toContain('提交确认')
expect(wrapper.text()).not.toContain('确认预装清单')
const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue'), 'utf8')
expect(source).toContain("router.push({ name: 'loading-plans' })")
})
it('does not query or expose editing controls without permission', async () => {
const wrapper = mountPanel([])
await flushPromises()
@@ -2,6 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { ElMessageBox } from 'element-plus'
import { RefreshCw, Save, Check, Play, Camera, Eye, Plus, Pencil, Trash2 } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -15,8 +16,6 @@ import { sanitizePreloadItem, validatePreloadItem, validatePreloadItems } from '
import {
completeLoading,
completePicking,
confirmingPreload,
confirmPreload,
downloadEvidenceFile,
getEvidence,
getOutboundDispatch,
@@ -52,6 +51,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ updated: [] }>()
const access = useAccessPreviewStore()
const router = useRouter()
const preload = ref<PreloadPlan | null>(null)
const preloadItems = ref<PreloadItem[]>([])
const picking = ref<PickingTask | null>(null)
@@ -87,8 +87,8 @@ const preloadVersion = computed(() => preload.value?.version ?? 0)
const pickingExpectedVersion = computed(() => picking.value?.version ?? props.pickingVersion ?? null)
const inboundExpectedVersion = computed(() => props.inboundVersion)
const canSplit = computed(() => !props.readOnly && access.can('firstleg:inbound:split'))
const canPreload = computed(() => !props.readOnly && access.can('firstleg:inbound:preload'))
const canPreloadView = computed(() => access.can('firstleg:inbound:preload-view'))
const canViewLoadingPlans = computed(() => access.can('platform:firstleg:preload:list'))
const canPicking = computed(() => !props.readOnly && access.can('platform:firstleg:inbound:picking:edit'))
const canPickingView = computed(() => access.can('platform:firstleg:inbound:picking:view'))
// 操作员、理货员由 IAM 授予 loading:edit;拣货员仅能在 PDA 现场采集,不授予该权限。
@@ -292,18 +292,8 @@ async function runSmartSplit() {
}
}
async function startPreloadConfirmation() {
const result = await run('preload-confirming', () => confirmingPreload(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
}), '预装清单已进入确认中')
if (result?.preloadPlan) preload.value = result.preloadPlan
}
async function finishPreloadConfirmation() {
const result = await run('preload-confirm', () => confirmPreload(props.tenantId, props.inboundId, {
expectedVersion: preloadVersion.value,
}), '预装清单已确认,订单进入装柜中')
if (result?.preloadPlan) preload.value = result.preloadPlan
function openPreloadPlans() {
void router.push({ name: 'loading-plans' })
}
function pickingRequest(): { expectedVersion: number; expectedCount: number; pickedCount: number; remark?: string } | null {
@@ -465,8 +455,11 @@ onMounted(() => { void refresh() })
</template>
<div class="workflow-actions">
<el-button :disabled="!canSplit || Boolean(busy) || Boolean(preloadLoadError)" :loading="busy === 'smart-split'" @click="runSmartSplit"><Save :size="15" />保存分柜清单</el-button>
<el-button :disabled="!canPreload || !preload || preload.status !== 'PENDING' || Boolean(busy)" :loading="busy === 'preload-confirming'" @click="startPreloadConfirmation"><Play :size="15" />提交确认</el-button>
<el-button type="primary" :disabled="!canPreload || !preload || preload?.status !== 'CONFIRMING' || Boolean(busy)" :loading="busy === 'preload-confirm'" @click="finishPreloadConfirmation"><Check :size="15" />确认预装清单</el-button>
</div>
<div class="preload-confirmation-guide">
<div><strong>计划级预装确认</strong><span>当前单票状态:{{ preloadStatusLabel(preload?.status) }}。请前往预装清单,从实际预装计划详情确认整份计划。</span></div>
<el-button v-if="canViewLoadingPlans" type="primary" plain @click="openPreloadPlans"><Eye :size="15" />前往预装清单</el-button>
<span v-else class="workflow-hint">当前账号无预装清单列表权限。</span>
</div>
</el-card>
@@ -573,6 +566,11 @@ onMounted(() => { void refresh() })
.preload-dialog-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.workflow-actions { margin-top: 14px; }
.workflow-remark { margin-top: 10px; }
.preload-confirmation-guide { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 12px; padding: 12px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: #f8fafc; }
.preload-confirmation-guide > div { display: grid; min-width: 0; gap: 4px; }
.preload-confirmation-guide strong { color: var(--cc-text, #1f2937); font-size: 13px; }
.preload-confirmation-guide span { color: var(--cc-text-secondary, #64748b); font-size: 12px; line-height: 1.5; }
.preload-confirmation-guide > .el-button { flex: 0 0 auto; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.evidence-item { display: grid; min-width: 0; gap: 8px; align-content: start; padding: 12px; border: 1px solid var(--cc-border); border-radius: 6px; }
.evidence-item strong { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
@@ -592,6 +590,7 @@ onMounted(() => { void refresh() })
@media (max-width: 640px) {
.preload-dialog-grid, .evidence-grid { grid-template-columns: 1fr; }
.preload-dialog-grid { gap: 0; }
.preload-confirmation-guide { align-items: stretch; flex-direction: column; }
.evidence-item, .gate-grid { grid-template-columns: 1fr; }
.workflow-actions > .el-button { flex: 1 1 150px; }
}