生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #20

Merged
admin merged 63 commits from release/test into main 2026-10-16 18:03:19 +08:00
7 changed files with 186 additions and 25 deletions
Showing only changes of commit 6bdaff3fdf - Show all commits
@@ -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%,当前阶段)')
})
})
@@ -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<string, string> = { WAITING_SOURCE: '等待数据准备', WAITING_UPSTREAM: '等待数据准备', READY: '等待执行', QUEUED: '等待执行', WAITING: '等待执行', RUNNING: '正在执行', RETRY_WAIT: '等待重试', YIELDED: '等待请求额度', DEAD: '已停止,需处理', SUCCEEDED: '执行完成', FAILED: '执行失败' }
return states[task.taskStatus] ?? label(task.taskStatus)
}
export function taskProgressText(task: Task | null | undefined) {
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 '完成时间暂无法预计'
}
@@ -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()
})
})
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Task } from '../types'
import { beijingTime, label, quantityText, unitText } from '../state'
const props = defineProps<{ task: Task; scopeLabel: string }>()
const quality = computed(() => props.task.progress)
const hasQuality = computed(() => [quality.value?.validationState, quality.value?.invalidCount, quality.value?.unknownCount, quality.value?.pendingReviewCount, quality.value?.summaryAsOf].some(value => value != null))
</script>
<template>
<div class="task-execution-facts" :aria-label="`${scopeLabel}执行信息`">
<p v-if="task.latestAttempt">尝试开始 {{ beijingTime(task.latestAttempt.startedAt) }};结束 {{ task.latestAttempt.finishedAt ? beijingTime(task.latestAttempt.finishedAt) : ['FETCHING', 'READY', 'VALIDATED', 'BUILDING'].includes(task.latestAttempt.status) ? '尚未结束' : '未记录' }};耗时 {{ quantityText(task.latestAttempt.elapsedSeconds) }} 秒;心跳 {{ beijingTime(task.latestAttempt.heartbeatAt) }} · {{ label(task.latestAttempt.executionHealth) }}。</p>
<p v-else-if="task.attemptCount === 0">{{ scopeLabel }}尚未开始执行尝试,开始、结束、耗时和心跳尚未产生。<template v-if="task.taskStatus === 'WAITING_SOURCE'">正在等待前面的数据准备任务完成。</template></p>
<p v-else>{{ scopeLabel }}尝试详情尚不可用,暂无法显示开始、结束、耗时和心跳。</p>
<p v-if="hasQuality">数据质量:{{ label(quality?.validationState) }};异常 {{ quantityText(quality?.invalidCount) }},未知 {{ quantityText(quality?.unknownCount) }},待核对 {{ quantityText(quality?.pendingReviewCount) }}({{ unitText(quality?.countUnit) }});摘要时间 {{ beijingTime(quality?.summaryAsOf) }}。未计算的数量不默认为零。</p>
<p v-else>数据质量:尚未生成校验摘要,异常、未知及待核对数量尚未计算。</p>
</div>
</template>
<style scoped>
p { margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }
</style>
@@ -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,不改查询和权限语义;待人工截图确认 |
@@ -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 个日样本')
@@ -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<string, { task: Task; warehouseCodes: string[] }>()
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<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 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',
<ListPageLayout v-else>
<div class="collection-workspace">
<DataTable appearance="integrated" :data="warehouses" :columns="overviewColumns" :loading="loading" empty-text="暂无仓库采集状态">
<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 #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="采集仓库筛选" preference-key="self-operated-collection-query" :loading="loading" @search="refresh" @reset="refresh" /></template>
<template #notices>
@@ -192,38 +208,49 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
<span v-if="hasSyntheticSamples">包含合成测试样本,不代表真实领星采集覆盖。</span>
<span class="collection-observed">最近更新:{{ beijingTime(observedAt) }}(北京时间)</span>
</div>
<div v-for="entry in preparationTasks" :key="entry.task.taskNo" class="collection-preparation" role="status" aria-label="来源数据准备任务">
<strong>来源数据准备</strong><span>{{ entry.warehouseCodes.join('、') }} 使用这批数据</span><span>{{ taskStateText(entry.task) }}</span><span>{{ taskProgressText(entry.task) }}</span>
<small>任务 {{ entry.task.taskNo }};这里的单数是来源账号的数据准备进度,不是各仓出库量,也不是各仓完成率。</small>
</div>
</template>
<template #configuration="{ row }"><el-button class="density-action" @click="openConfiguration(asWarehouse(row))">查看配置</el-button></template>
<template #warehouse="{ row }">{{ asWarehouse(row).warehouseName || asWarehouse(row).warehouseCode }}</template>
<template #family="{ row }">{{ family(asWarehouse(row).dataFamily) }}</template>
<template #enabled="{ row }">{{ asWarehouse(row).executionEnabled ? '已启用' : '已关闭' }}</template>
<template #execution="{ row }">{{ label(asWarehouse(row).currentTask?.taskStatus) }}</template>
<template #publication="{ row }">{{ label(asWarehouse(row).currentTask?.resultPublicationState) }}<small class="collection-secondary">{{ asWarehouse(row).lastPublishedAt ? beijingTime(asWarehouse(row).lastPublishedAt) : '尚无可用结果' }}</small></template>
<template #execution="{ row }">{{ taskStateText(asWarehouse(row).currentTask) }}</template>
<template #currentProgress="{ row }">{{ taskProgressText(asWarehouse(row).currentTask) }}</template>
<template #completion="{ row }">{{ taskCompletionText(asWarehouse(row).currentTask) }}</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 }">{{ beijingTime(asWarehouse(row).dataFamily === 'INVENTORY' ? asWarehouse(row).inventoryObservedUntil : asWarehouse(row).salesCoveredUntil) }}</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) }} / {{ label(asTask(row).executionPurpose) }}</template>
<template #range="{ row }">{{ beijingTime(asTask(row).queryFrom) }} 至 {{ beijingTime(asTask(row).queryUntil) }}</template>
<template #status="{ row }">{{ label(asTask(row).taskStatus) }}</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 }">{{ progressText(asTask(row).progress) }}</template>
<template #health="{ row }">{{ beijingTime(asTask(row).latestAttempt?.progressUpdatedAt) }} / {{ label(asTask(row).latestAttempt?.executionHealth) }}</template>
<template #block="{ row }">{{ blockReason(asTask(row).actionBlockReason) }}</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>执行:{{ label(selected.taskStatus) }};发布:{{ label(selected.resultPublicationState) }};尝试 {{ selected.attemptCount }} 次;{{ progressText(selected.progress) }}</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>
<p>尝试开始 {{ beijingTime(selected.latestAttempt?.startedAt) }};结束 {{ beijingTime(selected.latestAttempt?.finishedAt) }};耗时 {{ quantityText(selected.latestAttempt?.elapsedSeconds) }} 秒;心跳 {{ beijingTime(selected.latestAttempt?.heartbeatAt) }} · {{ label(selected.latestAttempt?.executionHealth) }}。</p>
<p>数据质量:{{ label(selected.progress?.validationState) }};异常 {{ quantityText(selected.progress?.invalidCount) }},未知 {{ quantityText(selected.progress?.unknownCount) }},待核对 {{ quantityText(selected.progress?.pendingReviewCount) }}({{ unitText(selected.progress?.countUnit) }});摘要时间 {{ beijingTime(selected.progress?.summaryAsOf) }}。未计算的数量不默认为零。</p>
<p v-for="(stage, index) in selected.progress?.stages" :key="index">{{ progressText(stage) }}</p>
<p v-if="selectedDetail?.sharedUpstream">账号共享上游:{{ selectedDetail.sharedUpstream.taskNo }} · {{ label(selectedDetail.sharedUpstream.taskStatus) }} · {{ progressText(selectedDetail.sharedUpstream.progress) }}</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>
@@ -269,7 +296,7 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
</template>
<p v-else-if="configItem.dataFamily === 'INVENTORY'">每日库存观测积累状态尚无可用资料。</p>
<p v-if="configItem.currentTask?.taskStatus === 'FAILED' || configItem.currentTask?.taskStatus === 'DEAD'">本次失败,{{ configItem.lastPublishedAt ? '仍展示上次成功数据;请核对数据时效。' : '尚无可用数据。' }}</p>
<p v-if="configItem.sharedUpstream">账号共享阶段:{{ configItem.sharedUpstream.taskNo || '—' }} · {{ label(configItem.sharedUpstream.taskStatus) }} · {{ progressText(configItem.sharedUpstream.progress) }}。共享采集完成不代表本仓发布成功。</p>
<p v-if="configItem.sharedUpstream">数据准备任务:{{ configItem.sharedUpstream.taskNo || '—' }} · {{ taskStateText(configItem.sharedUpstream) }} · {{ taskProgressText(configItem.sharedUpstream) }}。来源数据取完后,本仓还需校验、计算和发布。</p>
<p>账号日预算 {{ quantityText(configItem.protection.dailyRequestBudget) }};已保留请求 {{ quantityText(configItem.protection.reservedHttpRequests) }};已确认 HTTP {{ quantityText(configItem.protection.confirmedHttpRequests) }};额度日期 {{ configItem.protection.budgetDate ?? '—' }}。</p>
<p>验证会话预算(含保守预留):<span v-for="(used, key) in validation(configItem.currentTask)?.budgetConsumed" :key="key">{{ budgetName(String(key)) }} {{ budgetValue(used) }} / {{ budgetValue(limitFor(String(key), validation(configItem.currentTask))) }}; </span><span v-if="!validation(configItem.currentTask)?.budgetConsumed">尚无预算观测</span></p>
<p v-if="validation(configItem.currentTask)">会话限制:<span v-for="(limit, key) in validation(configItem.currentTask)?.budgetLimits" :key="key">{{ budgetName(String(key)) }} {{ budgetValue(limit) }}; </span>最多 1 个在途,请求起始间隔至少 5 秒。验证样本不会发布正式结果。</p>
@@ -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); }
</style>