Compare commits

...
61 changed files with 3113 additions and 341 deletions
+7 -6
View File
@@ -2,13 +2,14 @@
## 快速读取顺序
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
2. 视觉还原、布局/交互承载方式变更或真实页面验收时读取 `../../docs/Admin-Client前端原型还原验收规则.md`;需要原型依据才读取 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位目标片段。纯文案、逻辑或单元测试任务不自动读取。
Admin 样式优化还须读取 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”和“新模块接入规则”;以已确认密度与彩色页签为视觉基线,逐操作保留业务语义和承载方式。国内仓、客户管理是样板;新增菜单按本轮范围显式接入,货柜管理入口见 `../../docs/Admin货柜管理样式核对记录.md`。
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;海外仓全流程任务先读 `../../docs/海外仓业务/README.md`。
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件任务按需读取 `../../docs/统一认证与服务通信契约.md` 对应章节。
1. 由“模块快速路由”选最小模块并读最近 `AGENTS.md`,按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不扫描全部 `src/`。
2. 视觉、布局/交互承载变更或真实页面验收先读 `../../docs/Admin-Client前端原型还原验收规则.md`;需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发。
样式优化另读 `../../docs/Admin高密度界面规范.md` 的“样式维护边界”“新模块接入规则”,保留已确认密度、彩色页签、业务语义及承载。国内仓、客户管理为样板;新增菜单按本轮范围显式接入,货柜入口见 `../../docs/Admin货柜管理样式核对记录.md`。
蓝白样式接入见 [样板指南](../../docs/界面参考/蓝白卡片式工作台风格参考.md)。
3. 标准列表/公共组件读 `src/shared/components/AGENTS.md`;HTTP、Token/刷新读 `src/shared/api/AGENTS.md`;Finance 读 `src/modules/finance/AGENTS.md`;海外仓全流程先读 `../../docs/海外仓业务/README.md`。
4. 平台/客户层级及权限读 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件按需读 `../../docs/统一认证与服务通信契约.md` 对应章节。
普通单端任务的最小读取集是本文件、目标模块最近的 `AGENTS.md` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。
单端仅读本指引、最近模块指引及命中代码;跨服务状态、权限或接口边界不明才加后端契约。预算遵循根指引,先读适用规则、定向查业务表,跳过无关专题。
## 项目定位与技术基线
+9 -2
View File
@@ -8,6 +8,13 @@ import {
} from './adminNavigation'
describe('adminNavigation', () => {
it('keeps outsourced read access separate from owned access and write-only permissions', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:list']))
const vehicle = read.find(node => node.key === 'vehicle-management')
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/outsourced-business'])
const write = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:edit', 'platform:fleet:outsourced-business:upload']))
expect(getNavigationLeaves(write).some(node => node.path === '/overseas-master-data/outsourced-business')).toBe(false)
})
it('places owned-business under vehicle management and keeps edit/upload from granting page access', () => {
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:owned-business:list']))
const vehicle = read.find(node => node.key === 'vehicle-management')
@@ -40,7 +47,7 @@ describe('adminNavigation', () => {
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
{ title: '预约管理', children: ['预约列表'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '出车记录'] },
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配业务', '出车记录'] },
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
{ title: '客户服务', children: ['异常订单'] },
{ title: '财务管理', children: ['客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
@@ -49,7 +56,7 @@ describe('adminNavigation', () => {
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
])
expect(visible).toHaveLength(13)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(57)
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(58)
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
})
+1
View File
@@ -135,6 +135,7 @@ export const adminNavigation: AdminNavigationNode[] = [
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
{ key: 'fleet-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' },
{ key: 'fleet-outsourced-business', title: '外配业务', icon: Truck, path: '/overseas-master-data/outsourced-business', permission: 'platform:fleet:outsourced-business:list' },
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true },
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
],
@@ -0,0 +1,87 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { getOutsourcedBusiness, listOutsourcedBusiness, listOutsourcedBusinessHistory, saveOutsourcedBusinessOperations, uploadOutsourcedBusinessPhoto, bindOutsourcedBusinessPhoto, getOutsourcedBusinessPhotoContent } from './outsourcedBusiness'
import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures'
import type { OutsourcedBusinessPhoto, OutsourcedBusinessPhotoSlot, OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
vi.mock('@/shared/api/http', async importOriginal => {
const actual = await importOriginal<typeof import('@/shared/api/http')>()
return { ...actual, http: { get: vi.fn(), put: vi.fn(), post: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data }
})
beforeEach(() => vi.clearAllMocks())
const base = '/platform/v1/last-leg/fleet/outsourced-business'
const photo: OutsourcedBusinessPhoto = { slot: 'LOADING_SCENE', category: 'LOADING', fileId: '9007199254740993', fileName: 'loading.png', contentType: 'image/png', sizeBytes: '10', uploadedAt: '2026-10-20T10:00:00Z', savedAt: null }
describe('outsourced business HTTP contract', () => {
it('uses one list response for matched statistics and keeps physical count independent of cargo rows', async () => {
const detail = outsourcedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { records: [detail.row], page: '1', size: '20', total: '41', summary: { dispatchCount: '41', afterLoadingCount: '40', deliveringCount: '1', deliveredCount: '0' } } })
const query = { page: 1, size: 20, plannedDepartureFrom: '2026-10-20T00:00:00Z', plannedDepartureTo: '2026-10-21T00:00:00Z', keyword: 'FBA-1' }
const value = await listOutsourcedBusiness(query)
expect(http.get).toHaveBeenCalledExactlyOnceWith(base, { params: query })
expect(value.summary.dispatchCount).toBe(41); expect(value.records).toHaveLength(1); expect(value.records[0]!.actualPalletCount).toBe(1)
})
it('encodes the dispatch business number and preserves auxiliary/main versions independently', async () => {
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = '0'; detail.row.dispatchRecordVersion = '9007199254740993'
detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
vi.mocked(http.get).mockResolvedValue({ data: detail })
expect((await getOutsourcedBusiness('DS/1')).row.dispatchRecordVersion).toBe('9007199254740993')
expect(http.get).toHaveBeenCalledWith(`${base}/DS%2F1`, { params: undefined })
detail.dispatch.dispatchNo = 'another'; await expect(getOutsourcedBusiness('DS/1')).rejects.toThrow('不一致')
})
it('does not convert money text, calculate a client unit price or submit a dispatch version', async () => {
const body: OutsourcedBusinessSave = { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null }
const detail = outsourcedBusinessFixture(); detail.row.operations.money = body.operations.money
vi.mocked(http.put).mockResolvedValue({ data: detail })
const saved = await saveOutsourcedBusinessOperations('DS-OUT-1', body)
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS-OUT-1/operations`, body)
expect(body.operations.money!.amount).toBe('9999999999999999.99'); expect('dispatchRecordVersion' in body).toBe(false)
expect(saved.row.operations.money!.amount).toBe('9999999999999999.99')
})
it('keeps upload and versioned file binding separate with the same prepared file identity', async () => {
vi.mocked(http.post).mockResolvedValue({ data: photo })
const file = new File(['image'], 'loading.png', { type: 'image/png' })
await uploadOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', file, 'upload-key')
const [url, form] = vi.mocked(http.post).mock.calls[0]!
expect(url).toBe(`${base}/DS%2F1/photos/LOADING_SCENE/uploads`); expect((form as FormData).get('file')).toBe(file); expect((form as FormData).get('idempotencyKey')).toBe('upload-key')
const detail = outsourcedBusinessFixture(); detail.row.dispatchNo = 'DS/1'; detail.dispatch.dispatchNo = 'DS/1'
vi.mocked(http.put).mockResolvedValue({ data: detail })
await bindOutsourcedBusinessPhoto('DS/1', 'LOADING_SCENE', { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
expect(http.put).toHaveBeenCalledExactlyOnceWith(`${base}/DS%2F1/photos/LOADING_SCENE`, { recordVersion: '0', idempotencyKey: 'bind-key', fileId: photo.fileId })
})
it('retains the two history streams and reads historical files only at their explicit revision', async () => {
vi.mocked(http.get).mockResolvedValue({ data: { records: [{ eventSource: 'DISPATCH', recordVersion: null, dispatchRecordVersion: '9', operations: null, photos: [] }, { eventSource: 'OPERATIONS', recordVersion: '1', dispatchRecordVersion: null, operations: outsourcedBusinessFixture().row.operations, photos: [] }], page: '1', size: '20', total: '2' } })
const value = await listOutsourcedBusinessHistory('DS/1', 1, 20)
expect(value.records.map(record => [record.eventSource, record.recordVersion, record.dispatchRecordVersion])).toEqual([['DISPATCH',null,'9'],['OPERATIONS','1',null]])
vi.mocked(http.get).mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
await getOutsourcedBusinessPhotoContent('DS/1', photo, '1')
expect(http.get).toHaveBeenLastCalledWith(`${base}/DS%2F1/photos/LOADING_SCENE/files/${photo.fileId}/content`, { responseType: 'blob', params: { revisionVersion: '1' } })
})
it('never renders JSON errors or empty bytes as a photo', async () => {
vi.mocked(http.get).mockResolvedValue(new Blob([JSON.stringify({ code: 'A0403', message: '拒绝读取', requestId: 'request', traceId: 'trace' })], { type: 'application/json' }))
await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toMatchObject({ code: 'A0403', requestId: 'request', traceId: 'trace' })
vi.mocked(http.get).mockResolvedValue(new Blob([], { type: 'image/png' })); await expect(getOutsourcedBusinessPhotoContent('DS-OUT-1', photo)).rejects.toThrow('为空')
await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1','LOADING_SCENE',new File(['text'],'bad.txt',{type:'text/plain'}),'key')).rejects.toThrow('JPG')
})
it.each(['2', null])('accepts authoritative external vehicle identity %s without looking up the current master', async vehicleId => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle.vehicleId = vehicleId; detail.row.vehicle.carrierName = null
vi.mocked(http.get).mockResolvedValue({ data: detail })
const value = await getOutsourcedBusiness('DS-OUT-1')
expect(value.row.vehicle).toMatchObject({ vehicleId, ownership: 'DISPATCHED', carrierName: null })
expect(http.get).toHaveBeenCalledTimes(1)
})
it('rejects owned vehicle facts and unsupported fuel or mileage writes before any request', async () => {
const detail = outsourcedBusinessFixture()
vi.mocked(http.get).mockResolvedValue({ data: { ...detail, row: { ...detail.row, vehicle: { ...detail.row.vehicle, ownership: 'OWNED' } } } })
await expect(getOutsourcedBusiness('DS-OUT-1')).rejects.toThrow('来源或阶段')
for (const field of ['actualDistanceMiles','fuelConsumptionLitresPer100Km','actualFuelLitres']) {
const body = { recordVersion:'0', idempotencyKey:'original-key', operations:{ ...detail.row.operations, [field]:'1' }, reason:null }
await expect(saveOutsourcedBusinessOperations('DS-OUT-1', body)).rejects.toThrow('不支持里程或油耗')
}
expect(http.put).not.toHaveBeenCalled()
for (const slot of ['FUEL_START','FUEL_END']) {
await expect(uploadOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,new File(['image'],'loading.png',{type:'image/png'}),'upload-key')).rejects.toThrow('不支持该照片分类')
await expect(bindOutsourcedBusinessPhoto('DS-OUT-1',slot as OutsourcedBusinessPhotoSlot,{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})).rejects.toThrow('不支持该照片分类')
}
expect(http.post).not.toHaveBeenCalled(); expect(http.put).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,74 @@
import axios from 'axios'
import { http, unwrapApiResponse, ApiResponseError, type ApiEnvelope } from '@/shared/api/http'
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessPhotoRoles, type OutsourcedBusinessOperations, type OutsourcedBusinessRow, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessSave, type OutsourcedBusinessBind, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessCommand, type OutsourcedBusinessHistory, type OutsourcedBusinessPage } from '../outsourcedBusinessTypes'
const base = '/platform/v1/last-leg/fleet/outsourced-business'
const path = (no: string) => `${base}/${encodeURIComponent(no)}`
async function get<T>(url: string, params?: object): Promise<T> { return unwrapApiResponse(await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(url, { params })) }
function count(value: unknown): number { const text = outsourcedBusinessVersion(value); const result = Number(text); if (!Number.isSafeInteger(result)) throw new Error('运营记录数量超出精确范围'); return result }
function decimal(value: string | null, scale: number, precision: number): string | null {
if (value == null) return null
if (typeof value !== 'string' || outsourcedBusinessDecimal(value, scale, precision) !== value) throw new Error('运营金额或测量必须使用精确十进制字符串')
return value
}
function operations(value: OutsourcedBusinessOperations): OutsourcedBusinessOperations {
if ('actualDistanceMiles' in value || 'fuelConsumptionLitresPer100Km' in value || 'actualFuelLitres' in value) throw new Error('外配运营信息不支持里程或油耗')
if (value.money && !['CNY','USD'].includes(value.money.currency)) throw new Error('运营金额币种无效')
const amount = value.money ? decimal(value.money.amount, 2, 18) : null
if (value.money && amount == null) throw new Error('承运金额与币种必须同时登记')
return { money: value.money ? { currency: value.money.currency, amount: amount! } : null, arrivals: value.arrivals }
}
function requireSlot(slot: OutsourcedBusinessPhotoSlot) { if (!outsourcedBusinessPhotoRoles.some(role => role.slot === slot)) throw new Error('外配业务不支持该照片分类') }
function row(value: OutsourcedBusinessRow): OutsourcedBusinessRow {
if (!value.dispatchNo || !['AFTER_LOADING','DELIVERING','DELIVERED'].includes(value.stage) || value.vehicle?.ownership !== 'DISPATCHED') throw new Error('外配排车来源或阶段不完整,请重新读取')
return { ...value, operations: operations(value.operations), unitPrice: decimal(value.unitPrice,2,18), recordVersion: outsourcedBusinessVersion(value.recordVersion), dispatchRecordVersion: outsourcedBusinessVersion(value.dispatchRecordVersion), actualPalletCount: value.actualPalletCount == null ? null : count(value.actualPalletCount), photoSummary: { loadingCount: count(value.photoSummary.loadingCount) } }
}
function detail(value: OutsourcedBusinessDetail): OutsourcedBusinessDetail { if (value.row.dispatchNo !== value.dispatch.dispatchNo) throw new Error('运营资料与原排车单不一致'); return { ...value, row: row(value.row) } }
function command(value: OutsourcedBusinessCommand): OutsourcedBusinessCommand { return { ...value, recordVersion: value.recordVersion == null ? null : outsourcedBusinessVersion(value.recordVersion) } }
export async function listOutsourcedBusiness(query: OutsourcedBusinessQuery): Promise<OutsourcedBusinessList> {
const value = await get<OutsourcedBusinessList>(base, query)
return { ...value, records: value.records.map(row), page: count(value.page), size: count(value.size), total: count(value.total), summary: { dispatchCount: count(value.summary.dispatchCount), afterLoadingCount: count(value.summary.afterLoadingCount), deliveringCount: count(value.summary.deliveringCount), deliveredCount: count(value.summary.deliveredCount) } }
}
export async function getOutsourcedBusiness(no: string) { const value = detail(await get<OutsourcedBusinessDetail>(path(no))); if (value.row.dispatchNo !== no) throw new Error('运营资料与当前排车业务号不一致'); return value }
export async function listOutsourcedBusinessHistory(no: string, page: number, size: number): Promise<OutsourcedBusinessPage<OutsourcedBusinessHistory>> {
const value = await get<OutsourcedBusinessPage<OutsourcedBusinessHistory>>(`${path(no)}/history`, { page, size })
return { ...value, records: value.records.map(record => ({ ...record, operations: record.operations ? operations(record.operations) : null, recordVersion: record.recordVersion == null ? null : outsourcedBusinessVersion(record.recordVersion), dispatchRecordVersion: record.dispatchRecordVersion == null ? null : outsourcedBusinessVersion(record.dispatchRecordVersion) })), page: count(value.page), size: count(value.size), total: count(value.total) }
}
export const getOutsourcedBusinessCommand = (no: string, key: string) => get<OutsourcedBusinessCommand>(`${path(no)}/commands/${encodeURIComponent(key)}`).then(command)
export async function saveOutsourcedBusinessOperations(no: string, body: OutsourcedBusinessSave) {
outsourcedBusinessVersion(body.recordVersion)
operations(body.operations)
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OutsourcedBusinessDetail>, ApiEnvelope<OutsourcedBusinessDetail>>(`${path(no)}/operations`, body)))
if (value.row.dispatchNo !== no) throw new Error('运营保存结果与原排车单不一致')
return value
}
export async function uploadOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, file: File, key: string) {
requireSlot(slot)
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) throw new Error('请选择有效的JPG或PNG图片')
const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key)
const value = unwrapApiResponse(await http.post<ApiEnvelope<OutsourcedBusinessPhoto>, ApiEnvelope<OutsourcedBusinessPhoto>>(`${path(no)}/photos/${slot}/uploads`, form))
if (value.slot !== slot || !value.fileId) throw new Error('上传文件与原照片分类不一致')
return value
}
export async function bindOutsourcedBusinessPhoto(no: string, slot: OutsourcedBusinessPhotoSlot, body: OutsourcedBusinessBind) {
requireSlot(slot)
outsourcedBusinessVersion(body.recordVersion)
const value = detail(unwrapApiResponse(await http.put<ApiEnvelope<OutsourcedBusinessDetail>, ApiEnvelope<OutsourcedBusinessDetail>>(`${path(no)}/photos/${slot}`, body)))
if (value.row.dispatchNo !== no) throw new Error('照片绑定结果与原排车单不一致')
return value
}
async function downloadError(payload: unknown, status: number) {
let value = payload
if (payload instanceof Blob && payload.type.includes('json')) { try { value = JSON.parse(await payload.text()) as unknown } catch { throw new Error('图片接口返回了无法识别的错误') } }
if (value && typeof value === 'object' && 'code' in value) { const error = value as ApiEnvelope<unknown>; throw new ApiResponseError(error.message || '图片读取失败', error.code, status, error.data, error.requestId, error.traceId) }
}
export async function getOutsourcedBusinessPhotoContent(no: string, photo: OutsourcedBusinessPhoto, revisionVersion?: string): Promise<Blob> {
requireSlot(photo.slot)
if (revisionVersion != null && BigInt(outsourcedBusinessVersion(revisionVersion)) < 1n) throw new Error('历史图片版本必须大于零')
try {
const blob = await http.get<Blob, Blob>(`${path(no)}/photos/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob', params: revisionVersion == null ? undefined : { revisionVersion: outsourcedBusinessVersion(revisionVersion) } })
await downloadError(blob, 200)
if (!(blob instanceof Blob) || !['image/jpeg','image/png'].includes(blob.type) || !blob.size) throw new Error('图片内容为空或格式无效')
return blob
} catch (cause) { if (axios.isAxiosError(cause)) await downloadError(cause.response?.data, cause.response?.status || 0); throw cause }
}
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/outsourcedBusiness'
import { outsourcedBusinessFixture, outsourcedBusinessCommandFixture } from './OutsourcedBusinessFixtures'
import { acceptOutsourcedBusinessCommand, newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, outsourcedBusinessPendingKey } from './OutsourcedBusinessCommandRecovery'
import type { OutsourcedBusinessPhoto, OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
beforeEach(() => sessionStorage.clear())
const no = 'DS-OUT-1'
function operation() : OutsourcedBusinessSave { return { recordVersion: '0', idempotencyKey: 'original-key', operations: { ...outsourcedBusinessFixture().row.operations, money: { currency: 'USD', amount: '9999999999999999.99' } }, reason: null } }
function client() {
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null }
return { ...api, getOutsourcedBusiness: vi.fn(async () => current), getOutsourcedBusinessCommand: vi.fn(async () => outsourcedBusinessCommandFixture()), saveOutsourcedBusinessOperations: vi.fn(async () => current), bindOutsourcedBusinessPhoto: vi.fn(async () => current), uploadOutsourcedBusinessPhoto: vi.fn(async () => photo) }
}
describe('outsourced business exact command recovery', () => {
it('persists the complete original payload, actor and zero auxiliary version', () => {
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation())
persistOutsourcedBusinessPending('actor', no, 'operations', pending)
expect(restoreOutsourcedBusinessPending('actor', no, 'operations')).toEqual(pending)
expect(restoreOutsourcedBusinessPending('another', no, 'operations')).toBeNull()
const raw = JSON.parse(sessionStorage.getItem(outsourcedBusinessPendingKey('actor',no,'operations'))!)
raw.actor = 'another'; sessionStorage.setItem(outsourcedBusinessPendingKey('actor',no,'operations'),JSON.stringify(raw))
expect(() => restoreOutsourcedBusinessPending('actor',no,'operations')).toThrow('损坏')
})
it('looks up an unknown write first and retries only the unchanged original body on explicit not-found', async () => {
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()); pending.attempted = true
const service = client(), persist = vi.fn()
service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
await recoverOutsourcedBusinessCommand(pending,persist,() => {},service)
expect(service.getOutsourcedBusinessCommand).toHaveBeenCalledWith(no,'original-key')
expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledExactlyOnceWith(no,operation())
expect(pending.body).toEqual(operation()); expect(pending.body?.recordVersion).toBe('0')
})
it('retains a proven success through failed fresh reads without writing it a second time', async () => {
const pending = newOutsourcedBusinessPending('actor', no, 'SAVE_OPERATIONS', operation()), service = client()
service.getOutsourcedBusiness.mockRejectedValueOnce(new Error('read offline'))
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('read offline')
expect(pending.confirmed?.recordVersion).toBe('1')
await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)
expect(service.saveOutsourcedBusinessOperations).toHaveBeenCalledTimes(1); expect(service.getOutsourcedBusinessCommand).not.toHaveBeenCalled()
})
it('never retransmits a file for an unknown upload, including command not-found', async () => {
const pending = newOutsourcedBusinessPending('actor',no,'UPLOAD_PHOTO',null,'LOADING_SCENE'); pending.attempted = true
const service = client(); service.getOutsourcedBusinessCommand.mockRejectedValue(new ApiResponseError('not found','A0404',404))
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('禁止重复上传')
expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled(); expect(pending.key).toBeTruthy()
})
it('keeps the prepared file and exact binding key through unknown binding and later replacement', async () => {
const photo: OutsourcedBusinessPhoto = { slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:'2026-10-20T10:01:00Z' }
const pending = newOutsourcedBusinessPending('actor',no,'BIND_PHOTO',{recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId},photo.slot)
pending.attempted = true
const service = client(); const bound = outsourcedBusinessFixture(); bound.row.recordVersion='1'; bound.photos=[photo]
const original = outsourcedBusinessCommandFixture({action:'BIND_PHOTO',idempotencyKey:'bind-key',result:bound})
service.getOutsourcedBusinessCommand.mockResolvedValue(original)
const current = outsourcedBusinessFixture();current.row.recordVersion='2';current.photos=[{...photo,fileId:'9007199254740994'}]
service.getOutsourcedBusiness.mockResolvedValue(current)
const result = await recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)
expect(result.detail?.photos[0]?.fileId).toBe('9007199254740994')
expect(pending.body).toEqual({recordVersion:'0',idempotencyKey:'bind-key',fileId:photo.fileId})
expect(service.uploadOutsourcedBusinessPhoto).not.toHaveBeenCalled();expect(service.bindOutsourcedBusinessPhoto).not.toHaveBeenCalled()
})
it('refuses wrong command identities, stale reads, definite rejection retries and scope changes', async () => {
const pending = newOutsourcedBusinessPending('actor',no,'SAVE_OPERATIONS',operation()), service=client()
expect(() => acceptOutsourcedBusinessCommand(pending,outsourcedBusinessCommandFixture({dispatchNo:'another'}))).toThrow('不一致')
pending.command=outsourcedBusinessCommandFixture({recordVersion:'9007199254740993'})
await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('早于')
pending.rejected=true;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {},service)).rejects.toThrow('明确拒绝')
pending.rejected=false;await expect(recoverOutsourcedBusinessCommand(pending,() => {},() => {throw new Error('scope changed')},service)).rejects.toThrow('scope changed')
expect(service.saveOutsourcedBusinessOperations).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,89 @@
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/outsourcedBusiness'
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessAction, type OutsourcedBusinessBind, type OutsourcedBusinessCommand, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
export interface OutsourcedBusinessPending {
actor: string; dispatchNo: string; action: OutsourcedBusinessAction; key: string; slot?: OutsourcedBusinessPhotoSlot
body: OutsourcedBusinessSave | OutsourcedBusinessBind | null; attempted: boolean; command: OutsourcedBusinessCommand | null; confirmed: { recordVersion: string | null; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto } | null; rejected: boolean
}
export const outsourcedBusinessPendingKey = (actor: string, no: string, area: string) => `outsourced-business:${encodeURIComponent(actor)}:${encodeURIComponent(no)}:${area}`
export function restoreOutsourcedBusinessPending(actor: string, no: string, area: string): OutsourcedBusinessPending | null {
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, no, area))
if (!raw) return null
const pending = JSON.parse(raw) as OutsourcedBusinessPending
if (pending.actor !== actor || pending.dispatchNo !== no || !pending.key || !['SAVE_OPERATIONS','UPLOAD_PHOTO','BIND_PHOTO'].includes(pending.action)) throw new Error('原运营请求记录损坏,请保留现场核对')
if (pending.action !== 'SAVE_OPERATIONS' && !outsourcedBusinessPhotoRoles.some(role => role.slot === pending.slot) || pending.action !== 'UPLOAD_PHOTO' && (!pending.body || pending.body.idempotencyKey !== pending.key)) throw new Error('原运营请求内容不完整,请保留现场核对')
return pending
}
export function persistOutsourcedBusinessPending(actor: string, no: string, area: string, pending: OutsourcedBusinessPending | null) {
if (!actor || !no || pending && (pending.actor !== actor || pending.dispatchNo !== no)) throw new Error('运营请求身份已变化')
const key = outsourcedBusinessPendingKey(actor, no, area)
if (pending) sessionStorage.setItem(key, JSON.stringify(pending)); else sessionStorage.removeItem(key)
}
export function newOutsourcedBusinessPending(actor: string, no: string, action: OutsourcedBusinessAction, body: OutsourcedBusinessPending['body'], slot?: OutsourcedBusinessPhotoSlot): OutsourcedBusinessPending {
if (!actor || !no) throw new Error('当前运营作业身份缺失')
const key = body?.idempotencyKey || crypto.randomUUID()
return { actor, dispatchNo: no, action, key, slot, body: body ? structuredClone(body) : null, attempted: false, command: null, confirmed: null, rejected: false }
}
export function acceptOutsourcedBusinessResult(pending: OutsourcedBusinessPending, result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto) {
if (pending.action === 'UPLOAD_PHOTO') {
if (!('fileId' in result) || result.slot !== pending.slot || result.category !== 'LOADING' || typeof result.fileId !== 'string' || !/^[1-9]\d*$/.test(result.fileId) || !['image/jpeg','image/png'].includes(result.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
pending.confirmed = { recordVersion: null, result }
} else {
if (!('row' in result) || result.row.dispatchNo !== pending.dispatchNo) throw new Error('运营保存结果与原排车单不一致')
pending.confirmed = { recordVersion: outsourcedBusinessVersion(result.row.recordVersion), result }
}
}
export function acceptOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, command: OutsourcedBusinessCommand) {
if (command.dispatchNo !== pending.dispatchNo || command.action !== pending.action || command.idempotencyKey !== pending.key) throw new Error('返回结果与原运营请求不一致,请保留原请求核对')
pending.command = command
if (command.state === 'REJECTED') pending.rejected = true
if (command.state !== 'SUCCEEDED') throw new Error(command.state === 'REJECTED' ? '原请求已明确拒绝,请核对原因后重新登记' : '原请求结果尚未确定,请保留原请求继续核对')
if (!command.result) throw new Error('原运营命令的成功结果不完整,请继续核对')
acceptOutsourcedBusinessResult(pending, command.result)
if (pending.action !== 'UPLOAD_PHOTO' && (command.recordVersion == null || command.recordVersion !== pending.confirmed!.recordVersion)) throw new Error('原运营命令版本与结果不一致')
}
export async function recoverOutsourcedBusinessCommand(pending: OutsourcedBusinessPending, persist: () => void, ensureScope: () => void, client = api): Promise<{ detail?: OutsourcedBusinessDetail; photo?: OutsourcedBusinessPhoto }> {
ensureScope()
if (pending.rejected) throw new Error('原请求已明确拒绝,请先按明确拒绝结果重新核对')
if (pending.command?.state === 'SUCCEEDED') acceptOutsourcedBusinessCommand(pending, pending.command)
const previous = pending.attempted
if (!pending.confirmed) {
let original: OutsourcedBusinessCommand | null = null
if (previous) {
try { original = await client.getOutsourcedBusinessCommand(pending.dispatchNo, pending.key) }
catch (cause) { if (!(cause instanceof ApiResponseError) || !['404','A0404','HTTP_404'].includes(cause.code)) throw cause }
ensureScope()
}
if (original) { try { acceptOutsourcedBusinessCommand(pending, original) } finally { persist() } }
else {
if (pending.action === 'UPLOAD_PHOTO') throw new Error('图片上传结果未知,禁止重复上传;请继续核对原上传请求')
pending.attempted = true; persist()
try {
const result = pending.action === 'SAVE_OPERATIONS'
? await client.saveOutsourcedBusinessOperations(pending.dispatchNo, pending.body as OutsourcedBusinessSave)
: await client.bindOutsourcedBusinessPhoto(pending.dispatchNo, pending.slot!, pending.body as OutsourcedBusinessBind)
ensureScope(); acceptOutsourcedBusinessResult(pending, result); persist()
} catch (cause) {
ensureScope()
if (!previous && cause instanceof ApiResponseError && ['A0400','A0403','A0404','A0409','400','403','404','409','422'].includes(cause.code)) { pending.rejected = true; persist() }
throw cause
}
}
}
const confirmed = pending.confirmed!
if (pending.action === 'UPLOAD_PHOTO') {
const photo = confirmed.result as OutsourcedBusinessPhoto | null
if (!photo || photo.slot !== pending.slot || photo.category !== 'LOADING' || typeof photo.fileId !== 'string' || !/^[1-9]\d*$/.test(photo.fileId) || !['image/jpeg','image/png'].includes(photo.contentType)) throw new Error('原上传尚未返回本分类的真实文件')
return { photo }
}
const current = await client.getOutsourcedBusiness(pending.dispatchNo); ensureScope()
if (current.row.dispatchNo !== pending.dispatchNo || confirmed.recordVersion == null || BigInt(outsourcedBusinessVersion(current.row.recordVersion)) < BigInt(outsourcedBusinessVersion(confirmed.recordVersion))) throw new Error('当前运营版本早于原操作结果,请继续核对')
if (pending.action === 'BIND_PHOTO') {
const matches = (photo: OutsourcedBusinessPhoto) => photo.slot === pending.slot && photo.fileId === (pending.body as OutsourcedBusinessBind).fileId && Boolean(photo.savedAt)
const original = confirmed.result
const originalPhotos = original && 'row' in original ? original.photos : original && 'fileId' in original ? [original] : []
if (!current.photos.some(matches) && !originalPhotos.some(matches)) throw new Error('图片绑定结果尚未确认,请保留原文件和请求')
}
return { detail: current }
}
@@ -0,0 +1,36 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import AdminPage from '@/shared/components/AdminPage.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
defineProps<{ dispatchNo?: string; statusText?: string; loading?: boolean }>()
const activeTab = defineModel<'task' | 'route' | 'history'>('activeTab', { default: 'task' })
const emit = defineEmits<{ back: [] }>()
</script>
<template>
<AdminPage class="admin-density" spacing="compact">
<DetailPageLayout aria-label="外配业务详情">
<template #header>
<div class="outsourced-business-detail-heading">
<div><strong>外配业务</strong><span class="outsourced-business-reference">{{ dispatchNo || '—' }}</span><span v-if="statusText">{{ statusText }}</span></div>
<div class="density-actions"><slot name="actions" /><el-button class="density-action" @click="emit('back')">返回外配业务</el-button></div>
</div>
</template>
<slot name="state" />
<div v-loading="loading" :aria-busy="loading">
<el-tabs v-model="activeTab" class="density-element-tabs density-contained-tabs">
<el-tab-pane name="task" label="任务与车辆信息"><template #label><span class="density-tab-icon" aria-hidden="true">🚚</span>任务与车辆信息</template><slot v-if="activeTab === 'task'" name="task" /></el-tab-pane>
<el-tab-pane name="route" label="路线货物与凭证"><template #label><span class="density-tab-icon" aria-hidden="true">📦</span>路线货物与凭证</template><slot v-if="activeTab === 'route'" name="route" /></el-tab-pane>
<el-tab-pane name="history" label="操作记录"><template #label><span class="density-tab-icon" aria-hidden="true">📋</span>操作记录</template><slot v-if="activeTab === 'history'" name="history" /></el-tab-pane>
</el-tabs>
</div>
</DetailPageLayout>
</AdminPage>
</template>
<style scoped>
.outsourced-business-detail-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-4); }
.outsourced-business-detail-heading > div { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: var(--admin-space-4); }
.outsourced-business-reference { overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,20 @@
import type { OutsourcedBusinessCommand, OutsourcedBusinessDetail } from '../outsourcedBusinessTypes'
export function outsourcedBusinessFixture(): OutsourcedBusinessDetail {
const review = { status: 'APPROVED' as const, reason: null, reviewerId: null, reviewedAt: '2026-10-20T10:00:00Z', reviewRound: '1', basisHash: null, invalidatedReason: null }
return {
row: {
dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', plannedDepartureAt: '2026-10-20T10:00:00Z', loadingCompletedAt: '2026-10-20T09:00:00Z', departedAt: null, deliveryCompletedAt: null,
origin: { warehouseId: '1', warehouseCode: 'WH-1', warehouseName: '起运仓' }, vehicle: { vehicleId: '2', plate: 'OUT-1', ownership: 'DISPATCHED', carrierName: '测试外配承运商' }, driver: { name: null, phone: null },
destinations: [{ destinationKey: 'destination-1', warehouseCode: 'FBA-1', warehouseName: '目的仓', address: null, appointments: [{ appointmentNo: 'AP-1', confirmationNo: null, scheduledAt: null, allocatedPackages: '2' }], actualArrivedAt: null }],
actualPalletCount: 1, operations: { money: null, arrivals: [{ destinationKey: 'destination-1', actualArrivedAt: null }] }, unitPrice: null, recordVersion: '0', dispatchRecordVersion: '8',
photoSummary: { loadingCount: 0 }, actions: { edit: { allowed: true, blockReason: null }, upload: { allowed: true, blockReason: null } }, sourceIssues: [],
},
dispatch: { dispatchNo: 'DS-OUT-1', stage: 'AFTER_LOADING', status: 'ACTIVE', recordVersion: '8', reviewState: 'APPROVED', reviews: { dispatchReview: { ...review }, loadingReview: { ...review } }, counts: { itemCount: 2, palletCount: 1, packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 0 }, pickingReconciled: true, actions: {}, vehicleInfo: { vehicle: null, driver: null }, dispatchInfo: { route: null, plannedDepartureAt: '2026-10-20T10:00:00Z', departedAt: null, estimatedArrivalAt: null, fbaAppointments: [], appointmentBatches: [] }, documents: { dispatchNo: 'DS-OUT-1', outbounds: [], deliveries: [], createdAt: '2026-10-20T08:00:00Z', updatedAt: '2026-10-20T09:00:00Z', remark: null }, orderDetails: [] },
cargo: ['A','B'].map(customer => ({ sourceKey: `source-${customer}`, customerId: customer, customerName: `客户${customer}`, orderNo: `ORDER-${customer}`, inboundCode: `IN-${customer}`, palletNo: 'ONE-PHYSICAL-PALLET', destinationKey: 'destination-1', packages: '1' })), photos: [],
}
}
export function outsourcedBusinessCommandFixture(overrides: Partial<OutsourcedBusinessCommand> = {}): OutsourcedBusinessCommand {
const detail = outsourcedBusinessFixture(); detail.row.recordVersion = overrides.recordVersion ?? '1'
return { dispatchNo: 'DS-OUT-1', action: 'SAVE_OPERATIONS', idempotencyKey: 'original-key', state: 'SUCCEEDED', recordVersion: '1', replayed: false, result: detail, errorCode: null, retryAllowed: false, ...overrides }
}
@@ -0,0 +1,48 @@
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import OutsourcedBusinessDetailLayout from './OutsourcedBusinessDetailLayout.vue'
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
const Pane = defineComponent({ props: ['name', 'label'], template: '<article><slot /></article>' })
const Dialog = defineComponent({ props: ['width', 'showConfirm', 'beforeClose'], template: '<div><slot /></div>' })
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, AppDialog: Dialog, ElTabs: Tabs, ElTabPane: Pane, ElButton: Surface }, directives: { loading: () => {} } }
describe('OutsourcedBusiness presentation boundaries', () => {
it('mounts only the selected one of the three independent detail regions', async () => {
const wrapper = mount(OutsourcedBusinessDetailLayout, { global, props: { dispatchNo: 'DS-OUT-1' }, slots: { task: '<p>task-facts</p>', route: '<p>route-facts</p>', history: '<p>history-facts</p>' } })
expect(wrapper.findAllComponents(Pane).map(pane => pane.props('label'))).toEqual(['任务与车辆信息', '路线货物与凭证', '操作记录'])
expect(wrapper.text()).toContain('task-facts'); expect(wrapper.text()).not.toContain('history-facts')
wrapper.findComponent(Tabs).vm.$emit('update:modelValue', 'history'); await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('history-facts'); expect(wrapper.text()).not.toContain('task-facts')
})
it('preserves only five approved photo roles without demo images or uploaded claims', () => {
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true } })
expect(wrapper.findComponent(Dialog).props('width')).toBe('760px')
expect(wrapper.findComponent(Dialog).props('showConfirm')).toBe(false)
expect(wrapper.findAll('section[aria-label]').map(section => section.attributes('aria-label'))).toEqual(['装车现场', '装车清单', '出车前检查', '驾驶证附件', '签收凭证'])
expect(wrapper.findAll('img')).toHaveLength(0); expect(wrapper.text()).not.toContain('已上传')
})
it('blocks a close while busy and delegates unsaved-result decisions to the owner', async () => {
const guard = vi.fn(), done = vi.fn()
const wrapper = mount(OutsourcedBusinessPhotosDialog, { global, props: { modelValue: true, busy: true, beforeClose: guard } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(guard).not.toHaveBeenCalled(); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ busy: false }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
it('keeps an unknown operations request until its owner explicitly handles closing', async () => {
const done = vi.fn(), guard = vi.fn()
const wrapper = mount(OutsourcedBusinessOperationsDialog, { global, props: { modelValue: true, pending: true } })
const close = wrapper.findComponent(Dialog).props('beforeClose') as (done: () => void) => void
close(done); expect(done).not.toHaveBeenCalled()
await wrapper.setProps({ beforeClose: guard }); close(done)
expect(guard).toHaveBeenCalledWith(done); expect(done).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,16 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
</script>
<template>
<AdminPage class="admin-density" spacing="compact">
<slot name="statistics" />
<ListPageLayout aria-label="外配业务任务列表">
<template v-if="$slots.filters" #toolbar><slot name="filters" /></template>
<template v-if="$slots.state" #state><slot name="state" /></template>
<slot />
</ListPageLayout>
</AdminPage>
</template>
@@ -0,0 +1,34 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
const props = defineProps<{
dispatchNo?: string
busy?: boolean
saveDisabled?: boolean
pending?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{ save: [] }>()
function close(done: () => void) {
if (props.busy) return
if (props.beforeClose) return props.beforeClose(done)
if (!props.pending) done()
}
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="登记运营信息" width="1080px" :confirm-loading="busy" :confirm-disabled="saveDisabled" :confirm-text="pending ? '核对并重试原请求' : '保存登记'" :before-close="close" :close-on-press-escape="!busy" @confirm="emit('save')">
<p class="outsourced-business-operation-reference">排车单号 {{ dispatchNo || '—' }}</p>
<slot name="state" />
<fieldset class="outsourced-business-operation-section"><legend>整车承运金额</legend><slot name="amount" /><p>整车只登记一次;单价采用系统返回的承运金额与实际物理托数计算结果。</p></fieldset>
<fieldset class="outsourced-business-operation-section"><legend>逐目的地实际到达时间</legend><slot name="arrivals" /></fieldset>
<p>登记运营信息不推进排车或派送状态;照片在独立上传照片窗口中处理。</p>
</AppDialog>
</template>
<style scoped>
.outsourced-business-operation-reference { overflow-wrap: anywhere; }
.outsourced-business-operation-section { min-width: 0; margin: 0 0 var(--admin-space-4); padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
.outsourced-business-operation-section legend { padding: 0 var(--admin-space-2); font-weight: 600; }
</style>
@@ -0,0 +1,59 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import OutsourcedBusinessOperationsEditor from './OutsourcedBusinessOperationsEditor.vue'
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
const calls = vi.hoisted(() => ({ save: vi.fn(), detail: vi.fn(), command: vi.fn(), canEdit: true }))
vi.mock('../api/outsourcedBusiness', () => ({ saveOutsourcedBusinessOperations: calls.save, getOutsourcedBusiness: calls.detail, getOutsourcedBusinessCommand: calls.command }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'actor' } }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => calls.canEdit }) }))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration', () => ({ useUnsavedWorkRegistration: () => ({ captureBaseline: vi.fn() }) }))
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
const Frame = defineComponent({ props: ['modelValue','busy','saveDisabled','pending'], emits: ['save','update:modelValue'], setup(props,{ slots,emit }) { return () => h('div', [...Object.values(slots).map(slot => slot?.()), h('button',{ 'data-save':'',disabled: props.busy || props.saveDisabled,onClick: () => emit('save') },'save')]) } })
const Control = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' })
const Field = defineComponent({ props: ['label'], template: '<label>{{label}}<slot /></label>' })
const global = { stubs: { OutsourcedBusinessOperationsDialog: Frame, ElForm: { template:'<div><slot /></div>' }, ElFormItem: Field, ElInput: Control, ElSelect: Control, ElOption: true, ElDatePicker: Control, LoadErrorState: { props:['message'],template:'<div>{{message}}</div>' }, ApiErrorMeta: true } }
beforeEach(() => {
vi.clearAllMocks(); sessionStorage.clear(); calls.canEdit = true
const current = outsourcedBusinessFixture(); current.row.recordVersion = '1'
calls.save.mockResolvedValue(current); calls.detail.mockResolvedValue(current)
calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
})
describe('outsourced operations registration', () => {
it('sends explicit zero and destination arrival with auxiliary zero, without fuel or mileage fields', async () => {
const wrapper = mount(OutsourcedBusinessOperationsEditor,{ global,props:{modelValue:true,detail:outsourcedBusinessFixture()} }); await flushPromises()
const currency = wrapper.findAll('label').find(field => field.text() === '币种')!.find('input')
const amount = wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input')
await currency.setValue('USD'); await amount.setValue('0.00')
await wrapper.findAll('input').at(-1)!.setValue('2026-10-20T12:00:00+08:00')
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
const body = calls.save.mock.calls[0]![1]
expect(body).toEqual({ recordVersion:'0',idempotencyKey:expect.any(String),operations:{money:{currency:'USD',amount:'0.00'},arrivals:[{destinationKey:'destination-1',actualArrivedAt:'2026-10-20T12:00:00+08:00'}]},reason:null })
expect(calls.save).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1)
expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('里程'); wrapper.unmount()
})
it('clears money as null and retains exactly the original request after an unknown save', async () => {
const detail = outsourcedBusinessFixture(); detail.row.operations.money = {currency:'CNY',amount:'123.45'}
calls.save.mockRejectedValueOnce(new TypeError('network interrupted'))
const wrapper = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail}}); await flushPromises()
await wrapper.findAll('label').find(field => field.text() === '币种')!.find('input').setValue('')
await wrapper.findAll('label').find(field => field.text() === '整车承运金额')!.find('input').setValue('')
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
const original = JSON.parse(JSON.stringify(calls.save.mock.calls[0]![1]))
expect(original.operations.money).toBeNull(); expect(wrapper.emitted('saved')).toBeUndefined()
await wrapper.find('[data-save]').trigger('click'); await flushPromises()
expect(calls.command).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',original.idempotencyKey)
expect(calls.save).toHaveBeenNthCalledWith(2,'DS-OUT-1',original); expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('does not write for an unchanged form or a user without edit permission', async () => {
const first = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
await first.find('[data-save]').trigger('click'); await flushPromises(); expect(calls.save).not.toHaveBeenCalled(); first.unmount()
calls.canEdit = false
const readonly = mount(OutsourcedBusinessOperationsEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}}); await flushPromises()
expect(readonly.find('[data-save]').attributes('disabled')).toBeDefined(); expect(calls.save).not.toHaveBeenCalled(); readonly.unmount()
})
})
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OutsourcedBusinessOperationsDialog from './OutsourcedBusinessOperationsDialog.vue'
import { newOutsourcedBusinessPending, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessMoney, type OutsourcedBusinessSave } from '../outsourcedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { getOutsourcedBusiness } from '../api/outsourcedBusiness'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), pending = ref<OutsourcedBusinessPending | null>(null), error = ref<ApiErrorDetails | null>(null), corrupted = ref(false)
const form = reactive({ currency: '', amount: '', reason: '', arrivals: {} as Record<string,string> })
const active = computed(() => open.value || Boolean(pending.value))
const registration = useUnsavedWorkRegistration({ key: `outsourced-business-operations:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务运营登记'), isOpen: active, model: reactive({ form, pending }), requiredPermission: computed(() => permission.outsourcedBusinessEdit) })
let actor = '', number = '', baseline = ''
const writable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessEdit) && props.detail?.row.actions.edit.allowed === true)
const locked = computed(() => busy.value || Boolean(pending.value))
function persist() { persistOutsourcedBusinessPending(actor, number, 'operations', pending.value) }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('操作身份或排车单已变化,请重新打开') }
watch(busy, value => emit('busy', value))
watch(open, async value => {
if (!value || !props.detail) return
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo; error.value = null; corrupted.value = false
try {
pending.value = restoreOutsourcedBusinessPending(actor, number, 'operations')
const operations = pending.value?.action === 'SAVE_OPERATIONS' ? (pending.value.body as OutsourcedBusinessSave).operations : props.detail.row.operations
Object.assign(form, { currency: operations.money?.currency || '', amount: operations.money?.amount ?? '', reason: pending.value ? (pending.value.body as OutsourcedBusinessSave).reason || '' : '', arrivals: Object.fromEntries(props.detail.row.destinations.map(destination => [destination.destinationKey, operations.arrivals.find(arrival => arrival.destinationKey === destination.destinationKey)?.actualArrivedAt || ''])) })
baseline = JSON.stringify(form); await nextTick(); if (!pending.value) registration.captureBaseline()
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '运营资料恢复失败') }
}, { immediate: true })
function payload(): OutsourcedBusinessSave {
const amount = outsourcedBusinessDecimal(form.amount, 2, 18)
let money: OutsourcedBusinessMoney | null = null
if (amount != null) { if (!['CNY','USD'].includes(form.currency)) throw new Error('请选择金额币种'); money = { currency: form.currency as OutsourcedBusinessMoney['currency'], amount } }
else if (form.currency) throw new Error('金额和币种请成对填写,清空时同时清空两项')
const arrivals = props.detail!.row.destinations.map(destination => {
const value = form.arrivals[destination.destinationKey] || null
if (value && (!/(?:Z|[+-]\d{2}:\d{2})$/.test(value) || !Number.isFinite(Date.parse(value)))) throw new Error('实际到达时间必须包含有效时区')
return { destinationKey: destination.destinationKey, actualArrivedAt: value }
})
return { recordVersion: outsourcedBusinessVersion(props.detail!.row.recordVersion), idempotencyKey: crypto.randomUUID(), operations: { money, arrivals }, reason: form.reason.trim() || null }
}
async function save() {
if (busy.value || !writable.value || !props.detail) return
busy.value = true; error.value = null
try {
ensureScope()
if (!pending.value) { if (JSON.stringify(form) === baseline) { open.value = false; return }; pending.value = newOutsourcedBusinessPending(actor, number, 'SAVE_OPERATIONS', payload()); persist() }
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.detail) throw new Error('运营登记结果未确认')
pending.value = null; persist(); registration.captureBaseline(); emit('saved', result.detail); open.value = false; ElMessage.success('运营信息已保存')
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '运营登记结果尚未确认') }
finally { busy.value = false }
}
async function beforeClose(done: () => void) {
if (busy.value) return
if (pending.value || JSON.stringify(form) !== baseline) {
try { await ElMessageBox.confirm(pending.value ? '原请求将保留,重新打开后可核对原结果。是否关闭?' : '放弃尚未保存的运营信息?', '关闭运营登记', { customClass: 'admin-density-confirm', confirmButtonText: pending.value ? '保留请求并关闭' : '放弃修改', cancelButtonText: '继续编辑' }) } catch { return }
}
done()
}
async function discardRejected() {
if (!pending.value?.rejected || busy.value) return
busy.value = true
try { const current = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', current); error.value = null }
catch (cause) { error.value = getApiErrorDetails(cause, '当前运营版本核对失败') }
finally { busy.value = false }
}
</script>
<template>
<OutsourcedBusinessOperationsDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :pending="Boolean(pending)" :save-disabled="!writable" :before-close="beforeClose" @save="save">
<template #state><LoadErrorState v-if="error" title="运营登记未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原请求已保留,字段锁定;核对结果前不会建立新请求。</p><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝结果重新编辑</el-button><p v-if="!writable">{{ detail?.row.actions.edit.blockReason || '没有运营登记权限' }}</p></template>
<template #amount><el-form label-position="top" class="outsourced-business-form"><el-form-item label="币种"><el-select v-model="form.currency" clearable :disabled="locked"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select></el-form-item><el-form-item label="整车承运金额"><el-input v-model="form.amount" :disabled="locked" placeholder="未登记;允许明确填写0" /></el-form-item><el-form-item label="更正说明"><el-input v-model="form.reason" :disabled="locked" maxlength="1000" /></el-form-item></el-form></template>
<template #arrivals><div v-for="destination in detail?.row.destinations || []" :key="destination.destinationKey" class="outsourced-business-arrival"><span>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}<small v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}</small></span><el-date-picker v-model="form.arrivals[destination.destinationKey]" type="datetime" value-format="YYYY-MM-DDTHH:mm:ssZ" :disabled="locked" placeholder="实际到达时间,未登记留空" /></div><p>时间按浏览器本地时区输入,提交时保留时区。</p></template>
</OutsourcedBusinessOperationsDialog>
</template>
<style scoped>
.outsourced-business-form { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 0 var(--admin-space-4); }
.outsourced-business-arrival { display: grid; grid-template-columns: minmax(0,1fr) 280px; align-items: center; gap: var(--admin-space-4); margin-bottom: var(--admin-space-3); }
.outsourced-business-arrival small { display: block; }
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import AppDialog from '@/shared/components/AppDialog.vue'
import { outsourcedBusinessPhotoRoles } from '../outsourcedBusinessTypes'
const props = defineProps<{
dispatchNo?: string
busy?: boolean
beforeClose?: (done: () => void) => void | Promise<void>
}>()
const open = defineModel<boolean>({ default: false })
function close(done: () => void) {
if (props.busy) return
if (props.beforeClose) return props.beforeClose(done)
done()
}
</script>
<template>
<AppDialog v-model="open" class="admin-density-dialog admin-density" title="上传照片" width="760px" :show-confirm="false" cancel-text="关闭" :confirm-loading="busy" :close-on-press-escape="!busy" :before-close="close">
<p class="outsourced-business-photo-reference">排车单号 {{ dispatchNo || '—' }}</p>
<slot name="state" />
<p>每个附件方框单独处理,支持 JPG、PNG;上传照片不改变装车状态。</p>
<div class="outsourced-business-photo-grid">
<section v-for="photo in outsourcedBusinessPhotoRoles" :key="photo.slot" class="outsourced-business-photo-slot" :aria-label="photo.label">
<h3>{{ photo.label }}</h3><slot :name="photo.viewSlot"><p>暂无照片</p></slot>
</section>
</div>
</AppDialog>
</template>
<style scoped>
.outsourced-business-photo-reference { overflow-wrap: anywhere; }
.outsourced-business-photo-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-4); }
.outsourced-business-photo-slot { min-width: 0; padding: var(--admin-space-4); border: 1px solid var(--admin-table-border); }
.outsourced-business-photo-slot h3 { margin: 0 0 var(--admin-space-3); font-size: inherit; }
</style>
@@ -0,0 +1,64 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiResponseError } from '@/shared/api/http'
import OutsourcedBusinessPhotosEditor from './OutsourcedBusinessPhotosEditor.vue'
import { outsourcedBusinessFixture } from './OutsourcedBusinessFixtures'
import type { OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes'
const calls=vi.hoisted(()=>({upload:vi.fn(),bind:vi.fn(),command:vi.fn(),detail:vi.fn(),content:vi.fn(),actor:{user:{id:'actor'}},canUpload:true}))
vi.mock('../api/outsourcedBusiness',()=>({uploadOutsourcedBusinessPhoto:calls.upload,bindOutsourcedBusinessPhoto:calls.bind,getOutsourcedBusinessCommand:calls.command,getOutsourcedBusiness:calls.detail,getOutsourcedBusinessPhotoContent:calls.content}))
vi.mock('@/core/auth/auth',()=>({useAuthStore:()=>calls.actor}))
vi.mock('@/core/access/accessPreview',()=>({useAccessPreviewStore:()=>({can:()=>calls.canUpload})}))
vi.mock('@/core/unsaved-work/useUnsavedWorkRegistration',()=>({useUnsavedWorkRegistration:()=>({captureBaseline:vi.fn()})}))
vi.mock('element-plus',()=>({ElMessage:{success:vi.fn()},ElMessageBox:{confirm:vi.fn()}}))
const Frame=defineComponent({props:['modelValue','busy','beforeClose'],setup(_props,{slots}){return()=>h('div',Object.values(slots).map(slot=>slot?.()))}})
const Button=defineComponent({props:['disabled'],template:'<button :disabled="disabled"><slot /></button>'})
const global={stubs:{OutsourcedBusinessPhotosDialog:Frame,ElButton:Button,LoadErrorState:{props:['message'],template:'<div>{{message}}<slot name="details" /></div>'},ApiErrorMeta:true}}
const photo:OutsourcedBusinessPhoto={slot:'LOADING_SCENE',category:'LOADING',fileId:'9007199254740993',fileName:'loading.png',contentType:'image/png',sizeBytes:'5',uploadedAt:'2026-10-20T10:00:00Z',savedAt:null}
async function choose(wrapper:ReturnType<typeof mount>){const input=wrapper.find('input[type="file"]');Object.defineProperty(input.element,'files',{configurable:true,value:[new File(['image'],'loading.png',{type:'image/png'})]});await input.trigger('change');await flushPromises()}
beforeEach(()=>{
vi.clearAllMocks();sessionStorage.clear();calls.actor.user.id='actor';calls.canUpload=true
calls.upload.mockResolvedValue(photo);calls.command.mockRejectedValue(new ApiResponseError('not found','A0404',404))
const current=outsourcedBusinessFixture();current.row.recordVersion='1';current.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
calls.bind.mockResolvedValue(current);calls.detail.mockResolvedValue(current)
})
afterEach(() => vi.unstubAllGlobals())
describe('outsourced business per-slot photo save',()=>{
it('retains prepared file and original binding request through a failed bind without retransmitting the image',async()=>{
calls.bind.mockRejectedValueOnce(new TypeError('network interrupted'))
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('尚未确认绑定保存');expect(wrapper.emitted('saved')).toBeUndefined()
const original={...calls.bind.mock.calls[0]![2]}
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).toHaveBeenCalledTimes(2)
expect(calls.bind.mock.calls[1]![2]).toEqual(original);expect(original.fileId).toBe(photo.fileId);expect(original.recordVersion).toBe('0')
expect(wrapper.emitted('saved')).toHaveLength(1);wrapper.unmount()
})
it('does not offer a new upload after unknown upload or fabricate a saved photo',async()=>{
calls.upload.mockRejectedValue(new TypeError('network interrupted'))
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail:outsourcedBusinessFixture()}});await choose(wrapper)
await wrapper.findAll('button').find(button=>button.text()==='核对原结果并继续绑定')!.trigger('click');await flushPromises()
expect(calls.upload).toHaveBeenCalledTimes(1);expect(calls.bind).not.toHaveBeenCalled();expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.find('input[type="file"]').attributes('disabled')).toBeDefined();expect(wrapper.text()).toContain('禁止重复上传');wrapper.unmount()
})
it('keeps saved metadata readable without upload permission and fetches image bytes only on explicit preview',async()=>{
calls.canUpload=false
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
expect(wrapper.find('input[type="file"]').exists()).toBe(false);expect(wrapper.text()).toContain('已保存');expect(calls.content).not.toHaveBeenCalled()
wrapper.unmount()
})
it('reads a saved loading photo only on click and releases its URL on closing',async()=>{
const create=vi.fn(()=> 'blob:outsourced-loading'),revoke=vi.fn()
vi.stubGlobal('URL',class extends URL { static createObjectURL=create; static revokeObjectURL=revoke })
calls.canUpload=false;calls.content.mockResolvedValue(new Blob(['image'],{type:'image/png'}))
const detail=outsourcedBusinessFixture();detail.photos=[{...photo,savedAt:'2026-10-20T10:01:00Z'}]
const wrapper=mount(OutsourcedBusinessPhotosEditor,{global,props:{modelValue:true,detail}});await flushPromises()
expect(calls.content).not.toHaveBeenCalled()
await wrapper.findAll('button').find(button=>button.text()==='查看图片')!.trigger('click');await flushPromises()
expect(calls.content).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',detail.photos[0]);expect(wrapper.find('img').attributes('src')).toBe('blob:outsourced-loading')
await wrapper.setProps({modelValue:false});expect(revoke).toHaveBeenCalledWith('blob:outsourced-loading');wrapper.unmount()
})
})
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed, getCurrentInstance, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/core/auth/auth'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import OutsourcedBusinessPhotosDialog from './OutsourcedBusinessPhotosDialog.vue'
import { outsourcedBusinessPhotoRoles, outsourcedBusinessVersion, type OutsourcedBusinessDetail, type OutsourcedBusinessPhoto, type OutsourcedBusinessPhotoSlot } from '../outsourcedBusinessTypes'
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, uploadOutsourcedBusinessPhoto } from '../api/outsourcedBusiness'
import { overseasMasterDataPermissions as permission } from '../permissions'
import { acceptOutsourcedBusinessResult, newOutsourcedBusinessPending, outsourcedBusinessPendingKey, persistOutsourcedBusinessPending, restoreOutsourcedBusinessPending, recoverOutsourcedBusinessCommand, type OutsourcedBusinessPending } from './OutsourcedBusinessCommandRecovery'
const open = defineModel<boolean>({ default: false })
const props = defineProps<{ detail: OutsourcedBusinessDetail | null }>()
const emit = defineEmits<{ saved: [value: OutsourcedBusinessDetail]; busy: [value: boolean] }>()
const auth = useAuthStore(), access = useAccessPreviewStore(), busy = ref(false), error = ref<ApiErrorDetails | null>(null), pending = ref<OutsourcedBusinessPending | null>(null), prepared = ref<OutsourcedBusinessPhoto | null>(null), corrupted = ref(false)
const urls = ref<Partial<Record<OutsourcedBusinessPhotoSlot,string>>>({}), reading = ref<OutsourcedBusinessPhotoSlot | null>(null)
const uploadable = computed(() => !corrupted.value && access.can(permission.outsourcedBusinessUpload) && props.detail?.row.actions.upload.allowed === true)
useUnsavedWorkRegistration({ key: `outsourced-business-photos:${getCurrentInstance()?.uid}`, label: computed(() => '外配业务照片作业'), isOpen: computed(() => open.value || Boolean(pending.value) || Boolean(prepared.value)), model: reactive({ pending, prepared }), requiredPermission: computed(() => permission.outsourcedBusinessUpload) })
let actor = '', number = '', generation = 0
function savedPhoto(slot: OutsourcedBusinessPhotoSlot) { return props.detail?.photos.find(photo => photo.slot === slot && photo.savedAt) }
function release() { Object.values(urls.value).forEach(url => { if (url) URL.revokeObjectURL(url) }); urls.value = {} }
function ensureScope() { if (auth.user?.id !== actor || props.detail?.row.dispatchNo !== number) throw new Error('图片作业身份或排车单已变化,请重新打开') }
function persist() { persistOutsourcedBusinessPending(actor, number, 'photos', pending.value) }
function persistPrepared() {
const key = outsourcedBusinessPendingKey(actor, number, 'prepared-photo')
if (prepared.value) sessionStorage.setItem(key, JSON.stringify(prepared.value)); else sessionStorage.removeItem(key)
}
watch(busy, value => emit('busy', value))
watch(open, value => {
generation++; release(); reading.value = null; error.value = null; corrupted.value = false
if (!value || !props.detail) return
actor = auth.user?.id || ''; number = props.detail.row.dispatchNo
try {
pending.value = restoreOutsourcedBusinessPending(actor, number, 'photos')
const raw = sessionStorage.getItem(outsourcedBusinessPendingKey(actor, number, 'prepared-photo'))
prepared.value = raw ? JSON.parse(raw) as OutsourcedBusinessPhoto : null
if (prepared.value && (!prepared.value.fileId || !outsourcedBusinessPhotoRoles.some(role => role.slot === prepared.value!.slot))) throw new Error('原上传文件资料损坏,请保留现场核对')
} catch (cause) { corrupted.value = true; error.value = getApiErrorDetails(cause, '照片作业恢复失败') }
}, { immediate: true })
watch(() => props.detail?.photos, () => { generation++; release(); reading.value = null })
onBeforeUnmount(() => { generation++; release() })
async function preview(slot: OutsourcedBusinessPhotoSlot) {
const photo = savedPhoto(slot), current = generation
if (!photo || reading.value) return
reading.value = slot; error.value = null
try { const blob = await getOutsourcedBusinessPhotoContent(number, photo); if (current !== generation || !open.value) return; urls.value[slot] = URL.createObjectURL(blob) }
catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '图片读取失败') }
finally { if (current === generation) reading.value = null }
}
async function bindPrepared() {
if ((!prepared.value && !pending.value) || !props.detail || !uploadable.value) return
if (!pending.value) {
const body = { recordVersion: outsourcedBusinessVersion(props.detail.row.recordVersion), idempotencyKey: crypto.randomUUID(), fileId: prepared.value!.fileId }
pending.value = newOutsourcedBusinessPending(actor, number, 'BIND_PHOTO', body, prepared.value!.slot); persist()
}
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.detail) throw new Error('照片绑定尚未确认')
pending.value = null; persist(); prepared.value = null; persistPrepared(); emit('saved', result.detail); ElMessage.success('照片已绑定保存')
}
async function recover() {
if (busy.value || !uploadable.value) return
busy.value = true; error.value = null
try {
ensureScope()
if (pending.value?.action === 'UPLOAD_PHOTO') {
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.photo) throw new Error('原上传文件未确认')
prepared.value = result.photo; persistPrepared(); pending.value = null; persist()
}
await bindPrepared()
} catch (cause) { error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '照片结果尚未确认') }
finally { busy.value = false }
}
async function upload(slot: OutsourcedBusinessPhotoSlot, event: Event) {
const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = ''
if (!file || busy.value || pending.value || prepared.value || !uploadable.value) return
if (!['image/jpeg','image/png'].includes(file.type) || file.size <= 0) { error.value = { message: '请选择有效的JPG或PNG图片' }; return }
busy.value = true; error.value = null
try {
ensureScope(); pending.value = newOutsourcedBusinessPending(actor, number, 'UPLOAD_PHOTO', null, slot); pending.value.attempted = true; persist()
const uploaded = await uploadOutsourcedBusinessPhoto(number, slot, file, pending.value.key)
ensureScope(); acceptOutsourcedBusinessResult(pending.value, uploaded); persist()
const result = await recoverOutsourcedBusinessCommand(pending.value, persist, ensureScope)
if (!result.photo) throw new Error('真实上传文件未确认')
prepared.value = result.photo; persistPrepared(); pending.value = null; persist(); await bindPrepared()
} catch (cause) {
if (pending.value?.action === 'UPLOAD_PHOTO' && cause instanceof ApiResponseError && ['A0400','A0403','A0404','400','403','404','422'].includes(cause.code)) { pending.value.rejected = true; persist() }
error.value = getApiErrorDetails(cause, cause instanceof Error ? cause.message : '图片上传或绑定结果尚未确认')
} finally { busy.value = false }
}
async function discardRejected() {
if (busy.value || !pending.value?.rejected) return
busy.value = true
try { const latest = await getOutsourcedBusiness(number); ensureScope(); pending.value = null; persist(); emit('saved', latest); error.value = null }
catch (cause) { error.value = getApiErrorDetails(cause, '当前照片版本读取失败') }
finally { busy.value = false }
}
async function beforeClose(done: () => void) {
if (busy.value) return
if (pending.value || prepared.value) {
try { await ElMessageBox.confirm('未完成的原上传或绑定记录将保留,重新打开后继续核对;关闭不会保存尚未绑定的照片。是否关闭?', '关闭照片窗口', { customClass: 'admin-density-confirm', confirmButtonText: '保留记录并关闭', cancelButtonText: '继续核对' }) } catch { return }
}
done()
}
</script>
<template>
<OutsourcedBusinessPhotosDialog v-model="open" :dispatch-no="detail?.row.dispatchNo" :busy="busy" :before-close="beforeClose">
<template #state><LoadErrorState v-if="error" title="照片作业未完成" :message="error.message" :show-retry="false"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="pending">原{{ pending.action === 'UPLOAD_PHOTO' ? '上传' : '绑定' }}请求已保留,不会重新上传文件。</p><p v-if="prepared">{{ prepared.fileName }} 已上传,尚未确认绑定保存。</p><el-button v-if="pending || prepared" :loading="busy" :disabled="!uploadable" @click="recover">核对原结果并继续绑定</el-button><el-button v-if="pending?.rejected" :disabled="busy" @click="discardRejected">按明确拒绝重新核对,保留已上传文件</el-button><p v-if="!uploadable">{{ detail?.row.actions.upload.blockReason || '当前仅可查看照片' }}</p></template>
<template v-for="role in outsourcedBusinessPhotoRoles" :key="role.slot" #[role.viewSlot]>
<img v-if="urls[role.slot]" :src="urls[role.slot]" :alt="role.label" class="outsourced-business-photo" />
<template v-if="savedPhoto(role.slot)"><p>{{ savedPhoto(role.slot)?.fileName }} · 已保存</p><el-button :loading="reading === role.slot" :disabled="Boolean(reading)" @click="preview(role.slot)">查看图片</el-button></template>
<p v-else>暂无已保存照片</p>
<label v-if="uploadable" class="outsourced-business-upload">{{ savedPhoto(role.slot) ? '替换图片' : '上传图片' }}<input type="file" accept="image/jpeg,image/png" :disabled="busy || Boolean(pending) || Boolean(prepared)" @change="upload(role.slot, $event)" /></label>
</template>
</OutsourcedBusinessPhotosDialog>
</template>
<style scoped>
.outsourced-business-photo { display: block; width: 100%; max-height: 160px; object-fit: contain; }
.outsourced-business-upload { display: block; margin-top: var(--admin-space-3); }
.outsourced-business-upload input { display: block; width: 100%; margin-top: var(--admin-space-2); }
</style>
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { outsourcedBusinessDecimal, outsourcedBusinessVersion, outsourcedBusinessText, outsourcedBusinessPhotoRoles } from './outsourcedBusinessTypes'
describe('outsourced business decimal and version boundary', () => {
it('supports exactly the five approved loading photo slots', () => {
expect(outsourcedBusinessPhotoRoles.map(role => role.slot)).toEqual(['LOADING_SCENE','LOADING_LIST','PRE_DEPARTURE_CHECK','DRIVER_LICENSE','RECEIPT'])
expect(outsourcedBusinessPhotoRoles.every(role => role.category === 'LOADING')).toBe(true)
})
it('preserves the maximum decimal money text and distinguishes empty from explicit zero', () => {
expect(outsourcedBusinessDecimal('9999999999999999.99', 2, 18)).toBe('9999999999999999.99')
expect(outsourcedBusinessDecimal('0.00', 2, 18)).toBe('0.00'); expect(outsourcedBusinessDecimal(' ', 2, 18)).toBeNull()
expect(() => outsourcedBusinessDecimal('10000000000000000.00', 2, 18)).toThrow()
for (const value of ['-1','1e3','1.001','01','NaN','Infinity']) expect(() => outsourcedBusinessDecimal(value, 2, 18)).toThrow()
expect(outsourcedBusinessDecimal('123.456', 3, 12)).toBe('123.456')
})
it('keeps version zero and large string identities without accepting unsafe numeric versions', () => {
expect(outsourcedBusinessVersion(0)).toBe('0'); expect(outsourcedBusinessVersion('9007199254740993')).toBe('9007199254740993')
for (const value of [undefined,null,-1,0.5,Number.MAX_SAFE_INTEGER + 1,'','1e3']) expect(() => outsourcedBusinessVersion(value)).toThrow()
expect(outsourcedBusinessText(null)).toBe('—'); expect(outsourcedBusinessText(0)).toBe('0')
})
})
@@ -0,0 +1,45 @@
import type { DispatchDetail } from '@/modules/overseas-warehouse'
export type OutsourcedBusinessStage = 'AFTER_LOADING' | 'DELIVERING' | 'DELIVERED'
export type OutsourcedBusinessPhotoSlot = 'LOADING_SCENE' | 'LOADING_LIST' | 'PRE_DEPARTURE_CHECK' | 'DRIVER_LICENSE' | 'RECEIPT'
export interface OutsourcedBusinessMoney { currency: 'CNY' | 'USD'; amount: string }
export interface OutsourcedBusinessArrival { destinationKey: string; actualArrivedAt: string | null }
export interface OutsourcedBusinessOperations { money: OutsourcedBusinessMoney | null; arrivals: OutsourcedBusinessArrival[] }
export interface OutsourcedBusinessPhoto { slot: OutsourcedBusinessPhotoSlot; category: 'LOADING'; fileId: string; fileName: string; contentType: 'image/jpeg' | 'image/png'; sizeBytes: string; uploadedAt: string; savedAt: string | null }
export interface OutsourcedBusinessDestination { destinationKey: string; warehouseCode: string | null; warehouseName: string | null; address: string | null; appointments: { appointmentNo: string; confirmationNo: string | null; scheduledAt: string | null; allocatedPackages: string }[]; actualArrivedAt: string | null }
export interface OutsourcedBusinessRow {
dispatchNo: string; stage: OutsourcedBusinessStage; plannedDepartureAt: string | null; loadingCompletedAt: string; departedAt: string | null; deliveryCompletedAt: string | null
origin: { warehouseId: string; warehouseCode: string; warehouseName: string }
vehicle: { vehicleId: string | null; plate: string | null; ownership: 'DISPATCHED'; carrierName: string | null }; driver: { name: string | null; phone: string | null }
destinations: OutsourcedBusinessDestination[]; actualPalletCount: number | null; operations: OutsourcedBusinessOperations; unitPrice: string | null; recordVersion: string; dispatchRecordVersion: string
photoSummary: { loadingCount: number }; actions: { edit: OutsourcedBusinessGate; upload: OutsourcedBusinessGate }; sourceIssues: string[]
}
export interface OutsourcedBusinessGate { allowed: boolean; blockReason: string | null }
export interface OutsourcedBusinessQuery { page?: number; size?: number; plannedDepartureFrom?: string; plannedDepartureTo?: string; keyword?: string; stage?: OutsourcedBusinessStage; warehouseId?: string }
export interface OutsourcedBusinessSummary { dispatchCount: number; afterLoadingCount: number; deliveringCount: number; deliveredCount: number }
export interface OutsourcedBusinessPage<T> { records: T[]; page: number; size: number; total: number }
export interface OutsourcedBusinessList extends OutsourcedBusinessPage<OutsourcedBusinessRow> { summary: OutsourcedBusinessSummary }
export interface OutsourcedBusinessDetail { row: OutsourcedBusinessRow; dispatch: DispatchDetail; cargo: { sourceKey: string; customerId: string; customerName: string | null; orderNo: string | null; inboundCode: string | null; palletNo: string; destinationKey: string; packages: string }[]; photos: OutsourcedBusinessPhoto[] }
export interface OutsourcedBusinessSave { recordVersion: string; idempotencyKey: string; operations: OutsourcedBusinessOperations; reason: string | null }
export interface OutsourcedBusinessBind { recordVersion: string; idempotencyKey: string; fileId: string }
export type OutsourcedBusinessAction = 'SAVE_OPERATIONS' | 'UPLOAD_PHOTO' | 'BIND_PHOTO'
export interface OutsourcedBusinessCommand { dispatchNo: string; action: OutsourcedBusinessAction; idempotencyKey: string; state: 'SUCCEEDED' | 'PENDING' | 'UNKNOWN_RESULT' | 'REJECTED'; recordVersion: string | null; replayed: boolean; result: OutsourcedBusinessDetail | OutsourcedBusinessPhoto | null; errorCode: string | null; retryAllowed: boolean }
export interface OutsourcedBusinessHistory { eventSource: 'DISPATCH' | 'OPERATIONS'; recordVersion: string | null; dispatchRecordVersion: string | null; action: string; operatorName: string | null; occurredAt: string; reason: string | null; operations: OutsourcedBusinessOperations | null; photos: OutsourcedBusinessPhoto[] }
export const outsourcedBusinessStages: Record<OutsourcedBusinessStage, string> = { AFTER_LOADING: '装车完成', DELIVERING: '派送中', DELIVERED: '派送完成' }
export const outsourcedBusinessActionLabels: Record<string,string> = { CREATE: '新建排车', EDIT: '维护车辆信息', ADD_CARGO: '添加货物', REMOVE_CARGO: '移除货物', SUBMIT_REVIEW: '提交调度审核', DISPATCH_REVIEW: '调度审核', LOADING_REVIEW: '理货员审核', RETURN_TO_REVIEW: '退回重审', PICK: '真实拣货', LOADING_COMPLETE: '确认装车完成', ARRANGE_DELIVERY: '安排派送', DEPART: '实际发车', CANCEL: '取消排车', SAVE_OPERATIONS: '登记运营信息', BIND_PHOTO: '保存分类照片' }
export const outsourcedBusinessPhotoRoles: { slot: OutsourcedBusinessPhotoSlot; viewSlot: string; label: string; category: 'LOADING' }[] = [
{ slot: 'LOADING_SCENE', viewSlot: 'loading-scene', label: '装车现场', category: 'LOADING' }, { slot: 'LOADING_LIST', viewSlot: 'loading-list', label: '装车清单', category: 'LOADING' },
{ slot: 'PRE_DEPARTURE_CHECK', viewSlot: 'pre-departure-check', label: '出车前检查', category: 'LOADING' }, { slot: 'DRIVER_LICENSE', viewSlot: 'driver-license', label: '驾驶证附件', category: 'LOADING' },
{ slot: 'RECEIPT', viewSlot: 'delivery-receipt', label: '签收凭证', category: 'LOADING' },
]
export function outsourcedBusinessVersion(value: unknown): string {
if ((typeof value !== 'string' && typeof value !== 'number') || !/^(0|[1-9]\d*)$/.test(String(value)) || typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('运营记录版本无效,请重新读取')
return String(value)
}
export function outsourcedBusinessDecimal(value: string, scale: number, precision: number): string | null {
const text = value.trim()
if (!text) return null
if (!new RegExp(`^(?:0|[1-9]\\d*)(?:\\.\\d{1,${scale}})?$`).test(text) || text.split('.')[0]!.length > precision - scale) throw new Error(`请输入不超过${scale}位小数的有效非负数`)
return text
}
export function outsourcedBusinessText(value: unknown): string { return value == null || value === '' ? '—' : String(value) }
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import OutsourcedBusinessDetailLayout from '../components/OutsourcedBusinessDetailLayout.vue'
import OutsourcedBusinessOperationsEditor from '../components/OutsourcedBusinessOperationsEditor.vue'
import OutsourcedBusinessPhotosEditor from '../components/OutsourcedBusinessPhotosEditor.vue'
import { getOutsourcedBusiness, getOutsourcedBusinessPhotoContent, listOutsourcedBusinessHistory } from '../api/outsourcedBusiness'
import { outsourcedBusinessActionLabels, outsourcedBusinessStages, outsourcedBusinessText as text, outsourcedBusinessPhotoRoles, type OutsourcedBusinessDetail, type OutsourcedBusinessHistory, type OutsourcedBusinessPhoto } from '../outsourcedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
const dispatchNo = computed(() => typeof route.params.dispatchNo === 'string' ? route.params.dispatchNo : '')
const detail = ref<OutsourcedBusinessDetail | null>(null), loading = ref(false), error = ref<ApiErrorDetails | null>(null), activeTab = ref<'task'|'route'|'history'>('task')
const editorOpen = ref(false), photosOpen = ref(false), busy = ref(false)
const history = ref<OutsourcedBusinessHistory[]>([]), historyPage = ref(1), historySize = ref(20), historyTotal = ref(0), historyLoading = ref(false), historyError = ref<ApiErrorDetails | null>(null), previewError = ref<ApiErrorDetails | null>(null)
let generation = 0, historyGeneration = 0, active = true, initialActivation = true
const previewUrls = new Set<string>()
const historyRows = computed(() => history.value.map((event, index) => ({ ...event, eventKey: `${event.eventSource}:${event.recordVersion ?? event.dispatchRecordVersion ?? event.occurredAt}:${event.action}:${index}` })))
const cargoColumns: DataTableColumn[] = [{ prop: 'customerName', label: '客户', minWidth: 150 }, { prop: 'orderNo', label: '订单号', minWidth: 220 }, { prop: 'inboundCode', label: '入仓码', minWidth: 180 }, { prop: 'palletNo', label: '实物托盘码', minWidth: 180 }, { key: 'destination', label: '目的地', minWidth: 180, slotName: 'destination' }, { prop: 'packages', label: '箱数', minWidth: 90 }]
const historyColumns: DataTableColumn[] = [{ key: 'source', label: '事件来源', minWidth: 110, slotName: 'source' }, { key: 'revision', label: '运营版本', minWidth: 90, prop: 'recordVersion' }, { prop: 'dispatchRecordVersion', label: '排车版本', minWidth: 90 }, { key: 'action', label: '操作', minWidth: 150, slotName: 'action' }, { prop: 'operatorName', label: '操作人', minWidth: 140 }, { key: 'occurredAt', label: '操作时间', minWidth: 180, slotName: 'occurredAt' }, { prop: 'reason', label: '说明', minWidth: 160 }, { key: 'snapshot', label: '当时运营信息', minWidth: 250, slotName: 'snapshot' }, { key: 'photos', label: '当时照片', minWidth: 190, slotName: 'photos' }]
const taskFields = computed(() => {
const row = detail.value?.row
return [['计划发车', time(row?.plannedDepartureAt)], ['装车完成', time(row?.loadingCompletedAt)], ['实际发车', time(row?.departedAt)], ['派送完成', time(row?.deliveryCompletedAt)], ['车牌', text(row?.vehicle.plate)], ['司机', text(row?.driver.name)], ['司机电话', text(row?.driver.phone)], ['提货仓库', text(row?.origin.warehouseName)], ['实际物理托数', text(row?.actualPalletCount)], ['整车承运金额', row?.operations.money ? `${row.operations.money.currency} ${row.operations.money.amount}` : '未登记'], ['单价', row?.unitPrice == null ? '—' : `${row.operations.money?.currency || ''} ${row.unitPrice}`], ['承运商', text(row?.vehicle.carrierName)]]
})
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
function destinationLabel(key: string) { const value = detail.value?.row.destinations.find(destination => destination.destinationKey === key); return value?.warehouseCode || value?.warehouseName || value?.address || '—' }
function photoLabel(photo: OutsourcedBusinessPhoto) { return outsourcedBusinessPhotoRoles.find(role => role.slot === photo.slot)?.label || '照片' }
async function load() {
if (!active) return
const request = ++generation, no = dispatchNo.value; loading.value = true; error.value = null
try { if (!no || !access.can(permission.outsourcedBusinessList)) throw new Error('缺少排车业务号或查看权限'); const value = await getOutsourcedBusiness(no); if (active && request === generation) { if (value.row.dispatchNo !== no) throw new Error('当前运营资料与排车业务号不一致'); detail.value = value; if (activeTab.value === 'history') void loadHistory() } }
catch (cause) { if (active && request === generation) { detail.value = null; error.value = getApiErrorDetails(cause, '外配业务详情读取失败') } }
finally { if (request === generation) loading.value = false }
}
async function loadHistory() {
if (!active || activeTab.value !== 'history' || !detail.value) return
const request = ++historyGeneration, no = dispatchNo.value; historyLoading.value = true; historyError.value = null
try { const value = await listOutsourcedBusinessHistory(no, historyPage.value, historySize.value); if (active && request === historyGeneration && no === dispatchNo.value) { history.value = value.records; historyTotal.value = value.total } }
catch (cause) { if (request === historyGeneration) historyError.value = getApiErrorDetails(cause, '操作记录读取失败') }
finally { if (request === historyGeneration) historyLoading.value = false }
}
async function preview(photo: OutsourcedBusinessPhoto, revision: string | null) {
const no = dispatchNo.value, request = generation; previewError.value = null
if (revision == null) { previewError.value = { message: '历史照片缺少运营资料版本,请重新读取' }; return }
const windowRef = window.open('', '_blank')
if (!windowRef) { previewError.value = { message: '请允许本站打开图片预览窗口' }; return }
try { const blob = await getOutsourcedBusinessPhotoContent(no, photo, revision); if (!active || request !== generation || no !== dispatchNo.value) { windowRef.close(); return }; const url = URL.createObjectURL(blob); previewUrls.add(url); windowRef.location.href = url }
catch (cause) { windowRef.close(); previewError.value = getApiErrorDetails(cause, '历史照片读取失败') }
}
function saved(value: OutsourcedBusinessDetail) { if (value.row.dispatchNo !== dispatchNo.value) return; detail.value = value; if (activeTab.value === 'history') void loadHistory() }
watch([activeTab,historyPage,historySize], () => { if (active) void loadHistory() })
watch(dispatchNo, () => { generation++; historyGeneration++; detail.value = null; history.value = []; historyTotal.value = 0; activeTab.value = 'task'; editorOpen.value = false; photosOpen.value = false; if (active) void load() })
onMounted(load)
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
onDeactivated(() => { active = false; generation++; historyGeneration++; loading.value = false; historyLoading.value = false; editorOpen.value = false; photosOpen.value = false })
onBeforeUnmount(() => { active = false; generation++; historyGeneration++; previewUrls.forEach(url => URL.revokeObjectURL(url)) })
</script>
<template>
<OutsourcedBusinessDetailLayout v-model:active-tab="activeTab" :dispatch-no="detail?.row.dispatchNo || dispatchNo" :status-text="detail ? outsourcedBusinessStages[detail.row.stage] : ''" :loading="loading" @back="router.push('/overseas-master-data/outsourced-business')">
<template #actions><el-button v-if="detail && access.can(permission.outsourcedBusinessEdit)" class="density-action" :disabled="busy || !detail.row.actions.edit.allowed" @click="editorOpen = true">登记运营信息</el-button><el-button v-if="detail" class="density-action" :disabled="busy" @click="photosOpen = true">{{ access.can(permission.outsourcedBusinessUpload) && detail.row.actions.upload.allowed ? '上传照片' : '查看照片' }}</el-button></template>
<template #state><LoadErrorState v-if="error" title="外配业务详情读取失败" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-for="issue in detail?.row.sourceIssues || []" :key="issue">{{ issue }}</p></template>
<template #task><dl v-if="detail" class="density-fields outsourced-business-fields"><div v-for="field in taskFields" :key="field[0]"><dt>{{ field[0] }}</dt><dd>{{ field[1] }}</dd></div></dl></template>
<template #route><template v-if="detail"><section v-for="destination in detail.row.destinations" :key="destination.destinationKey" class="outsourced-business-route"><h3>{{ destination.warehouseCode || destination.warehouseName || destination.address || '目的地资料缺失' }}</h3><p v-if="destination.address">{{ destination.address }}</p><p v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约号 {{ appointment.appointmentNo }} · 确认号 {{ text(appointment.confirmationNo) }} · 预约到达 {{ time(appointment.scheduledAt) }}</p><p>实际到达 {{ time(destination.actualArrivedAt) }}</p></section><DataTable :data="detail.cargo" :columns="cargoColumns" row-key="sourceKey" column-settings-mode="hide" empty-text="暂无真实货物明细"><template #destination="{ row }">{{ destinationLabel(row.destinationKey) }}</template></DataTable><section class="outsourced-business-route"><h3>原排车关联单据</h3><p>出库单:{{ detail.dispatch.documents.outbounds.map(item => item.outboundNo).join('、') || '—' }}</p><p>派送单:{{ detail.dispatch.documents.deliveries.map(item => item.deliveryNo).join('、') || '—' }}</p><el-button @click="photosOpen = true">查看分类照片</el-button></section></template></template>
<template #history><LoadErrorState v-if="historyError" title="操作记录读取失败" :message="historyError.message" @retry="loadHistory"><template #details><ApiErrorMeta :details="historyError" /></template></LoadErrorState><LoadErrorState v-if="previewError" title="历史照片读取失败" :message="previewError.message" :show-retry="false"><template #details><ApiErrorMeta :details="previewError" /></template></LoadErrorState><DataTable v-model:page="historyPage" v-model:page-size="historySize" :data="historyRows" :columns="historyColumns" :total="historyTotal" :loading="historyLoading" row-key="eventKey" empty-text="暂无操作记录"><template #source="{ row }">{{ row.eventSource === 'DISPATCH' ? '原排车' : '运营登记' }}</template><template #action="{ row }">{{ outsourcedBusinessActionLabels[row.action] || '其他操作' }}</template><template #occurredAt="{ row }">{{ time(row.occurredAt) }}</template><template #snapshot="{ row }"><template v-if="row.operations"><p>整车承运金额 {{ row.operations.money ? row.operations.money.currency + ' ' + row.operations.money.amount : '未登记' }}</p><div v-for="arrival in row.operations.arrivals" :key="arrival.destinationKey">{{ destinationLabel(arrival.destinationKey) }} · {{ time(arrival.actualArrivedAt) }}</div></template><span v-else>—</span></template><template #photos="{ row }"><el-button v-for="photo in row.photos" :key="photo.slot" link @click="preview(photo, row.recordVersion)">{{ photoLabel(photo) }}</el-button></template></DataTable></template>
</OutsourcedBusinessDetailLayout>
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
<OutsourcedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="busy = $event" @saved="saved" />
</template>
<style scoped>
.outsourced-business-fields { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); padding: var(--admin-space-4); }
.outsourced-business-fields > div { min-width: 0; overflow-wrap: anywhere; }
.outsourced-business-route { padding: var(--admin-space-4); border-bottom: 1px solid var(--admin-table-border); }
.outsourced-business-route h3 { font-size: inherit; margin: 0 0 var(--admin-space-3); }
</style>
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import dayjs from 'dayjs'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import QueryToolbar, { type QueryField, type QueryModel, type QueryOption } from '@/shared/components/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import OutsourcedBusinessListLayout from '../components/OutsourcedBusinessListLayout.vue'
import OutsourcedBusinessOperationsEditor from '../components/OutsourcedBusinessOperationsEditor.vue'
import OutsourcedBusinessPhotosEditor from '../components/OutsourcedBusinessPhotosEditor.vue'
import { getOutsourcedBusiness, listOutsourcedBusiness } from '../api/outsourcedBusiness'
import { outsourcedBusinessStages, outsourcedBusinessText as text, type OutsourcedBusinessDetail, type OutsourcedBusinessList, type OutsourcedBusinessQuery, type OutsourcedBusinessRow, type OutsourcedBusinessStage } from '../outsourcedBusinessTypes'
import { overseasMasterDataPermissions as permission } from '../permissions'
const router = useRouter(), access = useAccessPreviewStore()
const result = ref<OutsourcedBusinessList | null>(null), page = ref(1), size = ref(20), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ plannedRange: [], keyword: '', stage: '', warehouseId: '' })
const warehouseOptions = ref<QueryOption[]>([]), warehouseError = ref<ApiErrorDetails | null>(null)
const detail = ref<OutsourcedBusinessDetail | null>(null), editorOpen = ref(false), photosOpen = ref(false), opening = ref(false), mutating = ref(false)
let applied: Omit<OutsourcedBusinessQuery,'page'|'size'> = {}, generation = 0, openGeneration = 0, active = true, initialActivation = true
const fields = computed<QueryField[]>(() => [
{ key: 'plannedRange', type: 'daterange', label: '计划发车日期', width: 270, props: { valueFormat: 'YYYY-MM-DD', 'aria-label': '计划发车日期范围' } },
{ key: 'keyword', type: 'input', label: '关键词', width: 230, placeholder: '排车单号 / 车牌 / 目的仓', props: { maxlength: 128 } },
{ key: 'stage', type: 'select', label: '阶段', width: 140, options: Object.entries(outsourcedBusinessStages).map(([value,label]) => ({ value,label })) },
{ key: 'warehouseId', type: 'select', label: '提货仓库', width: 180, options: warehouseOptions.value, props: { disabled: !access.can('platform:warehouse:list') } },
])
const summary = computed(() => [
['排车数', result.value?.summary.dispatchCount], ['装车完成', result.value?.summary.afterLoadingCount], ['派送中', result.value?.summary.deliveringCount], ['派送完成', result.value?.summary.deliveredCount],
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
{ key: 'appointments', label: '预约号', minWidth: 195, slotName: 'appointments' }, { prop: 'dispatchNo', label: '排车单号', minWidth: 300 },
{ key: 'carrier', label: '承运商', minWidth: 150, slotName: 'carrier' },
{ prop: 'vehicle.plate', label: '车牌', minWidth: 130 }, { key: 'driver', label: '司机', minWidth: 150, slotName: 'driver' }, { prop: 'origin.warehouseName', label: '提货仓库', minWidth: 150 },
{ key: 'destinations', label: '目的仓 / 预约到达 / 实际到达', minWidth: 285, slotName: 'destinations' },
{ prop: 'actualPalletCount', label: '实际托数', minWidth: 105 },
{ key: 'money', label: '整车承运金额 / 单价', minWidth: 170, slotName: 'money' },
{ key: 'plannedDepartureAt', label: '计划发车时间', minWidth: 180, slotName: 'plannedDeparture' }, { key: 'loadingCompletedAt', label: '实际装车完成时间', minWidth: 180, slotName: 'loaded' },
{ key: 'stage', label: '阶段', minWidth: 110, slotName: 'stage' },
]
function time(value: string | null | undefined) { return value && dayjs(value).isValid() ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—' }
function rowActions(row: OutsourcedBusinessRow): RowActionItem[] {
return [
{ key: 'detail', label: '查看明细' },
{ key: 'operations', label: '登记运营信息', visible: access.can(permission.outsourcedBusinessEdit), disabled: opening.value || mutating.value || !row.actions.edit.allowed },
{ key: 'photos', label: access.can(permission.outsourcedBusinessUpload) && row.actions.upload.allowed ? '上传照片' : '查看照片', disabled: opening.value || mutating.value },
]
}
function act(row: OutsourcedBusinessRow, key: string) {
if (key === 'detail') void router.push(`/overseas-master-data/outsourced-business/${encodeURIComponent(row.dispatchNo)}`)
else if (key === 'operations' || key === 'photos') void openAction(row, key)
}
async function load() {
if (!active) return
const request = ++generation
loading.value = true; error.value = null
try {
if (!access.can(permission.outsourcedBusinessList)) throw new Error('没有外配业务查看权限')
const value = await listOutsourcedBusiness({ ...applied, page: page.value, size: size.value })
if (active && request === generation) result.value = value
} catch (cause) { if (active && request === generation) { result.value = null; error.value = getApiErrorDetails(cause, '外配业务读取失败') } }
finally { if (request === generation) loading.value = false }
}
function search() {
try {
const range = filters.value.plannedRange
const dates = Array.isArray(range) ? range : []
if (dates.length && (dates.length !== 2 || !dayjs(String(dates[0])).isValid() || !dayjs(String(dates[1])).isValid() || dayjs(String(dates[1])).isBefore(dayjs(String(dates[0]))))) throw new Error('请选择有效的计划发车日期范围')
applied = { keyword: String(filters.value.keyword || '').trim() || undefined, stage: (filters.value.stage || undefined) as OutsourcedBusinessStage | undefined, warehouseId: String(filters.value.warehouseId || '') || undefined,
plannedDepartureFrom: dates.length ? dayjs(String(dates[0])).startOf('day').toISOString() : undefined, plannedDepartureTo: dates.length ? dayjs(String(dates[1])).add(1,'day').startOf('day').toISOString() : undefined }
if (page.value !== 1) page.value = 1; else void load()
} catch (cause) { error.value = getApiErrorDetails(cause, '筛选条件无效') }
}
async function loadWarehouses() {
if (!access.can('platform:warehouse:list')) return
try { const values = await listAllPlatformWarehouses({ warehouseType: 'OVERSEAS', status: 'ACTIVE' }); warehouseOptions.value = values.map(value => ({ value: String(value.id), label: value.warehouseName })) }
catch (cause) { warehouseError.value = getApiErrorDetails(cause, '仓库目录读取失败,可继续使用其他筛选') }
}
async function openAction(row: OutsourcedBusinessRow, kind: 'operations' | 'photos') {
if (opening.value || mutating.value) return
const code = kind === 'operations' ? permission.outsourcedBusinessEdit : permission.outsourcedBusinessList
if (!access.can(code) || kind === 'operations' && !row.actions.edit.allowed) return
const request = ++openGeneration; opening.value = true; error.value = null
try { const value = await getOutsourcedBusiness(row.dispatchNo); if (!active || request !== openGeneration) return; detail.value = value; if (kind === 'operations') editorOpen.value = true; else photosOpen.value = true }
catch (cause) { if (request === openGeneration) error.value = getApiErrorDetails(cause, '当前运营资料读取失败') }
finally { if (request === openGeneration) opening.value = false }
}
function saved(value: OutsourcedBusinessDetail) { detail.value = value; void load() }
watch([page,size], () => { if (active) void load() })
onMounted(() => { void load(); void loadWarehouses() })
onActivated(() => { active = true; if (initialActivation) initialActivation = false; else void load() })
onDeactivated(() => { active = false; generation++; openGeneration++; loading.value = false; opening.value = false; editorOpen.value = false; photosOpen.value = false })
onBeforeUnmount(() => { active = false; generation++; openGeneration++ })
</script>
<template>
<OutsourcedBusinessListLayout>
<template #statistics><section class="outsourced-business-statistics" aria-label="完整筛选范围外配业务统计" :aria-busy="loading"><div v-for="item in summary" :key="String(item[0])"><span>{{ item[0] }}</span><strong>{{ item[1] ?? '—' }}</strong></div></section></template>
<DataTable appearance="integrated" :stripe="false" v-model:page="page" v-model:page-size="size" :data="result?.records || []" :columns="columns" :total="result?.total || 0" :loading="loading" row-key="dispatchNo" preference-key="outsourced-business-table" empty-text="当前筛选范围暂无已装车的外配车辆排车单">
<template #toolbar-start><h1>外配业务</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="外配业务筛选" preference-key="outsourced-business-query" :loading="loading" @search="search" /><small>日期和时间按浏览器本地时区显示;统计覆盖同一完整筛选范围。</small></template>
<template #notices><LoadErrorState v-if="error" title="外配业务读取未完成" :message="error.message" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState><p v-if="warehouseError">{{ warehouseError.message }} <el-button link @click="loadWarehouses">重试目录</el-button></p><p v-if="!access.can('platform:warehouse:list')">没有公共仓库目录读取权限,仓库筛选暂不可选。</p></template>
<template #actions="{ row }"><RowActions :actions="rowActions(row as OutsourcedBusinessRow)" text-actions @action="act(row as OutsourcedBusinessRow, $event)" /></template>
<template #carrier="{ row }">{{ text(row.vehicle.carrierName) }}</template>
<template #driver="{ row }">{{ text(row.driver.name) }}<br>{{ text(row.driver.phone) }}</template>
<template #appointments="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="outsourced-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">{{ appointment.appointmentNo }}<small v-if="appointment.confirmationNo">确认号 {{ appointment.confirmationNo }}</small></div><span v-if="!destination.appointments.length">—</span></div></template>
<template #destinations="{ row }"><div v-for="destination in row.destinations" :key="destination.destinationKey" class="outsourced-business-destination"><strong>{{ destination.warehouseCode || destination.warehouseName || destination.address || '—' }}</strong><div v-for="appointment in destination.appointments" :key="appointment.appointmentNo">预约 {{ time(appointment.scheduledAt) }}</div><div>实到 {{ time(destination.actualArrivedAt) }}</div></div></template>
<template #money="{ row }"><template v-if="row.operations.money">{{ row.operations.money.currency }} {{ row.operations.money.amount }}<br>单价 {{ row.unitPrice ?? '—' }}</template><span v-else>未登记</span></template>
<template #plannedDeparture="{ row }">{{ time(row.plannedDepartureAt) }}</template><template #loaded="{ row }">{{ time(row.loadingCompletedAt) }}</template><template #stage="{ row }">{{ outsourcedBusinessStages[row.stage as OutsourcedBusinessStage] }}</template>
</DataTable>
<OutsourcedBusinessOperationsEditor v-model="editorOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
<OutsourcedBusinessPhotosEditor v-model="photosOpen" :detail="detail" @busy="mutating = $event" @saved="saved" />
</OutsourcedBusinessListLayout>
</template>
<style scoped>
.outsourced-business-statistics { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); background: white; border: 1px solid var(--admin-table-border); }
.outsourced-business-statistics > div { padding: var(--admin-space-4) var(--admin-space-5); border-right: 1px solid var(--admin-table-border); }
.outsourced-business-statistics > div:last-child { border-right: 0; }
.outsourced-business-statistics strong { display: block; margin-top: var(--admin-space-2); font-size: var(--admin-font-title); }
.outsourced-business-destination + .outsourced-business-destination { margin-top: var(--admin-space-3); border-top: 1px solid var(--admin-table-border); padding-top: var(--admin-space-3); }
.outsourced-business-destination small { display: block; }
</style>
@@ -0,0 +1,96 @@
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import dayjs from 'dayjs'
import OutsourcedBusinessView from './OutsourcedBusinessView.vue'
import OutsourcedBusinessDetailView from './OutsourcedBusinessDetailView.vue'
import { outsourcedBusinessFixture } from '../components/OutsourcedBusinessFixtures'
import { overseasMasterDataPermissions as permission } from '../permissions'
const calls = vi.hoisted(() => ({ list: vi.fn(), detail: vi.fn(), history: vi.fn(), content: vi.fn(), warehouses: vi.fn(), push: vi.fn(), allowed: new Set<string>() }))
vi.mock('../api/outsourcedBusiness', () => ({ listOutsourcedBusiness: calls.list, getOutsourcedBusiness: calls.detail, listOutsourcedBusinessHistory: calls.history, getOutsourcedBusinessPhotoContent: calls.content }))
vi.mock('@/modules/warehouses', () => ({ listAllPlatformWarehouses: calls.warehouses }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (code: string) => calls.allowed.has(code) }) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: calls.push }), useRoute: () => ({ params: { dispatchNo: 'DS-OUT-1' } }) }))
const Surface = defineComponent({ template: '<section><slot name="header" /><slot /></section>' })
const Button = defineComponent({ props: ['disabled'], template: '<button :disabled="disabled"><slot /></button>' })
const Tabs = defineComponent({ props: ['modelValue'], emits: ['update:modelValue'], template: '<div><slot /></div>' })
const Pane = defineComponent({ props: ['name','label'], template: '<article><slot /></article>' })
const Query = defineComponent({ props: ['modelValue','fields'], emits: ['update:modelValue','search'], template: '<div />' })
const Table = defineComponent({ props: ['data','columns','total','page','pageSize'], emits: ['update:page','update:pageSize'], setup(props,{slots}) { return () => h('div', [slots['toolbar-start']?.(), slots.filters?.(), slots.notices?.(), ...(props.data || []).map((row: object) => h('div', Object.entries(slots).filter(([name]) => !['toolbar-start','filters','notices'].includes(name)).map(([,slot]) => slot?.({row}))))]) } })
const OperationsEditor = defineComponent({ name: 'OutsourcedBusinessOperationsEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
const PhotosEditor = defineComponent({ name: 'OutsourcedBusinessPhotosEditorStub', props: ['modelValue','detail'], emits: ['update:modelValue','saved','busy'], template: '<div />' })
const global = { stubs: { AdminPage: Surface, DetailPageLayout: Surface, DataTable: Table, QueryToolbar: Query, ElTabs: Tabs, ElTabPane: Pane, ElButton: Button, OutsourcedBusinessOperationsEditor: OperationsEditor, OutsourcedBusinessPhotosEditor: PhotosEditor, LoadErrorState: { props:['message'], template:'<div>{{message}}<slot name="details" /></div>' }, ApiErrorMeta: true }, directives: { loading: () => {} } }
beforeEach(() => {
vi.clearAllMocks(); calls.allowed = new Set(Object.values(permission)); calls.allowed.add('platform:warehouse:list')
const detail = outsourcedBusinessFixture()
calls.list.mockResolvedValue({ records:[detail.row],page:1,size:20,total:41,summary:{dispatchCount:41,afterLoadingCount:30,deliveringCount:10,deliveredCount:1} })
calls.detail.mockResolvedValue(detail); calls.history.mockResolvedValue({records:[],page:1,size:20,total:0}); calls.warehouses.mockResolvedValue([])
})
describe('outsourced business views', () => {
it('uses one list request for all statistics and never hydrates each row', async () => {
const wrapper = mount(OutsourcedBusinessView,{global}); await flushPromises()
expect(calls.list).toHaveBeenCalledTimes(1); expect(calls.detail).not.toHaveBeenCalled(); expect(calls.content).not.toHaveBeenCalled()
expect(wrapper.find('[aria-label="完整筛选范围外配业务统计"]').text()).toContain('排车数41')
expect(wrapper.text()).not.toContain('今日排车')
expect(wrapper.findComponent(Table).props('columns')[0].key).toBe('actions')
expect(wrapper.findComponent(Table).props('total')).toBe(41)
expect(wrapper.text()).toContain('测试外配承运商'); expect(wrapper.text()).not.toContain('油耗'); expect(wrapper.text()).not.toContain('自有成本')
wrapper.unmount()
})
it('applies planned date half-open boundaries only on query and keeps all stage counts from the response', async () => {
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises();const query=wrapper.findComponent(Query)
query.vm.$emit('update:modelValue',{plannedRange:['2026-10-20','2026-10-21'],keyword:' FBA-1 ',stage:'AFTER_LOADING',warehouseId:'1'});await wrapper.vm.$nextTick()
expect(calls.list).toHaveBeenCalledTimes(1)
query.vm.$emit('search');await flushPromises()
expect(calls.list).toHaveBeenLastCalledWith({page:1,size:20,keyword:'FBA-1',stage:'AFTER_LOADING',warehouseId:'1',plannedDepartureFrom:dayjs('2026-10-20').startOf('day').toISOString(),plannedDepartureTo:dayjs('2026-10-21').add(1,'day').startOf('day').toISOString()})
expect(wrapper.find('[aria-label="完整筛选范围外配业务统计"]').text()).toContain('装车完成30')
wrapper.unmount()
})
it('opens the operations modal on demand with auxiliary zero version instead of the dispatch version', async () => {
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises()
expect(calls.detail).not.toHaveBeenCalled();expect(wrapper.findComponent(OperationsEditor).props('modelValue')).toBe(false)
await wrapper.findAll('button').find(button=>button.text()==='登记运营信息')!.trigger('click');await flushPromises()
expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OUT-1')
const editor=wrapper.findComponent(OperationsEditor)
expect(editor.props('modelValue')).toBe(true);expect(wrapper.findComponent(PhotosEditor).props('modelValue')).toBe(false)
expect(editor.props('detail').row.recordVersion).toBe('0');expect(editor.props('detail').dispatch.recordVersion).toBe('8')
wrapper.unmount()
})
it('keeps a list-only user read-only while retaining detail and authorized photo reads', async () => {
calls.allowed=new Set([permission.outsourcedBusinessList])
const wrapper=mount(OutsourcedBusinessView,{global});await flushPromises()
expect(wrapper.text()).not.toContain('登记运营信息');expect(wrapper.text()).toContain('查看照片');expect(calls.warehouses).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows a temporary external vehicle and a missing carrier without requiring a master ID', async () => {
const detail = outsourcedBusinessFixture(); detail.row.vehicle = { vehicleId:null,plate:'TEMP-1',ownership:'DISPATCHED',carrierName:null }
calls.detail.mockResolvedValue(detail)
const wrapper = mount(OutsourcedBusinessDetailView,{global}); await flushPromises()
expect(wrapper.text()).toContain('TEMP-1'); expect(wrapper.text()).toContain('承运商'); expect(wrapper.text()).toContain('—')
expect(calls.detail).toHaveBeenCalledExactlyOnceWith('DS-OUT-1'); expect(calls.content).not.toHaveBeenCalled(); wrapper.unmount()
})
it('loads aggregated history only on its tab, preserving null operations on dispatch events', async () => {
calls.history.mockResolvedValue({records:[{eventSource:'DISPATCH',recordVersion:null,dispatchRecordVersion:'8',action:'LOADING_COMPLETE',operatorName:null,occurredAt:'2026-10-20T09:00:00Z',reason:null,operations:null,photos:[]},{eventSource:'OPERATIONS',recordVersion:'1',dispatchRecordVersion:null,action:'SAVE_OPERATIONS',operatorName:'登记员',occurredAt:'2026-10-20T10:00:00Z',reason:null,operations:outsourcedBusinessFixture().row.operations,photos:[]}],page:1,size:20,total:2})
const wrapper=mount(OutsourcedBusinessDetailView,{global});await flushPromises()
expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.history).not.toHaveBeenCalled()
wrapper.findComponent(Tabs).vm.$emit('update:modelValue','history');await flushPromises()
expect(calls.history).toHaveBeenCalledExactlyOnceWith('DS-OUT-1',1,20)
expect(wrapper.text()).toContain('原排车');expect(wrapper.text()).toContain('运营登记')
const table=wrapper.findComponent(Table), records=table.props('data')
expect(records[0].eventKey).not.toBe(records[1].eventKey);expect(records[0].operations).toBeNull()
table.vm.$emit('update:page',2);await flushPromises();expect(calls.history).toHaveBeenLastCalledWith('DS-OUT-1',2,20)
expect(calls.detail).toHaveBeenCalledTimes(1);expect(calls.content).not.toHaveBeenCalled()
wrapper.unmount()
})
it('shows authoritative large money text and unit price without rounding or recomputing from two cargo rows', async () => {
const detail=outsourcedBusinessFixture();detail.row.operations.money={currency:'USD',amount:'9999999999999999.99'};detail.row.unitPrice='9999999999999999.99'
calls.detail.mockResolvedValue(detail)
const wrapper=mount(OutsourcedBusinessDetailView,{global});await flushPromises()
expect(wrapper.text()).toContain('USD 9999999999999999.99');expect(wrapper.text()).not.toContain('10000000000000000')
expect(wrapper.text()).not.toContain('4999999999999999')
wrapper.unmount()
})
})
@@ -17,6 +17,9 @@ export const overseasMasterDataPermissions = {
ownedBusinessList: 'platform:fleet:owned-business:list',
ownedBusinessEdit: 'platform:fleet:owned-business:edit',
ownedBusinessUpload: 'platform:fleet:owned-business:upload',
outsourcedBusinessList: 'platform:fleet:outsourced-business:list',
outsourcedBusinessEdit: 'platform:fleet:outsourced-business:edit',
outsourcedBusinessUpload: 'platform:fleet:outsourced-business:upload',
tripBookingCreate: 'platform:fleet:trip:booking-create',
tripBookingEdit: 'platform:fleet:trip:booking-edit',
tripBookingConfirm: 'platform:fleet:trip:booking-confirm',
@@ -13,6 +13,8 @@ describe('overseas master data routes', () => {
['overseas-master-data/trip-operations', overseasMasterDataPermissions.operationsList],
['overseas-master-data/owned-business', overseasMasterDataPermissions.ownedBusinessList],
['overseas-master-data/owned-business/:dispatchNo', overseasMasterDataPermissions.ownedBusinessList],
['overseas-master-data/outsourced-business', overseasMasterDataPermissions.outsourcedBusinessList],
['overseas-master-data/outsourced-business/:dispatchNo', overseasMasterDataPermissions.outsourcedBusinessList],
['overseas-master-data/trips', overseasMasterDataPermissions.tripList],
['overseas-master-data/trips/:tripNo', overseasMasterDataPermissions.tripList],
['overseas-master-data/drivers', overseasMasterDataPermissions.driverList],
@@ -45,6 +47,17 @@ describe('overseas master data routes', () => {
}
})
it('uses external list permission for both routes independently of owned and write permissions', () => {
const external = overseasMasterDataRoutes.filter(route => route.path.includes('/outsourced-business'))
expect(external).toHaveLength(2)
for (const route of external) {
expect(route.meta?.permission).toBe(overseasMasterDataPermissions.outsourcedBusinessList)
expect(route.meta?.permissionAnyOf).toBeUndefined()
expect(route.meta?.permission).not.toBe(overseasMasterDataPermissions.ownedBusinessList)
expect(route.meta?.permission).not.toBe(overseasMasterDataPermissions.outsourcedBusinessEdit)
}
})
it('keeps vehicle detail as a route page and does not add a duplicate partner warehouse route', () => {
expect(overseasMasterDataRoutes.at(-1)?.meta?.showPageHeading).toBe(false)
expect(overseasMasterDataRoutes.some((route) => route.path.includes('partner-warehouse'))).toBe(false)
@@ -38,6 +38,18 @@ export const overseasMasterDataRoutes = [
component: () => import('./pages/OwnedBusinessDetailView.vue'),
meta: { title: '自有业务详情', description: '查看原排车事实与附加运营信息', permission: permission.ownedBusinessList, showPageHeading: false },
},
{
path: 'overseas-master-data/outsourced-business',
name: 'overseas-master-data-outsourced-business',
component: () => import('./pages/OutsourcedBusinessView.vue'),
meta: { title: '外配业务', description: '按原排车查询外配车辆装车后的履约与承运信息', permission: permission.outsourcedBusinessList, showPageHeading: false },
},
{
path: 'overseas-master-data/outsourced-business/:dispatchNo',
name: 'overseas-master-data-outsourced-business-detail',
component: () => import('./pages/OutsourcedBusinessDetailView.vue'),
meta: { title: '外配业务详情', description: '查看原排车及外配承运登记', permission: permission.outsourcedBusinessList, showPageHeading: false },
},
{
path: 'overseas-master-data/trips',
name: 'overseas-master-data-trips',
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { Task } from './types'
import { taskStateText, taskProgressText, taskCompletionText } from './collectionPresentation'
import { collectionControlState, taskStateText, taskProgressText, taskCompletionText } from './collectionPresentation'
const base: Task = { taskNo: 'T-A', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'RUNNING', applyState: 'NOT_STARTED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: { phase: 'DETAIL', completed: 792, total: 2014, totalKnown: true, unit: 'ORDER' }, queryFrom: null, queryUntil: null }
describe('plain collection task presentation', () => {
it('requires the matching yielded attempt before describing RETRY_WAIT as normal continuation', () => {
@@ -22,6 +22,25 @@ describe('plain collection task presentation', () => {
expect(taskStateText({ ...base, taskStatus: 'DEAD' })).toBe('已停止,需处理')
expect(taskProgressText({ ...base, taskStatus: 'WAITING_SOURCE', attemptCount: 0 })).toBe('尚未开始,等待数据准备')
})
it('gives explicit off and pause evidence priority over automatic continuation copy', () => {
const waiting = { ...base, taskStatus: 'YIELDED' }
expect(taskStateText(waiting, { collectionMode: 'OFF' })).toBe('采集已关闭')
expect(taskStateText({ ...waiting, blockingReason: 'COLLECTION_OFF' })).toBe('采集已关闭')
expect(taskStateText(waiting, { accountPaused: true })).toBe('已暂停')
expect(taskStateText({ ...waiting, blockingReason: 'ACCOUNT_PAUSED' })).toBe('已暂停')
expect(taskStateText({ ...waiting, blockingReason: 'EXECUTION_DISABLED' })).toBe('执行未启用')
expect(collectionControlState(null, { collectionMode: 'OFF' })?.text).toBe('采集已关闭')
})
it('does not infer that an executing attempt stopped from a control switch or pause reason', () => {
for (const taskStatus of ['RUNNING', 'APPLYING']) {
const task = { ...base, taskStatus }
expect(taskStateText(task, { collectionMode: 'OFF' })).toBe('已关闭 · 执行中')
expect(taskStateText(task, { accountPaused: true })).toBe('已暂停 · 执行中')
expect(collectionControlState(task, { blockingReason: 'EXECUTION_DISABLED' })?.running).toBe(true)
}
expect(taskStateText({ ...base, taskStatus: 'SUCCEEDED' }, { collectionMode: 'OFF' })).toBe('执行完成')
expect(taskStateText({ ...base, taskStatus: 'DEAD', blockingReason: 'COLLECTION_OFF' })).toBe('已停止,需处理')
})
it('shows a stage percentage without presenting it as overall completion', () => {
expect(taskProgressText(base)).toBe('处理出库订单,已处理 792 / 2014单(39.3%,当前阶段)')
expect(taskCompletionText(base)).toBe('完成时间暂无法预计')
@@ -1,6 +1,24 @@
import type { Task } from './types'
import { label, phaseText, quantityText, unitText } from './state'
export interface CollectionControlEvidence {
collectionMode?: string | null
accountPaused?: boolean
blockingReason?: string | null
}
export function collectionControlState(task: Task | null | undefined, evidence: CollectionControlEvidence = {}) {
if (task && ['SUCCEEDED', 'FAILED', 'DEAD', 'ABORTED'].includes(task.taskStatus)) return null
const reasons = [task?.blockingReason, evidence.blockingReason]
const text = evidence.collectionMode === 'OFF' || reasons.includes('COLLECTION_OFF') ? '采集已关闭'
: evidence.accountPaused || reasons.includes('ACCOUNT_PAUSED') ? '已暂停'
: reasons.includes('EXECUTION_DISABLED') ? '执行未启用' : null
if (!text) return null
// A control switch alone cannot establish that an already running attempt has stopped.
const running = Boolean(task && ['RUNNING', 'APPLYING'].includes(task.taskStatus))
return { text, running, summary: running ? `${text === '采集已关闭' ? '已关闭' : text} · 执行中` : text }
}
export function isNormalContinuation(task: Task | null | undefined) {
const attempt = task?.latestAttempt
return task?.taskStatus === 'RETRY_WAIT' && attempt?.status === 'YIELDED'
@@ -9,8 +27,10 @@ export function isNormalContinuation(task: Task | null | undefined) {
&& !attempt.errorCode && !attempt.failureSummary
}
export function taskStateText(task: Task | null | undefined) {
export function taskStateText(task: Task | null | undefined, evidence: CollectionControlEvidence = {}) {
if (!task) return '尚无任务'
const control = collectionControlState(task, evidence)
if (control) return control.summary
if (isNormalContinuation(task)) return '本批已完成,等待续采'
const states: Record<string, string> = { WAITING_SOURCE: '等待数据准备', WAITING_UPSTREAM: '等待数据准备', READY: '等待执行', QUEUED: '等待执行', WAITING: '等待执行', RUNNING: '正在执行', RETRY_WAIT: '等待重试', YIELDED: '本批已完成,等待续采', DEAD: '已停止,需处理', SUCCEEDED: '执行完成', FAILED: '执行失败' }
return states[task.taskStatus] ?? label(task.taskStatus)
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { Info } from '@lucide/vue'
import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -96,7 +97,7 @@ watch(warehouseCode, code => { if (code) { selectedWarehouse = code; emit('wareh
const fields = computed<QueryField[]>(() => {
const result: QueryField[] = [
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 210, placeholder: '请选择仓库(必选)', props: { clearable: false }, options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] },
{ key: 'customerCode', label: '客户', type: 'select', width: 210, options: ownerDirectory.options.value, placeholder: '名称或编码搜索', props: { filterable: true, clearable: true, disabled: !!drilldown.value } },
{ key: 'customerCode', label: '货主', type: 'select', width: 210, options: ownerDirectory.options.value, placeholder: '货主名称或编码', props: { filterable: true, clearable: true, disabled: !!drilldown.value } },
{ key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
]
if (props.mode === 'inventory') result.push(
@@ -123,7 +124,7 @@ async function sortSalesQuantity() {
const summaryMode = computed(() => props.mode === 'sales' && tab.value === 'summary')
const columns = computed<DataTableColumn[]>(() => [
...(summaryMode.value ? [{ key: 'actions', label: '操作', width: 110, slotName: 'actions', fixed: 'left' }] : []),
{ prop: 'sku', label: 'SKU', minWidth: 170, ...(props.mode === 'inventory' ? { slotName: 'sku' } : {}) },
{ prop: 'sku', label: 'SKU', minWidth: 170, ...(props.mode === 'inventory' ? { slotName: 'sku', fixed: 'left' } : {}) },
{ prop: 'productName', label: '商品名称', minWidth: 190 },
{ key: 'owner', label: '所属货主', minWidth: 140, slotName: 'owner' },
{ key: 'unit', label: '单位', width: 70, slotName: 'unit' },
@@ -342,28 +343,29 @@ onDeactivated(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); tr
onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); document.removeEventListener('visibilitychange', visibility) })
</script>
<template>
<AdminPage class="admin-density">
<AdminPage class="admin-density admin-workbench">
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
<ListPageLayout v-else>
<div class="workbench-table" :class="{ 'is-compact-toolbar': mode === 'sales' && !$slots['view-tabs'] }">
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" :empty-text="!warehouseCode ? '请选择仓库后查询SKU' : error ? '查询失败,请重试' : '暂无符合条件的SKU数据'">
<template v-if="$slots['view-tabs']" #toolbar-start><slot name="view-tabs" /></template>
<template #filters><p v-if="ownerDirectory.error.value" role="alert">{{ ownerDirectory.error.value }}</p><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营SKU筛选" :preference-key="`self-operated-${mode}-query`" :loading="loading" :search-on-reset="false" @search="search" @reset="reset" /></template>
<template #notices>
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window-summary">销量统计(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }},共 {{ displayedWindowDays }} 天。{{ origin ? '沿用经营总览所选区间;未完整的日期不推算日均。' : '不含今天;今天的出库不计入日均及可售天数。' }}库存使用最新已查询的采集值。</p>
<p v-if="mode === 'inventory' && context" class="analytics-note workbench-note" data-testid="inventory-window-summary">销量统计(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }},共 {{ displayedWindowDays }} 天。{{ origin ? '沿用经营总览所选区间;未完整的日期不推算日均。' : '不含今天;今天的出库不计入日均及可售天数。' }}库存使用最新已查询的采集值。</p>
<p v-if="routeProblem" role="alert">{{ routeProblem }};已停止查询,不能按全仓条件继续。<el-button @click="returnOverview">返回经营总览</el-button></p>
<div v-if="origin" class="density-actions"><span>{{ drilldown?.summary ?? '来自经营总览 · 来源筛选已清除,保留原有效版本' }}</span><el-button v-if="drilldown" @click="clearDrilldown">清除来源条件</el-button><el-button @click="returnOverview">返回经营总览</el-button></div>
<p v-if="runtimeError" class="analytics-note" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p>
<p v-if="runtimeError" class="analytics-note workbench-note" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p>
<LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<p v-if="salesRangePending" class="analytics-note" role="status">日期范围尚未应用,请选择完整起止日期后点击查询。<template v-if="rows.length">当前列表及每日趋势仍使用上次已应用的统计区间。</template></p>
<p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据可用。' }}<template v-if="origin">请返回经营总览刷新原图。</template><template v-else>当前列表仍使用上次查询版本。<el-button :disabled="loading" @click="search">更新数据</el-button></template></p>
<p v-if="salesRangePending" class="analytics-note workbench-note" role="status">日期范围尚未应用,请选择完整起止日期后点击查询。<template v-if="rows.length">当前列表及每日趋势仍使用上次已应用的统计区间。</template></p>
<p v-if="newData || newDay" class="analytics-note workbench-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据可用。' }}<template v-if="origin">请返回经营总览刷新原图。</template><template v-else>当前列表仍使用上次查询版本。<el-button :disabled="loading" @click="search">更新数据</el-button></template></p>
</template>
<template #toolbar-actions><el-button :disabled="!warehouseCode" @click="link('/self-operated/collection')">查看采集状态</el-button><el-button :disabled="!warehouseCode" @click="link(mode === 'sales' ? '/self-operated/inventory' : '/self-operated/sales')">{{ mode === 'sales' ? '查看库存' : '查看销量' }}</el-button>
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间" teleported>
<template #reference><el-button aria-label="统计口径与采集时间" title="统计口径与采集时间"><Info :size="16" aria-hidden="true" /></el-button></template>
<div class="analytics-information admin-density">
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-observation">本仓库存采集时间(北京时间):{{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.inventoryObservedFrom && context.inventoryObservedUntil ? '当前列表来自本仓一轮完整分页观测,各页在该时段内采集,不代表同一瞬间的库存。' : '尚无完整库存观测时段,缺失时间不以发布时间代替。' }}</p>
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window">销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。</p>
<div class="analytics-information admin-density admin-workbench">
<p v-if="mode === 'inventory' && context" class="analytics-note workbench-note" data-testid="inventory-observation">本仓库存采集时间(北京时间):{{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.inventoryObservedFrom && context.inventoryObservedUntil ? '当前列表来自本仓一轮完整分页观测,各页在该时段内采集,不代表同一瞬间的库存。' : '尚无完整库存观测时段,缺失时间不以发布时间代替。' }}</p>
<p v-if="mode === 'inventory' && context" class="analytics-note workbench-note" data-testid="inventory-window">销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。</p>
<div class="analytics-rules">
<p>{{ summaryMode ? '最近3/7/15/30天均截至昨日(北京时间);自定义按所选起止日期查询。完整区间显示出库量,未完整区间显示已采集量;未知数据不按零计算。' : mode === 'inventory' ? '按页面所示销量区间计算;窗口未采齐时不以部分销量推算日均或可售天数。' : '使用截至昨日的完整自然日;日均和可售天数由已采集数据计算。' }}同一SKU属于不同货主时分别展示。</p>
@@ -403,8 +405,9 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
</template>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'frozenQuantity', 'defectivePhysicalQuantity', 'transitQuantity'] as const" :key="key" #[key]="{ row }">{{ quantityText(asInventory(row)[key]) }}<span v-if="asMetric(row).quantityStatuses?.[key] && asMetric(row).quantityStatuses?.[key] !== 'VALID'">({{ label(asMetric(row).quantityStatuses?.[key]) }})</span></template>
</DataTable>
</div>
</ListPageLayout>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable>
<AppDialog v-model="trendOpen" class="admin-density-dialog admin-workbench is-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable>
<div class="admin-density">
<LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError" compact><template #details><ApiErrorMeta v-if="trendErrorDetails" :details="trendErrorDetails" /></template></LoadErrorState>
<template v-if="daily">
@@ -422,7 +425,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
.inventory-sort-arrows { display: inline-flex; flex-direction: column; color: var(--el-text-color-placeholder); font-size: 9px; line-height: 10px; }
.inventory-sort-arrows .active { color: var(--el-color-primary); }
.inventory-missing { display: block; color: var(--el-text-color-secondary); }
.analytics-note, .analytics-rules { margin: 0; font-size: var(--admin-font-caption); }
.analytics-rules { margin: 0; font-size: var(--admin-font-caption); }
.analytics-information { max-height: 65vh; overflow-y: auto; overflow-wrap: anywhere; }
.analytics-information > p { margin: var(--admin-space-2) 0; }
.analytics-rules p { margin: var(--admin-space-2) 0; }
@@ -2,6 +2,7 @@
import { watch } from 'vue'
import { ElButton } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
import '@/styles/adminWorkbench.css'
const props = withDefaults(defineProps<{ title: string; width?: string; resetOn?: unknown; inline?: boolean; buttonLabel?: string }>(), { width: '1040px' })
const open = defineModel<boolean>({ default: false })
watch(() => props.resetOn, () => { open.value = false })
@@ -9,8 +10,8 @@ watch(() => props.resetOn, () => { open.value = false })
<template>
<div class="chart-disclosure" :class="{ 'chart-disclosure-inline': inline }">
<ElButton :link="inline" :type="inline ? 'primary' : undefined" :title="title" @click="open = true">{{ buttonLabel || title }}</ElButton>
<AppDialog v-model="open" class="admin-density-dialog" :title="title" :width="width" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close>
<div class="admin-density chart-evidence"><slot /></div>
<AppDialog v-model="open" class="admin-density-dialog admin-workbench is-dialog" :title="title" :width="width" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close>
<div class="admin-density chart-evidence workbench-evidence"><slot /></div>
</AppDialog>
</div>
</template>
@@ -12,6 +12,9 @@ function overview(account: Task | null = source): Overview {
warehouses: [{ warehouseCode: 'US01', dataFamily: 'OUTBOUND', activeTasks: [child], sharedUpstream: account }, { warehouseCode: 'US02', dataFamily: 'OUTBOUND', activeTasks: [{ ...child, taskNo: 'CHILD-2', warehouseCode: 'US02' }], sharedUpstream: account }] as WarehouseOverview[] }
}
const options = { global: { stubs: { 'el-button': true } } }
const retained: Task = { ...source, dataFamily: 'MASTER_DATA', taskStatus: 'RETRY_WAIT', attemptCount: 1, latestAttemptNo: 1, blockingReason: 'RETRY_SCHEDULED',
progress: { ...source.progress, phase: 'PRODUCTS', unit: 'PAGE', completed: 37, total: 86, attemptNo: 1 },
latestAttempt: { attemptNo: 1, status: 'YIELDED', phase: 'PRODUCTS', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 20, errorCode: null, failureSummary: null } }
describe('single account collection monitor', () => {
it('identifies account master-data work without calling it outbound detail fetching', () => {
const task = { ...source, dataFamily: 'MASTER_DATA', taskType: 'MASTER_DATA', progress: { ...source.progress!, phase: 'PRODUCTS', unit: 'PAGE', completed: 2, total: 86 } }
@@ -25,7 +28,8 @@ describe('single account collection monitor', () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: data } })
expect(w.find('.account-schedules').text()).toContain('US01 / US02')
expect(w.find('.account-schedules').text()).toContain('2026/10/14 00:05:00')
expect(w.find('.account-schedules').text()).toContain('计划时间不代表当前任务完成时间')
expect(w.get('.account-schedules strong').attributes('title')).toContain('计划时间不代表当前任务完成时间')
expect(w.find('.account-schedules').text()).not.toContain('计划时间不代表当前任务完成时间')
await w.setProps({ stale: true })
expect(w.find('.account-schedules').text()).toContain('上次自动采集计划')
await w.setProps({ overview: { ...data, warehouses: data.warehouses.map(row => ({ ...row, executionEnabled: false })) } })
@@ -55,7 +59,13 @@ describe('single account collection monitor', () => {
expect(w.findComponent(CollectionTaskProgress).props('task').taskNo).toBe('SOURCE')
expect(w.text()).toContain('12.1%'); expect(w.text()).toContain('2448')
expect(w.text()).toContain('US01 · 出库销量'); expect(w.text()).toContain('US02 · 出库销量')
expect(w.text()).toContain('完整队列数量及下一任务顺序尚未提供')
expect(w.get('.account-upnext h3').attributes('title')).toContain('完整队列数量及下一任务顺序尚未提供')
expect(w.text()).not.toContain('完整队列数量及下一任务顺序尚未提供')
expect(w.get('.account-flow h4').attributes('title')).toBe('只标记已确认的推进状态')
expect(w.get('.account-results > .account-section-title h4').attributes('title')).toBe('采集进度与报表发布分别确认')
expect(w.text()).not.toContain('只标记已确认的推进状态')
expect(w.text()).not.toContain('采集进度与报表发布分别确认')
expect(w.text()).not.toContain('运行看板')
expect(w.text()).not.toContain('查看配置'); expect(w.text()).not.toContain('执行心跳')
w.unmount()
})
@@ -74,8 +84,71 @@ describe('single account collection monitor', () => {
it('preserves real last counts when stale without claiming an estimate', () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview(), stale: true } })
expect(w.text()).toContain('339'); expect(w.text()).toContain('状态更新失败')
expect(w.text()).toContain('已暂停估算')
expect(w.find('.collection-estimate').exists()).toBe(false)
expect(w.get('.account-live').text()).toBe('状态待确认')
expect(w.find('.account-flow li[data-state="active"]').exists()).toBe(false)
w.unmount()
})
it('presents off as the main state while retaining a completed batch and reliable stage counts', () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: { ...overview(retained), collectionMode: 'OFF', warehouses: [] } } })
expect(w.get('.account-live').text()).toBe('采集已关闭')
expect(w.get('.account-status').text()).toBe('本批已完成')
expect(w.find('.collection-estimate').exists()).toBe(false)
expect(w.text().match(/采集已关闭/g)).toHaveLength(1)
expect(w.text()).not.toContain('等待续采')
expect(w.get('[role="progressbar"]').attributes('aria-valuenow')).toBe('43')
expect(w.findAll('.collection-count-grid dd strong').map(x => x.text())).toEqual(['86', '37', '49'])
const stages = w.findAll('.account-flow li')
expect(stages[0]?.text()).toContain('等待状态确认')
expect(stages[1]?.text()).toContain('当前阶段')
expect(stages[2]?.text()).toContain('等待状态确认')
expect(w.find('.account-flow li[data-state="done"]').exists()).toBe(false)
w.unmount()
})
it('retains running evidence when collection is off, then makes stale evidence explicit', async () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: { ...overview(source), collectionMode: 'OFF' } } })
expect(w.get('.account-live').text()).toBe('已关闭 · 执行中')
expect(w.find('.account-status').exists()).toBe(false)
expect(w.find('.account-flow li[data-state="active"]').exists()).toBe(true)
expect(w.find('.collection-estimate').exists()).toBe(false)
await w.setProps({ stale: true })
expect(w.get('.account-live').text()).toBe('状态待确认')
expect(w.find('.account-status').exists()).toBe(false)
expect(w.get('.account-now .account-eyebrow').text()).toBe('上次阶段记录')
expect(w.find('.account-flow li[data-state="active"]').exists()).toBe(false)
expect(w.get('.account-flow li[data-state="stale"]').text()).toContain('上次阶段')
expect(w.find('.collection-estimate').exists()).toBe(false)
expect(w.text()).toContain('12.1%')
w.unmount()
})
it('uses explicit account pause evidence without marking earlier stages as completed', () => {
const data = overview(retained)
data.warehouses = [{ ...data.warehouses[0]!, activeTasks: [], sharedUpstream: null, protection: {
accountPauseReason: 'MANUAL_PAUSE', accountCooldownUntil: null,
dailyRequestBudget: null, reservedHttpRequests: null, confirmedHttpRequests: null, budgetDate: null,
} }]
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: data } })
expect(w.get('.account-live').text()).toBe('已暂停')
expect(w.get('.account-status').text()).toBe('本批已完成')
expect(w.get('.account-flow li[data-state="paused"]').text()).toContain('当前阶段')
expect(w.find('.account-flow li[data-state="done"]').exists()).toBe(false)
expect(w.text()).not.toContain('等待续采')
w.unmount()
})
it('keeps idle distinct from off and unknown when there is no current task', async () => {
const data = { ...overview(null), warehouses: [] }
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: data } })
expect(w.get('.account-live').text()).toBe('待机')
expect(w.get('.account-empty h3').text()).toBe('等待下一次采集')
await w.setProps({ overview: { ...data, collectionMode: 'OFF' } })
expect(w.get('.account-live').text()).toBe('采集已关闭')
expect(w.find('.account-empty h3').exists()).toBe(false)
await w.setProps({ stale: true })
expect(w.get('.account-live').text()).toBe('状态待确认')
expect(w.get('.account-empty').text()).not.toContain('当前没有正在运行的采集')
await w.setProps({ overview: null, stale: false })
expect(w.get('.account-live').text()).toBe('未连接')
expect(w.text()).not.toContain('待机')
w.unmount()
})
})
@@ -1,12 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Activity, Check, Clock3, Database, Pause, RefreshCw } from '@lucide/vue'
import { Check, Clock3, Database, Pause, RefreshCw } from '@lucide/vue'
import type { Overview, Task } from '../types'
import CollectionTaskProgress from './CollectionTaskProgress.vue'
import { beijingTime, phaseText, label } from '../state'
import { taskStateText } from '../collectionPresentation'
import { collectionControlState, isNormalContinuation, taskStateText } from '../collectionPresentation'
const props = defineProps<{ overview: Overview | null; loading?: boolean; stale?: boolean; visible?: boolean }>()
const props = withDefaults(defineProps<{ overview: Overview | null; loading?: boolean; stale?: boolean; visible?: boolean }>(), { visible: true })
defineEmits<{ refresh: [] }>()
const activeStates = new Set(['RUNNING', 'APPLYING', 'READY', 'QUEUED', 'WAITING', 'WAITING_SOURCE', 'WAITING_UPSTREAM', 'RETRY_WAIT', 'YIELDED'])
const tasks = computed(() => {
@@ -18,6 +18,18 @@ const tasks = computed(() => {
})
const running = computed(() => tasks.value.filter(t => ['RUNNING', 'APPLYING'].includes(t.taskStatus)))
const current = computed(() => running.value[0] ?? tasks.value[0] ?? null)
const accountPaused = computed(() => Boolean(props.overview?.warehouses.some(w => w.protection?.accountPauseReason)))
const controlEvidence = computed(() => ({ collectionMode: props.overview?.collectionMode, accountPaused: accountPaused.value }))
const control = computed(() => collectionControlState(current.value, controlEvidence.value))
const batchComplete = computed(() => isNormalContinuation(current.value) || current.value?.taskStatus === 'YIELDED')
const liveText = computed(() => !props.overview ? props.loading ? '连接中' : '未连接'
: props.stale ? '状态待确认' : props.visible === false ? '暂停更新'
: control.value?.summary ?? (running.value.length ? '采集中' : '待机'))
const currentStateText = computed(() => props.stale ? '执行状态待确认' : props.visible === false ? '页面已暂停更新' : taskStateText(current.value, controlEvidence.value))
const currentEyebrow = computed(() => props.stale || props.visible === false ? '上次阶段记录'
: running.value.length ? '正在进行' : '当前等待')
const currentTitle = computed(() => current.value?.progress?.phase ? phaseText(current.value.progress.phase)
: control.value || props.stale || props.visible === false ? '任务进度' : currentStateText.value)
const waiting = computed(() => tasks.value.filter(t => t.taskNo !== current.value?.taskNo && !['RUNNING', 'APPLYING'].includes(t.taskStatus)))
const upcoming = computed(() => {
const groups = new Map<string, number>()
@@ -48,16 +60,15 @@ const nextStep = computed(() => {
if (['INVENTORY', 'FETCH'].includes(phase ?? '')) return '库存完整性校验 → 统计计算 → 发布结果'
if (['VALIDATE', 'VALIDATION', 'MERGE'].includes(phase ?? '')) return '统计计算 → 发布结果'
if (['BUILD', 'BUILDING', 'BUILT'].includes(phase ?? '')) return '发布结果'
if (['PUBLISH', 'PUBLISHED'].includes(phase ?? '')) return '本次发布完成后,由调度继续安排后续任务'
return '等待调度确认下一步'
if (['PUBLISH', 'PUBLISHED'].includes(phase ?? '')) return control.value ? '本次发布结果需另行确认' : '本次发布完成后,由调度继续安排后续任务'
return control.value ? '后续步骤待确认' : '等待调度确认下一步'
})
const scopeText = computed(() => current.value?.dataFamily === 'MASTER_DATA' ? '领星账号 · 客户/商品资料' : current.value?.taskNo === props.overview?.accountActivity?.taskNo || current.value?.progress?.progressScope === 'ACCOUNT_SHARED' ? '领星账号 · 出库数据' : `${current.value?.warehouseCode || '领星账号'} · ${current.value?.dataFamily === 'MASTER_DATA' ? '客户/商品资料' : current.value?.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量'}`)
const warehouseGroups = computed(() => [...new Set(props.overview?.warehouses.map(w => w.warehouseCode) ?? [])].map(code => ({ code, rows: props.overview!.warehouses.filter(w => w.warehouseCode === code) })))
const latestPublication = computed(() => (props.overview?.warehouses ?? []).filter(w => w.lastPublishedAt).sort((a, b) => Date.parse(b.lastPublishedAt!) - Date.parse(a.lastPublishedAt!))[0])
const idleReason = computed(() => {
if (!props.overview) return props.loading ? '正在连接,读取账号状态' : '暂未取得账号状态'
if (props.overview.collectionMode === 'OFF') return '采集已关闭'
if (props.overview.warehouses.some(w => w.protection?.accountPauseReason)) return '账号已暂停'
if (props.visible === false) return '页面已暂停更新'
if (props.overview.warehouses.some(w => ['DEAD', 'FAILED', 'ABORTED'].includes(w.currentTask?.taskStatus ?? ''))) return '暂无活动任务,最近有任务停止'
return '等待下一次采集'
})
@@ -69,107 +80,144 @@ function nodeState(phases: string[]) {
if (!p) return 'idle'
const evidence = [p, ...(p.stages ?? [])].find(s => phases.includes(s.phase ?? '') && s.attemptNo != null && s.attemptNo === p.attemptNo && s.scanRound === p.scanRound && s.sliceFrom === p.sliceFrom && s.sliceUntil === p.sliceUntil && s.progressScope === p.progressScope && s.stageFinishedAt)
if (evidence) return 'done'
return !props.stale && phases.includes(p.phase ?? '') && running.value.includes(current.value!) ? 'active' : 'idle'
if (!phases.includes(p.phase ?? '')) return 'idle'
if (props.stale || props.visible === false) return 'stale'
if (running.value.includes(current.value!)) return 'active'
return control.value ? 'paused' : 'waiting'
}
function nodeText(phases: string[]) {
const state = nodeState(phases)
if (state === 'done') return '已确认完成'
if (state === 'active') return '正在处理'
if (state === 'stale') return '上次阶段'
if (state === 'paused') return '当前阶段'
if (state === 'waiting') return '当前阶段 · 等待处理'
return current.value || control.value || !props.overview || props.stale ? '等待状态确认' : '待机'
}
</script>
<template>
<section class="account-monitor" aria-label="领星账号运行进度" :data-running="running.length > 0 && !stale">
<header class="account-header"><div class="account-brand"><Database :size="22" /><div><h2>数据采集</h2><span>领星账号 · 运行看板</span></div></div><div class="account-controls"><span class="account-live"><i />{{ stale ? '更新中断' : !overview ? '连接中' : running.length ? '采集中' : '待机' }}</span><el-button :loading="loading" @click="$emit('refresh')"><RefreshCw :size="15" />刷新状态</el-button></div></header>
<section class="account-monitor workbench-panel is-flush" aria-label="领星账号运行进度" :data-running="running.length > 0 && !stale && visible !== false">
<header class="account-header">
<div class="account-brand">
<span class="workbench-icon" aria-hidden="true"><Database :size="22" /></span>
<div class="workbench-heading"><h2 class="workbench-title">数据采集</h2></div>
</div>
<div class="account-controls">
<span class="account-live workbench-badge workbench-tone" :class="{ 'is-toned': (running.length > 0 || control) && !stale && visible !== false }" :data-tone="control ? 'amber' : 'green'"><i />{{ liveText }}</span>
<el-button :loading="loading" @click="$emit('refresh')"><RefreshCw :size="15" />刷新状态</el-button>
</div>
</header>
<div class="account-body">
<p v-if="stale" role="status">{{ overview ? '状态更新失败,以下保留最后一次读取的进度。' : '暂未取得账号状态,请稍后刷新。' }}</p>
<p v-if="stale" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ overview ? '状态更新失败,以下保留最后一次读取的进度。' : '暂未取得账号状态,请稍后刷新。' }}</p>
<div class="account-main"><section class="account-primary">
<template v-if="current">
<div class="account-now"><div><span class="account-eyebrow">{{ running.length ? '正在进行' : '当前等待' }}</span><h3>{{ current.progress?.phase ? phaseText(current.progress.phase) : taskStateText(current) }}</h3><p>{{ scopeText }}<template v-if="current.queryFrom"> · {{ beijingTime(current.queryFrom) }} 至 {{ beijingTime(current.queryUntil) }}(结束时刻不含)</template></p></div><span class="account-status">{{ taskStateText(current) }}</span></div>
<CollectionTaskProgress :key="current.taskNo" :task="current" compact :stale="stale" :visible="visible" :observed-at="overview?.observedAt" />
<p v-if="running.length > 1">同时运行:<span v-for="task in running.slice(1)" :key="task.taskNo">{{ task.warehouseCode || '账号' }} · {{ phaseText(task.progress?.phase) }};</span></p>
<div class="account-now">
<div class="workbench-heading">
<span v-if="!control || stale || visible === false" class="account-eyebrow workbench-note">{{ currentEyebrow }}</span>
<h3 class="workbench-title">{{ currentTitle }}</h3>
<p class="workbench-note">{{ scopeText }}<template v-if="current.queryFrom"> · {{ beijingTime(current.queryFrom) }} 至 {{ beijingTime(current.queryUntil) }}(结束时刻不含)</template></p>
</div>
<span v-if="!stale && visible !== false && (!control || batchComplete)" class="account-status workbench-badge is-toned">{{ control ? '本批已完成' : currentStateText }}</span>
</div>
<CollectionTaskProgress :key="current.taskNo" :task="current" compact :stale="stale" :visible="visible" :observed-at="overview?.observedAt" :collection-mode="overview?.collectionMode" :account-paused="accountPaused" />
<p v-if="running.length > 1" class="workbench-note">{{ stale || visible === false ? '上次读取的其他执行记录:' : '同时运行:' }}<span v-for="task in running.slice(1)" :key="task.taskNo">{{ task.warehouseCode || '账号' }} · {{ phaseText(task.progress?.phase) }};</span></p>
</template>
<div v-else class="account-empty"><div class="standby-ring"><Pause :size="38" /><span>{{ loading ? '读取中' : !overview ? '未连接' : '待机' }}</span></div><div><span class="account-eyebrow">{{ overview ? '当前没有正在运行的采集' : '连接状态' }}</span><h3>{{ idleReason }}</h3><div class="last-result"><Check :size="18" v-if="latestPublication" /><Clock3 :size="18" v-else /><div><strong>{{ latestPublication ? '最近发布结果' : '最近发布结果尚未提供' }}</strong><span v-if="latestPublication">{{ latestPublication.warehouseCode }} · {{ latestPublication.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<br />{{ beijingTime(latestPublication.lastPublishedAt) }}</span></div></div></div></div>
<section class="account-flow"><div class="account-section-title"><h4><Activity :size="16" />处理阶段</h4><span>只标记已确认的推进状态</span></div><ol :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }"><li v-for="(node, index) in flow" :key="node.name" :data-state="nodeState(node.phases)"><span class="step-index"><Check v-if="nodeState(node.phases) === 'done'" :size="16" /><template v-else>{{ index + 1 }}</template></span><strong>{{ node.name }}</strong><small>{{ nodeState(node.phases) === 'done' ? '已确认完成' : nodeState(node.phases) === 'active' ? '正在处理' : current || !overview ? '等待状态确认' : '待机' }}</small></li></ol></section>
<div v-else class="account-empty">
<div class="standby-ring"><Pause :size="38" /><span v-if="!control && !stale">{{ !overview ? loading ? '读取中' : '未连接' : visible === false ? '暂停更新' : '待机' }}</span></div>
<div>
<span class="account-eyebrow workbench-note">{{ !overview ? '连接状态' : stale || visible === false ? '上次账号记录' : '当前没有正在运行的采集' }}</span>
<h3 v-if="!control && !stale" class="workbench-title">{{ idleReason }}</h3>
<div class="last-result workbench-panel is-compact" :class="{ 'workbench-tone': Boolean(latestPublication) }" data-tone="green" :data-ready="Boolean(latestPublication)">
<Check :size="18" v-if="latestPublication" /><Clock3 :size="18" v-else />
<div><strong>{{ latestPublication ? '最近发布结果' : '最近发布结果尚未提供' }}</strong><span v-if="latestPublication" class="workbench-note">{{ latestPublication.warehouseCode }} · {{ latestPublication.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<br />{{ beijingTime(latestPublication.lastPublishedAt) }}</span></div>
</div>
</div>
</div>
<section class="account-flow workbench-section">
<div class="account-section-title workbench-section-heading"><h4 class="workbench-section-title" title="只标记已确认的推进状态">处理阶段</h4></div>
<ol :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }">
<li v-for="(node, index) in flow" :key="node.name" class="workbench-step workbench-tone" :data-tone="nodeState(node.phases) === 'done' ? 'green' : 'blue'" :data-state="nodeState(node.phases)">
<span class="step-index workbench-step-index"><Check v-if="nodeState(node.phases) === 'done'" :size="16" /><template v-else>{{ index + 1 }}</template></span>
<strong>{{ node.name }}</strong><small class="workbench-note">{{ nodeText(node.phases) }}</small>
</li>
</ol>
</section>
<section class="account-results" v-if="warehouseGroups.length"><div class="account-section-title"><h4><Database :size="16" />各仓最新数据</h4><span>采集进度与报表发布分别确认</span></div><div class="warehouse-results"><article v-for="group in warehouseGroups" :key="group.code"><h4>{{ group.code }}</h4><div v-for="row in group.rows" :key="row.dataFamily" class="warehouse-result"><span>{{ row.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<small>{{ row.lastPublishedAt ? beijingTime(row.lastPublishedAt) : '暂无发布结果' }}</small></span><span :class="{ 'result-ready': Boolean(row.lastPublishedAt) }">{{ row.lastPublishedAt ? '已有结果' : '尚未发布' }}<small>{{ row.lastPublishedAt ? label(row.dataFreshness) : '—' }}</small></span></div></article></div></section>
</section><aside class="account-upnext"><span class="account-eyebrow">后续安排</span><h3>{{ !overview ? '暂未取得安排' : upcoming.length ? '等待处理' : '等待新任务' }}</h3><ul v-if="upcoming.length"><li v-for="group in upcoming" :key="group.name"><span>{{ group.name }}</span><b>{{ group.count }} 项</b></li></ul><p v-else>{{ overview ? '当前摘要中暂无其他待处理项' : '连接恢复后更新后续任务' }}</p><div v-if="current" class="account-next"><strong>本阶段之后</strong><span>{{ nextStep }}</span></div><div v-if="schedules.length" class="account-next account-schedules"><strong>{{ stale ? '上次自动采集计划' : '下次自动采集计划' }}</strong><span v-for="schedule in schedules" :key="schedule.family + schedule.at">{{ schedule.family }} · {{ schedule.warehouses.join(' / ') }}<br />{{ beijingTime(schedule.at) }}(北京时间)</span><small>计划时间不代表当前任务完成时间。</small></div><small>仅已知活动摘要;完整队列数量及下一任务顺序尚未提供。</small></aside></div>
<footer><span>最近推进 {{ beijingTime(current?.progressUpdatedAt) }}</span><span>状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间</span></footer>
<section class="account-results workbench-section" v-if="warehouseGroups.length">
<div class="account-section-title workbench-section-heading"><h4 class="workbench-section-title" title="采集进度与报表发布分别确认">各仓最新数据</h4></div>
<div class="warehouse-results">
<article v-for="group in warehouseGroups" :key="group.code" class="workbench-panel is-compact">
<h4 class="workbench-chart-title">{{ group.code }}</h4>
<div v-for="row in group.rows" :key="row.dataFamily" class="warehouse-result">
<span>{{ row.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}<small class="workbench-note">{{ row.lastPublishedAt ? beijingTime(row.lastPublishedAt) : '暂无发布结果' }}</small></span>
<span class="workbench-tone" data-tone="green" :class="{ 'result-ready': Boolean(row.lastPublishedAt) }">{{ row.lastPublishedAt ? '已有结果' : '尚未发布' }}<small class="workbench-note">{{ row.lastPublishedAt ? label(row.dataFreshness) : '—' }}</small></span>
</div>
</article>
</div>
</section>
</section>
<aside class="account-upnext workbench-panel is-compact">
<span class="account-eyebrow workbench-note">后续安排</span>
<h3 class="workbench-chart-title" title="仅已知活动摘要;完整队列数量及下一任务顺序尚未提供。">{{ !overview ? '暂未取得安排' : stale || visible === false ? '上次待处理记录' : control ? upcoming.length ? '已保留的待处理项' : '暂无其他待处理项' : upcoming.length ? '等待处理' : '等待新任务' }}</h3>
<ul v-if="upcoming.length"><li v-for="group in upcoming" :key="group.name"><span>{{ group.name }}</span><b class="workbench-badge">{{ group.count }} 项</b></li></ul>
<p v-else class="workbench-note">{{ overview ? '当前摘要中暂无其他待处理项' : '连接恢复后更新后续任务' }}</p>
<div v-if="current" class="account-next workbench-panel is-compact"><strong>{{ control ? '后续处理步骤' : '本阶段之后' }}</strong><span class="workbench-note">{{ nextStep }}</span></div>
<div v-if="schedules.length" class="account-next account-schedules workbench-panel is-compact">
<strong title="计划时间不代表当前任务完成时间。">{{ stale ? '上次自动采集计划' : control ? '原采集计划' : '下次自动采集计划' }}</strong>
<span v-for="schedule in schedules" :key="schedule.family + schedule.at" class="workbench-note">{{ schedule.family }} · {{ schedule.warehouses.join(' / ') }}<br />{{ beijingTime(schedule.at) }}(北京时间)</span>
</div>
</aside></div>
<footer class="workbench-note"><span>最近推进 {{ beijingTime(current?.progressUpdatedAt) }}</span><span>状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间</span></footer>
</div>
</section>
</template>
<style scoped>
.account-monitor { color: var(--el-text-color-primary); background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius-lg); overflow: hidden; }
.account-header, .account-header > div, .account-now, .account-next, .account-pending, footer { display: flex; align-items: center; gap: var(--admin-space-4); }
.account-header { justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--el-border-color-light); }
h2, h3, p { margin: 0; }
h2 { font-size: var(--admin-font-section); }
h3 { font-size: 22px; margin: var(--admin-space-2) 0; }
.account-body { padding: var(--admin-space-6); }
.account-body > p[role="status"] { margin-bottom: var(--admin-space-5); color: var(--el-color-warning-dark-2); }
.account-header span, p, footer, small { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.account-monitor { overflow: hidden; }
.account-header, .account-brand, .account-controls, .account-live, .account-now { display: flex; align-items: center; gap: var(--workbench-grid-gap); }
.account-header { justify-content: space-between; padding: var(--workbench-card-space); border-bottom: 1px solid var(--workbench-border); }
.account-body { padding: var(--workbench-card-space); }
.account-body > p[role="status"] { margin-bottom: var(--workbench-section-gap); }
.account-now { justify-content: space-between; align-items: flex-start; }
.account-eyebrow { color: var(--el-color-primary); font-size: var(--admin-font-caption); }
.account-status { color: var(--el-color-primary); background: var(--el-color-primary-light-9); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); white-space: nowrap; }
.account-next { padding: var(--admin-space-4); background: var(--el-fill-color-light); border-left: 3px solid var(--el-color-primary); }
.account-next small { display: block; margin-top: var(--admin-space-2); }
.account-pending { flex-wrap: wrap; margin-top: var(--admin-space-5); }
.account-pending small { flex-basis: 100%; }
footer { justify-content: space-between; margin-top: var(--admin-space-5); padding-top: var(--admin-space-4); border-top: 1px solid var(--el-border-color-light); }
.account-main { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--admin-space-6); align-items: start; }
.account-primary { min-width: 0; }
.account-brand, .account-controls, .account-live, .account-section-title, h4 { display: flex; align-items: center; gap: 10px; }
.account-brand > svg { color: var(--el-color-primary); background: var(--el-color-primary-light-9); padding: var(--admin-space-3); box-sizing: content-box; border-radius: var(--admin-radius-lg); }
.account-header { background: var(--el-bg-color); }
.account-header h2 { margin-bottom: 4px; }
.account-live { color: var(--el-text-color-secondary); background: var(--el-fill-color-light); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius-lg); }
[data-running="true"] .account-live { color: var(--el-color-success-dark-2); background: var(--el-color-success-light-9); }
.account-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--el-text-color-secondary); }
[data-running="true"] .account-live i { background: var(--el-color-success); animation: signal 2s ease-in-out infinite; }
.account-now > div, .account-brand > div { min-width: 0; }
.account-status, .account-live { flex: 0 0 auto; white-space: nowrap; }
.account-live { gap: 8px; }
.account-live i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
[data-running="true"] .account-live i { animation: signal 2s ease-in-out infinite; }
.account-controls :deep(.el-button) { gap: 8px; }
.account-upnext { border-left: 1px solid var(--el-border-color-light); padding-left: 24px; }
.account-upnext h3 { font-size: 20px; margin: 8px 0 12px; }
.account-monitor footer { display: flex; justify-content: space-between; gap: var(--workbench-grid-gap); margin-top: var(--workbench-section-gap); padding-top: var(--workbench-section-gap); border-top: 1px solid var(--workbench-border); }
.account-main { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: var(--workbench-grid-gap); align-items: start; }
.account-primary { min-width: 0; }
.account-upnext h3 { margin: 8px 0 12px; }
.account-upnext ul { padding: 0; margin: 0; list-style: none; }
.account-upnext li { display: flex; justify-content: space-between; padding: 12px 0; gap: 8px; border-bottom: 1px solid var(--el-border-color-light); font-size: var(--admin-font-caption); }
.account-upnext > small { display: block; margin-top: 18px; line-height: 1.7; }
.account-next { display: grid; padding: 14px; margin-top: 12px; border-left-color: var(--el-color-primary); background: var(--el-color-primary-light-9); border-radius: var(--admin-radius); font-size: var(--admin-font-caption); line-height: 1.8; }
.account-empty { display: grid; grid-template-columns: 180px minmax(0, 1fr); align-items: center; gap: var(--admin-space-6); min-height: 190px; }
.standby-ring { width: 168px; height: 168px; border: 10px solid var(--el-border-color-light); border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--el-text-color-secondary); box-sizing: border-box; }
.last-result { display: flex; gap: 12px; padding: 16px; margin-top: 24px; background: var(--el-fill-color-light); border-radius: 6px; }
.last-result > svg { color: var(--el-color-success); }
.last-result strong, .last-result span { display: block; font-size: var(--admin-font-caption); line-height: 1.8; }
.last-result span { color: var(--el-text-color-secondary); }
.account-flow, .account-results { margin-top: var(--admin-space-6); padding-top: var(--admin-space-5); border-top: 1px solid var(--el-border-color-light); }
.account-section-title { justify-content: space-between; margin-bottom: var(--admin-space-5); }
h4 { margin: 0; font-size: var(--admin-font-body); }
.account-section-title > span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.account-flow ol { display: grid; gap: 16px; list-style: none; margin: 0; padding: 0; }
.account-flow li { border-top: 2px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-lighter); padding: var(--admin-space-3); display: grid; grid-template-columns: 28px 1fr; gap: 8px; align-items: center; }
.account-upnext li { display: flex; justify-content: space-between; align-items: flex-start; padding: 12px 0; gap: 8px; border-bottom: 1px solid var(--workbench-border); font-size: var(--workbench-note-size); }
.account-upnext li > span { min-width: 0; overflow-wrap: anywhere; }
.account-upnext li > b { flex: 0 0 auto; }
.account-next { display: grid; gap: 8px; margin-top: 12px; font-size: var(--workbench-note-size); line-height: 1.8; }
.account-empty { display: grid; grid-template-columns: 180px minmax(0, 1fr); align-items: center; gap: var(--workbench-grid-gap); min-height: 190px; }
.standby-ring { width: 168px; height: 168px; border: 10px solid var(--workbench-border); border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; color: var(--workbench-muted); box-sizing: border-box; }
.last-result { display: flex; gap: 12px; margin-top: var(--workbench-section-gap); }
.last-result > svg { flex: 0 0 auto; color: var(--workbench-muted); }
.last-result[data-ready="true"] > svg { color: var(--workbench-accent); }
.last-result strong { display: block; font-size: var(--workbench-note-size); line-height: 1.8; }
.account-flow, .account-results { margin-top: var(--workbench-section-gap); padding-top: var(--workbench-section-gap); border-top: 1px solid var(--workbench-border); }
.account-section-title { display: flex; align-items: center; justify-content: space-between; gap: var(--workbench-grid-gap); }
.account-flow ol { display: grid; gap: var(--workbench-grid-gap); list-style: none; margin: 0; padding: 0; }
.account-flow li { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 8px; align-items: center; }
.account-flow li small { grid-column: 2; }
.account-flow li strong { font-size: var(--admin-font-body); }
.step-index { display: grid; place-items: center; border-radius: 50%; width: 26px; height: 26px; background: var(--el-fill-color); font-size: var(--admin-font-caption); }
.account-flow li[data-state="active"] { border-color: var(--el-color-primary); color: var(--el-color-primary); background: var(--el-color-primary-light-9); }
.account-flow li[data-state="active"] .step-index { background: var(--el-color-primary); color: white; }
.account-flow li[data-state="done"] { border-color: var(--el-color-success); background: var(--el-color-success-light-9); }
.account-flow li[data-state="done"] .step-index { background: var(--el-color-success); color: white; }
.warehouse-results { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
.warehouse-results article { background: var(--el-fill-color-lighter); padding: 16px 20px; border: 1px solid var(--el-border-color-light); border-top: 3px solid var(--el-color-primary); border-radius: var(--admin-radius-lg); }
.warehouse-results article:nth-child(2n) { border-top-color: var(--el-color-success); }
.warehouse-results article h4 { color: var(--el-color-primary); }
.warehouse-results article:nth-child(2n) h4 { color: var(--el-color-success-dark-2); }
.warehouse-result { display: flex; justify-content: space-between; gap: 12px; padding-top: 14px; font-size: var(--admin-font-body); }
.warehouse-result small { display: block; margin-top: 4px; }
.account-flow li strong { min-width: 0; font-size: var(--workbench-note-size); overflow-wrap: anywhere; }
.warehouse-results { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
.warehouse-result { display: flex; justify-content: space-between; gap: 12px; padding-top: 14px; font-size: var(--workbench-note-size); }
.warehouse-result > span { min-width: 0; overflow-wrap: anywhere; }
.warehouse-result > span:last-child { text-align: right; }
.result-ready { color: var(--el-color-success); }
.account-monitor :deep(.collection-count-grid > div) { padding-inline: var(--admin-space-5); }
.account-monitor :deep(.collection-count-grid > div + div) { border-left: 1px solid var(--el-border-color-light); }
.account-monitor :deep(.collection-count-grid dd) { font-size: 36px; font-weight: 600; color: var(--el-text-color-primary); overflow-wrap: anywhere; }
.account-monitor :deep(.collection-count-grid > div:nth-child(2) dd) { color: var(--el-color-success-dark-2); }
.account-monitor :deep(.collection-count-grid > div:nth-child(3) dd) { color: var(--el-color-warning-dark-2); }
.account-monitor :deep(.collection-count-grid dd small) { display: block; margin-top: var(--admin-space-2); font-weight: 400; }
.account-monitor :deep(.collection-progress) { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: var(--admin-space-3) var(--admin-space-6); align-items: center; }
.warehouse-result > span.workbench-tone { color: var(--workbench-muted); }
.warehouse-result > span.result-ready { color: var(--workbench-accent); }
.account-monitor :deep(.collection-progress) { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 8px var(--workbench-grid-gap); align-items: center; }
.account-monitor :deep(.collection-stage-counts) { display: contents; }
.account-monitor :deep(.collection-ring) { width: 180px; height: 180px; grid-row: 1 / 3; }
.account-monitor :deep(.collection-progress[data-estimate="false"] .collection-ring) { grid-row: 1; }
.account-monitor :deep(.compact-counts) { grid-column: 2; }
.account-monitor :deep(.collection-progress > p) { grid-column: 1 / -1; }
.account-monitor :deep(.ring-label strong) { color: var(--el-color-primary); }
.account-monitor :deep(.collection-estimate) { grid-column: 2; margin: 0; padding: var(--admin-space-4) var(--admin-space-5); border-radius: var(--admin-radius); background: var(--el-color-primary-light-9); }
.account-monitor :deep(.collection-estimate > strong) { width: 100%; color: var(--el-color-primary); }
.account-monitor :deep(.collection-estimate > span) { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.account-monitor :deep(.collection-estimate) { grid-column: 2; margin: 0; }
@keyframes signal { 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } }
</style>
@@ -5,6 +5,7 @@ import AnalyticsChart from './AnalyticsChart.vue'
import { progressGaugeOption } from '../dashboardChartOptions'
import type { Progress } from '../types'
import { beijingTime, phaseText, quantityText, unitText } from '../state'
import '@/styles/adminWorkbench.css'
const props = defineProps<{ progress: Partial<Progress> | null | undefined; taskNo: string; stale?: boolean; compact?: boolean; running?: boolean }>()
const identity = computed(() => JSON.stringify([props.taskNo, props.progress?.progressScope, props.progress?.countBasis, props.progress?.attemptNo, props.progress?.phase, props.progress?.scanRound, props.progress?.sliceFrom, props.progress?.sliceUntil, props.progress?.unit]))
const changedTotal = ref(false)
@@ -22,19 +23,31 @@ const option = computed(() => progressGaugeOption(counts.value?.percent ?? null,
const readingDirectory = computed(() => !props.progress?.totalKnown && ['DIRECTORY', 'DISCOVERY'].includes(props.progress?.phase ?? ''))
</script>
<template>
<section class="collection-stage-counts" aria-label="当前阶段数量">
<section class="collection-stage-counts admin-workbench" aria-label="当前阶段数量">
<div class="collection-ring" :role="counts ? 'progressbar' : 'img'" :aria-label="`${scope} · ${phaseText(progress?.phase)} · ${counts ? '当前阶段' : '总量待确认'}`" :aria-valuenow="counts?.percent" :aria-valuemin="counts ? 0 : undefined" :aria-valuemax="counts ? 100 : undefined">
<AnalyticsChart :option="option" merge-updates class="ring-chart" :class="{ 'is-indeterminate': indeterminate }" aria-hidden="true" />
<div class="ring-label"><strong>{{ counts ? `${counts.percent.toFixed(1)}%` : indeterminate ? '执行中' : '—' }}</strong><span>{{ stale ? '上次阶段记录' : '当前阶段' }}</span></div>
</div>
<div :class="{ 'compact-counts': compact }">
<p v-if="!compact">{{ phaseText(progress?.phase) }} · {{ scope }}<InlineHelp v-if="scope === '账号共享'" title="阶段数量说明"><p>来源账号的阶段数量,不作为本仓出库量或整个同步完成率。</p></InlineHelp><span v-if="progress?.scanRound != null"> · 第 {{ progress.scanRound }} 轮</span></p>
<dl class="collection-count-grid"><div><dt>总量</dt><dd>{{ progress?.totalKnown ? quantityText(progress.total) : compact ? '—' : '待确认' }} <small>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</small></dd></div><div><dt>已完成</dt><dd>{{ quantityText(progress?.completed) }} <small>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</small></dd></div><div><dt>剩余</dt><dd>{{ counts ? counts.remaining : '—' }} <small>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</small></dd></div></dl>
<p v-if="changedTotal">总量发生变化,正在复核;暂不展示比例。</p>
<p v-else-if="!counts">{{ readingDirectory ? '目录总页数尚未返回,已读取数量见“已完成”。' : progress?.totalKnown && Number(progress.total) === 0 ? '本阶段数量为零;是否完成以阶段及发布证据为准。' : '总量或计数依据待确认,不估算完成比例。' }}</p>
<p v-else-if="compact" aria-hidden="true">&nbsp;</p>
<p v-if="!compact" class="workbench-note">{{ phaseText(progress?.phase) }} · {{ scope }}<InlineHelp v-if="scope === '账号共享'" title="阶段数量说明"><p>来源账号的阶段数量,不作为本仓出库量或整个同步完成率。</p></InlineHelp><span v-if="progress?.scanRound != null"> · 第 {{ progress.scanRound }} 轮</span></p>
<dl class="collection-count-grid">
<div class="workbench-metric is-compact">
<dt class="workbench-metric-label">总量</dt>
<dd class="workbench-metric-value"><strong>{{ progress?.totalKnown ? quantityText(progress.total) : compact ? '—' : '待确认' }}</strong><span>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</span></dd>
</div>
<div class="workbench-metric is-compact">
<dt class="workbench-metric-label">已完成</dt>
<dd class="workbench-metric-value"><strong>{{ quantityText(progress?.completed) }}</strong><span>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</span></dd>
</div>
<div class="workbench-metric is-compact">
<dt class="workbench-metric-label">剩余</dt>
<dd class="workbench-metric-value"><strong>{{ counts ? counts.remaining : '—' }}</strong><span>{{ compact && (!progress?.unit || progress.unit === 'NONE') ? '' : unitText(progress?.unit) }}</span></dd>
</div>
</dl>
<p v-if="changedTotal" class="workbench-note">总量发生变化,正在复核;暂不展示比例。</p>
<p v-else-if="!counts" class="workbench-note">{{ readingDirectory ? '目录总页数尚未返回,已读取数量见“已完成”。' : progress?.totalKnown && progress.total != null && Number(progress.total) === 0 ? '本阶段数量为零;是否完成以阶段及发布证据为准。' : '总量或计数依据待确认,不估算完成比例。' }}</p>
<p v-if="!compact && (progress?.sliceFrom || progress?.sliceUntil)">当前切片:{{ beijingTime(progress?.sliceFrom) }} 至 {{ beijingTime(progress?.sliceUntil) }}(结束边界不含)</p>
<p v-if="!compact && (progress?.sliceFrom || progress?.sliceUntil)" class="workbench-note">当前切片:{{ beijingTime(progress?.sliceFrom) }} 至 {{ beijingTime(progress?.sliceUntil) }}(结束边界不含)</p>
</div>
</section>
</template>
@@ -47,9 +60,8 @@ const readingDirectory = computed(() => !props.progress?.totalKnown && ['DIRECTO
@keyframes collection-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .ring-chart.is-indeterminate { animation: none; } }
.ring-label { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.ring-label strong { font-size: 28px; font-weight: 500; font-variant-numeric: tabular-nums; }
.ring-label strong { color: var(--workbench-primary); font-size: var(--workbench-compact-number-size); font-weight: 700; font-variant-numeric: tabular-nums; }
.ring-label span { margin-top: 4px; color: var(--workbench-muted); font-size: var(--workbench-note-size); }
.collection-count-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); margin: var(--admin-space-4) 0; }
dt, small, .ring-label span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
dd { margin: var(--admin-space-2) 0 0; font-size: 30px; font-variant-numeric: tabular-nums; }
p { margin: var(--admin-space-2) 0; font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.compact-counts .collection-count-grid { margin-block: 0; }
</style>
@@ -37,6 +37,60 @@ describe('collection task progress evidence', () => {
await w.setProps({ task: { ...yielded, blockingReason: 'ACCOUNT_COOLDOWN' } })
expect(w.find('.collection-estimate').text()).not.toContain('本批已完成,等待续采')
})
it('keeps a failure summary once across compact, stale, hidden and controlled displays', async () => {
const failed: Task = { ...source, taskStatus: 'RETRY_WAIT', blockingReason: 'UPSTREAM_FAILED', latestAttemptNo: 2,
latestAttempt: { attemptNo: 2, status: 'FAILED', phase: 'DETAIL', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 20, errorCode: 'UPSTREAM_ERROR', failureSummary: '来源读取失败' } }
const w = mount(CollectionTaskProgress, { props: { task: failed } }); wrappers.push(w)
for (const compact of [false, true]) {
for (const state of [
{ stale: false, visible: true, collectionMode: 'BUSINESS' },
{ stale: true, visible: true, collectionMode: 'BUSINESS' },
{ stale: false, visible: false, collectionMode: 'BUSINESS' },
{ stale: false, visible: true, collectionMode: 'OFF' },
]) {
await w.setProps({ compact, ...state })
expect(w.text().match(/来源读取失败/g)).toHaveLength(1)
expect(w.findAll('[role="status"]').filter(node => node.text() === '来源读取失败')).toHaveLength(1)
if (!state.stale && state.visible && state.collectionMode === 'BUSINESS') expect(w.text()).toContain('关联来源失败,等待恢复')
}
}
})
it('hides redundant estimate notices under off, pause and stale states while retaining counts', async () => {
const yielded: Task = { ...source, taskStatus: 'RETRY_WAIT', blockingReason: 'RETRY_SCHEDULED', latestAttemptNo: 2,
latestAttempt: { attemptNo: 2, status: 'YIELDED', phase: 'DETAIL', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 20, errorCode: null, failureSummary: null } }
const w = mount(CollectionTaskProgress, { props: { task: yielded, collectionMode: 'OFF', compact: true } }); wrappers.push(w)
expect(w.find('.collection-estimate').exists()).toBe(false)
expect(w.text()).not.toContain('等待续采')
expect(w.text()).toContain('12.1%')
expect(w.find('.compact-counts [aria-hidden="true"]').exists()).toBe(false)
await w.setProps({ collectionMode: 'BUSINESS', accountPaused: true })
expect(w.find('.collection-estimate').exists()).toBe(false)
await w.setProps({ stale: true })
expect(w.find('.collection-estimate').exists()).toBe(false)
await w.setProps({ stale: false, visible: false })
expect(w.find('.collection-estimate').exists()).toBe(false)
})
it('keeps an executing attempt visible under an off switch and pauses only stale animation', async () => {
const unknown = { ...source, blockingReason: 'COLLECTION_OFF', progress: { ...progress, totalKnown: false, total: null } }
const w = mount(CollectionTaskProgress, { props: { task: unknown, compact: true } }); wrappers.push(w)
expect(w.find('.collection-estimate').exists()).toBe(false)
expect(w.get('.ring-chart').classes()).toContain('is-indeterminate')
await w.setProps({ stale: true })
expect(w.find('.collection-estimate').exists()).toBe(false)
expect(w.get('.ring-chart').classes()).not.toContain('is-indeterminate')
})
it('marks only the evidenced current phase as paused and does not treat paused estimates as paused execution', async () => {
const paused: Task = { ...source, dataFamily: 'MASTER_DATA', taskStatus: 'RETRY_WAIT', blockingReason: 'ACCOUNT_PAUSED', progress: { ...progress, phase: 'PRODUCTS' } }
const w = mount(CollectionTaskProgress, { props: { task: paused } }); wrappers.push(w)
expect(w.findAll('.collection-flow li').map(li => li.get('span').text())).toEqual(['状态未确认', '当前阶段 · 已暂停', '状态未确认'])
expect(w.text()).not.toContain('正在处理')
await w.setProps({ task: { ...paused, blockingReason: 'EXECUTION_DISABLED' } })
expect(w.get('.density-actions span').text()).toBe('执行未启用')
await w.setProps({ task: { ...paused, taskStatus: 'RUNNING', blockingReason: null, stageEstimate: { status: 'PAUSED' } as StageEstimate } })
expect(w.get('.density-actions span').text()).toBe('正在执行')
expect(w.findAll('.collection-flow li')[1]?.text()).toContain('正在处理')
expect(w.get('.collection-estimate strong').text()).toBe('已暂停估算')
})
it('reads serialized Long seconds and rejects missing, malformed or unsafe values', async () => {
const w = mount(CollectionTaskProgress, { props: { task: { ...source, elapsedSeconds: '607' }, compact: true } }); wrappers.push(w)
expect(w.text()).toContain('任务累计耗时 10 分 7 秒')
@@ -70,6 +124,11 @@ describe('collection task progress evidence', () => {
it('uses only a confirmed source and does not infer earlier stage completion from the current phase', async () => {
const w = mount(CollectionTaskProgress, { props: { task, detail } }); wrappers.push(w)
expect(w.text()).toContain('TASK-S'); expect(w.text()).toContain('12.1%'); expect(w.text()).toContain('2448')
expect(w.classes()).toContain('admin-workbench')
expect(w.get('.collection-stage-counts').classes()).toContain('admin-workbench')
expect(w.findAll('.collection-count-grid .workbench-metric.is-compact')).toHaveLength(3)
expect(w.findAll('.collection-count-grid dd strong').map(value => value.text())).toEqual(['2787', '339', '2448'])
expect(w.get('.collection-estimate').classes()).toContain('workbench-notice')
expect(w.text()).toContain('账号共享'); expect(w.findAll('li')[0]?.text()).toContain('状态未确认')
expect(w.text()).not.toContain('✓')
await w.setProps({ task: { ...task, dependencyState: 'WAITING_UNCONFIRMED', progressSourceTaskNo: null } })
@@ -123,6 +182,14 @@ describe('collection task progress evidence', () => {
const d = { ...detail, sharedUpstream: { ...source, stageEstimate } }
const w = mount(CollectionTaskProgress, { props: { task, detail: d, visible: true } }); wrappers.push(w)
expect(w.text()).toContain('约82分钟')
await w.setProps({ compact: true })
expect(w.get('.collection-estimate').text()).toBe('当前阶段预计还需约82分钟')
expect(w.get('.collection-estimate strong').attributes('title')).toContain('最近速度 0.5 单/秒')
expect(w.get('.collection-estimate strong').attributes('title')).toContain('估算更新')
expect(w.get('.collection-estimate strong').attributes('title')).toContain('整个任务完成时间尚未估算')
await w.setProps({ compact: false })
expect(w.get('.collection-estimate').text()).toContain('最近速度')
expect(w.get('.collection-estimate').text()).toContain('整个任务完成时间尚未估算')
await w.setProps({ stale: true }); expect(w.text()).not.toContain('约82分钟'); expect(w.text()).toContain('状态更新失败')
await w.setProps({ stale: false, visible: false }); expect(w.text()).not.toContain('约82分钟')
await w.setProps({ visible: true, detail: { ...d, sharedUpstream: { ...d.sharedUpstream, progress: { ...progress, completed: 340 } } } }); expect(w.text()).not.toContain('约82分钟')
@@ -135,6 +202,15 @@ describe('collection task progress evidence', () => {
await w.setProps({ progress: { ...progress, attemptNo: 3, totalKnown: false } }); expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress: { ...progress, attemptNo: 4, total: 0, completed: 0 } }); expect(w.text()).toContain('本阶段数量为零'); expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress: { ...progress, attemptNo: 5, total: 1, completed: 2 } }); expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress: { ...progress, attemptNo: 6, total: null, completed: 0 } })
expect(w.find('[role="progressbar"]').exists()).toBe(false)
expect(w.find('.collection-count-grid dd').text()).toContain('—')
expect(w.text()).toContain('总量或计数依据待确认')
expect(w.text()).not.toContain('本阶段数量为零')
await w.setProps({ progress: { ...progress, attemptNo: 7, total: 0, completed: 0 } })
expect(w.find('[role="progressbar"]').exists()).toBe(false)
expect(w.find('.collection-count-grid dd strong').text()).toBe('0')
expect(w.text()).toContain('本阶段数量为零')
})
it('shows the percentage once an initially unknown total becomes available', async () => {
const w = mount(CollectionProgressGauge, { props: { taskNo: source.taskNo, progress: { ...progress, totalKnown: false, total: null } } }); wrappers.push(w)
@@ -3,13 +3,21 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import type { Progress, Task, TaskDetail, WarehouseOverview } from '../types'
import CollectionProgressGauge from './CollectionProgressGauge.vue'
import { beijingTime, label, phaseText, quantityText, reasonsText, unitText } from '../state'
import { taskStateText, isNormalContinuation } from '../collectionPresentation'
const props = defineProps<{ task: Task; detail?: TaskDetail | null; warehouse?: WarehouseOverview | null; stale?: boolean; visible?: boolean; observedAt?: string | null; compact?: boolean }>()
import { collectionControlState, taskStateText, isNormalContinuation } from '../collectionPresentation'
import '@/styles/adminWorkbench.css'
const props = withDefaults(defineProps<{ task: Task; detail?: TaskDetail | null; warehouse?: WarehouseOverview | null; stale?: boolean; visible?: boolean; observedAt?: string | null; compact?: boolean; collectionMode?: string | null; accountPaused?: boolean }>(), { visible: true })
const confirmedSource = computed(() => {
const source = props.detail?.sharedUpstream
return source && ['WAITING_CONFIRMED', 'SATISFIED', 'FAILED'].includes(props.task.dependencyState ?? '') ? source : null
})
const workingTask = computed(() => confirmedSource.value && props.task.dependencyState === 'WAITING_CONFIRMED' && props.task.progressSourceTaskNo === confirmedSource.value.taskNo ? confirmedSource.value : props.task)
const controlEvidence = computed(() => ({
collectionMode: props.collectionMode,
accountPaused: Boolean(props.accountPaused || props.detail?.protection?.accountPauseReason || props.warehouse?.protection?.accountPauseReason),
blockingReason: props.task.blockingReason,
}))
const control = computed(() => collectionControlState(workingTask.value, controlEvidence.value))
const stateText = (task: Task) => props.stale ? '执行状态待确认' : props.visible === false ? '页面已暂停更新' : taskStateText(task, controlEvidence.value)
const blockingText = computed(() => isNormalContinuation(props.task) && props.task.blockingReason === 'RETRY_SCHEDULED' ? '本批已完成,等待续采' : ({ UPSTREAM_UNCONFIRMED: '关联来源尚未确认', UPSTREAM_NOT_READY: '等待账号共享来源就绪', UPSTREAM_FAILED: '关联来源失败,等待恢复', RETRY_SCHEDULED: '等待计划重试', EXECUTION_YIELDED: '本批已完成,等待续采', QUEUED: '排队等待执行' } as Record<string, string>)[props.task.blockingReason ?? ''] ?? reasonsText(props.task.blockingReason ? [props.task.blockingReason] : []))
const progress = computed(() => workingTask.value.progress)
const now = ref(Date.now())
@@ -39,6 +47,16 @@ const estimateText = computed(() => {
if (!progress.value?.totalKnown) return '总量待确认,暂不估算'
return '估算依据待确认'
})
const progressNotice = computed(() => {
if (props.stale) return '状态更新失败,已暂停估算'
if (props.visible === false) return '页面已暂停更新,已暂停估算'
return props.compact && props.task.blockingReason ? blockingText.value : estimateText.value
})
const estimateDetail = computed(() => [
estimate.value ? `最近速度 ${quantityText(estimate.value.ratePerSecond)} ${unitText(progress.value?.unit)}/秒;估算更新 ${beijingTime(estimate.value.estimatedAt)}` : '',
'仅当前阶段;整个任务完成时间尚未估算。',
].filter(Boolean).join('\n'))
const showEstimate = computed(() => !control.value && (!props.compact || !props.stale && props.visible !== false))
const taskElapsed = computed(() => {
const value = props.task.elapsedSeconds
if (value == null || typeof value === 'string' && !/^\d+$/.test(value)) return null
@@ -59,23 +77,29 @@ function stageState(node: { title: string; scope: string; phases: string[] }) {
if (!p) return '状态未确认'
const matching = (stage: Partial<Progress>) => node.phases.includes(stage.phase ?? '') && stage.attemptNo != null && stage.attemptNo === p.attemptNo && stage.scanRound === p.scanRound && stage.sliceFrom === p.sliceFrom && stage.sliceUntil === p.sliceUntil && stage.progressScope === p.progressScope
if ([p, ...(p.stages ?? [])].some(stage => matching(stage) && stage.stageFinishedAt)) return '✓ 已结束'
if (node.phases.includes(p.phase ?? '') && ['RUNNING', 'APPLYING'].includes(task.taskStatus)) return '正在处理'
if (node.phases.includes(p.phase ?? '')) {
if (props.stale || props.visible === false) return '上次阶段 · 状态待确认'
if (['RUNNING', 'APPLYING'].includes(task.taskStatus)) return '正在处理'
const stageControl = collectionControlState(task, controlEvidence.value)
if (stageControl) return `当前阶段 · ${stageControl.text}`
}
if (node.scope === '本仓' && ['WAITING_CONFIRMED', 'WAITING_UNCONFIRMED', 'FAILED'].includes(task.dependencyState ?? '')) return '等待来源'
return '状态未确认'
}
</script>
<template>
<section class="collection-progress" aria-label="关注任务进展">
<section class="collection-progress admin-workbench" :data-estimate="showEstimate" aria-label="关注任务进展">
<template v-if="!compact">
<div class="density-actions"><strong>{{ task.warehouseCode }} · {{ task.dataFamily === 'MASTER_DATA' ? '客户/商品资料' : task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}</strong><span>{{ taskStateText(task) }}</span></div>
<p class="collection-caption">数据范围 {{ beijingTime(task.queryFrom) }} 至 {{ beijingTime(task.queryUntil) }}(结束边界不含)</p>
<div class="density-actions"><strong>{{ task.warehouseCode }} · {{ task.dataFamily === 'MASTER_DATA' ? '客户/商品资料' : task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}</strong><span>{{ stateText(task) }}</span></div>
<p class="collection-caption workbench-note">数据范围 {{ beijingTime(task.queryFrom) }} 至 {{ beijingTime(task.queryUntil) }}(结束边界不含)</p>
<p v-if="task.dependencyState === 'WAITING_UNCONFIRMED'">本任务等待来源;关联进度尚未确认。</p>
<p v-if="confirmedSource">已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ taskStateText(confirmedSource) }};来源完成后,本仓仍需更新出库数据、计算与发布。</p>
<p v-if="confirmedSource">已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ stateText(confirmedSource) }};来源完成后,本仓仍需更新出库数据、计算与发布。</p>
</template>
<p v-if="!compact && (task.blockingReason || task.latestAttempt?.failureSummary)" role="status">{{ task.blockingReason ? blockingText : task.latestAttempt?.failureSummary }}</p>
<p v-if="!compact && !control && !stale && visible !== false && task.blockingReason" role="status">{{ blockingText }}</p>
<CollectionProgressGauge :progress="progress" :task-no="workingTask.taskNo" :running="!stale && visible !== false && !progress?.stageFinishedAt && ['RUNNING', 'APPLYING'].includes(workingTask.taskStatus)" :stale="stale" :compact="compact" />
<div class="collection-estimate"><strong>{{ compact && !stale && visible !== false && task.blockingReason ? blockingText : estimateText }}</strong><span v-if="compact && task.latestAttempt?.failureSummary" role="status">{{ task.latestAttempt.failureSummary }}</span><span v-if="estimate">最近速度 {{ quantityText(estimate.ratePerSecond) }} {{ unitText(progress?.unit) }}/秒;估算更新 {{ beijingTime(estimate.estimatedAt) }}</span><span>仅当前阶段;整个任务完成时间尚未估算。</span></div>
<p>任务累计耗时 {{ taskElapsed == null ? '—' : `${Math.floor(taskElapsed / 60)} 分 ${taskElapsed % 60} 秒` }}<small>(含排队与重试)</small></p>
<div v-if="showEstimate" class="collection-estimate workbench-notice"><strong class="workbench-metric-label" :title="compact ? estimateDetail : undefined">{{ progressNotice }}</strong><span v-if="!compact && estimate" class="workbench-note">最近速度 {{ quantityText(estimate.ratePerSecond) }} {{ unitText(progress?.unit) }}/秒;估算更新 {{ beijingTime(estimate.estimatedAt) }}</span><span v-if="!compact" class="workbench-note">仅当前阶段;整个任务完成时间尚未估算。</span></div>
<p v-if="task.latestAttempt?.failureSummary" class="workbench-note" role="status">{{ task.latestAttempt.failureSummary }}</p>
<p class="workbench-note">任务累计耗时 {{ taskElapsed == null ? '—' : `${Math.floor(taskElapsed / 60)} 分 ${taskElapsed % 60} 秒` }}<small>(含排队与重试)</small></p>
<template v-if="!compact">
<ol class="collection-flow" :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }" aria-label="处理阶段"><li v-for="node in flow" :key="node.title" :data-state="stageState(node)"><strong>{{ node.title }}</strong><small>{{ node.scope }}</small><span>{{ stageState(node) }}</span></li></ol>
<div class="collection-clocks"><div>页面读取时间<strong>{{ beijingTime(detail?.observedAt || observedAt) }}</strong><small>读取成功不代表采集推进</small></div><div>最近真实推进 · {{ workingTask === task ? '本仓' : '账号共享' }}<strong>{{ beijingTime(workingTask.progressUpdatedAt || progress?.progressUpdatedAt) }}</strong><small>{{ task.attemptCount === 0 && workingTask === task ? '本仓尚未开始执行' : '以已提交检查点为准' }}</small></div><div>执行心跳<strong>{{ beijingTime(workingTask.latestAttempt?.heartbeatAt) }}</strong><small>{{ label(workingTask.latestAttempt?.executionHealth) }};心跳不代表完成数增加</small></div></div>
@@ -88,13 +112,14 @@ function stageState(node: { title: string; scope: string; phases: string[] }) {
<style scoped>
.collection-progress { min-width: 0; padding-block: var(--admin-space-4); }
p { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
.collection-caption, small { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.collection-estimate { display: flex; gap: var(--admin-space-4); flex-wrap: wrap; align-items: baseline; background: var(--el-fill-color-light); padding: var(--admin-space-3); margin-block: var(--admin-space-4); }
small { font-size: var(--workbench-note-size); color: var(--workbench-muted); }
.collection-estimate { display: flex; gap: 4px var(--admin-space-4); flex-wrap: wrap; align-items: baseline; margin-block: var(--admin-space-4); }
.collection-estimate > strong { flex-basis: 100%; }
.collection-flow { display: grid; gap: var(--admin-space-4); list-style: none; padding: 0; margin: var(--admin-space-5) 0; }
.collection-flow li { display: grid; gap: var(--admin-space-2); border-top: 2px solid var(--el-border-color); padding-top: var(--admin-space-3); }
.collection-flow li { display: grid; min-width: 0; gap: var(--admin-space-2); border-top: 2px solid var(--workbench-border); padding-top: var(--admin-space-3); overflow-wrap: anywhere; }
.collection-flow li[data-state^="✓"] { border-color: var(--el-color-success); }
.collection-flow li[data-state="正在处理"] { border-color: var(--el-color-primary); }
.collection-clocks { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: var(--admin-space-4); border-top: 1px solid var(--el-border-color); padding-top: var(--admin-space-4); }
.collection-clocks div { display: grid; gap: var(--admin-space-2); }
.collection-flow li[data-state="正在处理"] { border-color: var(--workbench-primary); }
.collection-clocks { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: var(--admin-space-4); border-top: 1px solid var(--workbench-border); padding-top: var(--admin-space-4); }
.collection-clocks div { display: grid; min-width: 0; gap: var(--admin-space-2); overflow-wrap: anywhere; }
.collection-clocks strong { font-weight: 500; font-variant-numeric: tabular-nums; }
</style>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ChartNoAxesCombined } from '@lucide/vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { ECElementEvent } from 'echarts'
import { quantityText } from '../state'
@@ -19,18 +20,21 @@ function selectChart(event: ECElementEvent) {
}
</script>
<template>
<div :aria-label="title" class="dashboard-bars">
<div :aria-label="title" class="dashboard-bars workbench-chart">
<DashboardChartHeading :title="title">
<span v-if="rows.length" class="chart-details-action"><el-button link type="primary" title="查看完整明细" @click="detailsOpen = true">明细</el-button></span>
<slot name="actions" />
</DashboardChartHeading>
<slot name="intro" />
<slot v-if="!rows.length" name="empty"><p>暂无符合条件的 SKU;请核对覆盖与待评估资料。</p></slot>
<div v-if="!rows.length" class="workbench-chart-empty" role="status">
<span class="workbench-chart-empty-icon" aria-hidden="true"><ChartNoAxesCombined :size="28" /></span>
<slot name="empty"><p>暂无符合条件的 SKU;请核对覆盖与待评估资料。</p></slot>
</div>
<template v-else>
<AnalyticsChart :option="option" :style="{ height: `${Math.max(240, rows.length * 38 + 65)}px` }" :aria-label="title" @select="selectChart" />
<p v-if="rows.some(row => row.value == null)">存在待核验数量,以“—”表示,不作为零值。</p>
<AppDialog v-model="detailsOpen" class="admin-density-dialog" :title="`${title} · 完整明细`" :width="detailsWidth || '1040px'" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close>
<div class="admin-density chart-details">
<AnalyticsChart :option="option" class="workbench-chart-canvas" :style="{ height: `${Math.max(240, rows.length * 38 + 65)}px` }" :aria-label="title" @select="selectChart" />
<p v-if="rows.some(row => row.value == null)" class="workbench-note">存在待核验数量,以“—”表示,不作为零值。</p>
<AppDialog v-model="detailsOpen" class="admin-density-dialog admin-workbench is-dialog" :title="`${title} · 完整明细`" :width="detailsWidth || '1040px'" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close>
<div class="admin-density chart-details workbench-evidence">
<slot name="details" :select="select">
<p>当前图表共 {{ rows.length }} 项;点击项目查看对应明细。</p>
<table class="density-table"><thead><tr><th v-if="ranked" class="rank-column">排名</th><th class="name-column">项目/完整名称</th><th class="quantity-column">数量({{ unit }})</th><th>说明</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="row.key"><td v-if="ranked">{{ index + 1 }}</td><td><button type="button" :disabled="disabled || row.value == null" @click="select(row)">{{ row.label }}</button><small v-if="row.secondary">{{ row.secondary }}</small></td><td>{{ quantityText(row.value) }}</td><td>{{ row.detail || '—' }}</td></tr></tbody></table>
@@ -2,15 +2,8 @@
defineProps<{ title: string }>()
</script>
<template>
<div class="dashboard-chart-heading">
<h3>{{ title }}</h3>
<div class="dashboard-chart-actions"><slot /></div>
<div class="dashboard-chart-heading workbench-chart-heading">
<h3 class="workbench-chart-title">{{ title }}</h3>
<div class="dashboard-chart-actions workbench-chart-actions"><slot /></div>
</div>
</template>
<style scoped>
.dashboard-chart-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-2); }
h3 { display: flex; align-items: center; gap: var(--admin-space-2); margin: 0; font-size: var(--admin-font-section); }
h3::before { content: ''; width: 3px; height: 14px; border-radius: 2px; background: #5575ed; flex-shrink: 0; }
.dashboard-chart-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--admin-space-3); margin-left: auto; }
.dashboard-chart-actions :deep(.el-button + .el-button) { margin-left: 0; }
</style>
@@ -10,6 +10,13 @@ function point(date: string, value: number | null, dataStatus = 'COMPLETE'): Dai
return { businessDate: date, outboundQuantity: dataStatus === 'COMPLETE' ? value : null, observedQuantity: value, dataStatus, reasonCodes: [], quantityUnit: 'PCS' }
}
describe('dashboard chart integrity and interaction', () => {
it('shows a truthful empty state without creating a placeholder chart', () => {
const wrapper = mount(DashboardBars, { props: { title: '库存排行', unit: '件', rows: [] } })
expect(wrapper.find('.workbench-chart-empty[role=status]').text()).toContain('暂无符合条件的 SKU')
expect(wrapper.findComponent({ name: 'VChart' }).exists()).toBe(false)
expect(wrapper.emitted('select')).toBeUndefined()
wrapper.unmount()
})
it('keeps dates and line explanations behind the description action', async () => {
const wrapper = mount(DashboardTrend, { props: { points: [point('2026-10-01', 2)] }, slots: { explanation: '<p>统计日期示例</p>' } })
expect(wrapper.text()).not.toContain('统计日期示例')
@@ -7,9 +7,10 @@ import type { DailyPoint } from '../types'
import { label, quantityText, reasonsText, shownQuantity } from '../state'
import { skuTrendOption } from '../skuTrendChart'
import AnalyticsChart from './AnalyticsChart.vue'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
const props = defineProps<{ points: DailyPoint[]; disabled?: boolean }>()
const emit = defineEmits<{ select: [date: string] }>()
const option = computed(() => ({ ...skuTrendOption(props.points), aria: { enabled: true, description: '全仓每日出库趋势,点击已知日点查看当日销量。缺失日期断线,未完整日虚线。' } }))
const option = computed(() => ({ ...skuTrendOption(props.points, adminWorkbenchChartTheme), aria: { enabled: true, description: '全仓每日出库趋势,点击已知日点查看当日销量。缺失日期断线,未完整日虚线。' } }))
const hasData = computed(() => props.points.some(point => point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null))
const detailsOpen = ref(false)
function available(point: DailyPoint) { return !props.disabled && point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null }
@@ -17,7 +18,7 @@ function select(point: DailyPoint) { if (available(point)) { detailsOpen.value =
function selectChart(event: ECElementEvent) { const point = props.points[event.dataIndex]; if (event.componentType === 'series' && point) select(point) }
</script>
<template>
<div>
<div class="workbench-chart">
<DashboardChartHeading title="出库趋势">
<ChartDetailsDialog v-model="detailsOpen" inline button-label="明细" title="查看每日数值与覆盖原因" :reset-on="points"><table class="density-table"><thead><tr><th>日期(点击查看)</th><th>正式量</th><th>已采集量</th><th>状态</th><th>原因</th></tr></thead><tbody><tr v-for="point in points" :key="point.businessDate"><td><button type="button" :disabled="!available(point)" @click="select(point)">{{ point.businessDate }}</button></td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr></tbody></table></ChartDetailsDialog>
<ChartDetailsDialog inline button-label="说明" title="出库趋势 · 统计说明" width="760px" :reset-on="points">
@@ -25,8 +26,8 @@ function selectChart(event: ECElementEvent) { const point = props.points[event.d
<p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 未知及缺失日期断线;今日为已采集累计</p>
</ChartDetailsDialog>
</DashboardChartHeading>
<AnalyticsChart :option="option" class="dashboard-trend" aria-label="全仓每日出库趋势" @select="selectChart" />
<p v-if="!hasData">所选日期暂无可靠出库资料,未知不按零显示。</p>
<AnalyticsChart :option="option" class="dashboard-trend workbench-chart-canvas" aria-label="全仓每日出库趋势" @select="selectChart" />
<p v-if="!hasData" class="workbench-note">所选日期暂无可靠出库资料,未知不按零显示。</p>
</div>
</template>
@@ -35,4 +36,4 @@ function selectChart(event: ECElementEvent) { const point = props.points[event.d
.trend-legend { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
td button { font: inherit; color: var(--el-color-primary); background: transparent; border: 0; cursor: pointer; padding: var(--admin-space-2); }
td button:disabled { color: var(--el-text-color-secondary); cursor: default; }
</style>
</style>
@@ -35,22 +35,29 @@ describe('inventory turnover page fixed snapshot behavior', () => {
const w = page(); await flushPromises()
const field = w.findComponent(toolbar).props('fields').find((f: {key: string}) => f.key === 'customerCodes')
expect(field.props).toMatchObject({ multiple: true, filterable: true, collapseTags: true })
expect(w.findComponent(table).props('columns')[0]).toMatchObject({ prop: 'sku', fixed: 'left' })
const fields = w.findComponent(toolbar).props('fields')
expect(fields.find((f: { key: string }) => f.key === 'focus').options[0]).toEqual({ value: 'ALL', label: '全部关注条件' })
expect(fields.find((f: { key: string }) => f.key === 'turnoverStatus').placeholder).toBe('全部样本状态')
field.props.onChange(['B', 'OWNER-A']); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['B', 'OWNER-A'], current: 1, includeOverview: true })
expect(w.text()).toContain('所选货主概览')
expect(w.get('.turnover-statistics').text()).toContain('所选货主概览')
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0]).toMatchObject({ customerCodes: ['B', 'OWNER-A'], current: 2, snapshotContext: 'token-A', includeOverview: false })
await change(w, { groupBy: 'OWNER' })
expect(w.findComponent(table).props('columns')[0]).toMatchObject({ prop: 'customerCode', fixed: 'left' })
await w.findAll('button').find(b => b.text() === 'OWNER-A')!.trigger('click'); await flushPromises()
await w.findAll('button').find(b => b.text() === '返回货主汇总')!.trigger('click'); await flushPromises()
expect(w.findComponent(toolbar).props('modelValue').customerCodes).toEqual(['B', 'OWNER-A'])
expect(w.findComponent(table).props('columns')[0]).toMatchObject({ prop: 'customerCode', fixed: 'left' })
w.findComponent(toolbar).vm.$emit('reset'); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0].customerCodes).toEqual([])
expect(w.findComponent(table).props('columns')[0]).toMatchObject({ prop: 'sku', fixed: 'left' })
})
it('submits one backend export with applied filters, visible columns and fixed version without paging in the browser', async () => {
const w = page(); await flushPromises()
await change(w, { customerCodes: ['OWNER-A'], sortBy: 'TURNOVER_DESC' }, true)
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
await w.findAll('button').find(b => b.text() === '显示更多指标')!.trigger('click')
w.findComponent(table).vm.$emit('update:page', 2); await flushPromises()
const before = mocks.turnover.mock.calls.length
await w.findAll('button').find(b => b.text() === '导出')!.trigger('click'); await flushPromises()
@@ -66,6 +73,10 @@ describe('inventory turnover page fixed snapshot behavior', () => {
expect(w.findComponent(table).props('page')).toBe(2)
expect(w.text()).toContain('导出任务已提交')
expect(turnoverPercent(null)).toBe('—'); expect(turnoverPercent(0)).toBe('0%'); expect(turnoverPercent(1.25)).toBe('125%')
await w.findAll('button').find(b => b.text() === '收起更多指标')!.trigger('click')
expect(w.findComponent(table).props('columns').some((column: { prop: string }) => column.prop === 'averagePhysicalQuantity')).toBe(false)
expect(w.findComponent(table).props('columns')[0]).toMatchObject({ prop: 'sku', fixed: 'left' })
expect(mocks.turnover).toHaveBeenCalledTimes(before)
})
it('requires independent export permission and displays submission failures', async () => {
mocks.exportAllowed = false
@@ -104,7 +115,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
const w = page(); await flushPromises()
expect(w.findComponent(table).props('columns').some((column: { prop: string }) => column.prop === 'attentionReasons')).toBe(false)
expect(w.text()).not.toContain('日样本 0/30')
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
await w.findAll('button').find(b => b.text() === '显示更多指标')!.trigger('click')
expect(w.findAll('span[title="历史库存记录不足,暂不能计算期间平均库存和周转次数"]').map(n => n.text())).toEqual(['—', '—', '—'])
// Object-valued native options retain the selected row, including its frozen evidence.
const select = w.get('select[aria-label="选择历史库存记录"]')
@@ -159,14 +170,19 @@ describe('inventory turnover page fixed snapshot behavior', () => {
return result
})
const w = page(); await flushPromises()
expect(w.text()).toContain('缺尺寸 2 SKU / 12 件')
expect(w.text()).toContain('缺尺寸 2 SKU')
expect(w.text()).toContain('已知小计 0.125 m³')
expect(w.text()).toContain('其中 40 件按商品尺寸估算')
const volumeValues = w.findAll('.turnover-volume-value')
expect(volumeValues[0]!.attributes('title')).toContain('缺尺寸 2 SKU / 12 件')
expect(volumeValues[1]!.attributes('title')).toContain('其中 40 件按商品尺寸估算')
expect(volumeValues[1]!.attributes('title')).toContain('按当前商品尺寸估算')
expect(w.findComponent(table).props('columns').find((c: { prop: string }) => c.prop === 'outboundVolumesM3').label).toContain('估算')
expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'unitVolumeM3')).toBe(true)
await change(w, { groupBy: 'OWNER', rangePreset: 'LAST_3_DAYS' })
expect(w.findComponent(table).props('columns').find((c: {prop: string}) => c.prop === 'outboundVolumesM3').label).toContain('3日')
expect(w.findComponent(table).props('columns').some((c: {prop: string}) => c.prop === 'unitVolumeM3')).toBe(false)
expect(w.text()).toContain('缺尺寸 2 SKU / 12 件')
expect(w.text()).toContain('缺尺寸 2 SKU')
expect(w.findAll('.turnover-volume-value')[0]!.attributes('title')).toContain('缺尺寸 2 SKU / 12 件')
})
it('filters turnover by directory customer and keeps the code in pagination', async () => {
@@ -180,7 +196,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
it('sorts every numeric header on the server in both directions with the same snapshot', async () => {
const w = page(); await flushPromises()
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
await w.findAll('button').find(b => b.text() === '显示更多指标')!.trigger('click')
const metrics = { inventoryVolumeM3: 'INVENTORY_VOLUME', outboundVolumesM3: 'OUTBOUND_VOLUME', unitVolumeM3: 'UNIT_VOLUME', physicalQuantity: 'PHYSICAL', availableQuantity: 'AVAILABLE', transitQuantity: 'TRANSIT', outbound7: 'OUTBOUND_7', outbound15: 'OUTBOUND_15', outbound30: 'OUTBOUND_30', averageDailyOutboundQuantity: 'AVERAGE_DAILY', inventoryCoverageDays: 'COVERAGE', frozenQuantity: 'FROZEN', defectiveQuantity: 'DEFECTIVE', averagePhysicalQuantity: 'AVERAGE_PHYSICAL', quantityTurnoverCount: 'TURNOVER' }
for (const [prop, metric] of Object.entries(metrics)) {
// Start on a later page and with another sort to prove that a header resets pagination.
@@ -216,7 +232,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
expect(header.attributes('aria-label')).toContain(direction === 'ASC' ? '当前升序' : '当前降序')
expect(w.findComponent(toolbar).props('modelValue').sortBy).toBe(`TURNOVER_${direction}`)
}
await w.findAll('button').find(b => b.text() === '扩展列')!.trigger('click')
await w.findAll('button').find(b => b.text() === '显示更多指标')!.trigger('click')
expect(w.get('button[data-column="quantityTurnoverCount"]').attributes('aria-label')).toContain('当前升序')
})
it('keeps the successful sort marker and page when a header query fails', async () => {
@@ -346,8 +362,34 @@ describe('inventory turnover page fixed snapshot behavior', () => {
expect(w.text()).toContain('300')
expect(w.text()).toContain('暂无可排名的库存数据')
expect(w.findAll('.turnover-cards article[data-kind="quantity"] strong').map(n => n.text())).toEqual(['100', '100', '100', '100'])
expect(w.findAll('.turnover-cards article small')).toHaveLength(0)
expect(w.find('.turnover-context').exists()).toBe(false)
expect(w.findAll('.turnover-summary-group > header').map(header => header.text())).toEqual(['当前库存', '所选时段分析'])
expect(w.get('.turnover-statistics').text()).toContain('当前库存采集')
expect(w.get('.turnover-statistics').text()).toContain('不随列表SKU或关注筛选改变')
expect(w.get('article[data-metric="inventory-volume"] strong').attributes('title')).toContain('按仓库实测尺寸计算')
expect(w.get('article[data-metric="outbound-volume"] strong').attributes('title')).toContain('按当前商品尺寸估算')
expect(w.findAll('.turnover-volume-value').map(value => value.text())).toEqual(['1.25 m³', '1.25 m³', '0.0125 m³'])
expect(w.findAll('.turnover-volume-value').map(value => value.attributes('title'))).toEqual(['按仓库实测尺寸计算', '按当前商品尺寸估算', '仓库实测尺寸'])
expect(w.findAllComponents({ name: 'AppDialog' }).every(d => d.attributes('append-to-body') !== undefined)).toBe(true)
})
it('keeps the chart toggle in the toolbar and preserves a collapsed preference across refresh', async () => {
sessionStorage.setItem('selfop-turnover-charts', 'false')
const w = page(); await flushPromises()
const toggle = () => w.findAll('button').find(button => button.text() === '图表')!
expect(toggle().attributes()).toMatchObject({ 'aria-pressed': 'false', title: '展开图表' })
expect(w.find('.turnover-charts').exists()).toBe(false)
const calls = mocks.turnover.mock.calls.length
await toggle().trigger('click'); await flushPromises()
expect(toggle().attributes()).toMatchObject({ 'aria-pressed': 'true', title: '收起图表' })
expect(w.find('.turnover-charts').exists()).toBe(true)
expect(mocks.turnover).toHaveBeenCalledTimes(calls)
await toggle().trigger('click'); await flushPromises()
expect(sessionStorage.getItem('selfop-turnover-charts')).toBe('false')
await w.findAll('button').find(button => button.text() === '整体刷新')!.trigger('click'); await flushPromises()
expect(toggle().attributes('aria-pressed')).toBe('false')
expect(w.find('.turnover-charts').exists()).toBe(false)
})
it('honors an explicit new route token in the same scope and reloads its overview', async () => {
const w = page(); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBeUndefined()
@@ -381,7 +423,7 @@ describe('inventory turnover page fixed snapshot behavior', () => {
expect((old[1] as AbortSignal).aborted).toBe(true)
finish(response(old[0])); await flushPromises()
expect(mocks.turnover.mock.calls.at(-1)![0].warehouseCode).toBe('B')
expect(w.text()).toContain('本仓概览')
expect(w.get('.turnover-statistics').text()).toContain('本仓概览')
await change(w, { sku: 'X' }, true)
expect(mocks.turnover.mock.calls.at(-1)![0].snapshotContext).toBe('token-B')
})
@@ -1,9 +1,10 @@
<script setup lang="ts">
import InlineHelp from '@/shared/components/InlineHelp.vue'
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Info } from '@lucide/vue'
import { Boxes, ChartNoAxesCombined, Info, Ruler } from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -68,8 +69,8 @@ const fields = computed<QueryField[]>(() => [
{ key: 'groupBy', label: '分组', type: 'select', width: 120, options: [{ value: 'SKU', label: '按SKU' }, { value: 'OWNER', label: '按货主' }], props: { clearable: false } },
{ key: 'customerCodes', label: '货主', type: 'select', width: 260, options: ownerDirectory.options.value, placeholder: '全部货主(可多选)', props: { multiple: true, multipleLimit: 100, collapseTags: true, collapseTagsTooltip: true, maxCollapseTags: 1, filterable: true, clearable: true, onChange: changeOwners } },
...(filters.value.groupBy === 'SKU' ? [{ key: 'sku', label: 'SKU', type: 'input' as const, width: 175, placeholder: 'SKU精确查询' }] : []),
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
{ key: 'focus', label: '关注条件', type: 'select', width: 165, props: { clearable: false }, options: [{ value: 'ALL', label: '全部关注条件' }, { value: 'NO_OUTBOUND_IN_WINDOW', label: '所选时段无出库' }, { value: 'COVERAGE_GT', label: '消化较慢' }, { value: 'COVERAGE_LE', label: '消化较快' }, { value: 'FROZEN', label: '有冻结库存' }, { value: 'DEFECTIVE', label: '有次品库存' }, { value: 'UNASSESSABLE', label: '待评估' }] },
{ key: 'turnoverStatus', label: '周转样本', type: 'select', width: 160, placeholder: '全部样本状态', options: [{ value: '', label: '全部样本状态' }, { value: 'INSUFFICIENT', label: '周转样本不足' }] },
{ key: 'sortBy', label: '排序', type: 'select', width: 190, props: { clearable: false }, options: turnoverSortOptions.filter(({ value }) => filters.value.groupBy === 'OWNER' ? !value.startsWith('COVERAGE_') && !value.startsWith('UNIT_VOLUME_') : value !== 'NO_OUTBOUND_PHYSICAL_DESC') },
])
const selectedOwners = computed(() => Array.isArray(filters.value.customerCodes) ? filters.value.customerCodes as string[] : [])
@@ -80,12 +81,12 @@ function changeOwners(codes: string[]) {
}
const ownerMode = computed(() => applied.value?.groupBy === 'OWNER')
const columns = computed<DataTableColumn[]>(() => [
...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slotName: 'sku', minWidth: 155 }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
{ prop: 'customerCode', label: '所属货主', slotName: 'owner', minWidth: 160 },
...(ownerMode.value ? [] : [{ prop: 'sku', label: 'SKU', slotName: 'sku', minWidth: 155, fixed: 'left' }, { prop: 'productName', label: '商品名称', minWidth: 170 }]),
{ prop: 'customerCode', label: '所属货主', slotName: 'owner', minWidth: 160, ...(ownerMode.value ? { fixed: 'left' } : {}) },
...(ownerMode.value ? [{ prop: 'physicalSkuCount', label: '有实物SKU数', minWidth: 125 }] : []),
{ prop: 'physicalQuantity', label: '实物件数', slotName: 'physicalQuantity', minWidth: 145 }, { prop: 'availableQuantity', label: '正品可售', slotName: 'availableQuantity', minWidth: 145 },
{ prop: 'inventoryVolumeM3', label: '库存立方数', slotName: 'inventoryVolume', minWidth: 145 },
{ prop: 'outboundVolumesM3', label: `${applied.value?.baseWindowDays ?? 30}日出库立方数`, slotName: 'outboundVolume', minWidth: 170 },
{ prop: 'outboundVolumesM3', label: `${applied.value?.baseWindowDays ?? 30}日出库立方数(估算)`, slotName: 'outboundVolume', minWidth: 170 },
...(ownerMode.value ? [] : [{ prop: 'unitVolumeM3', label: '单件体积', slotName: 'unitVolume', minWidth: 135 }]),
{ prop: 'transitQuantity', label: '在途', slotName: 'transitQuantity', minWidth: 125 },
...([7, 15, 30] as const).map(n => ({ prop: `outbound${n}`, label: `${n}日出库`, slotName: `outbound${n}`, minWidth: 160 })),
@@ -110,22 +111,30 @@ const incompleteDailyWindows = computed(() => data.value?.charts?.averageDaily.f
const summaryGroups = computed(() => {
const summary = data.value?.summary
if (!summary) return []
const quantity = (key: string, name: string, value: TurnoverTotal, tone: string) => ({ key, name, kind: 'quantity', tone, text: turnoverNumber(value.value), note: turnoverTotalNote(value), title: undefined as string | undefined })
const volume = (key: string, name: string, value: TurnoverVolume | undefined, outbound: boolean, tone: string) => ({ key, name, kind: 'volume', tone, text: turnoverNumber(value?.value, 2), note: volumeNote(value, outbound, 2), title: value?.value != null ? volumeText(value.value) : undefined })
const quantity = (key: string, name: string, value: TurnoverTotal, tone: string) => ({ key, name, kind: 'quantity', tone, text: turnoverNumber(value.value), note: value.value == null ? turnoverTotalNote(value) : '', title: undefined as string | undefined })
const volume = (key: string, name: string, value: TurnoverVolume | undefined, outbound: boolean, tone: string) => ({ key, name, kind: 'volume', tone, text: turnoverNumber(value?.value, 2), note: volumeWarning(value, 2), title: [value?.value != null ? volumeText(value.value) : '', volumeNote(value, outbound)].filter(Boolean).join(';') })
const days = applied.value?.baseWindowDays ?? 30
return [
{ key: 'stock', name: '当前库存', note: '使用当前库存快照,不随统计时段变化', cards: [
quantity('physical', '实物件数', summary.physicalQuantity, 'primary'),
{ key: 'stock', name: '当前库存', cards: [
quantity('physical', '实物件数', summary.physicalQuantity, 'blue'),
quantity('available', '正品可售件数', summary.availableQuantity, 'green'),
volume('inventory-volume', '库存立方数(m³)', summary.inventoryVolumeM3, false, 'purple'),
] },
{ key: 'period', name: '所选时段分析', note: `${days}天 · 截至 ${applied.value?.endDate ?? '—'}`, cards: [
quantity('no-outbound', '所选时段无出库SKU', summary.noOutboundSkuCount, 'orange'),
{ key: 'period', name: '所选时段分析', cards: [
quantity('no-outbound', '所选时段无出库SKU', summary.noOutboundSkuCount, 'amber'),
volume('outbound-volume', `${days}日出库立方数(m³)`, summary.outboundVolumesM3.find(w => w.days === days)?.volume, true, 'green'),
quantity('unassessable', '待评估SKU', summary.unassessableSkuCount, 'purple'),
] },
]
})
function volumeWarning(value: TurnoverVolume | undefined, decimals = 6) {
if (!value) return '数量或尺寸待确认'
const notes: string[] = []
if (value.value == null) notes.push(value.knownSubtotal == null ? '暂无已知小计' : `已知小计 ${volumeText(value.knownSubtotal, decimals)}`)
if (value.missingDimensionSkuCount) notes.push(`缺尺寸 ${value.missingDimensionSkuCount} SKU`)
if (value.unknownQuantitySkuCount) notes.push(`数量待确认 ${value.unknownQuantitySkuCount} SKU`)
return notes.join(' · ')
}
const runtimeError = ref('')
const inventoryRuntime = computed(() => runtime.value?.warehouses.find(w => w.warehouseCode === applied.value?.warehouseCode && w.dataFamily === 'INVENTORY'))
const collectionNotice = computed(() => !data.value || runtimeError.value ? '' : inventoryCollectionNotice(inventoryRuntime.value))
@@ -270,11 +279,12 @@ onActivated(() => { const resume = initialized && !active; active = true; if (re
onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() })
</script>
<template>
<AdminPage class="admin-density turnover-page">
<AdminPage class="admin-density admin-workbench turnover-page">
<p v-if="!permitted" role="alert">没有库存分析查看权限。</p>
<template v-else>
<LoadErrorState v-if="error" title="库存周转读取失败" :message="error.message" compact @retry="runtime ? load() : initialize()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
<p v-if="inputError" role="alert">{{ inputError }}</p><p v-if="notice" role="status">{{ notice }}</p>
<div class="workbench-table">
<DataTable :data="data?.rows ?? []" :columns="columns" :loading="loading" :total="data?.total ?? 0" :page="page" :page-size="size" appearance="integrated" :empty-text="!warehouses.length ? '暂无可用仓库' : error ? '读取失败,请重试' : '暂无符合条件的数据'" @update:page="page = $event; load()" @update:page-size="size = $event; search()">
<template #sortHeader="{ column }">
<span class="turnover-sort-heading">
@@ -294,8 +304,8 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
</el-popover>
</span>
</template>
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title">库存周转<span>销量 · 库存 · 周转效率</span><InlineHelp title="库存体积说明"><p>体积优先采用完整仓库实测尺寸,缺失时按商品尺寸估算;库存按当前全品质实物件数计算,历史出库按当前商品尺寸估算,不含在途及库位空隙。缺尺寸或数量未知时展示已知小计。</p></InlineHelp></h2></slot></template>
<template #toolbar-actions><el-button v-if="canExport" :disabled="loading || !data || exporting" title="后台导出全部匹配记录与当前显示列,在下载中心下载 Excel" @click="exportRows">{{ exporting ? '提交中…' : '导出' }}</el-button><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起扩展列' : '扩展列' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules"><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷流水还原并经库存锚点核对的每日末实物库存均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p>
<template #toolbar-start><slot name="view-tabs"><h2 class="turnover-title"><span class="workbench-title">库存周转</span><span class="workbench-note">销量 · 库存 · 周转效率</span><InlineHelp title="库存体积说明"><p>体积优先采用完整仓库实测尺寸,缺失时按商品尺寸估算;库存按当前全品质实物件数计算,历史出库按当前商品尺寸估算,不含在途及库位空隙。缺尺寸或数量未知时展示已知小计。</p></InlineHelp></h2></slot></template>
<template #toolbar-actions><el-button v-if="canExport" :disabled="loading || !data || exporting" title="后台导出全部匹配记录与当前显示列,在下载中心下载 Excel" @click="exportRows">{{ exporting ? '提交中…' : '导出' }}</el-button><el-button @click="router.push('/self-operated/collection')">数据采集</el-button><el-button @click="page = 1; load(true)">整体刷新</el-button><el-button v-if="data?.charts" :aria-pressed="chartsOpen" :title="chartsOpen ? '收起图表' : '展开图表'" @click="chartsOpen = !chartsOpen">图表</el-button><el-button @click="expanded = !expanded">{{ expanded ? '收起更多指标' : '显示更多指标' }}</el-button><el-popover title="统计说明" trigger="click" placement="bottom-end" :width="460" teleported><template #reference><el-button aria-label="库存周转统计说明" title="统计说明"><Info :size="16" /></el-button></template><div class="turnover-rules turnover-statistics"><p v-if="data">{{ applied?.customerCodes?.length ? '所选货主概览' : '本仓概览' }} · 截止 {{ applied?.endDate }} · 基准 {{ applied?.baseWindowDays }} 天。当前库存采集 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间)。</p><p>当前库存使用查询时固定的快照,不随统计时段变化。图表与指标为{{ applied?.customerCodes?.length ? '所选货主' : '本仓' }}整体,不随列表SKU或关注筛选改变。</p><p>库存体积优先采用仓库实测尺寸,缺失时按商品尺寸估算;历史出库体积按当前商品尺寸估算。缺尺寸或数量未知时仅展示已知小计,具体来源见数值提示。</p><p>7/15/30天均为截至所选日期的完整滚动窗口。日均仅使用完整窗口;预计可售天数使用查询时固定的当前正品可售库存,不是库龄。</p><p>期间件数周转次数=同期完整出库÷流水还原并经库存锚点核对的每日末实物库存均值。缺日、缺商品或单位异常不补零;当前库存不代替历史平均库存。件数不是占用空间,快慢标签仅供人工复核。</p><p>货主视图按“含此类SKU的货主”筛选;未知数量保留已知小计,不平均SKU可售天数。库存过期不抹去有完整证据的历史销量与周转。</p>
<p>历史库存记录用于计算期间平均库存和周转次数,与已采集的销量天数不同;记录不足不代表销量采集失败。</p>
<template v-if="data?.rows.length">
<label>查看本页历史库存记录
@@ -312,55 +322,83 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<QueryToolbar :model-value="filters" :fields="fields" ariaLabel="库存周转查询" preference-key="selfop-turnover-query" @update:model-value="changeFilters" @search="search" @reset="reset" />
</template>
<template #before-results>
<div v-if="data || selectedOwners.length" class="turnover-context">
<template v-if="selectedOwners.length"><el-tag v-for="code in selectedOwners" :key="code" closable @close="changeOwners(selectedOwners.filter(item => item !== code))">{{ ownerLabel(code) }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners">返回货主汇总</el-button></template>
<template v-if="data"><span>{{ applied?.customerCodes?.length ? '所选货主概览' : '本仓概览' }} · 截止 {{ applied?.endDate }} · 基准 {{ applied?.baseWindowDays }} 天</span><span>当前库存采集 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</span><span v-if="error">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</span></template>
<div v-if="selectedOwners.length" class="turnover-context">
<el-tag v-for="code in selectedOwners" :key="code" closable @close="changeOwners(selectedOwners.filter(item => item !== code))">{{ ownerLabel(code) }}</el-tag><el-button v-if="ownerReturn" link @click="returnOwners">返回货主汇总</el-button>
</div>
<p v-if="data && error" class="turnover-context" role="status">保留 {{ beijingTime(data.context.queryAsOf) }} 查询结果</p>
<p v-if="data?.context.analyticsContext.inventoryStatus === 'STALE'" class="turnover-context" role="status">当前库存已过期,实物、可售、货主排名及预计可售天数暂不可用;最近库存观测完成于 {{ beijingTime(data.context.inventoryObservedUntil) }}。采集成功后请整体刷新。</p>
<p v-else-if="data?.context.analyticsContext.inventoryStatus === 'UNKNOWN'" class="turnover-context" role="status">当前尚无可用库存版本,请查看数据采集。</p>
<p v-if="collectionNotice" class="turnover-context" role="status">{{ collectionNotice }};采集状态截至 {{ beijingTime(inventoryRuntime?.observedAt) }}。<el-button link @click="router.push('/self-operated/collection')">查看数据采集</el-button></p>
<p v-if="runtimeError" class="turnover-context" role="status">{{ runtimeError }}</p>
<div v-if="summaryGroups.length" class="turnover-summary">
<section v-for="group in summaryGroups" :key="group.key" class="turnover-summary-group" :data-group="group.key" :aria-label="group.name">
<header><h4>{{ group.name }}</h4><span>{{ group.note }}</span></header>
<header class="workbench-section-heading"><h4 class="workbench-section-title">{{ group.name }}</h4></header>
<div class="turnover-cards">
<article v-for="card in group.cards" :key="card.key" :data-metric="card.key" :data-kind="card.kind" :data-tone="card.tone">
<span>{{ card.name }}</span><strong :title="card.title">{{ card.text }}</strong><small>{{ card.note }}</small>
<article v-for="card in group.cards" :key="card.key" class="workbench-metric is-compact workbench-tone" :data-metric="card.key" :data-kind="card.kind" :data-tone="card.tone">
<div class="workbench-metric-heading">
<span class="workbench-icon is-compact" aria-hidden="true"><Ruler v-if="card.kind === 'volume'" :size="18" /><Boxes v-else :size="18" /></span>
<span class="workbench-metric-label">{{ card.name }}</span>
</div>
<div class="workbench-metric-value"><strong :title="card.title">{{ card.text }}</strong></div>
<small v-if="card.note" class="workbench-note">{{ card.note }}</small>
</article>
</div>
</section>
</div>
<template v-if="data?.charts">
<div class="turnover-context"><el-button link @click="chartsOpen = !chartsOpen">{{ chartsOpen ? '收起图表' : '展开图表' }}</el-button><span>图表与指标为{{ applied?.customerCodes?.length ? '所选货主' : '本仓' }}整体,不随列表SKU或关注筛选改变</span></div>
<div v-if="chartsOpen" class="turnover-charts">
<section><header class="turnover-chart-header"><h3>货主实物件数 Top10</h3><ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog></header><AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" /><div v-else class="turnover-chart-empty"><strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span></div><p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p></section>
<section aria-label="库存体积与出库对比">
<header class="turnover-chart-header">
<div class="density-status-tabs" role="group" aria-label="图表指标">
<section class="workbench-panel">
<header class="turnover-chart-header workbench-chart-heading">
<h3 class="workbench-chart-title">货主实物件数 Top10</h3>
<div class="workbench-chart-actions">
<ChartDetailsDialog inline button-label="查看明细" title="货主实物件数明细" :reset-on="data.context.snapshotContext"><table><thead><tr><th>货主</th><th>实物件数</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerPhysicalTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td>{{ turnoverNumber(owner.physicalQuantity) }}</td></tr></tbody></table></ChartDetailsDialog>
</div>
</header>
<AnalyticsChart v-if="data.charts.ownerPhysicalTop.length" class="turnover-chart" :option="turnoverChart(data.charts, 'owners')" @select="event => { const owner = data?.charts?.ownerPhysicalTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" />
<div v-else class="turnover-chart-empty workbench-chart-empty is-compact">
<span class="workbench-chart-empty-icon" aria-hidden="true"><ChartNoAxesCombined :size="24" /></span>
<strong>暂无可排名的库存数据</strong><span>库存数量尚未完整确认,确认后展示货主排名</span>
</div>
<p v-if="data.charts.unknownOwnerCount" class="turnover-chart-note workbench-note">{{ data.charts.unknownOwnerCount }} 个货主未能完整计算</p>
</section>
<section class="workbench-panel" aria-label="库存体积与出库对比">
<header class="turnover-chart-header workbench-chart-heading">
<div class="workbench-tabs" role="group" aria-label="图表指标">
<button type="button" :class="{ 'is-active': rightChart === 'volume' }" :aria-pressed="rightChart === 'volume'" @click="rightChart = 'volume'">货主库存立方数 Top10</button>
<button type="button" :class="{ 'is-active': rightChart === 'daily' }" :aria-pressed="rightChart === 'daily'" @click="rightChart = 'daily'">日均出库对比</button>
</div>
<div class="workbench-chart-actions">
<ChartDetailsDialog v-if="rightChart === 'volume'" key="volume" inline button-label="查看明细" title="货主库存立方数明细" :reset-on="data.context.snapshotContext">
<p>当前库存,不随统计时段变化;按完整体积排名,不含在途及库位空隙。</p>
<p v-if="data.charts.unknownVolumeOwnerCount">{{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知,未参与排名。</p>
<table class="density-table"><thead><tr><th>货主</th><th>库存立方数</th><th>计算说明</th></tr></thead><tbody><tr v-for="owner in data.charts.ownerVolumeTop" :key="owner.customerCode"><td><el-button link @click="selectOwner(owner.customerCode)">{{ owner.customerName || owner.customerCode }}</el-button></td><td :title="volumeText(owner.inventoryVolumeM3.value)">{{ volumeText(owner.inventoryVolumeM3.value, 2) }}</td><td>{{ volumeNote(owner.inventoryVolumeM3) }}</td></tr></tbody></table>
</ChartDetailsDialog>
<ChartDetailsDialog v-else key="daily" inline button-label="查看明细" title="日均出库与覆盖明细" :reset-on="data.context.snapshotContext"><p v-for="window in data.charts.averageDaily" :key="window.days">{{ window.startDate }} 至 {{ window.endDateExclusive }}(不含):{{ windowText(window) }};{{ reasonsText(window.reasonCodes) }}</p></ChartDetailsDialog>
</div>
</header>
<template v-if="rightChart === 'volume'">
<AnalyticsChart v-if="data.charts.ownerVolumeTop.length" key="volume" class="turnover-chart" :option="turnoverChart(data.charts, 'volume')" @select="event => { const owner = data?.charts?.ownerVolumeTop[event.dataIndex]; if (owner) selectOwner(owner.customerCode) }" />
<div v-else class="turnover-chart-empty"><strong>暂无可排名的库存体积数据</strong><span>需有可用库存及有效尺寸;未知体积不按零参与排名</span></div>
<p class="turnover-chart-note">当前库存,不随统计时段变化;尺寸口径见明细。<span v-if="data.charts.unknownVolumeOwnerCount">{{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知,未参与排名。</span></p>
<div v-else class="turnover-chart-empty workbench-chart-empty is-compact">
<span class="workbench-chart-empty-icon" aria-hidden="true"><ChartNoAxesCombined :size="24" /></span>
<strong>暂无可排名的库存体积数据</strong><span>需有可用库存及有效尺寸;未知体积不按零参与排名</span>
</div>
<p v-if="data.charts.unknownVolumeOwnerCount" class="turnover-chart-note workbench-note">{{ data.charts.unknownVolumeOwnerCount }} 个货主因缺尺寸或库存数量未知,未参与排名。</p>
</template>
<template v-else>
<p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p><AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" /><p v-else class="turnover-chart-empty">暂无完整窗口的日均出库数据,覆盖情况如下。</p><div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? '日均暂不可用' : '覆盖不足' }}(完整 {{ window.completeDayCount }} / {{ window.days }} 日)</span></div>
<p v-if="data.context.analyticsContext.rebuildRequired" class="turnover-chart-note workbench-note" role="status">日均出库按 {{ beijingTime(data.context.analyticsContext.salesPublishedAt) }} 已发布销量版本计算;新采集数据待重算,发布后请整体刷新。SKU库存评估仍等待重算完成。</p>
<AnalyticsChart v-if="data.charts.averageDaily.some(w => w.dataStatus === 'COMPLETE' && w.averageDailyOutboundQuantity != null)" class="turnover-chart" :option="turnoverChart(data.charts, 'daily')" />
<div v-else class="turnover-chart-empty workbench-chart-empty is-compact">
<span class="workbench-chart-empty-icon" aria-hidden="true"><ChartNoAxesCombined :size="24" /></span>
<p>暂无完整窗口的日均出库数据,覆盖情况如下。</p>
</div>
<div v-if="incompleteDailyWindows.length" class="turnover-window-gaps" role="status"><span v-for="window in incompleteDailyWindows" :key="window.days">{{ window.days }}日:{{ window.dataStatus === 'COMPLETE' ? '日均暂不可用' : '覆盖不足' }}(完整 {{ window.completeDayCount }} / {{ window.days }} 日)</span></div>
</template>
</section>
</div>
</template>
<div class="turnover-list-filters">
<nav class="turnover-focus" aria-label="库存关注快捷筛选">
<nav class="turnover-focus workbench-tabs" aria-label="库存关注快捷筛选">
<button type="button" :aria-pressed="applied?.focus === 'ALL'" @click="quickFocus('ALL', 'PHYSICAL_DESC')">全部</button>
<button type="button" :aria-pressed="applied?.focus === 'NO_OUTBOUND_IN_WINDOW'" @click="quickFocus('NO_OUTBOUND_IN_WINDOW', ownerMode ? 'NO_OUTBOUND_PHYSICAL_DESC' : 'PHYSICAL_DESC')">所选时段无出库</button>
<button type="button" :aria-pressed="applied?.focus === 'COVERAGE_GT'" @click="quickFocus('COVERAGE_GT', ownerMode ? 'PHYSICAL_DESC' : 'COVERAGE_DESC')">消化较慢</button>
@@ -370,7 +408,7 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<el-checkbox class="turnover-zero-filter" border aria-label="隐藏 0 库存" title="按当前实物件数筛选;库存未知仍保留" :model-value="filters.hideZeroInventory !== false" :disabled="loading" @change="toggleZeroInventory">隐藏 0 库存</el-checkbox>
</div>
<div v-if="['COVERAGE_GT', 'COVERAGE_LE'].includes(String(filters.focus))" class="turnover-threshold">
<label>可售天数阈值 <input v-model="filters.coverageDaysThreshold" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天</label>
<label>可售天数阈值 <input v-model="filters.coverageDaysThreshold" class="workbench-input" type="number" min="1" step="1" aria-label="可售天数阈值(天)" @keydown.enter.prevent="search()" /> 天</label>
<el-button @click="search()">应用</el-button>
<InlineHelp title="可售天数阈值说明"><p>按当前可售库存预计还能销售的天数筛选:超过阈值为较慢,不超过阈值为较快。此数值不是上方的统计时间范围;快捷筛选首次默认30天,可修改。零可售或无出库不归为较快。</p></InlineHelp>
</div>
@@ -383,9 +421,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<template #owner="{ row }"><el-button v-if="ownerMode" link type="primary" @click="selectOwner(castRow(row).customerCode)">{{ castRow(row).customerName || castRow(row).customerCode }}</el-button><span v-else>{{ castRow(row).customerName || castRow(row).customerCode }}</span></template>
<template v-for="key in ['physicalQuantity', 'availableQuantity', 'transitQuantity', 'frozenQuantity', 'defectiveQuantity'] as const" :key="key" #[key]="{ row }">{{ totalText(castRow(row)[key]) }}</template>
<template v-for="n in [7, 15, 30] as TurnoverWindow[]" :key="n" #[`outbound${n}`]="{ row }">{{ windowText(castRow(row).windows.find(w => w.days === n)) }}</template>
<template #inventoryVolume="{ row }">{{ volumeText(castRow(row).inventoryVolumeM3.value) }}<small>{{ volumeNote(castRow(row).inventoryVolumeM3) }}</small></template>
<template #outboundVolume="{ row }">{{ volumeText(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume.value) }}<small>{{ volumeNote(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume, true) }}</small></template>
<template #unitVolume="{ row }">{{ volumeText(castRow(row).unitVolumeM3, 9) }}<small>{{ dimensionSourceText(castRow(row).dimensionSource) }}</small></template>
<template #inventoryVolume="{ row }"><span class="turnover-volume-value" :title="volumeNote(castRow(row).inventoryVolumeM3)">{{ volumeText(castRow(row).inventoryVolumeM3.value) }}</span><small v-if="volumeWarning(castRow(row).inventoryVolumeM3)">{{ volumeWarning(castRow(row).inventoryVolumeM3) }}</small></template>
<template #outboundVolume="{ row }"><span class="turnover-volume-value" :title="volumeNote(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume, true)">{{ volumeText(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume.value) }}</span><small v-if="volumeWarning(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume)">{{ volumeWarning(castRow(row).outboundVolumesM3.find(w => w.days === applied?.baseWindowDays)?.volume) }}</small></template>
<template #unitVolume="{ row }"><span class="turnover-volume-value" :title="dimensionSourceText(castRow(row).dimensionSource)">{{ volumeText(castRow(row).unitVolumeM3, 9) }}</span><small v-if="castRow(row).unitVolumeM3 == null">缺少有效尺寸</small></template>
<template #daily="{ row }">{{ turnoverNumber(castRow(row).averageDailyOutboundQuantity) }}</template>
<template #turnoverRate="{ row }"><span :title="castRow(row).quantityTurnoverCount == null ? historicalMetricHint(castRow(row)) : `所选${applied?.baseWindowDays}天出库量 ÷ 期间平均实物库存 × 100%`">{{ turnoverPercent(castRow(row).quantityTurnoverCount) }}</span></template>
<template #coverage="{ row }">{{ turnoverNumber(castRow(row).inventoryCoverageDays, 1) }}<small>{{ reasonsText([castRow(row).coverageStatus]) }}</small></template>
@@ -395,8 +433,9 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
<template #turnover="{ row }"><span :title="castRow(row).quantityTurnoverCount == null ? historicalMetricHint(castRow(row)) : undefined">{{ castRow(row).quantityTurnoverCount == null ? '—' : `${turnoverNumber(castRow(row).quantityTurnoverCount)} 次/${applied?.baseWindowDays}日` }}</span></template>
<template #lastDate="{ row }">{{ castRow(row).lastObservedOutboundDate || '已采集历史内未见出库' }}</template>
</DataTable>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
<AppDialog v-model="evidenceOpen" class="admin-density-dialog" title="历史库存记录与销量覆盖" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><template v-if="evidence && data"><p>{{ evidence.customerName || evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};历史库存记录 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }}天;历史覆盖起点 {{ data.context.accumulationStartedOn || '尚无合格历史记录' }}</p><p>缺少库存记录的日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日库存口径:北京时间每日结束时的全品质实物库存,由完整库存流水和独立库存锚点还原;本次查询固定于 {{ beijingTime(data.context.queryAsOf) }} 的已发布版本。</p><p>历史库存记录与销量覆盖分别校验。流水缺页、库存锚点不符或正在校正的日期保持未知;不以当前库存代替历史库存,也不将缺失数据补零。</p></template></AppDialog>
</div>
<AppDialog v-model="trendOpen" class="admin-density-dialog admin-workbench is-dialog" title="SKU每日出库趋势" width="1120px" :show-confirm="false" :loading="trendLoading" cancel-text="关闭" append-to-body fullscreenable><LoadErrorState v-if="trendError" title="趋势读取失败" :message="trendError.message" compact><template #details><ApiErrorMeta :details="trendError" /></template></LoadErrorState><SkuTrend v-if="daily" :data="daily" /></AppDialog>
<AppDialog v-model="evidenceOpen" class="admin-density-dialog admin-workbench is-dialog" title="历史库存记录与销量覆盖" width="1040px" :show-confirm="false" cancel-text="关闭" append-to-body fullscreenable><div v-if="evidence && data" class="workbench-evidence"><p>{{ evidence.customerName || evidence.customerCode }} · {{ evidence.sku || '货主汇总' }};历史库存记录 {{ evidence.qualifiedSampleDays }}/{{ evidence.requiredSampleDays }}天;历史覆盖起点 {{ data.context.accumulationStartedOn || '尚无合格历史记录' }}</p><p>缺少库存记录的日期:{{ evidence.missingSampleDates.join('、') || '无日期缺口' }}</p><p>{{ reasonsText(evidence.sampleReasonCodes) }}</p><p v-for="window in evidence.windows" :key="window.days">{{ window.days }}日:{{ windowText(window) }};完整{{ window.completeDayCount }}日/部分{{ window.partialDayCount }}日/未知{{ window.unknownDayCount }}日</p><p>日库存口径:北京时间每日结束时的全品质实物库存,由完整库存流水和独立库存锚点还原;本次查询固定于 {{ beijingTime(data.context.queryAsOf) }} 的已发布版本。</p><p>历史库存记录与销量覆盖分别校验。流水缺页、库存锚点不符或正在校正的日期保持未知;不以当前库存代替历史库存,也不将缺失数据补零。</p></div></AppDialog>
</template>
</AdminPage>
</template>
@@ -410,45 +449,26 @@ onUnmounted(() => { fence.cancel(); runtimeFence.cancel(); trendFence.cancel() }
.turnover-page :deep(.data-table--integrated > .data-table__summary) { padding: 12px 20px 0; }
.turnover-page :deep(.data-table--integrated > .el-table) { width: calc(100% - 40px); margin-inline: 20px; }
.turnover-title { margin: 0; font-size: var(--admin-font-section); display: flex; align-items: center; gap: 16px; }
.turnover-title span { font-size: var(--admin-font-caption); font-weight: 400; color: var(--el-text-color-secondary); }
.turnover-title { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; }
.turnover-context { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; padding: 10px 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-summary { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; padding: 4px 0 12px; }
.turnover-summary { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: var(--workbench-grid-gap); padding: 4px 0 12px; }
.turnover-summary-group { min-width: 0; display: flex; flex-direction: column; }
.turnover-summary-group > header { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 8px; }
.turnover-summary-group h4 { margin: 0; font-size: var(--admin-font-body); }
.turnover-summary-group > header span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-summary-group > header { margin-bottom: var(--admin-space-3); }
.turnover-cards { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; flex: 1; }
.turnover-cards article { position: relative; overflow: hidden; min-width: 0; padding: 12px; border: 1px solid var(--el-color-primary-light-8); background: linear-gradient(120deg, #fff, var(--el-color-primary-light-9)); border-radius: 10px; color: var(--el-color-primary); }
.turnover-cards article[data-tone="green"] { color: #248260; border-color: #dbeee5; background: linear-gradient(120deg, #fff, #edf8f1); }
.turnover-cards article[data-tone="orange"] { color: #b37a22; border-color: #f4e7d3; background: linear-gradient(120deg, #fff, #fff6e8); }
.turnover-cards article[data-tone="purple"] { color: #8a62bf; border-color: #e9e0f5; background: linear-gradient(120deg, #fff, #f6f0fc); }
.turnover-cards article::after { content: ''; position: absolute; width: 88px; height: 88px; border-radius: 50%; right: -22px; top: -30px; background: currentColor; opacity: .07; pointer-events: none; }
.turnover-cards article > span { color: var(--el-text-color-regular); }
.turnover-cards strong { display: block; font-size: 24px; margin: 6px 0 4px; font-variant-numeric: tabular-nums; }
.turnover-charts { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 0 0 12px; align-items: stretch; }
.turnover-charts section { height: 360px; box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; background: var(--el-bg-color); border: 1px solid var(--el-border-color-light); padding: 16px; border-radius: 10px; }
.turnover-charts { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--workbench-grid-gap); padding: 0 0 12px; align-items: stretch; }
.turnover-charts > section { height: 360px; box-sizing: border-box; display: flex; flex-direction: column; min-width: 0; }
.turnover-chart { flex: 1 1 0; height: 0; min-height: 120px; width: 100%; }
.turnover-chart-empty { flex: 1 1 0; min-height: 0; padding: 16px; text-align: center; }
.turnover-chart-header { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.turnover-chart-header h3 { margin: 0; }
.turnover-chart-empty { flex: 1 1 0; min-height: 0; }
.turnover-chart-header { flex-shrink: 0; }
.turnover-chart-header :deep(.chart-disclosure) { flex-shrink: 0; }
.turnover-chart-empty { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; color: var(--el-text-color-secondary); background: var(--el-fill-color-lighter); border-radius: 8px; margin-top: 12px; }
.turnover-chart-empty strong { color: var(--el-text-color-regular); }
.turnover-chart-note { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); margin: 8px 0 0; }
.turnover-window-gaps { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 8px; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-list-filters { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-3); border-bottom: 1px solid var(--el-border-color-light); }
.turnover-zero-filter { flex: 0 0 auto; margin: var(--admin-space-1) 0; }
.turnover-focus { display: flex; flex-wrap: wrap; gap: 24px; margin: 0; }
.turnover-focus button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 12px 4px; font: inherit; color: var(--el-text-color-regular); cursor: pointer; }
.turnover-focus button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
.turnover-focus button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.turnover-threshold { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 10px 0; font-size: var(--admin-font-caption); }
.turnover-threshold label { display: flex; align-items: center; gap: 8px; }
.turnover-threshold input { width: 76px; padding: 6px 8px; border: 1px solid var(--el-border-color); border-radius: var(--admin-radius); font: inherit; color: var(--el-text-color-primary); }
.turnover-threshold input { width: 76px; }
.turnover-threshold > span { color: var(--el-text-color-secondary); }
.turnover-filter-feedback { margin: 8px 0; color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption); }
h3 { margin: 0 0 12px; font-size: var(--admin-font-body); border-left: 3px solid var(--el-color-primary); padding-left: 10px; }
small { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.turnover-rules { font-size: var(--admin-font-caption); line-height: 1.7; }
</style>
@@ -6,6 +6,7 @@ import { defineComponent } from 'vue'
vi.mock('vue-echarts', () => ({ default: defineComponent({ name: 'VChart', props: ['option', 'autoresize', 'initOptions'], template: '<div />' }) }))
import SkuTrend from './SkuTrend.vue'
import type { DailyResponse } from '../types'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
function data(status = 'COMPLETE'): DailyResponse {
return {
@@ -24,6 +25,13 @@ describe('SKU daily trend axes', () => {
expect(option.yAxis).toMatchObject({ type: 'value', min: 0, name: '出库量(件)' })
expect(option.series[1].data).toEqual(Array(30).fill(1))
expect(option.tooltip.trigger).toBe('axis')
expect(option.tooltip).toMatchObject({ backgroundColor: adminWorkbenchChartTheme.colors.surface, borderColor: adminWorkbenchChartTheme.colors.border })
expect(option.series[0].itemStyle.color).toBe(adminWorkbenchChartTheme.colors.primary)
expect(option.yAxis.splitLine).toEqual(adminWorkbenchChartTheme.splitLine)
expect(w.get('section').classes()).toContain('admin-workbench')
expect(w.find('.workbench-panel .workbench-chart-title').text()).toContain('SKU · 每日出库趋势')
expect(w.find('.workbench-meta').text()).toContain('所属货主:OWNER')
expect(w.findAll('.workbench-note').some(note => note.text().includes('完整出库量:30 件'))).toBe(true)
expect(chart.props('initOptions')).toEqual({ renderer: 'canvas' })
expect(w.get('.sku-trend').find('svg').exists()).toBe(false)
w.unmount()
@@ -37,4 +45,27 @@ describe('SKU daily trend axes', () => {
expect(option.xAxis.data).toHaveLength(30)
w.unmount()
})
it('retains partial segments, real zero and the coverage evidence dialog after theming', async () => {
const response = data()
response.points = [
{ ...response.points[0]!, outboundQuantity: '0', observedQuantity: '0' },
{ ...response.points[1]!, outboundQuantity: null, observedQuantity: '3', dataStatus: 'PARTIAL', coveredUntil: '2026-09-02 12:00:00' },
{ ...response.points[2]!, outboundQuantity: null, observedQuantity: null, dataStatus: 'UNKNOWN' },
{ ...response.points[3]!, outboundQuantity: '8', observedQuantity: '8' },
]
const w = mount(SkuTrend, { props: { data: response }, global: { plugins: [ElementPlus] } })
const option = w.findComponent({ name: 'VChart' }).props('option')
expect(option.series[0].data.map((point: { value: number | null }) => point.value)).toEqual([0, 3, null, 8])
expect(option.series[1]).toMatchObject({ data: [0, 3, null, null], lineStyle: { type: 'dashed', color: adminWorkbenchChartTheme.colors.primary } })
expect(option.series).toHaveLength(2)
expect(w.find('table').exists()).toBe(false)
const evidence = w.findAll('button').find(button => button.text() === '每日数量、完整性及覆盖证据')!
await evidence.trigger('click')
expect(w.get('table').text()).toContain('北京时间日期')
expect(w.get('table').text()).toContain('2026-09-02 12:00:00')
expect(w.findAll('tbody tr')).toHaveLength(4)
await w.setProps({ data: data('UNKNOWN') })
expect(w.find('table').exists()).toBe(false)
w.unmount()
})
})
@@ -6,22 +6,29 @@ import AnalyticsChart from './AnalyticsChart.vue'
import type { DailyResponse } from '../types'
import { label, quantityText, shownQuantity, reasonsText } from '../state'
import { skuTrendOption } from '../skuTrendChart'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
import '@/styles/adminWorkbench.css'
const props = defineProps<{ data: DailyResponse }>()
const option = computed(() => skuTrendOption(props.data.points))
const option = computed(() => skuTrendOption(props.data.points, adminWorkbenchChartTheme))
const hasData = computed(() => props.data.points.some(point => point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null))
</script>
<template>
<section aria-label="SKU每日出库趋势">
<h3>{{ data.summary.sku }} · 每日出库趋势 <InlineHelp title="出库趋势图例说明"><p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 空缺:未知,缺失日期断线</p></InlineHelp></h3>
<p>所属货主:{{ data.summary.customerCode || '—' }} · {{ data.summary.productName || '—' }}</p>
<p>{{ data.summary.dataStatus === 'COMPLETE' ? '完整出库量' : '已采集出库量' }}:{{ quantityText(shownQuantity(data.summary)) }} {{ data.summary.quantityUnit === 'PCS' ? '件' : '单位未知' }};{{ label(data.summary.dataStatus) }}。今日为已采集累计。</p>
<p v-if="!hasData">所选范围尚无可靠数据,未知日期不作为零销量。</p>
<AnalyticsChart :option="option" class="sku-trend" aria-label="SKU每日出库趋势" />
<ChartDetailsDialog title="每日数量、完整性及覆盖证据" :reset-on="data">
<table class="density-table"><thead><tr><th>北京时间日期</th><th>完整出库量</th><th>已采集量</th><th>状态</th><th>覆盖截止</th><th>缺口原因</th></tr></thead><tbody>
<tr v-for="point in data.points" :key="point.businessDate"><td>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ point.coveredUntil ?? '—' }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr>
</tbody></table>
</ChartDetailsDialog>
<section class="admin-workbench" aria-label="SKU每日出库趋势">
<div class="workbench-panel">
<header class="workbench-chart-heading">
<h3 class="workbench-chart-title">{{ data.summary.sku }} · 每日出库趋势</h3>
<div class="workbench-chart-actions"><InlineHelp title="出库趋势图例说明"><p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 空缺:未知,缺失日期断线</p></InlineHelp></div>
</header>
<p class="workbench-meta workbench-note">所属货主:{{ data.summary.customerCode || '—' }} · {{ data.summary.productName || '—' }}</p>
<p class="workbench-note">{{ data.summary.dataStatus === 'COMPLETE' ? '完整出库量' : '已采集出库量' }}:{{ quantityText(shownQuantity(data.summary)) }} {{ data.summary.quantityUnit === 'PCS' ? '件' : '单位未知' }};{{ label(data.summary.dataStatus) }}。今日为已采集累计。</p>
<p v-if="!hasData" class="workbench-note">所选范围尚无可靠数据,未知日期不作为零销量。</p>
<AnalyticsChart :option="option" class="sku-trend workbench-chart-canvas" aria-label="SKU每日出库趋势" />
<ChartDetailsDialog title="每日数量、完整性及覆盖证据" :reset-on="data">
<table class="density-table"><thead><tr><th>北京时间日期</th><th>完整出库量</th><th>已采集量</th><th>状态</th><th>覆盖截止</th><th>缺口原因</th></tr></thead><tbody>
<tr v-for="point in data.points" :key="point.businessDate"><td>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ point.coveredUntil ?? '—' }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr>
</tbody></table>
</ChartDetailsDialog>
</div>
</section>
</template>
<style scoped>
@@ -1,16 +1,21 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { GaugeSeriesOption, BarSeriesOption } from 'echarts'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
import { barChartOption, progressGaugeOption } from './dashboardChartOptions'
describe('self-operated ECharts configurations', () => {
it('wraps a concise tooltip and escapes source text rather than inserting markup', () => {
const option = barChartOption([{ key: 'sku', label: '<img src=x>', secondary: 'A&B', value: 12, detail: 'very long detail belongs in the dialog' }], '件', '热销', true)
const option = barChartOption([{ key: 'sku', label: '<img src=x>', secondary: 'A&B "\'货主', value: '12000.123456', detail: 'very long detail belongs in the dialog' }], '<件>', '热销', true)
const tooltip = option.tooltip as { formatter: (value: { dataIndex: number }) => string; extraCssText: string }
const content = tooltip.formatter({ dataIndex: 0 })
expect(content).toContain('&lt;img src=x&gt;')
expect(content).toContain('A&amp;B')
expect(content).toContain('A&amp;B &quot;&#39;货主')
expect(content).toContain('12,000.123456')
expect(content).toContain('&lt;件&gt;')
expect(content).not.toContain('<img')
expect(content).not.toContain('very long detail')
expect(tooltip.extraCssText).toContain('white-space: normal')
expect(option.tooltip).toMatchObject({ backgroundColor: adminWorkbenchChartTheme.colors.surface, borderColor: adminWorkbenchChartTheme.colors.border })
})
it('renders unknown progress as an empty track without a zero progress value', () => {
const unknown = (progressGaugeOption(null).series as GaugeSeriesOption[])[0]!
@@ -19,6 +24,30 @@ describe('self-operated ECharts configurations', () => {
const known = (progressGaugeOption(12.1).series as GaugeSeriesOption[])[0]!
expect(known.progress?.show).toBe(true)
expect(known.data).toEqual([{ value: 12.1 }])
expect(known.itemStyle).toMatchObject({ color: adminWorkbenchChartTheme.colors.primary })
expect(known.axisLine?.lineStyle?.color).toEqual([[1, adminWorkbenchChartTheme.colors.primarySoft]])
const zero = (progressGaugeOption(0).series as GaugeSeriesOption[])[0]!
expect(zero.progress?.show).toBe(true)
expect(zero.data).toEqual([{ value: 0 }])
})
it('keeps indeterminate activity distinct from known completion and honors reduced motion', () => {
const matchMedia = vi.fn(() => ({ matches: true }))
vi.stubGlobal('window', { matchMedia })
try {
const running = progressGaugeOption(null, true)
expect(running.animation).toBe(false)
expect((running.series as GaugeSeriesOption[])[0]).toMatchObject({
progress: { show: true, roundCap: true },
data: [{ value: 18 }],
})
expect(progressGaugeOption(12.1).animation).toBe(false)
expect(matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)')
matchMedia.mockReturnValue({ matches: false })
expect(progressGaugeOption(12.1).animation).toBe(true)
expect(progressGaugeOption(null, true).animation).toBe(false)
} finally {
vi.unstubAllGlobals()
}
})
it('keeps the seven bucket order, zero, null and common numeric scale', () => {
const rows = Array.from({ length: 7 }, (_, index) => ({ key: `bucket-${index}`, label: `分组${index}`, value: index === 6 ? null : index, detail: '' }))
@@ -27,5 +56,64 @@ describe('self-operated ECharts configurations', () => {
expect(option.xAxis).toMatchObject({ type: 'value', min: 0 })
expect((option.series as BarSeriesOption[])[0]?.data?.[0]).toMatchObject({ value: 0 })
expect((option.series as BarSeriesOption[])[0]?.data?.[6]).toMatchObject({ value: null })
expect((option.series as BarSeriesOption[])[0]).not.toHaveProperty('barMinHeight')
expect((option.series as BarSeriesOption[])[0]).not.toHaveProperty('showBackground')
})
it('assigns bucket colors by semantic key even when the rows are reordered', () => {
const expected: Array<[string, string]> = [
['UNKNOWN', adminWorkbenchChartTheme.colors.unknown],
['DAYS_GT_90', adminWorkbenchChartTheme.colors.teal],
['NO_AVAILABLE', adminWorkbenchChartTheme.colors.rose],
['DAYS_30_60', adminWorkbenchChartTheme.colors.primaryLight],
['NO_OUTBOUND_IN_WINDOW', adminWorkbenchChartTheme.colors.amber],
['DAYS_0_30', adminWorkbenchChartTheme.colors.primary],
['DAYS_60_90', adminWorkbenchChartTheme.colors.tealLight],
]
const rows = expected.map(([key]) => ({ key, label: key, value: 1, detail: '' }))
for (const orderedRows of [rows, [...rows].reverse()]) {
const option = barChartOption(orderedRows, 'SKU', '库存分布')
const data = (option.series as BarSeriesOption[])[0]!.data!
for (const [index, row] of orderedRows.entries()) {
expect(data[index]).toMatchObject({ itemStyle: { color: expected.find(([key]) => key === row.key)?.[1] } })
}
}
})
it('uses one blue for ranking and preserves real values instead of normalizing bar lengths', () => {
const option = barChartOption([
{ key: 'large', label: 'SKU-LARGE', value: '15000', detail: '' },
{ key: 'small', label: 'SKU-SMALL', value: '150', detail: '' },
{ key: 'zero', label: 'SKU-ZERO', value: 0, detail: '' },
{ key: 'unknown', label: 'SKU-UNKNOWN', value: null, detail: '' },
], '件', '热销', true)
const series = (option.series as BarSeriesOption[])[0]!
expect(series.data).toEqual([15000, 150, 0, null].map(value => ({
value, itemStyle: { color: adminWorkbenchChartTheme.colors.primary, borderRadius: [0, 6, 6, 0] },
})))
expect(series).not.toHaveProperty('barMinHeight')
expect(series).not.toHaveProperty('showBackground')
expect(option.xAxis).toMatchObject({ min: 0 })
const label = series.label as { formatter: (value: { dataIndex: number }) => string }
expect(label.formatter({ dataIndex: 0 })).toBe('15,000')
expect(label.formatter({ dataIndex: 2 })).toBe('0')
expect(label.formatter({ dataIndex: 3 })).toBe('—')
})
it('rounds day labels and tooltips to two decimals while retaining the full chart value', () => {
const option = barChartOption([{ key: 'sku', label: 'SKU', value: '1234.56789', detail: '' }], '天', '预计消化时间', true)
const series = (option.series as BarSeriesOption[])[0]!
expect(series.data?.[0]).toMatchObject({ value: 1234.56789 })
const label = series.label as { formatter: (value: { dataIndex: number }) => string }
const tooltip = option.tooltip as { formatter: (value: { dataIndex: number }) => string }
expect(label.formatter({ dataIndex: 0 })).toBe('1,234.57')
expect(tooltip.formatter({ dataIndex: 0 })).toContain('1,234.57 天')
})
it('retains every quantity digit in chart labels and tooltips for values beyond safe integers', () => {
const value = '12345678901234567890.123450000001'
const expected = '12,345,678,901,234,567,890.123450000001'
const option = barChartOption([{ key: 'sku', label: 'SKU', value, detail: '' }], '件', '热销', true)
const series = (option.series as BarSeriesOption[])[0]!
const label = series.label as { formatter: (value: { dataIndex: number }) => string }
const tooltip = option.tooltip as { formatter: (value: { dataIndex: number }) => string }
expect(label.formatter({ dataIndex: 0 })).toBe(expected)
expect(tooltip.formatter({ dataIndex: 0 })).toContain(`${expected} 件`)
})
})
@@ -1,34 +1,55 @@
import type { EChartsOption } from 'echarts'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
import type { Quantity } from './types'
import { quantityText } from './state'
import { groupedQuantityText } from './state'
export interface BarRow { key: string; label: string; secondary?: string; value: Quantity; detail: string }
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]!))
const palette = ['#5575ed', '#3da9ba', '#e8a54b', '#b083dc', '#df8397', '#8a9ab4', '#a2adbf']
const theme = adminWorkbenchChartTheme
const bucketColors: Record<string, string> = {
DAYS_0_30: theme.colors.primary,
DAYS_30_60: theme.colors.primaryLight,
DAYS_60_90: theme.colors.tealLight,
DAYS_GT_90: theme.colors.teal,
NO_OUTBOUND_IN_WINDOW: theme.colors.amber,
NO_AVAILABLE: theme.colors.rose,
UNKNOWN: theme.colors.unknown,
}
export function barChartOption(rows: BarRow[], unit: string, title: string, ranked = false): EChartsOption {
const valueText = (value: Quantity | undefined) => unit === '天' && value != null && Number.isFinite(Number(value)) ? Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2, useGrouping: false }) : quantityText(value)
const valueText = (value: Quantity | undefined) => {
if (unit === '天' && value != null && Number.isFinite(Number(value))) return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 2 })
return groupedQuantityText(value)
}
return {
animation: false,
grid: { left: 12, right: 48, top: 12, bottom: 24, containLabel: true },
xAxis: { type: 'value', min: 0, name: unit, splitNumber: 3, minInterval: 1, axisLabel: { color: '#697586' }, splitLine: { lineStyle: { type: 'dashed', color: '#e5eaf3' } } },
textStyle: { color: theme.colors.text, fontFamily: theme.fontFamily },
grid: { left: 8, right: 56, top: 16, bottom: 24, containLabel: true },
xAxis: {
type: 'value', min: 0, name: unit, splitNumber: 3, minInterval: 1,
nameTextStyle: theme.axisName,
axisLabel: theme.axisLabel,
axisLine: { show: false, lineStyle: { color: theme.colors.axis } },
axisTick: { show: false },
splitLine: theme.splitLine,
},
yAxis: { type: 'category', inverse: true, data: rows.map(row => row.key), triggerEvent: true,
axisTick: { show: false }, axisLine: { show: false },
axisLabel: { width: ranked ? 116 : 130, overflow: 'truncate', color: '#344054', rich: { rank: { color: '#5575ed', fontWeight: 'bold', width: 24, align: 'left' } }, formatter: key => { const index = rows.findIndex(row => row.key === key); const row = rows[index]; return row ? `${ranked ? `{rank|${index + 1}} ` : ''}${row.label}` : key } } },
tooltip: { trigger: 'item', renderMode: 'html', confine: true, extraCssText: 'max-width: min(300px, 80vw); white-space: normal; overflow-wrap: anywhere;', formatter: params => {
axisLabel: { ...theme.axisLabel, width: ranked ? 138 : 130, margin: 10, overflow: 'truncate', color: theme.colors.text, rich: { rank: { color: theme.colors.primary, fontWeight: 'bold', width: 24, align: 'left' } }, formatter: key => { const index = rows.findIndex(row => row.key === key); const row = rows[index]; return row ? `${ranked ? `{rank|${index + 1}} ` : ''}${row.label}` : key } } },
tooltip: { ...theme.tooltip, trigger: 'item', renderMode: 'html', confine: true, extraCssText: `${theme.tooltip.extraCssText} max-width: min(300px, 80vw); white-space: normal; overflow-wrap: anywhere;`, formatter: params => {
const item = Array.isArray(params) ? params[0] : params
const row = item ? rows[item.dataIndex] : undefined
return row ? `<strong>${escapeHtml(row.label)}</strong><br/>${row.secondary ? `${escapeHtml(row.secondary)}<br/>` : ''}${escapeHtml(valueText(row.value))} ${escapeHtml(unit)}` : ''
} },
aria: { enabled: true, description: `${title},横轴为${unit}。未知不作为零值;详细数据和键盘操作在下方完整明细弹框。` },
series: [{ type: 'bar', barMaxWidth: ranked ? 14 : 18, data: rows.map((row, index) => ({ value: row.value == null ? null : Number(row.value), itemStyle: { color: palette[ranked ? 0 : index % palette.length], borderRadius: [0, 4, 4, 0] } })),
label: { show: true, position: 'right', formatter: params => valueText(rows[params.dataIndex]?.value) } }],
series: [{ type: 'bar', barMaxWidth: ranked ? 18 : 20, data: rows.map(row => ({ value: row.value == null ? null : Number(row.value), itemStyle: { color: ranked ? theme.colors.primary : bucketColors[row.key] ?? theme.colors.unknown, borderRadius: [0, 6, 6, 0] } })),
label: { show: true, position: 'right', distance: 7, color: theme.colors.text, fontSize: 12, fontFamily: theme.fontFamily, formatter: params => valueText(rows[params.dataIndex]?.value) } }],
}
}
export function progressGaugeOption(percent: number | null, running = false): EChartsOption {
const animate = typeof window !== 'undefined' && !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
return { animation: animate && percent != null, animationDuration: 0, animationDurationUpdate: 700, animationEasingUpdate: 'cubicOut', series: [{ id: 'collection-progress', type: 'gauge', startAngle: 90, endAngle: -270, radius: '86%', min: 0, max: 100,
pointer: { show: false }, progress: { show: percent != null || running, roundCap: true, width: 10 },
axisLine: { lineStyle: { width: 10, color: [[1, '#e8eaf5']] } }, itemStyle: { color: '#6879ee' },
pointer: { show: false }, progress: { show: percent != null || running, roundCap: true, width: 12 },
axisLine: { lineStyle: { width: 12, color: [[1, theme.colors.primarySoft]] } }, itemStyle: { color: theme.colors.primary, shadowColor: theme.colors.primarySoft, shadowBlur: 6 },
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, title: { show: false }, detail: { show: false },
silent: true, data: percent == null && !running ? [] : [{ value: percent ?? 18 }],
}] }
@@ -1,9 +1,16 @@
# 自营分析逐操作矩阵
依据:2026-10-12 用户提供的销量/库存页面截图及“主要统计SKU,在看当前SKU是哪个货主就行,货主不重要”的明确纠正,优先于一期方案中的货主经营面板目标;密度沿用 `docs/Admin高密度界面规范.md`。用户最新确认销量、库存样式已完成,本轮以其截图为数据采集统一样板;采集页修改后的视觉仍待人工1920×1080、100%截图。
依据:2026-10-12 用户提供的销量/库存页面截图及“主要统计SKU,在看当前SKU是哪个货主就行,货主不重要”的明确纠正,优先于一期方案中的货主经营面板目标;密度沿用 `docs/Admin高密度界面规范.md`。2026-10-20 用户提供蓝白工作台参考,选经营总览制作样板,并追加销量、库存与数据采集;本次新样式均待人工1920×1080、100%截图确认。
| 页面 | 操作 | 依据与承载结论 | 布局与验证 |
| --- | --- | --- | --- |
| 自营四个入口 | 蓝白样板截图收尾 | 2026-10-20 用户提供五张实际截图并授权收尾及 Admin 测试环境发布:统一数量千分位、明确货主/关注/样本筛选文案、收紧多余留白,库存横向滚动时保留行标识;采集关闭/暂停状态优先展示,已保存进度与批次事实分别表达。随后明确清爽要求:库存快照、采集时间、图表范围等解释收至已有说明,取消正常指标重复的“已确认/完整”,图表入口并入顶部 | 不改变数量精度、筛选含义、调度和接口;未知、过期及失败仍可辨认,详细来源和估算口径按需查看。公共规则及后续页面参考入口写入蓝白工作台规范。整端推广待本轮样板确认,收尾后的实际视觉待人工截图确认 |
| 数据采集 | 蓝白账号运行看板 | 2026-10-20 用户追加数据采集一并调整:复用公共圆角面板、紧凑数量卡、进度环主题、阶段节点与状态标签;保留左侧当前进度和右侧后续安排、阶段流程与各仓发布结果 | 不变更只读刷新、轮询、阶段计数及预计时间依据;未知进度不补百分比,待机不表示完成;待人工1920×1080、100%截图确认 |
| 销量分析/库存明细 | 蓝白列表样板 | 2026-10-20 用户在经营总览样板基础上明确要求一起调整:保留原 DataTable/QueryToolbar、字段顺序、操作列、排序分页、来源条件与统计气泡;统一白底圆角容器、浅色表头与筛选带、蓝色操作和库存视图页签 | 公共 `admin-workbench` 作用域显式接入,补公共表格/页签外观;不重复壳层标题,不改查询及权限;待人工1920×1080、100%截图确认 |
| 库存周转 | 蓝白指标/图表/关注筛选 | 同次用户授权:保留两组各三张摘要卡、两列图表及货主件数/体积/日均三种内容,复用紧凑工作台卡片和公共图表主题;保留关注、隐藏零库存、阈值、扩展列和导出 | 紧凑指标不采用总览大卡最小高度;画布保留现有伸缩高度,真实比例、未知值、单位与货主下钻不变;待人工截图确认 |
| 销量/库存/经营总览 | 共用 SKU 趋势与证据弹窗外观 | 同次用户授权统一共用 SKU 趋势:仍为1120px居中 AppDialog,统计证据继续沿现有居中弹窗;接入蓝白主题、公共标题/说明/内容外观 | 保留全屏、关闭取消、缺日断线、部分日虚线、未知不补零及原覆盖明细;不改变弹窗尺寸与承载,待人工截图确认 |
| /self-operated/overview | 图表主题与空态 | 2026-10-20 用户提供五张图表区域截图并要求按蓝白样板优化:主趋势采用蓝色、连续完整段淡填色、灰蓝坐标与轻缩放条;柱图采用圆角、真实尺度、语义分桶配色及统一提示框;标题与空榜单使用公共样式 | 主题入口 `src/styles/adminWorkbenchChartTheme.ts`,不在 AnalyticsChart 全局注册;缺日断线、部分段虚线、未知/零及下钻不变;共用 SKU 趋势及采集环按本表新增授权接入,待人工新截图确认 |
| /self-operated/overview | 蓝白卡片风格样板 | 2026-10-20 用户选择本页直接改版;参考 `docs/界面参考/蓝白卡片式工作台参考.png`。保留 DataTable 顶栏、QueryToolbar、六指标、2:1 主图及三榜、统计说明和全部明细/趋势弹窗;指标改浅彩底座与蓝色大数字,增加分区标题,图表使用轻边框圆角容器 | 公共外观由 `src/styles/adminWorkbenchTokens.css` 与 `adminWorkbench.css` 维护,仅显式 `admin-workbench` 根启用;原图表高度、统计口径、下钻与弹窗密度保留。尚未批准全 Admin 推广;待人工1920×1080、100%截图及老板确认 |
| 库存周转 | 库存/出库立方数与客户汇总 | 2026-10-14 用户确认:仓库实测尺寸优先,商品尺寸补充并标估算;历史出库按当前尺寸估算。沿现有客户筛选、SKU/货主分组和客户下钻,增加两项概览、体积列与单件尺寸来源,不新增弹框 | 正式总量未知显示“—”;小计、缺尺寸件数/SKU和数量待确认分开显示;出库体积跟随所选时间;待人工1920×1080、100%截图确认 |
| 库存周转 | 库存过期、采集失败与未知总量 | 2026-10-14 用户库存空白截图及本轮修复确认:沿现有同页提示展示过期时间、同仓失败原因与任务号;整体刷新同步只读采集状态,失败独立提示;指标主数值未知显示“—”,小计留辅助说明;原样本证据弹框解释库存日样本与出库历史的区别 | 保留原数据采集入口与弹框承载,不新增写入操作;回归未知/真实0、切仓和刷新恢复,待人工1920×1080、100%截图确认 |
| /self-operated/inventory | 页签与操作栏整合 | 2026-10-14 用户截图 `codex-clipboard-784338aa-b985-4eb7-9f5e-b14f87f6d5e8.png`:页签独立在外、操作栏左侧空白;两个视图的切换页签统一放入DataTable的toolbar-start,操作按钮保留右侧,筛选紧接下方;阈值提示缩短为“天数阈值(天)” | 保留视图选中态、切仓与原图下钻;不改接口与计算,待人工1920×1080、100%截图确认 |
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, onActivated, onDeactivated, onMounted, onUnmounted, ref } from 'vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
@@ -46,7 +47,7 @@ onDeactivated(suspend)
onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange', visibility) })
</script>
<template>
<AdminPage class="admin-density">
<AdminPage class="admin-density admin-workbench">
<p v-if="!permitted" role="alert">没有采集状态查看权限。</p>
<template v-else>
<LoadErrorState v-if="error" title="采集状态更新失败" :message="error.message" :loading="loading" compact @retry="read"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
@@ -19,16 +19,10 @@ function switchView(view: 'TURNOVER' | 'DETAIL') {
<p v-if="selected.error" role="alert">{{ selected.error }}</p>
<component v-else :is="selected.view === 'DETAIL' ? AnalyticsView : InventoryTurnoverView" v-bind="selected.view === 'DETAIL' ? { mode: 'inventory' as const } : {}" @warehouse-selected="retainedWarehouse = $event">
<template #view-tabs>
<nav class="inventory-views" aria-label="库存分析视图">
<nav class="inventory-views workbench-tabs" aria-label="库存分析视图">
<button type="button" :aria-pressed="selected.view === 'TURNOVER'" @click="switchView('TURNOVER')">库存周转</button>
<button type="button" :aria-pressed="selected.view === 'DETAIL'" @click="switchView('DETAIL')">库存明细</button>
</nav>
</template>
</component>
</template>
<style scoped>
.inventory-views { display: flex; align-items: center; gap: var(--admin-space-4); }
.inventory-views button { border: 0; border-bottom: 2px solid transparent; background: transparent; min-height: var(--admin-control-height); padding: var(--admin-space-1) var(--admin-space-2); color: var(--el-text-color-regular); font: inherit; cursor: pointer; white-space: nowrap; }
.inventory-views button[aria-pressed="true"] { color: var(--el-color-primary); border-bottom-color: var(--el-color-primary); font-weight: 600; }
.inventory-views button:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
</style>
@@ -50,6 +50,31 @@ describe('warehouse operating dashboard', () => {
expect(response.kpis.averageDailyOutboundQuantity.value).toBe(value)
})
it('groups KPI quantities and subtotals without rounding or turning unavailable values into zero', async () => {
const response = structuredClone(dashboardFixture)
response.kpis.intervalOutboundQuantity.observedQuantity = '1234567.123450000001'
response.kpis.physicalQuantity.value = '9007199254740993'
// The fixture shares one KPI object; give these distinct values their own objects.
response.kpis.averageDailyOutboundQuantity = { ...response.kpis.averageDailyOutboundQuantity, value: '8' }
response.kpis.availableQuantity = { ...response.kpis.availableQuantity, value: null, knownSubtotal: '12345678901234567890.000100', unknownSkuCount: '1234' }
response.kpis.physicalSkuCount = { ...response.kpis.physicalSkuCount, value: '0' }
response.kpis.noOutboundSkuCount = { ...response.kpis.noOutboundSkuCount, value: null }
mocks.dashboard.mockResolvedValue(response)
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findAll('.dashboard-kpis strong').map(node => node.text())).toEqual(['1,234,567.123450000001', '8', '9,007,199,254,740,993', '—', '0', '—'])
expect(wrapper.findAll('.dashboard-kpis .workbench-status').map(node => node.text())).toEqual(['未完整', '未完整'])
expect(wrapper.find('.dashboard-kpis').text()).toContain('已知小计 12,345,678,901,234,567,890.000100')
expect(wrapper.find('.dashboard-kpis').text()).toContain('另 1,234 SKU 待核验')
expect(response.kpis.intervalOutboundQuantity.observedQuantity).toBe('1234567.123450000001')
expect(response.kpis.physicalQuantity.value).toBe('9007199254740993')
})
it('omits the ranking status row when all rankings are complete and current', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.find('.dashboard-ranking-status').exists()).toBe(false)
expect(wrapper.findAllComponents(DashboardBars).filter(chart => chart.props('ranked')).every(chart => !chart.vm.$slots.intro)).toBe(true)
})
it.each(['PARTIAL', 'UNKNOWN'])('renders omitted nullable KPI fields for %s instead of rejecting the dashboard', async status => {
const response = structuredClone(dashboardFixture)
for (const metric of Object.values(response.kpis)) {
@@ -63,6 +88,7 @@ describe('warehouse operating dashboard', () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).not.toContain('指标结构不完整')
expect(wrapper.findAll('.dashboard-kpis strong').map(node => node.text())).toEqual(['8', '—', '—', '—', '—', '—'])
expect(wrapper.findAll('.dashboard-kpis .workbench-status').map(node => node.text())).toEqual(Array(6).fill(status === 'PARTIAL' ? '未完整' : '未知'))
expect(wrapper.text()).toContain('已知小计 128')
expect(wrapper.findAllComponents(DashboardBars)).toHaveLength(4)
})
@@ -186,6 +212,7 @@ describe('warehouse operating dashboard', () => {
it('uses the common title/actions/filter surface without an empty result table', async () => {
const wrapper = page(); await flushPromises()
expect(wrapper.findComponent(DataTable).props()).toMatchObject({ appearance: 'integrated', resultsVisible: false, columnSettingsMode: 'hide' })
expect(wrapper.find('.workbench-toolbar > .data-table.data-table--integrated').exists()).toBe(true)
expect(wrapper.find('.data-table__toolbar-start h2').text()).toBe('经营总览')
expect(wrapper.find('.data-table__toolbar-actions').text()).toContain('整体刷新')
expect(wrapper.find('.data-table__filters').findComponent(query).exists()).toBe(true)
@@ -333,6 +360,7 @@ describe('warehouse operating dashboard', () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.text()).toContain('所选时段热销Top10')
expect(wrapper.text()).toContain('数据未采齐,排名仅供参考')
expect(wrapper.findAll('.dashboard-ranking-status').map(node => node.text())).toEqual(['数据未采齐,排名仅供参考', '', ''])
const hot = wrapper.findAllComponents(DashboardBars)[1]!
expect(hot.props('rows')[0]).toMatchObject({ value: '37' })
expect(hot.props('rows')[0].detail).toContain('13天窗口已采集出库 37 件')
@@ -363,6 +391,8 @@ describe('warehouse operating dashboard', () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
await vi.advanceTimersByTimeAsync(2000)
expect(wrapper.text()).toContain('原图库存已过期或不可用')
expect(wrapper.findAll('.dashboard-kpis .workbench-status').map(node => node.text())).toEqual(['未完整', '历史/待刷新 · 未完整', '历史/待刷新 · 完整', '历史/待刷新 · 完整', '历史/待刷新 · 完整'])
expect(wrapper.findAll('.dashboard-ranking-status').map(node => node.text())).toEqual(['', '历史数据 · 待刷新', '历史数据 · 待刷新'])
expect(wrapper.findAllComponents(DashboardBars)[0]!.props('disabled')).toBe(true)
expect(findButton(wrapper, '查看Top100').attributes('disabled')).toBeUndefined()
wrapper.findAllComponents(DashboardBars)[2]!.vm.$emit('select', '0'); await flushPromises()
@@ -1,5 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Activity, ArrowUpRight, Boxes, Package, Layers, Clock3, Info } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
@@ -13,7 +14,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { analyticsApi } from '../api/analytics'
import type { Dashboard, DashboardItem, DashboardRanking, DailyResponse, DashboardQuery, Overview, QueryContext, RangePreset } from '../types'
import { beijingTime, contextParams, label, quantityText, reasonsText, RequestFence, RuntimePoller, sameContext } from '../state'
import { beijingTime, contextParams, groupedQuantityText, label, quantityText, reasonsText, RequestFence, RuntimePoller, sameContext } from '../state'
import { buckets, expired, inventoryExpired, parseContext, parseOverviewReturn, previousDate, type OverviewReturn } from '../dashboardNavigation'
import { beijingDate, overviewRange, overviewRangeOptions } from '../overviewRange'
import HotSkuDetails from '../components/HotSkuDetails.vue'
@@ -77,8 +78,9 @@ function averageDailyText(value: string | number | null | undefined) {
return Number.isFinite(number) ? number.toLocaleString('zh-CN', { maximumFractionDigits: 2 }) : '—'
}
const metricIcons = [ArrowUpRight, Activity, Boxes, Package, Layers, Clock3]
const metricColors = ['#5575ed', '#3aa9ba', '#8871ce', '#35a987', '#c6923e', '#d67b91']
const metricTones = ['blue', 'teal', 'purple', 'green', 'amber', 'rose'] as const
const rankingNames = [{ key: 'hotSkus', label: '所选时段热销Top10', unit: '件', all: '查看Top100' }, { key: 'longCoverageSkus', label: '预计消化时间最长Top10', unit: '天', all: '查看全部' }, { key: 'noOutboundSkus', label: '所选时段无出库库存Top10', unit: '件', all: '查看全部' }] as const
const hasRankingWarnings = computed(() => Boolean(data.value && (contextExpired.value || stockExpired.value || rankingNames.some(ranking => data.value![ranking.key].dataStatus !== 'COMPLETE'))))
const bucketRows = computed<BarRow[]>(() => buckets.map(bucket => {
const item = data.value?.coverageDistribution.buckets.find(row => row.bucketCode === bucket.code)
return { key: bucket.code, label: bucket.label, value: item?.skuCount ?? null, detail: `已知实物小计 ${quantityText(item?.knownPhysicalQuantity)} 件;实物未知 ${quantityText(item?.unknownPhysicalSkuCount)} SKU` }
@@ -192,12 +194,13 @@ onMounted(() => { visibility(); poller.start(); timer = setInterval(() => { now.
onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clearInterval(timer); document.removeEventListener('visibilitychange', visibility) })
</script>
<template>
<AdminPage class="admin-density dashboard-page">
<AdminPage class="admin-density admin-workbench dashboard-page">
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
<template v-else>
<div class="workbench-toolbar">
<DataTable appearance="integrated" :data="[]" :columns="[]" :results-visible="false" column-settings-mode="hide">
<template #toolbar-start><div class="dashboard-title"><h2>经营总览</h2><span class="dashboard-snapshot">{{ runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode }}<i />{{ loading ? '正在更新' : data ? '固定版本 · 北京时间' : error ? '查询未完成' : '正在准备数据' }}</span></div></template>
<template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: data!.context.rangeStartDate!, endDate: previousDate(data!.context.rangeEndDateExclusive!) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template>
<template #toolbar-start><div class="workbench-heading"><h2><span class="workbench-title">经营总览</span></h2><span class="workbench-meta">{{ runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode }}<i aria-hidden="true" />{{ loading ? '正在更新' : data ? '固定版本 · 北京时间' : error ? '查询未完成' : '正在准备数据' }}</span></div></template>
<template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: data!.context.rangeStartDate!, endDate: previousDate(data!.context.rangeEndDateExclusive!) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button type="primary" :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template>
<template #toolbar-end>
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间">
<template #reference><el-button circle aria-label="统计口径与采集时间" title="统计口径与采集时间"><Info :size="16" aria-hidden="true" /></el-button></template>
@@ -206,27 +209,52 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
</template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" preference-key="self-operated-overview-query" ariaLabel="经营总览筛选" @search="load()" @reset="reset" /></template>
</DataTable>
<p v-if="!warehouseCode">请先选择一个仓库并查询经营数据。</p>
<p v-if="routeError" role="alert">{{ routeError }}。请返回正常入口重新选择仓库。</p>
</div>
<p v-if="!warehouseCode" class="workbench-notice">请先选择一个仓库并查询经营数据。</p>
<p v-if="routeError" class="workbench-notice workbench-tone" data-tone="rose" role="alert">{{ routeError }}。请返回正常入口重新选择仓库。</p>
<LoadErrorState v-if="error" title="经营总览查询未完成" :message="error" compact :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<p v-if="runtimeError" role="status">{{ runtimeError }}</p>
<p v-if="newData" role="status">有新数据,可整体刷新;当前图表仍使用原查询版本。</p>
<p v-if="contextExpired" role="alert">查询上下文已过期,以下图表为历史结果,请整体刷新。</p>
<p v-else-if="stockExpired && data" role="status">原图库存已过期或不可用,库存指标为历史/待刷新资料,严格库存下钻已停用;出库趋势与热销仍可查看。</p>
<div v-if="loading && !data" class="dashboard-placeholder" role="status" aria-busy="true"><span>正在读取六项指标、日趋势、库存分布和三个榜单…</span><div class="dashboard-skeleton-kpis" aria-hidden="true"><i v-for="index in 6" :key="index" /></div><div class="dashboard-skeleton-charts" aria-hidden="true"><i /><i /></div></div>
<p v-if="runtimeError" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ runtimeError }}</p>
<p v-if="newData" class="workbench-notice" role="status">有新数据,可整体刷新;当前图表仍使用原查询版本。</p>
<p v-if="contextExpired" class="workbench-notice workbench-tone" data-tone="amber" role="alert">查询上下文已过期,以下图表为历史结果,请整体刷新。</p>
<p v-else-if="stockExpired && data" class="workbench-notice workbench-tone" data-tone="amber" role="status">原图库存已过期或不可用,库存指标为历史/待刷新资料,严格库存下钻已停用;出库趋势与热销仍可查看。</p>
<div v-if="loading && !data" class="dashboard-placeholder workbench-section" role="status" aria-busy="true"><span class="workbench-note">正在读取六项指标、日趋势、库存分布和三个榜单…</span><div class="dashboard-skeleton-kpis" aria-hidden="true"><i v-for="index in 6" :key="index" class="workbench-skeleton" /></div><div class="dashboard-main-charts dashboard-skeleton-charts" aria-hidden="true"><i class="workbench-skeleton" /><i class="workbench-skeleton" /></div></div>
<template v-if="data">
<p v-if="includesToday" role="status">所选时段包含今日({{ data.context.anchorDate }}),尚不满足完整自然日要求,库存消化天数与两个库存榜单暂不能完成评估。可选择截至昨日的自定义时段;仍需满足销量覆盖、库存及观察期条件。</p>
<p v-if="data.context.rebuildRequired || data.context.salesStatus === 'REBUILD_REQUIRED'" role="status">销量有变动,待重新统计发布。出库趋势展示已发布版本的历史覆盖结果;库存评估等待新版销量发布。</p>
<p v-if="includesToday" class="workbench-notice workbench-tone" data-tone="amber" role="status">所选时段包含今日({{ data.context.anchorDate }}),尚不满足完整自然日要求,库存消化天数与两个库存榜单暂不能完成评估。可选择截至昨日的自定义时段;仍需满足销量覆盖、库存及观察期条件。</p>
<p v-if="data.context.rebuildRequired || data.context.salesStatus === 'REBUILD_REQUIRED'" class="workbench-notice workbench-tone" data-tone="amber" role="status">销量有变动,待重新统计发布。出库趋势展示已发布版本的历史覆盖结果;库存评估等待新版销量发布。</p>
<section class="workbench-section" aria-labelledby="overview-metrics-heading">
<header class="workbench-section-heading">
<h2 id="overview-metrics-heading" class="workbench-section-title">经营指标</h2>
<span v-if="data.context.rangeStartDate && data.context.rangeEndDateExclusive" class="workbench-badge">出库:{{ data.context.rangeStartDate }} 至 {{ previousDate(data.context.rangeEndDateExclusive) }}</span>
<span class="workbench-badge">库存:查询快照</span>
</header>
<div class="dashboard-kpis" aria-label="本仓经营指标">
<section v-for="(metric, index) in metricNames" :key="metric.key" :style="{ '--metric-color': metricColors[index], '--reveal-delay': `${index * 45}ms` }"><div class="dashboard-metric-heading"><h3>{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3><component :is="metricIcons[index]" :size="18" aria-hidden="true" /></div><div class="dashboard-metric-value"><strong>{{ metric.key === 'averageDailyOutboundQuantity' ? averageDailyText(data.kpis[metric.key].value) : quantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong><span> {{ metric.key === 'averageDailyOutboundQuantity' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span></div><p class="dashboard-metric-status">{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p><p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'">已知小计 {{ quantityText(data.kpis[metric.key].knownSubtotal) }}</p><p v-if="Number(data.kpis[metric.key].unknownSkuCount) > 0">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small v-if="data.kpis[metric.key].reasonCodes?.length">{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
<section v-for="(metric, index) in metricNames" :key="metric.key" class="workbench-metric workbench-tone" :data-tone="metricTones[index]">
<div class="workbench-metric-heading">
<span class="workbench-icon"><component :is="metricIcons[index]" :size="24" aria-hidden="true" /></span>
<h3 class="workbench-metric-label">{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3>
</div>
<div class="workbench-metric-value">
<strong>{{ metric.key === 'averageDailyOutboundQuantity' ? averageDailyText(data.kpis[metric.key].value) : groupedQuantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong>
<span>{{ metric.key === 'averageDailyOutboundQuantity' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span>
</div>
<p v-if="data.kpis[metric.key].dataStatus !== 'COMPLETE' || contextExpired || metric.inventory && stockExpired" class="workbench-status">{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p>
<p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'" class="workbench-note">已知小计 {{ groupedQuantityText(data.kpis[metric.key].knownSubtotal) }}</p>
<p v-if="Number(data.kpis[metric.key].unknownSkuCount) > 0" class="workbench-note">另 {{ groupedQuantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p>
<small v-if="data.kpis[metric.key].reasonCodes?.length" class="workbench-note">{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small>
</section>
</div>
</section>
<section class="workbench-section" aria-labelledby="overview-charts-heading">
<header class="workbench-section-heading">
<h2 id="overview-charts-heading" class="workbench-section-title workbench-tone" data-tone="teal">出库与库存分析</h2>
</header>
<div class="dashboard-main-charts">
<section class="dashboard-chart">
<section class="dashboard-chart workbench-panel">
<DashboardTrend :points="data.salesTrend.points" :disabled="contextExpired" @select="date => drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: date, endDate: date })">
<template #explanation><p>{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)</p></template>
</DashboardTrend>
</section>
<section class="dashboard-chart">
<section class="dashboard-chart workbench-panel">
<DashboardBars :rows="data.coverageDistribution.candidateSkuCount == null ? [] : bucketRows" title="库存消化天数分布" unit="SKU" :disabled="stockExpired || contextExpired" @select="code => drill('inventory', { coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' })">
<template #actions>
<ChartDetailsDialog inline button-label="说明" title="库存消化天数分布 · 统计说明" width="760px" :reset-on="data"><p>有实物库存或库存待核验 SKU;{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></ChartDetailsDialog>
@@ -236,8 +264,14 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
</DashboardBars>
</section>
</div>
</section>
<section class="workbench-section" aria-labelledby="overview-rankings-heading">
<header class="workbench-section-heading">
<h2 id="overview-rankings-heading" class="workbench-section-title workbench-tone" data-tone="purple">SKU 排行</h2>
<span class="workbench-badge">所选时段 · Top 10</span>
</header>
<div class="dashboard-rankings">
<section v-for="ranking in rankingNames" :key="ranking.key" class="dashboard-chart">
<section v-for="(ranking, index) in rankingNames" :key="ranking.key" class="dashboard-chart workbench-panel workbench-tone" :data-tone="metricTones[index]">
<DashboardBars ranked :details-width="ranking.key === 'hotSkus' ? '1280px' : undefined" :rows="rankingRows(data[ranking.key])" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!, data![ranking.key])">
<template #actions>
<ChartDetailsDialog inline button-label="说明" :title="`${ranking.label} · 统计说明`" width="1040px" :reset-on="data">
@@ -249,7 +283,7 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
</ChartDetailsDialog>
<el-button link type="primary" :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button>
</template>
<template #intro>
<template v-if="hasRankingWarnings" #intro>
<div class="dashboard-ranking-status" role="status">
<span v-if="contextExpired || ranking.key !== 'hotSkus' && stockExpired">历史数据 · 待刷新</span>
<span v-else-if="data[ranking.key].dataStatus !== 'COMPLETE'">{{ ranking.key === 'hotSkus' ? '数据未采齐,排名仅供参考' : Number(data[ranking.key].unassessableCount) > 0 ? `${quantityText(data[ranking.key].unassessableCount)} 个SKU待评估` : '数据待核验' }}</span>
@@ -259,43 +293,19 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
</DashboardBars>
</section>
</div>
</section>
</template>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close><div class="admin-density"><p v-if="trendError" role="alert">{{ trendError }}</p><SkuTrend v-if="trend" :data="trend" /></div></AppDialog>
<AppDialog v-model="trendOpen" class="admin-density-dialog admin-workbench is-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close><div class="admin-density"><p v-if="trendError" role="alert">{{ trendError }}</p><SkuTrend v-if="trend" :data="trend" /></div></AppDialog>
</template>
</AdminPage>
</template>
<style scoped>
.dashboard-page { display: grid; gap: var(--admin-space-3); }
.dashboard-title { display: flex; align-items: center; gap: 16px; }
.dashboard-data-notes { font-size: var(--admin-font-caption); color: #76849c; }
.dashboard-data-notes p { margin: 6px 0; }
.dashboard-snapshot { display: inline-flex; align-items: center; gap: 8px; color: #65748d; font-size: var(--admin-font-caption); }
.dashboard-snapshot i { width: 7px; height: 7px; border-radius: 50%; background: #5575ed; }
.dashboard-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
.dashboard-kpis section { min-width: 0; position: relative; padding: 16px; border: 1px solid #e5eaf4; border-radius: 8px; background: white; border-top: 3px solid var(--metric-color); animation: dashboard-reveal .5s ease-out both; animation-delay: var(--reveal-delay); }
.dashboard-metric-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.dashboard-metric-heading svg { color: var(--metric-color); flex-shrink: 0; }
.dashboard-kpis h3, .dashboard-chart h3 { margin: 0; font-size: var(--admin-font-section); }
.dashboard-kpis h3 { color: #65748d; font-size: var(--admin-font-caption); font-weight: 500; }
.dashboard-metric-value { margin: 12px 0 8px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px; }
.dashboard-kpis strong { font-size: 28px; font-weight: 700; letter-spacing: -.7px; font-variant-numeric: tabular-nums; color: #24334d; overflow-wrap: anywhere; }
.dashboard-metric-value > span { font-size: var(--admin-font-caption); color: #8290a6; }
.dashboard-metric-status { color: var(--metric-color); }
.dashboard-kpis p { margin: var(--admin-space-1) 0; }
.dashboard-main-charts { display: grid; grid-template-columns: 2fr 1fr; gap: var(--admin-space-4); }
.dashboard-kpis, .dashboard-skeleton-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
.dashboard-main-charts { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--workbench-grid-gap); }
.dashboard-ranking-status { min-height: 24px; margin-bottom: var(--admin-space-2); color: var(--el-color-warning-dark-2); font-size: var(--admin-font-caption); }
.dashboard-rankings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
.dashboard-chart { min-width: 0; padding: 18px; background: var(--admin-surface); border: 1px solid #e5eaf4; border-radius: 8px; animation: dashboard-reveal .6s ease-out both; }
.dashboard-rankings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
.dashboard-chart p { color: #76849c; font-size: var(--admin-font-caption); line-height: 1.7; }
.dashboard-rankings .dashboard-chart:nth-child(2) { animation-delay: .08s; }
.dashboard-rankings .dashboard-chart:nth-child(3) { animation-delay: .16s; }
.dashboard-placeholder { padding: 12px 0; color: #76849c; }
.dashboard-skeleton-kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin: 12px 0; }
.dashboard-skeleton-charts { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; }
.dashboard-placeholder i { display: block; height: 130px; border-radius: 8px; background: linear-gradient(100deg, #eaf0f8 30%, #f8faff 50%, #eaf0f8 70%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s infinite; }
.dashboard-skeleton-charts i { height: 320px; }
@keyframes dashboard-reveal { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes skeleton-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) { .dashboard-kpis section, .dashboard-chart, .dashboard-placeholder i { animation: none; } }
small { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
</style>
@@ -55,6 +55,7 @@ describe('self-operated actual workbench request state', () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
const field = wrapper.findComponent(queryStub).props('fields').find((f: { key: string }) => f.key === 'customerCode')
expect(field.options).toEqual([{ value: 'OWNER-A', label: '货主甲 (OWNER-A)' }])
expect(field).toMatchObject({ label: '货主', placeholder: '货主名称或编码' })
await setFilters(wrapper, { customerCode: 'OWNER-A' }); await findButton(wrapper, '查询').trigger('click'); await flushPromises()
expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ customerCode: 'OWNER-A', current: 1 })
await setFilters(wrapper, { customerCode: 'OWNER-B' }); wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises()
@@ -397,6 +398,17 @@ describe('self-operated actual workbench request state', () => {
expect(toolbar.exists()).toBe(true)
expect(toolbar.attributes('preference-key')).toBe(`self-operated-${mode}-query`)
expect(wrapper.findAllComponents(queryStub)).toHaveLength(1)
expect(wrapper.get('.workbench-table').classes().includes('is-compact-toolbar')).toBe(mode === 'sales')
expect(table.props('columns')[0]).toMatchObject(mode === 'sales' ? { key: 'actions', fixed: 'left' } : { prop: 'sku', fixed: 'left' })
})
it('retains the normal toolbar when analytics receives view tabs', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' }, slots: { 'view-tabs': () => h('nav', '视图切换') } })
wrappers.push(wrapper)
await flushPromises()
expect(wrapper.get('.workbench-table').classes()).not.toContain('is-compact-toolbar')
expect(wrapper.findComponent(tableStub).vm.$slots['toolbar-start']).toBeDefined()
expect(findButton(wrapper, '查看采集状态').exists()).toBe(true)
expect(findButton(wrapper, '查看库存').exists()).toBe(true)
})
it('uses one filter workspace with optional customer selection and fixed-version pagination', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
@@ -2,20 +2,43 @@ import { describe, expect, it } from 'vitest'
import type { LineSeriesOption } from 'echarts'
import type { DailyPoint } from './types'
import { skuTrendOption } from './skuTrendChart'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
const point = (day: number, status: string, value: string | null = '2'): DailyPoint => ({ businessDate: `2026-10-${day}`, dataStatus: status, outboundQuantity: value, observedQuantity: value, quantityUnit: 'PCS', reasonCodes: [] })
describe('SKU ECharts data configuration', () => {
describe.each([
['default', undefined],
['workbench', adminWorkbenchChartTheme],
] as const)('SKU ECharts data configuration with %s theme', (_, theme) => {
it('connects complete to partial days with dashed edges and resumes solid edges', () => {
const series = skuTrendOption([point(10, 'COMPLETE'), point(11, 'PARTIAL'), point(12, 'COMPLETE'), point(13, 'COMPLETE')]).series as LineSeriesOption[]
const series = skuTrendOption([point(10, 'COMPLETE'), point(11, 'PARTIAL'), point(12, 'COMPLETE'), point(13, 'COMPLETE')], theme).series as LineSeriesOption[]
expect(series[1]?.lineStyle?.type).toBe('dashed')
expect(series[1]?.data).toEqual([2, 2, 2, null])
expect(series[2]?.lineStyle?.type).toBe('solid')
expect(series[2]?.data).toEqual([null, null, 2, 2])
})
it('does not join across unknown or omitted dates and preserves zero', () => {
const series = skuTrendOption([point(10, 'COMPLETE', '0'), point(11, 'UNKNOWN', null), point(12, 'COMPLETE'), point(14, 'COMPLETE')]).series as LineSeriesOption[]
const series = skuTrendOption([point(10, 'COMPLETE', '0'), point(11, 'UNKNOWN', null), point(12, 'COMPLETE'), point(14, 'COMPLETE')], theme).series as LineSeriesOption[]
expect(series).toHaveLength(1)
expect(series[0]?.data?.[0]).toMatchObject({ value: 0 })
expect(series[0]?.data?.[1]).toMatchObject({ value: null })
expect(series[0]?.connectNulls).toBe(false)
})
})
it('only shades contiguous complete segments and retains date indices while zooming', () => {
const points = [point(10, 'COMPLETE'), point(11, 'COMPLETE'), point(12, 'UNKNOWN', null), point(13, 'PARTIAL'), point(14, 'COMPLETE'), point(16, 'COMPLETE'), point(17, 'COMPLETE')]
const option = skuTrendOption(points, adminWorkbenchChartTheme)
const series = option.series as LineSeriesOption[]
expect(series[0]?.areaStyle).toBeUndefined()
expect(series[1]?.areaStyle).toBeDefined()
expect(series[1]?.data).toEqual([2, 2, null, null, null, null, null])
expect(series[2]?.lineStyle?.type).toBe('dashed')
expect(series[2]?.areaStyle).toBeUndefined()
expect(series[3]?.data).toEqual([null, null, null, null, null, 2, 2])
expect(series.slice(1).every(item => item.silent && item.connectNulls === false)).toBe(true)
expect(option.dataZoom).toMatchObject([{ start: 0, end: 100, filterMode: 'none' }])
expect(option.xAxis).toMatchObject({ data: points.map(item => item.businessDate) })
expect(option.yAxis).toMatchObject({ min: 0 })
const original = skuTrendOption(points).series as LineSeriesOption[]
expect(original[0]?.itemStyle?.color).toBe('#6078ea')
expect(original.every(item => !item.areaStyle)).toBe(true)
})
+29 -9
View File
@@ -1,8 +1,10 @@
import type { EChartsOption, LineSeriesOption } from 'echarts'
import type { DailyPoint } from './types'
import { label, quantityText, reasonsText, shownQuantity } from './state'
import type { AdminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
export function skuTrendOption(points: DailyPoint[]): EChartsOption {
export function skuTrendOption(points: DailyPoint[], theme?: AdminWorkbenchChartTheme): EChartsOption {
const lineColor = theme?.colors.primary ?? '#6078ea'
const dates = points.map(point => point.businessDate)
const values = points.map(point => {
const value = shownQuantity(point)
@@ -10,9 +12,9 @@ export function skuTrendOption(points: DailyPoint[]): EChartsOption {
})
const series: LineSeriesOption[] = [{
name: '每日出库量', type: 'line', connectNulls: false, lineStyle: { opacity: 0 },
symbolSize: 8, showAllSymbol: true,
symbolSize: theme ? 7 : 8, showAllSymbol: true,
data: values.map((value, index) => ({ value, symbol: points[index]?.dataStatus === 'COMPLETE' ? 'circle' : 'emptyCircle' })),
itemStyle: { color: '#6078ea' }, emphasis: { scale: 1.5 },
itemStyle: { color: lineColor }, emphasis: { scale: 1.5 },
}]
let segment: { partial: boolean; data: (number | null)[] } | null = null
for (let index = 1; index < points.length; index++) {
@@ -21,7 +23,12 @@ export function skuTrendOption(points: DailyPoint[]): EChartsOption {
if (!segment || segment.partial !== partial) {
segment = { partial, data: Array<number | null>(points.length).fill(null) }
series.push({ type: 'line', data: segment.data, symbol: 'none', silent: true, connectNulls: false,
tooltip: { show: false }, lineStyle: { color: '#6078ea', width: 3, type: partial ? 'dashed' : 'solid' },
tooltip: { show: false }, lineStyle: { color: lineColor, width: theme ? 2.5 : 3, type: partial ? 'dashed' : 'solid' },
// Only known, contiguous, complete segments receive shading; gaps stay empty.
...(theme && !partial ? { areaStyle: { color: {
type: 'linear' as const, x: 0, y: 0, x2: 0, y2: 1,
colorStops: [{ offset: 0, color: theme.colors.primarySoft }, { offset: 1, color: theme.colors.primaryFaint }],
} } } : {}),
emphasis: { disabled: true }, animation: false })
}
segment.data[index - 1] = values[index - 1]!
@@ -29,19 +36,32 @@ export function skuTrendOption(points: DailyPoint[]): EChartsOption {
}
return {
animation: false,
...(theme ? { textStyle: { fontFamily: theme.fontFamily, color: theme.colors.text } } : {}),
grid: { left: 24, right: 32, top: 42, bottom: 80, containLabel: true },
xAxis: { type: 'category', data: dates, name: '日期(北京时间)', nameLocation: 'middle', nameGap: 30,
boundaryGap: false, axisLine: { show: true }, axisTick: { show: true },
axisLabel: { hideOverlap: true, showMinLabel: true, showMaxLabel: true, formatter: (value: string) => value.slice(5) } },
yAxis: { type: 'value', name: '出库量(件)', min: 0, axisLine: { show: true }, axisTick: { show: true }, splitLine: { show: true, lineStyle: { type: 'dashed', color: '#e5eaf3' } } },
tooltip: { trigger: 'axis', renderMode: 'richText', confine: true, axisPointer: { type: 'line' },
boundaryGap: false, axisLine: { show: true, ...(theme ? { lineStyle: { color: theme.colors.axis } } : {}) }, axisTick: { show: !theme },
...(theme ? { nameTextStyle: theme.axisName } : {}),
axisLabel: { ...theme?.axisLabel, hideOverlap: true, showMinLabel: true, showMaxLabel: true, formatter: (value: string) => value.slice(5) } },
yAxis: { type: 'value', name: '出库量(件)', min: 0, axisLine: { show: !theme }, axisTick: { show: !theme },
...(theme ? { axisLabel: theme.axisLabel, nameTextStyle: theme.axisName } : {}),
splitLine: theme?.splitLine ?? { show: true, lineStyle: { type: 'dashed', color: '#e5eaf3' } } },
tooltip: { ...theme?.tooltip, trigger: 'axis', renderMode: 'richText', confine: true,
axisPointer: { type: 'line', ...(theme ? { lineStyle: { color: theme.colors.primary, width: 1, type: 'dashed' as const } } : {}) },
formatter: params => {
const entries = Array.isArray(params) ? params : [params]
const item = entries.find(entry => entry.seriesIndex === 0) ?? entries[0]
const point = item ? points[item.dataIndex] : undefined
return point ? `${point.businessDate}\n出库量:${quantityText(shownQuantity(point))} 件\n${label(point.dataStatus)}\n覆盖截止:${point.coveredUntil ?? '未知'}\n${reasonsText(point.reasonCodes)}` : ''
} },
dataZoom: [{ type: 'slider', bottom: 4, height: 20, start: 0, end: 100, filterMode: 'none' }],
dataZoom: [{ type: 'slider', bottom: 4, height: theme ? 16 : 20, start: 0, end: 100, filterMode: 'none',
...(theme ? {
borderColor: theme.colors.border, backgroundColor: theme.colors.primaryFaint, fillerColor: theme.colors.primarySoft,
handleStyle: { color: theme.colors.surface, borderColor: theme.colors.primary, borderWidth: 1 },
moveHandleStyle: { color: theme.colors.axis }, textStyle: { color: theme.colors.muted },
dataBackground: { lineStyle: { color: theme.colors.axis }, areaStyle: { color: theme.colors.primaryFaint } },
selectedDataBackground: { lineStyle: { color: theme.colors.primary }, areaStyle: { color: theme.colors.primarySoft } },
} : {}),
}],
aria: { enabled: true, description: 'SKU每日出库趋势。横轴为北京时间日期,纵轴为出库件数。完整日实线,未完整日虚线,缺失日期断线。详细数值可展开下方表格。' },
series,
}
+18 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RequestFence, RetryIntent, RuntimePoller, contextParams, label, reasonsText, progressText, sameContext, shownQuantity, taskRetryAllowed, validateCustomRange } from './state'
import { RequestFence, RetryIntent, RuntimePoller, contextParams, groupedQuantityText, label, quantityText, reasonsText, progressText, sameContext, shownQuantity, taskRetryAllowed, validateCustomRange } from './state'
import type { QueryContext, Task } from './types'
const context: QueryContext = { salesRunId: '9007199254740999', inventoryRunId: null, salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: '2026-10-12T15:00:00+08:00', queryExpiresAt: '2026-10-12T15:30:00+08:00', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12' }
@@ -36,6 +36,23 @@ describe('self-operated query and command protection', () => {
expect(shownQuantity({ outboundQuantity: null, observedQuantity: null, dataStatus: 'UNKNOWN' })).toBeNull()
expect(shownQuantity({ outboundQuantity: '0', observedQuantity: '0', dataStatus: 'COMPLETE' })).toBe('0')
})
it('groups quantities without losing long integers, decimal digits or the original formatter semantics', () => {
const cases: Array<[string | number | null | undefined, string]> = [
['123456789012345678901234567890.123450000001', '123,456,789,012,345,678,901,234,567,890.123450000001'],
['9007199254740993', '9,007,199,254,740,993'],
['-1234567.000100', '-1,234,567.000100'],
[1234567.125, '1,234,567.125'],
['0.000000000001', '0.000000000001'],
['0', '0'],
[0, '0'],
[null, '—'],
[undefined, '—'],
]
for (const [value, expected] of cases) {
expect(groupedQuantityText(value)).toBe(expected)
expect(quantityText(value)).toBe(value == null ? '—' : String(value))
}
})
it('requires permission, server eligibility, correct warehouse and reason for retry', () => {
const scope = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A' }
expect(taskRetryAllowed(task, true, scope, '修复后恢复')).toBe(true)
+3
View File
@@ -39,6 +39,9 @@ export function shownQuantity(row: { outboundQuantity: string | number | null; o
return row.dataStatus === 'COMPLETE' ? row.outboundQuantity : row.observedQuantity
}
export function quantityText(value: string | number | null | undefined) { return value === null || value === undefined ? '—' : String(value) }
export function groupedQuantityText(value: string | number | null | undefined) {
return quantityText(value).replace(/^(-?\d+)(\.\d+)?$/, (_match, integer: string, fraction: string | undefined) => `${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}${fraction ?? ''}`)
}
export function beijingTime(value: string | null | undefined) {
if (!value) return '—'
const date = new Date(value)
@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { BarSeriesOption } from 'echarts'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
import { turnoverChart, turnoverNumber, volumeText, windowText, turnoverReasonsText, volumeNote } from './turnoverPresentation'
import type { TurnoverCharts, TurnoverWindowMetric } from './turnoverTypes'
describe('turnover honest precision and charts', () => {
@@ -35,4 +37,51 @@ describe('turnover honest precision and charts', () => {
expect(JSON.stringify(option.series)).toContain('"data":[10,null]')
expect(windowText(partial)).toBe('已采集 150,覆盖不足')
})
it.each(['owners', 'volume', 'daily'] as const)('applies the workbench theme to %s without adding artificial bar lengths', kind => {
const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: [] }
const option = turnoverChart(charts, kind)
expect(option.textStyle).toMatchObject({ color: adminWorkbenchChartTheme.colors.text, fontFamily: adminWorkbenchChartTheme.fontFamily })
expect(option.tooltip).toMatchObject({ backgroundColor: adminWorkbenchChartTheme.colors.surface, borderColor: adminWorkbenchChartTheme.colors.border, renderMode: 'richText' })
expect(kind === 'daily' ? option.yAxis : option.xAxis).toMatchObject({ type: 'value', min: 0, axisLabel: adminWorkbenchChartTheme.axisLabel, splitLine: adminWorkbenchChartTheme.splitLine })
const series = (option.series as BarSeriesOption[])[0]!
expect(series.itemStyle).toMatchObject({ color: adminWorkbenchChartTheme.colors.primary, borderRadius: kind === 'daily' ? [6, 6, 0, 0] : [0, 6, 6, 0] })
expect(series.data).toEqual([])
expect(series).not.toHaveProperty('showBackground')
expect(series).not.toHaveProperty('barMinHeight')
})
it('preserves owner ordering, volume precision, zero and unknown without changing click indexes', () => {
const volume = { value: null, knownSubtotal: null, missingDimensionQuantity: null, missingDimensionSkuCount: 0, unknownQuantitySkuCount: 0, declaredDimensionQuantity: 0, reasonCodes: [] }
const values = ['12345.678901', '0', null, '0.000001']
const charts: TurnoverCharts = {
ownerPhysicalTop: values.map((physicalQuantity, index) => ({ customerCode: `OWNER-${index}`, customerName: index === 0 ? '货主名称' : null, physicalQuantity })),
ownerVolumeTop: values.map((value, index) => ({ customerCode: `OWNER-${index}`, customerName: index === 0 ? '货主名称' : null, inventoryVolumeM3: { ...volume, value } })),
unknownOwnerCount: 1, unknownVolumeOwnerCount: 1, averageDaily: [],
}
for (const kind of ['owners', 'volume'] as const) {
const option = turnoverChart(charts, kind)
expect(option.yAxis).toMatchObject({ inverse: true, data: ['货主名称', 'OWNER-1', 'OWNER-2', 'OWNER-3'] })
expect((option.series as BarSeriesOption[])[0]!.data).toEqual([12345.678901, 0, null, 0.000001])
}
const option = turnoverChart(charts, 'volume')
expect(option.grid).toMatchObject({ right: 110 })
const label = (option.series as BarSeriesOption[])[0]!.label as { formatter: (params: { value: number | null }) => string }
const tooltip = option.tooltip as { valueFormatter: (value: number | null) => string }
expect(label.formatter({ value: 12345.678901 })).toBe('12,345.68 m³')
expect(label.formatter({ value: 0.000001 })).toBe('<0.01 m³')
expect(tooltip.valueFormatter(12345.678901)).toBe('12,345.68 m³')
expect(tooltip.valueFormatter(null)).toBe('— m³')
})
it('continues to honor reduced-motion preference', () => {
const charts: TurnoverCharts = { ownerPhysicalTop: [], unknownOwnerCount: 0, ownerVolumeTop: [], unknownVolumeOwnerCount: 0, averageDaily: [] }
const matchMedia = vi.fn(() => ({ matches: true }))
vi.stubGlobal('window', { matchMedia })
try {
expect(turnoverChart(charts, 'owners').animation).toBe(false)
expect(matchMedia).toHaveBeenCalledWith('(prefers-reduced-motion: reduce)')
matchMedia.mockReturnValue({ matches: false })
expect(turnoverChart(charts, 'owners').animation).toBe(true)
} finally {
vi.unstubAllGlobals()
}
})
})
@@ -1,6 +1,7 @@
import type { Quantity, WarehouseOverview } from './types'
import type { TurnoverCharts, TurnoverTotal, TurnoverVolume, TurnoverWindowMetric } from './turnoverTypes'
import type { EChartsOption } from 'echarts'
import { adminWorkbenchChartTheme } from '@/styles/adminWorkbenchChartTheme'
import { reasonsText } from './state'
const turnoverLabels: Record<string, string> = {
INDEPENDENT_ANCHOR_MISSING: '缺少独立库存对账记录', INDEPENDENT_ANCHOR_QUALITY_MISSING: '独立库存品质记录不完整',
@@ -76,6 +77,7 @@ export function windowText(metric: TurnoverWindowMetric | undefined) {
return metric.dataStatus === 'COMPLETE' ? turnoverNumber(metric.outboundQuantity) : `已采集 ${turnoverNumber(metric.observedQuantity)},覆盖不足`
}
export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'volume' | 'daily'): EChartsOption {
const theme = adminWorkbenchChartTheme
const owners = kind !== 'daily', volume = kind === 'volume'
const ownerRows = volume ? charts.ownerVolumeTop : charts.ownerPhysicalTop
const unit = volume ? 'm³' : owners ? '件' : '件/日'
@@ -84,13 +86,26 @@ export function turnoverChart(charts: TurnoverCharts, kind: 'owners' | 'volume'
: charts.averageDaily.map(w => w.dataStatus === 'COMPLETE' ? w.averageDailyOutboundQuantity : null)
return {
aria: { enabled: true }, animation: typeof window === 'undefined' || !window.matchMedia?.('(prefers-reduced-motion: reduce)').matches,
animationDuration: 450, color: ['#6c7bea'],
tooltip: { trigger: 'axis', renderMode: 'richText', valueFormatter: value => `${turnoverNumber(typeof value === 'number' || typeof value === 'string' ? value : null)} ${unit}` },
animationDuration: 450, color: [theme.colors.primary],
textStyle: { color: theme.colors.text, fontFamily: theme.fontFamily },
tooltip: { ...theme.tooltip, trigger: 'axis', renderMode: 'richText', valueFormatter: value => `${turnoverNumber(typeof value === 'number' || typeof value === 'string' ? value : null)} ${unit}` },
grid: { left: 12, right: volume ? 110 : 40, top: owners ? 12 : 48, bottom: 12, containLabel: true },
xAxis: owners ? { type: 'value', name: unit } : { type: 'category', data: charts.averageDaily.map(w => `${w.days}日`) },
yAxis: owners ? { type: 'category', inverse: true, data: ownerRows.map(w => w.customerName || w.customerCode) } : { type: 'value', name: unit, nameGap: 12, nameTextStyle: { align: 'left' } },
series: [{ type: 'bar', barMaxWidth: 24, itemStyle: { borderRadius: 4 },
label: { show: kind !== 'owners', position: volume ? 'right' : 'top', distance: 8, formatter: params => volume ? volumeText(typeof params.value === 'number' ? params.value : null, 2) : turnoverNumber(typeof params.value === 'number' ? params.value : null) },
xAxis: owners ? {
type: 'value', name: unit, min: 0, nameTextStyle: theme.axisName, axisLabel: theme.axisLabel,
axisLine: { show: false }, axisTick: { show: false }, splitLine: theme.splitLine,
} : {
type: 'category', data: charts.averageDaily.map(w => `${w.days}日`), axisLabel: theme.axisLabel,
axisLine: { lineStyle: { color: theme.colors.axis } }, axisTick: { show: false },
},
yAxis: owners ? {
type: 'category', inverse: true, data: ownerRows.map(w => w.customerName || w.customerCode),
axisLabel: theme.axisLabel, axisLine: { show: false }, axisTick: { show: false },
} : {
type: 'value', name: unit, min: 0, nameGap: 12, nameTextStyle: { ...theme.axisName, align: 'left' },
axisLabel: theme.axisLabel, axisLine: { show: false }, axisTick: { show: false }, splitLine: theme.splitLine,
},
series: [{ type: 'bar', barMaxWidth: 20, itemStyle: { color: theme.colors.primary, borderRadius: owners ? [0, 6, 6, 0] : [6, 6, 0, 0] },
label: { show: kind !== 'owners', position: volume ? 'right' : 'top', distance: 8, color: theme.colors.text, fontSize: 12, fontFamily: theme.fontFamily, formatter: params => volume ? volumeText(typeof params.value === 'number' ? params.value : null, 2) : turnoverNumber(typeof params.value === 'number' ? params.value : null) },
data: values.map(value => value == null ? null : Number(value)) }],
}
}
+5 -4
View File
@@ -97,7 +97,7 @@ const notificationConsumers = [
'notifications/components/TemplateEditorDialog.vue',
]
const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView.vue', 'iam/pages/PermissionsView.vue']
const selfOperatedPages = ['self-operated/components/AnalyticsView.vue']
const selfOperatedPages = ['self-operated/components/AnalyticsView.vue', 'self-operated/components/InventoryTurnoverView.vue']
const selfOperatedConsumers = [...selfOperatedPages, 'self-operated/components/SkuTrend.vue', 'self-operated/pages/OverviewView.vue']
const consumers = [
...selfOperatedConsumers,
@@ -444,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 dashboard-page">')
expect(source).toContain('<AdminPage class="admin-density admin-workbench dashboard-page">')
expect(source).toContain('class="dashboard-kpis"')
expect(source).toContain('class="dashboard-main-charts"')
expect(source).not.toContain('density-fields')
@@ -883,11 +883,12 @@ describe('approved Admin density ownership', () => {
it('keeps the collection monitor in the Admin theme without legacy list controls', () => {
const page = read('modules/self-operated/pages/CollectionView.vue')
const monitor = read('modules/self-operated/components/CollectionAccountMonitor.vue')
expect(page).toContain('class="admin-density"')
expect(page).toContain('class="admin-density admin-workbench"')
expect(page).toContain('@/styles/adminDensity.css')
expect(page).toContain('@/styles/adminWorkbench.css')
expect(page).toContain('<CollectionAccountMonitor')
expect(page).not.toContain('<DataTable')
expect(monitor).toContain('background: var(--el-bg-color)')
expect(monitor).toContain('workbench-panel')
expect(monitor).not.toMatch(/--el-[\w-]+\s*:/)
})
it('opts the confirmed self-operated pages into integrated text actions without changing older outlined consumers', () => {
+597
View File
@@ -0,0 +1,597 @@
@import './adminWorkbenchTokens.css';
.admin-workbench {
--el-color-primary: var(--workbench-primary);
--el-color-primary-light-3: var(--workbench-primary-hover);
--el-color-primary-light-5: var(--workbench-primary-disabled);
--el-color-primary-light-7: var(--workbench-accent-border);
--el-color-primary-light-8: var(--workbench-accent-border);
--el-color-primary-light-9: var(--workbench-accent-soft);
--el-color-primary-dark-2: var(--workbench-primary-active);
}
/* Keep business grids in the page; these primitives only provide appearance. */
.admin-workbench.admin-page {
min-width: 0;
padding: var(--workbench-page-space);
background: var(--workbench-surface);
color: var(--workbench-text);
}
/* Override AdminPage's scoped normal spacing only for this explicit opt-in. */
.admin-workbench.admin-page.admin-density.is-spacing-normal {
gap: var(--workbench-page-space);
}
.admin-workbench .workbench-heading {
display: grid;
min-width: 0;
gap: 6px;
}
.admin-workbench .workbench-title {
margin: 0;
color: var(--workbench-text);
font-size: var(--workbench-title-size);
font-weight: 700;
line-height: 1.4;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-meta {
display: inline-flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 6px;
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.6;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-meta > i {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--workbench-accent);
}
.admin-workbench .workbench-section {
display: grid;
min-width: 0;
gap: var(--workbench-section-gap);
}
.admin-workbench .workbench-section-heading {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.admin-workbench .workbench-section-title {
display: flex;
min-width: 0;
align-items: center;
gap: 9px;
margin: 0;
color: var(--workbench-text);
font-size: var(--workbench-section-title-size);
font-weight: 600;
line-height: 1.5;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-section-title::before {
width: 8px;
height: 8px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--workbench-accent);
content: '';
}
.admin-workbench .workbench-badge {
max-width: 100%;
padding: 3px 10px;
border-radius: 999px;
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.5;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-badge.is-toned {
border: 1px solid var(--workbench-accent-border);
background: var(--workbench-accent-soft);
color: var(--workbench-accent);
}
.admin-workbench .workbench-metric,
.admin-workbench .workbench-panel {
min-width: 0;
padding: var(--workbench-card-space);
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-radius);
background: var(--workbench-surface);
box-shadow: var(--workbench-shadow);
}
.admin-workbench .workbench-metric {
min-height: var(--workbench-metric-min-height);
box-sizing: border-box;
background: linear-gradient(145deg, var(--workbench-surface) 58%, var(--workbench-surface-tint));
}
.admin-workbench .workbench-panel.is-flush {
padding: 0;
}
.admin-workbench .workbench-panel.is-compact {
padding: var(--workbench-compact-card-space);
}
.admin-workbench .workbench-metric.is-compact {
min-height: 0;
padding: var(--workbench-compact-card-space);
}
.admin-workbench .workbench-metric.is-compact .workbench-metric-heading {
gap: 8px;
}
.admin-workbench .workbench-metric.is-compact .workbench-metric-label {
font-size: var(--workbench-note-size);
}
.admin-workbench .workbench-metric.is-compact .workbench-metric-value {
margin: 10px 0 6px;
}
.admin-workbench .workbench-metric.is-compact .workbench-metric-value > strong {
font-size: var(--workbench-compact-number-size);
}
.admin-workbench .workbench-icon.is-compact {
width: 32px;
height: 32px;
border-radius: var(--workbench-tab-radius);
}
.admin-workbench .workbench-icon.is-compact > svg {
width: 18px;
height: 18px;
}
.admin-workbench .workbench-metric-heading {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.admin-workbench .workbench-icon {
display: inline-flex;
width: 44px;
height: 44px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: var(--workbench-icon-radius);
background: var(--workbench-accent-soft);
color: var(--workbench-accent);
}
.admin-workbench .workbench-icon > svg {
width: 24px;
height: 24px;
}
.admin-workbench .workbench-metric-label {
min-width: 0;
margin: 0;
color: var(--workbench-text);
font-size: var(--workbench-label-size);
font-weight: 600;
line-height: 1.5;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-metric-value {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 6px;
margin: 20px 0 12px;
}
.admin-workbench .workbench-metric-value > strong {
min-width: 0;
color: var(--workbench-number);
font-size: var(--workbench-number-size);
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.2;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-metric-value > span {
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.5;
}
.admin-workbench .workbench-note,
.admin-workbench .workbench-status {
display: block;
margin: 4px 0;
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.6;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-panel.workbench-tone {
border-color: var(--workbench-accent-border);
}
.admin-workbench .workbench-chart {
display: flex;
min-width: 0;
height: 100%;
flex-direction: column;
}
.admin-workbench .workbench-chart-heading {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-bottom: 12px;
}
.admin-workbench .workbench-chart-title {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
margin: 0;
color: var(--workbench-text);
font-size: var(--workbench-label-size);
font-weight: 600;
line-height: 1.5;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-chart-title::before {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 50%;
background: var(--workbench-accent);
content: '';
}
.admin-workbench .workbench-chart-actions {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-left: auto;
}
.admin-workbench .workbench-chart-actions .el-button {
--el-button-text-color: var(--workbench-primary);
--el-button-hover-text-color: var(--workbench-primary-active);
--el-button-disabled-text-color: var(--workbench-primary-disabled);
font-size: var(--workbench-note-size);
}
.admin-workbench .workbench-chart-actions .el-button + .el-button {
margin-left: 0;
}
.admin-workbench .workbench-chart-canvas {
min-width: 0;
flex: 0 0 auto;
}
.admin-workbench .workbench-chart-empty {
display: flex;
min-height: 240px;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: var(--workbench-card-space);
border-radius: var(--workbench-icon-radius);
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
text-align: center;
}
.admin-workbench .workbench-chart-empty.is-compact {
min-height: 0;
padding: var(--workbench-compact-card-space);
}
.admin-workbench .workbench-chart-empty-icon {
display: grid;
width: 56px;
height: 56px;
flex: 0 0 auto;
place-items: center;
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-icon-radius);
background: var(--workbench-surface);
color: var(--workbench-muted);
}
.admin-workbench .workbench-chart-empty p {
max-width: 28em;
margin: 0;
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
line-height: 1.7;
overflow-wrap: anywhere;
}
/* DataTable retains ownership of toolbar size, controls and query layout. */
.admin-workbench .workbench-toolbar,
.admin-workbench .workbench-table {
min-width: 0;
}
/* DataTable forwards attrs to its inner table, so opt in through this wrapper. */
.admin-workbench .workbench-toolbar > .data-table,
.admin-workbench .workbench-table > .data-table {
--list-card-accent: var(--workbench-primary);
--list-card-border: var(--workbench-border);
--list-card-muted-bg: var(--workbench-surface-soft);
--list-card-radius: var(--workbench-radius);
min-width: 0;
border-color: var(--workbench-border);
border-radius: var(--workbench-radius);
background: var(--workbench-surface);
box-shadow: var(--workbench-shadow);
}
.admin-workbench .workbench-toolbar > .data-table > .data-table__toolbar,
.admin-workbench .workbench-table > .data-table > .data-table__toolbar {
border-color: var(--workbench-border);
background: var(--workbench-surface);
}
/* Only title-free list toolbars opt in; keep controls and wrapping intact. */
.admin-workbench .workbench-table.is-compact-toolbar > .data-table > .data-table__toolbar {
min-height: 52px;
padding-top: 8px;
padding-bottom: 8px;
}
.admin-workbench .workbench-table > .data-table > .el-table {
--el-table-border-color: var(--workbench-border);
--el-table-header-bg-color: var(--workbench-surface-soft);
--el-table-header-text-color: var(--workbench-muted);
--el-table-row-hover-bg-color: var(--workbench-accent-soft);
--el-table-current-row-bg-color: var(--workbench-accent-soft);
--el-table-text-color: var(--workbench-text);
color: var(--workbench-text);
}
.admin-workbench .workbench-table .el-table th.el-table__cell {
color: var(--workbench-muted);
}
.admin-workbench .workbench-table .el-table .density-action:not([data-tone]),
.admin-workbench .workbench-table .el-table .density-action[data-tone='primary'] {
--action-color: var(--workbench-primary);
--action-border: var(--workbench-accent-border);
--action-hover-bg: var(--workbench-accent-soft);
}
.admin-workbench .workbench-table .data-table .data-table__page-button[aria-current='page']:not(:disabled),
.admin-workbench .workbench-table .data-table__business-actions .el-button--primary:not(:disabled) {
box-shadow: var(--workbench-shadow);
}
/* Keep selection, focus and disabled states consistent for existing native tabs. */
.admin-workbench .workbench-tabs {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin: 0;
}
.admin-workbench .workbench-tabs > button {
display: inline-flex;
min-height: var(--admin-tab-height);
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid transparent;
border-radius: var(--workbench-tab-radius);
background: transparent;
color: var(--workbench-muted);
font: inherit;
font-weight: 500;
line-height: 1.5;
text-align: center;
cursor: pointer;
}
.admin-workbench .workbench-tabs > button:hover:not(:disabled):not([aria-pressed='true']):not(.is-active) {
background: var(--workbench-surface-soft);
color: var(--workbench-primary);
}
.admin-workbench .workbench-tabs > button[aria-pressed='true'],
.admin-workbench .workbench-tabs > button.is-active {
border-color: var(--workbench-accent-border);
background: var(--workbench-accent-soft);
color: var(--workbench-primary);
font-weight: 600;
}
.admin-workbench .workbench-tabs > button:focus-visible,
.admin-workbench .workbench-input:focus-visible {
outline: 2px solid var(--workbench-primary);
outline-offset: 2px;
}
.admin-workbench .workbench-tabs > button:disabled {
color: var(--workbench-primary-disabled);
cursor: not-allowed;
}
.admin-workbench .workbench-input {
min-height: var(--admin-control-height);
box-sizing: border-box;
padding: 6px 8px;
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-tab-radius);
background: var(--workbench-surface);
color: var(--workbench-text);
font: inherit;
}
.admin-workbench .workbench-input:disabled {
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
cursor: not-allowed;
}
/* Teleported dialogs opt in on their own root; retain the density frame geometry. */
.admin-workbench.is-dialog.el-dialog:not(.is-fullscreen) {
border-radius: var(--workbench-radius);
}
.admin-workbench.is-dialog.el-dialog .el-dialog__header,
.admin-workbench.is-dialog.el-dialog .el-dialog__footer {
border-color: var(--workbench-border);
background: var(--workbench-surface);
}
.admin-workbench.is-dialog.el-dialog .el-dialog__title {
color: var(--workbench-text);
}
.admin-workbench .workbench-evidence table {
width: 100%;
border-collapse: collapse;
font-size: var(--admin-font-body);
}
.admin-workbench .workbench-evidence th,
.admin-workbench .workbench-evidence td {
border: 1px solid var(--workbench-border);
text-align: left;
overflow-wrap: anywhere;
}
.admin-workbench .workbench-evidence table:not(.density-table) :is(th, td) {
padding: var(--admin-space-1) var(--admin-space-3);
line-height: 1.6;
}
.admin-workbench .workbench-evidence th {
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
font-weight: 600;
}
.admin-workbench .workbench-notice {
margin: 0;
padding: 10px 14px;
border: 1px solid var(--workbench-accent-border);
border-radius: var(--workbench-icon-radius);
background: var(--workbench-accent-soft);
color: var(--workbench-text);
font-size: var(--workbench-note-size);
line-height: 1.6;
overflow-wrap: anywhere;
}
/* State labels remain the source of meaning; color only reinforces them. */
.admin-workbench .workbench-step {
min-width: 0;
padding: var(--workbench-compact-card-space);
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-icon-radius);
background: var(--workbench-surface-soft);
color: var(--workbench-muted);
overflow-wrap: anywhere;
}
.admin-workbench .workbench-step-index {
display: grid;
width: 28px;
height: 28px;
flex: 0 0 auto;
place-items: center;
border: 1px solid var(--workbench-border);
border-radius: 50%;
background: var(--workbench-surface);
color: var(--workbench-muted);
font-size: var(--workbench-note-size);
font-variant-numeric: tabular-nums;
}
.admin-workbench .workbench-step[data-state='active'],
.admin-workbench .workbench-step[data-state='done'] {
border-color: var(--workbench-accent-border);
background: var(--workbench-accent-soft);
color: var(--workbench-accent);
}
.admin-workbench .workbench-step[data-state='active'] .workbench-step-index,
.admin-workbench .workbench-step[data-state='done'] .workbench-step-index {
border-color: var(--workbench-accent);
background: var(--workbench-accent);
color: var(--workbench-surface);
}
.admin-workbench .el-button--primary:not(.is-plain):not(.is-link):not(.is-text) {
--el-button-bg-color: var(--workbench-primary);
--el-button-border-color: var(--workbench-primary);
--el-button-hover-bg-color: var(--workbench-primary-hover);
--el-button-hover-border-color: var(--workbench-primary-hover);
--el-button-active-bg-color: var(--workbench-primary-active);
--el-button-active-border-color: var(--workbench-primary-active);
--el-button-disabled-bg-color: var(--workbench-primary-disabled);
--el-button-disabled-border-color: var(--workbench-primary-disabled);
}
.admin-workbench .workbench-skeleton {
display: block;
min-width: 0;
height: var(--workbench-metric-min-height);
border: 1px solid var(--workbench-border);
border-radius: var(--workbench-radius);
background: var(--workbench-surface-soft);
animation: admin-workbench-loading 1.6s ease-in-out infinite alternate;
}
@keyframes admin-workbench-loading {
from { opacity: 0.55; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.admin-workbench .workbench-skeleton {
animation: none;
}
}
+128
View File
@@ -0,0 +1,128 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { compileStyle, parse } from 'vue/compiler-sfc'
import { adminWorkbenchChartTheme } from './adminWorkbenchChartTheme'
const read = (path: string) => readFileSync(resolve(process.cwd(), 'src', path), 'utf8')
const tokens = read('styles/adminWorkbenchTokens.css')
const styles = read('styles/adminWorkbench.css')
const page = read('modules/self-operated/pages/OverviewView.vue')
const listPages = [
'modules/self-operated/components/AnalyticsView.vue',
'modules/self-operated/components/InventoryTurnoverView.vue',
]
const consumers = [
...listPages,
'modules/self-operated/pages/InventoryView.vue',
'modules/self-operated/components/SkuTrend.vue',
'modules/self-operated/components/ChartDetailsDialog.vue',
'modules/self-operated/pages/CollectionView.vue',
'modules/self-operated/components/CollectionAccountMonitor.vue',
'modules/self-operated/components/CollectionProgressGauge.vue',
'modules/self-operated/components/CollectionTaskProgress.vue',
]
const root = (source: string) => {
const result = compileStyle({ source, filename: 'workbench.css', id: 'workbench-contract' })
expect(result.errors).toEqual([])
return result.rawResult!.root
}
describe('Admin workbench opt-in styles', () => {
it('keeps canvas chart colors aligned with the public CSS palette', () => {
const declarations = new Map<string, string>()
root(tokens).walkRules(rule => {
if (rule.selector !== '.admin-workbench') return
rule.walkDecls(declaration => { declarations.set(declaration.prop, declaration.value) })
})
for (const name of ['primary', 'text', 'muted', 'border', 'surface'] as const) {
expect(adminWorkbenchChartTheme.colors[name]).toBe(declarations.get(`--workbench-${name}`))
}
expect(adminWorkbenchChartTheme.colors.primarySoft).toBe(declarations.get('--workbench-accent-soft'))
})
it('keeps lazy-loaded styles scoped to opted-in pages and dialog roots', () => {
for (const source of [tokens, styles]) {
root(source).walkRules(rule => {
if (rule.parent?.type === 'atrule' && rule.parent.name.endsWith('keyframes')) return
for (const selector of rule.selectors) {
expect(selector).toMatch(/^\.admin-workbench(?:\s|\.|:|\[|$)/)
expect(selector).not.toMatch(/:global|:root/)
if (selector.includes('.el-dialog')) expect(selector).toMatch(/^\.admin-workbench\.is-dialog\.el-dialog/)
}
})
root(source).walkDecls(declaration => {
expect(declaration.prop).not.toMatch(/^--admin-/)
expect(declaration.important).toBeFalsy()
})
}
})
it('resolves every workbench variable from the public tokens instead of page defaults', () => {
const declared = new Set<string>()
root(tokens).walkDecls(declaration => {
if (declaration.prop.startsWith('--workbench-')) declared.add(declaration.prop)
})
const pageStyles = [page, ...consumers.map(read)].flatMap(source => parse(source).descriptor.styles.map(style => style.content)).join('\n')
for (const source of [styles, pageStyles]) {
root(source).walkDecls(declaration => {
expect(declaration.prop).not.toMatch(/^--workbench-/)
})
for (const match of source.matchAll(/var\((--workbench-[\w-]+)/g)) {
expect(declared.has(match[1]!)).toBe(true)
}
}
})
it('opts list cards and teleported dialogs in on their actual rendering containers', () => {
for (const file of listPages) {
const source = read(file)
expect(source).toContain("import '@/styles/adminWorkbench.css'")
const template = parse(source).descriptor.template!.ast!
type TemplateElement = Extract<(typeof template.children)[number], { type: 1 }>
const classesOf = (node?: TemplateElement) => node?.props.flatMap(prop => prop.type === 6 && prop.name === 'class' ? prop.value?.content.split(/\s+/) ?? [] : []) ?? []
let tables = 0
const visit = (nodes: typeof template.children, ancestors: TemplateElement[]) => {
for (const node of nodes) {
if (node.type !== 1) continue
if (node.tag === 'AdminPage') expect(classesOf(node)).toEqual(expect.arrayContaining(['admin-density', 'admin-workbench']))
if (node.tag === 'DataTable') {
expect(classesOf(ancestors.at(-1))).toContain('workbench-table')
expect(classesOf(node)).not.toContain('workbench-table')
tables++
}
if (node.tag === 'AppDialog') expect(classesOf(node)).toEqual(expect.arrayContaining(['admin-density-dialog', 'admin-workbench', 'is-dialog']))
visit(node.children, [...ancestors, node])
}
}
visit(template.children, [])
expect(tables).toBe(1)
}
})
it('preserves complete metric values and the existing toolbar layout ownership', () => {
root(styles).walkRules(rule => {
if (rule.selector.includes('.workbench-metric')) {
rule.walkDecls(declaration => {
expect(['text-overflow', 'line-clamp', '-webkit-line-clamp']).not.toContain(declaration.prop)
if (declaration.prop === 'overflow') expect(declaration.value).not.toBe('hidden')
expect(declaration.prop).not.toBe('height')
})
}
if (rule.selector.includes('.data-table__toolbar')) {
rule.walkDecls(declaration => {
if (rule.selector.includes('.workbench-table.is-compact-toolbar')) {
expect(['min-height', 'padding-top', 'padding-bottom']).toContain(declaration.prop)
} else {
expect(declaration.prop).not.toMatch(/^(?:padding|height|min-height|display|gap)/)
}
})
}
if (rule.selector.includes('.is-dialog')) {
rule.walkDecls(declaration => {
expect(declaration.prop).not.toMatch(/^(?:width|height|min-height|max-height|padding|margin|overflow)/)
})
}
})
})
})
+43
View File
@@ -0,0 +1,43 @@
const fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif'
const colors = {
primary: '#3784fa',
primarySoft: '#eff6ff',
primaryFaint: 'rgba(55,132,250,0.03)',
primaryLight: '#5b9bfb',
teal: '#169b9b',
tealLight: '#46b5bc',
green: '#18a46f',
purple: '#8b5cf6',
amber: '#bf850e',
rose: '#e85e70',
muted: '#64748b',
text: '#1f2937',
border: '#e5eaf2',
axis: '#d8e2ef',
unknown: '#a2adbf',
surface: '#fff',
}
/** Explicitly consumed by workbench charts; never registers a global ECharts theme. */
export const adminWorkbenchChartTheme = {
fontFamily,
colors,
tooltip: {
backgroundColor: colors.surface,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 10,
padding: 12,
extraCssText: 'box-shadow: 0 8px 24px rgba(31, 55, 90, 0.1);',
textStyle: { color: colors.text, fontSize: 12, fontFamily },
},
axisLabel: { color: colors.muted, fontSize: 12 },
axisName: { color: colors.muted, fontSize: 12 },
splitLine: {
show: true,
lineStyle: { type: 'dashed' as const, color: colors.border, opacity: 0.8 },
},
}
export type AdminWorkbenchChartTheme = typeof adminWorkbenchChartTheme
+69
View File
@@ -0,0 +1,69 @@
/* Blue-and-white workbench preview. Pages opt in without changing admin density. */
.admin-workbench {
--workbench-primary: #3784fa;
--workbench-primary-hover: #5b9bfb;
--workbench-primary-active: #276dd5;
--workbench-primary-disabled: #a9c9fc;
--workbench-number: #1890ff;
--workbench-text: #1f2937;
--workbench-muted: #64748b;
--workbench-surface: #fff;
--workbench-surface-soft: #f5f7fb;
--workbench-surface-tint: #f8faff;
--workbench-border: #e5eaf2;
--workbench-shadow: 0 4px 18px rgb(31 55 90 / 4%);
--workbench-radius: 16px;
--workbench-icon-radius: 12px;
--workbench-page-space: 24px;
--workbench-section-gap: 16px;
--workbench-grid-gap: 18px;
--workbench-card-space: 20px;
--workbench-metric-min-height: 186px;
--workbench-title-size: 24px;
--workbench-section-title-size: 18px;
--workbench-label-size: 15px;
--workbench-number-size: 34px;
--workbench-compact-number-size: 26px;
--workbench-compact-card-space: 14px;
--workbench-tab-radius: 8px;
--workbench-note-size: 13px;
--workbench-accent: #3784fa;
--workbench-accent-soft: #eff6ff;
--workbench-accent-border: #dbeafe;
}
.admin-workbench .workbench-tone[data-tone='blue'] {
--workbench-accent: #3784fa;
--workbench-accent-soft: #eff6ff;
--workbench-accent-border: #dbeafe;
}
.admin-workbench .workbench-tone[data-tone='teal'] {
--workbench-accent: #169b9b;
--workbench-accent-soft: #effcfa;
--workbench-accent-border: #c7eeea;
}
.admin-workbench .workbench-tone[data-tone='green'] {
--workbench-accent: #18a46f;
--workbench-accent-soft: #ecfdf5;
--workbench-accent-border: #c7efdd;
}
.admin-workbench .workbench-tone[data-tone='purple'] {
--workbench-accent: #8b5cf6;
--workbench-accent-soft: #f5f3ff;
--workbench-accent-border: #e6ddfc;
}
.admin-workbench .workbench-tone[data-tone='amber'] {
--workbench-accent: #bf850e;
--workbench-accent-soft: #fffbeb;
--workbench-accent-border: #f5e2ad;
}
.admin-workbench .workbench-tone[data-tone='rose'] {
--workbench-accent: #e85e70;
--workbench-accent-soft: #fff1f3;
--workbench-accent-border: #f8d7df;
}