From 6bdaff3fdfac8c66040e992a9f6cb2934e99d482 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 12:50:29 +0800 Subject: [PATCH] fix(selfop): distinguish preparation progress from warehouse tasks --- .../collectionPresentation.test.ts | 23 ++++++ .../self-operated/collectionPresentation.ts | 29 +++++++ .../components/TaskExecutionFacts.test.ts | 35 +++++++++ .../components/TaskExecutionFacts.vue | 20 +++++ src/modules/self-operated/operation-matrix.md | 2 + .../pages/CollectionView.layout.test.ts | 25 +++++- .../self-operated/pages/CollectionView.vue | 77 +++++++++++++------ 7 files changed, 186 insertions(+), 25 deletions(-) create mode 100644 src/modules/self-operated/collectionPresentation.test.ts create mode 100644 src/modules/self-operated/collectionPresentation.ts create mode 100644 src/modules/self-operated/components/TaskExecutionFacts.test.ts create mode 100644 src/modules/self-operated/components/TaskExecutionFacts.vue diff --git a/src/modules/self-operated/collectionPresentation.test.ts b/src/modules/self-operated/collectionPresentation.test.ts new file mode 100644 index 00000000..9106b009 --- /dev/null +++ b/src/modules/self-operated/collectionPresentation.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import type { Task } from './types' +import { taskStateText, taskProgressText, taskCompletionText } from './collectionPresentation' +const base: Task = { taskNo: 'T-A', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'RUNNING', applyState: 'NOT_STARTED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: { phase: 'DETAIL', completed: 792, total: 2014, totalKnown: true, unit: 'ORDER' }, queryFrom: null, queryUntil: null } +describe('plain collection task presentation', () => { + it('distinguishes data preparation, waiting, stopped and running', () => { + expect(taskStateText(base)).toBe('正在执行') + expect(taskStateText({ ...base, taskStatus: 'WAITING_SOURCE' })).toBe('等待数据准备') + expect(taskStateText({ ...base, taskStatus: 'READY' })).toBe('等待执行') + expect(taskStateText({ ...base, taskStatus: 'DEAD' })).toBe('已停止,需处理') + expect(taskProgressText({ ...base, taskStatus: 'WAITING_SOURCE', attemptCount: 0 })).toBe('尚未开始,等待数据准备') + }) + it('shows a stage percentage without presenting it as overall completion', () => { + expect(taskProgressText(base)).toBe('获取出库详情,已处理 792 / 2014单(39.3%,当前阶段)') + expect(taskCompletionText(base)).toBe('完成时间暂无法预计') + expect(taskCompletionText({ ...base, taskStatus: 'SUCCEEDED' })).toBe('执行完成,结果尚未发布') + expect(taskCompletionText({ ...base, resultPublicationState: 'PUBLISHED' })).toBe('结果已可用') + }) + it('does not create percentages for unknown totals, zero denominators or repeated-scan counts above the total', () => { + for (const progress of [{ ...base.progress, totalKnown: false }, { ...base.progress, total: 0 }, { ...base.progress, completed: 2015 }]) expect(taskProgressText({ ...base, progress })).not.toContain('%') + expect(taskProgressText({ ...base, progress: { ...base.progress, completed: 0 } })).toContain('0 / 2014单(0%,当前阶段)') + }) +}) diff --git a/src/modules/self-operated/collectionPresentation.ts b/src/modules/self-operated/collectionPresentation.ts new file mode 100644 index 00000000..512e6b57 --- /dev/null +++ b/src/modules/self-operated/collectionPresentation.ts @@ -0,0 +1,29 @@ +import type { Task } from './types' +import { label, phaseText, quantityText, unitText } from './state' + +export function taskStateText(task: Task | null | undefined) { + if (!task) return '尚无任务' + const states: Record = { WAITING_SOURCE: '等待数据准备', WAITING_UPSTREAM: '等待数据准备', READY: '等待执行', QUEUED: '等待执行', WAITING: '等待执行', RUNNING: '正在执行', RETRY_WAIT: '等待重试', YIELDED: '等待请求额度', DEAD: '已停止,需处理', SUCCEEDED: '执行完成', FAILED: '执行失败' } + return states[task.taskStatus] ?? label(task.taskStatus) +} +export function taskProgressText(task: Task | null | undefined) { + if (!task) return '尚无任务' + if (['WAITING_SOURCE', 'WAITING_UPSTREAM'].includes(task.taskStatus)) return '尚未开始,等待数据准备' + if (task.attemptCount === 0 && ['READY', 'QUEUED', 'WAITING'].includes(task.taskStatus)) return '尚未开始,等待执行' + const progress = task.progress + if (!progress?.phase) return task.taskStatus === 'SUCCEEDED' ? '本任务执行完成' : '当前进度尚未提供' + const phase = phaseText(progress.phase) + if (progress.completed == null || !progress.unit || progress.unit === 'NONE') return phase + const amount = `${quantityText(progress.completed)}${unitText(progress.unit)}` + if (!progress.totalKnown || progress.total == null) return `${phase},已处理 ${amount},总量尚未确定` + const total = Number(progress.total), completed = Number(progress.completed) + const percent = Number.isFinite(total) && Number.isFinite(completed) && total > 0 && completed >= 0 && completed <= total ? `(${Math.floor(completed / total * 1000) / 10}%,当前阶段)` : '' + return `${phase},已处理 ${quantityText(progress.completed)} / ${quantityText(progress.total)}${unitText(progress.unit)}${percent}` +} +export function taskCompletionText(task: Task | null | undefined) { + if (!task) return '—' + if (task.resultPublicationState === 'PUBLISHED' || task.resultPublicationState === 'REUSED') return '结果已可用' + if (task.taskStatus === 'SUCCEEDED') return '执行完成,结果尚未发布' + if (['DEAD', 'FAILED', 'ABORTED'].includes(task.taskStatus)) return '已停止,需先处理异常' + return '完成时间暂无法预计' +} diff --git a/src/modules/self-operated/components/TaskExecutionFacts.test.ts b/src/modules/self-operated/components/TaskExecutionFacts.test.ts new file mode 100644 index 00000000..3ae9154a --- /dev/null +++ b/src/modules/self-operated/components/TaskExecutionFacts.test.ts @@ -0,0 +1,35 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import TaskExecutionFacts from './TaskExecutionFacts.vue' +import type { Task } from '../types' +import { hasActiveTask, label } from '../state' +const waiting: Task = { taskNo: 'LOCAL-A', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'WAITING_SOURCE', applyState: 'NOT_STARTED', attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: 'TASK_NOT_RETRYABLE', progress: null, queryFrom: null, queryUntil: null } +describe('collection execution facts', () => { + it('explains why a waiting warehouse has no attempt times or quality counts and keeps polling active', () => { + const wrapper = mount(TaskExecutionFacts, { props: { task: waiting, scopeLabel: '本仓任务' } }) + expect(wrapper.text()).toContain('本仓任务尚未开始执行尝试') + expect(wrapper.text()).toContain('正在等待前面的数据准备任务完成') + expect(wrapper.text()).toContain('尚未生成校验摘要') + expect(wrapper.text()).not.toContain('耗时 0 秒') + expect(label(waiting.taskStatus)).toBe('等待来源数据') + expect(hasActiveTask([waiting])).toBe(true) + wrapper.unmount() + }) + it('renders real upstream attempt times and preserves zero counts separately from missing counts', () => { + const upstream: Task = { ...waiting, taskNo: 'SHARED-A', taskStatus: 'RUNNING', attemptCount: 1, progress: { validationState: 'VALIDATING', invalidCount: 0, unknownCount: null, pendingReviewCount: 2, countUnit: 'ORDER', summaryAsOf: '2026-10-13T01:02:00Z' }, latestAttempt: { attemptNo: 1, status: 'FETCHING', phase: 'DETAIL', startedAt: '2026-10-13T01:00:00Z', finishedAt: null, progressUpdatedAt: '2026-10-13T01:02:00Z', heartbeatAt: '2026-10-13T01:02:00Z', executionHealth: 'HEALTHY', elapsedSeconds: 120, errorCode: null, failureSummary: null } } + const wrapper = mount(TaskExecutionFacts, { props: { task: upstream, scopeLabel: '账号共享上游' } }) + expect(wrapper.text()).toContain('2026/10/13 09:00:00') + expect(wrapper.text()).toContain('结束 尚未结束') + expect(wrapper.text()).toContain('耗时 120 秒') + expect(wrapper.text()).toContain('心跳 2026/10/13 09:02:00 · 正常') + expect(wrapper.text()).toContain('异常 0,未知 —,待核对 2(单)') + expect(wrapper.text()).not.toContain('尚未生成校验摘要') + wrapper.unmount() + }) + it('does not describe an unavailable recorded attempt as never started', () => { + const wrapper = mount(TaskExecutionFacts, { props: { task: { ...waiting, attemptCount: 1, taskStatus: 'DEAD' }, scopeLabel: '本仓任务' } }) + expect(wrapper.text()).toContain('尝试详情尚不可用') + expect(wrapper.text()).not.toContain('尚未开始执行尝试') + wrapper.unmount() + }) +}) diff --git a/src/modules/self-operated/components/TaskExecutionFacts.vue b/src/modules/self-operated/components/TaskExecutionFacts.vue new file mode 100644 index 00000000..c3d2d8c2 --- /dev/null +++ b/src/modules/self-operated/components/TaskExecutionFacts.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index a3c0a6f5..b1807ff9 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -21,6 +21,8 @@ | /self-operated/collection | 运行概览、逻辑任务列表与分页 | 本轮截图确认各仓20字段描述表重复展开;用户进一步确认“展示列表,配置项直接做成弹框”。主表集中仓库/类型/开关/执行/发布/覆盖/时效,各行查看配置打开只读AppDialog,保留预算、验证和覆盖字段 | 操作列首列;不新增配置写接口;关闭采集与已有结果分别表达,合成测试仓明确提示;待人工截图确认 | | /self-operated/collection | 任务详情和历史尝试 | 用户最新要求检查三页列表下方详情;改为1200px居中任务详情AppDialog,包含尝试历史 | 可关闭/全屏;保留任务业务号和分页;待人工截图确认 | | /self-operated/collection | 本仓/共享上游执行时间与校验摘要 | 2026-10-13 用户截图:本仓尝试0次、共享上游执行中。本仓明确说明等待来源且尚未产生执行时间;上游单独显示接口已有的 latestAttempt 时间/心跳,质量未生成时明确说明,不冒充本仓尝试 | WAITING_SOURCE 显示等待来源数据并维持活跃轮询;真实0与未知区分;前端修改,不发布后端;待人工截图确认 | +| /self-operated/collection | 当前状态/任务记录可读性 | 用户补充截图:同仓多条销量任务容易误认为重复执行。上表明确当前状态、处理阶段与完成情况;下表明确是按数据时间段的当前及历史任务记录,不在前端合并或删除分页任务。共享上游在产品文案中称数据准备任务,说明先取来源数据、再分别处理各仓 | 进度比例仅有可靠阶段分母时展示,不作为全流程完成率;没有可靠耗时模型时明确无法预计完成时间;保留真实任务号、时间段和独立恢复权限;不部署后端 | +| /self-operated/collection | 两仓来源进度重复展示修正 | 用户指出US01与US02销量行均显示1218/2014。此前把同一来源账号准备进度重复放入仓库行,修正为独立“来源数据准备”区域,按真实taskNo仅展示一次;仓库行仅消费currentTask,不使用sharedUpstream计数回退 | 关联仓库只作适用范围说明;不把来源单数当单仓出库量,不估算或分摊两仓计数;本仓等待状态及独立库存进度保留;不部署后端 | | /self-operated/collection | 恢复任务确认 | 原因表单与显式确认步骤移入任务弹框,RETRY单一命令不变 | 提交中锁定关闭;独立sync权限、资格及幂等不变;待人工截图确认 | | 三页 | 新增/编辑/审核/删除/文件预览 | 不在一期页面业务范围 | 不创建操作入口 | | 三页 | 公共搜索栏样式 | 用户提供异常订单公共搜索栏截图并要求自营统一;QueryToolbar放入集成DataTable的filters插槽 | 复用公共灰底筛选带、控件及查询/重置按钮,启用筛选布局设置;不复制CSS,不改查询和权限语义;待人工截图确认 | diff --git a/src/modules/self-operated/pages/CollectionView.layout.test.ts b/src/modules/self-operated/pages/CollectionView.layout.test.ts index 3e6989b9..161619d3 100644 --- a/src/modules/self-operated/pages/CollectionView.layout.test.ts +++ b/src/modules/self-operated/pages/CollectionView.layout.test.ts @@ -2,7 +2,7 @@ import { defineComponent, h } from 'vue' import { flushPromises, mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import CollectionView from './CollectionView.vue' -import type { Overview, WarehouseOverview } from '../types' +import type { Overview, Task, WarehouseOverview } from '../types' const mocks = vi.hoisted(() => ({ overview: vi.fn(), tasks: vi.fn(), runs: vi.fn(), task: vi.fn(), retry: vi.fn(), push: vi.fn(), warehouse: '' })) vi.mock('../api/analytics', () => ({ analyticsApi: mocks })) @@ -15,7 +15,7 @@ const dialog = defineComponent({ props: ['modelValue', 'title', 'showConfirm'], } }) const table = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [ h('div', { 'data-test': 'toolbar' }, slots['toolbar-actions']?.()), h('div', { 'data-test': 'filters' }, slots.filters?.()), h('div', { 'data-test': 'notices' }, slots.notices?.()), - ...(props.data ?? []).map((row: object) => h('div', [slots.configuration?.({ row }), slots.warehouse?.({ row }), slots.family?.({ row }), slots.enabled?.({ row }), slots.execution?.({ row }), slots.publication?.({ row }), slots.coverage?.({ row }), slots.freshness?.({ row }), slots.dailySamples?.({ row })])), + ...(props.data ?? []).map((row: object) => h('div', [slots.configuration?.({ row }), slots.warehouse?.({ row }), slots.family?.({ row }), slots.enabled?.({ row }), slots.execution?.({ row }), slots.currentProgress?.({ row }), slots.completion?.({ row }), slots.publication?.({ row }), slots.coverage?.({ row }), slots.freshness?.({ row }), slots.dailySamples?.({ row })])), !(props.data ?? []).length ? props.emptyText : null, ]) } }) const setup = { global: { stubs: { AdminPage: pass, ListPageLayout: pass, QueryToolbar: true, DataTable: table, AppDialog: dialog, @@ -36,10 +36,31 @@ beforeEach(() => { vi.clearAllMocks(); mocks.warehouse = ''; mocks.overview.mock afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) }) async function page() { const wrapper = mount(CollectionView, setup); wrappers.push(wrapper); await flushPromises(); return wrapper } describe('collection status list and per-row configuration dialog', () => { + 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 } + 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' } } }] }) + const wrapper = await page() + expect(wrapper.text()).toContain('等待数据准备') + expect(wrapper.text()).toContain('792 / 2014单(39.3%,当前阶段)') + 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 () => { + 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' } } + 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 wrapper = await page() + expect(wrapper.findAll('[aria-label="来源数据准备任务"]')).toHaveLength(1) + expect(wrapper.find('[aria-label="来源数据准备任务"]').text()).toContain('US01、US02') + expect(wrapper.text().match(/1218 \/ 2014/g)).toHaveLength(1) + expect(wrapper.text().match(/尚未开始,等待数据准备/g)).toHaveLength(2) + }) it('compares all warehouses in one compact table and distinguishes OFF from retained results', async () => { const wrapper = await page() 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').find((column: { key: string }) => column.key === 'enabled')).toMatchObject({ label: '采集开关' }) expect(wrapper.text()).toContain('自动采集已关闭,当前仅查看已留存结果') expect(wrapper.text()).toContain('包含合成测试样本') expect(wrapper.text()).toContain('已留存 2 / 30 个日样本') diff --git a/src/modules/self-operated/pages/CollectionView.vue b/src/modules/self-operated/pages/CollectionView.vue index 852669c4..e58790cf 100644 --- a/src/modules/self-operated/pages/CollectionView.vue +++ b/src/modules/self-operated/pages/CollectionView.vue @@ -12,8 +12,10 @@ import AppDialog from '@/shared/components/AppDialog.vue' import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { analyticsApi } from '../api/analytics' +import TaskExecutionFacts from '../components/TaskExecutionFacts.vue' +import { taskStateText, taskProgressText, taskCompletionText } from '../collectionPresentation' import type { Overview, Run, Task, TaskDetail, Validation, WarehouseOverview } from '../types' -import { RequestFence, RetryIntent, RuntimePoller, hasActiveTask, label, beijingTime, reasonsText, unitText, progressText, quantityText, taskRetryAllowed } from '../state' +import { RequestFence, RetryIntent, RuntimePoller, hasActiveTask, label, beijingTime, reasonsText, progressText, quantityText, taskRetryAllowed } from '../state' const access = useAccessPreviewStore() const route = useRoute() @@ -56,14 +58,28 @@ const actionFence = new RequestFence() const retryIntent = new RetryIntent() let active = false const warehouses = computed(() => overview.value?.warehouses.filter(row => !warehouseCode.value || row.warehouseCode === warehouseCode.value) ?? []) +const preparationTasks = computed(() => { + const visible = new Set(warehouses.value.map(item => item.sharedUpstream?.taskNo).filter(Boolean)) + const tasks = new Map() + for (const item of overview.value?.warehouses ?? []) { + const task = item.sharedUpstream + if (!task?.taskNo || !visible.has(task.taskNo)) continue + let entry = tasks.get(task.taskNo) + if (!entry) { entry = { task, warehouseCodes: [] }; tasks.set(task.taskNo, entry) } + if (!entry.warehouseCodes.includes(item.warehouseCode)) entry.warehouseCodes.push(item.warehouseCode) + } + return [...tasks.values()] +}) const fields = computed(() => [{ 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 hasSyntheticSamples = computed(() => warehouses.value.some(item => item.warehouseCode.startsWith('SELFOP-TEST-'))) const overviewColumns: DataTableColumn[] = [ { key: 'configuration', label: '操作', width: 110, fixed: 'left', slotName: 'configuration' }, { prop: 'warehouseName', label: '仓库', minWidth: 160, slotName: 'warehouse' }, { key: 'family', label: '数据类型', width: 110, slotName: 'family' }, - { key: 'enabled', label: '自动采集', width: 100, slotName: 'enabled' }, - { key: 'execution', label: '最近执行', width: 110, slotName: 'execution' }, + { key: 'enabled', label: '采集开关', width: 100, slotName: 'enabled' }, + { key: 'execution', label: '当前状态', minWidth: 150, slotName: 'execution' }, + { 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' }, @@ -74,14 +90,13 @@ const retryAllowed = computed(() => taskRetryAllowed(selected.value, canSync.val const columns: DataTableColumn[] = [ { key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, { prop: 'taskNo', label: '任务号', minWidth: 230 }, - { key: 'family', label: '任务类型 / 用途', minWidth: 160, slotName: 'family' }, + { key: 'family', label: '采集内容', minWidth: 160, slotName: 'family' }, { prop: 'warehouseCode', label: '仓库', minWidth: 120 }, - { key: 'range', label: '目标范围(北京时间)', minWidth: 280, slotName: 'range' }, - { key: 'status', label: '执行结果', minWidth: 130, slotName: 'status' }, + { key: 'range', label: '本任务数据时间段(北京时间)', minWidth: 280, slotName: 'range', showOverflowTooltip: false }, + { key: 'status', label: '任务状态', minWidth: 150, slotName: 'status' }, { key: 'publication', label: '发布结果', minWidth: 150, slotName: 'publication' }, - { key: 'progress', label: '阶段 / 可靠计数', minWidth: 310, slotName: 'progress' }, - { key: 'health', label: '最近进展 / 心跳', minWidth: 220, slotName: 'health' }, - { key: 'block', label: '阻断原因', minWidth: 210, slotName: 'block' }, + { key: 'progress', label: '当前阶段与进度', minWidth: 310, slotName: 'progress' }, + { key: 'completion', label: '完成情况', minWidth: 170, slotName: 'completion' }, ] const runColumns: DataTableColumn[] = [ { prop: 'attemptNo', label: '尝试序号', width: 100 }, @@ -182,6 +197,7 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
+ - - + + + + + + - - - + + + - - - + +

任务 {{ selected.taskNo || '—' }}

读取任务详情…

{{ detailError }}

-

执行:{{ label(selected.taskStatus) }};发布:{{ label(selected.resultPublicationState) }};尝试 {{ selected.attemptCount }} 次;{{ progressText(selected.progress) }}

+

状态:{{ taskStateText(selected) }};结果:{{ label(selected.resultPublicationState) }};已执行 {{ selected.attemptCount }} 次。

进度:{{ taskProgressText(selected) }};{{ taskCompletionText(selected) }}。

目标 {{ beijingTime(selected.queryFrom) }} 至 {{ beijingTime(selected.queryUntil) }};下次重试 {{ beijingTime(selected.nextRetryAt) }};{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : (selected.latestAttempt?.failureSummary ?? '') }}

-

尝试开始 {{ beijingTime(selected.latestAttempt?.startedAt) }};结束 {{ beijingTime(selected.latestAttempt?.finishedAt) }};耗时 {{ quantityText(selected.latestAttempt?.elapsedSeconds) }} 秒;心跳 {{ beijingTime(selected.latestAttempt?.heartbeatAt) }} · {{ label(selected.latestAttempt?.executionHealth) }}。

-

数据质量:{{ label(selected.progress?.validationState) }};异常 {{ quantityText(selected.progress?.invalidCount) }},未知 {{ quantityText(selected.progress?.unknownCount) }},待核对 {{ quantityText(selected.progress?.pendingReviewCount) }}({{ unitText(selected.progress?.countUnit) }});摘要时间 {{ beijingTime(selected.progress?.summaryAsOf) }}。未计算的数量不默认为零。

-

{{ progressText(stage) }}

-

账号共享上游:{{ selectedDetail.sharedUpstream.taskNo }} · {{ label(selectedDetail.sharedUpstream.taskStatus) }} · {{ progressText(selectedDetail.sharedUpstream.progress) }}

+ +
分阶段记录

{{ progressText(stage) }}

+
+

数据准备任务:{{ selectedDetail.sharedUpstream.taskNo }}

+

{{ taskStateText(selectedDetail.sharedUpstream) }};{{ taskProgressText(selectedDetail.sharedUpstream) }};{{ taskCompletionText(selectedDetail.sharedUpstream) }}。

+ +

系统先读取来源账号的数据,再按仓库分别校验、计算和发布。以上是前面的数据准备进度,可能包含其他仓库;本仓是否成功以结果发布为准,不把该进度计入本仓尝试次数。

+
@@ -269,7 +296,7 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',

每日库存观测积累状态尚无可用资料。

本次失败,{{ configItem.lastPublishedAt ? '仍展示上次成功数据;请核对数据时效。' : '尚无可用数据。' }}

-

账号共享阶段:{{ configItem.sharedUpstream.taskNo || '—' }} · {{ label(configItem.sharedUpstream.taskStatus) }} · {{ progressText(configItem.sharedUpstream.progress) }}。共享采集完成不代表本仓发布成功。

+

数据准备任务:{{ configItem.sharedUpstream.taskNo || '—' }} · {{ taskStateText(configItem.sharedUpstream) }} · {{ taskProgressText(configItem.sharedUpstream) }}。来源数据取完后,本仓还需校验、计算和发布。

账号日预算 {{ quantityText(configItem.protection.dailyRequestBudget) }};已保留请求 {{ quantityText(configItem.protection.reservedHttpRequests) }};已确认 HTTP {{ quantityText(configItem.protection.confirmedHttpRequests) }};额度日期 {{ configItem.protection.budgetDate ?? '—' }}。

验证会话预算(含保守预留):{{ budgetName(String(key)) }} {{ budgetValue(used) }} / {{ budgetValue(limitFor(String(key), validation(configItem.currentTask))) }}; 尚无预算观测

会话限制:{{ budgetName(String(key)) }} {{ budgetValue(limit) }}; 最多 1 个在途,请求起始间隔至少 5 秒。验证样本不会发布正式结果。

@@ -283,8 +310,12 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange', .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-secondary { display: block; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); } +.collection-history-note { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); } +.collection-preparation { display: flex; flex-wrap: wrap; align-items: center; gap: var(--admin-space-3); padding-top: var(--admin-space-3); margin-top: var(--admin-space-3); border-top: 1px solid var(--el-border-color); } +.collection-preparation small { flex-basis: 100%; } .collection-details summary, .collection-gaps summary { cursor: pointer; font-size: var(--admin-font-caption); } .collection-details h3 { font-size: var(--admin-font-section); margin: var(--admin-space-5) 0; } .collection-details p { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; } .collection-details section + section { margin-top: var(--admin-space-5); } +.collection-upstream { border-block: 1px solid var(--el-border-color); padding-block: var(--admin-space-3); }