From 4c4a3db1ebc277b2462a2e93c3e597ea86df36ae Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 20:44:31 +0800 Subject: [PATCH] feat(self-operated): add charts and continuous collection status --- package.json | 2 + pnpm-lock.yaml | 37 +++++++++ .../collectionPresentation.test.ts | 12 +++ .../self-operated/collectionPresentation.ts | 11 ++- .../components/AnalyticsChart.vue | 14 ++++ .../components/ChartDetailsDialog.vue | 22 +++++ .../CollectionAccountMonitor.test.ts | 24 +++++- .../components/CollectionAccountMonitor.vue | 59 +++++++++---- .../components/CollectionProgressGauge.vue | 21 +++-- .../components/CollectionTaskProgress.test.ts | 51 +++++++++++- .../components/CollectionTaskProgress.vue | 23 ++++-- .../components/DashboardBars.vue | 77 +++++++++-------- .../components/DashboardChartHeading.vue | 16 ++++ .../components/DashboardCharts.test.ts | 45 +++++++--- .../components/DashboardTrend.vue | 82 ++++++------------- .../components/HotSkuDetails.test.ts | 19 +++++ .../components/HotSkuDetails.vue | 30 +++++++ .../InventoryRankingDiagnostics.test.ts | 39 +++++++++ .../InventoryRankingDiagnostics.vue | 49 +++++++++++ .../self-operated/components/SkuTrend.test.ts | 39 +++++++++ .../self-operated/components/SkuTrend.vue | 35 +++----- .../dashboardChartOptions.test.ts | 31 +++++++ .../self-operated/dashboardChartOptions.ts | 33 ++++++++ src/modules/self-operated/operation-matrix.md | 27 +++++- .../pages/CollectionView.layout.test.ts | 3 +- .../pages/CollectionView.test.ts | 11 +++ .../self-operated/pages/OverviewView.test.ts | 26 ++++-- .../self-operated/pages/OverviewView.vue | 44 ++++++++-- .../self-operated/skuTrendChart.test.ts | 21 +++++ src/modules/self-operated/skuTrendChart.ts | 48 +++++++++++ src/modules/self-operated/state.test.ts | 13 +-- src/modules/self-operated/state.ts | 23 +----- src/modules/self-operated/types.ts | 4 +- 33 files changed, 780 insertions(+), 211 deletions(-) create mode 100644 src/modules/self-operated/components/AnalyticsChart.vue create mode 100644 src/modules/self-operated/components/ChartDetailsDialog.vue create mode 100644 src/modules/self-operated/components/DashboardChartHeading.vue create mode 100644 src/modules/self-operated/components/HotSkuDetails.test.ts create mode 100644 src/modules/self-operated/components/HotSkuDetails.vue create mode 100644 src/modules/self-operated/components/InventoryRankingDiagnostics.test.ts create mode 100644 src/modules/self-operated/components/InventoryRankingDiagnostics.vue create mode 100644 src/modules/self-operated/components/SkuTrend.test.ts create mode 100644 src/modules/self-operated/dashboardChartOptions.test.ts create mode 100644 src/modules/self-operated/dashboardChartOptions.ts create mode 100644 src/modules/self-operated/skuTrendChart.test.ts create mode 100644 src/modules/self-operated/skuTrendChart.ts diff --git a/package.json b/package.json index bc5bbdb5..c9b32f72 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "@lucide/vue": "^1.27.0", "axios": "^1.18.1", "dayjs": "^1.11.21", + "echarts": "^6.1.0", "element-plus": "^2.14.3", "pinia": "^4.0.2", "sortablejs": "^1.15.7", "vue": "^3.5.40", + "vue-echarts": "^8.3.2", "vue-router": "^5.2.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88a48763..7518878e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: dayjs: specifier: ^1.11.21 version: 1.11.21 + echarts: + specifier: ^6.1.0 + version: 6.1.0 element-plus: specifier: ^2.14.3 version: 2.14.3(vue@3.5.40(typescript@5.9.3)) @@ -32,6 +35,9 @@ importers: vue: specifier: ^3.5.40 version: 3.5.40(typescript@5.9.3) + vue-echarts: + specifier: ^8.3.2 + version: 8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3)) vue-router: specifier: ^5.2.0 version: 5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)) @@ -641,6 +647,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + echarts@6.1.0: + resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==} + editorconfig@1.0.7: resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} engines: {node: '>=14'} @@ -1150,6 +1159,9 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -1325,6 +1337,12 @@ packages: vue-component-type-helpers@3.3.8: resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==} + vue-echarts@8.3.2: + resolution: {integrity: sha512-FbJD49OZZZv1I8aclHQ+gcQxU+Y9nNWQMuod1sewE7/YjGQUJAaeVcf6WrSfQDBRPke+WoVsUtdRFIqPOsRrnw==} + peerDependencies: + echarts: ^6.0.0 + vue: ^3.3.0 + vue-router@5.2.0: resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==} peerDependencies: @@ -1406,6 +1424,9 @@ packages: engines: {node: '>= 14.6'} hasBin: true + zrender@6.1.0: + resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==} + snapshots: '@asamuzakjp/css-color@5.1.11': @@ -1960,6 +1981,11 @@ snapshots: eastasianwidth@0.2.0: {} + echarts@6.1.0: + dependencies: + tslib: 2.3.0 + zrender: 6.1.0 + editorconfig@1.0.7: dependencies: '@one-ini/wasm': 0.1.1 @@ -2436,6 +2462,8 @@ snapshots: dependencies: punycode: 2.3.1 + tslib@2.3.0: {} + tslib@2.8.1: optional: true @@ -2563,6 +2591,11 @@ snapshots: vue-component-type-helpers@3.3.8: {} + vue-echarts@8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3)): + dependencies: + echarts: 6.1.0 + vue: 3.5.40(typescript@5.9.3) + vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)): dependencies: '@babel/generator': 8.0.0 @@ -2658,3 +2691,7 @@ snapshots: xmlchars@2.2.0: {} yaml@2.9.0: {} + + zrender@6.1.0: + dependencies: + tslib: 2.3.0 diff --git a/src/modules/self-operated/collectionPresentation.test.ts b/src/modules/self-operated/collectionPresentation.test.ts index 9106b009..e929248f 100644 --- a/src/modules/self-operated/collectionPresentation.test.ts +++ b/src/modules/self-operated/collectionPresentation.test.ts @@ -3,10 +3,22 @@ import type { Task } from './types' import { taskStateText, taskProgressText, taskCompletionText } from './collectionPresentation' const base: Task = { taskNo: 'T-A', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'RUNNING', applyState: 'NOT_STARTED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: { phase: 'DETAIL', completed: 792, total: 2014, totalKnown: true, unit: 'ORDER' }, queryFrom: null, queryUntil: null } describe('plain collection task presentation', () => { + it('requires the matching yielded attempt before describing RETRY_WAIT as normal continuation', () => { + const latestAttempt: NonNullable = { attemptNo: 1, status: 'YIELDED', phase: 'DETAIL', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 40, errorCode: null, failureSummary: null } + const yielded = { ...base, taskStatus: 'RETRY_WAIT', latestAttempt } + expect(taskStateText(yielded)).toBe('本批已完成,等待续采') + for (const task of [ + { ...yielded, latestAttemptNo: 2 }, { ...yielded, attemptCount: 2 }, + { ...yielded, latestAttempt: { ...latestAttempt, status: 'FAILED' } }, + { ...yielded, latestAttempt: { ...latestAttempt, errorCode: 'TIMEOUT' } }, + { ...yielded, latestAttempt: undefined }, + ]) expect(taskStateText(task)).toBe('等待重试') + }) it('distinguishes data preparation, waiting, stopped and running', () => { expect(taskStateText(base)).toBe('正在执行') expect(taskStateText({ ...base, taskStatus: 'WAITING_SOURCE' })).toBe('等待数据准备') expect(taskStateText({ ...base, taskStatus: 'READY' })).toBe('等待执行') + expect(taskStateText({ ...base, taskStatus: 'YIELDED' })).toBe('本批已完成,等待续采') expect(taskStateText({ ...base, taskStatus: 'DEAD' })).toBe('已停止,需处理') expect(taskProgressText({ ...base, taskStatus: 'WAITING_SOURCE', attemptCount: 0 })).toBe('尚未开始,等待数据准备') }) diff --git a/src/modules/self-operated/collectionPresentation.ts b/src/modules/self-operated/collectionPresentation.ts index 512e6b57..0e8381b0 100644 --- a/src/modules/self-operated/collectionPresentation.ts +++ b/src/modules/self-operated/collectionPresentation.ts @@ -1,9 +1,18 @@ import type { Task } from './types' import { label, phaseText, quantityText, unitText } from './state' +export function isNormalContinuation(task: Task | null | undefined) { + const attempt = task?.latestAttempt + return task?.taskStatus === 'RETRY_WAIT' && attempt?.status === 'YIELDED' + && task.latestAttemptNo != null && task.latestAttemptNo > 0 + && attempt.attemptNo === task.latestAttemptNo && task.attemptCount === task.latestAttemptNo + && !attempt.errorCode && !attempt.failureSummary +} + export function taskStateText(task: Task | null | undefined) { if (!task) return '尚无任务' - const states: Record = { WAITING_SOURCE: '等待数据准备', WAITING_UPSTREAM: '等待数据准备', READY: '等待执行', QUEUED: '等待执行', WAITING: '等待执行', RUNNING: '正在执行', RETRY_WAIT: '等待重试', YIELDED: '等待请求额度', DEAD: '已停止,需处理', SUCCEEDED: '执行完成', FAILED: '执行失败' } + if (isNormalContinuation(task)) return '本批已完成,等待续采' + const states: Record = { WAITING_SOURCE: '等待数据准备', WAITING_UPSTREAM: '等待数据准备', READY: '等待执行', QUEUED: '等待执行', WAITING: '等待执行', RUNNING: '正在执行', RETRY_WAIT: '等待重试', YIELDED: '本批已完成,等待续采', DEAD: '已停止,需处理', SUCCEEDED: '执行完成', FAILED: '执行失败' } return states[task.taskStatus] ?? label(task.taskStatus) } export function taskProgressText(task: Task | null | undefined) { diff --git a/src/modules/self-operated/components/AnalyticsChart.vue b/src/modules/self-operated/components/AnalyticsChart.vue new file mode 100644 index 00000000..a06d99e5 --- /dev/null +++ b/src/modules/self-operated/components/AnalyticsChart.vue @@ -0,0 +1,14 @@ + + diff --git a/src/modules/self-operated/components/ChartDetailsDialog.vue b/src/modules/self-operated/components/ChartDetailsDialog.vue new file mode 100644 index 00000000..0c42a638 --- /dev/null +++ b/src/modules/self-operated/components/ChartDetailsDialog.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.test.ts b/src/modules/self-operated/components/CollectionAccountMonitor.test.ts index 48608611..2e0a09b7 100644 --- a/src/modules/self-operated/components/CollectionAccountMonitor.test.ts +++ b/src/modules/self-operated/components/CollectionAccountMonitor.test.ts @@ -1,5 +1,6 @@ +vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '
' } })) import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import CollectionAccountMonitor from './CollectionAccountMonitor.vue' import CollectionTaskProgress from './CollectionTaskProgress.vue' import type { Overview, Task, WarehouseOverview } from '../types' @@ -12,6 +13,27 @@ function overview(account: Task | null = source): Overview { } const options = { global: { stubs: { 'el-button': true } } } describe('single account collection monitor', () => { + it('shows server schedules once per family and time, excluding disabled scopes', async () => { + const data = overview() + data.warehouses.forEach(row => { row.executionEnabled = true; row.nextScheduledAt = '2026-10-13T16:05:00Z' }) + 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('计划时间不代表当前任务完成时间') + 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 })) } }) + expect(w.find('.account-schedules').exists()).toBe(false) + w.unmount() + }) + it('keeps the account snapshot when warehouse references contain older unknown counts', () => { + const data = overview() + data.warehouses[1]!.sharedUpstream = { ...source, progress: { ...source.progress!, phase: 'DIRECTORY', totalKnown: false, total: null, completed: 0 } } + const w = mount(CollectionAccountMonitor, { ...options, props: { overview: data } }) + expect(w.findComponent(CollectionTaskProgress).props('task')).toEqual(source) + expect(w.text()).toContain('12.1%') + w.unmount() + }) it('does not claim idle or an empty queue when the first status read fails', () => { const w = mount(CollectionAccountMonitor, { ...options, props: { overview: null, stale: true } }) expect(w.text()).toContain('暂未取得账号状态') diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.vue b/src/modules/self-operated/components/CollectionAccountMonitor.vue index c85bfe9c..4262aa23 100644 --- a/src/modules/self-operated/components/CollectionAccountMonitor.vue +++ b/src/modules/self-operated/components/CollectionAccountMonitor.vue @@ -11,7 +11,10 @@ defineEmits<{ refresh: [] }>() const activeStates = new Set(['RUNNING', 'APPLYING', 'READY', 'QUEUED', 'WAITING', 'WAITING_SOURCE', 'WAITING_UPSTREAM', 'RETRY_WAIT', 'YIELDED']) const tasks = computed(() => { const all = [props.overview?.accountActivity, ...(props.overview?.warehouses.flatMap(w => [...(w.activeTasks ?? []), w.currentTask, w.sharedUpstream]) ?? [])] - return [...new Map(all.filter((t): t is Task => Boolean(t && activeStates.has(t.taskStatus))).map(t => [t.taskNo, t])).values()] + const unique = new Map() + // Account activity is authoritative; warehouse references must not overwrite it. + for (const task of all) if (task && !unique.has(task.taskNo)) unique.set(task.taskNo, task) + return [...unique.values()].filter(t => activeStates.has(t.taskStatus)) }) const running = computed(() => tasks.value.filter(t => ['RUNNING', 'APPLYING'].includes(t.taskStatus))) const current = computed(() => running.value[0] ?? tasks.value[0] ?? null) @@ -24,10 +27,22 @@ const upcoming = computed(() => { } return [...groups].map(([name, count]) => ({ name, count })) }) +const schedules = computed(() => { + if (props.overview?.collectionMode === 'OFF') return [] + const groups = new Map() + for (const row of props.overview?.warehouses ?? []) { + if (!row.executionEnabled || !row.nextScheduledAt || !Number.isFinite(Date.parse(row.nextScheduledAt))) continue + const key = `${row.dataFamily}:${row.nextScheduledAt}` + const group = groups.get(key) ?? { family: row.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量', at: row.nextScheduledAt, warehouses: [] } + if (!group.warehouses.includes(row.warehouseCode)) group.warehouses.push(row.warehouseCode) + groups.set(key, group) + } + return [...groups.values()].sort((a, b) => Date.parse(a.at) - Date.parse(b.at)) +}) const nextStep = computed(() => { const phase = current.value?.progress?.phase - if (['DIRECTORY', 'DISCOVERY'].includes(phase ?? '')) return '获取出库详情 → 各仓校验与归并 → 统计计算 → 发布结果' - if (['DETAIL', 'DETAILS'].includes(phase ?? '')) return '各仓校验与归并 → 统计计算 → 发布结果' + if (['DIRECTORY', 'DISCOVERY'].includes(phase ?? '')) return '获取出库详情 → 更新各仓出库数据 → 统计计算 → 发布结果' + if (['DETAIL', 'DETAILS'].includes(phase ?? '')) return '更新各仓出库数据 → 统计计算 → 发布结果' if (['INVENTORY', 'FETCH'].includes(phase ?? '')) return '库存完整性校验 → 统计计算 → 发布结果' if (['VALIDATE', 'VALIDATION', 'MERGE'].includes(phase ?? '')) return '统计计算 → 发布结果' if (['BUILD', 'BUILDING', 'BUILT'].includes(phase ?? '')) return '发布结果' @@ -46,7 +61,7 @@ const idleReason = computed(() => { }) const flow = computed(() => current.value?.dataFamily === 'INVENTORY' ? [ { name: '读取库存', phases: ['INVENTORY', 'FETCH'] }, { name: '完整性校验', phases: ['VALIDATE', 'VALIDATION'] }, { name: '统计计算', phases: ['BUILD', 'BUILDING'] }, { name: '发布结果', phases: ['PUBLISH', 'PUBLISHED'] }, -] : [{ name: '获取出库目录', phases: ['DIRECTORY', 'DISCOVERY'] }, { name: '获取出库详情', phases: ['DETAIL', 'DETAILS'] }, { name: '各仓校验归并', phases: ['MERGE', 'VALIDATE', 'VALIDATION'] }, { name: '统计计算', phases: ['BUILD', 'BUILDING'] }, { name: '发布结果', phases: ['PUBLISH', 'PUBLISHED'] }]) +] : [{ name: '获取出库目录', phases: ['DIRECTORY', 'DISCOVERY'] }, { name: '获取出库详情', phases: ['DETAIL', 'DETAILS'] }, { name: '更新各仓出库数据', phases: ['MERGE', 'VALIDATE', 'VALIDATION'] }, { name: '统计计算', phases: ['BUILD', 'BUILDING'] }, { name: '发布结果', phases: ['PUBLISH', 'PUBLISHED'] }]) function nodeState(phases: string[]) { const p = current.value?.progress if (!p) return 'idle' @@ -67,7 +82,7 @@ function nodeState(phases: string[]) {

同时运行:{{ task.warehouseCode || '账号' }} · {{ phaseText(task.progress?.phase) }};

-
+
最近推进 {{ beijingTime(current?.progressUpdatedAt) }}状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间
@@ -94,10 +109,11 @@ footer { justify-content: space-between; margin-top: var(--admin-space-5); paddi .account-main { display: grid; grid-template-columns: minmax(0, 1fr) 290px; gap: 32px; } .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); } -.account-header { background: var(--el-bg-color); padding: 16px 24px; } +.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: linear-gradient(105deg, var(--el-color-primary-light-9), var(--el-bg-color) 65%); padding: 16px 24px; border-top: 3px solid var(--el-color-primary); } .account-header h2 { margin-bottom: 4px; } -.account-live { color: var(--el-text-color-secondary); } +.account-live { color: var(--el-text-color-secondary); background: var(--el-fill-color-light); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius-lg); } +[data-running="true"] .account-live { color: var(--el-color-success-dark-2); background: var(--el-color-success-light-9); } .account-live i { width: 7px; height: 7px; border-radius: 50%; background: var(--el-text-color-secondary); } [data-running="true"] .account-live i { background: var(--el-color-success); animation: signal 2s ease-in-out infinite; } .account-controls :deep(.el-button) { gap: 8px; } @@ -106,7 +122,7 @@ footer { justify-content: space-between; margin-top: var(--admin-space-5); paddi .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: 20px; border-left-color: var(--el-color-primary); font-size: var(--admin-font-caption); line-height: 1.8; } +.account-next { display: grid; padding: 14px; margin-top: 20px; 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: 32px; min-height: 220px; } .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; } @@ -118,21 +134,32 @@ footer { justify-content: space-between; margin-top: var(--admin-space-5); paddi 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); padding-top: 12px; display: grid; grid-template-columns: 28px 1fr; gap: 8px; align-items: center; } +.account-flow li { border-top: 2px solid var(--el-border-color-light); border-radius: var(--admin-radius); background: var(--el-fill-color-lighter); padding: var(--admin-space-3); display: grid; grid-template-columns: 28px 1fr; gap: 8px; align-items: center; } .account-flow li small { grid-column: 2; } .account-flow li strong { 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); } -.account-flow li[data-state="done"] { border-color: var(--el-color-success); } +.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-radius: 6px; } +.warehouse-results article { background: var(--el-fill-color-lighter); padding: 16px 20px; border: 1px solid var(--el-border-color-light); border-top: 3px solid var(--el-color-primary); border-radius: var(--admin-radius-lg); } +.warehouse-results article:nth-child(2n) { border-top-color: var(--el-color-success); } +.warehouse-results article h4 { color: var(--el-color-primary); } +.warehouse-results article:nth-child(2n) h4 { color: var(--el-color-success-dark-2); } .warehouse-result { display: flex; justify-content: space-between; gap: 12px; padding-top: 14px; font-size: var(--admin-font-body); } .warehouse-result small { display: block; margin-top: 4px; } .warehouse-result > span:last-child { text-align: right; } .result-ready { color: var(--el-color-success); } -.account-monitor :deep(.ring-value) { transition: stroke-dashoffset .5s ease; } -.account-monitor :deep(.collection-count-grid dd) { font-size: 36px; } +.account-monitor :deep(.collection-count-grid > div) { --metric-color: var(--el-color-primary); --metric-tint: var(--el-color-primary-light-9); position: relative; overflow: hidden; padding: var(--admin-space-4); border: 1px solid var(--metric-tint); border-radius: var(--admin-radius-lg); background: linear-gradient(135deg, var(--el-bg-color), var(--metric-tint)); } +.account-monitor :deep(.collection-count-grid > div:nth-child(2)) { --metric-color: var(--el-color-success-dark-2); --metric-tint: var(--el-color-success-light-9); } +.account-monitor :deep(.collection-count-grid > div:nth-child(3)) { --metric-color: var(--el-color-warning-dark-2); --metric-tint: var(--el-color-warning-light-9); } +.account-monitor :deep(.collection-count-grid > div::before) { content: ''; position: absolute; right: -18px; top: -24px; width: 86px; height: 86px; border-radius: 50%; background: var(--metric-color); opacity: .1; pointer-events: none; } +.account-monitor :deep(.collection-count-grid dt), .account-monitor :deep(.collection-count-grid dd) { position: relative; } +.account-monitor :deep(.collection-count-grid dd) { font-size: 36px; color: var(--metric-color); white-space: nowrap; } +.account-monitor :deep(.collection-ring) { background: var(--el-color-primary-light-9); border-radius: 50%; } +.account-monitor :deep(.ring-label strong) { color: var(--el-color-primary); } .account-monitor :deep(.collection-estimate) { padding: 16px; border-radius: 6px; } @keyframes signal { 50% { opacity: .45; } } -@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } .account-monitor :deep(.ring-value) { transition: none; } } +@media (prefers-reduced-motion: reduce) { [data-running="true"] .account-live i { animation: none; } } diff --git a/src/modules/self-operated/components/CollectionProgressGauge.vue b/src/modules/self-operated/components/CollectionProgressGauge.vue index e0f0a954..24815aaf 100644 --- a/src/modules/self-operated/components/CollectionProgressGauge.vue +++ b/src/modules/self-operated/components/CollectionProgressGauge.vue @@ -1,11 +1,13 @@ -

{{ trendError }}

+

{{ trendError }}

@@ -229,12 +260,9 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea .dashboard-main-charts { display: grid; grid-template-columns: 2fr 1fr; gap: var(--admin-space-4); } .dashboard-rankings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); } .dashboard-chart { min-width: 0; padding: 18px; background: var(--admin-surface); border: 1px solid #e5eaf4; border-radius: 8px; animation: dashboard-reveal .6s ease-out both; } -.dashboard-chart > h3, .dashboard-ranking-heading h3 { display: flex; align-items: center; gap: 8px; } -.dashboard-chart > h3::before, .dashboard-ranking-heading h3::before { content: ''; width: 3px; height: 14px; border-radius: 2px; background: #5575ed; } -.dashboard-chart > p { color: #76849c; font-size: var(--admin-font-caption); line-height: 1.7; } +.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-ranking-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-2); } .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; } diff --git a/src/modules/self-operated/skuTrendChart.test.ts b/src/modules/self-operated/skuTrendChart.test.ts new file mode 100644 index 00000000..f5f56880 --- /dev/null +++ b/src/modules/self-operated/skuTrendChart.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import type { LineSeriesOption } from 'echarts' +import type { DailyPoint } from './types' +import { skuTrendOption } from './skuTrendChart' +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', () => { + 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[] + 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[] + 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) + }) +}) diff --git a/src/modules/self-operated/skuTrendChart.ts b/src/modules/self-operated/skuTrendChart.ts new file mode 100644 index 00000000..b40dc697 --- /dev/null +++ b/src/modules/self-operated/skuTrendChart.ts @@ -0,0 +1,48 @@ +import type { EChartsOption, LineSeriesOption } from 'echarts' +import type { DailyPoint } from './types' +import { label, quantityText, reasonsText, shownQuantity } from './state' + +export function skuTrendOption(points: DailyPoint[]): EChartsOption { + const dates = points.map(point => point.businessDate) + const values = points.map(point => { + const value = shownQuantity(point) + return point.dataStatus !== 'UNKNOWN' && value != null && Number.isFinite(Number(value)) ? Number(value) : null + }) + const series: LineSeriesOption[] = [{ + name: '每日出库量', type: 'line', connectNulls: false, lineStyle: { opacity: 0 }, + symbolSize: 8, showAllSymbol: true, + data: values.map((value, index) => ({ value, symbol: points[index]?.dataStatus === 'COMPLETE' ? 'circle' : 'emptyCircle' })), + itemStyle: { color: '#6078ea' }, emphasis: { scale: 1.5 }, + }] + let segment: { partial: boolean; data: (number | null)[] } | null = null + for (let index = 1; index < points.length; index++) { + if (values[index - 1] == null || values[index] == null || Date.parse(dates[index]!) - Date.parse(dates[index - 1]!) !== 86400000) { segment = null; continue } + const partial = points[index - 1]!.dataStatus !== 'COMPLETE' || points[index]!.dataStatus !== 'COMPLETE' + if (!segment || segment.partial !== partial) { + segment = { partial, data: Array(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' }, + emphasis: { disabled: true }, animation: false }) + } + segment.data[index - 1] = values[index - 1]! + segment.data[index] = values[index]! + } + return { + animation: false, + 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' }, + 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' }], + aria: { enabled: true, description: 'SKU每日出库趋势。横轴为北京时间日期,纵轴为出库件数。完整日实线,未完整日虚线,缺失日期断线。详细数值可展开下方表格。' }, + series, + } +} diff --git a/src/modules/self-operated/state.test.ts b/src/modules/self-operated/state.test.ts index 61c79267..3dc8bd83 100644 --- a/src/modules/self-operated/state.test.ts +++ b/src/modules/self-operated/state.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { RequestFence, RetryIntent, RuntimePoller, contextParams, progressText, sameContext, shownQuantity, taskRetryAllowed, trendSegments, validateCustomRange } from './state' -import type { DailyPoint, QueryContext, Task } from './types' +import { RequestFence, RetryIntent, RuntimePoller, contextParams, 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' } const task: Task = { taskNo: 'COL-TEST-001', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'FAILED', applyState: 'BLOCKED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: ['RETRY'], actionBlockReason: null, progress: null, queryFrom: null, queryUntil: null } @@ -52,15 +52,6 @@ describe('self-operated query and command protection', () => { expect(intent.create(task.taskNo, 'WH-A', '新的原因', key).requestKey).toBe('key-3') expect(first).not.toHaveProperty('taskId') }) - it('breaks trend at unknown days and retains a single reliable zero point', () => { - const point = (businessDate: string, value: string | null, status: string): DailyPoint => ({ businessDate, outboundQuantity: value, observedQuantity: value, dataStatus: status, reasonCodes: [], quantityUnit: 'PCS' }) - const segments = trendSegments([point('2026-10-10', '0', 'COMPLETE'), point('2026-10-11', null, 'UNKNOWN'), point('2026-10-12', '4', 'PARTIAL')]) - expect(segments).toHaveLength(2) - expect(segments[0]?.path).not.toContain(' L') - expect(segments[1]?.partial).toBe(true) - expect(trendSegments([point('2026-10-12', '0', 'COMPLETE')])).toHaveLength(1) - expect(trendSegments([point('2026-10-12', null, 'UNKNOWN')])).toEqual([]) - }) it('does not invent progress denominator or combine warehouse and account counts', () => { const text = progressText({ progressScope: 'ACCOUNT_SHARED', countBasis: 'ATTEMPT_ONLY', attemptNo: 2, scanRound: 2, phase: 'DIRECTORY', completed: 3, total: null, totalKnown: false, unit: 'PAGE', sliceFrom: null, sliceUntil: null }) expect(text).toContain('账号共享') diff --git a/src/modules/self-operated/state.ts b/src/modules/self-operated/state.ts index 7dd258fa..353fcd9e 100644 --- a/src/modules/self-operated/state.ts +++ b/src/modules/self-operated/state.ts @@ -1,4 +1,4 @@ -import type { BusinessQuery, DailyPoint, Progress, QueryContext, RetryCommand, Scope, Task } from './types' +import type { BusinessQuery, Progress, QueryContext, RetryCommand, Scope, Task } from './types' // Aborting saves work; generation fencing also protects against adapters that return after abort. export class RequestFence { @@ -90,7 +90,7 @@ export const reasonLabels: Record = { } export function reasonsText(reasons: readonly string[] | null | undefined) { return reasons?.map(reason => reasonLabels[reason] ?? statusLabels[reason] ?? '存在待核对项').join('、') || '—' } export function phaseText(phase: string | null | undefined) { - const phases: Record = { DIRECTORY: '获取出库目录', DISCOVERY: '获取出库目录', DETAIL: '获取出库详情', DETAILS: '获取出库详情', INVENTORY: '获取产品库存', VALIDATE: '校验', VALIDATION: '校验', MERGE: '归并', BUILD: '统计计算', PUBLISH: '发布', WAIT_UPSTREAM: '等待账号共享上游', WAIT_BUDGET: '预算等待', WAITING_SOURCE: '等待来源准备', WAITING: '等待执行', SAMPLE_DONE: '样本结束', FAILED: '执行失败', FETCH: '获取数据', PLANNED: '已规划', FETCHED: '数据已取完', VALIDATED: '已校验', BUILDING: '统计计算', BUILT: '计算完成', PUBLISHED: '已发布' } + const phases: Record = { DIRECTORY: '获取出库目录', DISCOVERY: '获取出库目录', DETAIL: '获取出库详情', DETAILS: '获取出库详情', INVENTORY: '获取产品库存', VALIDATE: '校验', VALIDATION: '校验', MERGE: '更新本仓出库数据', BUILD: '统计计算', PUBLISH: '发布', WAIT_UPSTREAM: '等待账号共享上游', WAIT_BUDGET: '预算等待', WAITING_SOURCE: '等待来源准备', WAITING: '等待执行', SAMPLE_DONE: '样本结束', FAILED: '执行失败', FETCH: '获取数据', PLANNED: '已规划', FETCHED: '数据已取完', VALIDATED: '已校验', BUILDING: '统计计算', BUILT: '计算完成', PUBLISHED: '已发布' } return phase ? phases[phase] ?? '阶段待核对' : '阶段待确认' } export function label(value: string | null | undefined) { return value ? statusLabels[value] ?? '待核对' : '—' } @@ -142,22 +142,3 @@ export class RuntimePoller { if (generation === this.generation && this.active && this.visible) this.timer = setTimeout(() => void this.tick(generation), busy ? 10000 : 60000) } } -export function trendSegments(points: DailyPoint[]) { - const max = Math.max(1, ...points.map(point => Number(shownQuantity(point) ?? 0))) - const segments: { path: string; partial: boolean }[] = [] - let path = '' - let partial = false - const flush = () => { if (path) segments.push({ path, partial }); path = '' } - points.forEach((point, index) => { - const quantity = shownQuantity(point) - if (quantity == null || point.dataStatus === 'UNKNOWN') { flush(); return } - const x = points.length === 1 ? 400 : 30 + index / (points.length - 1) * 740 - const y = 175 - Number(quantity) / max * 145 - const pointPartial = point.dataStatus !== 'COMPLETE' - if (path && partial !== pointPartial) { flush() } - path += `${path ? ' L' : 'M'}${x},${y}` - partial = pointPartial - }) - flush() - return segments -} diff --git a/src/modules/self-operated/types.ts b/src/modules/self-operated/types.ts index aa2cc02b..8209038a 100644 --- a/src/modules/self-operated/types.ts +++ b/src/modules/self-operated/types.ts @@ -181,12 +181,14 @@ export interface Run { progressUpdatedAt: string | null heartbeatAt: string | null executionHealth: string | null - elapsedSeconds: number | null + elapsedSeconds: Quantity errorCode: string | null failureSummary: string | null progress?: Partial | null } export interface Task { + createdAt?: string | null + elapsedSeconds?: Quantity stageEstimate?: StageEstimate | null dependencyState?: 'NONE' | 'WAITING_CONFIRMED' | 'WAITING_UNCONFIRMED' | 'SATISFIED' | 'FAILED' blockingReason?: string | null