From 1de73b5f1c95b5851e5692467eac381ef1a73023 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 13 Oct 2026 13:43:16 +0800 Subject: [PATCH] fix: keep analytics date ranges together and apply atomically --- .../components/AnalyticsView.vue | 48 ++++++++++----- src/modules/self-operated/operation-matrix.md | 1 + .../self-operated/pages/OverviewView.test.ts | 41 ++++++++++++- .../self-operated/pages/OverviewView.vue | 24 +++++--- .../self-operated/pages/workbench.test.ts | 60 ++++++++++++++++++- 5 files changed, 147 insertions(+), 27 deletions(-) diff --git a/src/modules/self-operated/components/AnalyticsView.vue b/src/modules/self-operated/components/AnalyticsView.vue index b3755b57..c7cf5323 100644 --- a/src/modules/self-operated/components/AnalyticsView.vue +++ b/src/modules/self-operated/components/AnalyticsView.vue @@ -32,7 +32,7 @@ function readDrilldown() { } readDrilldown() function beijingToday() { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date()) } -function initialFilters(): QueryModel { return { warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: drilldown.value?.analysis ?? 'summary', rangePreset: drilldown.value?.rangePreset ?? 'TODAY', windowDays: 30, salesWindowDays: 30, salesWindowEndDate: previousDate(drilldown.value?.context.anchorDate ?? beijingToday()), attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customStart: drilldown.value?.startDate ?? '', customEnd: drilldown.value?.endDate ?? '' } } +function initialFilters(): QueryModel { return { warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: drilldown.value?.analysis ?? 'summary', rangePreset: drilldown.value?.rangePreset ?? 'TODAY', windowDays: 30, salesWindowDays: 30, salesWindowEndDate: previousDate(drilldown.value?.context.anchorDate ?? beijingToday()), attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customRange: drilldown.value?.startDate && drilldown.value?.endDate ? [drilldown.value.startDate, drilldown.value.endDate] : [] } } const filters = ref(initialFilters()) const applied = ref({ sku: String(filters.value.sku) }) const appliedWindow = ref({ salesWindowDays: 30 as 10 | 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }) @@ -60,8 +60,13 @@ const current = ref(1) const size = ref(20) const total = ref(0) const preset = computed(() => (filters.value.rangePreset || 'TODAY') as RangePreset) -const customStart = computed(() => String(filters.value.customStart ?? '')) -const customEnd = computed(() => String(filters.value.customEnd ?? '')) +const customRange = computed(() => Array.isArray(filters.value.customRange) ? filters.value.customRange : []) +const customStart = computed(() => String(customRange.value[0] ?? '')) +const customEnd = computed(() => String(customRange.value[1] ?? '')) +type SalesRange = { rangePreset: RangePreset; startDate?: string; endDate?: string } +function draftSalesRange(): SalesRange { return { rangePreset: preset.value, ...(preset.value === 'CUSTOM' ? { startDate: customStart.value, endDate: customEnd.value } : {}) } } +const appliedSalesRange = ref(draftSalesRange()) +const salesRangePending = computed(() => props.mode === 'sales' && tab.value === 'summary' && JSON.stringify(draftSalesRange()) !== JSON.stringify(appliedSalesRange.value)) const tab = computed(() => (filters.value.analysis || 'summary') as 'summary' | 'hot' | 'slow') const windowDays = computed(() => Number(filters.value.windowDays || 30) as 7 | 30) const sourceAccount = 'lingxing-primary' @@ -82,7 +87,7 @@ const fields = computed(() => { result.push({ key: 'analysis', type: 'select', label: '销量类别', width: 170, props: { clearable: false }, options: [{ value: 'summary', label: 'SKU销量' }, { value: 'hot', label: '热销Top100' }, { value: 'slow', label: '滞销Top100' }] }) if (tab.value === 'summary') { result.push({ key: 'rangePreset', type: 'select', label: '销量日期', width: 150, props: { clearable: false }, options: [{ value: 'TODAY', label: '今日' }, { value: 'LAST_3_DAYS', label: '近3天' }, { value: 'THIS_WEEK', label: '本周' }, { value: 'LAST_15_DAYS', label: '近15天' }, { value: 'THIS_MONTH', label: '本月' }, { value: 'CUSTOM', label: '自定义' }] }) - if (preset.value === 'CUSTOM') result.push({ key: 'customStart', type: 'date', label: '开始日期', width: 160 }, { key: 'customEnd', type: 'date', label: '结束日期', width: 160 }) + if (preset.value === 'CUSTOM') result.push({ key: 'customRange', type: 'daterange', label: '销量日期范围', width: 300, props: { 'start-placeholder': '开始日期', 'end-placeholder': '结束日期', 'range-separator': '至' } }) } else if (tab.value === 'hot') result.push({ key: 'windowDays', type: 'select', label: '热销窗口', width: 180, props: { clearable: false }, options: [{ value: 7, label: '近7个完整自然日' }, { value: 30, label: '近30个完整自然日' }] }) } return drilldown.value ? result.map(field => field.key === 'warehouseCode' ? field : { ...field, props: { ...field.props, disabled: true } }) : result @@ -119,8 +124,7 @@ function query(): InventoryQuery { const result: InventoryQuery = { sourceAccount, warehouseCode: warehouseCode.value, sku: applied.value.sku || undefined, current: current.value, size: size.value, ...contextParams(context.value) } if (drilldown.value?.customerCode) result.customerCode = drilldown.value.customerCode if (summaryMode.value) { - result.rangePreset = preset.value - if (preset.value === 'CUSTOM') { result.startDate = customStart.value; result.endDate = customEnd.value } + Object.assign(result, appliedSalesRange.value) } else if (props.mode === 'sales' && tab.value === 'hot') result.windowDays = windowDays.value if (props.mode === 'inventory') Object.assign(result, appliedInventory.value, appliedWindow.value, drilldown.value?.inventory) return result @@ -129,10 +133,7 @@ async function load(reset = false, keepContext = false) { if (!permitted.value || !warehouseCode.value || routeProblem.value) return if (origin.value && context.value && expired(context.value)) { error.value = '原图查询已过期,请返回经营总览整体刷新'; return } if (drilldown.value && props.mode === 'inventory' && context.value && inventoryExpired(context.value)) { error.value = '原图库存已过期,请刷新总览'; return } - if (summaryMode.value && preset.value === 'CUSTOM') { - const problem = validateCustomRange(customStart.value, customEnd.value, context.value?.anchorDate) - if (problem) { error.value = problem; return } - } + if (summaryMode.value && !appliedSalesRange.value) return if (reset) { if (!origin.value && !keepContext) context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 } trendOpen.value = false dailyFence.cancel() @@ -162,11 +163,11 @@ async function showTrend(row: SalesSummary | Metric | InventoryMetric) { trendErrorDetails.value = null const request = dailyFence.begin() 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 } + 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' : appliedSalesRange.value?.rangePreset } if (props.mode === 'inventory' && context.value?.rangeStartDate && context.value.rangeEndDateExclusive) { params.startDate = context.value.rangeStartDate params.endDate = new Date(Date.parse(`${context.value.rangeEndDateExclusive}T00:00:00Z`) - 86400000).toISOString().slice(0, 10) - } else if (params.rangePreset === 'CUSTOM') { params.startDate = customStart.value; params.endDate = customEnd.value } + } else if (params.rangePreset === 'CUSTOM') { params.startDate = appliedSalesRange.value?.startDate; params.endDate = appliedSalesRange.value?.endDate } const result = await analyticsApi.daily(params, request.signal) if (!request.current()) return if (!context.value || !sameContext(context.value, result.context)) throw new Error('趋势版本与列表不一致,请重新查询') @@ -192,7 +193,14 @@ async function readRuntime() { 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 clearSalesResults() { dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; daily.value = null; rows.value = []; total.value = 0; loading.value = false; error.value = ''; errorDetails.value = null } +function applySalesRange(anchor?: string) { + const problem = preset.value === 'CUSTOM' ? validateCustomRange(customStart.value, customEnd.value, anchor) : '' + if (problem) { clearSalesResults(); appliedSalesRange.value = null; error.value = problem; return false } + appliedSalesRange.value = draftSalesRange(); return true +} function search() { + if (summaryMode.value && !applySalesRange(origin.value?.context.anchorDate ?? beijingToday())) return let nextWindow = appliedWindow.value if (props.mode === 'inventory') { const end = String(filters.value.salesWindowEndDate ?? '') @@ -217,14 +225,15 @@ function search() { current.value = 1 void load(true, keepContext) } -function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } } +function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' } } function returnOverview() { const saved = origin.value void router.push({ path: '/self-operated/overview', query: saved ? { warehouseCode: warehouseCode.value, context: JSON.stringify(saved.context), overviewReturn: JSON.stringify(saved.returnTo) } : {} }) } async function clearDrilldown() { drilldown.value = null - filters.value = { ...filters.value, sku: '', analysis: 'summary', rangePreset: 'TODAY', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customStart: '', customEnd: '' } + filters.value = { ...filters.value, sku: '', analysis: 'summary', rangePreset: 'TODAY', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customRange: [] } + appliedSalesRange.value = draftSalesRange() applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }; current.value = 1 await nextTick(); void load() } @@ -232,13 +241,19 @@ function link(path: string) { void router.push({ path, query: { warehouseCode: w let applyingRoute = false watch(warehouseCode, () => { if (applyingRoute) return; drilldown.value = null; origin.value = null; 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([() => filters.value.salesWindowDays, () => filters.value.salesWindowEndDate], () => { if (!applyingRoute && props.mode === 'inventory' && !drilldown.value) search() }) -watch([preset, tab, windowDays], () => { if (applyingRoute) return; current.value = 1; if (!summaryMode.value || preset.value !== 'CUSTOM' || customStart.value && customEnd.value) void load() }) +watch([customStart, customEnd], () => { if (applyingRoute || !summaryMode.value) return; error.value = ''; errorDetails.value = null }) +watch([preset, tab, windowDays], () => { + if (applyingRoute) return + clearSalesResults() + if (summaryMode.value && !applySalesRange(context.value?.anchorDate ?? beijingToday())) return + current.value = 1; void load() +}) watch([current, size], () => { if (!applyingRoute) void load() }) onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() }) watch(() => route.query, async () => { if (route.path && route.path !== `/self-operated/${props.mode}`) return applyingRoute = true; dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; rows.value = []; total.value = 0 - readDrilldown(); context.value = drilldown.value?.context ?? null; filters.value = initialFilters(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: String(filters.value.sku ?? '') }; appliedInventory.value = { attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' }; current.value = 1 + readDrilldown(); context.value = drilldown.value?.context ?? null; filters.value = initialFilters(); appliedSalesRange.value = draftSalesRange(); appliedWindow.value = { salesWindowDays: 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }; applied.value = { sku: String(filters.value.sku ?? '') }; appliedInventory.value = { attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' }; current.value = 1 await nextTick(); applyingRoute = false; void load() }) onActivated(() => { visibility(); poller.start() }) @@ -256,6 +271,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
{{ drilldown?.summary ?? '来自经营总览 · 来源筛选已清除,保留原有效版本' }}清除来源条件返回经营总览

{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}

+

日期范围尚未应用,请选择完整起止日期后点击查询。

{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。

本仓库存采集时间(北京时间):{{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.inventoryObservedFrom && context.inventoryObservedUntil ? '当前列表来自本仓一轮完整分页观测,各页在该时段内采集,不代表同一瞬间的库存。' : '尚无完整库存观测时段,缺失时间不以发布时间代替。' }}

销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。

diff --git a/src/modules/self-operated/operation-matrix.md b/src/modules/self-operated/operation-matrix.md index b1807ff9..91a2148b 100644 --- a/src/modules/self-operated/operation-matrix.md +++ b/src/modules/self-operated/operation-matrix.md @@ -4,6 +4,7 @@ | 页面 | 操作 | 依据与承载结论 | 布局与验证 | | --- | --- | --- | --- | +| /self-operated/sales | 自定义销量日期 | 2026-10-13 用户截图 `codex-clipboard-fac03bb1-6703-467d-b357-5731ea8cfb32.png`:两个单日框分散换行,未完整选择时残留逆序错误和旧结果。原筛选区改为公共QueryToolbar单一日期范围控件,明确开始/结束日期,支持同日;不新增浮层或改变统计口径 | 日期范围整体换行,校验完整范围后查询;草稿修改不改变已查询列表的分页/趋势条件,提交失败不冒充新范围结果;补充自动回归,待人工1920×1080、100%截图确认 | | /self-operated/overview | 查询、刷新与六项指标 | `docs/自营业务/经营总览图表设计.md`“菜单与页面布局”“指标带”;单仓+趋势日期QueryToolbar、紧凑指标带,整体固定版本 | 不新增货主控件;未知/过期独立提示;待人工1920×1080截图确认 | | /self-operated/overview | 日趋势、七桶与三个Top10 | 同设计“五张图表”;SVG折线与横条、趋势约2/3宽、分布约1/3宽,三榜并排 | 可聚焦提示、缺日断线、部分日文字、真实数值比例;待人工截图确认 | | /self-operated/overview | SKU趋势 | 同设计“图表交互与视觉依据”;1120px居中AppDialog,固定30日、精确货主SKU | 关闭取消请求,不改现有详情承载;待人工截图确认 | diff --git a/src/modules/self-operated/pages/OverviewView.test.ts b/src/modules/self-operated/pages/OverviewView.test.ts index a8ec2692..0393662c 100644 --- a/src/modules/self-operated/pages/OverviewView.test.ts +++ b/src/modules/self-operated/pages/OverviewView.test.ts @@ -12,7 +12,7 @@ vi.mock('../api/analytics', () => ({ analyticsApi: mocks })) vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }), useRouter: () => ({ push: mocks.push }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.permitted }) })) const passthrough = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } }) -const query = defineComponent({ props: ['modelValue'], emits: ['update:modelValue', 'search'], setup(_, { emit }) { return () => h('button', { onClick: () => emit('search') }, '查询') } }) +const query = defineComponent({ props: ['modelValue', 'fields'], emits: ['update:modelValue', 'search'], setup(_, { emit }) { return () => h('button', { onClick: () => emit('search') }, '查询') } }) 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) } }) @@ -20,7 +20,7 @@ const options = { global: { directives: { loading: () => undefined }, stubs: { A const wrappers: ReturnType[] = [] function page() { const wrapper = mount(OverviewView, options); wrappers.push(wrapper); return wrapper } function findButton(wrapper: ReturnType, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! } -async function choose(wrapper: ReturnType, values: Record) { const toolbar = wrapper.findComponent(query); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() } +async function choose(wrapper: ReturnType, values: Record) { const toolbar = wrapper.findComponent(query); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() } beforeEach(() => { vi.clearAllMocks(); mocks.query = {}; mocks.permitted = true vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined) @@ -30,6 +30,43 @@ beforeEach(() => { }) afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() }) describe('warehouse operating dashboard', () => { + it('uses one date range field, blocks incomplete ranges and clears obsolete validation errors', async () => { + const wrapper = page(); await choose(wrapper, { warehouseCode: 'WH-A', rangePreset: 'CUSTOM' }) + const toolbar = wrapper.findComponent(query) + expect(toolbar.props('fields').filter((field: { type: string }) => field.type.includes('date'))).toEqual([{ key: 'dateRange', type: 'daterange', label: '日期范围', width: 300 }]) + expect(mocks.dashboard).not.toHaveBeenCalled() + await findButton(wrapper, '查询').trigger('click'); await flushPromises() + expect(mocks.dashboard).not.toHaveBeenCalled() + await choose(wrapper, { dateRange: ['2026-10-10'] }) + await findButton(wrapper, '查询').trigger('click'); await flushPromises() + expect(mocks.dashboard).not.toHaveBeenCalled() + expect(wrapper.findComponent(error).exists()).toBe(true) + await choose(wrapper, { dateRange: ['2026-10-10', '2026-10-10'] }) + expect(wrapper.findComponent(error).exists()).toBe(false) + await findButton(wrapper, '查询').trigger('click'); await flushPromises() + expect(mocks.dashboard).toHaveBeenCalledOnce() + expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' }) + expect(mocks.dashboard.mock.calls[0]?.[0]).not.toHaveProperty('dateRange') + await choose(wrapper, { dateRange: null }) + await findButton(wrapper, '查询').trigger('click'); await flushPromises() + expect(mocks.dashboard).toHaveBeenCalledOnce() + await choose(wrapper, { rangePreset: 'TODAY' }) + expect(wrapper.findComponent(error).exists()).toBe(false) + }) + it('restores a custom range and keeps the applied dates when an unsubmitted range is edited', async () => { + mocks.query = { warehouseCode: 'WH-A', context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10', scroll: 123 }) } + const wrapper = page(); await flushPromises() + expect(wrapper.findComponent(query).props('modelValue').dateRange).toEqual(['2026-10-10', '2026-10-10']) + expect(mocks.dashboard.mock.calls[0]?.[0]).toMatchObject({ startDate: '2026-10-10', endDate: '2026-10-10', salesRunId: '11' }) + await choose(wrapper, { dateRange: ['2026-10-11', '2026-10-12'] }) + await findButton(wrapper, '查看Top100').trigger('click') + expect(JSON.parse(mocks.push.mock.calls.at(-1)?.[0].query.overviewReturn)).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' }) + wrapper.findComponent(query).vm.$emit('reset'); await flushPromises() + expect(wrapper.findComponent(query).props('modelValue').dateRange).toEqual([]) + expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('startDate') + expect(mocks.dashboard.mock.calls.at(-1)?.[0]).not.toHaveProperty('endDate') + }) + it('does not replace an invalid warehouse-less return with the default warehouse', async () => { mocks.query = { context: JSON.stringify(dashboardFixture.context), overviewReturn: JSON.stringify({ rangePreset: 'THIS_MONTH', scroll: 760 }) } mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: 'A仓' }], observedAt: '2026-10-13T00:00:00Z' }) diff --git a/src/modules/self-operated/pages/OverviewView.vue b/src/modules/self-operated/pages/OverviewView.vue index ff9898fd..953f7ec9 100644 --- a/src/modules/self-operated/pages/OverviewView.vue +++ b/src/modules/self-operated/pages/OverviewView.vue @@ -24,13 +24,14 @@ const router = useRouter() const access = useAccessPreviewStore() const permitted = computed(() => access.can('platform:self-operated-analytics:list')) const sourceAccount = 'lingxing-primary' -const filters = ref({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' }) +const filters = ref({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', rangePreset: 'LAST_15_DAYS', dateRange: [] }) let selectedWarehouse = String(filters.value.warehouseCode) const context = ref(null) const data = ref(null) const runtime = ref(null) const loading = ref(false) const error = ref('') +const rangeValidationError = ref('') const errorDetails = ref(null) const runtimeError = ref('') const routeError = ref('') @@ -45,7 +46,7 @@ try { if (typeof route.query.context !== 'string' || typeof route.query.overviewReturn !== 'string' || !filters.value.warehouseCode) throw new Error('经营总览返回参数无效') context.value = parseContext(route.query.context) appliedRange = parseOverviewReturn(route.query.overviewReturn) - Object.assign(filters.value, { rangePreset: appliedRange.rangePreset, startDate: appliedRange.startDate ?? '', endDate: appliedRange.endDate ?? '' }) + Object.assign(filters.value, { rangePreset: appliedRange.rangePreset, dateRange: appliedRange.startDate && appliedRange.endDate ? [appliedRange.startDate, appliedRange.endDate] : [] }) restoreScroll = appliedRange.scroll } } catch (cause) { routeError.value = cause instanceof Error ? cause.message : '经营总览返回参数无效' } @@ -55,7 +56,7 @@ const stockExpired = computed(() => Boolean(context.value && inventoryExpired(co const fields = computed(() => [ { key: 'warehouseCode', type: 'select', label: '仓库', width: 220, placeholder: '切换仓库', props: { clearable: false }, options: [...new Map((runtime.value?.warehouses ?? []).map(item => [item.warehouseCode, { value: item.warehouseCode, label: item.warehouseName || item.warehouseCode }])).values()] }, { key: 'rangePreset', type: 'select', label: '出库趋势范围', width: 180, props: { clearable: false }, options: rangeOptions }, - ...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'startDate', type: 'date' as const, label: '开始日期', width: 160 }, { key: 'endDate', type: 'date' as const, label: '结束日期', width: 160 }] : []), + ...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'dateRange', type: 'daterange' as const, label: '日期范围', width: 300 }] : []), ]) const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: boolean }[] = [ { key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity30d', label: '近30日日均出库' }, @@ -82,13 +83,14 @@ async function load(refresh = false) { if (!refresh && contextExpired.value) { error.value = '原图查询已过期,请整体刷新经营总览'; return } const range = String(filters.value.rangePreset) as RangePreset if (!rangeOptions.some(option => option.value === range)) { error.value = '请选择有效的出库趋势范围'; return } - const start = String(filters.value.startDate ?? '') - const end = String(filters.value.endDate ?? '') - if (range === 'CUSTOM') { const problem = validateCustomRange(start, end, context.value?.anchorDate); if (problem) { error.value = problem; return } } + const selected = Array.isArray(filters.value.dateRange) ? filters.value.dateRange : [] + const start = typeof selected[0] === 'string' ? selected[0] : '' + const end = typeof selected[1] === 'string' ? selected[1] : '' + if (range === 'CUSTOM') { const problem = validateCustomRange(start, end, context.value?.anchorDate); if (problem) { rangeValidationError.value = problem; error.value = problem; errorDetails.value = null; return } } const pinned = refresh ? null : context.value const request = dataFence.begin() trendOpen.value = false - loading.value = true; error.value = ''; errorDetails.value = null + loading.value = true; error.value = ''; rangeValidationError.value = ''; errorDetails.value = null try { const params: DashboardQuery = { sourceAccount, warehouseCode: warehouseCode.value, rangePreset: range, ...contextParams(pinned), ...(range === 'CUSTOM' ? { startDate: start, endDate: end } : {}) } const result = await analyticsApi.dashboard(params, request.signal) @@ -144,7 +146,7 @@ async function showTrend(item: DashboardItem) { finally { if (request.current()) trendLoading.value = false } } async function reset() { - filters.value = { warehouseCode: warehouseCode.value || selectedWarehouse || runtime.value?.warehouses.find(item => item.warehouseCode)?.warehouseCode || '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' } + filters.value = { warehouseCode: warehouseCode.value || selectedWarehouse || runtime.value?.warehouses.find(item => item.warehouseCode)?.warehouseCode || '', rangePreset: 'LAST_15_DAYS', dateRange: [] } routeError.value = '' await nextTick() void load(true) @@ -153,6 +155,12 @@ watch(warehouseCode, code => { dataFence.cancel(); trendFence.cancel(); data.value = null; context.value = null; trendOpen.value = false; loading.value = false; error.value = ''; errorDetails.value = null; newData.value = false; routeError.value = ''; restoreScroll = null if (code) { selectedWarehouse = code; void load(true) } }) +watch(() => [filters.value.rangePreset, filters.value.dateRange], () => { + if (rangeValidationError.value) { + if (error.value === rangeValidationError.value) error.value = '' + rangeValidationError.value = '' + } +}, { deep: true }) watch(trendOpen, open => { if (!open) { trendFence.cancel(); trend.value = null; trendLoading.value = false } }) onMounted(() => { visibility(); poller.start(); timer = setInterval(() => { now.value = Date.now() }, 1000); document.addEventListener('visibilitychange', visibility); if (context.value && !routeError.value) void load() }) onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clearInterval(timer); document.removeEventListener('visibilitychange', visibility) }) diff --git a/src/modules/self-operated/pages/workbench.test.ts b/src/modules/self-operated/pages/workbench.test.ts index db656939..6f2f39b0 100644 --- a/src/modules/self-operated/pages/workbench.test.ts +++ b/src/modules/self-operated/pages/workbench.test.ts @@ -31,7 +31,7 @@ const dialog = defineComponent({ props: ['modelValue', 'title', 'confirmLoading' 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() } +async function setFilters(wrapper: ReturnType, values: Record) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() } beforeEach(() => { vi.clearAllMocks() mocks.canSync = true; mocks.routeState = null @@ -50,6 +50,64 @@ beforeEach(() => { }) afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() }) describe('self-operated actual workbench request state', () => { + 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' }) + expect(wrapper.findComponent(tableStub).props('data')).toEqual([]) + const fields = wrapper.findComponent(queryStub).props('fields') + expect(fields.filter((f: { type: string }) => f.type === 'daterange')).toHaveLength(1) + expect(fields.some((f: { key: string }) => ['customStart', 'customEnd'].includes(f.key))).toBe(false) + const calls = mocks.api.summary.mock.calls.length + await setFilters(wrapper, { customRange: ['2026-10-10', '2026-10-10'] }) + expect(mocks.api.summary).toHaveBeenCalledTimes(calls) + await findButton(wrapper, '查询').trigger('click'); await flushPromises() + expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10' }) + await setFilters(wrapper, { customRange: ['2026-10-11', '2026-10-11'] }) + expect(wrapper.text()).toContain('当前列表及每日趋势仍使用上次已应用的统计区间') + wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises() + expect(mocks.api.summary.mock.calls.at(-1)?.[0]).toMatchObject({ current: 2, startDate: '2026-10-10', endDate: '2026-10-10', queryAsOf: context.queryAsOf }) + await findButton(wrapper, '每日趋势').trigger('click'); await flushPromises() + expect(mocks.api.daily.mock.calls.at(-1)?.[0]).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-10', endDate: '2026-10-10', queryAsOf: context.queryAsOf }) + }) + it('clears obsolete reverse-date errors and blocks incomplete submissions without retaining old results', async () => { + const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises() + await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2026-10-11', '2026-10-10'] }) + await findButton(wrapper, '查询').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('开始日期不得晚于结束日期') + const calls = mocks.api.summary.mock.calls.length + await setFilters(wrapper, { customRange: ['2026-10-10'] }) + expect(wrapper.text()).not.toContain('开始日期不得晚于结束日期') + await findButton(wrapper, '查询').trigger('click'); await flushPromises() + expect(wrapper.text()).toContain('请选择有效的起止日期') + expect(wrapper.findComponent(tableStub).props('data')).toEqual([]) + expect(mocks.api.summary).toHaveBeenCalledTimes(calls) + wrapper.findComponent(tableStub).vm.$emit('update:page', 2); await flushPromises() + expect(mocks.api.summary).toHaveBeenCalledTimes(calls) + }) + it('cancels an old preset response when custom dates are incomplete and clears stale results during the next request', async () => { + let resolveOld!: (value: unknown) => void + mocks.api.summary.mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve })) + const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises() + const signal = mocks.api.summary.mock.calls[0]?.[1] as AbortSignal + await setFilters(wrapper, { rangePreset: 'CUSTOM' }) + expect(signal.aborted).toBe(true) + resolveOld({ context, records: [summary], total: 1 }); await flushPromises() + expect(wrapper.findComponent(tableStub).props('data')).toEqual([]) + await setFilters(wrapper, { rangePreset: 'THIS_MONTH' }) + expect(mocks.api.summary.mock.calls.at(-1)?.[0].rangePreset).toBe('THIS_MONTH') + expect(mocks.api.summary.mock.calls.at(-1)?.[0]).not.toHaveProperty('startDate') + expect(wrapper.text()).not.toContain('请选择有效的起止日期') + }) + it('validates an explicit fresh query against Beijing today instead of yesterday’s pinned context', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-10-13T01:00:00Z')) + const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'sales' } }); wrappers.push(wrapper); await flushPromises() + await setFilters(wrapper, { rangePreset: 'CUSTOM', customRange: ['2026-10-13', '2026-10-13'] }) + await findButton(wrapper, '查询').trigger('click'); await flushPromises() + const request = mocks.api.summary.mock.calls.at(-1)?.[0] + expect(request).toMatchObject({ rangePreset: 'CUSTOM', startDate: '2026-10-13', endDate: '2026-10-13' }) + expect(request).not.toHaveProperty('queryAsOf') + }) + it('defaults to Beijing yesterday and excludes rolling 30-day predicates for a historical cutoff', async () => { vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date('2026-10-12T17:00:00Z')) const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()