Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99cb25dac4 | ||
|
|
464b72cff9 |
@@ -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` 和目标代码命中段;只有跨服务状态、权限或接口边界无法判断时才增加后端契约。读取预算遵循根指引的行数与字符数标准,先读适用规则,再定向读取业务表,不全文加载无关专题。
|
||||
单端仅读本指引、最近模块指引及命中代码;跨服务状态、权限或接口边界不明才加后端契约。预算遵循根指引,先读适用规则、定向查业务表,跳过无关专题。
|
||||
|
||||
## 项目定位与技术基线
|
||||
|
||||
|
||||
@@ -56,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()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { projectContainerSchedule } from './schedule'
|
||||
import { getCalendarSources, getWarehouseArrivals } from './scheduleApi'
|
||||
|
||||
vi.mock('@/shared/api/http', async original => ({
|
||||
...await original<typeof import('@/shared/api/http')>(),
|
||||
http: { get: vi.fn(), post: vi.fn() },
|
||||
}))
|
||||
|
||||
const range = { from: '2026-09-28', to: '2026-11-02' }
|
||||
const bookingId = '9007199254740993'
|
||||
// 使用真实接口的结构:Long 数量序列化为字符串,空时间字段不输出。
|
||||
const wire = {
|
||||
sourceContainerBookingId: bookingId, containerNo: 'CALENDAR-CONT',
|
||||
warehouseName: '接仓仓库', warehouseCode: 'US-W', version: 0, planCount: '0', canEdit: true,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'OK', data: {
|
||||
containers: [{ containerBookingId: bookingId, cabinetNo: 'D-CALENDAR', containerNo: 'CALENDAR-CONT',
|
||||
vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null,
|
||||
arrivalAt: '2026-10-20T23:00:00', arrivalType: 'ESTIMATED' }],
|
||||
emptyReturns: [], emptyReturnsReadable: true,
|
||||
} })
|
||||
})
|
||||
|
||||
async function readSchedule(overrides: Record<string, unknown> = {}) {
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'OK', data: [{ ...wire, ...overrides }] })
|
||||
const sources = await getCalendarSources(range)
|
||||
const warehouses = await getWarehouseArrivals(range, [bookingId])
|
||||
return { warehouses, event: projectContainerSchedule(sources, warehouses, range, true).find(row => row.type === 'warehouse') }
|
||||
}
|
||||
|
||||
describe('warehouse calendar response to schedule', () => {
|
||||
it.each(['0', 0])('uses zero plan count %j to show arrival plus two days without inventing a time', async planCount => {
|
||||
const { warehouses, event } = await readSchedule({ planCount })
|
||||
expect(warehouses[0]).toMatchObject({ sourceContainerBookingId: bookingId, planCount: 0, plannedAt: null, actualAt: null })
|
||||
expect(event).toMatchObject({ date: '2026-10-22', time: '', canEdit: true, subtitle: '到港日期+2个自然日(默认)' })
|
||||
expect(event?.fields).toContainEqual({ label: '计划到仓', value: '2026-10-22' })
|
||||
expect(event?.fields).toContainEqual({ label: '实际到仓', value: '—' })
|
||||
})
|
||||
|
||||
it('keeps the authoritative frozen plan instead of recalculating from arrival', async () => {
|
||||
const { warehouses, event } = await readSchedule({ planCount: '2', plannedAt: '2026-10-25T09:30:00', canEdit: false })
|
||||
expect(warehouses[0]?.planCount).toBe(2)
|
||||
expect(event).toMatchObject({ date: '2026-10-25', time: '09:30', canEdit: false, subtitle: '已有入库计划,日期已冻结' })
|
||||
})
|
||||
|
||||
it('keeps a manually maintained time when there is no inbound plan', async () => {
|
||||
const { event } = await readSchedule({ version: 1, plannedAt: '2026-10-24T08:00:00' })
|
||||
expect(event).toMatchObject({ date: '2026-10-24', time: '08:00', subtitle: '人工维护' })
|
||||
})
|
||||
|
||||
it('does not calculate a default when existing plans have no unified time', async () => {
|
||||
const { event } = await readSchedule({ planCount: '2', canEdit: false })
|
||||
expect(event?.date).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps conflicting authoritative versions unresolved even with zero plans', async () => {
|
||||
const { event } = await readSchedule({ version: -1, canEdit: false })
|
||||
expect(event).toMatchObject({ date: null, canEdit: false, subtitle: '柜内计划时间不一致,待核实' })
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', 'invalid', '-1', '1.5', '9007199254740992', -1, Infinity])(
|
||||
'rejects invalid or unsafe plan count %j rather than treating it as no plans', async planCount => {
|
||||
await expect(readSchedule({ planCount })).rejects.toThrow('入库计划数量无效')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -31,15 +31,35 @@ export interface WarehouseArrival {
|
||||
canEdit: boolean
|
||||
}
|
||||
export interface ScheduleRange { from: string; to: string }
|
||||
type WarehouseArrivalWire = Omit<WarehouseArrival, 'planCount' | 'plannedAt' | 'actualAt'> & {
|
||||
planCount: string | number
|
||||
plannedAt?: string | null
|
||||
actualAt?: string | null
|
||||
}
|
||||
|
||||
/** 在接口边界统一长整型数量,未知数量不能被当成零触发默认日期。 */
|
||||
function normalizeWarehouseArrival(row: WarehouseArrivalWire): WarehouseArrival {
|
||||
const count = row.planCount
|
||||
if (typeof count !== 'number' && (typeof count !== 'string' || !/^(0|[1-9]\d*)$/.test(count))) {
|
||||
throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
|
||||
}
|
||||
const planCount = Number(count)
|
||||
if (!Number.isSafeInteger(planCount) || planCount < 0) {
|
||||
throw new Error('到仓记录的入库计划数量无效,请刷新后重试')
|
||||
}
|
||||
return { ...row, planCount, plannedAt: row.plannedAt ?? null, actualAt: row.actualAt ?? null }
|
||||
}
|
||||
|
||||
export async function getCalendarSources(range: ScheduleRange, signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.get<ApiEnvelope<CalendarSources>, ApiEnvelope<CalendarSources>>(
|
||||
'/platform/v1/terminal-drayages/calendar-sources', { params: range, signal },
|
||||
))
|
||||
}
|
||||
export async function getWarehouseArrivals(range: ScheduleRange, sourceContainerBookingIds: string[], signal?: AbortSignal) {
|
||||
return unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrival[]>, ApiEnvelope<WarehouseArrival[]>>(
|
||||
const rows = unwrapApiResponse(await http.post<ApiEnvelope<WarehouseArrivalWire[]>, ApiEnvelope<WarehouseArrivalWire[]>>(
|
||||
'/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query', { ...range, sourceContainerBookingIds }, { signal },
|
||||
))
|
||||
return rows.map(normalizeWarehouseArrival)
|
||||
}
|
||||
export interface WarehouseArrivalChange { estimatedWarehouseArrivalAt: string; expectedVersion: number }
|
||||
export async function saveWarehouseArrival(containerId: string, body: WarehouseArrivalChange, key: string) {
|
||||
|
||||
@@ -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"> </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('<img src=x>')
|
||||
expect(content).toContain('A&B')
|
||||
expect(content).toContain('A&B "'货主')
|
||||
expect(content).toContain('12,000.123456')
|
||||
expect(content).toContain('<件>')
|
||||
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)) }],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)/)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user