Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9726e5c3ba |
@@ -2,14 +2,13 @@
|
||||
|
||||
## 快速读取顺序
|
||||
|
||||
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` 对应章节。
|
||||
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` 对应章节。
|
||||
|
||||
单端仅读本指引、最近模块指引及命中代码;跨服务状态、权限或接口边界不明才加后端契约。预算遵循根指引,先读适用规则、定向查业务表,跳过无关专题。
|
||||
普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。
|
||||
|
||||
## 项目定位与技术基线
|
||||
|
||||
|
||||
@@ -8,13 +8,6 @@ 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')
|
||||
@@ -47,7 +40,7 @@ describe('adminNavigation', () => {
|
||||
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
||||
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
|
||||
{ title: '预约管理', children: ['预约列表'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配业务', '出车记录'] },
|
||||
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '出车记录'] },
|
||||
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
|
||||
{ title: '客户服务', children: ['异常订单'] },
|
||||
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||
@@ -56,7 +49,7 @@ describe('adminNavigation', () => {
|
||||
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||
])
|
||||
expect(visible).toHaveLength(13)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58)
|
||||
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(57)
|
||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -135,7 +135,6 @@ 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,4 +1,3 @@
|
||||
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'
|
||||
@@ -14,7 +13,6 @@ 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,
|
||||
} } }) }
|
||||
@@ -178,7 +176,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[data-picker-kind="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
|
||||
expect((wrapper.findAll('input[type="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 class="density-form" @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
|
||||
<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' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<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>
|
||||
<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>
|
||||
</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,13 +62,10 @@ 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)
|
||||
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('input[type="datetime-local"]').setValue('2026-10-22T09:30')
|
||||
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||
expect(picker.props('modelValue')).toBe('2026-10-22T09:30')
|
||||
expect((dialog.get('input').element as HTMLInputElement).value).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))
|
||||
})
|
||||
@@ -76,7 +73,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') === '调整计划到仓')!
|
||||
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
|
||||
await dialog.get('input').setValue('2026-10-22T09:30')
|
||||
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||
expect(dialog.get('input').attributes('disabled')).toBeDefined()
|
||||
expect(dialog.text()).toContain('上次提交结果尚未确认')
|
||||
@@ -84,17 +81,6 @@ 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,4 +1,3 @@
|
||||
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'
|
||||
@@ -18,7 +17,6 @@ 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>' },
|
||||
@@ -111,7 +109,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[data-temporal-picker]').setValue('2026-10-22T09:30')
|
||||
await wrapper.get('input[type="datetime-local"]').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="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 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>
|
||||
</section>
|
||||
<p v-if="unknownResult" role="status">上次提交结果尚未确认,请使用原请求重试,避免重复修改。</p>
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
# 预约工作日历逐操作矩阵
|
||||
|
||||
## 2026-10-20 日期时间组件统一(当前确认)
|
||||
|
||||
依据:用户截图 `codex-clipboard-ed442cca-1bd2-43d3-87e8-6671e5bcc508.png`,以及随后明确要求排查并优化同类原生日期输入。保留现有业务入口与承载方式,使用 Element Plus 日期/时间控件及已有公共表单样式。
|
||||
|
||||
| 页面/组件 | 操作与承载 | 本次调整 | 验收 |
|
||||
|---|---|---|---|
|
||||
| AppointmentCalendarView | 调整计划到仓,原520px居中弹框及全屏能力 | 1处日期时间选择器;分钟精度沿用原请求格式;标签宽度150px,说明图标与标签并排,控件沿用公共满宽样式 | 自动检查通过;待人工1920×1080、100%截图确认 |
|
||||
| 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或人工页面截图验收。
|
||||
|
||||
## 2026-10-20 排车入口收敛
|
||||
|
||||
用户指出预约日历仍跳旧车次详情。排车标签改用服务端关联真实整车主单返回的排车单号,打开 `/overseas-warehouse/dispatches/:dispatchNo` 独立排车详情;返回按钮显示“返回预约日历”并保留原日期与月/周视图。无合法日历返回参数时继续走原列表或拼车登记返回逻辑,外部返回地址拒绝。读取权限与排车列表一致,不要求旧车次列表权限;独立旧车次、取消排车主单不进入活动日历,不按TR号猜DS号。原四类、地点日期和实际发车归属规则保持。自动检查与发布/真实HTTP分别记录,1920×1080、100%人工操作截图待确认。
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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'
|
||||
@@ -14,7 +13,6 @@ 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,
|
||||
} } })
|
||||
@@ -81,7 +79,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[data-picker-kind=date]'), times = wrapper.findAll('input[data-picker-kind=time]')
|
||||
const dates = wrapper.findAll('input[type=date]'), times = wrapper.findAll('input[type=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()
|
||||
@@ -95,7 +93,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[data-picker-kind=date]'), times = wrapper.findAll('input[data-picker-kind=time]')
|
||||
const dates = wrapper.findAll('input[type=date]'), times = wrapper.findAll('input[type=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()
|
||||
@@ -104,7 +102,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[data-picker-kind=date]')[0]!.setValue('2026-10-15')
|
||||
await wrapper.findAll('input[type=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>下次跟进日期(可留空)<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 === '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 === '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>目标日期(可暂不填)<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 === '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 === '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>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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'
|
||||
@@ -27,7 +26,6 @@ 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 class="density-form" @submit.prevent="save()">
|
||||
<form v-else @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>目标送仓日期<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>
|
||||
<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>
|
||||
<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>后送目标日期<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 === '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 === '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>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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,35 +31,15 @@ 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) {
|
||||
const rows = unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrivalWire[]>, ApiEnvelope<WarehouseArrivalWire[]>>(
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrival[]>, ApiEnvelope<WarehouseArrival[]>>(
|
||||
'/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) {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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 }
|
||||
@@ -1,36 +0,0 @@
|
||||
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([])
|
||||
})
|
||||
@@ -23,16 +23,19 @@ describe('ContainerSimulationCreate opted-in form', () => {
|
||||
await wrapper.findAll('button').find(button => button.text().includes('新增模拟柜'))!.trigger('click')
|
||||
await flushPromises()
|
||||
const dialog = wrapper.findComponent(AppDialog)
|
||||
expect(dialog.props('width')).toBe(dense ? 'min(1000px, calc(100vw - 28px))' : 'min(560px, calc(100vw - 28px))')
|
||||
dialog.vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
expect(createContainerSimulation).not.toHaveBeenCalled()
|
||||
const fields = dialog.findAllComponents({ name: 'ElFormItem' })
|
||||
expect(fields).toHaveLength(3)
|
||||
expect(fields).toHaveLength(4)
|
||||
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 { Container, Plus } from '@lucide/vue'
|
||||
import { 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 } from '@/shared/domain/containerSpecifications'
|
||||
import { CONTAINER_SPECIFICATIONS, DEFAULT_CONTAINER_TYPE, applyContainerSpecification, containerSpecification } from '@/shared/domain/containerSpecifications'
|
||||
import { createContainerSimulation } from '../api/platformContainerLoading'
|
||||
import type { ContainerSimulationCreateRequest } from '../types'
|
||||
import { SMART_LOADING_CHANNEL_GROUPS } from '../smartLoadingChannelGroups'
|
||||
@@ -95,54 +95,14 @@ async function saveSimulation() {
|
||||
|
||||
<template>
|
||||
<el-button type="primary" @click="openSimulation"><Plus :size="16" />新增模拟柜</el-button>
|
||||
<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>
|
||||
<AppDialog :class="{ 'admin-density-dialog': dense }" v-model="simulationOpen" title="新增模拟柜" :width="dense ? 'min(1000px, calc(100vw - 28px))' : '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>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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; }
|
||||
}
|
||||
.simulation-form :deep(.el-select), .simulation-form :deep(.el-input-number) { width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,74 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<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>
|
||||
@@ -1,20 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
<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>
|
||||
@@ -1,34 +0,0 @@
|
||||
<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>
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
<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>
|
||||
@@ -1,36 +0,0 @@
|
||||
<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>
|
||||
@@ -1,64 +0,0 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
<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>
|
||||
@@ -1,21 +0,0 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,45 +0,0 @@
|
||||
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) }
|
||||
@@ -1,82 +0,0 @@
|
||||
<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>
|
||||
@@ -1,128 +0,0 @@
|
||||
<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>
|
||||
@@ -1,96 +0,0 @@
|
||||
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,9 +17,6 @@ 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,8 +13,6 @@ 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],
|
||||
@@ -47,17 +45,6 @@ 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,18 +38,6 @@ 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,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Task } from './types'
|
||||
import { collectionControlState, taskStateText, taskProgressText, taskCompletionText } from './collectionPresentation'
|
||||
import { 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,25 +22,6 @@ 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,24 +1,6 @@
|
||||
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'
|
||||
@@ -27,10 +9,8 @@ export function isNormalContinuation(task: Task | null | undefined) {
|
||||
&& !attempt.errorCode && !attempt.failureSummary
|
||||
}
|
||||
|
||||
export function taskStateText(task: Task | null | undefined, evidence: CollectionControlEvidence = {}) {
|
||||
export function taskStateText(task: Task | null | undefined) {
|
||||
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,6 +1,5 @@
|
||||
<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'
|
||||
@@ -97,7 +96,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(
|
||||
@@ -124,7 +123,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', fixed: 'left' } : {}) },
|
||||
{ prop: 'sku', label: 'SKU', minWidth: 170, ...(props.mode === 'inventory' ? { slotName: 'sku' } : {}) },
|
||||
{ prop: 'productName', label: '商品名称', minWidth: 190 },
|
||||
{ key: 'owner', label: '所属货主', minWidth: 140, slotName: 'owner' },
|
||||
{ key: 'unit', label: '单位', width: 70, slotName: 'unit' },
|
||||
@@ -343,29 +342,28 @@ 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 admin-workbench">
|
||||
<AdminPage class="admin-density">
|
||||
<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 workbench-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" 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 workbench-note" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p>
|
||||
<p v-if="runtimeError" class="analytics-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 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>
|
||||
<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>
|
||||
|
||||
</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 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-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-rules">
|
||||
|
||||
<p>{{ summaryMode ? '最近3/7/15/30天均截至昨日(北京时间);自定义按所选起止日期查询。完整区间显示出库量,未完整区间显示已采集量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
|
||||
@@ -405,9 +403,8 @@ 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 admin-workbench is-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable>
|
||||
<AppDialog v-model="trendOpen" class="admin-density-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">
|
||||
@@ -425,7 +422,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-rules { margin: 0; font-size: var(--admin-font-caption); }
|
||||
.analytics-note, .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,7 +2,6 @@
|
||||
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 })
|
||||
@@ -10,8 +9,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 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 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,9 +12,6 @@ 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 } }
|
||||
@@ -28,8 +25,7 @@ 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.get('.account-schedules strong').attributes('title')).toContain('计划时间不代表当前任务完成时间')
|
||||
expect(w.find('.account-schedules').text()).not.toContain('计划时间不代表当前任务完成时间')
|
||||
expect(w.find('.account-schedules').text()).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 })) } })
|
||||
@@ -59,13 +55,7 @@ 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.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()).toContain('完整队列数量及下一任务顺序尚未提供')
|
||||
expect(w.text()).not.toContain('查看配置'); expect(w.text()).not.toContain('执行心跳')
|
||||
w.unmount()
|
||||
})
|
||||
@@ -84,71 +74,8 @@ 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.find('.collection-estimate').exists()).toBe(false)
|
||||
expect(w.get('.account-live').text()).toBe('状态待确认')
|
||||
expect(w.text()).toContain('已暂停估算')
|
||||
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 { Check, Clock3, Database, Pause, RefreshCw } from '@lucide/vue'
|
||||
import { Activity, 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 { collectionControlState, isNormalContinuation, taskStateText } from '../collectionPresentation'
|
||||
import { taskStateText } from '../collectionPresentation'
|
||||
|
||||
const props = withDefaults(defineProps<{ overview: Overview | null; loading?: boolean; stale?: boolean; visible?: boolean }>(), { visible: true })
|
||||
const props = defineProps<{ overview: Overview | null; loading?: boolean; stale?: boolean; visible?: boolean }>()
|
||||
defineEmits<{ refresh: [] }>()
|
||||
const activeStates = new Set(['RUNNING', 'APPLYING', 'READY', 'QUEUED', 'WAITING', 'WAITING_SOURCE', 'WAITING_UPSTREAM', 'RETRY_WAIT', 'YIELDED'])
|
||||
const tasks = computed(() => {
|
||||
@@ -18,18 +18,6 @@ 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>()
|
||||
@@ -60,15 +48,16 @@ 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 control.value ? '本次发布结果需另行确认' : '本次发布完成后,由调度继续安排后续任务'
|
||||
return control.value ? '后续步骤待确认' : '等待调度确认下一步'
|
||||
if (['PUBLISH', 'PUBLISHED'].includes(phase ?? '')) return '本次发布完成后,由调度继续安排后续任务'
|
||||
return '等待调度确认下一步'
|
||||
})
|
||||
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.visible === false) return '页面已暂停更新'
|
||||
if (props.overview.collectionMode === 'OFF') return '采集已关闭'
|
||||
if (props.overview.warehouses.some(w => w.protection?.accountPauseReason)) return '账号已暂停'
|
||||
if (props.overview.warehouses.some(w => ['DEAD', 'FAILED', 'ABORTED'].includes(w.currentTask?.taskStatus ?? ''))) return '暂无活动任务,最近有任务停止'
|
||||
return '等待下一次采集'
|
||||
})
|
||||
@@ -80,144 +69,107 @@ 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'
|
||||
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 ? '等待状态确认' : '待机'
|
||||
return !props.stale && phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle'
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<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>
|
||||
<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>
|
||||
<div class="account-body">
|
||||
<p v-if="stale" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ overview ? '状态更新失败,以下保留最后一次读取的进度。' : '暂未取得账号状态,请稍后刷新。' }}</p>
|
||||
<p v-if="stale" role="status">{{ overview ? '状态更新失败,以下保留最后一次读取的进度。' : '暂未取得账号状态,请稍后刷新。' }}</p>
|
||||
<div class="account-main"><section class="account-primary">
|
||||
<template v-if="current">
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.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-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-now { justify-content: space-between; align-items: flex-start; }
|
||||
.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-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-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-upnext h3 { margin: 8px 0 12px; }
|
||||
.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-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-upnext ul { padding: 0; margin: 0; list-style: none; }
|
||||
.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-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-flow li small { grid-column: 2; }
|
||||
.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; }
|
||||
.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; }
|
||||
.warehouse-result > span:last-child { text-align: right; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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(.collection-estimate) { grid-column: 2; margin: 0; }
|
||||
.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); }
|
||||
@keyframes signal { 50% { opacity: .45; } }
|
||||
@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } }
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,6 @@ 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)
|
||||
@@ -23,31 +22,19 @@ 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 admin-workbench" aria-label="当前阶段数量">
|
||||
<section class="collection-stage-counts" 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" 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">{{ 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"> </p>
|
||||
|
||||
<p v-if="!compact && (progress?.sliceFrom || progress?.sliceUntil)" class="workbench-note">当前切片:{{ beijingTime(progress?.sliceFrom) }} 至 {{ beijingTime(progress?.sliceUntil) }}(结束边界不含)</p>
|
||||
<p v-if="!compact && (progress?.sliceFrom || progress?.sliceUntil)">当前切片:{{ beijingTime(progress?.sliceFrom) }} 至 {{ beijingTime(progress?.sliceUntil) }}(结束边界不含)</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -60,8 +47,9 @@ 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 { 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); }
|
||||
.ring-label strong { font-size: 28px; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.collection-count-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); margin: var(--admin-space-4) 0; }
|
||||
.compact-counts .collection-count-grid { margin-block: 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; }
|
||||
</style>
|
||||
|
||||
@@ -37,60 +37,6 @@ 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 秒')
|
||||
@@ -124,11 +70,6 @@ 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 } })
|
||||
@@ -182,14 +123,6 @@ 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分钟')
|
||||
@@ -202,15 +135,6 @@ 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,21 +3,13 @@ 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 { 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 })
|
||||
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 }>()
|
||||
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())
|
||||
@@ -47,16 +39,6 @@ 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
|
||||
@@ -77,29 +59,23 @@ 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 ?? '')) {
|
||||
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.phases.includes(p.phase ?? '') && ['RUNNING', 'APPLYING'].includes(task.taskStatus)) return '正在处理'
|
||||
if (node.scope === '本仓' && ['WAITING_CONFIRMED', 'WAITING_UNCONFIRMED', 'FAILED'].includes(task.dependencyState ?? '')) return '等待来源'
|
||||
return '状态未确认'
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<section class="collection-progress admin-workbench" :data-estimate="showEstimate" aria-label="关注任务进展">
|
||||
<section class="collection-progress" aria-label="关注任务进展">
|
||||
<template v-if="!compact">
|
||||
<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>
|
||||
<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>
|
||||
<p v-if="task.dependencyState === 'WAITING_UNCONFIRMED'">本任务等待来源;关联进度尚未确认。</p>
|
||||
<p v-if="confirmedSource">已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ stateText(confirmedSource) }};来源完成后,本仓仍需更新出库数据、计算与发布。</p>
|
||||
<p v-if="confirmedSource">已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ taskStateText(confirmedSource) }};来源完成后,本仓仍需更新出库数据、计算与发布。</p>
|
||||
</template>
|
||||
<p v-if="!compact && !control && !stale && visible !== false && task.blockingReason" role="status">{{ blockingText }}</p>
|
||||
<p v-if="!compact && (task.blockingReason || task.latestAttempt?.failureSummary)" role="status">{{ task.blockingReason ? blockingText : task.latestAttempt?.failureSummary }}</p>
|
||||
<CollectionProgressGauge :progress="progress" :task-no="workingTask.taskNo" :running="!stale && visible !== false && !progress?.stageFinishedAt && ['RUNNING', 'APPLYING'].includes(workingTask.taskStatus)" :stale="stale" :compact="compact" />
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -112,14 +88,13 @@ 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; }
|
||||
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-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); }
|
||||
.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; 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 { display: grid; gap: var(--admin-space-2); border-top: 2px solid var(--el-border-color); padding-top: var(--admin-space-3); }
|
||||
.collection-flow li[data-state^="✓"] { border-color: var(--el-color-success); }
|
||||
.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-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-clocks strong { font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<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'
|
||||
@@ -20,21 +19,18 @@ function selectChart(event: ECElementEvent) {
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div :aria-label="title" class="dashboard-bars workbench-chart">
|
||||
<div :aria-label="title" class="dashboard-bars">
|
||||
<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" />
|
||||
<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>
|
||||
<slot v-if="!rows.length" name="empty"><p>暂无符合条件的 SKU;请核对覆盖与待评估资料。</p></slot>
|
||||
<template v-else>
|
||||
<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">
|
||||
<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">
|
||||
<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,8 +2,15 @@
|
||||
defineProps<{ title: string }>()
|
||||
</script>
|
||||
<template>
|
||||
<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 class="dashboard-chart-heading">
|
||||
<h3>{{ title }}</h3>
|
||||
<div class="dashboard-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,13 +10,6 @@ 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,10 +7,9 @@ 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, adminWorkbenchChartTheme), aria: { enabled: true, description: '全仓每日出库趋势,点击已知日点查看当日销量。缺失日期断线,未完整日虚线。' } }))
|
||||
const option = computed(() => ({ ...skuTrendOption(props.points), 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 }
|
||||
@@ -18,7 +17,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 class="workbench-chart">
|
||||
<div>
|
||||
<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">
|
||||
@@ -26,8 +25,8 @@ function selectChart(event: ECElementEvent) { const point = props.points[event.d
|
||||
<p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 未知及缺失日期断线;今日为已采集累计</p>
|
||||
</ChartDetailsDialog>
|
||||
</DashboardChartHeading>
|
||||
<AnalyticsChart :option="option" class="dashboard-trend workbench-chart-canvas" aria-label="全仓每日出库趋势" @select="selectChart" />
|
||||
<p v-if="!hasData" class="workbench-note">所选日期暂无可靠出库资料,未知不按零显示。</p>
|
||||
<AnalyticsChart :option="option" class="dashboard-trend" aria-label="全仓每日出库趋势" @select="selectChart" />
|
||||
<p v-if="!hasData">所选日期暂无可靠出库资料,未知不按零显示。</p>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -36,4 +35,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,29 +35,22 @@ 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.get('.turnover-statistics').text()).toContain('所选货主概览')
|
||||
expect(w.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()
|
||||
@@ -73,10 +66,6 @@ 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
|
||||
@@ -115,7 +104,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="选择历史库存记录"]')
|
||||
@@ -170,19 +159,14 @@ describe('inventory turnover page fixed snapshot behavior', () => {
|
||||
return result
|
||||
})
|
||||
const w = page(); await flushPromises()
|
||||
expect(w.text()).toContain('缺尺寸 2 SKU')
|
||||
expect(w.text()).toContain('缺尺寸 2 SKU / 12 件')
|
||||
expect(w.text()).toContain('已知小计 0.125 m³')
|
||||
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.text()).toContain('其中 40 件按商品尺寸估算')
|
||||
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')
|
||||
expect(w.findAll('.turnover-volume-value')[0]!.attributes('title')).toContain('缺尺寸 2 SKU / 12 件')
|
||||
expect(w.text()).toContain('缺尺寸 2 SKU / 12 件')
|
||||
})
|
||||
|
||||
it('filters turnover by directory customer and keeps the code in pagination', async () => {
|
||||
@@ -196,7 +180,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.
|
||||
@@ -232,7 +216,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 () => {
|
||||
@@ -362,34 +346,8 @@ 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()
|
||||
@@ -423,7 +381,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.get('.turnover-statistics').text()).toContain('本仓概览')
|
||||
expect(w.text()).toContain('本仓概览')
|
||||
await change(w, { sku: 'X' }, true)
|
||||
expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBe('token-B')
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<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 { Boxes, ChartNoAxesCombined, Info, Ruler } from '@lucide/vue'
|
||||
import { Info } 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'
|
||||
@@ -69,8 +68,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, placeholder: '全部样本状态', 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, 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[] : [])
|
||||
@@ -81,12 +80,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, fixed: 'left' }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
|
||||
{ prop: 'customerCode', label: '所属货主', slotName: 'owner', minWidth: 160, ...(ownerMode.value ? { fixed: 'left' } : {}) },
|
||||
...(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: '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 })),
|
||||
@@ -111,30 +110,22 @@ 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: 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 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 days = applied.value?.baseWindowDays ?? 30
|
||||
return [
|
||||
{ key: 'stock', name: '当前库存', cards: [
|
||||
quantity('physical', '实物件数', summary.physicalQuantity, 'blue'),
|
||||
{ key: 'stock', name: '当前库存', note: '使用当前库存快照,不随统计时段变化', cards: [
|
||||
quantity('physical', '实物件数', summary.physicalQuantity, 'primary'),
|
||||
quantity('available', '正品可售件数', summary.availableQuantity, 'green'),
|
||||
volume('inventory-volume', '库存立方数(m³)', summary.inventoryVolumeM3, false, 'purple'),
|
||||
] },
|
||||
{ key: 'period', name: '所选时段分析', cards: [
|
||||
quantity('no-outbound', '所选时段无出库SKU', summary.noOutboundSkuCount, 'amber'),
|
||||
{ key: 'period', name: '所选时段分析', note: `${days}天 · 截至 ${applied.value?.endDate ?? '—'}`, cards: [
|
||||
quantity('no-outbound', '所选时段无出库SKU', summary.noOutboundSkuCount, 'orange'),
|
||||
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))
|
||||
@@ -279,12 +270,11 @@ onActivated(() => { const resume = initialized && !active; active = true; if (re
|
||||
onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench turnover-page">
|
||||
<AdminPage class="admin-density 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">
|
||||
@@ -304,8 +294,8 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
|
||||
</el-popover>
|
||||
</span>
|
||||
</template>
|
||||
<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>
|
||||
<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>
|
||||
<p>历史库存记录用于计算期间平均库存和周转次数,与已采集的销量天数不同;记录不足不代表销量采集失败。</p>
|
||||
<template v-if="data?.rows.length">
|
||||
<label>查看本页历史库存记录
|
||||
@@ -322,83 +312,55 @@ 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="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 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>
|
||||
<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 class="workbench-section-heading"><h4 class="workbench-section-title">{{ group.name }}</h4></header>
|
||||
<header><h4>{{ group.name }}</h4><span>{{ group.note }}</span></header>
|
||||
<div class="turnover-cards">
|
||||
<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 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>
|
||||
</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 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="图表指标">
|
||||
<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="图表指标">
|
||||
<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 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>
|
||||
<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>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<div class="turnover-list-filters">
|
||||
<nav class="turnover-focus workbench-tabs" aria-label="库存关注快捷筛选">
|
||||
<nav class="turnover-focus" 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>
|
||||
@@ -408,7 +370,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" class="workbench-input" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天</label>
|
||||
<label>可售天数阈值 <input v-model="filters.coverageDaysThreshold" 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>
|
||||
@@ -421,9 +383,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 }"><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 #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 #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>
|
||||
@@ -433,9 +395,8 @@ 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>
|
||||
</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>
|
||||
<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>
|
||||
</template>
|
||||
</AdminPage>
|
||||
</template>
|
||||
@@ -449,26 +410,45 @@ 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 { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; }
|
||||
.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-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: var(--workbench-grid-gap); padding: 4px 0 12px; }
|
||||
.turnover-summary { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 4px 0 12px; }
|
||||
.turnover-summary-group { min-width: 0; display: flex; flex-direction: column; }
|
||||
.turnover-summary-group > header { margin-bottom: var(--admin-space-3); }
|
||||
.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-cards { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; flex: 1; }
|
||||
.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-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-chart { flex: 1 1 0; height: 0; min-height: 120px; width: 100%; }
|
||||
.turnover-chart-empty { flex: 1 1 0; min-height: 0; }
|
||||
.turnover-chart-header { flex-shrink: 0; }
|
||||
.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-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; }
|
||||
.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 > 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,7 +6,6 @@ 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 {
|
||||
@@ -25,13 +24,6 @@ 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()
|
||||
@@ -45,27 +37,4 @@ 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,29 +6,22 @@ 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, adminWorkbenchChartTheme))
|
||||
const option = computed(() => skuTrendOption(props.data.points))
|
||||
const hasData = computed(() => props.data.points.some(point => point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null))
|
||||
</script>
|
||||
<template>
|
||||
<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 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>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } 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: '12000.123456', detail: 'very long detail belongs in the dialog' }], '<件>', '热销', true)
|
||||
const option = barChartOption([{ key: 'sku', label: '<img src=x>', secondary: 'A&B', value: 12, 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('<img src=x>')
|
||||
expect(content).toContain('A&B "'货主')
|
||||
expect(content).toContain('12,000.123456')
|
||||
expect(content).toContain('<件>')
|
||||
expect(content).not.toContain('<img')
|
||||
expect(content).toContain('A&B')
|
||||
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]!
|
||||
@@ -24,30 +19,6 @@ 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: '' }))
|
||||
@@ -56,64 +27,5 @@ 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,55 +1,34 @@
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
|
||||
import type { Quantity } from './types'
|
||||
import { groupedQuantityText } from './state'
|
||||
import { quantityText } from './state'
|
||||
|
||||
export interface BarRow { key: string; label: string; secondary?: string; value: Quantity; detail: string }
|
||||
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]!))
|
||||
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,
|
||||
}
|
||||
const palette = ['#5575ed', '#3da9ba', '#e8a54b', '#b083dc', '#df8397', '#8a9ab4', '#a2adbf']
|
||||
export function barChartOption(rows: BarRow[], unit: string, title: string, ranked = false): EChartsOption {
|
||||
const valueText = (value: Quantity | undefined) => {
|
||||
if (unit === '天' && value != null && Number.isFinite(Number(value))) return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 })
|
||||
return groupedQuantityText(value)
|
||||
}
|
||||
const valueText = (value: Quantity | undefined) => unit === '天' && value != null && Number.isFinite(Number(value)) ? Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2, useGrouping: false }) : quantityText(value)
|
||||
return {
|
||||
animation: false,
|
||||
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,
|
||||
},
|
||||
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' } } },
|
||||
yAxis: { type: 'category', inverse: true, data: rows.map(row => row.key), triggerEvent: true,
|
||||
axisTick: { show: false }, axisLine: { show: false },
|
||||
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 => {
|
||||
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 => {
|
||||
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 ? 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) } }],
|
||||
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) } }],
|
||||
}
|
||||
}
|
||||
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: 12 },
|
||||
axisLine: { lineStyle: { width: 12, color: [[1, theme.colors.primarySoft]] } }, itemStyle: { color: theme.colors.primary, shadowColor: theme.colors.primarySoft, shadowBlur: 6 },
|
||||
pointer: { show: false }, progress: { show: percent != null || running, roundCap: true, width: 10 },
|
||||
axisLine: { lineStyle: { width: 10, color: [[1, '#e8eaf5']] } }, itemStyle: { color: '#6879ee' },
|
||||
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,16 +1,9 @@
|
||||
# 自营分析逐操作矩阵
|
||||
|
||||
依据:2026-10-12 用户提供的销量/库存页面截图及“主要统计SKU,在看当前SKU是哪个货主就行,货主不重要”的明确纠正,优先于一期方案中的货主经营面板目标;密度沿用 `docs/Admin高密度界面规范.md`。2026-10-20 用户提供蓝白工作台参考,选经营总览制作样板,并追加销量、库存与数据采集;本次新样式均待人工1920×1080、100%截图确认。
|
||||
依据:2026-10-12 用户提供的销量/库存页面截图及“主要统计SKU,在看当前SKU是哪个货主就行,货主不重要”的明确纠正,优先于一期方案中的货主经营面板目标;密度沿用 `docs/Admin高密度界面规范.md`。用户最新确认销量、库存样式已完成,本轮以其截图为数据采集统一样板;采集页修改后的视觉仍待人工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,6 +1,5 @@
|
||||
<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'
|
||||
@@ -47,7 +46,7 @@ onDeactivated(suspend)
|
||||
onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange', visibility) })
|
||||
</script>
|
||||
<template>
|
||||
<AdminPage class="admin-density admin-workbench">
|
||||
<AdminPage class="admin-density">
|
||||
<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,10 +19,16 @@ 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 workbench-tabs" aria-label="库存分析视图">
|
||||
<nav class="inventory-views" 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,31 +50,6 @@ 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)) {
|
||||
@@ -88,7 +63,6 @@ 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)
|
||||
})
|
||||
@@ -212,7 +186,6 @@ 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)
|
||||
@@ -360,7 +333,6 @@ 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 件')
|
||||
@@ -391,8 +363,6 @@ 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,6 +1,5 @@
|
||||
<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'
|
||||
@@ -14,7 +13,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, groupedQuantityText, label, quantityText, reasonsText, RequestFence, RuntimePoller, sameContext } from '../state'
|
||||
import { beijingTime, contextParams, 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'
|
||||
@@ -78,9 +77,8 @@ 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 metricTones = ['blue', 'teal', 'purple', 'green', 'amber', 'rose'] as const
|
||||
const metricColors = ['#5575ed', '#3aa9ba', '#8871ce', '#35a987', '#c6923e', '#d67b91']
|
||||
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` }
|
||||
@@ -194,13 +192,12 @@ 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 admin-workbench dashboard-page">
|
||||
<AdminPage class="admin-density 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="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-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-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>
|
||||
@@ -209,52 +206,27 @@ 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>
|
||||
</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>
|
||||
<p v-if="!warehouseCode">请先选择一个仓库并查询经营数据。</p>
|
||||
<p v-if="routeError" 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" 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>
|
||||
<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>
|
||||
<template v-if="data">
|
||||
<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>
|
||||
<p v-if="includesToday" role="status">所选时段包含今日({{ data.context.anchorDate }}),尚不满足完整自然日要求,库存消化天数与两个库存榜单暂不能完成评估。可选择截至昨日的自定义时段;仍需满足销量覆盖、库存及观察期条件。</p>
|
||||
<p v-if="data.context.rebuildRequired || data.context.salesStatus === 'REBUILD_REQUIRED'" role="status">销量有变动,待重新统计发布。出库趋势展示已发布版本的历史覆盖结果;库存评估等待新版销量发布。</p>
|
||||
<div class="dashboard-kpis" aria-label="本仓经营指标">
|
||||
<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>
|
||||
<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>
|
||||
</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 workbench-panel">
|
||||
<section class="dashboard-chart">
|
||||
<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 workbench-panel">
|
||||
<section class="dashboard-chart">
|
||||
<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>
|
||||
@@ -264,14 +236,8 @@ 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, index) in rankingNames" :key="ranking.key" class="dashboard-chart workbench-panel workbench-tone" :data-tone="metricTones[index]">
|
||||
<section v-for="ranking in rankingNames" :key="ranking.key" class="dashboard-chart">
|
||||
<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">
|
||||
@@ -283,7 +249,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 v-if="hasRankingWarnings" #intro>
|
||||
<template #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>
|
||||
@@ -293,19 +259,43 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
|
||||
</DashboardBars>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<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>
|
||||
<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>
|
||||
</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-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-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-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(--workbench-grid-gap); }
|
||||
.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-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,7 +55,6 @@ 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()
|
||||
@@ -398,17 +397,6 @@ 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,43 +2,20 @@ 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.each([
|
||||
['default', undefined],
|
||||
['workbench', adminWorkbenchChartTheme],
|
||||
] as const)('SKU ECharts data configuration with %s theme', (_, theme) => {
|
||||
describe('SKU ECharts data configuration', () => {
|
||||
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')], theme).series as LineSeriesOption[]
|
||||
const series = skuTrendOption([point(10, 'COMPLETE'), point(11, 'PARTIAL'), point(12, 'COMPLETE'), point(13, 'COMPLETE')]).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')], theme).series as LineSeriesOption[]
|
||||
const series = skuTrendOption([point(10, 'COMPLETE', '0'), point(11, 'UNKNOWN', null), point(12, 'COMPLETE'), point(14, 'COMPLETE')]).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)
|
||||
})
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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[], theme?: AdminWorkbenchChartTheme): EChartsOption {
|
||||
const lineColor = theme?.colors.primary ?? '#6078ea'
|
||||
export function skuTrendOption(points: DailyPoint[]): EChartsOption {
|
||||
const dates = points.map(point => point.businessDate)
|
||||
const values = points.map(point => {
|
||||
const value = shownQuantity(point)
|
||||
@@ -12,9 +10,9 @@ export function skuTrendOption(points: DailyPoint[], theme?: AdminWorkbenchChart
|
||||
})
|
||||
const series: LineSeriesOption[] = [{
|
||||
name: '每日出库量', type: 'line', connectNulls: false, lineStyle: { opacity: 0 },
|
||||
symbolSize: theme ? 7 : 8, showAllSymbol: true,
|
||||
symbolSize: 8, showAllSymbol: true,
|
||||
data: values.map((value, index) => ({ value, symbol: points[index]?.dataStatus === 'COMPLETE' ? 'circle' : 'emptyCircle' })),
|
||||
itemStyle: { color: lineColor }, emphasis: { scale: 1.5 },
|
||||
itemStyle: { color: '#6078ea' }, emphasis: { scale: 1.5 },
|
||||
}]
|
||||
let segment: { partial: boolean; data: (number | null)[] } | null = null
|
||||
for (let index = 1; index < points.length; index++) {
|
||||
@@ -23,12 +21,7 @@ export function skuTrendOption(points: DailyPoint[], theme?: AdminWorkbenchChart
|
||||
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: 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 }],
|
||||
} } } : {}),
|
||||
tooltip: { show: false }, lineStyle: { color: '#6078ea', width: 3, type: partial ? 'dashed' : 'solid' },
|
||||
emphasis: { disabled: true }, animation: false })
|
||||
}
|
||||
segment.data[index - 1] = values[index - 1]!
|
||||
@@ -36,32 +29,19 @@ export function skuTrendOption(points: DailyPoint[], theme?: AdminWorkbenchChart
|
||||
}
|
||||
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, ...(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 } } : {}) },
|
||||
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' },
|
||||
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: 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 } },
|
||||
} : {}),
|
||||
}],
|
||||
dataZoom: [{ type: 'slider', bottom: 4, height: 20, start: 0, end: 100, filterMode: 'none' }],
|
||||
aria: { enabled: true, description: 'SKU每日出库趋势。横轴为北京时间日期,纵轴为出库件数。完整日实线,未完整日虚线,缺失日期断线。详细数值可展开下方表格。' },
|
||||
series,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RequestFence, RetryIntent, RuntimePoller, contextParams, groupedQuantityText, label, quantityText, reasonsText, progressText, sameContext, shownQuantity, taskRetryAllowed, validateCustomRange } from './state'
|
||||
import { RequestFence, RetryIntent, RuntimePoller, contextParams, label, 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,23 +36,6 @@ 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)
|
||||
|
||||
@@ -39,9 +39,6 @@ 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,6 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { BarSeriesOption } from 'echarts'
|
||||
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { turnoverChart, turnoverNumber, volumeText, windowText, turnoverReasonsText, volumeNote } from './turnoverPresentation'
|
||||
import type { TurnoverCharts, TurnoverWindowMetric } from './turnoverTypes'
|
||||
describe('turnover honest precision and charts', () => {
|
||||
@@ -37,51 +35,4 @@ 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,7 +1,6 @@
|
||||
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: '独立库存品质记录不完整',
|
||||
@@ -77,7 +76,6 @@ 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 ? '件' : '件/日'
|
||||
@@ -86,26 +84,13 @@ 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: [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}` },
|
||||
animationDuration: 450, color: ['#6c7bea'],
|
||||
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, 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) },
|
||||
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) },
|
||||
data: values.map(value => value == null ? null : Number(value)) }],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,18 +67,6 @@
|
||||
.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; }
|
||||
|
||||
|
||||
@@ -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', 'self-operated/components/InventoryTurnoverView.vue']
|
||||
const selfOperatedPages = ['self-operated/components/AnalyticsView.vue']
|
||||
const selfOperatedConsumers = [...selfOperatedPages, 'self-operated/components/SkuTrend.vue', 'self-operated/pages/OverviewView.vue']
|
||||
const consumers = [
|
||||
...selfOperatedConsumers,
|
||||
@@ -399,8 +399,7 @@ 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="150px"')
|
||||
expect(view.template!.content).toContain('<el-date-picker v-model="plannedAt" type="datetime"')
|
||||
expect(view.template!.content).toContain('label-position="left" label-width="110px"')
|
||||
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')
|
||||
@@ -445,7 +444,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 admin-workbench dashboard-page">')
|
||||
expect(source).toContain('<AdminPage class="admin-density dashboard-page">')
|
||||
expect(source).toContain('class="dashboard-kpis"')
|
||||
expect(source).toContain('class="dashboard-main-charts"')
|
||||
expect(source).not.toContain('density-fields')
|
||||
@@ -884,12 +883,11 @@ 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 admin-workbench"')
|
||||
expect(page).toContain('class="admin-density"')
|
||||
expect(page).toContain('@/styles/adminDensity.css')
|
||||
expect(page).toContain('@/styles/adminWorkbench.css')
|
||||
expect(page).toContain('<CollectionAccountMonitor')
|
||||
expect(page).not.toContain('<DataTable')
|
||||
expect(monitor).toContain('workbench-panel')
|
||||
expect(monitor).toContain('background: var(--el-bg-color)')
|
||||
expect(monitor).not.toMatch(/--el-[\w-]+\s*:/)
|
||||
})
|
||||
it('opts the confirmed self-operated pages into integrated text actions without changing older outlined consumers', () => {
|
||||
@@ -1036,14 +1034,6 @@ 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 => {
|
||||
@@ -1065,6 +1055,7 @@ describe('approved Admin density ownership', () => {
|
||||
expect(read('modules/loading-lists/components/LoadingListsView.vue')).toContain('class="business-page loading-lists-page admin-density"')
|
||||
expect(read('modules/loading-lists/components/PalletLabelPrintDialog.vue')).toContain("'admin-density-dialog': dense")
|
||||
expect(read('modules/container-loading/components/ContainerSimulationCreate.vue')).toContain("'admin-density-dialog': dense")
|
||||
expect(read('modules/container-loading/components/ContainerSimulationCreate.vue')).toContain("dense ? 'min(1000px, calc(100vw - 28px))' : 'min(560px, calc(100vw - 28px))'")
|
||||
for (const file of ['loading-lists/components/LoadingListDetailDrawer.vue', 'loading-lists/components/PalletLabelPrintDialog.vue', 'container-loading/components/ContainerSimulationCreate.vue']) {
|
||||
const template = parse(read(`modules/${file}`)).descriptor.template!.ast!
|
||||
const visit = (nodes: typeof template.children) => {
|
||||
|
||||
@@ -1,597 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
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)/)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
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
|
||||
@@ -1,69 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user