feat(self-operated): add charts and continuous collection status

This commit is contained in:
2026-10-13 20:44:31 +08:00
parent 698ca34529
commit 4c4a3db1eb
33 changed files with 780 additions and 211 deletions
+2
View File
@@ -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": {
+37
View File
@@ -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
@@ -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<Task['latestAttempt']> = { 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('尚未开始,等待数据准备')
})
@@ -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<string, string> = { WAITING_SOURCE: '等待数据准备', WAITING_UPSTREAM: '等待数据准备', READY: '等待执行', QUEUED: '等待执行', WAITING: '等待执行', RUNNING: '正在执行', RETRY_WAIT: '等待重试', YIELDED: '等待请求额度', DEAD: '已停止,需处理', SUCCEEDED: '执行完成', FAILED: '执行失败' }
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)
}
export function taskProgressText(task: Task | null | undefined) {
@@ -0,0 +1,14 @@
<script setup lang="ts">
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import type { EChartsOption, ECElementEvent } from 'echarts'
import { BarChart, GaugeChart, LineChart } from 'echarts/charts'
import { AriaComponent, DataZoomComponent, GridComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
use([LineChart, BarChart, GaugeChart, GridComponent, TooltipComponent, DataZoomComponent, AriaComponent, CanvasRenderer])
defineProps<{ option: EChartsOption }>()
defineEmits<{ select: [event: ECElementEvent] }>()
</script>
<template>
<VChart :option="option" :init-options="{ renderer: 'canvas' }" :update-options="{ notMerge: true }" autoresize @click="$emit('select', $event)" />
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { watch } from 'vue'
import { ElButton } from 'element-plus'
import AppDialog from '@/shared/components/AppDialog.vue'
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 })
</script>
<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>
</div>
</template>
<style scoped>
.chart-disclosure { margin-top: var(--admin-space-3); }
.chart-disclosure-inline { margin-top: 0; display: inline-flex; }
.chart-evidence :deep(table) { width: 100%; }
.chart-evidence :deep(td) { overflow-wrap: anywhere; }
</style>
@@ -1,5 +1,6 @@
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
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('暂未取得账号状态')
@@ -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<string, Task>()
// 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<string, { family: string; at: string; warehouses: string[] }>()
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[]) {
<p v-if="running.length > 1">同时运行:<span v-for="task in running.slice(1)" :key="task.taskNo">{{ task.warehouseCode || '账号' }} · {{ phaseText(task.progress?.phase) }};</span></p>
</template>
<div v-else class="account-empty"><div class="standby-ring"><Pause :size="38" /><span>{{ loading ? '读取中' : !overview ? '未连接' : '待机' }}</span></div><div><span class="account-eyebrow">{{ overview ? '当前没有正在运行的采集' : '连接状态' }}</span><h3>{{ idleReason }}</h3><p>有新任务时自动更新进度。</p><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><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><small>仅已知活动摘要;完整队列数量及下一任务顺序尚未提供。</small></aside></div>
</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>
<section class="account-flow"><div class="account-section-title"><h4><Activity :size="16" />处理阶段</h4><span>只标记已确认的推进状态</span></div><ol :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }"><li v-for="(node, index) in flow" :key="node.name" :data-state="nodeState(node.phases)"><span class="step-index"><Check v-if="nodeState(node.phases) === 'done'" :size="16" /><template v-else>{{ index + 1 }}</template></span><strong>{{ node.name }}</strong><small>{{ nodeState(node.phases) === 'done' ? '已确认完成' : nodeState(node.phases) === 'active' ? '正在处理' : current || !overview ? '等待状态确认' : '待机' }}</small></li></ol></section>
<section class="account-results" 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>
<footer><span>最近推进 {{ beijingTime(current?.progressUpdatedAt) }}</span><span>状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间</span></footer>
@@ -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; } }
</style>
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AnalyticsChart from './AnalyticsChart.vue'
import { progressGaugeOption } from '../dashboardChartOptions'
import type { Progress } from '../types'
import { beijingTime, phaseText, quantityText, unitText } from '../state'
const props = defineProps<{ progress: Partial<Progress> | null | undefined; taskNo: string; stale?: boolean; compact?: 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)
watch([identity, () => props.progress?.total], ([id, total], [oldId, oldTotal]) => { changedTotal.value = id === oldId ? changedTotal.value || total !== oldTotal : false })
watch([identity, () => props.progress?.total], ([id, total], [oldId, oldTotal]) => { changedTotal.value = id === oldId ? changedTotal.value || (oldTotal != null && total != null && total !== oldTotal) : false })
const counts = computed(() => {
const p = props.progress
if (!p || p.completed == null || p.total == null || !p.totalKnown || !p.unit || p.unit === 'NONE' || !['ACCOUNT_SHARED', 'WAREHOUSE'].includes(p.progressScope ?? '') || !['TASK_CUMULATIVE', 'ATTEMPT_ONLY'].includes(p.countBasis ?? '') || changedTotal.value) return null
@@ -14,18 +16,21 @@ const counts = computed(() => {
return { remaining: total - done, percent: Number(BigInt(done) * 1000n / BigInt(total)) / 10 }
})
const scope = computed(() => props.progress?.progressScope === 'ACCOUNT_SHARED' ? '账号共享' : props.progress?.progressScope === 'WAREHOUSE' ? '本仓' : '范围待确认')
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="当前阶段数量">
<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">
<svg viewBox="0 0 160 160" aria-hidden="true"><circle cx="80" cy="80" r="64" class="ring-track" /><circle v-if="counts" cx="80" cy="80" r="64" class="ring-value" :stroke-dasharray="402.124" :stroke-dashoffset="402.124 * (1 - counts.percent / 100)" /></svg>
<AnalyticsChart :option="option" class="ring-chart" aria-hidden="true" />
<div class="ring-label"><strong>{{ counts ? `${counts.percent.toFixed(1)}%` : '—' }}</strong><span>{{ stale ? '上次阶段记录' : '当前阶段' }}</span></div>
</div>
<div>
<div :class="{ 'compact-counts': compact }">
<p v-if="!compact">{{ phaseText(progress?.phase) }} · {{ scope }}<span v-if="progress?.scanRound != null"> · 第 {{ progress.scanRound }} 轮</span></p>
<dl class="collection-count-grid"><div><dt>总量</dt><dd>{{ progress?.totalKnown ? quantityText(progress.total) : '待确认' }} <small>{{ unitText(progress?.unit) }}</small></dd></div><div><dt>已完成</dt><dd>{{ quantityText(progress?.completed) }} <small>{{ unitText(progress?.unit) }}</small></dd></div><div><dt>剩余</dt><dd>{{ counts ? counts.remaining : '—' }} <small>{{ unitText(progress?.unit) }}</small></dd></div></dl>
<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">{{ progress?.totalKnown && Number(progress.total) === 0 ? '本阶段数量为零;是否完成以阶段及发布证据为准。' : '总量或计数依据待确认,不估算完成比例。' }}</p>
<p v-else-if="!counts">{{ readingDirectory ? '目录总页数尚未返回,已读取数量见“已完成”。' : progress?.totalKnown && Number(progress.total) === 0 ? '本阶段数量为零;是否完成以阶段及发布证据为准。' : '总量或计数依据待确认,不估算完成比例。' }}</p>
<p v-else-if="compact" aria-hidden="true">&nbsp;</p>
<p v-if="!compact && scope === '账号共享'">来源账号的阶段数量,不能当作本仓出库量或整个同步完成率。</p>
<p v-if="!compact && (progress?.sliceFrom || progress?.sliceUntil)">当前切片:{{ beijingTime(progress?.sliceFrom) }} 至 {{ beijingTime(progress?.sliceUntil) }}(结束边界不含)</p>
</div>
@@ -34,10 +39,8 @@ const scope = computed(() => props.progress?.progressScope === 'ACCOUNT_SHARED'
<style scoped>
.collection-stage-counts { display: grid; grid-template-columns: 156px minmax(0, 1fr); align-items: center; gap: var(--admin-space-6); }
.collection-ring { width: 156px; height: 156px; position: relative; }
.collection-ring svg { width: 100%; height: 100%; }
.ring-track, .ring-value { fill: none; stroke-width: 10; }
.ring-track { stroke: var(--el-border-color-light); }
.ring-value { stroke: var(--el-color-primary); stroke-linecap: round; transform: rotate(-90deg); transform-origin: 80px 80px; }
.compact-counts { min-width: 0; }
.ring-chart { width: 100%; height: 100%; }
.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; }
.collection-count-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); margin: var(--admin-space-4) 0; }
@@ -1,3 +1,4 @@
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import CollectionTaskProgress from './CollectionTaskProgress.vue'
@@ -10,6 +11,46 @@ const detail = { task, sharedUpstream: source, observedAt: '2026-10-13T01:02:00Z
const wrappers: ReturnType<typeof mount>[] = []
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
describe('collection task progress evidence', () => {
it('explains a confirmed normal batch continuation without hiding failures or protection blocks', 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, compact: true, visible: true } }); wrappers.push(w)
expect(w.find('.collection-estimate').text()).toContain('本批已完成,等待续采')
await w.setProps({ task: { ...yielded, latestAttempt: { ...yielded.latestAttempt!, status: 'FAILED' } } })
expect(w.find('.collection-estimate').text()).toContain('等待计划重试')
await w.setProps({ task: { ...yielded, blockingReason: 'ACCOUNT_COOLDOWN' } })
expect(w.find('.collection-estimate').text()).not.toContain('本批已完成,等待续采')
})
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 秒')
await w.setProps({ task: { ...source, elapsedSeconds: '0' } })
expect(w.text()).toContain('任务累计耗时 0 分 0 秒')
for (const elapsedSeconds of [null, undefined, '', ' ', '-1', '1.5', 'NaN', '9007199254740992', -1]) {
await w.setProps({ task: { ...source, elapsedSeconds } })
expect(w.text()).toContain('任务累计耗时 —')
}
})
it('shows task lifetime across retries and stage changes without borrowing upstream elapsed time', async () => {
const current = { ...task, elapsedSeconds: 607 }
const w = mount(CollectionTaskProgress, { props: { task: current, detail: { ...detail, sharedUpstream: { ...source, elapsedSeconds: 7 } } } }); wrappers.push(w)
expect(w.text()).toContain('任务累计耗时 10 分 7 秒')
expect(w.text()).toContain('含排队与重试')
await w.setProps({ task: { ...current, taskStatus: 'RUNNING', dependencyState: 'SATISFIED', attemptCount: 3, elapsedSeconds: 667, progress: { ...progress, phase: 'MERGE', stageStartedAt: '2026-10-13T01:01:59Z' } } })
expect(w.text()).toContain('任务累计耗时 11 分 7 秒')
await w.setProps({ task: { ...current, elapsedSeconds: null } })
expect(w.text()).toContain('任务累计耗时 —')
})
it('keeps waiting reasons below the gauge and explains unknown directory totals', async () => {
const w = mount(CollectionTaskProgress, { props: { task: { ...source, taskStatus: 'RETRY_WAIT', blockingReason: 'RETRY_SCHEDULED' }, compact: true, visible: true } }); wrappers.push(w)
expect(w.element.firstElementChild?.classList.contains('collection-stage-counts')).toBe(true)
expect(w.find('.collection-estimate').text()).toContain('等待计划重试')
expect(w.text()).toContain('12.1%')
await w.setProps({ task: { ...source, progress: { ...progress, phase: 'DIRECTORY', unit: 'PAGE', totalKnown: false, total: null, completed: 0 } } })
expect(w.find('.collection-count-grid dd').text()).toContain('—')
expect(w.text()).toContain('目录总页数尚未返回')
expect(w.find('[role="progressbar"]').exists()).toBe(false)
})
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')
@@ -41,13 +82,13 @@ describe('collection task progress evidence', () => {
it('uses unavailable reasons as the primary estimate text and prioritizes explicit stage evidence', async () => {
const e: StageEstimate = { status: 'UNAVAILABLE', progressIdentity: 'identity', completed: 339, total: 2787, sampleFrom: null, sampleUntil: null, completedDelta: null, ratePerSecond: null, remainingSeconds: null, estimatedAt: null, validUntil: null, unavailableReason: 'TERMINAL' }
const w = mount(CollectionTaskProgress, { props: { task, visible: true, detail: { ...detail, sharedUpstream: { ...source, stageEstimate: e } } } }); wrappers.push(w)
for (const [reason, expected] of [['TERMINAL', '任务已结束,不再估算'], ['COUNT_COMPLETE', '计数已齐,等待阶段确认'], ['NO_RECENT_PROGRESS', '近期无新增进度,暂不估算'], ['SAMPLING', '正在测速,可靠样本尚未齐全'], ['UNRECOGNIZED', '估算依据待确认']]) {
for (const [reason, expected] of [['TERMINAL', '任务已结束,不再估算'], ['COUNT_COMPLETE', '计数已齐,等待阶段确认'], ['NO_RECENT_PROGRESS', '近期无新增进度,暂不估算'], ['SAMPLING', '本次执行采样中,需积累至少1分钟的有效进度'], ['UNRECOGNIZED', '估算依据待确认']]) {
await w.setProps({ detail: { ...detail, sharedUpstream: { ...source, stageEstimate: { ...e, unavailableReason: reason } } } })
expect(w.find('.collection-estimate strong').text()).toBe(expected)
}
await w.setProps({ detail: { ...detail, sharedUpstream: { ...source, progress: { ...progress, stageFinishedAt: '2026-10-13T01:02:00Z' }, stageEstimate: { ...e, unavailableReason: 'SAMPLING' } } } })
expect(w.find('.collection-estimate strong').text()).toContain('本阶段已结束')
expect(w.text()).not.toContain('正在测速')
expect(w.text()).not.toContain('本次执行采样中')
})
it('distinguishes unobserved warehouse metadata from a confirmed missing publication', async () => {
const w = mount(CollectionTaskProgress, { props: { task, detail } }); wrappers.push(w)
@@ -79,4 +120,10 @@ describe('collection task progress evidence', () => {
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)
})
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)
expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress })
expect(w.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('12.1')
})
})
@@ -3,14 +3,14 @@ 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 } from '../collectionPresentation'
import { taskStateText, isNormalContinuation } from '../collectionPresentation'
const props = defineProps<{ task: Task; detail?: TaskDetail | null; warehouse?: WarehouseOverview | null; stale?: boolean; visible?: boolean; observedAt?: string | null; compact?: boolean }>()
const confirmedSource = computed(() => {
const source = props.detail?.sharedUpstream
return source && ['WAITING_CONFIRMED', 'SATISFIED', 'FAILED'].includes(props.task.dependencyState ?? '') ? source : null
})
const workingTask = computed(() => confirmedSource.value && props.task.dependencyState === 'WAITING_CONFIRMED' && props.task.progressSourceTaskNo === confirmedSource.value.taskNo ? confirmedSource.value : props.task)
const blockingText = computed(() => ({ 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 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())
let expiryTimer: ReturnType<typeof setTimeout> | undefined
@@ -26,7 +26,7 @@ const estimate = computed(() => {
if (p?.stageFinishedAt || !e || e.status !== 'AVAILABLE' || props.stale || props.visible === false || !e.progressIdentity || !p || !p.totalKnown || String(e.completed) !== String(p.completed) || String(e.total) !== String(p.total) || !e.validUntil || Date.parse(e.validUntil) <= now.value || !Number.isFinite(Date.parse(e.validUntil)) || e.remainingSeconds == null || !Number.isFinite(Number(e.remainingSeconds)) || Number(e.remainingSeconds) <= 0 || !Number.isFinite(Number(e.ratePerSecond)) || Number(e.ratePerSecond) <= 0) return null
return e
})
const estimateReason = computed(() => ({ TERMINAL: '任务已结束,不再估算', PAUSED: '执行已暂停;恢复后重新测速', HEALTH_UNCONFIRMED: '执行健康待确认,已暂停估算', TOTAL_UNKNOWN: '总量待确认,暂不估算', INVALID_PROGRESS: '阶段计数异常,正在复核', COUNT_COMPLETE: '计数已齐,等待阶段确认', SAMPLING: '正在测速,可靠样本尚未齐全', NO_RECENT_PROGRESS: '近期无新增进度,暂不估算', SAMPLE_EXPIRED: '测速样本已过期,等待新检查点', INVALID_SAMPLE: '测速依据待复核,暂不估算' } as Record<string, string>)[workingTask.value.stageEstimate?.unavailableReason ?? ''] ?? '估算依据待确认')
const estimateReason = computed(() => ({ TERMINAL: '任务已结束,不再估算', PAUSED: '执行已暂停;恢复后重新测速', HEALTH_UNCONFIRMED: '执行健康待确认,已暂停估算', TOTAL_UNKNOWN: '总量待确认,暂不估算', INVALID_PROGRESS: '阶段计数异常,正在复核', COUNT_COMPLETE: '计数已齐,等待阶段确认', SAMPLING: '本次执行采样中,需积累至少1分钟的有效进度', NO_RECENT_PROGRESS: '近期无新增进度,暂不估算', SAMPLE_EXPIRED: '测速样本已过期,等待新检查点', INVALID_SAMPLE: '测速依据待复核,暂不估算' } as Record<string, string>)[workingTask.value.stageEstimate?.unavailableReason ?? ''] ?? '估算依据待确认')
const estimateText = computed(() => {
if (progress.value?.stageFinishedAt) return '本阶段已结束;后续计算与发布分别确认'
if (props.stale) return '状态更新失败,已暂停估算'
@@ -39,11 +39,16 @@ const estimateText = computed(() => {
if (!progress.value?.totalKnown) return '总量待确认,暂不估算'
return '估算依据待确认'
})
const stageElapsed = computed(() => { const from = Date.parse(progress.value?.stageStartedAt ?? ''), until = Date.parse(progress.value?.stageFinishedAt || props.detail?.observedAt || props.observedAt || ''); return Number.isFinite(from) && Number.isFinite(until) && until >= from ? Math.floor((until - from) / 1000) : null })
const taskElapsed = computed(() => {
const value = props.task.elapsedSeconds
if (value == null || typeof value === 'string' && !/^\d+$/.test(value)) return null
const seconds = Number(value)
return Number.isSafeInteger(seconds) && seconds >= 0 ? seconds : null
})
const flow = computed(() => props.task.dataFamily === 'INVENTORY' ? [
{ title: '本仓库存分页', scope: '本仓', phases: ['INVENTORY', 'FETCH'] }, { title: '完整性校验', scope: '本仓', phases: ['VALIDATE', 'VALIDATION'] }, { title: '统计计算', scope: '本仓', phases: ['BUILD', 'BUILDING', 'BUILT'] }, { title: '结果发布', scope: '本仓', phases: ['PUBLISH', 'PUBLISHED'] },
] : [
{ title: '来源目录', scope: '账号共享', phases: ['DIRECTORY', 'DISCOVERY'] }, { title: '来源详情', scope: '账号共享', phases: ['DETAIL', 'DETAILS'] }, { title: '本仓校验归并', scope: '本仓', phases: ['MERGE', 'VALIDATE', 'VALIDATION'] }, { title: '统计计算', scope: '本仓', phases: ['BUILD', 'BUILDING', 'BUILT'] }, { title: '结果发布', scope: '本仓', phases: ['PUBLISH', 'PUBLISHED'] },
{ title: '来源目录', scope: '账号共享', phases: ['DIRECTORY', 'DISCOVERY'] }, { title: '来源详情', scope: '账号共享', phases: ['DETAIL', 'DETAILS'] }, { title: '更新本仓出库数据', scope: '本仓', phases: ['MERGE', 'VALIDATE', 'VALIDATION'] }, { title: '统计计算', scope: '本仓', phases: ['BUILD', 'BUILDING', 'BUILT'] }, { title: '结果发布', scope: '本仓', phases: ['PUBLISH', 'PUBLISHED'] },
])
function stageState(node: { title: string; scope: string; phases: string[] }) {
const task = node.scope === '账号共享' ? confirmedSource.value : props.task
@@ -65,12 +70,12 @@ function stageState(node: { title: string; scope: string; phases: string[] }) {
<div class="density-actions"><strong>{{ task.warehouseCode }} · {{ task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量' }}</strong><span>{{ taskStateText(task) }}</span></div>
<p class="collection-caption">数据范围 {{ beijingTime(task.queryFrom) }} 至 {{ beijingTime(task.queryUntil) }}(结束边界不含)</p>
<p v-if="task.dependencyState === 'WAITING_UNCONFIRMED'">本任务等待来源;关联进度尚未确认。</p>
<p v-if="confirmedSource">已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ taskStateText(confirmedSource) }};来源完成后,本仓仍需归并、计算与发布。</p>
<p v-if="confirmedSource">已确认关联来源:{{ confirmedSource.taskNo }} · 账号共享 · {{ taskStateText(confirmedSource) }};来源完成后,本仓仍需更新出库数据、计算与发布。</p>
</template>
<p v-if="task.blockingReason || task.latestAttempt?.failureSummary" role="status">{{ task.blockingReason ? blockingText : task.latestAttempt?.failureSummary }}</p>
<p v-if="!compact && (task.blockingReason || task.latestAttempt?.failureSummary)" role="status">{{ task.blockingReason ? blockingText : task.latestAttempt?.failureSummary }}</p>
<CollectionProgressGauge :progress="progress" :task-no="workingTask.taskNo" :stale="stale" :compact="compact" />
<div class="collection-estimate"><strong>{{ estimateText }}</strong><span v-if="estimate">最近速度 {{ quantityText(estimate.ratePerSecond) }} {{ unitText(progress?.unit) }}/秒;估算更新 {{ beijingTime(estimate.estimatedAt) }}</span><span>仅当前阶段;整个任务完成时间尚未估算。</span></div>
<p v-if="stageElapsed != null">本阶段已耗时 {{ Math.floor(stageElapsed / 60) }} 分 {{ stageElapsed % 60 }} 秒</p>
<div class="collection-estimate"><strong>{{ compact && !stale && visible !== false && task.blockingReason ? blockingText : estimateText }}</strong><span v-if="compact && task.latestAttempt?.failureSummary" role="status">{{ task.latestAttempt.failureSummary }}</span><span v-if="estimate">最近速度 {{ quantityText(estimate.ratePerSecond) }} {{ unitText(progress?.unit) }}/秒;估算更新 {{ beijingTime(estimate.estimatedAt) }}</span><span>仅当前阶段;整个任务完成时间尚未估算。</span></div>
<p>任务累计耗时 {{ taskElapsed == null ? '—' : `${Math.floor(taskElapsed / 60)} 分 ${taskElapsed % 60} 秒` }}<small>(含排队与重试)</small></p>
<template v-if="!compact">
<ol class="collection-flow" :style="{ gridTemplateColumns: `repeat(${flow.length}, minmax(0, 1fr))` }" aria-label="处理阶段"><li v-for="node in flow" :key="node.title" :data-state="stageState(node)"><strong>{{ node.title }}</strong><small>{{ node.scope }}</small><span>{{ stageState(node) }}</span></li></ol>
<div class="collection-clocks"><div>页面读取时间<strong>{{ beijingTime(detail?.observedAt || observedAt) }}</strong><small>读取成功不代表采集推进</small></div><div>最近真实推进 · {{ workingTask === task ? '本仓' : '账号共享' }}<strong>{{ beijingTime(workingTask.progressUpdatedAt || progress?.progressUpdatedAt) }}</strong><small>{{ task.attemptCount === 0 && workingTask === task ? '本仓尚未开始执行' : '以已提交检查点为准' }}</small></div><div>执行心跳<strong>{{ beijingTime(workingTask.latestAttempt?.heartbeatAt) }}</strong><small>{{ label(workingTask.latestAttempt?.executionHealth) }};心跳不代表完成数增加</small></div></div>
@@ -1,43 +1,54 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Quantity } from '../types'
import { computed, ref, watch } from 'vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import type { ECElementEvent } from 'echarts'
import { quantityText } from '../state'
export interface BarRow { key: string; label: string; secondary?: string; value: Quantity; detail: string }
const props = defineProps<{ rows: BarRow[]; unit: string; disabled?: boolean; title: string; ranked?: boolean }>()
import { barChartOption, type BarRow } from '../dashboardChartOptions'
import AnalyticsChart from './AnalyticsChart.vue'
import DashboardChartHeading from './DashboardChartHeading.vue'
export type { BarRow } from '../dashboardChartOptions'
const props = defineProps<{ rows: BarRow[]; unit: string; disabled?: boolean; title: string; ranked?: boolean; detailsWidth?: string }>()
const emit = defineEmits<{ select: [key: string] }>()
const focused = ref('')
const maximum = computed(() => Math.max(1, ...props.rows.map(row => Number(row.value ?? 0))))
const palette = ['#5575ed', '#3da9ba', '#e8a54b', '#b083dc', '#df8397', '#8a9ab4', '#a2adbf']
function color(index: number) { return props.ranked ? palette[0] : palette[index % palette.length] }
const option = computed(() => barChartOption(props.rows, props.unit, props.title, props.ranked))
const detailsOpen = ref(false)
watch(() => props.rows, () => { detailsOpen.value = false })
function select(row: BarRow) { if (!props.disabled && row.value != null) { detailsOpen.value = false; emit('select', row.key) } }
function selectChart(event: ECElementEvent) {
const row = event.componentType === 'yAxis' ? props.rows.find(row => row.key === String(event.value)) : event.componentType === 'series' ? props.rows[event.dataIndex] : undefined
if (row) select(row)
}
</script>
<template>
<div :aria-label="title" class="dashboard-bars">
<div v-if="!rows.length" class="bar-empty"><span aria-hidden="true">—</span><p>暂无符合条件的 SKU;请核对覆盖与待评估资料。</p></div>
<button v-for="(row, index) in rows" :key="row.key" type="button" class="bar-row" :style="{ '--bar-color': color(index), '--bar-delay': `${index * 45}ms` }" :aria-disabled="disabled || row.value == null" :aria-label="`${row.label},${row.secondary ?? ''},${quantityText(row.value)} ${unit};${row.detail}`" :title="`${row.label} ${row.secondary ?? ''};${row.detail}`" @focus="focused = row.key" @mouseenter="focused = row.key" @click="!disabled && row.value != null && emit('select', row.key)">
<span class="bar-heading"><span class="bar-label"><span v-if="ranked" class="bar-rank" :class="{ 'is-leading': index < 3 }">{{ String(index + 1).padStart(2, '0') }}</span>{{ row.label }}</span><strong>{{ quantityText(row.value) }} <small>{{ unit }}</small></strong></span>
<span v-if="row.secondary" class="bar-secondary">{{ row.secondary }}</span>
<span class="bar-track" aria-hidden="true"><span v-if="row.value != null" class="bar-fill" :style="{ width: `${Number(row.value) / maximum * 100}%` }" /></span>
</button>
<p v-if="focused" class="bar-detail" role="status">{{ rows.find(row => row.key === focused)?.detail }}</p>
<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>
<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">
<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>
</slot>
</div>
</AppDialog>
</template>
</div>
</template>
<style scoped>
.dashboard-bars { display: grid; gap: var(--admin-space-2); }
.bar-row { appearance: none; border: 0; padding: 7px 8px; border-radius: 6px; background: transparent; text-align: left; color: inherit; cursor: pointer; width: 100%; font: inherit; transition: background .2s; }
.bar-row:hover, .bar-row:focus-visible { background: #f5f7fc; }
.bar-row:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 2px; }
.bar-row[aria-disabled="true"] { cursor: default; }
.bar-heading { display: flex; gap: var(--admin-space-2); justify-content: space-between; align-items: baseline; }
.bar-label { overflow-wrap: anywhere; }
.bar-rank { display: inline-block; width: 28px; color: #929db3; font-size: var(--admin-font-caption); font-weight: 700; font-variant-numeric: tabular-nums; }
.bar-rank.is-leading { color: var(--bar-color); }
.bar-heading strong { white-space: nowrap; }
.bar-heading small { font-weight: 400; color: var(--el-text-color-secondary); }
.bar-secondary, .bar-detail { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
.bar-track { display: block; height: 10px; margin-top: 7px; border-radius: 3px; overflow: hidden; background: #f0f3f9; }
.bar-fill { display: block; height: 100%; background: var(--bar-color); border-radius: 3px; transform-origin: left; animation: bar-reveal .7s cubic-bezier(.2,.7,.3,1) both; animation-delay: var(--bar-delay); transition: width .5s; }
.bar-empty { min-height: 150px; display: grid; align-content: center; justify-items: center; color: var(--el-text-color-secondary); text-align: center; }
.bar-empty > span { font-size: 38px; color: #a9b7d8; }
@keyframes bar-reveal { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@media (prefers-reduced-motion: reduce) { .bar-fill { animation: none; transition: none; } .bar-row { transition: none; } }
.dashboard-bars { min-width: 0; }
small { display: block; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
td { overflow-wrap: anywhere; vertical-align: top; }
.chart-details-action { display: inline-flex; }
.chart-details table { width: 100%; table-layout: fixed; }
.rank-column { width: 56px; }
.name-column { width: 30%; }
.quantity-column { width: 120px; }
.chart-details button { font: inherit; color: var(--el-color-primary); background: transparent; border: 0; cursor: pointer; padding: var(--admin-space-2); }
.chart-details button:disabled { color: var(--el-text-color-secondary); cursor: default; }
</style>
@@ -0,0 +1,16 @@
<script setup lang="ts">
defineProps<{ title: string }>()
</script>
<template>
<div class="dashboard-chart-heading">
<h3>{{ title }}</h3>
<div class="dashboard-chart-actions"><slot /></div>
</div>
</template>
<style scoped>
.dashboard-chart-heading { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--admin-space-2); }
h3 { display: flex; align-items: center; gap: var(--admin-space-2); margin: 0; font-size: var(--admin-font-section); }
h3::before { content: ''; width: 3px; height: 14px; border-radius: 2px; background: #5575ed; flex-shrink: 0; }
.dashboard-chart-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: var(--admin-space-3); margin-left: auto; }
.dashboard-chart-actions :deep(.el-button + .el-button) { margin-left: 0; }
</style>
@@ -1,5 +1,7 @@
vi.mock('@/shared/components/AppDialog.vue', () => ({ default: { props: ['modelValue', 'title', 'appendToBody'], template: '<section v-if="modelValue" role="dialog"><h2>{{ title }}</h2><slot /></section>' } }))
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import DashboardTrend from './DashboardTrend.vue'
import DashboardBars from './DashboardBars.vue'
import type { DailyPoint } from '../types'
@@ -10,32 +12,49 @@ function point(date: string, value: number | null, dataStatus = 'COMPLETE'): Dai
describe('dashboard chart integrity and interaction', () => {
it('fills only contiguous known segments and keeps unknown days unavailable for drilldown', async () => {
const wrapper = mount(DashboardTrend, { props: { points: [point('2026-10-01', 2), point('2026-10-02', 4), point('2026-10-03', null, 'UNKNOWN'), point('2026-10-04', 1, 'PARTIAL')] } })
const areas = wrapper.findAll('.trend-area').filter(path => path.attributes('d'))
expect(areas).toHaveLength(1)
expect(areas[0]!.attributes('d')).not.toContain('770')
const days = wrapper.findAll('g')
const chart = wrapper.findComponent({ name: 'VChart' })
const series = chart.props('option').series
expect(series[1].data).toEqual([2, 4, null, null])
expect(series[0].data[3].symbol).toBe('emptyCircle')
expect(wrapper.find('[role=dialog]').exists()).toBe(false)
expect(wrapper.find('.dashboard-chart-heading .chart-disclosure button').classes()).toContain('is-link')
await wrapper.find('.chart-disclosure button').trigger('click')
const days = wrapper.findAll('tbody button')
await days[2]!.trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
await days[3]!.trigger('keydown', { key: 'Enter' })
chart.vm.$emit('click', { componentType: 'series', dataIndex: 3 })
expect(wrapper.emitted('select')).toEqual([['2026-10-04']])
expect(days[3]!.find('.trend-point').attributes('fill')).toBe('var(--el-bg-color)')
await wrapper.setProps({ disabled: true })
chart.vm.$emit('click', { componentType: 'series', dataIndex: 0 })
expect(wrapper.emitted('select')).toHaveLength(1)
await wrapper.setProps({ points: [point('2026-10-05', 7)] })
expect(wrapper.find('.trend-tooltip').exists()).toBe(false)
wrapper.unmount()
})
it('preserves zero and unknown bars, exact relative widths and disabled selection', async () => {
const wrapper = mount(DashboardBars, { props: { title: '分布', unit: 'SKU', rows: [
const wrapper = mount(DashboardBars, { global: { stubs: { ElButton: { template: '<button><slot /></button>' } } }, props: { title: '分布', unit: 'SKU', rows: [
{ key: 'a', label: 'A', value: 20, detail: '' }, { key: 'b', label: 'B', value: 5, detail: '' },
{ key: 'zero', label: '零值', value: 0, detail: '' }, { key: 'unknown', label: '未知', value: null, detail: '' },
] } })
expect(wrapper.findAll('.bar-fill').map(bar => bar.attributes('style'))).toEqual(['width: 100%;', 'width: 25%;', 'width: 0%;'])
await wrapper.findAll('button')[3]!.trigger('click')
const chart = wrapper.findComponent({ name: 'VChart' })
expect(chart.props('option').series[0].data.map((point: { value: number | null }) => point.value)).toEqual([20, 5, 0, null])
expect(wrapper.find('[role=dialog]').exists()).toBe(false)
await wrapper.find('.dashboard-chart-heading .chart-details-action button').trigger('click')
expect(wrapper.find('[role=dialog]').text()).toContain('分布 · 完整明细')
await wrapper.findAll('tbody button')[3]!.trigger('click')
expect(wrapper.emitted('select')).toBeUndefined()
await wrapper.findAll('button')[2]!.trigger('click')
await wrapper.findAll('tbody button')[2]!.trigger('click')
expect(wrapper.find('[role=dialog]').exists()).toBe(false)
expect(wrapper.emitted('select')).toEqual([['zero']])
chart.vm.$emit('click', { componentType: 'yAxis', value: 'a' })
expect(wrapper.emitted('select')).toEqual([['zero'], ['a']])
await wrapper.setProps({ disabled: true })
await wrapper.findAll('button')[0]!.trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
await wrapper.find('.chart-details-action button').trigger('click')
await wrapper.findAll('tbody button')[0]!.trigger('click')
chart.vm.$emit('click', { componentType: 'series', dataIndex: 0 })
expect(wrapper.emitted('select')).toHaveLength(2)
await wrapper.setProps({ rows: [{ key: 'new', label: '新数据', value: 1, detail: '' }] })
expect(wrapper.find('[role=dialog]').exists()).toBe(false)
wrapper.unmount()
})
})
@@ -1,66 +1,36 @@
<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue'
import { computed, ref } from 'vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
import DashboardChartHeading from './DashboardChartHeading.vue'
import type { ECElementEvent } from 'echarts'
import type { DailyPoint } from '../types'
import { label, quantityText, reasonsText, shownQuantity, trendSegments } from '../state'
import { label, quantityText, reasonsText, shownQuantity } from '../state'
import { skuTrendOption } from '../skuTrendChart'
import AnalyticsChart from './AnalyticsChart.vue'
const props = defineProps<{ points: DailyPoint[]; disabled?: boolean }>()
const emit = defineEmits<{ select: [date: string] }>()
const focused = ref<DailyPoint | null>(null)
const gradientId = `trend-area-${useId()}`
const chartKey = computed(() => props.points.map(point => `${point.businessDate}:${shownQuantity(point)}:${point.dataStatus}`).join('|'))
watch(() => props.points, () => { focused.value = null })
const segments = computed(() => trendSegments(props.points))
const maximum = computed(() => Math.max(1, ...props.points.map(point => Number(shownQuantity(point) ?? 0))))
function x(index: number) { return props.points.length === 1 ? 400 : 30 + index / (props.points.length - 1) * 740 }
function y(point: DailyPoint) { return 175 - Number(shownQuantity(point) ?? 0) / maximum.value * 145 }
function text(point: DailyPoint) { return `${point.businessDate}:${quantityText(shownQuantity(point))} 件,${label(point.dataStatus)};${reasonsText(point.reasonCodes)}` }
function select(point: DailyPoint) { focused.value = point; if (!props.disabled && point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null) emit('select', point.businessDate) }
function area(path: string) {
const coordinates = [...path.matchAll(/[ML]\s*([\d.]+),([\d.]+)/g)]
if (coordinates.length < 2) return ''
return `${path} L${coordinates.at(-1)![1]},175 L${coordinates[0]![1]},175 Z`
}
const option = computed(() => ({ ...skuTrendOption(props.points), aria: { enabled: true, description: '全仓每日出库趋势,点击已知日点查看当日销量。缺失日期断线,未完整日虚线。' } }))
const hasData = computed(() => props.points.some(point => point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null))
const detailsOpen = ref(false)
function available(point: DailyPoint) { return !props.disabled && point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null }
function select(point: DailyPoint) { if (available(point)) { detailsOpen.value = false; emit('select', point.businessDate) } }
function selectChart(event: ECElementEvent) { const point = props.points[event.dataIndex]; if (event.componentType === 'series' && point) select(point) }
</script>
<template>
<div>
<div class="trend-legend"><span><i class="legend-dot" />完整日</span><span><i class="legend-dot is-partial" />未完整日</span><span>× 未知 · 缺失日期断线</span><span class="trend-today">今日为已采集累计</span></div>
<svg :key="chartKey" viewBox="0 0 800 220" class="dashboard-trend" role="group" aria-label="全仓每日出库趋势,点击已知日点查看当日销量">
<defs><linearGradient :id="gradientId" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="#5575ed" stop-opacity=".22" /><stop offset="100%" stop-color="#5575ed" stop-opacity=".01" /></linearGradient></defs>
<line v-for="level in [0, 1, 2, 3, 4]" :key="level" x1="30" :y1="175 - level * 36.25" x2="770" :y2="175 - level * 36.25" stroke="#e7edf6" :stroke-dasharray="level ? '4 5' : undefined" />
<text x="10" y="190">0</text><text x="30" y="20">{{ quantityText(maximum) }} 件</text>
<path v-for="(segment, index) in segments" :key="`area-${index}`" :d="area(segment.path)" :fill="`url(#${gradientId})`" class="trend-area" />
<path v-for="(segment, index) in segments" :key="index" :d="segment.path" fill="none" stroke="#5575ed" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" :stroke-dasharray="segment.partial ? '5 4' : undefined" :class="{ 'trend-line': !segment.partial }" :pathLength="segment.partial ? undefined : 1" />
<line v-if="focused" :x1="x(points.indexOf(focused))" :x2="x(points.indexOf(focused))" y1="30" y2="175" stroke="#b4c2e5" stroke-dasharray="3 4" />
<g v-for="(point, index) in points" :key="point.businessDate" tabindex="0" role="button" :aria-label="text(point)" :aria-disabled="disabled || point.dataStatus === 'UNKNOWN' || shownQuantity(point) == null" @focus="focused = point" @mouseenter="focused = point" @click="select(point)" @keydown.enter.prevent="select(point)" @keydown.space.prevent="select(point)">
<title>{{ text(point) }}</title>
<circle class="trend-hit" :cx="x(index)" :cy="point.dataStatus === 'UNKNOWN' ? 175 : y(point)" r="12" fill="transparent" />
<circle v-if="point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null" class="trend-point" :cx="x(index)" :cy="y(point)" r="5" :fill="point.dataStatus === 'COMPLETE' ? '#5575ed' : 'var(--el-bg-color)'" stroke="#5575ed" stroke-width="2" />
<text v-else :x="x(index)" y="174" text-anchor="middle">×</text>
<text v-if="index % Math.max(1, Math.ceil(points.length / 8)) === 0 || index === points.length - 1" :x="x(index)" y="208" text-anchor="middle">{{ point.businessDate.slice(5) }}</text>
</g>
</svg>
<p v-if="focused" class="trend-tooltip" role="status">{{ text(focused) }}</p>
<p v-if="!segments.length">所选日期暂无可靠出库资料,未知不按零显示。</p>
<details><summary>查看每日数值与覆盖原因</summary><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>{{ point.businessDate }}</td><td>{{ quantityText(point.outboundQuantity) }}</td><td>{{ quantityText(point.observedQuantity) }}</td><td>{{ label(point.dataStatus) }}</td><td>{{ reasonsText(point.reasonCodes) }}</td></tr></tbody></table></details>
<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>
</DashboardChartHeading>
<slot name="intro" />
<p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 未知及缺失日期断线;今日为已采集累计</p>
<AnalyticsChart :option="option" class="dashboard-trend" aria-label="全仓每日出库趋势" @select="selectChart" />
<p v-if="!hasData">所选日期暂无可靠出库资料,未知不按零显示。</p>
</div>
</template>
<style scoped>
.dashboard-trend { width: 100%; height: 300px; overflow: visible; }
text { font-size: var(--admin-font-caption); fill: #76849c; }
.trend-legend { display: flex; flex-wrap: wrap; gap: 16px; color: #76849c; font-size: var(--admin-font-caption); align-items: center; }
.trend-legend span { display: inline-flex; gap: 6px; align-items: center; }
.legend-dot { width: 7px; height: 7px; border-radius: 50%; background: #5575ed; border: 1px solid #5575ed; }
.legend-dot.is-partial { background: white; }
.trend-today { margin-left: auto; }
.trend-line { stroke-dasharray: 1; stroke-dashoffset: 0; animation: trend-draw 1.1s ease-out; }
.trend-area { animation: area-reveal 1.1s ease-out; }
.trend-point { animation: area-reveal .8s ease-out; }
.trend-tooltip { padding: 8px 12px; background: #f2f5ff; border-left: 3px solid #5575ed; border-radius: 4px; }
g { cursor: pointer; }
g[aria-disabled="true"] { cursor: default; }
g:focus { outline: none; }
g:focus .trend-point, g:hover .trend-point { stroke: #304aa3; stroke-width: 3px; }
g:focus .trend-hit { stroke: #5575ed; stroke-width: 1px; }
@keyframes trend-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@keyframes area-reveal { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .trend-line, .trend-area, .trend-point { animation: none; } }
</style>
.dashboard-trend { width: 100%; height: 340px; }
.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>
@@ -0,0 +1,19 @@
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import HotSkuDetails from './HotSkuDetails.vue'
import type { DashboardRanking, DashboardItem } from '../types'
it('shows each SKU identity and ranked quantity once, preserves unknown and owner-specific drilldown', async () => {
const item: DashboardItem = { sku: 'SKU-X', customerCode: 'OWNER-A', productName: 'SKU-X 商品', value: 164, physicalQuantity: 891, availableQuantity: 0, averageDailyOutboundQuantity: null, inventoryCoverageDays: null, unit: 'PCS', windowOutboundQuantity: 164, skuOutboundOrderCount: 1, frozenQuantity: 0, defectivePhysicalQuantity: 0 }
const ranking: DashboardRanking = { windowStartDate: '2026-10-01', windowEndDateExclusive: '2026-10-14', calculationWindowDays: 13, eligibleCount: 2, unassessableCount: 0, dataStatus: 'PARTIAL', reasonCodes: [], items: [item, { ...item, customerCode: 'OWNER-B', productName: 'SKU-X' }] }
const wrapper = mount(HotSkuDetails, { props: { ranking } })
const cells = wrapper.findAll('tbody tr')[0]!.findAll('td').map(cell => cell.text())
expect(cells).toEqual(['1', 'SKU-X', '商品', 'OWNER-A', '164', '891', '0', '—', '—'])
expect(wrapper.findAll('tbody tr')[1]!.findAll('td')[2]!.text()).toBe('—')
expect(wrapper.text()).toContain('时段已采集出库量')
await wrapper.findAll('button')[1]!.trigger('click')
expect(wrapper.emitted('select')).toEqual([[1]])
await wrapper.setProps({ disabled: true })
await wrapper.findAll('button')[0]!.trigger('click')
expect(wrapper.emitted('select')).toHaveLength(1)
wrapper.unmount()
})
@@ -0,0 +1,30 @@
<script setup lang="ts">
import type { DashboardRanking } from '../types'
import { label, quantityText, reasonsText } from '../state'
defineProps<{ ranking: DashboardRanking; disabled?: boolean }>()
const emit = defineEmits<{ select: [index: number] }>()
function productName(sku: string, name: string | null) {
const text = name?.trim() || ''
if (text === sku) return '—'
return (text.startsWith(`${sku} `) ? text.slice(sku.length).trim() : text) || '—'
}
</script>
<template>
<div class="hot-sku-details">
<p>{{ ranking.windowStartDate }} 至 {{ ranking.windowEndDateExclusive }}(截止日不含) · {{ label(ranking.dataStatus) }}<template v-if="ranking.reasonCodes.length"> · {{ reasonsText(ranking.reasonCodes) }}</template></p>
<p>按所选时段{{ ranking.dataStatus === 'COMPLETE' ? '出库量' : '已采集出库量' }}排名;点击 SKU 查看每日趋势。<template v-if="ranking.dataStatus !== 'COMPLETE'">数据尚未采齐,排名可能变化;未采集日期不计为零出库。</template>“—”表示暂无可靠数据。</p>
<table class="density-table"><thead><tr><th class="rank">排名</th><th class="sku">SKU</th><th>商品名称</th><th class="owner">货主</th><th class="number">{{ ranking.dataStatus === 'COMPLETE' ? '时段出库量' : '时段已采集出库量' }}(件)</th><th class="number">实物库存(件)</th><th class="number">可售库存(件)</th><th class="number">窗口日均(件)</th><th class="number">预计可售(天)</th></tr></thead><tbody>
<tr v-for="(item, index) in ranking.items" :key="`${item.customerCode}:${item.sku}`"><td>{{ index + 1 }}</td><td><button type="button" :disabled="disabled || item.value == null" @click="emit('select', index)">{{ item.sku }}</button></td><td>{{ productName(item.sku, item.productName) }}</td><td>{{ item.customerCode }}</td><td class="number">{{ quantityText(item.value) }}</td><td class="number">{{ quantityText(item.physicalQuantity) }}</td><td class="number">{{ quantityText(item.availableQuantity) }}</td><td class="number">{{ quantityText(item.averageDailyOutboundQuantity) }}</td><td class="number">{{ quantityText(item.inventoryCoverageDays) }}</td></tr>
</tbody></table>
</div>
</template>
<style scoped>
.hot-sku-details table { width: 100%; table-layout: fixed; }
th, td { overflow-wrap: anywhere; }
.rank { width: 44px; }
.sku { width: 16%; }
.owner { width: 90px; }
.number { width: 86px; text-align: right; font-variant-numeric: tabular-nums; }
button { border: 0; padding: 0; background: transparent; color: var(--el-color-primary); font: inherit; text-align: left; overflow-wrap: anywhere; cursor: pointer; }
button:disabled { color: var(--el-text-color-secondary); cursor: default; }
</style>
@@ -0,0 +1,39 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import InventoryRankingDiagnostics from './InventoryRankingDiagnostics.vue'
import { dashboardFixture } from '../pages/dashboard.fixture'
describe('inventory ranking evidence', () => {
it('shows fixed-query coverage and keeps distribution counts distinct from ranking counts', () => {
const data = structuredClone(dashboardFixture)
Object.assign(data.context, { completeDayFrom: '2026-10-01', completeDayUntil: '2026-10-13', salesCoveredFrom: '2026-09-30T16:00:00Z', salesCoveredUntil: '2026-10-12T16:00:00Z', inventoryStatus: 'VALID' })
data.longCoverageSkus.unassessableCount = '2711'
data.noOutboundSkus.unassessableCount = '2741'
data.coverageDistribution.unknownReasons = { SALES_WINDOW_UNAVAILABLE: '2700', OBSERVATION_INSUFFICIENT: '11', PHYSICAL_INVENTORY_UNKNOWN: '0' }
const wrapper = mount(InventoryRankingDiagnostics, { props: { data, warehouse: '测试仓', historical: false } })
expect(wrapper.text()).toContain('2026-10-01 至 2026-10-13(截止日不含)')
expect(wrapper.text()).toContain('2026/10/01 00:00:00')
expect(wrapper.text()).toContain('待评估 2711 SKU')
expect(wrapper.text()).toContain('待评估 2741 SKU')
expect(wrapper.text()).toContain('不是两个榜单各自的排除明细')
expect(wrapper.text()).toContain('每个SKU仅列首个命中的原因')
expect(wrapper.text()).toContain('仅补齐窗口内销量不一定满足此条件')
expect(wrapper.text()).not.toContain('实物库存待核验')
})
it('preserves unknown counts and marks stale evidence without claiming complete data', () => {
const data = structuredClone(dashboardFixture)
data.longCoverageSkus.unassessableCount = null
data.coverageDistribution.unknownReasons = { OBSERVATION_INSUFFICIENT: null }
const wrapper = mount(InventoryRankingDiagnostics, { props: { data, warehouse: '测试仓', historical: true } })
expect(wrapper.text()).toContain('待评估 — SKU')
expect(wrapper.text()).toContain('尚无可确认的完整日覆盖范围')
expect(wrapper.text()).toContain('当前为历史查询结果')
expect(wrapper.findAll('table')[1]!.text()).toContain('观察期不足—')
})
it('does not interpret absent cause counts as proof that data is complete', () => {
const data = structuredClone(dashboardFixture)
data.coverageDistribution.unknownReasons = {}
const wrapper = mount(InventoryRankingDiagnostics, { props: { data, warehouse: '测试仓', historical: false } })
expect(wrapper.text()).toContain('不能据此认定资料全部齐备')
})
})
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Dashboard } from '../types'
import { beijingTime, label, quantityText, reasonsText } from '../state'
const props = defineProps<{ data: Dashboard; warehouse: string; historical: boolean }>()
const explanations: Record<string, string> = {
PHYSICAL_INVENTORY_UNKNOWN: '实物库存缺失、异常或已过期,需核对库存采集结果。',
AVAILABLE_INVENTORY_UNKNOWN: '正品可售库存缺失、异常或已过期,暂不能计算消化天数。',
UNIT_UNKNOWN_OR_CONFLICT: '销量或库存的数量单位未确认或不一致。',
SALES_WINDOW_UNAVAILABLE: '近30个完整日的正式销量不可用,需核对覆盖范围、销量状态与修订核对结果。',
OBSERVATION_INSUFFICIENT: '缺少统计窗口起点及以前的本仓有效出库或库存观察依据;仅补齐窗口内销量不一定满足此条件。',
METRIC_DEPENDENCY_UNKNOWN: '计算所需资料仍有未知项,需进入库存分析核对具体SKU。',
}
const causes = computed(() => Object.entries(props.data.coverageDistribution.unknownReasons)
.filter(([, count]) => count == null || !Number.isFinite(Number(count)) || Number(count) > 0))
</script>
<template>
<div class="inventory-ranking-diagnostics">
<p><strong>{{ warehouse || '—' }}</strong> · 查询时间 {{ beijingTime(data.context.queryAsOf) }}(北京时间)</p>
<p v-if="historical" role="status">当前为历史查询结果,请整体刷新后再核对最新采集覆盖。</p>
<table class="density-table">
<tbody>
<tr><th scope="row">榜单要求的销量区间</th><td>{{ data.longCoverageSkus.windowStartDate }} 至 {{ data.longCoverageSkus.windowEndDateExclusive }}(截止日不含)</td></tr>
<tr><th scope="row">已发布销量覆盖</th><td>{{ beijingTime(data.context.salesCoveredFrom) }} 至 {{ beijingTime(data.context.salesCoveredUntil) }}(截止时刻不含)</td></tr>
<tr><th scope="row">完整日覆盖</th><td v-if="data.context.completeDayFrom && data.context.completeDayUntil">{{ data.context.completeDayFrom }} 至 {{ data.context.completeDayUntil }}(截止日不含)</td><td v-else>尚无可确认的完整日覆盖范围</td></tr>
<tr><th scope="row">销量状态</th><td>{{ label(data.context.salesStatus) }};近30日日均:{{ label(data.kpis.averageDailyOutboundQuantity30d.dataStatus) }} · {{ reasonsText(data.kpis.averageDailyOutboundQuantity30d.reasonCodes) }}</td></tr>
<tr><th scope="row">库存采集时间</th><td>{{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}</td></tr>
<tr><th scope="row">库存状态/有效期</th><td>{{ label(data.context.inventoryStatus) }};有效至 {{ beijingTime(data.context.inventoryFreshUntil) }}</td></tr>
<tr><th scope="row">本次查询提示</th><td>{{ reasonsText(data.context.reasonCodes) }}</td></tr>
<tr><th scope="row">预计消化时间最长</th><td>符合条件 {{ quantityText(data.longCoverageSkus.eligibleCount) }} SKU;待评估 {{ quantityText(data.longCoverageSkus.unassessableCount) }} SKU</td></tr>
<tr><th scope="row">近30日无出库库存</th><td>符合条件 {{ quantityText(data.noOutboundSkus.eligibleCount) }} SKU;待评估 {{ quantityText(data.noOutboundSkus.unassessableCount) }} SKU</td></tr>
</tbody>
</table>
<h4>全仓库存分布的待核验原因</h4>
<p>以下为全仓库存分布的原因计数,不是两个榜单各自的排除明细。每个SKU仅列首个命中的原因,补齐后仍可能有其他条件待满足。</p>
<table v-if="causes.length" class="density-table">
<thead><tr><th scope="col">原因</th><th scope="col">SKU数</th><th scope="col">需要核对的数据</th></tr></thead>
<tbody><tr v-for="[reason, count] in causes" :key="reason"><td>{{ reasonsText([reason]) }}</td><td>{{ quantityText(count) }}</td><td>{{ explanations[reason] || '接口未提供更细说明,请核对库存分析中的SKU资料。' }}</td></tr></tbody>
</table>
<p v-else>本次查询未列出非零的具体原因计数,不能据此认定资料全部齐备。</p>
<p>消化榜还要求正可售库存且日均出库大于零;无出库榜要求正实物库存且完整窗口出库为零。两榜均需满足本仓观察资格;未知销量不能按零处理。</p>
</div>
</template>
<style scoped>
table { width: 100%; }
th, td { text-align: left; overflow-wrap: anywhere; }
tbody th { width: 180px; }
</style>
@@ -0,0 +1,39 @@
vi.mock('@/shared/components/AppDialog.vue', () => ({ default: { props: ['modelValue'], template: '<section v-if="modelValue"><slot /></section>' } }))
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
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'
function data(status = 'COMPLETE'): DailyResponse {
return {
context: { salesRunId: null, inventoryRunId: null, salesScopeHash: null, inventoryScopeHash: null, queryAsOf: '', queryExpiresAt: '', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-13' },
summary: { sourceAccount: 'test', customerCode: 'OWNER', warehouseCode: 'WH', sku: 'SKU', productName: null, quantityUnit: 'PCS', outboundQuantity: '30', observedQuantity: '30', dataStatus: status, reasonCodes: [], completeDayCount: 30, partialDayCount: 0, unknownDayCount: 0 },
points: Array.from({ length: 30 }, (_, index) => ({ businessDate: `2026-09-${String(index + 1).padStart(2, '0')}`, outboundQuantity: status === 'UNKNOWN' ? null : '1', observedQuantity: status === 'UNKNOWN' ? null : '1', dataStatus: status, quantityUnit: 'PCS', reasonCodes: [] })),
}
}
describe('SKU daily trend axes', () => {
it('shows both axes, intermediate dates, zero and maximum ticks for a 30-day window', () => {
const w = mount(SkuTrend, { props: { data: data() } })
const chart = w.findComponent({ name: 'VChart' })
const option = chart.props('option')
expect(option.xAxis).toMatchObject({ type: 'category', name: '日期(北京时间)' })
expect(option.xAxis.data).toHaveLength(30)
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(chart.props('initOptions')).toEqual({ renderer: 'canvas' })
expect(w.find('svg').exists()).toBe(false)
w.unmount()
})
it('retains axes and date markers for an unknown window without drawing zero points', () => {
const w = mount(SkuTrend, { props: { data: data('UNKNOWN') } })
expect(w.text()).toContain('未知日期不作为零销量')
const option = w.findComponent({ name: 'VChart' }).props('option')
expect(option.series[0].data.every((point: { value: number | null }) => point.value === null)).toBe(true)
expect(option.series).toHaveLength(1)
expect(option.xAxis.data).toHaveLength(30)
w.unmount()
})
})
@@ -1,39 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import ChartDetailsDialog from './ChartDetailsDialog.vue'
import AnalyticsChart from './AnalyticsChart.vue'
import type { DailyResponse } from '../types'
import { label, quantityText, shownQuantity, trendSegments, reasonsText } from '../state'
import { label, quantityText, shownQuantity, reasonsText } from '../state'
import { skuTrendOption } from '../skuTrendChart'
const props = defineProps<{ data: DailyResponse }>()
const segments = computed(() => trendSegments(props.data.points))
const maximum = computed(() => Math.max(1, ...props.data.points.map(point => Number(shownQuantity(point) ?? 0))))
function x(index: number) { return props.data.points.length === 1 ? 400 : 30 + index / (props.data.points.length - 1) * 740 }
function y(value: string | number | null) { return 175 - Number(value ?? 0) / maximum.value * 145 }
const option = computed(() => skuTrendOption(props.data.points))
const hasData = computed(() => props.data.points.some(point => point.dataStatus !== 'UNKNOWN' && shownQuantity(point) != null))
</script>
<template>
<section aria-label="SKU每日出库趋势">
<h3>{{ data.summary.sku }} · 每日出库趋势</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="!segments.length">所选范围尚无可靠数据,未知日期不作为零销量。</p>
<svg v-else viewBox="0 0 800 220" role="img" aria-label="每日出库件数;虚线点表示未完整,缺失日期断线" class="sku-trend">
<line x1="30" y1="175" x2="770" y2="175" stroke="currentColor" />
<text x="30" y="20">{{ maximum }} 件</text>
<path v-for="(segment, index) in segments" :key="index" :d="segment.path" fill="none" stroke="var(--el-color-primary)" stroke-width="2" :stroke-dasharray="segment.partial ? '5 4' : undefined" />
<g v-for="(point, index) in data.points" :key="point.businessDate">
<circle v-if="shownQuantity(point) != null && point.dataStatus !== 'UNKNOWN'" :cx="x(index)" :cy="y(shownQuantity(point))" r="4" :fill="point.dataStatus === 'COMPLETE' ? 'var(--el-color-primary)' : 'white'" stroke="var(--el-color-primary)">
<title>{{ point.businessDate }}:{{ quantityText(shownQuantity(point)) }} {{ point.quantityUnit === 'PCS' ? '件' : '单位未知' }},{{ label(point.dataStatus) }};覆盖截止 {{ point.coveredUntil ?? '未知' }}</title>
</circle>
<text v-if="data.points.length <= 15 || index === 0 || index === data.points.length - 1" :x="x(index)" y="205" text-anchor="middle">{{ point.businessDate.slice(5) }}</text>
</g>
</svg>
<details>
<summary>每日数量、完整性及覆盖证据</summary>
<p v-if="!hasData">所选范围尚无可靠数据,未知日期不作为零销量。</p>
<p class="trend-legend">实线/实心点:完整日 · 虚线/空心点:未完整日 · 空缺:未知,缺失日期断线</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>
</details>
</ChartDetailsDialog>
</section>
</template>
<style scoped>
.sku-trend { width: 100%; max-height: 280px; }
text { font-size: var(--admin-font-caption); }
.sku-trend { display: block; width: 100%; height: 340px; }
.trend-legend { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
</style>
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import type { GaugeSeriesOption, BarSeriesOption } from 'echarts'
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 tooltip = option.tooltip as { formatter: (value: { dataIndex: number }) => string; extraCssText: string }
const content = tooltip.formatter({ dataIndex: 0 })
expect(content).toContain('&lt;img src=x&gt;')
expect(content).toContain('A&amp;B')
expect(content).not.toContain('very long detail')
expect(tooltip.extraCssText).toContain('white-space: normal')
})
it('renders unknown progress as an empty track without a zero progress value', () => {
const unknown = (progressGaugeOption(null).series as GaugeSeriesOption[])[0]!
expect(unknown.progress?.show).toBe(false)
expect(unknown.data).toEqual([])
const known = (progressGaugeOption(12.1).series as GaugeSeriesOption[])[0]!
expect(known.progress?.show).toBe(true)
expect(known.data).toEqual([{ value: 12.1 }])
})
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: '' }))
const option = barChartOption(rows, 'SKU', '库存分布')
expect(option.yAxis).toMatchObject({ inverse: true, data: rows.map(row => row.key) })
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 })
})
})
@@ -0,0 +1,33 @@
import type { EChartsOption } from 'echarts'
import type { Quantity } from './types'
import { quantityText } from './state'
export interface BarRow { key: string; label: string; secondary?: string; value: Quantity; detail: string }
const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]!))
const palette = ['#5575ed', '#3da9ba', '#e8a54b', '#b083dc', '#df8397', '#8a9ab4', '#a2adbf']
export function barChartOption(rows: BarRow[], unit: string, title: string, ranked = false): EChartsOption {
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' } } },
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 => {
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(quantityText(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 => quantityText(rows[params.dataIndex]?.value) } }],
}
}
export function progressGaugeOption(percent: number | null): EChartsOption {
return { animation: false, series: [{ type: 'gauge', startAngle: 90, endAngle: -270, radius: '86%', min: 0, max: 100,
pointer: { show: false }, progress: { show: percent != null, roundCap: true, width: 10 },
axisLine: { lineStyle: { width: 10, color: [[1, '#e8eaf5']] } }, itemStyle: { color: '#6879ee' },
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, title: { show: false }, detail: { show: false },
silent: true, data: percent == null ? [] : [{ value: percent }],
}] }
}
+25 -2
View File
@@ -4,11 +4,15 @@
| 页面 | 操作 | 依据与承载结论 | 布局与验证 |
| --- | --- | --- | --- |
| 自营业务全部图表 | 统一ECharts | 2026-10-13 用户要求全面替换手写图表:经营总览日趋势、库存七桶与三个榜单、SKU日趋势、采集进度环统一通过AnalyticsChart使用ECharts Canvas;移除SVG路径生成与CSS条形绘图,保留真实数量、未知/零区分及原有下钻边界 | 日期点/分类轴/柱体点击及展开表格键盘操作同资格;未知进度仅空环;坐标、tooltip、缩放及尺寸由组件配置;待人工1920×1080、100%截图确认 |
| 库存/销量 | SKU每日出库趋势连线与坐标 | 2026-10-13 用户反馈线未连接、坐标不显示,并明确要求图表组件:保留居中弹框,改用ECharts+vue-echarts(Canvas),配置日期轴、数量轴、网格、提示框、图例说明和范围缩放;相邻有效日连接,涉及未完整日用虚线,未知或缺失日期断线 | 库存/销量/总览的SKU弹框共用组件;总览主图的连线修复同步回归;不把未知补零;待人工1920×1080、100%截图确认 |
| /self-operated/collection | 看板配色 | 2026-10-13 用户追加当前页面与原型首页截图:采用蓝紫主色、浅色圆形装饰数量卡、绿/橙数量强调;流程与仓库结果增加色彩层次,保持原有左右布局及真实状态文字 | 仅本看板作用域内应用;颜色不代替状态或代表未知完成度;待人工1920×1080、100%截图确认 |
| /self-operated/collection | 等待提示与刷新稳定性 | 2026-10-13 用户两张运行看板截图:移除环图上方临时插入的等待说明,原因合并到下方估算说明区;未知总量以“—”及目录总页数未返回说明展示;刷新保留已读数据,同任务优先使用账号活动摘要 | 数量说明预留一行,避免已知/未知切换挤动数字;阶段或尝试变化不沿用旧百分比;待人工1920×1080、100%截图确认 |
| /self-operated/collection | 单账号运行首屏 | 2026-10-13 最新实际页面及原型截图:首屏改为账号活动优先,环形进度、总量/完成/剩余及阶段预计时间;移除原筛选、配置、历史、详情和恢复表单,只读账号概览;待机保留阶段、最近发布及各仓结果 | 按用户原型排版,使用现有 Admin 白色卡片、主题强调色及统一按钮,无详情弹框;本行覆盖下方旧采集页面承载记录;后续流程不冒充调度顺序,有界待处理摘要不冒充完整队列;待人工1920×1080、100%截图确认 |
| 销量/库存 | 默认仓库与重置 | 2026-10-13 用户确认进入页面即按仓库显示:无传入仓库时自动选中接口返回的首个可用仓库并查询;链接带入仓库优先;重置保留当前仓库并重新查询 | 保留切仓与迟到响应隔离;没有可用仓库不发无仓库查询;自动回归覆盖两页首次进入、链接进入、缓存页返回及重置 |
| /self-operated/sales | 自定义销量日期 | 2026-10-13 用户截图 `codex-clipboard-fac03bb1-6703-467d-b357-5731ea8cfb32.png`:两个单日框分散换行,未完整选择时残留逆序错误和旧结果。原筛选区改为公共QueryToolbar单一日期范围控件,明确开始/结束日期,支持同日;不新增浮层或改变统计口径 | 日期范围整体换行,校验完整范围后查询;草稿修改不改变已查询列表的分页/趋势条件,提交失败不冒充新范围结果;补充自动回归,待人工1920×1080、100%截图确认 |
| /self-operated/overview | 查询、刷新与六项指标 | `docs/自营业务/经营总览图表设计.md`“菜单与页面布局”“指标带”;单仓+趋势日期QueryToolbar、紧凑指标带,整体固定版本 | 不新增货主控件;未知/过期独立提示;待人工1920×1080截图确认 |
| /self-operated/overview | 日趋势、七桶与三个Top10 | 同设计“五张图表”;SVG折线与横条、趋势约2/3宽、分布约1/3宽,三榜并排 | 热销随时间筛选按已采集量排名并提示覆盖不足;可聚焦提示、缺日断线、部分日文字、真实数值比例;待人工截图确认 |
| /self-operated/overview | 日趋势、七桶与三个Top10 | 同设计“五张图表”;ECharts折线与横条、趋势约2/3宽、分布约1/3宽,三榜并排 | 热销随时间筛选按已采集量排名并提示覆盖不足;可聚焦提示、缺日断线、部分日文字、真实数值比例;待人工截图确认 |
| /self-operated/overview | SKU趋势 | 同设计“图表交互与视觉依据”;1120px居中AppDialog,热销沿所选时段、其它榜沿固定30日,精确货主SKU | 关闭取消请求,不改现有详情承载;待人工截图确认 |
| 总览→销量/库存 | 日期/榜单/分桶下钻与返回 | 同设计“精确下钻合同”;目标页验证完整版本、精确筛选及互斥,显示可清除来源摘要,固定本地返回 | 库存时效到期阻止严格下钻;返回恢复趋势范围、版本、滚动;待人工截图确认 |
| /self-operated/sales | 仓库、SKU、日期/榜单查询及分页 | 当前截图:货主控件分散筛选;本轮冻结为单一紧凑筛选区,仓库明确提示必须选择,SKU为主 | 复用 AdminPage、QueryToolbar、DataTable、高密度样式;日期/榜单切换和分页保持版本;待人工截图确认 |
@@ -58,7 +62,26 @@
| --- | --- | --- | --- |
| /self-operated/collection | 查询与完整任务号定位 | 原 QueryToolbar 增加采集内容与完整任务号;按仓库直接读取受权限约束的任务详情,同步 `warehouseCode,taskNo`,不靠翻历史列表定位 | 不跨权限搜索;不存在、权限拒绝、首次读取失败沿服务端错误呈现;待人工1920×1080、100%截图确认 |
| /self-operated/collection | 当前关注与活动切换 | 筛选区下先展示关注任务及完整编号;默认只选服务端 `activeTasks`,手动选择和历史任务持续保留,提供返回当前活动;账号其他活动单列,不借作本任务来源 | 已确认 `progressSourceTaskNo` 才消费对应共享上游;来源数量不跨仓累加;待人工截图确认 |
| /self-operated/collection | 环图、阶段和结果 | 当前阶段SVG环图与总量/完成/剩余共用区域;未知、零量、异常或变化分母撤去比例。出库五阶段、库存四阶段各自展示,阶段完成只依据同身份结束证据;本次发布与当前可用报表独立 | 不用步骤序号或数量100%推定发布,不把来源单数当SKU销量;待人工截图确认 |
| /self-operated/collection | 环图、阶段和结果 | 当前阶段ECharts环图与总量/完成/剩余共用区域;未知、零量、异常或变化分母撤去比例。出库五阶段、库存四阶段各自展示,阶段完成只依据同身份结束证据;本次发布与当前可用报表独立 | 不用步骤序号或数量100%推定发布,不把来源单数当SKU销量;待人工截图确认 |
| /self-operated/collection | 阶段速度与预计时间 | 仅显示服务端当前阶段估算,向上取整为分钟;同时展示读取、推进、心跳时钟,阶段耗时不使用尝试总耗时替代 | ETA过期、读取失败、隐藏、计数不一致撤销估算;不可估算原因明确说明;不推算全任务ETA;待人工截图确认 |
| /self-operated/collection | 历史与详情 | 历史压缩为操作、任务/范围、数据时间段、阶段进度、最近进展、执行/发布六列,默认10行。沿用1200px居中AppDialog及全屏,标题只出现一次完整任务号 | 无本仓尝试只显示摘要;执行记录和诊断默认折叠,尝试分页按需请求,轮询不重置分页;待人工截图确认 |
| /self-operated/collection | 刷新与恢复 | 一个运行轮询器,活动/等待10秒、闲置60秒,隐藏和离页停止,恢复立即读;旧响应取消与身份校验。配置仍为独立只读弹框;恢复表单仅独立sync权限且allowedActions允许、无阻断时出现 | 状态更新失败保留观测数量、不判采集失败;恢复沿原RETRY及幂等确认,仅提示已排队;自动测试与人工视觉分别记录 |
## Top10 展示与完整明细(2026-10-13 用户截图)
用户最新截图要求图表底部操作移至卡片右上角并使用 link 按钮。出库趋势、库存分布和三榜统一标题栏;明细、分组说明、待评估原因及查看全部均使用文字链接,移除底部操作区及重复辅助说明。弹框宽度、根部挂载、刷新关闭与原下钻权限保持,长标题下操作区允许自然换行且右对齐;待人工1920×1080、100%截图确认。
| 页面/操作 | 截图与当前实现 | 本轮结论 | 验证 |
| --- | --- | --- | --- |
| 经营总览/两个库存榜单空榜原因 | 用户截图显示符合条件0、待评估2711/2741;用户要求修改后自行截图 | 在两个库存榜单内提供“采集覆盖与待评估原因”,沿用1040px居中ChartDetailsDialog;显示当前查询的仓库、覆盖区间、库存时间/状态及全仓库存分布原因计数,明确非两榜各自排除明细、只含首个命中原因;保持计算与下钻条件 | 不新增请求,不混入轮询版本;保留未知和历史提示,刷新关闭旧弹框;待人工1920×1080、100%截图确认 |
| 页面/操作 | 截图与当前实现 | 本轮结论 | 验证 |
| --- | --- | --- | --- |
| 经营总览/排行榜 | 用户截图中长悬浮说明越界,标签拥挤;现为 ECharts 横条 | 保留 ECharts,压缩悬浮摘要、限制宽度并换行;名次独立强调、保留统一数值轴 | 待人工1920×1080、100%截图确认 |
| 经营总览/查看完整名称、数值与明细 | 当前为卡片内 details 展开;用户要求弹框 | 使用1040px居中 AppDialog,完整名称、数值、说明换行展示;下钻先关闭弹框,刷新数据关闭旧明细 | 自动交互回归;待人工截图确认 |
| 查看Top100/查看全部 | 当前跳转对应列表 | 保留原列表入口与筛选上下文,完整明细弹框仅展示当前图中的数据 | 原下钻合同不变 |
| 经营总览/每日数值、库存分组原因、SKU趋势覆盖证据 | 用户要求移除图表后方的内嵌展开 | 改为根部挂载的居中 AppDialog:数值/证据1040px,分组说明760px;保留未知、覆盖原因和原日期下钻资格 | 定向测试通过;待人工截图确认 |
| 图表完整明细遮罩与定位 | 用户截图显示被卡片裁剪 | 所有新增图表弹框 append-to-body;不受卡片 transform 和滚动区域限制 | 待人工1920×1080、100%截图确认 |
| 热销Top10/完整明细字段去重 | 用户截图中SKU、货主、出库量重复 | 热销专用1280px弹框表格,身份与各指标独立列,统计区间与覆盖提醒移至表头上方;其他榜单字段保留 | 同SKU不同货主、零/未知值、下钻回归;待人工截图确认 |
@@ -1,5 +1,6 @@
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import CollectionAccountMonitor from '../components/CollectionAccountMonitor.vue'
import type { Overview, WarehouseOverview } from '../types'
@@ -1,3 +1,4 @@
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { defineComponent, h, reactive } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -16,6 +17,16 @@ function page() { const w = mount(CollectionView, { global: { stubs: { AdminPage
beforeEach(() => { vi.clearAllMocks(); mocks.allowed = true; mocks.route = reactive({ query: { warehouseCode: 'US01', taskNo: 'OLD-HISTORY' } }); mocks.overview.mockResolvedValue(overview) })
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers(); Object.defineProperty(document, 'hidden', { configurable: true, value: false }) })
describe('account-only collection page', () => {
it('keeps the last successful progress visible while a manual refresh is pending', async () => {
const w = page(); await flushPromises()
let finish!: (value: Overview) => void
mocks.overview.mockImplementationOnce(() => new Promise(resolve => { finish = resolve }))
await w.findAll('button').find(b => b.text().includes('刷新状态'))!.trigger('click')
expect(w.text()).toContain('12.1%')
expect(w.text()).toContain('2448')
finish(overview); await flushPromises()
expect(w.text()).toContain('12.1%')
})
it('ignores old warehouse/history links and only reads the account overview', async () => {
const w = page(); await flushPromises()
expect(mocks.overview.mock.calls[0]?.slice(0, 2)).toEqual(['lingxing-primary', undefined])
@@ -1,3 +1,4 @@
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { defineComponent, h } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -14,7 +15,7 @@ vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({
const passthrough = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const query = defineComponent({ props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], setup(_, { emit }) { return () => h('button', { onClick: () => emit('search') }, '查询') } })
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
const dialog = defineComponent({ props: ['modelValue', 'width'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', [h('button', { onClick: () => undefined }, '趋势弹框'), slots.default?.()]) : null } })
const dialog = defineComponent({ props: ['modelValue', 'width', 'title', 'appendToBody'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', [h('button', { onClick: () => undefined }, '趋势弹框'), slots.default?.()]) : null } })
const error = defineComponent({ props: ['message'], setup(props) { return () => h('p', props.message) } })
const popover = defineComponent({ props: ['trigger', 'placement', 'title', 'width'], setup(_, { slots }) { return () => h('div', slots.reference?.()) } })
const options = { global: { directives: { loading: () => undefined }, stubs: { AdminPage: passthrough, QueryToolbar: query, AppDialog: dialog, LoadErrorState: error, ApiErrorMeta: true, SkuTrend: true, 'el-button': button, 'el-popover': popover, 'el-table-column': true, 'el-table': true, 'el-option': true, 'el-select': true } } }
@@ -31,6 +32,19 @@ beforeEach(() => {
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() })
describe('warehouse operating dashboard', () => {
it('offers coverage evidence for both inventory rankings even when they are empty', async () => {
const data = structuredClone(dashboardFixture)
data.longCoverageSkus = { ...data.longCoverageSkus, items: [], eligibleCount: '0', unassessableCount: '2711', dataStatus: 'PARTIAL' }
data.noOutboundSkus = { ...data.noOutboundSkus, items: [], eligibleCount: '0', unassessableCount: '2741', dataStatus: 'PARTIAL' }
mocks.dashboard.mockResolvedValue(data)
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
expect(wrapper.findAll('.dashboard-chart-actions button').filter(node => node.text() === '待评估原因')).toHaveLength(2)
await findButton(wrapper, '待评估原因').trigger('click'); await flushPromises()
expect(wrapper.text()).toContain('全仓库存分布的待核验原因')
expect(wrapper.text()).toContain('待评估 2711 SKU')
expect(wrapper.text()).toContain('待评估 2741 SKU')
expect(mocks.dashboard).toHaveBeenCalledOnce()
})
it('uses one date range field, blocks incomplete ranges and clears obsolete validation errors', async () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A', rangePreset: 'CUSTOM' })
const toolbar = wrapper.findComponent(query)
@@ -201,8 +215,9 @@ describe('warehouse operating dashboard', () => {
const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A' })
wrapper.findAllComponents(DashboardBars)[1]!.vm.$emit('select', '1'); await flushPromises()
expect(mocks.daily.mock.calls[0]?.[0]).toMatchObject({ customerCode: 'OWNER-B', sku: 'SKU-X', rangePreset: 'CUSTOM', startDate: '2026-09-13', endDate: '2026-10-12', salesRunId: '11' })
expect(wrapper.findComponent(dialog).props('width')).toBe('1120px')
wrapper.findComponent(dialog).vm.$emit('update:modelValue', false); await flushPromises()
expect(wrapper.findAllComponents(dialog).find(item => item.props('title') === 'SKU每日出库趋势')!.props('width')).toBe('1120px')
for (const modal of wrapper.findAllComponents(dialog)) expect(modal.props('appendToBody')).not.toBeUndefined()
wrapper.findAllComponents(dialog).find(item => item.props('title') === 'SKU每日出库趋势')!.vm.$emit('update:modelValue', false); await flushPromises()
finish({ context: dashboardFixture.context, summary: { ...dashboardFixture.salesTrend.summary, sku: 'LATE-SKU' }, points: [] }); await flushPromises()
expect(mocks.daily.mock.calls[0]?.[1].aborted).toBe(true)
expect(wrapper.text()).not.toContain('LATE-SKU')
@@ -234,10 +249,11 @@ describe('warehouse operating dashboard', () => {
wrapper.findAllComponents(DashboardBars)[0]!.vm.$emit('select', 'DAYS_GT_90'); await flushPromises()
expect(mocks.push.mock.calls.at(-1)?.[0]).toMatchObject({ path: '/self-operated/inventory', query: { coverageBucket: 'DAYS_GT_90', bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1', from: 'overview' } })
const chart = wrapper.findComponent(DashboardTrend)
await chart.findAll('g')[1]!.trigger('keydown', { key: 'Enter' })
await chart.find('.chart-disclosure button').trigger('click')
await chart.findAll('tbody button')[1]!.trigger('click')
expect(mocks.push).toHaveBeenCalledTimes(1)
expect(chart.text()).toContain('销量未覆盖')
await chart.findAll('g')[0]!.trigger('keydown', { key: 'Enter' })
await chart.findAll('tbody button')[0]!.trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'SKU', rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' })
await findButton(wrapper, '查看Top100').trigger('click')
expect(mocks.push.mock.calls.at(-1)?.[0].query).toMatchObject({ analysis: 'HOT', rangePreset: 'CUSTOM', startDate: '2026-09-13', endDate: '2026-10-12' })
@@ -15,6 +15,9 @@ 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, validateCustomRange } from '../state'
import { buckets, expired, inventoryExpired, parseContext, parseOverviewReturn, previousDate, rangeOptions, type OverviewReturn } from '../dashboardNavigation'
import HotSkuDetails from '../components/HotSkuDetails.vue'
import InventoryRankingDiagnostics from '../components/InventoryRankingDiagnostics.vue'
import ChartDetailsDialog from '../components/ChartDetailsDialog.vue'
import DashboardBars, { type BarRow } from '../components/DashboardBars.vue'
import DashboardTrend from '../components/DashboardTrend.vue'
import SkuTrend from '../components/SkuTrend.vue'
@@ -197,14 +200,42 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<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>{{ quantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong><span> {{ metric.key === 'averageDailyOutboundQuantity30d' ? '件/日' : 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="data.kpis[metric.key].unknownSkuCount != null">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small>{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
</div>
<div class="dashboard-main-charts">
<section class="dashboard-chart"><h3>出库趋势</h3><p>{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)</p><DashboardTrend :points="data.salesTrend.points" :disabled="contextExpired" @select="date => drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: date, endDate: date })" /></section>
<section class="dashboard-chart"><h3>库存消化天数分布</h3><p>有实物库存或库存待核验 SKU;{{ data.context.fixedWindowStartDate }} 至 {{ data.context.fixedWindowEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p><p v-if="data.coverageDistribution.candidateSkuCount == null">无可用库存分布资料;销售候选不作为库存总量。</p><DashboardBars v-else :rows="bucketRows" title="库存消化天数分布" unit="SKU" :disabled="stockExpired || contextExpired" @select="code => drill('inventory', { coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' })" /><details><summary>分组说明与待核验原因</summary><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></details></section>
<section class="dashboard-chart">
<DashboardTrend :points="data.salesTrend.points" :disabled="contextExpired" @select="date => drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: date, endDate: date })">
<template #intro><p>{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)</p></template>
</DashboardTrend>
</section>
<section class="dashboard-chart">
<DashboardBars :rows="data.coverageDistribution.candidateSkuCount == null ? [] : bucketRows" title="库存消化天数分布" unit="SKU" :disabled="stockExpired || contextExpired" @select="code => drill('inventory', { coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' })">
<template #actions>
<ChartDetailsDialog inline button-label="分组说明" title="分组说明与待核验原因" width="760px" :reset-on="data"><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></ChartDetailsDialog>
</template>
<template #intro><p>有实物库存或库存待核验 SKU;{{ data.context.fixedWindowStartDate }} 至 {{ data.context.fixedWindowEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p></template>
<template #empty><p>无可用库存分布资料;销售候选不作为库存总量。</p></template>
</DashboardBars>
</section>
</div>
<div class="dashboard-rankings">
<section v-for="ranking in rankingNames" :key="ranking.key" class="dashboard-chart"><div class="dashboard-ranking-heading"><h3>{{ ranking.label }}</h3><el-button :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button></div><p>{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。</p><p>{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}</p><p v-if="ranking.key === 'hotSkus' && data.hotSkus.dataStatus !== 'COMPLETE'">按所选时段已采集量排名,数据尚未采齐,排名可能随采集变化;未采集日期不计为零出库。</p><p v-else-if="ranking.key !== 'hotSkus' && data[ranking.key].dataStatus !== 'COMPLETE'">本榜仍需完整近30日销量与库存观察资料;资料不足的 SKU 暂不评估,未采集日期不计为零出库。</p><DashboardBars ranked :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])" /></section>
<section v-for="ranking in rankingNames" :key="ranking.key" class="dashboard-chart">
<DashboardBars ranked :details-width="ranking.key === 'hotSkus' ? '1280px' : undefined" :rows="rankingRows(data[ranking.key])" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!, data![ranking.key])">
<template #actions>
<ChartDetailsDialog v-if="ranking.key !== 'hotSkus'" inline button-label="待评估原因" title="采集覆盖与待评估原因" width="1040px" :reset-on="data">
<InventoryRankingDiagnostics :data="data" :warehouse="runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode" :historical="contextExpired || stockExpired" />
</ChartDetailsDialog>
<el-button link type="primary" :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button>
</template>
<template #intro>
<p>{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。</p>
<p>{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}</p>
<p v-if="ranking.key === 'hotSkus' && data.hotSkus.dataStatus !== 'COMPLETE'">按所选时段已采集量排名,数据尚未采齐,排名可能随采集变化;未采集日期不计为零出库。</p>
<p v-else-if="ranking.key !== 'hotSkus' && data[ranking.key].dataStatus !== 'COMPLETE'">本榜仍需完整近30日销量与库存观察资料;资料不足的 SKU 暂不评估,未采集日期不计为零出库。</p>
</template>
<template v-if="ranking.key === 'hotSkus'" #details="{ select }"><HotSkuDetails :ranking="data.hotSkus" :disabled="contextExpired" @select="index => select(rankingRows(data!.hotSkus)[index]!)" /></template>
</DashboardBars>
</section>
</div>
</template>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable><div class="admin-density"><p v-if="trendError" role="alert">{{ trendError }}</p><SkuTrend v-if="trend" :data="trend" /></div></AppDialog>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close><div class="admin-density"><p v-if="trendError" role="alert">{{ trendError }}</p><SkuTrend v-if="trend" :data="trend" /></div></AppDialog>
</template>
</AdminPage>
</template>
@@ -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; }
@@ -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)
})
})
@@ -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<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' },
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,
}
}
+2 -11
View File
@@ -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('账号共享')
+2 -21
View File
@@ -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<string, string> = {
}
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<string, string> = { 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<string, string> = { 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
}
+3 -1
View File
@@ -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<Progress> | null
}
export interface Task {
createdAt?: string | null
elapsedSeconds?: Quantity
stageEstimate?: StageEstimate | null
dependencyState?: 'NONE' | 'WAITING_CONFIRMED' | 'WAITING_UNCONFIRMED' | 'SATISFIED' | 'FAILED'
blockingReason?: string | null