This commit is contained in:
2026-10-22 11:56:54 +08:00
parent 090431b409
commit 3e77c3976e
40 changed files with 976 additions and 241 deletions
@@ -114,12 +114,12 @@ describe('ContainerCustomsPanel', () => {
wrapper.unmount()
})
it('开始报关确认显示柜次号,取消不会冻结资料或调用开始接口', async () => {
it('开始报关确认使用简短文案,取消不会冻结资料或调用开始接口', async () => {
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel')
const wrapper = await render('READY_TO_START')
await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click')
await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.any(String), '开始整柜报关 · MCR08290001', expect.objectContaining({ customClass: 'admin-density-confirm' }))
expect(confirm).toHaveBeenCalledWith('确认开始整柜报送?', '整柜报关', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认' }))
expect(api.start).not.toHaveBeenCalled()
confirm.mockRestore()
wrapper.unmount()
@@ -183,7 +183,7 @@ async function resolveFormalResult(batch: DeclarationBatch, result: DeclarationB
async function start() {
if (!customs.value || customs.value.status !== 'READY_TO_START') return
try {
await ElMessageBox.confirm('开始后将冻结客户当前退税资料版本、关联入仓码覆盖范围及平台买单准备事实,确认开始整柜报关?', customsDialogTitle('开始整柜报关'), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认开始' })
await ElMessageBox.confirm('确认开始整柜报送?', '整柜报关', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认' })
} catch { return }
actionLoading.value = true; actionError.value = null
try { customs.value = await startContainerCustoms(props.loadingPlanId, customs.value.version); ElMessage.success('整柜报关已开始') }
@@ -59,6 +59,8 @@
/* Customer dialog business geometry; common appearance stays in adminDensity.css. */
.customer-editor-dialog .density-form--outlined { --el-border-radius-base: var(--cc-radius-lg); }
.customer-editor-dialog .density-form--outlined .el-input-number .el-input__inner { text-align: left; }
.customer-editor-dialog .customer-editor-table { table-layout: fixed; }
.customer-editor-dialog .customer-editor-table td:not([colspan]) { width: calc(100% / 3); }
.customer-review-table th { width: 120px; }
@@ -182,7 +182,7 @@ describe('CustomersView', () => {
it('searches the customer list by customer name only', () => {
expect(customersSource).toContain("label: '客户名称', placeholder: '请输入客户名称'")
expect(customersSource).toContain("label: '客户昵称'")
expect(customersSource).toContain('中文拼音大写首字母,如 SZWL')
expect(customersSource).toContain('placeholder="选填,默认自动生成"')
expect(customersSource).not.toContain('公司名称、客户编号或联系人')
expect(customersSource).not.toContain('row.tenantCode.toLowerCase().includes(keyword)')
expect(customersSource).not.toContain('String(row.id).includes(keyword)')
@@ -210,12 +210,16 @@ describe('CustomersView', () => {
expect(document.querySelector<HTMLElement>('.customer-editor-dialog .qualification-credit-code .el-form-item__label')?.style.width).toBe('144px')
const company = document.querySelector<HTMLInputElement>('.customer-editor-dialog input[placeholder="请输入工商登记公司名称"]')!
expect(company.closest('.el-form-item')?.querySelector<HTMLElement>('.el-form-item__label')?.style.width).toBe('120px')
const legalName = document.querySelector<HTMLInputElement>('.customer-editor-dialog input[placeholder="请输入营业执照上的企业名称"]')!
expect(legalName.closest('.el-form-item')?.querySelector<HTMLElement>('.el-form-item__label')?.style.width).toBe('144px')
expect(company.closest('form')?.classList.contains('density-form--outlined')).toBe(true)
company.value = ''
company.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
company.dispatchEvent(new FocusEvent('blur'))
await vi.waitFor(() => expect(company.closest('.el-form-item')?.querySelector('.el-form-item__error')?.textContent).toContain('请输入公司名称'))
expect(document.querySelector('.customer-editor-dialog .el-dialog__footer')?.textContent).toContain('保存资料和资质')
expect(document.querySelector('.customer-editor-dialog .el-dialog__footer')?.textContent).not.toContain('保存资料时会一并上传已选资质')
expect(updatePlatformTenant).not.toHaveBeenCalled()
})
+9 -10
View File
@@ -1085,10 +1085,10 @@ onMounted(() => { void loadCustomers() })
</ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog admin-workbench is-dialog" :loading="editorLoading" :confirm-loading="saving" fullscreenable destroy-on-close>
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="120px" class="density-form density-form--outlined density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
<section class="density-dialog-section workbench-evidence"><h3 class="density-dialog-section__title">基础资料</h3>
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td></tr>
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="选填,默认自动生成" maxlength="32" /></el-form-item></td><td><el-form-item label="营业执照企业名称" prop="legalName" label-width="144px" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td></tr>
<tr><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
<tr><td colspan="3"><el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item></td></tr>
<tr><td colspan="3"><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></td></tr>
@@ -1103,8 +1103,8 @@ onMounted(() => { void loadCustomers() })
</tbody></table>
</section>
<section class="density-dialog-section qualification-section workbench-evidence"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp></h3><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" label-width="144px" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
<section class="density-dialog-section qualification-section workbench-evidence"><h3 class="density-dialog-section__title">企业认证资料<InlineHelp title="资质提交规则"><p>营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><p>支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p></InlineHelp><el-tag v-if="editorStatus" :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></h3>
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" label-width="144px" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small v-if="!creditCodeEstablished">必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。</small></el-form-item></td></tr></tbody></table>
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
<div class="qualification-table" role="table" aria-label="客户资质文件">
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
@@ -1124,7 +1124,7 @@ onMounted(() => { void loadCustomers() })
</section>
</el-form>
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div class="density-actions density-actions--footer"><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
<template #footer><div class="customer-editor-footer"><div class="density-actions density-actions--footer"><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
</AppDialog>
<AppDialog v-model="qualificationPreviewOpen" :title="customerDialogTitle('查看资质文件', editorCustomer)" width="min(860px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog qualification-preview-dialog customer-qualification-preview admin-workbench is-dialog" fullscreenable destroy-on-close :show-confirm="false" cancel-text="关闭预览">
@@ -1183,13 +1183,12 @@ onMounted(() => { void loadCustomers() })
<div class="density-dialog-section qualification-preview" data-tone="source"><img v-if="reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></div>
</AppDialog>
<AppDialog v-model="passwordDialogOpen" :title="customerDialogTitle('重置客户密码', passwordTenant)" :confirm-loading="passwordSubmitting" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog admin-workbench is-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
<AppDialog v-model="passwordDialogOpen" title="重置客户密码" :confirm-loading="passwordSubmitting" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog admin-workbench is-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top" class="density-form">
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword" required><el-input v-model="passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="请再次输入新密码" /></el-form-item>
<el-checkbox v-model="passwordForm.forceChangePassword">客户下次登录时必须修改密码</el-checkbox>
<p class="density-dialog-note">重置对象为企业 OWNER 管理员,原密码将立即失效。</p>
</el-form>
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="passwordSubmitting" @click="passwordDialogOpen = false">取消</el-button><el-button type="primary" :loading="passwordSubmitting" @click="submitResetPassword">确认重置</el-button></div></template>
</AppDialog>
@@ -1219,7 +1218,7 @@ onMounted(() => { void loadCustomers() })
.customer-company-name { display:block; overflow:hidden; color:var(--workbench-text); font-weight:500; text-overflow:ellipsis; white-space:nowrap; }
:deep(.customer-list-table .customer-code-column .cell) { white-space:nowrap; }
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; }
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:var(--admin-space-5, 12px); } .qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
.qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
.qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--workbench-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--workbench-muted); line-height:1.5; }
.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--workbench-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--workbench-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:var(--cc-list-table-header-bg); color:var(--cc-list-table-header-text); font-size:var(--admin-font-caption, 12px); font-weight:600; }
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
@@ -1240,7 +1239,7 @@ onMounted(() => { void loadCustomers() })
.qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; }
.qualification-preview-action { position:absolute; inset:auto 0 0; display:flex; min-height:22px; align-items:center; justify-content:center; gap:3px; background:rgba(23,32,51,.78); color:var(--workbench-surface); }
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-4, 10px); margin-top:var(--admin-space-5, 12px); padding:var(--admin-space-4, 10px) var(--admin-space-5, 12px); border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:var(--admin-font-caption, 12px); }
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); } .customer-editor-footer > span { color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); text-align:left; }
.customer-editor-footer { display:flex; align-items:center; justify-content:flex-end; gap:var(--admin-space-6, 16px); }
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); background:var(--workbench-surface-soft); } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--workbench-text); font-size:var(--admin-font-body, 13px); } .decision-summary span { color:var(--workbench-muted); font-size:var(--admin-font-caption, 11px); }
.review-document-state { padding:14px; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
.review-document-error { padding:14px; color:var(--workbench-muted); font-size:var(--admin-font-caption, 12px); }
+4 -2
View File
@@ -20,12 +20,14 @@
## 关键边界
- 订舱草稿按当前版本维护资料;放舱后资料与表单只读,不允许绕过版本直接改字段。
- 打印对草稿与已放舱订舱均开放,按当前**已保存**资料生成,未填写字段显示“—”,不用未保存的表单值。
- 订舱草稿按当前版本维护资料;放舱后运输、关系方、柜型需求、预报货量和附件只读;仅实货量可由订舱编辑权限通过 `/actual-cargo` 按版本、幂等键和原因补录并审计,禁止绕过运输冻结边界。
- 打印对草稿与已放舱订舱均开放,按当前**已保存**资料生成,未填写字段显示“—”,不用未保存的表单值。托运单取委值,提单确认件取实值;实值由客服手工录入,不回退为委值,不生成正式提单。
- 关联候选只消费服务端 `/link-candidates` 分页结果,不在浏览器重排或本地拼接候选。
- 订舱是拖车预约的唯一归属:拖车由 `../trucking/` 创建,本模块不直接建拖车单。
- 出仓前船期只展示订舱级计划,实际节点归 `../port-schedules/` 与海外回写,本模块不写实际时间。
- 本轮布局与操作证据维护在 `PROTOTYPE_MATRIX.md`,缺截图时保留待人工确认。
## 验证
- 定向:`pnpm test src/modules/ocean-bookings/<目标测试>`;资料字段或打印变更同时核对 `print/` 模板与 `file/` 附件契约。
@@ -0,0 +1,13 @@
# 订舱页面受影响操作核对
业务依据为用户提供的《订舱信息页面说明.docx》及页面截图;本轮确认:放舱后仅补实货量、客服手工录入、保留现有提单确认件,正式提单从外部附件接收。样式使用 `docs/Admin高密度界面规范.md` 的详情与基本信息、蓝白主题及既有公共密度。用户后续提供现页与产品稿对比,确认现页缺少层次、重点且间距过大;订舱页以该产品稿为局部视觉依据:紧凑页头、蓝色圆点连线流程、编号区块、细线小标题、浅底三方卡片及右下主打印按钮。可编辑输入、下拉和文本域使用白底、清晰边框与文字,悬停/聚焦使用蓝色反馈;灰底和禁止光标仅用于真实禁用状态,校验错误仍保留组件错误色。
流程条根据已保存订舱、当前未保存修改、放舱状态及实货量推进:当前步骤蓝底,已完成节点勾选,待处理灰色。第一步的“已保存”不代替放舱完整性校验;打印节点仅在本次页面成功生成对应数据的 PDF 预览后显示“已预览”,失败或只点击按钮不推进,也不声称实际打印完成。预览标记随本次页面保留,刷新后按服务端保存/放舱/实值恢复进度,打印仍可重新预览;单据消费的数据改变后,该单据重新进入待预览。
| 页面/路由 | 操作 | 参考承载与布局 | 当前实现 | 人工结论 |
|---|---|---|---|---|
| `/ocean-bookings/new`、`/ocean-bookings/:bookingId` | 草稿新增、维护 | 独立页面;左主资料、右三方并排,下方放舱资料 | 独立页面;按产品稿重排视觉层级,保留仓库、渠道、预计到港、多柜型和附件;港代码与港名成组、委实成对输入,新增保存留在详情 | 待人工截图确认 |
| `/ocean-bookings/:bookingId` | 已放舱补实 | 客服补录实际货量 | 运输及预报只读,实值可按编辑权限补录,原因必填并版本审计 | 待人工截图确认 |
| `/ocean-bookings/:bookingId` | 托运单、提单确认件 | 两个作业入口,A4单据预览 | 两张业务卡片底部右侧的蓝色主按钮,分别直达现有PDF预览;读已保存委/实数据,缺失显示“—” | 待人工截图确认 |
人工按 1920×1080、100% 缩放核对三方长名称/地址、两类货量、保存失败与版本冲突、冻结/无编辑权限,以及两类 PDF 预览的打开、关闭和重开。自动检查结果在交付中单独报告,不能代替人工截图。
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
import { createOceanBooking, deleteOceanBookingDraft, downloadOceanBookingDocument, downloadOceanBookingPrint, invalidateOceanBookingDocument, listOceanBookingAgents, listOceanBookingDocuments, listOceanBookingLinkCandidates, listOceanBookings, listWarehouseLogisticsChannels, referenceOceanBookingDocument, releaseOceanBooking, updateOceanBookingActualCargo, updateOceanBookingDraft, uploadOceanBookingDocument } from './platformOceanBookings'
import { http } from '@/shared/api/http'
const detailResponse = (version = 0, status = 'DRAFT') => ({
@@ -14,6 +14,17 @@ const detailResponse = (version = 0, status = 'DRAFT') => ({
})
describe('ocean bookings api', () => {
it('sends actual cargo only to the versioned supplement endpoint and normalizes decimal responses', async () => {
const payload = { expectedVersion: 3, actualPackageCount: 480, actualGrossWeightKg: 14000.125, actualVolumeCbm: 65.321, reason: '客服补录' }
const response = detailResponse(4, 'RELEASED')
const put = vi.spyOn(http, 'put').mockResolvedValue({ code: '200', data: {
...response, oceanBooking: { ...response.oceanBooking, actualPackageCount: 480, actualGrossWeightKg: '14000.125', actualVolumeCbm: '65.321', declaredPackageCount: 500 },
} })
const saved = await updateOceanBookingActualCargo('123', payload)
expect(put).toHaveBeenCalledWith('/platform/v1/ocean-bookings/123/actual-cargo', payload, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(saved).toMatchObject({ status: 'RELEASED', version: 4, actualPackageCount: 480, actualGrossWeightKg: 14000.125, actualVolumeCbm: 65.321, declaredPackageCount: 500 })
})
it('preserves the backend history creation timestamp when normalizing a detail response', async () => {
const createdAt = '2026-10-20T09:12:34'
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: {
@@ -1,6 +1,6 @@
import axios from 'axios'
import { ApiResponseError, http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingDocument, OceanBookingDocumentInvalidation, OceanBookingDocumentMutation, OceanBookingDocumentType, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingPrintType, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
import type { OceanBookingActualCargoRequest, OceanBookingAgentOption, OceanBookingCarrierCode, OceanBookingDetail, OceanBookingDocument, OceanBookingDocumentInvalidation, OceanBookingDocumentMutation, OceanBookingDocumentType, OceanBookingLinkCandidatePage, OceanBookingLinkCandidateQuery, OceanBookingPage, OceanBookingPrintType, OceanBookingQuery, OceanBookingResource, OceanBookingSaveRequest, OceanBookingStatus, OceanBookingReleaseRequest } from '../types'
const basePath = '/platform/v1/ocean-bookings'
const maxErrorEnvelopeBytes = 1024 * 1024
@@ -21,6 +21,12 @@ function booking(raw: OceanBookingResource): OceanBookingResource {
carrierCode: raw.carrierCode ?? null,
bookingAgentId: raw.bookingAgentId == null ? null : String(raw.bookingAgentId),
containerCount: Number(raw.containerCount ?? 0),
declaredPackageCount: raw.declaredPackageCount == null ? null : Number(raw.declaredPackageCount),
declaredGrossWeightKg: raw.declaredGrossWeightKg == null ? null : Number(raw.declaredGrossWeightKg),
declaredVolumeCbm: raw.declaredVolumeCbm == null ? null : Number(raw.declaredVolumeCbm),
actualPackageCount: raw.actualPackageCount == null ? null : Number(raw.actualPackageCount),
actualGrossWeightKg: raw.actualGrossWeightKg == null ? null : Number(raw.actualGrossWeightKg),
actualVolumeCbm: raw.actualVolumeCbm == null ? null : Number(raw.actualVolumeCbm),
pol: raw.pol ?? raw.departurePortName ?? null,
pod: raw.pod ?? raw.destinationPortName ?? null,
carrierName: raw.carrierName ?? raw.shippingCompanyName ?? null,
@@ -88,6 +94,10 @@ export async function getOceanBooking(id: string) { const response = await http.
function idempotencyKey() { return `ocean-booking-${Date.now()}-${Math.random().toString(36).slice(2)}` }
export async function createOceanBooking(payload: OceanBookingSaveRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(basePath, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export async function updateOceanBookingDraft(id: string, payload: OceanBookingSaveRequest) { const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
export async function updateOceanBookingActualCargo(id: string, payload: OceanBookingActualCargoRequest) {
const response = await http.put<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/actual-cargo`, payload, { headers: { 'Idempotency-Key': idempotencyKey() } })
return detail(unwrapApiResponse(response))
}
export async function deleteOceanBookingDraft(id: string, expectedVersion: number) { await http.delete(`${basePath}/${encodeURIComponent(id)}`, { data: { expectedVersion }, headers: { 'Idempotency-Key': idempotencyKey() } }) }
export async function releaseOceanBooking(id: string, request: OceanBookingReleaseRequest) { const response = await http.post<ApiEnvelope<OceanBookingDetail>, ApiEnvelope<OceanBookingDetail>>(`${basePath}/${encodeURIComponent(id)}/release`, request, { headers: { 'Idempotency-Key': idempotencyKey() } }); return detail(unwrapApiResponse(response)) }
const printPaths: Record<OceanBookingPrintType, string> = {
@@ -33,6 +33,7 @@ describe('OceanBookingPrintDialog', () => {
await flushPromises()
expect(downloadOceanBookingPrint).toHaveBeenCalledWith('123', 'SHIPPING_INSTRUCTION')
expect(wrapper.emitted('previewed')).toEqual([[{ bookingId: '123', type: 'SHIPPING_INSTRUCTION' }]])
expect(document.body.querySelector('iframe')?.getAttribute('src')).toBe('blob:booking-print')
expect(document.body.querySelector('.el-dialog__title')?.textContent).toBe('出口货物托运单 · OB-123')
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click()
@@ -144,4 +145,24 @@ describe('OceanBookingPrintDialog identity and resource lifecycle', () => {
wrapper.unmount()
expect(URL.revokeObjectURL).toHaveBeenCalledExactlyOnceWith('blob:current-print')
})
it('opens the selected document directly and reloads the other type after closing', async () => {
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:booking-print'), revokeObjectURL: vi.fn() })
vi.mocked(downloadOceanBookingPrint).mockResolvedValue(new Blob(['pdf'], { type: 'application/pdf' }))
const wrapper = mount(OceanBookingPrintDialog, {
props: { modelValue: true, bookingId: '123', bookingNo: 'OB-123', allowed: true, initialType: 'SHIPPING_INSTRUCTION' },
global: { plugins: [ElementPlus] }, attachTo: document.body,
})
await flushPromises()
expect(downloadOceanBookingPrint).toHaveBeenLastCalledWith('123', 'SHIPPING_INSTRUCTION')
expect(document.body.querySelector('iframe')).not.toBeNull()
await wrapper.setProps({ modelValue: false })
await flushPromises()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:booking-print')
await wrapper.setProps({ initialType: 'BILL_OF_LADING_CONFIRMATION', modelValue: true })
await flushPromises()
expect(downloadOceanBookingPrint).toHaveBeenLastCalledWith('123', 'BILL_OF_LADING_CONFIRMATION')
expect(document.body.querySelector('.el-dialog__title')?.textContent).toBe('提单确认件 · OB-123')
wrapper.unmount()
})
})
@@ -10,7 +10,8 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { downloadOceanBookingPrint } from '../api/platformOceanBookings'
import type { OceanBookingPrintType } from '../types'
const props = defineProps<{ bookingId: string; bookingNo?: string | null; allowed: boolean }>()
const props = defineProps<{ bookingId: string; bookingNo?: string | null; allowed: boolean; initialType?: OceanBookingPrintType }>()
const emit = defineEmits<{ previewed: [event: { bookingId: string; type: OceanBookingPrintType }] }>()
const open = defineModel<boolean>({ default: false })
const selectedType = ref<OceanBookingPrintType | ''>('')
const loading = ref(false)
@@ -42,15 +43,18 @@ function reset() {
}
async function preview() {
if (!props.allowed || !selectedType.value || loading.value) return
const bookingId = props.bookingId
const type = selectedType.value
const request = ++previewRequest
loading.value = true
error.value = null
releasePreview()
try {
const blob = await downloadOceanBookingPrint(props.bookingId, selectedType.value)
const blob = await downloadOceanBookingPrint(bookingId, type)
if (request !== previewRequest || !open.value) return
printBlob.value = blob
previewUrl.value = URL.createObjectURL(blob)
emit('previewed', { bookingId, type })
} catch (cause) {
if (request === previewRequest) error.value = getApiErrorDetails(cause, '打印单据加载失败,请稍后重试')
} finally {
@@ -71,7 +75,13 @@ function download() {
URL.revokeObjectURL(url)
}
watch(open, (value) => { if (!value) reset() })
watch(open, (value) => {
if (!value) reset()
else if (props.initialType) {
selectedType.value = props.initialType
void preview()
}
}, { immediate: true })
onBeforeUnmount(() => { previewRequest += 1; releasePreview() })
</script>
@@ -12,10 +12,12 @@ import {
listWarehouseLogisticsChannels,
releaseOceanBooking,
updateOceanBookingDraft,
updateOceanBookingActualCargo,
} from '../api/platformOceanBookings'
import type { OceanBookingDetail, OceanBookingStatus } from '../types'
import OceanBookingDetailView from './OceanBookingDetailView.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
const routerReplace = vi.hoisted(() => vi.fn())
const routeState = vi.hoisted(() => ({ name: 'ocean-booking-detail', params: { bookingId: '123' } }))
@@ -23,7 +25,8 @@ vi.mock('vue-router', () => ({
useRoute: () => routeState,
useRouter: () => ({ push: vi.fn(), replace: routerReplace }),
}))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const deniedPermissions = vi.hoisted(() => new Set<string>())
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !deniedPermissions.has(permission) }) }))
vi.mock('@/core/workspace-tabs/workspaceTabs', () => ({ useWorkspaceTabsStore: () => ({ removeByIds: vi.fn(), bumpCacheEpoch: vi.fn() }) }))
vi.mock('@/modules/iam', async importOriginal => ({
...await importOriginal<typeof import('@/modules/iam')>(), listPlatformTenants: vi.fn() }))
@@ -32,7 +35,7 @@ vi.mock('@/modules/warehouses', () => ({ listPlatformWarehouses: vi.fn() }))
vi.mock('../api/platformOceanBookings', () => ({
createOceanBooking: vi.fn(), deleteOceanBookingDraft: vi.fn(), getOceanBooking: vi.fn(),
listOceanBookingAgents: vi.fn(), listWarehouseLogisticsChannels: vi.fn(),
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(),
releaseOceanBooking: vi.fn(), updateOceanBookingDraft: vi.fn(), updateOceanBookingActualCargo: vi.fn(),
}))
function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLadingNo = 'BL-001'): OceanBookingDetail {
@@ -66,6 +69,11 @@ function booking(status: OceanBookingStatus = 'DRAFT', version = 3, billOfLading
type ViewModel = {
form: { data: import('../types').OceanBookingDraftData }
saveActualCargo: () => Promise<void>
actualReason: string
cargoWritable: boolean
hasUnsavedActualChanges: boolean
printDisabledReason: string
save: () => Promise<void>
selectCustomer: (value: unknown) => void
canManageDocuments: boolean
@@ -95,6 +103,7 @@ describe('OceanBookingDetailView one-click release', () => {
beforeEach(() => {
routeState.name = 'ocean-booking-detail'; routeState.params.bookingId = '123'
vi.clearAllMocks()
deniedPermissions.clear()
vi.mocked(getOceanBooking).mockResolvedValue(booking())
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
vi.mocked(listPlatformPartners).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 0 })
@@ -103,6 +112,23 @@ describe('OceanBookingDetailView one-click release', () => {
vi.mocked(listOceanBookingAgents).mockResolvedValue([{ supplierId: '20', name: '订舱代理', carrierCodes: ['MATSON'] }])
})
it('renders actual workflow progress and advances only on a successful matching preview', async () => {
const { wrapper } = await mountView()
const states = () => wrapper.findAll('.booking-workflow li').map(item => item.attributes('data-state'))
expect(states()).toEqual(['complete', 'current', 'pending', 'pending'])
expect(wrapper.get('[aria-current="step"]').text()).toContain('托运单')
const print = wrapper.findComponent(OceanBookingPrintDialog)
print.vm.$emit('previewed', { bookingId: 'another', type: 'SHIPPING_INSTRUCTION' })
await flushPromises()
expect(states()).toEqual(['complete', 'current', 'pending', 'pending'])
print.vm.$emit('previewed', { bookingId: '123', type: 'SHIPPING_INSTRUCTION' })
await flushPromises()
expect(states()).toEqual(['complete', 'complete', 'current', 'pending'])
expect(wrapper.findAll('.booking-workflow li')[1]?.text()).toContain('已预览')
expect(wrapper.get('[aria-current="step"]').text()).toContain('确认放舱')
wrapper.unmount()
})
it('finds a customer outside the initial page and saves its actual ID and name in a new booking', async () => {
routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new'
const customer = { id: '9007199254740999', tenantName: '柏悦物流', status: 'ACTIVE' } as PlatformTenantResource
@@ -137,7 +163,7 @@ describe('OceanBookingDetailView one-click release', () => {
expect(wrapper.get('.release-summary').text()).toContain('EDITED-BL')
expect(wrapper.get('.release-summary').element.closest('section')?.hasAttribute('data-tone')).toBe(false)
expect(wrapper.find('.el-alert--warning').exists()).toBe(false)
expect(wrapper.text()).toContain('不能修改、不能删除')
expect(dialog.find('.density-dialog-note').exists()).toBe(false)
expect(wrapper.text()).not.toContain('处理数据库')
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
expect(releaseOceanBooking).not.toHaveBeenCalled()
@@ -281,4 +307,80 @@ describe('OceanBookingDetailView one-click release', () => {
await Promise.all([first, duplicate])
expect(vm.actionSaving).toBe(false)
})
it('keeps newly saved booking open and preserves partial parties and manual cargo', async () => {
routeState.name = 'ocean-booking-create'; routeState.params.bookingId = 'new'
vi.mocked(createOceanBooking).mockResolvedValue(booking('DRAFT', 0))
const { vm, wrapper } = await mountView()
vm.form.data.parties[0]!.contactEmail = 'ops@example.test'
vm.form.data.declaredPackageCount = 500
vm.form.data.declaredGrossWeightKg = 15000
vm.form.data.declaredVolumeCbm = 68
await vm.save()
expect(createOceanBooking).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({
freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS',
declaredPackageCount: 500, actualPackageCount: null,
parties: expect.arrayContaining([expect.objectContaining({ partyType: 'SHIPPER', partyName: '', contactEmail: 'ops@example.test' })]),
}) }))
expect(routerReplace).toHaveBeenCalledWith('/ocean-bookings/123')
expect(vm.detail?.bookingNo).toBe('OB-123')
wrapper.unmount()
})
it('saves released actual cargo alone with version and reason while transport remains frozen', async () => {
vi.mocked(getOceanBooking).mockResolvedValue({ ...booking('RELEASED'), declaredPackageCount: 500 })
vi.mocked(updateOceanBookingActualCargo).mockResolvedValue({ ...booking('RELEASED', 4), declaredPackageCount: 500, actualPackageCount: 480, actualGrossWeightKg: 14320.125, actualVolumeCbm: 65.321 })
const { vm, wrapper } = await mountView()
expect(wrapper.find('input[placeholder="请输入船名"]').attributes('disabled')).toBeDefined()
const actualInput = wrapper.find('[aria-label="实际件数"]')
expect(actualInput.attributes('disabled')).toBeUndefined()
vm.form.data.actualPackageCount = 480
vm.form.data.actualGrossWeightKg = 14320.125
vm.form.data.actualVolumeCbm = 65.321
vm.actualReason = ' 实装后补录 '
await flushPromises()
expect(vm.printDisabledReason).toContain('请先保存')
await vm.saveActualCargo()
expect(updateOceanBookingActualCargo).toHaveBeenCalledWith('123', { expectedVersion: 3, actualPackageCount: 480, actualGrossWeightKg: 14320.125, actualVolumeCbm: 65.321, reason: '实装后补录' })
expect(updateOceanBookingDraft).not.toHaveBeenCalled()
expect(vm.detail?.version).toBe(4)
expect(vm.actualReason).toBe('')
expect(vm.hasUnsavedActualChanges).toBe(false)
expect(vm.form.data.declaredPackageCount).toBe(500)
wrapper.unmount()
})
it('preserves actual inputs and reason on a stale-version failure and blocks duplicate writes', async () => {
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
let rejectSave!: (cause: unknown) => void
vi.mocked(updateOceanBookingActualCargo).mockReturnValue(new Promise((_, reject) => { rejectSave = reject }))
const { vm, wrapper } = await mountView()
vm.form.data.actualPackageCount = 480
await vm.saveActualCargo()
expect(updateOceanBookingActualCargo).not.toHaveBeenCalled()
expect(vm.actionError?.message).toContain('原因')
vm.actualReason = '修正'
const first = vm.saveActualCargo()
await vm.saveActualCargo()
expect(updateOceanBookingActualCargo).toHaveBeenCalledTimes(1)
rejectSave(new ApiResponseError('版本已变化', 'A0409', 409))
await first
expect(vm.form.data.actualPackageCount).toBe(480)
expect(vm.actualReason).toBe('修正')
expect(vm.detail?.version).toBe(3)
expect(vm.actionError?.code).toBe('A0409')
wrapper.unmount()
})
it('keeps actual cargo read-only without edit permission', async () => {
deniedPermissions.add('platform:firstleg:ocean-booking:edit')
vi.mocked(getOceanBooking).mockResolvedValue(booking('RELEASED'))
const { vm, wrapper } = await mountView()
expect(vm.cargoWritable).toBe(false)
expect(wrapper.text()).not.toContain('保存实际货量')
vm.actualReason = '补录'
await vm.saveActualCargo()
expect(updateOceanBookingActualCargo).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -34,7 +34,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).toContain('function draftPayload(): OceanBookingDraftData')
expect(source).toContain('channelCode: form.data.channelCode?.trim() || null')
expect(source).toContain('warehouseId: form.data.warehouseId?.trim() || null')
expect(source).toContain('parties: form.data.parties.filter((party) => party.partyName.trim())')
expect(source).toContain('parties: form.data.parties.map')
expect(source).toContain('containerRequirements: form.data.containerRequirements.map')
expect(source).not.toContain('登记后直接确认')
})
@@ -51,7 +51,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).not.toContain('新增真实柜')
})
it('performs the complete release gate and presents the irreversible warning', () => {
it('performs the complete release gate without the extra explanatory line', () => {
expect(source).toContain('function releaseValidationError()')
expect(source).toContain('预计到港时间不能早于预计开船时间')
expect(source).toContain('放舱前请填写起运港代码或名称')
@@ -61,7 +61,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).toContain("if (!form.data.carrierCode) return '放舱前请重新选择船公司,所选船公司必须配置标准编码'")
expect(source).not.toContain('请先保存草稿后再放舱')
expect(source).toContain('放舱前请填写${missing[1]}')
expect(source).toContain('放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。')
expect(source).not.toContain('放舱后运输资料和预报货量不能修改、不能删除;实际货量可另行补录并记录原因。请确认运输资料无误。')
expect(source).toContain('if (hasUnsavedDraftChanges.value)')
expect(source).toContain('const saved = await persistDraft(false)')
expect(source).toContain('releaseVersion = saved.version')
@@ -70,7 +70,7 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).toContain('if (!saved) return')
expect(source).toContain("{{ form.data.billOfLadingNo || '—' }}")
expect(source).not.toContain("{{ detail?.billOfLadingNo || '—' }}")
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value)')
expect(source).toContain('const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value)')
expect(source).toContain(':disabled="workflowBusy" @click="openReleaseDialog"')
expect(source).not.toContain('releaseReason')
expect(source).toContain('放舱前请上传有效的订舱确认文件或提单文件')
@@ -112,15 +112,16 @@ describe('OceanBookingDetailView draft and release contract', () => {
expect(source).toContain('@media(max-width:720px)')
expect(source).toContain('@media(max-width:420px)')
expect(source).toContain('OceanBookingPrintDialog')
expect(source).toContain('打印单据')
expect(source).toContain('打印托运单')
expect(source).toContain('打印提单确认件')
})
it('keeps a DRAFT detail read-only when the user lacks write permission', () => {
expect(source).toContain('const formWritable = computed(() => canSave.value)')
expect(source).toContain(':disabled="!formWritable" class="booking-groups density-form"')
expect(source).toContain('.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:var(--admin-space-5)}')
expect(source).toContain('.booking-group:last-child{grid-column:1/-1}')
expect(source).toContain('.container-table-wrap{max-width:100%;overflow-x:auto')
expect(source).toMatch(/\.booking-core\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1\.2fr\) minmax\(0,\s*1fr\)/)
expect(source).toMatch(/\.booking-group:last-child\s*\{\s*grid-column:\s*1\s*\/\s*-1/)
expect(source).toMatch(/\.container-table-wrap\s*\{[^}]*max-width:\s*100%;\s*overflow-x:\s*auto/)
expect(source).toContain('v-if="formWritable" class="container-actions"')
expect(source).toContain(':disabled="!formWritable"')
})
@@ -4,7 +4,7 @@ import '@/styles/adminWorkbench.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Printer, Save, Trash2 } from '@lucide/vue'
import { ArrowLeft, Check, Printer, Save, Ship, Trash2 } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -30,6 +30,7 @@ import {
releaseOceanBooking,
listWarehouseLogisticsChannels,
updateOceanBookingDraft,
updateOceanBookingActualCargo,
type OceanLogisticsChannel,
} from '../api/platformOceanBookings'
import type {
@@ -41,10 +42,12 @@ import type {
OceanBookingParty,
OceanBookingSaveRequest,
OceanBookingStatus,
OceanBookingPrintType,
} from '../types'
import { oceanBookingStatusLabel } from '../status'
import { containerRequirementError, createBlankContainerRequirement, selectContainerRequirementType } from '../containerRequirements'
import { shouldClearBookingAgent } from '../bookingAgents'
import { oceanBookingPrintSignature, oceanBookingWorkflow, type OceanBookingPreviewSignatures } from '../workflow'
import OceanBookingPrintDialog from '../components/OceanBookingPrintDialog.vue'
import OceanBookingDocumentsPanel from '../components/OceanBookingDocumentsPanel.vue'
@@ -95,10 +98,13 @@ const channelGroups = computed(() => {
const channelUnavailable = computed(() => Boolean(form.data.warehouseId) && !channelsLoading.value && !channelsError.value && channelGroups.value.length === 0)
function blankParties(): OceanBookingParty[] {
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', address: '' }))
return partyTypes.map(({ value }) => ({ partyType: value, partyName: '', contactName: '', contactPhone: '', contactEmail: '', taxNo: '', address: '' }))
}
function blankData(): OceanBookingDraftData {
return {
freightTerm: 'FREIGHT PREPAID', serviceTerm: 'CY-CY', packageUnit: 'CARTONS',
cargoDescription: '', marksAndNos: '', declaredPackageCount: null, declaredGrossWeightKg: null, declaredVolumeCbm: null,
actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
customerId: null, customerName: null, channelCode: '', warehouseId: '', parties: blankParties(),
billOfLadingNo: '', vesselName: '', voyageNo: '', estimatedSailingAt: null, estimatedArrivalAt: null,
shippingCompanyId: null, shippingCompanyName: null, carrierCode: null,
@@ -117,13 +123,22 @@ const canRelease = computed(() => detail.value?.status === 'DRAFT' && access.can
const canPrint = computed(() => !!detail.value && access.can('platform:firstleg:ocean-booking:list'))
const canManageDocuments = computed(() => detail.value?.status === 'DRAFT' && access.can('platform:firstleg:ocean-booking:edit'))
const documentManageReason = computed(() => !access.can('platform:firstleg:ocean-booking:edit') ? '暂无订舱资料维护权限' : '')
const workflowBusy = computed(() => saving.value || actionSaving.value)
const actualSaving = ref(false)
const actualReason = ref('')
const cargoWritable = computed(() => formWritable.value || (detail.value?.status === 'RELEASED' && access.can('platform:firstleg:ocean-booking:edit')))
const workflowBusy = computed(() => saving.value || actionSaving.value || actualSaving.value)
const releaseDialogOpen = ref(false)
const printOpen = ref(false)
const printType = ref<OceanBookingPrintType>('SHIPPING_INSTRUCTION')
const previewSignatures = ref<OceanBookingPreviewSignatures>({})
function statusLabel(status: OceanBookingStatus | string | null | undefined) {
return oceanBookingStatusLabel(status)
}
function historyActionLabel(action: string) {
const labels: Record<string, string> = { CREATE: '创建草稿', UPDATE_DRAFT: '编辑草稿', RELEASE: '确认放舱', UPDATE_ACTUAL_CARGO: '实货量补录/修正', UPLOAD_DOCUMENT: '上传文件', ATTACH_DOCUMENT: '关联文件', INVALIDATE_DOCUMENT: '作废文件' }
return labels[action] || '资料维护'
}
async function loadOptions() {
optionsLoading.value = true
optionsError.value = ''
@@ -163,12 +178,49 @@ function draftPayload(): OceanBookingDraftData {
billOfLadingNo: form.data.billOfLadingNo.trim(),
vesselName: form.data.vesselName.trim(),
voyageNo: form.data.voyageNo.trim(),
parties: form.data.parties.filter((party) => party.partyName.trim()).map((party) => ({ ...party, partyName: party.partyName.trim() })),
parties: form.data.parties.map((party) => ({ ...party, partyName: party.partyName.trim() })),
containerRequirements: form.data.containerRequirements.map(({ containerType, containerKind, quantity, capacityCbm, maxWeightKg }) => ({ containerType, containerKind, quantity: Number(quantity), capacityCbm, maxWeightKg })),
}
}
function draftSignature() { return JSON.stringify(draftPayload()) }
const hasUnsavedDraftChanges = computed(() => Boolean(detail.value?.status === 'DRAFT' && savedDraftSignature.value && draftSignature() !== savedDraftSignature.value))
const hasUnsavedActualChanges = computed(() => detail.value?.status === 'RELEASED' && (
(form.data.actualPackageCount ?? null) !== (detail.value.actualPackageCount ?? null)
|| (form.data.actualGrossWeightKg ?? null) !== (detail.value.actualGrossWeightKg ?? null)
|| (form.data.actualVolumeCbm ?? null) !== (detail.value.actualVolumeCbm ?? null)
))
const printDisabledReason = computed(() => !detail.value ? '请先保存草稿' : hasUnsavedDraftChanges.value || hasUnsavedActualChanges.value ? '请先保存修改,再打印已保存资料' : !canPrint.value ? '暂无订舱单据打印权限' : '')
const workflowSteps = computed(() => oceanBookingWorkflow(detail.value, hasUnsavedDraftChanges.value, hasUnsavedActualChanges.value, previewSignatures.value))
function onPrintPreviewed(event: { bookingId: string; type: OceanBookingPrintType }) {
if (!detail.value || event.bookingId !== detail.value.id || printDisabledReason.value) return
previewSignatures.value[event.type] = oceanBookingPrintSignature(detail.value, event.type)
}
function openPrint(type: OceanBookingPrintType) {
if (printDisabledReason.value || workflowBusy.value) return
printType.value = type
printOpen.value = true
}
async function saveActualCargo() {
if (detail.value?.status !== 'RELEASED' || !cargoWritable.value || workflowBusy.value) return
const validationError = cargoValidationError()
if (validationError || !actualReason.value.trim()) {
actionError.value = { message: validationError || '请填写实货量补录或修正原因' }
return
}
actualSaving.value = true
actionError.value = null
try {
applyDetail(await updateOceanBookingActualCargo(detail.value.id, {
expectedVersion: detail.value.version,
actualPackageCount: form.data.actualPackageCount ?? null,
actualGrossWeightKg: form.data.actualGrossWeightKg ?? null,
actualVolumeCbm: form.data.actualVolumeCbm ?? null,
reason: actualReason.value.trim(),
}))
ElMessage.success('实际货量已保存')
} catch (cause) { actionError.value = getApiErrorDetails(cause) }
finally { actualSaving.value = false }
}
function selectWarehouse(value: unknown) {
const warehouseId = value == null || value === '' ? null : String(value)
form.data.warehouseId = warehouseId
@@ -248,9 +300,18 @@ function applyDetail(value: OceanBookingDetail) {
destinationPortCode: value.destinationPortCode, destinationPortName: value.destinationPortName,
placeOfDelivery: value.placeOfDelivery,
remark: value.remark,
parties: value.parties.length ? value.parties.map(({ partyType, partyName, contactName, contactPhone, address }) => ({ partyType, partyName, contactName, contactPhone, address })) : blankParties(),
freightTerm: value.freightTerm, serviceTerm: value.serviceTerm, packageUnit: value.packageUnit,
cargoDescription: value.cargoDescription, marksAndNos: value.marksAndNos,
declaredPackageCount: value.declaredPackageCount ?? null,
declaredGrossWeightKg: value.declaredGrossWeightKg == null ? null : Number(value.declaredGrossWeightKg),
declaredVolumeCbm: value.declaredVolumeCbm == null ? null : Number(value.declaredVolumeCbm),
actualPackageCount: value.actualPackageCount ?? null,
actualGrossWeightKg: value.actualGrossWeightKg == null ? null : Number(value.actualGrossWeightKg),
actualVolumeCbm: value.actualVolumeCbm == null ? null : Number(value.actualVolumeCbm),
parties: blankParties().map((blank) => ({ ...blank, ...value.parties.find((party) => party.partyType === blank.partyType) })),
containerRequirements: value.containerRequirements.length ? value.containerRequirements.map(({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg }) => ({ requirementId, containerType, containerKind, quantity, remainingQuantity, capacityCbm, maxWeightKg })) : [createBlankContainerRequirement()],
}
actualReason.value = ''
selectedChannelName.value = value.channelName || ''
savedDraftSignature.value = draftSignature()
channels.value = []
@@ -265,9 +326,24 @@ async function load() {
catch (cause) { error.value = getApiErrorDetails(cause) }
finally { loading.value = false }
}
function cargoValidationError() {
for (const kind of ['declared', 'actual'] as const) {
const count = form.data[`${kind}PackageCount`]
if (count != null && (!Number.isInteger(count) || count < 1 || count > 999999999)) return '货物件数必须为1到999999999的整数'
for (const number of [form.data[`${kind}GrossWeightKg`], form.data[`${kind}VolumeCbm`]]) {
if (number != null && (!Number.isFinite(number) || number <= 0 || number > 999999999999.999)) return '货物重量和体积必须为有效正数'
}
}
return ''
}
async function validateDraftForm() {
const valid = await formRef.value?.validate().catch(() => false)
if (valid === false) { actionError.value = { message: '请检查表单中的必填项和格式' }; return false }
const cargoMessage = cargoValidationError()
if (cargoMessage) { actionError.value = { message: cargoMessage }; return false }
if (form.data.parties.some((party) => party.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(party.contactEmail))) {
actionError.value = { message: '请填写有效的关系方邮箱' }; return false
}
const requirementMessage = containerRequirementError(form.data.containerRequirements)
if (requirementMessage) { actionError.value = { message: requirementMessage }; return false }
return true
@@ -283,10 +359,13 @@ async function persistDraft(showSuccess: boolean) {
if (showSuccess) ElMessage.success(isNew.value ? `订舱创建成功:${saved.bookingNo || '未生成订舱号'}` : '订舱草稿已保存')
if (isNew.value) {
workspace.removeByIds(['ocean-booking-create'])
await router.replace('/ocean-bookings')
applyDetail(saved)
workspace.bumpCacheEpoch('ocean-bookings')
await router.replace(`/ocean-bookings/${encodeURIComponent(saved.id)}`)
return saved
}
applyDetail(saved)
workspace.bumpCacheEpoch('ocean-bookings')
return saved
} catch (cause) { actionError.value = getApiErrorDetails(cause); return null }
}
@@ -377,53 +456,122 @@ onMounted(() => { void load(); void loadOptions() })
<template #header>
<div class="detail-heading">
<el-button link class="density-back" @click="router.push('/ocean-bookings')"><ArrowLeft :size="16" />返回列表</el-button>
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱草稿'" :description="detail ? `${detail.customerName ? `${detail.customerName} · ` : ''}${detail.pol || '—'} → ${detail.pod || '—'}` : ''">
<template v-if="detail" #actions><div class="heading-status"><StatusBadge :status="statusLabel(detail.status)" /></div></template>
<span class="booking-brand" aria-hidden="true"><Ship :size="22" /></span>
<PageToolbar class="booking-page-toolbar" as="section" :title="detail?.bookingNo || '订舱信息管理'" :description="detail ? `${detail.customerName ? `${detail.customerName} · ` : ''}${detail.pol || '—'} → ${detail.pod || '—'}` : '出口订舱作业'">
<template #actions>
<div class="actions booking-page-actions">
<StatusBadge v-if="detail" :status="statusLabel(detail.status)" />
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
<el-button v-if="canSave" plain :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
</div>
</template>
</PageToolbar>
</div>
</template>
<LoadErrorState v-if="error" title="订舱详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-else>
<section class="workbench-panel is-compact">
<div class="section-title">
<h2>订舱信息</h2>
<div class="actions">
<el-tooltip :content="canPrint ? '按当前已保存资料打印出口货物托运单或提单确认件' : '暂无订舱单据打印权限'" placement="top"><span><el-button v-if="detail" plain :disabled="!canPrint || workflowBusy" @click="printOpen = true"><Printer :size="16" />打印单据</el-button></span></el-tooltip>
<el-button v-if="canDelete" type="danger" plain :loading="actionSaving" :disabled="workflowBusy" @click="removeDraft"><Trash2 :size="16" />删除草稿</el-button>
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" :disabled="workflowBusy" @click="openReleaseDialog">放舱</el-button>
<el-button v-if="canSave" type="primary" :loading="saving" :disabled="workflowBusy" @click="save"><Save :size="16" />保存草稿</el-button>
</div>
<ol class="booking-workflow" aria-label="订舱作业流程">
<li v-for="(step, index) in workflowSteps" :key="step.label" :data-state="step.state" :aria-current="step.state === 'current' ? 'step' : undefined">
<span class="booking-workflow__index" aria-hidden="true"><Check v-if="step.state === 'complete'" :size="16" /><template v-else>{{ index + 1 }}</template></span>
<span class="booking-workflow__label">{{ step.label }}</span>
<span class="booking-workflow__status">{{ step.status }}</span>
</li>
</ol>
<section class="workbench-panel is-compact booking-section booking-section--core">
<div class="section-title booking-section__heading">
<h2><span class="booking-section__number" aria-hidden="true">1</span>一、核心订舱信息</h2>
</div>
<el-alert v-if="optionsError" type="warning" :closable="false" show-icon :title="optionsError"><el-button link type="warning" :loading="optionsLoading" @click="loadOptions">重新加载选项</el-button></el-alert>
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!formWritable" class="booking-groups density-form">
<div class="booking-core">
<div class="booking-core__main">
<el-form ref="formRef" :model="form.data" :rules="rules" label-position="top" :disabled="!formWritable" class="booking-groups density-form">
<section class="booking-group" aria-label="基本信息">
<h3>基本信息</h3><div class="booking-group__body booking-group__body--fields">
<h3 class="booking-group__title"><span>基础信息</span></h3>
<div class="booking-group__body booking-group__body--fields">
<el-form-item label="订舱号"><el-input :model-value="detail?.bookingNo || '保存后生成'" disabled /></el-form-item>
<el-form-item label="客户(选填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="单客户可选择,多客户可留空" no-data-text="暂无可选客户" :loading="customersLoading" remote :remote-method="loadCustomers" @end-reached="customerEndReached" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select><LoadErrorState v-if="customersError" title="客户选项加载失败" :message="customersError.message" :loading="customersLoading" compact @retry="retryCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState></el-form-item>
<el-form-item label="状态"><el-input :model-value="detail ? statusLabel(detail.status) : '保存后为草稿'" disabled /></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><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 :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
<el-form-item label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
<el-form-item label="船公司"><el-select :model-value="form.data.shippingCompanyId" filterable clearable placeholder="请选择船公司" :loading="optionsLoading" style="width:100%" @update:model-value="selectShippingCompany(String($event || ''))"><el-option v-if="form.data.shippingCompanyId && !shippingCompanies.some((partner) => String(partner.id) === String(form.data.shippingCompanyId))" :label="form.data.shippingCompanyName || '船公司名称缺失'" :value="String(form.data.shippingCompanyId)" /><el-option v-for="partner in shippingCompanies" :key="String(partner.id)" :label="partner.partnerName" :value="String(partner.id)" /></el-select></el-form-item>
<el-form-item label="订舱代理"><el-select :model-value="form.data.bookingAgentId" filterable clearable :disabled="!editable || !form.data.carrierCode || bookingAgentsLoading || !!bookingAgentsError" :placeholder="form.data.carrierCode ? '请选择订舱代理' : '请先选择船公司'" :loading="bookingAgentsLoading" no-data-text="当前船公司暂无可用订舱代理" style="width:100%" @update:model-value="selectBookingAgent(String($event || ''))"><el-option v-if="!editable && form.data.bookingAgentId && !bookingAgents.some((supplier) => supplier.supplierId === String(form.data.bookingAgentId))" :label="form.data.bookingAgentName || '订舱代理名称缺失'" :value="String(form.data.bookingAgentId)" /><el-option v-for="supplier in bookingAgents" :key="supplier.supplierId" :label="supplier.name" :value="supplier.supplierId" /></el-select><div v-if="bookingAgentsError" class="field-error"><ApiErrorMeta :details="bookingAgentsError" /><el-button link type="primary" :loading="bookingAgentsLoading" @click="form.data.carrierCode && loadBookingAgents(form.data.carrierCode)">重新加载</el-button></div></el-form-item>
<el-form-item label="客户(选填)"><el-select :model-value="form.data.customerId" filterable clearable placeholder="单客户可选择,多客户可留空" no-data-text="暂无可选客户" :loading="customersLoading" remote :remote-method="loadCustomers" @end-reached="customerEndReached" style="width:100%" @update:model-value="selectCustomer"><el-option v-if="form.data.customerId && !selectableCustomers.some((customer) => String(customer.id) === String(form.data.customerId))" :label="form.data.customerName || '客户名称缺失'" :value="String(form.data.customerId)" /><el-option v-for="customer in selectableCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select><LoadErrorState v-if="customersError" title="客户选项加载失败" :message="customersError.message" :loading="customersLoading" compact @retry="retryCustomers"><template #details><ApiErrorMeta :details="customersError" /></template></LoadErrorState></el-form-item>
<el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="form.data.warehouseId" filterable placeholder="请选择装柜仓库" :loading="optionsLoading" style="width:100%" @update:model-value="selectWarehouse"><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 :model-value="form.data.channelCode" filterable :disabled="!formWritable || !form.data.warehouseId || channelsLoading || !!channelsError || channelUnavailable" :loading="channelsLoading" :placeholder="form.data.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="该仓库暂无有效物流渠道" style="width:100%" @update:model-value="selectChannel(String($event || ''))"><el-option v-if="form.data.channelCode && !channelGroups.some((channel) => channel.code === form.data.channelCode)" :label="selectedChannelName || form.data.channelCode" :value="form.data.channelCode" /><el-option v-for="channel in channelGroups" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select><div v-if="channelsError" class="field-error"><span>{{ channelsError }}</span><el-button link type="primary" :loading="channelsLoading" @click="form.data.warehouseId && loadChannels(form.data.warehouseId)">重新加载</el-button></div><div v-else-if="channelUnavailable" class="field-hint">该仓库暂无有效物流渠道,请更换仓库或联系管理员。</div></el-form-item>
</div>
</section>
<section class="booking-group" aria-label="航线船期">
<h3>航线船期</h3><div class="booking-group__body booking-group__body--fields">
<el-form-item label="船名" prop="vesselName"><el-input v-model="form.data.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="form.data.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item label="起运港代码"><el-input v-model="form.data.departurePortCode" placeholder="例如:CNNGB" /></el-form-item>
<el-form-item label="起运港"><el-input v-model="form.data.departurePortName" placeholder="例如:宁波港" /></el-form-item>
<el-form-item label="目的港代码"><el-input v-model="form.data.destinationPortCode" placeholder="例如:USLAX" /></el-form-item>
<el-form-item label="目的港"><el-input v-model="form.data.destinationPortName" placeholder="例如:洛杉矶港" /></el-form-item>
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
<h3 class="booking-group__title"><span>港口航线</span></h3>
<div class="booking-group__body booking-group__body--fields">
<el-form-item label="起运港"><div class="booking-port-fields"><el-input v-model="form.data.departurePortCode" placeholder="港代码" aria-label="起运港代码" /><el-input v-model="form.data.departurePortName" placeholder="港口名称" aria-label="起运港名称" /></div></el-form-item>
<el-form-item label="目的港"><div class="booking-port-fields"><el-input v-model="form.data.destinationPortCode" placeholder="港代码" aria-label="目的港代码" /><el-input v-model="form.data.destinationPortName" placeholder="港口名称" aria-label="目的港名称" /></div></el-form-item>
<el-form-item label="预计开船" prop="estimatedSailingAt"><el-date-picker v-model="form.data.estimatedSailingAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计开船时间" style="width:100%" /></el-form-item>
<el-form-item label="预计到港" prop="estimatedArrivalAt"><el-date-picker v-model="form.data.estimatedArrivalAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择预计到港时间" style="width:100%" /></el-form-item>
<el-form-item class="booking-field--wide" label="交货地点"><el-input v-model="form.data.placeOfDelivery" placeholder="请输入最终交货地点" /></el-form-item>
<el-form-item label="运费条款"><el-input v-model="form.data.freightTerm" maxlength="64" /></el-form-item>
<el-form-item label="运输条款"><el-input v-model="form.data.serviceTerm" maxlength="64" /></el-form-item>
</div>
</section>
</el-form>
<section class="booking-group" aria-label="货物信息">
<h3 class="booking-group__title"><span>货物信息</span></h3>
<el-form label-position="top" class="density-form booking-group__body">
<div class="cargo-metrics" role="group" aria-label="货物预报与实际数量">
<div class="cargo-metric">
<span class="cargo-metric__label">件数(委 / 实)</span>
<div class="cargo-metric__inputs">
<el-input-number v-model="form.data.declaredPackageCount" :disabled="!formWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="预报量" aria-label="预报件数" />
<span class="cargo-metric__separator" aria-hidden="true">/</span>
<el-input-number v-model="form.data.actualPackageCount" :disabled="!cargoWritable" :min="1" :max="999999999" :precision="0" :controls="false" placeholder="待录实值" aria-label="实际件数" />
<span class="cargo-metric__unit">{{ form.data.packageUnit || '—' }}</span>
</div>
</div>
<div class="cargo-metric">
<span class="cargo-metric__label">毛重(委 / 实)</span>
<div class="cargo-metric__inputs">
<el-input-number v-model="form.data.declaredGrossWeightKg" :disabled="!formWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="预报量" aria-label="预报毛重" />
<span class="cargo-metric__separator" aria-hidden="true">/</span>
<el-input-number v-model="form.data.actualGrossWeightKg" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际毛重" />
<span class="cargo-metric__unit">KGS</span>
</div>
</div>
<div class="cargo-metric">
<span class="cargo-metric__label">体积(委 / 实)</span>
<div class="cargo-metric__inputs">
<el-input-number v-model="form.data.declaredVolumeCbm" :disabled="!formWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="预报量" aria-label="预报体积" />
<span class="cargo-metric__separator" aria-hidden="true">/</span>
<el-input-number v-model="form.data.actualVolumeCbm" :disabled="!cargoWritable" :min="0.001" :max="999999999999.999" :precision="3" :controls="false" placeholder="待录实值" aria-label="实际体积" />
<span class="cargo-metric__unit">CBM</span>
</div>
</div>
</div>
<div class="booking-group__body--fields">
<el-form-item label="包装单位"><el-select v-model="form.data.packageUnit" :disabled="!formWritable" placeholder="请选择包装单位"><el-option label="箱 CARTONS" value="CARTONS" /><el-option label="件 PCS" value="PCS" /><el-option label="托 PALLETS" value="PALLETS" /></el-select></el-form-item>
<el-form-item label="唛头和号码"><el-input v-model="form.data.marksAndNos" :disabled="!formWritable" maxlength="1000" /></el-form-item>
<el-form-item class="booking-field--wide" label="品名及托运要求"><el-input v-model="form.data.cargoDescription" :disabled="!formWritable" type="textarea" :rows="2" maxlength="1000" placeholder="填写实际品名及免用箱等托运要求" /></el-form-item>
</div>
<template v-if="detail?.status === 'RELEASED' && cargoWritable">
<el-form-item label="补录或修正原因" required><el-input v-model="actualReason" type="textarea" :rows="2" maxlength="1000" placeholder="请填写本次补录或修正原因" /></el-form-item>
<div class="actions"><el-button type="primary" :loading="actualSaving" :disabled="workflowBusy || !hasUnsavedActualChanges" @click="saveActualCargo">保存实际货量</el-button></div>
</template>
</el-form>
</section>
</div>
<aside class="booking-parties" aria-label="订舱关系方"><h3 class="booking-group__title"><span>订舱关系方</span></h3>
<el-form class="party-grid density-form" label-position="top" :disabled="!formWritable">
<section v-for="(party, index) in form.data.parties" :key="party.partyType" class="subsection booking-party-card">
<h3>{{ partyTypes[index]?.label || party.partyType }}</h3>
<el-form-item label="单位名称" :required="true"><el-input v-model="party.partyName" :disabled="!formWritable" maxlength="255" :aria-label="`${partyTypes[index]?.label || party.partyType}单位名称(必填)`" placeholder="单位名称" /></el-form-item>
<el-form-item label="联系人" ><el-input v-model="party.contactName" :disabled="!formWritable" maxlength="128" :aria-label="`${partyTypes[index]?.label || party.partyType}联系人`" placeholder="联系人" /></el-form-item>
<el-form-item label="邮箱" ><el-input v-model="party.contactEmail" :disabled="!formWritable" maxlength="255" :aria-label="`${partyTypes[index]?.label || party.partyType}邮箱`" placeholder="邮箱" /></el-form-item>
<el-form-item label="联系电话" ><el-input v-model="party.contactPhone" :disabled="!formWritable" maxlength="64" :aria-label="`${partyTypes[index]?.label || party.partyType}联系电话`" placeholder="联系电话" /></el-form-item>
<el-form-item label="税号" ><el-input v-model="party.taxNo" :disabled="!formWritable" maxlength="128" :aria-label="`${partyTypes[index]?.label || party.partyType}税号`" placeholder="税号" /></el-form-item>
<el-form-item label="地址" ><el-input v-model="party.address" :disabled="!formWritable" maxlength="1000" :aria-label="`${partyTypes[index]?.label || party.partyType}地址`" placeholder="地址" type="textarea" :rows="2" /></el-form-item>
</section>
</el-form>
</aside>
</div>
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-requirements">
<section class="booking-group" aria-label="柜型需求">
<h3>柜型需求</h3><div class="booking-group__body">
<h3 class="booking-group__title"><span>柜型需求与备注</span></h3><div class="booking-group__body">
<div class="container-table-wrap" aria-label="柜型需求">
<div class="workbench-evidence"><table class="container-table density-table">
<thead><tr><th scope="col">操作</th><th scope="col">柜型</th><th scope="col">箱种</th><th scope="col">数量</th><th scope="col">容量</th><th scope="col">限重</th><th v-if="detail?.status === 'RELEASED'" scope="col">剩余可关联</th></tr></thead>
@@ -450,19 +598,21 @@ onMounted(() => { void load(); void loadOptions() })
</div>
</section>
</el-form>
<footer class="booking-section__footer">
<el-tooltip :content="printDisabledReason || '按已保存预报货量打印托运单'" placement="top"><span><el-button type="primary" :disabled="!!printDisabledReason || workflowBusy" @click="openPrint('SHIPPING_INSTRUCTION')"><Printer :size="16" />打印托运单</el-button></span></el-tooltip>
</footer>
</section>
<section class="workbench-panel is-compact">
<h2>订舱关系方</h2>
<div class="party-grid">
<div v-for="(party, index) in form.data.parties" :key="party.partyType" class="subsection">
<h3><span class="required-mark" aria-hidden="true">*</span>{{ partyTypes[index]?.label || party.partyType }}</h3>
<el-input v-model="party.partyName" :disabled="!formWritable" :aria-label="`${partyTypes[index]?.label || party.partyType}单位名称(必填)`" placeholder="单位名称(必填)" />
<el-input v-model="party.contactName" :disabled="!formWritable" placeholder="联系人" />
<el-input v-model="party.contactPhone" :disabled="!formWritable" placeholder="联系电话" />
<el-input v-model="party.address" :disabled="!formWritable" placeholder="地址" />
</div>
</div>
<section class="workbench-panel is-compact booking-section booking-section--release" aria-label="放舱信息">
<div class="section-title booking-section__heading"><h2><span class="booking-section__number" aria-hidden="true">3</span>二、放舱信息</h2></div>
<el-form :model="form.data" label-position="top" :disabled="!formWritable" class="density-form booking-group__body--fields">
<el-form-item label="船名" prop="vesselName"><el-input v-model="form.data.vesselName" maxlength="128" placeholder="请输入船名" /></el-form-item>
<el-form-item label="航次" prop="voyageNo"><el-input v-model="form.data.voyageNo" maxlength="64" placeholder="请输入航次" /></el-form-item>
<el-form-item class="booking-field--wide" label="提单号" prop="billOfLadingNo"><el-input v-model="form.data.billOfLadingNo" maxlength="128" placeholder="请输入提单号" /></el-form-item>
</el-form>
<footer class="booking-section__footer">
<el-button v-if="canRelease" type="primary" plain :loading="actionSaving" :disabled="workflowBusy" @click="openReleaseDialog">放舱</el-button>
<el-tooltip :content="printDisabledReason || '按已保存实际货量打印提单确认件'" placement="top"><span><el-button type="primary" :disabled="!!printDisabledReason || workflowBusy" @click="openPrint('BILL_OF_LADING_CONFIRMATION')"><Printer :size="16" />打印提单确认件</el-button></span></el-tooltip>
</footer>
</section>
<OceanBookingDocumentsPanel v-if="detail" :booking-id="detail.id" :booking-no="detail.bookingNo" :booking-version="detail.version" :booking-status="detail.status" :documents="detail.documents" :can-manage="canManageDocuments" :manage-reason="documentManageReason" @updated="updateDocuments" />
@@ -471,15 +621,14 @@ onMounted(() => { void load(); void loadOptions() })
<section v-if="detail" class="workbench-panel is-compact history-panel">
<div class="section-title"><div><h2>状态历史</h2><p>当前进度:{{ statusLabel(detail.status) }}</p></div></div>
<div class="workbench-evidence"><table class="density-table booking-history-table"><thead><tr><th>时间</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="event in detail.histories" :key="event.id"><td>{{ event.createdAt || '—' }}</td><td>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</td><td>{{ event.operatorName || '系统' }}</td><td>{{ event.reason || '—' }}</td></tr><tr v-if="!detail.histories.length"><td colspan="4">暂无状态历史</td></tr></tbody></table></div>
<div class="workbench-evidence"><table class="density-table booking-history-table"><thead><tr><th>时间</th><th>操作</th><th>状态变化</th><th>操作人</th><th>备注</th></tr></thead><tbody><tr v-for="event in detail.histories" :key="event.id"><td>{{ event.createdAt || '—' }}</td><td>{{ historyActionLabel(event.action) }}</td><td>{{ statusLabel(event.fromStatus) }} → {{ statusLabel(event.toStatus) }}</td><td>{{ event.operatorName || '系统' }}</td><td>{{ event.reason || '—' }}</td></tr><tr v-if="!detail.histories.length"><td colspan="5">暂无状态历史</td></tr></tbody></table></div>
</section>
</template>
</DetailPageLayout>
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" />
<OceanBookingPrintDialog v-if="detail" v-model="printOpen" :booking-id="detail.id" :booking-no="detail.bookingNo" :allowed="canPrint" :initial-type="printType" @previewed="onPrintPreviewed" />
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="releaseDialogOpen" :title="bookingDialogTitle('确认放舱')" width="min(560px, 94vw)" :confirm-loading="actionSaving" :confirm-disabled="workflowBusy" confirm-text="确认放舱" cancel-text="取消" @confirm="submitRelease">
<div class="density-dialog-sections">
<p class="density-dialog-note">放舱后资料将同步到关联货柜,不能修改、不能删除,请确认资料无误。</p>
<section class="density-dialog-section"><el-descriptions class="release-summary density-descriptions" :column="2" border>
<el-descriptions-item label="提单号">{{ form.data.billOfLadingNo || '—' }}</el-descriptions-item>
<el-descriptions-item label="船名">{{ form.data.vesselName || '—' }}</el-descriptions-item>
@@ -493,6 +642,196 @@ onMounted(() => { void load(); void loadOptions() })
</template>
<style scoped>
.channel-code{display:block;margin-left:var(--admin-space-3);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.field-error,.field-hint{display:flex;align-items:center;justify-content:space-between;gap:var(--admin-space-3);margin-top:5px;color:var(--workbench-muted);font-size:var(--admin-font-caption);line-height:20px}.field-error{color:var(--el-color-danger)}
.booking-detail{min-width:0}.detail-heading{display:flex;min-width:0;gap:var(--admin-space-5);padding:var(--admin-space-3) var(--admin-space-5);background:var(--admin-surface);border:1px solid var(--workbench-border);align-items:center}.booking-page-toolbar{min-width:0;flex:1}.section-title p,.history-panel p{margin:5px 0 0;color:var(--workbench-muted);font-size:var(--admin-font-body)}.heading-status{display:flex;align-items:center;gap:var(--admin-space-3)}.surface-panel h2{margin:0;font-size:var(--admin-font-section)}.section-title{display:flex;justify-content:space-between;gap:var(--admin-space-5);align-items:flex-start;margin-bottom:var(--admin-space-5)}.actions{display:flex;flex-wrap:wrap;gap:var(--admin-space-3)}.booking-groups{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(0,1fr);gap:var(--admin-space-5)}.booking-group{min-width:0;overflow:hidden;background:var(--admin-surface);border:0}.booking-group:last-child{grid-column:1/-1}.booking-group>h3{margin:0;color:#1f2937;font-size:var(--admin-font-section);padding:var(--admin-space-3) 0;border-bottom:1px solid var(--workbench-border)}.booking-group__body{padding:var(--admin-space-3) 0 0}.booking-group__body--fields,.booking-terms{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:var(--admin-space-4);row-gap:var(--admin-space-3)}.booking-field--wide{grid-column:1/-1}.booking-terms :deep(.el-select){width:100%}.party-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:var(--admin-space-4);margin-top:var(--admin-space-5)}.subsection{display:grid;gap:var(--admin-space-4);border:0;padding:0}.subsection h3{margin:0;font-size:var(--admin-font-section)}.required-mark{margin-right:var(--admin-space-1);color:var(--el-color-danger)}.container-table-wrap{max-width:100%;overflow-x:auto;margin-bottom:var(--admin-space-4)}.container-table{width:100%;min-width:780px;table-layout:fixed}.container-table th:first-child,.container-table td:first-child{width:80px}.container-table th:nth-child(2),.container-table td:nth-child(2){width:180px}.container-table th:nth-child(3),.container-table td:nth-child(3){width:90px}.container-table th:nth-child(4),.container-table td:nth-child(4){width:110px}.container-table th:nth-child(5),.container-table td:nth-child(5),.container-table th:nth-child(6),.container-table td:nth-child(6){width:100px}.container-table td :deep(.el-select),.container-table td :deep(.el-input){width:100%;min-width:0}.container-table__action{text-align:center!important}.container-table__specification{color:var(--workbench-muted);white-space:nowrap}.container-actions{display:flex;align-items:center;gap:var(--admin-space-4);margin-bottom:var(--admin-space-5);color:var(--workbench-muted);font-size:var(--admin-font-caption)}.add-container{margin:0}@media(max-width:720px){.booking-groups,.party-grid{grid-template-columns:1fr}.booking-group:last-child{grid-column:auto}.detail-heading{flex-direction:column;padding:var(--admin-space-3) var(--admin-space-5);background:var(--admin-surface);border:1px solid var(--workbench-border);align-items:center}.booking-page-toolbar{width:100%}.section-title{flex-direction:column}.actions{width:100%}}@media(max-width:520px){.booking-group__body--fields,.booking-terms{grid-template-columns:1fr}.booking-field--wide{grid-column:auto}}@media(max-width:420px){.booking-group__body{padding:var(--admin-space-3) 0 0}.actions :deep(.el-button){min-height:44px}}.booking-history-table th:nth-child(1){width:180px}.booking-history-table th:nth-child(2){width:220px}.booking-history-table th:nth-child(3){width:140px}.booking-page-toolbar :deep(h1){font-size:var(--admin-font-title)}
.booking-detail {
min-width: 0;
}
.detail-heading {
display: flex;
align-items: center;
gap: var(--admin-space-5);
min-width: 0;
padding: var(--admin-space-3) 0 var(--admin-space-5);
border-bottom: 1px solid var(--workbench-border);
}
.booking-brand {
display: grid;
flex: 0 0 36px;
place-items: center;
width: 36px;
height: 36px;
border-radius: var(--admin-dialog-section-radius);
color: #fff;
background: var(--workbench-primary);
box-shadow: 0 3px 8px rgb(55 132 250 / 24%);
}
.booking-page-toolbar { min-width: 0; flex: 1; }
.booking-page-toolbar :deep(h1) { font-size: var(--admin-font-title); }
.booking-page-toolbar :deep(.page-toolbar__description) { margin-top: var(--admin-space-1); font-size: var(--admin-font-caption); color: var(--workbench-muted); }
.booking-page-actions { align-items: center; }
.actions { display: flex; flex-wrap: wrap; gap: var(--admin-space-3); }
.booking-workflow {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
list-style: none;
margin: 0 0 var(--admin-space-6);
padding: var(--admin-space-6) var(--admin-space-5);
border: 1px solid var(--workbench-border);
border-radius: var(--admin-dialog-section-radius);
background: var(--workbench-surface-tint);
}
.booking-workflow li { position: relative; display: grid; justify-items: center; align-content: start; gap: var(--admin-space-3); min-width: 0; }
.booking-workflow li:not(:last-child)::after {
position: absolute;
top: 14px;
left: calc(50% + 16px);
width: calc(100% - 32px);
height: 2px;
background: var(--workbench-border);
content: '';
}
.booking-workflow__index {
z-index: 1;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
color: var(--workbench-muted);
background: var(--workbench-surface-soft);
border: 1px solid var(--workbench-border);
box-sizing: border-box;
font-size: var(--admin-font-section);
font-weight: 600;
}
.booking-workflow__label { padding: 0 var(--admin-space-2); color: var(--workbench-text); font-size: var(--admin-font-caption); font-weight: 600; text-align: center; line-height: 1.5; }
.booking-workflow__status { color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 18px; text-align: center; }
.booking-workflow li[data-state='current'] .booking-workflow__index { border-color: var(--workbench-primary); color: #fff; background: var(--workbench-primary); box-shadow: 0 0 0 4px var(--workbench-accent-soft); }
.booking-workflow li[data-state='current'] .booking-workflow__label,
.booking-workflow li[data-state='current'] .booking-workflow__status { color: var(--workbench-primary-active); }
.booking-workflow li[data-state='complete'] .booking-workflow__index { color: var(--workbench-primary-active); background: var(--workbench-accent-soft); border-color: var(--workbench-accent-border); }
.booking-workflow li[data-state='complete']::after { background: var(--workbench-primary); }
.booking-workflow li[data-state='pending'] .booking-workflow__label { color: var(--workbench-muted); font-weight: 500; }
.booking-detail.admin-workbench .booking-section {
padding: var(--admin-space-6);
margin-bottom: var(--admin-space-6);
border-radius: var(--admin-dialog-section-radius);
}
.section-title { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-5); }
.booking-section__heading { margin-bottom: var(--admin-space-6); }
.booking-section__heading h2 {
display: flex;
align-items: center;
gap: var(--admin-space-3);
margin: 0;
color: var(--workbench-text);
font-size: var(--admin-font-section);
font-weight: 700;
line-height: 24px;
}
.booking-section__number {
display: inline-grid;
flex: 0 0 24px;
place-items: center;
width: 24px;
height: 24px;
border-radius: var(--admin-action-radius);
background: var(--workbench-primary);
color: #fff;
box-shadow: 0 2px 5px rgb(55 132 250 / 20%);
font-size: var(--admin-font-caption);
}
.booking-core { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: var(--admin-space-7); }
.booking-core__main, .booking-parties, .booking-group { min-width: 0; }
.booking-groups { display: grid; grid-template-columns: 1fr; gap: var(--admin-space-6); }
.booking-group:last-child { grid-column: 1 / -1; }
.booking-group__title {
display: flex;
align-items: center;
gap: var(--admin-space-3);
margin: 0 0 var(--admin-space-5);
color: var(--workbench-muted);
font-size: var(--admin-font-caption);
font-weight: 600;
line-height: 20px;
}
.booking-group__title::before { flex: 0 0 5px; width: 5px; height: 5px; border-radius: 50%; background: var(--workbench-accent-border); content: ''; }
.booking-group__title::after { flex: 1; height: 1px; background: var(--workbench-border); content: ''; }
.booking-group__body--fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: var(--admin-space-5); row-gap: var(--admin-space-3); }
.booking-field--wide { grid-column: 1 / -1; }
.booking-port-fields { display: grid; grid-template-columns: minmax(76px, .65fr) minmax(0, 1fr); gap: var(--admin-space-2); width: 100%; }
.booking-core__main > .booking-group { margin-top: var(--admin-space-6); }
.booking-parties { padding-left: var(--admin-space-7); border-left: 1px solid var(--workbench-border); }
.party-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); align-items: start; }
.booking-party-card { display: grid; min-width: 0; gap: var(--admin-space-3); padding: var(--admin-space-4); border: 1px solid var(--workbench-border); border-radius: var(--admin-dialog-section-radius); background: var(--workbench-surface-tint); }
.booking-party-card h3 { margin: 0 0 var(--admin-space-1); padding-bottom: var(--admin-space-3); border-bottom: 1px dashed var(--workbench-border); color: var(--workbench-text); font-size: var(--admin-font-body); font-weight: 600; line-height: 20px; }
/* 网格间距负责排版,避免再叠加 Element 表单项的默认下边距。 */
.booking-detail :deep(.density-form .el-form-item) { margin-bottom: 0; }
.booking-detail :deep(.density-form .el-form-item__label) { height: auto; margin-bottom: var(--admin-space-1); padding: 0; color: var(--workbench-muted); font-size: var(--admin-font-caption); font-weight: 500; line-height: 18px; }
.booking-detail :deep(.density-form .el-input),
.booking-detail :deep(.density-form .el-select),
.booking-detail :deep(.density-form .el-textarea) {
--el-input-bg-color: var(--workbench-surface);
--el-fill-color-blank: var(--workbench-surface);
--el-input-text-color: var(--workbench-text);
--el-input-border-color: var(--el-border-color-darker);
--el-input-hover-border-color: var(--workbench-primary-hover);
--el-input-focus-border-color: var(--workbench-primary);
--el-border-color: var(--el-border-color-darker);
--el-border-color-hover: var(--workbench-primary-hover);
}
/* 白底和清晰文字表示可编辑;禁用控件继续使用组件原生灰底、弱文字及禁止光标。 */
.booking-detail :deep(.density-form .el-input:not(.is-disabled)),
.booking-detail :deep(.density-form .el-textarea:not(.is-disabled)),
.booking-detail :deep(.density-form .el-select__wrapper:not(.is-disabled)) {
--el-input-placeholder-color: var(--workbench-muted);
--el-text-color-placeholder: var(--workbench-muted);
}
.booking-detail :deep(.density-form .el-input__inner),
.booking-detail :deep(.density-form .el-textarea__inner),
.booking-detail :deep(.density-form .el-select__wrapper) { font-size: var(--admin-font-body); }
.booking-detail :deep(.density-form .el-input__wrapper),
.booking-detail :deep(.density-form .el-select__wrapper) { min-height: var(--admin-control-height); box-sizing: border-box; }
.cargo-metrics { display: grid; gap: var(--admin-space-3); margin-bottom: var(--admin-space-5); }
.cargo-metric { display: grid; gap: var(--admin-space-1); }
.cargo-metric__label { color: var(--workbench-muted); font-size: var(--admin-font-caption); font-weight: 500; line-height: 18px; }
.cargo-metric__inputs { display: grid; grid-template-columns: minmax(0, 1fr) 12px minmax(0, 1fr) 64px; align-items: center; gap: var(--admin-space-2); }
.cargo-metric__separator { color: var(--workbench-muted); text-align: center; }
.cargo-metric__unit { color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.cargo-metric__inputs :deep(.el-input__inner) { text-align: left; }
.booking-requirements { margin-top: var(--admin-space-6); }
.container-table-wrap { max-width: 100%; overflow-x: auto; margin-bottom: var(--admin-space-4); }
.container-table { width: 100%; min-width: 780px; table-layout: fixed; }
.container-table th:first-child, .container-table td:first-child { width: 80px; }
.container-table th:nth-child(2), .container-table td:nth-child(2) { width: 180px; }
.container-table th:nth-child(3), .container-table td:nth-child(3) { width: 90px; }
.container-table th:nth-child(4), .container-table td:nth-child(4) { width: 110px; }
.container-table th:nth-child(5), .container-table td:nth-child(5), .container-table th:nth-child(6), .container-table td:nth-child(6) { width: 100px; }
.container-table td :deep(.el-select), .container-table td :deep(.el-input) { width: 100%; min-width: 0; }
.container-table__action { text-align: center !important; }
.container-table__specification { color: var(--workbench-muted); white-space: nowrap; }
.container-actions { display: flex; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-5); color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.add-container { margin: 0; }
.booking-section__footer { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--admin-space-3); margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px dashed var(--workbench-border); }
.field-error, .field-hint { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-3); margin-top: var(--admin-space-1); color: var(--workbench-muted); font-size: var(--admin-font-caption); line-height: 20px; }
.field-error { color: var(--el-color-danger); }
.history-panel h2 { margin: 0; font-size: var(--admin-font-section); }
.history-panel p { margin: var(--admin-space-1) 0 var(--admin-space-5); color: var(--workbench-muted); font-size: var(--admin-font-caption); }
.booking-history-table th:nth-child(1) { width: 180px; }
.booking-history-table th:nth-child(2) { width: 160px; }
.booking-history-table th:nth-child(3) { width: 200px; }
.booking-history-table th:nth-child(4) { width: 140px; }
@media(max-width:720px) {
.booking-core, .booking-workflow, .party-grid { grid-template-columns: 1fr; }
.booking-parties { padding-left: 0; border-left: 0; }
.detail-heading { flex-wrap: wrap; }
.booking-page-toolbar { width: 100%; }
.booking-workflow li:not(:last-child)::after { display: none; }
}
@media(max-width:520px) {
.booking-group__body--fields { grid-template-columns: 1fr; }
.booking-field--wide { grid-column: auto; }
}
@media(max-width:420px) {
.actions :deep(.el-button) { min-height: 44px; }
}
</style>
+23 -3
View File
@@ -32,7 +32,7 @@ export interface OceanBookingAgentOption {
carrierCodes: OceanBookingCarrierCode[]
}
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null }
export interface OceanBookingParty { id?: string; partyType: string; partyName: string; contactName?: string | null; contactPhone?: string | null; address?: string | null; contactEmail?: string | null; taxNo?: string | null }
export interface OceanBookingContainerRequirement {
id?: string
requirementId?: string
@@ -53,7 +53,27 @@ export interface OceanBookingAllocatedContainer {
capacityCbm: number
maxWeightKg: number
}
export interface OceanBookingDraftData {
export interface OceanBookingCargoData {
freightTerm?: string | null
serviceTerm?: string | null
packageUnit?: 'CARTONS' | 'PCS' | 'PALLETS' | null
cargoDescription?: string | null
marksAndNos?: string | null
declaredPackageCount?: number | null
declaredGrossWeightKg?: number | null
declaredVolumeCbm?: number | null
actualPackageCount?: number | null
actualGrossWeightKg?: number | null
actualVolumeCbm?: number | null
}
export interface OceanBookingActualCargoRequest {
expectedVersion: number
actualPackageCount: number | null
actualGrossWeightKg: number | null
actualVolumeCbm: number | null
reason: string
}
export interface OceanBookingDraftData extends OceanBookingCargoData {
customerId: string | null; customerName: string | null; parties: OceanBookingParty[]; containerRequirements: OceanBookingContainerRequirement[]
channelCode: string | null; warehouseId: string | null
billOfLadingNo: string; vesselName: string; voyageNo: string
@@ -63,7 +83,7 @@ export interface OceanBookingDraftData {
placeOfDelivery?: string | null
remark?: string | null
}
export interface OceanBookingResource {
export interface OceanBookingResource extends OceanBookingCargoData {
id: string; bookingNo: string | null; status: OceanBookingStatus; version: number
customerId: string | null; customerName: string | null; billOfLadingNo: string | null
/** 订舱柜型需求 quantity 的总和,不是已关联真实柜数。 */
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { oceanBookingPrintSignature, oceanBookingWorkflow } from './workflow'
import type { OceanBookingDetail, OceanBookingResource } from './types'
function detail(): OceanBookingDetail {
// 流程只消费订舱已保存事实,不依赖权限、选项请求或页面临时输入。
const resource: OceanBookingResource = {
id: '123', bookingNo: 'OB-123', status: 'DRAFT', version: 1,
customerId: null, customerName: null, billOfLadingNo: null,
shipperName: null, consigneeName: null, containerCount: 1,
channelCode: null, channelName: null, warehouseId: null, warehouseName: null,
shippingCompanyId: null, shippingCompanyName: null, carrierCode: null, bookingAgentId: null, bookingAgentName: null,
vesselName: null, voyageNo: null, departurePortCode: null, departurePortName: null,
destinationPortCode: null, destinationPortName: null, placeOfDelivery: null,
estimatedSailingAt: null, estimatedArrivalAt: null, remark: null, createdAt: null, updatedAt: null,
orderNo: null, carrierName: null, bookingAgent: null, pol: null, pod: null,
mblShipper: null, mblConsignee: null, soNo: null, cutoffAt: null,
etd: null, atd: null, eta: null, ata: null, containerSummary: null, amount: null, currency: null,
declaredPackageCount: 500, declaredGrossWeightKg: 15000, declaredVolumeCbm: 68,
actualPackageCount: null, actualGrossWeightKg: null, actualVolumeCbm: null,
}
return { ...resource, oceanBooking: resource, parties: [], containerRequirements: [], containers: [], documents: [], histories: [], statusHistory: [] }
}
describe('ocean booking workflow milestones', () => {
it('starts on the first step and does not mark an unsaved form as complete', () => {
expect(oceanBookingWorkflow(null, false, false, {}).map(step => step.state))
.toEqual(['current', 'pending', 'pending', 'pending'])
expect(oceanBookingWorkflow(detail(), true, false, {})[0]).toMatchObject({ state: 'current', status: '修改待保存' })
})
it('advances from saved draft to preview, then to release without inventing a print record', () => {
const saved = detail()
const initial = oceanBookingWorkflow(saved, false, false, {})
expect(initial.map(step => step.state)).toEqual(['complete', 'current', 'pending', 'pending'])
expect(initial[0]?.status).toBe('已保存')
const previews = { SHIPPING_INSTRUCTION: oceanBookingPrintSignature(saved, 'SHIPPING_INSTRUCTION') }
const previewed = oceanBookingWorkflow(saved, false, false, previews)
expect(previewed.map(step => step.state)).toEqual(['complete', 'complete', 'current', 'pending'])
expect(previewed[1]?.status).toBe('已预览')
expect(previewed.some(step => step.status === '已打印')).toBe(false)
})
it('restores released progress from server data and requires all saved actual values before final completion', () => {
const saved = detail()
saved.status = 'RELEASED'
let steps = oceanBookingWorkflow(saved, false, false, {})
expect(steps[2]).toMatchObject({ state: 'complete', status: '已放舱' })
expect(steps[3]).toMatchObject({ state: 'current', status: '待补齐实值' })
saved.actualPackageCount = 480
saved.actualGrossWeightKg = 14320
saved.actualVolumeCbm = 65
const previews = { BILL_OF_LADING_CONFIRMATION: oceanBookingPrintSignature(saved, 'BILL_OF_LADING_CONFIRMATION') }
expect(oceanBookingWorkflow(saved, false, true, previews)[3]).toMatchObject({ state: 'current', status: '实值待保存' })
steps = oceanBookingWorkflow(saved, false, false, {})
expect(steps[3]).toMatchObject({ state: 'current', status: '可预览 / 打印' })
expect(oceanBookingWorkflow(saved, false, false, previews)[3]).toMatchObject({ state: 'complete', status: '已预览' })
})
it('invalidates only previews whose actual data changed', () => {
const saved = detail()
saved.status = 'RELEASED'
saved.actualPackageCount = 480; saved.actualGrossWeightKg = 14320; saved.actualVolumeCbm = 65
const previews = {
SHIPPING_INSTRUCTION: oceanBookingPrintSignature(saved, 'SHIPPING_INSTRUCTION'),
BILL_OF_LADING_CONFIRMATION: oceanBookingPrintSignature(saved, 'BILL_OF_LADING_CONFIRMATION'),
}
saved.version = 100 // 附件等引起的订舱版本变化不会伪造或抹去相同单据的预览事实。
saved.actualPackageCount = 490
let steps = oceanBookingWorkflow(saved, false, false, previews)
expect(steps[1]?.state).toBe('complete')
expect(steps[3]).toMatchObject({ state: 'current', status: '可预览 / 打印' })
saved.declaredPackageCount = 510
steps = oceanBookingWorkflow(saved, false, false, previews)
expect(steps[1]?.state).toBe('pending')
})
})
+50
View File
@@ -0,0 +1,50 @@
import type { OceanBookingDetail, OceanBookingPrintType } from './types'
export interface OceanBookingWorkflowStep {
label: string
status: string
state: 'complete' | 'current' | 'pending'
}
export type OceanBookingPreviewSignatures = Partial<Record<OceanBookingPrintType, string>>
// 预览只对应其实际消费的数据;补录实值不会作废已经预览的托运单。
export function oceanBookingPrintSignature(detail: OceanBookingDetail, type: OceanBookingPrintType): string {
return JSON.stringify([
detail.id, detail.billOfLadingNo, detail.vesselName, detail.voyageNo, detail.estimatedSailingAt,
detail.shippingCompanyName, detail.bookingAgentName,
detail.departurePortCode, detail.departurePortName, detail.destinationPortCode, detail.destinationPortName,
detail.placeOfDelivery, detail.serviceTerm, detail.freightTerm, detail.packageUnit,
detail.cargoDescription, detail.marksAndNos, detail.remark,
detail.parties.map(party => [party.partyType, party.partyName, party.address, party.contactName, party.contactPhone, party.contactEmail, party.taxNo]).sort((a, b) => String(a[0]).localeCompare(String(b[0]))),
detail.containerRequirements.map(item => [item.containerType, item.containerKind, item.quantity]),
type === 'SHIPPING_INSTRUCTION'
? [detail.declaredPackageCount, detail.declaredGrossWeightKg, detail.declaredVolumeCbm]
: [detail.actualPackageCount, detail.actualGrossWeightKg, detail.actualVolumeCbm],
])
}
export function oceanBookingWorkflow(
detail: OceanBookingDetail | null,
draftDirty: boolean,
actualDirty: boolean,
previews: OceanBookingPreviewSignatures,
): OceanBookingWorkflowStep[] {
const saved = Boolean(detail && !draftDirty)
const released = detail?.status === 'RELEASED'
const actualComplete = Boolean(detail && [detail.actualPackageCount, detail.actualGrossWeightKg, detail.actualVolumeCbm]
.every(value => value != null && Number(value) > 0))
const previewed = (type: OceanBookingPrintType) => Boolean(detail && previews[type] === oceanBookingPrintSignature(detail, type))
const instructionPreviewed = saved && previewed('SHIPPING_INSTRUCTION')
const confirmationPreviewed = saved && released && actualComplete && !actualDirty && previewed('BILL_OF_LADING_CONFIRMATION')
const current = !saved ? 0 : !released ? instructionPreviewed ? 2 : 1 : confirmationPreviewed ? -1 : 3
const step = (index: number, label: string, complete: boolean, status: string): OceanBookingWorkflowStep => ({
label, status, state: complete ? 'complete' : current === index ? 'current' : 'pending',
})
return [
step(0, '填写并保存订舱信息', saved, saved ? '已保存' : detail ? '修改待保存' : '待填写并保存'),
step(1, '托运单预览 / 打印', instructionPreviewed, instructionPreviewed ? '已预览' : saved ? '可预览 / 打印' : '待保存资料'),
step(2, '补齐资料并确认放舱', released, released ? '已放舱' : saved ? '待确认放舱' : '待补齐资料'),
step(3, '补实并预览提单确认件', confirmationPreviewed, !released ? '待放舱' : actualDirty ? '实值待保存' : !actualComplete ? '待补齐实值' : confirmationPreviewed ? '已预览' : '可预览 / 打印'),
]
}
@@ -5,7 +5,6 @@ import { ElMessage } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasMasterDataPermissions as permission } from '../permissions'
@@ -97,7 +96,7 @@ onBeforeUnmount(() => { warehouseRequest++; editSession++ })
<el-form-item label="可驾驶车型"><el-input v-model="profile.vehicleModels" maxlength="128" placeholder="如 53 尺拖车" /></el-form-item>
<LoadErrorState v-if="warehouseError" class="full-width" title="仓库候选加载失败" :message="warehouseError.message" @retry="loadWarehouses" ><template #details><ApiErrorMeta :details="warehouseError" /></template></LoadErrorState>
<el-form-item label="语言 / 服务区域" class="full-width"><el-input v-model="profile.languagesAndArea" maxlength="256" placeholder="如 中文、英语 / 洛杉矶区域" /></el-form-item>
<h3 class="full-width driver-section-heading">驾驶资质<InlineHelp title="驾驶资质规则"><p>驾驶证在 30 天内到期但仍有效时允许派车并提醒;预计任务结束前到期则禁止。其他证照日期用于档案记录。</p></InlineHelp></h3>
<h3 class="full-width">驾驶资质</h3>
<el-form-item label="驾照类型"><el-select v-model="form.licenseType" filterable allow-create default-first-option clearable><el-option v-for="kind in ['Class A', 'Class B', 'Class C', '普通驾照']" :key="kind" :label="kind" :value="kind" /></el-select></el-form-item>
<el-form-item label="驾照编号"><el-input v-model="form.licenseNumber" maxlength="128" placeholder="请输入驾驶证编号" /></el-form-item>
<el-form-item label="证照有效时区" required><el-select v-model="form.licenseZone" filterable allow-create default-first-option placeholder="请选择或输入 IANA 时区"><el-option v-for="zone in zones" :key="zone" :label="zone" :value="zone" /></el-select></el-form-item>
@@ -106,12 +105,11 @@ onBeforeUnmount(() => { warehouseRequest++; editSession++ })
<el-form-item label="体检到期日"><el-date-picker v-model="profile.medicalExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="培训 / 安全到期日"><el-date-picker v-model="profile.trainingExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<el-form-item label="保险到期日"><el-date-picker v-model="profile.insuranceExpiresOn" type="date" value-format="YYYY-MM-DD" /></el-form-item>
<h3 class="full-width driver-section-heading">证照附件<el-tag v-if="current?.licenseFileId" size="small" type="info">已有附件</el-tag><InlineHelp title="驾驶证上传说明"><p>支持 PDF、JPG、PNG,最大 10MiB。上传新驾驶证后需要重新审核;未上传时可先保存档案,补齐驾驶证后才能通过审核。</p></InlineHelp></h3>
<h3 class="full-width driver-section-heading">证照附件<el-tag v-if="current?.licenseFileId" size="small" type="info">已有附件</el-tag></h3>
<div class="full-width">
<label v-if="access.can(permission.driverUpload)">驾驶证<input ref="fileInput" type="file" accept="application/pdf,image/jpeg,image/png" :disabled="saving" @change="selectFile" /></label>
<p v-if="selectedFile && current?.licenseFileId" class="density-dialog-note" role="status">更换驾驶证后需重新审核。</p>
</div>
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" placeholder="补充司机档案说明(选填)" /></el-form-item>
<el-form-item label="备注" class="full-width"><el-input v-model="form.remark" type="textarea" :rows="2" maxlength="1000" /></el-form-item>
</el-form>
</AppDialog>
</template>
@@ -2,12 +2,12 @@
## 排车发布内部散板需求(2026-10-21)
依据本轮用户确认默认勾选发布,详见拼车主契约当前页面范围。
依据用户确认及 2026-10-22 简化要求,详见拼车主契约当前页面范围。
| 页面/操作 | 当前承载与最小调整 | 验收 |
|---|---|---|
| 智能排车/新建空单或选货转换 | 保持原弹框,在每车计划信息内增加默认勾选的“发布公共散板需求”;每车独立提交,不新增发布页面 | 本轮8文件115项定向测试及受影响文件类型检查通过;待人工1920×1080、100%截图确认 |
| 维护车辆信息/保存及提交审核 | 保持原弹框,回显并保存本车发布选项;与原资源命令同事务,后续提交审核使用新版本 | 同上 |
| 智能排车/新建空单或选货转换 | 保持原弹框,搜索区增加默认不勾选的“发布公共需求”,统一用于本次生成的所有排车单;移除下方计划时间与备注区,沿用关联排车维护 | 3文件59项定向测试通过;组件及两份测试类型检查通过,规划测试原有VueWrapper辅助函数类型不匹配仍阻断其类型检查;待人工1920×1080、100%截图确认 |
| 维护车辆信息/保存及提交审核 | 保持原弹框,回显并保存本车发布选项;与原资源命令同事务,后续提交审核使用新版本 | 沿用此前8文件115项定向测试及受影响文件类型检查结果;本轮未修改,待人工1920×1080、100%截图确认 |
## 2026-10-21 智能排车区域入口替代仓库搜索
@@ -60,7 +60,7 @@
| 页面/操作 | 截图承载与当前实现 | 验证 |
|---|---|---|
| 排车列表/装车后页签 | 在装车中与派送中之间显示;计数与分页按服务端AFTER_LOADING查询,行内管理资料进入原居中弹框 | 定向自动检查;待1920×1080、100%人工截图确认 |
| 管理资料/装车资料 | 原居中弹框内两列共五个装车照片框;装车后默认打开,Admin与PDA共享整车照片;第五张保存后自动进入派送中 | 上传恢复、权限和服务端事务定向检查;待人工截图及PDA真机确认 |
| 管理资料/装车资料 | 原居中弹框内五行装车照片表格(2026-10-22 简化确认),保留图片预览;装车后默认打开,Admin与PDA共享整车照片;第五张保存后自动进入派送中 | 上传恢复、权限和服务端事务定向检查;待人工截图及PDA真机确认 |
| 装车完成/原请求恢复 | 装车结果为AFTER_LOADING;已确认结果重读失败不重复POST,当前派送状态另读 | 定向恢复检查 |
## 2026-10-21 智能排车恢复10月18日布局
@@ -445,6 +445,6 @@
| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 |
|---|---|---|---|---|
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;1920×1080、100%待人工截图确认 |
| 排车列表:装车中、装车后、派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;2026-10-22 用户截图确认移除资料核对、油耗和装车照片改表格;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;嵌入编辑器隐藏资料核对;三个页签仅显示各自资料,装车资料只保留整车装车照片,不再附带BOL及车辆/司机附件,保存资料按钮仅在签收页签显示,切换保留签收草稿;油耗两行、装车照片五行表格,操作列在首列,保留预览、上传/替换和原保存行为;装车中及装车后默认打开装车资料,可同步上传,确认装车且资料齐全后自动派送;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 4文件27项定向测试及受影响组件与测试源码类型检查通过;页签隔离追加修改后5项定向测试及组件/测试类型检查通过;1920×1080、100%待人工截图确认 |
| 仓库派送排车列表 → 维护车辆 | 提交审核入口合并 | 2026-10-21 用户确认:隐藏调整卸货顺序,提交审核移入车辆维护弹框,不需要备注 | 沿用原居中弹框;底部保存/提交审核,提交先保存本次资料及三类照片再按新版本送审;去掉维护和提交备注输入,保留审核驳回原因;详情保持只读 | 定向自动检查单独报告;1920×1080、100%待人工截图确认 |
@@ -118,7 +118,7 @@ async function save() {
<component :is="embedded ? 'div' : AppDialog" class="admin-density-dialog admin-workbench is-dialog" v-model="open" :title="embedded ? undefined : '派送单所需资料 · ' + (deliveryNo || '—')" width="min(1040px, calc(100vw - 40px))" fullscreenable confirm-text="保存资料" cancel-text="关闭" :loading="loading" :show-confirm="canEdit" :confirm-loading="busy || loading" :confirm-disabled="conflict || loading" @confirm="save">
<div class="density-dialog-sections">
<LoadErrorState v-if="error" compact title="派送资料操作失败" :message="error.message" :loading="busy || loading" :show-retry="!snapshot || conflict" @retry="reload"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/' + visibleGroups.length + ' 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
<section v-if="!embedded" class="density-dialog-section" data-tone="summary" aria-label="派送资料摘要"><h3 class="density-dialog-section__title">资料核对</h3><table class="density-table"><tbody><tr><th scope="row">派送单号</th><td>{{ deliveryNo || '—' }}</td><th scope="row">资料类别</th><td>{{ snapshot ? uploadedCount + '/' + visibleGroups.length + ' 已上传' : loading ? '正在读取…' : '资料未返回' }}</td></tr></tbody></table><p v-if="snapshot" class="density-dialog-note">{{ canEdit ? '上传、替换和删除将在保存资料后生效;关闭将放弃未保存修改。' : '当前账号仅可查看已保存资料。' }}</p></section>
<template v-if="snapshot">
<section v-if="!categories || categories.includes('BOL')" class="density-dialog-section" data-tone="work" aria-label="BOL资料登记"><h3 class="density-dialog-section__title">BOL资料</h3><el-form class="density-form" label-position="left" label-width="80px" :disabled="busy || conflict || loading"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="BOL号"><el-input v-if="canEdit" v-model="bolNo" :disabled="busy || conflict || loading" /><span v-else>{{ bolNo || '—' }}</span></el-form-item></td></tr></tbody></table></el-form></section>
<section v-for="group in visibleGroups" :key="group.category" class="document-group density-dialog-section" data-tone="source" :data-category="group.category">
@@ -15,7 +15,7 @@ const dialogStub = defineComponent({ props: ['modelValue', 'showConfirm'], emits
function documents(no = 'DL-1', customerId = '101'): DeliveryDocuments {
return { deliveryNo: no, customerId, version: 1, bolNo: 'BOL-ORIGINAL', uploadedCategoryCount: 1, totalCategoryCount: 3, allowedActions: ['MANAGE_DOCUMENTS'], replayed: false, categories: deliveryDocumentCategories.map(item => ({ category: item.category, status: item.category === 'RECEIPT' ? 'UPLOADED' : 'MISSING', fileCount: item.category === 'RECEIPT' ? 1 : 0, files: item.category === 'RECEIPT' ? [{ fileId: '99', fileName: 'existing-pod.pdf', contentType: 'application/pdf', sizeBytes: 10, uploadedAt: '2026-10-21T00:00:00' }] : [] })) }
}
function setup() { return mount(DispatchDocumentsDialog, { props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub, DispatchVehicleDocuments: true, DispatchLoadingDocuments: true, DispatchEvidenceFiles: true } } }) }
function setup() { return mount(DispatchDocumentsDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus], stubs: { AppDialog: dialogStub, DispatchVehicleDocuments: true, DispatchLoadingDocuments: true, DispatchEvidenceFiles: true } } }) }
beforeEach(() => {
vi.clearAllMocks(); can.mockReturnValue(true)
const value = detail(); value.stage = 'DELIVERING'
@@ -32,6 +32,8 @@ it.each(['LOADING', 'AFTER_LOADING'] as const)('opens loading documents in %s be
can.mockImplementation((value?: string) => value !== 'platform:overseas-warehouse:delivery:list')
const wrapper = setup(); await flushPromises()
expect(wrapper.find('dispatch-loading-documents-stub').exists()).toBe(true)
expect(wrapper.find('dispatch-evidence-files-stub').exists()).toBe(false)
expect(wrapper.find('.save').exists()).toBe(false)
expect(wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.attributes('aria-selected')).toBe('true')
expect(getDeliveryDocuments).not.toHaveBeenCalled(); wrapper.unmount()
})
@@ -39,6 +41,8 @@ it('reads existing delivery files in one dialog and saves receipt without requir
const wrapper = setup(); await flushPromises()
expect(getDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101')
expect(wrapper.text()).toContain('existing-pod.pdf')
expect(wrapper.text()).not.toContain('资料核对')
expect(wrapper.find('[aria-label="派送资料摘要"]').exists()).toBe(false)
expect(wrapper.findAllComponents(DeliveryDocumentsDialog)).toHaveLength(1)
await wrapper.get('.save').trigger('click'); await flushPromises()
expect(saveDeliveryDocuments).toHaveBeenCalledWith('DL-1', '101', expect.objectContaining({ bolNo: 'BOL-ORIGINAL', documents: [{ category: 'LOADING_PHOTO', fileIds: [] }, { category: 'BOL', fileIds: [] }, { category: 'RECEIPT', fileIds: ['99'] }] }))
@@ -48,13 +52,31 @@ it('reads existing delivery files in one dialog and saves receipt without requir
})
it('switches customer scope exactly and keeps unsaved delivery edits when switching tabs', async () => {
const wrapper = setup(); await flushPromises()
const receipt = wrapper.getComponent(DeliveryDocumentsDialog)
await receipt.findAll('button').find(button => button.text() === '删除')!.trigger('click')
expect(receipt.text()).not.toContain('existing-pod.pdf')
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '装车资料')!.trigger('click')
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('categories')).toEqual(['BOL'])
expect(wrapper.getComponent(DeliveryDocumentsDialog).props('categories')).toEqual(['RECEIPT'])
expect(receipt.isVisible()).toBe(false)
expect(wrapper.find('dispatch-loading-documents-stub').isVisible()).toBe(true)
expect(wrapper.find('dispatch-evidence-files-stub').exists()).toBe(false)
expect(wrapper.find('.save').exists()).toBe(false)
expect(wrapper.text()).not.toContain('关联派送单资料')
expect(wrapper.text()).not.toContain('车辆及司机附件')
expect(wrapper.find('[data-category="BOL"]').exists()).toBe(false)
expect(wrapper.find('[data-category="LOADING_PHOTO"]').exists()).toBe(false)
expect(wrapper.getComponent(DeliveryDocumentsDialog).text()).toContain('0/1 已上传')
await wrapper.getComponent(DeliveryDocumentsDialog).find('input').setValue('BOL-CHANGED')
expect(wrapper.getComponent(DeliveryDocumentsDialog).find('[aria-label="派送资料摘要"]').exists()).toBe(false)
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '油耗图片')!.trigger('click')
expect(wrapper.find('dispatch-loading-documents-stub').exists()).toBe(false)
expect(wrapper.find('dispatch-vehicle-documents-stub').isVisible()).toBe(true)
expect(receipt.isVisible()).toBe(false)
expect(wrapper.find('.save').exists()).toBe(false)
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === '签收文件')!.trigger('click')
expect(receipt.isVisible()).toBe(true)
expect(receipt.text()).not.toContain('existing-pod.pdf')
expect(wrapper.find('dispatch-vehicle-documents-stub').isVisible()).toBe(false)
expect(wrapper.find('.save').exists()).toBe(true)
const confirmation = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValue('cancel')
wrapper.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DL-2'); await flushPromises()
expect(getDeliveryDocuments).toHaveBeenCalledTimes(1)
@@ -11,11 +11,9 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { overseasWarehousePermissions as permission } from '../permissions'
import { getDispatch } from '../api/dispatches'
import type { DispatchDetail } from '../dispatchContracts'
import { dispatchReviewAttachmentFields, dispatchReviewEvidence } from '../dispatchPresentation'
import DeliveryDocumentsDialog from './DeliveryDocumentsDialog.vue'
import DispatchVehicleDocuments from './DispatchVehicleDocuments.vue'
import DispatchLoadingDocuments from './DispatchLoadingDocuments.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ dispatchNo: string }>()
@@ -28,7 +26,6 @@ const loadingEditor = ref<InstanceType<typeof DispatchLoadingDocuments>>()
const deliveries = computed(() => detail.value?.documents.deliveries || [])
const beforeDeparture = computed(() => ['LOADING', 'AFTER_LOADING'].includes(detail.value?.stage || ''))
const delivery = computed(() => deliveries.value.find(item => item.deliveryNo === selected.value))
const reviewEvidence = computed(() => dispatchReviewEvidence(detail.value))
const busy = computed(() => Boolean(editor.value?.busy || vehicleEditor.value?.busy || loadingEditor.value?.busy))
let sequence = 0
onBeforeUnmount(() => { sequence++ })
@@ -61,20 +58,18 @@ watch(() => [open.value, props.dispatchNo] as const, ([visible]) => {
}, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab !== 'FUEL' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
<AppDialog v-model="open" class="admin-density admin-density-dialog admin-workbench is-dialog" :title="'管理资料 · ' + (dispatchNo || '—')" width="min(1100px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="busy" :before-close="beforeClose" :show-confirm="tab === 'RECEIPT' && Boolean(editor?.canEdit)" :confirm-disabled="loading || editor?.loading || editor?.conflict" confirm-text="保存资料" cancel-text="关闭" @confirm="editor?.save()">
<LoadErrorState v-if="error" compact title="排车资料读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<template v-if="detail">
<div class="density-status-tabs" role="tablist" aria-label="排车资料类别"><button v-for="item in [{ key: 'RECEIPT', label: '签收文件' }, { key: 'FUEL', label: '油耗图片' }, { key: 'LOADING', label: '装车资料' }]" :key="item.key" role="tab" :aria-selected="tab === item.key" :disabled="busy" :class="{ 'is-active': tab === item.key }" @click="selectTab(item.key)">{{ item.label }}</button></div>
<DispatchLoadingDocuments v-if="tab === 'LOADING'" ref="loadingEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="loadingSaved" />
<div v-show="tab !== 'FUEL'" v-if="!beforeDeparture">
<h3 v-if="tab === 'LOADING'" class="density-dialog-section__title">关联派送单资料</h3>
<div v-show="tab === 'RECEIPT'" v-if="!beforeDeparture">
<el-form class="density-form" label-position="left" label-width="80px"><el-form-item label="派送单"><el-select :model-value="selected" :disabled="busy || !access.can(permission.deliveryList)" aria-label="选择派送单" @update:model-value="selectDelivery"><el-option v-for="item in deliveries" :key="item.deliveryNo" :value="item.deliveryNo" :label="item.deliveryNo" /></el-select></el-form-item></el-form>
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="tab === 'RECEIPT' ? ['RECEIPT'] : ['BOL']" @saved="saved" />
<DeliveryDocumentsDialog v-if="delivery && access.can(permission.deliveryList)" ref="editor" :key="delivery.customerId + ':' + delivery.deliveryNo" :model-value="open" embedded :delivery-no="delivery.deliveryNo" :customer-id="delivery.customerId" :categories="['RECEIPT']" @saved="saved" />
<p v-else>{{ access.can(permission.deliveryList) ? '暂无关联派送单' : '当前账号无派送资料查看权限' }}</p>
</div>
<p v-if="beforeDeparture && tab !== 'LOADING'">完成装车并补齐装车资料后,进入派送中管理{{ tab === 'FUEL' ? '油耗图片' : '签收文件' }}。</p>
<DispatchVehicleDocuments v-if="fuelVisited && !beforeDeparture" v-show="tab === 'FUEL'" ref="vehicleEditor" :key="dispatchNo" :dispatch-no="dispatchNo" @saved="saved" />
<section v-if="tab === 'LOADING'" class="density-dialog-section"><h3 class="density-dialog-section__title">车辆及司机附件</h3><DispatchEvidenceFiles v-for="field in dispatchReviewAttachmentFields" :key="field.key" :model-value="reviewEvidence[field.key]" :dispatch-no="dispatchNo" :kind="field.kind" :label="field.label" readonly /></section>
</template>
</AppDialog>
</template>
@@ -27,11 +27,15 @@ beforeEach(() => {
it('offers five whole-dispatch slots and only reports saved after binding succeeds', async () => {
const wrapper = setup(); await flushPromises()
expect(wrapper.findAll('input[type="file"]')).toHaveLength(5)
const table = wrapper.get('table[aria-label="装车照片"]')
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '照片类别', '文件名称', '图片预览', '保存时间'])
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(['装车照片 1', '装车照片 2', '装车照片 3', '装车照片 4', '装车照片 5'])
await upload(wrapper)
expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String))
expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' }))
expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).toContain('图片读取失败')
expect(table.get('tbody tr').text()).toContain('loading.png')
wrapper.unmount()
})
it('keeps the original binding and file after an unknown result and never uploads again', async () => {
@@ -94,22 +94,31 @@ defineExpose({ busy })
<p v-if="snapshot?.stage === 'AFTER_LOADING'">请上传 5 张装车照片,全部保存成功后自动进入派送中。</p>
<LoadErrorState v-if="error" compact title="装车资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending" role="status">有装车照片尚未确认保存。<el-button :disabled="busy || !editable" @click="recover">{{ pending.conflict ? '刷新并重试保存' : '核对原请求并继续保存' }}</el-button></div>
<div class="loading-photo-grid">
<section v-for="(slot, index) in loadingPhotoSlots" :key="slot" class="density-dialog-section loading-photo-card">
<h3 class="density-dialog-section__title">装车照片 {{ index + 1 }}</h3>
<template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
<table class="density-table loading-documents-table" aria-label="装车照片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">照片类别</th><th>文件名称</th><th class="photo-preview">图片预览</th><th class="photo-time">保存时间</th></tr></thead><tbody>
<tr v-for="(slot, index) in loadingPhotoSlots" :key="slot">
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || Boolean(pending) }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy || Boolean(pending)" @change="upload(slot, $event)" /></label><span v-else>—</span></td>
<td>装车照片 {{ index + 1 }}</td>
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.fileName }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">未上传</span></td>
<td><template v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">
<el-image v-if="images[photo.fileId]" :src="images[photo.fileId]" :preview-src-list="[images[photo.fileId]!]" fit="contain" class="loading-photo-image" preview-teleported />
<el-button v-else link :disabled="busy" @click="load">图片读取失败,点击重试</el-button>
</template>
<div v-if="!snapshot?.photos.some(item => item.slot === slot)" class="loading-photo-empty">未上传</div>
<label v-if="editable">上传/替换图片<input type="file" accept="image/jpeg,image/png" :aria-label="`装车照片 ${index + 1}`" :disabled="busy || Boolean(pending)" @change="upload(slot, $event)" /></label>
</section>
</div>
</template><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === slot)">—</span></td>
</tr>
</tbody></table>
</div>
</template>
<style scoped>
.loading-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
.loading-photo-image, .loading-photo-empty { width: 100%; height: 150px; margin-bottom: var(--admin-space-3); }
.loading-photo-empty { display: grid; place-items: center; background: var(--cc-bg-soft, #f3f7ff); color: var(--cc-text-secondary, #64748b); }
.loading-photo-card input { display: block; margin-top: var(--admin-space-2); max-width: 100%; }
.loading-documents-table { table-layout: fixed; }
.loading-documents-table td { overflow-wrap: anywhere; }
.photo-actions { width: 110px; }
.photo-category { width: 120px; }
.photo-preview { width: 140px; }
.photo-time { width: 190px; }
.loading-photo-image { display: block; width: 100px; height: 64px; }
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.photo-upload.is-disabled { color: var(--el-text-color-disabled); cursor: not-allowed; }
.photo-upload.is-disabled input { cursor: not-allowed; }
</style>
@@ -22,6 +22,9 @@ beforeEach(() => {
it('binds the uploaded file to the car and restores the original binding after reopening without uploading twice', async () => {
vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new Error('network timeout'))
const first = setup(); await flushPromises(); await upload(first)
const table = first.get('table[aria-label="油耗图片"]')
expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '图片类别', '文件名称', '保存时间'])
expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(['起始油耗表盘', '结束油耗表盘'])
const original = vi.mocked(bindVehiclePhoto).mock.calls[0]![2]
expect(original).toMatchObject({ recordVersion: '3', fileId: photo.fileId })
first.unmount()
@@ -30,6 +33,7 @@ it('binds the uploaded file to the car and restores the original binding after r
expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', original)
expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1)
expect(reopened.text()).toContain('fuel.png')
expect(reopened.get('table tbody tr').text()).toContain('2026-10-21 00:00:00Z')
expect(sessionStorage.length).toBe(0)
reopened.unmount()
})
@@ -93,17 +93,28 @@ defineExpose({ busy })
<div v-loading="busy" class="density-dialog-sections">
<LoadErrorState v-if="error" compact title="车辆资料操作失败" :message="error.message" :show-retry="!pending && !corrupted" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<div v-if="pending" role="status">有图片尚未确认保存。<el-button v-if="pending.versionConflict" :disabled="busy || !editable" @click="refreshBinding">刷新版本并保存已上传图片</el-button><el-button v-else :disabled="busy || !editable" @click="recover">核对原请求并继续保存</el-button></div>
<section v-for="role in roles" :key="role.slot" class="density-dialog-section">
<h3 class="density-dialog-section__title">{{ role.label }}</h3>
<div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId"><el-button link :disabled="busy" @click="view(photo)">{{ photo.fileName }}</el-button></div>
<span v-if="snapshot && !snapshot.photos.some(item => item.slot === role.slot)">未上传</span>
<label v-if="editable" class="photo-upload">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy || Boolean(pending)" @change="upload(role.slot, $event)" /></label>
</section>
<table class="density-table fuel-documents-table" aria-label="油耗图片"><thead><tr><th class="photo-actions">操作</th><th class="photo-category">图片类别</th><th>文件名称</th><th class="photo-time">保存时间</th></tr></thead><tbody>
<tr v-for="role in roles" :key="role.slot">
<td><label v-if="editable" class="photo-upload" :class="{ 'is-disabled': busy || Boolean(pending) }">上传/替换<input type="file" accept="image/jpeg,image/png" :aria-label="role.label" :disabled="busy || Boolean(pending)" @change="upload(role.slot, $event)" /></label><span v-else>—</span></td>
<td>{{ role.label }}</td>
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId"><el-button link :disabled="busy" @click="view(photo)">{{ photo.fileName }}</el-button></div><span v-if="!snapshot?.photos.some(item => item.slot === role.slot)">未上传</span></td>
<td><div v-for="photo in snapshot?.photos.filter(item => item.slot === role.slot) || []" :key="photo.fileId">{{ photo.savedAt?.replace('T', ' ') || '—' }}</div><span v-if="!snapshot?.photos.some(item => item.slot === role.slot)">—</span></td>
</tr>
</tbody></table>
<img v-if="preview" :src="preview" alt="油耗图片预览" class="photo-preview" />
</div>
</template>
<style scoped>
.photo-upload { display: block; margin-top: var(--admin-space-3); }
.photo-upload input { margin-left: var(--admin-space-3); }
.fuel-documents-table { table-layout: fixed; }
.fuel-documents-table td { overflow-wrap: anywhere; }
.fuel-documents-table :deep(.el-button > span) { white-space: normal; text-align: left; overflow-wrap: anywhere; }
.photo-actions { width: 110px; }
.photo-category { width: 160px; }
.photo-time { width: 190px; }
.photo-upload { position: relative; display: inline-flex; color: var(--el-color-primary); cursor: pointer; }
.photo-upload input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; cursor: pointer; }
.photo-upload:focus-within { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.photo-upload.is-disabled { color: var(--el-text-color-disabled); cursor: not-allowed; }
.photo-upload.is-disabled input { cursor: not-allowed; }
.photo-preview { display: block; max-width: 100%; max-height: 400px; object-fit: contain; }
</style>
@@ -200,7 +200,7 @@ describe('new dispatch native layout', () => {
expect(calls).toHaveLength(2);expect(calls[1]![0].idempotencyKey).not.toBe(calls[0]![0].idempotencyKey)
expect(api.createDispatchDraft).toHaveBeenCalledOnce();expect(api.lockDispatchDraftPallet).toHaveBeenCalledOnce()
})
it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
it('keeps publication available before cargo selection without extra planning fields', async () => {
const { dialog } = await render()
expect(view(dialog).find('[aria-label="排车设置"] table').exists()).toBe(false)
expect(view(dialog).find('[aria-label="排车设置"] .density-dialog-note').exists()).toBe(false)
@@ -213,14 +213,16 @@ describe('new dispatch native layout', () => {
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
await restore(dialog)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeUndefined()
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeUndefined()
})
it('creates an unassigned empty dispatch while preserving optional dates and remark through fullscreen', async () => {
it('creates an empty dispatch with optional publication preserved through fullscreen', async () => {
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
field(dialog, '计划发车时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T10:00:00Z')
field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z')
await field(dialog, '备注').get('input').setValue('本趟备注')
expect(view(dialog).find('[aria-label="排车计划时间与备注"]').exists()).toBe(false)
expect(view(dialog).findComponent({ name: 'ElDatePicker' }).exists()).toBe(false)
const publication = view(dialog).get('.dispatch-candidate-query input[type="checkbox"]')
expect(publication.element).toHaveProperty('checked', false)
await publication.setValue(true)
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
await view(dialog).get('button[aria-label="退出全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(false)
@@ -229,7 +231,7 @@ describe('new dispatch native layout', () => {
expect(view(dialog).text()).toContain('空单暂不能核平、装车或发车')
expect(view(dialog).find('[aria-label="智能排车三栏选货"]').exists()).toBe(false)
await view(dialog).findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), publishLooseboardDemand: true, warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: null, driver: null })
expect(api.createDispatch).toHaveBeenCalledExactlyOnceWith({ idempotencyKey: expect.any(String), publishLooseboardDemand: true, warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, vehicle: null, driver: null })
})
it('creates with only the warehouse and never requests vehicle or driver directories', async () => {
const { dialog } = await render(); await emptyMode(dialog); await selectWarehouse(dialog)
@@ -239,15 +241,14 @@ describe('new dispatch native layout', () => {
expect(listVehicles).not.toHaveBeenCalled()
expect(listDrivers).not.toHaveBeenCalled()
})
it('keeps each group configuration independent and keeps form values editable after conversion read failure', async () => {
it('applies publication to all cars and keeps it editable after conversion read failure', async () => {
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('details not ready')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const { wrapper, dialog } = await render(); await restore(dialog)
await field(dialog, '备注').get('input').setValue('第一组')
await view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').setValue(true)
const radios = view(dialog).get('[aria-label="车辆装载"]').findAll('input[type="radio"]')
await radios[1]!.setValue(true); await flushPromises()
await field(dialog, '备注').get('input').setValue('第二组')
await radios[0]!.setValue(true); await flushPromises()
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
await view(dialog).get('button[aria-label="退出全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(false)
@@ -256,7 +257,7 @@ describe('new dispatch native layout', () => {
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
const request = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]
expect(request.recordVersion).toBe(0)
expect(request.groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '第一组'], [null, null, '第二组']])
expect(request.groups.map(group => [group.dispatch.publishLooseboardDemand, group.dispatch.plannedDepartureAt, group.dispatch.estimatedArrivalAt, group.dispatch.remark])).toEqual([[true, null, null, null], [true, null, null, null]])
expect(wrapper.emitted('created')).toBeUndefined()
expect(view(dialog).text()).toContain('本次排车操作失败,请重试')
dialog.vm.$emit('confirm'); await flushPromises()
@@ -273,12 +274,12 @@ describe('new dispatch native layout', () => {
expect(dialog.props('confirmText')).toBe('生成排车单')
const original = JSON.parse(JSON.stringify(vi.mocked(api.convertDispatchDraft).mock.calls[0]![0])) as ConvertDraft
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(view(dialog).text()).toContain('new-dispatch-request')
expect(view(dialog).text()).not.toContain('原请求结果尚待核对')
expect(dialog.props('confirmText')).toBe('生成排车单')
expect(field(dialog,'备注').get('input').attributes('disabled')).toBeUndefined()
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').attributes('disabled')).toBeUndefined()
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
@@ -79,7 +79,6 @@ async function render() {
return wrapper.getComponent(AppDialog)
}
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(row => row.props('label') === label)! }
function button(dialog: VueWrapper, label: string) { return view(dialog).findAll('button').find(row => row.text() === label)! }
function car(dialog: VueWrapper, index: number) { return view(dialog).get(`article[aria-label="第 ${index} 辆车"]`) }
function cargoRow(dialog: VueWrapper, palletNo: string) { return view(dialog).get(`[aria-label="托盘 ${palletNo}"]`) }
@@ -128,24 +127,24 @@ describe('three-column cargo planning through visible controls', () => {
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
expect(dialog.props('confirmDisabled')).toBe(true)
})
it('retains the first car cargo, isolates per-car fields and adds later cargo to the car selected again', async () => {
it('retains car cargo and the publication choice when switching cars', async () => {
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
await field(dialog, '备注').get('input').setValue('第一车独立备注')
await view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').setValue(true)
await button(dialog, '添加车辆').trigger('click'); await flushPromises()
expect(car(dialog, 1).text()).toContain('PALLET-1')
expect(car(dialog, 2).get('input[type="radio"]').element).toHaveProperty('checked', true)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await field(dialog, '备注').get('input').setValue('第二车独立备注'); await join(dialog, 'PALLET-2')
await join(dialog, 'PALLET-2')
expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls[1]![0].draftContainerId).toBe('container-2')
await chooseCar(dialog, 1)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一车独立备注')
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
await join(dialog, 'PALLET-3')
expect(vi.mocked(api.lockDispatchDraftPallet).mock.calls[2]![0].draftContainerId).toBe('container-1')
expect(car(dialog, 1).text()).toContain('PALLET-3'); expect(car(dialog, 2).text()).toContain('PALLET-2')
await chooseCar(dialog, 2)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第二车独立备注')
expect(view(dialog).get('.dispatch-candidate-query input[type="checkbox"]').element).toHaveProperty('checked', true)
})
it('selects the newly added ID even when its response lists the new container first and old container last', async () => {
const dialog = await render(); await warehouseSelected(dialog); await join(dialog, 'PALLET-1')
@@ -133,13 +133,12 @@ it('keeps planning remarks independent with unassigned resources and permits res
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('detail unavailable')).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; activeContainerId: string; form: { remark: string } }
state.restoreDraft('server-token'); await flushPromises(); state.form.remark = '车甲'
state.activeContainerId = 'container-B'; state.form.remark = '车乙'
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }
state.restoreDraft('server-token'); await flushPromises()
await state.submit(); expect(wrapper.emitted('created')).toBeUndefined()
await state.submit(); expect(api.convertDispatchDraft).toHaveBeenCalledTimes(2)
const groups = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0].groups
expect(groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, '车甲'], [null, null, '车乙']])
expect(groups.map(group => [group.dispatch.vehicle, group.dispatch.driver, group.dispatch.remark])).toEqual([[null, null, null], [null, null, null]])
expect(wrapper.emitted('created')).toHaveLength(1); wrapper.unmount()
})
it('clears an old departure request without blocking new empty creation',async()=>{
@@ -178,14 +177,14 @@ it('keeps server draft cargo across pages, resets filters and sends regional tab
} finally { wrapper.unmount() }
})
it('defaults publication on for every new dispatch and carries opt-out in the atomic draft conversion', async () => {
it.each([false, true])('defaults publication off and carries publication %s in the atomic draft conversion', async publish => {
vi.mocked(api.convertDispatchDraft).mockImplementation(async request => ({ draftToken: request.draftToken, replayed: false, vehicles: request.groups.map(group => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey) })) }))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; form: { publishLooseboardDemand: boolean } }
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void>; publishLooseboardDemand: boolean }
state.restoreDraft('server-token'); await flushPromises()
expect(state.form.publishLooseboardDemand).toBe(true)
state.form.publishLooseboardDemand = false
expect(state.publishLooseboardDemand).toBe(false)
state.publishLooseboardDemand = publish
await state.submit()
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ publishLooseboardDemand: false }) })] }))
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ publishLooseboardDemand: publish }) })] }))
wrapper.unmount()
})
@@ -41,13 +41,7 @@ const { candidates, page, size, total } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const query = reactive({ orderNo: '', destinationWarehouseCode: '', sourceStage: '' })
const warehouseTab = ref<SmartDispatchWarehouseTab>('ALL')
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string; publishLooseboardDemand: boolean } }>>({})
const currentSettings = computed(() => {
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
settings[key] ||= { form: { plannedDepartureAt: '', estimatedArrivalAt: '', remark: '', publishLooseboardDemand: true } }
return settings[key]!
})
const form = computed(() => currentSettings.value.form)
const publishLooseboardDemand = ref(false)
const frozen = computed(() => !canCreate.value || preparing.value || saving.value || recovery.busy.value)
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
const canEditCargo = computed(() => access.can(dispatchActionPermissions.ADD_CARGO))
@@ -147,12 +141,11 @@ async function loadCandidates(sequence = generation) {
function resetSearch() { if (frozen.value) return; query.orderNo = ''; query.destinationWarehouseCode = ''; query.sourceStage = ''; warehouseTab.value = 'ALL'; void search() }
async function search() { if (frozen.value) return; page.value = 1; await loadCandidates() }
function restoreDraft(token: string) { if (frozen.value) return; const value = drafts.value.find(row => row.draftToken === token); if (!value) return; draft.value = value; warehouseId.value = value.warehouseId; warehouseTab.value = 'ALL'; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
function creation(cargo: Candidate[]): CreateDispatch {
candidatePage.assertCurrent(cargo)
requireBatchVersions(cargo.flatMap(row => row.appointmentBatches))
if (cargo.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('货物仓库、来源版本或预约客户不一致,请重新读取')
const config = containerId ? settings[containerId] : currentSettings.value
return { publishLooseboardDemand: config?.form.publishLooseboardDemand ?? true, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: config?.form.plannedDepartureAt || null, estimatedArrivalAt: config?.form.estimatedArrivalAt || null, remark: config?.form.remark || null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
return { publishLooseboardDemand: publishLooseboardDemand.value, idempotencyKey: crypto.randomUUID(), warehouseId: warehouseId.value, vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: expandSelectionGroups(cargo), batchAllocations: cargo.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => { if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对'); return { planPalletId: row.selection.planPalletId, batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion } }))) }
}
async function draftAction(action: DraftCommand['action'], source?: Candidate, containerId?: string): Promise<boolean> {
if (!canCreate.value || loading.value || saving.value || pending.value) return false
@@ -178,7 +171,7 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
const groups = current.containers.filter(row => row.sourceKeys.length).map(row => {
const cargo = row.sourceKeys.map(keyValue => current.sources.find(value => value.sourceKey === keyValue))
if (cargo.some(rowValue => !rowValue)) throw new DispatchInputError('草稿来源尚未恢复完整,请重新读取')
return { draftContainerId: row.draftContainerId, dispatch: creation(cargo as Candidate[], row.draftContainerId) }
return { draftContainerId: row.draftContainerId, dispatch: creation(cargo as Candidate[]) }
})
if (!groups.length) throw new DispatchInputError('请至少选择一组真实货物;空单请使用先建空单')
body = { ...common, groups } satisfies ConvertDraft
@@ -250,7 +243,7 @@ async function submit() {
if (result) { emit('created'); emit('update:modelValue', false); ElMessage.success(`空排车单 ${result.dispatchNo} 已创建`) }
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '建单失败,请重试') }
}
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
watch(() => props.modelValue, value => { if (value) { publishLooseboardDemand.value = false; void init() } else { generation++; candidatePage.reset() } }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density admin-workbench is-dialog smart-dispatch-dialog" :class="{ 'is-cargo-workspace': workspace === 'create' && mode === 'cargo' }" append-to-body :lock-scroll="true" :model-value="modelValue" title="智能排车" width="min(1800px, calc(100vw - 40px))" fullscreenable default-fullscreen :loading="working" :show-confirm="workspace === 'create'" :confirm-loading="saving || recovery.busy.value || existingBusy" :confirm-disabled="confirmDisabled" :confirm-text="mode === 'empty' ? '保存空排车单' : '生成排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
@@ -264,7 +257,7 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<section class="density-dialog-section dispatch-search" aria-label="候选货物查询">
<div class="dispatch-candidate-query">
<template v-if="mode === 'cargo'"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button type="primary" :disabled="frozen || loading" @click="search">查询</el-button><el-button :disabled="frozen || loading" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button></div>
<template v-if="mode === 'cargo'"><el-input v-model="query.orderNo" :disabled="frozen || loading" placeholder="订单号/入仓码" /><el-input v-model="query.destinationWarehouseCode" :disabled="frozen || loading" placeholder="目的仓码" /><el-select v-model="query.sourceStage" :disabled="frozen || loading" clearable placeholder="全部来源"><el-option label="在途预排(已离港)" value="PLAN" /><el-option label="已收货实物" value="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button type="primary" :disabled="frozen || loading" @click="search">查询</el-button><el-button :disabled="frozen || loading" @click="resetSearch">重置</el-button></template><el-button :aria-expanded="settingsExpanded" aria-controls="smart-dispatch-settings" @click="settingsExpanded = !settingsExpanded">排车设置</el-button><el-checkbox v-model="publishLooseboardDemand" :disabled="frozen || loading">发布公共需求</el-checkbox></div>
</section>
<section v-show="settingsExpanded" id="smart-dispatch-settings" class="dispatch-context-toolbar" aria-label="排车设置">
<el-form inline label-position="left" label-width="70px" class="density-form dispatch-setup-fields" :disabled="frozen || loading">
@@ -289,16 +282,6 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
</template>
</SmartDispatchCargoBoard>
</template>
<el-form v-if="mode === 'empty' || currentContainer" label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
<section class="density-dialog-section" aria-label="排车计划时间与备注">
<h3 class="density-dialog-section__title">{{ mode === 'empty' ? '计划时间与备注' : '第 ' + currentContainer!.sequenceNo + ' 辆车 · 计划时间与备注' }}<small>选填</small><InlineHelp title="计划时间与备注说明"><p>时间和备注分别保存在当前选中的排车安排中。车辆和司机在生成排车单后,通过“维护车辆”补充。</p></InlineHelp></h3>
<table class="density-table density-table--editable dispatch-create-table"><tbody>
<tr><td><el-form-item label="计划发车时间"><el-date-picker v-model="form.plannedDepartureAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="可先保存,审核前补齐" /></el-form-item></td><td><el-form-item label="预计到达时间"><el-date-picker v-model="form.estimatedArrivalAt" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" placeholder="请选择预计到达时间(选填)" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="散板需求"><el-checkbox v-model="form.publishLooseboardDemand" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)">发布公共散板需求</el-checkbox></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="备注"><el-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
</tbody></table>
</section>
</el-form>
</div>
</template>
</AppDialog>
@@ -318,8 +301,6 @@ watch(() => props.modelValue, value => { if (value) void init(); else { generati
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-vehicle-content) { min-height: 0; max-height: 100%; overflow-y: auto; }
:global(.smart-dispatch-dialog.is-fullscreen.is-cargo-workspace .cargo-board-vehicle-list) { max-height: none; overflow: visible; }
.dispatch-create-table { table-layout: fixed; }
.dispatch-create-table td:not([colspan]) { width: 50%; }
.dispatch-context-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3) var(--admin-space-6); }
.dispatch-setup-fields { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-4) var(--admin-space-6); }
.dispatch-setup-fields :deep(.el-form-item) { margin: 0; }
@@ -266,7 +266,7 @@ onMounted(() => { void load() })
<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="planEditOpen" :title="`编辑入库单 · ${planEditTarget?.orderNo || '—'}`" fullscreenable confirm-text="保存" :loading="planEditLoading" :confirm-disabled="planEditLoading || !planEditForm.warehouseId" :confirm-loading="planEditSaving" @confirm="submitPlanEdit"><div class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source" aria-label="入库计划来源"><h3 class="density-dialog-section__title">计划信息</h3><table class="density-table"><tbody><tr><th scope="row">入库单号</th><td>{{ planEditTarget?.orderNo || '—' }}</td></tr></tbody></table></section>
<LoadErrorState v-if="mutationError" compact title="入库单保存失败" :message="mutationError.message" :loading="planEditSaving" retry-text="重试保存" @retry="submitPlanEdit"><template #details><ApiErrorMeta :details="mutationError" /></template></LoadErrorState>
<section class="density-dialog-section" data-tone="work" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
<section class="density-dialog-section" aria-label="目的仓登记"><h3 class="density-dialog-section__title">目的仓登记</h3><el-form class="density-form" label-position="left" label-width="100px" :disabled="planEditLoading || planEditSaving"><table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="目的仓" required><el-select v-model="planEditForm.warehouseId" filterable placeholder="请选择目的仓" @change="(id: string) => { const w = warehouses.find((item) => item.id === id); planEditForm.warehouseCode = w?.warehouseCode || ''; planEditForm.warehouseName = w?.warehouseName || '' }"><el-option v-for="warehouse in warehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="warehouse.id" /></el-select></el-form-item></td></tr></tbody></table></el-form></section>
</div></AppDialog>
</AdminPage>
</template>
@@ -1289,7 +1289,7 @@ onMounted(() => { void load() })
<tr><th scope="row">计划入库仓</th><td>{{ inboundPlanWarehouseLabel || '—' }}</td></tr><tr><th scope="row">入仓码</th><td>{{ inboundPlanForm.inboundCode || '—' }}</td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划登记</h3>
<section class="density-dialog-section" aria-label="入库计划登记"><h3 class="density-dialog-section__title">计划登记</h3>
<table class="density-table density-table--editable last-mile-command-table"><tbody>
<tr><td><el-form-item label="托数" required><span v-if="inboundPlanIsDropship" class="frozen-pallet-count">{{ inboundPlanForm.palletCount }}</span><el-input-number v-else v-model="inboundPlanForm.palletCount" :min="inboundPlanMinimumPalletCount" :max="999" controls-position="right" /><span class="density-dialog-note" v-if="inboundPlanIsDropship">一件代发固定按头程已封托托盘数整体入库,不可增减。</span><span class="density-dialog-note" v-else>按柜级预览至少 {{ inboundPlanMinimumPalletCount }} 托,可增加,不能减少。</span></el-form-item></td><td><el-form-item label="托盘类型"><el-select v-model="inboundPlanForm.palletType" placeholder="请选择托盘类型"><el-option label="标准托盘" value="STANDARD" /><el-option label="木托盘" value="WOOD" /><el-option label="纸托盘" value="PAPER" /></el-select></el-form-item></td></tr>
<tr><td><el-form-item label="是否分配"><el-switch v-model="inboundPlanForm.assigned" inline-prompt active-text="是" inactive-text="否" /></el-form-item></td><td><el-form-item label="允许合托"><el-switch v-model="inboundPlanForm.mergeRequired" inline-prompt active-text="是" inactive-text="否" /><InlineHelp title="合托提示说明">仅作为现场可选提示,不强制工人合托。</InlineHelp></el-form-item></td></tr>
@@ -33,7 +33,7 @@ describe('PalletsView', () => {
})
it('loads the all-warehouse pallet list and summary when the page opens', async () => {
vi.mocked(getPalletSummary).mockResolvedValueOnce({ totalCount: 250, idleCount: 100, occupiedCount: 98, scrappedCount: 52 })
vi.mocked(getPalletSummary).mockResolvedValueOnce({ totalCount: 1250, idleCount: 1100, occupiedCount: 98, scrappedCount: 52 })
const wrapper = mount(PalletsView, { attachTo: document.body, global: { plugins: [ElementPlus] } })
await flushPromises()
@@ -42,10 +42,10 @@ describe('PalletsView', () => {
expect(getPalletSummary).toHaveBeenCalledWith('', { page: 1, size: 10, keyword: undefined, palletType: undefined, status: undefined })
expect(wrapper.get('[aria-label="托盘管理列表"]').text()).toContain('PLT-1')
expect(wrapper.get('[aria-label="托盘管理列表"]').text()).not.toContain('请先选择仓库后查询')
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('托盘总数250')
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('闲置托盘100')
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('占用托盘98')
expect(wrapper.get('[aria-label="海外托盘数量概览"]').text()).toContain('已报废52')
const cards = wrapper.get('[aria-label="海外托盘数量概览"]').findAll('article')
expect(cards.map(card => card.get('h2').text())).toEqual(['托盘总数', '闲置托盘', '占用托盘', '已报废'])
expect(cards.map(card => card.get('strong').text())).toEqual(['1,250', '1,100', '98', '52'])
expect(cards.map(card => card.get('.workbench-metric-value > span').text())).toEqual(['个', '个', '个', '个'])
})
it('prints one pallet from the all-warehouse list and allows retry after failure', async () => {
@@ -2,7 +2,7 @@
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus } from '@lucide/vue'
import { Layers, Package, PackageCheck, PackageX, Plus } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
@@ -167,7 +167,24 @@ onMounted(initialize)
<template>
<AdminPage class="admin-density admin-workbench"><OperationErrorToast :details="mutationError" />
<section class="summary-grid workbench-panel is-flush" aria-label="海外托盘数量概览"><article><span>托盘总数</span><strong>{{ summary.totalCount }}</strong></article><article><span>闲置托盘</span><strong class="idle">{{ summary.idleCount }}</strong></article><article><span>占用托盘</span><strong class="occupied">{{ summary.occupiedCount }}</strong></article><article><span>已报废</span><strong>{{ summary.scrappedCount }}</strong></article></section>
<section class="summary-grid" aria-label="海外托盘数量概览">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Layers aria-hidden="true" /></span><h2 class="workbench-metric-label">托盘总数</h2></div>
<div class="workbench-metric-value"><strong>{{ summary.totalCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">闲置托盘</h2></div>
<div class="workbench-metric-value"><strong>{{ summary.idleCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Package aria-hidden="true" /></span><h2 class="workbench-metric-label">占用托盘</h2></div>
<div class="workbench-metric-value"><strong>{{ summary.occupiedCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="rose">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><PackageX aria-hidden="true" /></span><h2 class="workbench-metric-label">已报废</h2></div>
<div class="workbench-metric-value"><strong>{{ summary.scrappedCount.toLocaleString('zh-CN') }}</strong><span>个</span></div>
</article>
</section>
<ListPageLayout aria-label="托盘管理列表">
@@ -203,13 +220,9 @@ onMounted(initialize)
</template>
<style scoped>
.summary-grid span { color:var(--workbench-muted); font-size:var(--admin-font-caption); }.summary-grid .idle { color:var(--el-color-success); }.summary-grid .occupied { color:var(--el-color-warning); }
.pallet-editor-table { table-layout: fixed; }
.pallet-editor-table td:not([colspan]) { width: 50%; }
.summary-grid { display: flex; gap: 0; flex-wrap: wrap; }
.summary-grid article { display: flex; gap: var(--admin-space-3); padding: var(--admin-space-3) var(--admin-space-5); border: 0; border-radius: 0; background:var(--workbench-surface); flex: 1 1 200px; align-items: baseline; border-right: 1px solid var(--workbench-border); }
.summary-grid article:last-child { border-right: 0; }
.summary-grid strong { font-size: var(--admin-font-body); }
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--workbench-grid-gap); }
</style>
@@ -190,6 +190,28 @@ describe('UnifiedOrdersView list request coordination', () => {
}
})
it.each(['全部', '测量数据审核'])('keeps the %s tab bar mounted without a loading text row during query', async (label) => {
const wrapper = mountView()
await flushPromises()
if (label !== '全部') {
await wrapper.findAll('[role="tab"]').find(tab => tab.text() === label)!.trigger('click')
await flushPromises()
}
const tabList = wrapper.get('[role="tablist"]').element
let resolveCounts!: (value: { counts: {} }) => void
vi.mocked(getPlatformUnifiedOrderStatusCounts).mockImplementationOnce(() => new Promise(resolve => { resolveCounts = resolve }))
wrapper.getComponent({ name: 'QueryToolbar' }).vm.$emit('search')
await flushPromises()
expect(wrapper.get('[role="tablist"]').element).toBe(tabList)
expect(wrapper.get('[role="tablist"]').attributes('aria-busy')).toBe('true')
expect(wrapper.find('.order-status-feedback').exists()).toBe(false)
resolveCounts({ counts: {} })
await flushPromises()
expect(wrapper.get('[role="tablist"]').element).toBe(tabList)
expect(wrapper.get('[role="tablist"]').attributes('aria-busy')).toBe('false')
wrapper.unmount()
})
it.each(['success', 'failure'] as const)('ignores an older tab request %s after the current tab has loaded', async (outcome) => {
const wrapper = mountView()
await flushPromises()
@@ -565,7 +565,7 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('label="尺寸图片"')
expect(source).toContain(':file-id="row.intelligentImageFileId"')
expect(source).toContain('openMeasurementEvidence(row.intelligentImageFileId')
expect(source).toContain('v-for="weightResult in measurementDetail.weightResults"')
expect(source).toContain('<el-table :data="measurementDetail?.weightResults ?? []" row-key="id" border stripe empty-text="暂无称重图片与确认结果">')
expect(source).toContain(':file-id="weightResult.fileId"')
expect(source).toContain('openMeasurementEvidence(weightResult.fileId')
expect(source).toContain('measurementDetail?.actualGrossWeightKg')
@@ -2732,30 +2732,37 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
</el-table>
</div>
<div class="measurement-total"><strong>合计</strong><span>箱数:{{ measurementTotals(actualMeasurementRows).count ?? '—' }}</span><span>总毛重:{{ formatNumber(measurementTotals(actualMeasurementRows).gross) }}</span><span>总体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).volume) }}</span><span>总材积重:{{ formatNumber(measurementTotals(actualMeasurementRows).volumetric) }}</span><span>重量体积:{{ formatMeasurementVolume(measurementTotals(actualMeasurementRows).weightVolume) }}</span></div>
<section class="weight-evidence" aria-label="称重图片与服务端确认重量">
<div class="weight-evidence__heading">
<div><h4>称重图片与确认重量</h4></div>
<dl><dt>实际总毛重</dt><dd>{{ formatNumber(measurementDetail?.actualGrossWeightKg) }} kg</dd></dl>
<section class="measurement-section" aria-label="称重图片与服务端确认重量">
<div class="measurement-section-title"><h3>称重图片与确认重量</h3></div>
<div class="detail-table-wrap">
<el-table :data="measurementDetail?.weightResults ?? []" row-key="id" border stripe empty-text="暂无称重图片与确认结果">
<el-table-column label="称重图片" width="148">
<template #default="{ row: weightResult }">
<MeasurementEvidenceThumbnail
:customer-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:file-id="weightResult.fileId"
:alt="`称重图片 · ${weightResult.palletType || '未记录类型'}`"
empty-text="未绑定称重图片"
@open="openMeasurementEvidence(weightResult.fileId, `称重图片 · ${weightResult.palletType || '未记录类型'}`)"
/>
</template>
</el-table-column>
<el-table-column label="托盘类型" min-width="160">
<template #default="{ row: weightResult }">{{ weightResult.palletType || '未记录类型' }}</template>
</el-table-column>
<el-table-column label="服务端确认毛重(kg)" min-width="180" align="right">
<template #default="{ row: weightResult }">{{ formatNumber(weightResult.grossWeightKg) }}</template>
</el-table-column>
<el-table-column label="托盘皮重(kg)" min-width="140" align="right">
<template #default="{ row: weightResult }">{{ formatNumber(weightResult.palletTareWeightKg) }}</template>
</el-table-column>
<el-table-column label="货物重量(kg)" min-width="140" align="right">
<template #default="{ row: weightResult }">{{ formatNumber(weightResult.netWeightKg) }}</template>
</el-table-column>
</el-table>
</div>
<div v-if="measurementDetail?.weightResults.length" class="weight-evidence-grid">
<article v-for="weightResult in measurementDetail.weightResults" :key="weightResult.id" class="weight-evidence-card">
<MeasurementEvidenceThumbnail
:customer-id="String(detail.summary.tenantId)"
:inbound-id="String(detail.summary.inboundId)"
:file-id="weightResult.fileId"
:alt="`称重结果 ${weightResult.id} 的称重图片`"
empty-text="未绑定称重图片"
@open="openMeasurementEvidence(weightResult.fileId, `称重图片 · ${weightResult.palletType || '未记录类型'}`)"
/>
<dl>
<div><dt>托盘类型</dt><dd>{{ weightResult.palletType || '未记录类型' }}</dd></div>
<div><dt>服务端确认毛重</dt><dd>{{ formatNumber(weightResult.grossWeightKg) }} kg</dd></div>
<div><dt>托盘皮重</dt><dd>{{ formatNumber(weightResult.palletTareWeightKg) }} kg</dd></div>
<div><dt>货物重量</dt><dd>{{ formatNumber(weightResult.netWeightKg) }} kg</dd></div>
</dl>
</article>
</div>
<p v-else class="compact-empty">暂无称重图片与确认结果</p>
<div class="measurement-total"><strong>合计</strong><span>实际总毛重:{{ formatNumber(measurementDetail?.actualGrossWeightKg) }} kg</span></div>
</section>
</div>
</section>
@@ -2810,8 +2817,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
@keydown="handleTabKeydown($event, index)"
><span>{{ tab.label }}</span><span v-if="tabCount(tab.value) !== undefined" class="order-status-count">({{ tabCount(tab.value) }})</span></button>
</div>
<div v-if="statusCountsLoading" class="order-status-feedback" role="status">页签数量加载中…</div>
<div v-else-if="statusCountsError" class="order-status-feedback" role="status">
<div v-if="statusCountsError" class="order-status-feedback" role="status">
<span>{{ statusCountsError.message }}</span>
<el-button link type="primary" @click="loadStatusCounts">重试数量</el-button>
<ApiErrorMeta :details="statusCountsError" />
@@ -2854,8 +2860,7 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
><span>{{ tab.label }}</span><span v-if="tabCount(tab.value) !== undefined" class="order-status-count">({{ tabCount(tab.value) }})</span></button>
</div>
<div v-if="statusCountsLoading" class="order-status-feedback" role="status">页签数量加载中…</div>
<div v-else-if="statusCountsError" class="order-status-feedback" role="status">
<div v-if="statusCountsError" class="order-status-feedback" role="status">
<span>{{ statusCountsError.message }}</span>
<el-button link type="primary" @click="loadStatusCounts">重试数量</el-button>
<ApiErrorMeta :details="statusCountsError" />
@@ -3264,19 +3269,6 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
.measurement-total { display: flex; min-height: var(--admin-control-height, 32px); flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: var(--admin-space-1, 4px) 20px; padding: var(--admin-space-2, 6px) var(--admin-space-4, 10px); border: 1px solid var(--workbench-border); border-top: 0; background: var(--admin-label-bg, #f8fafc); color: #475467; font-size: var(--admin-font-caption, 12px); font-variant-numeric: tabular-nums; }
.measurement-total strong { margin-right: auto; color: var(--workbench-text); font-size: var(--admin-font-body, 13px); }
.weight-evidence { margin-top: var(--admin-space-5, 12px); padding: var(--admin-space-4, 10px) 0 0; border-top: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
.weight-evidence__heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-6, 16px); margin-bottom: var(--admin-space-3, 8px); }
.weight-evidence__heading h4 { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-section, 14px); }
.weight-evidence__heading p { margin: 5px 0 0; color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
.weight-evidence__heading dl { margin: 0; text-align: right; }
.weight-evidence__heading dt { color: var(--workbench-muted); font-size: var(--admin-font-caption, 11px); }
.weight-evidence__heading dd { margin: 0; color: var(--workbench-text); font-size: var(--admin-font-body, 13px); font-weight: 600; }
.weight-evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--admin-space-3, 8px); }
.weight-evidence-card { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; min-width: 0; padding: var(--admin-space-3, 8px); border: 1px solid var(--workbench-border); background: var(--admin-surface, #fff); }
.weight-evidence-card > dl { display: grid; gap: var(--admin-space-1, 4px); margin: 0; padding: 0 0 0 var(--admin-space-3, 8px); }
.weight-evidence-card > dl > div { display: flex; min-width: 0; justify-content: space-between; gap: var(--admin-space-5, 12px); }
.weight-evidence-card dt { color: var(--workbench-muted); font-size: var(--admin-font-caption, 11px); }
.weight-evidence-card dd { min-width: 0; margin: 0; color: var(--workbench-text); font-size: var(--admin-font-caption, 12px); font-weight: 650; text-align: right; overflow-wrap: anywhere; }
.measurement-original-preview { display: block; width: 100%; max-height: min(72vh, 760px); object-fit: contain; border-radius: 8px; background: #f2f4f7; }
.hold-section { padding-bottom: var(--admin-space-5, 12px); border-bottom: 1px solid var(--workbench-border); }
.other-notes-section { padding-top: var(--admin-space-4, 10px); }
@@ -3306,10 +3298,8 @@ function orderDialogTitle(action: string, inboundCode?: string | null) {
.notes-list span { display: block; margin-top: 5px; color: var(--workbench-muted); font-size: var(--admin-font-caption, 12px); }
@media (max-width: 960px) { .order-info-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .measurement-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .page-intro { align-items: stretch; flex-direction: column; } .page-intro :deep(.el-button) { width: 100%; min-height: 44px; } .table-actions :deep(.el-button) { width: 100%; min-height: 40px; } .row-actions { flex-wrap: wrap; } .order-detail-header { align-items: stretch; flex-direction: column; } .order-detail-heading { flex-wrap: wrap; } .detail-header-actions { display: grid; grid-template-columns: 1fr; } .detail-header-actions :deep(.el-button) { min-height: 40px; } .order-info-grid, .measurement-grid { grid-template-columns: 1fr; } .order-info-grid > div.wide { grid-column: auto; } .detail-tabs button { padding: 0 18px; } .detail-panel { padding: 16px; } .measurement-section-title { align-items: flex-start; flex-wrap: wrap; padding: 12px; } .measurement-section-summary { width: 100%; flex-basis: calc(100% - 34px); flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; white-space: normal; } .measurement-section-title > svg { margin-left: auto; } .measurement-total { flex-wrap: wrap; justify-content: flex-start; gap: 6px 16px; } .measurement-total strong { width: 100%; } }
@media (max-width: 720px) { .weight-evidence__heading { flex-direction: column; } .weight-evidence__heading dl { text-align: left; } }
@media (max-width: 720px) { .fee-overview { grid-template-columns: 1fr; } .fee-overview > div + div { border-top: 1px solid var(--workbench-border); border-left: 0; } }
@media (max-width: 720px) { .measurement-section-title { align-items: stretch; flex-direction: column; } .measurement-section-toggle { align-items: flex-start; flex-wrap: wrap; } .measurement-section-toggle > svg { margin-left: auto; } .measurement-section-mode { width: 100%; flex-basis: auto; } }
@media (max-width: 420px) { .weight-evidence { padding: 10px; } .weight-evidence-grid { grid-template-columns: 1fr; } }
</style>
<style src="@/styles/adminDensity.css"></style>
+5
View File
@@ -285,6 +285,11 @@
--el-input-hover-border-color: var(--el-color-primary);
--el-input-bg-color: var(--admin-surface);
}
:is(.admin-density, .admin-density-dialog) .density-form--outlined :is(.el-input, .el-textarea) {
--el-input-border-color: var(--el-border-color);
--el-input-hover-border-color: var(--el-border-color-hover);
--el-input-bg-color: var(--admin-surface);
}
:is(.admin-density, .admin-density-dialog) .density-table-editor { display: flex; align-items: center; gap: var(--admin-space-3); min-width: 0; }
:is(.admin-density, .admin-density-dialog) .density-table-editor .el-input-number { flex: 1; width: 100%; min-width: 0; }
:is(.admin-density, .admin-density-dialog) .density-table--editable .el-input__inner { text-align: left; }
+21
View File
@@ -288,6 +288,27 @@ describe('approved Admin density ownership', () => {
expect(view.styles.map(style => style.content).join('\n')).not.toContain('--el-input-border-color')
})
it('outlines opted-in customer forms without overriding native focus, validation or disabled styles', () => {
const rules = new Map<string, Record<string, string>>()
cssRoot(shared).walkRules(rule => {
const values: Record<string, string> = {}
rule.walkDecls(decl => { values[decl.prop] = decl.value })
rules.set(rule.selector, values)
})
expect(rules.get(':is(.admin-density, .admin-density-dialog) .density-form--outlined :is(.el-input, .el-textarea)')).toEqual({
'--el-input-border-color': 'var(--el-border-color)',
'--el-input-hover-border-color': 'var(--el-border-color-hover)',
'--el-input-bg-color': 'var(--admin-surface)',
})
const customerRules = new Map<string, Record<string, string>>()
cssRoot(read('modules/customers/customerDensity.css')).walkRules(rule => {
const values: Record<string, string> = {}
rule.walkDecls(decl => { values[decl.prop] = decl.value })
customerRules.set(rule.selector, values)
})
expect(customerRules.get('.customer-editor-dialog .density-form--outlined .el-input-number .el-input__inner')).toEqual({ 'text-align': 'left' })
})
it('outlines only the three opted-in customer billing editors without overriding native focus or disabled styles', () => {
const rules = new Map<string, Record<string, string>>()
cssRoot(shared).walkRules(rule => {