diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.test.ts b/src/modules/self-operated/components/CollectionAccountMonitor.test.ts index 3eda9d53..48608611 100644 --- a/src/modules/self-operated/components/CollectionAccountMonitor.test.ts +++ b/src/modules/self-operated/components/CollectionAccountMonitor.test.ts @@ -12,6 +12,15 @@ function overview(account: Task | null = source): Overview { } const options = { global: { stubs: { 'el-button': true } } } describe('single account collection monitor', () => { + it('does not claim idle or an empty queue when the first status read fails', () => { + const w = mount(CollectionAccountMonitor, { ...options, props: { overview: null, stale: true } }) + expect(w.text()).toContain('暂未取得账号状态') + expect(w.text()).toContain('暂未取得安排') + expect(w.text()).not.toContain('待机') + expect(w.text()).not.toContain('保留最后一次') + expect(w.text()).not.toContain('暂无其他待处理项') + w.unmount() + }) 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) diff --git a/src/modules/self-operated/components/CollectionAccountMonitor.vue b/src/modules/self-operated/components/CollectionAccountMonitor.vue index 4c7eebc8..c85bfe9c 100644 --- a/src/modules/self-operated/components/CollectionAccountMonitor.vue +++ b/src/modules/self-operated/components/CollectionAccountMonitor.vue @@ -1,15 +1,16 @@ diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index 5928de1f..cc7c4e36 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -4,7 +4,7 @@ | 页面 | 操作 | 依据与承载结论 | 布局与验证 | | --- | --- | --- | --- | -| /self-operated/collection | 单账号运行首屏 | 2026-10-13 最新实际页面及原型截图:首屏改为账号活动优先,环形进度、总量/完成/剩余及阶段预计时间;历史任务定位不替换首屏,原筛选、配置、历史与诊断默认折叠 | 沿用浅色Admin样式与既有详情弹框;后续流程不冒充调度顺序,有界待处理摘要不冒充完整队列;待人工1920×1080、100%截图确认 | +| /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截图确认 | diff --git a/src/modules/self-operated/pages/CollectionView.layout.test.ts b/src/modules/self-operated/pages/CollectionView.layout.test.ts index bf051a5f..85cb9e53 100644 --- a/src/modules/self-operated/pages/CollectionView.layout.test.ts +++ b/src/modules/self-operated/pages/CollectionView.layout.test.ts @@ -1,122 +1,24 @@ -import { defineComponent, h } from 'vue' -import { flushPromises, mount } from '@vue/test-utils' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import CollectionView from './CollectionView.vue' -import type { Overview, Task, WarehouseOverview } from '../types' +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import CollectionAccountMonitor from '../components/CollectionAccountMonitor.vue' +import type { Overview, WarehouseOverview } from '../types' -const mocks = vi.hoisted(() => ({ overview: vi.fn(), tasks: vi.fn(), runs: vi.fn(), task: vi.fn(), retry: vi.fn(), push: vi.fn(), warehouse: '' })) -vi.mock('../api/analytics', () => ({ analyticsApi: mocks })) -vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) -vi.mock('vue-router', () => ({ useRoute: () => ({ query: { warehouseCode: mocks.warehouse } }), useRouter: () => ({ push: mocks.push }) })) -const pass = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } }) -const button = defineComponent({ props: ['disabled'], emits: ['click'], setup(props, { emit, slots }) { return () => h('button', { disabled: props.disabled, onClick: () => emit('click') }, slots.default?.()) } }) -const dialog = defineComponent({ props: ['modelValue', 'title', 'showConfirm'], emits: ['update:modelValue'], setup(props, { emit, slots }) { - return () => props.modelValue ? h('section', { 'data-test': 'configuration-dialog' }, [h('h2', props.title), slots.default?.(), h('button', { onClick: () => emit('update:modelValue', false) }, '关闭')]) : null -} }) -const table = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [ - h('div', { 'data-test': 'toolbar' }, slots['toolbar-actions']?.()), h('div', { 'data-test': 'filters' }, slots.filters?.()), h('div', { 'data-test': 'notices' }, slots.notices?.()), - ...(props.data ?? []).map((row: object) => h('div', [slots.configuration?.({ row }), slots.warehouse?.({ row }), slots.family?.({ row }), slots.enabled?.({ row }), slots.execution?.({ row }), slots.currentProgress?.({ row }), slots.completion?.({ row }), slots.publication?.({ row }), slots.coverage?.({ row }), slots.freshness?.({ row }), slots.dailySamples?.({ row })])), - !(props.data ?? []).length ? props.emptyText : null, -]) } }) -const setup = { global: { stubs: { AdminPage: pass, ListPageLayout: pass, QueryToolbar: true, DataTable: table, AppDialog: dialog, - LoadErrorState: true, ApiErrorMeta: true, 'el-button': button, 'el-descriptions': pass, 'el-descriptions-item': pass, 'el-input': true } } } -const stock: WarehouseOverview = { - warehouseCode: 'SELFOP-TEST-A', warehouseName: '合成验收仓A', dataFamily: 'INVENTORY', executionEnabled: false, publicationEnabled: false, - disabledReason: 'COLLECTION_OFF', nextScheduledAt: null, observedAt: '2026-10-12T13:00:00Z', initialFrom: null, initialUntil: null, - initialCoverageStatus: 'COMPLETE', salesCoveredFrom: null, salesCoveredUntil: null, lastSuccessfulTaskNo: null, lastSucceededAt: null, - lastPublishedAt: '2026-10-12T12:47:00Z', inventoryObservedFrom: '2026-10-12T12:40:00Z', inventoryObservedUntil: '2026-10-12T12:45:00Z', - dataFreshness: 'VALID', rebuildRequired: false, revisionReviewStatus: 'CURRENT', - protection: { accountCooldownUntil: null, accountPauseReason: null, dailyRequestBudget: 0, reservedHttpRequests: null, confirmedHttpRequests: null, budgetDate: null }, - inventoryDailySamples: { windowFrom: '2026-09-12', windowUntil: '2026-10-12', expectedDays: 30, sampleCount: 2, missingDates: ['2026-10-10'], accumulationStartedOn: '2026-10-09' }, -} -const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'test', collectionMode: 'OFF', observedAt: stock.observedAt, - warehouses: [{ ...stock, dataFamily: 'OUTBOUND', inventoryDailySamples: null, salesCoveredUntil: stock.inventoryObservedUntil }, stock] } -const wrappers: ReturnType[] = [] -beforeEach(() => { vi.clearAllMocks(); mocks.warehouse = ''; mocks.overview.mockResolvedValue(overview); mocks.tasks.mockResolvedValue({ records: [], total: 0, listUpperCreatedAt: null, listUpperId: null }) }) -afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) }) -async function page() { const wrapper = mount(CollectionView, setup); wrappers.push(wrapper); await flushPromises(); return wrapper } -describe('collection status list and per-row configuration dialog', () => { - it('shows preparation progress separately from a warehouse waiting to start', async () => { - const waiting: Task = { taskNo: 'LOCAL-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', warehouseCode: stock.warehouseCode, taskStatus: 'WAITING_SOURCE', applyState: 'NOT_STARTED', queryFrom: null, queryUntil: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null } - 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.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 } - 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() - 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')).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 个日样本') - expect(wrapper.text()).not.toContain('账号日预算') - expect(wrapper.find('[data-test="configuration-dialog"]').exists()).toBe(false) - expect(mocks.tasks).not.toHaveBeenCalled() - const notices = wrapper.find('[data-test="notices"]') - expect(notices.text()).toContain('自动采集已关闭') - expect(notices.text()).toContain('包含合成测试样本') - expect(notices.find('.collection-observed').text()).toContain('北京时间') - const links = wrapper.find('[data-test="toolbar"]').findAll('button') - expect(links.map(item => item.text())).toEqual(['查看销量', '查看库存']) - expect(links.every(item => item.attributes('disabled') !== undefined)).toBe(true) - }) - it('opens only the chosen warehouse family and keeps gaps folded, with no configuration write', async () => { - const wrapper = await page() - const buttons = wrapper.findAll('button').filter(item => item.text() === '查看配置') - await buttons[1]?.trigger('click'); await flushPromises() - const modal = wrapper.find('[data-test="configuration-dialog"]') - expect(modal.text()).toContain('合成验收仓A · 产品库存配置') - expect(modal.text()).toContain('账号日预算 0') - expect(modal.text()).toContain('数据积累起点 2026-10-09') - expect(modal.find('details').attributes('open')).toBeUndefined() - expect(wrapper.findComponent(dialog).props('showConfirm')).toBe(false) - await modal.findAll('button').find(item => item.text() === '关闭')?.trigger('click') - expect(wrapper.find('[data-test="configuration-dialog"]').exists()).toBe(false) - expect(mocks.retry).not.toHaveBeenCalled() - expect(mocks.overview).toHaveBeenCalledTimes(1) - }) - it('preserves zero daily samples and an unknown accumulation date in the dialog', async () => { - mocks.overview.mockResolvedValue({ ...overview, warehouses: [{ ...stock, inventoryDailySamples: { ...stock.inventoryDailySamples, sampleCount: 0, accumulationStartedOn: null } }] }) - const wrapper = await page() - expect(wrapper.text()).toContain('已留存 0 / 30 个日样本') - await wrapper.findAll('button').find(item => item.text() === '查看配置')?.trigger('click'); await flushPromises() - expect(wrapper.find('[data-test="configuration-dialog"]').text()).toContain('数据积累起点 尚无日样本') - expect(wrapper.find('[data-test="configuration-dialog"]').text()).not.toContain('数据积累起点 2026-10-09') - }) - it('navigates to the corresponding analysis with only the selected warehouse and makes no collection command', async () => { - mocks.warehouse = 'SELFOP-TEST-A' - const wrapper = await page() - const toolbar = wrapper.find('[data-test="toolbar"]') - await toolbar.findAll('button').find(item => item.text() === '查看库存')?.trigger('click') - expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/inventory', query: { warehouseCode: 'SELFOP-TEST-A' } }) - await toolbar.findAll('button').find(item => item.text() === '查看销量')?.trigger('click') - expect(mocks.push).toHaveBeenCalledWith({ path: '/self-operated/sales', query: { warehouseCode: 'SELFOP-TEST-A' } }) - expect(mocks.retry).not.toHaveBeenCalled() +describe('prototype collection monitor layout', () => { + it('keeps the process and independent warehouse results visible even while idle', () => { + const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'BUSINESS', observedAt: '2026-10-13T08:00:00Z', warehouses: [ + { warehouseCode: 'US01', dataFamily: 'OUTBOUND', lastPublishedAt: null, protection: {}, currentTask: { taskStatus: 'DEAD' } }, + { warehouseCode: 'US01', dataFamily: 'INVENTORY', lastPublishedAt: '2026-10-13T07:00:00Z', dataFreshness: 'FRESH', protection: {} }, + { warehouseCode: 'US02', dataFamily: 'INVENTORY', lastPublishedAt: '2026-10-13T06:00:00Z', dataFreshness: 'STALE', protection: {} }, + ] as WarehouseOverview[] } + const w = mount(CollectionAccountMonitor, { props: { overview }, global: { stubs: { 'el-button': true } } }) + expect(w.find('.standby-ring').exists()).toBe(true) + expect(w.find('[role="progressbar"]').exists()).toBe(false) + expect(w.findAll('.account-flow li')).toHaveLength(5) + expect(w.findAll('.warehouse-results article')).toHaveLength(2) + expect(w.text()).toContain('最近有任务停止') + expect(w.text()).toContain('最近发布结果') + expect(w.findAll('.warehouse-result').map(r => r.text())).toEqual(expect.arrayContaining([expect.stringContaining('尚未发布'), expect.stringContaining('已有结果')])) + expect(w.text()).not.toContain('全部完成'); expect(w.text()).not.toContain('100%') + w.unmount() }) }) diff --git a/src/modules/self-operated/pages/CollectionView.test.ts b/src/modules/self-operated/pages/CollectionView.test.ts index 43ebc42d..815e2cb4 100644 --- a/src/modules/self-operated/pages/CollectionView.test.ts +++ b/src/modules/self-operated/pages/CollectionView.test.ts @@ -2,71 +2,52 @@ 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 } })) +import type { Overview, Task } from '../types' +const mocks = vi.hoisted(() => ({ overview: vi.fn(), task: vi.fn(), tasks: vi.fn(), runs: vi.fn(), retry: vi.fn(), allowed: true, route: { query: {} } })) 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' } +vi.mock('vue-router', () => ({ useRoute: () => mocks.route })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.allowed }) })) 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 button = defineComponent({ emits: ['click'], setup(_, { slots, emit }) { return () => h('button', { onClick: () => emit('click') }, slots.default?.()) } }) +const task: Task = { taskNo: 'ACTIVE', warehouseCode: '', dataFamily: 'OUTBOUND', taskType: 'BACKFILL', taskStatus: 'RUNNING', queryFrom: null, queryUntil: null, applyState: null, attemptCount: 1, latestAttemptNo: 1, nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: { phase: 'DETAIL', progressScope: 'ACCOUNT_SHARED', countBasis: 'TASK_CUMULATIVE', completed: 339, total: 2787, totalKnown: true, unit: 'ORDER' }, resultPublicationState: 'NOT_PUBLISHED' } +const overview: Overview = { sourceAccount: 'lingxing-primary', environmentLabel: 'TEST', collectionMode: 'BUSINESS', observedAt: '2026-10-13T08:00:00Z', accountActivity: task, warehouses: [] } 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 }) -}) +function page() { const w = mount(CollectionView, { global: { stubs: { AdminPage: shell, 'el-button': button, ApiErrorMeta: true } } }); wrappers.push(w); return w } +beforeEach(() => { vi.clearAllMocks(); mocks.allowed = true; mocks.route = reactive({ query: { warehouseCode: 'US01', taskNo: 'OLD-HISTORY' } }); mocks.overview.mockResolvedValue(overview) }) 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') - }) +describe('account-only collection page', () => { + it('ignores old warehouse/history links and only reads the account overview', async () => { + const w = page(); await flushPromises() + expect(mocks.overview.mock.calls[0]?.slice(0, 2)).toEqual(['lingxing-primary', undefined]) + expect(w.text()).toContain('12.1%'); expect(w.text()).toContain('2448') + expect(w.text()).not.toContain('OLD-HISTORY') + for (const name of ['task', 'tasks', 'runs', 'retry'] as const) expect(mocks[name]).not.toHaveBeenCalled() + for (const text of ['查看配置', '查看详情', '历史记录与排查', '核对恢复请求', '任务号定位']) expect(w.text()).not.toContain(text) + expect(w.find('form').exists()).toBe(false); expect(w.find('details').exists()).toBe(false) + }) + it('uses one poller and aborts hidden requests before resuming', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + page(); await flushPromises(); expect(mocks.overview).toHaveBeenCalledTimes(1) + let finish!: (value: Overview) => void + mocks.overview.mockImplementationOnce(() => new Promise(resolve => { finish = resolve })) + await vi.advanceTimersByTimeAsync(10000) + const signal = mocks.overview.mock.calls.at(-1)?.[2] as AbortSignal + Object.defineProperty(document, 'hidden', { configurable: true, value: true }); document.dispatchEvent(new Event('visibilitychange')) + expect(signal.aborted).toBe(true) + finish({ ...overview, accountActivity: null }); await flushPromises() + const count = mocks.overview.mock.calls.length + await vi.advanceTimersByTimeAsync(60000); expect(mocks.overview).toHaveBeenCalledTimes(count) + Object.defineProperty(document, 'hidden', { configurable: true, value: false }); document.dispatchEvent(new Event('visibilitychange')); await flushPromises() + expect(mocks.overview).toHaveBeenCalledTimes(count + 1) + }) + it('retains last counts after a failed refresh and never turns failure into idle', async () => { + const w = page(); await flushPromises(); mocks.overview.mockRejectedValue(new Error('offline')) + await w.findAll('button').find(b => b.text().includes('刷新状态'))!.trigger('click'); await flushPromises() + expect(w.text()).toContain('状态更新失败'); expect(w.text()).toContain('339') + expect(w.text()).not.toContain('等待下一次采集') + }) + it('does not request account data without list permission', async () => { + mocks.allowed = false; const w = page(); await flushPromises() + expect(w.text()).toContain('没有采集状态查看权限'); expect(mocks.overview).not.toHaveBeenCalled() + }) }) diff --git a/src/modules/self-operated/pages/CollectionView.vue b/src/modules/self-operated/pages/CollectionView.vue index 40b56a11..4650e409 100644 --- a/src/modules/self-operated/pages/CollectionView.vue +++ b/src/modules/self-operated/pages/CollectionView.vue @@ -1,227 +1,45 @@ - diff --git a/src/modules/self-operated/pages/workbench.test.ts b/src/modules/self-operated/pages/workbench.test.ts index 81bf4ac1..a5f0bce3 100644 --- a/src/modules/self-operated/pages/workbench.test.ts +++ b/src/modules/self-operated/pages/workbench.test.ts @@ -171,14 +171,14 @@ describe('self-operated actual workbench request state', () => { expect(mocks.api.inventory).toHaveBeenCalledTimes(calls) }) - it('resumes a cached collection page with the new deep-linked warehouse and stops hidden polling and late requests', async () => { + it('resumes the account overview without warehouse filtering and stops hidden polling and late requests', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) const route = reactive({ path: '/self-operated/collection', query: { warehouseCode: 'WH-A' } }) mocks.routeState = route const visible = ref(true) const host = defineComponent({ setup: () => () => h(KeepAlive, null, { default: () => visible.value ? h(CollectionView) : null }) }) const wrapper = mount(host, options); wrappers.push(wrapper); await flushPromises() - expect(mocks.api.tasks.mock.calls.at(-1)?.[0].warehouseCode).toBe('WH-A') + expect(mocks.api.overview.mock.calls.at(-1)?.[1]).toBeUndefined() let resolveOld!: (value: Overview) => void mocks.api.overview.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) await vi.advanceTimersByTimeAsync(60000) @@ -195,9 +195,9 @@ describe('self-operated actual workbench request state', () => { route.path = '/self-operated/collection'; route.query = { warehouseCode: 'WH-B' } mocks.api.tasks.mockResolvedValue({ records: [{ ...task, taskNo: 'COL-B', warehouseCode: 'WH-B' }], total: 1 }) visible.value = true; await flushPromises() - expect(wrapper.findComponent(queryStub).props('modelValue').warehouseCode).toBe('WH-B') - expect(mocks.api.tasks.mock.calls.at(-1)?.[0].warehouseCode).toBe('WH-B') - expect(wrapper.text()).toContain('COL-B'); expect(wrapper.text()).not.toContain('COL-A') + expect(wrapper.findComponent(queryStub).exists()).toBe(false) + expect(mocks.api.overview.mock.calls.length).toBeGreaterThan(readsBefore) + expect(mocks.api.tasks).not.toHaveBeenCalled() }) it.each([ { coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' }, @@ -273,8 +273,8 @@ describe('self-operated actual workbench request state', () => { expect(wrapper.get('[data-testid="inventory-observation"]').text()).toContain('— 至 —') expect(wrapper.get('[data-testid="inventory-observation"]').text()).toContain('尚无完整库存观测时段') }) - it.each(['sales', 'inventory', 'collection'] as const)('uses the public integrated filter region and layout settings for %s', async mode => { - const wrapper = mode === 'collection' ? mount(CollectionView, options) : mount(AnalyticsView, { ...options, props: { mode } }) + it.each(['sales', 'inventory'] as const)('uses the public integrated filter region and layout settings for %s', async mode => { + const wrapper = mount(AnalyticsView, { ...options, props: { mode } }) wrappers.push(wrapper) await flushPromises() const table = wrapper.findComponent(tableStub) @@ -358,15 +358,6 @@ describe('self-operated actual workbench request state', () => { 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] }) - const wrapper = mount(CollectionView, options); wrappers.push(wrapper) - await flushPromises() - expect(wrapper.text()).toContain('已留存 0 / 30 个日样本') - await findButton(wrapper, '查看配置').trigger('click'); await flushPromises() - expect(wrapper.text()).toContain('数据积累起点 尚无日样本') - expect(wrapper.text()).toContain('缺口日期:2026-09-12') - }) it('pins versions and date across range and ranking switches, ranking omits chart parameters', async () => { const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper) await flushPromises() @@ -409,31 +400,15 @@ describe('self-operated actual workbench request state', () => { expect(wrapper.text()).toContain('状态更新失败') expect(wrapper.text()).toContain('2026/10/12 09:00:00') }) - it('requires confirmation inside the task dialog and preserves same key on ambiguous retry failure', async () => { - mocks.api.retry.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ taskNo: task.taskNo, status: 'QUEUED' }) + it('keeps collection read-only without configuration, history or retry controls', async () => { const wrapper = mount(CollectionView, options); wrappers.push(wrapper) 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('已修复,恢复原任务') - await findButton(wrapper, '核对恢复请求').trigger('click') - expect(mocks.api.retry).not.toHaveBeenCalled() - await wrapper.find('form').trigger('submit'); await flushPromises() - const first = mocks.api.retry.mock.calls[0]?.[0] - await wrapper.find('form').trigger('submit'); await flushPromises() - expect(mocks.api.retry.mock.calls[1]?.[0]).toEqual(first) - expect(first).toMatchObject({ action: 'RETRY', taskNo: 'COL-A', warehouseCode: 'WH-A', reason: '已修复,恢复原任务' }) - expect(first).not.toHaveProperty('taskId') - expect(wrapper.text()).toContain('恢复请求已排队受理,尚未完成采集或发布') - }) - it('read-only permission allows task details but never creates retry form', async () => { - mocks.canSync = false - const wrapper = mount(CollectionView, options); wrappers.push(wrapper) - await flushPromises() - await findButton(wrapper, '查看详情').trigger('click'); await flushPromises() expect(wrapper.find('form').exists()).toBe(false) - expect(wrapper.text()).toContain('没有任务恢复权限') + expect(wrapper.findComponent(queryStub).exists()).toBe(false) + expect(wrapper.text()).not.toContain('查看配置') + expect(wrapper.text()).not.toContain('查看详情') + expect(mocks.api.tasks).not.toHaveBeenCalled() + expect(mocks.api.retry).not.toHaveBeenCalled() }) it.each(['sales', 'inventory'] as const)('opens %s daily details in a dialog and discards a closed pending response', async mode => { let resolveTrend!: (value: object) => void diff --git a/src/styles/adminDensity.test.ts b/src/styles/adminDensity.test.ts index 5adaf641..84594c9a 100644 --- a/src/styles/adminDensity.test.ts +++ b/src/styles/adminDensity.test.ts @@ -90,7 +90,7 @@ const notificationConsumers = [ 'notifications/components/TemplateEditorDialog.vue', ] const systemConsumers = ['iam/pages/InternalUsersView.vue', 'iam/pages/RolesView.vue', 'iam/pages/PermissionsView.vue'] -const selfOperatedPages = ['self-operated/components/AnalyticsView.vue', 'self-operated/pages/CollectionView.vue'] +const selfOperatedPages = ['self-operated/components/AnalyticsView.vue'] const selfOperatedConsumers = [...selfOperatedPages, 'self-operated/components/SkuTrend.vue', 'self-operated/pages/OverviewView.vue'] const consumers = [ ...selfOperatedConsumers, @@ -466,6 +466,16 @@ describe('approved Admin density ownership', () => { } }) + it('keeps the collection monitor in the Admin theme without legacy list controls', () => { + const page = read('modules/self-operated/pages/CollectionView.vue') + const monitor = read('modules/self-operated/components/CollectionAccountMonitor.vue') + expect(page).toContain('class="admin-density"') + expect(page).toContain('@/styles/adminDensity.css') + expect(page).toContain(' { expect(getNavigationLeaves(adminNavigation).map(node => node.path)).toEqual(expect.arrayContaining([ '/self-operated/sales', '/self-operated/inventory', '/self-operated/collection',