diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue index c7cf5323..3b2fbee1 100644 --- a/src/modules/self-operated/components/AnalyticsView.vue +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -71,9 +71,12 @@ const tab = computed(() => (filters.value.analysis || 'summary') as 'summary' | const windowDays = computed(() => Number(filters.value.windowDays || 30) as 7 | 30) const sourceAccount = 'lingxing-primary' const warehouseCode = computed(() => String(filters.value.warehouseCode ?? '')) +let selectedWarehouse = warehouseCode.value +function defaultWarehouse() { return overview.value?.warehouses.find(row => row.warehouseCode)?.warehouseCode ?? '' } +watch(warehouseCode, code => { if (code) selectedWarehouse = code }) const fields = computed(() => { const result: QueryField[] = [ - { key: 'warehouseCode', type: 'select', label: '仓库', width: 210, placeholder: '请选择仓库(必选)', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] }, + { key: 'warehouseCode', type: 'select', label: '仓库', width: 210, placeholder: '请选择仓库(必选)', props: { clearable: false }, options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { value: row.warehouseCode, label: row.warehouseName || row.warehouseCode }])).values()] }, { key: 'sku', type: 'input', label: 'SKU', width: 230, placeholder: '输入SKU编码(精确查询)' }, ] if (props.mode === 'inventory') result.push( @@ -182,6 +185,7 @@ async function readRuntime() { const result = await analyticsApi.overview(sourceAccount, undefined, request.signal) if (!request.current()) return false overview.value = result + if (!warehouseCode.value && !routeProblem.value) filters.value.warehouseCode = defaultWarehouse() runtimeError.value = '' const today = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()) newDay.value = Boolean(context.value && context.value.anchorDate !== today) @@ -225,7 +229,13 @@ function search() { current.value = 1 void load(true, keepContext) } -function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } } +async function reset() { + applyingRoute = true + drilldown.value = null; origin.value = null + filters.value = { ...initialFilters(), warehouseCode: warehouseCode.value || selectedWarehouse || defaultWarehouse(), sku: '' } + appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } + await nextTick(); applyingRoute = false; void load(true) +} function returnOverview() { const saved = origin.value void router.push({ path: '/self-operated/overview', query: saved ? { warehouseCode: warehouseCode.value, context: JSON.stringify(saved.context), overviewReturn: JSON.stringify(saved.returnTo) } : {} }) @@ -254,6 +264,7 @@ watch(() => route.query, async () => { if (route.path && route.path !== `/self-operated/${props.mode}`) return applyingRoute = true; dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; rows.value = []; total.value = 0 readDrilldown(); context.value = drilldown.value?.context ?? null; filters.value = initialFilters(); appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: String(filters.value.sku ?? '') }; appliedInventory.value = { attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' }; current.value = 1 + if (!warehouseCode.value && !routeProblem.value) filters.value.warehouseCode = defaultWarehouse() await nextTick(); applyingRoute = false; void load() }) onActivated(() => { visibility(); poller.start() }) diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.test.ts b/src/modules/self-operated/components/CollectionAccountMonitor.test.ts new file mode 100644 index 00000000..3eda9d53 --- /dev/null +++ b/src/modules/self-operated/components/CollectionAccountMonitor.test.ts @@ -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() + }) +}) diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.vue b/src/modules/self-operated/components/CollectionAccountMonitor.vue new file mode 100644 index 00000000..4c7eebc8 --- /dev/null +++ b/src/modules/self-operated/components/CollectionAccountMonitor.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/modules/self-operated/components/CollectionProgressGauge.vue b/src/modules/self-operated/components/CollectionProgressGauge.vue new file mode 100644 index 00000000..e0f0a954 --- /dev/null +++ b/src/modules/self-operated/components/CollectionProgressGauge.vue @@ -0,0 +1,47 @@ + + + diff --git a/src/modules/self-operated/components/CollectionTaskProgress.test.ts b/src/modules/self-operated/components/CollectionTaskProgress.test.ts new file mode 100644 index 00000000..ecc00e22 --- /dev/null +++ b/src/modules/self-operated/components/CollectionTaskProgress.test.ts @@ -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[] = [] +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) + }) +}) diff --git a/src/modules/self-operated/components/CollectionTaskProgress.vue b/src/modules/self-operated/components/CollectionTaskProgress.vue new file mode 100644 index 00000000..14479ec4 --- /dev/null +++ b/src/modules/self-operated/components/CollectionTaskProgress.vue @@ -0,0 +1,95 @@ + + + diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index 91a2148b..5928de1f 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -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/overview | 查询、刷新与六项指标 | `docs/自营业务/经营总览图表设计.md`“菜单与页面布局”“指标带”;单仓+趋势日期QueryToolbar、紧凑指标带,整体固定版本 | 不新增货主控件;未知/过期独立提示;待人工1920×1080截图确认 | | /self-operated/overview | 日趋势、七桶与三个Top10 | 同设计“五张图表”;SVG折线与横条、趋势约2/3宽、分布约1/3宽,三榜并排 | 可聚焦提示、缺日断线、部分日文字、真实数值比例;待人工截图确认 | @@ -42,7 +44,21 @@ | 首次加载/更新 | 骨架加载、指标和图形入场;减少动态效果偏好下禁用动画 | 原版本、关闭取消、过期提示及权限保持;待人工截图确认 | | 顶部区域/默认仓库 | 用户补充要求顶部提示紧凑且默认展示仓库;标题与操作合并一行,统计口径和采集时间默认折叠;首次自动加载接口首个仓库,深链指定仓库优先,重置保留仓库 | 保留过期/失败提示及切仓入口;不硬编码仓库;待人工截图确认 | | 统一搜索/切仓 | 2026-10-13 用户以客户账户截图要求统一标题和右侧操作栏;复用 integrated DataTable 的 toolbar-start/toolbar-actions/filters,关闭结果区,不增加空表;QueryToolbar 接入筛选布局设置。切仓取消旧请求、清旧图后自动加载新仓,重置仍保留当前仓 | 自动查询只读本地聚合接口;验证迟到响应不覆盖、加载与重置;待人工截图确认 | +| 统计说明入口 | 用户最新截图要求统计口径与采集时间不占主屏;从独立折叠行移至右侧操作按钮旁的信息图标,点击打开460px说明气泡,保留窗口口径与本次查询采集时间 | 使用Element Plus Popover及可访问按钮名称;不隐藏真实过期/失败提示;待人工截图确认 | ## 数据采集样式统一(2026-10-12 当前截图确认) 用户确认销量、库存样式已完成,本轮以两页截图中的“顶部操作栏 → 灰底筛选带 → 提示区 → 表格”为样板,仅统一采集页。采集模式、合成样本声明及更新时间移入主表 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及幂等确认,仅提示已排队;自动测试与人工视觉分别记录 | diff --git a/src/modules/self-operated/pages/CollectionView.layout.test.ts b/src/modules/self-operated/pages/CollectionView.layout.test.ts index 161619d3..bf051a5f 100644 --- a/src/modules/self-operated/pages/CollectionView.layout.test.ts +++ b/src/modules/self-operated/pages/CollectionView.layout.test.ts @@ -38,29 +38,40 @@ async function page() { const wrapper = mount(CollectionView, setup); wrappers.p 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' } } }] }) + 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() expect(wrapper.text()).toContain('等待数据准备') - expect(wrapper.text()).toContain('792 / 2014单(39.3%,当前阶段)') - expect(wrapper.text()).toContain('不是各仓出库量,也不是各仓完成率') - expect(wrapper.text()).toContain('完成时间暂无法预计') - expect(wrapper.text()).toContain('上次结果:') + expect(wrapper.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('39.3') + expect(wrapper.find('[aria-label="当前阶段数量"]').text()).toContain('已完成792 单') + 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 }] }) + 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: 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() - 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) + const monitor = wrapper.find('[aria-label="领星账号运行进度"]') + expect(monitor.findAll('[role="progressbar"]')).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) }) 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.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('已留存 2 / 30 个日样本') diff --git a/src/modules/self-operated/pages/CollectionView.test.ts b/src/modules/self-operated/pages/CollectionView.test.ts new file mode 100644 index 00000000..43ebc42d --- /dev/null +++ b/src/modules/self-operated/pages/CollectionView.test.ts @@ -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 } })) +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[] = [] +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') + }) +}) diff --git a/src/modules/self-operated/pages/CollectionView.vue b/src/modules/self-operated/pages/CollectionView.vue index e58790cf..40b56a11 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 CollectionTaskProgress from '../components/CollectionTaskProgress.vue' +import CollectionAccountMonitor from '../components/CollectionAccountMonitor.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 { 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 canSync = computed(() => access.can('platform:self-operated-analytics:sync')) const sourceAccount = 'lingxing-primary' -const filters = ref({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '' }) +const filters = ref({ 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 overview = ref(null) 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 rows = ref([]) const current = ref(1) -const size = ref(20) +const size = ref(10) const total = ref(0) const upperBound = ref<{ listUpperCreatedAt: string | null; listUpperId: string | null } | null>(null) const observedAt = ref(null) @@ -48,55 +56,56 @@ const attemptsTotal = ref(0) const attemptsUpperBound = ref<{ listUpperCreatedAt: string | null; listUpperId: string | null } | null>(null) const detailLoading = ref(false) const detailError = ref('') +const detailErrorDetails = ref(null) const reason = ref('') const confirming = ref(false) const submitting = ref(false) const actionFeedback = ref('') const fence = new RequestFence() const detailFence = new RequestFence() +const historyFence = new RequestFence() 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) +const activityTasks = computed(() => warehouses.value.flatMap(item => item.activeTasks ?? []).filter(task => !dataFamily.value || task.dataFamily === dataFamily.value)) +const focusWarehouse = computed(() => overview.value?.warehouses.find(item => item.warehouseCode === selected.value?.warehouseCode && item.dataFamily === selected.value?.dataFamily) ?? null) +const sharedSources = computed(() => { + const sources = new Map() + for (const row of warehouses.value) { + if (dataFamily.value && row.dataFamily !== dataFamily.value) continue + const source = row.sharedUpstream + if (!source || !['WAITING_CONFIRMED', 'SATISFIED', 'FAILED'].includes(row.currentTask?.dependencyState ?? '')) continue + 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(() => [{ 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(() => [ + { 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 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: '当前状态', 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' }, + { key: 'configuration', label: '操作', width: 100, fixed: 'left', slotName: 'configuration' }, + { key: 'warehouse', label: '仓库/采集内容', minWidth: 160, slotName: 'warehouse' }, + { key: 'execution', label: '当前活动', minWidth: 220, slotName: 'execution', showOverflowTooltip: false }, + { key: 'publication', label: '最近可用数据', minWidth: 220, slotName: 'publication', showOverflowTooltip: false }, + { key: 'coverage', label: '覆盖/新鲜度', minWidth: 240, slotName: 'coverage', showOverflowTooltip: false }, { 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 columns: DataTableColumn[] = [ - { key: 'actions', label: '操作', width: 110, fixed: 'left', slotName: 'actions' }, - { prop: 'taskNo', label: '任务号', minWidth: 230 }, - { key: 'family', label: '采集内容', minWidth: 160, slotName: 'family' }, - { prop: 'warehouseCode', label: '仓库', minWidth: 120 }, - { 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: 'completion', label: '完成情况', minWidth: 170, slotName: 'completion' }, + { key: 'actions', label: '操作', width: 100, fixed: 'left', slotName: 'actions' }, + { key: 'identity', label: '任务/范围', minWidth: 265, slotName: 'identity', showOverflowTooltip: false }, + { key: 'range', label: '数据时间段', minWidth: 170, slotName: 'range', showOverflowTooltip: false }, + { key: 'progress', label: '当前阶段与进度', minWidth: 220, slotName: 'progress', showOverflowTooltip: false }, + { key: 'advance', label: '最近进展', minWidth: 180, slotName: 'advance', showOverflowTooltip: false }, + { key: 'result', label: '执行/发布结果', minWidth: 170, slotName: 'result', showOverflowTooltip: false }, ] const runColumns: DataTableColumn[] = [ { 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 asWarehouse(row: unknown) { return row as WarehouseOverview } 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 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 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)[key] ?? '其他保护额度' } function limitFor(used: string, value: Validation | null) { const key = ({ pages: 'maxPages', detailHttp: 'maxDetailHttp', http: 'maxHttp', confirmedHttp: 'maxHttp', distinctDetails: 'maxDistinctDetails' } as Record)[used]; return key ? value?.budgetLimits?.[key] : null } -async function read() { - if (!active || !canList.value) return false +async function read(refreshHistory = false) { + if (!active || !canList.value || !visible.value) return false const request = fence.begin() loading.value = !overview.value try { const nextOverview = await analyticsApi.overview(sourceAccount, undefined, request.signal) 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 - if (!request.current()) return false - overview.value = nextOverview - observedAt.value = nextOverview.observedAt - if (tasks) { rows.value = tasks.records; total.value = tasks.total; upperBound.value = { listUpperCreatedAt: tasks.listUpperCreatedAt, listUpperId: tasks.listUpperId } } - error.value = '' - errorDetails.value = null - return hasActiveTask([...rows.value, ...nextOverview.warehouses.flatMap(row => [row.currentTask, row.sharedUpstream].filter((task): task is Task => Boolean(task)))]) - } catch (cause) { if (request.current()) { errorDetails.value = getApiErrorDetails(cause, '状态更新失败,保留最后读取数据'); error.value = errorDetails.value.message }; return hasActiveTask(rows.value) } + overview.value = nextOverview; observedAt.value = nextOverview.observedAt + if (warehouseCode.value && (refreshHistory || !historyLoaded.value)) { + const tasks = await analyticsApi.tasks({ sourceAccount, warehouseCode: warehouseCode.value, dataFamily: dataFamily.value, current: current.value, size: size.value, ...upperBound.value }, request.signal) + if (!request.current()) return false + rows.value = tasks.records; total.value = tasks.total; historyLoaded.value = true + upperBound.value = { listUpperCreatedAt: tasks.listUpperCreatedAt, listUpperId: tasks.listUpperId } + } + error.value = ''; errorDetails.value = null + 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 } } -async function selectTask(task: Task, reset = true) { - if (!active || !task.taskNo || task.warehouseCode !== warehouseCode.value) return - 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 request = detailFence.begin() - detailLoading.value = true - detailError.value = '' +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)) } +async function readFocus() { + const taskNo = focusNo.value, warehouse = selected.value?.warehouseCode || warehouseCode.value + if (!active || !visible.value || !taskNo || !warehouse) return + const request = detailFence.begin(); detailLoading.value = true try { - const detail = await analyticsApi.task(scope.value, task.taskNo, request.signal) - if (!request.current()) return - const history = await analyticsApi.runs({ ...scope.value, taskNo: task.taskNo, current: attemptsPage.value, size: attemptsSize.value, ...attemptsUpperBound.value }, request.signal) - if (!request.current()) return - if (detail.task.taskNo !== task.taskNo || detail.task.warehouseCode !== warehouseCode.value) throw new Error('任务归属不匹配') - 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, '任务详情更新失败,保留上次详情') } + const detail = await analyticsApi.task({ sourceAccount, warehouseCode: warehouse }, taskNo, request.signal) + if (!request.current() || taskNo !== focusNo.value) return + if (detail.task.taskNo !== taskNo || detail.task.warehouseCode !== warehouse) throw new Error('任务归属不匹配,请核对所选仓库') + selected.value = detail.task; selectedDetail.value = detail; detailError.value = ''; detailErrorDetails.value = null + } catch (cause) { if (request.current()) { detailErrorDetails.value = getApiErrorDetails(cause, selectedDetail.value ? '关注任务状态更新失败,保留最后有效观测' : '任务首次读取失败,请核对仓库和完整任务号后重试'); detailError.value = detailErrorDetails.value.message } } 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() { if (!confirming.value || !retryAllowed.value || submitting.value || !selected.value) return const target = selected.value @@ -160,7 +191,7 @@ async function submitRetry() { submitting.value = true actionFeedback.value = '' 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 (accepted.taskNo !== target.taskNo) throw new Error('受理任务号不匹配') retryIntent.accepted() @@ -171,21 +202,26 @@ async function submitRetry() { } catch (cause) { if (request.current()) actionFeedback.value = getApiErrorMessage(cause, '恢复请求失败;再次提交相同内容将沿用原幂等键') } 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() }) -function visibility() { poller.setVisible(!document.hidden) } -function refresh() { upperBound.value = null; current.value = 1; void read() } -function openAnalysis(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value } }) } +const poller = new RuntimePoller(() => read(), () => { fence.cancel(); detailFence.cancel(); historyFence.cancel() }) +function visibility() { visible.value = !document.hidden; poller.setVisible(visible.value) } +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 || selected.value?.warehouseCode } }) } watch([() => route.path, () => route.query], () => { 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(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([current, size], () => { void read() }) -watch([attemptsPage, attemptsSize], () => { if (selected.value) void selectTask(selected.value, false) }) +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) { historyFence.cancel(); historyOpen.value = false; confirming.value = false; reason.value = ''; actionFeedback.value = '' } }) +watch([current, size], () => { historyLoaded.value = false; void read(true) }) +watch([attemptsPage, attemptsSize], () => { void readAttempts() }) +watch(historyOpen, open => { if (open) void readAttempts(); else historyFence.cancel() }) watch(reason, () => { confirming.value = false }) 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) }) onActivated(resume) onDeactivated(suspend) @@ -195,75 +231,67 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',

没有采集状态查看权限。

+ +
历史记录与排查
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

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

-

读取任务详情…

{{ detailError }}

-

状态:{{ 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 ?? '') }}

- -
分阶段记录

{{ progressText(stage) }}

-
-

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

-

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

- -

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

-
- - + + + + + + + + + + + -
- -

{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : '当前任务不允许恢复' }}

- 核对恢复请求 - - -

没有任务恢复权限。

{{ actionFeedback }}

-
-
+ + + + + + + + + + + +
+

{{ detailError }}

+ +

本仓尚未开始执行;来源准备进度见上方。

+
执行记录({{ selected.attemptCount }}次) + + + +
+
校验与诊断

操作资格:{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : '以服务端允许操作为准' }};下次重试 {{ beijingTime(selected.nextRetryAt) }}。

账号冷却 {{ beijingTime(selectedDetail?.protection.accountCooldownUntil) }};{{ selectedDetail?.protection.accountPauseReason ?? '未提供暂停原因' }}

{{ progressText(stage) }}

+
+ + 核对恢复请求 + + +

没有任务恢复权限。

{{ actionFeedback }}

+
+
+
@@ -278,7 +306,7 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange', {{ label(configItem.currentTask?.resultPublicationState) }} {{ configItem.lastSucceededAt ? beijingTime(configItem.lastSucceededAt) : '尚无成功记录' }} {{ configItem.lastPublishedAt ? beijingTime(configItem.lastPublishedAt) : '尚无可用数据' }} - {{ beijingTime(configItem.initialFrom) }} 至 {{ beijingTime(configItem.initialUntil) }} + {{ beijingTime(configItem.initialFrom) }} 至 {{ beijingTime(configItem.initialUntil) }} {{ label(configItem.initialCoverageStatus) }} {{ beijingTime(configItem.salesCoveredUntil) }} {{ label(configItem.dataFreshness) }} @@ -306,6 +334,9 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',