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
@@ -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>