fix: keep analytics date ranges together and apply atomically
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user