feat: focus collection page on account activity and progress

This commit is contained in:
2026-10-13 16:52:09 +08:00
parent 1de73b5f1c
commit bda0743520
14 changed files with 695 additions and 166 deletions
@@ -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 个日样本')
@@ -0,0 +1,72 @@
import { defineComponent, h, reactive } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import CollectionView from './CollectionView.vue'
import CollectionAccountMonitor from '../components/CollectionAccountMonitor.vue'
import type { Task, WarehouseOverview } from '../types'
const mocks = vi.hoisted(() => ({ overview: vi.fn(), tasks: vi.fn(), task: vi.fn(), runs: vi.fn(), retry: vi.fn(), push: vi.fn(), route: { path: '/self-operated/collection', query: {} as Record<string, string> } }))
vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
vi.mock('vue-router', () => ({ useRoute: () => mocks.route, useRouter: () => ({ push: mocks.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
const task: Task = { taskNo: 'TASK-A-FULL-NUMBER', warehouseCode: 'WH-A', dataFamily: 'OUTBOUND', taskType: 'BACKFILL', taskStatus: 'WAITING_SOURCE', dependencyState: 'WAITING_UNCONFIRMED', queryFrom: null, queryUntil: null, applyState: null, attemptCount: 0, latestAttemptNo: null, nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null, resultPublicationState: 'NOT_PUBLISHED' }
const shell = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } })
const table = defineComponent({ props: ['data', 'columns', 'total'], emits: ['update:page'], setup(props, { slots }) { return () => h('div', [slots.filters?.(), slots.notices?.(), ...(props.data ?? []).map((row: unknown) => h('div', [(props.columns ?? []).map((c: { slotName?: string }) => c.slotName ? slots[c.slotName]?.({ row }) : null)]))]) } })
const query = defineComponent({ props: ['modelValue', 'fields'], emits: ['search', 'update:modelValue'], setup() { return () => h('div') } })
const dialog = defineComponent({ props: ['modelValue', 'title'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-dialog': props.title }, slots.default?.()) : null } })
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
const options = { global: { stubs: { AdminPage: shell, ListPageLayout: shell, DataTable: table, QueryToolbar: query, AppDialog: dialog, 'el-button': button, 'el-input': true, 'el-descriptions': shell, 'el-descriptions-item': shell } } }
const wrappers: ReturnType<typeof mount>[] = []
function mountPage() { const w = mount(CollectionView, options); wrappers.push(w); return w }
beforeEach(() => {
vi.clearAllMocks(); mocks.route = reactive({ path: '/self-operated/collection', query: { warehouseCode: 'WH-A' } })
mocks.overview.mockResolvedValue({ sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'BUSINESS', observedAt: '2026-10-13T01:02:00Z', warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'OUTBOUND', activeTasks: [task], currentTask: task, protection: {} } as WarehouseOverview] })
mocks.tasks.mockResolvedValue({ records: [task], total: 21, current: 1, size: 10, listUpperCreatedAt: '2026-10-13T01:00:00Z', listUpperId: '10' })
mocks.task.mockImplementation(async (_, taskNo: string) => ({ task: { ...task, taskNo }, sharedUpstream: null, protection: {}, observedAt: '2026-10-13T01:02:00Z' }))
mocks.runs.mockResolvedValue({ records: [], total: 0 })
})
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers(); Object.defineProperty(document, 'hidden', { configurable: true, value: false }) })
describe('collection focus and one runtime polling scheduler', () => {
it('loads a deep-linked full task number without paging for it and retains focus when activity changes', async () => {
mocks.route.query.taskNo = 'HISTORICAL-FULL-NUMBER'
const w = mountPage(); await flushPromises()
expect(mocks.task.mock.calls[0]?.[1]).toBe('HISTORICAL-FULL-NUMBER')
expect(w.text()).toContain('HISTORICAL-FULL-NUMBER'); expect(w.text()).toContain('手动选择/历史记录')
expect(w.findAllComponents(table)[1]?.props('columns')).toHaveLength(6)
expect(mocks.runs).not.toHaveBeenCalled(); expect(w.find('form').exists()).toBe(false)
expect(w.find('.collection-secondary-tools').attributes('open')).toBeUndefined()
expect(w.findComponent(CollectionAccountMonitor).text()).not.toContain('HISTORICAL-FULL-NUMBER')
})
it('polls only runtime summaries at ten seconds, preserves history pages, and pauses while hidden', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const w = mountPage(); await flushPromises()
expect(mocks.tasks).toHaveBeenCalledTimes(1)
w.findAllComponents(table)[1]!.vm.$emit('update:page', 2); await flushPromises()
expect(mocks.tasks.mock.calls.at(-1)?.[0].current).toBe(2)
const pages = mocks.tasks.mock.calls.length
await vi.advanceTimersByTimeAsync(10000); await flushPromises()
expect(mocks.tasks).toHaveBeenCalledTimes(pages); expect(mocks.task.mock.calls.length).toBeGreaterThan(1)
Object.defineProperty(document, 'hidden', { configurable: true, value: true }); document.dispatchEvent(new Event('visibilitychange')); await flushPromises()
const reads = mocks.overview.mock.calls.length
await vi.advanceTimersByTimeAsync(60000); expect(mocks.overview).toHaveBeenCalledTimes(reads)
Object.defineProperty(document, 'hidden', { configurable: true, value: false }); document.dispatchEvent(new Event('visibilitychange')); await flushPromises()
expect(mocks.overview.mock.calls.length).toBeGreaterThan(reads); expect(mocks.tasks).toHaveBeenCalledTimes(pages)
})
it('does not fetch empty attempts or show a retry form for waiting tasks', async () => {
const w = mountPage(); await flushPromises()
const open = w.findAll('button').find(b => b.text() === '查看关注任务详情')!; await open.trigger('click'); await flushPromises()
expect(w.text()).toContain('本仓尚未开始执行;来源准备进度见上方')
expect(mocks.runs).not.toHaveBeenCalled(); expect(w.find('form').exists()).toBe(false)
expect(w.findAllComponents(table)).toHaveLength(2)
})
it('does not replace task selection with a late detail response and shows lookup failure explicitly', async () => {
let resolveOld!: (value: unknown) => void
mocks.task.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
const w = mountPage(); await flushPromises()
const q = w.findComponent(query); q.vm.$emit('update:modelValue', { warehouseCode: 'WH-A', taskNo: 'TASK-B', dataFamily: '' }); q.vm.$emit('search'); await flushPromises()
resolveOld({ task, sharedUpstream: null, protection: {}, observedAt: '2026-10-13T01:02:00Z' }); await flushPromises()
expect(w.find('.collection-focus')?.text()).toContain('TASK-B')
mocks.task.mockRejectedValue(new Error('指定仓库下找不到此任务'))
q.vm.$emit('update:modelValue', { warehouseCode: 'WH-A', taskNo: 'MISSING', dataFamily: '' }); q.vm.$emit('search'); await flushPromises()
expect(w.text()).toContain('任务首次读取失败,请核对仓库和完整任务号后重试'); expect(w.find('.collection-focus')?.text()).not.toContain('TASK-B')
})
})
+171 -140
View File
@@ -12,8 +12,10 @@ import AppDialog from '@/shared/components/AppDialog.vue'
import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { 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<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '' })
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', dataFamily: '', taskNo: typeof route.query.taskNo === 'string' ? route.query.taskNo : '' })
const dataFamily = computed(() => (filters.value.dataFamily || undefined) as 'OUTBOUND' | 'INVENTORY' | undefined)
const focusNo = ref(typeof route.query.taskNo === 'string' ? route.query.taskNo : '')
const manualFocus = ref(Boolean(focusNo.value))
const historyLoaded = ref(false)
const historyOpen = ref(false)
const visible = ref(!document.hidden)
const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
const overview = ref<Overview | null>(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<Task[]>([])
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<string | null>(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<ApiErrorDetails | null>(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<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)
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<string, { task: Task; warehouses: string[] }>()
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<QueryField[]>(() => [{ key: 'warehouseCode', label: '仓库', type: 'select', width: 230, placeholder: '全部仓库采集状态', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { label: row.warehouseName || row.warehouseCode, value: row.warehouseCode }])).values()] }])
const fields = computed<QueryField[]>(() => [
{ key: 'warehouseCode', label: '仓库', type: 'select', width: 230, placeholder: '全部仓库采集状态', options: [...new Map((overview.value?.warehouses ?? []).map(row => [row.warehouseCode, { label: row.warehouseName || row.warehouseCode, value: row.warehouseCode }])).values()] },
{ key: 'dataFamily', label: '采集内容', type: 'select', width: 160, options: [{ value: '', label: '全部采集内容' }, { value: 'OUTBOUND', label: '出库销量' }, { value: 'INVENTORY', label: '产品库存' }] },
{ key: 'taskNo', label: '任务号定位', type: 'input', width: 320, placeholder: '粘贴完整任务号;先选择仓库' },
])
const hasSyntheticSamples = computed(() => warehouses.value.some(item => item.warehouseCode.startsWith('SELFOP-TEST-')))
const 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<string, string>)[key] ?? '其他保护额度' }
function limitFor(used: string, value: Validation | null) { const key = ({ pages: 'maxPages', detailHttp: 'maxDetailHttp', http: 'maxHttp', confirmedHttp: 'maxHttp', distinctDetails: 'maxDistinctDetails' } as Record<string, string>)[used]; return key ? value?.budgetLimits?.[key] : null }
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',
<AdminPage class="admin-density">
<p v-if="!canList" role="alert">没有采集状态查看权限。</p>
<ListPageLayout v-else>
<CollectionAccountMonitor :overview="overview" :loading="loading" :stale="Boolean(error)" :visible="visible" @refresh="read()" />
<details class="collection-secondary-tools"><summary>历史记录与排查</summary>
<div class="collection-workspace">
<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>
<LoadErrorState v-if="error" title="采集状态更新失败" :message="error" :loading="loading" compact @retry="read"><template #details><p>状态观测时间 {{ beijingTime(observedAt) }}。任务运行失败与本页读取失败分别判断。</p><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<div class="collection-summary" role="status">
<span v-if="overview?.collectionMode === 'OFF'">自动采集已关闭,当前仅查看已留存结果。</span>
<span v-else>采集模式:{{ label(overview?.collectionMode) }}</span>
<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 }">{{ 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) }}<small class="collection-secondary">{{ asTask(row).taskType === 'BACKFILL' ? '补采历史数据' : asTask(row).taskType === 'SCHEDULED' ? '定时更新' : label(asTask(row).executionPurpose) }}</small></template>
<template #range="{ row }">{{ beijingTime(asTask(row).queryFrom) }}<br />至 {{ beijingTime(asTask(row).queryUntil) }}</template>
<template #status="{ row }">{{ taskStateText(asTask(row)) }}</template>
<template #publication="{ row }">{{ label(asTask(row).resultPublicationState) }}</template>
<template #progress="{ row }">{{ taskProgressText(asTask(row)) }}</template>
<template #completion="{ row }">{{ taskCompletionText(asTask(row)) }}</template>
</DataTable>
<AppDialog v-model="taskOpen" class="admin-density-dialog" :title="`采集任务详情 · ${selected?.taskNo || '—'}`" width="1200px" :loading="detailLoading" :confirm-loading="submitting" :show-confirm="false" cancel-text="关闭" fullscreenable>
<section v-if="selected" class="collection-details admin-density" aria-label="选中任务详情">
<h3>任务 {{ selected.taskNo || '—' }}</h3>
<p v-if="detailLoading">读取任务详情…</p><p v-if="detailError" role="alert">{{ detailError }}</p>
<p>状态:{{ taskStateText(selected) }};结果:{{ label(selected.resultPublicationState) }};已执行 {{ selected.attemptCount }} 次。</p><p>进度:{{ taskProgressText(selected) }};{{ taskCompletionText(selected) }}。</p>
<p>目标 {{ beijingTime(selected.queryFrom) }} 至 {{ beijingTime(selected.queryUntil) }};下次重试 {{ beijingTime(selected.nextRetryAt) }};{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : (selected.latestAttempt?.failureSummary ?? '') }}</p>
<TaskExecutionFacts :task="selected" scope-label="本仓任务" />
<details v-if="selected.progress?.stages?.length"><summary>分阶段记录</summary><p v-for="(stage, index) in selected.progress.stages" :key="index">{{ progressText(stage) }}</p></details>
<section v-if="selectedDetail?.sharedUpstream" class="collection-upstream" aria-label="数据准备任务详情">
<h3>数据准备任务:{{ selectedDetail.sharedUpstream.taskNo }}</h3>
<p>{{ taskStateText(selectedDetail.sharedUpstream) }};{{ taskProgressText(selectedDetail.sharedUpstream) }};{{ taskCompletionText(selectedDetail.sharedUpstream) }}。</p>
<TaskExecutionFacts :task="selectedDetail.sharedUpstream" scope-label="数据准备任务" />
<p>系统先读取来源账号的数据,再按仓库分别校验、计算和发布。以上是前面的数据准备进度,可能包含其他仓库;本仓是否成功以结果发布为准,不把该进度计入本仓尝试次数。</p>
</section>
<DataTable appearance="integrated" v-model:page="attemptsPage" v-model:page-size="attemptsSize" :data="attempts" :columns="runColumns" :total="attemptsTotal" :loading="detailLoading" empty-text="尚无执行尝试">
<template #status="{ row }">{{ label(asRun(row).status) }}</template><template #progress="{ row }">{{ progressText(asRun(row).progress) }}</template><template #startedAt="{ row }">{{ beijingTime(asRun(row).startedAt) }}</template><template #finishedAt="{ row }">{{ beijingTime(asRun(row).finishedAt) }}</template>
<DataTable appearance="integrated" :data="warehouses.filter(row => !dataFamily || row.dataFamily === dataFamily)" :columns="overviewColumns" :loading="loading" empty-text="暂无仓库采集状态">
<template #toolbar-start><h2>各仓数据可用情况</h2></template>
<template #toolbar-actions><el-button :disabled="!warehouseCode && !selected" @click="openAnalysis('/self-operated/sales')">查看销量</el-button><el-button :disabled="!warehouseCode && !selected" @click="openAnalysis('/self-operated/inventory')">查看库存</el-button></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="采集任务筛选" preference-key="self-operated-collection-query" :loading="loading" @search="refresh" @reset="refresh" /></template>
<template #notices>
<LoadErrorState v-if="error" title="采集状态更新失败" :message="error" :loading="loading" compact @retry="read"><template #details><p>最后有效观测 {{ beijingTime(observedAt) }};任务是否失败由服务端确认。</p><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<div class="collection-summary" role="status"><span>{{ overview?.collectionMode === 'OFF' ? '自动采集已关闭,当前仅查看已留存结果。' : `采集模式:${label(overview?.collectionMode)}` }}</span><span v-if="hasSyntheticSamples">包含合成测试样本,不代表真实采集覆盖。</span><span class="collection-observed">状态读取 {{ beijingTime(observedAt) }}(北京时间)</span></div>
<section class="collection-focus" aria-label="当前关注任务">
<div class="density-actions"><h2>{{ manualFocus ? '当前关注任务 · 手动选择/历史记录' : '当前关注任务' }}</h2><el-button v-if="selected" @click="taskOpen = true">查看关注任务详情</el-button><el-button v-if="manualFocus" @click="returnActivity">返回当前活动</el-button></div>
<p v-if="focusNo" class="collection-task-no">{{ focusNo }}</p>
<div v-if="activityTasks.length > 1" class="density-actions"><span>本范围 {{ activityTasks.length }} 个活动/等待任务</span><el-button v-for="task in activityTasks" :key="task.taskNo" :aria-label="`关注 ${task.taskNo}`" @click="selectTask(task)">{{ task.taskNo }}</el-button></div>
<p v-if="detailLoading && !selectedDetail">正在读取关注任务…</p><p v-if="detailError" role="alert">{{ detailError }}<ApiErrorMeta v-if="detailErrorDetails" :details="detailErrorDetails" /></p>
<CollectionTaskProgress v-if="selected" :task="selected" :detail="selectedDetail" :warehouse="focusWarehouse" :stale="Boolean(error || detailError)" :visible="visible" :observed-at="observedAt" />
<p v-else-if="!detailLoading && !detailError">{{ overview ? '暂无已确认的活动任务;可选择仓库后定位完整任务号或查看历史记录。' : '尚未读取采集任务。' }}</p>
</section>
<section v-for="source in sharedSources" :key="source.task.taskNo" aria-label="来源数据准备任务" class="collection-preparation">
<template v-if="source.task.taskNo === selectedDetail?.sharedUpstream?.taskNo">关注任务的关联来源</template><template v-else>关联来源 {{ source.task.taskNo }} · 账号共享 · {{ taskStateText(source.task) }} · {{ taskProgressText(source.task) }}</template>;关联仓库 {{ source.warehouses.join('、') }}。共享数量只记一次,不计入各仓完成量。
</section>
<p v-if="overview?.accountActivity && overview.accountActivity.taskNo !== selectedDetail?.sharedUpstream?.taskNo && !sharedSources.some(source => source.task.taskNo === overview?.accountActivity?.taskNo)" class="collection-preparation">账号正在执行其他采集:{{ overview.accountActivity.taskNo }} · {{ taskStateText(overview.accountActivity) }}。与当前任务的依赖尚未确认,不作为本任务进度。</p>
</template>
<template #configuration="{ row }"><el-button class="density-action" @click="openConfiguration(asWarehouse(row))">查看配置</el-button></template>
<template #warehouse="{ row }">{{ asWarehouse(row).warehouseName || asWarehouse(row).warehouseCode }}<small class="collection-secondary">{{ family(asWarehouse(row).dataFamily) }} · {{ asWarehouse(row).executionEnabled ? '采集已启用' : '采集已关闭' }}</small></template>
<template #execution="{ row }">{{ asWarehouse(row).activeTasks?.length ? `${asWarehouse(row).activeTasks!.length}个活动/等待任务` : '暂无已确认活动' }}<small v-if="asWarehouse(row).currentTask" class="collection-secondary">{{ taskStateText(asWarehouse(row).currentTask) }} · {{ taskProgressText(asWarehouse(row).currentTask) }}</small></template>
<template #publication="{ row }">{{ asWarehouse(row).lastPublishedAt ? beijingTime(asWarehouse(row).lastPublishedAt) : '尚无可用结果' }}<small class="collection-secondary">本次任务:{{ label(asWarehouse(row).currentTask?.resultPublicationState) }}</small></template>
<template #coverage="{ row }">{{ asWarehouse(row).dataFamily === 'INVENTORY' ? '库存观测截至' : '销量覆盖截至(不含)' }} {{ beijingTime(asWarehouse(row).dataFamily === 'INVENTORY' ? asWarehouse(row).inventoryObservedUntil : asWarehouse(row).salesCoveredUntil) }}<small class="collection-secondary">{{ label(asWarehouse(row).dataFreshness) }}</small></template>
<template #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>
<form v-if="canSync" class="density-form" @submit.prevent="submitRetry">
<label for="retry-reason">恢复原因</label><el-input id="retry-reason" v-model="reason" type="textarea" placeholder="请说明已完成的修复及恢复原任务的原因" :maxlength="500" :disabled="submitting" />
<p v-if="!selected.allowedActions.includes('RETRY') || selected.actionBlockReason">{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : '当前任务不允许恢复' }}</p>
<el-button v-if="!confirming" :disabled="!retryAllowed || submitting" @click="confirming = true">核对恢复请求</el-button>
<template v-else><p>确认恢复任务 {{ selected.taskNo }}(仓库 {{ selected.warehouseCode }}),范围保持不变,原因:{{ reason }}。受理后仍须等待执行与发布。</p><el-button :disabled="!retryAllowed || submitting" :loading="submitting" native-type="submit" type="primary">确认提交恢复请求</el-button><el-button :disabled="submitting" @click="confirming = false">返回修改</el-button></template>
</form>
<p v-else>没有任务恢复权限。</p><p v-if="actionFeedback" role="status">{{ actionFeedback }}</p>
</section>
</AppDialog>
<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 #identity="{ row }"><span class="collection-task-no">{{ asTask(row).taskNo }}</span><small class="collection-secondary">{{ asTask(row).warehouseCode }} · {{ family(asTask(row).dataFamily) }} · {{ label(asTask(row).executionPurpose) }}</small></template>
<template #range="{ row }">{{ shortDate(asTask(row).queryFrom) }} 至 {{ shortDate(asTask(row).queryUntil) }}<small class="collection-secondary">结束时刻不含;精确范围见详情</small></template>
<template #progress="{ row }">{{ taskStateText(asTask(row)) }}<small class="collection-secondary">{{ taskProgressText(asTask(row)) }}</small></template>
<template #advance="{ row }">{{ asTask(row).attemptCount ? beijingTime(asTask(row).progressUpdatedAt || asTask(row).progress?.progressUpdatedAt) : '本仓尚未开始执行' }}<small class="collection-secondary">{{ advanceAge(asTask(row)) }}</small></template>
<template #result="{ row }">{{ taskStateText(asTask(row)) }} · {{ label(asTask(row).resultPublicationState) }}</template>
</DataTable>
<AppDialog v-model="taskOpen" class="admin-density-dialog" :title="`采集任务详情 · ${selected?.taskNo || focusNo || '—'}`" width="1200px" :loading="detailLoading" :confirm-loading="submitting" :show-confirm="false" cancel-text="关闭" fullscreenable>
<section v-if="selected" class="collection-details admin-density" aria-label="选中任务详情">
<p v-if="detailError" role="alert">{{ detailError }}<ApiErrorMeta v-if="detailErrorDetails" :details="detailErrorDetails" /></p>
<CollectionTaskProgress :task="selected" :detail="selectedDetail" :warehouse="focusWarehouse" :stale="Boolean(error || detailError)" :visible="visible && taskOpen" :observed-at="observedAt" />
<p v-if="!Number(selected.attemptCount)">本仓尚未开始执行;来源准备进度见上方。</p>
<details v-else :open="historyOpen" @toggle="historyOpen = ($event.target as HTMLDetailsElement).open"><summary>执行记录({{ selected.attemptCount }}次)</summary>
<DataTable v-if="historyOpen" appearance="integrated" v-model:page="attemptsPage" v-model:page-size="attemptsSize" :data="attempts" :columns="runColumns" :total="attemptsTotal" empty-text="尚无已读取的执行记录">
<template #status="{ row }">{{ label(asRun(row).status) }}</template><template #progress="{ row }">{{ progressText(asRun(row).progress) }}</template><template #startedAt="{ row }">{{ beijingTime(asRun(row).startedAt) }}</template><template #finishedAt="{ row }">{{ beijingTime(asRun(row).finishedAt) }}</template>
</DataTable>
</details>
<details><summary>校验与诊断</summary><TaskExecutionFacts :task="selected" scope-label="本仓任务" /><p>操作资格:{{ selected.actionBlockReason ? blockReason(selected.actionBlockReason) : '以服务端允许操作为准' }};下次重试 {{ beijingTime(selected.nextRetryAt) }}。</p><p>账号冷却 {{ beijingTime(selectedDetail?.protection.accountCooldownUntil) }};{{ selectedDetail?.protection.accountPauseReason ?? '未提供暂停原因' }}</p><p v-for="(stage, index) in selected.progress?.stages ?? []" :key="index">{{ progressText(stage) }}</p><TaskExecutionFacts v-if="selectedDetail?.sharedUpstream && selected.progressSourceTaskNo === selectedDetail.sharedUpstream.taskNo" :task="selectedDetail.sharedUpstream" scope-label="账号共享来源" /></details>
<form v-if="retryEligible" class="density-form" @submit.prevent="submitRetry">
<label for="retry-reason">恢复原因</label><el-input id="retry-reason" v-model="reason" type="textarea" placeholder="请说明已完成的修复及恢复原任务的原因" :maxlength="500" :disabled="submitting" />
<el-button v-if="!confirming" :disabled="!retryAllowed || submitting" @click="confirming = true">核对恢复请求</el-button>
<template v-else><p>确认恢复任务 {{ selected.taskNo }}(仓库 {{ selected.warehouseCode }}),范围保持不变,原因:{{ reason }}。受理后仍须等待执行与发布。</p><el-button :disabled="!retryAllowed || submitting" :loading="submitting" native-type="submit" type="primary">确认提交恢复请求</el-button><el-button :disabled="submitting" @click="confirming = false">返回修改</el-button></template>
</form>
<p v-else-if="!canSync">没有任务恢复权限。</p><p v-if="actionFeedback" role="status">{{ actionFeedback }}</p>
</section>
</AppDialog>
</div>
</details>
</ListPageLayout>
<AppDialog v-model="configOpen" class="admin-density-dialog" :title="`${configItem?.warehouseName || configItem?.warehouseCode || '仓库'} · ${family(configItem?.dataFamily || '')}配置`" width="1120px" :show-confirm="false" cancel-text="关闭" fullscreenable>
<div class="admin-density">
@@ -278,7 +306,7 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
<el-descriptions-item label="发布结果">{{ label(configItem.currentTask?.resultPublicationState) }}</el-descriptions-item>
<el-descriptions-item label="最近成功">{{ configItem.lastSucceededAt ? beijingTime(configItem.lastSucceededAt) : '尚无成功记录' }}</el-descriptions-item>
<el-descriptions-item label="结果发布时间">{{ configItem.lastPublishedAt ? beijingTime(configItem.lastPublishedAt) : '尚无可用数据' }}</el-descriptions-item>
<el-descriptions-item label="两个月目标">{{ beijingTime(configItem.initialFrom) }} 至 {{ beijingTime(configItem.initialUntil) }}</el-descriptions-item>
<el-descriptions-item label="首采目标范围">{{ beijingTime(configItem.initialFrom) }} 至 {{ beijingTime(configItem.initialUntil) }}</el-descriptions-item>
<el-descriptions-item label="首采完整覆盖">{{ label(configItem.initialCoverageStatus) }}</el-descriptions-item>
<el-descriptions-item label="连续覆盖截至">{{ beijingTime(configItem.salesCoveredUntil) }}</el-descriptions-item>
<el-descriptions-item label="数据新鲜度">{{ label(configItem.dataFreshness) }}</el-descriptions-item>
@@ -306,6 +334,9 @@ onUnmounted(() => { suspend(); document.removeEventListener('visibilitychange',
</AdminPage>
</template>
<style scoped>
.collection-secondary-tools > summary { cursor: pointer; color: var(--el-text-color-secondary); padding: var(--admin-space-3) 0; font-size: var(--admin-font-caption); }
.collection-task-no { overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
.collection-focus { border-block: 1px solid var(--el-border-color); padding-block: var(--admin-space-4); margin-block: var(--admin-space-4); }
.collection-workspace { display: grid; min-width: 0; gap: var(--admin-space-6); }
.collection-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); }
@@ -16,7 +16,8 @@ const query = defineComponent({ props: ['modelValue', 'fields'], emits: ['update
const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } })
const dialog = defineComponent({ props: ['modelValue', 'width'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', [h('button', { onClick: () => undefined }, '趋势弹框'), slots.default?.()]) : null } })
const error = defineComponent({ props: ['message'], setup(props) { return () => h('p', props.message) } })
const options = { global: { directives: { loading: () => undefined }, stubs: { AdminPage: passthrough, QueryToolbar: query, AppDialog: dialog, LoadErrorState: error, ApiErrorMeta: true, SkuTrend: true, 'el-button': button, 'el-popover': true, 'el-table-column': true, 'el-table': true, 'el-option': true, 'el-select': true } } }
const popover = defineComponent({ props: ['trigger', 'placement', 'title', 'width'], setup(_, { slots }) { return () => h('div', slots.reference?.()) } })
const options = { global: { directives: { loading: () => undefined }, stubs: { AdminPage: passthrough, QueryToolbar: query, AppDialog: dialog, LoadErrorState: error, ApiErrorMeta: true, SkuTrend: true, 'el-button': button, 'el-popover': popover, 'el-table-column': true, 'el-table': true, 'el-option': true, 'el-select': true } } }
const wrappers: ReturnType<typeof mount>[] = []
function page() { const wrapper = mount(OverviewView, options); wrappers.push(wrapper); return wrapper }
function findButton(wrapper: ReturnType<typeof mount>, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
@@ -86,6 +87,9 @@ describe('warehouse operating dashboard', () => {
expect(wrapper.find('.data-table__filters').findComponent(query).exists()).toBe(true)
expect(wrapper.findComponent(query).attributes('preference-key')).toBe('self-operated-overview-query')
expect(wrapper.find('el-table-stub').exists()).toBe(false)
expect(wrapper.findComponent(popover).props()).toMatchObject({ trigger: 'click', placement: 'bottom-end', title: '统计口径与采集时间', width: 460 })
expect(wrapper.find('.data-table__toolbar-actions button[aria-label="统计口径与采集时间"]').exists()).toBe(true)
expect(wrapper.find('details.dashboard-data-notes').exists()).toBe(false)
})
it('automatically loads the first warehouse and keeps it selected on reset', async () => {
mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: '仓A' }, { warehouseCode: 'WH-A' }, { warehouseCode: 'WH-B' }] })
@@ -93,7 +97,7 @@ describe('warehouse operating dashboard', () => {
expect(wrapper.findComponent(query).props('modelValue').warehouseCode).toBe('WH-A')
expect(mocks.dashboard).toHaveBeenCalledTimes(1)
expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
expect(wrapper.find('.dashboard-data-notes').attributes('open')).toBeUndefined()
expect(wrapper.find('.dashboard-data-notes').exists()).toBe(false)
await choose(wrapper, { rangePreset: 'TODAY' })
wrapper.findComponent(query).vm.$emit('reset'); await flushPromises()
expect(wrapper.findComponent(query).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', rangePreset: 'LAST_15_DAYS' })
@@ -1,7 +1,7 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Activity, ArrowUpRight, Boxes, Package, Layers, Clock3 } from '@lucide/vue'
import { Activity, ArrowUpRight, Boxes, Package, Layers, Clock3, Info } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
@@ -172,9 +172,14 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<DataTable appearance="integrated" :data="[]" :columns="[]" :results-visible="false" column-settings-mode="hide">
<template #toolbar-start><div class="dashboard-title"><h2>经营总览</h2><span class="dashboard-snapshot">{{ runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode }}<i />{{ loading ? '正在更新' : data ? '固定版本 · 北京时间' : '正在准备数据' }}</span></div></template>
<template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: appliedRange.rangePreset, ...(appliedRange.startDate ? { startDate: appliedRange.startDate, endDate: appliedRange.endDate! } : {}) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template>
<template #toolbar-end>
<el-popover placement="bottom-end" trigger="click" :width="460" title="统计口径与采集时间">
<template #reference><el-button circle aria-label="统计口径与采集时间" title="统计口径与采集时间"><Info :size="16" aria-hidden="true" /></el-button></template>
<div class="dashboard-data-notes admin-density"><p>趋势范围只影响区间出库与日趋势,其余指标使用近30个完整日。</p><p v-if="data">销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p><p v-else>查询仓库数据后显示对应采集时间。</p></div>
</el-popover>
</template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" preference-key="self-operated-overview-query" ariaLabel="经营总览筛选" @search="load()" @reset="reset" /></template>
</DataTable>
<details class="dashboard-data-notes"><summary>统计口径与采集时间</summary><p>趋势范围只影响区间出库与日趋势,其余指标使用近30个完整日。</p><p v-if="data">销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p></details>
<p v-if="!warehouseCode">请先选择一个仓库并查询经营数据。</p>
<p v-if="routeError" role="alert">{{ routeError }}。请返回正常入口重新选择仓库。</p>
<LoadErrorState v-if="error" title="经营总览查询未完成" :message="error" compact :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
@@ -204,7 +209,6 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
.dashboard-page { display: grid; gap: var(--admin-space-3); }
.dashboard-title { display: flex; align-items: center; gap: 16px; }
.dashboard-data-notes { font-size: var(--admin-font-caption); color: #76849c; }
.dashboard-data-notes summary { cursor: pointer; width: fit-content; }
.dashboard-data-notes p { margin: 6px 0; }
.dashboard-snapshot { display: inline-flex; align-items: center; gap: 8px; color: #65748d; font-size: var(--admin-font-caption); }
.dashboard-snapshot i { width: 7px; height: 7px; border-radius: 50%; background: #5575ed; }
@@ -50,6 +50,32 @@ beforeEach(() => {
})
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() })
describe('self-operated actual workbench request state', () => {
it.each(['sales', 'inventory'] as const)('automatically loads the first available warehouse on %s entry', async mode => {
mocks.routeQuery = {}
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-B' }, { warehouseCode: 'WH-A' }] })
const wrapper = mount(AnalyticsView, { ...options, props: { mode } }); wrappers.push(wrapper); await flushPromises()
const api = mode === 'sales' ? mocks.api.summary : mocks.api.inventory
expect(wrapper.findComponent(queryStub).props('modelValue').warehouseCode).toBe('WH-B')
expect(api).toHaveBeenCalledTimes(1)
expect(api.mock.calls[0]?.[0]).toMatchObject({ warehouseCode: 'WH-B', current: 1 })
expect(wrapper.findComponent(tableStub).props('data')).toHaveLength(1)
})
it.each(['sales', 'inventory'] as const)('preserves the linked warehouse on %s entry', async mode => {
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-B' }, { warehouseCode: 'WH-A' }] })
const wrapper = mount(AnalyticsView, { ...options, props: { mode } }); wrappers.push(wrapper); await flushPromises()
const api = mode === 'sales' ? mocks.api.summary : mocks.api.inventory
expect(api).toHaveBeenCalledTimes(1)
expect(api.mock.calls[0]?.[0].warehouseCode).toBe('WH-A')
})
it.each(['sales', 'inventory'] as const)('defaults the warehouse when returning to a cached %s route without parameters', async mode => {
mocks.routeState = reactive({ path: `/self-operated/${mode}`, query: { warehouseCode: 'WH-A' } })
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-B' }] })
const wrapper = mount(AnalyticsView, { ...options, props: { mode } }); wrappers.push(wrapper); await flushPromises()
mocks.routeState.query = {}
await flushPromises()
const api = mode === 'sales' ? mocks.api.summary : mocks.api.inventory
expect(api.mock.calls.at(-1)?.[0].warehouseCode).toBe('WH-B')
})
it('uses one date range field, allows a same-day query, and isolates draft dates from pagination and trends', async () => {
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises()
await setFilters(wrapper, { rangePreset: 'CUSTOM' })
@@ -308,7 +334,7 @@ describe('self-operated actual workbench request state', () => {
expect(mocks.api.owners).not.toHaveBeenCalled()
expect(mocks.api.ownerOverview).not.toHaveBeenCalled()
})
it('separates unselected warehouse, no results and failure; reset removes warehouse and query criteria', async () => {
it('separates unavailable warehouses, no results and failure; reset retains warehouse and reloads', async () => {
mocks.routeQuery = {}
const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper)
await flushPromises()
@@ -322,9 +348,15 @@ describe('self-operated actual workbench request state', () => {
expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('查询失败,请重试')
expect(wrapper.find('[role="alert"]').text()).toContain('查询失败,请重新查询')
await setFilters(wrapper, { sku: 'SKU-A', attention: 'FROZEN_STOCK', threshold: '10' })
await findButton(wrapper, '重置').trigger('click'); await flushPromises()
expect(wrapper.findComponent(queryStub).props('modelValue')).toMatchObject({ warehouseCode: '', sku: '', attention: 'ALL', threshold: '' })
expect(wrapper.findComponent(tableStub).props('emptyText')).toBe('请选择仓库后查询SKU')
mocks.api.inventory.mockResolvedValue({ context, records: [metric], total: 1 })
// The real toolbar clears fields synchronously before emitting reset.
const toolbar = wrapper.findComponent(queryStub)
toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), warehouseCode: '', sku: '' })
toolbar.vm.$emit('reset')
await flushPromises()
expect(wrapper.findComponent(queryStub).props('modelValue')).toMatchObject({ warehouseCode: 'WH-A', sku: '', attention: 'ALL', threshold: '' })
expect(mocks.api.inventory.mock.calls.at(-1)?.[0]).toMatchObject({ warehouseCode: 'WH-A', current: 1, attention: 'ALL' })
expect(wrapper.findComponent(tableStub).props('data')).toEqual([metric])
})
it('shows actual inventory daily sample gaps without inventing an accumulation date', async () => {
mocks.api.overview.mockResolvedValue({ ...overview, warehouses: [{ warehouseCode: 'WH-A', dataFamily: 'INVENTORY', protection: {}, inventoryDailySamples: { windowFrom: '2026-09-12', windowUntil: '2026-10-12', expectedDays: 30, sampleCount: 0, missingDates: ['2026-09-12'], accumulationStartedOn: null } } as WarehouseOverview] })
@@ -381,7 +413,7 @@ describe('self-operated actual workbench request state', () => {
mocks.api.retry.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ taskNo: task.taskNo, status: 'QUEUED' })
const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
await flushPromises()
await findButton(wrapper, '详情 / 恢复').trigger('click'); await flushPromises()
await findButton(wrapper, '查看详情').trigger('click'); await flushPromises()
expect(wrapper.find('[data-test="detail-dialog"]').exists()).toBe(true)
expect(wrapper.findAllComponents(dialog).find(item => item.props('modelValue'))?.props('title')).toContain('COL-A')
await wrapper.find('textarea').setValue('已修复,恢复原任务')
@@ -399,7 +431,7 @@ describe('self-operated actual workbench request state', () => {
mocks.canSync = false
const wrapper = mount(CollectionView, options); wrappers.push(wrapper)
await flushPromises()
await findButton(wrapper, '详情 / 恢复').trigger('click'); await flushPromises()
await findButton(wrapper, '查看详情').trigger('click'); await flushPromises()
expect(wrapper.find('form').exists()).toBe(false)
expect(wrapper.text()).toContain('没有任务恢复权限')
})