fix(selfop): preserve warehouse navigation and refine overview presentation

This commit is contained in:
2026-10-13 11:06:20 +08:00
parent b73dc0d410
commit 58df0ed9e3
9 changed files with 296 additions and 57 deletions
@@ -1,9 +1,11 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Activity, ArrowUpRight, Boxes, Package, Layers, Clock3 } from '@lucide/vue'
import { useRoute, useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
@@ -23,6 +25,7 @@ const access = useAccessPreviewStore()
const permitted = computed(() => access.can('platform:self-operated-analytics:list'))
const sourceAccount = 'lingxing-primary'
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' })
let selectedWarehouse = String(filters.value.warehouseCode)
const context = ref<QueryContext | null>(null)
const data = ref<Dashboard | null>(null)
const runtime = ref<Overview | null>(null)
@@ -35,6 +38,7 @@ const newData = ref(false)
const now = ref(Date.now())
let timer: ReturnType<typeof setInterval> | undefined
let restoreScroll: number | null = null
let initialWarehouseResolved = false
let appliedRange: OverviewReturn = { rangePreset: 'LAST_15_DAYS', scroll: 0 }
try {
if (route.query.context !== undefined || route.query.overviewReturn !== undefined) {
@@ -49,7 +53,7 @@ const warehouseCode = computed(() => String(filters.value.warehouseCode ?? ''))
const contextExpired = computed(() => Boolean(context.value && expired(context.value, now.value)))
const stockExpired = computed(() => Boolean(context.value && inventoryExpired(context.value, now.value)))
const fields = computed<QueryField[]>(() => [
{ key: 'warehouseCode', type: 'select', label: '仓库', width: 220, placeholder: '请选择仓库(必选)', 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 },
...(filters.value.rangePreset === 'CUSTOM' ? [{ key: 'startDate', type: 'date' as const, label: '开始日期', width: 160 }, { key: 'endDate', type: 'date' as const, label: '结束日期', width: 160 }] : []),
])
@@ -58,6 +62,8 @@ const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: bo
{ key: 'physicalQuantity', label: '实物库存', inventory: true }, { key: 'availableQuantity', label: '正品可售', inventory: true },
{ key: 'physicalSkuCount', label: '有库存SKU', inventory: true }, { key: 'noOutboundSkuCount30d', label: '近30日无出库SKU', inventory: true },
]
const metricIcons = [ArrowUpRight, Activity, Boxes, Package, Layers, Clock3]
const metricColors = ['#5575ed', '#3aa9ba', '#8871ce', '#35a987', '#c6923e', '#d67b91']
const rankingNames = [{ key: 'hotSkus', label: '近30日热销Top10', unit: '件', all: '查看Top100' }, { key: 'longCoverageSkus', label: '预计消化时间最长Top10', unit: '天', all: '查看全部' }, { key: 'noOutboundSkus', label: '近30日无出库库存Top10', unit: '件', all: '查看全部' }] as const
const bucketRows = computed<BarRow[]>(() => buckets.map(bucket => {
const item = data.value?.coverageDistribution.buckets.find(row => row.bucketCode === bucket.code)
@@ -101,6 +107,12 @@ async function readRuntime() {
const result = await analyticsApi.overview(sourceAccount, undefined, request.signal)
if (!request.current()) return false
runtime.value = result; runtimeError.value = ''
if (!initialWarehouseResolved && !routeError.value) {
initialWarehouseResolved = true
if (!warehouseCode.value) filters.value.warehouseCode = result.warehouses.find(item => item.warehouseCode)?.warehouseCode ?? ''
await nextTick()
if (warehouseCode.value && !routeError.value && !loading.value && !data.value) void load()
}
if (context.value && result.warehouses.some(item => item.warehouseCode === warehouseCode.value && item.lastPublishedAt && Date.parse(item.lastPublishedAt) > Date.parse(context.value!.queryAsOf))) newData.value = true
} catch { if (request.current()) runtimeError.value = '采集状态更新失败,经营图表仍保留原查询版本。' }
return false
@@ -131,8 +143,16 @@ async function showTrend(item: DashboardItem) {
} catch (cause) { if (request.current()) trendError.value = getApiErrorDetails(cause, 'SKU趋势读取失败').message }
finally { if (request.current()) trendLoading.value = false }
}
function reset() { filters.value = { warehouseCode: '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' }; routeError.value = '' }
watch(warehouseCode, () => { dataFence.cancel(); trendFence.cancel(); data.value = null; context.value = null; trendOpen.value = false; loading.value = false; error.value = ''; newData.value = false; routeError.value = ''; restoreScroll = null })
async function reset() {
filters.value = { warehouseCode: warehouseCode.value || selectedWarehouse || runtime.value?.warehouses.find(item => item.warehouseCode)?.warehouseCode || '', rangePreset: 'LAST_15_DAYS', startDate: '', endDate: '' }
routeError.value = ''
await nextTick()
void load(true)
}
watch(warehouseCode, code => {
dataFence.cancel(); trendFence.cancel(); data.value = null; context.value = null; trendOpen.value = false; loading.value = false; error.value = ''; errorDetails.value = null; newData.value = false; routeError.value = ''; restoreScroll = null
if (code) { selectedWarehouse = code; void load(true) }
})
watch(trendOpen, open => { if (!open) { trendFence.cancel(); trend.value = null; trendLoading.value = false } })
onMounted(() => { visibility(); poller.start(); timer = setInterval(() => { now.value = Date.now() }, 1000); document.addEventListener('visibilitychange', visibility); if (context.value && !routeError.value) void load() })
onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clearInterval(timer); document.removeEventListener('visibilitychange', visibility) })
@@ -141,9 +161,12 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<AdminPage class="admin-density dashboard-page">
<p v-if="!permitted" role="alert">没有自营经营数据查看权限。</p>
<template v-else>
<div class="density-actions dashboard-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: appliedRange.rangePreset, ...(appliedRange.startDate ? { startDate: appliedRange.startDate, endDate: appliedRange.endDate! } : {}) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button></div>
<QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" ariaLabel="经营总览筛选" @search="load()" @reset="reset" />
<div class="density-actions"><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button><span>趋势范围只影响区间出库与日趋势,其余指标使用近30个完整日。</span></div>
<DataTable appearance="integrated" :data="[]" :columns="[]" :results-visible="false" column-settings-mode="hide">
<template #toolbar-start><div class="dashboard-title"><h2>经营总览</h2><span class="dashboard-snapshot">{{ runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode }}<i />{{ loading ? '正在更新' : data ? '固定版本 · 北京时间' : '正在准备数据' }}</span></div></template>
<template #toolbar-actions><div class="density-actions"><el-button :disabled="!data || contextExpired" @click="drill('sales', { analysis: 'SKU', rangePreset: appliedRange.rangePreset, ...(appliedRange.startDate ? { startDate: appliedRange.startDate, endDate: appliedRange.endDate! } : {}) })">查看销量</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/inventory', query: { warehouseCode } })">{{ stockExpired ? '普通库存诊断' : '查看库存' }}</el-button><el-button :disabled="!warehouseCode" @click="router.push({ path: '/self-operated/collection', query: { warehouseCode } })">数据采集</el-button><el-button :disabled="!warehouseCode || loading || Boolean(routeError)" @click="load(true)">整体刷新</el-button></div></template>
<template #filters><QueryToolbar v-model="filters" :fields="fields" :loading="loading" :search-on-reset="false" preference-key="self-operated-overview-query" ariaLabel="经营总览筛选" @search="load()" @reset="reset" /></template>
</DataTable>
<details class="dashboard-data-notes"><summary>统计口径与采集时间</summary><p>趋势范围只影响区间出库与日趋势,其余指标使用近30个完整日。</p><p v-if="data">销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p></details>
<p v-if="!warehouseCode">请先选择一个仓库并查询经营数据。</p>
<p v-if="routeError" role="alert">{{ routeError }}。请返回正常入口重新选择仓库。</p>
<LoadErrorState v-if="error" title="经营总览查询未完成" :message="error" compact :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta v-if="errorDetails" :details="errorDetails" /></template></LoadErrorState>
@@ -151,18 +174,18 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
<p v-if="newData" role="status">有新数据,可整体刷新;当前图表仍使用原查询版本。</p>
<p v-if="contextExpired" role="alert">查询上下文已过期,以下图表为历史结果,请整体刷新。</p>
<p v-else-if="stockExpired && data" role="status">原图库存已过期或不可用,库存指标为历史/待刷新资料,严格库存下钻已停用;出库趋势与热销仍可查看。</p>
<div v-if="loading && !data" class="dashboard-placeholder" role="status" aria-busy="true">正在读取六项指标、日趋势、库存分布和三个榜单…</div>
<div v-if="loading && !data" class="dashboard-placeholder" role="status" aria-busy="true"><span>正在读取六项指标、日趋势、库存分布和三个榜单…</span><div class="dashboard-skeleton-kpis" aria-hidden="true"><i v-for="index in 6" :key="index" /></div><div class="dashboard-skeleton-charts" aria-hidden="true"><i /><i /></div></div>
<template v-if="data">
<p>销量覆盖截至 {{ beijingTime(data.context.salesCoveredUntil) }};库存采集于 {{ beijingTime(data.context.inventoryObservedFrom) }} 至 {{ beijingTime(data.context.inventoryObservedUntil) }}(北京时间,本仓一轮完整分页观测)。</p>
<div class="dashboard-kpis" aria-label="本仓经营指标">
<section v-for="metric in metricNames" :key="metric.key"><h3>{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3><strong>{{ quantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong><span> {{ metric.key === 'averageDailyOutboundQuantity30d' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span><p>{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p><p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'">已知小计 {{ quantityText(data.kpis[metric.key].knownSubtotal) }}</p><p v-if="data.kpis[metric.key].unknownSkuCount != null">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small>{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
<section v-for="(metric, index) in metricNames" :key="metric.key" :style="{ '--metric-color': metricColors[index], '--reveal-delay': `${index * 45}ms` }"><div class="dashboard-metric-heading"><h3>{{ metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? '区间已采集出库量' : metric.label }}</h3><component :is="metricIcons[index]" :size="18" aria-hidden="true" /></div><div class="dashboard-metric-value"><strong>{{ quantityText(metric.key === 'intervalOutboundQuantity' && data.kpis[metric.key].dataStatus !== 'COMPLETE' ? data.kpis[metric.key].observedQuantity : data.kpis[metric.key].value) }}</strong><span> {{ metric.key === 'averageDailyOutboundQuantity30d' ? '件/日' : data.kpis[metric.key].unit === 'SKU' ? 'SKU' : '件' }}</span></div><p class="dashboard-metric-status">{{ contextExpired || metric.inventory && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data.kpis[metric.key].dataStatus) }}</p><p v-if="data.kpis[metric.key].value == null && metric.key !== 'intervalOutboundQuantity'">已知小计 {{ quantityText(data.kpis[metric.key].knownSubtotal) }}</p><p v-if="data.kpis[metric.key].unknownSkuCount != null">另 {{ quantityText(data.kpis[metric.key].unknownSkuCount) }} SKU 待核验</p><small>{{ reasonsText(data.kpis[metric.key].reasonCodes) }}</small></section>
</div>
<div class="dashboard-main-charts">
<section class="dashboard-chart"><h3>出库趋势</h3><p>{{ data.context.rangeStartDate }} 至 {{ data.context.rangeEndDateExclusive }}(截止日不含)</p><DashboardTrend :points="data.salesTrend.points" :disabled="contextExpired" @select="date => drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: date, endDate: date })" /></section>
<section class="dashboard-chart"><h3>库存消化天数分布</h3><p>有实物库存或库存待核验 SKU;{{ data.context.fixedWindowStartDate }} 至 {{ data.context.fixedWindowEndDateExclusive }}(截止日不含)。</p><p>候选 {{ quantityText(data.coverageDistribution.candidateSkuCount) }};已排除确定零实物 {{ quantityText(data.coverageDistribution.excludedZeroPhysicalSkuCount) }} SKU。{{ label(data.coverageDistribution.dataStatus) }} · {{ reasonsText(data.coverageDistribution.reasonCodes) }}</p><p v-if="data.coverageDistribution.candidateSkuCount == null">无可用库存分布资料;销售候选不作为库存总量。</p><DashboardBars v-else :rows="bucketRows" title="库存消化天数分布" unit="SKU" :disabled="stockExpired || contextExpired" @select="code => drill('inventory', { coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' })" /><details><summary>分组说明与待核验原因</summary><p>30/60/90 仅为图形分组,不是经营评级;无可售库存不代表周转良好。零出库不计无限天数。</p><p v-for="(count, reason) in data.coverageDistribution.unknownReasons" :key="reason">{{ reasonsText([String(reason)]) }}:{{ quantityText(count) }} SKU</p></details></section>
</div>
<div class="dashboard-rankings">
<section v-for="ranking in rankingNames" :key="ranking.key" class="dashboard-chart"><div class="dashboard-ranking-heading"><h3>{{ ranking.label }}</h3><el-button :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button></div><p>{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。</p><p>{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}</p><DashboardBars :rows="rankingRows(data[ranking.key].items)" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!)" /></section>
<section v-for="ranking in rankingNames" :key="ranking.key" class="dashboard-chart"><div class="dashboard-ranking-heading"><h3>{{ ranking.label }}</h3><el-button :disabled="contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click="allRanking(ranking.key)">{{ ranking.all }}</el-button></div><p>{{ data[ranking.key].windowStartDate }} 至 {{ data[ranking.key].windowEndDateExclusive }}(截止日不含);符合条件 {{ quantityText(data[ranking.key].eligibleCount) }} SKU,待评估 {{ quantityText(data[ranking.key].unassessableCount) }}。</p><p>{{ contextExpired || ranking.key !== 'hotSkus' && stockExpired ? '历史/待刷新 · ' : '' }}{{ label(data[ranking.key].dataStatus) }} · {{ reasonsText(data[ranking.key].reasonCodes) }}</p><DashboardBars ranked :rows="rankingRows(data[ranking.key].items)" :title="ranking.label" :unit="ranking.unit" :disabled="contextExpired" @select="index => showTrend(data![ranking.key].items[Number(index)]!)" /></section>
</div>
</template>
<AppDialog v-model="trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable><div class="admin-density"><p v-if="trendError" role="alert">{{ trendError }}</p><SkuTrend v-if="trend" :data="trend" /></div></AppDialog>
@@ -171,15 +194,39 @@ onUnmounted(() => { dataFence.cancel(); trendFence.cancel(); poller.stop(); clea
</template>
<style scoped>
.dashboard-page { display: grid; gap: var(--admin-space-3); }
.dashboard-actions { justify-content: flex-end; }
.dashboard-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); padding: var(--admin-space-3); border-block: 1px solid var(--el-border-color); gap: var(--admin-space-3); }
.dashboard-title { display: flex; align-items: center; gap: 16px; }
.dashboard-data-notes { font-size: var(--admin-font-caption); color: #76849c; }
.dashboard-data-notes summary { cursor: pointer; width: fit-content; }
.dashboard-data-notes p { margin: 6px 0; }
.dashboard-snapshot { display: inline-flex; align-items: center; gap: 8px; color: #65748d; font-size: var(--admin-font-caption); }
.dashboard-snapshot i { width: 7px; height: 7px; border-radius: 50%; background: #5575ed; }
.dashboard-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
.dashboard-kpis section { min-width: 0; position: relative; padding: 16px; border: 1px solid #e5eaf4; border-radius: 8px; background: white; border-top: 3px solid var(--metric-color); animation: dashboard-reveal .5s ease-out both; animation-delay: var(--reveal-delay); }
.dashboard-metric-heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.dashboard-metric-heading svg { color: var(--metric-color); flex-shrink: 0; }
.dashboard-kpis h3, .dashboard-chart h3 { margin: 0; font-size: var(--admin-font-section); }
.dashboard-kpis strong { font-size: var(--admin-font-title); }
.dashboard-kpis h3 { color: #65748d; font-size: var(--admin-font-caption); font-weight: 500; }
.dashboard-metric-value { margin: 12px 0 8px; display: flex; flex-wrap: wrap; align-items: baseline; gap: 5px; }
.dashboard-kpis strong { font-size: 28px; font-weight: 700; letter-spacing: -.7px; font-variant-numeric: tabular-nums; color: #24334d; overflow-wrap: anywhere; }
.dashboard-metric-value > span { font-size: var(--admin-font-caption); color: #8290a6; }
.dashboard-metric-status { color: var(--metric-color); }
.dashboard-kpis p { margin: var(--admin-space-1) 0; }
.dashboard-main-charts { display: grid; grid-template-columns: 2fr 1fr; gap: var(--admin-space-4); }
.dashboard-rankings { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--admin-space-4); }
.dashboard-chart { min-width: 0; padding: var(--admin-space-3); background: var(--admin-surface); border: 1px solid var(--el-border-color); border-radius: var(--admin-radius); }
.dashboard-chart { min-width: 0; padding: 18px; background: var(--admin-surface); border: 1px solid #e5eaf4; border-radius: 8px; animation: dashboard-reveal .6s ease-out both; }
.dashboard-chart > h3, .dashboard-ranking-heading h3 { display: flex; align-items: center; gap: 8px; }
.dashboard-chart > h3::before, .dashboard-ranking-heading h3::before { content: ''; width: 3px; height: 14px; border-radius: 2px; background: #5575ed; }
.dashboard-chart > p { color: #76849c; font-size: var(--admin-font-caption); line-height: 1.7; }
.dashboard-rankings .dashboard-chart:nth-child(2) { animation-delay: .08s; }
.dashboard-rankings .dashboard-chart:nth-child(3) { animation-delay: .16s; }
.dashboard-ranking-heading { display: flex; align-items: center; justify-content: space-between; gap: var(--admin-space-2); }
.dashboard-placeholder { min-height: 320px; padding: var(--admin-space-4); background: var(--el-fill-color-light); }
.dashboard-placeholder { padding: 12px 0; color: #76849c; }
.dashboard-skeleton-kpis { display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px; margin: 12px 0; }
.dashboard-skeleton-charts { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; }
.dashboard-placeholder i { display: block; height: 130px; border-radius: 8px; background: linear-gradient(100deg, #eaf0f8 30%, #f8faff 50%, #eaf0f8 70%); background-size: 200% 100%; animation: skeleton-shimmer 1.5s infinite; }
.dashboard-skeleton-charts i { height: 320px; }
@keyframes dashboard-reveal { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes skeleton-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
@media (prefers-reduced-motion: reduce) { .dashboard-kpis section, .dashboard-chart, .dashboard-placeholder i { animation: none; } }
small { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
</style>