Compare commits

...
98 changed files with 4099 additions and 540 deletions
+7 -6
View File
@@ -2,13 +2,14 @@
## 快速读取顺序
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。
Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件任务按需读取 `../../docs/统一认证与服务通信契约.md` 对应章节。
1. 由“模块快速路由”选最小模块并读最近 `AGENTS.md`,按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不扫描全部 `src/`。
2. 视觉、布局/交互承载变更或真实页面验收先读 `../../docs/Admin-Client前端原型还原验收规则.md`;需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发。
样式优化另读 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”“新模块接入规则”,保留已确认密度、彩色页签、业务语义及承载。国内仓、客户管理为样板;新增菜单按本轮范围显式接入,货柜入口见 `../../docs/Admin货柜管理样式核对记录.md`。
蓝白样式接入见 [样板指南](../../docs/界面参考/蓝白卡片式工作台风格参考.md)。
3. 标准列表/公共组件读 `src/shared/components/AGENTS.md`;HTTP、Token/刷新读 `src/shared/api/AGENTS.md`;Finance 读 `src/modules/finance/AGENTS.md`;海外仓全流程先读 `../../docs/海外仓业务/README.md`。
4. 平台/客户层级及权限读 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件按需读 `../../docs/统一认证与服务通信契约.md` 对应章节。
普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。
单端仅读本指引、最近模块指引及命中代码;跨服务状态、权限或接口边界不明才加后端契约。预算遵循根指引,先读适用规则、定向查业务表,跳过无关专题。
## 项目定位与技术基线
+9 -2
View File
@@ -8,6 +8,13 @@ import {
} from './adminNavigation'
describe('adminNavigation', () => {
it('keeps outsourced read access separate from owned access and write-only permissions', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:list']))
const vehicle = read.find(node => node.key === 'vehicle-management')
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/outsourced-business'])
const write = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:edit', 'platform:fleet:outsourced-business:upload']))
expect(getNavigationLeaves(write).some(node => node.path === '/overseas-master-data/outsourced-business')).toBe(false)
})
it('places owned-business under vehicle management and keeps edit/upload from granting page access', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:owned-business:list']))
const vehicle = read.find(node => node.key === 'vehicle-management')
@@ -40,7 +47,7 @@ describe('adminNavigation', () => {
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
{ title: '预约管理', children: ['预约列表'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '出车记录'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配业务', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
@@ -49,7 +56,7 @@ describe('adminNavigation', () => {
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(13)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(57)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
+1
View File
@@ -135,6 +135,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
{ key: 'fleet-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' },
{ key: 'fleet-outsourced-business', title: '外配业务', icon: Truck, path: '/overseas-master-data/outsourced-business', permission: 'platform:fleet:outsourced-business:list' },
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true },
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
],
@@ -1,3 +1,4 @@
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import AppointmentBatchForm from './AppointmentBatchForm.vue'
@@ -13,6 +14,7 @@ vi.mock('./batchApi', async original => ({ ...await original<typeof import('./ba
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkItems: vi.fn() }))
let guard: LeaveGuard
function render() { return mount(AppointmentBatchForm, { props: { batchKey: 'FBA_APPOINTMENT:1' }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType || \'button\'" :disabled="disabled"><slot /></button>' },
LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true, CargoVerificationForm: true,
} } }) }
@@ -176,7 +178,7 @@ it('retries a new destination without exposing the prior batch or retaining its
expect(wrapper.text()).not.toContain('AP-001'); expect(wrapper.find('form').exists()).toBe(false)
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('AP-CURRENT')
expect((wrapper.findAll('input[type="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
expect((wrapper.findAll('input[data-picker-kind="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('0')
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:2')
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
@@ -121,10 +121,10 @@ onMounted(() => load())
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
<div v-if="!context.sources.length" aria-label="原批次货物快照"><p>当前批次无分批资格,以下为原预约保留的货物资料。</p><div v-for="(item, index) in context.appointment.items" :key="item.id" class="allocation-row"><b>来源 {{ index + 1 }} · {{ item.inboundCode || '原票业务号未提供' }}</b><p>{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div></div>
<div class="batch-modes"><el-button :disabled="saving || !!pending || !context.allowedActions.includes('SPLIT')" @click="changeMode('split')">分成两次送仓</el-button><el-button :disabled="saving || !!pending || !context.allowedActions.includes('MERGE')" @click="changeMode('merge')">同仓合并安排</el-button></div>
<form @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
<form class="density-form" @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
<div v-for="(source, index) in context.sources" :key="allocationKey(source)" class="allocation-row"><b>来源 {{ index + 1 }} · {{ sourceLabel(source) }}</b><p>本批 {{ source.packageCount ?? '待核实' }} 箱 · 原箱数 {{ source.originalPackages ?? '未知' }}</p><p>货件:{{ source.shipmentReferences.join('、') || '待核实' }}</p><label v-if="mode === 'split'">分到第二批的箱数<input v-model="form.moved[allocationKey(source)]" type="number" min="0" :max="source.packageCount ?? undefined" step="1" :aria-label="`来源${index + 1}分出箱数`"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)">核实本票资料</el-button></div>
<template v-if="mode === 'merge'"><label>查找另一批工作事项<input v-model="keyword" placeholder="预约号、批次号、柜号" maxlength="80" @keydown.enter.prevent="searchCandidates()"></label><el-button :loading="candidateLoading" @click="searchCandidates()">查询批次</el-button><LoadErrorState v-if="candidateError" title="合并候选不可用" :message="candidateError.message" @retry="searchCandidates(candidatePage)" /><div v-for="candidate in candidates?.records.filter(item => item.batchKey && item.batchKey !== activeKey)" :key="candidate.workItemKey" class="batch-candidate"><span>{{ candidate.title }} · {{ candidate.destinationName || '目的仓待核实' }}</span><el-button :disabled="candidateLoading" @click="chooseOther(candidate.batchKey!)">核对并选择</el-button></div><p v-if="candidates && !candidates.records.length">本页未查询到工作事项。</p><div v-if="candidates && candidates.total > 8" class="batch-modes"><el-button :disabled="candidatePage === 1 || candidateLoading" @click="searchCandidates(candidatePage - 1)">上一页</el-button><span>第 {{ candidatePage }} 页</span><el-button :disabled="candidatePage * 8 >= candidates.total || candidateLoading" @click="searchCandidates(candidatePage + 1)">下一页</el-button></div><p v-if="other">已核对:{{ other.appointment.appointmentNo }},{{ other.sources.length }} 条来源;提交时再次逐来源校验。</p></template>
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<input v-model="form.firstDate" type="date"></label><label v-if="mode === 'split'">第二批目标日期(可不填)<input v-model="form.nextDate" type="date"></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" /></label>
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<el-date-picker v-model="form.firstDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label v-if="mode === 'split'">第二批目标日期(可不填)<el-date-picker v-model="form.nextDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" /></label>
</fieldset><LoadErrorState v-if="saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></form>
</template>
</template>
@@ -62,10 +62,13 @@ it('keeps the arrival context and typed local time across native fullscreen and
expect(dialog.props('width')).toBe('520px')
expect(dialog.get('[aria-label="到仓业务信息"]').text()).toContain('TEST-CONT 计划到仓')
expect(dialog.text()).not.toContain(warehouse.sourceContainerBookingId)
await dialog.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
const picker = dialog.getComponent({ name: 'ElDatePicker' })
expect(picker.props()).toMatchObject({ type: 'datetime', valueFormat: 'YYYY-MM-DDTHH:mm', format: 'YYYY-MM-DD HH:mm' })
expect(dialog.getComponent({ name: 'ElForm' }).props('labelWidth')).toBe('150px')
picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect((dialog.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
expect(picker.props('modelValue')).toBe('2026-10-22T09:30')
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String))
})
@@ -73,7 +76,7 @@ it('locks the real arrival input after an unknown outcome and reuses its immutab
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost'))
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
await dialog.get('input').setValue('2026-10-22T09:30')
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(dialog.get('input').attributes('disabled')).toBeDefined()
expect(dialog.text()).toContain('上次提交结果尚未确认')
@@ -81,6 +84,17 @@ it('locks the real arrival input after an unknown outcome and reuses its immutab
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
expect(vi.mocked(saveWarehouseArrival).mock.calls[1]).toEqual(vi.mocked(saveWarehouseArrival).mock.calls[0])
})
it('clears the Element arrival picker without saving an empty required time', async () => {
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
const picker = dialog.getComponent({ name: 'ElDatePicker' })
expect(dialog.find('input[type="datetime-local"]').exists()).toBe(false)
picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(false)
picker.vm.$emit('update:modelValue', null); await flushPromises()
expect(dialog.props('confirmDisabled')).toBe(true)
expect(saveWarehouseArrival).not.toHaveBeenCalled()
})
it('keeps complete long cargo names accessible in the selectable table', async () => {
const wrapper = await open()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
@@ -1,3 +1,4 @@
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { onBeforeRouteLeave } from 'vue-router'
@@ -17,6 +18,7 @@ const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
export function renderSchedule() {
return mount(AppointmentCalendarView, { global: { stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
AdminPage: { template: '<main><slot /></main>' },
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode', 'canCreate'], template: '<section data-calendar />' },
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
@@ -109,7 +111,7 @@ describe('simplified appointment entry', () => {
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
const wrapper = renderSchedule(); await flushPromises()
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
await wrapper.get('input[type="datetime-local"]').setValue('2026-10-22T09:30')
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30')
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('提交结果尚未确认')
expect(wrapper.get('input').attributes('disabled')).toBeDefined()
@@ -183,8 +183,8 @@ onBeforeUnmount(() => { ++generation; controller?.abort() })
</section>
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
<el-form class="density-form" label-position="left" label-width="110px" :disabled="saving || unknownResult">
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label><span>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></span></template><el-input v-model="plannedAt" type="datetime-local" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" /></el-form-item></td></tr></tbody></table>
<el-form class="density-form" label-position="left" label-width="150px" :disabled="saving || unknownResult">
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving || unknownResult" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></el-form-item></td></tr></tbody></table>
</el-form>
</section>
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
@@ -1,5 +1,53 @@
# 预约工作日历逐操作矩阵
## 2026-10-21 排车候选分页
| 页面/组件 | 操作与承载依据 | 本次调整 | 验收 |
|---|---|---|---|
| WorkDispatchForm | 保留既有预约事项深链内的表单、批次范围与整车草稿流程;现行排车契约第17项候选分页 | 表下分页仅请求当前页;说明总数为关联计划候选数,表格仅显示本批货物;跨页保留选择和原批次版本,完整组/来源版本变化阻止建单 | 定向自动检查单独记录;1920×1080、100%待人工截图确认 |
## 2026-10-20 日期时间组件统一(当前确认)
依据:用户截图 `codex-clipboard-ed442cca-1bd2-43d3-87e8-6671e5bcc508.png`,以及随后明确要求排查并优化同类原生日期输入。保留现有业务入口与承载方式,使用 Element Plus 日期/时间控件及已有公共表单样式。
| 页面/组件 | 操作与承载 | 本次调整 | 验收 |
|---|---|---|---|
| AppointmentCalendarView | 调整计划到仓,原520px居中弹框及全屏能力 | 1处日期时间选择器;分钟精度沿用原请求格式;标签宽度150px,说明图标与标签并排,控件沿用公共满宽样式 | 真实浏览器检查通过:日期面板、选值、空值禁用、全屏保值、未保存保护及放弃返回;未提交业务保存 |
| WorkReceivingPanel | 原业务深链中的客户计划、后送与跟进,原表单 | 5处日期/日期时间输入;日期与秒精度的当地时间分别保存;调整开始/结束时段后仍清除原时差依据 | 自动检查通过;待人工截图确认 |
| WorkCommandForm | 跟进、收货要求,原表单 | 3处日期、2处时间输入;跨日结束日期显式保留;不推断次日、不转换地点时区 | 自动检查通过;待人工截图确认 |
| AppointmentBatchForm | 原分批/合并表单 | 2处可空目标日期,保留跨批分配、版本、幂等与原写权限 | 自动检查通过;待人工截图确认 |
共4组件13处。日期使用 `el-date-picker`,时间使用 `el-time-picker`;保留原权限、处理中及未知结果锁定、清空与未保存保护。新增控件显式接收原禁用条件,宽度复用 `density-form`,不新增全局日期控件外观规则。
自动验证:受影响7个测试文件88项通过;类型检查与生产构建通过。全量回归执行464个文件4203项,4201项通过,两项旧公共样式断言(标签宽度、追加样式类)更新后,该样式文件65项全部复验通过。后台业务模板原生日期/时间控件残留检查通过。本轮未部署TEST,未进行真实HTTP或人工页面截图验收。
补充浏览器验收:用户明确要求打开页面后,使用已发布TEST页面,页面报告1920×1080、DPR1、visual scale1。TSTU5446539的默认计划、Element日期面板、快捷选值、全屏保值、清空禁用、未保存保护与放弃返回通过;原计划仍2026-10-01,未点击保存。MSCU7264017现已有入库计划,按冻结时间显示且无调整入口。本轮仅覆盖日历与调整到仓弹框,其余三表单仍待逐页操作;输入09:30的最终确认未单独完成,不计通过。证据见 `.logs/appointment-element-release-20261020/ui/result.json` 及同目录截图。
## 2026-10-20 蓝白工作台续批(写权已协调)
本次续批修改文件(相对本模块):
- `AppointmentBatchForm.vue`
- `AppointmentCalendarPanel.vue`
- `AppointmentCalendarView.vue`
- `AppointmentCreatePanel.vue`
- `ReceivingScope.vue`
- `SourceAppointmentPanel.vue`
- `TripAppointmentPanel.vue`
- `WorkDetailPanel.vue`
- `WorkReceivingPanel.vue`
总控已核实预约交付及排车只读预审状态,本轮授权续接展示;逐文件按当前内容快照核对后写入。依据蓝白样板与本矩阵既有逐操作承载。
| 实际入口 | 操作/承载与冻结结论 | 验收 |
|---|---|---|
| 预约日历主页面及原业务深链的实际表单、工作详情;保留四类、月周日期与原弹框承载 | 只接入主题、公共列表/面板和局部旧外观清理;尺寸、字段、事件、权限、请求及业务计算保留 | 续批定向回归通过;待人工1920×1080、100%截图确认 |
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
当前无正式入口引用的11个旧面板保留原样:AppointmentCancellationDialog、AppointmentCancellationHistory、AppointmentCancellationRecords、AppointmentCargoPanel、AppointmentCargoTabs、AppointmentContainerPanel、AppointmentExternalCandidates、AppointmentFollowupDialog、AppointmentImpactDialog、AppointmentInboundCodes、WorkCalendarPanel。不是新增或恢复入口;TripAppointmentPanel仍由正式车次详情引用,已纳入本次续批。
## 2026-10-20 排车入口收敛
用户指出预约日历仍跳旧车次详情。排车标签改用服务端关联真实整车主单返回的排车单号,打开 `/overseas-warehouse/dispatches/:dispatchNo` 独立排车详情;返回按钮显示“返回预约日历”并保留原日期与月/周视图。无合法日历返回参数时继续走原列表或拼车登记返回逻辑,外部返回地址拒绝。读取权限与排车列表一致,不要求旧车次列表权限;独立旧车次、取消排车主单不进入活动日历,不按TR号猜DS号。原四类、地点日期和实际发车归属规则保持。自动检查与发布/真实HTTP分别记录,1920×1080、100%人工操作截图待确认。
@@ -221,3 +269,5 @@ V13 日历布局:按已确认定高要求,月日格固定 146px,周日格
| 刷新后沿用已有FBA排车单 | 当前对话指定沿用原DS;`WorkExistingDispatchForm.vue` 的既有原排车列表和 `TripPreparationDialog` | 保留事项弹框,显示本批原排车单;继续用原安排车次弹框,不重复选托盘或创建 | `WorkDispatchForm.vue` 先读当前批次 `receiving.transport`,再按原排车单 `appointmentBatches` 校验批次/客户;已绑定车次直接查看,关系读取失败不显示创建入口;剩余实物仅显式选择后读取。待人工截图确认 |
视觉目标:1920×1080、100%缩放,待人工截图确认。单元测试与类型检查单独报告,不代表真实接口或页面验收通过。原业务跳转缺少的业务号/客户范围在协作交接中列明,不由前端补造;工作 Slot 与原预约编辑均保留完整跨日时间。原业务页面返回入口只接受预约工作页,并保留原事项、前置事项、筛选及页码。
2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。预约页面没有额外整页面板,当前双面板结构保持,不给左右面板增加该modifier。
@@ -1,3 +1,4 @@
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import WorkCommandForm from './WorkCommandForm.vue'
@@ -13,6 +14,7 @@ vi.mock('./workApi', async original => ({ ...await original<typeof import('./wor
let guard: LeaveGuard
function render(action: WorkAction = followAction, detail = workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) })) {
return mount(WorkCommandForm, { props: { detail, action, refreshing: false }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType" :disabled="disabled"><slot /></button>' },
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}</div>' }, ApiErrorMeta: true,
} } })
@@ -79,7 +81,7 @@ const requirementAction: WorkAction = { code: 'SAVE_REQUIREMENT', label: '登记
it('preserves an explicit cross-day local interval in the requirement request', async () => {
const wrapper = render(requirementAction)
await wrapper.findAll('textarea')[1]!.setValue('跨日收货窗口')
const dates = wrapper.findAll('input[type=date]'), times = wrapper.findAll('input[type=time]')
const dates = wrapper.findAll('input[data-picker-kind=date]'), times = wrapper.findAll('input[data-picker-kind=time]')
await dates[0]!.setValue('2026-10-15'); await times[0]!.setValue('23:00')
await dates[1]!.setValue('2026-10-16'); await times[1]!.setValue('01:00')
await wrapper.get('form').trigger('submit'); await flushPromises()
@@ -93,7 +95,7 @@ it.each([
])('rejects incomplete or non-increasing interval %s %s %s without inferring a next day', async (endDate, endTime, startTime) => {
const wrapper = render(requirementAction)
await wrapper.findAll('textarea')[1]!.setValue('核对时间')
const dates = wrapper.findAll('input[type=date]'), times = wrapper.findAll('input[type=time]')
const dates = wrapper.findAll('input[data-picker-kind=date]'), times = wrapper.findAll('input[data-picker-kind=time]')
await dates[0]!.setValue('2026-10-15'); await times[0]!.setValue(startTime)
await dates[1]!.setValue(endDate); await times[1]!.setValue(endTime)
await wrapper.get('form').trigger('submit'); await flushPromises()
@@ -102,7 +104,7 @@ it.each([
it('saves a date-only requirement with all time fields null', async () => {
const wrapper = render(requirementAction)
await wrapper.findAll('textarea')[1]!.setValue('具体时段待回复')
await wrapper.findAll('input[type=date]')[0]!.setValue('2026-10-15')
await wrapper.findAll('input[data-picker-kind=date]')[0]!.setValue('2026-10-15')
await wrapper.get('form').trigger('submit'); await flushPromises()
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({ payload: expect.objectContaining({ target: { date: '2026-10-15', startTime: null, endDate: null, endTime: null, timezone: 'America/Los_Angeles' } }) }))
wrapper.unmount()
@@ -92,9 +92,9 @@ async function save() {
<p v-if="missingContext" role="alert">{{ missingContext }}</p>
<fieldset :disabled="!allowed || refreshing || saving || !!pending">
<template v-if="command === 'CONFIRM_ROUTE'"><label>运输方式<select v-model="form.flow"><option value="TRANSFER">先入海外仓,再分批送仓</option><option value="DIRECT">整柜直接送目的仓</option></select></label><label>路线说明<textarea v-model="form.reason" maxlength="1000" /></label></template>
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" /></label><label>下次跟进日期(可留空)<input v-model="form.followupDate" type="date"></label></template>
<template v-else-if="command === 'SAVE_FOLLOWUP'"><label>跟进负责人<select v-model="form.ownerId" required><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>本次回复<textarea v-model="form.note" required maxlength="1000" /></label><label>下次跟进日期(可留空)<el-date-picker v-model="form.followupDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label></template>
<template v-else-if="command === 'RESOLVE_REVIEW'"><p v-for="reason in detail.review?.reasons" :key="reason">{{ reason }}</p><label>本次核对结果<textarea v-model="form.note" required maxlength="1000" /></label></template>
<template v-else-if="command === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<input v-model="form.date" type="date"></label><div class="time-fields"><label>开始时段(可留空)<input v-model="form.startTime" type="time"></label><label>结束日期(可留空)<input v-model="form.endDate" type="date"></label><label>结束时段(可留空)<input v-model="form.endTime" type="time"></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" /></label></template>
<template v-else-if="command === 'SAVE_REQUIREMENT'"><label>目标日期(可暂不填)<el-date-picker v-model="form.date" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><div class="time-fields"><label>开始时段(可留空)<el-time-picker v-model="form.startTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label><label>结束日期(可留空)<el-date-picker v-model="form.endDate" type="date" :disabled="!allowed || refreshing || saving || !!pending" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>结束时段(可留空)<el-time-picker v-model="form.endTime" :disabled="!allowed || refreshing || saving || !!pending" format="HH:mm" value-format="HH:mm" clearable /></label></div><p>{{ item.timezone || '地点时区待核实' }}</p><label v-if="item.type === 'DELIVERY'"><input v-model="form.bookingRequired" type="checkbox">需要收货方预约</label><label>收货要求<textarea v-model="form.receivingRequirements" :required="!form.bookingRequired" maxlength="1000" /></label><label>处理说明<textarea v-model="form.reason" required maxlength="1000" /></label></template>
<template v-else-if="command === 'HANDOFF_RETURN'"><p>转交保留原预约和责任记录;接手方确认前持续跟进。</p><label>接手方类型<select v-model="form.recipientKind" @change="changeRecipient"><option value="EXTERNAL">外部车队 / 联系人</option><option value="SUPPLIER">已登记海外车队</option><option value="USER">当前关联负责人</option></select></label><label v-if="form.recipientKind === 'EXTERNAL'">接手方名称<input v-model="form.recipientName" required maxlength="200"></label><template v-else-if="form.recipientKind === 'SUPPLIER'"><label>查找海外车队<input v-model="supplierKeyword" maxlength="80" @keydown.enter.prevent="searchSuppliers"></label><el-button :loading="supplierLoading" @click="searchSuppliers">查询车队</el-button><LoadErrorState v-if="supplierError" title="还空车队不可用" :message="supplierError.message" @retry="searchSuppliers" /><label>接手车队<select v-model="form.recipientId" required :disabled="supplierLoading || !!supplierError"><option value="" disabled>请选择已登记海外车队</option><option v-for="supplier in suppliers" :key="supplier.id" :value="supplier.id">{{ supplier.name }}</option></select></label><p v-if="supplierTotal > suppliers.length">请输入更完整名称缩小查询范围。</p></template><label v-else>接手负责人<select v-model="form.recipientId" required><option value="" disabled>请选择</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>转交原因<textarea v-model="form.reason" required maxlength="1000" /></label></template>
<template v-else-if="command === 'ACCEPT_RETURN'"><p>原责任方:{{ detail.returnHandoff?.from.name || '—' }} → 接手方:{{ detail.returnHandoff?.to.name || '—' }}</p><label>接手反馈依据<textarea v-model="form.note" :required="detail.returnHandoff?.to.kind !== 'USER'" maxlength="1000" /></label><p>确认接手只记录责任,实际还空仍按原业务操作。</p></template>
</fieldset>
@@ -48,7 +48,7 @@ beforeEach(() => {
natives = [candidate()]; draft = { draftToken: 'server-owned-token', recordVersion: 0, status: 'ACTIVE', warehouseId: 'origin-1', containers: [], sources: [], expiresAt: '2026-10-20T00:00:00Z' }
vi.mocked(getReceivingContext).mockImplementation(async key => receiving(key))
vi.mocked(getBatchDispatchCandidates).mockImplementation(async key => ({ batchKey: key, version: 4, candidates: [row('9007199254740993', 'customer-a', key)], blockReasons: [] }))
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
vi.mocked(listDispatchCandidates).mockImplementation(async () => ({ total: 1, page: 1, size: 20, pages: 1, candidates: clone(natives), complete: true, snapshotAt: '2026-10-19T00:00:00Z' }))
vi.mocked(createDispatchDraft).mockImplementation(async () => clone(draft))
vi.mocked(createDispatchDraftContainer).mockImplementation(async body => { draft.recordVersion = 1; draft.containers = [{ draftContainerId: 'container-1', sequenceNo: 1, capacity: body.capacity, sourceKeys: [] }]; return clone(draft) })
vi.mocked(lockDispatchDraftPallet).mockImplementation(async body => { const source = natives.find(value => value.selection.planPalletId === body.source.planPalletId)!; draft.recordVersion = Number(draft.recordVersion) + 1; draft.sources.push(clone(source)); draft.containers[0]!.sourceKeys.push(source.sourceKey); return clone(draft) })
@@ -58,7 +58,7 @@ beforeEach(() => {
})
it('creates the whole dispatch when native Long candidate quantities arrive as strings and batch quantities are Integers', async () => {
const actual = await vi.importActual<typeof import('../overseas-warehouse/api/dispatches')>('../overseas-warehouse/api/dispatches')
const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data: { candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } })))
const get = vi.spyOn(http, 'get').mockResolvedValue(parseApiJsonResponse(JSON.stringify({ code: '200', data: { total: 1, page: 1, size: 20, pages: 1, candidates: [{ ...candidate(), availablePackages: '12' }], complete: true, snapshotAt: '' } })))
vi.mocked(listDispatchCandidates).mockImplementation(actual.listDispatchCandidates)
const wrapper = render()
try {
@@ -98,7 +98,7 @@ it('uses a server-owned draft, explicit zero versions, native source hashes and
})
it('combines same-warehouse real customers in one container and keeps each batch source owner', async () => {
const second = row('9007199254740994', 'customer-b'); natives.push(candidate(second)); vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), second], blockReasons: [] })
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, true])
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); const groups = vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups
expect(groups).toHaveLength(1); expect(groups[0]!.dispatch.cargo).toEqual(natives.map(value => value.selection)); expect(groups[0]!.dispatch.batchAllocations.map(value => value.customerId)).toEqual(['customer-a', 'customer-b']); expect(wrapper.text()).not.toContain('共车'); wrapper.unmount()
})
@@ -112,12 +112,12 @@ it('keeps blocked, mismatched-customer, wrong-source and unknown-quantity cargo
const blocked = { ...row('2'), blockReasons: ['本托已被其他排车占用'] }, wrong = row('3'), unknown = { ...row('4'), packageCount: null }, wrongSource = row('5')
wrong.allocations[0]!.customerId = 'customer-b'; wrongSource.allocations[0]!.sourceKey = 'ORDER:unrelated'; natives.push(...[blocked, wrong, unknown, wrongSource].map(value => candidate(value)))
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [row(), blocked, wrong, unknown, wrongSource], blockReasons: [] })
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
const wrapper = render(); await flushPromises(); expect(wrapper.findAll('input[type=checkbox]').map(input => (input.element as HTMLInputElement).checked)).toEqual([true, false, false, false, false])
expect(wrapper.text()).toContain('本托已被其他排车占用'); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises(); expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual(row().allocations); wrapper.unmount()
})
it('fails closed on incomplete native candidates, missing versions, and cross-warehouse batches', async () => {
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.text()).toContain('未完整返回'); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.get('input').attributes('disabled')).toBeDefined(); missing.unmount()
it('accepts partial pages but fails closed on missing versions and cross-warehouse batches', async () => {
vi.mocked(listDispatchCandidates).mockResolvedValueOnce({ total: 1, page: 1, size: 20, pages: 1, candidates: natives, complete: false, snapshotAt: '' }); const incomplete = render(); await flushPromises(); expect(incomplete.get('input[type=checkbox]').attributes('disabled')).toBeUndefined(); expect(createDispatchDraft).not.toHaveBeenCalled(); incomplete.unmount()
natives[0]!.selection.sourceVersion = ''; const missing = render(); await flushPromises(); expect(missing.text()).toContain('来源版本不完整'); missing.unmount()
vi.mocked(getReceivingContext).mockImplementation(async key => { const value = receiving(key); if (key !== batch) value.batch.originWarehouseId = 'another-warehouse'; return value })
const cross = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(cross.text()).toContain('相同的起运仓'); expect(convertDispatchDraft).not.toHaveBeenCalled(); cross.unmount()
})
@@ -160,3 +160,38 @@ it('shows batch-level blockers even without rows and refuses duplicate physical
const blocked = render(); await flushPromises(); expect(blocked.get('[role="alert"]').text()).toContain('原批次已替代'); expect(createDispatchDraft).not.toHaveBeenCalled(); blocked.unmount()
const duplicate = render({ batchKeys: [batch, 'FBA_APPOINTMENT:2'] }); await flushPromises(); expect(duplicate.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true); expect(duplicate.text()).toContain('同一实物'); expect(convertDispatchDraft).not.toHaveBeenCalled(); duplicate.unmount()
})
it('keeps original batch allocations and selected cargo while paging through associated plan candidates', async () => {
const first = row(), second = row('9007199254740994', 'customer-b')
natives = [candidate(first), candidate(second)]
vi.mocked(getBatchDispatchCandidates).mockResolvedValue({ batchKey: batch, version: 4, candidates: [first, second], blockReasons: [] })
vi.mocked(listDispatchCandidates).mockImplementation(async query => ({ total: 21, page: query.page!, size: query.size!, pages: 2, candidates: [clone(natives[query.page === 2 ? 1 : 0]!)], complete: false, snapshotAt: '' }))
const wrapper = render()
try {
await flushPromises(); expect(wrapper.findAll('input[type=checkbox]')).toHaveLength(1)
await wrapper.get('input[type=checkbox]').setValue(true)
const state = wrapper.vm as unknown as { changeCandidatePage(page: number): void }
state.changeCandidatePage(2); await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
expect(listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 20, warehouseTab: 'ALL', warehouseId: 'origin-1' }))
expect(getBatchDispatchCandidates).toHaveBeenCalledTimes(1)
expect(listDispatchCandidates).toHaveBeenCalledTimes(2)
await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(vi.mocked(convertDispatchDraft).mock.calls[0]![0].groups[0]!.dispatch.batchAllocations).toEqual([...first.allocations, ...second.allocations])
} finally { wrapper.unmount() }
})
it('rejects a selected batch source whose group version changed on another candidate page', async () => {
vi.mocked(listDispatchCandidates).mockImplementation(async query => {
const value = clone(natives[0]!)
if (query.page === 2) value.selectionGroup.groupVersion = 'new-group-version'
return { total: 21, page: query.page!, size: query.size!, pages: 2, candidates: [value], complete: false, snapshotAt: '' }
})
const wrapper = render()
try {
await flushPromises(); await wrapper.get('input[type=checkbox]').setValue(true)
;(wrapper.vm as unknown as { changeCandidatePage(page: number): void }).changeCandidatePage(2)
await flushPromises(); await button(wrapper, '创建整车排车单').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('选货组组成已变化')
expect(createDispatchDraft).not.toHaveBeenCalled()
} finally { wrapper.unmount() }
})
@@ -4,7 +4,7 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
import { getDispatch, getBatchDispatchCandidates, listDispatchCandidates, useDispatchCandidates, createDispatchDraft, createDispatchDraftContainer, listDispatchDrafts, lockDispatchDraftPallet, convertDispatchDraft, releaseDispatchDraft, dispatchVehicleSpecs, DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection, type BatchDispatchCandidate, type BatchDispatchCandidates, type Candidate, type DispatchDetail, type DispatchDraft, type ConvertDraft, type ConvertedDraft, type DraftSelection, type VersionCommand } from '@/modules/overseas-warehouse'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
@@ -21,14 +21,20 @@ type Preparation = { warehouseId: string; capacity: number; rows: ReadyRow[]; di
const access = useAccessPreviewStore(), auth = useAuthStore(), owner = auth.user?.id || ''
const scope = JSON.stringify([...new Set(props.batchKeys)].sort()), storageKey = `appointment-dispatch:${owner}:${scope}`
const contexts = ref<BatchDispatchCandidates[]>([]), rows = ref<BatchRow[]>([]), selected = ref<string[]>([]), warehouseId = ref('')
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error, listDispatchCandidates)
const { page: candidatePageNumber, size: candidatePageSize, total: candidateTotal } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const selectedRows = ref<Record<string, BatchRow>>({})
let receivingContexts: Awaited<ReturnType<typeof getReceivingContext>>[] = []
let nativeGeneration = 0
const capacity = ref(30), result = ref<DispatchDetail | null>(null), pending = ref<Preparation | null>(null), corrupted = ref(false)
const adding = ref(false), addingBusy = ref(false), arrangingRemaining = ref(false), existingReady = ref(false)
const existing = ref<Array<{ batchKey: string; dispatchNo: string; customerId: string }>>([]), baseline = ref('[]')
const readable = computed(() => ['platform:appointment:list', 'platform:overseas-warehouse:last-mile-order:list', 'platform:overseas-warehouse:inbound-order:list', 'platform:overseas-warehouse:dispatch:list'].every(permission => access.can(permission)))
const canCreate = computed(() => readable.value && access.can('platform:overseas-warehouse:dispatch:create'))
const writable = computed(() => props.canWrite && canCreate.value && !corrupted.value && (!existing.value.length || existingReady.value))
const chosen = computed(() => rows.value.filter(row => selected.value.includes(row.key)))
const chosen = computed(() => selected.value.map(key => selectedRows.value[key]).filter((row): row is BatchRow => Boolean(row)))
const unresolved = computed(() => saving.value || Boolean(pending.value) || corrupted.value)
useWorkDraft(computed(() => JSON.stringify(selected.value) !== baseline.value && !result.value), unresolved)
const clone = <T,>(value: T): T => JSON.parse(JSON.stringify(value)) as T
@@ -43,7 +49,7 @@ function detailPath(no: string) { return `/overseas-warehouse/dispatches/${encod
async function load() {
if (!readable.value || pending.value || corrupted.value) return
const current = ++generation, batches = [...new Set(props.batchKeys)]
loading.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []
working.value = true; error.value = null; existingReady.value = false; rows.value = []; selected.value = []; baseline.value = '[]'; contexts.value = []; selectedRows.value = {}; candidatePage.reset(); nativeGeneration++
try {
if (!batches.length || batches.some(key => !/^FBA_APPOINTMENT:\d+$/.test(key))) throw new DispatchInputError('请从真实预约批次进入排车,不能重组其他业务批次')
const receiving = await Promise.all(batches.map(key => getReceivingContext(key)))
@@ -66,16 +72,24 @@ async function load() {
if (current !== generation) return
if (batchCandidates.some((context, index) => context.batchKey !== batches[index])) throw new DispatchInputError('实物候选与当前预约批次不一致')
requireBatchVersions(batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)))
contexts.value = batchCandidates
const planIds = [...new Set(batchCandidates.flatMap(context => context.candidates.map(row => row.planId)))]
const native = planIds.length ? await listDispatchCandidates({ warehouseId: warehouseId.value, planIds }) : { candidates: [], complete: true }
if (current !== generation) return
requireBatchVersions([...batchCandidates.flatMap(context => context.candidates.flatMap(row => row.allocations)), ...native.candidates.flatMap(row => row.appointmentBatches)])
if (native.complete !== true) throw new DispatchInputError('本仓原生候选未完整返回,不能按部分结果建单')
rows.value = batchCandidates.flatMap((context, index) => context.candidates.map(row => {
const matches = native.candidates.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
contexts.value = batchCandidates; receivingContexts = receiving
await loadCandidatePage(true)
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
finally { if (current === generation) working.value = false }
}
async function loadCandidatePage(autoSelect = false) {
const sequence = ++nativeGeneration, current = generation
const planIds = [...new Set(contexts.value.flatMap(context => context.candidates.map(row => row.planId)))]
if (!planIds.length) { candidatePage.reset(); rows.value = []; return }
await candidatePage.load({ warehouseId: warehouseId.value, planIds, page: candidatePageNumber.value, size: candidatePageSize.value, warehouseTab: 'ALL' })
if (sequence !== nativeGeneration || current !== generation) return
if (error.value) { rows.value = []; return }
try {
requireBatchVersions(candidatePage.candidates.value.flatMap(row => row.appointmentBatches))
rows.value = contexts.value.flatMap((context, index) => context.candidates.map(row => {
const matches = candidatePage.candidates.value.filter(value => value.selection.sourceType === 'INBOUND_PLAN' && value.selection.planPalletId === row.planPalletId && value.customerId === row.customerId)
const candidate = matches.length === 1 ? matches[0]! : null
const sources = receiving[index]!.batch.items
const sources = receivingContexts[index]!.batch.items
const blocks = [...context.blockReasons, ...row.blockReasons]
if (!candidate || candidate.warehouseId !== warehouseId.value || !candidate.sourceVersion || candidate.selection.sourceVersion !== candidate.sourceVersion) blocks.push('原生实物来源、起运仓或版本待核实')
if (candidate) { const groupBlock = selectionGroupBlockReason(candidate); if (groupBlock) blocks.push(groupBlock) }
@@ -84,20 +98,25 @@ async function load() {
if (row.allocations.reduce((sum, allocation) => sum + allocation.packageCount, 0) !== row.packageCount) blocks.push('逐来源批次分配与实物箱数不一致')
return { ...row, key: `${context.batchKey}:${row.customerId}:${row.planPalletId}`, batchKey: context.batchKey, native: candidate, blockReasons: [...new Set(blocks)] }
}))
if (candidateTotal.value > candidatePageSize.value) rows.value = rows.value.filter(row => row.native)
for (const row of rows.value) if (row.native && rows.value.filter(other => other.native?.sourceKey === row.native!.sourceKey).length > 1) row.blockReasons.push('同一实物出现在多条批次候选,请先核对分配范围')
const eligible = rows.value.filter(row => !reasons(row).length)
selected.value = batches.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
baseline.value = JSON.stringify(selected.value)
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
finally { if (current === generation) loading.value = false }
if (autoSelect && candidateTotal.value <= candidatePageSize.value) {
const eligible = rows.value.filter(row => !reasons(row).length)
selected.value = props.batchKeys.length === 1 && physicalCount(eligible) <= capacity.value ? eligible.map(row => row.key) : []
selectedRows.value = Object.fromEntries(eligible.filter(row => selected.value.includes(row.key)).map(row => [row.key, clone(row)]))
baseline.value = JSON.stringify(selected.value)
}
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '批次可排实物读取失败') }
}
function changeCandidatePage(page: number) { candidatePageNumber.value = page; void loadCandidatePage() }
function changeCandidateSize(size: number) { candidatePageSize.value = size; candidatePageNumber.value = 1; void loadCandidatePage() }
function select(row: BatchRow, event: Event) {
const input = event.target as HTMLInputElement
if (!writable.value || unresolved.value || loading.value || error.value || reasons(row).length) { input.checked = selected.value.includes(row.key); return }
if (!input.checked) selected.value = selected.value.filter(key => key !== row.key)
else if (!selected.value.includes(row.key)) {
if (physicalCount([...chosen.value, row]) > capacity.value) ElMessage.warning('已达到本组计划托位上限,请核对实际托盘')
else selected.value.push(row.key)
else { selectedRows.value[row.key] = clone(row); selected.value.push(row.key) }
}
input.checked = selected.value.includes(row.key)
}
@@ -153,6 +172,7 @@ async function save() {
ensureOwner()
if (!pending.value) {
if (!writable.value || error.value || result.value || !chosen.value.length || chosen.value.some(row => reasons(row).length || !row.native) || physicalCount(chosen.value) > capacity.value) throw new DispatchInputError('请核对本仓实物资格、来源批次与计划托位')
candidatePage.assertCurrent(chosen.value.map(row => row.native!))
pending.value = { warehouseId: warehouseId.value, capacity: capacity.value, rows: clone(chosen.value) as ReadyRow[], dispatchKey: crypto.randomUUID(), draft: null, containerId: null, lockedKeys: [], step: null, converted: null, releasing: false }; persist()
}
saving.value = true
@@ -200,7 +220,7 @@ async function releaseRejected() {
}
async function remaining() { if (!unresolved.value && !addingBusy.value) { result.value = null; adding.value = false; arrangingRemaining.value = true; await load() } }
onMounted(load)
onBeforeUnmount(() => { generation++ })
onBeforeUnmount(() => { generation++; nativeGeneration++; candidatePage.reset() })
</script>
<template>
<section class="work-form" aria-label="按预约批次创建整车排车">
@@ -218,6 +238,8 @@ onBeforeUnmount(() => { generation++ })
<el-button v-if="batchKeys.length === 1" :disabled="!writable || unresolved" @click="emit('batch', batchKeys[0]!)">按当前批次箱数分批</el-button><p>部分箱数先按本批逐来源数量分批;不对应完整实物时,先在原库存作业拆托。</p>
<label>本组计划托位<select v-model.number="capacity" :disabled="!writable || unresolved"><option v-for="spec in dispatchVehicleSpecs" :key="spec.value" :value="spec.capacity">{{ spec.label }}</option></select></label>
<table class="density-table"><thead><tr><th>选择</th><th>客户</th><th>托盘码</th><th>入库单号</th><th>来源</th><th>箱数</th><th>不可选择原因</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="`${row.key}:${index}`"><td><input type="checkbox" :checked="selected.includes(row.key)" :disabled="!writable || unresolved || reasons(row).length > 0" @change="select(row, $event)"></td><td>{{ row.native?.customerName || '客户待核实' }}</td><td>{{ row.palletNo || '—' }}</td><td>{{ row.planNo || '—' }}</td><td>{{ row.sourceStage === 'RECEIVED' ? '已收货实物' : '计划托盘' }}</td><td>{{ row.packageCount ?? '待核实' }}</td><td>{{ reasons(row).join(';') || '可选' }}</td></tr></tbody></table><p v-if="!rows.length">当前批次尚无可排实物。</p>
<el-pagination aria-label="批次关联候选分页" :current-page="candidatePageNumber" :page-size="candidatePageSize" :page-sizes="[10, 20, 50]" :total="candidateTotal" :disabled="unresolved || loading" layout="total, sizes, prev, pager, next" @update:current-page="changeCandidatePage" @update:page-size="changeCandidateSize" />
<p>上方总数为关联计划的候选数;当前页展示其中属于本批次的实物。已选 {{ chosen.length }} 条(含其他页)。</p>
<el-button type="primary" :disabled="!writable || !chosen.length" :loading="saving" @click="save">创建整车排车单</el-button>
</template>
<p v-if="pending" role="status">本次草稿与原请求已保留,请勿改变来源、版本或另建排车单。<el-button :disabled="!canCreate || saving" :loading="saving" @click="save">核对并重试原请求</el-button></p>
@@ -1,3 +1,4 @@
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import { ElMessageBox } from 'element-plus'
@@ -26,6 +27,7 @@ function agreement(status = 'ACTIVE'): api.ReceivingAgreement { return { agreeme
function pendingChange(): api.ReceivingPending { return { changeKey: 'CHANGE-1', version: 1, status: 'WAITING', intent: { batchKey: 'FBA_APPOINTMENT:1', batchVersion: 3, customerId: 'customer-a', quantities: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', retainedPackages: 0, expectedSourceVersion: 'source-v3', expectedVerificationVersion: 2 }], laterDate: null, ownerId: 'owner-related', nextFollowupDate: '2026-10-21', reason: '本客户后送', transportVersion: 'transport-v1' }, originalMembers: [members()[0]!], originalAgreements: [agreement()], followup: { ownerId: 'owner-related', ownerName: '已核实负责人', nextFollowupDate: '2026-10-21' } } }
function result(status = 'APPLIED'): api.ReceivingResult { return { changeKey: null, status, batches: [context().batch], agreements: [], steps: [{ step: 'BOOK_RECEIVING', status: 'SUCCESS', targetKey: 'GROUP-1', message: '已保存真实预约' }], nextActions: ['REVIEW_TRANSPORT'] } }
function render() { return mount(WorkReceivingPanel, { props: { batchKey: 'FBA_APPOINTMENT:1', canWrite: true, owners: [{ id: 'owner-related', name: '关联负责人' }] }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
ElButton: { props: ['disabled', 'nativeType'], template: '<button :disabled="disabled" :type="nativeType || \'button\'"><slot /></button>' },
FbaAppointmentFields: { name: 'FbaAppointmentFields', props: ['model', 'disabled'], template: '<div data-booking-fields />' },
LoadErrorState: { props: ['message', 'showRetry'], template: '<section role="alert">{{ message }}<button v-if="showRetry !== false" @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true,
@@ -264,7 +264,7 @@ onMounted(async () => { await load(); if (props.initialCustomerId && customers.v
<ReceivingScope :members="[]" :transport="context.transport" />
<el-button :disabled="locked || !edit || !context.allowedActions.includes('BOOK_RECEIVING')" @click="newBooking">登记当前未覆盖货物的正式预约</el-button>
</template>
<form v-else @submit.prevent="save()">
<form v-else class="density-form" @submit.prevent="save()">
<fieldset :disabled="locked || !formWritable">
<template v-if="mode === 'refresh'">
<h4>{{ customerName(customer) || '—' }} · 核对本客户最新货物资料</h4><p>采用后仅更新本客户的资料快照,本批箱数和其他客户的确认保持不变;保存核实资料不会自动执行采用。</p>
@@ -279,15 +279,15 @@ onMounted(async () => { await load(); if (props.initialCustomerId && customers.v
<p v-for="item in customerItems" :key="item.id">{{ item.inboundCode || '—' }} · {{ item.packageCount ?? '箱数待核实' }}</p>
<p>{{ planTimezone || '目的仓时区待核实' }} · 只保存本客户计划,其他客户的确认保持不变。</p>
<p v-if="!planEligible(customer)" role="status">本客户资格已变化,暂不能保存普通计划,请返回范围概览核对。</p>
<label>目标送仓日期<input v-model="planForm.plannedDate" type="date" aria-label="客户计划日期"></label>
<label>计划开始时段<input v-model="planForm.plannedAt" @change="planForm.startOffset = ''" type="datetime-local" step="1" aria-label="客户计划开始"></label>
<label>计划结束时段<input v-model="planForm.plannedEndAt" @change="planForm.endOffset = ''" type="datetime-local" step="1" aria-label="客户计划结束"></label>
<label>目标送仓日期<el-date-picker v-model="planForm.plannedDate" type="date" aria-label="客户计划日期" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label>
<label>计划开始时段<el-date-picker v-model="planForm.plannedAt" @change="planForm.startOffset = ''" type="datetime" aria-label="客户计划开始" :disabled="locked || !formWritable" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></label>
<label>计划结束时段<el-date-picker v-model="planForm.plannedEndAt" @change="planForm.endOffset = ''" type="datetime" aria-label="客户计划结束" :disabled="locked || !formWritable" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DDTHH:mm:ss" clearable /></label>
<details><summary>计划时段的夏令时信息</summary><p>当地时钟回拨导致时刻重复时,请按仓库确认填写对应时差。跳时期间不存在的当地时刻不可用,请选择有效时间。</p><label>计划开始时差<input v-model="planForm.startOffset" aria-label="客户计划开始时差" placeholder="如 -07:00"></label><label>计划结束时差<input v-model="planForm.endOffset" aria-label="客户计划结束时差" placeholder="如 -08:00"></label></details>
<label>本次联系结果<select v-model="planForm.appointmentStatus" aria-label="客户计划状态"><option value="PENDING">等待对方确认</option><option value="FAILED">未约成</option></select></label>
<label>{{ planForm.appointmentStatus === 'FAILED' ? '未约成原因' : '联系备注' }}<textarea v-model="planForm.appointmentRemark" maxlength="1000" aria-label="客户计划备注" /></label>
</template>
<template v-if="mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数"></label></div><label>后送目标日期<input v-model="adjustment.laterDate" type="date"></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<input v-model="adjustment.followup" type="date"></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></template>
<template v-if="mode === 'customer'"><h4>仅本客户调整,其他客户保持原安排</h4><div v-for="item in customerItems" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · 当前 {{ item.packageCount ?? '待核实' }} 箱</p><label v-if="item.destinationKey">本次先送箱数<input v-model="adjustment.retained[sourceKey({ sourceKey: item.sourceKey, destinationKey: item.destinationKey })]" type="number" min="0" :max="item.packageCount ?? undefined" step="1" aria-label="本次先送箱数"></label></div><label>后送目标日期<el-date-picker v-model="adjustment.laterDate" type="date" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>负责人<select v-model="adjustment.ownerId" required aria-label="调整负责人"><option value="" disabled>请选择负责人</option><option v-for="person in owners" :key="person.id" :value="person.id">{{ person.name }}</option></select></label><label>下一次跟进日期<el-date-picker v-model="adjustment.followup" type="date" :disabled="locked || !formWritable" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="adjustment.reason" maxlength="1000" aria-label="调整原因" /></label><p>保存仅登记待确认变化,原箱数与原确认仍保留,相关发车暂受阻。</p></template>
<template v-if="mode === 'resolve' && change"><h4>仓库回复:本次客户调整</h4><ReceivingScope :members="change.originalMembers" :transport="context.transport" /><p v-for="(quantity, index) in change.intent.quantities" :key="sourceKey(quantity)">货物 {{ index + 1 }} · 本次先送 {{ quantity.retainedPackages }} 箱</p><p>负责人 {{ change.followup.ownerName || '—' }} · 跟进 {{ change.followup.nextFollowupDate || '待安排' }}</p><p>后送日期 {{ change.intent.laterDate || '未定' }} · {{ change.intent.reason }}</p><p v-for="agreement in change.originalAgreements" :key="agreement.agreementKey">原约号 {{ agreement.details.confirmationNo || '—' }} · {{ agreement.details.appointmentAt || '时段未定' }} · {{ agreement.timezone || '时区待核实' }}</p><label>仓库同意保留量和移出量的回复依据<textarea v-model="evidence" maxlength="1000" aria-label="仓库回复依据" /></label></template>
<template v-if="group"><h4>{{ mode === 'book' ? '整组重约当前范围' : '整份预约当前范围' }}</h4><ReceivingScope :members="mode === 'book' ? selectedBookMembers : group.agreement.members" :transport="group.transport" /><div v-for="(excluded, index) in group.excludedMembers" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除范围 {{ index + 1 }} · {{ excluded.member.appointmentNo }}:{{ excluded.reason }}</div></template>
<template v-if="mode === 'book'"><p>未取得确认时,可先按客户保存目标或未约成原因。</p><el-button v-for="member in bookMembers.filter(row => row.batchKey === batchKey && planEligible(row.customerId))" :key="member.customerId" :disabled="locked || !edit" @click="openPlan(member.customerId)">{{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }} · 保存目标 / 未约成</el-button><p v-if="!group && cargoIncomplete" role="status">货物数量或来源资料待核实,当前不能登记正式预约。</p><p v-if="bookingScopeChanged" role="status">当前成员范围已变化,请核对上方范围后重新填写预约,原输入不会自动套用给其他客户。<el-button :disabled="locked" @click="resetBookingScope">按当前范围重新填写</el-button></p><template v-if="!group"><p v-for="(excluded, index) in context.executionScope.members.filter(row => row.completed || !row.canRebook)" :key="`${excluded.member.batchKey}:${excluded.member.customerId}`" role="status">排除客户范围 {{ index + 1 }}:{{ excluded.reason || (excluded.completed ? '已完成' : '当前不可重新预约') }}</p></template><ReceivingScope v-if="!group" :members="selectedBookMembers" :transport="context.transport" /><LoadErrorState v-if="scopeError" title="所选客户执行资格读取失败" :message="scopeError.message" :loading="scopeLoading" @retry="refreshSelectedScope" /><p v-if="scopeLoading">正在核对所选客户执行资格…</p><p v-if="!scopeLoading && !scopeError && selectedBookMembers.length && !selectedExecutionAllowed" role="status">所选客户执行资格不完整或不可办理,请核对后重新选择。</p><p v-for="(eligibility, index) in selectedExecution?.members.filter(row => row.completed || !row.canRebook) || []" :key="index" role="status">所选范围 {{ index + 1 }}:{{ eligibility.reason || '当前不可重新预约' }}</p><div aria-label="本次预约客户"><h4>选择本次取得确认的客户</h4><label v-for="(member, index) in bookMembers" :key="memberKey(member)"><input v-model="selectedMemberKeys" @change="refreshSelectedScope" :value="memberKey(member)" type="checkbox" :aria-label="`选择预约客户 ${index + 1}`">{{ member.appointmentNo }} · {{ member.customerName || member.sources.map(source => source.inboundCode || '—').join('、') }}</label><p v-if="!selectedBookMembers.length">请至少选择一个客户。</p></div><label><input v-model="separate" type="checkbox">所选客户分别登记预约</label><p>共同预约须由仓库明确确认覆盖当前全部所选客户和货量;重复填写同一约号不能代替共同预约。</p><template v-for="(row, index) in (bookingScopeChanged ? [] : bookingRows)" :key="index"><h4>{{ separate ? `${row.member?.appointmentNo} · ${row.member?.customerName || '—'} · 客户范围 ${index + 1}` : '一份共同预约' }}</h4><FbaAppointmentFields :model="bookingForms[row.index]!" :timezone="group?.agreement.timezone || context.batch.timezone" :disabled="locked || !edit" /><label>仓库确认覆盖依据<textarea v-model="bookingEvidence[row.index]" maxlength="1000" aria-label="预约覆盖依据" /></label></template></template>
@@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { projectContainerSchedule } from './schedule'
import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
vi.mock('@/shared/api/http', async original => ({
...await original<typeof import('@/shared/api/http')>(),
http: { get: vi.fn(), post: vi.fn() },
}))
const range = { from: '2026-09-28', to: '2026-11-02' }
const bookingId = '9007199254740993'
// 使用真实接口的结构:Long 数量序列化为字符串,空时间字段不输出。
const wire = {
sourceContainerBookingId: bookingId, containerNo: 'CALENDAR-CONT',
warehouseName: '接仓仓库', warehouseCode: 'US-W', version: 0, planCount: '0', canEdit: true,
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'OK', data: {
containers: [{ containerBookingId: bookingId, cabinetNo: 'D-CALENDAR', containerNo: 'CALENDAR-CONT',
vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null,
arrivalAt: '2026-10-20T23:00:00', arrivalType: 'ESTIMATED' }],
emptyReturns: [], emptyReturnsReadable: true,
} })
})
async function readSchedule(overrides: Record<string, unknown> = {}) {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'OK', data: [{ ...wire, ...overrides }] })
const sources = await getCalendarSources(range)
const warehouses = await getWarehouseArrivals(range, [bookingId])
return { warehouses, event: projectContainerSchedule(sources, warehouses, range, true).find(row => row.type === 'warehouse') }
}
describe('warehouse calendar response to schedule', () => {
it.each(['0', 0])('uses zero plan count %j to show arrival plus two days without inventing a time', async planCount => {
const { warehouses, event } = await readSchedule({ planCount })
expect(warehouses[0]).toMatchObject({ sourceContainerBookingId: bookingId, planCount: 0, plannedAt: null, actualAt: null })
expect(event).toMatchObject({ date: '2026-10-22', time: '', canEdit: true, subtitle: '到港日期+2个自然日(默认)' })
expect(event?.fields).toContainEqual({ label: '计划到仓', value: '2026-10-22' })
expect(event?.fields).toContainEqual({ label: '实际到仓', value: '—' })
})
it('keeps the authoritative frozen plan instead of recalculating from arrival', async () => {
const { warehouses, event } = await readSchedule({ planCount: '2', plannedAt: '2026-10-25T09:30:00', canEdit: false })
expect(warehouses[0]?.planCount).toBe(2)
expect(event).toMatchObject({ date: '2026-10-25', time: '09:30', canEdit: false, subtitle: '已有入库计划,日期已冻结' })
})
it('keeps a manually maintained time when there is no inbound plan', async () => {
const { event } = await readSchedule({ version: 1, plannedAt: '2026-10-24T08:00:00' })
expect(event).toMatchObject({ date: '2026-10-24', time: '08:00', subtitle: '人工维护' })
})
it('does not calculate a default when existing plans have no unified time', async () => {
const { event } = await readSchedule({ planCount: '2', canEdit: false })
expect(event?.date).toBeNull()
})
it('keeps conflicting authoritative versions unresolved even with zero plans', async () => {
const { event } = await readSchedule({ version: -1, canEdit: false })
expect(event).toMatchObject({ date: null, canEdit: false, subtitle: '柜内计划时间不一致,待核实' })
})
it.each([undefined, null, '', 'invalid', '-1', '1.5', '9007199254740992', -1, Infinity])(
'rejects invalid or unsafe plan count %j rather than treating it as no plans', async planCount => {
await expect(readSchedule({ planCount })).rejects.toThrow('入库计划数量无效')
},
)
})
@@ -31,15 +31,35 @@ export interface WarehouseArrival {
canEdit: boolean
}
export interface ScheduleRange { from: string; to: string }
type WarehouseArrivalWire = Omit<WarehouseArrival, 'planCount' | 'plannedAt' | 'actualAt'> & {
planCount: string | number
plannedAt?: string | null
actualAt?: string | null
}
/** 在接口边界统一长整型数量,未知数量不能被当成零触发默认日期。 */
function normalizeWarehouseArrival(row: WarehouseArrivalWire): WarehouseArrival {
const count = row.planCount
if (typeof count !== 'number' && (typeof count !== 'string' || !/^(0|[1-9]\d*)$/.test(count))) {
throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
}
const planCount = Number(count)
if (!Number.isSafeInteger(planCount) || planCount < 0) {
throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
}
return { ...row, planCount, plannedAt: row.plannedAt ?? null, actualAt: row.actualAt ?? null }
}
export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
return unwrapApiResponse(await http.get<ApiEnvelope<CalendarSources>, ApiEnvelope<CalendarSources>>(
'/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
))
}
export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
return unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrival[]>, ApiEnvelope<WarehouseArrival[]>>(
const rows = unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrivalWire[]>, ApiEnvelope<WarehouseArrivalWire[]>>(
'/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
))
return rows.map(normalizeWarehouseArrival)
}
export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
@@ -0,0 +1,8 @@
const input = {
props: ['modelValue', 'disabled', 'type', 'format', 'valueFormat'],
emits: ['update:modelValue', 'change'],
template: `<input data-temporal-picker :data-picker-kind="type || 'time'" :value="modelValue" :disabled="disabled" @input="$emit('update:modelValue', $event.target.value); $emit('change', $event.target.value)">`,
}
export const DatePickerStub = { name: 'ElDatePicker', ...input }
export const TimePickerStub = { name: 'ElTimePicker', ...input }
@@ -0,0 +1,36 @@
import { readdirSync, readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { parse } from 'vue/compiler-sfc'
import { expect, it } from 'vitest'
/** 持续检查后台业务模板,避免再次用原生日期控件替代 Element 公共控件。 */
it('uses Element date and time pickers throughout Admin business templates', () => {
const root = join(process.cwd(), 'src/modules')
const violations: string[] = []
function inspect(content: DocumentFragment, file: string) {
for (const input of content.querySelectorAll('input, el-input')) {
const type = input.getAttribute('type') || ''
if (/^(date|time|month|week|datetime-local)$/.test(type)) {
violations.push(`${relative(root, file)} ${input.tagName.toLowerCase()} type=${type}`)
}
}
// 条件和插槽模板的内容位于独立的 DocumentFragment 中。
for (const template of content.querySelectorAll('template')) inspect(template.content, file)
}
function visit(directory: string) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const file = join(directory, entry.name)
if (entry.isDirectory()) visit(file)
else if (entry.name.endsWith('.vue')) {
const source = parse(readFileSync(file, 'utf8')).descriptor.template?.content
if (source) {
const template = document.createElement('template')
template.innerHTML = source
inspect(template.content, file)
}
}
}
}
visit(root)
expect(violations).toEqual([])
})
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
import { createDeclarationBatch, submitDeclarationBatch, recordClearanceResult, type DeclarationBatch } from './platformContainerCustoms'
import { completeAllBuyContainerCustoms, downloadContainerCustomsReceipt, downloadContainerCustomsDocument, getContainerCustoms, sendContainerCustomsToBroker, startContainerCustoms, uploadContainerCustomsReceipt } from './platformContainerCustoms'
const customs = {
@@ -15,6 +15,22 @@ const customs = {
describe('platform container customs API', () => {
beforeEach(() => vi.restoreAllMocks())
it('preserves the server batch number when creating and submitting a declaration', async () => {
const batch = { id: '123', customsContainerId: '456', loadingPlanId: '789', batchNo: 'DECL202610200001', sourceVersion: 1, version: 0, results: [] }
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: batch })
const created = await createDeclarationBatch('789', 3)
expect(created.batchNo).toBe('DECL202610200001')
expect(post.mock.calls[0]?.slice(0, 2)).toEqual([
'/platform/v1/customs/loading-plans/789/declaration-batches', { expectedVersion: 3, remark: null },
])
const submitted = await submitDeclarationBatch(created, ' CUSTOMS-EXTERNAL-1 ')
expect(submitted.batchNo).toBe(created.batchNo)
expect(post.mock.calls[1]?.slice(0, 2)).toEqual([
'/platform/v1/customs/loading-plans/declaration-batches/123/submit',
{ expectedVersion: 0, customsEntryNo: 'CUSTOMS-EXTERNAL-1', remark: null },
])
})
it('records domestic customs time in Shanghai even when UTC falls on the previous day', async () => {
vi.useFakeTimers()
try {
@@ -178,13 +178,13 @@ export async function getDeclarationBatches(loadingPlanId: string) {
return (unwrapApiResponse(response) || []).map(normalizeDeclarationBatch)
}
export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number, batchNo?: string) {
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, batchNo: batchNo?.trim() || null, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } })
export async function createDeclarationBatch(loadingPlanId: string, expectedVersion: number) {
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/${encodeURIComponent(loadingPlanId)}/declaration-batches`, { expectedVersion, remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-create') } })
return normalizeDeclarationBatch(unwrapApiResponse(response))
}
export async function submitDeclarationBatch(batch: DeclarationBatch, customsEntryNo: string) {
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, batchNo: batch.batchNo, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } })
const response = await http.post<ApiEnvelope<DeclarationBatch>, ApiEnvelope<DeclarationBatch>>(`${basePath}/declaration-batches/${encodeURIComponent(batch.id)}/submit`, { expectedVersion: batch.version, customsEntryNo: customsEntryNo.trim(), remark: null }, { headers: { 'Idempotency-Key': operationId('customs-declaration-batch-submit') } })
return normalizeDeclarationBatch(unwrapApiResponse(response))
}
@@ -27,14 +27,12 @@ describe('ContainerSimulationCreate opted-in form', () => {
await flushPromises()
expect(createContainerSimulation).not.toHaveBeenCalled()
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
expect(fields).toHaveLength(4)
expect(fields).toHaveLength(3)
fields.find(field => field.props('prop') === 'warehouseId')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', '8')
await flushPromises()
expect(listWarehouseLogisticsChannels).toHaveBeenCalledExactlyOnceWith('8')
fields.find(field => field.props('prop') === 'channelGroupCode')!.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'MATSON_CLX')
await flushPromises()
const specification = fields.find(field => field.props('label') === '统一规格')!
expect(specification.find('input').exists()).toBe(!dense)
dialog.vm.$emit('confirm')
await flushPromises()
expect(createContainerSimulation).toHaveBeenCalledExactlyOnceWith({ warehouseId: '8', channelGroupCode: 'MATSON_CLX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, reactive, ref } from 'vue'
import { Plus } from '@lucide/vue'
import { Container, Plus } from '@lucide/vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
import { listWarehouseLogisticsChannels, type OceanLogisticsChannel } from '@/modules/ocean-bookings'
@@ -9,7 +9,7 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification } from '@/shared/domain/containerSpecifications'
import { createContainerSimulation } from '../api/platformContainerLoading'
import type { ContainerSimulationCreateRequest } from '../types'
import { SMART_LOADING_CHANNEL_GROUPS } from '../smartLoadingChannelGroups'
@@ -95,14 +95,54 @@ async function saveSimulation() {
<template>
<el-button type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button>
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="simulationOpen" title="新增模拟柜" width="min(560px, calc(100vw - 28px))" confirm-text="创建模拟柜" cancel-text="取消" :confirm-loading="simulationSaving" @confirm="saveSimulation">
<section :class="{ 'density-dialog-section': dense }" data-tone="work"> <el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" :label-position="dense ? 'left' : 'top'" :label-width="dense ? '120px' : undefined" class="simulation-form" :class="{ 'density-form': dense }"><template v-if="dense"><table :class="{ 'density-table': dense, 'density-table--editable': dense }" aria-label="模拟柜资料"><tbody><tr><td><el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item></td><td><el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item></td></tr><tr><td><el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item></td><td><el-form-item label="统一规格"><span>{{ `${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg` }}</span><small :class="{ 'density-dialog-note': dense }">{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item></td></tr></tbody></table></template><template v-else><el-form-item label="装柜仓库" prop="warehouseId"><el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" /></el-select></el-form-item><el-form-item label="物流渠道" prop="channelGroupCode"><el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道"><el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" /></el-select></el-form-item><el-form-item label="柜型" prop="containerType"><el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))"><el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" /></el-select></el-form-item><el-form-item label="统一规格"><el-input :model-value="`${containerSpecification(simulationForm.containerType)?.recommendedMinCbm || '—'}–${containerSpecification(simulationForm.containerType)?.capacityCbm || '—'} m³ / 货重 ≤ ${containerSpecification(simulationForm.containerType)?.maxWeightKg || '—'} kg`" disabled /><small>{{ containerSpecification(simulationForm.containerType)?.description }}</small></el-form-item></template></el-form></section>
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
<AppDialog
v-model="simulationOpen"
class="simulation-create-dialog"
:class="{ 'admin-density-dialog': dense, 'admin-density-dialog--comfortable': dense }"
title="新增模拟柜"
width="min(800px, calc(100vw - 32px))"
confirm-text="创建模拟柜"
cancel-text="取消"
:confirm-loading="simulationSaving"
:confirm-disabled="warehouseLoading || simulationChannelsLoading"
@confirm="saveSimulation"
>
<template #header>
<div class="simulation-heading"><span class="simulation-heading__icon"><Container :size="20" aria-hidden="true" /></span><span>新增模拟柜</span></div>
</template>
<el-form ref="simulationFormRef" :model="simulationForm" :rules="simulationRules" label-position="top" class="simulation-form">
<div class="simulation-fields">
<el-form-item label="装柜仓库" prop="warehouseId">
<el-select :model-value="simulationForm.warehouseId" filterable placeholder="请选择国内装柜仓库" :loading="warehouseLoading" @update:model-value="selectSimulationWarehouse">
<el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :label="warehouse.warehouseName" :value="String(warehouse.id)" />
</el-select>
</el-form-item>
<el-form-item label="物流渠道" prop="channelGroupCode">
<el-select v-model="simulationForm.channelGroupCode" filterable :disabled="!simulationForm.warehouseId || simulationChannelsLoading" :loading="simulationChannelsLoading" :placeholder="simulationForm.warehouseId ? '请选择物流渠道' : '请先选择装柜仓库'" no-data-text="当前仓库暂无有效渠道">
<el-option v-for="group in simulationChannelGroups" :key="group.code" :label="group.name" :value="group.code" />
</el-select>
</el-form-item>
<el-form-item label="柜型" prop="containerType" class="simulation-type">
<el-select :model-value="simulationForm.containerType" @update:model-value="selectSimulationContainerType(String($event || ''))">
<el-option v-for="item in CONTAINER_SPECIFICATIONS" :key="item.type" :label="item.type" :value="item.type" />
</el-select>
</el-form-item>
</div>
</el-form>
<LoadErrorState v-if="simulationError" compact title="模拟柜创建失败" :message="simulationError.message" :show-retry="false"><template #details><ApiErrorMeta :details="simulationError" /></template></LoadErrorState>
</AppDialog>
</template>
<style scoped>
.simulation-form :deep(.el-select), .simulation-form :deep(.el-input-number) { width: 100%; }
.simulation-heading { display: flex; align-items: center; gap: 10px; font-size: var(--admin-font-title, 16px); }
.simulation-heading__icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 9px; color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.simulation-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; row-gap: 8px; }
.simulation-fields > * { min-width: 0; }
.simulation-form :deep(.el-select) { width: 100%; }
.simulation-type { grid-column: 1 / -1; }
.simulation-type :deep(.el-select) { max-width: calc(50% - 12px); }
@media (max-width: 600px) {
.simulation-fields { grid-template-columns: minmax(0, 1fr); gap: 4px; }
.simulation-type :deep(.el-select) { max-width: none; }
}
</style>
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getOutsourcedBusiness, listOutsourcedBusiness, listOutsourcedBusinessHistory, saveOutsourcedBusinessOperations, uploadOutsourcedBusinessPhoto, bindOutsourcedBusinessPhoto, getOutsourcedBusinessPhotoContent } from './outsourcedBusiness'
import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures'
import type { OutsourcedBusinessPhoto, OutsourcedBusinessPhotoSlot, OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
vi.mock('@/shared/api/http', async importOriginal => {
const actual = await importOriginal<typeof import('@/shared/api/http')>()
return { ...actual, http: { get: vi.fn(), put: vi.fn(), post: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }
})
beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/outsourced-business'
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('outsourced business HTTP contract', () => {
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
const detail = outsourcedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
const query = { page: 1, size: 20, plannedDepartureFrom: '2026-10-20T00:00:00Z', plannedDepartureTo: '2026-10-21T00:00:00Z', keyword: 'FBA-1' }
const value = await listOutsourcedBusiness(query)
expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query })
expect(value.summary.dispatchCount).toBe(41); expect(value.records).toHaveLength(1); expect(value.records[0]!.actualPalletCount).toBe(1)
})
it('encodes the dispatch business number and preserves auxiliary/main versions independently', async () => {
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = '0'; detail.row.dispatchRecordVersion = '9007199254740993'
detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
vi.mocked(http.get).mockResolvedValue({ data: detail })
expect((await getOutsourcedBusiness('DS/1')).row.dispatchRecordVersion).toBe('9007199254740993')
expect(http.get).toHaveBeenCalledWith(`${base}/DS%2F1`, { params: undefined })
detail.dispatch.dispatchNo = 'another'; await expect(getOutsourcedBusiness('DS/1')).rejects.toThrow('不一致')
})
it('does not convert money text, calculate a client unit price or submit a dispatch version', async () => {
const body: OutsourcedBusinessSave = { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null }
const detail = outsourcedBusinessFixture(); detail.row.operations.money = body.operations.money
vi.mocked(http.put).mockResolvedValue({ data: detail })
const saved = await saveOutsourcedBusinessOperations('DS-OUT-1', body)
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS-OUT-1/operations`, body)
expect(body.operations.money!.amount).toBe('9999999999999999.99'); expect('dispatchRecordVersion' in body).toBe(false)
expect(saved.row.operations.money!.amount).toBe('9999999999999999.99')
})
it('keeps upload and versioned file binding separate with the same prepared file identity', async () => {
vi.mocked(http.post).mockResolvedValue({ data: photo })
const file = new File(['image'], 'loading.png', { type: 'image/png' })
await uploadOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', file, 'upload-key')
const [url, form] = vi.mocked(http.post).mock.calls[0]!
expect(url).toBe(`${base}/DS%2F1/photos/LOADING_SCENE/uploads`); expect((form as FormData).get('file')).toBe(file); expect((form as FormData).get('idempotencyKey')).toBe('upload-key')
const detail = outsourcedBusinessFixture(); detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
vi.mocked(http.put).mockResolvedValue({ data: detail })
await bindOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS%2F1/photos/LOADING_SCENE`, { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
})
it('retains the two history streams and reads historical files only at their explicit revision', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ eventSource: 'DISPATCH', recordVersion: null, dispatchRecordVersion: '9', operations: null, photos: [] }, { eventSource: 'OPERATIONS', recordVersion: '1', dispatchRecordVersion: null, operations: outsourcedBusinessFixture().row.operations, photos: [] }], page: '1', size: '20', total: '2' } })
const value = await listOutsourcedBusinessHistory('DS/1', 1, 20)
expect(value.records.map(record => [record.eventSource, record.recordVersion, record.dispatchRecordVersion])).toEqual([['DISPATCH',null,'9'],['OPERATIONS','1',null]])
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
await getOutsourcedBusinessPhotoContent('DS/1', photo, '1')
expect(http.get).toHaveBeenLastCalledWith(`${base}/DS%2F1/photos/LOADING_SCENE/files/${photo.fileId}/content`, { responseType: 'blob', params: { revisionVersion: '1' } })
})
it('never renders JSON errors or empty bytes as a photo', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({ code: 'A0403', message: '拒绝读取', requestId: 'request', traceId: 'trace' })], { type: 'application/json' }))
await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toMatchObject({ code: 'A0403', requestId: 'request', traceId: 'trace' })
vi.mocked(http.get).mockResolvedValue(new Blob([], { type: 'image/png' })); await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toThrow('为空')
await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1','LOADING_SCENE',new File(['text'],'bad.txt',{type:'text/plain'}),'key')).rejects.toThrow('JPG')
})
it.each(['2', null])('accepts authoritative external vehicle identity %s without looking up the current master', async vehicleId => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle.vehicleId = vehicleId; detail.row.vehicle.carrierName = null
vi.mocked(http.get).mockResolvedValue({ data: detail })
const value = await getOutsourcedBusiness('DS-OUT-1')
expect(value.row.vehicle).toMatchObject({ vehicleId, ownership: 'DISPATCHED', carrierName: null })
expect(http.get).toHaveBeenCalledTimes(1)
})
it('rejects owned vehicle facts and unsupported fuel or mileage writes before any request', async () => {
const detail = outsourcedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership: 'OWNED' } } } })
await expect(getOutsourcedBusiness('DS-OUT-1')).rejects.toThrow('来源或阶段')
for (const field of ['actualDistanceMiles','fuelConsumptionLitresPer100Km','actualFuelLitres']) {
const body = { recordVersion:'0', idempotencyKey:'original-key', operations:{ ...detail.row.operations, [field]:'1' }, reason:null }
await expect(saveOutsourcedBusinessOperations('DS-OUT-1', body)).rejects.toThrow('不支持里程或油耗')
}
expect(http.put).not.toHaveBeenCalled()
for (const slot of ['FUEL_START','FUEL_END']) {
await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,new File(['image'],'loading.png',{type:'image/png'}),'upload-key')).rejects.toThrow('不支持该照片分类')
await expect(bindOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})).rejects.toThrow('不支持该照片分类')
}
expect(http.post).not.toHaveBeenCalled(); expect(http.put).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,74 @@
import axios from 'axios'
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessPhotoRoles, type OutsourcedBusinessOperations, type OutsourcedBusinessRow, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessSave, type OutsourcedBusinessBind, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessCommand, type OutsourcedBusinessHistory, type OutsourcedBusinessPage } from '../outsourcedBusinessTypes'
const base = '/platform/v1/last-leg/fleet/outsourced-business'
const path = (no: string) => `${base}/${encodeURIComponent(no)}`
async function get<T>(url: string, params?: object): Promise<T> { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
function count(value: unknown): number { const text = outsourcedBusinessVersion(value); const result = Number(text); if (!Number.isSafeInteger(result)) throw new Error('运营记录数量超出精确范围'); return result }
function decimal(value: string | null, scale: number, precision: number): string | null {
if (value == null) return null
if (typeof value !== 'string' || outsourcedBusinessDecimal(value, scale, precision) !== value) throw new Error('运营金额或测量必须使用精确十进制字符串')
return value
}
function operations(value: OutsourcedBusinessOperations): OutsourcedBusinessOperations {
if ('actualDistanceMiles' in value || 'fuelConsumptionLitresPer100Km' in value || 'actualFuelLitres' in value) throw new Error('外配运营信息不支持里程或油耗')
if (value.money && !['CNY','USD'].includes(value.money.currency)) throw new Error('运营金额币种无效')
const amount = value.money ? decimal(value.money.amount, 2, 18) : null
if (value.money && amount == null) throw new Error('承运金额与币种必须同时登记')
return { money: value.money ? { currency: value.money.currency, amount: amount! } : null, arrivals: value.arrivals }
}
function requireSlot(slot: OutsourcedBusinessPhotoSlot) { if (!outsourcedBusinessPhotoRoles.some(role => role.slot === slot)) throw new Error('外配业务不支持该照片分类') }
function row(value: OutsourcedBusinessRow): OutsourcedBusinessRow {
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'DISPATCHED') throw new Error('外配排车来源或阶段不完整,请重新读取')
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: outsourcedBusinessVersion(value.recordVersion), dispatchRecordVersion: outsourcedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount) } }
}
function detail(value: OutsourcedBusinessDetail): OutsourcedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
function command(value: OutsourcedBusinessCommand): OutsourcedBusinessCommand { return { ...value, recordVersion: value.recordVersion == null ? null : outsourcedBusinessVersion(value.recordVersion) } }
export async function listOutsourcedBusiness(query: OutsourcedBusinessQuery): Promise<OutsourcedBusinessList> {
const value = await get<OutsourcedBusinessList>(base, query)
return { ...value, records: value.records.map(row), page: count(value.page), size: count(value.size), total: count(value.total), summary: { dispatchCount: count(value.summary.dispatchCount), afterLoadingCount: count(value.summary.afterLoadingCount), deliveringCount: count(value.summary.deliveringCount), deliveredCount: count(value.summary.deliveredCount) } }
}
export async function getOutsourcedBusiness(no: string) { const value = detail(await get<OutsourcedBusinessDetail>(path(no))); if (value.row.dispatchNo !== no) throw new Error('运营资料与当前排车业务号不一致'); return value }
export async function listOutsourcedBusinessHistory(no: string, page: number, size: number): Promise<OutsourcedBusinessPage<OutsourcedBusinessHistory>> {
const value = await get<OutsourcedBusinessPage<OutsourcedBusinessHistory>>(`${path(no)}/history`, { page, size })
return { ...value, records: value.records.map(record => ({ ...record, operations: record.operations ? operations(record.operations) : null, recordVersion: record.recordVersion == null ? null : outsourcedBusinessVersion(record.recordVersion), dispatchRecordVersion: record.dispatchRecordVersion == null ? null : outsourcedBusinessVersion(record.dispatchRecordVersion) })), page: count(value.page), size: count(value.size), total: count(value.total) }
}
export const getOutsourcedBusinessCommand = (no: string, key: string) => get<OutsourcedBusinessCommand>(`${path(no)}/commands/${encodeURIComponent(key)}`).then(command)
export async function saveOutsourcedBusinessOperations(no: string, body: OutsourcedBusinessSave) {
outsourcedBusinessVersion(body.recordVersion)
operations(body.operations)
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OutsourcedBusinessDetail>, ApiEnvelope<OutsourcedBusinessDetail>>(`${path(no)}/operations`, body)))
if (value.row.dispatchNo !== no) throw new Error('运营保存结果与原排车单不一致')
return value
}
export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) {
requireSlot(slot)
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
const value = unwrapApiResponse(await http.post<ApiEnvelope<OutsourcedBusinessPhoto>, ApiEnvelope<OutsourcedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
return value
}
export async function bindOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, body: OutsourcedBusinessBind) {
requireSlot(slot)
outsourcedBusinessVersion(body.recordVersion)
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OutsourcedBusinessDetail>, ApiEnvelope<OutsourcedBusinessDetail>>(`${path(no)}/photos/${slot}`, body)))
if (value.row.dispatchNo !== no) throw new Error('照片绑定结果与原排车单不一致')
return value
}
async function downloadError(payload: unknown, status: number) {
let value = payload
if (payload instanceof Blob && payload.type.includes('json')) { try { value = JSON.parse(await payload.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') } }
if (value && typeof value === 'object' && 'code' in value) { const error = value as ApiEnvelope<unknown>; throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId) }
}
export async function getOutsourcedBusinessPhotoContent(no: string, photo: OutsourcedBusinessPhoto, revisionVersion?: string): Promise<Blob> {
requireSlot(photo.slot)
if (revisionVersion != null && BigInt(outsourcedBusinessVersion(revisionVersion)) < 1n) throw new Error('历史图片版本必须大于零')
try {
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } })
await downloadError(blob, 200)
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
return blob
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
}
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/outsourcedBusiness'
import { outsourcedBusinessFixture, outsourcedBusinessCommandFixture } from './OutsourcedBusinessFixtures'
import { acceptOutsourcedBusinessCommand, newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, outsourcedBusinessPendingKey } from './OutsourcedBusinessCommandRecovery'
import type { OutsourcedBusinessPhoto, OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
beforeEach(() => sessionStorage.clear())
const no = 'DS-OUT-1'
function operation() : OutsourcedBusinessSave { return { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null } }
function client() {
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null }
return { ...api, getOutsourcedBusiness: vi.fn(async () => current), getOutsourcedBusinessCommand: vi.fn(async () => outsourcedBusinessCommandFixture()), saveOutsourcedBusinessOperations: vi.fn(async () => current), bindOutsourcedBusinessPhoto: vi.fn(async () => current), uploadOutsourcedBusinessPhoto: vi.fn(async () => photo) }
}
describe('outsourced business exact command recovery', () => {
it('persists the complete original payload, actor and zero auxiliary version', () => {
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation())
persistOutsourcedBusinessPending('actor', no, 'operations', pending)
expect(restoreOutsourcedBusinessPending('actor', no, 'operations')).toEqual(pending)
expect(restoreOutsourcedBusinessPending('another', no, 'operations')).toBeNull()
const raw = JSON.parse(sessionStorage.getItem(outsourcedBusinessPendingKey('actor',no,'operations'))!)
raw.actor = 'another'; sessionStorage.setItem(outsourcedBusinessPendingKey('actor',no,'operations'),JSON.stringify(raw))
expect(() => restoreOutsourcedBusinessPending('actor',no,'operations')).toThrow('损坏')
})
it('looks up an unknown write first and retries only the unchanged original body on explicit not-found', async () => {
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()); pending.attempted = true
const service = client(), persist = vi.fn()
service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
await recoverOutsourcedBusinessCommand(pending,persist,() => {},service)
expect(service.getOutsourcedBusinessCommand).toHaveBeenCalledWith(no,'original-key')
expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledExactlyOnceWith(no,operation())
expect(pending.body).toEqual(operation()); expect(pending.body?.recordVersion).toBe('0')
})
it('retains a proven success through failed fresh reads without writing it a second time', async () => {
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()), service = client()
service.getOutsourcedBusiness.mockRejectedValueOnce(new Error('read offline'))
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('read offline')
expect(pending.confirmed?.recordVersion).toBe('1')
await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)
expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledTimes(1); expect(service.getOutsourcedBusinessCommand).not.toHaveBeenCalled()
})
it('never retransmits a file for an unknown upload, including command not-found', async () => {
const pending = newOutsourcedBusinessPending('actor',no,'UPLOAD_PHOTO',null,'LOADING_SCENE'); pending.attempted = true
const service = client(); service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('禁止重复上传')
expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(pending.key).toBeTruthy()
})
it('keeps the prepared file and exact binding key through unknown binding and later replacement', async () => {
const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:'2026-10-20T10:01:00Z' }
const pending = newOutsourcedBusinessPending('actor',no,'BIND_PHOTO',{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId},photo.slot)
pending.attempted = true
const service = client(); const bound = outsourcedBusinessFixture(); bound.row.recordVersion='1'; bound.photos=[photo]
const original = outsourcedBusinessCommandFixture({action:'BIND_PHOTO',idempotencyKey:'bind-key',result:bound})
service.getOutsourcedBusinessCommand.mockResolvedValue(original)
const current = outsourcedBusinessFixture();current.row.recordVersion='2';current.photos=[{...photo,fileId:'9007199254740994'}]
service.getOutsourcedBusiness.mockResolvedValue(current)
const result = await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)
expect(result.detail?.photos[0]?.fileId).toBe('9007199254740994')
expect(pending.body).toEqual({recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})
expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled();expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled()
})
it('refuses wrong command identities, stale reads, definite rejection retries and scope changes', async () => {
const pending = newOutsourcedBusinessPending('actor',no,'SAVE_OPERATIONS',operation()), service=client()
expect(() => acceptOutsourcedBusinessCommand(pending,outsourcedBusinessCommandFixture({dispatchNo:'another'}))).toThrow('不一致')
pending.command=outsourcedBusinessCommandFixture({recordVersion:'9007199254740993'})
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('早于')
pending.rejected=true;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('明确拒绝')
pending.rejected=false;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {throw new Error('scope changed')},service)).rejects.toThrow('scope changed')
expect(service.saveOutsourcedBusinessOperations).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,89 @@
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/outsourcedBusiness'
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessAction, type OutsourcedBusinessBind, type OutsourcedBusinessCommand, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
export interface OutsourcedBusinessPending {
actor: string; dispatchNo: string; action: OutsourcedBusinessAction; key: string; slot?: OutsourcedBusinessPhotoSlot
body: OutsourcedBusinessSave | OutsourcedBusinessBind | null; attempted: boolean; command: OutsourcedBusinessCommand | null; confirmed: { recordVersion: string | null; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto } | null; rejected: boolean
}
export const outsourcedBusinessPendingKey = (actor: string, no: string, area: string) => `outsourced-business:${encodeURIComponent(actor)}:${encodeURIComponent(no)}:${area}`
export function restoreOutsourcedBusinessPending(actor: string, no: string, area: string): OutsourcedBusinessPending | null {
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, no, area))
if (!raw) return null
const pending = JSON.parse(raw) as OutsourcedBusinessPending
if (pending.actor !== actor || pending.dispatchNo !== no || !pending.key || !['SAVE_OPERATIONS','UPLOAD_PHOTO','BIND_PHOTO'].includes(pending.action)) throw new Error('原运营请求记录损坏,请保留现场核对')
if (pending.action !== 'SAVE_OPERATIONS' && !outsourcedBusinessPhotoRoles.some(role => role.slot === pending.slot) || pending.action !== 'UPLOAD_PHOTO' && (!pending.body || pending.body.idempotencyKey !== pending.key)) throw new Error('原运营请求内容不完整,请保留现场核对')
return pending
}
export function persistOutsourcedBusinessPending(actor: string, no: string, area: string, pending: OutsourcedBusinessPending | null) {
if (!actor || !no || pending && (pending.actor !== actor || pending.dispatchNo !== no)) throw new Error('运营请求身份已变化')
const key = outsourcedBusinessPendingKey(actor, no, area)
if (pending) sessionStorage.setItem(key, JSON.stringify(pending)); else sessionStorage.removeItem(key)
}
export function newOutsourcedBusinessPending(actor: string, no: string, action: OutsourcedBusinessAction, body: OutsourcedBusinessPending['body'], slot?: OutsourcedBusinessPhotoSlot): OutsourcedBusinessPending {
if (!actor || !no) throw new Error('当前运营作业身份缺失')
const key = body?.idempotencyKey || crypto.randomUUID()
return { actor, dispatchNo: no, action, key, slot, body: body ? structuredClone(body) : null, attempted: false, command: null, confirmed: null, rejected: false }
}
export function acceptOutsourcedBusinessResult(pending: OutsourcedBusinessPending, result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto) {
if (pending.action === 'UPLOAD_PHOTO') {
if (!('fileId' in result) || result.slot !== pending.slot || result.category !== 'LOADING' || typeof result.fileId !== 'string' || !/^[1-9]\d*$/.test(result.fileId) || !['image/jpeg','image/png'].includes(result.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
pending.confirmed = { recordVersion: null, result }
} else {
if (!('row' in result) || result.row.dispatchNo !== pending.dispatchNo) throw new Error('运营保存结果与原排车单不一致')
pending.confirmed = { recordVersion: outsourcedBusinessVersion(result.row.recordVersion), result }
}
}
export function acceptOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, command: OutsourcedBusinessCommand) {
if (command.dispatchNo !== pending.dispatchNo || command.action !== pending.action || command.idempotencyKey !== pending.key) throw new Error('返回结果与原运营请求不一致,请保留原请求核对')
pending.command = command
if (command.state === 'REJECTED') pending.rejected = true
if (command.state !== 'SUCCEEDED') throw new Error(command.state === 'REJECTED' ? '原请求已明确拒绝,请核对原因后重新登记' : '原请求结果尚未确定,请保留原请求继续核对')
if (!command.result) throw new Error('原运营命令的成功结果不完整,请继续核对')
acceptOutsourcedBusinessResult(pending, command.result)
if (pending.action !== 'UPLOAD_PHOTO' && (command.recordVersion == null || command.recordVersion !== pending.confirmed!.recordVersion)) throw new Error('原运营命令版本与结果不一致')
}
export async function recoverOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, persist: () => void, ensureScope: () => void, client = api): Promise<{ detail?: OutsourcedBusinessDetail; photo?: OutsourcedBusinessPhoto }> {
ensureScope()
if (pending.rejected) throw new Error('原请求已明确拒绝,请先按明确拒绝结果重新核对')
if (pending.command?.state === 'SUCCEEDED') acceptOutsourcedBusinessCommand(pending, pending.command)
const previous = pending.attempted
if (!pending.confirmed) {
let original: OutsourcedBusinessCommand | null = null
if (previous) {
try { original = await client.getOutsourcedBusinessCommand(pending.dispatchNo, pending.key) }
catch (cause) { if (!(cause instanceof ApiResponseError) || !['404','A0404','HTTP_404'].includes(cause.code)) throw cause }
ensureScope()
}
if (original) { try { acceptOutsourcedBusinessCommand(pending, original) } finally { persist() } }
else {
if (pending.action === 'UPLOAD_PHOTO') throw new Error('图片上传结果未知,禁止重复上传;请继续核对原上传请求')
pending.attempted = true; persist()
try {
const result = pending.action === 'SAVE_OPERATIONS'
? await client.saveOutsourcedBusinessOperations(pending.dispatchNo, pending.body as OutsourcedBusinessSave)
: await client.bindOutsourcedBusinessPhoto(pending.dispatchNo, pending.slot!, pending.body as OutsourcedBusinessBind)
ensureScope(); acceptOutsourcedBusinessResult(pending, result); persist()
} catch (cause) {
ensureScope()
if (!previous && cause instanceof ApiResponseError && ['A0400','A0403','A0404','A0409','400','403','404','409','422'].includes(cause.code)) { pending.rejected = true; persist() }
throw cause
}
}
}
const confirmed = pending.confirmed!
if (pending.action === 'UPLOAD_PHOTO') {
const photo = confirmed.result as OutsourcedBusinessPhoto | null
if (!photo || photo.slot !== pending.slot || photo.category !== 'LOADING' || typeof photo.fileId !== 'string' || !/^[1-9]\d*$/.test(photo.fileId) || !['image/jpeg','image/png'].includes(photo.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
return { photo }
}
const current = await client.getOutsourcedBusiness(pending.dispatchNo); ensureScope()
if (current.row.dispatchNo !== pending.dispatchNo || confirmed.recordVersion == null || BigInt(outsourcedBusinessVersion(current.row.recordVersion)) < BigInt(outsourcedBusinessVersion(confirmed.recordVersion))) throw new Error('当前运营版本早于原操作结果,请继续核对')
if (pending.action === 'BIND_PHOTO') {
const matches = (photo: OutsourcedBusinessPhoto) => photo.slot === pending.slot && photo.fileId === (pending.body as OutsourcedBusinessBind).fileId && Boolean(photo.savedAt)
const original = confirmed.result
const originalPhotos = original && 'row' in original ? original.photos : original && 'fileId' in original ? [original] : []
if (!current.photos.some(matches) && !originalPhotos.some(matches)) throw new Error('图片绑定结果尚未确认,请保留原文件和请求')
}
return { detail: current }
}
@@ -0,0 +1,36 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
defineProps<{ dispatchNo?: string; statusText?: string; loading?: boolean }>()
const activeTab = defineModel<'task' | 'route' | 'history'>('activeTab', { default: 'task' })
const emit = defineEmits<{ back: [] }>()
</script>
<template>
<AdminPage class="admin-density" spacing="compact">
<DetailPageLayout aria-label="外配业务详情">
<template #header>
<div class="outsourced-business-detail-heading">
<div><strong>外配业务</strong><span class="outsourced-business-reference">{{ dispatchNo || '—' }}</span><span v-if="statusText">{{ statusText }}</span></div>
<div class="density-actions"><slot name="actions" /><el-button class="density-action" @click="emit('back')">返回外配业务</el-button></div>
</div>
</template>
<slot name="state" />
<div v-loading="loading" :aria-busy="loading">
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
<el-tab-pane name="task" label="任务与车辆信息"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span>任务与车辆信息</template><slot v-if="activeTab === 'task'" name="task" /></el-tab-pane>
<el-tab-pane name="route" label="路线货物与凭证"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span>路线货物与凭证</template><slot v-if="activeTab === 'route'" name="route" /></el-tab-pane>
<el-tab-pane name="history" label="操作记录"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span>操作记录</template><slot v-if="activeTab === 'history'" name="history" /></el-tab-pane>
</el-tabs>
</div>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.outsourced-business-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
.outsourced-business-detail-heading > div { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
.outsourced-business-reference { overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,20 @@
import type { OutsourcedBusinessCommand, OutsourcedBusinessDetail } from '../outsourcedBusinessTypes'
export function outsourcedBusinessFixture(): OutsourcedBusinessDetail {
const review = { status: 'APPROVED' as const, reason: null, reviewerId: null, reviewedAt: '2026-10-20T10:00:00Z', reviewRound: '1', basisHash: null, invalidatedReason: null }
return {
row: {
dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', plannedDepartureAt: '2026-10-20T10:00:00Z', loadingCompletedAt: '2026-10-20T09:00:00Z', departedAt: null, deliveryCompletedAt: null,
origin: { warehouseId: '1', warehouseCode: 'WH-1', warehouseName: '起运仓' }, vehicle: { vehicleId: '2', plate: 'OUT-1', ownership: 'DISPATCHED', carrierName: '测试外配承运商' }, driver: { name: null, phone: null },
destinations: [{ destinationKey: 'destination-1', warehouseCode: 'FBA-1', warehouseName: '目的仓', address: null, appointments: [{ appointmentNo: 'AP-1', confirmationNo: null, scheduledAt: null, allocatedPackages: '2' }], actualArrivedAt: null }],
actualPalletCount: 1, operations: { money: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
photoSummary: { loadingCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
},
dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
}
}
export function outsourcedBusinessCommandFixture(overrides: Partial<OutsourcedBusinessCommand> = {}): OutsourcedBusinessCommand {
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = overrides.recordVersion ?? '1'
return { dispatchNo: 'DS-OUT-1', action: 'SAVE_OPERATIONS', idempotencyKey: 'original-key', state: 'SUCCEEDED', recordVersion: '1', replayed: false, result: detail, errorCode: null, retryAllowed: false, ...overrides }
}
@@ -0,0 +1,48 @@
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import OutsourcedBusinessDetailLayout from './OutsourcedBusinessDetailLayout.vue'
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
const Pane = defineComponent({ props: ['name', 'label'], template: '<article><slot /></article>' })
const Dialog = defineComponent({ props: ['width', 'showConfirm', 'beforeClose'], template: '<div><slot /></div>' })
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, AppDialog: Dialog, ElTabs: Tabs, ElTabPane: Pane, ElButton: Surface }, directives: { loading: () => {} } }
describe('OutsourcedBusiness presentation boundaries', () => {
it('mounts only the selected one of the three independent detail regions', async () => {
const wrapper = mount(OutsourcedBusinessDetailLayout, { global, props: { dispatchNo: 'DS-OUT-1' }, slots: { task: '<p>task-facts</p>', route: '<p>route-facts</p>', history: '<p>history-facts</p>' } })
expect(wrapper.findAllComponents(Pane).map(pane => pane.props('label'))).toEqual(['任务与车辆信息', '路线货物与凭证', '操作记录'])
expect(wrapper.text()).toContain('task-facts'); expect(wrapper.text()).not.toContain('history-facts')
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'history'); await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('history-facts'); expect(wrapper.text()).not.toContain('task-facts')
})
it('preserves only five approved photo roles without demo images or uploaded claims', () => {
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证'])
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
})
it('blocks a close while busy and delegates unsaved-result decisions to the owner', async () => {
const guard = vi.fn(), done = vi.fn()
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true, busy: true, beforeClose: guard } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(guard).not.toHaveBeenCalled(); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ busy: false }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
const done = vi.fn(), guard = vi.fn()
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ beforeClose: guard }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,16 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
</script>
<template>
<AdminPage class="admin-density" spacing="compact">
<slot name="statistics" />
<ListPageLayout aria-label="外配业务任务列表">
<template v-if="$slots.filters" #toolbar><slot name="filters" /></template>
<template v-if="$slots.state" #state><slot name="state" /></template>
<slot />
</ListPageLayout>
</AdminPage>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
const props = defineProps<{
dispatchNo?: string
busy?: boolean
saveDisabled?: boolean
pending?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{ save: [] }>()
function close(done: () => void) {
if (props.busy) return
if (props.beforeClose) return props.beforeClose(done)
if (!props.pending) done()
}
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" :confirm-text="pending ? '核对并重试原请求' : '保存登记'" :before-close="close" :close-on-press-escape="!busy" @confirm="emit('save')">
<p class="outsourced-business-operation-reference">排车单号 {{ dispatchNo || '—' }}</p>
<slot name="state" />
<fieldset class="outsourced-business-operation-section"><legend>整车承运金额</legend><slot name="amount" /><p>整车只登记一次;单价采用系统返回的承运金额与实际物理托数计算结果。</p></fieldset>
<fieldset class="outsourced-business-operation-section"><legend>逐目的地实际到达时间</legend><slot name="arrivals" /></fieldset>
<p>登记运营信息不推进排车或派送状态;照片在独立上传照片窗口中处理。</p>
</AppDialog>
</template>
<style scoped>
.outsourced-business-operation-reference { overflow-wrap: anywhere; }
.outsourced-business-operation-section { min-width: 0; margin: 0 0 var(--admin-space-4); padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
.outsourced-business-operation-section legend { padding: 0 var(--admin-space-2); font-weight: 600; }
</style>
@@ -0,0 +1,59 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import OutsourcedBusinessOperationsEditor from './OutsourcedBusinessOperationsEditor.vue'
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
const calls = vi.hoisted(() => ({ save: vi.fn(), detail: vi.fn(), command: vi.fn(), canEdit: true }))
vi.mock('../api/outsourcedBusiness', () => ({ saveOutsourcedBusinessOperations: calls.save, getOutsourcedBusiness: calls.detail, getOutsourcedBusinessCommand: calls.command }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'actor' } }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => calls.canEdit }) }))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const Frame = defineComponent({ props: ['modelValue','busy','saveDisabled','pending'], emits: ['save','update:modelValue'], setup(props,{ slots,emit }) { return () => h('div', [...Object.values(slots).map(slot => slot?.()), h('button',{ 'data-save':'',disabled: props.busy || props.saveDisabled,onClick: () => emit('save') },'save')]) } })
const Control = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' })
const Field = defineComponent({ props: ['label'], template: '<label>{{label}}<slot /></label>' })
const global = { stubs: { OutsourcedBusinessOperationsDialog: Frame, ElForm: { template:'<div><slot /></div>' }, ElFormItem: Field, ElInput: Control, ElSelect: Control, ElOption: true, ElDatePicker: Control, LoadErrorState: { props:['message'],template:'<div>{{message}}</div>' }, ApiErrorMeta: true } }
beforeEach(() => {
vi.clearAllMocks(); sessionStorage.clear(); calls.canEdit = true
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
calls.save.mockResolvedValue(current); calls.detail.mockResolvedValue(current)
calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
})
describe('outsourced operations registration', () => {
it('sends explicit zero and destination arrival with auxiliary zero, without fuel or mileage fields', async () => {
const wrapper = mount(OutsourcedBusinessOperationsEditor,{ global,props:{modelValue:true,detail:outsourcedBusinessFixture()} }); await flushPromises()
const currency = wrapper.findAll('label').find(field => field.text() === '币种')!.find('input')
const amount = wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input')
await currency.setValue('USD'); await amount.setValue('0.00')
await wrapper.findAll('input').at(-1)!.setValue('2026-10-20T12:00:00+08:00')
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
const body = calls.save.mock.calls[0]![1]
expect(body).toEqual({ recordVersion:'0',idempotencyKey:expect.any(String),operations:{money:{currency:'USD',amount:'0.00'},arrivals:[{destinationKey:'destination-1',actualArrivedAt:'2026-10-20T12:00:00+08:00'}]},reason:null })
expect(calls.save).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('里程'); wrapper.unmount()
})
it('clears money as null and retains exactly the original request after an unknown save', async () => {
const detail = outsourcedBusinessFixture(); detail.row.operations.money = {currency:'CNY',amount:'123.45'}
calls.save.mockRejectedValueOnce(new TypeError('network interrupted'))
const wrapper = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail}}); await flushPromises()
await wrapper.findAll('label').find(field => field.text() === '币种')!.find('input').setValue('')
await wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input').setValue('')
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
const original = JSON.parse(JSON.stringify(calls.save.mock.calls[0]![1]))
expect(original.operations.money).toBeNull(); expect(wrapper.emitted('saved')).toBeUndefined()
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
expect(calls.command).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',original.idempotencyKey)
expect(calls.save).toHaveBeenNthCalledWith(2,'DS-OUT-1',original); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('does not write for an unchanged form or a user without edit permission', async () => {
const first = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
await first.find('[data-save]').trigger('click'); await flushPromises(); expect(calls.save).not.toHaveBeenCalled(); first.unmount()
calls.canEdit = false
const readonly = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
expect(readonly.find('[data-save]').attributes('disabled')).toBeDefined(); expect(calls.save).not.toHaveBeenCalled(); readonly.unmount()
})
})
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
import { newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessMoney, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { getOutsourcedBusiness } from '../api/outsourcedBusiness'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), pending = ref<OutsourcedBusinessPending | null>(null), error = ref<ApiErrorDetails | null>(null), corrupted = ref(false)
const form = reactive({ currency: '', amount: '', reason: '', arrivals: {} as Record<string,string> })
const active = computed(() => open.value || Boolean(pending.value))
const registration = useUnsavedWorkRegistration({ key: `outsourced-business-operations:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务运营登记'), isOpen: active, model: reactive({ form, pending }), requiredPermission: computed(() => permission.outsourcedBusinessEdit) })
let actor = '', number = '', baseline = ''
const writable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessEdit) && props.detail?.row.actions.edit.allowed === true)
const locked = computed(() => busy.value || Boolean(pending.value))
function persist() { persistOutsourcedBusinessPending(actor, number, 'operations', pending.value) }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('操作身份或排车单已变化,请重新打开') }
watch(busy, value => emit('busy', value))
watch(open, async value => {
if (!value || !props.detail) return
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo; error.value = null; corrupted.value = false
try {
pending.value = restoreOutsourcedBusinessPending(actor, number, 'operations')
const operations = pending.value?.action === 'SAVE_OPERATIONS' ? (pending.value.body as OutsourcedBusinessSave).operations : props.detail.row.operations
Object.assign(form, { currency: operations.money?.currency || '', amount: operations.money?.amount ?? '', reason: pending.value ? (pending.value.body as OutsourcedBusinessSave).reason || '' : '', arrivals: Object.fromEntries(props.detail.row.destinations.map(destination => [destination.destinationKey, operations.arrivals.find(arrival => arrival.destinationKey === destination.destinationKey)?.actualArrivedAt || ''])) })
baseline = JSON.stringify(form); await nextTick(); if (!pending.value) registration.captureBaseline()
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '运营资料恢复失败') }
}, { immediate: true })
function payload(): OutsourcedBusinessSave {
const amount = outsourcedBusinessDecimal(form.amount, 2, 18)
let money: OutsourcedBusinessMoney | null = null
if (amount != null) { if (!['CNY','USD'].includes(form.currency)) throw new Error('请选择金额币种'); money = { currency: form.currency as OutsourcedBusinessMoney['currency'], amount } }
else if (form.currency) throw new Error('金额和币种请成对填写,清空时同时清空两项')
const arrivals = props.detail!.row.destinations.map(destination => {
const value = form.arrivals[destination.destinationKey] || null
if (value && (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))) throw new Error('实际到达时间必须包含有效时区')
return { destinationKey: destination.destinationKey, actualArrivedAt: value }
})
return { recordVersion: outsourcedBusinessVersion(props.detail!.row.recordVersion), idempotencyKey: crypto.randomUUID(), operations: { money, arrivals }, reason: form.reason.trim() || null }
}
async function save() {
if (busy.value || !writable.value || !props.detail) return
busy.value = true; error.value = null
try {
ensureScope()
if (!pending.value) { if (JSON.stringify(form) === baseline) { open.value = false; return }; pending.value = newOutsourcedBusinessPending(actor, number, 'SAVE_OPERATIONS', payload()); persist() }
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.detail) throw new Error('运营登记结果未确认')
pending.value = null; persist(); registration.captureBaseline(); emit('saved', result.detail); open.value = false; ElMessage.success('运营信息已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '运营登记结果尚未确认') }
finally { busy.value = false }
}
async function beforeClose(done: () => void) {
if (busy.value) return
if (pending.value || JSON.stringify(form) !== baseline) {
try { await ElMessageBox.confirm(pending.value ? '原请求将保留,重新打开后可核对原结果。是否关闭?' : '放弃尚未保存的运营信息?', '关闭运营登记', { customClass: 'admin-density-confirm', confirmButtonText: pending.value ? '保留请求并关闭' : '放弃修改', cancelButtonText: '继续编辑' }) } catch { return }
}
done()
}
async function discardRejected() {
if (!pending.value?.rejected || busy.value) return
busy.value = true
try { const current = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', current); error.value = null }
catch (cause) { error.value = getApiErrorDetails(cause, '当前运营版本核对失败') }
finally { busy.value = false }
}
</script>
<template>
<OutsourcedBusinessOperationsDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :pending="Boolean(pending)" :save-disabled="!writable" :before-close="beforeClose" @save="save">
<template #state><LoadErrorState v-if="error" title="运营登记未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原请求已保留,字段锁定;核对结果前不会建立新请求。</p><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝结果重新编辑</el-button><p v-if="!writable">{{ detail?.row.actions.edit.blockReason || '没有运营登记权限' }}</p></template>
<template #amount><el-form label-position="top" class="outsourced-business-form"><el-form-item label="币种"><el-select v-model="form.currency" clearable :disabled="locked"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select></el-form-item><el-form-item label="整车承运金额"><el-input v-model="form.amount" :disabled="locked" placeholder="未登记;允许明确填写0" /></el-form-item><el-form-item label="更正说明"><el-input v-model="form.reason" :disabled="locked" maxlength="1000" /></el-form-item></el-form></template>
<template #arrivals><div v-for="destination in detail?.row.destinations || []" :key="destination.destinationKey" class="outsourced-business-arrival"><span>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}<small v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}</small></span><el-date-picker v-model="form.arrivals[destination.destinationKey]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" :disabled="locked" placeholder="实际到达时间,未登记留空" /></div><p>时间按浏览器本地时区输入,提交时保留时区。</p></template>
</OutsourcedBusinessOperationsDialog>
</template>
<style scoped>
.outsourced-business-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-4); }
.outsourced-business-arrival { display: grid; grid-template-columns: minmax(0,1fr) 280px; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-3); }
.outsourced-business-arrival small { display: block; }
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
import { outsourcedBusinessPhotoRoles } from '../outsourcedBusinessTypes'
const props = defineProps<{
dispatchNo?: string
busy?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
function close(done: () => void) {
if (props.busy) return
if (props.beforeClose) return props.beforeClose(done)
done()
}
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}</p>
<slot name="state" />
<p>每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</p>
<div class="outsourced-business-photo-grid">
<section v-for="photo in outsourcedBusinessPhotoRoles" :key="photo.slot" class="outsourced-business-photo-slot" :aria-label="photo.label">
<h3>{{ photo.label }}</h3><slot :name="photo.viewSlot"><p>暂无照片</p></slot>
</section>
</div>
</AppDialog>
</template>
<style scoped>
.outsourced-business-photo-reference { overflow-wrap: anywhere; }
.outsourced-business-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
.outsourced-business-photo-slot { min-width: 0; padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
.outsourced-business-photo-slot h3 { margin: 0 0 var(--admin-space-3); font-size: inherit; }
</style>
@@ -0,0 +1,64 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import OutsourcedBusinessPhotosEditor from './OutsourcedBusinessPhotosEditor.vue'
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
import type { OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes'
const calls=vi.hoisted(()=>({upload:vi.fn(),bind:vi.fn(),command:vi.fn(),detail:vi.fn(),content:vi.fn(),actor:{user:{id:'actor'}},canUpload:true}))
vi.mock('../api/outsourcedBusiness',()=>({uploadOutsourcedBusinessPhoto:calls.upload,bindOutsourcedBusinessPhoto:calls.bind,getOutsourcedBusinessCommand:calls.command,getOutsourcedBusiness:calls.detail,getOutsourcedBusinessPhotoContent:calls.content}))
vi.mock('@/core/auth/auth',()=>({useAuthStore:()=>calls.actor}))
vi.mock('@/core/access/accessPreview',()=>({useAccessPreviewStore:()=>({can:()=>calls.canUpload})}))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration',()=>({useUnsavedWorkRegistration:()=>({captureBaseline:vi.fn()})}))
vi.mock('element-plus',()=>({ElMessage:{success:vi.fn()},ElMessageBox:{confirm:vi.fn()}}))
const Frame=defineComponent({props:['modelValue','busy','beforeClose'],setup(_props,{slots}){return()=>h('div',Object.values(slots).map(slot=>slot?.()))}})
const Button=defineComponent({props:['disabled'],template:'<button :disabled="disabled"><slot /></button>'})
const global={stubs:{OutsourcedBusinessPhotosDialog:Frame,ElButton:Button,LoadErrorState:{props:['message'],template:'<div>{{message}}<slot name="details" /></div>'},ApiErrorMeta:true}}
const photo:OutsourcedBusinessPhoto={slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null}
async function choose(wrapper:ReturnType<typeof mount>){const input=wrapper.find('input[type="file"]');Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['image'],'loading.png',{type:'image/png'})]});await input.trigger('change');await flushPromises()}
beforeEach(()=>{
vi.clearAllMocks();sessionStorage.clear();calls.actor.user.id='actor';calls.canUpload=true
calls.upload.mockResolvedValue(photo);calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
const current=outsourcedBusinessFixture();current.row.recordVersion='1';current.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
calls.bind.mockResolvedValue(current);calls.detail.mockResolvedValue(current)
})
afterEach(() => vi.unstubAllGlobals())
describe('outsourced business per-slot photo save',()=>{
it('retains prepared file and original binding request through a failed bind without retransmitting the image',async()=>{
calls.bind.mockRejectedValueOnce(new TypeError('network interrupted'))
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('尚未确认绑定保存');expect(wrapper.emitted('saved')).toBeUndefined()
const original={...calls.bind.mock.calls[0]![2]}
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(2)
expect(calls.bind.mock.calls[1]![2]).toEqual(original);expect(original.fileId).toBe(photo.fileId);expect(original.recordVersion).toBe('0')
expect(wrapper.emitted('saved')).toHaveLength(1);wrapper.unmount()
})
it('does not offer a new upload after unknown upload or fabricate a saved photo',async()=>{
calls.upload.mockRejectedValue(new TypeError('network interrupted'))
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).not.toHaveBeenCalled();expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined();expect(wrapper.text()).toContain('禁止重复上传');wrapper.unmount()
})
it('keeps saved metadata readable without upload permission and fetches image bytes only on explicit preview',async()=>{
calls.canUpload=false
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
expect(wrapper.find('input[type="file"]').exists()).toBe(false);expect(wrapper.text()).toContain('已保存');expect(calls.content).not.toHaveBeenCalled()
wrapper.unmount()
})
it('reads a saved loading photo only on click and releases its URL on closing',async()=>{
const create=vi.fn(()=> 'blob:outsourced-loading'),revoke=vi.fn()
vi.stubGlobal('URL',class extends URL { static createObjectURL=create; static revokeObjectURL=revoke })
calls.canUpload=false;calls.content.mockResolvedValue(new Blob(['image'],{type:'image/png'}))
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
expect(calls.content).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises()
expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading')
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
})
})
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot } from '../outsourcedBusinessTypes'
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, uploadOutsourcedBusinessPhoto } from '../api/outsourcedBusiness'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { acceptOutsourcedBusinessResult, newOutsourcedBusinessPending, outsourcedBusinessPendingKey, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null), pending = ref<OutsourcedBusinessPending | null>(null), prepared = ref<OutsourcedBusinessPhoto | null>(null), corrupted = ref(false)
const urls = ref<Partial<Record<OutsourcedBusinessPhotoSlot,string>>>({}), reading = ref<OutsourcedBusinessPhotoSlot | null>(null)
const uploadable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
useUnsavedWorkRegistration({ key: `outsourced-business-photos:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务照片作业'), isOpen: computed(() => open.value || Boolean(pending.value) || Boolean(prepared.value)), model: reactive({ pending, prepared }), requiredPermission: computed(() => permission.outsourcedBusinessUpload) })
let actor = '', number = '', generation = 0
function savedPhoto(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
function persist() { persistOutsourcedBusinessPending(actor, number, 'photos', pending.value) }
function persistPrepared() {
const key = outsourcedBusinessPendingKey(actor, number, 'prepared-photo')
if (prepared.value) sessionStorage.setItem(key, JSON.stringify(prepared.value)); else sessionStorage.removeItem(key)
}
watch(busy, value => emit('busy', value))
watch(open, value => {
generation++; release(); reading.value = null; error.value = null; corrupted.value = false
if (!value || !props.detail) return
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo
try {
pending.value = restoreOutsourcedBusinessPending(actor, number, 'photos')
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, number, 'prepared-photo'))
prepared.value = raw ? JSON.parse(raw) as OutsourcedBusinessPhoto : null
if (prepared.value && (!prepared.value.fileId || !outsourcedBusinessPhotoRoles.some(role => role.slot === prepared.value!.slot))) throw new Error('原上传文件资料损坏,请保留现场核对')
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '照片作业恢复失败') }
}, { immediate: true })
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
onBeforeUnmount(() => { generation++; release() })
async function preview(slot: OutsourcedBusinessPhotoSlot) {
const photo = savedPhoto(slot), current = generation
if (!photo || reading.value) return
reading.value = slot; error.value = null
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
finally { if (current === generation) reading.value = null }
}
async function bindPrepared() {
if ((!prepared.value && !pending.value) || !props.detail || !uploadable.value) return
if (!pending.value) {
const body = { recordVersion: outsourcedBusinessVersion(props.detail.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: prepared.value!.fileId }
pending.value = newOutsourcedBusinessPending(actor, number, 'BIND_PHOTO', body, prepared.value!.slot); persist()
}
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.detail) throw new Error('照片绑定尚未确认')
pending.value = null; persist(); prepared.value = null; persistPrepared(); emit('saved', result.detail); ElMessage.success('照片已绑定保存')
}
async function recover() {
if (busy.value || !uploadable.value) return
busy.value = true; error.value = null
try {
ensureScope()
if (pending.value?.action === 'UPLOAD_PHOTO') {
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.photo) throw new Error('原上传文件未确认')
prepared.value = result.photo; persistPrepared(); pending.value = null; persist()
}
await bindPrepared()
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '照片结果尚未确认') }
finally { busy.value = false }
}
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || pending.value || prepared.value || !uploadable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
busy.value = true; error.value = null
try {
ensureScope(); pending.value = newOutsourcedBusinessPending(actor, number, 'UPLOAD_PHOTO', null, slot); pending.value.attempted = true; persist()
const uploaded = await uploadOutsourcedBusinessPhoto(number, slot, file, pending.value.key)
ensureScope(); acceptOutsourcedBusinessResult(pending.value, uploaded); persist()
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.photo) throw new Error('真实上传文件未确认')
prepared.value = result.photo; persistPrepared(); pending.value = null; persist(); await bindPrepared()
} catch (cause) {
if (pending.value?.action === 'UPLOAD_PHOTO' && cause instanceof ApiResponseError && ['A0400','A0403','A0404','400','403','404','422'].includes(cause.code)) { pending.value.rejected = true; persist() }
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '图片上传或绑定结果尚未确认')
} finally { busy.value = false }
}
async function discardRejected() {
if (busy.value || !pending.value?.rejected) return
busy.value = true
try { const latest = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', latest); error.value = null }
catch (cause) { error.value = getApiErrorDetails(cause, '当前照片版本读取失败') }
finally { busy.value = false }
}
async function beforeClose(done: () => void) {
if (busy.value) return
if (pending.value || prepared.value) {
try { await ElMessageBox.confirm('未完成的原上传或绑定记录将保留,重新打开后继续核对;关闭不会保存尚未绑定的照片。是否关闭?', '关闭照片窗口', { customClass: 'admin-density-confirm', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
}
done()
}
</script>
<template>
<OutsourcedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原{{ pending.action === 'UPLOAD_PHOTO' ? '上传' : '绑定' }}请求已保留,不会重新上传文件。</p><p v-if="prepared">{{ prepared.fileName }} 已上传,尚未确认绑定保存。</p><el-button v-if="pending || prepared" :loading="busy" :disabled="!uploadable" @click="recover">核对原结果并继续绑定</el-button><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝重新核对,保留已上传文件</el-button><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="outsourced-business-photo" />
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
<p v-else>暂无已保存照片</p>
<label v-if="uploadable" class="outsourced-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy || Boolean(pending) || Boolean(prepared)" @change="upload(role.slot, $event)" /></label>
</template>
</OutsourcedBusinessPhotosDialog>
</template>
<style scoped>
.outsourced-business-photo { display: block; width: 100%; max-height: 160px; object-fit: contain; }
.outsourced-business-upload { display: block; margin-top: var(--admin-space-3); }
.outsourced-business-upload input { display: block; width: 100%; margin-top: var(--admin-space-2); }
</style>
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessText, outsourcedBusinessPhotoRoles } from './outsourcedBusinessTypes'
describe('outsourced business decimal and version boundary', () => {
it('supports exactly the five approved loading photo slots', () => {
expect(outsourcedBusinessPhotoRoles.map(role => role.slot)).toEqual(['LOADING_SCENE','LOADING_LIST','PRE_DEPARTURE_CHECK','DRIVER_LICENSE','RECEIPT'])
expect(outsourcedBusinessPhotoRoles.every(role => role.category === 'LOADING')).toBe(true)
})
it('preserves the maximum decimal money text and distinguishes empty from explicit zero', () => {
expect(outsourcedBusinessDecimal('9999999999999999.99', 2, 18)).toBe('9999999999999999.99')
expect(outsourcedBusinessDecimal('0.00', 2, 18)).toBe('0.00'); expect(outsourcedBusinessDecimal(' ', 2, 18)).toBeNull()
expect(() => outsourcedBusinessDecimal('10000000000000000.00', 2, 18)).toThrow()
for (const value of ['-1','1e3','1.001','01','NaN','Infinity']) expect(() => outsourcedBusinessDecimal(value, 2, 18)).toThrow()
expect(outsourcedBusinessDecimal('123.456', 3, 12)).toBe('123.456')
})
it('keeps version zero and large string identities without accepting unsafe numeric versions', () => {
expect(outsourcedBusinessVersion(0)).toBe('0'); expect(outsourcedBusinessVersion('9007199254740993')).toBe('9007199254740993')
for (const value of [undefined,null,-1,0.5,Number.MAX_SAFE_INTEGER + 1,'','1e3']) expect(() => outsourcedBusinessVersion(value)).toThrow()
expect(outsourcedBusinessText(null)).toBe('—'); expect(outsourcedBusinessText(0)).toBe('0')
})
})
@@ -0,0 +1,45 @@
import type { DispatchDetail } from '@/modules/overseas-warehouse'
export type OutsourcedBusinessStage = 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
export type OutsourcedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEPARTURE_CHECK' | 'DRIVER_LICENSE' | 'RECEIPT'
export interface OutsourcedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
export interface OutsourcedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
export interface OutsourcedBusinessOperations { money: OutsourcedBusinessMoney | null; arrivals: OutsourcedBusinessArrival[] }
export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OutsourcedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
export interface OutsourcedBusinessRow {
dispatchNo: string; stage: OutsourcedBusinessStage; plannedDepartureAt: string | null; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
origin: { warehouseId: string; warehouseCode: string; warehouseName: string }
vehicle: { vehicleId: string | null; plate: string | null; ownership: 'DISPATCHED'; carrierName: string | null }; driver: { name: string | null; phone: string | null }
destinations: OutsourcedBusinessDestination[]; actualPalletCount: number | null; operations: OutsourcedBusinessOperations; unitPrice: string | null; recordVersion: string; dispatchRecordVersion: string
photoSummary: { loadingCount: number }; actions: { edit: OutsourcedBusinessGate; upload: OutsourcedBusinessGate }; sourceIssues: string[]
}
export interface OutsourcedBusinessGate { allowed: boolean; blockReason: string | null }
export interface OutsourcedBusinessQuery { page?: number; size?: number; plannedDepartureFrom?: string; plannedDepartureTo?: string; keyword?: string; stage?: OutsourcedBusinessStage; warehouseId?: string }
export interface OutsourcedBusinessSummary { dispatchCount: number; afterLoadingCount: number; deliveringCount: number; deliveredCount: number }
export interface OutsourcedBusinessPage<T> { records: T[]; page: number; size: number; total: number }
export interface OutsourcedBusinessList extends OutsourcedBusinessPage<OutsourcedBusinessRow> { summary: OutsourcedBusinessSummary }
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] }
export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null }
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] }
export const outsourcedBusinessStages: Record<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' },
]
export function outsourcedBusinessVersion(value: unknown): string {
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
return String(value)
}
export function outsourcedBusinessDecimal(value: string, scale: number, precision: number): string | null {
const text = value.trim()
if (!text) return null
if (!new RegExp(`^(?:0|[1-9]\\d*)(?:\\.\\d{1,${scale}})?$`).test(text) || text.split('.')[0]!.length > precision - scale) throw new Error(`请输入不超过${scale}位小数的有效非负数`)
return text
}
export function outsourcedBusinessText(value: unknown): string { return value == null || value === '' ? '—' : String(value) }
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import OutsourcedBusinessDetailLayout from '../components/OutsourcedBusinessDetailLayout.vue'
import OutsourcedBusinessOperationsEditor from '../components/OutsourcedBusinessOperationsEditor.vue'
import OutsourcedBusinessPhotosEditor from '../components/OutsourcedBusinessPhotosEditor.vue'
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, listOutsourcedBusinessHistory } from '../api/outsourcedBusiness'
import { outsourcedBusinessActionLabels, outsourcedBusinessStages, outsourcedBusinessText as text, outsourcedBusinessPhotoRoles, type OutsourcedBusinessDetail, type OutsourcedBusinessHistory, type OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const dispatchNo = computed(() => typeof route.params.dispatchNo === 'string' ? route.params.dispatchNo : '')
const detail = ref<OutsourcedBusinessDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), activeTab = ref<'task'|'route'|'history'>('task')
const editorOpen = ref(false), photosOpen = ref(false), busy = ref(false)
const history = ref<OutsourcedBusinessHistory[]>([]), historyPage = ref(1), historySize = ref(20), historyTotal = ref(0), historyLoading = ref(false), historyError = ref<ApiErrorDetails | null>(null), previewError = ref<ApiErrorDetails | null>(null)
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
const previewUrls = new Set<string>()
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
const taskFields = computed(() => {
const row = detail.value?.row
return [['计划发车', time(row?.plannedDepartureAt)], ['装车完成', time(row?.loadingCompletedAt)], ['实际发车', time(row?.departedAt)], ['派送完成', time(row?.deliveryCompletedAt)], ['车牌', text(row?.vehicle.plate)], ['司机', text(row?.driver.name)], ['司机电话', text(row?.driver.phone)], ['提货仓库', text(row?.origin.warehouseName)], ['实际物理托数', text(row?.actualPalletCount)], ['整车承运金额', row?.operations.money ? `${row.operations.money.currency} ${row.operations.money.amount}` : '未登记'], ['单价', row?.unitPrice == null ? '—' : `${row.operations.money?.currency || ''} ${row.unitPrice}`], ['承运商', text(row?.vehicle.carrierName)]]
})
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
function destinationLabel(key: string) { const value = detail.value?.row.destinations.find(destination => destination.destinationKey === key); return value?.warehouseCode || value?.warehouseName || value?.address || '—' }
function photoLabel(photo: OutsourcedBusinessPhoto) { return outsourcedBusinessPhotoRoles.find(role => role.slot === photo.slot)?.label || '照片' }
async function load() {
if (!active) return
const request = ++generation, no = dispatchNo.value; loading.value = true; error.value = null
try { if (!no || !access.can(permission.outsourcedBusinessList)) throw new Error('缺少排车业务号或查看权限'); const value = await getOutsourcedBusiness(no); if (active && request === generation) { if (value.row.dispatchNo !== no) throw new Error('当前运营资料与排车业务号不一致'); detail.value = value; if (activeTab.value === 'history') void loadHistory() } }
catch (cause) { if (active && request === generation) { detail.value = null; error.value = getApiErrorDetails(cause, '外配业务详情读取失败') } }
finally { if (request === generation) loading.value = false }
}
async function loadHistory() {
if (!active || activeTab.value !== 'history' || !detail.value) return
const request = ++historyGeneration, no = dispatchNo.value; historyLoading.value = true; historyError.value = null
try { const value = await listOutsourcedBusinessHistory(no, historyPage.value, historySize.value); if (active && request === historyGeneration && no === dispatchNo.value) { history.value = value.records; historyTotal.value = value.total } }
catch (cause) { if (request === historyGeneration) historyError.value = getApiErrorDetails(cause, '操作记录读取失败') }
finally { if (request === historyGeneration) historyLoading.value = false }
}
async function preview(photo: OutsourcedBusinessPhoto, revision: string | null) {
const no = dispatchNo.value, request = generation; previewError.value = null
if (revision == null) { previewError.value = { message: '历史照片缺少运营资料版本,请重新读取' }; return }
const windowRef = window.open('', '_blank')
if (!windowRef) { previewError.value = { message: '请允许本站打开图片预览窗口' }; return }
try { const blob = await getOutsourcedBusinessPhotoContent(no, photo, revision); if (!active || request !== generation || no !== dispatchNo.value) { windowRef.close(); return }; const url = URL.createObjectURL(blob); previewUrls.add(url); windowRef.location.href = url }
catch (cause) { windowRef.close(); previewError.value = getApiErrorDetails(cause, '历史照片读取失败') }
}
function saved(value: OutsourcedBusinessDetail) { if (value.row.dispatchNo !== dispatchNo.value) return; detail.value = value; if (activeTab.value === 'history') void loadHistory() }
watch([activeTab,historyPage,historySize], () => { if (active) void loadHistory() })
watch(dispatchNo, () => { generation++; historyGeneration++; detail.value = null; history.value = []; historyTotal.value = 0; activeTab.value = 'task'; editorOpen.value = false; photosOpen.value = false; if (active) void load() })
onMounted(load)
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
onDeactivated(() => { active = false; generation++; historyGeneration++; loading.value = false; historyLoading.value = false; editorOpen.value = false; photosOpen.value = false })
onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previewUrls.forEach(url => URL.revokeObjectURL(url)) })
</script>
<template>
<OutsourcedBusinessDetailLayout v-model:active-tab="activeTab" :dispatch-no="detail?.row.dispatchNo || dispatchNo" :status-text="detail ? outsourcedBusinessStages[detail.row.stage] : ''" :loading="loading" @back="router.push('/overseas-master-data/outsourced-business')">
<template #actions><el-button v-if="detail && access.can(permission.outsourcedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.outsourcedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
<template #state><LoadErrorState v-if="error" title="外配业务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
<template #task><dl v-if="detail" class="density-fields outsourced-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="outsourced-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable><section class="outsourced-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ outsourcedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车承运金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></template>
</OutsourcedBusinessDetailLayout>
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
<OutsourcedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
</template>
<style scoped>
.outsourced-business-fields { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); padding: var(--admin-space-4); }
.outsourced-business-fields > div { min-width: 0; overflow-wrap: anywhere; }
.outsourced-business-route { padding: var(--admin-space-4); border-bottom: 1px solid var(--admin-table-border); }
.outsourced-business-route h3 { font-size: inherit; margin: 0 0 var(--admin-space-3); }
</style>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import QueryToolbar, { type QueryField, type QueryModel, type QueryOption } from '@/shared/components/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OutsourcedBusinessListLayout from '../components/OutsourcedBusinessListLayout.vue'
import OutsourcedBusinessOperationsEditor from '../components/OutsourcedBusinessOperationsEditor.vue'
import OutsourcedBusinessPhotosEditor from '../components/OutsourcedBusinessPhotosEditor.vue'
import { getOutsourcedBusiness, listOutsourcedBusiness } from '../api/outsourcedBusiness'
import { outsourcedBusinessStages, outsourcedBusinessText as text, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessRow, type OutsourcedBusinessStage } from '../outsourcedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
const router = useRouter(), access = useAccessPreviewStore()
const result = ref<OutsourcedBusinessList | null>(null), page = ref(1), size = ref(20), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ plannedRange: [], keyword: '', stage: '', warehouseId: '' })
const warehouseOptions = ref<QueryOption[]>([]), warehouseError = ref<ApiErrorDetails | null>(null)
const detail = ref<OutsourcedBusinessDetail | null>(null), editorOpen = ref(false), photosOpen = ref(false), opening = ref(false), mutating = ref(false)
let applied: Omit<OutsourcedBusinessQuery,'page'|'size'> = {}, generation = 0, openGeneration = 0, active = true, initialActivation = true
const fields = computed<QueryField[]>(() => [
{ key: 'plannedRange', type: 'daterange', label: '计划发车日期', width: 270, props: { valueFormat: 'YYYY-MM-DD', 'aria-label': '计划发车日期范围' } },
{ key: 'keyword', type: 'input', label: '关键词', width: 230, placeholder: '排车单号 / 车牌 / 目的仓', props: { maxlength: 128 } },
{ key: 'stage', type: 'select', label: '阶段', width: 140, options: Object.entries(outsourcedBusinessStages).map(([value,label]) => ({ value,label })) },
{ key: 'warehouseId', type: 'select', label: '提货仓库', width: 180, options: warehouseOptions.value, props: { disabled: !access.can('platform:warehouse:list') } },
])
const summary = computed(() => [
['排车数', result.value?.summary.dispatchCount], ['装车完成', result.value?.summary.afterLoadingCount], ['派送中', result.value?.summary.deliveringCount], ['派送完成', result.value?.summary.deliveredCount],
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
{ key: 'carrier', label: '承运商', minWidth: 150, slotName: 'carrier' },
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
{ key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 285, slotName: 'destinations' },
{ prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
{ key: 'money', label: '整车承运金额 / 单价', minWidth: 170, slotName: 'money' },
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
{ key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' },
]
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
function rowActions(row: OutsourcedBusinessRow): RowActionItem[] {
return [
{ key: 'detail', label: '查看明细' },
{ key: 'operations', label: '登记运营信息', visible: access.can(permission.outsourcedBusinessEdit), disabled: opening.value || mutating.value || !row.actions.edit.allowed },
{ key: 'photos', label: access.can(permission.outsourcedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片', disabled: opening.value || mutating.value },
]
}
function act(row: OutsourcedBusinessRow, key: string) {
if (key === 'detail') void router.push(`/overseas-master-data/outsourced-business/${encodeURIComponent(row.dispatchNo)}`)
else if (key === 'operations' || key === 'photos') void openAction(row, key)
}
async function load() {
if (!active) return
const request = ++generation
loading.value = true; error.value = null
try {
if (!access.can(permission.outsourcedBusinessList)) throw new Error('没有外配业务查看权限')
const value = await listOutsourcedBusiness({ ...applied, page: page.value, size: size.value })
if (active && request === generation) result.value = value
} catch (cause) { if (active && request === generation) { result.value = null; error.value = getApiErrorDetails(cause, '外配业务读取失败') } }
finally { if (request === generation) loading.value = false }
}
function search() {
try {
const range = filters.value.plannedRange
const dates = Array.isArray(range) ? range : []
if (dates.length && (dates.length !== 2 || !dayjs(String(dates[0])).isValid() || !dayjs(String(dates[1])).isValid() || dayjs(String(dates[1])).isBefore(dayjs(String(dates[0]))))) throw new Error('请选择有效的计划发车日期范围')
applied = { keyword: String(filters.value.keyword || '').trim() || undefined, stage: (filters.value.stage || undefined) as OutsourcedBusinessStage | undefined, warehouseId: String(filters.value.warehouseId || '') || undefined,
plannedDepartureFrom: dates.length ? dayjs(String(dates[0])).startOf('day').toISOString() : undefined, plannedDepartureTo: dates.length ? dayjs(String(dates[1])).add(1,'day').startOf('day').toISOString() : undefined }
if (page.value !== 1) page.value = 1; else void load()
} catch (cause) { error.value = getApiErrorDetails(cause, '筛选条件无效') }
}
async function loadWarehouses() {
if (!access.can('platform:warehouse:list')) return
try { const values = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }); warehouseOptions.value = values.map(value => ({ value: String(value.id), label: value.warehouseName })) }
catch (cause) { warehouseError.value = getApiErrorDetails(cause, '仓库目录读取失败,可继续使用其他筛选') }
}
async function openAction(row: OutsourcedBusinessRow, kind: 'operations' | 'photos') {
if (opening.value || mutating.value) return
const code = kind === 'operations' ? permission.outsourcedBusinessEdit : permission.outsourcedBusinessList
if (!access.can(code) || kind === 'operations' && !row.actions.edit.allowed) return
const request = ++openGeneration; opening.value = true; error.value = null
try { const value = await getOutsourcedBusiness(row.dispatchNo); if (!active || request !== openGeneration) return; detail.value = value; if (kind === 'operations') editorOpen.value = true; else photosOpen.value = true }
catch (cause) { if (request === openGeneration) error.value = getApiErrorDetails(cause, '当前运营资料读取失败') }
finally { if (request === openGeneration) opening.value = false }
}
function saved(value: OutsourcedBusinessDetail) { detail.value = value; void load() }
watch([page,size], () => { if (active) void load() })
onMounted(() => { void load(); void loadWarehouses() })
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
onDeactivated(() => { active = false; generation++; openGeneration++; loading.value = false; opening.value = false; editorOpen.value = false; photosOpen.value = false })
onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
</script>
<template>
<OutsourcedBusinessListLayout>
<template #statistics><section class="outsourced-business-statistics" aria-label="完整筛选范围外配业务统计" :aria-busy="loading"><div v-for="item in summary" :key="String(item[0])"><span>{{ item[0] }}</span><strong>{{ item[1] ?? '—' }}</strong></div></section></template>
<DataTable appearance="integrated" :stripe="false" v-model:page="page" v-model:page-size="size" :data="result?.records || []" :columns="columns" :total="result?.total || 0" :loading="loading" row-key="dispatchNo" preference-key="outsourced-business-table" empty-text="当前筛选范围暂无已装车的外配车辆排车单">
<template #toolbar-start><h1>外配业务</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="外配业务筛选" preference-key="outsourced-business-query" :loading="loading" @search="search" /><small>日期和时间按浏览器本地时区显示;统计覆盖同一完整筛选范围。</small></template>
<template #notices><LoadErrorState v-if="error" title="外配业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
<template #actions="{ row }"><RowActions :actions="rowActions(row as OutsourcedBusinessRow)" text-actions @action="act(row as OutsourcedBusinessRow, $event)" /></template>
<template #carrier="{ row }">{{ text(row.vehicle.carrierName) }}</template>
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="outsourced-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="outsourced-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约 {{ time(appointment.scheduledAt) }}</div><div>实到 {{ time(destination.actualArrivedAt) }}</div></div></template>
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}<br>单价 {{ row.unitPrice ?? '—' }}</template><span v-else>未登记</span></template>
<template #plannedDeparture="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #loaded="{ row }">{{ time(row.loadingCompletedAt) }}</template><template #stage="{ row }">{{ outsourcedBusinessStages[row.stage as OutsourcedBusinessStage] }}</template>
</DataTable>
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
<OutsourcedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
</OutsourcedBusinessListLayout>
</template>
<style scoped>
.outsourced-business-statistics { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); background: white; border: 1px solid var(--admin-table-border); }
.outsourced-business-statistics > div { padding: var(--admin-space-4) var(--admin-space-5); border-right: 1px solid var(--admin-table-border); }
.outsourced-business-statistics > div:last-child { border-right: 0; }
.outsourced-business-statistics strong { display: block; margin-top: var(--admin-space-2); font-size: var(--admin-font-title); }
.outsourced-business-destination + .outsourced-business-destination { margin-top: var(--admin-space-3); border-top: 1px solid var(--admin-table-border); padding-top: var(--admin-space-3); }
.outsourced-business-destination small { display: block; }
</style>
@@ -0,0 +1,96 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import dayjs from 'dayjs'
import OutsourcedBusinessView from './OutsourcedBusinessView.vue'
import OutsourcedBusinessDetailView from './OutsourcedBusinessDetailView.vue'
import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures'
import { overseasMasterDataPermissions as permission } from '../permissions'
const calls = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), history: vi.fn(), content: vi.fn(), warehouses: vi.fn(), push: vi.fn(), allowed: new Set<string>() }))
vi.mock('../api/outsourcedBusiness', () => ({ listOutsourcedBusiness: calls.list, getOutsourcedBusiness: calls.detail, listOutsourcedBusinessHistory: calls.history, getOutsourcedBusinessPhotoContent: calls.content }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: calls.warehouses }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => calls.allowed.has(code) }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: calls.push }), useRoute: () => ({ params: { dispatchNo: 'DS-OUT-1' } }) }))
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
const Button = defineComponent({ props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' })
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
const Pane = defineComponent({ props: ['name','label'], template: '<article><slot /></article>' })
const Query = defineComponent({ props: ['modelValue','fields'], emits: ['update:modelValue','search'], template: '<div />' })
const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([,slot]) => slot?.({row}))))]) } })
const OperationsEditor = defineComponent({ name: 'OutsourcedBusinessOperationsEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
const PhotosEditor = defineComponent({ name: 'OutsourcedBusinessPhotosEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, DataTable: Table, QueryToolbar: Query, ElTabs: Tabs, ElTabPane: Pane, ElButton: Button, OutsourcedBusinessOperationsEditor: OperationsEditor, OutsourcedBusinessPhotosEditor: PhotosEditor, LoadErrorState: { props:['message'], template:'<div>{{message}}<slot name="details" /></div>' }, ApiErrorMeta: true }, directives: { loading: () => {} } }
beforeEach(() => {
vi.clearAllMocks(); calls.allowed = new Set(Object.values(permission)); calls.allowed.add('platform:warehouse:list')
const detail = outsourcedBusinessFixture()
calls.list.mockResolvedValue({ records:[detail.row],page:1,size:20,total:41,summary:{dispatchCount:41,afterLoadingCount:30,deliveringCount:10,deliveredCount:1} })
calls.detail.mockResolvedValue(detail); calls.history.mockResolvedValue({records:[],page:1,size:20,total:0}); calls.warehouses.mockResolvedValue([])
})
describe('outsourced business views', () => {
it('uses one list request for all statistics and never hydrates each row', async () => {
const wrapper = mount(OutsourcedBusinessView,{global}); await flushPromises()
expect(calls.list).toHaveBeenCalledTimes(1); expect(calls.detail).not.toHaveBeenCalled(); expect(calls.content).not.toHaveBeenCalled()
expect(wrapper.find('[aria-label="完整筛选范围外配业务统计"]').text()).toContain('排车数41')
expect(wrapper.text()).not.toContain('今日排车')
expect(wrapper.findComponent(Table).props('columns')[0].key).toBe('actions')
expect(wrapper.findComponent(Table).props('total')).toBe(41)
expect(wrapper.text()).toContain('测试外配承运商'); expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('自有成本')
wrapper.unmount()
})
it('applies planned date half-open boundaries only on query and keeps all stage counts from the response', async () => {
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises();const query=wrapper.findComponent(Query)
query.vm.$emit('update:modelValue',{plannedRange:['2026-10-20','2026-10-21'],keyword:' FBA-1 ',stage:'AFTER_LOADING',warehouseId:'1'});await wrapper.vm.$nextTick()
expect(calls.list).toHaveBeenCalledTimes(1)
query.vm.$emit('search');await flushPromises()
expect(calls.list).toHaveBeenLastCalledWith({page:1,size:20,keyword:'FBA-1',stage:'AFTER_LOADING',warehouseId:'1',plannedDepartureFrom:dayjs('2026-10-20').startOf('day').toISOString(),plannedDepartureTo:dayjs('2026-10-21').add(1,'day').startOf('day').toISOString()})
expect(wrapper.find('[aria-label="完整筛选范围外配业务统计"]').text()).toContain('装车完成30')
wrapper.unmount()
})
it('opens the operations modal on demand with auxiliary zero version instead of the dispatch version', async () => {
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises()
expect(calls.detail).not.toHaveBeenCalled();expect(wrapper.findComponent(OperationsEditor).props('modelValue')).toBe(false)
await wrapper.findAll('button').find(button=>button.text()==='登记运营信息')!.trigger('click');await flushPromises()
expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OUT-1')
const editor=wrapper.findComponent(OperationsEditor)
expect(editor.props('modelValue')).toBe(true);expect(wrapper.findComponent(PhotosEditor).props('modelValue')).toBe(false)
expect(editor.props('detail').row.recordVersion).toBe('0');expect(editor.props('detail').dispatch.recordVersion).toBe('8')
wrapper.unmount()
})
it('keeps a list-only user read-only while retaining detail and authorized photo reads', async () => {
calls.allowed=new Set([permission.outsourcedBusinessList])
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises()
expect(wrapper.text()).not.toContain('登记运营信息');expect(wrapper.text()).toContain('查看照片');expect(calls.warehouses).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows a temporary external vehicle and a missing carrier without requiring a master ID', async () => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle = { vehicleId:null,plate:'TEMP-1',ownership:'DISPATCHED',carrierName:null }
calls.detail.mockResolvedValue(detail)
const wrapper = mount(OutsourcedBusinessDetailView,{global}); await flushPromises()
expect(wrapper.text()).toContain('TEMP-1'); expect(wrapper.text()).toContain('承运商'); expect(wrapper.text()).toContain('—')
expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OUT-1'); expect(calls.content).not.toHaveBeenCalled(); wrapper.unmount()
})
it('loads aggregated history only on its tab, preserving null operations on dispatch events', async () => {
calls.history.mockResolvedValue({records:[{eventSource:'DISPATCH',recordVersion:null,dispatchRecordVersion:'8',action:'LOADING_COMPLETE',operatorName:null,occurredAt:'2026-10-20T09:00:00Z',reason:null,operations:null,photos:[]},{eventSource:'OPERATIONS',recordVersion:'1',dispatchRecordVersion:null,action:'SAVE_OPERATIONS',operatorName:'登记员',occurredAt:'2026-10-20T10:00:00Z',reason:null,operations:outsourcedBusinessFixture().row.operations,photos:[]}],page:1,size:20,total:2})
const wrapper=mount(OutsourcedBusinessDetailView,{global});await flushPromises()
expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.history).not.toHaveBeenCalled()
wrapper.findComponent(Tabs).vm.$emit('update:modelValue','history');await flushPromises()
expect(calls.history).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',1,20)
expect(wrapper.text()).toContain('原排车');expect(wrapper.text()).toContain('运营登记')
const table=wrapper.findComponent(Table), records=table.props('data')
expect(records[0].eventKey).not.toBe(records[1].eventKey);expect(records[0].operations).toBeNull()
table.vm.$emit('update:page',2);await flushPromises();expect(calls.history).toHaveBeenLastCalledWith('DS-OUT-1',2,20)
expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.content).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows authoritative large money text and unit price without rounding or recomputing from two cargo rows', async () => {
const detail=outsourcedBusinessFixture();detail.row.operations.money={currency:'USD',amount:'9999999999999999.99'};detail.row.unitPrice='9999999999999999.99'
calls.detail.mockResolvedValue(detail)
const wrapper=mount(OutsourcedBusinessDetailView,{global});await flushPromises()
expect(wrapper.text()).toContain('USD 9999999999999999.99');expect(wrapper.text()).not.toContain('10000000000000000')
expect(wrapper.text()).not.toContain('4999999999999999')
wrapper.unmount()
})
})
@@ -17,6 +17,9 @@ export const overseasMasterDataPermissions = {
ownedBusinessList: 'platform:fleet:owned-business:list',
ownedBusinessEdit: 'platform:fleet:owned-business:edit',
ownedBusinessUpload: 'platform:fleet:owned-business:upload',
outsourcedBusinessList: 'platform:fleet:outsourced-business:list',
outsourcedBusinessEdit: 'platform:fleet:outsourced-business:edit',
outsourcedBusinessUpload: 'platform:fleet:outsourced-business:upload',
tripBookingCreate: 'platform:fleet:trip:booking-create',
tripBookingEdit: 'platform:fleet:trip:booking-edit',
tripBookingConfirm: 'platform:fleet:trip:booking-confirm',
@@ -13,6 +13,8 @@ describe('overseas master data routes', () => {
['overseas-master-data/trip-operations', overseasMasterDataPermissions.operationsList],
['overseas-master-data/owned-business', overseasMasterDataPermissions.ownedBusinessList],
['overseas-master-data/owned-business/:dispatchNo', overseasMasterDataPermissions.ownedBusinessList],
['overseas-master-data/outsourced-business', overseasMasterDataPermissions.outsourcedBusinessList],
['overseas-master-data/outsourced-business/:dispatchNo', overseasMasterDataPermissions.outsourcedBusinessList],
['overseas-master-data/trips', overseasMasterDataPermissions.tripList],
['overseas-master-data/trips/:tripNo', overseasMasterDataPermissions.tripList],
['overseas-master-data/drivers', overseasMasterDataPermissions.driverList],
@@ -45,6 +47,17 @@ describe('overseas master data routes', () => {
}
})
it('uses external list permission for both routes independently of owned and write permissions', () => {
const external = overseasMasterDataRoutes.filter(route => route.path.includes('/outsourced-business'))
expect(external).toHaveLength(2)
for (const route of external) {
expect(route.meta?.permission).toBe(overseasMasterDataPermissions.outsourcedBusinessList)
expect(route.meta?.permissionAnyOf).toBeUndefined()
expect(route.meta?.permission).not.toBe(overseasMasterDataPermissions.ownedBusinessList)
expect(route.meta?.permission).not.toBe(overseasMasterDataPermissions.outsourcedBusinessEdit)
}
})
it('keeps vehicle detail as a route page and does not add a duplicate partner warehouse route', () => {
expect(overseasMasterDataRoutes.at(-1)?.meta?.showPageHeading).toBe(false)
expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false)
@@ -38,6 +38,18 @@ export const overseasMasterDataRoutes = [
component: () => import('./pages/OwnedBusinessDetailView.vue'),
meta: { title: '自有业务详情', description: '查看原排车事实与附加运营信息', permission: permission.ownedBusinessList, showPageHeading: false },
},
{
path: 'overseas-master-data/outsourced-business',
name: 'overseas-master-data-outsourced-business',
component: () => import('./pages/OutsourcedBusinessView.vue'),
meta: { title: '外配业务', description: '按原排车查询外配车辆装车后的履约与承运信息', permission: permission.outsourcedBusinessList, showPageHeading: false },
},
{
path: 'overseas-master-data/outsourced-business/:dispatchNo',
name: 'overseas-master-data-outsourced-business-detail',
component: () => import('./pages/OutsourcedBusinessDetailView.vue'),
meta: { title: '外配业务详情', description: '查看原排车及外配承运登记', permission: permission.outsourcedBusinessList, showPageHeading: false },
},
{
path: 'overseas-master-data/trips',
name: 'overseas-master-data-trips',
@@ -1,5 +1,131 @@
# 海外仓原型核对矩阵
## 尾程订单汇总文字隐藏(2026-10-20)
按用户截图`codex-clipboard-168f9aa5-5478-4085-a9b4-d59e7b08ca1f.png`及明确要求,移除查询区下方“仓储转运·全筛选”和“当前筛选中的退货”两段长汇总,不再挂载仅提供转运汇总的组件。订单明细、退货行维度、筛选、分页和错误反馈保持;不删交易数据或改变统计口径。原列表顶部矩阵中的“数量汇总”不再指这两段常驻文字。本页行为回归75项通过,差异检查通过;待人工截图确认。
## 2026-10-20 列表顶部紧凑收尾
依据为本轮用户整柜业务截图及“所有列表顶部紧凑”的确认;列表公共工具栏和筛选内距由 `adminWorkbench.css` 统一维护。
| 页面/操作 | 原承载与本轮调整 | 保留边界/验证 |
|---|---|---|
| 尾程订单/状态切换、查询 | 状态页签移至表格 `toolbar-start`,替代正文重复标题;查询仍在 filters | 保留方向键、Home/End、焦点、选择清空、数量汇总、高级筛选与全部业务按钮;待定向检查和人工截图确认 |
| 排车列表/执行阶段、审核分区与查询 | 主阶段页签并入顶部工具栏;装车前审核子分区保留在筛选区首部及原层级样式 | 服务端阶段数量、返回预约工作、审核分区、未知操作结果和错误重试不变;待定向检查和人工截图确认 |
| 退货库存/库存、进销存、合箱计划 | 已有 Element 页签和图标移至工具栏;筛选仍在 filters | 查询、分页、列宽偏好、创建合箱计划与所有原操作不变;无页签的退货箱号标题保留;待定向检查和人工截图确认 |
本次仅调整列表顶部插槽位置与公共类,不改变详情、新增、编辑、审核、文件预览或取消等操作承载,不修改业务脚本、API 或权限。
## 2026-10-20 候选服务端分页
| 页面/操作 | 已确认承载与依据 | 本批实现 | 验收 |
|---|---|---|---|
| 智能排车/选货分组 | 本文“智能排车在途预排与操作整理”矩阵;现行排车契约第17项的服务端分页;`SmartDispatchDialog.vue` 居中弹框与候选/装车组布局 | 候选表下显示总数、页码和10/20/50条选择;筛选与区域页签交给服务端,变化后回第一页;草稿保留跨页完整组 | 自动检查单独记录;1920×1080、100%待人工截图确认 |
| 既有排车/加货 | `DispatchWorkflowDialog.vue` 的既有1120px居中工作流弹框;同一候选分页契约 | 候选表下分页并显示已选完整组数;跨页保留选择,提交继续检查组版本和批次原始版本 | 自动检查单独记录;1920×1080、100%待人工截图确认 |
## 2026-10-20 智能排车紧凑布局复核
### 后续截图:标签、标题与底部操作区对齐
用户最新截图显示建单方式/起运仓标签偏上、候选货物与装车分组标题未处于同一高度,且按钮下方留有大块空白。共享密度样式将行内表单标签垂直居中;排车左右标题统一最小高度和居中方式;固定高度工作区的正文填充剩余空间,使页脚落在弹框底部。正文继续承担纵向滚动,表格不新增内层纵向滚动。最终效果仍待1920×1080、100%人工截图确认。
用户同时补充无草稿参数的 candidates 查询超过5秒。本轮将来源摘要中的库存、入库实收、退回实收三类查询由逐候选读取改为当前请求内按客户/托盘批读,保持原排序、客户隔离、完整来源组与来源版本;在途计划无实托时不发空查询,写入锁序保持。超过1秒的候选服务读取记录候选、组装完整组、预约引用及响应组装各阶段耗时,不记录业务身份或草稿凭据。此记录不包含网关、网络和JSON传输耗时,不能代替端到端请求测量。
最终自动检查:前端定向6文件123项通过,后端5个测试类75项通过(包括同客户批读、跨客户批读、同托客户隔离、退回实收、请求间版本变化及在途零空查);前端类型检查与构建通过。前端全量4203/4204通过,唯一失败为`AdminSidebarMenu.test.ts`的收起侧栏点击后路由期望`/warehouses`但得到`/`;该文件原样单独复核7/7通过,未修改侧栏或放宽断言,完整回归仍记录为有一次失败。完整结果见本地`.logs/smart-dispatch-alignment-20261020.json`。本轮未发布,不能据本地检查断言真实5秒问题已经消除。
### 前轮紧凑布局与滚动修正
直接依据为用户本轮截图及“对不齐、选择占太多位置、已有1/2步骤无需重复提示”的反馈,覆盖上轮对应视觉结论。
| 页面/操作 | 承载与本轮调整 | 验证 |
|---|---|---|
| 智能排车/新建、选货与空单 | 保留居中弹框、原宽度、全屏、分组及提交。第1、2步各采用标题与控件同一行的白底工具栏;起运仓/草稿240px,托位100px;候选查询采用固定业务宽度,右侧分组320px;托盘展开箭头、业务号和数量同一行。删除正文重复步骤引导,操作说明保留信息浮层,错误及空单执行限制直接显示 | 初轮定向133项及类型/构建通过;用户后续截图补充下拉框挤窄与多重滚动问题,最终回归待运行;1920×1080、100%最终页面待用户截图确认 |
用户随后反馈 candidates 约4秒及三层滚动:起运仓固定表单项宽度,启用共享工作区高度和 body 挂载/滚动锁,移除表格内部纵向限高;共享工作区上下边距对称,避免浮层总高溢出。新建空草稿和装车组不再重复读取候选;后端同页实托可排来源在当前只读事务批读,页外闭包仍补读,正式写入继续原锁内核验。接口、权限、资格与来源版本语义保持;真实接口耗时待环境复测。
本轮最终直接排车组件30项通过(真实Teleport挂载后从ElDialogContent读取,不以Teleport桩替代实际结构);共享弹框与国内测量/任务弹框消费者的定向检查通过。后端候选/完整来源/整托换标/草稿54项通过,同客户多托批读次数及请求间版本变化另3项复验通过;构建内含vue-tsc类型检查,通过。完整Admin回归4197/4200通过,另3项公共样式契约失败:`adminDensity.test.ts`的日历表单标签布局与列表分类页签检查,以及`adminWorkbench.test.ts`的工具栏布局属性归属检查;这些失败涉及本轮排车修改范围之外的日历、列表和共享工作台样式,未据此改动对应页面或放宽断言。完整结果见本地`.logs/smart-dispatch-compact-final-20261020.json`。本轮未部署,真实HTTP时延与最终截图尚未验收。
## 2026-10-20 智能排车在途预排与操作整理
用户本轮“新建排车”截图(顶部建单设置、车辆司机、草稿,底部才出现候选货物)及澄清“就是入库单的智能排车,离港后,就能排车”为本次直接依据。
| 页面 | 操作 | 依据与原承载 | 当前调整 | 验收 |
|---|---|---|---|---|
| 入库单智能排车/排车列表新建 | 新建、先选货、先建空单 | 本轮截图;`components/SmartDispatchDialog.vue` 的 `AppDialog` 居中弹框,1800px 上限、全屏、固定底部提交 | 起运仓与方式 → 选货分组 → 当前组车辆时间;开始选货串行建立草稿和首组;未选组不展示禁用资料;保留独立组设置及原请求恢复 | 前端定向121项、类型检查通过;复用同快照Admin全量4198项及构建通过结果;1920×1080、100%真实页面待人工截图确认 |
| 同一弹框 | 在途预排、草稿继续、分组与提交 | 本轮用户确认离港后可预排;现行服务端 PLAN 来源及来源版本/整组原子命令 | 显示“在途预排(已离港)”筛选与在途计划来源;显示当前操作和生成组数;候选表限高并保留全部业务列、明细及区域页签 | 后端相关53项通过,仓库归属投影补充场景14项复验通过;查询与写入核对真实离港,预约关联按计划量预排,实收后仍须原 PDA 门禁;未部署及真实HTTP验收 |
本轮自动检查全部经 `baiyue-firstleg/scripts/local-check.ps1`。前端全量结果复用当前工作区 `2026-10-20 21:12:13–21:21:15` 的实际 `vitest run --reporter=json`(4198/4198);当前组件及直接测试分别冻结于21:09:42、21:11:44,定向后未再改动。构建复用21:13:19–21:13:48同快照通过记录;本轮单独执行类型检查通过。后端验证为候选Mapper、完整来源、草稿、PDA拣货、既有FBA预约关联与预排的六组测试,53/53;随后补充已预留但无库存的起运仓投影,所属Mapper14/14复验。自动检查不替代部署、真实HTTP与人工截图。
本次不修改详情四页签、既有单据编辑/审核、文件预览及取消承载。沿用已有蓝白主题和公共密度,不新增车辆预约、日历预约或旧预排协议。
## 2026-10-20 蓝白工作台续批(写权已协调)
本次续批修改文件(相对本模块):
- `components/DispatchEvidenceFiles.vue`
- `components/DispatchWorkflowDialog.vue`
- `components/SmartDispatchDialog.vue`
- `pages/DispatchDetailView.vue`
- `pages/DispatchesView.vue`
总控已核实预约交付及排车只读预审状态,本轮授权续接展示;逐文件按当前内容快照核对后写入。依据蓝白样板与本矩阵既有逐操作承载。
| 实际入口 | 操作/承载与冻结结论 | 验收 |
|---|---|---|
| 排车列表/详情、智能排车、工作流、资源/安排和凭证弹框;保留四页签、原Gate与作业承载 | 只接入主题、公共列表/面板和局部旧外观清理;尺寸、字段、事件、权限、请求及业务计算保留 | 续批定向回归通过;待人工1920×1080、100%截图确认 |
续批验证:81个文件671项定向回归首轮669项通过,出车图表两项旧颜色期望按公共主题更新后,所属1文件5项复验通过;50个修改Vue模板与局部样式编译通过。写前、写后快照核对未发现外部漂移;所有事件/表单绑定、非图表业务脚本保留,图表仅接入配色/字体/tooltip主题。未改API、日期计算、driver、权限、状态与路由。类型检查、全量测试和构建由总控统一执行;截图均待人工确认。
## 2026-10-20 蓝白工作台展示改造
本批文件清单(相对本模块):
- `components/DeliveryDocumentsDialog.vue`
- `components/ExceptionProcessingPanel.vue`
- `components/OverseasDetailShell.vue`
- `components/OverseasReadOnlyDetail.vue`
- `components/WarehouseTransferFulfillmentPanel.vue`
- `components/WarehouseTransferReceiptPanel.vue`
- `components/WarehouseTransferReviewDialog.vue`
- `pages/CargoDiscardsView.vue`
- `pages/CustomerVisitsView.vue`
- `pages/DeliveryOrderDetailView.vue`
- `pages/DeliveryOrdersView.vue`
- `pages/ExceptionOrdersView.vue`
- `pages/InboundOrderDetailView.vue`
- `pages/InboundOrdersView.vue`
- `pages/InventoryDetailView.vue`
- `pages/InventoryTransactionsView.vue`
- `pages/InventoryView.vue`
- `pages/LastMileOrderDetailView.vue`
- `pages/LastMileOrdersView.vue`
- `pages/LocationsView.vue`
- `pages/MojiaSkuSyncView.vue`
- `pages/OutboundDetailView.vue`
- `pages/OutboundsView.vue`
- `pages/PalletDetailView.vue`
- `pages/PalletsView.vue`
- `pages/RelabelOrderDetailView.vue`
- `pages/RelabelOrdersView.vue`
- `pages/ReturnInventoryManagementView.vue`
- `pages/LastMileOrdersView.behavior.test.ts`
- `pages/prototypeContract.test.ts`
- `PROTOTYPE_MATRIX.md`
依据:[蓝白样板](../../../../../docs/界面参考/蓝白卡片式工作台风格参考.md)、本矩阵既有逐操作承载及[密度核对记录](../../../../../docs/Admin海外仓样式核对记录.md)。只调整外观,业务字段、密度、权限、请求、金额及状态均沿当前实现。
| 页面 | 操作与现有承载 | 本轮结论 | 验收 |
|---|---|---|---|
| 尾程订单、入库单、废弃单、换标、库存及流水、托盘、库位、出库、派送、异常、回访、退货库存/箱号、称重机器 | 正式列表、既有查询与行操作 | 显式主题根、公共列表外壳,保留宽表和行高;汇总使用紧凑面板 | 定向回归通过;待人工1920×1080、100%截图确认 |
| 尾程、入库、库存、托盘、换标、出库、派送详情 | 既有独立路由及页签 | 连续信息表与原字段分区保留,蓝白外观显式接入;共享详情壳按调用方选择 | 定向回归通过;待人工截图确认 |
| 上述页面的新建、编辑、审核、确认、附件和预览 | 沿下文逐操作矩阵的现有居中弹框/预览 | 浮层自身接入主题;尺寸、全屏、关闭重开和危险提示保留;打印版面保持原样 | 定向回归通过;待人工截图确认 |
| 排车列表、排车详情、智能排车、排车工作流及其资源/安排/证据组件 | 在途专项 | 首批保留;写权协调后由本次续批接入 | 见顶部续批结论 |
### 尾程商业来源展示
| 页面 | 操作 | 依据 | 承载与结论 | 验收 |
@@ -53,6 +179,11 @@
第一批桌面布局按 `1920×1080` 验收:列表使用紧凑查询区与横向宽表;详情使用连续白底、双列或三列紧凑字段分区,长表保持横向滚动。当前缺少人工页面截图,视觉结果待总控按真实页面截图确认。
本批验证:54个文件609项定向回归,首轮两项旧样式字符串断言更新后,其所属2文件96项复验通过。事件及表单绑定与改造前一致;类型检查、全量测试和构建由总控集成执行。
首批曾保留、续批已获得展示写权的文件:`pages/DispatchesView.vue`、`pages/DispatchDetailView.vue`、`components/SmartDispatchDialog.vue`、`components/DispatchWorkflowDialog.vue`、`components/DispatchArrangeFields.vue`、`components/DispatchResourceFields.vue`、`components/DispatchEvidenceFiles.vue`。预约模块开始与收尾均有在途的`../appointment-calendar/scheduleApi.ts`及`scheduleApi.test.ts`,首批未改预约页面和组件;本次续批已接入当前实际页面及表单,API仍保留原改动。
## 第二批:换标单、托盘、箱号、产品库
| 页面 | 操作 | 原型证据 | 原型承载方式与尺寸 | 实施结论 |
@@ -112,3 +243,7 @@
|---|---|---|---|---|
| 智能排车 | 退回来源选择、恢复草稿、预排 | 当前车辆异常退回与重派目标;沿`SmartDispatchDialog.vue`既有居中排车弹框 | 展示退回实收凭据号,按真实退回来源选用;草稿来源未全部恢复时保留选择、提示重试并阻止漏提交 | 组件交互覆盖恢复失败及重试后混合来源提交;1920×1080真实页面待验收 |
| 异常订单 | 详情内开始处理、上传证据、分批实收、取消/完成 | 本轮完整异常退回流程接线授权;保持 `ExceptionOrdersView.vue` 既有760px居中详情弹框 | `ExceptionProcessingPanel.vue`读取权威详情、allowedActions/version;真实托盘库位选择,未知写结果保留原键与载荷,不新增撤拣或多车拆分 | 两批实收、未知结果重试、无权限、版本冲突及切单迟到响应组件交互覆盖;待人工1920×1080/100%截图确认 |
2026-10-20 用户追加整页白底修正:整页主要列表外层workbench-table及整页详情workbench-panel显式增加is-page-surface,由公共样式移除最外圈border/radius/shadow。内部指标、图表、并列卡片、子表格线、弹框和日历左右独立面板保留;本批不改padding、脚本或承载。layoutAcceptance.test.ts仅将旧页头分隔线颜色期望改为当前workbench-border,托盘先于预约及字段列数等布局断言保留。
本次整页面层收口:40个Vue文件仅新增class modifier,逐文件内容对照与差异检查通过;layoutAcceptance.test.ts定向6项通过。文件已固定,公共样式及全量门禁由总控收口,人工视觉仍待截图确认。
@@ -65,10 +65,10 @@ describe('native whole-vehicle dispatch API', () => {
expect(vi.mocked(http.get).mock.calls.map(call => call[0])).toEqual(['/platform/v1/last-leg/dispatches/PC%2F1', '/platform/v1/last-leg/dispatches/PC%2F1/loading', '/platform/v1/last-leg/dispatches/PC%2F1/commands/key%2F1'])
})
it('reads candidates with their exact sourceVersion and repeated plan query parameters', async () => {
const data = { candidates: [candidate()], snapshotAt: '2026-10-19T10:00:00Z', complete: true }
const data = { total: 1, page: 1, size: 20, pages: 1, candidates: [candidate()], snapshotAt: '2026-10-19T10:00:00Z', complete: true }
vi.mocked(http.get).mockResolvedValueOnce({ data })
expect(await api.listDispatchCandidates({ warehouseId: '321', planIds: ['11', '12'] })).toEqual(data)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'] }, paramsSerializer: { indexes: null } })
expect(await api.listDispatchCandidates({ warehouseId: '321', planIds: ['11', '12'], page: 2, size: 20, warehouseTab: 'NYC' })).toEqual(data)
expect(http.get).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/candidates', { params: { warehouseId: '321', planIds: ['11', '12'], page: 2, size: 20, warehouseTab: 'NYC' }, paramsSerializer: { indexes: null } })
})
it('creates an empty real dispatch without an artificial customer or cargo', async () => {
const request: CreateDispatch = { idempotencyKey: 'create', warehouseId: '321', vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: null, cargo: [], batchAllocations: [] }
@@ -54,7 +54,7 @@ beforeEach(() => {
vi.mocked(api.getDispatchRoute).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: 0, edit: gate, stops: [{ index: 0, name: '起运仓', address: '起点地址', destinationKey: null }, { index: 1, name: '目的仓甲', address: '甲地址', destinationKey: 'A' }, { index: 2, name: '外接卸货点', address: '乙地址', destinationKey: 'B' }] })
vi.mocked(api.getDispatchItems).mockResolvedValue({ dispatchNo: 'PC/1', recordVersion: '9007199254740993', status: 'ACTIVE', stage: 'BEFORE_LOADING', items: [item('9223372036854775001'), item('picked', 1)] })
vi.mocked(api.listDispatches).mockResolvedValue(page())
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [sharedPalletCandidates()[0]!], complete: true, snapshotAt: '' })
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [sharedPalletCandidates()[0]!], complete: true, snapshotAt: '' })
vi.mocked(api.getDispatchArrangement).mockResolvedValue(arrangement())
vi.mocked(api.getDispatchEvidence).mockImplementation(async (_, __, id) => evidence(id))
vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
@@ -16,7 +16,7 @@ vi.mock('../api/dispatches', () => ({ getDispatch: vi.fn(), getDispatchItems: vi
interface State { submit(): Promise<void>; retryError(): Promise<void>; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
it('adds all hidden shared-pallet sources once and rejects a changed group composition before another command', async () => {
const [first, second] = sharedPalletCandidates()
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [first!], complete: true, snapshotAt: '' })
const { wrapper, state } = await open('ADD_CARGO')
const selection = wrapper.vm as unknown as { selectCargo(row: NonNullable<typeof first>, checked: boolean): void }
selection.selectCargo(first!, true)
@@ -25,6 +25,7 @@ it('adds all hidden shared-pallet sources once and rejects a changed group compo
first!.selectionGroup.complete = false; selection.selectCargo(first!, false)
await state.submit(); expect(api.addDispatchCargo).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('请选择来源完整')
first!.selectionGroup.complete = true
await (wrapper.vm as unknown as { searchCargo(): Promise<void> }).searchCargo()
selection.selectCargo(first!, true)
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit(); expect(api.addDispatchCargo).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ cargo: [first!.selection, second!.selection] }))
@@ -32,7 +33,7 @@ it('adds all hidden shared-pallet sources once and rejects a changed group compo
})
it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s while keeping the parent dispatch version independent', async recordVersion => {
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }] })
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })
const { wrapper, state } = await open('ADD_CARGO')
try {
(wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true)
@@ -43,7 +44,7 @@ it.each([0, '0', '9007199254740993'])('adds cargo with original batch version %s
})
it('blocks cargo addition when the authoritative batch version is absent', async () => {
const value = candidate({ appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion: undefined as unknown as number, sources: [] }] })
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [value], complete: true, snapshotAt: '' })
vi.mocked(api.listDispatches).mockResolvedValue(page()); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })
const { wrapper, state } = await open('ADD_CARGO')
try {
(wrapper.vm as unknown as { selectCargo(row: typeof value, checked: boolean): void }).selectCargo(value, true); await state.submit()
@@ -234,3 +235,30 @@ it('describes a proven success with a failed GET as completed and retries only i
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
it('preserves selected complete groups across pages and submits hidden sources with their original versions', async () => {
const [first, hidden] = sharedPalletCandidates(), second = candidate({ sourceKey: 'next-page' })
vi.mocked(api.listDispatches).mockResolvedValue(page())
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.page === 2 ? [second] : [first!], page: query.page!, size: query.size!, total: 21, pages: 2, complete: false, snapshotAt: '' }))
const { wrapper, state } = await open('ADD_CARGO')
try {
const selection = wrapper.vm as unknown as { selectCargo(row: typeof second, checked: boolean): void; changeCandidatePage(value: number): void; candidatePageNumber: number; candidateKeyword: string; searchCargo(): Promise<void> }
selection.selectCargo(first!, true); selection.changeCandidatePage(2); await flushPromises(); selection.selectCargo(second, true); await flushPromises()
expect(wrapper.text()).toContain('已选择 2 个完整来源组')
selection.candidateKeyword = 'next'; await selection.searchCargo()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderNo: 'next' }))
expect(api.listDispatches).toHaveBeenCalledTimes(1)
vi.mocked(api.addDispatchCargo).mockImplementation(async (_, request) => command('ADD_CARGO', request.idempotencyKey)); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit()
expect(api.addDispatchCargo).toHaveBeenCalledWith('PC/1', expect.objectContaining({ cargo: [first!.selection, hidden!.selection, second.selection] }))
} finally { wrapper.unmount() }
})
it('shows authoritative warehouse lookup failures when retrying a cargo search', async () => {
vi.mocked(api.listDispatches).mockRejectedValue(new Error('warehouse lookup unavailable'))
const { wrapper } = await open('ADD_CARGO')
try {
await (wrapper.vm as unknown as { searchCargo(): Promise<void> }).searchCargo()
expect(wrapper.text()).toContain('货物候选读取失败')
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
} finally { wrapper.unmount() }
})
@@ -1,4 +1,5 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
@@ -10,6 +11,7 @@ import LineClampText from '@/shared/components/LineClampText.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import { useDispatchCandidates } from '../useDispatchCandidates'
import type { AddCargo, ArrangeDelivery, ArrangeGroup, ArrangementPreview, Candidate, CommandResult, DispatchAction, DispatchDetail, Driver, LoadingComplete, LoadingPreview, ReasonCommand, ReviewCommand, UpdateDispatch, Vehicle, VersionCommand } from '../dispatchContracts'
import type { DispatchItems, DispatchRoute, DispatchRouteCommand, DispatchRouteStop } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
@@ -29,11 +31,16 @@ const currentAction = computed(() => pending.value?.action || props.action)
const currentNo = computed(() => pending.value?.dispatchNo || props.dispatchNo)
const currentOperation = computed(() => pending.value ? pending.value.operation : props.operation)
const items = ref<DispatchItems | null>(null), itemId = ref(''), route = ref<DispatchRoute | null>(null), stops = ref<DispatchRouteStop[]>([])
const detail = ref<DispatchDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const detail = ref<DispatchDetail | null>(null), working = ref(false), error = ref<ApiErrorDetails | null>(null)
const factsStale = ref(false)
const preview = ref<LoadingPreview | null>(null), arrangement = ref<ArrangementPreview | null>(null), groups = ref<ArrangeGroup[]>([])
const arrangeFields = ref<InstanceType<typeof DispatchArrangeFields> | null>(null)
const candidates = ref<Candidate[]>([]), selected = ref<Array<{ sourceKey: string; groupKey: string; groupVersion: string }>>([]), candidateKeyword = ref('')
const candidatePage = useDispatchCandidates(error)
const { candidates, page: candidatePageNumber, size: candidatePageSize, total: candidateTotal } = candidatePage
const loading = computed(() => working.value || candidatePage.loading.value)
const selected = ref<Array<{ sourceKey: string; groupKey: string; groupVersion: string; row: Candidate }>>([]), candidateKeyword = ref('')
const candidateWarehouseId = ref('')
let candidateFilterKey = ''
const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; palletSpaces: number | null; weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null }>>([])
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
@@ -49,9 +56,10 @@ const uploadingEvidence = computed(() => evidenceBusy.VEHICLE || evidenceBusy.DR
async function load() {
const sequence = ++generation, no = currentNo.value, action = currentAction.value
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { loading.value = false; return }
loading.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
if (editorRequestKey && pending.value?.body.idempotencyKey === editorRequestKey && detail.value?.dispatchNo === no) { working.value = false; return }
working.value = true; factsStale.value = false; error.value = null; detail.value = null; preview.value = null; arrangement.value = null; selected.value = []
items.value = null; itemId.value = ''; route.value = null; stops.value = []
candidatePage.reset(); candidateWarehouseId.value = ''
try {
if (action === 'CREATE') throw new DispatchInputError('请从排车列表的原请求入口恢复新建排车')
const value = await api.getDispatch(no)
@@ -96,20 +104,32 @@ async function load() {
}
if (action === 'ADD_CARGO') await loadCandidates(sequence)
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车操作资料读取失败') }
finally { if (sequence === generation) loading.value = false }
finally { if (sequence === generation) working.value = false }
}
async function loadCandidates(sequence = generation) {
// Detail has no warehouse field: resolve the authoritative list row by this business number.
const page = await api.listDispatches({ page: 1, size: 1, dispatchNo: currentNo.value, status: 'ACTIVE' })
const row = page.records.find(item => item.dispatchNo === currentNo.value)
if (!row?.warehouseId) throw new DispatchInputError('当前排车起运仓未返回,请刷新核对')
const result = await api.listDispatchCandidates({ warehouseId: row.warehouseId, orderNo: candidateKeyword.value || undefined })
if (!candidateWarehouseId.value) {
// Detail has no warehouse field: resolve the authoritative list row by this business number.
const no = currentNo.value
const result = await api.listDispatches({ page: 1, size: 1, dispatchNo: no, status: 'ACTIVE' })
if (sequence !== generation) return
const row = result.records.find(item => item.dispatchNo === no)
if (!row?.warehouseId) throw new DispatchInputError('当前排车起运仓未返回,请刷新核对')
candidateWarehouseId.value = row.warehouseId
}
if (sequence !== generation) return
if (result.complete !== true) throw new DispatchInputError('候选来源未完整返回,请缩小范围重新查询')
if (result.candidates.some(source => source.warehouseId !== row.warehouseId || !source.sourceVersion || source.selection.sourceVersion !== source.sourceVersion)) throw new DispatchInputError('货物起运仓或来源版本不完整,请重新读取')
candidates.value = result.candidates
const filterKey = JSON.stringify([candidateWarehouseId.value, candidateKeyword.value])
if (filterKey !== candidateFilterKey) { candidatePageNumber.value = 1; candidateFilterKey = filterKey }
await candidatePage.load({ warehouseId: candidateWarehouseId.value, orderNo: candidateKeyword.value || undefined, page: candidatePageNumber.value, size: candidatePageSize.value, warehouseTab: 'ALL' })
}
async function searchCargo() { if (frozen.value) return; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } }
async function searchCargo() {
if (frozen.value) return
const sequence = generation
candidatePageNumber.value = 1
try { await loadCandidates(sequence) }
catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') }
}
function changeCandidatePage(value: number) { candidatePageNumber.value = value; void loadCandidates() }
function changeCandidateSize(value: number) { candidatePageSize.value = value; void searchCargo() }
function requirePlanPallet(row: Candidate): string {
if (!row.selection.planPalletId) throw new DispatchInputError('批次分配缺少真实计划来源,请核对')
@@ -121,7 +141,7 @@ function selectCargo(row: Candidate, checked: unknown) {
if (checked !== true) { selected.value = selected.value.filter(value => value.groupKey !== row.selectionGroup?.groupKey && value.sourceKey !== row.sourceKey); return }
const group = requireSelectionGroup(row)
selected.value = selected.value.filter(value => value.groupKey !== group.groupKey && value.sourceKey !== row.sourceKey)
if (checked === true) selected.value.push({ sourceKey: row.sourceKey, groupKey: group.groupKey, groupVersion: group.groupVersion })
if (checked === true) selected.value.push({ sourceKey: row.sourceKey, groupKey: group.groupKey, groupVersion: group.groupVersion, row })
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '完整选货组无效') }
}
function payload(): object {
@@ -141,12 +161,13 @@ function payload(): object {
case 'EDIT': return { ...common, vehicle: vehicle.value, driver: driver.value, plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
case 'ADD_CARGO': {
const chosen = selected.value.map(value => {
const row = candidates.value.find(candidate => candidate.sourceKey === value.sourceKey)
const row = value.row
if (!row || row.selectionGroup?.groupKey !== value.groupKey || row.selectionGroup.groupVersion !== value.groupVersion) throw new DispatchInputError('选货组组成已变化,请重新读取并选择货物')
return row
})
candidatePage.assertCurrent(chosen)
requireBatchVersions(chosen.flatMap(row => row.appointmentBatches))
if (!chosen.length || chosen.some(row => row.blockReasons.length || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('请选择来源完整、预约客户一致且可排的货物')
if (!chosen.length || chosen.some(row => row.warehouseId !== candidateWarehouseId.value || row.blockReasons.length || !row.sourceVersion || row.sourceVersion !== row.selection.sourceVersion || row.appointmentBatches.some(batch => batch.customerId !== row.customerId))) throw new DispatchInputError('请选择来源完整、预约客户一致且可排的货物')
return { ...common, cargo: expandSelectionGroups(chosen), batchAllocations: chosen.flatMap(row => row.appointmentBatches.flatMap(batch => batch.sources.map(source => ({ planPalletId: requirePlanPallet(row), batchKey: batch.batchKey, customerId: batch.customerId, ...source, recordVersion: batch.recordVersion })))) } satisfies AddCargo
}
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
@@ -220,7 +241,7 @@ async function submit() {
}
async function refreshFacts() {
const sequence = ++generation, no = currentNo.value
loading.value = true; factsStale.value = true
working.value = true; factsStale.value = true
try {
const value = await api.getDispatch(no)
if (sequence !== generation) return
@@ -237,14 +258,14 @@ async function refreshFacts() {
} catch (cause) {
// Retain the original rejection and every editor value if its follow-up read also fails.
if (sequence === generation && !error.value) error.value = getApiErrorDetails(cause, '当前操作资格读取失败,请重试刷新')
} finally { if (sequence === generation) loading.value = false }
} finally { if (sequence === generation) working.value = false }
}
async function retryError() { if (pending.value) await submit(); else if (detail.value) await refreshFacts(); else await load() }
function moveStop(index: number, offset: number) { const target = index + offset; if (frozen.value || target < 0 || target >= stops.value.length) return; [stops.value[index], stops.value[target]] = [stops.value[target]!, stops.value[index]!] }
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else generation++ }, { immediate: true })
watch([open, () => props.dispatchNo, () => props.action, () => props.operation], ([value]) => { if (value) void load(); else { generation++; candidatePage.reset() } }, { immediate: true })
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<AppDialog v-model="open" class="admin-density-dialog admin-density admin-workbench is-dialog" :title="(currentOperation === 'ROUTE' ? '调整卸货顺序' : dispatchActionLabels[currentAction]) + ' · ' + (currentNo || '—')" :width="width" :fullscreenable="width !== '560px'" :loading="loading" :confirm-loading="busy || uploadingEvidence" :confirm-disabled="loading || uploadingEvidence || !pending && !allowed" :confirm-text="pending?.result ? '刷新页面资料' : pending ? '核对并重试原请求' : '确认提交'" @confirm="submit">
<div class="density-dialog-sections">
<el-alert v-if="pending" role="status" :title="pending.result ? '操作已完成,页面资料待刷新;刷新不会再次提交。' : '原操作结果尚待核对,当前使用原版本和原请求内容。'" :type="pending.result ? 'info' : 'warning'" :closable="false" show-icon />
<LoadErrorState v-if="error" :title="pending?.result ? '操作已完成,页面资料待刷新' : '排车操作未完成'" :message="error.message" :loading="loading || busy || uploadingEvidence" :retry-text="pending?.result ? '刷新页面资料' : pending ? '核对原请求' : '刷新当前资料'" @retry="retryError"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
@@ -272,6 +293,8 @@ watch([open, () => props.dispatchNo, () => props.action, () => props.operation],
<section v-else-if="currentAction === 'ADD_CARGO'" class="density-dialog-section" data-tone="source" aria-label="本车添加货物"><h3 class="density-dialog-section__title">选择完整货物组</h3>
<div class="workflow-cargo-query"><el-input v-model="candidateKeyword" :disabled="frozen || loading || uploadingEvidence || !allowed" placeholder="订单号/入仓码" /><el-button :disabled="frozen || loading || uploadingEvidence || !allowed" @click="searchCargo">查询</el-button></div>
<el-table :data="candidates" border><el-table-column label="选择" width="70" fixed="left"><template #default="{ row }"><el-checkbox :model-value="isCargoSelected(row as Candidate)" @change="selectCargo(row as Candidate, $event)" :disabled="frozen || loading || uploadingEvidence || !allowed || (!isCargoSelected(row as Candidate) && Boolean(selectionGroupBlockReason(row as Candidate)))" :aria-label="'选择货物 ' + (row.palletNo || row.orderNo || '待核实')" /></template></el-table-column><el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip /><el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip /><el-table-column label="托盘与完整来源组" min-width="240"><template #default="{ row }"><LineClampText :content="row.palletNo || '—'" /><details class="workflow-source-group"><summary>查看完整来源</summary><ul><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details><span>{{ selectionGroupBlockReason(row as Candidate) }}</span></template></el-table-column><el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ row.destination.warehouseCode || row.destination.address || '—' }}</template></el-table-column></el-table>
<el-pagination aria-label="加货候选分页" :current-page="candidatePageNumber" :page-size="candidatePageSize" :page-sizes="[10, 20, 50]" :total="candidateTotal" :disabled="frozen || loading || !allowed" layout="total, sizes, prev, pager, next" @update:current-page="changeCandidatePage" @update:page-size="changeCandidateSize" />
<p class="density-dialog-note">已选择 {{ selected.length }} 个完整来源组(含其他页)</p>
</section>
<template v-else-if="currentAction === 'LOADING_COMPLETE' && preview">
<el-alert :title="preview.loadingComplete.blockReason || 'PDA真实拣货已核平,请核对每托实际装载。'" type="info" :closable="false" show-icon />
@@ -37,7 +37,7 @@ beforeEach(() => {
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
vi.mocked(listAllPlatformWarehouses).mockResolvedValue([warehouse])
vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()])
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: draft().sources, complete: true, snapshotAt: '' })
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: draft().sources, complete: true, snapshotAt: '' })
vi.mocked(api.createDispatch).mockImplementation(async body => command('CREATE', body.idempotencyKey))
vi.mocked(api.convertDispatchDraft).mockImplementation(async body => ({ draftToken: body.draftToken, replayed: false, vehicles: body.groups.map((group, index) => ({ draftContainerId: group.draftContainerId, dispatch: command('CREATE', group.dispatch.idempotencyKey, `PC/${index + 1}`) })) }))
vi.mocked(api.getDispatch).mockImplementation(async no => detail({ dispatchNo: no, recordVersion: 1 }))
@@ -45,10 +45,11 @@ beforeEach(() => {
vi.mocked(listDrivers).mockResolvedValue({ records: [driverRecord], page: 1, size: 100, total: 1, pages: 1, summary: { total: 1, owned: 1, dispatched: 0, expiringSoon: 0, expired: 0 } })
})
async function render() { const wrapper = mount(SmartDispatchDialog, { attachTo: document.body, props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }); await flushPromises(); return { wrapper, dialog: wrapper.findComponent(AppDialog) } }
function view(dialog: VueWrapper) { return dialog.getComponent({ name: 'ElDialogContent' }) }
function field(dialog: VueWrapper, label: string) { return dialog.findAllComponents({ name: 'ElFormItem' }).find(item => item.props('label') === label)! }
async function selectWarehouse(dialog: VueWrapper) { const select = field(dialog, '起运仓').getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', '321'); select.vm.$emit('change', '321'); await flushPromises() }
async function emptyMode(dialog: VueWrapper) { field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'empty'); await flushPromises() }
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '显式恢复已有草稿')!.vm.$emit('change', 'server-token'); await flushPromises() }
async function restore(dialog: VueWrapper) { dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '继续已有草稿')!.vm.$emit('change', 'server-token'); await flushPromises() }
async function externalResources(dialog: VueWrapper, plate: string, name: string) {
for (const label of ['车辆来源', '司机来源']) { const select = field(dialog, label).getComponent({ name: 'ElSelect' }); select.vm.$emit('update:modelValue', 'EXTERNAL'); select.vm.$emit('change', 'EXTERNAL'); await flushPromises() }
await field(dialog, '车牌号').get('input').setValue(plate)
@@ -62,11 +63,57 @@ async function externalResources(dialog: VueWrapper, plate: string, name: string
}
describe('new dispatch native layout', () => {
it('starts cargo with one explicit action, creates the first group and enables submission only after cargo is assigned', async () => {
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
const grouped: DispatchDraft = { ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] }
const planned = candidate({ selection: { ...candidate().selection, warehousePalletId: null } })
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
vi.mocked(api.createDispatchDraftContainer).mockResolvedValue(grouped)
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [planned], complete: true, snapshotAt: '' })
vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue({ ...grouped, recordVersion: 2, sources: [planned], containers: [{ ...grouped.containers[0]!, sourceKeys: [planned.sourceKey] }] })
const { dialog } = await render()
const start = () => view(dialog).findAll('button').find(button => button.text() === '开始选货')!
expect(start().attributes('disabled')).toBeDefined()
expect(api.createDispatchDraft).not.toHaveBeenCalled()
await selectWarehouse(dialog)
expect(view(dialog).text()).toContain('在途计划')
const candidateCalls = vi.mocked(api.listDispatchCandidates).mock.calls.length
await start().trigger('click'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledExactlyOnceWith({ warehouseId: '321', idempotencyKey: expect.any(String) })
expect(api.createDispatchDraftContainer).toHaveBeenCalledExactlyOnceWith({ draftToken: 'server-token', recordVersion: 0, capacity: 30, idempotencyKey: expect.any(String) })
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(candidateCalls)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(true)
expect(dialog.props('confirmDisabled')).toBe(true)
await view(dialog).findAll('button').find(button => button.text() === '加入本组')!.trigger('click'); await flushPromises()
expect(api.lockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ draftContainerId: 'first-group', source: planned.selection, recordVersion: 1 }))
expect(dialog.props('confirmDisabled')).toBe(false)
expect(view(dialog).text()).toContain('1 组有货')
expect(view(dialog).findAll('button').find(button => button.text() === '已加入第1组')!.attributes('disabled')).toBeDefined()
})
it('retains the exact first-group command after an unknown result without creating a second draft', async () => {
const empty: DispatchDraft = { ...draft(), containers: [], sources: [] }
vi.mocked(api.createDispatchDraft).mockResolvedValue(empty)
vi.mocked(api.createDispatchDraftContainer).mockRejectedValueOnce(new Error('network unknown')).mockResolvedValueOnce({ ...empty, recordVersion: 1, containers: [{ draftContainerId: 'first-group', sequenceNo: 1, capacity: 30, sourceKeys: [] }] })
const { dialog } = await render(); await selectWarehouse(dialog)
await view(dialog).findAll('button').find(button => button.text() === '开始选货')!.trigger('click'); await flushPromises()
const original = vi.mocked(api.createDispatchDraftContainer).mock.calls[0]![0]
expect(dialog.props('confirmDisabled')).toBe(false)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(api.createDispatchDraftContainer).toHaveBeenNthCalledWith(2, original)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(true)
expect(api.convertDispatchDraft).not.toHaveBeenCalled()
})
it('requires an active cargo group before accepting per-group settings while keeping setup controls available', async () => {
const { dialog } = await render()
expect(dialog.text()).toContain('请先选择装车组')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
expect(field(dialog, '备注').get('input').attributes('disabled')).toBeDefined()
expect(view(dialog).find('[aria-label="排车设置"] table').exists()).toBe(false)
expect(view(dialog).find('[aria-label="排车设置"] .density-dialog-note').exists()).toBe(false)
expect(view(dialog).classes()).toContain('admin-density-workspace')
expect(dialog.getComponent({ name: 'ElDialog' }).props()).toMatchObject({ appendToBody: true, lockScroll: true })
expect(view(dialog).text()).not.toContain('点击“开始选货”建立第一组')
expect(view(dialog).text()).not.toContain('选择起运仓后点击')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(dialog.props('confirmDisabled')).toBe(true)
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
await restore(dialog)
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(false)
@@ -81,13 +128,13 @@ describe('new dispatch native layout', () => {
field(dialog, '预计到达时间').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02T18:00:00Z')
await field(dialog, '备注').get('input').setValue('本趟备注')
expect(state.vehicle.plate, 'parent vehicle after dates and remark').toBe('CA-EXTERNAL')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
await view(dialog).get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
expect(state.vehicle.plate, 'parent vehicle after fullscreen').toBe('CA-EXTERNAL')
expect(dialog.getComponent(DispatchResourceFields).props('continuous')).toBe(true)
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
expect(dialog.text()).toContain('空单暂不能核平、装车或发车')
expect(dialog.find('[aria-label="排车候选货物"]').exists()).toBe(false)
await dialog.findAll('button').find(button => button.text() === '保存空排车单')!.trigger('click'); await flushPromises()
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), warehouseId: '321', cargo: [], batchAllocations: [], plannedDepartureAt: '2026-11-02T10:00:00Z', estimatedArrivalAt: '2026-11-02T18:00:00Z', remark: '本趟备注', vehicle: { mode: 'EXTERNAL', vehicleId: null, plate: 'CA-EXTERNAL', carrierName: '本趟承运公司', capacity: { palletSpaces: 17, maxLoadKg: 15000, maxVolumeCbm: 62 }, evidenceFileIds: [] }, driver: { mode: 'EXTERNAL', driverId: null, name: '临时司机', phone: '555-0102', evidence: { licenseNo: 'EXTERNAL-LICENSE', licenseExpiresOn: '2030-01-01', fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } })
})
it('queries real master directories and keeps business labels and task-end evidence in the command', async () => {
@@ -101,7 +148,7 @@ describe('new dispatch native layout', () => {
driverSelect.vm.$emit('update:modelValue', driverRecord.id); driverSelect.vm.$emit('change', driverRecord.id); await flushPromises()
expect(listVehicles).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', status: 'IN_SERVICE' })
expect(listDrivers).toHaveBeenCalledWith({ page: 1, size: 100, keyword: '', region: 'OVERSEAS', active: true, reviewStatus: 'APPROVED', expectedEnd: '2026-11-03T18:00:00Z' })
expect(dialog.text()).not.toContain(vehicleRecord.id)
expect(view(dialog).text()).not.toContain(vehicleRecord.id)
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.createDispatch).toHaveBeenCalledWith(expect.objectContaining({ vehicle: { mode: 'MASTER', vehicleId: vehicleRecord.id, plate: 'CA-MASTER', carrierName: null, capacity: null, evidenceFileIds: [] }, driver: { mode: 'MASTER', driverId: driverRecord.id, name: '档案司机', phone: '555-0101', evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: '2026-11-03T18:00:00Z' } } }))
})
@@ -109,13 +156,13 @@ describe('new dispatch native layout', () => {
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 externalResources(dialog, 'CA-A', '司机甲'); await field(dialog, '备注').get('input').setValue('第一组')
const radios = dialog.get('[aria-label="排车装车组"]').findAllComponents({ name: 'ElRadio' })
const radios = view(dialog).get('[aria-label="排车装车组"]').findAllComponents({ name: 'ElRadio' })
radios[1]!.vm.$emit('update:modelValue', 'container-2'); await flushPromises()
await externalResources(dialog, 'CA-B', '司机乙'); await field(dialog, '备注').get('input').setValue('第二组')
radios[0]!.vm.$emit('update:modelValue', 'container-1'); await flushPromises()
expect((field(dialog, '车牌号').get('input').element as HTMLInputElement).value).toBe('CA-A')
expect((field(dialog, '备注').get('input').element as HTMLInputElement).value).toBe('第一组')
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
await view(dialog).get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
const request = vi.mocked(api.convertDispatchDraft).mock.calls[0]![0]
@@ -123,7 +170,7 @@ describe('new dispatch native layout', () => {
expect(request.groups.map(group => [group.dispatch.vehicle?.plate, group.dispatch.driver?.name, group.dispatch.remark])).toEqual([['CA-A', '司机甲', '第一组'], ['CA-B', '司机乙', '第二组']])
expect(request.groups.map(group => group.dispatch.vehicle?.capacity?.palletSpaces)).toEqual([17, 17])
expect(wrapper.emitted('created')).toBeUndefined()
expect(dialog.text()).toContain('草稿操作结果未确认,原请求已保留')
expect(view(dialog).text()).toContain('草稿操作结果未确认,原请求已保留')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.convertDispatchDraft).toHaveBeenCalledTimes(1)
expect(wrapper.emitted('created')).toHaveLength(1)
@@ -138,22 +185,23 @@ describe('new dispatch native layout', () => {
expect(dialog.findComponent({ name: 'ElDialog' }).props()).toMatchObject({ showClose: false, closeOnPressEscape: false })
expect(field(dialog, '车牌号').get('input').attributes('disabled')).toBeDefined()
reject(new ApiResponseError('转换结果尚未确认', 'A0503', 503, null, 'new-dispatch-request')); await flushPromises()
expect(dialog.text()).toContain('new-dispatch-request')
expect(view(dialog).text()).toContain('new-dispatch-request')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
dialog.vm.$emit('confirm'); await flushPromises()
expect(vi.mocked(api.convertDispatchDraft).mock.calls[1]![0]).toEqual(original)
})
it('keeps all candidate rows and complete source details in the body with shared ellipsis components', async () => {
const rows = Array.from({ length: 25 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) }))
const rows = Array.from({ length: 20 }, (_, index) => candidate({ sourceKey: `SOURCE-${index}`, palletNo: `PALLET-${index}`, orderNo: `ORDER-${index}`, customerName: '很长的客户名称'.repeat(8) }))
rows[0] = sharedPalletCandidates()[0]!
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: rows, complete: true, snapshotAt: '' })
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 20, page: 1, size: 20, pages: 1, candidates: rows, complete: true, snapshotAt: '' })
const { dialog } = await render(); await selectWarehouse(dialog)
const table = dialog.get('[aria-label="排车候选货物"]')
expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(25)
expect(table.text()).toContain('PALLET-24')
const table = view(dialog).get('[aria-label="排车候选货物"]')
expect(table.findAll('.el-table__body-wrapper .el-table__row')).toHaveLength(20)
expect(table.text()).toContain('PALLET-19')
expect(dialog.findComponent({ name: 'ElTable' }).props('height')).toBeUndefined()
expect(dialog.findAllComponents(LineClampText).length).toBeGreaterThanOrEqual(50)
expect(dialog.findComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined()
expect(dialog.findAllComponents(LineClampText).length).toBeGreaterThanOrEqual(40)
const sourceGroup = table.findAll('details')[0]!
await sourceGroup.get('summary').trigger('click'); await flushPromises()
expect(sourceGroup.element.hasAttribute('open')).toBe(true)
@@ -162,22 +210,23 @@ describe('new dispatch native layout', () => {
expect(sourceGroup.text()).toContain('客户B')
expect(api.lockDispatchDraftPallet).not.toHaveBeenCalled()
})
it('keeps query conditions and regional tabs on the original candidate transport', async () => {
it.each(['PLAN', 'RELABEL'])('keeps %s query conditions and regional tabs on the original candidate transport', async sourceStage => {
const { dialog } = await render(); await selectWarehouse(dialog)
await dialog.get('input[placeholder="订单号/入仓码"]').setValue('IN-QUERY')
await dialog.get('input[placeholder="目的仓码"]').setValue('ONT8')
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', 'RELABEL'); await flushPromises()
await dialog.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage: 'RELABEL', draftToken: undefined })
await view(dialog).get('input[placeholder="订单号/入仓码"]').setValue('IN-QUERY')
await view(dialog).get('input[placeholder="目的仓码"]').setValue('ONT8')
dialog.findAllComponents({ name: 'ElSelect' }).find(select => select.props('placeholder') === '全部来源')!.vm.$emit('update:modelValue', sourceStage); await flushPromises()
await view(dialog).findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith({ page: 1, size: 20, warehouseTab: 'ALL', warehouseId: '321', orderNo: 'IN-QUERY', destinationWarehouseCode: 'ONT8', sourceStage, draftToken: undefined })
const calls = vi.mocked(api.listDispatchCandidates).mock.calls.length
await dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
expect(dialog.get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls)
await view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.trigger('click'); await flushPromises()
expect(view(dialog).get('[aria-label="候选仓库展示"]').findAll('button').find(button => button.text() === '纽约')!.attributes('aria-selected')).toBe('true')
expect(api.listDispatchCandidates).toHaveBeenCalledTimes(calls + 1)
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, warehouseTab: 'NYC', orderNo: 'IN-QUERY', sourceStage }))
})
it('shows loading and initialization diagnostics without issuing create commands', async () => {
vi.mocked(api.listDispatchDrafts).mockRejectedValueOnce(new ApiResponseError('草稿加载失败', 'A0503', 503, null, 'draft-load-request')).mockResolvedValueOnce([draft()])
const { dialog } = await render()
expect(dialog.text()).toContain('draft-load-request')
expect(view(dialog).text()).toContain('draft-load-request')
dialog.findComponent(LoadErrorState).vm.$emit('retry'); await flushPromises()
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.createDispatchDraft).not.toHaveBeenCalled()
@@ -185,13 +234,13 @@ describe('new dispatch native layout', () => {
it('requires the explicit recheck action after a definite rejection without automatically resubmitting', async () => {
vi.mocked(api.createDispatchDraft).mockRejectedValue(new ApiResponseError('草稿条件已变化', 'A0409', 409, null, 'draft-rejected-request'))
const { dialog } = await render(); await selectWarehouse(dialog)
await dialog.findAll('button').find(button => button.text() === '新建草稿')!.trigger('click'); await flushPromises()
expect(dialog.text()).toContain('draft-rejected-request')
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
await dialog.findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
await view(dialog).findAll('button').find(button => button.text() === '开始选货')!.trigger('click'); await flushPromises()
expect(view(dialog).text()).toContain('draft-rejected-request')
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
await view(dialog).findAll('button').find(button => button.text() === '按明确驳回结果重新核对')!.trigger('click'); await flushPromises()
expect(api.createDispatchDraft).toHaveBeenCalledTimes(1)
expect(field(dialog, '建单方式').getComponent({ name: 'ElRadioGroup' }).props('disabled')).toBe(false)
expect(dialog.getComponent(DispatchResourceFields).props('disabled')).toBe(true)
expect(dialog.findComponent(DispatchResourceFields).exists()).toBe(false)
expect(api.listDispatchDrafts).toHaveBeenCalledTimes(2)
})
it('keeps creation unavailable without its write permission', async () => {
@@ -20,7 +20,7 @@ const source = () => candidate(), draft = (): DispatchDraft => ({ draftToken: 's
it('converts a filtered relabel group with both authoritative sources and locks it through one HTTP command', async () => {
const [first, second] = sharedPalletCandidates(), original = draft()
original.sources = [first!]; original.containers[0]!.sourceKeys = [first!.sourceKey]
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [first!], complete: true, snapshotAt: '' })
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [first!], complete: true, snapshotAt: '' })
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original]); vi.mocked(api.lockDispatchDraftPallet).mockResolvedValue(original)
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()
@@ -41,7 +41,7 @@ it('allows one atomic removal of a restored group that is no longer selectable',
expect(api.unlockDispatchDraftPallet).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ source: first.selection })); wrapper.unmount()
})
const options = { props: { modelValue: true, orders: [] }, global: { plugins: [ElementPlus] } }
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); identity.id = crypto.randomUUID(); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); vi.mocked(listAllPlatformWarehouses).mockResolvedValue([{ id: '321', warehouseName: '洛杉矶仓' }] as Awaited<ReturnType<typeof listAllPlatformWarehouses>>); vi.mocked(api.listDispatchDrafts).mockResolvedValue([draft()]); vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 1, page: 1, size: 20, pages: 1, candidates: [source()], complete: true, snapshotAt: '2026-10-19T00:00:00Z' }); vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 })) })
it.each([0, '0', '9007199254740993'])('transmits original authoritative batch version %s in complete draft conversion and exact retries', async recordVersion => {
const original = draft()
original.sources[0]!.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: 'C1', recordVersion, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 2 }] }]
@@ -65,7 +65,7 @@ it.each([undefined, null, 'bad', 9007199254740992])('requires rereading a restor
try {
await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); await state.submit()
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约批次版本缺失或无效,请重新读取候选')
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('预约批次版本缺失或无效,请重新读取候选')
} finally { wrapper.unmount() }
})
it('requires an explicit warehouse and allows saving a true empty dispatch with no fake cargo', async () => {
@@ -94,11 +94,11 @@ it('retains conversion payload and key after an unknown result instead of rebuil
})
it('does not discard draft selections when recovery fails and never submits an incomplete source list', async () => {
vi.mocked(api.listDispatchDrafts).mockRejectedValue(new Error('draft unavailable'))
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); expect(wrapper.text()).toContain('排车候选或草稿恢复失败'); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('排车候选或草稿恢复失败'); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); wrapper.unmount()
})
it('refuses an incomplete candidate page without pretending its filtered rows are all available cargo', async () => {
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ candidates: [], complete: false, snapshotAt: '' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.text()).toContain('尚未完整返回'); wrapper.unmount()
it('accepts a partial candidate page without requiring the filtered whole set', async () => {
vi.mocked(api.listDispatchCandidates).mockResolvedValue({ total: 21, page: 1, size: 20, pages: 2, candidates: [source()], complete: false, snapshotAt: '' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises(); const state = wrapper.vm as unknown as { restoreDraft: (token: string) => void }; state.restoreDraft('server-token'); await flushPromises(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).not.toContain('尚未完整返回'); wrapper.unmount()
})
it('keeps vehicle assignments independent between containers and reuses a proven conversion after a detail read fails', async () => {
const original = draft(); original.containers.push({ draftContainerId: 'container-B', sequenceNo: 2, capacity: 15, sourceKeys: ['source-B'] }); original.sources.push(candidate({ sourceKey: 'source-B', customerId: 'B' }))
@@ -119,12 +119,31 @@ it('never sends an existing departure command through the empty creation transpo
useDispatchCommandRecovery(() => identity.id).begin('PC/1', 'DEPART', { recordVersion: 0, idempotencyKey: 'original-key' })
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
await (wrapper.vm as unknown as { submit(): Promise<void> }).submit()
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('从列表的原请求恢复入口'); wrapper.unmount()
expect(api.createDispatch).not.toHaveBeenCalled(); expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('从列表的原请求恢复入口'); wrapper.unmount()
})
it('rejects a restored source whose batch customer differs from its real cargo owner', async () => {
const original = draft(); original.sources[0]!.appointmentBatches = [{ batchKey: 'batch-1', batchNo: 'BATCH-1', recordVersion: 0, customerId: 'other-customer', sources: [{ sourceKey: 'source-1', destinationKey: 'CODE:ONT8', packageCount: 2 }] }]
vi.mocked(api.listDispatchDrafts).mockResolvedValue([original])
const wrapper = mount(SmartDispatchDialog, options); await flushPromises()
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; submit(): Promise<void> }; state.restoreDraft('server-token'); await flushPromises(); await state.submit()
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('预约客户不一致'); wrapper.unmount()
expect(api.convertDispatchDraft).not.toHaveBeenCalled(); expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('预约客户不一致'); wrapper.unmount()
})
it('keeps server draft cargo across pages, resets filters and sends regional tabs to the server', async () => {
vi.mocked(api.listDispatchCandidates).mockImplementation(async query => ({ candidates: query.page === 2 ? [] : [source()], complete: false, snapshotAt: '', total: 21, page: query.page!, size: query.size!, pages: Math.ceil(21 / query.size!) }))
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()
try {
const state = wrapper.vm as unknown as { restoreDraft(token: string): void; changePage(page: number): void; changeTab(tab: string): void; changeSize(size: number): void; query: { orderNo: string }; search(): Promise<void>; submit(): Promise<void> }
state.restoreDraft('server-token'); await flushPromises(); state.changePage(2); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2, size: 20 }))
state.query.orderNo = 'query'; await state.search()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, orderNo: 'query' }))
state.changeTab('NYC'); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, warehouseTab: 'NYC' }))
state.changeSize(10); await flushPromises()
expect(api.listDispatchCandidates).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10 }))
state.changePage(2); await flushPromises(); await state.submit()
expect(api.convertDispatchDraft).toHaveBeenCalledWith(expect.objectContaining({ groups: [expect.objectContaining({ dispatch: expect.objectContaining({ cargo: [source().selection] }) })] }))
} finally { wrapper.unmount() }
})
@@ -1,6 +1,8 @@
<script setup lang="ts">
import '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import { computed, reactive, ref, watch } from 'vue'
import { ChevronRight } from '@lucide/vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
@@ -13,13 +15,14 @@ import LineClampText from '@/shared/components/LineClampText.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import * as api from '../api/dispatches'
import { useDispatchCandidates } from '../useDispatchCandidates'
import type { Candidate, ConvertedDraft, ConvertDraft, CreateDispatch, DispatchDraft, Driver, DraftSelection, Vehicle, VersionCommand } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, assertSelectionGroupVersions, selectionGroupBlockReason } from '../dispatchContracts'
import { DispatchInputError, requireVersion, requireBatchVersions, requireSelectionGroup, expandSelectionGroups, selectionGroupBlockReason } from '../dispatchContracts'
import { dispatchActionPermissions } from '../dispatchPresentation'
import { useDispatchCommandRecovery, validateCurrentDispatch, isDefiniteDispatchRejection } from '../dispatchCommandRecovery'
import type { InboundOrderListItem } from '../types'
import DispatchResourceFields from './DispatchResourceFields.vue'
import { matchesSmartDispatchWarehouseTab, type SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
import type { SmartDispatchWarehouseTab } from '../smartDispatchWarehouseTab'
const props = defineProps<{ modelValue: boolean; orders: InboundOrderListItem[] }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean]; created: [] }>()
@@ -31,10 +34,12 @@ const pending = ref<DraftCommand | null>(null)
if (owner && sessionStorage.getItem(storageKey)) pending.value = JSON.parse(sessionStorage.getItem(storageKey)!) as DraftCommand
const mode = ref<'cargo' | 'empty'>('cargo'), warehouseId = ref(''), warehouses = ref<PlatformWarehouseResource[]>([])
const draft = ref<DispatchDraft | null>(null), drafts = ref<DispatchDraft[]>([]), activeContainerId = ref(''), capacity = ref(30)
const loading = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null), candidates = ref<Candidate[]>([])
const working = ref(false), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
const candidatePage = useDispatchCandidates(error)
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 visibleCandidates = computed(() => candidates.value.filter(row => matchesSmartDispatchWarehouseTab(row, warehouseTab.value)))
const settings = reactive<Record<string, { form: { plannedDepartureAt: string; estimatedArrivalAt: string; remark: string }; vehicle: Vehicle; driver: Driver }>>({})
const currentSettings = computed(() => {
const key = mode.value === 'empty' ? 'empty' : activeContainerId.value || 'unselected'
@@ -44,17 +49,34 @@ const currentSettings = computed(() => {
const form = computed(() => currentSettings.value.form)
const vehicle = computed({ get: () => currentSettings.value.vehicle, set: (value: Vehicle) => { currentSettings.value.vehicle = value } })
const driver = computed({ get: () => currentSettings.value.driver, set: (value: Driver) => { currentSettings.value.driver = value } })
const frozen = computed(() => saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const frozen = computed(() => !canCreate.value || saving.value || Boolean(pending.value) || Boolean(recovery.pending.value))
const canCreate = computed(() => access.can(dispatchActionPermissions.CREATE))
const planIds = computed(() => [...new Set(props.orders.map(row => row.planId).filter(Boolean))])
const currentContainer = computed(() => draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value))
const populatedGroupCount = computed(() => draft.value?.containers.filter(row => row.sourceKeys.length).length || 0)
const confirmDisabled = computed(() => loading.value || !canCreate.value || (!pending.value && !recovery.pending.value && (mode.value === 'empty' ? !warehouseId.value : !populatedGroupCount.value)))
function assignedGroup(row: Candidate) {
return draft.value?.containers.find(group => group.sourceKeys.some(key => row.selectionGroup.sources.some(source => source.sourceKey === key)))
}
async function startCargo() {
if (frozen.value || loading.value || !warehouseId.value) return
if (!draft.value) await draftAction('CREATE')
if (draft.value && !error.value && !pending.value && !draft.value.containers.length) await draftAction('CONTAINER')
}
function changeWarehouse() { candidatePage.reset(); warehouseTab.value = 'ALL'; void search() }
function changeTab(tab: SmartDispatchWarehouseTab) { warehouseTab.value = tab; void search() }
function changePage(value: number) { page.value = value; void loadCandidates() }
function changeSize(value: number) { size.value = value; void search() }
let generation = 0
let candidateFilterKey = ''
function persist() {
if (!owner || owner !== auth.user?.id) throw new DispatchInputError('登录身份已变化,请重新进入排车列表')
if (pending.value) sessionStorage.setItem(storageKey, JSON.stringify(pending.value)); else sessionStorage.removeItem(storageKey)
}
async function init() {
const sequence = ++generation
loading.value = true; error.value = null
working.value = true; error.value = null
try {
if (recovery.pending.value?.action === 'CREATE') { mode.value = 'empty'; warehouseId.value = String(recovery.pending.value.body.warehouseId || '') }
const [directory, saved] = await Promise.all([listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }), api.listDispatchDrafts()])
@@ -68,20 +90,18 @@ async function init() {
}
if (warehouseId.value) await loadCandidates(sequence)
} catch (cause) { if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '排车候选或草稿恢复失败,请保留原选择重试') }
finally { if (sequence === generation) loading.value = false }
finally { if (sequence === generation) working.value = false }
}
async function loadCandidates(sequence = generation) {
if (!warehouseId.value) return
const result = await api.listDispatchCandidates({ warehouseId: warehouseId.value, orderNo: query.orderNo || undefined, destinationWarehouseCode: query.destinationWarehouseCode || undefined, sourceStage: query.sourceStage || undefined, ...(query.sourceStage !== 'RELABEL' && planIds.value.length ? { planIds: planIds.value } : {}), draftToken: draft.value?.draftToken })
if (sequence !== generation) return
if (result.complete !== true) throw new DispatchInputError('候选来源尚未完整返回,请缩小筛选范围重试')
if (result.candidates.some(row => row.warehouseId !== warehouseId.value || !row.sourceVersion || row.selection.sourceVersion !== row.sourceVersion)) throw new DispatchInputError('候选起运仓或来源版本不完整,请重新读取')
candidates.value = result.candidates
const filterKey = JSON.stringify([warehouseId.value, query, warehouseTab.value, planIds.value, draft.value?.draftToken])
if (filterKey !== candidateFilterKey) { page.value = 1; candidateFilterKey = filterKey }
await candidatePage.load({ warehouseId: warehouseId.value, page: page.value, size: size.value, warehouseTab: warehouseTab.value, orderNo: query.orderNo || undefined, destinationWarehouseCode: query.destinationWarehouseCode || undefined, sourceStage: query.sourceStage || undefined, ...(query.sourceStage !== 'RELABEL' && planIds.value.length ? { planIds: planIds.value } : {}), draftToken: draft.value?.draftToken })
}
async function search() { if (frozen.value) return; loading.value = true; error.value = null; try { await loadCandidates() } catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败') } finally { loading.value = false } }
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; activeContainerId.value = value.containers[0]?.draftContainerId || ''; void search() }
function creation(cargo: Candidate[], containerId?: string): CreateDispatch {
assertSelectionGroupVersions(cargo, candidates.value)
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
@@ -100,7 +120,7 @@ async function draftAction(action: DraftCommand['action'], source?: Candidate, c
if (!current) throw new DispatchInputError('请先新建或恢复草稿')
const common = { recordVersion: requireVersion(current.recordVersion), idempotencyKey: key, draftToken: current.draftToken }
if (action === 'CONTAINER') body = { ...common, capacity: capacity.value }
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') requireSelectionGroup(source); body = { ...common, draftContainerId: containerId, source: source.selection } }
else if (action === 'LOCK' || action === 'UNLOCK') { if (!source || !containerId) throw new DispatchInputError('请选择货物和装车容器'); if (action === 'LOCK') { requireSelectionGroup(source); candidatePage.assertCurrent([source]) } body = { ...common, draftContainerId: containerId, source: source.selection } }
else if (action === 'DELETE') body = { ...common, reason: '移除本草稿装车容器' }
else if (action === 'RELEASE') body = common
else {
@@ -147,7 +167,7 @@ async function retryDraft() {
activeContainerId.value = draft.value?.containers.find(row => row.draftContainerId === activeContainerId.value)?.draftContainerId || draft.value?.containers[0]?.draftContainerId || ''
// No new source mutation before the original response has been validated.
pending.value = null; persist()
await loadCandidates()
if (command.action !== 'CREATE' && command.action !== 'CONTAINER') await loadCandidates()
} catch (cause) { if (!previousAttempt && !command.result && isDefiniteDispatchRejection(cause)) command.rejected = true; persist(); throw cause }
finally { saving.value = false }
}
@@ -163,66 +183,93 @@ 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++ }, { immediate: true })
watch(() => props.modelValue, value => { if (value) void init(); else { generation++; candidatePage.reset() } }, { immediate: true })
</script>
<template>
<AppDialog class="admin-density-dialog admin-density" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="loading || !canCreate" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<AppDialog class="admin-density-dialog admin-density admin-density-workspace admin-workbench is-dialog" append-to-body :lock-scroll="true" :model-value="modelValue" title="新建排车" width="min(1800px, calc(100vw - 40px))" fullscreenable :loading="loading" :confirm-loading="saving || recovery.busy.value" :confirm-disabled="confirmDisabled" :confirm-text="pending || recovery.pending.value ? '重试原请求' : mode === 'empty' ? '保存空排车单' : '生成整车排车单'" @update:model-value="emit('update:modelValue', $event)" @confirm="submit">
<div class="density-dialog-sections">
<LoadErrorState v-if="error" title="排车未完成" :message="error.message" :loading="loading || saving || recovery.busy.value" @retry="pending || recovery.pending.value ? submit() : init()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<el-alert v-if="pending || recovery.pending.value" role="status" title="原请求结果尚待核对,请勿更改选择、版本或重新建单。" type="warning" :closable="false" show-icon />
<el-button v-if="pending?.rejected" :disabled="saving || loading" @click="pending = null; persist(); init()">按明确驳回结果重新核对</el-button>
<el-form label-position="left" label-width="130px" class="density-form density-dialog-sections" :disabled="frozen || loading">
<section class="density-dialog-section" data-tone="work" aria-label="排车设置"><h3 class="density-dialog-section__title">建单设置 <small v-if="mode === 'cargo'">当前编辑:{{ draft?.containers.find(row => row.draftContainerId === activeContainerId)?.sequenceNo ? '装车组 ' + draft.containers.find(row => row.draftContainerId === activeContainerId)!.sequenceNo : '请先选择装车组' }}</small><InlineHelp v-if="mode === 'cargo'" title="装车组说明">每组车辆、司机和时间独立保存。</InlineHelp></h3>
<table class="density-table density-table--editable dispatch-create-table"><tbody>
<tr><td><el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">先选货建单</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item></td><td><el-form-item label="起运仓" required><el-select v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="search"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item></td></tr>
<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-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="work" aria-label="排车车辆与司机"><h3 class="density-dialog-section__title">车辆与司机</h3><DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></section>
</el-form>
<section class="density-dialog-section dispatch-setup" aria-label="排车设置">
<h3 class="density-dialog-section__title">1. 排车设置<InlineHelp title="在途预排说明"><p>货物离港并生成入库计划后,即可加入装车组。预排只安排运输,实际装车仍须完成海外仓实收和 PDA 拣货核平。</p></InlineHelp></h3>
<el-form inline label-position="left" label-width="70px" class="density-form dispatch-setup-fields" :disabled="frozen || loading">
<el-form-item label="建单方式"><el-radio-group v-model="mode" :disabled="frozen || loading"><el-radio value="cargo">选货排车</el-radio><el-radio value="empty">先建空单后加货</el-radio></el-radio-group></el-form-item>
<el-form-item class="dispatch-warehouse-field" label="起运仓" required><el-select class="dispatch-warehouse-picker" v-model="warehouseId" :disabled="frozen || loading || Boolean(draft)" placeholder="请选择起运仓" @change="changeWarehouse"><el-option v-for="warehouse in warehouses" :key="String(warehouse.id)" :value="String(warehouse.id)" :label="warehouse.warehouseName" /></el-select></el-form-item>
</el-form>
</section>
<el-alert v-if="mode === 'empty'" title="空单暂不能核平、装车或发车。" type="info" :closable="false" show-icon />
<template v-else>
<section class="density-dialog-section" data-tone="summary" aria-label="整车草稿管理"><h3 class="density-dialog-section__title">整车草稿</h3>
<div class="dispatch-draft-tools density-actions"><el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="显式恢复已有草稿" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '组 · ' + saved.sources.length + '条货物'" /></el-select><el-button :disabled="frozen || loading || !warehouseId || Boolean(draft)" @click="draftAction('CREATE')">新建草稿</el-button><el-select class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select><el-button :disabled="frozen || loading || !draft" @click="draftAction('CONTAINER')">添加装车组</el-button><el-button :disabled="frozen || loading || !draft" @click="draftAction('RELEASE')">释放本草稿</el-button></div>
<template v-if="mode === 'cargo'">
<section class="density-dialog-section dispatch-draft-toolbar" aria-label="整车草稿管理">
<h3 class="density-dialog-section__title">2. 选货分组<InlineHelp title="分组与草稿说明"><p>每个有货的装车组生成一张排车单,同仓可混装多个客户和目的地。草稿用于暂存选货并占用来源;退出后可继续已有草稿,放弃时释放草稿。</p></InlineHelp></h3>
<div class="dispatch-draft-tools density-actions">
<el-button v-if="!draft || !draft.containers.length" type="primary" :disabled="frozen || loading || !warehouseId" @click="startCargo">开始选货</el-button>
<el-select class="dispatch-draft-picker" :model-value="draft?.draftToken" :disabled="frozen || loading" placeholder="继续已有草稿" aria-label="继续已有草稿" @change="restoreDraft"><el-option v-for="saved in drafts" :key="saved.draftToken" :value="saved.draftToken" :label="(warehouses.find(row => String(row.id) === saved.warehouseId)?.warehouseName || '仓库待核实') + ' · ' + saved.containers.length + '组 · ' + saved.sources.length + '条货物'" /></el-select>
<label class="dispatch-capacity-field"><span>计划托位</span><el-select aria-label="新组计划托位" class="dispatch-capacity-picker" v-model="capacity" :disabled="frozen || loading" placeholder="计划托位"><el-option label="30托位" :value="30" /><el-option label="15托位" :value="15" /></el-select></label>
<el-button v-if="draft?.containers.length" :disabled="frozen || loading" @click="draftAction('CONTAINER')">添加装车组</el-button>
<InlineHelp title="计划托位说明"><p>此处只设置新组的预排容量,不代表车辆实际载重或容积。真实车辆容量与装车实测仍由服务端校验。</p></InlineHelp>
<el-button v-if="draft" :disabled="frozen || loading" @click="draftAction('RELEASE')">释放本草稿</el-button>
</div>
</section>
<div class="cargo-workspace">
<section class="density-dialog-section" data-tone="source" aria-label="排车候选货物"><h3 class="density-dialog-section__title">候选货物 <small>重量/体积仅供预排参考</small></h3>
<div class="dispatch-candidate-query"><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="RECEIVED" /><el-option label="待出库换标" value="RELABEL" /></el-select><el-button :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button></div>
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="warehouseTab = tab.value">{{ tab.label }}</button></div>
<el-table :data="visibleCandidates" border>
<el-table-column label="操作" width="95" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || loading || !activeContainerId || Boolean(selectionGroupBlockReason(row as Candidate))" @click="draftAction('LOCK', row as Candidate, activeContainerId)">加入本组</el-button></template></el-table-column>
<section class="density-dialog-section" aria-label="排车候选货物"><h3 class="density-dialog-section__title">候选货物 <small>重量/体积仅供预排参考</small></h3>
<div class="dispatch-candidate-query"><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 :disabled="frozen || loading || !warehouseId" @click="search">查询</el-button></div>
<div class="warehouse-tabs density-tabs" role="tablist" aria-label="候选仓库展示"><button type="button" v-for="tab in ([{ value: 'ALL', label: '全部' }, { value: 'LAX', label: '洛杉矶' }, { value: 'NYC', label: '纽约' }] as const)" :key="tab.value" role="tab" :aria-selected="warehouseTab === tab.value" :disabled="frozen || loading" @click="changeTab(tab.value)">{{ tab.label }}</button></div>
<el-table :data="candidates" border :empty-text="!warehouseId ? '请先选择起运仓' : '暂无符合条件的可排货物;在途货物请核对离港节点、入库计划和目的地'">
<el-table-column label="操作" width="115" fixed="left"><template #default="{ row }"><el-button link :disabled="frozen || loading || !currentContainer || Boolean(selectionGroupBlockReason(row as Candidate)) || Boolean(assignedGroup(row as Candidate))" @click="draftAction('LOCK', row as Candidate, activeContainerId)">{{ assignedGroup(row as Candidate) ? '已加入第' + assignedGroup(row as Candidate)!.sequenceNo + '组' : '加入本组' }}</el-button></template></el-table-column>
<el-table-column prop="customerName" label="客户" min-width="140" show-overflow-tooltip />
<el-table-column label="来源" min-width="130"><template #default="{ row }"><span class="density-dialog-note">{{ ({ INBOUND_PLAN: '计划托盘', RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}</span><LineClampText :content="row.selection.relabelNo || row.planNo || '—'" /></template></el-table-column>
<el-table-column label="来源" min-width="130"><template #default="{ row }"><span class="density-dialog-note">{{ row.selection.sourceType === 'INBOUND_PLAN' ? (row.selection.warehousePalletId ? '已收货托盘' : '在途计划') : ({ RETURN_RECEIPT: '退回实收', RELABEL: '换标' } as Record<string, string>)[row.selection.sourceType] || '—' }}</span><LineClampText :content="row.selection.relabelNo || row.planNo || '—'" /></template></el-table-column>
<el-table-column prop="orderNo" label="订单号" min-width="170" show-overflow-tooltip /><el-table-column prop="inboundCode" label="入仓码" min-width="170" show-overflow-tooltip />
<el-table-column label="托盘与完整来源组" min-width="210"><template #default="{ row }"><details class="dispatch-source-group"><summary><div class="dispatch-group-summary"><LineClampText :content="row.palletNo || '—'" /><span>{{ row.selectionGroup.pallets.length }}个实托</span></div></summary><ul class="dispatch-source-list"><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details></template></el-table-column>
<el-table-column label="托盘与完整来源组" min-width="210"><template #default="{ row }"><details class="dispatch-source-group"><summary class="dispatch-group-summary"><ChevronRight class="dispatch-source-toggle" :size="14" aria-hidden="true" /><LineClampText :content="row.palletNo || '—'" /><span>{{ row.selectionGroup.pallets.length }}{{ row.selection.warehousePalletId ? '个实托' : '个计划托盘' }}</span></summary><ul class="dispatch-source-list"><li v-for="source in row.selectionGroup.sources" :key="source.sourceKey">{{ source.sourceBusinessNo || '—' }} · {{ source.customerName }} · {{ source.packages }}箱</li></ul></details></template></el-table-column>
<el-table-column prop="availablePackages" label="可排箱数" width="100" /><el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="{ row }">{{ [row.destination.warehouseCode || row.destination.address, row.destination.postalCode].filter(Boolean).join(' · ') || '—' }}</template></el-table-column>
<el-table-column label="参考重量/体积" min-width="150"><template #default="{ row }">{{ row.suggestedWeightKg ?? '—' }}kg/{{ row.suggestedVolumeCbm ?? '—' }}m³</template></el-table-column><el-table-column prop="containerNo" label="集装箱号" min-width="170" show-overflow-tooltip /><el-table-column label="资格" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ selectionGroupBlockReason(row as Candidate) || '可排整组' }}</template></el-table-column>
</el-table>
<el-pagination aria-label="候选货物分页" :current-page="page" :page-size="size" :page-sizes="[10, 20, 50]" :total="total" :disabled="frozen || loading" layout="total, sizes, prev, pager, next" @update:current-page="changePage" @update:page-size="changeSize" />
</section>
<section class="density-dialog-section" data-tone="summary" aria-label="排车装车组"><h3 class="density-dialog-section__title">装车分组<InlineHelp title="装车分组说明"><p>新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p></InlineHelp></h3>
<table v-for="container in draft?.containers || []" :key="container.draftContainerId" class="density-table draft-group-table"><tbody>
<section class="density-dialog-section" aria-label="排车装车组"><h3 class="density-dialog-section__title">装车分组 <small>{{ populatedGroupCount }} 组有货</small><InlineHelp title="装车分组说明"><p>新建或恢复草稿后添加装车组。同仓可混装多个客户与目的仓。</p></InlineHelp></h3>
<p v-if="!draft?.containers.length" class="density-dialog-note">暂无装车组</p>
<table v-for="container in draft?.containers || []" :key="container.draftContainerId" class="density-table draft-group-table" :aria-label="`装车组 ${container.sequenceNo}`"><tbody>
<tr><td><el-radio v-model="activeContainerId" :disabled="frozen || loading" :value="container.draftContainerId">装车组 {{ container.sequenceNo }} · {{ container.capacity }}托位</el-radio></td><td><el-button link :disabled="frozen || loading" @click="draftAction('DELETE', undefined, container.draftContainerId)">移除本组</el-button></td></tr>
<tr><td colspan="2"><ul class="dispatch-draft-sources"><li v-for="key in container.sourceKeys" :key="key" class="dispatch-draft-source"><LineClampText :content="draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复'" /><el-button link :disabled="frozen || loading || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></td></tr>
<tr><td colspan="2"><p v-if="!container.sourceKeys.length" class="density-dialog-note">暂无货物</p><ul class="dispatch-draft-sources"><li v-for="key in container.sourceKeys" :key="key" class="dispatch-draft-source"><LineClampText :content="draft?.sources.find(row => row.sourceKey === key)?.palletNo || '来源待恢复'" /><el-button link :disabled="frozen || loading || !draft?.sources.some(row => row.sourceKey === key)" @click="draftAction('UNLOCK', draft?.sources.find(row => row.sourceKey === key), container.draftContainerId)">移除货物</el-button></li></ul></td></tr>
</tbody></table>
</section>
</div>
</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' ? '2. 车辆与时间' : '3. 车辆与时间 · 装车组 ' + 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-input v-model="form.remark" :disabled="frozen || loading || (mode === 'cargo' && !activeContainerId)" /></el-form-item></td></tr>
</tbody></table>
<DispatchResourceFields continuous v-model:vehicle="vehicle" v-model:driver="driver" :disabled="frozen || loading" />
</section>
</el-form>
</div>
</AppDialog>
</template>
<style scoped>
.dispatch-create-table { table-layout: fixed; }
.dispatch-create-table td:not([colspan]) { width: 50%; }
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 2fr) minmax(340px, 1fr); gap: var(--admin-space-5); align-items: start; }
.cargo-workspace { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: var(--admin-space-5); align-items: start; }
.cargo-workspace > .density-dialog-section { margin: 0; }
.dispatch-draft-tools { flex-wrap: wrap; }
.dispatch-draft-picker { width: 320px; }
.dispatch-capacity-picker { width: 130px; }
.dispatch-candidate-query { display: grid; grid-template-columns: minmax(160px, 1fr) minmax(120px, 1fr) minmax(130px, .8fr) auto; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
.dispatch-group-summary { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--admin-space-2); }
.cargo-workspace > .density-dialog-section > .density-dialog-section__title { min-height: 24px; align-items: center; }
.dispatch-setup, .dispatch-draft-toolbar { display: grid; grid-template-columns: 160px minmax(0, 1fr); align-items: center; gap: var(--admin-space-5); }
.dispatch-setup > .density-dialog-section__title, .dispatch-draft-toolbar > .density-dialog-section__title { align-items: center; margin: 0; }
.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; }
.dispatch-setup-fields :deep(.dispatch-warehouse-field) { width: 310px; flex: 0 0 310px; }
.dispatch-warehouse-picker { width: 100%; }
.dispatch-draft-tools { flex-wrap: wrap; align-items: center; }
.dispatch-draft-picker { width: 240px; }
.dispatch-capacity-field { display: flex; align-items: center; gap: var(--admin-space-3); margin: 0; }
.dispatch-capacity-picker { width: 100px; }
.dispatch-candidate-query { display: grid; grid-template-columns: 240px 160px 170px auto; justify-content: start; gap: var(--admin-space-3); margin-bottom: var(--admin-space-4); }
.dispatch-group-summary { display: grid; grid-template-columns: 14px minmax(0, 1fr) auto; list-style: none; align-items: center; gap: var(--admin-space-2); }
.dispatch-source-group > summary { cursor: pointer; }
.dispatch-source-group > summary::-webkit-details-marker { display: none; }
.dispatch-source-group[open] .dispatch-source-toggle { transform: rotate(90deg); }
.dispatch-source-list { margin: var(--admin-space-2) 0 0; padding-left: 18px; }
.draft-group-table + .draft-group-table { margin-top: var(--admin-space-4); }
.dispatch-draft-sources { margin: 0; padding: 0; list-style: none; }
@@ -24,13 +24,13 @@ export interface DispatchBatchAllocation { planPalletId: string; batchKey: strin
export interface DispatchQuery { page: number; size: number; dispatchNo?: string; vehiclePlate?: string; warehouseId?: string; customerId?: string; fbaAppointmentNo?: string; status?: DispatchStatus | 'ALL'; stage?: DispatchStage; beforeLoadingSection?: BeforeLoadingSection; reviewStatus?: ReviewStatus }
export interface ReviewCounts { notSubmitted: number; pending: number; approved: number; rejected: number }
export interface DispatchStatistics { matchedTotal: number; activeTotal: number; cancelledTotal: number; stageCounts: Record<DispatchStage, number>; beforeLoadingReviewCounts: { vehicleInfoTotal: number; dispatchReview: ReviewCounts; loadingReview: ReviewCounts } }
export interface DispatchListRow { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
export interface DispatchListRow { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; resources: Resources; warehouseId: string; warehouseCode: string; warehouseName: string; plannedDepartureAt: string | null; departedAt: string | null; counts: Counts; pickingReconciled: boolean; arranged: boolean; actions: DispatchActions }
export interface DispatchPage { records: DispatchListRow[]; total: number; page: number; size: number; pages: number; statistics: DispatchStatistics; snapshotAt: string }
export interface OutboundReference { customerId: string; customerName: string; outboundNo: string; status: string; packageCount: number; palletCount: number }
export interface DeliveryReference { groupKey: string; customerId: string; customerName: string; outboundNo: string; deliveryNo: string; destination: Destination; status: string; packageCount: number; palletCount: number }
export interface FbaAppointment { batchKey: string; batchNo: string | null; appointmentNo: string | null; destinationKey: string; destinationWarehouseCode: string | null; status: string; confirmedAt: string | null; blockReason: string | null }
export interface OrderDetail { customerId: string; customerName: string; orderNo: string; inboundCode: string | null; grossWeightKg: number | null; palletCount: number | null; destinationFbaWarehouse: string | null; volumeCbm: number | null; containerNo: string | null; sourceKey: string; sourceBusinessType: string | null; sourceBusinessNo: string | null }
export interface DispatchDetail { dispatchNo: string; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
export interface DispatchDetail { dispatchNo: string; taskType?: 'DELIVERY' | 'PICKUP' | 'LOCAL_TRUCK'; status: DispatchStatus; stage: DispatchStage; recordVersion: Version; reviewState: string; reviews: Reviews; counts: Counts; pickingReconciled: boolean; actions: DispatchActions; vehicleInfo: Resources; dispatchInfo: { route: string | null; plannedDepartureAt: string | null; departedAt: string | null; estimatedArrivalAt: string | null; fbaAppointments: FbaAppointment[]; appointmentBatches: DispatchBatchReference[] }; documents: { dispatchNo: string; outbounds: OutboundReference[]; deliveries: DeliveryReference[]; createdAt: string; updatedAt: string; remark: string | null }; orderDetails: OrderDetail[] }
export interface CargoSelection { sourceType: 'INBOUND_PLAN' | 'RETURN_RECEIPT' | 'RELABEL'; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; sourceVersion: string }
export interface CreateDispatch { idempotencyKey: string; warehouseId: string; vehicle: Vehicle | null; driver: Driver | null; plannedDepartureAt: string | null; estimatedArrivalAt: string | null; remark: string | null; cargo: CargoSelection[]; batchAllocations: DispatchBatchAllocation[] }
export interface VersionCommand { recordVersion: Version; idempotencyKey: string }
@@ -46,7 +46,7 @@ export interface SelectionGroupSource { selection: CargoSelection; sourceKey: st
export interface SelectionGroupPallet { warehousePalletId: string | null; palletNo: string | null; sourceKeys: string[]; packageCount: number }
export interface SelectionGroup { groupKey: string; groupVersion: string; sources: SelectionGroupSource[]; pallets: SelectionGroupPallet[]; complete: boolean; blockReasons: string[] }
export interface Candidate { selection: CargoSelection; sourceKey: string; sourceVersion: string; customerId: string; customerName: string; warehouseId: string; warehouseCode: string; warehouseName: string; orderNo: string | null; inboundCode: string | null; planNo: string | null; palletNo: string | null; containerNo: string | null; destination: Destination; availablePackages: number; suggestedWeightKg: number | null; suggestedVolumeCbm: number | null; measurementSource: string | null; appointmentBatches: DispatchBatchReference[]; blockReasons: string[]; selectionGroup: SelectionGroup }
export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string }
export interface CandidateQuery { warehouseId?: string; planIds?: string[]; orderNo?: string; destinationWarehouseCode?: string; sourceStage?: string; draftToken?: string; page?: number; size?: number; warehouseTab?: 'ALL' | 'LAX' | 'NYC' }
export interface DispatchCargoItem { dispatchItemId: string; customerId: string; customerName: string; sourceKey: string; sourceVersion: string; sourceType: CargoSelection['sourceType']; planPalletId: string | null; returnReceiptId: string | null; relabelNo: string | null; warehousePalletId: string | null; palletNo: string | null; orderNo: string | null; inboundCode: string | null; destination: Destination; scheduledPackages: number; pickedPackages: number; remove: Gate }
export interface DispatchItems { dispatchNo: string; recordVersion: Version; status: DispatchStatus; stage: DispatchStage; items: DispatchCargoItem[] }
export interface DispatchRouteStop { index: number; name: string; address: string | null; destinationKey: string | null }
@@ -54,7 +54,7 @@ export interface DispatchRoute { dispatchNo: string; recordVersion: Version; sto
export interface DispatchRouteCommand extends VersionCommand { destinationKeys: string[]; reason: string }
export type DispatchEvidenceKind = 'VEHICLE' | 'DRIVER'
export interface DispatchEvidenceFile { fileId: string; fileName: string; contentType: string; sizeBytes: number }
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean }
export interface CandidateList { candidates: Candidate[]; snapshotAt: string; complete: boolean; total: number; page: number; size: number; pages: number }
export interface DraftContainer { draftContainerId: string; sequenceNo: number; capacity: number; sourceKeys: string[] }
export interface DispatchDraft { draftToken: string; recordVersion: Version; status: string; warehouseId: string; containers: DraftContainer[]; sources: Candidate[]; expiresAt: string }
export interface DraftSelection extends VersionCommand { draftToken: string; draftContainerId: string; source: CargoSelection }
@@ -35,9 +35,9 @@ it('rejects changed group composition and conflicting source versions before cre
it('normalizes nested group Long quantities and preserves source identities and versions without loss', () => {
const row = sharedPalletCandidates()[0]!, id = '9007199254740993'
const value: CandidateWire = { ...row, selectionGroup: { ...row.selectionGroup, sources: row.selectionGroup.sources.map(source => ({ ...source, customerId: id, selection: { ...source.selection, sourceVersion: id }, packages: String(source.packages) })), pallets: [{ warehousePalletId: id, palletNo: 'PALLET-1', sourceKeys: ['RELABEL:A', 'RELABEL:B'], packageCount: '8' }] } }
const result = normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' }).candidates[0]!
const result = normalizeCandidateList({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' }).candidates[0]!
expect(result.selectionGroup.sources.map(source => source.packages)).toEqual([3, 5]); expect(result.selectionGroup.pallets[0]!.packageCount).toBe(8)
expect(result.selectionGroup.sources[0]!.selection.sourceVersion).toBe(id); expect(result.selectionGroup.pallets[0]!.warehousePalletId).toBe(id)
value.selectionGroup.sources[0]!.packages = '9007199254740993'
expect(() => normalizeCandidateList({ candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
expect(() => normalizeCandidateList({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).toThrow('安全整数')
})
@@ -30,7 +30,7 @@ describe('native dispatch Long quantity boundary', () => {
it.each([0, '0', id])('keeps actual batch recordVersion %s lossless in candidates, drafts and existing dispatch references', version => {
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [{ sourceKey: 'ORDER:1', destinationKey: 'FBA:ONT8', packageCount: 12 }] }
const value = { ...source(), appointmentBatches: [reference] }
expect(wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
expect(wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
expect(wire.normalizeDispatchDraft(parsed({ ...draft(), sources: [value] })).sources[0]!.appointmentBatches[0]!.recordVersion).toBe(version)
const existing = detail()
expect(wire.normalizeDispatchDetail(parsed({ ...existing, dispatchInfo: { ...existing.dispatchInfo, appointmentBatches: [reference] } })).dispatchInfo.appointmentBatches[0]!.recordVersion).toBe(version)
@@ -38,11 +38,11 @@ describe('native dispatch Long quantity boundary', () => {
it.each([undefined, null, -1, '', 'bad'])('rejects missing or invalid authoritative candidate batch version %s', version => {
const reference = { batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: version, sources: [] }
const value = { ...source(), appointmentBatches: [reference] } as unknown as wire.CandidateWire
expect(() => wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' }))).toThrow('预约批次版本缺失或无效,请重新读取候选')
expect(() => wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' }))).toThrow('预约批次版本缺失或无效,请重新读取候选')
})
it('preserves a raw large JSON integer that the shared parser safely converts into a version string', () => {
const value = { ...source(), appointmentBatches: [{ batchKey: 'FBA_APPOINTMENT:1', batchNo: 'AP-1', customerId: id, recordVersion: 9007199254740992, sources: [] }] }
expect(wire.normalizeCandidateList(parsed({ candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe('9007199254740992')
expect(wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [value], complete: true, snapshotAt: '' })).candidates[0]!.appointmentBatches[0]!.recordVersion).toBe('9007199254740992')
})
it.each([undefined, null, true, false, '', ' ', ' 1', '1 ', '01', '+1', '-1', '1.0', '1e2', -1, 1.5, NaN, Infinity, '9007199254740992', 9007199254740992])('rejects invalid or unsafe quantity %s instead of silently changing it', value => {
expect(() => wire.dispatchQuantity(value, '箱数')).toThrow('排车响应')
@@ -63,7 +63,7 @@ describe('native dispatch Long quantity boundary', () => {
expect(result.documents.outbounds[0]).toMatchObject({ customerId: id, packageCount: 12, palletCount: 2 }); expect(result.documents.deliveries[0]!.packageCount).toBe(12)
})
it('normalizes candidates and draft sources and keeps source versions and container IDs', () => {
const candidates = wire.normalizeCandidateList(parsed({ candidates: [source()], snapshotAt: '', complete: true }))
const candidates = wire.normalizeCandidateList(parsed({ total: 1, page: 1, size: 20, pages: 1, candidates: [source()], snapshotAt: '', complete: true }))
expect(candidates.candidates[0]).toMatchObject({ availablePackages: 12, customerId: id, sourceVersion: 'source-hash' })
const result = wire.normalizeDispatchDraft(parsed(draft()))
expect(result.sources[0]!.availablePackages).toBe(12); expect(result.recordVersion).toBe(id); expect(result.containers[0]).toMatchObject({ draftContainerId: id, sequenceNo: 1, capacity: 30 })
@@ -100,3 +100,12 @@ describe('native dispatch Long quantity boundary', () => {
expect(() => wire.normalizeDispatchCommand({ ...command('EDIT'), counts: { ...counts, pickedPackageCount: undefined } } as unknown as wire.CommandResultWire)).toThrow('有效非负整数')
})
})
it('normalizes paginated long totals while keeping whole-group and page completeness independent', () => {
const result = wire.normalizeCandidateList(parsed({ candidates: [source()], complete: false, snapshotAt: '', total: '41', pages: '3', page: 2, size: 20 }))
expect(result).toMatchObject({ total: 41, pages: 3, page: 2, size: 20, complete: false })
expect(result.candidates[0]!.selectionGroup.complete).toBe(true)
})
it.each([{ total: '9007199254740993' }, { pages: '9007199254740993' }, { total: undefined }, { size: 51 }, { page: 0 }, { pages: '2' }])('fails closed for invalid pagination metadata %j', overrides => {
expect(() => wire.normalizeCandidateList(parsed({ candidates: [], complete: false, snapshotAt: '', total: '21', pages: '3', page: 2, size: 10, ...overrides } as unknown as wire.CandidateListWire))).toThrow(/候选分页信息无效|排车响应/)
})
@@ -19,7 +19,7 @@ export type DispatchPageWire = Omit<DispatchPage, 'records' | 'total' | 'page' |
export type DispatchDetailWire = Omit<DispatchDetail, 'counts' | 'documents'> & { counts: CountsWire; documents: Omit<DispatchDetail['documents'], keyof ReferencesWire> & ReferencesWire }
type SelectionGroupWire = Omit<SelectionGroup, 'sources' | 'pallets'> & { sources: Array<LongFields<SelectionGroup['sources'][number], 'packages'>>; pallets: Array<LongFields<SelectionGroup['pallets'][number], 'packageCount'>> }
export type CandidateWire = Omit<LongFields<Candidate, 'availablePackages'>, 'selectionGroup'> & { selectionGroup: SelectionGroupWire }
export type CandidateListWire = Omit<CandidateList, 'candidates'> & { candidates: CandidateWire[] }
export type CandidateListWire = Omit<LongFields<CandidateList, 'total' | 'pages'>, 'candidates'> & { candidates: CandidateWire[] }
export type DispatchDraftWire = Omit<DispatchDraft, 'sources'> & { sources: CandidateWire[] }
export type DispatchItemsWire = Omit<DispatchItems, 'items'> & { items: Array<LongFields<DispatchItems['items'][number], 'scheduledPackages' | 'pickedPackages'>> }
export type LoadingPreviewWire = Omit<LoadingPreview, 'pallets'> & { pallets: Array<LongFields<LoadingPreview['pallets'][number], 'scheduledPackages' | 'pickedPackages'>> }
@@ -60,7 +60,11 @@ function statistics(value: StatisticsWire): DispatchStatistics {
}
export function normalizeDispatchPage(value: DispatchPageWire): DispatchPage { return { ...value, total: dispatchQuantity(value.total, 'total'), page: dispatchQuantity(value.page, 'page'), size: dispatchQuantity(value.size, 'size'), pages: dispatchQuantity(value.pages, 'pages'), records: value.records.map(row => ({ ...row, counts: counts(row.counts) })), statistics: statistics(value.statistics) } }
export function normalizeDispatchDetail(value: DispatchDetailWire): DispatchDetail { requireBatchVersions(value.dispatchInfo.appointmentBatches); return { ...value, counts: counts(value.counts), documents: { ...value.documents, ...references(value.documents) } } }
export function normalizeCandidateList(value: CandidateListWire): CandidateList { return { ...value, candidates: value.candidates.map(candidate) } }
export function normalizeCandidateList(value: CandidateListWire): CandidateList {
const result = { ...value, candidates: value.candidates.map(candidate), total: dispatchQuantity(value.total, 'total'), page: dispatchQuantity(value.page, 'page'), size: dispatchQuantity(value.size, 'size'), pages: dispatchQuantity(value.pages, 'pages') }
if (result.page < 1 || result.size < 1 || result.size > 50 || result.pages !== Math.ceil(result.total / result.size) || result.candidates.length > result.size) throw new DispatchInputError('候选分页信息无效,请重新读取')
return result
}
export function normalizeDispatchDraft(value: DispatchDraftWire): DispatchDraft { return { ...value, sources: value.sources.map(candidate) } }
export function normalizeDispatchItems(value: DispatchItemsWire): DispatchItems { return { ...value, items: value.items.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
export function normalizeLoadingPreview(value: LoadingPreviewWire): LoadingPreview { return { ...value, pallets: value.pallets.map(row => ({ ...row, scheduledPackages: dispatchQuantity(row.scheduledPackages, 'scheduledPackages'), pickedPackages: dispatchQuantity(row.pickedPackages, 'pickedPackages') })) } }
+1
View File
@@ -13,5 +13,6 @@ export { DispatchInputError, requireVersion, requireBatchVersions, requireSelect
export { useDispatchCommandRecovery, validateCommandResult, validateCurrentDispatch, isDefiniteDispatchRejection } from './dispatchCommandRecovery'
export { default as DispatchWorkflowDialog } from './components/DispatchWorkflowDialog.vue'
export { dispatchVehicleSpecs } from './dispatchVehicleSpecs'
export { useDispatchCandidates } from './useDispatchCandidates'
export { getContainerPlanProgress, getContainerInboundPlanPreview, convertLastMileContainerToInboundPlans } from './api/lastMileOrders'
export type { ContainerPlanProgressDetailDto, ContainerInboundPlanPreviewDto, ConvertContainerInboundPlanPayload } from './api/lastMileOrders'
@@ -98,7 +98,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
if (fileName !== 'RelabelOrderDetailView.vue') expect(value).not.toContain('AppDialog')
}
const relabel = source('RelabelOrderDetailView.vue')
expect(relabel).toContain('<AppDialog class="admin-density-dialog" v-model="previewOpen"')
expect(relabel).toContain('<AppDialog class="admin-density-dialog admin-workbench is-dialog" v-model="previewOpen"')
expect(relabel).toContain('<iframe :src="previewUrl"')
})
@@ -257,9 +257,9 @@ describe('overseas warehouse first-batch prototype contract', () => {
it('connects native same-warehouse multi-customer draft containers and independent resources', () => {
const dialog = source('../components/SmartDispatchDialog.vue')
for (const symbol of ['listDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'result.complete !== true', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
for (const symbol of ['useDispatchCandidates', 'createDispatchDraft', 'lockDispatchDraftPallet', 'unlockDispatchDraftPallet', 'releaseDispatchDraft', 'convertDispatchDraft', 'requireVersion', 'draftContainerId', 'sourceVersion', 'candidatePage.assertCurrent', 'validateCurrentDispatch', 'warehouse-tabs density-tabs', '洛杉矶', '纽约', '先建空单后加货', 'settings[containerId]']) expect(dialog).toContain(symbol)
// A filtered candidate must still submit its complete server-owned selection group.
expect(dialog).toContain('assertSelectionGroupVersions(cargo, candidates.value)')
expect(dialog).toContain('candidatePage.assertCurrent(cargo)')
expect(dialog).toContain('cargo: expandSelectionGroups(cargo)')
expect(dialog).toContain('同仓可混装多个客户与目的仓')
expect(dialog).not.toContain('一次排车只能装同一客户')
@@ -346,7 +346,7 @@ describe('overseas warehouse first-batch prototype contract', () => {
expect(value).toContain('请选择目的 FBA 仓')
expect(value).toContain('aria-label="尾程订单状态"')
expect(value).toContain("const statusTabs = ['全部', '开船前', '开船后'] as const")
expect(value).toContain('class="density-status-tabs" role="tablist"')
expect(value).toContain('class="density-status-tabs workbench-list-tabs" role="tablist"')
const columns = value.slice(value.indexOf('const columns:'), value.indexOf('const selectedContainerIds'))
expect(columns.indexOf("label: '入库单号'")).toBeLessThan(columns.indexOf("label: '出库单号'"))
expect(columns.indexOf("label: '出库单号'")).toBeLessThan(columns.indexOf("label: '派送单号'"))
@@ -0,0 +1,73 @@
import { ref } from 'vue'
import { beforeEach, expect, it, vi } from 'vitest'
import type { ApiErrorDetails } from '@/shared/api/http'
import { listDispatchCandidates } from './api/dispatches'
import { candidate, sharedPalletCandidates } from './dispatchTestFixtures'
import type { CandidateList } from './dispatchContracts'
import { useDispatchCandidates } from './useDispatchCandidates'
vi.mock('./api/dispatches', () => ({ listDispatchCandidates: vi.fn() }))
beforeEach(() => vi.resetAllMocks())
const response = (overrides: Partial<CandidateList> = {}): CandidateList => ({ candidates: [candidate()], snapshotAt: '', complete: false, page: 1, size: 20, total: 41, pages: 3, ...overrides })
function deferred() {
let resolve!: (value: CandidateList) => void, reject!: (error: Error) => void
const promise = new Promise<CandidateList>((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
function setup() {
const error = ref<ApiErrorDetails | null>(null)
return { ...useDispatchCandidates(error), error }
}
it('deduplicates concurrent identical requests and accepts complete groups on a partial page', async () => {
const pending = deferred(), state = setup()
vi.mocked(listDispatchCandidates).mockReturnValue(pending.promise)
const first = state.load({ warehouseId: '321', page: 2, warehouseTab: 'LAX', orderNo: 'LM' })
const second = state.load({ warehouseId: '321', page: 2, warehouseTab: 'LAX', orderNo: 'LM' })
expect(first).toBe(second); expect(listDispatchCandidates).toHaveBeenCalledTimes(1)
pending.resolve(response({ candidates: [sharedPalletCandidates()[0]!], page: 2 }))
await first
expect(state.total.value).toBe(41); expect(state.page.value).toBe(2)
expect(state.candidates.value[0]!.selectionGroup.sources).toHaveLength(2)
expect(() => state.assertCurrent(state.candidates.value)).not.toThrow()
})
it.each(['success', 'failure'])('ignores stale %s while the latest page is still loading', async outcome => {
const old = deferred(), latest = deferred(), state = setup()
vi.mocked(listDispatchCandidates).mockReturnValueOnce(old.promise).mockReturnValueOnce(latest.promise)
const first = state.load({ warehouseId: '321', page: 1 })
const second = state.load({ warehouseId: '321', page: 2 })
if (outcome === 'success') old.resolve(response()); else old.reject(new Error('stale failure'))
await first
expect(state.loading.value).toBe(true); expect(state.error.value).toBeNull(); expect(state.candidates.value).toEqual([])
latest.resolve(response({ page: 2 })); await second
expect(state.page.value).toBe(2); expect(state.loading.value).toBe(false)
})
it('ignores an old response after a new warehouse succeeds and invalidates requests on close', async () => {
const old = deferred(), state = setup(), other = candidate({ warehouseId: 'other' })
vi.mocked(listDispatchCandidates).mockReturnValueOnce(old.promise).mockResolvedValueOnce(response({ candidates: [other] }))
const first = state.load({ warehouseId: '321' })
await state.load({ warehouseId: 'other' })
old.resolve(response()); await first
expect(state.candidates.value[0]!.warehouseId).toBe('other')
expect(() => state.assertCurrent([candidate()])).toThrow('组成已变化')
const closing = deferred()
vi.mocked(listDispatchCandidates).mockReturnValueOnce(closing.promise)
const request = state.load({ warehouseId: 'other', page: 2 })
state.reset(); closing.resolve(response({ page: 2, candidates: [other] })); await request
expect(state.candidates.value).toEqual([]); expect(state.total.value).toBe(0); expect(state.loading.value).toBe(false)
})
it('remembers a group or source version change even after navigating to an unrelated page', async () => {
const state = setup(), selected = candidate()
const changed = candidate({ selectionGroup: { ...selected.selectionGroup, groupVersion: 'changed' } })
vi.mocked(listDispatchCandidates).mockResolvedValueOnce(response()).mockResolvedValueOnce(response({ page: 2, candidates: [changed] })).mockResolvedValueOnce(response({ page: 3, candidates: [] }))
await state.load({ warehouseId: '321' }); await state.load({ warehouseId: '321', page: 2 }); await state.load({ warehouseId: '321', page: 3 })
expect(() => state.assertCurrent([selected])).toThrow('组成已变化')
expect(() => state.assertCurrent([changed])).not.toThrow()
})
it.each(['warehouse', 'page', 'version'])('rejects a mismatched %s response instead of exposing selectable stale rows', async mismatch => {
const state = setup(), row = candidate()
if (mismatch === 'warehouse') row.warehouseId = 'other'
if (mismatch === 'version') row.sourceVersion = 'changed'
vi.mocked(listDispatchCandidates).mockResolvedValue(response({ candidates: [row], page: mismatch === 'page' ? 2 : 1 }))
await state.load({ warehouseId: '321' })
expect(state.candidates.value).toEqual([]); expect(state.error.value).not.toBeNull()
})
@@ -0,0 +1,60 @@
import { ref, type Ref } from 'vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { listDispatchCandidates } from './api/dispatches'
import { DispatchInputError, assertSelectionGroupVersions, type Candidate, type CandidateQuery } from './dispatchContracts'
/** One visible server page; selected cargo remains owned by the caller or server draft. */
export function useDispatchCandidates(error: Ref<ApiErrorDetails | null>, fetchCandidates = listDispatchCandidates) {
const candidates = ref<Candidate[]>([]), page = ref(1), size = ref(20), total = ref(0), loading = ref(false)
let generation = 0, warehouse = ''
let pending: { key: string; promise: Promise<void> } | undefined
const groupVersions = new Map<string, string>()
const sourceGroups = new Map<string, { groupKey: string; sourceVersion: string }>()
function reset() {
generation++; pending = undefined; warehouse = ''
candidates.value = []; page.value = 1; total.value = 0; loading.value = false
groupVersions.clear(); sourceGroups.clear()
}
function assertCurrent(rows: Candidate[]) {
assertSelectionGroupVersions(rows, candidates.value)
for (const row of rows) {
const group = row.selectionGroup
if (row.warehouseId !== warehouse || (groupVersions.has(group.groupKey) && groupVersions.get(group.groupKey) !== group.groupVersion)
|| group.sources.some(source => {
const latest = sourceGroups.get(source.sourceKey)
return latest && (latest.groupKey !== group.groupKey || latest.sourceVersion !== source.selection.sourceVersion)
})) throw new DispatchInputError('选货组组成已变化,请重新读取并选择货物')
}
}
function load(query: CandidateQuery): Promise<void> {
if (!query.warehouseId) { reset(); return Promise.resolve() }
if (warehouse !== query.warehouseId) { reset(); warehouse = query.warehouseId }
const request = { ...query, page: query.page ?? page.value, size: query.size ?? size.value, warehouseTab: query.warehouseTab ?? 'ALL' }
const key = JSON.stringify(request)
if (pending?.key === key) return pending.promise
const sequence = ++generation
// Preserve the last total while loading: zero would make the pager emit page 1.
loading.value = true; error.value = null; candidates.value = []
const promise = (async () => {
try {
const result = await fetchCandidates(request)
if (sequence !== generation) return
if (result.page !== request.page || result.size !== request.size) throw new DispatchInputError('候选响应页码不一致,请重新读取')
if (result.candidates.some(row => row.warehouseId !== request.warehouseId || !row.sourceVersion || row.selection.sourceVersion !== row.sourceVersion)) throw new DispatchInputError('候选起运仓或来源版本不完整,请重新读取')
for (const row of result.candidates) {
groupVersions.set(row.selectionGroup.groupKey, row.selectionGroup.groupVersion)
for (const source of row.selectionGroup.sources) sourceGroups.set(source.sourceKey, { groupKey: row.selectionGroup.groupKey, sourceVersion: source.selection.sourceVersion })
}
candidates.value = result.candidates; page.value = result.page; size.value = result.size; total.value = result.total
} catch (cause) {
if (sequence === generation) error.value = getApiErrorDetails(cause, cause instanceof DispatchInputError ? cause.message : '货物候选读取失败')
} finally {
if (sequence === generation) { loading.value = false; pending = undefined }
}
})()
pending = { key, promise }
return promise
}
return { candidates, page, size, total, loading, load, reset, assertCurrent }
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { Task } from './types'
import { taskStateText, taskProgressText, taskCompletionText } from './collectionPresentation'
import { collectionControlState, taskStateText, taskProgressText, taskCompletionText } from './collectionPresentation'
const base: Task = { taskNo: 'T-A', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'RUNNING', applyState: 'NOT_STARTED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: { phase: 'DETAIL', completed: 792, total: 2014, totalKnown: true, unit: 'ORDER' }, queryFrom: null, queryUntil: null }
describe('plain collection task presentation', () => {
it('requires the matching yielded attempt before describing RETRY_WAIT as normal continuation', () => {
@@ -22,6 +22,25 @@ describe('plain collection task presentation', () => {
expect(taskStateText({ ...base, taskStatus: 'DEAD' })).toBe('已停止,需处理')
expect(taskProgressText({ ...base, taskStatus: 'WAITING_SOURCE', attemptCount: 0 })).toBe('尚未开始,等待数据准备')
})
it('gives explicit off and pause evidence priority over automatic continuation copy', () => {
const waiting = { ...base, taskStatus: 'YIELDED' }
expect(taskStateText(waiting, { collectionMode: 'OFF' })).toBe('采集已关闭')
expect(taskStateText({ ...waiting, blockingReason: 'COLLECTION_OFF' })).toBe('采集已关闭')
expect(taskStateText(waiting, { accountPaused: true })).toBe('已暂停')
expect(taskStateText({ ...waiting, blockingReason: 'ACCOUNT_PAUSED' })).toBe('已暂停')
expect(taskStateText({ ...waiting, blockingReason: 'EXECUTION_DISABLED' })).toBe('执行未启用')
expect(collectionControlState(null, { collectionMode: 'OFF' })?.text).toBe('采集已关闭')
})
it('does not infer that an executing attempt stopped from a control switch or pause reason', () => {
for (const taskStatus of ['RUNNING', 'APPLYING']) {
const task = { ...base, taskStatus }
expect(taskStateText(task, { collectionMode: 'OFF' })).toBe('已关闭 · 执行中')
expect(taskStateText(task, { accountPaused: true })).toBe('已暂停 · 执行中')
expect(collectionControlState(task, { blockingReason: 'EXECUTION_DISABLED' })?.running).toBe(true)
}
expect(taskStateText({ ...base, taskStatus: 'SUCCEEDED' }, { collectionMode: 'OFF' })).toBe('执行完成')
expect(taskStateText({ ...base, taskStatus: 'DEAD', blockingReason: 'COLLECTION_OFF' })).toBe('已停止,需处理')
})
it('shows a stage percentage without presenting it as overall completion', () => {
expect(taskProgressText(base)).toBe('处理出库订单,已处理 792 / 2014单(39.3%,当前阶段)')
expect(taskCompletionText(base)).toBe('完成时间暂无法预计')
@@ -1,6 +1,24 @@
import type { Task } from './types'
import { label, phaseText, quantityText, unitText } from './state'
export interface CollectionControlEvidence {
collectionMode?: string | null
accountPaused?: boolean
blockingReason?: string | null
}
export function collectionControlState(task: Task | null | undefined, evidence: CollectionControlEvidence = {}) {
if (task && ['SUCCEEDED', 'FAILED', 'DEAD', 'ABORTED'].includes(task.taskStatus)) return null
const reasons = [task?.blockingReason, evidence.blockingReason]
const text = evidence.collectionMode === 'OFF' || reasons.includes('COLLECTION_OFF') ? '采集已关闭'
: evidence.accountPaused || reasons.includes('ACCOUNT_PAUSED') ? '已暂停'
: reasons.includes('EXECUTION_DISABLED') ? '执行未启用' : null
if (!text) return null
// A control switch alone cannot establish that an already running attempt has stopped.
const running = Boolean(task && ['RUNNING', 'APPLYING'].includes(task.taskStatus))
return { text, running, summary: running ? `${text === '采集已关闭' ? '已关闭' : text} · 执行中` : text }
}
export function isNormalContinuation(task: Task | null | undefined) {
const attempt = task?.latestAttempt
return task?.taskStatus === 'RETRY_WAIT' && attempt?.status === 'YIELDED'
@@ -9,8 +27,10 @@ export function isNormalContinuation(task: Task | null | undefined) {
&& !attempt.errorCode && !attempt.failureSummary
}
export function taskStateText(task: Task | null | undefined) {
export function taskStateText(task: Task | null | undefined, evidence: CollectionControlEvidence = {}) {
if (!task) return '尚无任务'
const control = collectionControlState(task, evidence)
if (control) return control.summary
if (isNormalContinuation(task)) return '本批已完成,等待续采'
const states: Record<string, string> = { WAITING_SOURCE: '等待数据准备', WAITING_UPSTREAM: '等待数据准备', READY: '等待执行', QUEUED: '等待执行', WAITING: '等待执行', RUNNING: '正在执行', RETRY_WAIT: '等待重试', YIELDED: '本批已完成,等待续采', DEAD: '已停止,需处理', SUCCEEDED: '执行完成', FAILED: '执行失败' }
return states[task.taskStatus] ?? label(task.taskStatus)
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { Info } from '@lucide/vue'
import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -96,7 +97,7 @@ watch(warehouseCode, code => { if (code) { selectedWarehouse = code; emit('wareh
const fields = computed<QueryField[]>(() => {
const result: QueryField[] = [
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 210, placeholder: '请选择仓库(必选)', props: { clearable: false }, options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] },
{ key: 'customerCode', label: '客户', type: 'select', width: 210, options: ownerDirectory.options.value, placeholder: '名称或编码搜索', props: { filterable: true, clearable: true, disabled: !!drilldown.value } },
{ key: 'customerCode', label: '货主', type: 'select', width: 210, options: ownerDirectory.options.value, placeholder: '货主名称或编码', props: { filterable: true, clearable: true, disabled: !!drilldown.value } },
{ key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
]
if (props.mode === 'inventory') result.push(
@@ -123,7 +124,7 @@ async function sortSalesQuantity() {
const summaryMode = computed(() => props.mode === 'sales' && tab.value === 'summary')
const columns = computed<DataTableColumn[]>(() => [
...(summaryMode.value ? [{ key: 'actions', label: '操作', width: 110, slotName: 'actions', fixed: 'left' }] : []),
{ prop: 'sku', label: 'SKU', minWidth: 170, ...(props.mode === 'inventory' ? { slotName: 'sku' } : {}) },
{ prop: 'sku', label: 'SKU', minWidth: 170, ...(props.mode === 'inventory' ? { slotName: 'sku', fixed: 'left' } : {}) },
{ prop: 'productName', label: '商品名称', minWidth: 190 },
{ key: 'owner', label: '所属货主', minWidth: 140, slotName: 'owner' },
{ key: 'unit', label: '单位', width: 70, slotName: 'unit' },
@@ -342,28 +343,29 @@ onDeactivated(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); tr
onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); document.removeEventListener('visibilitychange', visibility) })
</script>
<template>
<AdminPage class="admin-density">
<AdminPage class="admin-density admin-workbench">
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
<ListPageLayout v-else>
<div class="workbench-table" :class="{ 'is-compact-toolbar': mode === 'sales' && !$slots['view-tabs'] }">
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" :empty-text="!warehouseCode ? '请选择仓库后查询SKU' : error ? '查询失败,请重试' : '暂无符合条件的SKU数据'">
<template v-if="$slots['view-tabs']" #toolbar-start><slot name="view-tabs" /></template>
<template #filters><p v-if="ownerDirectory.error.value" role="alert">{{ ownerDirectory.error.value }}</p><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营SKU筛选" :preference-key="`self-operated-${mode}-query`" :loading="loading" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices>
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window-summary">销量统计(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }},共 {{ displayedWindowDays }} 天。{{ origin ? '沿用经营总览所选区间;未完整的日期不推算日均。' : '不含今天;今天的出库不计入日均及可售天数。' }}库存使用最新已查询的采集值。</p>
<p v-if="mode === 'inventory' && context" class="analytics-note workbench-note" data-testid="inventory-window-summary">销量统计(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }},共 {{ displayedWindowDays }} 天。{{ origin ? '沿用经营总览所选区间;未完整的日期不推算日均。' : '不含今天;今天的出库不计入日均及可售天数。' }}库存使用最新已查询的采集值。</p>
<p v-if="routeProblem" role="alert">{{ routeProblem }};已停止查询,不能按全仓条件继续。<el-button @click="returnOverview">返回经营总览</el-button></p>
<div v-if="origin" class="density-actions"><span>{{ drilldown?.summary ?? '来自经营总览 · 来源筛选已清除,保留原有效版本' }}</span><el-button v-if="drilldown" @click="clearDrilldown">清除来源条件</el-button><el-button @click="returnOverview">返回经营总览</el-button></div>
<p v-if="runtimeError" class="analytics-note" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p>
<p v-if="runtimeError" class="analytics-note workbench-note" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p>
<LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<p v-if="salesRangePending" class="analytics-note" role="status">日期范围尚未应用,请选择完整起止日期后点击查询。<template v-if="rows.length">当前列表及每日趋势仍使用上次已应用的统计区间。</template></p>
<p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据可用。' }}<template v-if="origin">请返回经营总览刷新原图。</template><template v-else>当前列表仍使用上次查询版本。<el-button :disabled="loading" @click="search">更新数据</el-button></template></p>
<p v-if="salesRangePending" class="analytics-note workbench-note" role="status">日期范围尚未应用,请选择完整起止日期后点击查询。<template v-if="rows.length">当前列表及每日趋势仍使用上次已应用的统计区间。</template></p>
<p v-if="newData || newDay" class="analytics-note workbench-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据可用。' }}<template v-if="origin">请返回经营总览刷新原图。</template><template v-else>当前列表仍使用上次查询版本。<el-button :disabled="loading" @click="search">更新数据</el-button></template></p>
</template>
<template #toolbar-actions><el-button :disabled="!warehouseCode" @click="link('/self-operated/collection')">查看采集状态</el-button><el-button :disabled="!warehouseCode" @click="link(mode === 'sales' ? '/self-operated/inventory' : '/self-operated/sales')">{{ mode === 'sales' ? '查看库存' : '查看销量' }}</el-button>
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间" teleported>
<template #reference><el-button aria-label="统计口径与采集时间" title="统计口径与采集时间"><Info :size="16" aria-hidden="true" /></el-button></template>
<div class="analytics-information admin-density">
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-observation">本仓库存采集时间(北京时间):{{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.inventoryObservedFrom && context.inventoryObservedUntil ? '当前列表来自本仓一轮完整分页观测,各页在该时段内采集,不代表同一瞬间的库存。' : '尚无完整库存观测时段,缺失时间不以发布时间代替。' }}</p>
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window">销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。</p>
<div class="analytics-information admin-density admin-workbench">
<p v-if="mode === 'inventory' && context" class="analytics-note workbench-note" data-testid="inventory-observation">本仓库存采集时间(北京时间):{{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.inventoryObservedFrom && context.inventoryObservedUntil ? '当前列表来自本仓一轮完整分页观测,各页在该时段内采集,不代表同一瞬间的库存。' : '尚无完整库存观测时段,缺失时间不以发布时间代替。' }}</p>
<p v-if="mode === 'inventory' && context" class="analytics-note workbench-note" data-testid="inventory-window">销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。</p>
<div class="analytics-rules">
<p>{{ summaryMode ? '最近3/7/15/30天均截至昨日(北京时间);自定义按所选起止日期查询。完整区间显示出库量,未完整区间显示已采集量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
@@ -403,8 +405,9 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
</template>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity'] as const" :key="key" #[key]="{ row }">{{ quantityText(asInventory(row)[key]) }}<span v-if="asMetric(row).quantityStatuses?.[key] && asMetric(row).quantityStatuses?.[key] !== 'VALID'">({{ label(asMetric(row).quantityStatuses?.[key]) }})</span></template>
</DataTable>
</div>
</ListPageLayout>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable>
<AppDialog v-model="trendOpen" class="admin-density-dialog admin-workbench is-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable>
<div class="admin-density">
<LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError" compact><template #details><ApiErrorMeta v-if="trendErrorDetails" :details="trendErrorDetails" /></template></LoadErrorState>
<template v-if="daily">
@@ -422,7 +425,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
.inventory-sort-arrows { display: inline-flex; flex-direction: column; color: var(--el-text-color-placeholder); font-size: 9px; line-height: 10px; }
.inventory-sort-arrows .active { color: var(--el-color-primary); }
.inventory-missing { display: block; color: var(--el-text-color-secondary); }
.analytics-note, .analytics-rules { margin: 0; font-size: var(--admin-font-caption); }
.analytics-rules { margin: 0; font-size: var(--admin-font-caption); }
.analytics-information { max-height: 65vh; overflow-y: auto; overflow-wrap: anywhere; }
.analytics-information > p { margin: var(--admin-space-2) 0; }
.analytics-rules p { margin: var(--admin-space-2) 0; }
@@ -2,6 +2,7 @@
import { watch } from 'vue'
import { ElButton } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import '@/styles/adminWorkbench.css'
const props = withDefaults(defineProps<{ title: string; width?: string; resetOn?: unknown; inline?: boolean; buttonLabel?: string }>(), { width: '1040px' })
const open = defineModel<boolean>({ default: false })
watch(() => props.resetOn, () => { open.value = false })
@@ -9,8 +10,8 @@ watch(() => props.resetOn, () => { open.value = false })
<template>
<div class="chart-disclosure" :class="{ 'chart-disclosure-inline': inline }">
<ElButton :link="inline" :type="inline ? 'primary' : undefined" :title="title" @click="open = true">{{ buttonLabel || title }}</ElButton>
<AppDialog v-model="open" class="admin-density-dialog" :title="title" :width="width" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close>
<div class="admin-density chart-evidence"><slot /></div>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="title" :width="width" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close>
<div class="admin-density chart-evidence workbench-evidence"><slot /></div>
</AppDialog>
</div>
</template>
@@ -12,6 +12,9 @@ function overview(account: Task | null = source): Overview {
warehouses: [{ warehouseCode: 'US01', dataFamily: 'OUTBOUND', activeTasks: [child], sharedUpstream: account }, { warehouseCode: 'US02', dataFamily: 'OUTBOUND', activeTasks: [{ ...child, taskNo: 'CHILD-2', warehouseCode: 'US02' }], sharedUpstream: account }] as WarehouseOverview[] }
}
const options = { global: { stubs: { 'el-button': true } } }
const retained: Task = { ...source, dataFamily: 'MASTER_DATA', taskStatus: 'RETRY_WAIT', attemptCount: 1, latestAttemptNo: 1, blockingReason: 'RETRY_SCHEDULED',
progress: { ...source.progress, phase: 'PRODUCTS', unit: 'PAGE', completed: 37, total: 86, attemptNo: 1 },
latestAttempt: { attemptNo: 1, status: 'YIELDED', phase: 'PRODUCTS', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 20, errorCode: null, failureSummary: null } }
describe('single account collection monitor', () => {
it('identifies account master-data work without calling it outbound detail fetching', () => {
const task = { ...source, dataFamily: 'MASTER_DATA', taskType: 'MASTER_DATA', progress: { ...source.progress!, phase: 'PRODUCTS', unit: 'PAGE', completed: 2, total: 86 } }
@@ -25,7 +28,8 @@ describe('single account collection monitor', () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: data } })
expect(w.find('.account-schedules').text()).toContain('US01 / US02')
expect(w.find('.account-schedules').text()).toContain('2026/10/14 00:05:00')
expect(w.find('.account-schedules').text()).toContain('计划时间不代表当前任务完成时间')
expect(w.get('.account-schedules strong').attributes('title')).toContain('计划时间不代表当前任务完成时间')
expect(w.find('.account-schedules').text()).not.toContain('计划时间不代表当前任务完成时间')
await w.setProps({ stale: true })
expect(w.find('.account-schedules').text()).toContain('上次自动采集计划')
await w.setProps({ overview: { ...data, warehouses: data.warehouses.map(row => ({ ...row, executionEnabled: false })) } })
@@ -55,7 +59,13 @@ describe('single account collection monitor', () => {
expect(w.findComponent(CollectionTaskProgress).props('task').taskNo).toBe('SOURCE')
expect(w.text()).toContain('12.1%'); expect(w.text()).toContain('2448')
expect(w.text()).toContain('US01 · 出库销量'); expect(w.text()).toContain('US02 · 出库销量')
expect(w.text()).toContain('完整队列数量及下一任务顺序尚未提供')
expect(w.get('.account-upnext h3').attributes('title')).toContain('完整队列数量及下一任务顺序尚未提供')
expect(w.text()).not.toContain('完整队列数量及下一任务顺序尚未提供')
expect(w.get('.account-flow h4').attributes('title')).toBe('只标记已确认的推进状态')
expect(w.get('.account-results > .account-section-title h4').attributes('title')).toBe('采集进度与报表发布分别确认')
expect(w.text()).not.toContain('只标记已确认的推进状态')
expect(w.text()).not.toContain('采集进度与报表发布分别确认')
expect(w.text()).not.toContain('运行看板')
expect(w.text()).not.toContain('查看配置'); expect(w.text()).not.toContain('执行心跳')
w.unmount()
})
@@ -74,8 +84,71 @@ describe('single account collection monitor', () => {
it('preserves real last counts when stale without claiming an estimate', () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview(), stale: true } })
expect(w.text()).toContain('339'); expect(w.text()).toContain('状态更新失败')
expect(w.text()).toContain('已暂停估算')
expect(w.find('.collection-estimate').exists()).toBe(false)
expect(w.get('.account-live').text()).toBe('状态待确认')
expect(w.find('.account-flow li[data-state="active"]').exists()).toBe(false)
w.unmount()
})
it('presents off as the main state while retaining a completed batch and reliable stage counts', () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: { ...overview(retained), collectionMode: 'OFF', warehouses: [] } } })
expect(w.get('.account-live').text()).toBe('采集已关闭')
expect(w.get('.account-status').text()).toBe('本批已完成')
expect(w.find('.collection-estimate').exists()).toBe(false)
expect(w.text().match(/采集已关闭/g)).toHaveLength(1)
expect(w.text()).not.toContain('等待续采')
expect(w.get('[role="progressbar"]').attributes('aria-valuenow')).toBe('43')
expect(w.findAll('.collection-count-grid dd strong').map(x => x.text())).toEqual(['86', '37', '49'])
const stages = w.findAll('.account-flow li')
expect(stages[0]?.text()).toContain('等待状态确认')
expect(stages[1]?.text()).toContain('当前阶段')
expect(stages[2]?.text()).toContain('等待状态确认')
expect(w.find('.account-flow li[data-state="done"]').exists()).toBe(false)
w.unmount()
})
it('retains running evidence when collection is off, then makes stale evidence explicit', async () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: { ...overview(source), collectionMode: 'OFF' } } })
expect(w.get('.account-live').text()).toBe('已关闭 · 执行中')
expect(w.find('.account-status').exists()).toBe(false)
expect(w.find('.account-flow li[data-state="active"]').exists()).toBe(true)
expect(w.find('.collection-estimate').exists()).toBe(false)
await w.setProps({ stale: true })
expect(w.get('.account-live').text()).toBe('状态待确认')
expect(w.find('.account-status').exists()).toBe(false)
expect(w.get('.account-now .account-eyebrow').text()).toBe('上次阶段记录')
expect(w.find('.account-flow li[data-state="active"]').exists()).toBe(false)
expect(w.get('.account-flow li[data-state="stale"]').text()).toContain('上次阶段')
expect(w.find('.collection-estimate').exists()).toBe(false)
expect(w.text()).toContain('12.1%')
w.unmount()
})
it('uses explicit account pause evidence without marking earlier stages as completed', () => {
const data = overview(retained)
data.warehouses = [{ ...data.warehouses[0]!, activeTasks: [], sharedUpstream: null, protection: {
accountPauseReason: 'MANUAL_PAUSE', accountCooldownUntil: null,
dailyRequestBudget: null, reservedHttpRequests: null, confirmedHttpRequests: null, budgetDate: null,
} }]
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: data } })
expect(w.get('.account-live').text()).toBe('已暂停')
expect(w.get('.account-status').text()).toBe('本批已完成')
expect(w.get('.account-flow li[data-state="paused"]').text()).toContain('当前阶段')
expect(w.find('.account-flow li[data-state="done"]').exists()).toBe(false)
expect(w.text()).not.toContain('等待续采')
w.unmount()
})
it('keeps idle distinct from off and unknown when there is no current task', async () => {
const data = { ...overview(null), warehouses: [] }
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: data } })
expect(w.get('.account-live').text()).toBe('待机')
expect(w.get('.account-empty h3').text()).toBe('等待下一次采集')
await w.setProps({ overview: { ...data, collectionMode: 'OFF' } })
expect(w.get('.account-live').text()).toBe('采集已关闭')
expect(w.find('.account-empty h3').exists()).toBe(false)
await w.setProps({ stale: true })
expect(w.get('.account-live').text()).toBe('状态待确认')
expect(w.get('.account-empty').text()).not.toContain('当前没有正在运行的采集')
await w.setProps({ overview: null, stale: false })
expect(w.get('.account-live').text()).toBe('未连接')
expect(w.text()).not.toContain('待机')
w.unmount()
})
})
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Activity, Check, Clock3, Database, Pause, RefreshCw } from '@lucide/vue'
import { Check, Clock3, Database, Pause, RefreshCw } from '@lucide/vue'
import type { Overview, Task } from '../types'
import CollectionTaskProgress from './CollectionTaskProgress.vue'
import { beijingTime, phaseText, label } from '../state'
import { taskStateText } from '../collectionPresentation'
import { collectionControlState, isNormalContinuation, taskStateText } from '../collectionPresentation'
const props = defineProps<{ overview: Overview | null; loading?: boolean; stale?: boolean; visible?: boolean }>()
const props = withDefaults(defineProps<{ overview: Overview | null; loading?: boolean; stale?: boolean; visible?: boolean }>(), { visible: true })
defineEmits<{ refresh: [] }>()
const activeStates = new Set(['RUNNING', 'APPLYING', 'READY', 'QUEUED', 'WAITING', 'WAITING_SOURCE', 'WAITING_UPSTREAM', 'RETRY_WAIT', 'YIELDED'])
const tasks = computed(() => {
@@ -18,6 +18,18 @@ const tasks = computed(() => {
})
const running = computed(() => tasks.value.filter(t => ['RUNNING', 'APPLYING'].includes(t.taskStatus)))
const current = computed(() => running.value[0] ?? tasks.value[0] ?? null)
const accountPaused = computed(() => Boolean(props.overview?.warehouses.some(w => w.protection?.accountPauseReason)))
const controlEvidence = computed(() => ({ collectionMode: props.overview?.collectionMode, accountPaused: accountPaused.value }))
const control = computed(() => collectionControlState(current.value, controlEvidence.value))
const batchComplete = computed(() => isNormalContinuation(current.value) || current.value?.taskStatus === 'YIELDED')
const liveText = computed(() => !props.overview ? props.loading ? '连接中' : '未连接'
: props.stale ? '状态待确认' : props.visible === false ? '暂停更新'
: control.value?.summary ?? (running.value.length ? '采集中' : '待机'))
const currentStateText = computed(() => props.stale ? '执行状态待确认' : props.visible === false ? '页面已暂停更新' : taskStateText(current.value, controlEvidence.value))
const currentEyebrow = computed(() => props.stale || props.visible === false ? '上次阶段记录'
: running.value.length ? '正在进行' : '当前等待')
const currentTitle = computed(() => current.value?.progress?.phase ? phaseText(current.value.progress.phase)
: control.value || props.stale || props.visible === false ? '任务进度' : currentStateText.value)
const waiting = computed(() => tasks.value.filter(t => t.taskNo !== current.value?.taskNo && !['RUNNING', 'APPLYING'].includes(t.taskStatus)))
const upcoming = computed(() => {
const groups = new Map<string, number>()
@@ -48,16 +60,15 @@ const nextStep = computed(() => {
if (['INVENTORY', 'FETCH'].includes(phase ?? '')) return '库存完整性校验 → 统计计算 → 发布结果'
if (['VALIDATE', 'VALIDATION', 'MERGE'].includes(phase ?? '')) return '统计计算 → 发布结果'
if (['BUILD', 'BUILDING', 'BUILT'].includes(phase ?? '')) return '发布结果'
if (['PUBLISH', 'PUBLISHED'].includes(phase ?? '')) return '本次发布完成后,由调度继续安排后续任务'
return '等待调度确认下一步'
if (['PUBLISH', 'PUBLISHED'].includes(phase ?? '')) return control.value ? '本次发布结果需另行确认' : '本次发布完成后,由调度继续安排后续任务'
return control.value ? '后续步骤待确认' : '等待调度确认下一步'
})
const scopeText = computed(() => current.value?.dataFamily === 'MASTER_DATA' ? '领星账号 · 客户/商品资料' : current.value?.taskNo === props.overview?.accountActivity?.taskNo || current.value?.progress?.progressScope === 'ACCOUNT_SHARED' ? '领星账号 · 出库数据' : `${current.value?.warehouseCode || '领星账号'} · ${current.value?.dataFamily === 'MASTER_DATA' ? '客户/商品资料' : current.value?.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量'}`)
const warehouseGroups = computed(() => [...new Set(props.overview?.warehouses.map(w => w.warehouseCode) ?? [])].map(code => ({ code, rows: props.overview!.warehouses.filter(w => w.warehouseCode === code) })))
const latestPublication = computed(() => (props.overview?.warehouses ?? []).filter(w => w.lastPublishedAt).sort((a, b) => Date.parse(b.lastPublishedAt!) - Date.parse(a.lastPublishedAt!))[0])
const idleReason = computed(() => {
if (!props.overview) return props.loading ? '正在连接,读取账号状态' : '暂未取得账号状态'
if (props.overview.collectionMode === 'OFF') return '采集已关闭'
if (props.overview.warehouses.some(w => w.protection?.accountPauseReason)) return '账号已暂停'
if (props.visible === false) return '页面已暂停更新'
if (props.overview.warehouses.some(w => ['DEAD', 'FAILED', 'ABORTED'].includes(w.currentTask?.taskStatus ?? ''))) return '暂无活动任务,最近有任务停止'
return '等待下一次采集'
})
@@ -69,107 +80,144 @@ function nodeState(phases: string[]) {
if (!p) return 'idle'
const evidence = [p, ...(p.stages ?? [])].find(s => phases.includes(s.phase ?? '') && s.attemptNo != null && s.attemptNo === p.attemptNo && s.scanRound === p.scanRound && s.sliceFrom === p.sliceFrom && s.sliceUntil === p.sliceUntil && s.progressScope === p.progressScope && s.stageFinishedAt)
if (evidence) return 'done'
return !props.stale && phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle'
if (!phases.includes(p.phase ?? '')) return 'idle'
if (props.stale || props.visible === false) return 'stale'
if (running.value.includes(current.value!)) return 'active'
return control.value ? 'paused' : 'waiting'
}
function nodeText(phases: string[]) {
const state = nodeState(phases)
if (state === 'done') return '已确认完成'
if (state === 'active') return '正在处理'
if (state === 'stale') return '上次阶段'
if (state === 'paused') return '当前阶段'
if (state === 'waiting') return '当前阶段 · 等待处理'
return current.value || control.value || !props.overview || props.stale ? '等待状态确认' : '待机'
}
</script>
<template>
<section class="account-monitor" aria-label="领星账号运行进度" :data-running="running.length > 0 && !stale">
<header class="account-header"><div class="account-brand"><Database :size="22" /><div><h2>数据采集</h2><span>领星账号 · 运行看板</span></div></div><div class="account-controls"><span class="account-live"><i />{{ stale ? '更新中断' : !overview ? '连接中' : running.length ? '采集中' : '待机' }}</span><el-button :loading="loading" @click="$emit('refresh')"><RefreshCw :size="15" />刷新状态</el-button></div></header>
<section class="account-monitor workbench-panel is-flush" aria-label="领星账号运行进度" :data-running="running.length > 0 && !stale && visible !== false">
<header class="account-header">
<div class="account-brand">
<span class="workbench-icon" aria-hidden="true"><Database :size="22" /></span>
<div class="workbench-heading"><h2 class="workbench-title">数据采集</h2></div>
</div>
<div class="account-controls">
<span class="account-live workbench-badge workbench-tone" :class="{ 'is-toned': (running.length > 0 || control) && !stale && visible !== false }" :data-tone="control ? 'amber' : 'green'"><i />{{ liveText }}</span>
<el-button :loading="loading" @click="$emit('refresh')"><RefreshCw :size="15" />刷新状态</el-button>
</div>
</header>
<div class="account-body">
<p v-if="stale" role="status">{{ overview ? '状态更新失败,以下保留最后一次读取的进度。' : '暂未取得账号状态,请稍后刷新。' }}</p>
<p v-if="stale" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ overview ? '状态更新失败,以下保留最后一次读取的进度。' : '暂未取得账号状态,请稍后刷新。' }}</p>
<div class="account-main"><section class="account-primary">
<template v-if="current">
<div class="account-now"><div><span class="account-eyebrow">{{ running.length ? '正在进行' : '当前等待' }}</span><h3>{{ current.progress?.phase ? phaseText(current.progress.phase) : taskStateText(current) }}</h3><p>{{ scopeText }}<template v-if="current.queryFrom"> · {{ beijingTime(current.queryFrom) }} 至 {{ beijingTime(current.queryUntil) }}(结束时刻不含)</template></p></div><span class="account-status">{{ taskStateText(current) }}</span></div>
<CollectionTaskProgress :key="current.taskNo" :task="current" compact :stale="stale" :visible="visible" :observed-at="overview?.observedAt" />
<p v-if="running.length > 1">同时运行:<span v-for="task in running.slice(1)" :key="task.taskNo">{{ task.warehouseCode || '账号' }} · {{ phaseText(task.progress?.phase) }};</span></p>
<div class="account-now">
<div class="workbench-heading">
<span v-if="!control || stale || visible === false" class="account-eyebrow workbench-note">{{ currentEyebrow }}</span>
<h3 class="workbench-title">{{ currentTitle }}</h3>
<p class="workbench-note">{{ scopeText }}<template v-if="current.queryFrom"> · {{ beijingTime(current.queryFrom) }} 至 {{ beijingTime(current.queryUntil) }}(结束时刻不含)</template></p>
</div>
<span v-if="!stale && visible !== false && (!control || batchComplete)" class="account-status workbench-badge is-toned">{{ control ? '本批已完成' : currentStateText }}</span>
</div>
<CollectionTaskProgress :key="current.taskNo" :task="current" compact :stale="stale" :visible="visible" :observed-at="overview?.observedAt" :collection-mode="overview?.collectionMode" :account-paused="accountPaused" />
<p v-if="running.length > 1" class="workbench-note">{{ stale || visible === false ? '上次读取的其他执行记录:' : '同时运行:' }}<span v-for="task in running.slice(1)" :key="task.taskNo">{{ task.warehouseCode || '账号' }} · {{ phaseText(task.progress?.phase) }};</span></p>
</template>
<div v-else class="account-empty"><div class="standby-ring"><Pause :size="38" /><span>{{ loading ? '读取中' : !overview ? '未连接' : '待机' }}</span></div><div><span class="account-eyebrow">{{ overview ? '当前没有正在运行的采集' : '连接状态' }}</span><h3>{{ idleReason }}</h3><div class="last-result"><Check :size="18" v-if="latestPublication" /><Clock3 :size="18" v-else /><div><strong>{{ latestPublication ? '最近发布结果' : '最近发布结果尚未提供' }}</strong><span v-if="latestPublication">{{ latestPublication.warehouseCode }} · {{ latestPublication.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<br />{{ beijingTime(latestPublication.lastPublishedAt) }}</span></div></div></div></div>
<section class="account-flow"><div class="account-section-title"><h4><Activity :size="16" />处理阶段</h4><span>只标记已确认的推进状态</span></div><ol :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }"><li v-for="(node, index) in flow" :key="node.name" :data-state="nodeState(node.phases)"><span class="step-index"><Check v-if="nodeState(node.phases) === 'done'" :size="16" /><template v-else>{{ index + 1 }}</template></span><strong>{{ node.name }}</strong><small>{{ nodeState(node.phases) === 'done' ? '已确认完成' : nodeState(node.phases) === 'active' ? '正在处理' : current || !overview ? '等待状态确认' : '待机' }}</small></li></ol></section>
<div v-else class="account-empty">
<div class="standby-ring"><Pause :size="38" /><span v-if="!control && !stale">{{ !overview ? loading ? '读取中' : '未连接' : visible === false ? '暂停更新' : '待机' }}</span></div>
<div>
<span class="account-eyebrow workbench-note">{{ !overview ? '连接状态' : stale || visible === false ? '上次账号记录' : '当前没有正在运行的采集' }}</span>
<h3 v-if="!control && !stale" class="workbench-title">{{ idleReason }}</h3>
<div class="last-result workbench-panel is-compact" :class="{ 'workbench-tone': Boolean(latestPublication) }" data-tone="green" :data-ready="Boolean(latestPublication)">
<Check :size="18" v-if="latestPublication" /><Clock3 :size="18" v-else />
<div><strong>{{ latestPublication ? '最近发布结果' : '最近发布结果尚未提供' }}</strong><span v-if="latestPublication" class="workbench-note">{{ latestPublication.warehouseCode }} · {{ latestPublication.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<br />{{ beijingTime(latestPublication.lastPublishedAt) }}</span></div>
</div>
</div>
</div>
<section class="account-flow workbench-section">
<div class="account-section-title workbench-section-heading"><h4 class="workbench-section-title" title="只标记已确认的推进状态">处理阶段</h4></div>
<ol :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }">
<li v-for="(node, index) in flow" :key="node.name" class="workbench-step workbench-tone" :data-tone="nodeState(node.phases) === 'done' ? 'green' : 'blue'" :data-state="nodeState(node.phases)">
<span class="step-index workbench-step-index"><Check v-if="nodeState(node.phases) === 'done'" :size="16" /><template v-else>{{ index + 1 }}</template></span>
<strong>{{ node.name }}</strong><small class="workbench-note">{{ nodeText(node.phases) }}</small>
</li>
</ol>
</section>
<section class="account-results" v-if="warehouseGroups.length"><div class="account-section-title"><h4><Database :size="16" />各仓最新数据</h4><span>采集进度与报表发布分别确认</span></div><div class="warehouse-results"><article v-for="group in warehouseGroups" :key="group.code"><h4>{{ group.code }}</h4><div v-for="row in group.rows" :key="row.dataFamily" class="warehouse-result"><span>{{ row.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<small>{{ row.lastPublishedAt ? beijingTime(row.lastPublishedAt) : '暂无发布结果' }}</small></span><span :class="{ 'result-ready': Boolean(row.lastPublishedAt) }">{{ row.lastPublishedAt ? '已有结果' : '尚未发布' }}<small>{{ row.lastPublishedAt ? label(row.dataFreshness) : '—' }}</small></span></div></article></div></section>
</section><aside class="account-upnext"><span class="account-eyebrow">后续安排</span><h3>{{ !overview ? '暂未取得安排' : upcoming.length ? '等待处理' : '等待新任务' }}</h3><ul v-if="upcoming.length"><li v-for="group in upcoming" :key="group.name"><span>{{ group.name }}</span><b>{{ group.count }} 项</b></li></ul><p v-else>{{ overview ? '当前摘要中暂无其他待处理项' : '连接恢复后更新后续任务' }}</p><div v-if="current" class="account-next"><strong>本阶段之后</strong><span>{{ nextStep }}</span></div><div v-if="schedules.length" class="account-next account-schedules"><strong>{{ stale ? '上次自动采集计划' : '下次自动采集计划' }}</strong><span v-for="schedule in schedules" :key="schedule.family + schedule.at">{{ schedule.family }} · {{ schedule.warehouses.join(' / ') }}<br />{{ beijingTime(schedule.at) }}(北京时间)</span><small>计划时间不代表当前任务完成时间。</small></div><small>仅已知活动摘要;完整队列数量及下一任务顺序尚未提供。</small></aside></div>
<footer><span>最近推进 {{ beijingTime(current?.progressUpdatedAt) }}</span><span>状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间</span></footer>
<section class="account-results workbench-section" v-if="warehouseGroups.length">
<div class="account-section-title workbench-section-heading"><h4 class="workbench-section-title" title="采集进度与报表发布分别确认">各仓最新数据</h4></div>
<div class="warehouse-results">
<article v-for="group in warehouseGroups" :key="group.code" class="workbench-panel is-compact">
<h4 class="workbench-chart-title">{{ group.code }}</h4>
<div v-for="row in group.rows" :key="row.dataFamily" class="warehouse-result">
<span>{{ row.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<small class="workbench-note">{{ row.lastPublishedAt ? beijingTime(row.lastPublishedAt) : '暂无发布结果' }}</small></span>
<span class="workbench-tone" data-tone="green" :class="{ 'result-ready': Boolean(row.lastPublishedAt) }">{{ row.lastPublishedAt ? '已有结果' : '尚未发布' }}<small class="workbench-note">{{ row.lastPublishedAt ? label(row.dataFreshness) : '—' }}</small></span>
</div>
</article>
</div>
</section>
</section>
<aside class="account-upnext workbench-panel is-compact">
<span class="account-eyebrow workbench-note">后续安排</span>
<h3 class="workbench-chart-title" title="仅已知活动摘要;完整队列数量及下一任务顺序尚未提供。">{{ !overview ? '暂未取得安排' : stale || visible === false ? '上次待处理记录' : control ? upcoming.length ? '已保留的待处理项' : '暂无其他待处理项' : upcoming.length ? '等待处理' : '等待新任务' }}</h3>
<ul v-if="upcoming.length"><li v-for="group in upcoming" :key="group.name"><span>{{ group.name }}</span><b class="workbench-badge">{{ group.count }} 项</b></li></ul>
<p v-else class="workbench-note">{{ overview ? '当前摘要中暂无其他待处理项' : '连接恢复后更新后续任务' }}</p>
<div v-if="current" class="account-next workbench-panel is-compact"><strong>{{ control ? '后续处理步骤' : '本阶段之后' }}</strong><span class="workbench-note">{{ nextStep }}</span></div>
<div v-if="schedules.length" class="account-next account-schedules workbench-panel is-compact">
<strong title="计划时间不代表当前任务完成时间。">{{ stale ? '上次自动采集计划' : control ? '原采集计划' : '下次自动采集计划' }}</strong>
<span v-for="schedule in schedules" :key="schedule.family + schedule.at" class="workbench-note">{{ schedule.family }} · {{ schedule.warehouses.join(' / ') }}<br />{{ beijingTime(schedule.at) }}(北京时间)</span>
</div>
</aside></div>
<footer class="workbench-note"><span>最近推进 {{ beijingTime(current?.progressUpdatedAt) }}</span><span>状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间</span></footer>
</div>
</section>
</template>
<style scoped>
.account-monitor { color: var(--el-text-color-primary); background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius-lg); overflow: hidden; }
.account-header, .account-header > div, .account-now, .account-next, .account-pending, footer { display: flex; align-items: center; gap: var(--admin-space-4); }
.account-header { justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--el-border-color-light); }
h2, h3, p { margin: 0; }
h2 { font-size: var(--admin-font-section); }
h3 { font-size: 22px; margin: var(--admin-space-2) 0; }
.account-body { padding: var(--admin-space-6); }
.account-body > p[role="status"] { margin-bottom: var(--admin-space-5); color: var(--el-color-warning-dark-2); }
.account-header span, p, footer, small { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.account-monitor { overflow: hidden; }
.account-header, .account-brand, .account-controls, .account-live, .account-now { display: flex; align-items: center; gap: var(--workbench-grid-gap); }
.account-header { justify-content: space-between; padding: var(--workbench-card-space); border-bottom: 1px solid var(--workbench-border); }
.account-body { padding: var(--workbench-card-space); }
.account-body > p[role="status"] { margin-bottom: var(--workbench-section-gap); }
.account-now { justify-content: space-between; align-items: flex-start; }
.account-eyebrow { color: var(--el-color-primary); font-size: var(--admin-font-caption); }
.account-status { color: var(--el-color-primary); background: var(--el-color-primary-light-9); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); white-space: nowrap; }
.account-next { padding: var(--admin-space-4); background: var(--el-fill-color-light); border-left: 3px solid var(--el-color-primary); }
.account-next small { display: block; margin-top: var(--admin-space-2); }
.account-pending { flex-wrap: wrap; margin-top: var(--admin-space-5); }
.account-pending small { flex-basis: 100%; }
footer { justify-content: space-between; margin-top: var(--admin-space-5); padding-top: var(--admin-space-4); border-top: 1px solid var(--el-border-color-light); }
.account-main { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--admin-space-6); align-items: start; }
.account-primary { min-width: 0; }
.account-brand, .account-controls, .account-live, .account-section-title, h4 { display: flex; align-items: center; gap: 10px; }
.account-brand > svg { color: var(--el-color-primary); background: var(--el-color-primary-light-9); padding: var(--admin-space-3); box-sizing: content-box; border-radius: var(--admin-radius-lg); }
.account-header { background: var(--el-bg-color); }
.account-header h2 { margin-bottom: 4px; }
.account-live { color: var(--el-text-color-secondary); background: var(--el-fill-color-light); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius-lg); }
[data-running="true"] .account-live { color: var(--el-color-success-dark-2); background: var(--el-color-success-light-9); }
.account-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--el-text-color-secondary); }
[data-running="true"] .account-live i { background: var(--el-color-success); animation: signal 2s ease-in-out infinite; }
.account-now > div, .account-brand > div { min-width: 0; }
.account-status, .account-live { flex: 0 0 auto; white-space: nowrap; }
.account-live { gap: 8px; }
.account-live i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
[data-running="true"] .account-live i { animation: signal 2s ease-in-out infinite; }
.account-controls :deep(.el-button) { gap: 8px; }
.account-upnext { border-left: 1px solid var(--el-border-color-light); padding-left: 24px; }
.account-upnext h3 { font-size: 20px; margin: 8px 0 12px; }
.account-monitor footer { display: flex; justify-content: space-between; gap: var(--workbench-grid-gap); margin-top: var(--workbench-section-gap); padding-top: var(--workbench-section-gap); border-top: 1px solid var(--workbench-border); }
.account-main { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--workbench-grid-gap); align-items: start; }
.account-primary { min-width: 0; }
.account-upnext h3 { margin: 8px 0 12px; }
.account-upnext ul { padding: 0; margin: 0; list-style: none; }
.account-upnext li { display: flex; justify-content: space-between; padding: 12px 0; gap: 8px; border-bottom: 1px solid var(--el-border-color-light); font-size: var(--admin-font-caption); }
.account-upnext > small { display: block; margin-top: 18px; line-height: 1.7; }
.account-next { display: grid; padding: 14px; margin-top: 12px; border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); border-radius: var(--admin-radius); font-size: var(--admin-font-caption); line-height: 1.8; }
.account-empty { display: grid; grid-template-columns: 180px minmax(0, 1fr); align-items: center; gap: var(--admin-space-6); min-height: 190px; }
.standby-ring { width: 168px; height: 168px; border: 10px solid var(--el-border-color-light); border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--el-text-color-secondary); box-sizing: border-box; }
.last-result { display: flex; gap: 12px; padding: 16px; margin-top: 24px; background: var(--el-fill-color-light); border-radius: 6px; }
.last-result > svg { color: var(--el-color-success); }
.last-result strong, .last-result span { display: block; font-size: var(--admin-font-caption); line-height: 1.8; }
.last-result span { color: var(--el-text-color-secondary); }
.account-flow, .account-results { margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px solid var(--el-border-color-light); }
.account-section-title { justify-content: space-between; margin-bottom: var(--admin-space-5); }
h4 { margin: 0; font-size: var(--admin-font-body); }
.account-section-title > span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.account-flow ol { display: grid; gap: 16px; list-style: none; margin: 0; padding: 0; }
.account-flow li { border-top: 2px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-lighter); padding: var(--admin-space-3); display: grid; grid-template-columns: 28px 1fr; gap: 8px; align-items: center; }
.account-upnext li { display: flex; justify-content: space-between; align-items: flex-start; padding: 12px 0; gap: 8px; border-bottom: 1px solid var(--workbench-border); font-size: var(--workbench-note-size); }
.account-upnext li > span { min-width: 0; overflow-wrap: anywhere; }
.account-upnext li > b { flex: 0 0 auto; }
.account-next { display: grid; gap: 8px; margin-top: 12px; font-size: var(--workbench-note-size); line-height: 1.8; }
.account-empty { display: grid; grid-template-columns: 180px minmax(0, 1fr); align-items: center; gap: var(--workbench-grid-gap); min-height: 190px; }
.standby-ring { width: 168px; height: 168px; border: 10px solid var(--workbench-border); border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--workbench-muted); box-sizing: border-box; }
.last-result { display: flex; gap: 12px; margin-top: var(--workbench-section-gap); }
.last-result > svg { flex: 0 0 auto; color: var(--workbench-muted); }
.last-result[data-ready="true"] > svg { color: var(--workbench-accent); }
.last-result strong { display: block; font-size: var(--workbench-note-size); line-height: 1.8; }
.account-flow, .account-results { margin-top: var(--workbench-section-gap); padding-top: var(--workbench-section-gap); border-top: 1px solid var(--workbench-border); }
.account-section-title { display: flex; align-items: center; justify-content: space-between; gap: var(--workbench-grid-gap); }
.account-flow ol { display: grid; gap: var(--workbench-grid-gap); list-style: none; margin: 0; padding: 0; }
.account-flow li { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 8px; align-items: center; }
.account-flow li small { grid-column: 2; }
.account-flow li strong { font-size: var(--admin-font-body); }
.step-index { display: grid; place-items: center; border-radius: 50%; width: 26px; height: 26px; background: var(--el-fill-color); font-size: var(--admin-font-caption); }
.account-flow li[data-state="active"] { border-color: var(--el-color-primary); color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.account-flow li[data-state="active"] .step-index { background: var(--el-color-primary); color: white; }
.account-flow li[data-state="done"] { border-color: var(--el-color-success); background: var(--el-color-success-light-9); }
.account-flow li[data-state="done"] .step-index { background: var(--el-color-success); color: white; }
.warehouse-results { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
.warehouse-results article { background: var(--el-fill-color-lighter); padding: 16px 20px; border: 1px solid var(--el-border-color-light); border-top: 3px solid var(--el-color-primary); border-radius: var(--admin-radius-lg); }
.warehouse-results article:nth-child(2n) { border-top-color: var(--el-color-success); }
.warehouse-results article h4 { color: var(--el-color-primary); }
.warehouse-results article:nth-child(2n) h4 { color: var(--el-color-success-dark-2); }
.warehouse-result { display: flex; justify-content: space-between; gap: 12px; padding-top: 14px; font-size: var(--admin-font-body); }
.warehouse-result small { display: block; margin-top: 4px; }
.account-flow li strong { min-width: 0; font-size: var(--workbench-note-size); overflow-wrap: anywhere; }
.warehouse-results { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
.warehouse-result { display: flex; justify-content: space-between; gap: 12px; padding-top: 14px; font-size: var(--workbench-note-size); }
.warehouse-result > span { min-width: 0; overflow-wrap: anywhere; }
.warehouse-result > span:last-child { text-align: right; }
.result-ready { color: var(--el-color-success); }
.account-monitor :deep(.collection-count-grid > div) { padding-inline: var(--admin-space-5); }
.account-monitor :deep(.collection-count-grid > div + div) { border-left: 1px solid var(--el-border-color-light); }
.account-monitor :deep(.collection-count-grid dd) { font-size: 36px; font-weight: 600; color: var(--el-text-color-primary); overflow-wrap: anywhere; }
.account-monitor :deep(.collection-count-grid > div:nth-child(2) dd) { color: var(--el-color-success-dark-2); }
.account-monitor :deep(.collection-count-grid > div:nth-child(3) dd) { color: var(--el-color-warning-dark-2); }
.account-monitor :deep(.collection-count-grid dd small) { display: block; margin-top: var(--admin-space-2); font-weight: 400; }
.account-monitor :deep(.collection-progress) { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--admin-space-3) var(--admin-space-6); align-items: center; }
.warehouse-result > span.workbench-tone { color: var(--workbench-muted); }
.warehouse-result > span.result-ready { color: var(--workbench-accent); }
.account-monitor :deep(.collection-progress) { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 8px var(--workbench-grid-gap); align-items: center; }
.account-monitor :deep(.collection-stage-counts) { display: contents; }
.account-monitor :deep(.collection-ring) { width: 180px; height: 180px; grid-row: 1 / 3; }
.account-monitor :deep(.collection-progress[data-estimate="false"] .collection-ring) { grid-row: 1; }
.account-monitor :deep(.compact-counts) { grid-column: 2; }
.account-monitor :deep(.collection-progress > p) { grid-column: 1 / -1; }
.account-monitor :deep(.ring-label strong) { color: var(--el-color-primary); }
.account-monitor :deep(.collection-estimate) { grid-column: 2; margin: 0; padding: var(--admin-space-4) var(--admin-space-5); border-radius: var(--admin-radius); background: var(--el-color-primary-light-9); }
.account-monitor :deep(.collection-estimate > strong) { width: 100%; color: var(--el-color-primary); }
.account-monitor :deep(.collection-estimate > span) { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.account-monitor :deep(.collection-estimate) { grid-column: 2; margin: 0; }
@keyframes signal { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } }
</style>
@@ -5,6 +5,7 @@ import AnalyticsChart from './AnalyticsChart.vue'
import { progressGaugeOption } from '../dashboardChartOptions'
import type { Progress } from '../types'
import { beijingTime, phaseText, quantityText, unitText } from '../state'
import '@/styles/adminWorkbench.css'
const props = defineProps<{ progress: Partial<Progress> | null | undefined; taskNo: string; stale?: boolean; compact?: boolean; running?: boolean }>()
const identity = computed(() => JSON.stringify([props.taskNo, props.progress?.progressScope, props.progress?.countBasis, props.progress?.attemptNo, props.progress?.phase, props.progress?.scanRound, props.progress?.sliceFrom, props.progress?.sliceUntil, props.progress?.unit]))
const changedTotal = ref(false)
@@ -22,19 +23,31 @@ const option = computed(() => progressGaugeOption(counts.value?.percent ?? null,
const readingDirectory = computed(() => !props.progress?.totalKnown && ['DIRECTORY', 'DISCOVERY'].includes(props.progress?.phase ?? ''))
</script>
<template>
<section class="collection-stage-counts" aria-label="当前阶段数量">
<section class="collection-stage-counts admin-workbench" aria-label="当前阶段数量">
<div class="collection-ring" :role="counts ? 'progressbar' : 'img'" :aria-label="`${scope} · ${phaseText(progress?.phase)} · ${counts ? '当前阶段' : '总量待确认'}`" :aria-valuenow="counts?.percent" :aria-valuemin="counts ? 0 : undefined" :aria-valuemax="counts ? 100 : undefined">
<AnalyticsChart :option="option" merge-updates class="ring-chart" :class="{ 'is-indeterminate': indeterminate }" aria-hidden="true" />
<div class="ring-label"><strong>{{ counts ? `${counts.percent.toFixed(1)}%` : indeterminate ? '执行中' : '—' }}</strong><span>{{ stale ? '上次阶段记录' : '当前阶段' }}</span></div>
</div>
<div :class="{ 'compact-counts': compact }">
<p v-if="!compact">{{ phaseText(progress?.phase) }} · {{ scope }}<InlineHelp v-if="scope === '账号共享'" title="阶段数量说明"><p>来源账号的阶段数量,不作为本仓出库量或整个同步完成率。</p></InlineHelp><span v-if="progress?.scanRound != null"> · 第 {{ progress.scanRound }} 轮</span></p>
<dl class="collection-count-grid"><div><dt>总量</dt><dd>{{ progress?.totalKnown ? quantityText(progress.total) : compact ? '—' : '待确认' }} <small>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</small></dd></div><div><dt>已完成</dt><dd>{{ quantityText(progress?.completed) }} <small>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</small></dd></div><div><dt>剩余</dt><dd>{{ counts ? counts.remaining : '—' }} <small>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</small></dd></div></dl>
<p v-if="changedTotal">总量发生变化,正在复核;暂不展示比例。</p>
<p v-else-if="!counts">{{ readingDirectory ? '目录总页数尚未返回,已读取数量见“已完成”。' : progress?.totalKnown && Number(progress.total) === 0 ? '本阶段数量为零;是否完成以阶段及发布证据为准。' : '总量或计数依据待确认,不估算完成比例。' }}</p>
<p v-else-if="compact" aria-hidden="true">&nbsp;</p>
<p v-if="!compact" class="workbench-note">{{ phaseText(progress?.phase) }} · {{ scope }}<InlineHelp v-if="scope === '账号共享'" title="阶段数量说明"><p>来源账号的阶段数量,不作为本仓出库量或整个同步完成率。</p></InlineHelp><span v-if="progress?.scanRound != null"> · 第 {{ progress.scanRound }} 轮</span></p>
<dl class="collection-count-grid">
<div class="workbench-metric is-compact">
<dt class="workbench-metric-label">总量</dt>
<dd class="workbench-metric-value"><strong>{{ progress?.totalKnown ? quantityText(progress.total) : compact ? '—' : '待确认' }}</strong><span>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</span></dd>
</div>
<div class="workbench-metric is-compact">
<dt class="workbench-metric-label">已完成</dt>
<dd class="workbench-metric-value"><strong>{{ quantityText(progress?.completed) }}</strong><span>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</span></dd>
</div>
<div class="workbench-metric is-compact">
<dt class="workbench-metric-label">剩余</dt>
<dd class="workbench-metric-value"><strong>{{ counts ? counts.remaining : '—' }}</strong><span>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</span></dd>
</div>
</dl>
<p v-if="changedTotal" class="workbench-note">总量发生变化,正在复核;暂不展示比例。</p>
<p v-else-if="!counts" class="workbench-note">{{ readingDirectory ? '目录总页数尚未返回,已读取数量见“已完成”。' : progress?.totalKnown && progress.total != null && Number(progress.total) === 0 ? '本阶段数量为零;是否完成以阶段及发布证据为准。' : '总量或计数依据待确认,不估算完成比例。' }}</p>
<p v-if="!compact && (progress?.sliceFrom || progress?.sliceUntil)">当前切片:{{ beijingTime(progress?.sliceFrom) }} 至 {{ beijingTime(progress?.sliceUntil) }}(结束边界不含)</p>
<p v-if="!compact && (progress?.sliceFrom || progress?.sliceUntil)" class="workbench-note">当前切片:{{ beijingTime(progress?.sliceFrom) }} 至 {{ beijingTime(progress?.sliceUntil) }}(结束边界不含)</p>
</div>
</section>
</template>
@@ -47,9 +60,8 @@ const readingDirectory = computed(() => !props.progress?.totalKnown && ['DIRECTO
@keyframes collection-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .ring-chart.is-indeterminate { animation: none; } }
.ring-label { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.ring-label strong { font-size: 28px; font-weight: 500; font-variant-numeric: tabular-nums; }
.ring-label strong { color: var(--workbench-primary); font-size: var(--workbench-compact-number-size); font-weight: 700; font-variant-numeric: tabular-nums; }
.ring-label span { margin-top: 4px; color: var(--workbench-muted); font-size: var(--workbench-note-size); }
.collection-count-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); margin: var(--admin-space-4) 0; }
dt, small, .ring-label span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
dd { margin: var(--admin-space-2) 0 0; font-size: 30px; font-variant-numeric: tabular-nums; }
p { margin: var(--admin-space-2) 0; font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.compact-counts .collection-count-grid { margin-block: 0; }
</style>
@@ -37,6 +37,60 @@ describe('collection task progress evidence', () => {
await w.setProps({ task: { ...yielded, blockingReason: 'ACCOUNT_COOLDOWN' } })
expect(w.find('.collection-estimate').text()).not.toContain('本批已完成,等待续采')
})
it('keeps a failure summary once across compact, stale, hidden and controlled displays', async () => {
const failed: Task = { ...source, taskStatus: 'RETRY_WAIT', blockingReason: 'UPSTREAM_FAILED', latestAttemptNo: 2,
latestAttempt: { attemptNo: 2, status: 'FAILED', phase: 'DETAIL', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 20, errorCode: 'UPSTREAM_ERROR', failureSummary: '来源读取失败' } }
const w = mount(CollectionTaskProgress, { props: { task: failed } }); wrappers.push(w)
for (const compact of [false, true]) {
for (const state of [
{ stale: false, visible: true, collectionMode: 'BUSINESS' },
{ stale: true, visible: true, collectionMode: 'BUSINESS' },
{ stale: false, visible: false, collectionMode: 'BUSINESS' },
{ stale: false, visible: true, collectionMode: 'OFF' },
]) {
await w.setProps({ compact, ...state })
expect(w.text().match(/来源读取失败/g)).toHaveLength(1)
expect(w.findAll('[role="status"]').filter(node => node.text() === '来源读取失败')).toHaveLength(1)
if (!state.stale && state.visible && state.collectionMode === 'BUSINESS') expect(w.text()).toContain('关联来源失败,等待恢复')
}
}
})
it('hides redundant estimate notices under off, pause and stale states while retaining counts', async () => {
const yielded: Task = { ...source, taskStatus: 'RETRY_WAIT', blockingReason: 'RETRY_SCHEDULED', latestAttemptNo: 2,
latestAttempt: { attemptNo: 2, status: 'YIELDED', phase: 'DETAIL', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 20, errorCode: null, failureSummary: null } }
const w = mount(CollectionTaskProgress, { props: { task: yielded, collectionMode: 'OFF', compact: true } }); wrappers.push(w)
expect(w.find('.collection-estimate').exists()).toBe(false)
expect(w.text()).not.toContain('等待续采')
expect(w.text()).toContain('12.1%')
expect(w.find('.compact-counts [aria-hidden="true"]').exists()).toBe(false)
await w.setProps({ collectionMode: 'BUSINESS', accountPaused: true })
expect(w.find('.collection-estimate').exists()).toBe(false)
await w.setProps({ stale: true })
expect(w.find('.collection-estimate').exists()).toBe(false)
await w.setProps({ stale: false, visible: false })
expect(w.find('.collection-estimate').exists()).toBe(false)
})
it('keeps an executing attempt visible under an off switch and pauses only stale animation', async () => {
const unknown = { ...source, blockingReason: 'COLLECTION_OFF', progress: { ...progress, totalKnown: false, total: null } }
const w = mount(CollectionTaskProgress, { props: { task: unknown, compact: true } }); wrappers.push(w)
expect(w.find('.collection-estimate').exists()).toBe(false)
expect(w.get('.ring-chart').classes()).toContain('is-indeterminate')
await w.setProps({ stale: true })
expect(w.find('.collection-estimate').exists()).toBe(false)
expect(w.get('.ring-chart').classes()).not.toContain('is-indeterminate')
})
it('marks only the evidenced current phase as paused and does not treat paused estimates as paused execution', async () => {
const paused: Task = { ...source, dataFamily: 'MASTER_DATA', taskStatus: 'RETRY_WAIT', blockingReason: 'ACCOUNT_PAUSED', progress: { ...progress, phase: 'PRODUCTS' } }
const w = mount(CollectionTaskProgress, { props: { task: paused } }); wrappers.push(w)
expect(w.findAll('.collection-flow li').map(li => li.get('span').text())).toEqual(['状态未确认', '当前阶段 · 已暂停', '状态未确认'])
expect(w.text()).not.toContain('正在处理')
await w.setProps({ task: { ...paused, blockingReason: 'EXECUTION_DISABLED' } })
expect(w.get('.density-actions span').text()).toBe('执行未启用')
await w.setProps({ task: { ...paused, taskStatus: 'RUNNING', blockingReason: null, stageEstimate: { status: 'PAUSED' } as StageEstimate } })
expect(w.get('.density-actions span').text()).toBe('正在执行')
expect(w.findAll('.collection-flow li')[1]?.text()).toContain('正在处理')
expect(w.get('.collection-estimate strong').text()).toBe('已暂停估算')
})
it('reads serialized Long seconds and rejects missing, malformed or unsafe values', async () => {
const w = mount(CollectionTaskProgress, { props: { task: { ...source, elapsedSeconds: '607' }, compact: true } }); wrappers.push(w)
expect(w.text()).toContain('任务累计耗时 10 分 7 秒')
@@ -70,6 +124,11 @@ describe('collection task progress evidence', () => {
it('uses only a confirmed source and does not infer earlier stage completion from the current phase', async () => {
const w = mount(CollectionTaskProgress, { props: { task, detail } }); wrappers.push(w)
expect(w.text()).toContain('TASK-S'); expect(w.text()).toContain('12.1%'); expect(w.text()).toContain('2448')
expect(w.classes()).toContain('admin-workbench')
expect(w.get('.collection-stage-counts').classes()).toContain('admin-workbench')
expect(w.findAll('.collection-count-grid .workbench-metric.is-compact')).toHaveLength(3)
expect(w.findAll('.collection-count-grid dd strong').map(value => value.text())).toEqual(['2787', '339', '2448'])
expect(w.get('.collection-estimate').classes()).toContain('workbench-notice')
expect(w.text()).toContain('账号共享'); expect(w.findAll('li')[0]?.text()).toContain('状态未确认')
expect(w.text()).not.toContain('✓')
await w.setProps({ task: { ...task, dependencyState: 'WAITING_UNCONFIRMED', progressSourceTaskNo: null } })
@@ -123,6 +182,14 @@ describe('collection task progress evidence', () => {
const d = { ...detail, sharedUpstream: { ...source, stageEstimate } }
const w = mount(CollectionTaskProgress, { props: { task, detail: d, visible: true } }); wrappers.push(w)
expect(w.text()).toContain('约82分钟')
await w.setProps({ compact: true })
expect(w.get('.collection-estimate').text()).toBe('当前阶段预计还需约82分钟')
expect(w.get('.collection-estimate strong').attributes('title')).toContain('最近速度 0.5 单/秒')
expect(w.get('.collection-estimate strong').attributes('title')).toContain('估算更新')
expect(w.get('.collection-estimate strong').attributes('title')).toContain('整个任务完成时间尚未估算')
await w.setProps({ compact: false })
expect(w.get('.collection-estimate').text()).toContain('最近速度')
expect(w.get('.collection-estimate').text()).toContain('整个任务完成时间尚未估算')
await w.setProps({ stale: true }); expect(w.text()).not.toContain('约82分钟'); expect(w.text()).toContain('状态更新失败')
await w.setProps({ stale: false, visible: false }); expect(w.text()).not.toContain('约82分钟')
await w.setProps({ visible: true, detail: { ...d, sharedUpstream: { ...d.sharedUpstream, progress: { ...progress, completed: 340 } } } }); expect(w.text()).not.toContain('约82分钟')
@@ -135,6 +202,15 @@ describe('collection task progress evidence', () => {
await w.setProps({ progress: { ...progress, attemptNo: 3, totalKnown: false } }); expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress: { ...progress, attemptNo: 4, total: 0, completed: 0 } }); expect(w.text()).toContain('本阶段数量为零'); expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress: { ...progress, attemptNo: 5, total: 1, completed: 2 } }); expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress: { ...progress, attemptNo: 6, total: null, completed: 0 } })
expect(w.find('[role="progressbar"]').exists()).toBe(false)
expect(w.find('.collection-count-grid dd').text()).toContain('—')
expect(w.text()).toContain('总量或计数依据待确认')
expect(w.text()).not.toContain('本阶段数量为零')
await w.setProps({ progress: { ...progress, attemptNo: 7, total: 0, completed: 0 } })
expect(w.find('[role="progressbar"]').exists()).toBe(false)
expect(w.find('.collection-count-grid dd strong').text()).toBe('0')
expect(w.text()).toContain('本阶段数量为零')
})
it('shows the percentage once an initially unknown total becomes available', async () => {
const w = mount(CollectionProgressGauge, { props: { taskNo: source.taskNo, progress: { ...progress, totalKnown: false, total: null } } }); wrappers.push(w)
@@ -3,13 +3,21 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import type { Progress, Task, TaskDetail, WarehouseOverview } from '../types'
import CollectionProgressGauge from './CollectionProgressGauge.vue'
import { beijingTime, label, phaseText, quantityText, reasonsText, unitText } from '../state'
import { taskStateText, isNormalContinuation } from '../collectionPresentation'
const props = defineProps<{ task: Task; detail?: TaskDetail | null; warehouse?: WarehouseOverview | null; stale?: boolean; visible?: boolean; observedAt?: string | null; compact?: boolean }>()
import { collectionControlState, taskStateText, isNormalContinuation } from '../collectionPresentation'
import '@/styles/adminWorkbench.css'
const props = withDefaults(defineProps<{ task: Task; detail?: TaskDetail | null; warehouse?: WarehouseOverview | null; stale?: boolean; visible?: boolean; observedAt?: string | null; compact?: boolean; collectionMode?: string | null; accountPaused?: boolean }>(), { visible: true })
const confirmedSource = computed(() => {
const source = props.detail?.sharedUpstream
return source && ['WAITING_CONFIRMED', 'SATISFIED', 'FAILED'].includes(props.task.dependencyState ?? '') ? source : null
})
const workingTask = computed(() => confirmedSource.value && props.task.dependencyState === 'WAITING_CONFIRMED' && props.task.progressSourceTaskNo === confirmedSource.value.taskNo ? confirmedSource.value : props.task)
const controlEvidence = computed(() => ({
collectionMode: props.collectionMode,
accountPaused: Boolean(props.accountPaused || props.detail?.protection?.accountPauseReason || props.warehouse?.protection?.accountPauseReason),
blockingReason: props.task.blockingReason,
}))
const control = computed(() => collectionControlState(workingTask.value, controlEvidence.value))
const stateText = (task: Task) => props.stale ? '执行状态待确认' : props.visible === false ? '页面已暂停更新' : taskStateText(task, controlEvidence.value)
const blockingText = computed(() => isNormalContinuation(props.task) && props.task.blockingReason === 'RETRY_SCHEDULED' ? '本批已完成,等待续采' : ({ UPSTREAM_UNCONFIRMED: '关联来源尚未确认', UPSTREAM_NOT_READY: '等待账号共享来源就绪', UPSTREAM_FAILED: '关联来源失败,等待恢复', RETRY_SCHEDULED: '等待计划重试', EXECUTION_YIELDED: '本批已完成,等待续采', QUEUED: '排队等待执行' } as Record<string, string>)[props.task.blockingReason ?? ''] ?? reasonsText(props.task.blockingReason ? [props.task.blockingReason] : []))
const progress = computed(() => workingTask.value.progress)
const now = ref(Date.now())
@@ -39,6 +47,16 @@ const estimateText = computed(() => {
if (!progress.value?.totalKnown) return '总量待确认,暂不估算'
return '估算依据待确认'
})
const progressNotice = computed(() => {
if (props.stale) return '状态更新失败,已暂停估算'
if (props.visible === false) return '页面已暂停更新,已暂停估算'
return props.compact && props.task.blockingReason ? blockingText.value : estimateText.value
})
const estimateDetail = computed(() => [
estimate.value ? `最近速度 ${quantityText(estimate.value.ratePerSecond)} ${unitText(progress.value?.unit)}/秒;估算更新 ${beijingTime(estimate.value.estimatedAt)}` : '',
'仅当前阶段;整个任务完成时间尚未估算。',
].filter(Boolean).join('\n'))
const showEstimate = computed(() => !control.value && (!props.compact || !props.stale && props.visible !== false))
const taskElapsed = computed(() => {
const value = props.task.elapsedSeconds
if (value == null || typeof value === 'string' && !/^\d+$/.test(value)) return null
@@ -59,23 +77,29 @@ function stageState(node: { title: string; scope: string; phases: string[] }) {
if (!p) return '状态未确认'
const matching = (stage: Partial<Progress>) => node.phases.includes(stage.phase ?? '') && stage.attemptNo != null && stage.attemptNo === p.attemptNo && stage.scanRound === p.scanRound && stage.sliceFrom === p.sliceFrom && stage.sliceUntil === p.sliceUntil && stage.progressScope === p.progressScope
if ([p, ...(p.stages ?? [])].some(stage => matching(stage) && stage.stageFinishedAt)) return '✓ 已结束'
if (node.phases.includes(p.phase ?? '') && ['RUNNING', 'APPLYING'].includes(task.taskStatus)) return '正在处理'
if (node.phases.includes(p.phase ?? '')) {
if (props.stale || props.visible === false) return '上次阶段 · 状态待确认'
if (['RUNNING', 'APPLYING'].includes(task.taskStatus)) return '正在处理'
const stageControl = collectionControlState(task, controlEvidence.value)
if (stageControl) return `当前阶段 · ${stageControl.text}`
}
if (node.scope === '本仓' && ['WAITING_CONFIRMED', 'WAITING_UNCONFIRMED', 'FAILED'].includes(task.dependencyState ?? '')) return '等待来源'
return '状态未确认'
}
</script>
<template>
<section class="collection-progress" aria-label="关注任务进展">
<section class="collection-progress admin-workbench" :data-estimate="showEstimate" aria-label="关注任务进展">
<template v-if="!compact">
<div class="density-actions"><strong>{{ task.warehouseCode }} · {{ task.dataFamily === 'MASTER_DATA' ? '客户/商品资料' : task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}</strong><span>{{ taskStateText(task) }}</span></div>
<p class="collection-caption">数据范围 {{ beijingTime(task.queryFrom) }} 至 {{ beijingTime(task.queryUntil) }}(结束边界不含)</p>
<div class="density-actions"><strong>{{ task.warehouseCode }} · {{ task.dataFamily === 'MASTER_DATA' ? '客户/商品资料' : task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}</strong><span>{{ stateText(task) }}</span></div>
<p class="collection-caption workbench-note">数据范围 {{ beijingTime(task.queryFrom) }} 至 {{ beijingTime(task.queryUntil) }}(结束边界不含)</p>
<p v-if="task.dependencyState === 'WAITING_UNCONFIRMED'">本任务等待来源;关联进度尚未确认。</p>
<p v-if="confirmedSource">已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ taskStateText(confirmedSource) }};来源完成后,本仓仍需更新出库数据、计算与发布。</p>
<p v-if="confirmedSource">已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ stateText(confirmedSource) }};来源完成后,本仓仍需更新出库数据、计算与发布。</p>
</template>
<p v-if="!compact && (task.blockingReason || task.latestAttempt?.failureSummary)" role="status">{{ task.blockingReason ? blockingText : task.latestAttempt?.failureSummary }}</p>
<p v-if="!compact && !control && !stale && visible !== false && task.blockingReason" role="status">{{ blockingText }}</p>
<CollectionProgressGauge :progress="progress" :task-no="workingTask.taskNo" :running="!stale && visible !== false && !progress?.stageFinishedAt && ['RUNNING', 'APPLYING'].includes(workingTask.taskStatus)" :stale="stale" :compact="compact" />
<div class="collection-estimate"><strong>{{ compact && !stale && visible !== false && task.blockingReason ? blockingText : estimateText }}</strong><span v-if="compact && task.latestAttempt?.failureSummary" role="status">{{ task.latestAttempt.failureSummary }}</span><span v-if="estimate">最近速度 {{ quantityText(estimate.ratePerSecond) }} {{ unitText(progress?.unit) }}/秒;估算更新 {{ beijingTime(estimate.estimatedAt) }}</span><span>仅当前阶段;整个任务完成时间尚未估算。</span></div>
<p>任务累计耗时 {{ taskElapsed == null ? '—' : `${Math.floor(taskElapsed / 60)} 分 ${taskElapsed % 60} 秒` }}<small>(含排队与重试)</small></p>
<div v-if="showEstimate" class="collection-estimate workbench-notice"><strong class="workbench-metric-label" :title="compact ? estimateDetail : undefined">{{ progressNotice }}</strong><span v-if="!compact && estimate" class="workbench-note">最近速度 {{ quantityText(estimate.ratePerSecond) }} {{ unitText(progress?.unit) }}/秒;估算更新 {{ beijingTime(estimate.estimatedAt) }}</span><span v-if="!compact" class="workbench-note">仅当前阶段;整个任务完成时间尚未估算。</span></div>
<p v-if="task.latestAttempt?.failureSummary" class="workbench-note" role="status">{{ task.latestAttempt.failureSummary }}</p>
<p class="workbench-note">任务累计耗时 {{ taskElapsed == null ? '—' : `${Math.floor(taskElapsed / 60)} 分 ${taskElapsed % 60} 秒` }}<small>(含排队与重试)</small></p>
<template v-if="!compact">
<ol class="collection-flow" :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }" aria-label="处理阶段"><li v-for="node in flow" :key="node.title" :data-state="stageState(node)"><strong>{{ node.title }}</strong><small>{{ node.scope }}</small><span>{{ stageState(node) }}</span></li></ol>
<div class="collection-clocks"><div>页面读取时间<strong>{{ beijingTime(detail?.observedAt || observedAt) }}</strong><small>读取成功不代表采集推进</small></div><div>最近真实推进 · {{ workingTask === task ? '本仓' : '账号共享' }}<strong>{{ beijingTime(workingTask.progressUpdatedAt || progress?.progressUpdatedAt) }}</strong><small>{{ task.attemptCount === 0 && workingTask === task ? '本仓尚未开始执行' : '以已提交检查点为准' }}</small></div><div>执行心跳<strong>{{ beijingTime(workingTask.latestAttempt?.heartbeatAt) }}</strong><small>{{ label(workingTask.latestAttempt?.executionHealth) }};心跳不代表完成数增加</small></div></div>
@@ -88,13 +112,14 @@ function stageState(node: { title: string; scope: string; phases: string[] }) {
<style scoped>
.collection-progress { min-width: 0; padding-block: var(--admin-space-4); }
p { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
.collection-caption, small { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.collection-estimate { display: flex; gap: var(--admin-space-4); flex-wrap: wrap; align-items: baseline; background: var(--el-fill-color-light); padding: var(--admin-space-3); margin-block: var(--admin-space-4); }
small { font-size: var(--workbench-note-size); color: var(--workbench-muted); }
.collection-estimate { display: flex; gap: 4px var(--admin-space-4); flex-wrap: wrap; align-items: baseline; margin-block: var(--admin-space-4); }
.collection-estimate > strong { flex-basis: 100%; }
.collection-flow { display: grid; gap: var(--admin-space-4); list-style: none; padding: 0; margin: var(--admin-space-5) 0; }
.collection-flow li { display: grid; gap: var(--admin-space-2); border-top: 2px solid var(--el-border-color); padding-top: var(--admin-space-3); }
.collection-flow li { display: grid; min-width: 0; gap: var(--admin-space-2); border-top: 2px solid var(--workbench-border); padding-top: var(--admin-space-3); overflow-wrap: anywhere; }
.collection-flow li[data-state^="✓"] { border-color: var(--el-color-success); }
.collection-flow li[data-state="正在处理"] { border-color: var(--el-color-primary); }
.collection-clocks { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: var(--admin-space-4); border-top: 1px solid var(--el-border-color); padding-top: var(--admin-space-4); }
.collection-clocks div { display: grid; gap: var(--admin-space-2); }
.collection-flow li[data-state="正在处理"] { border-color: var(--workbench-primary); }
.collection-clocks { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: var(--admin-space-4); border-top: 1px solid var(--workbench-border); padding-top: var(--admin-space-4); }
.collection-clocks div { display: grid; min-width: 0; gap: var(--admin-space-2); overflow-wrap: anywhere; }
.collection-clocks strong { font-weight: 500; font-variant-numeric: tabular-nums; }
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ChartNoAxesCombined } from '@lucide/vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { ECElementEvent } from 'echarts'
import { quantityText } from '../state'
@@ -19,18 +20,21 @@ function selectChart(event: ECElementEvent) {
}
</script>
<template>
<div :aria-label="title" class="dashboard-bars">
<div :aria-label="title" class="dashboard-bars workbench-chart">
<DashboardChartHeading :title="title">
<span v-if="rows.length" class="chart-details-action"><el-button link type="primary" title="查看完整明细" @click="detailsOpen = true">明细</el-button></span>
<slot name="actions" />
</DashboardChartHeading>
<slot name="intro" />
<slot v-if="!rows.length" name="empty"><p>暂无符合条件的 SKU;请核对覆盖与待评估资料。</p></slot>
<div v-if="!rows.length" class="workbench-chart-empty" role="status">
<span class="workbench-chart-empty-icon" aria-hidden="true"><ChartNoAxesCombined :size="28" /></span>
<slot name="empty"><p>暂无符合条件的 SKU;请核对覆盖与待评估资料。</p></slot>
</div>
<template v-else>
<AnalyticsChart :option="option" :style="{ height: `${Math.max(240, rows.length * 38 + 65)}px` }" :aria-label="title" @select="selectChart" />
<p v-if="rows.some(row => row.value == null)">存在待核验数量,以“—”表示,不作为零值。</p>
<AppDialog v-model="detailsOpen" class="admin-density-dialog" :title="`${title} · 完整明细`" :width="detailsWidth || '1040px'" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close>
<div class="admin-density chart-details">
<AnalyticsChart :option="option" class="workbench-chart-canvas" :style="{ height: `${Math.max(240, rows.length * 38 + 65)}px` }" :aria-label="title" @select="selectChart" />
<p v-if="rows.some(row => row.value == null)" class="workbench-note">存在待核验数量,以“—”表示,不作为零值。</p>
<AppDialog v-model="detailsOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`${title} · 完整明细`" :width="detailsWidth || '1040px'" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close>
<div class="admin-density chart-details workbench-evidence">
<slot name="details" :select="select">
<p>当前图表共 {{ rows.length }} 项;点击项目查看对应明细。</p>
<table class="density-table"><thead><tr><th v-if="ranked" class="rank-column">排名</th><th class="name-column">项目/完整名称</th><th class="quantity-column">数量({{ unit }})</th><th>说明</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="row.key"><td v-if="ranked">{{ index + 1 }}</td><td><button type="button" :disabled="disabled || row.value == null" @click="select(row)">{{ row.label }}</button><small v-if="row.secondary">{{ row.secondary }}</small></td><td>{{ quantityText(row.value) }}</td><td>{{ row.detail || '—' }}</td></tr></tbody></table>
@@ -2,15 +2,8 @@
defineProps<{ title: string }>()
</script>
<template>
<div class="dashboard-chart-heading">
<h3>{{ title }}</h3>
<div class="dashboard-chart-actions"><slot /></div>
<div class="dashboard-chart-heading workbench-chart-heading">
<h3 class="workbench-chart-title">{{ title }}</h3>
<div class="dashboard-chart-actions workbench-chart-actions"><slot /></div>
</div>
</template>
<style scoped>
.dashboard-chart-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-2); }
h3 { display: flex; align-items: center; gap: var(--admin-space-2); margin: 0; font-size: var(--admin-font-section); }
h3::before { content: ''; width: 3px; height: 14px; border-radius: 2px; background: #5575ed; flex-shrink: 0; }
.dashboard-chart-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--admin-space-3); margin-left: auto; }
.dashboard-chart-actions :deep(.el-button + .el-button) { margin-left: 0; }
</style>
@@ -10,6 +10,13 @@ function point(date: string, value: number | null, dataStatus = 'COMPLETE'): Dai
return { businessDate: date, outboundQuantity: dataStatus === 'COMPLETE' ? value : null, observedQuantity: value, dataStatus, reasonCodes: [], quantityUnit: 'PCS' }
}
describe('dashboard chart integrity and interaction', () => {
it('shows a truthful empty state without creating a placeholder chart', () => {
const wrapper = mount(DashboardBars, { props: { title: '库存排行', unit: '件', rows: [] } })
expect(wrapper.find('.workbench-chart-empty[role=status]').text()).toContain('暂无符合条件的 SKU')
expect(wrapper.findComponent({ name: 'VChart' }).exists()).toBe(false)
expect(wrapper.emitted('select')).toBeUndefined()
wrapper.unmount()
})
it('keeps dates and line explanations behind the description action', async () => {
const wrapper = mount(DashboardTrend, { props: { points: [point('2026-10-01', 2)] }, slots: { explanation: '<p>统计日期示例</p>' } })
expect(wrapper.text()).not.toContain('统计日期示例')
@@ -7,9 +7,10 @@ import type { DailyPoint } from '../types'
import { label, quantityText, reasonsText, shownQuantity } from '../state'
import { skuTrendOption } from '../skuTrendChart'
import AnalyticsChart from './AnalyticsChart.vue'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
const props = defineProps<{ points: DailyPoint[]; disabled?: boolean }>()
const emit = defineEmits<{ select: [date: string] }>()
const option = computed(() => ({ ...skuTrendOption(props.points), aria: { enabled: true, description: '全仓每日出库趋势,点击已知日点查看当日销量。缺失日期断线,未完整日虚线。' } }))
const option = computed(() => ({ ...skuTrendOption(props.points, adminWorkbenchChartTheme), aria: { enabled: true, description: '全仓每日出库趋势,点击已知日点查看当日销量。缺失日期断线,未完整日虚线。' } }))
const hasData = computed(() => props.points.some(point => point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null))
const detailsOpen = ref(false)
function available(point: DailyPoint) { return !props.disabled && point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null }
@@ -17,7 +18,7 @@ function select(point: DailyPoint) { if (available(point)) { detailsOpen.value =
function selectChart(event: ECElementEvent) { const point = props.points[event.dataIndex]; if (event.componentType === 'series' && point) select(point) }
</script>
<template>
<div>
<div class="workbench-chart">
<DashboardChartHeading title="出库趋势">
<ChartDetailsDialog v-model="detailsOpen" inline button-label="明细" title="查看每日数值与覆盖原因" :reset-on="points"><table class="density-table"><thead><tr><th>日期(点击查看)</th><th>正式量</th><th>已采集量</th><th>状态</th><th>原因</th></tr></thead><tbody><tr v-for="point in points" :key="point.businessDate"><td><button type="button" :disabled="!available(point)" @click="select(point)">{{ point.businessDate }}</button></td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr></tbody></table></ChartDetailsDialog>
<ChartDetailsDialog inline button-label="说明" title="出库趋势 · 统计说明" width="760px" :reset-on="points">
@@ -25,8 +26,8 @@ function selectChart(event: ECElementEvent) { const point = props.points[event.d
<p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 未知及缺失日期断线;今日为已采集累计</p>
</ChartDetailsDialog>
</DashboardChartHeading>
<AnalyticsChart :option="option" class="dashboard-trend" aria-label="全仓每日出库趋势" @select="selectChart" />
<p v-if="!hasData">所选日期暂无可靠出库资料,未知不按零显示。</p>
<AnalyticsChart :option="option" class="dashboard-trend workbench-chart-canvas" aria-label="全仓每日出库趋势" @select="selectChart" />
<p v-if="!hasData" class="workbench-note">所选日期暂无可靠出库资料,未知不按零显示。</p>
</div>
</template>
@@ -35,4 +36,4 @@ function selectChart(event: ECElementEvent) { const point = props.points[event.d
.trend-legend { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
td button { font: inherit; color: var(--el-color-primary); background: transparent; border: 0; cursor: pointer; padding: var(--admin-space-2); }
td button:disabled { color: var(--el-text-color-secondary); cursor: default; }
</style>
</style>
@@ -35,22 +35,29 @@ describe('inventory turnover page fixed snapshot behavior', () => {
const w = page(); await flushPromises()
const field = w.findComponent(toolbar).props('fields').find((f: {key: string}) => f.key === 'customerCodes')
expect(field.props).toMatchObject({ multiple: true, filterable: true, collapseTags: true })
expect(w.findComponent(table).props('columns')[0]).toMatchObject({ prop: 'sku', fixed: 'left' })
const fields = w.findComponent(toolbar).props('fields')
expect(fields.find((f: { key: string }) => f.key === 'focus').options[0]).toEqual({ value: 'ALL', label: '全部关注条件' })
expect(fields.find((f: { key: string }) => f.key === 'turnoverStatus').placeholder).toBe('全部样本状态')
field.props.onChange(['B', 'OWNER-A']); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['B', 'OWNER-A'], current: 1, includeOverview: true })
expect(w.text()).toContain('所选货主概览')
expect(w.get('.turnover-statistics').text()).toContain('所选货主概览')
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['B', 'OWNER-A'], current: 2, snapshotContext: 'token-A', includeOverview: false })
await change(w, { groupBy: 'OWNER' })
expect(w.findComponent(table).props('columns')[0]).toMatchObject({ prop: 'customerCode', fixed: 'left' })
await w.findAll('button').find(b => b.text() === 'OWNER-A')!.trigger('click'); await flushPromises()
await w.findAll('button').find(b => b.text() === '返回货主汇总')!.trigger('click'); await flushPromises()
expect(w.findComponent(toolbar).props('modelValue').customerCodes).toEqual(['B', 'OWNER-A'])
expect(w.findComponent(table).props('columns')[0]).toMatchObject({ prop: 'customerCode', fixed: 'left' })
w.findComponent(toolbar).vm.$emit('reset'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0].customerCodes).toEqual([])
expect(w.findComponent(table).props('columns')[0]).toMatchObject({ prop: 'sku', fixed: 'left' })
})
it('submits one backend export with applied filters, visible columns and fixed version without paging in the browser', async () => {
const w = page(); await flushPromises()
await change(w, { customerCodes: ['OWNER-A'], sortBy: 'TURNOVER_DESC' }, true)
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
await w.findAll('button').find(b => b.text() === '显示更多指标')!.trigger('click')
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
const before = mocks.turnover.mock.calls.length
await w.findAll('button').find(b => b.text() === '导出')!.trigger('click'); await flushPromises()
@@ -66,6 +73,10 @@ describe('inventory turnover page fixed snapshot behavior', () => {
expect(w.findComponent(table).props('page')).toBe(2)
expect(w.text()).toContain('导出任务已提交')
expect(turnoverPercent(null)).toBe('—'); expect(turnoverPercent(0)).toBe('0%'); expect(turnoverPercent(1.25)).toBe('125%')
await w.findAll('button').find(b => b.text() === '收起更多指标')!.trigger('click')
expect(w.findComponent(table).props('columns').some((column: { prop: string }) => column.prop === 'averagePhysicalQuantity')).toBe(false)
expect(w.findComponent(table).props('columns')[0]).toMatchObject({ prop: 'sku', fixed: 'left' })
expect(mocks.turnover).toHaveBeenCalledTimes(before)
})
it('requires independent export permission and displays submission failures', async () => {
mocks.exportAllowed = false
@@ -104,7 +115,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
const w = page(); await flushPromises()
expect(w.findComponent(table).props('columns').some((column: { prop: string }) => column.prop === 'attentionReasons')).toBe(false)
expect(w.text()).not.toContain('日样本 0/30')
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
await w.findAll('button').find(b => b.text() === '显示更多指标')!.trigger('click')
expect(w.findAll('span[title="历史库存记录不足,暂不能计算期间平均库存和周转次数"]').map(n => n.text())).toEqual(['—', '—', '—'])
// Object-valued native options retain the selected row, including its frozen evidence.
const select = w.get('select[aria-label="选择历史库存记录"]')
@@ -159,14 +170,19 @@ describe('inventory turnover page fixed snapshot behavior', () => {
return result
})
const w = page(); await flushPromises()
expect(w.text()).toContain('缺尺寸 2 SKU / 12 件')
expect(w.text()).toContain('缺尺寸 2 SKU')
expect(w.text()).toContain('已知小计 0.125 m³')
expect(w.text()).toContain('其中 40 件按商品尺寸估算')
const volumeValues = w.findAll('.turnover-volume-value')
expect(volumeValues[0]!.attributes('title')).toContain('缺尺寸 2 SKU / 12 件')
expect(volumeValues[1]!.attributes('title')).toContain('其中 40 件按商品尺寸估算')
expect(volumeValues[1]!.attributes('title')).toContain('按当前商品尺寸估算')
expect(w.findComponent(table).props('columns').find((c: { prop: string }) => c.prop === 'outboundVolumesM3').label).toContain('估算')
expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'unitVolumeM3')).toBe(true)
await change(w, { groupBy: 'OWNER', rangePreset: 'LAST_3_DAYS' })
expect(w.findComponent(table).props('columns').find((c: {prop: string}) => c.prop === 'outboundVolumesM3').label).toContain('3日')
expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'unitVolumeM3')).toBe(false)
expect(w.text()).toContain('缺尺寸 2 SKU / 12 件')
expect(w.text()).toContain('缺尺寸 2 SKU')
expect(w.findAll('.turnover-volume-value')[0]!.attributes('title')).toContain('缺尺寸 2 SKU / 12 件')
})
it('filters turnover by directory customer and keeps the code in pagination', async () => {
@@ -180,7 +196,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
it('sorts every numeric header on the server in both directions with the same snapshot', async () => {
const w = page(); await flushPromises()
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
await w.findAll('button').find(b => b.text() === '显示更多指标')!.trigger('click')
const metrics = { inventoryVolumeM3: 'INVENTORY_VOLUME', outboundVolumesM3: 'OUTBOUND_VOLUME', unitVolumeM3: 'UNIT_VOLUME', physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' }
for (const [prop, metric] of Object.entries(metrics)) {
// Start on a later page and with another sort to prove that a header resets pagination.
@@ -216,7 +232,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
expect(header.attributes('aria-label')).toContain(direction === 'ASC' ? '当前升序' : '当前降序')
expect(w.findComponent(toolbar).props('modelValue').sortBy).toBe(`TURNOVER_${direction}`)
}
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
await w.findAll('button').find(b => b.text() === '显示更多指标')!.trigger('click')
expect(w.get('button[data-column="quantityTurnoverCount"]').attributes('aria-label')).toContain('当前升序')
})
it('keeps the successful sort marker and page when a header query fails', async () => {
@@ -346,8 +362,34 @@ describe('inventory turnover page fixed snapshot behavior', () => {
expect(w.text()).toContain('300')
expect(w.text()).toContain('暂无可排名的库存数据')
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong').map(n => n.text())).toEqual(['100', '100', '100', '100'])
expect(w.findAll('.turnover-cards article small')).toHaveLength(0)
expect(w.find('.turnover-context').exists()).toBe(false)
expect(w.findAll('.turnover-summary-group > header').map(header => header.text())).toEqual(['当前库存', '所选时段分析'])
expect(w.get('.turnover-statistics').text()).toContain('当前库存采集')
expect(w.get('.turnover-statistics').text()).toContain('不随列表SKU或关注筛选改变')
expect(w.get('article[data-metric="inventory-volume"] strong').attributes('title')).toContain('按仓库实测尺寸计算')
expect(w.get('article[data-metric="outbound-volume"] strong').attributes('title')).toContain('按当前商品尺寸估算')
expect(w.findAll('.turnover-volume-value').map(value => value.text())).toEqual(['1.25 m³', '1.25 m³', '0.0125 m³'])
expect(w.findAll('.turnover-volume-value').map(value => value.attributes('title'))).toEqual(['按仓库实测尺寸计算', '按当前商品尺寸估算', '仓库实测尺寸'])
expect(w.findAllComponents({ name: 'AppDialog' }).every(d => d.attributes('append-to-body') !== undefined)).toBe(true)
})
it('keeps the chart toggle in the toolbar and preserves a collapsed preference across refresh', async () => {
sessionStorage.setItem('selfop-turnover-charts', 'false')
const w = page(); await flushPromises()
const toggle = () => w.findAll('button').find(button => button.text() === '图表')!
expect(toggle().attributes()).toMatchObject({ 'aria-pressed': 'false', title: '展开图表' })
expect(w.find('.turnover-charts').exists()).toBe(false)
const calls = mocks.turnover.mock.calls.length
await toggle().trigger('click'); await flushPromises()
expect(toggle().attributes()).toMatchObject({ 'aria-pressed': 'true', title: '收起图表' })
expect(w.find('.turnover-charts').exists()).toBe(true)
expect(mocks.turnover).toHaveBeenCalledTimes(calls)
await toggle().trigger('click'); await flushPromises()
expect(sessionStorage.getItem('selfop-turnover-charts')).toBe('false')
await w.findAll('button').find(button => button.text() === '整体刷新')!.trigger('click'); await flushPromises()
expect(toggle().attributes('aria-pressed')).toBe('false')
expect(w.find('.turnover-charts').exists()).toBe(false)
})
it('honors an explicit new route token in the same scope and reloads its overview', async () => {
const w = page(); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBeUndefined()
@@ -381,7 +423,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
expect((old[1] as AbortSignal).aborted).toBe(true)
finish(response(old[0])); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0].warehouseCode).toBe('B')
expect(w.text()).toContain('本仓概览')
expect(w.get('.turnover-statistics').text()).toContain('本仓概览')
await change(w, { sku: 'X' }, true)
expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBe('token-B')
})
@@ -1,9 +1,10 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Info } from '@lucide/vue'
import { Boxes, ChartNoAxesCombined, Info, Ruler } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -68,8 +69,8 @@ const fields = computed<QueryField[]>(() => [
{ key: 'groupBy', label: '分组', type: 'select', width: 120, options: [{ value: 'SKU', label: '按SKU' }, { value: 'OWNER', label: '按货主' }], props: { clearable: false } },
{ key: 'customerCodes', label: '货主', type: 'select', width: 260, options: ownerDirectory.options.value, placeholder: '全部货主(可多选)', props: { multiple: true, multipleLimit: 100, collapseTags: true, collapseTagsTooltip: true, maxCollapseTags: 1, filterable: true, clearable: true, onChange: changeOwners } },
...(filters.value.groupBy === 'SKU' ? [{ key: 'sku', label: 'SKU', type: 'input' as const, width: 175, placeholder: 'SKU精确查询' }] : []),
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部关注条件' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, placeholder: '全部样本状态', options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') && !value.startsWith('UNIT_VOLUME_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
])
const selectedOwners = computed(() => Array.isArray(filters.value.customerCodes) ? filters.value.customerCodes as string[] : [])
@@ -80,12 +81,12 @@ function changeOwners(codes: string[]) {
}
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
const columns = computed<DataTableColumn[]>(() => [
...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slotName: 'sku', minWidth: 155 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
{ prop: 'customerCode', label: '所属货主', slotName: 'owner', minWidth: 160 },
...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slotName: 'sku', minWidth: 155, fixed: 'left' }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
{ prop: 'customerCode', label: '所属货主', slotName: 'owner', minWidth: 160, ...(ownerMode.value ? { fixed: 'left' } : {}) },
...(ownerMode.value ? [{ prop: 'physicalSkuCount', label: '有实物SKU数', minWidth: 125 }] : []),
{ prop: 'physicalQuantity', label: '实物件数', slotName: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slotName: 'availableQuantity', minWidth: 145 },
{ prop: 'inventoryVolumeM3', label: '库存立方数', slotName: 'inventoryVolume', minWidth: 145 },
{ prop: 'outboundVolumesM3', label: `${applied.value?.baseWindowDays ?? 30}日出库立方数`, slotName: 'outboundVolume', minWidth: 170 },
{ prop: 'outboundVolumesM3', label: `${applied.value?.baseWindowDays ?? 30}日出库立方数(估算)`, slotName: 'outboundVolume', minWidth: 170 },
...(ownerMode.value ? [] : [{ prop: 'unitVolumeM3', label: '单件体积', slotName: 'unitVolume', minWidth: 135 }]),
{ prop: 'transitQuantity', label: '在途', slotName: 'transitQuantity', minWidth: 125 },
...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slotName: `outbound${n}`, minWidth: 160 })),
@@ -110,22 +111,30 @@ const incompleteDailyWindows = computed(() => data.value?.charts?.averageDaily.f
const summaryGroups = computed(() => {
const summary = data.value?.summary
if (!summary) return []
const quantity = (key: string, name: string, value: TurnoverTotal, tone: string) => ({ key, name, kind: 'quantity', tone, text: turnoverNumber(value.value), note: turnoverTotalNote(value), title: undefined as string | undefined })
const volume = (key: string, name: string, value: TurnoverVolume | undefined, outbound: boolean, tone: string) => ({ key, name, kind: 'volume', tone, text: turnoverNumber(value?.value, 2), note: volumeNote(value, outbound, 2), title: value?.value != null ? volumeText(value.value) : undefined })
const quantity = (key: string, name: string, value: TurnoverTotal, tone: string) => ({ key, name, kind: 'quantity', tone, text: turnoverNumber(value.value), note: value.value == null ? turnoverTotalNote(value) : '', title: undefined as string | undefined })
const volume = (key: string, name: string, value: TurnoverVolume | undefined, outbound: boolean, tone: string) => ({ key, name, kind: 'volume', tone, text: turnoverNumber(value?.value, 2), note: volumeWarning(value, 2), title: [value?.value != null ? volumeText(value.value) : '', volumeNote(value, outbound)].filter(Boolean).join(';') })
const days = applied.value?.baseWindowDays ?? 30
return [
{ key: 'stock', name: '当前库存', note: '使用当前库存快照,不随统计时段变化', cards: [
quantity('physical', '实物件数', summary.physicalQuantity, 'primary'),
{ key: 'stock', name: '当前库存', cards: [
quantity('physical', '实物件数', summary.physicalQuantity, 'blue'),
quantity('available', '正品可售件数', summary.availableQuantity, 'green'),
volume('inventory-volume', '库存立方数(m³)', summary.inventoryVolumeM3, false, 'purple'),
] },
{ key: 'period', name: '所选时段分析', note: `${days}天 · 截至 ${applied.value?.endDate ?? '—'}`, cards: [
quantity('no-outbound', '所选时段无出库SKU', summary.noOutboundSkuCount, 'orange'),
{ key: 'period', name: '所选时段分析', cards: [
quantity('no-outbound', '所选时段无出库SKU', summary.noOutboundSkuCount, 'amber'),
volume('outbound-volume', `${days}日出库立方数(m³)`, summary.outboundVolumesM3.find(w => w.days === days)?.volume, true, 'green'),
quantity('unassessable', '待评估SKU', summary.unassessableSkuCount, 'purple'),
] },
]
})
function volumeWarning(value: TurnoverVolume | undefined, decimals = 6) {
if (!value) return '数量或尺寸待确认'
const notes: string[] = []
if (value.value == null) notes.push(value.knownSubtotal == null ? '暂无已知小计' : `已知小计 ${volumeText(value.knownSubtotal, decimals)}`)
if (value.missingDimensionSkuCount) notes.push(`缺尺寸 ${value.missingDimensionSkuCount} SKU`)
if (value.unknownQuantitySkuCount) notes.push(`数量待确认 ${value.unknownQuantitySkuCount} SKU`)
return notes.join(' · ')
}
const runtimeError = ref('')
const inventoryRuntime = computed(() => runtime.value?.warehouses.find(w => w.warehouseCode === applied.value?.warehouseCode && w.dataFamily === 'INVENTORY'))
const collectionNotice = computed(() => !data.value || runtimeError.value ? '' : inventoryCollectionNotice(inventoryRuntime.value))
@@ -270,11 +279,12 @@ onActivated(() => { const resume = initialized && !active; active = true; if (re
onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() })
</script>
<template>
<AdminPage class="admin-density turnover-page">
<AdminPage class="admin-density admin-workbench turnover-page">
<p v-if="!permitted" role="alert">没有库存分析查看权限。</p>
<template v-else>
<LoadErrorState v-if="error" title="库存周转读取失败" :message="error.message" compact @retry="runtime ? load() : initialize()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="inputError" role="alert">{{ inputError }}</p><p v-if="notice" role="status">{{ notice }}</p>
<div class="workbench-table">
<DataTable :data="data?.rows ?? []" :columns="columns" :loading="loading" :total="data?.total ?? 0" :page="page" :page-size="size" appearance="integrated" :empty-text="!warehouses.length ? '暂无可用仓库' : error ? '读取失败,请重试' : '暂无符合条件的数据'" @update:page="page = $event; load()" @update:page-size="size = $event; search()">
<template #sortHeader="{ column }">
<span class="turnover-sort-heading">
@@ -294,8 +304,8 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
</el-popover>
</span>
</template>
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span><InlineHelp title="库存体积说明"><p>体积优先采用完整仓库实测尺寸,缺失时按商品尺寸估算;库存按当前全品质实物件数计算,历史出库按当前商品尺寸估算,不含在途及库位空隙。缺尺寸或数量未知时展示已知小计。</p></InlineHelp></h2></slot></template>
<template #toolbar-actions><el-button v-if="canExport" :disabled="loading || !data || exporting" title="后台导出全部匹配记录与当前显示列,在下载中心下载 Excel" @click="exportRows">{{ exporting ? '提交中…' : '导出' }}</el-button><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷流水还原并经库存锚点核对的每日末实物库存均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p>
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title"><span class="workbench-title">库存周转</span><span class="workbench-note">销量 · 库存 · 周转效率</span><InlineHelp title="库存体积说明"><p>体积优先采用完整仓库实测尺寸,缺失时按商品尺寸估算;库存按当前全品质实物件数计算,历史出库按当前商品尺寸估算,不含在途及库位空隙。缺尺寸或数量未知时展示已知小计。</p></InlineHelp></h2></slot></template>
<template #toolbar-actions><el-button v-if="canExport" :disabled="loading || !data || exporting" title="后台导出全部匹配记录与当前显示列,在下载中心下载 Excel" @click="exportRows">{{ exporting ? '提交中…' : '导出' }}</el-button><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button v-if="data?.charts" :aria-pressed="chartsOpen" :title="chartsOpen ? '收起图表' : '展开图表'" @click="chartsOpen = !chartsOpen">图表</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起更多指标' : '显示更多指标' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules turnover-statistics"><p v-if="data">{{ applied?.customerCodes?.length ? '所选货主概览' : '本仓概览' }} · 截止 {{ applied?.endDate }} · 基准 {{ applied?.baseWindowDays }} 天。当前库存采集 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间)。</p><p>当前库存使用查询时固定的快照,不随统计时段变化。图表与指标为{{ applied?.customerCodes?.length ? '所选货主' : '本仓' }}整体,不随列表SKU或关注筛选改变。</p><p>库存体积优先采用仓库实测尺寸,缺失时按商品尺寸估算;历史出库体积按当前商品尺寸估算。缺尺寸或数量未知时仅展示已知小计,具体来源见数值提示。</p><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷流水还原并经库存锚点核对的每日末实物库存均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p>
<p>历史库存记录用于计算期间平均库存和周转次数,与已采集的销量天数不同;记录不足不代表销量采集失败。</p>
<template v-if="data?.rows.length">
<label>查看本页历史库存记录
@@ -312,55 +322,83 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<QueryToolbar :model-value="filters" :fields="fields" ariaLabel="库存周转查询" preference-key="selfop-turnover-query" @update:model-value="changeFilters" @search="search" @reset="reset" />
</template>
<template #before-results>
<div v-if="data || selectedOwners.length" class="turnover-context">
<template v-if="selectedOwners.length"><el-tag v-for="code in selectedOwners" :key="code" closable @close="changeOwners(selectedOwners.filter(item => item !== code))">{{ ownerLabel(code) }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners">返回货主汇总</el-button></template>
<template v-if="data"><span>{{ applied?.customerCodes?.length ? '所选货主概览' : '本仓概览' }} · 截止 {{ applied?.endDate }} · 基准 {{ applied?.baseWindowDays }} 天</span><span>当前库存采集 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</span><span v-if="error">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</span></template>
<div v-if="selectedOwners.length" class="turnover-context">
<el-tag v-for="code in selectedOwners" :key="code" closable @close="changeOwners(selectedOwners.filter(item => item !== code))">{{ ownerLabel(code) }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners">返回货主汇总</el-button>
</div>
<p v-if="data && error" class="turnover-context" role="status">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</p>
<p v-if="data?.context.analyticsContext.inventoryStatus === 'STALE'" class="turnover-context" role="status">当前库存已过期,实物、可售、货主排名及预计可售天数暂不可用;最近库存观测完成于 {{ beijingTime(data.context.inventoryObservedUntil) }}。采集成功后请整体刷新。</p>
<p v-else-if="data?.context.analyticsContext.inventoryStatus === 'UNKNOWN'" class="turnover-context" role="status">当前尚无可用库存版本,请查看数据采集。</p>
<p v-if="collectionNotice" class="turnover-context" role="status">{{ collectionNotice }};采集状态截至 {{ beijingTime(inventoryRuntime?.observedAt) }}。<el-button link @click="router.push('/self-operated/collection')">查看数据采集</el-button></p>
<p v-if="runtimeError" class="turnover-context" role="status">{{ runtimeError }}</p>
<div v-if="summaryGroups.length" class="turnover-summary">
<section v-for="group in summaryGroups" :key="group.key" class="turnover-summary-group" :data-group="group.key" :aria-label="group.name">
<header><h4>{{ group.name }}</h4><span>{{ group.note }}</span></header>
<header class="workbench-section-heading"><h4 class="workbench-section-title">{{ group.name }}</h4></header>
<div class="turnover-cards">
<article v-for="card in group.cards" :key="card.key" :data-metric="card.key" :data-kind="card.kind" :data-tone="card.tone">
<span>{{ card.name }}</span><strong :title="card.title">{{ card.text }}</strong><small>{{ card.note }}</small>
<article v-for="card in group.cards" :key="card.key" class="workbench-metric is-compact workbench-tone" :data-metric="card.key" :data-kind="card.kind" :data-tone="card.tone">
<div class="workbench-metric-heading">
<span class="workbench-icon is-compact" aria-hidden="true"><Ruler v-if="card.kind === 'volume'" :size="18" /><Boxes v-else :size="18" /></span>
<span class="workbench-metric-label">{{ card.name }}</span>
</div>
<div class="workbench-metric-value"><strong :title="card.title">{{ card.text }}</strong></div>
<small v-if="card.note" class="workbench-note">{{ card.note }}</small>
</article>
</div>
</section>
</div>
<template v-if="data?.charts">
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCodes?.length ? '所选货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
<div v-if="chartsOpen" class="turnover-charts">
<section><header class="turnover-chart-header"><h3>货主实物件数 Top10</h3><ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p></section>
<section aria-label="库存体积与出库对比">
<header class="turnover-chart-header">
<div class="density-status-tabs" role="group" aria-label="图表指标">
<section class="workbench-panel">
<header class="turnover-chart-header workbench-chart-heading">
<h3 class="workbench-chart-title">货主实物件数 Top10</h3>
<div class="workbench-chart-actions">
<ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog>
</div>
</header>
<AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" />
<div v-else class="turnover-chart-empty workbench-chart-empty is-compact">
<span class="workbench-chart-empty-icon" aria-hidden="true"><ChartNoAxesCombined :size="24" /></span>
<strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span>
</div>
<p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note workbench-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p>
</section>
<section class="workbench-panel" aria-label="库存体积与出库对比">
<header class="turnover-chart-header workbench-chart-heading">
<div class="workbench-tabs" role="group" aria-label="图表指标">
<button type="button" :class="{ 'is-active': rightChart === 'volume' }" :aria-pressed="rightChart === 'volume'" @click="rightChart = 'volume'">货主库存立方数 Top10</button>
<button type="button" :class="{ 'is-active': rightChart === 'daily' }" :aria-pressed="rightChart === 'daily'" @click="rightChart = 'daily'">日均出库对比</button>
</div>
<div class="workbench-chart-actions">
<ChartDetailsDialog v-if="rightChart === 'volume'" key="volume" inline button-label="查看明细" title="货主库存立方数明细" :reset-on="data.context.snapshotContext">
<p>当前库存,不随统计时段变化;按完整体积排名,不含在途及库位空隙。</p>
<p v-if="data.charts.unknownVolumeOwnerCount">{{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知,未参与排名。</p>
<table class="density-table"><thead><tr><th>货主</th><th>库存立方数</th><th>计算说明</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerVolumeTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td :title="volumeText(owner.inventoryVolumeM3.value)">{{ volumeText(owner.inventoryVolumeM3.value, 2) }}</td><td>{{ volumeNote(owner.inventoryVolumeM3) }}</td></tr></tbody></table>
</ChartDetailsDialog>
<ChartDetailsDialog v-else key="daily" inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog>
</div>
</header>
<template v-if="rightChart === 'volume'">
<AnalyticsChart v-if="data.charts.ownerVolumeTop.length" key="volume" class="turnover-chart" :option="turnoverChart(data.charts, 'volume')" @select="event => { const owner = data?.charts?.ownerVolumeTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" />
<div v-else class="turnover-chart-empty"><strong>暂无可排名的库存体积数据</strong><span>需有可用库存及有效尺寸;未知体积不按零参与排名</span></div>
<p class="turnover-chart-note">当前库存,不随统计时段变化;尺寸口径见明细。<span v-if="data.charts.unknownVolumeOwnerCount">{{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知,未参与排名。</span></p>
<div v-else class="turnover-chart-empty workbench-chart-empty is-compact">
<span class="workbench-chart-empty-icon" aria-hidden="true"><ChartNoAxesCombined :size="24" /></span>
<strong>暂无可排名的库存体积数据</strong><span>需有可用库存及有效尺寸;未知体积不按零参与排名</span>
</div>
<p v-if="data.charts.unknownVolumeOwnerCount" class="turnover-chart-note workbench-note">{{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知,未参与排名。</p>
</template>
<template v-else>
<p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? '日均暂不可用' : '覆盖不足' }}(完整 {{ window.completeDayCount }} / {{ window.days }} 日)</span></div>
<p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note workbench-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p>
<AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" />
<div v-else class="turnover-chart-empty workbench-chart-empty is-compact">
<span class="workbench-chart-empty-icon" aria-hidden="true"><ChartNoAxesCombined :size="24" /></span>
<p>暂无完整窗口的日均出库数据,覆盖情况如下。</p>
</div>
<div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? '日均暂不可用' : '覆盖不足' }}(完整 {{ window.completeDayCount }} / {{ window.days }} 日)</span></div>
</template>
</section>
</div>
</template>
<div class="turnover-list-filters">
<nav class="turnover-focus" aria-label="库存关注快捷筛选">
<nav class="turnover-focus workbench-tabs" aria-label="库存关注快捷筛选">
<button type="button" :aria-pressed="applied?.focus === 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部</button>
<button type="button" :aria-pressed="applied?.focus === 'NO_OUTBOUND_IN_WINDOW'" @click="quickFocus('NO_OUTBOUND_IN_WINDOW', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">所选时段无出库</button>
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_GT'" @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</button>
@@ -370,7 +408,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<el-checkbox class="turnover-zero-filter" border aria-label="隐藏 0 库存" title="按当前实物件数筛选;库存未知仍保留" :model-value="filters.hideZeroInventory !== false" :disabled="loading" @change="toggleZeroInventory">隐藏 0 库存</el-checkbox>
</div>
<div v-if="['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold">
<label>可售天数阈值 <input v-model="filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天</label>
<label>可售天数阈值 <input v-model="filters.coverageDaysThreshold" class="workbench-input" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天</label>
<el-button @click="search()">应用</el-button>
<InlineHelp title="可售天数阈值说明"><p>按当前可售库存预计还能销售的天数筛选:超过阈值为较慢,不超过阈值为较快。此数值不是上方的统计时间范围;快捷筛选首次默认30天,可修改。零可售或无出库不归为较快。</p></InlineHelp>
</div>
@@ -383,9 +421,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<template #owner="{ row }"><el-button v-if="ownerMode" link type="primary" @click="selectOwner(castRow(row).customerCode)">{{ castRow(row).customerName || castRow(row).customerCode }}</el-button><span v-else>{{ castRow(row).customerName || castRow(row).customerCode }}</span></template>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'transitQuantity', 'frozenQuantity', 'defectiveQuantity'] as const" :key="key" #[key]="{ row }">{{ totalText(castRow(row)[key]) }}</template>
<template v-for="n in [7, 15, 30] as TurnoverWindow[]" :key="n" #[`outbound${n}`]="{ row }">{{ windowText(castRow(row).windows.find(w => w.days === n)) }}</template>
<template #inventoryVolume="{ row }">{{ volumeText(castRow(row).inventoryVolumeM3.value) }}<small>{{ volumeNote(castRow(row).inventoryVolumeM3) }}</small></template>
<template #outboundVolume="{ row }">{{ volumeText(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume.value) }}<small>{{ volumeNote(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume, true) }}</small></template>
<template #unitVolume="{ row }">{{ volumeText(castRow(row).unitVolumeM3, 9) }}<small>{{ dimensionSourceText(castRow(row).dimensionSource) }}</small></template>
<template #inventoryVolume="{ row }"><span class="turnover-volume-value" :title="volumeNote(castRow(row).inventoryVolumeM3)">{{ volumeText(castRow(row).inventoryVolumeM3.value) }}</span><small v-if="volumeWarning(castRow(row).inventoryVolumeM3)">{{ volumeWarning(castRow(row).inventoryVolumeM3) }}</small></template>
<template #outboundVolume="{ row }"><span class="turnover-volume-value" :title="volumeNote(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume, true)">{{ volumeText(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume.value) }}</span><small v-if="volumeWarning(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume)">{{ volumeWarning(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume) }}</small></template>
<template #unitVolume="{ row }"><span class="turnover-volume-value" :title="dimensionSourceText(castRow(row).dimensionSource)">{{ volumeText(castRow(row).unitVolumeM3, 9) }}</span><small v-if="castRow(row).unitVolumeM3 == null">缺少有效尺寸</small></template>
<template #daily="{ row }">{{ turnoverNumber(castRow(row).averageDailyOutboundQuantity) }}</template>
<template #turnoverRate="{ row }"><span :title="castRow(row).quantityTurnoverCount == null ? historicalMetricHint(castRow(row)) : `所选${applied?.baseWindowDays}天出库量 ÷ 期间平均实物库存 × 100%`">{{ turnoverPercent(castRow(row).quantityTurnoverCount) }}</span></template>
<template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template>
@@ -395,8 +433,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<template #turnover="{ row }"><span :title="castRow(row).quantityTurnoverCount == null ? historicalMetricHint(castRow(row)) : undefined">{{ castRow(row).quantityTurnoverCount == null ? '—' : `${turnoverNumber(castRow(row).quantityTurnoverCount)} 次/${applied?.baseWindowDays}日` }}</span></template>
<template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template>
</DataTable>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="历史库存记录与销量覆盖" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerName || evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};历史库存记录 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }}天;历史覆盖起点 {{ data.context.accumulationStartedOn || '尚无合格历史记录' }}</p><p>缺少库存记录的日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日库存口径:北京时间每日结束时的全品质实物库存,由完整库存流水和独立库存锚点还原;本次查询固定于 {{ beijingTime(data.context.queryAsOf) }} 的已发布版本。</p><p>历史库存记录与销量覆盖分别校验。流水缺页、库存锚点不符或正在校正的日期保持未知;不以当前库存代替历史库存,也不将缺失数据补零。</p></template></AppDialog>
</div>
<AppDialog v-model="trendOpen" class="admin-density-dialog admin-workbench is-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
<AppDialog v-model="evidenceOpen" class="admin-density-dialog admin-workbench is-dialog" title="历史库存记录与销量覆盖" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><div v-if="evidence && data" class="workbench-evidence"><p>{{ evidence.customerName || evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};历史库存记录 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }}天;历史覆盖起点 {{ data.context.accumulationStartedOn || '尚无合格历史记录' }}</p><p>缺少库存记录的日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日库存口径:北京时间每日结束时的全品质实物库存,由完整库存流水和独立库存锚点还原;本次查询固定于 {{ beijingTime(data.context.queryAsOf) }} 的已发布版本。</p><p>历史库存记录与销量覆盖分别校验。流水缺页、库存锚点不符或正在校正的日期保持未知;不以当前库存代替历史库存,也不将缺失数据补零。</p></div></AppDialog>
</template>
</AdminPage>
</template>
@@ -410,45 +449,26 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
.turnover-page :deep(.data-table--integrated > .data-table__summary) { padding: 12px 20px 0; }
.turnover-page :deep(.data-table--integrated > .el-table) { width: calc(100% - 40px); margin-inline: 20px; }
.turnover-title { margin: 0; font-size: var(--admin-font-section); display: flex; align-items: center; gap: 16px; }
.turnover-title span { font-size: var(--admin-font-caption); font-weight: 400; color: var(--el-text-color-secondary); }
.turnover-title { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; }
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-summary { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 4px 0 12px; }
.turnover-summary { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: var(--workbench-grid-gap); padding: 4px 0 12px; }
.turnover-summary-group { min-width: 0; display: flex; flex-direction: column; }
.turnover-summary-group > header { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 8px; }
.turnover-summary-group h4 { margin: 0; font-size: var(--admin-font-body); }
.turnover-summary-group > header span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-summary-group > header { margin-bottom: var(--admin-space-3); }
.turnover-cards { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; flex: 1; }
.turnover-cards article { position: relative; overflow: hidden; min-width: 0; padding: 12px; border: 1px solid var(--el-color-primary-light-8); background: linear-gradient(120deg, #fff, var(--el-color-primary-light-9)); border-radius: 10px; color: var(--el-color-primary); }
.turnover-cards article[data-tone="green"] { color: #248260; border-color: #dbeee5; background: linear-gradient(120deg, #fff, #edf8f1); }
.turnover-cards article[data-tone="orange"] { color: #b37a22; border-color: #f4e7d3; background: linear-gradient(120deg, #fff, #fff6e8); }
.turnover-cards article[data-tone="purple"] { color: #8a62bf; border-color: #e9e0f5; background: linear-gradient(120deg, #fff, #f6f0fc); }
.turnover-cards article::after { content: ''; position: absolute; width: 88px; height: 88px; border-radius: 50%; right: -22px; top: -30px; background: currentColor; opacity: .07; pointer-events: none; }
.turnover-cards article > span { color: var(--el-text-color-regular); }
.turnover-cards strong { display: block; font-size: 24px; margin: 6px 0 4px; font-variant-numeric: tabular-nums; }
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 0 12px; align-items: stretch; }
.turnover-charts section { height: 360px; box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 10px; }
.turnover-charts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--workbench-grid-gap); padding: 0 0 12px; align-items: stretch; }
.turnover-charts > section { height: 360px; box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; }
.turnover-chart { flex: 1 1 0; height: 0; min-height: 120px; width: 100%; }
.turnover-chart-empty { flex: 1 1 0; min-height: 0; padding: 16px; text-align: center; }
.turnover-chart-header { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.turnover-chart-header h3 { margin: 0; }
.turnover-chart-empty { flex: 1 1 0; min-height: 0; }
.turnover-chart-header { flex-shrink: 0; }
.turnover-chart-header :deep(.chart-disclosure) { flex-shrink: 0; }
.turnover-chart-empty { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; color: var(--el-text-color-secondary); background: var(--el-fill-color-lighter); border-radius: 8px; margin-top: 12px; }
.turnover-chart-empty strong { color: var(--el-text-color-regular); }
.turnover-chart-note { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin: 8px 0 0; }
.turnover-window-gaps { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 8px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-list-filters { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); border-bottom: 1px solid var(--el-border-color-light); }
.turnover-zero-filter { flex: 0 0 auto; margin: var(--admin-space-1) 0; }
.turnover-focus { display: flex; flex-wrap: wrap; gap: 24px; margin: 0; }
.turnover-focus button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 12px 4px; font: inherit; color: var(--el-text-color-regular); cursor: pointer; }
.turnover-focus button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
.turnover-focus button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.turnover-threshold { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 10px 0; font-size: var(--admin-font-caption); }
.turnover-threshold label { display: flex; align-items: center; gap: 8px; }
.turnover-threshold input { width: 76px; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: var(--admin-radius); font: inherit; color: var(--el-text-color-primary); }
.turnover-threshold input { width: 76px; }
.turnover-threshold > span { color: var(--el-text-color-secondary); }
.turnover-filter-feedback { margin: 8px 0; color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption); }
h3 { margin: 0 0 12px; font-size: var(--admin-font-body); border-left: 3px solid var(--el-color-primary); padding-left: 10px; }
small { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-rules { font-size: var(--admin-font-caption); line-height: 1.7; }
</style>
@@ -6,6 +6,7 @@ import { defineComponent } from 'vue'
vi.mock('vue-echarts', () => ({ default: defineComponent({ name: 'VChart', props: ['option', 'autoresize', 'initOptions'], template: '<div />' }) }))
import SkuTrend from './SkuTrend.vue'
import type { DailyResponse } from '../types'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
function data(status = 'COMPLETE'): DailyResponse {
return {
@@ -24,6 +25,13 @@ describe('SKU daily trend axes', () => {
expect(option.yAxis).toMatchObject({ type: 'value', min: 0, name: '出库量(件)' })
expect(option.series[1].data).toEqual(Array(30).fill(1))
expect(option.tooltip.trigger).toBe('axis')
expect(option.tooltip).toMatchObject({ backgroundColor: adminWorkbenchChartTheme.colors.surface, borderColor: adminWorkbenchChartTheme.colors.border })
expect(option.series[0].itemStyle.color).toBe(adminWorkbenchChartTheme.colors.primary)
expect(option.yAxis.splitLine).toEqual(adminWorkbenchChartTheme.splitLine)
expect(w.get('section').classes()).toContain('admin-workbench')
expect(w.find('.workbench-panel .workbench-chart-title').text()).toContain('SKU · 每日出库趋势')
expect(w.find('.workbench-meta').text()).toContain('所属货主:OWNER')
expect(w.findAll('.workbench-note').some(note => note.text().includes('完整出库量:30 件'))).toBe(true)
expect(chart.props('initOptions')).toEqual({ renderer: 'canvas' })
expect(w.get('.sku-trend').find('svg').exists()).toBe(false)
w.unmount()
@@ -37,4 +45,27 @@ describe('SKU daily trend axes', () => {
expect(option.xAxis.data).toHaveLength(30)
w.unmount()
})
it('retains partial segments, real zero and the coverage evidence dialog after theming', async () => {
const response = data()
response.points = [
{ ...response.points[0]!, outboundQuantity: '0', observedQuantity: '0' },
{ ...response.points[1]!, outboundQuantity: null, observedQuantity: '3', dataStatus: 'PARTIAL', coveredUntil: '2026-09-02 12:00:00' },
{ ...response.points[2]!, outboundQuantity: null, observedQuantity: null, dataStatus: 'UNKNOWN' },
{ ...response.points[3]!, outboundQuantity: '8', observedQuantity: '8' },
]
const w = mount(SkuTrend, { props: { data: response }, global: { plugins: [ElementPlus] } })
const option = w.findComponent({ name: 'VChart' }).props('option')
expect(option.series[0].data.map((point: { value: number | null }) => point.value)).toEqual([0, 3, null, 8])
expect(option.series[1]).toMatchObject({ data: [0, 3, null, null], lineStyle: { type: 'dashed', color: adminWorkbenchChartTheme.colors.primary } })
expect(option.series).toHaveLength(2)
expect(w.find('table').exists()).toBe(false)
const evidence = w.findAll('button').find(button => button.text() === '每日数量、完整性及覆盖证据')!
await evidence.trigger('click')
expect(w.get('table').text()).toContain('北京时间日期')
expect(w.get('table').text()).toContain('2026-09-02 12:00:00')
expect(w.findAll('tbody tr')).toHaveLength(4)
await w.setProps({ data: data('UNKNOWN') })
expect(w.find('table').exists()).toBe(false)
w.unmount()
})
})
@@ -6,22 +6,29 @@ import AnalyticsChart from './AnalyticsChart.vue'
import type { DailyResponse } from '../types'
import { label, quantityText, shownQuantity, reasonsText } from '../state'
import { skuTrendOption } from '../skuTrendChart'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
import '@/styles/adminWorkbench.css'
const props = defineProps<{ data: DailyResponse }>()
const option = computed(() => skuTrendOption(props.data.points))
const option = computed(() => skuTrendOption(props.data.points, adminWorkbenchChartTheme))
const hasData = computed(() => props.data.points.some(point => point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null))
</script>
<template>
<section aria-label="SKU每日出库趋势">
<h3>{{ data.summary.sku }} · 每日出库趋势 <InlineHelp title="出库趋势图例说明"><p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 空缺:未知,缺失日期断线</p></InlineHelp></h3>
<p>所属货主:{{ data.summary.customerCode || '—' }} · {{ data.summary.productName || '—' }}</p>
<p>{{ data.summary.dataStatus === 'COMPLETE' ? '完整出库量' : '已采集出库量' }}:{{ quantityText(shownQuantity(data.summary)) }} {{ data.summary.quantityUnit === 'PCS' ? '件' : '单位未知' }};{{ label(data.summary.dataStatus) }}。今日为已采集累计。</p>
<p v-if="!hasData">所选范围尚无可靠数据,未知日期不作为零销量。</p>
<AnalyticsChart :option="option" class="sku-trend" aria-label="SKU每日出库趋势" />
<ChartDetailsDialog title="每日数量、完整性及覆盖证据" :reset-on="data">
<table class="density-table"><thead><tr><th>北京时间日期</th><th>完整出库量</th><th>已采集量</th><th>状态</th><th>覆盖截止</th><th>缺口原因</th></tr></thead><tbody>
<tr v-for="point in data.points" :key="point.businessDate"><td>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ point.coveredUntil ?? '—' }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr>
</tbody></table>
</ChartDetailsDialog>
<section class="admin-workbench" aria-label="SKU每日出库趋势">
<div class="workbench-panel">
<header class="workbench-chart-heading">
<h3 class="workbench-chart-title">{{ data.summary.sku }} · 每日出库趋势</h3>
<div class="workbench-chart-actions"><InlineHelp title="出库趋势图例说明"><p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 空缺:未知,缺失日期断线</p></InlineHelp></div>
</header>
<p class="workbench-meta workbench-note">所属货主:{{ data.summary.customerCode || '—' }} · {{ data.summary.productName || '—' }}</p>
<p class="workbench-note">{{ data.summary.dataStatus === 'COMPLETE' ? '完整出库量' : '已采集出库量' }}:{{ quantityText(shownQuantity(data.summary)) }} {{ data.summary.quantityUnit === 'PCS' ? '件' : '单位未知' }};{{ label(data.summary.dataStatus) }}。今日为已采集累计。</p>
<p v-if="!hasData" class="workbench-note">所选范围尚无可靠数据,未知日期不作为零销量。</p>
<AnalyticsChart :option="option" class="sku-trend workbench-chart-canvas" aria-label="SKU每日出库趋势" />
<ChartDetailsDialog title="每日数量、完整性及覆盖证据" :reset-on="data">
<table class="density-table"><thead><tr><th>北京时间日期</th><th>完整出库量</th><th>已采集量</th><th>状态</th><th>覆盖截止</th><th>缺口原因</th></tr></thead><tbody>
<tr v-for="point in data.points" :key="point.businessDate"><td>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ point.coveredUntil ?? '—' }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr>
</tbody></table>
</ChartDetailsDialog>
</div>
</section>
</template>
<style scoped>
@@ -1,16 +1,21 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { GaugeSeriesOption, BarSeriesOption } from 'echarts'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
import { barChartOption, progressGaugeOption } from './dashboardChartOptions'
describe('self-operated ECharts configurations', () => {
it('wraps a concise tooltip and escapes source text rather than inserting markup', () => {
const option = barChartOption([{ key: 'sku', label: '<img src=x>', secondary: 'A&B', value: 12, detail: 'very long detail belongs in the dialog' }], '件', '热销', true)
const option = barChartOption([{ key: 'sku', label: '<img src=x>', secondary: 'A&B "\'货主', value: '12000.123456', detail: 'very long detail belongs in the dialog' }], '<件>', '热销', true)
const tooltip = option.tooltip as { formatter: (value: { dataIndex: number }) => string; extraCssText: string }
const content = tooltip.formatter({ dataIndex: 0 })
expect(content).toContain('&lt;img src=x&gt;')
expect(content).toContain('A&amp;B')
expect(content).toContain('A&amp;B &quot;&#39;货主')
expect(content).toContain('12,000.123456')
expect(content).toContain('&lt;件&gt;')
expect(content).not.toContain('<img')
expect(content).not.toContain('very long detail')
expect(tooltip.extraCssText).toContain('white-space: normal')
expect(option.tooltip).toMatchObject({ backgroundColor: adminWorkbenchChartTheme.colors.surface, borderColor: adminWorkbenchChartTheme.colors.border })
})
it('renders unknown progress as an empty track without a zero progress value', () => {
const unknown = (progressGaugeOption(null).series as GaugeSeriesOption[])[0]!
@@ -19,6 +24,30 @@ describe('self-operated ECharts configurations', () => {
const known = (progressGaugeOption(12.1).series as GaugeSeriesOption[])[0]!
expect(known.progress?.show).toBe(true)
expect(known.data).toEqual([{ value: 12.1 }])
expect(known.itemStyle).toMatchObject({ color: adminWorkbenchChartTheme.colors.primary })
expect(known.axisLine?.lineStyle?.color).toEqual([[1, adminWorkbenchChartTheme.colors.primarySoft]])
const zero = (progressGaugeOption(0).series as GaugeSeriesOption[])[0]!
expect(zero.progress?.show).toBe(true)
expect(zero.data).toEqual([{ value: 0 }])
})
it('keeps indeterminate activity distinct from known completion and honors reduced motion', () => {
const matchMedia = vi.fn(() => ({ matches: true }))
vi.stubGlobal('window', { matchMedia })
try {
const running = progressGaugeOption(null, true)
expect(running.animation).toBe(false)
expect((running.series as GaugeSeriesOption[])[0]).toMatchObject({
progress: { show: true, roundCap: true },
data: [{ value: 18 }],
})
expect(progressGaugeOption(12.1).animation).toBe(false)
expect(matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)')
matchMedia.mockReturnValue({ matches: false })
expect(progressGaugeOption(12.1).animation).toBe(true)
expect(progressGaugeOption(null, true).animation).toBe(false)
} finally {
vi.unstubAllGlobals()
}
})
it('keeps the seven bucket order, zero, null and common numeric scale', () => {
const rows = Array.from({ length: 7 }, (_, index) => ({ key: `bucket-${index}`, label: `分组${index}`, value: index === 6 ? null : index, detail: '' }))
@@ -27,5 +56,64 @@ describe('self-operated ECharts configurations', () => {
expect(option.xAxis).toMatchObject({ type: 'value', min: 0 })
expect((option.series as BarSeriesOption[])[0]?.data?.[0]).toMatchObject({ value: 0 })
expect((option.series as BarSeriesOption[])[0]?.data?.[6]).toMatchObject({ value: null })
expect((option.series as BarSeriesOption[])[0]).not.toHaveProperty('barMinHeight')
expect((option.series as BarSeriesOption[])[0]).not.toHaveProperty('showBackground')
})
it('assigns bucket colors by semantic key even when the rows are reordered', () => {
const expected: Array<[string, string]> = [
['UNKNOWN', adminWorkbenchChartTheme.colors.unknown],
['DAYS_GT_90', adminWorkbenchChartTheme.colors.teal],
['NO_AVAILABLE', adminWorkbenchChartTheme.colors.rose],
['DAYS_30_60', adminWorkbenchChartTheme.colors.primaryLight],
['NO_OUTBOUND_IN_WINDOW', adminWorkbenchChartTheme.colors.amber],
['DAYS_0_30', adminWorkbenchChartTheme.colors.primary],
['DAYS_60_90', adminWorkbenchChartTheme.colors.tealLight],
]
const rows = expected.map(([key]) => ({ key, label: key, value: 1, detail: '' }))
for (const orderedRows of [rows, [...rows].reverse()]) {
const option = barChartOption(orderedRows, 'SKU', '库存分布')
const data = (option.series as BarSeriesOption[])[0]!.data!
for (const [index, row] of orderedRows.entries()) {
expect(data[index]).toMatchObject({ itemStyle: { color: expected.find(([key]) => key === row.key)?.[1] } })
}
}
})
it('uses one blue for ranking and preserves real values instead of normalizing bar lengths', () => {
const option = barChartOption([
{ key: 'large', label: 'SKU-LARGE', value: '15000', detail: '' },
{ key: 'small', label: 'SKU-SMALL', value: '150', detail: '' },
{ key: 'zero', label: 'SKU-ZERO', value: 0, detail: '' },
{ key: 'unknown', label: 'SKU-UNKNOWN', value: null, detail: '' },
], '件', '热销', true)
const series = (option.series as BarSeriesOption[])[0]!
expect(series.data).toEqual([15000, 150, 0, null].map(value => ({
value, itemStyle: { color: adminWorkbenchChartTheme.colors.primary, borderRadius: [0, 6, 6, 0] },
})))
expect(series).not.toHaveProperty('barMinHeight')
expect(series).not.toHaveProperty('showBackground')
expect(option.xAxis).toMatchObject({ min: 0 })
const label = series.label as { formatter: (value: { dataIndex: number }) => string }
expect(label.formatter({ dataIndex: 0 })).toBe('15,000')
expect(label.formatter({ dataIndex: 2 })).toBe('0')
expect(label.formatter({ dataIndex: 3 })).toBe('—')
})
it('rounds day labels and tooltips to two decimals while retaining the full chart value', () => {
const option = barChartOption([{ key: 'sku', label: 'SKU', value: '1234.56789', detail: '' }], '天', '预计消化时间', true)
const series = (option.series as BarSeriesOption[])[0]!
expect(series.data?.[0]).toMatchObject({ value: 1234.56789 })
const label = series.label as { formatter: (value: { dataIndex: number }) => string }
const tooltip = option.tooltip as { formatter: (value: { dataIndex: number }) => string }
expect(label.formatter({ dataIndex: 0 })).toBe('1,234.57')
expect(tooltip.formatter({ dataIndex: 0 })).toContain('1,234.57 天')
})
it('retains every quantity digit in chart labels and tooltips for values beyond safe integers', () => {
const value = '12345678901234567890.123450000001'
const expected = '12,345,678,901,234,567,890.123450000001'
const option = barChartOption([{ key: 'sku', label: 'SKU', value, detail: '' }], '件', '热销', true)
const series = (option.series as BarSeriesOption[])[0]!
const label = series.label as { formatter: (value: { dataIndex: number }) => string }
const tooltip = option.tooltip as { formatter: (value: { dataIndex: number }) => string }
expect(label.formatter({ dataIndex: 0 })).toBe(expected)
expect(tooltip.formatter({ dataIndex: 0 })).toContain(`${expected} 件`)
})
})
@@ -1,34 +1,55 @@
import type { EChartsOption } from 'echarts'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
import type { Quantity } from './types'
import { quantityText } from './state'
import { groupedQuantityText } from './state'
export interface BarRow { key: string; label: string; secondary?: string; value: Quantity; detail: string }
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]!))
const palette = ['#5575ed', '#3da9ba', '#e8a54b', '#b083dc', '#df8397', '#8a9ab4', '#a2adbf']
const theme = adminWorkbenchChartTheme
const bucketColors: Record<string, string> = {
DAYS_0_30: theme.colors.primary,
DAYS_30_60: theme.colors.primaryLight,
DAYS_60_90: theme.colors.tealLight,
DAYS_GT_90: theme.colors.teal,
NO_OUTBOUND_IN_WINDOW: theme.colors.amber,
NO_AVAILABLE: theme.colors.rose,
UNKNOWN: theme.colors.unknown,
}
export function barChartOption(rows: BarRow[], unit: string, title: string, ranked = false): EChartsOption {
const valueText = (value: Quantity | undefined) => unit === '天' && value != null && Number.isFinite(Number(value)) ? Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2, useGrouping: false }) : quantityText(value)
const valueText = (value: Quantity | undefined) => {
if (unit === '天' && value != null && Number.isFinite(Number(value))) return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 })
return groupedQuantityText(value)
}
return {
animation: false,
grid: { left: 12, right: 48, top: 12, bottom: 24, containLabel: true },
xAxis: { type: 'value', min: 0, name: unit, splitNumber: 3, minInterval: 1, axisLabel: { color: '#697586' }, splitLine: { lineStyle: { type: 'dashed', color: '#e5eaf3' } } },
textStyle: { color: theme.colors.text, fontFamily: theme.fontFamily },
grid: { left: 8, right: 56, top: 16, bottom: 24, containLabel: true },
xAxis: {
type: 'value', min: 0, name: unit, splitNumber: 3, minInterval: 1,
nameTextStyle: theme.axisName,
axisLabel: theme.axisLabel,
axisLine: { show: false, lineStyle: { color: theme.colors.axis } },
axisTick: { show: false },
splitLine: theme.splitLine,
},
yAxis: { type: 'category', inverse: true, data: rows.map(row => row.key), triggerEvent: true,
axisTick: { show: false }, axisLine: { show: false },
axisLabel: { width: ranked ? 116 : 130, overflow: 'truncate', color: '#344054', rich: { rank: { color: '#5575ed', fontWeight: 'bold', width: 24, align: 'left' } }, formatter: key => { const index = rows.findIndex(row => row.key === key); const row = rows[index]; return row ? `${ranked ? `{rank|${index + 1}} ` : ''}${row.label}` : key } } },
tooltip: { trigger: 'item', renderMode: 'html', confine: true, extraCssText: 'max-width: min(300px, 80vw); white-space: normal; overflow-wrap: anywhere;', formatter: params => {
axisLabel: { ...theme.axisLabel, width: ranked ? 138 : 130, margin: 10, overflow: 'truncate', color: theme.colors.text, rich: { rank: { color: theme.colors.primary, fontWeight: 'bold', width: 24, align: 'left' } }, formatter: key => { const index = rows.findIndex(row => row.key === key); const row = rows[index]; return row ? `${ranked ? `{rank|${index + 1}} ` : ''}${row.label}` : key } } },
tooltip: { ...theme.tooltip, trigger: 'item', renderMode: 'html', confine: true, extraCssText: `${theme.tooltip.extraCssText} max-width: min(300px, 80vw); white-space: normal; overflow-wrap: anywhere;`, formatter: params => {
const item = Array.isArray(params) ? params[0] : params
const row = item ? rows[item.dataIndex] : undefined
return row ? `<strong>${escapeHtml(row.label)}</strong><br/>${row.secondary ? `${escapeHtml(row.secondary)}<br/>` : ''}${escapeHtml(valueText(row.value))} ${escapeHtml(unit)}` : ''
} },
aria: { enabled: true, description: `${title},横轴为${unit}。未知不作为零值;详细数据和键盘操作在下方完整明细弹框。` },
series: [{ type: 'bar', barMaxWidth: ranked ? 14 : 18, data: rows.map((row, index) => ({ value: row.value == null ? null : Number(row.value), itemStyle: { color: palette[ranked ? 0 : index % palette.length], borderRadius: [0, 4, 4, 0] } })),
label: { show: true, position: 'right', formatter: params => valueText(rows[params.dataIndex]?.value) } }],
series: [{ type: 'bar', barMaxWidth: ranked ? 18 : 20, data: rows.map(row => ({ value: row.value == null ? null : Number(row.value), itemStyle: { color: ranked ? theme.colors.primary : bucketColors[row.key] ?? theme.colors.unknown, borderRadius: [0, 6, 6, 0] } })),
label: { show: true, position: 'right', distance: 7, color: theme.colors.text, fontSize: 12, fontFamily: theme.fontFamily, formatter: params => valueText(rows[params.dataIndex]?.value) } }],
}
}
export function progressGaugeOption(percent: number | null, running = false): EChartsOption {
const animate = typeof window !== 'undefined' && !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
return { animation: animate && percent != null, animationDuration: 0, animationDurationUpdate: 700, animationEasingUpdate: 'cubicOut', series: [{ id: 'collection-progress', type: 'gauge', startAngle: 90, endAngle: -270, radius: '86%', min: 0, max: 100,
pointer: { show: false }, progress: { show: percent != null || running, roundCap: true, width: 10 },
axisLine: { lineStyle: { width: 10, color: [[1, '#e8eaf5']] } }, itemStyle: { color: '#6879ee' },
pointer: { show: false }, progress: { show: percent != null || running, roundCap: true, width: 12 },
axisLine: { lineStyle: { width: 12, color: [[1, theme.colors.primarySoft]] } }, itemStyle: { color: theme.colors.primary, shadowColor: theme.colors.primarySoft, shadowBlur: 6 },
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, title: { show: false }, detail: { show: false },
silent: true, data: percent == null && !running ? [] : [{ value: percent ?? 18 }],
}] }
@@ -1,9 +1,16 @@
# 自营分析逐操作矩阵
依据:2026-10-12 用户提供的销量/库存页面截图及“主要统计SKU,在看当前SKU是哪个货主就行,货主不重要”的明确纠正,优先于一期方案中的货主经营面板目标;密度沿用 `docs/Admin高密度界面规范.md`。用户最新确认销量、库存样式已完成,本轮以其截图为数据采集统一样板;采集页修改后的视觉仍待人工1920×1080、100%截图。
依据:2026-10-12 用户提供的销量/库存页面截图及“主要统计SKU,在看当前SKU是哪个货主就行,货主不重要”的明确纠正,优先于一期方案中的货主经营面板目标;密度沿用 `docs/Admin高密度界面规范.md`。2026-10-20 用户提供蓝白工作台参考,选经营总览制作样板,并追加销量、库存与数据采集;本次新样式均待人工1920×1080、100%截图确认。
| 页面 | 操作 | 依据与承载结论 | 布局与验证 |
| --- | --- | --- | --- |
| 自营四个入口 | 蓝白样板截图收尾 | 2026-10-20 用户提供五张实际截图并授权收尾及 Admin 测试环境发布:统一数量千分位、明确货主/关注/样本筛选文案、收紧多余留白,库存横向滚动时保留行标识;采集关闭/暂停状态优先展示,已保存进度与批次事实分别表达。随后明确清爽要求:库存快照、采集时间、图表范围等解释收至已有说明,取消正常指标重复的“已确认/完整”,图表入口并入顶部 | 不改变数量精度、筛选含义、调度和接口;未知、过期及失败仍可辨认,详细来源和估算口径按需查看。公共规则及后续页面参考入口写入蓝白工作台规范。整端推广待本轮样板确认,收尾后的实际视觉待人工截图确认 |
| 数据采集 | 蓝白账号运行看板 | 2026-10-20 用户追加数据采集一并调整:复用公共圆角面板、紧凑数量卡、进度环主题、阶段节点与状态标签;保留左侧当前进度和右侧后续安排、阶段流程与各仓发布结果 | 不变更只读刷新、轮询、阶段计数及预计时间依据;未知进度不补百分比,待机不表示完成;待人工1920×1080、100%截图确认 |
| 销量分析/库存明细 | 蓝白列表样板 | 2026-10-20 用户在经营总览样板基础上明确要求一起调整:保留原 DataTable/QueryToolbar、字段顺序、操作列、排序分页、来源条件与统计气泡;统一白底圆角容器、浅色表头与筛选带、蓝色操作和库存视图页签 | 公共 `admin-workbench` 作用域显式接入,补公共表格/页签外观;不重复壳层标题,不改查询及权限;待人工1920×1080、100%截图确认 |
| 库存周转 | 蓝白指标/图表/关注筛选 | 同次用户授权:保留两组各三张摘要卡、两列图表及货主件数/体积/日均三种内容,复用紧凑工作台卡片和公共图表主题;保留关注、隐藏零库存、阈值、扩展列和导出 | 紧凑指标不采用总览大卡最小高度;画布保留现有伸缩高度,真实比例、未知值、单位与货主下钻不变;待人工截图确认 |
| 销量/库存/经营总览 | 共用 SKU 趋势与证据弹窗外观 | 同次用户授权统一共用 SKU 趋势:仍为1120px居中 AppDialog,统计证据继续沿现有居中弹窗;接入蓝白主题、公共标题/说明/内容外观 | 保留全屏、关闭取消、缺日断线、部分日虚线、未知不补零及原覆盖明细;不改变弹窗尺寸与承载,待人工截图确认 |
| /self-operated/overview | 图表主题与空态 | 2026-10-20 用户提供五张图表区域截图并要求按蓝白样板优化:主趋势采用蓝色、连续完整段淡填色、灰蓝坐标与轻缩放条;柱图采用圆角、真实尺度、语义分桶配色及统一提示框;标题与空榜单使用公共样式 | 主题入口 `src/styles/adminWorkbenchChartTheme.ts`,不在 AnalyticsChart 全局注册;缺日断线、部分段虚线、未知/零及下钻不变;共用 SKU 趋势及采集环按本表新增授权接入,待人工新截图确认 |
| /self-operated/overview | 蓝白卡片风格样板 | 2026-10-20 用户选择本页直接改版;参考 `docs/界面参考/蓝白卡片式工作台参考.png`。保留 DataTable 顶栏、QueryToolbar、六指标、2:1 主图及三榜、统计说明和全部明细/趋势弹窗;指标改浅彩底座与蓝色大数字,增加分区标题,图表使用轻边框圆角容器 | 公共外观由 `src/styles/adminWorkbenchTokens.css` 与 `adminWorkbench.css` 维护,仅显式 `admin-workbench` 根启用;原图表高度、统计口径、下钻与弹窗密度保留。尚未批准全 Admin 推广;待人工1920×1080、100%截图及老板确认 |
| 库存周转 | 库存/出库立方数与客户汇总 | 2026-10-14 用户确认:仓库实测尺寸优先,商品尺寸补充并标估算;历史出库按当前尺寸估算。沿现有客户筛选、SKU/货主分组和客户下钻,增加两项概览、体积列与单件尺寸来源,不新增弹框 | 正式总量未知显示“—”;小计、缺尺寸件数/SKU和数量待确认分开显示;出库体积跟随所选时间;待人工1920×1080、100%截图确认 |
| 库存周转 | 库存过期、采集失败与未知总量 | 2026-10-14 用户库存空白截图及本轮修复确认:沿现有同页提示展示过期时间、同仓失败原因与任务号;整体刷新同步只读采集状态,失败独立提示;指标主数值未知显示“—”,小计留辅助说明;原样本证据弹框解释库存日样本与出库历史的区别 | 保留原数据采集入口与弹框承载,不新增写入操作;回归未知/真实0、切仓和刷新恢复,待人工1920×1080、100%截图确认 |
| /self-operated/inventory | 页签与操作栏整合 | 2026-10-14 用户截图 `codex-clipboard-784338aa-b985-4eb7-9f5e-b14f87f6d5e8.png`:页签独立在外、操作栏左侧空白;两个视图的切换页签统一放入DataTable的toolbar-start,操作按钮保留右侧,筛选紧接下方;阈值提示缩短为“天数阈值(天)” | 保留视图选中态、切仓与原图下钻;不改接口与计算,待人工1920×1080、100%截图确认 |
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -46,7 +47,7 @@ onDeactivated(suspend)
onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange', visibility) })
</script>
<template>
<AdminPage class="admin-density">
<AdminPage class="admin-density admin-workbench">
<p v-if="!permitted" role="alert">没有采集状态查看权限。</p>
<template v-else>
<LoadErrorState v-if="error" title="采集状态更新失败" :message="error.message" :loading="loading" compact @retry="read"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
@@ -19,16 +19,10 @@ function switchView(view: 'TURNOVER' | 'DETAIL') {
<p v-if="selected.error" role="alert">{{ selected.error }}</p>
<component v-else :is="selected.view === 'DETAIL' ? AnalyticsView : InventoryTurnoverView" v-bind="selected.view === 'DETAIL' ? { mode: 'inventory' as const } : {}" @warehouse-selected="retainedWarehouse = $event">
<template #view-tabs>
<nav class="inventory-views" aria-label="库存分析视图">
<nav class="inventory-views workbench-tabs" aria-label="库存分析视图">
<button type="button" :aria-pressed="selected.view === 'TURNOVER'" @click="switchView('TURNOVER')">库存周转</button>
<button type="button" :aria-pressed="selected.view === 'DETAIL'" @click="switchView('DETAIL')">库存明细</button>
</nav>
</template>
</component>
</template>
<style scoped>
.inventory-views { display: flex; align-items: center; gap: var(--admin-space-4); }
.inventory-views button { border: 0; border-bottom: 2px solid transparent; background: transparent; min-height: var(--admin-control-height); padding: var(--admin-space-1) var(--admin-space-2); color: var(--el-text-color-regular); font: inherit; cursor: pointer; white-space: nowrap; }
.inventory-views button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
.inventory-views button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
</style>
@@ -50,6 +50,31 @@ describe('warehouse operating dashboard', () => {
expect(response.kpis.averageDailyOutboundQuantity.value).toBe(value)
})
it('groups KPI quantities and subtotals without rounding or turning unavailable values into zero', async () => {
const response = structuredClone(dashboardFixture)
response.kpis.intervalOutboundQuantity.observedQuantity = '1234567.123450000001'
response.kpis.physicalQuantity.value = '9007199254740993'
// The fixture shares one KPI object; give these distinct values their own objects.
response.kpis.averageDailyOutboundQuantity = { ...response.kpis.averageDailyOutboundQuantity, value: '8' }
response.kpis.availableQuantity = { ...response.kpis.availableQuantity, value: null, knownSubtotal: '12345678901234567890.000100', unknownSkuCount: '1234' }
response.kpis.physicalSkuCount = { ...response.kpis.physicalSkuCount, value: '0' }
response.kpis.noOutboundSkuCount = { ...response.kpis.noOutboundSkuCount, value: null }
mocks.dashboard.mockResolvedValue(response)
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findAll('.dashboard-kpis strong').map(node => node.text())).toEqual(['1,234,567.123450000001', '8', '9,007,199,254,740,993', '—', '0', '—'])
expect(wrapper.findAll('.dashboard-kpis .workbench-status').map(node => node.text())).toEqual(['未完整', '未完整'])
expect(wrapper.find('.dashboard-kpis').text()).toContain('已知小计 12,345,678,901,234,567,890.000100')
expect(wrapper.find('.dashboard-kpis').text()).toContain('另 1,234 SKU 待核验')
expect(response.kpis.intervalOutboundQuantity.observedQuantity).toBe('1234567.123450000001')
expect(response.kpis.physicalQuantity.value).toBe('9007199254740993')
})
it('omits the ranking status row when all rankings are complete and current', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.find('.dashboard-ranking-status').exists()).toBe(false)
expect(wrapper.findAllComponents(DashboardBars).filter(chart => chart.props('ranked')).every(chart => !chart.vm.$slots.intro)).toBe(true)
})
it.each(['PARTIAL', 'UNKNOWN'])('renders omitted nullable KPI fields for %s instead of rejecting the dashboard', async status => {
const response = structuredClone(dashboardFixture)
for (const metric of Object.values(response.kpis)) {
@@ -63,6 +88,7 @@ describe('warehouse operating dashboard', () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).not.toContain('指标结构不完整')
expect(wrapper.findAll('.dashboard-kpis strong').map(node => node.text())).toEqual(['8', '—', '—', '—', '—', '—'])
expect(wrapper.findAll('.dashboard-kpis .workbench-status').map(node => node.text())).toEqual(Array(6).fill(status === 'PARTIAL' ? '未完整' : '未知'))
expect(wrapper.text()).toContain('已知小计 128')
expect(wrapper.findAllComponents(DashboardBars)).toHaveLength(4)
})
@@ -186,6 +212,7 @@ describe('warehouse operating dashboard', () => {
it('uses the common title/actions/filter surface without an empty result table', async () => {
const wrapper = page(); await flushPromises()
expect(wrapper.findComponent(DataTable).props()).toMatchObject({ appearance: 'integrated', resultsVisible: false, columnSettingsMode: 'hide' })
expect(wrapper.find('.workbench-toolbar > .data-table.data-table--integrated').exists()).toBe(true)
expect(wrapper.find('.data-table__toolbar-start h2').text()).toBe('经营总览')
expect(wrapper.find('.data-table__toolbar-actions').text()).toContain('整体刷新')
expect(wrapper.find('.data-table__filters').findComponent(query).exists()).toBe(true)
@@ -333,6 +360,7 @@ describe('warehouse operating dashboard', () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).toContain('所选时段热销Top10')
expect(wrapper.text()).toContain('数据未采齐,排名仅供参考')
expect(wrapper.findAll('.dashboard-ranking-status').map(node => node.text())).toEqual(['数据未采齐,排名仅供参考', '', ''])
const hot = wrapper.findAllComponents(DashboardBars)[1]!
expect(hot.props('rows')[0]).toMatchObject({ value: '37' })
expect(hot.props('rows')[0].detail).toContain('13天窗口已采集出库 37 件')
@@ -363,6 +391,8 @@ describe('warehouse operating dashboard', () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
await vi.advanceTimersByTimeAsync(2000)
expect(wrapper.text()).toContain('原图库存已过期或不可用')
expect(wrapper.findAll('.dashboard-kpis .workbench-status').map(node => node.text())).toEqual(['未完整', '历史/待刷新 · 未完整', '历史/待刷新 · 完整', '历史/待刷新 · 完整', '历史/待刷新 · 完整'])
expect(wrapper.findAll('.dashboard-ranking-status').map(node => node.text())).toEqual(['', '历史数据 · 待刷新', '历史数据 · 待刷新'])
expect(wrapper.findAllComponents(DashboardBars)[0]!.props('disabled')).toBe(true)
expect(findButton(wrapper, '查看Top100').attributes('disabled')).toBeUndefined()
wrapper.findAllComponents(DashboardBars)[2]!.vm.$emit('select', '0'); await flushPromises()
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Activity, ArrowUpRight, Boxes, Package, Layers, Clock3, Info } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
@@ -13,7 +14,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { analyticsApi } from '../api/analytics'
import type { Dashboard, DashboardItem, DashboardRanking, DailyResponse, DashboardQuery, Overview, QueryContext, RangePreset } from '../types'
import { beijingTime, contextParams, label, quantityText, reasonsText, RequestFence, RuntimePoller, sameContext } from '../state'
import { beijingTime, contextParams, groupedQuantityText, label, quantityText, reasonsText, RequestFence, RuntimePoller, sameContext } from '../state'
import { buckets, expired, inventoryExpired, parseContext, parseOverviewReturn, previousDate, type OverviewReturn } from '../dashboardNavigation'
import { beijingDate, overviewRange, overviewRangeOptions } from '../overviewRange'
import HotSkuDetails from '../components/HotSkuDetails.vue'
@@ -77,8 +78,9 @@ function averageDailyText(value: string | number | null | undefined) {
return Number.isFinite(number) ? number.toLocaleString('zh-CN', { maximumFractionDigits: 2 }) : '—'
}
const metricIcons = [ArrowUpRight, Activity, Boxes, Package, Layers, Clock3]
const metricColors = ['#5575ed', '#3aa9ba', '#8871ce', '#35a987', '#c6923e', '#d67b91']
const metricTones = ['blue', 'teal', 'purple', 'green', 'amber', 'rose'] as const
const rankingNames = [{ key: 'hotSkus', label: '所选时段热销Top10', unit: '件', all: '查看Top100' }, { key: 'longCoverageSkus', label: '预计消化时间最长Top10', unit: '天', all: '查看全部' }, { key: 'noOutboundSkus', label: '所选时段无出库库存Top10', unit: '件', all: '查看全部' }] as const
const hasRankingWarnings = computed(() => Boolean(data.value && (contextExpired.value || stockExpired.value || rankingNames.some(ranking => data.value![ranking.key].dataStatus !== 'COMPLETE'))))
const bucketRows = computed<BarRow[]>(() => buckets.map(bucket => {
const item = data.value?.coverageDistribution.buckets.find(row => row.bucketCode === bucket.code)
return { key: bucket.code, label: bucket.label, value: item?.skuCount ?? null, detail: `已知实物小计 ${quantityText(item?.knownPhysicalQuantity)} 件;实物未知 ${quantityText(item?.unknownPhysicalSkuCount)} SKU` }
@@ -192,12 +194,13 @@ onMounted(() => { visibility(); poller.start(); timer = setInterval(() => { now.
onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clearInterval(timer); document.removeEventListener('visibilitychange', visibility) })
</script>
<template>
<AdminPage class="admin-density dashboard-page">
<AdminPage class="admin-density admin-workbench dashboard-page">
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
<template v-else>
<div class="workbench-toolbar">
<DataTable appearance="integrated" :data="[]" :columns="[]" :results-visible="false" column-settings-mode="hide">
<template #toolbar-start><div class="dashboard-title"><h2>经营总览</h2><span class="dashboard-snapshot">{{ runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode }}<i />{{ loading ? '正在更新' : data ? '固定版本 · 北京时间' : error ? '查询未完成' : '正在准备数据' }}</span></div></template>
<template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: data!.context.rangeStartDate!, endDate: previousDate(data!.context.rangeEndDateExclusive!) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template>
<template #toolbar-start><div class="workbench-heading"><h2><span class="workbench-title">经营总览</span></h2><span class="workbench-meta">{{ runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode }}<i aria-hidden="true" />{{ loading ? '正在更新' : data ? '固定版本 · 北京时间' : error ? '查询未完成' : '正在准备数据' }}</span></div></template>
<template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: data!.context.rangeStartDate!, endDate: previousDate(data!.context.rangeEndDateExclusive!) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button type="primary" :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template>
<template #toolbar-end>
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间">
<template #reference><el-button circle aria-label="统计口径与采集时间" title="统计口径与采集时间"><Info :size="16" aria-hidden="true" /></el-button></template>
@@ -206,27 +209,52 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
</template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" preference-key="self-operated-overview-query" ariaLabel="经营总览筛选" @search="load()" @reset="reset" /></template>
</DataTable>
<p v-if="!warehouseCode">请先选择一个仓库并查询经营数据。</p>
<p v-if="routeError" role="alert">{{ routeError }}。请返回正常入口重新选择仓库。</p>
</div>
<p v-if="!warehouseCode" class="workbench-notice">请先选择一个仓库并查询经营数据。</p>
<p v-if="routeError" class="workbench-notice workbench-tone" data-tone="rose" role="alert">{{ routeError }}。请返回正常入口重新选择仓库。</p>
<LoadErrorState v-if="error" title="经营总览查询未完成" :message="error" compact :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<p v-if="runtimeError" role="status">{{ runtimeError }}</p>
<p v-if="newData" role="status">有新数据,可整体刷新;当前图表仍使用原查询版本。</p>
<p v-if="contextExpired" role="alert">查询上下文已过期,以下图表为历史结果,请整体刷新。</p>
<p v-else-if="stockExpired && data" role="status">原图库存已过期或不可用,库存指标为历史/待刷新资料,严格库存下钻已停用;出库趋势与热销仍可查看。</p>
<div v-if="loading && !data" class="dashboard-placeholder" role="status" aria-busy="true"><span>正在读取六项指标、日趋势、库存分布和三个榜单…</span><div class="dashboard-skeleton-kpis" aria-hidden="true"><i v-for="index in 6" :key="index" /></div><div class="dashboard-skeleton-charts" aria-hidden="true"><i /><i /></div></div>
<p v-if="runtimeError" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ runtimeError }}</p>
<p v-if="newData" class="workbench-notice" role="status">有新数据,可整体刷新;当前图表仍使用原查询版本。</p>
<p v-if="contextExpired" class="workbench-notice workbench-tone" data-tone="amber" role="alert">查询上下文已过期,以下图表为历史结果,请整体刷新。</p>
<p v-else-if="stockExpired && data" class="workbench-notice workbench-tone" data-tone="amber" role="status">原图库存已过期或不可用,库存指标为历史/待刷新资料,严格库存下钻已停用;出库趋势与热销仍可查看。</p>
<div v-if="loading && !data" class="dashboard-placeholder workbench-section" role="status" aria-busy="true"><span class="workbench-note">正在读取六项指标、日趋势、库存分布和三个榜单…</span><div class="dashboard-skeleton-kpis" aria-hidden="true"><i v-for="index in 6" :key="index" class="workbench-skeleton" /></div><div class="dashboard-main-charts dashboard-skeleton-charts" aria-hidden="true"><i class="workbench-skeleton" /><i class="workbench-skeleton" /></div></div>
<template v-if="data">
<p v-if="includesToday" role="status">所选时段包含今日({{ data.context.anchorDate }}),尚不满足完整自然日要求,库存消化天数与两个库存榜单暂不能完成评估。可选择截至昨日的自定义时段;仍需满足销量覆盖、库存及观察期条件。</p>
<p v-if="data.context.rebuildRequired || data.context.salesStatus === 'REBUILD_REQUIRED'" role="status">销量有变动,待重新统计发布。出库趋势展示已发布版本的历史覆盖结果;库存评估等待新版销量发布。</p>
<p v-if="includesToday" class="workbench-notice workbench-tone" data-tone="amber" role="status">所选时段包含今日({{ data.context.anchorDate }}),尚不满足完整自然日要求,库存消化天数与两个库存榜单暂不能完成评估。可选择截至昨日的自定义时段;仍需满足销量覆盖、库存及观察期条件。</p>
<p v-if="data.context.rebuildRequired || data.context.salesStatus === 'REBUILD_REQUIRED'" class="workbench-notice workbench-tone" data-tone="amber" role="status">销量有变动,待重新统计发布。出库趋势展示已发布版本的历史覆盖结果;库存评估等待新版销量发布。</p>
<section class="workbench-section" aria-labelledby="overview-metrics-heading">
<header class="workbench-section-heading">
<h2 id="overview-metrics-heading" class="workbench-section-title">经营指标</h2>
<span v-if="data.context.rangeStartDate && data.context.rangeEndDateExclusive" class="workbench-badge">出库:{{ data.context.rangeStartDate }} 至 {{ previousDate(data.context.rangeEndDateExclusive) }}</span>
<span class="workbench-badge">库存:查询快照</span>
</header>
<div class="dashboard-kpis" aria-label="本仓经营指标">
<section v-for="(metric, index) in metricNames" :key="metric.key" :style="{ '--metric-color': metricColors[index], '--reveal-delay': `${index * 45}ms` }"><div class="dashboard-metric-heading"><h3>{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3><component :is="metricIcons[index]" :size="18" aria-hidden="true" /></div><div class="dashboard-metric-value"><strong>{{ metric.key === 'averageDailyOutboundQuantity' ? averageDailyText(data.kpis[metric.key].value) : quantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong><span> {{ metric.key === 'averageDailyOutboundQuantity' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span></div><p class="dashboard-metric-status">{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p><p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'">已知小计 {{ quantityText(data.kpis[metric.key].knownSubtotal) }}</p><p v-if="Number(data.kpis[metric.key].unknownSkuCount) > 0">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small v-if="data.kpis[metric.key].reasonCodes?.length">{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
<section v-for="(metric, index) in metricNames" :key="metric.key" class="workbench-metric workbench-tone" :data-tone="metricTones[index]">
<div class="workbench-metric-heading">
<span class="workbench-icon"><component :is="metricIcons[index]" :size="24" aria-hidden="true" /></span>
<h3 class="workbench-metric-label">{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3>
</div>
<div class="workbench-metric-value">
<strong>{{ metric.key === 'averageDailyOutboundQuantity' ? averageDailyText(data.kpis[metric.key].value) : groupedQuantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong>
<span>{{ metric.key === 'averageDailyOutboundQuantity' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span>
</div>
<p v-if="data.kpis[metric.key].dataStatus !== 'COMPLETE' || contextExpired || metric.inventory && stockExpired" class="workbench-status">{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p>
<p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'" class="workbench-note">已知小计 {{ groupedQuantityText(data.kpis[metric.key].knownSubtotal) }}</p>
<p v-if="Number(data.kpis[metric.key].unknownSkuCount) > 0" class="workbench-note">另 {{ groupedQuantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p>
<small v-if="data.kpis[metric.key].reasonCodes?.length" class="workbench-note">{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small>
</section>
</div>
</section>
<section class="workbench-section" aria-labelledby="overview-charts-heading">
<header class="workbench-section-heading">
<h2 id="overview-charts-heading" class="workbench-section-title workbench-tone" data-tone="teal">出库与库存分析</h2>
</header>
<div class="dashboard-main-charts">
<section class="dashboard-chart">
<section class="dashboard-chart workbench-panel">
<DashboardTrend :points="data.salesTrend.points" :disabled="contextExpired" @select="date => drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: date, endDate: date })">
<template #explanation><p>{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)</p></template>
</DashboardTrend>
</section>
<section class="dashboard-chart">
<section class="dashboard-chart workbench-panel">
<DashboardBars :rows="data.coverageDistribution.candidateSkuCount == null ? [] : bucketRows" title="库存消化天数分布" unit="SKU" :disabled="stockExpired || contextExpired" @select="code => drill('inventory', { coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' })">
<template #actions>
<ChartDetailsDialog inline button-label="说明" title="库存消化天数分布 · 统计说明" width="760px" :reset-on="data"><p>有实物库存或库存待核验 SKU;{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></ChartDetailsDialog>
@@ -236,8 +264,14 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
</DashboardBars>
</section>
</div>
</section>
<section class="workbench-section" aria-labelledby="overview-rankings-heading">
<header class="workbench-section-heading">
<h2 id="overview-rankings-heading" class="workbench-section-title workbench-tone" data-tone="purple">SKU 排行</h2>
<span class="workbench-badge">所选时段 · Top 10</span>
</header>
<div class="dashboard-rankings">
<section v-for="ranking in rankingNames" :key="ranking.key" class="dashboard-chart">
<section v-for="(ranking, index) in rankingNames" :key="ranking.key" class="dashboard-chart workbench-panel workbench-tone" :data-tone="metricTones[index]">
<DashboardBars ranked :details-width="ranking.key === 'hotSkus' ? '1280px' : undefined" :rows="rankingRows(data[ranking.key])" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!, data![ranking.key])">
<template #actions>
<ChartDetailsDialog inline button-label="说明" :title="`${ranking.label} · 统计说明`" width="1040px" :reset-on="data">
@@ -249,7 +283,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
</ChartDetailsDialog>
<el-button link type="primary" :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button>
</template>
<template #intro>
<template v-if="hasRankingWarnings" #intro>
<div class="dashboard-ranking-status" role="status">
<span v-if="contextExpired || ranking.key !== 'hotSkus' && stockExpired">历史数据 · 待刷新</span>
<span v-else-if="data[ranking.key].dataStatus !== 'COMPLETE'">{{ ranking.key === 'hotSkus' ? '数据未采齐,排名仅供参考' : Number(data[ranking.key].unassessableCount) > 0 ? `${quantityText(data[ranking.key].unassessableCount)} 个SKU待评估` : '数据待核验' }}</span>
@@ -259,43 +293,19 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
</DashboardBars>
</section>
</div>
</section>
</template>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close><div class="admin-density"><p v-if="trendError" role="alert">{{ trendError }}</p><SkuTrend v-if="trend" :data="trend" /></div></AppDialog>
<AppDialog v-model="trendOpen" class="admin-density-dialog admin-workbench is-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close><div class="admin-density"><p v-if="trendError" role="alert">{{ trendError }}</p><SkuTrend v-if="trend" :data="trend" /></div></AppDialog>
</template>
</AdminPage>
</template>
<style scoped>
.dashboard-page { display: grid; gap: var(--admin-space-3); }
.dashboard-title { display: flex; align-items: center; gap: 16px; }
.dashboard-data-notes { font-size: var(--admin-font-caption); color: #76849c; }
.dashboard-data-notes p { margin: 6px 0; }
.dashboard-snapshot { display: inline-flex; align-items: center; gap: 8px; color: #65748d; font-size: var(--admin-font-caption); }
.dashboard-snapshot i { width: 7px; height: 7px; border-radius: 50%; background: #5575ed; }
.dashboard-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
.dashboard-kpis section { min-width: 0; position: relative; padding: 16px; border: 1px solid #e5eaf4; border-radius: 8px; background: white; border-top: 3px solid var(--metric-color); animation: dashboard-reveal .5s ease-out both; animation-delay: var(--reveal-delay); }
.dashboard-metric-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.dashboard-metric-heading svg { color: var(--metric-color); flex-shrink: 0; }
.dashboard-kpis h3, .dashboard-chart h3 { margin: 0; font-size: var(--admin-font-section); }
.dashboard-kpis h3 { color: #65748d; font-size: var(--admin-font-caption); font-weight: 500; }
.dashboard-metric-value { margin: 12px 0 8px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px; }
.dashboard-kpis strong { font-size: 28px; font-weight: 700; letter-spacing: -.7px; font-variant-numeric: tabular-nums; color: #24334d; overflow-wrap: anywhere; }
.dashboard-metric-value > span { font-size: var(--admin-font-caption); color: #8290a6; }
.dashboard-metric-status { color: var(--metric-color); }
.dashboard-kpis p { margin: var(--admin-space-1) 0; }
.dashboard-main-charts { display: grid; grid-template-columns: 2fr 1fr; gap: var(--admin-space-4); }
.dashboard-kpis, .dashboard-skeleton-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
.dashboard-main-charts { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--workbench-grid-gap); }
.dashboard-ranking-status { min-height: 24px; margin-bottom: var(--admin-space-2); color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption); }
.dashboard-rankings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
.dashboard-chart { min-width: 0; padding: 18px; background: var(--admin-surface); border: 1px solid #e5eaf4; border-radius: 8px; animation: dashboard-reveal .6s ease-out both; }
.dashboard-rankings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
.dashboard-chart p { color: #76849c; font-size: var(--admin-font-caption); line-height: 1.7; }
.dashboard-rankings .dashboard-chart:nth-child(2) { animation-delay: .08s; }
.dashboard-rankings .dashboard-chart:nth-child(3) { animation-delay: .16s; }
.dashboard-placeholder { padding: 12px 0; color: #76849c; }
.dashboard-skeleton-kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin: 12px 0; }
.dashboard-skeleton-charts { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; }
.dashboard-placeholder i { display: block; height: 130px; border-radius: 8px; background: linear-gradient(100deg, #eaf0f8 30%, #f8faff 50%, #eaf0f8 70%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s infinite; }
.dashboard-skeleton-charts i { height: 320px; }
@keyframes dashboard-reveal { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes skeleton-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) { .dashboard-kpis section, .dashboard-chart, .dashboard-placeholder i { animation: none; } }
small { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
</style>
@@ -55,6 +55,7 @@ describe('self-operated actual workbench request state', () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
const field = wrapper.findComponent(queryStub).props('fields').find((f: { key: string }) => f.key === 'customerCode')
expect(field.options).toEqual([{ value: 'OWNER-A', label: '货主甲 (OWNER-A)' }])
expect(field).toMatchObject({ label: '货主', placeholder: '货主名称或编码' })
await setFilters(wrapper, { customerCode: 'OWNER-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-A', current: 1 })
await setFilters(wrapper, { customerCode: 'OWNER-B' }); wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
@@ -397,6 +398,17 @@ describe('self-operated actual workbench request state', () => {
expect(toolbar.exists()).toBe(true)
expect(toolbar.attributes('preference-key')).toBe(`self-operated-${mode}-query`)
expect(wrapper.findAllComponents(queryStub)).toHaveLength(1)
expect(wrapper.get('.workbench-table').classes().includes('is-compact-toolbar')).toBe(mode === 'sales')
expect(table.props('columns')[0]).toMatchObject(mode === 'sales' ? { key: 'actions', fixed: 'left' } : { prop: 'sku', fixed: 'left' })
})
it('retains the normal toolbar when analytics receives view tabs', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' }, slots: { 'view-tabs': () => h('nav', '视图切换') } })
wrappers.push(wrapper)
await flushPromises()
expect(wrapper.get('.workbench-table').classes()).not.toContain('is-compact-toolbar')
expect(wrapper.findComponent(tableStub).vm.$slots['toolbar-start']).toBeDefined()
expect(findButton(wrapper, '查看采集状态').exists()).toBe(true)
expect(findButton(wrapper, '查看库存').exists()).toBe(true)
})
it('uses one filter workspace with optional customer selection and fixed-version pagination', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
@@ -2,20 +2,43 @@ import { describe, expect, it } from 'vitest'
import type { LineSeriesOption } from 'echarts'
import type { DailyPoint } from './types'
import { skuTrendOption } from './skuTrendChart'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
const point = (day: number, status: string, value: string | null = '2'): DailyPoint => ({ businessDate: `2026-10-${day}`, dataStatus: status, outboundQuantity: value, observedQuantity: value, quantityUnit: 'PCS', reasonCodes: [] })
describe('SKU ECharts data configuration', () => {
describe.each([
['default', undefined],
['workbench', adminWorkbenchChartTheme],
] as const)('SKU ECharts data configuration with %s theme', (_, theme) => {
it('connects complete to partial days with dashed edges and resumes solid edges', () => {
const series = skuTrendOption([point(10, 'COMPLETE'), point(11, 'PARTIAL'), point(12, 'COMPLETE'), point(13, 'COMPLETE')]).series as LineSeriesOption[]
const series = skuTrendOption([point(10, 'COMPLETE'), point(11, 'PARTIAL'), point(12, 'COMPLETE'), point(13, 'COMPLETE')], theme).series as LineSeriesOption[]
expect(series[1]?.lineStyle?.type).toBe('dashed')
expect(series[1]?.data).toEqual([2, 2, 2, null])
expect(series[2]?.lineStyle?.type).toBe('solid')
expect(series[2]?.data).toEqual([null, null, 2, 2])
})
it('does not join across unknown or omitted dates and preserves zero', () => {
const series = skuTrendOption([point(10, 'COMPLETE', '0'), point(11, 'UNKNOWN', null), point(12, 'COMPLETE'), point(14, 'COMPLETE')]).series as LineSeriesOption[]
const series = skuTrendOption([point(10, 'COMPLETE', '0'), point(11, 'UNKNOWN', null), point(12, 'COMPLETE'), point(14, 'COMPLETE')], theme).series as LineSeriesOption[]
expect(series).toHaveLength(1)
expect(series[0]?.data?.[0]).toMatchObject({ value: 0 })
expect(series[0]?.data?.[1]).toMatchObject({ value: null })
expect(series[0]?.connectNulls).toBe(false)
})
})
it('only shades contiguous complete segments and retains date indices while zooming', () => {
const points = [point(10, 'COMPLETE'), point(11, 'COMPLETE'), point(12, 'UNKNOWN', null), point(13, 'PARTIAL'), point(14, 'COMPLETE'), point(16, 'COMPLETE'), point(17, 'COMPLETE')]
const option = skuTrendOption(points, adminWorkbenchChartTheme)
const series = option.series as LineSeriesOption[]
expect(series[0]?.areaStyle).toBeUndefined()
expect(series[1]?.areaStyle).toBeDefined()
expect(series[1]?.data).toEqual([2, 2, null, null, null, null, null])
expect(series[2]?.lineStyle?.type).toBe('dashed')
expect(series[2]?.areaStyle).toBeUndefined()
expect(series[3]?.data).toEqual([null, null, null, null, null, 2, 2])
expect(series.slice(1).every(item => item.silent && item.connectNulls === false)).toBe(true)
expect(option.dataZoom).toMatchObject([{ start: 0, end: 100, filterMode: 'none' }])
expect(option.xAxis).toMatchObject({ data: points.map(item => item.businessDate) })
expect(option.yAxis).toMatchObject({ min: 0 })
const original = skuTrendOption(points).series as LineSeriesOption[]
expect(original[0]?.itemStyle?.color).toBe('#6078ea')
expect(original.every(item => !item.areaStyle)).toBe(true)
})
+29 -9
View File
@@ -1,8 +1,10 @@
import type { EChartsOption, LineSeriesOption } from 'echarts'
import type { DailyPoint } from './types'
import { label, quantityText, reasonsText, shownQuantity } from './state'
import type { AdminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
export function skuTrendOption(points: DailyPoint[]): EChartsOption {
export function skuTrendOption(points: DailyPoint[], theme?: AdminWorkbenchChartTheme): EChartsOption {
const lineColor = theme?.colors.primary ?? '#6078ea'
const dates = points.map(point => point.businessDate)
const values = points.map(point => {
const value = shownQuantity(point)
@@ -10,9 +12,9 @@ export function skuTrendOption(points: DailyPoint[]): EChartsOption {
})
const series: LineSeriesOption[] = [{
name: '每日出库量', type: 'line', connectNulls: false, lineStyle: { opacity: 0 },
symbolSize: 8, showAllSymbol: true,
symbolSize: theme ? 7 : 8, showAllSymbol: true,
data: values.map((value, index) => ({ value, symbol: points[index]?.dataStatus === 'COMPLETE' ? 'circle' : 'emptyCircle' })),
itemStyle: { color: '#6078ea' }, emphasis: { scale: 1.5 },
itemStyle: { color: lineColor }, emphasis: { scale: 1.5 },
}]
let segment: { partial: boolean; data: (number | null)[] } | null = null
for (let index = 1; index < points.length; index++) {
@@ -21,7 +23,12 @@ export function skuTrendOption(points: DailyPoint[]): EChartsOption {
if (!segment || segment.partial !== partial) {
segment = { partial, data: Array<number | null>(points.length).fill(null) }
series.push({ type: 'line', data: segment.data, symbol: 'none', silent: true, connectNulls: false,
tooltip: { show: false }, lineStyle: { color: '#6078ea', width: 3, type: partial ? 'dashed' : 'solid' },
tooltip: { show: false }, lineStyle: { color: lineColor, width: theme ? 2.5 : 3, type: partial ? 'dashed' : 'solid' },
// Only known, contiguous, complete segments receive shading; gaps stay empty.
...(theme && !partial ? { areaStyle: { color: {
type: 'linear' as const, x: 0, y: 0, x2: 0, y2: 1,
colorStops: [{ offset: 0, color: theme.colors.primarySoft }, { offset: 1, color: theme.colors.primaryFaint }],
} } } : {}),
emphasis: { disabled: true }, animation: false })
}
segment.data[index - 1] = values[index - 1]!
@@ -29,19 +36,32 @@ export function skuTrendOption(points: DailyPoint[]): EChartsOption {
}
return {
animation: false,
...(theme ? { textStyle: { fontFamily: theme.fontFamily, color: theme.colors.text } } : {}),
grid: { left: 24, right: 32, top: 42, bottom: 80, containLabel: true },
xAxis: { type: 'category', data: dates, name: '日期(北京时间)', nameLocation: 'middle', nameGap: 30,
boundaryGap: false, axisLine: { show: true }, axisTick: { show: true },
axisLabel: { hideOverlap: true, showMinLabel: true, showMaxLabel: true, formatter: (value: string) => value.slice(5) } },
yAxis: { type: 'value', name: '出库量(件)', min: 0, axisLine: { show: true }, axisTick: { show: true }, splitLine: { show: true, lineStyle: { type: 'dashed', color: '#e5eaf3' } } },
tooltip: { trigger: 'axis', renderMode: 'richText', confine: true, axisPointer: { type: 'line' },
boundaryGap: false, axisLine: { show: true, ...(theme ? { lineStyle: { color: theme.colors.axis } } : {}) }, axisTick: { show: !theme },
...(theme ? { nameTextStyle: theme.axisName } : {}),
axisLabel: { ...theme?.axisLabel, hideOverlap: true, showMinLabel: true, showMaxLabel: true, formatter: (value: string) => value.slice(5) } },
yAxis: { type: 'value', name: '出库量(件)', min: 0, axisLine: { show: !theme }, axisTick: { show: !theme },
...(theme ? { axisLabel: theme.axisLabel, nameTextStyle: theme.axisName } : {}),
splitLine: theme?.splitLine ?? { show: true, lineStyle: { type: 'dashed', color: '#e5eaf3' } } },
tooltip: { ...theme?.tooltip, trigger: 'axis', renderMode: 'richText', confine: true,
axisPointer: { type: 'line', ...(theme ? { lineStyle: { color: theme.colors.primary, width: 1, type: 'dashed' as const } } : {}) },
formatter: params => {
const entries = Array.isArray(params) ? params : [params]
const item = entries.find(entry => entry.seriesIndex === 0) ?? entries[0]
const point = item ? points[item.dataIndex] : undefined
return point ? `${point.businessDate}\n出库量:${quantityText(shownQuantity(point))} 件\n${label(point.dataStatus)}\n覆盖截止:${point.coveredUntil ?? '未知'}\n${reasonsText(point.reasonCodes)}` : ''
} },
dataZoom: [{ type: 'slider', bottom: 4, height: 20, start: 0, end: 100, filterMode: 'none' }],
dataZoom: [{ type: 'slider', bottom: 4, height: theme ? 16 : 20, start: 0, end: 100, filterMode: 'none',
...(theme ? {
borderColor: theme.colors.border, backgroundColor: theme.colors.primaryFaint, fillerColor: theme.colors.primarySoft,
handleStyle: { color: theme.colors.surface, borderColor: theme.colors.primary, borderWidth: 1 },
moveHandleStyle: { color: theme.colors.axis }, textStyle: { color: theme.colors.muted },
dataBackground: { lineStyle: { color: theme.colors.axis }, areaStyle: { color: theme.colors.primaryFaint } },
selectedDataBackground: { lineStyle: { color: theme.colors.primary }, areaStyle: { color: theme.colors.primarySoft } },
} : {}),
}],
aria: { enabled: true, description: 'SKU每日出库趋势。横轴为北京时间日期,纵轴为出库件数。完整日实线,未完整日虚线,缺失日期断线。详细数值可展开下方表格。' },
series,
}
+18 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RequestFence, RetryIntent, RuntimePoller, contextParams, label, reasonsText, progressText, sameContext, shownQuantity, taskRetryAllowed, validateCustomRange } from './state'
import { RequestFence, RetryIntent, RuntimePoller, contextParams, groupedQuantityText, label, quantityText, reasonsText, progressText, sameContext, shownQuantity, taskRetryAllowed, validateCustomRange } from './state'
import type { QueryContext, Task } from './types'
const context: QueryContext = { salesRunId: '9007199254740999', inventoryRunId: null, salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: '2026-10-12T15:00:00+08:00', queryExpiresAt: '2026-10-12T15:30:00+08:00', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12' }
@@ -36,6 +36,23 @@ describe('self-operated query and command protection', () => {
expect(shownQuantity({ outboundQuantity: null, observedQuantity: null, dataStatus: 'UNKNOWN' })).toBeNull()
expect(shownQuantity({ outboundQuantity: '0', observedQuantity: '0', dataStatus: 'COMPLETE' })).toBe('0')
})
it('groups quantities without losing long integers, decimal digits or the original formatter semantics', () => {
const cases: Array<[string | number | null | undefined, string]> = [
['123456789012345678901234567890.123450000001', '123,456,789,012,345,678,901,234,567,890.123450000001'],
['9007199254740993', '9,007,199,254,740,993'],
['-1234567.000100', '-1,234,567.000100'],
[1234567.125, '1,234,567.125'],
['0.000000000001', '0.000000000001'],
['0', '0'],
[0, '0'],
[null, '—'],
[undefined, '—'],
]
for (const [value, expected] of cases) {
expect(groupedQuantityText(value)).toBe(expected)
expect(quantityText(value)).toBe(value == null ? '—' : String(value))
}
})
it('requires permission, server eligibility, correct warehouse and reason for retry', () => {
const scope = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A' }
expect(taskRetryAllowed(task, true, scope, '修复后恢复')).toBe(true)
+3
View File
@@ -39,6 +39,9 @@ export function shownQuantity(row: { outboundQuantity: string | number | null; o
return row.dataStatus === 'COMPLETE' ? row.outboundQuantity : row.observedQuantity
}
export function quantityText(value: string | number | null | undefined) { return value === null || value === undefined ? '—' : String(value) }
export function groupedQuantityText(value: string | number | null | undefined) {
return quantityText(value).replace(/^(-?\d+)(\.\d+)?$/, (_match, integer: string, fraction: string | undefined) => `${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${fraction ?? ''}`)
}
export function beijingTime(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { BarSeriesOption } from 'echarts'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
import { turnoverChart, turnoverNumber, volumeText, windowText, turnoverReasonsText, volumeNote } from './turnoverPresentation'
import type { TurnoverCharts, TurnoverWindowMetric } from './turnoverTypes'
describe('turnover honest precision and charts', () => {
@@ -35,4 +37,51 @@ describe('turnover honest precision and charts', () => {
expect(JSON.stringify(option.series)).toContain('"data":[10,null]')
expect(windowText(partial)).toBe('已采集 150,覆盖不足')
})
it.each(['owners', 'volume', 'daily'] as const)('applies the workbench theme to %s without adding artificial bar lengths', kind => {
const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: [] }
const option = turnoverChart(charts, kind)
expect(option.textStyle).toMatchObject({ color: adminWorkbenchChartTheme.colors.text, fontFamily: adminWorkbenchChartTheme.fontFamily })
expect(option.tooltip).toMatchObject({ backgroundColor: adminWorkbenchChartTheme.colors.surface, borderColor: adminWorkbenchChartTheme.colors.border, renderMode: 'richText' })
expect(kind === 'daily' ? option.yAxis : option.xAxis).toMatchObject({ type: 'value', min: 0, axisLabel: adminWorkbenchChartTheme.axisLabel, splitLine: adminWorkbenchChartTheme.splitLine })
const series = (option.series as BarSeriesOption[])[0]!
expect(series.itemStyle).toMatchObject({ color: adminWorkbenchChartTheme.colors.primary, borderRadius: kind === 'daily' ? [6, 6, 0, 0] : [0, 6, 6, 0] })
expect(series.data).toEqual([])
expect(series).not.toHaveProperty('showBackground')
expect(series).not.toHaveProperty('barMinHeight')
})
it('preserves owner ordering, volume precision, zero and unknown without changing click indexes', () => {
const volume = { value: null, knownSubtotal: null, missingDimensionQuantity: null, missingDimensionSkuCount: 0, unknownQuantitySkuCount: 0, declaredDimensionQuantity: 0, reasonCodes: [] }
const values = ['12345.678901', '0', null, '0.000001']
const charts: TurnoverCharts = {
ownerPhysicalTop: values.map((physicalQuantity, index) => ({ customerCode: `OWNER-${index}`, customerName: index === 0 ? '货主名称' : null, physicalQuantity })),
ownerVolumeTop: values.map((value, index) => ({ customerCode: `OWNER-${index}`, customerName: index === 0 ? '货主名称' : null, inventoryVolumeM3: { ...volume, value } })),
unknownOwnerCount: 1, unknownVolumeOwnerCount: 1, averageDaily: [],
}
for (const kind of ['owners', 'volume'] as const) {
const option = turnoverChart(charts, kind)
expect(option.yAxis).toMatchObject({ inverse: true, data: ['货主名称', 'OWNER-1', 'OWNER-2', 'OWNER-3'] })
expect((option.series as BarSeriesOption[])[0]!.data).toEqual([12345.678901, 0, null, 0.000001])
}
const option = turnoverChart(charts, 'volume')
expect(option.grid).toMatchObject({ right: 110 })
const label = (option.series as BarSeriesOption[])[0]!.label as { formatter: (params: { value: number | null }) => string }
const tooltip = option.tooltip as { valueFormatter: (value: number | null) => string }
expect(label.formatter({ value: 12345.678901 })).toBe('12,345.68 m³')
expect(label.formatter({ value: 0.000001 })).toBe('<0.01 m³')
expect(tooltip.valueFormatter(12345.678901)).toBe('12,345.68 m³')
expect(tooltip.valueFormatter(null)).toBe('— m³')
})
it('continues to honor reduced-motion preference', () => {
const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: [] }
const matchMedia = vi.fn(() => ({ matches: true }))
vi.stubGlobal('window', { matchMedia })
try {
expect(turnoverChart(charts, 'owners').animation).toBe(false)
expect(matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)')
matchMedia.mockReturnValue({ matches: false })
expect(turnoverChart(charts, 'owners').animation).toBe(true)
} finally {
vi.unstubAllGlobals()
}
})
})
@@ -1,6 +1,7 @@
import type { Quantity, WarehouseOverview } from './types'
import type { TurnoverCharts, TurnoverTotal, TurnoverVolume, TurnoverWindowMetric } from './turnoverTypes'
import type { EChartsOption } from 'echarts'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
import { reasonsText } from './state'
const turnoverLabels: Record<string, string> = {
INDEPENDENT_ANCHOR_MISSING: '缺少独立库存对账记录', INDEPENDENT_ANCHOR_QUALITY_MISSING: '独立库存品质记录不完整',
@@ -76,6 +77,7 @@ export function windowText(metric: TurnoverWindowMetric | undefined) {
return metric.dataStatus === 'COMPLETE' ? turnoverNumber(metric.outboundQuantity) : `已采集 ${turnoverNumber(metric.observedQuantity)},覆盖不足`
}
export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'volume' | 'daily'): EChartsOption {
const theme = adminWorkbenchChartTheme
const owners = kind !== 'daily', volume = kind === 'volume'
const ownerRows = volume ? charts.ownerVolumeTop : charts.ownerPhysicalTop
const unit = volume ? 'm³' : owners ? '件' : '件/日'
@@ -84,13 +86,26 @@ export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'volume'
: charts.averageDaily.map(w => w.dataStatus === 'COMPLETE' ? w.averageDailyOutboundQuantity : null)
return {
aria: { enabled: true }, animation: typeof window === 'undefined' || !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
animationDuration: 450, color: ['#6c7bea'],
tooltip: { trigger: 'axis', renderMode: 'richText', valueFormatter: value => `${turnoverNumber(typeof value === 'number' || typeof value === 'string' ? value : null)} ${unit}` },
animationDuration: 450, color: [theme.colors.primary],
textStyle: { color: theme.colors.text, fontFamily: theme.fontFamily },
tooltip: { ...theme.tooltip, trigger: 'axis', renderMode: 'richText', valueFormatter: value => `${turnoverNumber(typeof value === 'number' || typeof value === 'string' ? value : null)} ${unit}` },
grid: { left: 12, right: volume ? 110 : 40, top: owners ? 12 : 48, bottom: 12, containLabel: true },
xAxis: owners ? { type: 'value', name: unit } : { type: 'category', data: charts.averageDaily.map(w => `${w.days}日`) },
yAxis: owners ? { type: 'category', inverse: true, data: ownerRows.map(w => w.customerName || w.customerCode) } : { type: 'value', name: unit, nameGap: 12, nameTextStyle: { align: 'left' } },
series: [{ type: 'bar', barMaxWidth: 24, itemStyle: { borderRadius: 4 },
label: { show: kind !== 'owners', position: volume ? 'right' : 'top', distance: 8, formatter: params => volume ? volumeText(typeof params.value === 'number' ? params.value : null, 2) : turnoverNumber(typeof params.value === 'number' ? params.value : null) },
xAxis: owners ? {
type: 'value', name: unit, min: 0, nameTextStyle: theme.axisName, axisLabel: theme.axisLabel,
axisLine: { show: false }, axisTick: { show: false }, splitLine: theme.splitLine,
} : {
type: 'category', data: charts.averageDaily.map(w => `${w.days}日`), axisLabel: theme.axisLabel,
axisLine: { lineStyle: { color: theme.colors.axis } }, axisTick: { show: false },
},
yAxis: owners ? {
type: 'category', inverse: true, data: ownerRows.map(w => w.customerName || w.customerCode),
axisLabel: theme.axisLabel, axisLine: { show: false }, axisTick: { show: false },
} : {
type: 'value', name: unit, min: 0, nameGap: 12, nameTextStyle: { ...theme.axisName, align: 'left' },
axisLabel: theme.axisLabel, axisLine: { show: false }, axisTick: { show: false }, splitLine: theme.splitLine,
},
series: [{ type: 'bar', barMaxWidth: 20, itemStyle: { color: theme.colors.primary, borderRadius: owners ? [0, 6, 6, 0] : [6, 6, 0, 0] },
label: { show: kind !== 'owners', position: volume ? 'right' : 'top', distance: 8, color: theme.colors.text, fontSize: 12, fontFamily: theme.fontFamily, formatter: params => volume ? volumeText(typeof params.value === 'number' ? params.value : null, 2) : turnoverNumber(typeof params.value === 'number' ? params.value : null) },
data: values.map(value => value == null ? null : Number(value)) }],
}
}
+12
View File
@@ -67,6 +67,18 @@
.admin-density-dialog.el-dialog .el-empty__image { display: none; }
.admin-density-dialog.el-dialog .el-empty__description { margin-top: 0; }
/* Opt-in comfortable editor frame for short forms that need room to breathe. */
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog { --el-component-size: 40px; border-radius: 12px; }
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog:not(.is-fullscreen) { margin-top: 10vh; }
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-dialog__header { padding: 18px 48px 18px 24px; }
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-dialog__headerbtn { top: 6px; }
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-dialog__body { padding: 24px; }
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-dialog__footer { padding: 16px 24px; background: var(--admin-label-bg); }
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-form-item { margin-bottom: 20px; }
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-form-item__label { margin-bottom: 8px; font-weight: 500; }
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-select__wrapper { min-height: 40px; }
.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .app-dialog__footer .el-button { min-width: 88px; height: 38px; }
/* Opted-in detail and editor content reuse the existing compact drawer frame. */
:is(.admin-density-dialog, .admin-density-drawer:where(.admin-density-drawer--detail, .admin-density-drawer--editor)) .density-code-block { margin:0; padding:var(--admin-space-3); border:1px solid var(--admin-table-border); border-radius:var(--admin-radius); background:var(--admin-surface); color:var(--cc-text); font:var(--admin-font-caption)/1.65 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; overflow-wrap:anywhere; }
+15 -5
View File
@@ -97,7 +97,7 @@ const notificationConsumers = [
'notifications/components/TemplateEditorDialog.vue',
]
const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView.vue', 'iam/pages/PermissionsView.vue']
const selfOperatedPages = ['self-operated/components/AnalyticsView.vue']
const selfOperatedPages = ['self-operated/components/AnalyticsView.vue', 'self-operated/components/InventoryTurnoverView.vue']
const selfOperatedConsumers = [...selfOperatedPages, 'self-operated/components/SkuTrend.vue', 'self-operated/pages/OverviewView.vue']
const consumers = [
...selfOperatedConsumers,
@@ -399,7 +399,8 @@ describe('approved Admin density ownership', () => {
expect(dialogs).toHaveLength(3)
for (const dialog of dialogs) { expect(dialog).toContain('admin-density-dialog'); expect(dialog).toContain('fullscreenable') }
expect(view.template!.content).toContain('density-table density-table--editable')
expect(view.template!.content).toContain('label-position="left" label-width="110px"')
expect(view.template!.content).toContain('label-position="left" label-width="150px"')
expect(view.template!.content).toContain('<el-date-picker v-model="plannedAt" type="datetime"')
expect(view.styles.map(style => style.content).join('\n')).not.toContain('arrival-field')
expect(create.template!.content).toContain('aria-label="预约保存回执"')
expect(create.template!.content).not.toContain('<FbaAppointmentFields')
@@ -444,7 +445,7 @@ describe('approved Admin density ownership', () => {
it('registers the self-operated dashboard with an explicit density root and chart layout', () => {
const source = read('modules/self-operated/pages/OverviewView.vue')
expect(source).toContain("@/styles/adminDensity.css")
expect(source).toContain('<AdminPage class="admin-density dashboard-page">')
expect(source).toContain('<AdminPage class="admin-density admin-workbench dashboard-page">')
expect(source).toContain('class="dashboard-kpis"')
expect(source).toContain('class="dashboard-main-charts"')
expect(source).not.toContain('density-fields')
@@ -883,11 +884,12 @@ describe('approved Admin density ownership', () => {
it('keeps the collection monitor in the Admin theme without legacy list controls', () => {
const page = read('modules/self-operated/pages/CollectionView.vue')
const monitor = read('modules/self-operated/components/CollectionAccountMonitor.vue')
expect(page).toContain('class="admin-density"')
expect(page).toContain('class="admin-density admin-workbench"')
expect(page).toContain('@/styles/adminDensity.css')
expect(page).toContain('@/styles/adminWorkbench.css')
expect(page).toContain('<CollectionAccountMonitor')
expect(page).not.toContain('<DataTable')
expect(monitor).toContain('background: var(--el-bg-color)')
expect(monitor).toContain('workbench-panel')
expect(monitor).not.toMatch(/--el-[\w-]+\s*:/)
})
it('opts the confirmed self-operated pages into integrated text actions without changing older outlined consumers', () => {
@@ -1034,6 +1036,14 @@ describe('approved Admin density ownership', () => {
})
})
it('gives simulation creation an opt-in comfortable editor frame', () => {
const source = read('modules/container-loading/components/ContainerSimulationCreate.vue')
expect(source).toContain("'admin-density-dialog--comfortable': dense")
expect(source).toContain('width="min(800px, calc(100vw - 32px))"')
expect(shared).toContain('.admin-density-dialog.admin-density-dialog--comfortable.el-dialog { --el-component-size: 40px; border-radius: 12px; }')
expect(shared).toContain('.admin-density-dialog.admin-density-dialog--comfortable.el-dialog .el-select__wrapper { min-height: 40px; }')
})
it('keeps conditional shared consumers opt-in instead of enabling other menus', () => {
const actionWeights = new Map<string, string>()
cssRoot(read('styles/adminTextActions.css')).walkRules(rule => {
+597
View File
@@ -0,0 +1,597 @@
@import './adminWorkbenchTokens.css';
.admin-workbench {
--el-color-primary: var(--workbench-primary);
--el-color-primary-light-3: var(--workbench-primary-hover);
--el-color-primary-light-5: var(--workbench-primary-disabled);
--el-color-primary-light-7: var(--workbench-accent-border);
--el-color-primary-light-8: var(--workbench-accent-border);
--el-color-primary-light-9: var(--workbench-accent-soft);
--el-color-primary-dark-2: var(--workbench-primary-active);
}
/* Keep business grids in the page; these primitives only provide appearance. */
.admin-workbench.admin-page {
min-width: 0;
padding: var(--workbench-page-space);
background: var(--workbench-surface);
color: var(--workbench-text);
}
/* Override AdminPage's scoped normal spacing only for this explicit opt-in. */
.admin-workbench.admin-page.admin-density.is-spacing-normal {
gap: var(--workbench-page-space);
}
.admin-workbench .workbench-heading {
display: grid;
min-width: 0;
gap: 6px;
}
.admin-workbench .workbench-title {
margin: 0;
color: var(--workbench-text);
font-size: var(--workbench-title-size);
font-weight: 700;
line-height: 1.4;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-meta {
display: inline-flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 6px;
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.6;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-meta > i {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--workbench-accent);
}
.admin-workbench .workbench-section {
display: grid;
min-width: 0;
gap: var(--workbench-section-gap);
}
.admin-workbench .workbench-section-heading {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.admin-workbench .workbench-section-title {
display: flex;
min-width: 0;
align-items: center;
gap: 9px;
margin: 0;
color: var(--workbench-text);
font-size: var(--workbench-section-title-size);
font-weight: 600;
line-height: 1.5;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-section-title::before {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--workbench-accent);
content: '';
}
.admin-workbench .workbench-badge {
max-width: 100%;
padding: 3px 10px;
border-radius: 999px;
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.5;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-badge.is-toned {
border: 1px solid var(--workbench-accent-border);
background: var(--workbench-accent-soft);
color: var(--workbench-accent);
}
.admin-workbench .workbench-metric,
.admin-workbench .workbench-panel {
min-width: 0;
padding: var(--workbench-card-space);
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-radius);
background: var(--workbench-surface);
box-shadow: var(--workbench-shadow);
}
.admin-workbench .workbench-metric {
min-height: var(--workbench-metric-min-height);
box-sizing: border-box;
background: linear-gradient(145deg, var(--workbench-surface) 58%, var(--workbench-surface-tint));
}
.admin-workbench .workbench-panel.is-flush {
padding: 0;
}
.admin-workbench .workbench-panel.is-compact {
padding: var(--workbench-compact-card-space);
}
.admin-workbench .workbench-metric.is-compact {
min-height: 0;
padding: var(--workbench-compact-card-space);
}
.admin-workbench .workbench-metric.is-compact .workbench-metric-heading {
gap: 8px;
}
.admin-workbench .workbench-metric.is-compact .workbench-metric-label {
font-size: var(--workbench-note-size);
}
.admin-workbench .workbench-metric.is-compact .workbench-metric-value {
margin: 10px 0 6px;
}
.admin-workbench .workbench-metric.is-compact .workbench-metric-value > strong {
font-size: var(--workbench-compact-number-size);
}
.admin-workbench .workbench-icon.is-compact {
width: 32px;
height: 32px;
border-radius: var(--workbench-tab-radius);
}
.admin-workbench .workbench-icon.is-compact > svg {
width: 18px;
height: 18px;
}
.admin-workbench .workbench-metric-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.admin-workbench .workbench-icon {
display: inline-flex;
width: 44px;
height: 44px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: var(--workbench-icon-radius);
background: var(--workbench-accent-soft);
color: var(--workbench-accent);
}
.admin-workbench .workbench-icon > svg {
width: 24px;
height: 24px;
}
.admin-workbench .workbench-metric-label {
min-width: 0;
margin: 0;
color: var(--workbench-text);
font-size: var(--workbench-label-size);
font-weight: 600;
line-height: 1.5;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-metric-value {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 6px;
margin: 20px 0 12px;
}
.admin-workbench .workbench-metric-value > strong {
min-width: 0;
color: var(--workbench-number);
font-size: var(--workbench-number-size);
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-metric-value > span {
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.5;
}
.admin-workbench .workbench-note,
.admin-workbench .workbench-status {
display: block;
margin: 4px 0;
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.6;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-panel.workbench-tone {
border-color: var(--workbench-accent-border);
}
.admin-workbench .workbench-chart {
display: flex;
min-width: 0;
height: 100%;
flex-direction: column;
}
.admin-workbench .workbench-chart-heading {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-bottom: 12px;
}
.admin-workbench .workbench-chart-title {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
margin: 0;
color: var(--workbench-text);
font-size: var(--workbench-label-size);
font-weight: 600;
line-height: 1.5;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-chart-title::before {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--workbench-accent);
content: '';
}
.admin-workbench .workbench-chart-actions {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-left: auto;
}
.admin-workbench .workbench-chart-actions .el-button {
--el-button-text-color: var(--workbench-primary);
--el-button-hover-text-color: var(--workbench-primary-active);
--el-button-disabled-text-color: var(--workbench-primary-disabled);
font-size: var(--workbench-note-size);
}
.admin-workbench .workbench-chart-actions .el-button + .el-button {
margin-left: 0;
}
.admin-workbench .workbench-chart-canvas {
min-width: 0;
flex: 0 0 auto;
}
.admin-workbench .workbench-chart-empty {
display: flex;
min-height: 240px;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: var(--workbench-card-space);
border-radius: var(--workbench-icon-radius);
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
text-align: center;
}
.admin-workbench .workbench-chart-empty.is-compact {
min-height: 0;
padding: var(--workbench-compact-card-space);
}
.admin-workbench .workbench-chart-empty-icon {
display: grid;
width: 56px;
height: 56px;
flex: 0 0 auto;
place-items: center;
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-icon-radius);
background: var(--workbench-surface);
color: var(--workbench-muted);
}
.admin-workbench .workbench-chart-empty p {
max-width: 28em;
margin: 0;
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.7;
overflow-wrap: anywhere;
}
/* DataTable retains ownership of toolbar size, controls and query layout. */
.admin-workbench .workbench-toolbar,
.admin-workbench .workbench-table {
min-width: 0;
}
/* DataTable forwards attrs to its inner table, so opt in through this wrapper. */
.admin-workbench .workbench-toolbar > .data-table,
.admin-workbench .workbench-table > .data-table {
--list-card-accent: var(--workbench-primary);
--list-card-border: var(--workbench-border);
--list-card-muted-bg: var(--workbench-surface-soft);
--list-card-radius: var(--workbench-radius);
min-width: 0;
border-color: var(--workbench-border);
border-radius: var(--workbench-radius);
background: var(--workbench-surface);
box-shadow: var(--workbench-shadow);
}
.admin-workbench .workbench-toolbar > .data-table > .data-table__toolbar,
.admin-workbench .workbench-table > .data-table > .data-table__toolbar {
border-color: var(--workbench-border);
background: var(--workbench-surface);
}
/* Only title-free list toolbars opt in; keep controls and wrapping intact. */
.admin-workbench .workbench-table.is-compact-toolbar > .data-table > .data-table__toolbar {
min-height: 52px;
padding-top: 8px;
padding-bottom: 8px;
}
.admin-workbench .workbench-table > .data-table > .el-table {
--el-table-border-color: var(--workbench-border);
--el-table-header-bg-color: var(--workbench-surface-soft);
--el-table-header-text-color: var(--workbench-muted);
--el-table-row-hover-bg-color: var(--workbench-accent-soft);
--el-table-current-row-bg-color: var(--workbench-accent-soft);
--el-table-text-color: var(--workbench-text);
color: var(--workbench-text);
}
.admin-workbench .workbench-table .el-table th.el-table__cell {
color: var(--workbench-muted);
}
.admin-workbench .workbench-table .el-table .density-action:not([data-tone]),
.admin-workbench .workbench-table .el-table .density-action[data-tone='primary'] {
--action-color: var(--workbench-primary);
--action-border: var(--workbench-accent-border);
--action-hover-bg: var(--workbench-accent-soft);
}
.admin-workbench .workbench-table .data-table .data-table__page-button[aria-current='page']:not(:disabled),
.admin-workbench .workbench-table .data-table__business-actions .el-button--primary:not(:disabled) {
box-shadow: var(--workbench-shadow);
}
/* Keep selection, focus and disabled states consistent for existing native tabs. */
.admin-workbench .workbench-tabs {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin: 0;
}
.admin-workbench .workbench-tabs > button {
display: inline-flex;
min-height: var(--admin-tab-height);
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid transparent;
border-radius: var(--workbench-tab-radius);
background: transparent;
color: var(--workbench-muted);
font: inherit;
font-weight: 500;
line-height: 1.5;
text-align: center;
cursor: pointer;
}
.admin-workbench .workbench-tabs > button:hover:not(:disabled):not([aria-pressed='true']):not(.is-active) {
background: var(--workbench-surface-soft);
color: var(--workbench-primary);
}
.admin-workbench .workbench-tabs > button[aria-pressed='true'],
.admin-workbench .workbench-tabs > button.is-active {
border-color: var(--workbench-accent-border);
background: var(--workbench-accent-soft);
color: var(--workbench-primary);
font-weight: 600;
}
.admin-workbench .workbench-tabs > button:focus-visible,
.admin-workbench .workbench-input:focus-visible {
outline: 2px solid var(--workbench-primary);
outline-offset: 2px;
}
.admin-workbench .workbench-tabs > button:disabled {
color: var(--workbench-primary-disabled);
cursor: not-allowed;
}
.admin-workbench .workbench-input {
min-height: var(--admin-control-height);
box-sizing: border-box;
padding: 6px 8px;
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-tab-radius);
background: var(--workbench-surface);
color: var(--workbench-text);
font: inherit;
}
.admin-workbench .workbench-input:disabled {
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
cursor: not-allowed;
}
/* Teleported dialogs opt in on their own root; retain the density frame geometry. */
.admin-workbench.is-dialog.el-dialog:not(.is-fullscreen) {
border-radius: var(--workbench-radius);
}
.admin-workbench.is-dialog.el-dialog .el-dialog__header,
.admin-workbench.is-dialog.el-dialog .el-dialog__footer {
border-color: var(--workbench-border);
background: var(--workbench-surface);
}
.admin-workbench.is-dialog.el-dialog .el-dialog__title {
color: var(--workbench-text);
}
.admin-workbench .workbench-evidence table {
width: 100%;
border-collapse: collapse;
font-size: var(--admin-font-body);
}
.admin-workbench .workbench-evidence th,
.admin-workbench .workbench-evidence td {
border: 1px solid var(--workbench-border);
text-align: left;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-evidence table:not(.density-table) :is(th, td) {
padding: var(--admin-space-1) var(--admin-space-3);
line-height: 1.6;
}
.admin-workbench .workbench-evidence th {
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
font-weight: 600;
}
.admin-workbench .workbench-notice {
margin: 0;
padding: 10px 14px;
border: 1px solid var(--workbench-accent-border);
border-radius: var(--workbench-icon-radius);
background: var(--workbench-accent-soft);
color: var(--workbench-text);
font-size: var(--workbench-note-size);
line-height: 1.6;
overflow-wrap: anywhere;
}
/* State labels remain the source of meaning; color only reinforces them. */
.admin-workbench .workbench-step {
min-width: 0;
padding: var(--workbench-compact-card-space);
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-icon-radius);
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
overflow-wrap: anywhere;
}
.admin-workbench .workbench-step-index {
display: grid;
width: 28px;
height: 28px;
flex: 0 0 auto;
place-items: center;
border: 1px solid var(--workbench-border);
border-radius: 50%;
background: var(--workbench-surface);
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
font-variant-numeric: tabular-nums;
}
.admin-workbench .workbench-step[data-state='active'],
.admin-workbench .workbench-step[data-state='done'] {
border-color: var(--workbench-accent-border);
background: var(--workbench-accent-soft);
color: var(--workbench-accent);
}
.admin-workbench .workbench-step[data-state='active'] .workbench-step-index,
.admin-workbench .workbench-step[data-state='done'] .workbench-step-index {
border-color: var(--workbench-accent);
background: var(--workbench-accent);
color: var(--workbench-surface);
}
.admin-workbench .el-button--primary:not(.is-plain):not(.is-link):not(.is-text) {
--el-button-bg-color: var(--workbench-primary);
--el-button-border-color: var(--workbench-primary);
--el-button-hover-bg-color: var(--workbench-primary-hover);
--el-button-hover-border-color: var(--workbench-primary-hover);
--el-button-active-bg-color: var(--workbench-primary-active);
--el-button-active-border-color: var(--workbench-primary-active);
--el-button-disabled-bg-color: var(--workbench-primary-disabled);
--el-button-disabled-border-color: var(--workbench-primary-disabled);
}
.admin-workbench .workbench-skeleton {
display: block;
min-width: 0;
height: var(--workbench-metric-min-height);
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-radius);
background: var(--workbench-surface-soft);
animation: admin-workbench-loading 1.6s ease-in-out infinite alternate;
}
@keyframes admin-workbench-loading {
from { opacity: 0.55; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.admin-workbench .workbench-skeleton {
animation: none;
}
}
+128
View File
@@ -0,0 +1,128 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { compileStyle, parse } from 'vue/compiler-sfc'
import { adminWorkbenchChartTheme } from './adminWorkbenchChartTheme'
const read = (path: string) => readFileSync(resolve(process.cwd(), 'src', path), 'utf8')
const tokens = read('styles/adminWorkbenchTokens.css')
const styles = read('styles/adminWorkbench.css')
const page = read('modules/self-operated/pages/OverviewView.vue')
const listPages = [
'modules/self-operated/components/AnalyticsView.vue',
'modules/self-operated/components/InventoryTurnoverView.vue',
]
const consumers = [
...listPages,
'modules/self-operated/pages/InventoryView.vue',
'modules/self-operated/components/SkuTrend.vue',
'modules/self-operated/components/ChartDetailsDialog.vue',
'modules/self-operated/pages/CollectionView.vue',
'modules/self-operated/components/CollectionAccountMonitor.vue',
'modules/self-operated/components/CollectionProgressGauge.vue',
'modules/self-operated/components/CollectionTaskProgress.vue',
]
const root = (source: string) => {
const result = compileStyle({ source, filename: 'workbench.css', id: 'workbench-contract' })
expect(result.errors).toEqual([])
return result.rawResult!.root
}
describe('Admin workbench opt-in styles', () => {
it('keeps canvas chart colors aligned with the public CSS palette', () => {
const declarations = new Map<string, string>()
root(tokens).walkRules(rule => {
if (rule.selector !== '.admin-workbench') return
rule.walkDecls(declaration => { declarations.set(declaration.prop, declaration.value) })
})
for (const name of ['primary', 'text', 'muted', 'border', 'surface'] as const) {
expect(adminWorkbenchChartTheme.colors[name]).toBe(declarations.get(`--workbench-${name}`))
}
expect(adminWorkbenchChartTheme.colors.primarySoft).toBe(declarations.get('--workbench-accent-soft'))
})
it('keeps lazy-loaded styles scoped to opted-in pages and dialog roots', () => {
for (const source of [tokens, styles]) {
root(source).walkRules(rule => {
if (rule.parent?.type === 'atrule' && rule.parent.name.endsWith('keyframes')) return
for (const selector of rule.selectors) {
expect(selector).toMatch(/^\.admin-workbench(?:\s|\.|:|\[|$)/)
expect(selector).not.toMatch(/:global|:root/)
if (selector.includes('.el-dialog')) expect(selector).toMatch(/^\.admin-workbench\.is-dialog\.el-dialog/)
}
})
root(source).walkDecls(declaration => {
expect(declaration.prop).not.toMatch(/^--admin-/)
expect(declaration.important).toBeFalsy()
})
}
})
it('resolves every workbench variable from the public tokens instead of page defaults', () => {
const declared = new Set<string>()
root(tokens).walkDecls(declaration => {
if (declaration.prop.startsWith('--workbench-')) declared.add(declaration.prop)
})
const pageStyles = [page, ...consumers.map(read)].flatMap(source => parse(source).descriptor.styles.map(style => style.content)).join('\n')
for (const source of [styles, pageStyles]) {
root(source).walkDecls(declaration => {
expect(declaration.prop).not.toMatch(/^--workbench-/)
})
for (const match of source.matchAll(/var\((--workbench-[\w-]+)/g)) {
expect(declared.has(match[1]!)).toBe(true)
}
}
})
it('opts list cards and teleported dialogs in on their actual rendering containers', () => {
for (const file of listPages) {
const source = read(file)
expect(source).toContain("import '@/styles/adminWorkbench.css'")
const template = parse(source).descriptor.template!.ast!
type TemplateElement = Extract<(typeof template.children)[number], { type: 1 }>
const classesOf = (node?: TemplateElement) => node?.props.flatMap(prop => prop.type === 6 && prop.name === 'class' ? prop.value?.content.split(/\s+/) ?? [] : []) ?? []
let tables = 0
const visit = (nodes: typeof template.children, ancestors: TemplateElement[]) => {
for (const node of nodes) {
if (node.type !== 1) continue
if (node.tag === 'AdminPage') expect(classesOf(node)).toEqual(expect.arrayContaining(['admin-density', 'admin-workbench']))
if (node.tag === 'DataTable') {
expect(classesOf(ancestors.at(-1))).toContain('workbench-table')
expect(classesOf(node)).not.toContain('workbench-table')
tables++
}
if (node.tag === 'AppDialog') expect(classesOf(node)).toEqual(expect.arrayContaining(['admin-density-dialog', 'admin-workbench', 'is-dialog']))
visit(node.children, [...ancestors, node])
}
}
visit(template.children, [])
expect(tables).toBe(1)
}
})
it('preserves complete metric values and the existing toolbar layout ownership', () => {
root(styles).walkRules(rule => {
if (rule.selector.includes('.workbench-metric')) {
rule.walkDecls(declaration => {
expect(['text-overflow', 'line-clamp', '-webkit-line-clamp']).not.toContain(declaration.prop)
if (declaration.prop === 'overflow') expect(declaration.value).not.toBe('hidden')
expect(declaration.prop).not.toBe('height')
})
}
if (rule.selector.includes('.data-table__toolbar')) {
rule.walkDecls(declaration => {
if (rule.selector.includes('.workbench-table.is-compact-toolbar')) {
expect(['min-height', 'padding-top', 'padding-bottom']).toContain(declaration.prop)
} else {
expect(declaration.prop).not.toMatch(/^(?:padding|height|min-height|display|gap)/)
}
})
}
if (rule.selector.includes('.is-dialog')) {
rule.walkDecls(declaration => {
expect(declaration.prop).not.toMatch(/^(?:width|height|min-height|max-height|padding|margin|overflow)/)
})
}
})
})
})
+43
View File
@@ -0,0 +1,43 @@
const fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif'
const colors = {
primary: '#3784fa',
primarySoft: '#eff6ff',
primaryFaint: 'rgba(55,132,250,0.03)',
primaryLight: '#5b9bfb',
teal: '#169b9b',
tealLight: '#46b5bc',
green: '#18a46f',
purple: '#8b5cf6',
amber: '#bf850e',
rose: '#e85e70',
muted: '#64748b',
text: '#1f2937',
border: '#e5eaf2',
axis: '#d8e2ef',
unknown: '#a2adbf',
surface: '#fff',
}
/** Explicitly consumed by workbench charts; never registers a global ECharts theme. */
export const adminWorkbenchChartTheme = {
fontFamily,
colors,
tooltip: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 10,
padding: 12,
extraCssText: 'box-shadow: 0 8px 24px rgba(31, 55, 90, 0.1);',
textStyle: { color: colors.text, fontSize: 12, fontFamily },
},
axisLabel: { color: colors.muted, fontSize: 12 },
axisName: { color: colors.muted, fontSize: 12 },
splitLine: {
show: true,
lineStyle: { type: 'dashed' as const, color: colors.border, opacity: 0.8 },
},
}
export type AdminWorkbenchChartTheme = typeof adminWorkbenchChartTheme
+69
View File
@@ -0,0 +1,69 @@
/* Blue-and-white workbench preview. Pages opt in without changing admin density. */
.admin-workbench {
--workbench-primary: #3784fa;
--workbench-primary-hover: #5b9bfb;
--workbench-primary-active: #276dd5;
--workbench-primary-disabled: #a9c9fc;
--workbench-number: #1890ff;
--workbench-text: #1f2937;
--workbench-muted: #64748b;
--workbench-surface: #fff;
--workbench-surface-soft: #f5f7fb;
--workbench-surface-tint: #f8faff;
--workbench-border: #e5eaf2;
--workbench-shadow: 0 4px 18px rgb(31 55 90 / 4%);
--workbench-radius: 16px;
--workbench-icon-radius: 12px;
--workbench-page-space: 24px;
--workbench-section-gap: 16px;
--workbench-grid-gap: 18px;
--workbench-card-space: 20px;
--workbench-metric-min-height: 186px;
--workbench-title-size: 24px;
--workbench-section-title-size: 18px;
--workbench-label-size: 15px;
--workbench-number-size: 34px;
--workbench-compact-number-size: 26px;
--workbench-compact-card-space: 14px;
--workbench-tab-radius: 8px;
--workbench-note-size: 13px;
--workbench-accent: #3784fa;
--workbench-accent-soft: #eff6ff;
--workbench-accent-border: #dbeafe;
}
.admin-workbench .workbench-tone[data-tone='blue'] {
--workbench-accent: #3784fa;
--workbench-accent-soft: #eff6ff;
--workbench-accent-border: #dbeafe;
}
.admin-workbench .workbench-tone[data-tone='teal'] {
--workbench-accent: #169b9b;
--workbench-accent-soft: #effcfa;
--workbench-accent-border: #c7eeea;
}
.admin-workbench .workbench-tone[data-tone='green'] {
--workbench-accent: #18a46f;
--workbench-accent-soft: #ecfdf5;
--workbench-accent-border: #c7efdd;
}
.admin-workbench .workbench-tone[data-tone='purple'] {
--workbench-accent: #8b5cf6;
--workbench-accent-soft: #f5f3ff;
--workbench-accent-border: #e6ddfc;
}
.admin-workbench .workbench-tone[data-tone='amber'] {
--workbench-accent: #bf850e;
--workbench-accent-soft: #fffbeb;
--workbench-accent-border: #f5e2ad;
}
.admin-workbench .workbench-tone[data-tone='rose'] {
--workbench-accent: #e85e70;
--workbench-accent-soft: #fff1f3;
--workbench-accent-border: #f8d7df;
}