fix: keep analytics date ranges together and apply atomically

This commit is contained in:
2026-10-13 13:43:16 +08:00
parent 6bdaff3fdf
commit 1de73b5f1c
5 changed files with 147 additions and 27 deletions
@@ -32,7 +32,7 @@ function readDrilldown() {
} }
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 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<QueryModel>(initialFilters()) const filters = ref<QueryModel>(initialFilters())
const applied = ref({ sku: String(filters.value.sku) }) const applied = ref({ sku: String(filters.value.sku) })
const appliedWindow = ref({ salesWindowDays: 30 as 10 | 30, salesWindowEndDate: String(filters.value.salesWindowEndDate) }) 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 size = ref(20)
const total = ref(0) const total = ref(0)
const preset = computed(() => (filters.value.rangePreset || 'TODAY') as RangePreset) const preset = computed(() => (filters.value.rangePreset || 'TODAY') as RangePreset)
const customStart = computed(() => String(filters.value.customStart ?? '')) const customRange = computed(() => Array.isArray(filters.value.customRange) ? filters.value.customRange : [])
const customEnd = computed(() => String(filters.value.customEnd ?? '')) 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<SalesRange | null>(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 tab = computed(() => (filters.value.analysis || 'summary') as 'summary' | 'hot' | 'slow')
const windowDays = computed(() => Number(filters.value.windowDays || 30) as 7 | 30) const windowDays = computed(() => Number(filters.value.windowDays || 30) as 7 | 30)
const sourceAccount = 'lingxing-primary' const sourceAccount = 'lingxing-primary'
@@ -82,7 +87,7 @@ const fields = computed<QueryField[]>(() => {
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' }] }) 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') { 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: '自定义' }] }) 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个完整自然日' }] }) } 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 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) } 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 (drilldown.value?.customerCode) result.customerCode = drilldown.value.customerCode
if (summaryMode.value) { if (summaryMode.value) {
result.rangePreset = preset.value Object.assign(result, appliedSalesRange.value)
if (preset.value === 'CUSTOM') { result.startDate = customStart.value; result.endDate = customEnd.value }
} else if (props.mode === 'sales' && tab.value === 'hot') result.windowDays = windowDays.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) if (props.mode === 'inventory') Object.assign(result, appliedInventory.value, appliedWindow.value, drilldown.value?.inventory)
return result return result
@@ -129,10 +133,7 @@ async function load(reset = false, keepContext = false) {
if (!permitted.value || !warehouseCode.value || routeProblem.value) return if (!permitted.value || !warehouseCode.value || routeProblem.value) return
if (origin.value && context.value && expired(context.value)) { error.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 (drilldown.value && props.mode === 'inventory' && context.value && inventoryExpired(context.value)) { error.value = '原图库存已过期,请刷新总览'; return }
if (summaryMode.value && preset.value === 'CUSTOM') { if (summaryMode.value && !appliedSalesRange.value) return
const problem = validateCustomRange(customStart.value, customEnd.value, context.value?.anchorDate)
if (problem) { error.value = problem; return }
}
if (reset) { if (!origin.value && !keepContext) context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 } 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 trendOpen.value = false
dailyFence.cancel() dailyFence.cancel()
@@ -162,11 +163,11 @@ async function showTrend(row: SalesSummary | Metric | InventoryMetric) {
trendErrorDetails.value = null trendErrorDetails.value = null
const request = dailyFence.begin() const request = dailyFence.begin()
try { 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) { if (props.mode === 'inventory' && context.value?.rangeStartDate && context.value.rangeEndDateExclusive) {
params.startDate = context.value.rangeStartDate params.startDate = context.value.rangeStartDate
params.endDate = new Date(Date.parse(`${context.value.rangeEndDateExclusive}T00:00:00Z`) - 86400000).toISOString().slice(0, 10) 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) const result = await analyticsApi.daily(params, request.signal)
if (!request.current()) return if (!request.current()) return
if (!context.value || !sameContext(context.value, result.context)) throw new Error('趋势版本与列表不一致,请重新查询') 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()) 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 } }) 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 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() { function search() {
if (summaryMode.value && !applySalesRange(origin.value?.context.anchorDate ?? beijingToday())) return
let nextWindow = appliedWindow.value let nextWindow = appliedWindow.value
if (props.mode === 'inventory') { if (props.mode === 'inventory') {
const end = String(filters.value.salesWindowEndDate ?? '') const end = String(filters.value.salesWindowEndDate ?? '')
@@ -217,14 +225,15 @@ function search() {
current.value = 1 current.value = 1
void load(true, keepContext) 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() { function returnOverview() {
const saved = origin.value 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) } : {} }) 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() { async function clearDrilldown() {
drilldown.value = null 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 applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }; current.value = 1
await nextTick(); void load() await nextTick(); void load()
} }
@@ -232,13 +241,19 @@ function link(path: string) { void router.push({ path, query: { warehouseCode: w
let applyingRoute = false 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(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([() => 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() }) watch([current, size], () => { if (!applyingRoute) void load() })
onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() }) onMounted(() => { visibility(); poller.start(); document.addEventListener('visibilitychange', visibility); void load() })
watch(() => route.query, async () => { watch(() => route.query, async () => {
if (route.path && route.path !== `/self-operated/${props.mode}`) return if (route.path && route.path !== `/self-operated/${props.mode}`) return
applyingRoute = true; dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false; rows.value = []; total.value = 0 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() await nextTick(); applyingRoute = false; void load()
}) })
onActivated(() => { visibility(); poller.start() }) onActivated(() => { visibility(); poller.start() })
@@ -256,6 +271,7 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
<div v-if="origin" class="density-actions"><span>{{ drilldown?.summary ?? '来自经营总览 · 来源筛选已清除,保留原有效版本' }}</span><el-button v-if="drilldown" @click="clearDrilldown">清除来源条件</el-button><el-button @click="returnOverview">返回经营总览</el-button></div> <div v-if="origin" class="density-actions"><span>{{ drilldown?.summary ?? '来自经营总览 · 来源筛选已清除,保留原有效版本' }}</span><el-button v-if="drilldown" @click="clearDrilldown">清除来源条件</el-button><el-button @click="returnOverview">返回经营总览</el-button></div>
<p v-if="runtimeError" class="analytics-note" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p> <p v-if="runtimeError" class="analytics-note" role="status">{{ runtimeError }};最近观测:{{ beijingTime(overview?.observedAt) }}</p>
<LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState> <LoadErrorState v-if="error" title="查询未完成" :message="error" :loading="loading" compact @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
<p v-if="salesRangePending" class="analytics-note" role="status">日期范围尚未应用,请选择完整起止日期后点击查询。<template v-if="rows.length">当前列表及每日趋势仍使用上次已应用的统计区间。</template></p>
<p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。</p> <p v-if="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。</p>
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-observation">本仓库存采集时间(北京时间):{{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.inventoryObservedFrom && context.inventoryObservedUntil ? '当前列表来自本仓一轮完整分页观测,各页在该时段内采集,不代表同一瞬间的库存。' : '尚无完整库存观测时段,缺失时间不以发布时间代替。' }}</p> <p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-observation">本仓库存采集时间(北京时间):{{ beijingTime(context.inventoryObservedFrom) }} 至 {{ beijingTime(context.inventoryObservedUntil) }}。{{ context.inventoryObservedFrom && context.inventoryObservedUntil ? '当前列表来自本仓一轮完整分页观测,各页在该时段内采集,不代表同一瞬间的库存。' : '尚无完整库存观测时段,缺失时间不以发布时间代替。' }}</p>
<p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window">销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。</p> <p v-if="mode === 'inventory' && context" class="analytics-note" data-testid="inventory-window">销量计算区间(北京时间):{{ context.rangeStartDate ?? '—' }} 至 {{ context.rangeEndDateExclusive ? previousDate(context.rangeEndDateExclusive) : '—' }}(含截止日),{{ displayedWindowDays }} 天;销量状态:{{ label(context.salesStatus) }}。未采齐不推算日均及预计可售天数。</p>
@@ -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 | 查询、刷新与六项指标 | `docs/自营业务/经营总览图表设计.md`“菜单与页面布局”“指标带”;单仓+趋势日期QueryToolbar、紧凑指标带,整体固定版本 | 不新增货主控件;未知/过期独立提示;待人工1920×1080截图确认 |
| /self-operated/overview | 日趋势、七桶与三个Top10 | 同设计“五张图表”;SVG折线与横条、趋势约2/3宽、分布约1/3宽,三榜并排 | 可聚焦提示、缺日断线、部分日文字、真实数值比例;待人工截图确认 | | /self-operated/overview | 日趋势、七桶与三个Top10 | 同设计“五张图表”;SVG折线与横条、趋势约2/3宽、分布约1/3宽,三榜并排 | 可聚焦提示、缺日断线、部分日文字、真实数值比例;待人工截图确认 |
| /self-operated/overview | SKU趋势 | 同设计“图表交互与视觉依据”;1120px居中AppDialog,固定30日、精确货主SKU | 关闭取消请求,不改现有详情承载;待人工截图确认 | | /self-operated/overview | SKU趋势 | 同设计“图表交互与视觉依据”;1120px居中AppDialog,固定30日、精确货主SKU | 关闭取消请求,不改现有详情承载;待人工截图确认 |
@@ -12,7 +12,7 @@ vi.mock('../api/analytics', () => ({ analyticsApi: mocks }))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }), useRouter: () => ({ push: mocks.push }) })) vi.mock('vue-router', () => ({ useRoute: () => ({ query: mocks.query }), useRouter: () => ({ push: mocks.push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.permitted }) })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => mocks.permitted }) }))
const passthrough = defineComponent({ setup(_, { slots }) { return () => h('div', slots.default?.()) } }) 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 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 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 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<typeof mount>[] = [] const wrappers: ReturnType<typeof mount>[] = []
function page() { const wrapper = mount(OverviewView, options); wrappers.push(wrapper); return wrapper } 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)! } function findButton(wrapper: ReturnType<typeof mount>, text: string) { return wrapper.findAll('button').find(node => node.text() === text)! }
async function choose(wrapper: ReturnType<typeof mount>, values: Record<string, string>) { const toolbar = wrapper.findComponent(query); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() } async function choose(wrapper: ReturnType<typeof mount>, values: Record<string, string | string[] | null>) { const toolbar = wrapper.findComponent(query); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); mocks.query = {}; mocks.permitted = true vi.clearAllMocks(); mocks.query = {}; mocks.permitted = true
vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined) vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined)
@@ -30,6 +30,43 @@ beforeEach(() => {
}) })
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() }) afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers(); vi.restoreAllMocks() })
describe('warehouse operating dashboard', () => { 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 () => { 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.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' }) mocks.overview.mockResolvedValue({ warehouses: [{ warehouseCode: 'WH-A', warehouseName: 'A仓' }], observedAt: '2026-10-13T00:00:00Z' })
@@ -24,13 +24,14 @@ const router = useRouter()
const access = useAccessPreviewStore() const access = useAccessPreviewStore()
const permitted = computed(() => access.can('platform:self-operated-analytics:list')) const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
const sourceAccount = 'lingxing-primary' const sourceAccount = 'lingxing-primary'
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' }) const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', rangePreset: 'LAST_15_DAYS', dateRange: [] })
let selectedWarehouse = String(filters.value.warehouseCode) let selectedWarehouse = String(filters.value.warehouseCode)
const context = ref<QueryContext | null>(null) const context = ref<QueryContext | null>(null)
const data = ref<Dashboard | null>(null) const data = ref<Dashboard | null>(null)
const runtime = ref<Overview | null>(null) const runtime = ref<Overview | null>(null)
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const rangeValidationError = ref('')
const errorDetails = ref<ApiErrorDetails | null>(null) const errorDetails = ref<ApiErrorDetails | null>(null)
const runtimeError = ref('') const runtimeError = ref('')
const routeError = 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('经营总览返回参数无效') if (typeof route.query.context !== 'string' || typeof route.query.overviewReturn !== 'string' || !filters.value.warehouseCode) throw new Error('经营总览返回参数无效')
context.value = parseContext(route.query.context) context.value = parseContext(route.query.context)
appliedRange = parseOverviewReturn(route.query.overviewReturn) 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 restoreScroll = appliedRange.scroll
} }
} catch (cause) { routeError.value = cause instanceof Error ? cause.message : '经营总览返回参数无效' } } catch (cause) { routeError.value = cause instanceof Error ? cause.message : '经营总览返回参数无效' }
@@ -55,7 +56,7 @@ const stockExpired = computed(() => Boolean(context.value && inventoryExpired(co
const fields = computed<QueryField[]>(() => [ const fields = computed<QueryField[]>(() => [
{ 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: '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 }, { 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 }[] = [ const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: boolean }[] = [
{ key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity30d', label: '近30日日均出库' }, { key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity30d', label: '近30日日均出库' },
@@ -82,13 +83,14 @@ async function load(refresh = false) {
if (!refresh && contextExpired.value) { error.value = '原图查询已过期,请整体刷新经营总览'; return } if (!refresh && contextExpired.value) { error.value = '原图查询已过期,请整体刷新经营总览'; return }
const range = String(filters.value.rangePreset) as RangePreset const range = String(filters.value.rangePreset) as RangePreset
if (!rangeOptions.some(option => option.value === range)) { error.value = '请选择有效的出库趋势范围'; return } if (!rangeOptions.some(option => option.value === range)) { error.value = '请选择有效的出库趋势范围'; return }
const start = String(filters.value.startDate ?? '') const selected = Array.isArray(filters.value.dateRange) ? filters.value.dateRange : []
const end = String(filters.value.endDate ?? '') const start = typeof selected[0] === 'string' ? selected[0] : ''
if (range === 'CUSTOM') { const problem = validateCustomRange(start, end, context.value?.anchorDate); if (problem) { error.value = problem; return } } 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 pinned = refresh ? null : context.value
const request = dataFence.begin() const request = dataFence.begin()
trendOpen.value = false trendOpen.value = false
loading.value = true; error.value = ''; errorDetails.value = null loading.value = true; error.value = ''; rangeValidationError.value = ''; errorDetails.value = null
try { try {
const params: DashboardQuery = { sourceAccount, warehouseCode: warehouseCode.value, rangePreset: range, ...contextParams(pinned), ...(range === 'CUSTOM' ? { startDate: start, endDate: end } : {}) } 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) const result = await analyticsApi.dashboard(params, request.signal)
@@ -144,7 +146,7 @@ async function showTrend(item: DashboardItem) {
finally { if (request.current()) trendLoading.value = false } finally { if (request.current()) trendLoading.value = false }
} }
async function reset() { 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 = '' routeError.value = ''
await nextTick() await nextTick()
void load(true) 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 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) } 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 } }) 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() }) 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) }) onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clearInterval(timer); document.removeEventListener('visibilitychange', visibility) })
@@ -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 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<typeof mount>[] = [] const wrappers: ReturnType<typeof mount>[] = []
function findButton(wrapper: ReturnType<typeof mount>, text: string) { const result = wrapper.findAll('button').find(item => item.text() === text); if (!result) throw new Error(`missing button ${text}`); return result } function findButton(wrapper: ReturnType<typeof mount>, 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<typeof mount>, values: Record<string, string | number>) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() } async function setFilters(wrapper: ReturnType<typeof mount>, values: Record<string, string | number | string[]>) { const toolbar = wrapper.findComponent(queryStub); toolbar.vm.$emit('update:modelValue', { ...toolbar.props('modelValue'), ...values }); await flushPromises() }
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
mocks.canSync = true; mocks.routeState = null mocks.canSync = true; mocks.routeState = null
@@ -50,6 +50,64 @@ beforeEach(() => {
}) })
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() }) afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()); vi.useRealTimers() })
describe('self-operated actual workbench request state', () => { 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 () => { 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')) 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() const wrapper = mount(AnalyticsView, { ...options, props: { mode: 'inventory' } }); wrappers.push(wrapper); await flushPromises()