diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue index ba177dff..ef73bb9c 100644 --- a/src/modules/self-operated/components/AnalyticsView.vue +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -8,6 +8,7 @@ import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/compone import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue' import LoadErrorState from '@/shared/components/LoadErrorState.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' +import AppDialog from '@/shared/components/AppDialog.vue' import { useAccessPreviewStore } from '@/core/access/accessPreview' import { getApiErrorMessage, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { analyticsApi } from '../api/analytics' @@ -27,6 +28,10 @@ const overview = ref(null) const context = ref(null) const rows = ref<(SalesSummary | Metric)[]>([]) const daily = ref(null) +const trendOpen = ref(false) +const trendLoading = ref(false) +const trendError = ref('') +const trendErrorDetails = ref(null) const attention = computed(() => (filters.value.attention || 'ALL') as InventoryAttention) const sortBy = computed(() => (filters.value.sortBy || 'AVAILABLE_DESC') as InventorySort) const thresholdInput = computed(() => String(filters.value.threshold ?? '')) @@ -109,6 +114,7 @@ async function load(reset = false) { if (problem) { error.value = problem; return } } if (reset) { context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 } + trendOpen.value = false dailyFence.cancel() const request = dataFence.begin() loading.value = true @@ -129,8 +135,12 @@ async function load(reset = false) { finally { if (request.current()) loading.value = false } } async function showTrend(row: SalesSummary | Metric) { + trendOpen.value = true + trendLoading.value = true + daily.value = null + trendError.value = '' + trendErrorDetails.value = null const request = dailyFence.begin() - error.value = '' try { const params: BusinessQuery = { sourceAccount, warehouseCode: warehouseCode.value, customerCode: row.customerCode, sku: row.sku, current: 1, size: 20, ...contextParams(context.value), rangePreset: props.mode === 'inventory' ? 'CUSTOM' : preset.value } if (props.mode === 'inventory' && context.value?.rangeStartDate && context.value.rangeEndDateExclusive) { @@ -141,7 +151,8 @@ async function showTrend(row: SalesSummary | Metric) { if (!request.current()) return if (!context.value || !sameContext(context.value, result.context)) throw new Error('趋势版本与列表不一致,请重新查询') daily.value = result - } catch (cause) { if (request.current()) error.value = getApiErrorMessage(cause, 'SKU趋势读取失败') } + } catch (cause) { if (request.current()) { trendErrorDetails.value = getApiErrorDetails(cause, 'SKU趋势读取失败'); trendError.value = trendErrorDetails.value.message } } + finally { if (request.current()) trendLoading.value = false } } async function readRuntime() { if (!permitted.value) return false @@ -159,6 +170,7 @@ async function readRuntime() { } catch (cause) { if (request.current()) runtimeError.value = getApiErrorMessage(cause, '状态更新失败,保留上次观测结果'); return false } } const poller = new RuntimePoller(readRuntime, () => runtimeFence.cancel()) +watch(trendOpen, open => { if (!open) { dailyFence.cancel(); daily.value = null; trendLoading.value = false; trendError.value = ''; trendErrorDetails.value = null } }) function visibility() { poller.setVisible(!document.hidden) } function search() { const rawThreshold = thresholdInput.value.trim() @@ -173,7 +185,7 @@ function search() { } function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } } function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, sku: applied.value.sku } }) } -watch(warehouseCode, () => { dataFence.cancel(); dailyFence.cancel(); rows.value = []; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) }) +watch(warehouseCode, () => { trendOpen.value = false; dataFence.cancel(); dailyFence.cancel(); rows.value = []; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) }) watch([preset, tab, windowDays], () => { current.value = 1; if (!summaryMode.value || preset.value !== 'CUSTOM' || customStart.value && customEnd.value) void load() }) watch([current, size], () => { void load() }) onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() }) @@ -183,7 +195,9 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu

没有自营经营数据查看权限。

- + + + @@ -208,11 +222,16 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu - + +
+ + +
+
diff --git a/src/modules/self-operated/pages/workbench.test.ts b/src/modules/self-operated/pages/workbench.test.ts index ad25f1b3..a48c9fe7 100644 --- a/src/modules/self-operated/pages/workbench.test.ts +++ b/src/modules/self-operated/pages/workbench.test.ts @@ -23,11 +23,12 @@ const queryStub = defineComponent({ props: ['modelValue', 'fields'], emits: ['up h('button', { 'data-test': 'switch-warehouse', onClick: () => { emit('update:modelValue', { ...props.modelValue, warehouseCode: 'WH-B' }); emit('search') } }, '切换仓库'), h('button', { onClick: () => emit('search') }, '查询'), h('button', { onClick: () => emit('reset') }, '重置'), ]) } }) -const tableStub = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), !(props.data ?? []).length ? props.emptyText : null, ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.sku?.({ row }), slots.owner?.({ row }), slots.unit?.({ row })]))]) } }) +const tableStub = defineComponent({ props: ['data', 'columns', 'emptyText'], setup(props, { slots }) { return () => h('div', [slots['toolbar-actions']?.(), slots.filters?.(), slots.notices?.(), !(props.data ?? []).length ? props.emptyText : null, ...(props.data ?? []).map((row: object) => h('div', [JSON.stringify(row), slots.actions?.({ row }), slots.quantity?.({ row }), slots.sku?.({ row }), slots.owner?.({ row }), slots.unit?.({ row }), slots.dailySamples?.({ row }), slots.configuration?.({ row })]))]) } }) const button = defineComponent({ props: ['disabled', 'nativeType'], emits: ['click'], setup(props, { slots, emit }) { return () => h('button', { disabled: props.disabled, type: props.nativeType || 'button', onClick: () => emit('click') }, slots.default?.()) } }) const input = defineComponent({ props: ['modelValue', 'id'], emits: ['update:modelValue'], setup(props, { emit }) { return () => h('textarea', { id: props.id, value: props.modelValue, onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLTextAreaElement).value) }) } }) const loadError = defineComponent({ props: ['message'], setup(props) { return () => h('p', { role: 'alert' }, props.message) } }) -const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, SkuTrend: true, OwnerPicker: true, 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } } +const dialog = defineComponent({ props: ['modelValue', 'title', 'confirmLoading'], emits: ['update:modelValue'], setup(props, { slots }) { return () => props.modelValue ? h('section', { 'data-test': 'detail-dialog' }, slots.default?.()) : null } }) +const options = { global: { stubs: { AdminPage: passthrough, ListPageLayout: passthrough, QueryToolbar: queryStub, DataTable: tableStub, LoadErrorState: loadError, AppDialog: dialog, SkuTrend: true, OwnerPicker: true, 'el-button': button, 'el-input': input, 'el-date-picker': true, 'el-select': passthrough, 'el-option': true, 'el-descriptions': passthrough, 'el-descriptions-item': passthrough } } } const wrappers: ReturnType[] = [] function findButton(wrapper: ReturnType, text: string) { const result = wrapper.findAll('button').find(item => item.text() === text); if (!result) throw new Error(`missing button ${text}`); return result } async function setFilters(wrapper: ReturnType, values: Record) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() } @@ -49,6 +50,17 @@ beforeEach(() => { }) afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) }) describe('self-operated actual workbench request state', () => { + 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 } }) + wrappers.push(wrapper) + await flushPromises() + const table = wrapper.findComponent(tableStub) + expect(table.vm.$slots.filters).toBeDefined() + const toolbar = table.findComponent(queryStub) + expect(toolbar.exists()).toBe(true) + expect(toolbar.attributes('preference-key')).toBe(`self-operated-${mode}-query`) + expect(wrapper.findAllComponents(queryStub)).toHaveLength(1) + }) it('uses one SKU filter workspace and no owner query, keeping pagination and attention scoped to the fixed versions', async () => { const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper) await flushPromises() @@ -122,6 +134,7 @@ describe('self-operated actual workbench request state', () => { 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') }) @@ -167,11 +180,13 @@ describe('self-operated actual workbench request state', () => { expect(wrapper.text()).toContain('状态更新失败') expect(wrapper.text()).toContain('2026/10/12 09:00:00') }) - it('requires inline confirmation and preserves same key on ambiguous retry failure', async () => { + 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' }) 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() @@ -191,4 +206,19 @@ describe('self-operated actual workbench request state', () => { expect(wrapper.find('form').exists()).toBe(false) expect(wrapper.text()).toContain('没有任务恢复权限') }) + 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 + mocks.api.daily.mockImplementationOnce(() => new Promise(resolve => { resolveTrend = resolve })) + const wrapper = mount(AnalyticsView, { ...options, props: { mode } }); wrappers.push(wrapper) + await flushPromises() + expect(wrapper.find('[data-test="detail-dialog"]').exists()).toBe(false) + await findButton(wrapper, mode === 'sales' ? '每日趋势' : 'SKU-A').trigger('click'); await flushPromises() + expect(wrapper.find('[data-test="detail-dialog"]').exists()).toBe(true) + expect(wrapper.findComponent(dialog).props('title')).toBe('SKU每日出库趋势') + wrapper.findComponent(dialog).vm.$emit('update:modelValue', false); await flushPromises() + expect(mocks.api.daily.mock.calls.at(-1)?.[1].aborted).toBe(true) + resolveTrend({ context, summary, points: [] }); await flushPromises() + expect(wrapper.find('[data-test="detail-dialog"]').exists()).toBe(false) + expect(wrapper.findComponent({ name: 'SkuTrend' }).exists()).toBe(false) + }) })