fix: keep analytics date ranges together and apply atomically
This commit is contained in:
@@ -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<QueryModel>(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<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 windowDays = computed(() => Number(filters.value.windowDays || 30) as 7 | 30)
|
||||
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' }] })
|
||||
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
|
||||
<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>
|
||||
<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="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>
|
||||
|
||||
Reference in New Issue
Block a user