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 windowDays = computed(() => Number(filters.value.windowDays || 30) as 7 | 30)
const sourceAccount = 'lingxing-primary' const sourceAccount = 'lingxing-primary'
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? '')) 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 fields = computed<QueryField[]>(() => {
const result: 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编码(精确查询)' }, { key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' },
] ]
if (props.mode === 'inventory') result.push( if (props.mode === 'inventory') result.push(
@@ -182,6 +185,7 @@ async function readRuntime() {
const result = await analyticsApi.overview(sourceAccount, undefined, request.signal) const result = await analyticsApi.overview(sourceAccount, undefined, request.signal)
if (!request.current()) return false if (!request.current()) return false
overview.value = result overview.value = result
if (!warehouseCode.value && !routeProblem.value) filters.value.warehouseCode = defaultWarehouse()
runtimeError.value = '' runtimeError.value = ''
const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()) 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) newDay.value = Boolean(context.value && context.value.anchorDate !== today)
@@ -225,7 +229,13 @@ function search() {
current.value = 1 current.value = 1
void load(true, keepContext) 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() { function returnOverview() {
const saved = origin.value 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) } : {} }) 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 if (route.path && route.path !== `/self-operated/${props.mode}`) return
applyingRoute = true; dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; rows.value = []; total.value = 0 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 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() await nextTick(); applyingRoute = false; void load()
}) })
onActivated(() => { visibility(); poller.start() }) 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>
@@ -4,6 +4,8 @@
| 页面 | 操作 | 依据与承载结论 | 布局与验证 | | 页面 | 操作 | 依据与承载结论 | 布局与验证 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| /self-operated/collection | 单账号运行首屏 | 2026-10-13 最新实际页面及原型截图:首屏改为账号活动优先,环形进度、总量/完成/剩余及阶段预计时间;历史任务定位不替换首屏,原筛选、配置、历史与诊断默认折叠 | 沿用浅色Admin样式与既有详情弹框;后续流程不冒充调度顺序,有界待处理摘要不冒充完整队列;待人工1920×1080、100%截图确认 |
| 销量/库存 | 默认仓库与重置 | 2026-10-13 用户确认进入页面即按仓库显示:无传入仓库时自动选中接口返回的首个可用仓库并查询;链接带入仓库优先;重置保留当前仓库并重新查询 | 保留切仓与迟到响应隔离;没有可用仓库不发无仓库查询;自动回归覆盖两页首次进入、链接进入、缓存页返回及重置 |
| /self-operated/sales | 自定义销量日期 | 2026-10-13 用户截图 `codex-clipboard-fac03bb1-6703-467d-b357-5731ea8cfb32.png`:两个单日框分散换行,未完整选择时残留逆序错误和旧结果。原筛选区改为公共QueryToolbar单一日期范围控件,明确开始/结束日期,支持同日;不新增浮层或改变统计口径 | 日期范围整体换行,校验完整范围后查询;草稿修改不改变已查询列表的分页/趋势条件,提交失败不冒充新范围结果;补充自动回归,待人工1920×1080、100%截图确认 | | /self-operated/sales | 自定义销量日期 | 2026-10-13 用户截图 `codex-clipboard-fac03bb1-6703-467d-b357-5731ea8cfb32.png`:两个单日框分散换行,未完整选择时残留逆序错误和旧结果。原筛选区改为公共QueryToolbar单一日期范围控件,明确开始/结束日期,支持同日;不新增浮层或改变统计口径 | 日期范围整体换行,校验完整范围后查询;草稿修改不改变已查询列表的分页/趋势条件,提交失败不冒充新范围结果;补充自动回归,待人工1920×1080、100%截图确认 |
| /self-operated/overview | 查询、刷新与六项指标 | `docs/自营业务/经营总览图表设计.md`“菜单与页面布局”“指标带”;单仓+趋势日期QueryToolbar、紧凑指标带,整体固定版本 | 不新增货主控件;未知/过期独立提示;待人工1920×1080截图确认 | | /self-operated/overview | 查询、刷新与六项指标 | `docs/自营业务/经营总览图表设计.md`“菜单与页面布局”“指标带”;单仓+趋势日期QueryToolbar、紧凑指标带,整体固定版本 | 不新增货主控件;未知/过期独立提示;待人工1920×1080截图确认 |
| /self-operated/overview | 日趋势、七桶与三个Top10 | 同设计“五张图表”;SVG折线与横条、趋势约2/3宽、分布约1/3宽,三榜并排 | 可聚焦提示、缺日断线、部分日文字、真实数值比例;待人工截图确认 | | /self-operated/overview | 日趋势、七桶与三个Top10 | 同设计“五张图表”;SVG折线与横条、趋势约2/3宽、分布约1/3宽,三榜并排 | 可聚焦提示、缺日断线、部分日文字、真实数值比例;待人工截图确认 |
@@ -42,7 +44,21 @@
| 首次加载/更新 | 骨架加载、指标和图形入场;减少动态效果偏好下禁用动画 | 原版本、关闭取消、过期提示及权限保持;待人工截图确认 | | 首次加载/更新 | 骨架加载、指标和图形入场;减少动态效果偏好下禁用动画 | 原版本、关闭取消、过期提示及权限保持;待人工截图确认 |
| 顶部区域/默认仓库 | 用户补充要求顶部提示紧凑且默认展示仓库;标题与操作合并一行,统计口径和采集时间默认折叠;首次自动加载接口首个仓库,深链指定仓库优先,重置保留仓库 | 保留过期/失败提示及切仓入口;不硬编码仓库;待人工截图确认 | | 顶部区域/默认仓库 | 用户补充要求顶部提示紧凑且默认展示仓库;标题与操作合并一行,统计口径和采集时间默认折叠;首次自动加载接口首个仓库,深链指定仓库优先,重置保留仓库 | 保留过期/失败提示及切仓入口;不硬编码仓库;待人工截图确认 |
| 统一搜索/切仓 | 2026-10-13 用户以客户账户截图要求统一标题和右侧操作栏;复用 integrated DataTable 的 toolbar-start/toolbar-actions/filters,关闭结果区,不增加空表;QueryToolbar 接入筛选布局设置。切仓取消旧请求、清旧图后自动加载新仓,重置仍保留当前仓 | 自动查询只读本地聚合接口;验证迟到响应不覆盖、加载与重置;待人工截图确认 | | 统一搜索/切仓 | 2026-10-13 用户以客户账户截图要求统一标题和右侧操作栏;复用 integrated DataTable 的 toolbar-start/toolbar-actions/filters,关闭结果区,不增加空表;QueryToolbar 接入筛选布局设置。切仓取消旧请求、清旧图后自动加载新仓,重置仍保留当前仓 | 自动查询只读本地聚合接口;验证迟到响应不覆盖、加载与重置;待人工截图确认 |
| 统计说明入口 | 用户最新截图要求统计口径与采集时间不占主屏;从独立折叠行移至右侧操作按钮旁的信息图标,点击打开460px说明气泡,保留窗口口径与本次查询采集时间 | 使用Element Plus Popover及可访问按钮名称;不隐藏真实过期/失败提示;待人工截图确认 |
## 数据采集样式统一(2026-10-12 当前截图确认) ## 数据采集样式统一(2026-10-12 当前截图确认)
用户确认销量、库存样式已完成,本轮以两页截图中的“顶部操作栏 → 灰底筛选带 → 提示区 → 表格”为样板,仅统一采集页。采集模式、合成样本声明及更新时间移入主表 notices,顶部保留查看销量/库存的跨页入口,未选仓库时禁用;概览与任务表之间保留单一标准间距。沿用既有配置弹框、任务内嵌详情和恢复确认,不修改采集接口、资格或状态机。修改后待人工1920×1080、100%截图确认。 用户确认销量、库存样式已完成,本轮以两页截图中的“顶部操作栏 → 灰底筛选带 → 提示区 → 表格”为样板,仅统一采集页。采集模式、合成样本声明及更新时间移入主表 notices,顶部保留查看销量/库存的跨页入口,未选仓库时禁用;概览与任务表之间保留单一标准间距。沿用既有配置弹框、任务内嵌详情和恢复确认,不修改采集接口、资格或状态机。修改后待人工1920×1080、100%截图确认。
## 数据采集进度改版(用户确认设计)
本包依据 `docs/自营业务/数据采集页面设计.md`“首页”“详情”“状态刷新”及配套 `collection-progress-preview.html` 的 `cp-overview`、`cp-detail`、`cp-progressarea`、`cp-flow`;以下采集页操作以该最新设计为准。保留本文件其他窗口的销量日期与总览说明条目。
| 页面 | 操作 | 承载与实现边界 | 验证边界 |
| --- | --- | --- | --- |
| /self-operated/collection | 查询与完整任务号定位 | 原 QueryToolbar 增加采集内容与完整任务号;按仓库直接读取受权限约束的任务详情,同步 `warehouseCode,taskNo`,不靠翻历史列表定位 | 不跨权限搜索;不存在、权限拒绝、首次读取失败沿服务端错误呈现;待人工1920×1080、100%截图确认 |
| /self-operated/collection | 当前关注与活动切换 | 筛选区下先展示关注任务及完整编号;默认只选服务端 `activeTasks`,手动选择和历史任务持续保留,提供返回当前活动;账号其他活动单列,不借作本任务来源 | 已确认 `progressSourceTaskNo` 才消费对应共享上游;来源数量不跨仓累加;待人工截图确认 |
| /self-operated/collection | 环图、阶段和结果 | 当前阶段SVG环图与总量/完成/剩余共用区域;未知、零量、异常或变化分母撤去比例。出库五阶段、库存四阶段各自展示,阶段完成只依据同身份结束证据;本次发布与当前可用报表独立 | 不用步骤序号或数量100%推定发布,不把来源单数当SKU销量;待人工截图确认 |
| /self-operated/collection | 阶段速度与预计时间 | 仅显示服务端当前阶段估算,向上取整为分钟;同时展示读取、推进、心跳时钟,阶段耗时不使用尝试总耗时替代 | ETA过期、读取失败、隐藏、计数不一致撤销估算;不可估算原因明确说明;不推算全任务ETA;待人工截图确认 |
| /self-operated/collection | 历史与详情 | 历史压缩为操作、任务/范围、数据时间段、阶段进度、最近进展、执行/发布六列,默认10行。沿用1200px居中AppDialog及全屏,标题只出现一次完整任务号 | 无本仓尝试只显示摘要;执行记录和诊断默认折叠,尝试分页按需请求,轮询不重置分页;待人工截图确认 |
| /self-operated/collection | 刷新与恢复 | 一个运行轮询器,活动/等待10秒、闲置60秒,隐藏和离页停止,恢复立即读;旧响应取消与身份校验。配置仍为独立只读弹框;恢复表单仅独立sync权限且allowedActions允许、无阻断时出现 | 状态更新失败保留观测数量、不判采集失败;恢复沿原RETRY及幂等确认,仅提示已排队;自动测试与人工视觉分别记录 |
@@ -38,29 +38,40 @@ async function page() { const wrapper = mount(CollectionView, setup); wrappers.p
describe('collection status list and per-row configuration dialog', () => { describe('collection status list and per-row configuration dialog', () => {
it('shows preparation progress separately from a warehouse waiting to start', async () => { it('shows preparation progress separately from a warehouse waiting to start', async () => {
const waiting: Task = { taskNo: 'LOCAL-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', warehouseCode: stock.warehouseCode, taskStatus: 'WAITING_SOURCE', applyState: 'NOT_STARTED', queryFrom: null, queryUntil: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null } const waiting: Task = { taskNo: 'LOCAL-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', warehouseCode: stock.warehouseCode, taskStatus: 'WAITING_SOURCE', applyState: 'NOT_STARTED', queryFrom: null, queryUntil: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null }
mocks.overview.mockResolvedValue({ ...overview, warehouses: [{ ...stock, dataFamily: 'OUTBOUND', currentTask: waiting, sharedUpstream: { ...waiting, taskNo: 'PREPARE-A', taskStatus: 'RUNNING', attemptCount: 1, progress: { phase: 'DETAIL', completed: 792, total: 2014, totalKnown: true, unit: 'ORDER' } } }] }) waiting.dependencyState = 'WAITING_CONFIRMED'; waiting.progressSourceTaskNo = 'PREPARE-A'
const shared: Task = { ...waiting, taskNo: 'PREPARE-A', taskStatus: 'RUNNING', attemptCount: 1, progress: { phase: 'DETAIL', progressScope: 'ACCOUNT_SHARED', countBasis: 'TASK_CUMULATIVE', completed: 792, total: 2014, totalKnown: true, unit: 'ORDER' } }
mocks.task.mockResolvedValue({ task: waiting, sharedUpstream: shared, protection: stock.protection, observedAt: stock.observedAt })
mocks.overview.mockResolvedValue({ ...overview, warehouses: [{ ...stock, dataFamily: 'OUTBOUND', currentTask: waiting, activeTasks: [waiting], sharedUpstream: shared }] })
const wrapper = await page() const wrapper = await page()
expect(wrapper.text()).toContain('等待数据准备') expect(wrapper.text()).toContain('等待数据准备')
expect(wrapper.text()).toContain('792 / 2014单(39.3%,当前阶段)') expect(wrapper.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('39.3')
expect(wrapper.text()).toContain('不是各仓出库量,也不是各仓完成率') expect(wrapper.find('[aria-label="当前阶段数量"]').text()).toContain('已完成792 单')
expect(wrapper.text()).toContain('完成时间暂无法预计') expect(wrapper.text()).toContain('不能当作本仓出库量或整个同步完成率')
expect(wrapper.text()).toContain('上次结果:') expect(wrapper.text()).toContain('整个任务完成时间尚未估算')
expect(wrapper.text()).toContain('当前报表:使用')
}) })
it('shows one preparation task only once across two warehouses and keeps their progress independent', async () => { it('shows one preparation task only once across two warehouses and keeps their progress independent', async () => {
const waiting: Task = { taskNo: 'LOCAL-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', warehouseCode: 'US01', taskStatus: 'WAITING_SOURCE', applyState: 'NOT_STARTED', queryFrom: null, queryUntil: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null } const waiting: Task = { taskNo: 'LOCAL-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', warehouseCode: 'US01', taskStatus: 'WAITING_SOURCE', applyState: 'NOT_STARTED', queryFrom: null, queryUntil: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null }
const shared = { ...waiting, taskNo: 'PREPARE-A', taskStatus: 'RUNNING', attemptCount: 1, progress: { phase: 'DETAIL', completed: 1218, total: 2014, totalKnown: true, unit: 'ORDER' } } waiting.dependencyState = 'WAITING_CONFIRMED'; waiting.progressSourceTaskNo = 'PREPARE-A'
mocks.overview.mockResolvedValue({ ...overview, warehouses: [{ ...stock, warehouseCode: 'US01', dataFamily: 'OUTBOUND', currentTask: waiting, sharedUpstream: shared }, { ...stock, warehouseCode: 'US02', dataFamily: 'OUTBOUND', currentTask: { ...waiting, warehouseCode: 'US02', taskNo: 'LOCAL-B' }, sharedUpstream: shared }] }) const shared: Task = { ...waiting, taskNo: 'PREPARE-A', taskStatus: 'RUNNING', attemptCount: 1, progress: { phase: 'DETAIL', progressScope: 'ACCOUNT_SHARED', countBasis: 'TASK_CUMULATIVE', completed: 1218, total: 2014, totalKnown: true, unit: 'ORDER' } }
const second = { ...waiting, warehouseCode: 'US02', taskNo: 'LOCAL-B' }
mocks.task.mockResolvedValue({ task: waiting, sharedUpstream: shared, protection: stock.protection, observedAt: stock.observedAt })
mocks.overview.mockResolvedValue({ ...overview, warehouses: [{ ...stock, warehouseCode: 'US01', dataFamily: 'OUTBOUND', currentTask: waiting, activeTasks: [waiting], sharedUpstream: shared }, { ...stock, warehouseCode: 'US02', dataFamily: 'OUTBOUND', currentTask: second, activeTasks: [second], sharedUpstream: shared }] })
const wrapper = await page() const wrapper = await page()
expect(wrapper.findAll('[aria-label="来源数据准备任务"]')).toHaveLength(1) const monitor = wrapper.find('[aria-label="领星账号运行进度"]')
expect(wrapper.find('[aria-label="来源数据准备任务"]').text()).toContain('US01、US02') expect(monitor.findAll('[role="progressbar"]')).toHaveLength(1)
expect(wrapper.text().match(/1218 \/ 2014/g)).toHaveLength(1) expect(monitor.text()).toContain('US01 · 出库销量')
expect(monitor.text()).toContain('US02 · 出库销量')
expect(monitor.find('[aria-label="当前阶段数量"]').text()).toContain('已完成1218 单')
expect(wrapper.find('.collection-secondary-tools').attributes('open')).toBeUndefined()
expect(wrapper.text().match(/尚未开始,等待数据准备/g)).toHaveLength(2) expect(wrapper.text().match(/尚未开始,等待数据准备/g)).toHaveLength(2)
}) })
it('compares all warehouses in one compact table and distinguishes OFF from retained results', async () => { it('compares all warehouses in one compact table and distinguishes OFF from retained results', async () => {
const wrapper = await page() const wrapper = await page()
expect(wrapper.findAllComponents(table)[0]?.props('data')).toHaveLength(2) expect(wrapper.findAllComponents(table)[0]?.props('data')).toHaveLength(2)
expect(wrapper.findAllComponents(table)[0]?.props('columns')[0]).toMatchObject({ label: '操作', fixed: 'left', slotName: 'configuration' }) expect(wrapper.findAllComponents(table)[0]?.props('columns')[0]).toMatchObject({ label: '操作', fixed: 'left', slotName: 'configuration' })
expect(wrapper.findAllComponents(table)[0]?.props('columns').find((column: { key: string }) => column.key === 'enabled')).toMatchObject({ label: '采集开关' }) expect(wrapper.findAllComponents(table)[0]?.props('columns')).toHaveLength(6)
expect(wrapper.findAll('.collection-secondary').filter(item => item.text().includes('采集已关闭'))).toHaveLength(2)
expect(wrapper.text()).toContain('自动采集已关闭,当前仅查看已留存结果') expect(wrapper.text()).toContain('自动采集已关闭,当前仅查看已留存结果')
expect(wrapper.text()).toContain('包含合成测试样本') expect(wrapper.text()).toContain('包含合成测试样本')
expect(wrapper.text()).toContain('已留存 2 / 30 个日样本') expect(wrapper.text()).toContain('已留存 2 / 30 个日样本')
@@ -0,0 +1,72 @@
import { defineComponent, h, reactive } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CollectionView from './CollectionView.vue'
import CollectionAccountMonitor from '../components/CollectionAccountMonitor.vue'
import type { Task, WarehouseOverview } from '../types'
const mocks = vi.hoisted(() => ({ overview: vi.fn(), tasks: vi.fn(), task: vi.fn(), runs: vi.fn(), retry: vi.fn(), push: vi.fn(), route: { path: '/self-operated/collection', query: {} as Record<string, string> } }))
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const task: Task = { taskNo: 'TASK-A-FULL-NUMBER', warehouseCode: 'WH-A', dataFamily: 'OUTBOUND', taskType: 'BACKFILL', taskStatus: 'WAITING_SOURCE', dependencyState: 'WAITING_UNCONFIRMED', queryFrom: null, queryUntil: null, applyState: null, attemptCount: 0, latestAttemptNo: null, nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null, resultPublicationState: 'NOT_PUBLISHED' }
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const table = defineComponent({ props: ['data', 'columns', 'total'], emits: ['update:page'], setup(props, { slots }) { return () => h('div', [slots.filters?.(), slots.notices?.(), ...(props.data ?? []).map((row: unknown) => h('div', [(props.columns ?? []).map((c: { slotName?: string }) => c.slotName ? slots[c.slotName]?.({ row }) : null)]))]) } })
const query = defineComponent({ props: ['modelValue', 'fields'], emits: ['search', 'update:modelValue'], setup() { return () => h('div') } })
const dialog = defineComponent({ props: ['modelValue', 'title'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-dialog': props.title }, slots.default?.()) : null } })
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
const options = { global: { stubs: { AdminPage: shell, ListPageLayout: shell, DataTable: table, QueryToolbar: query, AppDialog: dialog, 'el-button': button, 'el-input': true, 'el-descriptions': shell, 'el-descriptions-item': shell } } }
const wrappers: ReturnType<typeof mount>[] = []
function mountPage() { const w = mount(CollectionView, options); wrappers.push(w); return w }
beforeEach(() => {
vi.clearAllMocks(); mocks.route = reactive({ path: '/self-operated/collection', query: { warehouseCode: 'WH-A' } })
mocks.overview.mockResolvedValue({ sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'BUSINESS', observedAt: '2026-10-13T01:02:00Z', warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'OUTBOUND', activeTasks: [task], currentTask: task, protection: {} } as WarehouseOverview] })
mocks.tasks.mockResolvedValue({ records: [task], total: 21, current: 1, size: 10, listUpperCreatedAt: '2026-10-13T01:00:00Z', listUpperId: '10' })
mocks.task.mockImplementation(async (_, taskNo: string) => ({ task: { ...task, taskNo }, sharedUpstream: null, protection: {}, observedAt: '2026-10-13T01:02:00Z' }))
mocks.runs.mockResolvedValue({ records: [], total: 0 })
})
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers(); Object.defineProperty(document, 'hidden', { configurable: true, value: false }) })
describe('collection focus and one runtime polling scheduler', () => {
it('loads a deep-linked full task number without paging for it and retains focus when activity changes', async () => {
mocks.route.query.taskNo = 'HISTORICAL-FULL-NUMBER'
const w = mountPage(); await flushPromises()
expect(mocks.task.mock.calls[0]?.[1]).toBe('HISTORICAL-FULL-NUMBER')
expect(w.text()).toContain('HISTORICAL-FULL-NUMBER'); expect(w.text()).toContain('手动选择/历史记录')
expect(w.findAllComponents(table)[1]?.props('columns')).toHaveLength(6)
expect(mocks.runs).not.toHaveBeenCalled(); expect(w.find('form').exists()).toBe(false)
expect(w.find('.collection-secondary-tools').attributes('open')).toBeUndefined()
expect(w.findComponent(CollectionAccountMonitor).text()).not.toContain('HISTORICAL-FULL-NUMBER')
})
it('polls only runtime summaries at ten seconds, preserves history pages, and pauses while hidden', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const w = mountPage(); await flushPromises()
expect(mocks.tasks).toHaveBeenCalledTimes(1)
w.findAllComponents(table)[1]!.vm.$emit('update:page', 2); await flushPromises()
expect(mocks.tasks.mock.calls.at(-1)?.[0].current).toBe(2)
const pages = mocks.tasks.mock.calls.length
await vi.advanceTimersByTimeAsync(10000); await flushPromises()
expect(mocks.tasks).toHaveBeenCalledTimes(pages); expect(mocks.task.mock.calls.length).toBeGreaterThan(1)
Object.defineProperty(document, 'hidden', { configurable: true, value: true }); document.dispatchEvent(new Event('visibilitychange')); await flushPromises()
const reads = mocks.overview.mock.calls.length
await vi.advanceTimersByTimeAsync(60000); expect(mocks.overview).toHaveBeenCalledTimes(reads)
Object.defineProperty(document, 'hidden', { configurable: true, value: false }); document.dispatchEvent(new Event('visibilitychange')); await flushPromises()
expect(mocks.overview.mock.calls.length).toBeGreaterThan(reads); expect(mocks.tasks).toHaveBeenCalledTimes(pages)
})
it('does not fetch empty attempts or show a retry form for waiting tasks', async () => {
const w = mountPage(); await flushPromises()
const open = w.findAll('button').find(b => b.text() === '查看关注任务详情')!; await open.trigger('click'); await flushPromises()
expect(w.text()).toContain('本仓尚未开始执行;来源准备进度见上方')
expect(mocks.runs).not.toHaveBeenCalled(); expect(w.find('form').exists()).toBe(false)
expect(w.findAllComponents(table)).toHaveLength(2)
})
it('does not replace task selection with a late detail response and shows lookup failure explicitly', async () => {
let resolveOld!: (value: unknown) => void
mocks.task.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const w = mountPage(); await flushPromises()
const q = w.findComponent(query); q.vm.$emit('update:modelValue', { warehouseCode: 'WH-A', taskNo: 'TASK-B', dataFamily: '' }); q.vm.$emit('search'); await flushPromises()
resolveOld({ task, sharedUpstream: null, protection: {}, observedAt: '2026-10-13T01:02:00Z' }); await flushPromises()
expect(w.find('.collection-focus')?.text()).toContain('TASK-B')
mocks.task.mockRejectedValue(new Error('指定仓库下找不到此任务'))
q.vm.$emit('update:modelValue', { warehouseCode: 'WH-A', taskNo: 'MISSING', dataFamily: '' }); q.vm.$emit('search'); await flushPromises()
expect(w.text()).toContain('任务首次读取失败,请核对仓库和完整任务号后重试'); expect(w.find('.collection-focus')?.text()).not.toContain('TASK-B')
})
})
+171 -140
View File
@@ -12,8 +12,10 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { useAccessPreviewStore } from '@/core/access/accessPreview' import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { analyticsApi } from '../api/analytics' import { analyticsApi } from '../api/analytics'
import CollectionTaskProgress from '../components/CollectionTaskProgress.vue'
import CollectionAccountMonitor from '../components/CollectionAccountMonitor.vue'
import TaskExecutionFacts from '../components/TaskExecutionFacts.vue' import TaskExecutionFacts from '../components/TaskExecutionFacts.vue'
import { taskStateText, taskProgressText, taskCompletionText } from '../collectionPresentation' import { taskStateText, taskProgressText } from '../collectionPresentation'
import type { Overview, Run, Task, TaskDetail, Validation, WarehouseOverview } from '../types' import type { Overview, Run, Task, TaskDetail, Validation, WarehouseOverview } from '../types'
import { RequestFence, RetryIntent, RuntimePoller, hasActiveTask, label, beijingTime, reasonsText, progressText, quantityText, taskRetryAllowed } from '../state' import { RequestFence, RetryIntent, RuntimePoller, hasActiveTask, label, beijingTime, reasonsText, progressText, quantityText, taskRetryAllowed } from '../state'
@@ -23,7 +25,13 @@ const router = useRouter()
const canList = computed(() => access.can('platform:self-operated-analytics:list')) const canList = computed(() => access.can('platform:self-operated-analytics:list'))
const canSync = computed(() => access.can('platform:self-operated-analytics:sync')) const canSync = computed(() => access.can('platform:self-operated-analytics:sync'))
const sourceAccount = 'lingxing-primary' const sourceAccount = 'lingxing-primary'
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '' }) const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', dataFamily: '', taskNo: typeof route.query.taskNo === 'string' ? route.query.taskNo : '' })
const dataFamily = computed(() => (filters.value.dataFamily || undefined) as 'OUTBOUND' | 'INVENTORY' | undefined)
const focusNo = ref(typeof route.query.taskNo === 'string' ? route.query.taskNo : '')
const manualFocus = ref(Boolean(focusNo.value))
const historyLoaded = ref(false)
const historyOpen = ref(false)
const visible = ref(!document.hidden)
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? '')) const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
const overview = ref<Overview | null>(null) const overview = ref<Overview | null>(null)
const configOpen = ref(false) const configOpen = ref(false)
@@ -31,7 +39,7 @@ const configKey = ref('')
const configItem = computed(() => overview.value?.warehouses.find(item => `${item.warehouseCode}:${item.dataFamily}` === configKey.value) ?? null) const configItem = computed(() => overview.value?.warehouses.find(item => `${item.warehouseCode}:${item.dataFamily}` === configKey.value) ?? null)
const rows = ref<Task[]>([]) const rows = ref<Task[]>([])
const current = ref(1) const current = ref(1)
const size = ref(20) const size = ref(10)
const total = ref(0) const total = ref(0)
const upperBound = ref<{ listUpperCreatedAt: string | null; listUpperId: string | null } | null>(null) const upperBound = ref<{ listUpperCreatedAt: string | null; listUpperId: string | null } | null>(null)
const observedAt = ref<string | null>(null) const observedAt = ref<string | null>(null)
@@ -48,55 +56,56 @@ const attemptsTotal = ref(0)
const attemptsUpperBound = ref<{ listUpperCreatedAt: string | null; listUpperId: string | null } | null>(null) const attemptsUpperBound = ref<{ listUpperCreatedAt: string | null; listUpperId: string | null } | null>(null)
const detailLoading = ref(false) const detailLoading = ref(false)
const detailError = ref('') const detailError = ref('')
const detailErrorDetails = ref<ApiErrorDetails | null>(null)
const reason = ref('') const reason = ref('')
const confirming = ref(false) const confirming = ref(false)
const submitting = ref(false) const submitting = ref(false)
const actionFeedback = ref('') const actionFeedback = ref('')
const fence = new RequestFence() const fence = new RequestFence()
const detailFence = new RequestFence() const detailFence = new RequestFence()
const historyFence = new RequestFence()
const actionFence = new RequestFence() const actionFence = new RequestFence()
const retryIntent = new RetryIntent() const retryIntent = new RetryIntent()
let active = false let active = false
const warehouses = computed(() => overview.value?.warehouses.filter(row => !warehouseCode.value || row.warehouseCode === warehouseCode.value) ?? []) const warehouses = computed(() => overview.value?.warehouses.filter(row => !warehouseCode.value || row.warehouseCode === warehouseCode.value) ?? [])
const preparationTasks = computed(() => { const activityTasks = computed(() => warehouses.value.flatMap(item => item.activeTasks ?? []).filter(task => !dataFamily.value || task.dataFamily === dataFamily.value))
const visible = new Set(warehouses.value.map(item => item.sharedUpstream?.taskNo).filter(Boolean)) const focusWarehouse = computed(() => overview.value?.warehouses.find(item => item.warehouseCode === selected.value?.warehouseCode && item.dataFamily === selected.value?.dataFamily) ?? null)
const tasks = new Map<string, { task: Task; warehouseCodes: string[] }>() const sharedSources = computed(() => {
for (const item of overview.value?.warehouses ?? []) { const sources = new Map<string, { task: Task; warehouses: string[] }>()
const task = item.sharedUpstream for (const row of warehouses.value) {
if (!task?.taskNo || !visible.has(task.taskNo)) continue if (dataFamily.value && row.dataFamily !== dataFamily.value) continue
let entry = tasks.get(task.taskNo) const source = row.sharedUpstream
if (!entry) { entry = { task, warehouseCodes: [] }; tasks.set(task.taskNo, entry) } if (!source || !['WAITING_CONFIRMED', 'SATISFIED', 'FAILED'].includes(row.currentTask?.dependencyState ?? '')) continue
if (!entry.warehouseCodes.includes(item.warehouseCode)) entry.warehouseCodes.push(item.warehouseCode) const item = sources.get(source.taskNo) ?? { task: source, warehouses: [] }
if (!item.warehouses.includes(row.warehouseCode)) item.warehouses.push(row.warehouseCode)
sources.set(source.taskNo, item)
} }
return [...tasks.values()] return [...sources.values()]
}) })
const fields = computed<QueryField[]>(() => [{ key: 'warehouseCode', label: '仓库', type: 'select', width: 230, placeholder: '全部仓库采集状态', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { label: row.warehouseName || row.warehouseCode, value: row.warehouseCode }])).values()] }]) const fields = computed<QueryField[]>(() => [
{ key: 'warehouseCode', label: '仓库', type: 'select', width: 230, placeholder: '全部仓库采集状态', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { label: row.warehouseName || row.warehouseCode, value: row.warehouseCode }])).values()] },
{ key: 'dataFamily', label: '采集内容', type: 'select', width: 160, options: [{ value: '', label: '全部采集内容' }, { value: 'OUTBOUND', label: '出库销量' }, { value: 'INVENTORY', label: '产品库存' }] },
{ key: 'taskNo', label: '任务号定位', type: 'input', width: 320, placeholder: '粘贴完整任务号;先选择仓库' },
])
const hasSyntheticSamples = computed(() => warehouses.value.some(item => item.warehouseCode.startsWith('SELFOP-TEST-'))) const hasSyntheticSamples = computed(() => warehouses.value.some(item => item.warehouseCode.startsWith('SELFOP-TEST-')))
const overviewColumns: DataTableColumn[] = [ const overviewColumns: DataTableColumn[] = [
{ key: 'configuration', label: '操作', width: 110, fixed: 'left', slotName: 'configuration' }, { key: 'configuration', label: '操作', width: 100, fixed: 'left', slotName: 'configuration' },
{ prop: 'warehouseName', label: '仓库', minWidth: 160, slotName: 'warehouse' }, { key: 'warehouse', label: '仓库/采集内容', minWidth: 160, slotName: 'warehouse' },
{ key: 'family', label: '数据类型', width: 110, slotName: 'family' }, { key: 'execution', label: '当前活动', minWidth: 220, slotName: 'execution', showOverflowTooltip: false },
{ key: 'enabled', label: '采集开关', width: 100, slotName: 'enabled' }, { key: 'publication', label: '最近可用数据', minWidth: 220, slotName: 'publication', showOverflowTooltip: false },
{ key: 'execution', label: '当前状态', minWidth: 150, slotName: 'execution' }, { key: 'coverage', label: '覆盖/新鲜度', minWidth: 240, slotName: 'coverage', showOverflowTooltip: false },
{ key: 'currentProgress', label: '本仓处理进度', minWidth: 300, slotName: 'currentProgress' },
{ key: 'completion', label: '完成情况', minWidth: 170, slotName: 'completion' },
{ key: 'publication', label: '最近发布', minWidth: 180, slotName: 'publication' },
{ key: 'coverage', label: '数据覆盖截至', minWidth: 180, slotName: 'coverage' },
{ key: 'freshness', label: '数据时效', width: 100, slotName: 'freshness' },
{ key: 'dailySamples', label: '库存日样本', minWidth: 150, slotName: 'dailySamples' }, { key: 'dailySamples', label: '库存日样本', minWidth: 150, slotName: 'dailySamples' },
] ]
const scope = computed(() => ({ sourceAccount, warehouseCode: warehouseCode.value })) const scope = computed(() => ({ sourceAccount, warehouseCode: selected.value?.warehouseCode || warehouseCode.value }))
const retryEligible = computed(() => Boolean(canSync.value && selected.value?.allowedActions.includes('RETRY') && !selected.value.actionBlockReason))
const retryAllowed = computed(() => taskRetryAllowed(selected.value, canSync.value, scope.value, reason.value)) const retryAllowed = computed(() => taskRetryAllowed(selected.value, canSync.value, scope.value, reason.value))
const columns: DataTableColumn[] = [ const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' },
{ prop: 'taskNo', label: '任务号', minWidth: 230 }, { key: 'identity', label: '任务/范围', minWidth: 265, slotName: 'identity', showOverflowTooltip: false },
{ key: 'family', label: '采集内容', minWidth: 160, slotName: 'family' }, { key: 'range', label: '数据时间段', minWidth: 170, slotName: 'range', showOverflowTooltip: false },
{ prop: 'warehouseCode', label: '仓库', minWidth: 120 }, { key: 'progress', label: '当前阶段与进度', minWidth: 220, slotName: 'progress', showOverflowTooltip: false },
{ key: 'range', label: '本任务数据时间段(北京时间)', minWidth: 280, slotName: 'range', showOverflowTooltip: false }, { key: 'advance', label: '最近进展', minWidth: 180, slotName: 'advance', showOverflowTooltip: false },
{ key: 'status', label: '任务状态', minWidth: 150, slotName: 'status' }, { key: 'result', label: '执行/发布结果', minWidth: 170, slotName: 'result', showOverflowTooltip: false },
{ key: 'publication', label: '发布结果', minWidth: 150, slotName: 'publication' },
{ key: 'progress', label: '当前阶段与进度', minWidth: 310, slotName: 'progress' },
{ key: 'completion', label: '完成情况', minWidth: 170, slotName: 'completion' },
] ]
const runColumns: DataTableColumn[] = [ const runColumns: DataTableColumn[] = [
{ prop: 'attemptNo', label: '尝试序号', width: 100 }, { prop: 'attemptNo', label: '尝试序号', width: 100 },
@@ -109,50 +118,72 @@ function asTask(row: unknown) { return row as Task }
function asRun(row: unknown) { return row as Run } function asRun(row: unknown) { return row as Run }
function asWarehouse(row: unknown) { return row as WarehouseOverview } function asWarehouse(row: unknown) { return row as WarehouseOverview }
function openConfiguration(item: WarehouseOverview) { configKey.value = `${item.warehouseCode}:${item.dataFamily}`; configOpen.value = true } function openConfiguration(item: WarehouseOverview) { configKey.value = `${item.warehouseCode}:${item.dataFamily}`; configOpen.value = true }
function shortDate(value: string | null) { return value ? beijingTime(value).split(' ')[0] : '—' }
function advanceAge(task: Task) { const at = task.progressUpdatedAt || task.progress?.progressUpdatedAt; if (!at || !observedAt.value) return ''; const minutes = Math.floor((Date.parse(observedAt.value) - Date.parse(at)) / 60000); return Number.isFinite(minutes) && minutes >= 0 ? (minutes < 1 ? '本次读取前1分钟内推进' : `本次读取前${minutes}分钟推进`) : '' }
function family(value: string) { return value === 'INVENTORY' ? '产品库存' : value === 'OUTBOUND' || value === 'SALES' ? '出库销量' : '任务类型待核对' } function family(value: string) { return value === 'INVENTORY' ? '产品库存' : value === 'OUTBOUND' || value === 'SALES' ? '出库销量' : '任务类型待核对' }
function validation(task: Task | null | undefined): Validation | null { return task?.validation ?? null } function validation(task: Task | null | undefined): Validation | null { return task?.validation ?? null }
function budgetValue(value: unknown) { return typeof value === 'number' || typeof value === 'string' ? String(value) : '—' } function budgetValue(value: unknown) { return typeof value === 'number' || typeof value === 'string' ? String(value) : '—' }
function blockReason(value: string | null | undefined) { return value ? reasonsText([value]) : '—' } function blockReason(value: string | null | undefined) { return value ? reasonsText([value]) : '—' }
function budgetName(key: string) { return ({ pages: '分页HTTP', detailHttp: '详情HTTP', http: '全部HTTP保留额度', confirmedHttp: '已确认真实HTTP', distinctDetails: '不同详情对象', maxPages: '分页HTTP上限', maxPageSize: '每页数量上限', maxDistinctDetails: '不同详情对象上限', maxDetailHttp: '详情HTTP上限', maxInventoryPagesPerWarehouse: '每仓库存页上限', maxHttp: '全部HTTP上限' } as Record<string, string>)[key] ?? '其他保护额度' } function budgetName(key: string) { return ({ pages: '分页HTTP', detailHttp: '详情HTTP', http: '全部HTTP保留额度', confirmedHttp: '已确认真实HTTP', distinctDetails: '不同详情对象', maxPages: '分页HTTP上限', maxPageSize: '每页数量上限', maxDistinctDetails: '不同详情对象上限', maxDetailHttp: '详情HTTP上限', maxInventoryPagesPerWarehouse: '每仓库存页上限', maxHttp: '全部HTTP上限' } as Record<string, string>)[key] ?? '其他保护额度' }
function limitFor(used: string, value: Validation | null) { const key = ({ pages: 'maxPages', detailHttp: 'maxDetailHttp', http: 'maxHttp', confirmedHttp: 'maxHttp', distinctDetails: 'maxDistinctDetails' } as Record<string, string>)[used]; return key ? value?.budgetLimits?.[key] : null } function limitFor(used: string, value: Validation | null) { const key = ({ pages: 'maxPages', detailHttp: 'maxDetailHttp', http: 'maxHttp', confirmedHttp: 'maxHttp', distinctDetails: 'maxDistinctDetails' } as Record<string, string>)[used]; return key ? value?.budgetLimits?.[key] : null }
async function read() { async function read(refreshHistory = false) {
if (!active || !canList.value) return false if (!active || !canList.value || !visible.value) return false
const request = fence.begin() const request = fence.begin()
loading.value = !overview.value loading.value = !overview.value
try { try {
const nextOverview = await analyticsApi.overview(sourceAccount, undefined, request.signal) const nextOverview = await analyticsApi.overview(sourceAccount, undefined, request.signal)
if (!request.current()) return false if (!request.current()) return false
const tasks = warehouseCode.value ? await analyticsApi.tasks({ ...scope.value, current: current.value, size: size.value, ...upperBound.value }, request.signal) : null overview.value = nextOverview; observedAt.value = nextOverview.observedAt
if (!request.current()) return false if (warehouseCode.value && (refreshHistory || !historyLoaded.value)) {
overview.value = nextOverview const tasks = await analyticsApi.tasks({ sourceAccount, warehouseCode: warehouseCode.value, dataFamily: dataFamily.value, current: current.value, size: size.value, ...upperBound.value }, request.signal)
observedAt.value = nextOverview.observedAt if (!request.current()) return false
if (tasks) { rows.value = tasks.records; total.value = tasks.total; upperBound.value = { listUpperCreatedAt: tasks.listUpperCreatedAt, listUpperId: tasks.listUpperId } } rows.value = tasks.records; total.value = tasks.total; historyLoaded.value = true
error.value = '' upperBound.value = { listUpperCreatedAt: tasks.listUpperCreatedAt, listUpperId: tasks.listUpperId }
errorDetails.value = null }
return hasActiveTask([...rows.value, ...nextOverview.warehouses.flatMap(row => [row.currentTask, row.sharedUpstream].filter((task): task is Task => Boolean(task)))]) error.value = ''; errorDetails.value = null
} catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '状态更新失败,保留最后读取数据'); error.value = errorDetails.value.message }; return hasActiveTask(rows.value) } if (!manualFocus.value && activityTasks.value[0] && focusNo.value !== activityTasks.value[0].taskNo) { resetFocusState(); selected.value = activityTasks.value[0]; selectedDetail.value = null; focusNo.value = selected.value.taskNo }
if (focusNo.value && !submitting.value) await readFocus()
return isBusy()
} catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '状态更新失败,保留最后读取数据'); error.value = errorDetails.value.message }; return isBusy() }
finally { if (request.current()) loading.value = false } finally { if (request.current()) loading.value = false }
} }
async function selectTask(task: Task, reset = true) { function isBusy() { const tasks = [...activityTasks.value, ...(overview.value?.accountActivity ? [overview.value.accountActivity] : []), ...(selected.value ? [selected.value] : []), ...(selectedDetail.value?.sharedUpstream ? [selectedDetail.value.sharedUpstream] : [])]; return hasActiveTask(tasks) || tasks.some(t => ['READY', 'APPLYING'].includes(t.taskStatus)) }
if (!active || !task.taskNo || task.warehouseCode !== warehouseCode.value) return async function readFocus() {
if (reset) { taskOpen.value = true; actionFence.cancel(); submitting.value = false; selected.value = task; selectedDetail.value = null; attempts.value = []; attemptsPage.value = 1; attemptsUpperBound.value = null; reason.value = ''; confirming.value = false; actionFeedback.value = '' } const taskNo = focusNo.value, warehouse = selected.value?.warehouseCode || warehouseCode.value
const request = detailFence.begin() if (!active || !visible.value || !taskNo || !warehouse) return
detailLoading.value = true const request = detailFence.begin(); detailLoading.value = true
detailError.value = ''
try { try {
const detail = await analyticsApi.task(scope.value, task.taskNo, request.signal) const detail = await analyticsApi.task({ sourceAccount, warehouseCode: warehouse }, taskNo, request.signal)
if (!request.current()) return if (!request.current() || taskNo !== focusNo.value) return
const history = await analyticsApi.runs({ ...scope.value, taskNo: task.taskNo, current: attemptsPage.value, size: attemptsSize.value, ...attemptsUpperBound.value }, request.signal) if (detail.task.taskNo !== taskNo || detail.task.warehouseCode !== warehouse) throw new Error('任务归属不匹配,请核对所选仓库')
if (!request.current()) return selected.value = detail.task; selectedDetail.value = detail; detailError.value = ''; detailErrorDetails.value = null
if (detail.task.taskNo !== task.taskNo || detail.task.warehouseCode !== warehouseCode.value) throw new Error('任务归属不匹配') } catch (cause) { if (request.current()) { detailErrorDetails.value = getApiErrorDetails(cause, selectedDetail.value ? '关注任务状态更新失败,保留最后有效观测' : '任务首次读取失败,请核对仓库和完整任务号后重试'); detailError.value = detailErrorDetails.value.message } }
selected.value = detail.task
selectedDetail.value = detail
attempts.value = history.records
attemptsTotal.value = history.total
attemptsUpperBound.value = { listUpperCreatedAt: history.listUpperCreatedAt, listUpperId: history.listUpperId }
} catch (cause) { if (request.current()) detailError.value = getApiErrorMessage(cause, '任务详情更新失败,保留上次详情') }
finally { if (request.current()) detailLoading.value = false } finally { if (request.current()) detailLoading.value = false }
} }
async function readAttempts() {
if (!active || !visible.value || !selected.value || !historyOpen.value || !Number(selected.value.attemptCount)) return
const taskNo = selected.value.taskNo, request = historyFence.begin()
try {
const history = await analyticsApi.runs({ ...scope.value, taskNo, current: attemptsPage.value, size: attemptsSize.value, ...attemptsUpperBound.value }, request.signal)
if (!request.current() || taskNo !== selected.value?.taskNo) return
attempts.value = history.records; attemptsTotal.value = history.total
attemptsUpperBound.value = { listUpperCreatedAt: history.listUpperCreatedAt, listUpperId: history.listUpperId }
} catch (cause) { if (request.current()) detailError.value = getApiErrorMessage(cause, '执行记录读取失败') }
}
async function selectTask(task: Task, reset = true) {
if (!active || !task.taskNo || (warehouseCode.value && task.warehouseCode !== warehouseCode.value)) return
if (reset) { manualFocus.value = true; selected.value = task; focusNo.value = task.taskNo; selectedDetail.value = null; resetFocusState(); taskOpen.value = true; syncFocusRoute(task.warehouseCode, task.taskNo) }
await readFocus()
}
function resetFocusState() { detailFence.cancel(); historyFence.cancel(); actionFence.cancel(); detailError.value = ''; detailErrorDetails.value = null; submitting.value = false; attempts.value = []; attemptsTotal.value = 0; attemptsPage.value = 1; attemptsUpperBound.value = null; historyOpen.value = false; reason.value = ''; confirming.value = false; actionFeedback.value = '' }
function syncFocusRoute(warehouse: string, taskNo: string) { if (route.query.warehouseCode !== warehouse || route.query.taskNo !== taskNo) void router.push({ path: '/self-operated/collection', query: { warehouseCode: warehouse, ...(taskNo ? { taskNo } : {}) } }) }
async function locateTask() {
const taskNo = String(filters.value.taskNo ?? '').trim()
if (!taskNo) return
if (!warehouseCode.value) { detailError.value = '请先选择仓库,再定位完整任务号'; return }
manualFocus.value = true; focusNo.value = taskNo; selected.value = null; selectedDetail.value = null; resetFocusState(); syncFocusRoute(warehouseCode.value, taskNo); await readFocus()
}
async function returnActivity() { manualFocus.value = false; focusNo.value = ''; selected.value = null; selectedDetail.value = null; filters.value.taskNo = ''; resetFocusState(); syncFocusRoute(warehouseCode.value, ''); await read() }
async function submitRetry() { async function submitRetry() {
if (!confirming.value || !retryAllowed.value || submitting.value || !selected.value) return if (!confirming.value || !retryAllowed.value || submitting.value || !selected.value) return
const target = selected.value const target = selected.value
@@ -160,7 +191,7 @@ async function submitRetry() {
submitting.value = true submitting.value = true
actionFeedback.value = '' actionFeedback.value = ''
try { try {
const accepted = await analyticsApi.retry(retryIntent.create(target.taskNo, warehouseCode.value, reason.value), request.signal) const accepted = await analyticsApi.retry(retryIntent.create(target.taskNo, target.warehouseCode, reason.value), request.signal)
if (!request.current()) return if (!request.current()) return
if (accepted.taskNo !== target.taskNo) throw new Error('受理任务号不匹配') if (accepted.taskNo !== target.taskNo) throw new Error('受理任务号不匹配')
retryIntent.accepted() retryIntent.accepted()
@@ -171,21 +202,26 @@ async function submitRetry() {
} catch (cause) { if (request.current()) actionFeedback.value = getApiErrorMessage(cause, '恢复请求失败;再次提交相同内容将沿用原幂等键') } } catch (cause) { if (request.current()) actionFeedback.value = getApiErrorMessage(cause, '恢复请求失败;再次提交相同内容将沿用原幂等键') }
finally { if (request.current()) submitting.value = false } finally { if (request.current()) submitting.value = false }
} }
const poller = new RuntimePoller(async () => { const busy = await read(); if (selected.value && !submitting.value) await selectTask(selected.value, false); return busy }, () => { fence.cancel(); detailFence.cancel() }) const poller = new RuntimePoller(() => read(), () => { fence.cancel(); detailFence.cancel(); historyFence.cancel() })
function visibility() { poller.setVisible(!document.hidden) } function visibility() { visible.value = !document.hidden; poller.setVisible(visible.value) }
function refresh() { upperBound.value = null; current.value = 1; void read() } function refresh() { upperBound.value = null; current.value = 1; historyLoaded.value = false; if (String(filters.value.taskNo ?? '').trim()) void locateTask(); void read(true) }
function openAnalysis(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value } }) } function openAnalysis(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value || selected.value?.warehouseCode } }) }
watch([() => route.path, () => route.query], () => { watch([() => route.path, () => route.query], () => {
if (route.path && route.path !== '/self-operated/collection') return if (route.path && route.path !== '/self-operated/collection') return
filters.value.warehouseCode = typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '' const warehouse = typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : ''
const taskNo = typeof route.query.taskNo === 'string' ? route.query.taskNo : ''
if (warehouse === warehouseCode.value && taskNo === focusNo.value) return
filters.value.warehouseCode = warehouse; filters.value.taskNo = taskNo
selected.value = null; selectedDetail.value = null; focusNo.value = taskNo; manualFocus.value = Boolean(taskNo); resetFocusState(); void read()
}) })
watch(warehouseCode, () => { configOpen.value = false; taskOpen.value = false; fence.cancel(); detailFence.cancel(); actionFence.cancel(); selected.value = null; rows.value = []; total.value = 0; attempts.value = []; upperBound.value = null; confirming.value = false; submitting.value = false; reason.value = ''; actionFeedback.value = ''; current.value = 1; void read() }) watch([warehouseCode, dataFamily], () => { configOpen.value = false; taskOpen.value = false; fence.cancel(); resetFocusState(); selected.value = null; selectedDetail.value = null; focusNo.value = String(filters.value.taskNo ?? '').trim(); manualFocus.value = Boolean(focusNo.value); rows.value = []; total.value = 0; upperBound.value = null; historyLoaded.value = false; current.value = 1; void read() })
watch(taskOpen, open => { if (!open) { detailFence.cancel(); detailLoading.value = false; selected.value = null; selectedDetail.value = null; attempts.value = []; confirming.value = false; reason.value = ''; actionFeedback.value = '' } }) watch(taskOpen, open => { if (!open) { historyFence.cancel(); historyOpen.value = false; confirming.value = false; reason.value = ''; actionFeedback.value = '' } })
watch([current, size], () => { void read() }) watch([current, size], () => { historyLoaded.value = false; void read(true) })
watch([attemptsPage, attemptsSize], () => { if (selected.value) void selectTask(selected.value, false) }) watch([attemptsPage, attemptsSize], () => { void readAttempts() })
watch(historyOpen, open => { if (open) void readAttempts(); else historyFence.cancel() })
watch(reason, () => { confirming.value = false }) watch(reason, () => { confirming.value = false })
function resume() { if (active) return; active = true; visibility(); poller.start() } function resume() { if (active) return; active = true; visibility(); poller.start() }
function suspend() { active = false; poller.stop(); actionFence.cancel(); configOpen.value = false; taskOpen.value = false; loading.value = false; detailLoading.value = false; submitting.value = false } function suspend() { active = false; visible.value = false; poller.stop(); actionFence.cancel(); configOpen.value = false; taskOpen.value = false; loading.value = false; detailLoading.value = false; submitting.value = false }
onMounted(() => { resume(); document.addEventListener('visibilitychange', visibility) }) onMounted(() => { resume(); document.addEventListener('visibilitychange', visibility) })
onActivated(resume) onActivated(resume)
onDeactivated(suspend) onDeactivated(suspend)
@@ -195,75 +231,67 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
<AdminPage class="admin-density"> <AdminPage class="admin-density">
<p v-if="!canList" role="alert">没有采集状态查看权限。</p> <p v-if="!canList" role="alert">没有采集状态查看权限。</p>
<ListPageLayout v-else> <ListPageLayout v-else>
<CollectionAccountMonitor :overview="overview" :loading="loading" :stale="Boolean(error)" :visible="visible" @refresh="read()" />
<details class="collection-secondary-tools"><summary>历史记录与排查</summary>
<div class="collection-workspace"> <div class="collection-workspace">
<DataTable appearance="integrated" :data="warehouses" :columns="overviewColumns" :loading="loading" empty-text="暂无仓库采集状态"> <DataTable appearance="integrated" :data="warehouses.filter(row => !dataFamily || row.dataFamily === dataFamily)" :columns="overviewColumns" :loading="loading" empty-text="暂无仓库采集状态">
<template #toolbar-start><h2>当前采集状态</h2></template> <template #toolbar-start><h2>各仓数据可用情况</h2></template>
<template #toolbar-actions><el-button :disabled="!warehouseCode" @click="openAnalysis('/self-operated/sales')">查看销量</el-button><el-button :disabled="!warehouseCode" @click="openAnalysis('/self-operated/inventory')">查看库存</el-button></template> <template #toolbar-actions><el-button :disabled="!warehouseCode && !selected" @click="openAnalysis('/self-operated/sales')">查看销量</el-button><el-button :disabled="!warehouseCode && !selected" @click="openAnalysis('/self-operated/inventory')">查看库存</el-button></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="采集仓库筛选" preference-key="self-operated-collection-query" :loading="loading" @search="refresh" @reset="refresh" /></template> <template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="采集任务筛选" preference-key="self-operated-collection-query" :loading="loading" @search="refresh" @reset="refresh" /></template>
<template #notices> <template #notices>
<LoadErrorState v-if="error" title="采集状态更新失败" :message="error" :loading="loading" compact @retry="read"><template #details><p>状态观测时间 {{ beijingTime(observedAt) }}。任务运行失败与本页读取失败分别判断。</p><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState> <LoadErrorState v-if="error" title="采集状态更新失败" :message="error" :loading="loading" compact @retry="read"><template #details><p>最后有效观测 {{ beijingTime(observedAt) }};任务是否失败由服务端确认。</p><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<div class="collection-summary" role="status"> <div class="collection-summary" role="status"><span>{{ overview?.collectionMode === 'OFF' ? '自动采集已关闭,当前仅查看已留存结果。' : `采集模式:${label(overview?.collectionMode)}` }}</span><span v-if="hasSyntheticSamples">包含合成测试样本,不代表真实采集覆盖。</span><span class="collection-observed">状态读取 {{ beijingTime(observedAt) }}(北京时间)</span></div>
<span v-if="overview?.collectionMode === 'OFF'">自动采集已关闭,当前仅查看已留存结果。</span> <section class="collection-focus" aria-label="当前关注任务">
<span v-else>采集模式:{{ label(overview?.collectionMode) }}</span> <div class="density-actions"><h2>{{ manualFocus ? '当前关注任务 · 手动选择/历史记录' : '当前关注任务' }}</h2><el-button v-if="selected" @click="taskOpen = true">查看关注任务详情</el-button><el-button v-if="manualFocus" @click="returnActivity">返回当前活动</el-button></div>
<span v-if="hasSyntheticSamples">包含合成测试样本,不代表真实领星采集覆盖。</span> <p v-if="focusNo" class="collection-task-no">{{ focusNo }}</p>
<span class="collection-observed">最近更新:{{ beijingTime(observedAt) }}(北京时间)</span> <div v-if="activityTasks.length > 1" class="density-actions"><span>本范围 {{ activityTasks.length }} 个活动/等待任务</span><el-button v-for="task in activityTasks" :key="task.taskNo" :aria-label="`关注 ${task.taskNo}`" @click="selectTask(task)">{{ task.taskNo }}</el-button></div>
</div> <p v-if="detailLoading && !selectedDetail">正在读取关注任务…</p><p v-if="detailError" role="alert">{{ detailError }}<ApiErrorMeta v-if="detailErrorDetails" :details="detailErrorDetails" /></p>
<div v-for="entry in preparationTasks" :key="entry.task.taskNo" class="collection-preparation" role="status" aria-label="来源数据准备任务"> <CollectionTaskProgress v-if="selected" :task="selected" :detail="selectedDetail" :warehouse="focusWarehouse" :stale="Boolean(error || detailError)" :visible="visible" :observed-at="observedAt" />
<strong>来源数据准备</strong><span>{{ entry.warehouseCodes.join('、') }} 使用这批数据</span><span>{{ taskStateText(entry.task) }}</span><span>{{ taskProgressText(entry.task) }}</span> <p v-else-if="!detailLoading && !detailError">{{ overview ? '暂无已确认的活动任务;可选择仓库后定位完整任务号或查看历史记录。' : '尚未读取采集任务。' }}</p>
<small>任务 {{ entry.task.taskNo }};这里的单数是来源账号的数据准备进度,不是各仓出库量,也不是各仓完成率。</small> </section>
</div> <section v-for="source in sharedSources" :key="source.task.taskNo" aria-label="来源数据准备任务" class="collection-preparation">
</template> <template v-if="source.task.taskNo === selectedDetail?.sharedUpstream?.taskNo">关注任务的关联来源</template><template v-else>关联来源 {{ source.task.taskNo }} · 账号共享 · {{ taskStateText(source.task) }} · {{ taskProgressText(source.task) }}</template>;关联仓库 {{ source.warehouses.join('、') }}。共享数量只记一次,不计入各仓完成量。
<template #configuration="{ row }"><el-button class="density-action" @click="openConfiguration(asWarehouse(row))">查看配置</el-button></template> </section>
<template #warehouse="{ row }">{{ asWarehouse(row).warehouseName || asWarehouse(row).warehouseCode }}</template> <p v-if="overview?.accountActivity && overview.accountActivity.taskNo !== selectedDetail?.sharedUpstream?.taskNo && !sharedSources.some(source => source.task.taskNo === overview?.accountActivity?.taskNo)" class="collection-preparation">账号正在执行其他采集:{{ overview.accountActivity.taskNo }} · {{ taskStateText(overview.accountActivity) }}。与当前任务的依赖尚未确认,不作为本任务进度。</p>
<template #family="{ row }">{{ family(asWarehouse(row).dataFamily) }}</template> </template>
<template #enabled="{ row }">{{ asWarehouse(row).executionEnabled ? '已启用' : '已关闭' }}</template> <template #configuration="{ row }"><el-button class="density-action" @click="openConfiguration(asWarehouse(row))">查看配置</el-button></template>
<template #execution="{ row }">{{ taskStateText(asWarehouse(row).currentTask) }}</template> <template #warehouse="{ row }">{{ asWarehouse(row).warehouseName || asWarehouse(row).warehouseCode }}<small class="collection-secondary">{{ family(asWarehouse(row).dataFamily) }} · {{ asWarehouse(row).executionEnabled ? '采集已启用' : '采集已关闭' }}</small></template>
<template #currentProgress="{ row }">{{ taskProgressText(asWarehouse(row).currentTask) }}</template> <template #execution="{ row }">{{ asWarehouse(row).activeTasks?.length ? `${asWarehouse(row).activeTasks!.length}个活动/等待任务` : '暂无已确认活动' }}<small v-if="asWarehouse(row).currentTask" class="collection-secondary">{{ taskStateText(asWarehouse(row).currentTask) }} · {{ taskProgressText(asWarehouse(row).currentTask) }}</small></template>
<template #completion="{ row }">{{ taskCompletionText(asWarehouse(row).currentTask) }}</template> <template #publication="{ row }">{{ asWarehouse(row).lastPublishedAt ? beijingTime(asWarehouse(row).lastPublishedAt) : '尚无可用结果' }}<small class="collection-secondary">本次任务:{{ label(asWarehouse(row).currentTask?.resultPublicationState) }}</small></template>
<template #publication="{ row }">{{ label(asWarehouse(row).currentTask?.resultPublicationState) }}<small class="collection-secondary">{{ asWarehouse(row).lastPublishedAt ? `上次结果:${beijingTime(asWarehouse(row).lastPublishedAt)}` : '尚无可用结果' }}</small></template> <template #coverage="{ row }">{{ asWarehouse(row).dataFamily === 'INVENTORY' ? '库存观测截至' : '销量覆盖截至(不含)' }} {{ beijingTime(asWarehouse(row).dataFamily === 'INVENTORY' ? asWarehouse(row).inventoryObservedUntil : asWarehouse(row).salesCoveredUntil) }}<small class="collection-secondary">{{ label(asWarehouse(row).dataFreshness) }}</small></template>
<template #coverage="{ row }">{{ beijingTime(asWarehouse(row).dataFamily === 'INVENTORY' ? asWarehouse(row).inventoryObservedUntil : asWarehouse(row).salesCoveredUntil) }}</template> <template #dailySamples="{ row }"><template v-if="asWarehouse(row).dataFamily === 'INVENTORY' && asWarehouse(row).inventoryDailySamples">已留存 {{ asWarehouse(row).inventoryDailySamples?.sampleCount }} / {{ asWarehouse(row).inventoryDailySamples?.expectedDays }} 个日样本</template><template v-else>—</template></template>
<template #freshness="{ row }">{{ label(asWarehouse(row).dataFreshness) }}</template>
<template #dailySamples="{ row }"><template v-if="asWarehouse(row).dataFamily === 'INVENTORY' && asWarehouse(row).inventoryDailySamples">已留存 {{ asWarehouse(row).inventoryDailySamples?.sampleCount }} / {{ asWarehouse(row).inventoryDailySamples?.expectedDays }} 个日样本</template><template v-else>—</template></template>
</DataTable>
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :empty-text="!warehouseCode ? '选择仓库后查看采集任务与尝试历史' : error && !rows.length ? '任务读取失败,请重试' : '暂无采集任务'">
<template #toolbar-start><h2>采集任务记录</h2></template>
<template #notices><p class="collection-history-note">这里包含当前任务和历史任务。每行对应一个任务及数据时间段;同一仓库出现多条出库销量记录,不代表这些任务同时执行。当前运行情况请看上方“当前采集状态”。</p></template>
<template #actions="{ row }"><el-button link :disabled="!asTask(row).taskNo" @click="selectTask(asTask(row))">详情 / 恢复</el-button></template>
<template #family="{ row }">{{ family(asTask(row).dataFamily) }}<small class="collection-secondary">{{ asTask(row).taskType === 'BACKFILL' ? '补采历史数据' : asTask(row).taskType === 'SCHEDULED' ? '定时更新' : label(asTask(row).executionPurpose) }}</small></template>
<template #range="{ row }">{{ beijingTime(asTask(row).queryFrom) }}<br />至 {{ beijingTime(asTask(row).queryUntil) }}</template>
<template #status="{ row }">{{ taskStateText(asTask(row)) }}</template>
<template #publication="{ row }">{{ label(asTask(row).resultPublicationState) }}</template>
<template #progress="{ row }">{{ taskProgressText(asTask(row)) }}</template>
<template #completion="{ row }">{{ taskCompletionText(asTask(row)) }}</template>
</DataTable>
<AppDialog v-model="taskOpen" class="admin-density-dialog" :title="`采集任务详情 · ${selected?.taskNo || '—'}`" width="1200px" :loading="detailLoading" :confirm-loading="submitting" :show-confirm="false" cancel-text="关闭" fullscreenable>
<section v-if="selected" class="collection-details admin-density" aria-label="选中任务详情">
<h3>任务 {{ selected.taskNo || '—' }}</h3>
<p v-if="detailLoading">读取任务详情…</p><p v-if="detailError" role="alert">{{ detailError }}</p>
<p>状态:{{ taskStateText(selected) }};结果:{{ label(selected.resultPublicationState) }};已执行 {{ selected.attemptCount }} 次。</p><p>进度:{{ taskProgressText(selected) }};{{ taskCompletionText(selected) }}。</p>
<p>目标 {{ beijingTime(selected.queryFrom) }} 至 {{ beijingTime(selected.queryUntil) }};下次重试 {{ beijingTime(selected.nextRetryAt) }};{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : (selected.latestAttempt?.failureSummary ?? '') }}</p>
<TaskExecutionFacts :task="selected" scope-label="本仓任务" />
<details v-if="selected.progress?.stages?.length"><summary>分阶段记录</summary><p v-for="(stage, index) in selected.progress.stages" :key="index">{{ progressText(stage) }}</p></details>
<section v-if="selectedDetail?.sharedUpstream" class="collection-upstream" aria-label="数据准备任务详情">
<h3>数据准备任务:{{ selectedDetail.sharedUpstream.taskNo }}</h3>
<p>{{ taskStateText(selectedDetail.sharedUpstream) }};{{ taskProgressText(selectedDetail.sharedUpstream) }};{{ taskCompletionText(selectedDetail.sharedUpstream) }}。</p>
<TaskExecutionFacts :task="selectedDetail.sharedUpstream" scope-label="数据准备任务" />
<p>系统先读取来源账号的数据,再按仓库分别校验、计算和发布。以上是前面的数据准备进度,可能包含其他仓库;本仓是否成功以结果发布为准,不把该进度计入本仓尝试次数。</p>
</section>
<DataTable appearance="integrated" v-model:page="attemptsPage" v-model:page-size="attemptsSize" :data="attempts" :columns="runColumns" :total="attemptsTotal" :loading="detailLoading" empty-text="尚无执行尝试">
<template #status="{ row }">{{ label(asRun(row).status) }}</template><template #progress="{ row }">{{ progressText(asRun(row).progress) }}</template><template #startedAt="{ row }">{{ beijingTime(asRun(row).startedAt) }}</template><template #finishedAt="{ row }">{{ beijingTime(asRun(row).finishedAt) }}</template>
</DataTable> </DataTable>
<form v-if="canSync" class="density-form" @submit.prevent="submitRetry"> <DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :empty-text="!warehouseCode ? '选择仓库后查看任务记录' : error && !rows.length ? '任务读取失败,请重试' : '暂无采集任务'">
<label for="retry-reason">恢复原因</label><el-input id="retry-reason" v-model="reason" type="textarea" placeholder="请说明已完成的修复及恢复原任务的原因" :maxlength="500" :disabled="submitting" /> <template #toolbar-start><h2>采集任务记录</h2></template>
<p v-if="!selected.allowedActions.includes('RETRY') || selected.actionBlockReason">{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : '当前任务不允许恢复' }}</p> <template #notices><p class="collection-history-note">历史记录不代表同时执行;轮询保留当前分页和关注任务。</p></template>
<el-button v-if="!confirming" :disabled="!retryAllowed || submitting" @click="confirming = true">核对恢复请求</el-button> <template #actions="{ row }"><el-button link :disabled="!asTask(row).taskNo" @click="selectTask(asTask(row))">查看详情</el-button></template>
<template v-else><p>确认恢复任务 {{ selected.taskNo }}(仓库 {{ selected.warehouseCode }}),范围保持不变,原因:{{ reason }}。受理后仍须等待执行与发布。</p><el-button :disabled="!retryAllowed || submitting" :loading="submitting" native-type="submit" type="primary">确认提交恢复请求</el-button><el-button :disabled="submitting" @click="confirming = false">返回修改</el-button></template> <template #identity="{ row }"><span class="collection-task-no">{{ asTask(row).taskNo }}</span><small class="collection-secondary">{{ asTask(row).warehouseCode }} · {{ family(asTask(row).dataFamily) }} · {{ label(asTask(row).executionPurpose) }}</small></template>
</form> <template #range="{ row }">{{ shortDate(asTask(row).queryFrom) }} 至 {{ shortDate(asTask(row).queryUntil) }}<small class="collection-secondary">结束时刻不含;精确范围见详情</small></template>
<p v-else>没有任务恢复权限。</p><p v-if="actionFeedback" role="status">{{ actionFeedback }}</p> <template #progress="{ row }">{{ taskStateText(asTask(row)) }}<small class="collection-secondary">{{ taskProgressText(asTask(row)) }}</small></template>
</section> <template #advance="{ row }">{{ asTask(row).attemptCount ? beijingTime(asTask(row).progressUpdatedAt || asTask(row).progress?.progressUpdatedAt) : '本仓尚未开始执行' }}<small class="collection-secondary">{{ advanceAge(asTask(row)) }}</small></template>
</AppDialog> <template #result="{ row }">{{ taskStateText(asTask(row)) }} · {{ label(asTask(row).resultPublicationState) }}</template>
</DataTable>
<AppDialog v-model="taskOpen" class="admin-density-dialog" :title="`采集任务详情 · ${selected?.taskNo || focusNo || '—'}`" width="1200px" :loading="detailLoading" :confirm-loading="submitting" :show-confirm="false" cancel-text="关闭" fullscreenable>
<section v-if="selected" class="collection-details admin-density" aria-label="选中任务详情">
<p v-if="detailError" role="alert">{{ detailError }}<ApiErrorMeta v-if="detailErrorDetails" :details="detailErrorDetails" /></p>
<CollectionTaskProgress :task="selected" :detail="selectedDetail" :warehouse="focusWarehouse" :stale="Boolean(error || detailError)" :visible="visible && taskOpen" :observed-at="observedAt" />
<p v-if="!Number(selected.attemptCount)">本仓尚未开始执行;来源准备进度见上方。</p>
<details v-else :open="historyOpen" @toggle="historyOpen = ($event.target as HTMLDetailsElement).open"><summary>执行记录({{ selected.attemptCount }}次)</summary>
<DataTable v-if="historyOpen" appearance="integrated" v-model:page="attemptsPage" v-model:page-size="attemptsSize" :data="attempts" :columns="runColumns" :total="attemptsTotal" empty-text="尚无已读取的执行记录">
<template #status="{ row }">{{ label(asRun(row).status) }}</template><template #progress="{ row }">{{ progressText(asRun(row).progress) }}</template><template #startedAt="{ row }">{{ beijingTime(asRun(row).startedAt) }}</template><template #finishedAt="{ row }">{{ beijingTime(asRun(row).finishedAt) }}</template>
</DataTable>
</details>
<details><summary>校验与诊断</summary><TaskExecutionFacts :task="selected" scope-label="本仓任务" /><p>操作资格:{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : '以服务端允许操作为准' }};下次重试 {{ beijingTime(selected.nextRetryAt) }}。</p><p>账号冷却 {{ beijingTime(selectedDetail?.protection.accountCooldownUntil) }};{{ selectedDetail?.protection.accountPauseReason ?? '未提供暂停原因' }}</p><p v-for="(stage, index) in selected.progress?.stages ?? []" :key="index">{{ progressText(stage) }}</p><TaskExecutionFacts v-if="selectedDetail?.sharedUpstream && selected.progressSourceTaskNo === selectedDetail.sharedUpstream.taskNo" :task="selectedDetail.sharedUpstream" scope-label="账号共享来源" /></details>
<form v-if="retryEligible" class="density-form" @submit.prevent="submitRetry">
<label for="retry-reason">恢复原因</label><el-input id="retry-reason" v-model="reason" type="textarea" placeholder="请说明已完成的修复及恢复原任务的原因" :maxlength="500" :disabled="submitting" />
<el-button v-if="!confirming" :disabled="!retryAllowed || submitting" @click="confirming = true">核对恢复请求</el-button>
<template v-else><p>确认恢复任务 {{ selected.taskNo }}(仓库 {{ selected.warehouseCode }}),范围保持不变,原因:{{ reason }}。受理后仍须等待执行与发布。</p><el-button :disabled="!retryAllowed || submitting" :loading="submitting" native-type="submit" type="primary">确认提交恢复请求</el-button><el-button :disabled="submitting" @click="confirming = false">返回修改</el-button></template>
</form>
<p v-else-if="!canSync">没有任务恢复权限。</p><p v-if="actionFeedback" role="status">{{ actionFeedback }}</p>
</section>
</AppDialog>
</div> </div>
</details>
</ListPageLayout> </ListPageLayout>
<AppDialog v-model="configOpen" class="admin-density-dialog" :title="`${configItem?.warehouseName || configItem?.warehouseCode || '仓库'} · ${family(configItem?.dataFamily || '')}配置`" width="1120px" :show-confirm="false" cancel-text="关闭" fullscreenable> <AppDialog v-model="configOpen" class="admin-density-dialog" :title="`${configItem?.warehouseName || configItem?.warehouseCode || '仓库'} · ${family(configItem?.dataFamily || '')}配置`" width="1120px" :show-confirm="false" cancel-text="关闭" fullscreenable>
<div class="admin-density"> <div class="admin-density">
@@ -278,7 +306,7 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
<el-descriptions-item label="发布结果">{{ label(configItem.currentTask?.resultPublicationState) }}</el-descriptions-item> <el-descriptions-item label="发布结果">{{ label(configItem.currentTask?.resultPublicationState) }}</el-descriptions-item>
<el-descriptions-item label="最近成功">{{ configItem.lastSucceededAt ? beijingTime(configItem.lastSucceededAt) : '尚无成功记录' }}</el-descriptions-item> <el-descriptions-item label="最近成功">{{ configItem.lastSucceededAt ? beijingTime(configItem.lastSucceededAt) : '尚无成功记录' }}</el-descriptions-item>
<el-descriptions-item label="结果发布时间">{{ configItem.lastPublishedAt ? beijingTime(configItem.lastPublishedAt) : '尚无可用数据' }}</el-descriptions-item> <el-descriptions-item label="结果发布时间">{{ configItem.lastPublishedAt ? beijingTime(configItem.lastPublishedAt) : '尚无可用数据' }}</el-descriptions-item>
<el-descriptions-item label="两个月目标">{{ beijingTime(configItem.initialFrom) }} 至 {{ beijingTime(configItem.initialUntil) }}</el-descriptions-item> <el-descriptions-item label="首采目标范围">{{ beijingTime(configItem.initialFrom) }} 至 {{ beijingTime(configItem.initialUntil) }}</el-descriptions-item>
<el-descriptions-item label="首采完整覆盖">{{ label(configItem.initialCoverageStatus) }}</el-descriptions-item> <el-descriptions-item label="首采完整覆盖">{{ label(configItem.initialCoverageStatus) }}</el-descriptions-item>
<el-descriptions-item label="连续覆盖截至">{{ beijingTime(configItem.salesCoveredUntil) }}</el-descriptions-item> <el-descriptions-item label="连续覆盖截至">{{ beijingTime(configItem.salesCoveredUntil) }}</el-descriptions-item>
<el-descriptions-item label="数据新鲜度">{{ label(configItem.dataFreshness) }}</el-descriptions-item> <el-descriptions-item label="数据新鲜度">{{ label(configItem.dataFreshness) }}</el-descriptions-item>
@@ -306,6 +334,9 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
</AdminPage> </AdminPage>
</template> </template>
<style scoped> <style scoped>
.collection-secondary-tools > summary { cursor: pointer; color: var(--el-text-color-secondary); padding: var(--admin-space-3) 0; font-size: var(--admin-font-caption); }
.collection-task-no { overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.collection-focus { border-block: 1px solid var(--el-border-color); padding-block: var(--admin-space-4); margin-block: var(--admin-space-4); }
.collection-workspace { display: grid; min-width: 0; gap: var(--admin-space-6); } .collection-workspace { display: grid; min-width: 0; gap: var(--admin-space-6); }
.collection-summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-5); font-size: var(--admin-font-caption); } .collection-summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-5); font-size: var(--admin-font-caption); }
.collection-observed { margin-left: auto; color: var(--el-text-color-secondary); } .collection-observed { margin-left: auto; color: var(--el-text-color-secondary); }
@@ -16,7 +16,8 @@ const query = defineComponent({ props: ['modelValue', 'fields'], emits: ['update
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } }) const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
const dialog = defineComponent({ props: ['modelValue', 'width'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', [h('button', { onClick: () => undefined }, '趋势弹框'), slots.default?.()]) : null } }) const dialog = defineComponent({ props: ['modelValue', 'width'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', [h('button', { onClick: () => undefined }, '趋势弹框'), slots.default?.()]) : null } })
const error = defineComponent({ props: ['message'], setup(props) { return () => h('p', props.message) } }) const error = defineComponent({ props: ['message'], setup(props) { return () => h('p', props.message) } })
const options = { global: { directives: { loading: () => undefined }, stubs: { AdminPage: passthrough, QueryToolbar: query, AppDialog: dialog, LoadErrorState: error, ApiErrorMeta: true, SkuTrend: true, 'el-button': button, 'el-popover': true, 'el-table-column': true, 'el-table': true, 'el-option': true, 'el-select': true } } } const popover = defineComponent({ props: ['trigger', 'placement', 'title', 'width'], setup(_, { slots }) { return () => h('div', slots.reference?.()) } })
const options = { global: { directives: { loading: () => undefined }, stubs: { AdminPage: passthrough, QueryToolbar: query, AppDialog: dialog, LoadErrorState: error, ApiErrorMeta: true, SkuTrend: true, 'el-button': button, 'el-popover': popover, 'el-table-column': true, 'el-table': true, 'el-option': true, 'el-select': true } } }
const wrappers: ReturnType<typeof mount>[] = [] const wrappers: ReturnType<typeof mount>[] = []
function page() { const wrapper = mount(OverviewView, options); wrappers.push(wrapper); return wrapper } function page() { const wrapper = mount(OverviewView, options); wrappers.push(wrapper); return wrapper }
function findButton(wrapper: ReturnType<typeof mount>, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! } function findButton(wrapper: ReturnType<typeof mount>, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
@@ -86,6 +87,9 @@ describe('warehouse operating dashboard', () => {
expect(wrapper.find('.data-table__filters').findComponent(query).exists()).toBe(true) expect(wrapper.find('.data-table__filters').findComponent(query).exists()).toBe(true)
expect(wrapper.findComponent(query).attributes('preference-key')).toBe('self-operated-overview-query') expect(wrapper.findComponent(query).attributes('preference-key')).toBe('self-operated-overview-query')
expect(wrapper.find('el-table-stub').exists()).toBe(false) expect(wrapper.find('el-table-stub').exists()).toBe(false)
expect(wrapper.findComponent(popover).props()).toMatchObject({ trigger: 'click', placement: 'bottom-end', title: '统计口径与采集时间', width: 460 })
expect(wrapper.find('.data-table__toolbar-actions button[aria-label="统计口径与采集时间"]').exists()).toBe(true)
expect(wrapper.find('details.dashboard-data-notes').exists()).toBe(false)
}) })
it('automatically loads the first warehouse and keeps it selected on reset', async () => { it('automatically loads the first warehouse and keeps it selected on reset', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: '仓A' }, { warehouseCode: 'WH-A' }, { warehouseCode: 'WH-B' }] }) mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: '仓A' }, { warehouseCode: 'WH-A' }, { warehouseCode: 'WH-B' }] })
@@ -93,7 +97,7 @@ describe('warehouse operating dashboard', () => {
expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('WH-A') expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('WH-A')
expect(mocks.dashboard).toHaveBeenCalledTimes(1) expect(mocks.dashboard).toHaveBeenCalledTimes(1)
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' }) expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
expect(wrapper.find('.dashboard-data-notes').attributes('open')).toBeUndefined() expect(wrapper.find('.dashboard-data-notes').exists()).toBe(false)
await choose(wrapper, { rangePreset: 'TODAY' }) await choose(wrapper, { rangePreset: 'TODAY' })
wrapper.findComponent(query).vm.$emit('reset'); await flushPromises() wrapper.findComponent(query).vm.$emit('reset'); await flushPromises()
expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' }) expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import '@/styles/adminDensity.css' import '@/styles/adminDensity.css'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Activity, ArrowUpRight, Boxes, Package, Layers, Clock3 } from '@lucide/vue' import { Activity, ArrowUpRight, Boxes, Package, Layers, Clock3, Info } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue' import AdminPage from '@/shared/components/AdminPage.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue' import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -172,9 +172,14 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<DataTable appearance="integrated" :data="[]" :columns="[]" :results-visible="false" column-settings-mode="hide"> <DataTable appearance="integrated" :data="[]" :columns="[]" :results-visible="false" column-settings-mode="hide">
<template #toolbar-start><div class="dashboard-title"><h2>经营总览</h2><span class="dashboard-snapshot">{{ runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode }}<i />{{ loading ? '正在更新' : data ? '固定版本 · 北京时间' : '正在准备数据' }}</span></div></template> <template #toolbar-start><div class="dashboard-title"><h2>经营总览</h2><span class="dashboard-snapshot">{{ runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode }}<i />{{ loading ? '正在更新' : data ? '固定版本 · 北京时间' : '正在准备数据' }}</span></div></template>
<template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: appliedRange.rangePreset, ...(appliedRange.startDate ? { startDate: appliedRange.startDate, endDate: appliedRange.endDate! } : {}) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template> <template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: appliedRange.rangePreset, ...(appliedRange.startDate ? { startDate: appliedRange.startDate, endDate: appliedRange.endDate! } : {}) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template>
<template #toolbar-end>
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间">
<template #reference><el-button circle aria-label="统计口径与采集时间" title="统计口径与采集时间"><Info :size="16" aria-hidden="true" /></el-button></template>
<div class="dashboard-data-notes admin-density"><p>趋势范围只影响区间出库与日趋势,其余指标使用近30个完整日。</p><p v-if="data">销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p><p v-else>查询仓库数据后显示对应采集时间。</p></div>
</el-popover>
</template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" preference-key="self-operated-overview-query" ariaLabel="经营总览筛选" @search="load()" @reset="reset" /></template> <template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" preference-key="self-operated-overview-query" ariaLabel="经营总览筛选" @search="load()" @reset="reset" /></template>
</DataTable> </DataTable>
<details class="dashboard-data-notes"><summary>统计口径与采集时间</summary><p>趋势范围只影响区间出库与日趋势,其余指标使用近30个完整日。</p><p v-if="data">销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p></details>
<p v-if="!warehouseCode">请先选择一个仓库并查询经营数据。</p> <p v-if="!warehouseCode">请先选择一个仓库并查询经营数据。</p>
<p v-if="routeError" role="alert">{{ routeError }}。请返回正常入口重新选择仓库。</p> <p v-if="routeError" role="alert">{{ routeError }}。请返回正常入口重新选择仓库。</p>
<LoadErrorState v-if="error" title="经营总览查询未完成" :message="error" compact :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState> <LoadErrorState v-if="error" title="经营总览查询未完成" :message="error" compact :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
@@ -204,7 +209,6 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
.dashboard-page { display: grid; gap: var(--admin-space-3); } .dashboard-page { display: grid; gap: var(--admin-space-3); }
.dashboard-title { display: flex; align-items: center; gap: 16px; } .dashboard-title { display: flex; align-items: center; gap: 16px; }
.dashboard-data-notes { font-size: var(--admin-font-caption); color: #76849c; } .dashboard-data-notes { font-size: var(--admin-font-caption); color: #76849c; }
.dashboard-data-notes summary { cursor: pointer; width: fit-content; }
.dashboard-data-notes p { margin: 6px 0; } .dashboard-data-notes p { margin: 6px 0; }
.dashboard-snapshot { display: inline-flex; align-items: center; gap: 8px; color: #65748d; font-size: var(--admin-font-caption); } .dashboard-snapshot { display: inline-flex; align-items: center; gap: 8px; color: #65748d; font-size: var(--admin-font-caption); }
.dashboard-snapshot i { width: 7px; height: 7px; border-radius: 50%; background: #5575ed; } .dashboard-snapshot i { width: 7px; height: 7px; border-radius: 50%; background: #5575ed; }
@@ -50,6 +50,32 @@ beforeEach(() => {
}) })
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() }) afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() })
describe('self-operated actual workbench request state', () => { describe('self-operated actual workbench request state', () => {
it.each(['sales', 'inventory'] as const)('automatically loads the first available warehouse on %s entry', async mode => {
mocks.routeQuery = {}
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-B' }, { warehouseCode: 'WH-A' }] })
const wrapper = mount(AnalyticsView, { ...options, props: { mode } }); wrappers.push(wrapper); await flushPromises()
const api = mode === 'sales' ? mocks.api.summary : mocks.api.inventory
expect(wrapper.findComponent(queryStub).props('modelValue').warehouseCode).toBe('WH-B')
expect(api).toHaveBeenCalledTimes(1)
expect(api.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-B', current: 1 })
expect(wrapper.findComponent(tableStub).props('data')).toHaveLength(1)
})
it.each(['sales', 'inventory'] as const)('preserves the linked warehouse on %s entry', async mode => {
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-B' }, { warehouseCode: 'WH-A' }] })
const wrapper = mount(AnalyticsView, { ...options, props: { mode } }); wrappers.push(wrapper); await flushPromises()
const api = mode === 'sales' ? mocks.api.summary : mocks.api.inventory
expect(api).toHaveBeenCalledTimes(1)
expect(api.mock.calls[0]?.[0].warehouseCode).toBe('WH-A')
})
it.each(['sales', 'inventory'] as const)('defaults the warehouse when returning to a cached %s route without parameters', async mode => {
mocks.routeState = reactive({ path: `/self-operated/${mode}`, query: { warehouseCode: 'WH-A' } })
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-B' }] })
const wrapper = mount(AnalyticsView, { ...options, props: { mode } }); wrappers.push(wrapper); await flushPromises()
mocks.routeState.query = {}
await flushPromises()
const api = mode === 'sales' ? mocks.api.summary : mocks.api.inventory
expect(api.mock.calls.at(-1)?.[0].warehouseCode).toBe('WH-B')
})
it('uses one date range field, allows a same-day query, and isolates draft dates from pagination and trends', async () => { it('uses one date range field, allows a same-day query, and isolates draft dates from pagination and trends', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises() const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
await setFilters(wrapper, { rangePreset: 'CUSTOM' }) await setFilters(wrapper, { rangePreset: 'CUSTOM' })
@@ -308,7 +334,7 @@ describe('self-operated actual workbench request state', () => {
expect(mocks.api.owners).not.toHaveBeenCalled() expect(mocks.api.owners).not.toHaveBeenCalled()
expect(mocks.api.ownerOverview).not.toHaveBeenCalled() expect(mocks.api.ownerOverview).not.toHaveBeenCalled()
}) })
it('separates unselected warehouse, no results and failure; reset removes warehouse and query criteria', async () => { it('separates unavailable warehouses, no results and failure; reset retains warehouse and reloads', async () => {
mocks.routeQuery = {} mocks.routeQuery = {}
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper) const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises() await flushPromises()
@@ -322,9 +348,15 @@ describe('self-operated actual workbench request state', () => {
expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('查询失败,请重试') expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('查询失败,请重试')
expect(wrapper.find('[role="alert"]').text()).toContain('查询失败,请重新查询') expect(wrapper.find('[role="alert"]').text()).toContain('查询失败,请重新查询')
await setFilters(wrapper, { sku: 'SKU-A', attention: 'FROZEN_STOCK', threshold: '10' }) await setFilters(wrapper, { sku: 'SKU-A', attention: 'FROZEN_STOCK', threshold: '10' })
await findButton(wrapper, '重置').trigger('click'); await flushPromises() mocks.api.inventory.mockResolvedValue({ context, records: [metric], total: 1 })
expect(wrapper.findComponent(queryStub).props('modelValue')).toMatchObject({ warehouseCode: '', sku: '', attention: 'ALL', threshold: '' }) // The real toolbar clears fields synchronously before emitting reset.
expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('请选择仓库后查询SKU') const toolbar = wrapper.findComponent(queryStub)
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), warehouseCode: '', sku: '' })
toolbar.vm.$emit('reset')
await flushPromises()
expect(wrapper.findComponent(queryStub).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', sku: '', attention: 'ALL', threshold: '' })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ warehouseCode: 'WH-A', current: 1, attention: 'ALL' })
expect(wrapper.findComponent(tableStub).props('data')).toEqual([metric])
}) })
it('shows actual inventory daily sample gaps without inventing an accumulation date', async () => { it('shows actual inventory daily sample gaps without inventing an accumulation date', async () => {
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', protection: {}, inventoryDailySamples: { windowFrom: '2026-09-12', windowUntil: '2026-10-12', expectedDays: 30, sampleCount: 0, missingDates: ['2026-09-12'], accumulationStartedOn: null } } as WarehouseOverview] }) mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', protection: {}, inventoryDailySamples: { windowFrom: '2026-09-12', windowUntil: '2026-10-12', expectedDays: 30, sampleCount: 0, missingDates: ['2026-09-12'], accumulationStartedOn: null } } as WarehouseOverview] })
@@ -381,7 +413,7 @@ describe('self-operated actual workbench request state', () => {
mocks.api.retry.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ taskNo: task.taskNo, status: 'QUEUED' }) mocks.api.retry.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ taskNo: task.taskNo, status: 'QUEUED' })
const wrapper = mount(CollectionView, options); wrappers.push(wrapper) const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
await flushPromises() await flushPromises()
await findButton(wrapper, '详情 / 恢复').trigger('click'); await flushPromises() await findButton(wrapper, '查看详情').trigger('click'); await flushPromises()
expect(wrapper.find('[data-test="detail-dialog"]').exists()).toBe(true) expect(wrapper.find('[data-test="detail-dialog"]').exists()).toBe(true)
expect(wrapper.findAllComponents(dialog).find(item => item.props('modelValue'))?.props('title')).toContain('COL-A') expect(wrapper.findAllComponents(dialog).find(item => item.props('modelValue'))?.props('title')).toContain('COL-A')
await wrapper.find('textarea').setValue('已修复,恢复原任务') await wrapper.find('textarea').setValue('已修复,恢复原任务')
@@ -399,7 +431,7 @@ describe('self-operated actual workbench request state', () => {
mocks.canSync = false mocks.canSync = false
const wrapper = mount(CollectionView, options); wrappers.push(wrapper) const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
await flushPromises() await flushPromises()
await findButton(wrapper, '详情 / 恢复').trigger('click'); await flushPromises() await findButton(wrapper, '查看详情').trigger('click'); await flushPromises()
expect(wrapper.find('form').exists()).toBe(false) expect(wrapper.find('form').exists()).toBe(false)
expect(wrapper.text()).toContain('没有任务恢复权限') expect(wrapper.text()).toContain('没有任务恢复权限')
}) })
+10 -2
View File
@@ -160,6 +160,8 @@ export interface Progress {
sliceFrom: string | null sliceFrom: string | null
sliceUntil: string | null sliceUntil: string | null
progressUpdatedAt?: string | null progressUpdatedAt?: string | null
stageStartedAt?: string | null
stageFinishedAt?: string | null
stages?: Partial<Progress>[] stages?: Partial<Progress>[]
validationState?: string | null validationState?: string | null
invalidCount?: number | null invalidCount?: number | null
@@ -168,6 +170,7 @@ export interface Progress {
countUnit?: string | null countUnit?: string | null
summaryAsOf?: string | null summaryAsOf?: string | null
} }
export interface StageEstimate { status: 'AVAILABLE' | 'UNAVAILABLE' | 'PAUSED'; progressIdentity: string; completed: number | string | null; total: number | string | null; sampleFrom: string | null; sampleUntil: string | null; completedDelta: number | string | null; ratePerSecond: number | null; remainingSeconds: number | string | null; estimatedAt: string | null; validUntil: string | null; unavailableReason: string | null }
export interface Run { export interface Run {
attemptNo: number | null attemptNo: number | null
status: string status: string
@@ -183,6 +186,10 @@ export interface Run {
progress?: Partial<Progress> | null progress?: Partial<Progress> | null
} }
export interface Task { export interface Task {
stageEstimate?: StageEstimate | null
dependencyState?: 'NONE' | 'WAITING_CONFIRMED' | 'WAITING_UNCONFIRMED' | 'SATISFIED' | 'FAILED'
blockingReason?: string | null
progressSourceTaskNo?: string | null
taskNo: string taskNo: string
taskType: string taskType: string
dataFamily: string dataFamily: string
@@ -205,7 +212,7 @@ export interface Task {
validation?: Validation | null validation?: Validation | null
} }
export interface RuntimePage<T> { records: T[]; total: number; current: number; size: number; listUpperCreatedAt: string | null; listUpperId: string | null; observedAt: string } export interface RuntimePage<T> { records: T[]; total: number; current: number; size: number; listUpperCreatedAt: string | null; listUpperId: string | null; observedAt: string }
export interface RuntimeQuery extends Scope { current: number; size: number; taskNo?: string; listUpperCreatedAt?: string | null; listUpperId?: string | null } export interface RuntimeQuery extends Scope { current: number; size: number; dataFamily?: 'OUTBOUND' | 'INVENTORY'; taskNo?: string; listUpperCreatedAt?: string | null; listUpperId?: string | null }
export interface Protection { accountCooldownUntil: string | null; accountPauseReason: string | null; dailyRequestBudget: number | null; reservedHttpRequests: number | null; confirmedHttpRequests: number | null; budgetDate: string | null } export interface Protection { accountCooldownUntil: string | null; accountPauseReason: string | null; dailyRequestBudget: number | null; reservedHttpRequests: number | null; confirmedHttpRequests: number | null; budgetDate: string | null }
export interface Validation { validationSessionNo: string | null; budgetLimits: Record<string, unknown> | null; budgetConsumed: Record<string, unknown> | null; deadlineAt: string | null; stopReason: string | null } export interface Validation { validationSessionNo: string | null; budgetLimits: Record<string, unknown> | null; budgetConsumed: Record<string, unknown> | null; deadlineAt: string | null; stopReason: string | null }
export interface TaskDetail { task: Task; sharedUpstream: Task | null; protection: Protection; observedAt: string } export interface TaskDetail { task: Task; sharedUpstream: Task | null; protection: Protection; observedAt: string }
@@ -232,12 +239,13 @@ export interface WarehouseOverview {
rebuildRequired: boolean rebuildRequired: boolean
revisionReviewStatus: string | null revisionReviewStatus: string | null
protection: Protection protection: Protection
activeTasks?: Task[]
currentTask?: Task | null currentTask?: Task | null
sharedUpstream?: Task | null sharedUpstream?: Task | null
salesRunId?: string | null salesRunId?: string | null
inventoryRunId?: string | null inventoryRunId?: string | null
inventoryDailySamples?: { windowFrom: string; windowUntil: string; expectedDays: number; sampleCount: number; missingDates: string[]; accumulationStartedOn: string | null } | null inventoryDailySamples?: { windowFrom: string; windowUntil: string; expectedDays: number; sampleCount: number; missingDates: string[]; accumulationStartedOn: string | null } | null
} }
export interface Overview { sourceAccount: string; environmentLabel: string; collectionMode: string; observedAt: string; warehouses: WarehouseOverview[] } export interface Overview { sourceAccount: string; environmentLabel: string; collectionMode: string; observedAt: string; warehouses: WarehouseOverview[]; accountActivity?: Task | null }
export interface RetryCommand { action: 'RETRY'; requestKey: string; warehouseCode: string; taskNo: string; reason: string } export interface RetryCommand { action: 'RETRY'; requestKey: string; warehouseCode: string; taskNo: string; reason: string }
export interface RetryResult { taskNo: string; status: string } export interface RetryResult { taskNo: string; status: string }