feat(selfop): add operating dashboard and preserve drilldown context
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import '@/styles/adminDensity.css'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onActivated, onDeactivated, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
@@ -15,17 +15,27 @@ import { analyticsApi } from '../api/analytics'
|
||||
import type { BusinessQuery, DailyResponse, InventoryAttention, InventorySort, Metric, Overview, QueryContext, RangePreset, SalesSummary } from '../types'
|
||||
import { RequestFence, RuntimePoller, contextParams, hasActiveTask, label, beijingTime, reasonsText, quantityText, sameContext, shownQuantity, validateCustomRange } from '../state'
|
||||
import SkuTrend from './SkuTrend.vue'
|
||||
import { expired, inventoryExpired, parseDashboardDrilldown, type DashboardDrilldown } from '../dashboardNavigation'
|
||||
|
||||
const props = defineProps<{ mode: 'sales' | 'inventory' }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
|
||||
function initialFilters(): QueryModel { return { warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', sku: typeof route.query.sku === 'string' ? route.query.sku : '', analysis: 'summary', rangePreset: 'TODAY', windowDays: 30, attention: 'ALL', sortBy: 'AVAILABLE_DESC', threshold: '', customStart: '', customEnd: '' } }
|
||||
const routeProblem = ref('')
|
||||
const drilldown = ref<DashboardDrilldown | null>(null)
|
||||
const origin = ref<DashboardDrilldown | null>(null)
|
||||
function readDrilldown() {
|
||||
routeProblem.value = ''
|
||||
try { drilldown.value = parseDashboardDrilldown(route.query, props.mode); origin.value = drilldown.value }
|
||||
catch (cause) { drilldown.value = null; origin.value = null; routeProblem.value = cause instanceof Error ? cause.message : '经营总览下钻条件无效' }
|
||||
}
|
||||
readDrilldown()
|
||||
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, attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC', threshold: '', customStart: drilldown.value?.startDate ?? '', customEnd: drilldown.value?.endDate ?? '' } }
|
||||
const filters = ref<QueryModel>(initialFilters())
|
||||
const applied = ref({ sku: String(filters.value.sku) })
|
||||
const overview = ref<Overview | null>(null)
|
||||
const context = ref<QueryContext | null>(null)
|
||||
const context = ref<QueryContext | null>(drilldown.value?.context ?? null)
|
||||
const rows = ref<(SalesSummary | Metric)[]>([])
|
||||
const daily = ref<DailyResponse | null>(null)
|
||||
const trendOpen = ref(false)
|
||||
@@ -35,7 +45,7 @@ const trendErrorDetails = ref<ApiErrorDetails | null>(null)
|
||||
const attention = computed(() => (filters.value.attention || 'ALL') as InventoryAttention)
|
||||
const sortBy = computed(() => (filters.value.sortBy || 'AVAILABLE_DESC') as InventorySort)
|
||||
const thresholdInput = computed(() => String(filters.value.threshold ?? ''))
|
||||
const appliedInventory = ref<{ attention: InventoryAttention; sortBy: InventorySort; coverageDaysThreshold?: number }>({ attention: 'ALL', sortBy: 'AVAILABLE_DESC' })
|
||||
const appliedInventory = ref<{ attention: InventoryAttention; sortBy: InventorySort; coverageDaysThreshold?: number }>({ attention: drilldown.value?.inventory.attention ?? 'ALL', sortBy: drilldown.value?.inventory.sortBy ?? 'AVAILABLE_DESC' })
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const errorDetails = ref<ApiErrorDetails | null>(null)
|
||||
@@ -69,7 +79,7 @@ const fields = computed<QueryField[]>(() => {
|
||||
if (preset.value === 'CUSTOM') result.push({ key: 'customStart', type: 'date', label: '开始日期', width: 160 }, { key: 'customEnd', type: 'date', label: '结束日期', width: 160 })
|
||||
} 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 result
|
||||
return drilldown.value ? result.map(field => field.key === 'warehouseCode' ? field : { ...field, props: { ...field.props, disabled: true } }) : result
|
||||
})
|
||||
const summaryMode = computed(() => props.mode === 'sales' && tab.value === 'summary')
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
@@ -100,20 +110,23 @@ function asSummary(row: unknown) { return row as SalesSummary }
|
||||
function asMetric(row: unknown) { return row as Metric }
|
||||
function query(): BusinessQuery {
|
||||
const result: BusinessQuery = { 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 }
|
||||
} else if (props.mode === 'sales' && tab.value === 'hot') result.windowDays = windowDays.value
|
||||
if (props.mode === 'inventory') Object.assign(result, appliedInventory.value)
|
||||
if (props.mode === 'inventory') Object.assign(result, appliedInventory.value, drilldown.value?.inventory)
|
||||
return result
|
||||
}
|
||||
async function load(reset = false) {
|
||||
if (!permitted.value || !warehouseCode.value) return
|
||||
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 (reset) { context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
|
||||
if (reset) { if (!origin.value) context.value = null; daily.value = null; rows.value = []; total.value = 0; newData.value = false; current.value = 1 }
|
||||
trendOpen.value = false
|
||||
dailyFence.cancel()
|
||||
const request = dataFence.begin()
|
||||
@@ -184,11 +197,30 @@ function search() {
|
||||
void load(true)
|
||||
}
|
||||
function reset() { filters.value = { ...initialFilters(), warehouseCode: '', sku: '' }; 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: '' }
|
||||
applied.value = { sku: '' }; appliedInventory.value = { attention: 'ALL', sortBy: 'AVAILABLE_DESC' }; current.value = 1
|
||||
await nextTick(); void load()
|
||||
}
|
||||
function link(path: string) { void router.push({ path, query: { warehouseCode: warehouseCode.value, sku: applied.value.sku } }) }
|
||||
watch(warehouseCode, () => { trendOpen.value = false; dataFence.cancel(); dailyFence.cancel(); rows.value = []; total.value = 0; context.value = null; daily.value = null; error.value = ''; current.value = 1; void load(true) })
|
||||
watch([preset, tab, windowDays], () => { current.value = 1; if (!summaryMode.value || preset.value !== 'CUSTOM' || customStart.value && customEnd.value) void load() })
|
||||
watch([current, size], () => { void load() })
|
||||
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([preset, tab, windowDays], () => { if (applyingRoute) return; current.value = 1; if (!summaryMode.value || preset.value !== 'CUSTOM' || customStart.value && customEnd.value) 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(); 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() })
|
||||
onDeactivated(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); trendOpen.value = false })
|
||||
onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); document.removeEventListener('visibilitychange', visibility) })
|
||||
</script>
|
||||
<template>
|
||||
@@ -198,6 +230,8 @@ onUnmounted(() => { poller.stop(); dataFence.cancel(); dailyFence.cancel(); docu
|
||||
<DataTable appearance="integrated" v-model:page="current" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" :empty-text="!warehouseCode ? '请选择仓库后查询SKU' : error ? '查询失败,请重试' : '暂无符合条件的SKU数据'">
|
||||
<template #filters><QueryToolbar v-model="filters" :fields="fields" ariaLabel="自营SKU筛选" :preference-key="`self-operated-${mode}-query`" :loading="loading" :search-on-reset="false" @search="search" @reset="reset" /></template>
|
||||
<template #notices>
|
||||
<p v-if="routeProblem" role="alert">{{ routeProblem }};已停止查询,不能按全仓条件继续。<el-button @click="returnOverview">返回经营总览</el-button></p>
|
||||
<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="newData || newDay" class="analytics-note" role="status">{{ newDay ? '北京时间已跨日。' : '有新数据。' }}点击查询更新数据。</p>
|
||||
|
||||
Reference in New Issue
Block a user