feat: focus collection page on account activity and progress

This commit is contained in:
2026-10-13 16:52:09 +08:00
parent 1de73b5f1c
commit bda0743520
14 changed files with 695 additions and 166 deletions
@@ -71,9 +71,12 @@ const tab = computed(() => (filters.value.analysis || 'summary') as 'summary' |
const windowDays = computed(() => Number(filters.value.windowDays || 30) as 7 | 30)
const sourceAccount = 'lingxing-primary'
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
let selectedWarehouse = warehouseCode.value
function defaultWarehouse() { return overview.value?.warehouses.find(row => row.warehouseCode)?.warehouseCode ?? '' }
watch(warehouseCode, code => { if (code) selectedWarehouse = code })
const fields = computed<QueryField[]>(() => {
const result: QueryField[] = [
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 210, placeholder: '请选择仓库(必选)', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] },
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 210, placeholder: '请选择仓库(必选)', props: { clearable: false }, options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] },
{ key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
]
if (props.mode === 'inventory') result.push(
@@ -182,6 +185,7 @@ async function readRuntime() {
const result = await analyticsApi.overview(sourceAccount, undefined, request.signal)
if (!request.current()) return false
overview.value = result
if (!warehouseCode.value && !routeProblem.value) filters.value.warehouseCode = defaultWarehouse()
runtimeError.value = ''
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date())
newDay.value = Boolean(context.value && context.value.anchorDate !== today)
@@ -225,7 +229,13 @@ function search() {
current.value = 1
void load(true, keepContext)
}
function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } }
async function reset() {
applyingRoute = true
drilldown.value = null; origin.value = null
filters.value = { ...initialFilters(), warehouseCode: warehouseCode.value || selectedWarehouse || defaultWarehouse(), sku: '' }
appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }
await nextTick(); applyingRoute = false; void load(true)
}
function returnOverview() {
const saved = origin.value
void router.push({ path: '/self-operated/overview', query: saved ? { warehouseCode: warehouseCode.value, context: JSON.stringify(saved.context), overviewReturn: JSON.stringify(saved.returnTo) } : {} })
@@ -254,6 +264,7 @@ watch(() => route.query, async () => {
if (route.path && route.path !== `/self-operated/${props.mode}`) return
applyingRoute = true; dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; rows.value = []; total.value = 0
readDrilldown(); context.value = drilldown.value?.context ?? null; filters.value = initialFilters(); appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: String(filters.value.sku ?? '') }; appliedInventory.value = { attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' }; current.value = 1
if (!warehouseCode.value && !routeProblem.value) filters.value.warehouseCode = defaultWarehouse()
await nextTick(); applyingRoute = false; void load()
})
onActivated(() => { visibility(); poller.start() })
@@ -0,0 +1,43 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import CollectionAccountMonitor from './CollectionAccountMonitor.vue'
import CollectionTaskProgress from './CollectionTaskProgress.vue'
import type { Overview, Task, WarehouseOverview } from '../types'
const child: Task = { taskNo: 'CHILD', warehouseCode: 'US01', dataFamily: 'OUTBOUND', taskStatus: 'WAITING_SOURCE', allowedActions: [], progress: null, taskType: 'BACKFILL', queryFrom: null, queryUntil: null, applyState: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, actionBlockReason: null }
const source: Task = { ...child, taskNo: 'SOURCE', warehouseCode: '', taskStatus: 'RUNNING', progress: { phase: 'DETAIL', progressScope: 'ACCOUNT_SHARED', countBasis: 'TASK_CUMULATIVE', completed: 339, total: 2787, totalKnown: true, unit: 'ORDER' } }
function overview(account: Task | null = source): Overview {
return { sourceAccount: 'lingxing-primary', collectionMode: 'BUSINESS', environmentLabel: 'TEST', observedAt: '2026-10-13T08:00:00Z', accountActivity: account,
warehouses: [{ warehouseCode: 'US01', dataFamily: 'OUTBOUND', activeTasks: [child], sharedUpstream: account }, { warehouseCode: 'US02', dataFamily: 'OUTBOUND', activeTasks: [{ ...child, taskNo: 'CHILD-2', warehouseCode: 'US02' }], sharedUpstream: account }] as WarehouseOverview[] }
}
const options = { global: { stubs: { 'el-button': true } } }
describe('single account collection monitor', () => {
it('shows account work once despite two waiting warehouses and keeps the phase counts', () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview() } })
expect(w.findAllComponents(CollectionTaskProgress)).toHaveLength(1)
expect(w.findComponent(CollectionTaskProgress).props('task').taskNo).toBe('SOURCE')
expect(w.text()).toContain('12.1%'); expect(w.text()).toContain('2448')
expect(w.text()).toContain('US01 · 出库销量'); expect(w.text()).toContain('US02 · 出库销量')
expect(w.text()).toContain('完整队列数量及下一任务顺序尚未提供')
expect(w.text()).not.toContain('查看配置'); expect(w.text()).not.toContain('执行心跳')
w.unmount()
})
it('moves on when account work ends and does not present historical completion as running', async () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview() } })
const next = overview({ ...source, taskStatus: 'SUCCEEDED' })
const inventory = { ...child, taskNo: 'STOCK', warehouseCode: 'US02', dataFamily: 'INVENTORY', taskStatus: 'RUNNING' }
next.warehouses[1]!.activeTasks = [inventory]
await w.setProps({ overview: next })
expect(w.findComponent(CollectionTaskProgress).props('task').taskNo).toBe('STOCK')
await w.setProps({ overview: { ...next, warehouses: [] } })
expect(w.text()).toContain('当前没有正在运行的采集')
expect(w.findComponent(CollectionTaskProgress).exists()).toBe(false)
w.unmount()
})
it('preserves real last counts when stale without claiming an estimate', () => {
const w = mount(CollectionAccountMonitor, { ...options, props: { overview: overview(), stale: true } })
expect(w.text()).toContain('339'); expect(w.text()).toContain('状态更新失败')
expect(w.text()).toContain('已暂停估算')
w.unmount()
})
})
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Overview, Task } from '../types'
import CollectionTaskProgress from './CollectionTaskProgress.vue'
import { beijingTime, phaseText } from '../state'
import { taskStateText } from '../collectionPresentation'
const props = defineProps<{ overview: Overview | null; loading?: boolean; stale?: boolean; visible?: boolean }>()
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.sharedUpstream]) ?? [])]
return [...new Map(all.filter((t): t is Task => Boolean(t && activeStates.has(t.taskStatus))).map(t => [t.taskNo, t])).values()]
})
const running = computed(() => tasks.value.filter(t => ['RUNNING', 'APPLYING'].includes(t.taskStatus)))
const current = computed(() => running.value[0] ?? tasks.value[0] ?? null)
const waiting = computed(() => tasks.value.filter(t => t.taskNo !== current.value?.taskNo && !['RUNNING', 'APPLYING'].includes(t.taskStatus)))
const upcoming = computed(() => {
const groups = new Map<string, number>()
for (const task of waiting.value) {
const name = `${task.warehouseCode || '账号'} · ${task.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量'}`
groups.set(name, (groups.get(name) ?? 0) + 1)
}
return [...groups].map(([name, count]) => ({ name, count }))
})
const nextStep = computed(() => {
const phase = current.value?.progress?.phase
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 '发布结果'
if (['PUBLISH', 'PUBLISHED'].includes(phase ?? '')) return '本次发布完成后,由调度继续安排后续任务'
return '等待调度确认下一步'
})
const scopeText = computed(() => current.value?.taskNo === props.overview?.accountActivity?.taskNo || current.value?.progress?.progressScope === 'ACCOUNT_SHARED' ? '领星账号 · 出库数据' : `${current.value?.warehouseCode || '领星账号'} · ${current.value?.dataFamily === 'INVENTORY' ? '产品库存' : '出库销量'}`)
</script>
<template>
<section class="account-monitor" aria-label="领星账号运行进度">
<header class="account-header"><div><h2>数据采集</h2><span>领星账号</span></div><el-button :loading="loading" @click="$emit('refresh')">刷新状态</el-button></header>
<div class="account-body">
<p v-if="stale" role="status">状态更新失败,以下保留最后一次读取的进度。</p>
<template v-if="current">
<div class="account-now"><div><span class="account-eyebrow">{{ running.length ? '正在进行' : '当前等待' }}</span><h3>{{ current.progress?.phase ? phaseText(current.progress.phase) : taskStateText(current) }}</h3><p>{{ scopeText }}<template v-if="current.queryFrom"> · {{ beijingTime(current.queryFrom) }} 至 {{ beijingTime(current.queryUntil) }}(结束时刻不含)</template></p></div><span class="account-status">{{ taskStateText(current) }}</span></div>
<CollectionTaskProgress :key="current.taskNo" :task="current" compact :stale="stale" :visible="visible" :observed-at="overview?.observedAt" />
<div class="account-next"><strong>后续处理</strong><span>{{ nextStep }}<small>流程参考,实际切换由调度确认。</small></span></div>
<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"><h3>{{ loading ? '正在读取采集状态…' : overview ? '当前没有正在运行的采集' : '尚未读取到采集状态' }}</h3><p>{{ overview?.collectionMode === 'OFF' ? '采集已关闭,已有报表仍可查看。' : '运行任务会在这里自动更新。' }}</p></div>
<div v-if="overview" class="account-pending"><strong>已知待处理</strong><span v-for="group in upcoming" :key="group.name">{{ group.name }} <b>{{ group.count }}</b> 项</span><span v-if="!upcoming.length">当前摘要中暂无其他待处理项</span><small>仅当前活动摘要;完整队列数量及下一任务顺序尚未提供。</small></div>
<footer><span>最近推进 {{ beijingTime(current?.progressUpdatedAt) }}</span><span>状态更新 {{ beijingTime(overview?.observedAt) }} · 北京时间</span></footer>
</div>
</section>
</template>
<style scoped>
.account-monitor { background: var(--admin-surface, #fff); border: 1px solid var(--el-border-color-light); border-radius: var(--admin-radius); overflow: hidden; }
.account-header, .account-header > div, .account-now, .account-next, .account-pending, footer { display: flex; align-items: center; gap: var(--admin-space-4); }
.account-header { justify-content: space-between; padding: var(--admin-space-5) var(--admin-space-6); border-bottom: 1px solid var(--el-border-color-light); }
h2, h3, p { margin: 0; }
h2 { font-size: var(--admin-font-section); }
h3 { font-size: 24px; margin: var(--admin-space-2) 0; }
.account-body { max-width: 1180px; margin-inline: auto; padding: var(--admin-space-6); }
.account-header span, p, footer, small { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
.account-now { justify-content: space-between; align-items: flex-start; }
.account-eyebrow { color: var(--el-color-primary); font-size: var(--admin-font-caption); }
.account-status { color: var(--el-color-primary); background: var(--el-color-primary-light-9); padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); white-space: nowrap; }
.account-next { padding: var(--admin-space-4); background: var(--el-fill-color-light); border-left: 3px solid var(--el-color-primary); }
.account-next small { display: block; margin-top: var(--admin-space-2); }
.account-pending { flex-wrap: wrap; margin-top: var(--admin-space-5); }
.account-pending small { flex-basis: 100%; }
footer { justify-content: space-between; margin-top: var(--admin-space-5); padding-top: var(--admin-space-4); border-top: 1px solid var(--el-border-color-light); }
.account-empty { padding-block: var(--admin-space-6); }
</style>
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
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 })
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
const done = Number(p.completed), total = Number(p.total)
if (!Number.isSafeInteger(done) || !Number.isSafeInteger(total) || done < 0 || total < done || total <= 0) return null
return { remaining: total - done, percent: Number(BigInt(done) * 1000n / BigInt(total)) / 10 }
})
const scope = computed(() => props.progress?.progressScope === 'ACCOUNT_SHARED' ? '账号共享' : props.progress?.progressScope === 'WAREHOUSE' ? '本仓' : '范围待确认')
</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>
<div class="ring-label"><strong>{{ counts ? `${counts.percent.toFixed(1)}%` : '—' }}</strong><span>{{ stale ? '上次阶段记录' : '当前阶段' }}</span></div>
</div>
<div>
<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>
<p v-if="changedTotal">总量发生变化,正在复核;暂不展示比例。</p>
<p v-else-if="!counts">{{ progress?.totalKnown && Number(progress.total) === 0 ? '本阶段数量为零;是否完成以阶段及发布证据为准。' : '总量或计数依据待确认,不估算完成比例。' }}</p>
<p v-if="!compact && scope === '账号共享'">来源账号的阶段数量,不能当作本仓出库量或整个同步完成率。</p>
<p v-if="!compact && (progress?.sliceFrom || progress?.sliceUntil)">当前切片:{{ beijingTime(progress?.sliceFrom) }} 至 {{ beijingTime(progress?.sliceUntil) }}(结束边界不含)</p>
</div>
</section>
</template>
<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; }
.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; }
dt, small, .ring-label span { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
dd { margin: var(--admin-space-2) 0 0; font-size: 30px; font-variant-numeric: tabular-nums; }
p { margin: var(--admin-space-2) 0; font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,82 @@
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import CollectionTaskProgress from './CollectionTaskProgress.vue'
import CollectionProgressGauge from './CollectionProgressGauge.vue'
import type { Task, TaskDetail, StageEstimate, WarehouseOverview } from '../types'
const task: Task = { taskNo: 'TASK-W', warehouseCode: 'W', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'WAITING_SOURCE', dependencyState: 'WAITING_CONFIRMED', progressSourceTaskNo: 'TASK-S', queryFrom: null, queryUntil: null, applyState: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null }
const progress = { phase: 'DETAIL', progressScope: 'ACCOUNT_SHARED', countBasis: 'TASK_CUMULATIVE', attemptNo: 2, scanRound: 1, completed: 339, total: 2787, totalKnown: true, unit: 'ORDER', sliceFrom: null, sliceUntil: null, progressUpdatedAt: '2026-10-13T01:01:00Z' }
const source: Task = { ...task, taskNo: 'TASK-S', taskStatus: 'RUNNING', dependencyState: 'NONE', progressSourceTaskNo: null, attemptCount: 2, progress }
const detail = { task, sharedUpstream: source, observedAt: '2026-10-13T01:02:00Z', protection: {} } as TaskDetail
const wrappers: ReturnType<typeof mount>[] = []
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
describe('collection task progress evidence', () => {
it('uses only a confirmed source and does not infer earlier stage completion from the current phase', async () => {
const w = mount(CollectionTaskProgress, { props: { task, detail } }); wrappers.push(w)
expect(w.text()).toContain('TASK-S'); expect(w.text()).toContain('12.1%'); expect(w.text()).toContain('2448')
expect(w.text()).toContain('账号共享'); expect(w.findAll('li')[0]?.text()).toContain('状态未确认')
expect(w.text()).not.toContain('✓')
await w.setProps({ task: { ...task, dependencyState: 'WAITING_UNCONFIRMED', progressSourceTaskNo: null } })
expect(w.text()).toContain('关联进度尚未确认'); expect(w.text()).not.toContain('339'); expect(w.text()).not.toContain('12.1%')
})
it('accepts only explicit matching stage completion and keeps publication independent', () => {
const w = mount(CollectionTaskProgress, { props: { task, detail: { ...detail, sharedUpstream: { ...source, progress: { ...progress, stages: [{ ...progress, phase: 'DIRECTORY', stageFinishedAt: '2026-10-13T01:00:00Z' }, { ...progress, phase: 'PUBLISH', stageFinishedAt: '2026-10-13T01:00:00Z' }] } } } } }); wrappers.push(w)
expect(w.findAll('li')[0]?.text()).toContain('✓ 已结束')
expect(w.findAll('li')[4]?.text()).not.toContain('✓')
expect(w.text()).toContain('报表可用状态待确认')
})
it('retains confirmed source evidence after dependency failure without borrowing its current count', () => {
const w = mount(CollectionTaskProgress, { props: { task: { ...task, dependencyState: 'FAILED', progressSourceTaskNo: task.taskNo, blockingReason: 'UPSTREAM_FAILED' }, detail } }); wrappers.push(w)
expect(w.text()).toContain('已确认关联来源:TASK-S')
expect(w.text()).toContain('关联来源失败,等待恢复')
expect(w.text()).not.toContain('12.1%')
})
it('treats COUNT_COMPLETE including zero of zero only as completed counts', () => {
const stageEstimate: StageEstimate = { status: 'UNAVAILABLE', progressIdentity: 'zero-count', completed: '0', total: '0', sampleFrom: null, sampleUntil: null, completedDelta: null, ratePerSecond: null, remainingSeconds: null, estimatedAt: null, validUntil: null, unavailableReason: 'COUNT_COMPLETE' }
const w = mount(CollectionTaskProgress, { props: { task, visible: true, detail: { ...detail, sharedUpstream: { ...source, progress: { ...progress, completed: 0, total: 0 }, stageEstimate } } } }); wrappers.push(w)
expect(w.text()).toContain('计数已齐,等待阶段确认')
expect(w.text()).not.toContain('✓ 已结束')
expect(w.text()).not.toContain('预计还需')
expect(w.find('[role="progressbar"]').exists()).toBe(false)
})
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', '估算依据待确认']]) {
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('正在测速')
})
it('distinguishes unobserved warehouse metadata from a confirmed missing publication', async () => {
const w = mount(CollectionTaskProgress, { props: { task, detail } }); wrappers.push(w)
expect(w.text()).toContain('报表可用状态待确认')
expect(w.text()).not.toContain('尚无可用数据')
await w.setProps({ warehouse: { lastPublishedAt: null } as WarehouseOverview })
expect(w.text()).toContain('尚无可用数据')
expect(w.text()).not.toContain('报表可用状态待确认')
await w.setProps({ warehouse: { lastPublishedAt: '2026-10-13T01:00:00Z' } as WarehouseOverview })
expect(w.text()).toContain('已发布数据')
expect(w.text()).not.toContain('尚无可用数据')
})
it('withdraws ETA when expired, count identity changes, hidden, or reads fail', async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-13T01:02:00Z'))
const stageEstimate: StageEstimate = { status: 'AVAILABLE', progressIdentity: 'source-attempt-2-detail-1', completed: 339, total: 2787, sampleFrom: '2026-10-13T01:00:00Z', sampleUntil: '2026-10-13T01:02:00Z', completedDelta: 60, ratePerSecond: 0.5, remainingSeconds: 4896, estimatedAt: '2026-10-13T01:02:00Z', validUntil: '2026-10-13T01:02:10Z', unavailableReason: null }
const d = { ...detail, sharedUpstream: { ...source, stageEstimate } }
const w = mount(CollectionTaskProgress, { props: { task, detail: d, visible: true } }); wrappers.push(w)
expect(w.text()).toContain('约82分钟')
await w.setProps({ stale: true }); expect(w.text()).not.toContain('约82分钟'); expect(w.text()).toContain('状态更新失败')
await w.setProps({ stale: false, visible: false }); expect(w.text()).not.toContain('约82分钟')
await w.setProps({ visible: true, detail: { ...d, sharedUpstream: { ...d.sharedUpstream, progress: { ...progress, completed: 340 } } } }); expect(w.text()).not.toContain('约82分钟')
await w.setProps({ detail: d }); await vi.advanceTimersByTimeAsync(10001); expect(w.text()).toContain('估算已过期'); expect(w.text()).not.toContain('约82分钟')
})
it('never paints a percentage for unknown, zero, invalid, or changing denominators', async () => {
const w = mount(CollectionProgressGauge, { props: { taskNo: source.taskNo, progress } }); wrappers.push(w)
expect(w.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('12.1')
await w.setProps({ progress: { ...progress, total: 3000 } }); expect(w.find('[role="progressbar"]').exists()).toBe(false); expect(w.text()).toContain('正在复核')
await w.setProps({ progress: { ...progress, attemptNo: 3, totalKnown: false } }); expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress: { ...progress, attemptNo: 4, total: 0, completed: 0 } }); expect(w.text()).toContain('本阶段数量为零'); expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress: { ...progress, attemptNo: 5, total: 1, completed: 2 } }); expect(w.find('[role="progressbar"]').exists()).toBe(false)
})
})
@@ -0,0 +1,95 @@
<script setup lang="ts">
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'
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 progress = computed(() => workingTask.value.progress)
const now = ref(Date.now())
let expiryTimer: ReturnType<typeof setTimeout> | undefined
watch(() => [workingTask.value.stageEstimate?.validUntil, props.visible], () => {
if (expiryTimer) clearTimeout(expiryTimer)
now.value = Date.now()
const until = Date.parse(workingTask.value.stageEstimate?.validUntil ?? '')
if (props.visible !== false && Number.isFinite(until) && until > now.value) expiryTimer = setTimeout(() => { now.value = Date.now() }, Math.min(until - now.value + 1, 2147483647))
}, { immediate: true })
onUnmounted(() => { if (expiryTimer) clearTimeout(expiryTimer) })
const estimate = computed(() => {
const e = workingTask.value.stageEstimate, p = progress.value
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 estimateText = computed(() => {
if (progress.value?.stageFinishedAt) return '本阶段已结束;后续计算与发布分别确认'
if (props.stale) return '状态更新失败,已暂停估算'
if (props.visible === false) return '页面已暂停更新,已暂停估算'
const e = workingTask.value.stageEstimate
if (estimate.value) return Number(estimate.value.remainingSeconds) < 60 ? '当前阶段预计还需约1分钟内' : `当前阶段预计还需约${Math.ceil(Number(estimate.value.remainingSeconds) / 60)}分钟`
if (e?.status === 'AVAILABLE') return '估算已过期或依据变化,等待重新估算'
if (e?.status === 'UNAVAILABLE' || e?.unavailableReason) return estimateReason.value
if (e?.status === 'PAUSED') return '已暂停估算'
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 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'] },
])
function stageState(node: { title: string; scope: string; phases: string[] }) {
const task = node.scope === '账号共享' ? confirmedSource.value : props.task
if (!task) return '状态未确认'
if (node.title === '结果发布' && task.resultPublicationState === 'PUBLISHED') return '✓ 已发布'
if (node.title === '结果发布' && task.resultPublicationState === 'REUSED') return '复用已有结果'
const p = task.progress
if (!p) return '状态未确认'
const matching = (stage: Partial<Progress>) => node.phases.includes(stage.phase ?? '') && stage.attemptNo != null && stage.attemptNo === p.attemptNo && stage.scanRound === p.scanRound && stage.sliceFrom === p.sliceFrom && stage.sliceUntil === p.sliceUntil && stage.progressScope === p.progressScope
if ([p, ...(p.stages ?? [])].some(stage => matching(stage) && stage.stageFinishedAt)) return '✓ 已结束'
if (node.phases.includes(p.phase ?? '') && ['RUNNING', 'APPLYING'].includes(task.taskStatus)) return '正在处理'
if (node.scope === '本仓' && ['WAITING_CONFIRMED', 'WAITING_UNCONFIRMED', 'FAILED'].includes(task.dependencyState ?? '')) return '等待来源'
return '状态未确认'
}
</script>
<template>
<section class="collection-progress" aria-label="关注任务进展">
<template v-if="!compact">
<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>
</template>
<p v-if="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>
<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>
<p>本次结果:{{ label(task.resultPublicationState) }}。当前报表:{{ !warehouse ? '报表可用状态待确认' : warehouse.lastPublishedAt ? `使用 ${beijingTime(warehouse.lastPublishedAt)} 已发布数据` : '尚无可用数据' }}。</p>
<p v-if="warehouse">{{ task.dataFamily === 'INVENTORY' ? '库存观测' : '销量连续覆盖' }}:{{ beijingTime(task.dataFamily === 'INVENTORY' ? warehouse.inventoryObservedFrom : warehouse.salesCoveredFrom) }} 至 {{ beijingTime(task.dataFamily === 'INVENTORY' ? warehouse.inventoryObservedUntil : warehouse.salesCoveredUntil) }};{{ label(warehouse.dataFreshness) }}。</p>
<p v-if="warehouse?.initialFrom || warehouse?.initialUntil">首采目标 {{ beijingTime(warehouse.initialFrom) }} 至 {{ beijingTime(warehouse.initialUntil) }}(结束边界不含);{{ label(warehouse.initialCoverageStatus) }}。单日完成不代表10/30日观察窗口已齐全。</p>
</template>
</section>
</template>
<style scoped>
.collection-progress { min-width: 0; padding-block: var(--admin-space-4); }
p { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
.collection-caption, small { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
.collection-estimate { display: flex; gap: var(--admin-space-4); flex-wrap: wrap; align-items: baseline; background: var(--el-fill-color-light); padding: var(--admin-space-3); margin-block: var(--admin-space-4); }
.collection-flow { display: grid; gap: var(--admin-space-4); list-style: none; padding: 0; margin: var(--admin-space-5) 0; }
.collection-flow li { display: grid; gap: var(--admin-space-2); border-top: 2px solid var(--el-border-color); padding-top: var(--admin-space-3); }
.collection-flow li[data-state^="✓"] { border-color: var(--el-color-success); }
.collection-flow li[data-state="正在处理"] { border-color: var(--el-color-primary); }
.collection-clocks { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: var(--admin-space-4); border-top: 1px solid var(--el-border-color); padding-top: var(--admin-space-4); }
.collection-clocks div { display: grid; gap: var(--admin-space-2); }
.collection-clocks strong { font-weight: 500; font-variant-numeric: tabular-nums; }
</style>