2026-10-13 00:45:41 +08:00
< script setup lang = "ts" >
import '@/styles/adminDensity.css'
import { computed , nextTick , onMounted , onUnmounted , ref , watch } from 'vue'
2026-10-13 16:52:09 +08:00
import { Activity , ArrowUpRight , Boxes , Package , Layers , Clock3 , Info } from '@lucide/vue'
2026-10-13 00:45:41 +08:00
import { useRoute , useRouter } from 'vue-router'
import AdminPage from '@/shared/components/AdminPage.vue'
import QueryToolbar , { type QueryField , type QueryModel } from '@/shared/components/QueryToolbar.vue'
2026-10-13 11:06:20 +08:00
import DataTable from '@/shared/components/DataTable.vue'
2026-10-13 00:45:41 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails , type ApiErrorDetails } from '@/shared/api/http'
import { analyticsApi } from '../api/analytics'
2026-10-13 18:47:56 +08:00
import type { Dashboard , DashboardItem , DashboardRanking , DailyResponse , DashboardQuery , Overview , QueryContext , RangePreset } from '../types'
2026-10-13 00:45:41 +08:00
import { beijingTime , contextParams , label , quantityText , reasonsText , RequestFence , RuntimePoller , sameContext , validateCustomRange } from '../state'
import { buckets , expired , inventoryExpired , parseContext , parseOverviewReturn , previousDate , rangeOptions , type OverviewReturn } from '../dashboardNavigation'
2026-10-13 20:44:31 +08:00
import HotSkuDetails from '../components/HotSkuDetails.vue'
import InventoryRankingDiagnostics from '../components/InventoryRankingDiagnostics.vue'
import ChartDetailsDialog from '../components/ChartDetailsDialog.vue'
2026-10-13 00:45:41 +08:00
import DashboardBars , { type BarRow } from '../components/DashboardBars.vue'
import DashboardTrend from '../components/DashboardTrend.vue'
import SkuTrend from '../components/SkuTrend.vue'
const route = useRoute ()
const router = useRouter ()
const access = useAccessPreviewStore ()
const permitted = computed (() => access . can ( 'platform:self-operated-analytics:list' ))
const sourceAccount = 'lingxing-primary'
2026-10-13 13:43:16 +08:00
const filters = ref < QueryModel >({ warehouseCode : typeof route . query . warehouseCode === 'string' ? route . query . warehouseCode : '' , rangePreset : 'LAST_15_DAYS' , dateRange : [] })
2026-10-13 11:06:20 +08:00
let selectedWarehouse = String ( filters . value . warehouseCode )
2026-10-13 00:45:41 +08:00
const context = ref < QueryContext | null >( null )
const data = ref < Dashboard | null >( null )
const runtime = ref < Overview | null >( null )
const loading = ref ( false )
const error = ref ( '' )
2026-10-13 13:43:16 +08:00
const rangeValidationError = ref ( '' )
2026-10-13 00:45:41 +08:00
const errorDetails = ref < ApiErrorDetails | null >( null )
const runtimeError = ref ( '' )
const routeError = ref ( '' )
const newData = ref ( false )
const now = ref ( Date . now ())
let timer : ReturnType < typeof setInterval > | undefined
let restoreScroll : number | null = null
2026-10-13 11:06:20 +08:00
let initialWarehouseResolved = false
2026-10-13 00:45:41 +08:00
let appliedRange : OverviewReturn = { rangePreset : 'LAST_15_DAYS' , scroll : 0 }
try {
if ( route . query . context !== undefined || route . query . overviewReturn !== undefined ) {
if ( typeof route . query . context !== 'string' || typeof route . query . overviewReturn !== 'string' || ! filters . value . warehouseCode ) throw new Error ( '经营总览返回参数无效' )
context . value = parseContext ( route . query . context )
appliedRange = parseOverviewReturn ( route . query . overviewReturn )
2026-10-13 13:43:16 +08:00
Object . assign ( filters . value , { rangePreset : appliedRange . rangePreset , dateRange : appliedRange . startDate && appliedRange . endDate ? [ appliedRange . startDate , appliedRange . endDate ] : [] })
2026-10-13 00:45:41 +08:00
restoreScroll = appliedRange . scroll
}
} catch ( cause ) { routeError . value = cause instanceof Error ? cause . message : '经营总览返回参数无效' }
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 [] >(() => [
2026-10-13 11:06:20 +08:00
{ 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 ()] },
2026-10-14 11:02:56 +08:00
{ key : 'rangePreset' , type : 'select' , label : '统计时间范围' , width : 180 , props : { clearable : false }, options : rangeOptions },
2026-10-13 13:43:16 +08:00
...( filters . value . rangePreset === 'CUSTOM' ? [{ key : 'dateRange' , type : 'daterange' as const , label : '日期范围' , width : 300 }] : []),
2026-10-13 00:45:41 +08:00
])
const metricNames : { key : keyof Dashboard [ 'kpis' ]; label : string ; inventory ?: boolean }[] = [
2026-10-14 11:02:56 +08:00
{ key : 'intervalOutboundQuantity' , label : '区间出库量' }, { key : 'averageDailyOutboundQuantity' , label : '所选时段日均出库' },
2026-10-13 00:45:41 +08:00
{ key : 'physicalQuantity' , label : '实物库存' , inventory : true }, { key : 'availableQuantity' , label : '正品可售' , inventory : true },
2026-10-14 11:02:56 +08:00
{ key : 'physicalSkuCount' , label : '有库存SKU' , inventory : true }, { key : 'noOutboundSkuCount' , label : '所选时段无出库SKU' , inventory : true },
2026-10-13 00:45:41 +08:00
]
2026-10-13 11:06:20 +08:00
const metricIcons = [ ArrowUpRight , Activity , Boxes , Package , Layers , Clock3 ]
const metricColors = [ '#5575ed' , '#3aa9ba' , '#8871ce' , '#35a987' , '#c6923e' , '#d67b91' ]
2026-10-14 11:02:56 +08:00
const rankingNames = [{ key : 'hotSkus' , label : '所选时段热销Top10' , unit : '件' , all : '查看Top100' }, { key : 'longCoverageSkus' , label : '预计消化时间最长Top10' , unit : '天' , all : '查看全部' }, { key : 'noOutboundSkus' , label : '所选时段无出库库存Top10' , unit : '件' , all : '查看全部' }] as const
2026-10-13 00:45:41 +08:00
const bucketRows = computed < BarRow [] >(() => buckets . map ( bucket => {
const item = data . value ? . coverageDistribution . buckets . find ( row => row . bucketCode === bucket . code )
return { key : bucket . code , label : bucket . label , value : item ? . skuCount ?? null , detail : `已知实物小计 ${ quantityText ( item ? . knownPhysicalQuantity ) } 件;实物未知 ${ quantityText ( item ? . unknownPhysicalSkuCount ) } SKU` }
}))
2026-10-13 18:47:56 +08:00
function rankingRows ( ranking : DashboardRanking ) : BarRow [] { return ranking . items . map (( item , index ) => ({ key : String ( index ), label : item . sku , secondary : ` ${ item . productName || '—' } · 货主 ${ item . customerCode } ` , value : item . value , detail : `SKU ${ item . sku } ;货主 ${ item . customerCode } ;实物 ${ quantityText ( item . physicalQuantity ) } 件,可售 ${ quantityText ( item . availableQuantity ) } 件, ${ ranking . calculationWindowDays } 天窗口 ${ ranking . dataStatus === 'COMPLETE' ? '出库' : '已采集出库' } ${ quantityText ( item . windowOutboundQuantity ) } 件,窗口日均 ${ quantityText ( item . averageDailyOutboundQuantity ) } 件,可售 ${ quantityText ( item . inventoryCoverageDays ) } 天` })) }
2026-10-13 00:45:41 +08:00
const dataFence = new RequestFence ()
const runtimeFence = new RequestFence ()
const trendFence = new RequestFence ()
const trendOpen = ref ( false )
const trendLoading = ref ( false )
const trend = ref < DailyResponse | null >( null )
const trendError = ref ( '' )
async function load ( refresh = false ) {
if ( ! permitted . value || ! warehouseCode . value || routeError . value ) return
if ( ! refresh && contextExpired . value ) { error . value = '原图查询已过期,请整体刷新经营总览' ; return }
const range = String ( filters . value . rangePreset ) as RangePreset
if ( ! rangeOptions . some ( option => option . value === range )) { error . value = '请选择有效的出库趋势范围' ; return }
2026-10-13 13:43:16 +08:00
const selected = Array . isArray ( filters . value . dateRange ) ? filters . value . dateRange : []
const start = typeof selected [ 0 ] === 'string' ? selected [ 0 ] : ''
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 } }
2026-10-13 00:45:41 +08:00
const pinned = refresh ? null : context . value
const request = dataFence . begin ()
trendOpen . value = false
2026-10-13 13:43:16 +08:00
loading . value = true ; error . value = '' ; rangeValidationError . value = '' ; errorDetails . value = null
2026-10-13 00:45:41 +08:00
try {
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 )
if ( ! request . current ()) return
2026-10-14 11:02:56 +08:00
if ( ! result ? . context || metricNames . some (({ key }) => {
const metric = result ? . kpis ? .[ key ]
2026-10-14 11:22:59 +08:00
// Unknown quantities may be omitted by the server's NON_NULL serialization.
return metric == null || typeof metric !== 'object' || ! [ 'COMPLETE' , 'PARTIAL' , 'UNKNOWN' ]. includes ( metric . dataStatus )
2026-10-14 11:02:56 +08:00
})) {
2026-10-14 11:22:59 +08:00
error . value = `经营总览返回的指标结构不完整,请重试或联系管理员 ${ data . value ? ';展示上次查询' : '' } `
2026-10-14 11:02:56 +08:00
return
}
2026-10-13 00:45:41 +08:00
if ( pinned && ! sameContext ( pinned , result . context )) { error . value = '查询版本不一致,请整体刷新经营总览' ; return }
data . value = result ; context . value = result . context ; appliedRange = { rangePreset : range , startDate : range === 'CUSTOM' ? start : undefined , endDate : range === 'CUSTOM' ? end : undefined , scroll : 0 }; newData . value = false
if ( restoreScroll !== null ) { await nextTick (); window . scrollTo ({ top : restoreScroll }); restoreScroll = null }
} catch ( cause ) {
if ( request . current ()) { errorDetails . value = getApiErrorDetails ( cause , data . value ? '刷新失败,展示上次查询;请重试' : '经营总览读取失败,请重试' ); error . value = ` ${ errorDetails . value . message }${ data . value ? ';展示上次查询' : '' } ` }
} finally { if ( request . current ()) loading . value = false }
}
async function readRuntime () {
if ( ! permitted . value ) return false
const request = runtimeFence . begin ()
try {
const result = await analyticsApi . overview ( sourceAccount , undefined , request . signal )
if ( ! request . current ()) return false
runtime . value = result ; runtimeError . value = ''
2026-10-13 11:06:20 +08:00
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 ()
}
2026-10-13 00:45:41 +08:00
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
}
const poller = new RuntimePoller ( readRuntime , () => runtimeFence . cancel ())
function visibility () { now . value = Date . now (); poller . setVisible ( ! document . hidden ) }
function drill ( path : 'sales' | 'inventory' , params : Record < string , string >, strictStock = true ) {
if ( ! context . value || expired ( context . value )) { error . value = '原图查询已过期,请整体刷新经营总览' ; return }
if ( path === 'inventory' && strictStock && inventoryExpired ( context . value )) { error . value = '原图库存已过期,请刷新总览;可查看普通库存诊断' ; return }
void router . push ({ path : `/self-operated/ ${ path } ` , query : { warehouseCode : warehouseCode . value , from : 'overview' , context : JSON . stringify ( context . value ), overviewReturn : JSON . stringify ({ ... appliedRange , scroll : window . scrollY }), ... params } })
}
function allRanking ( key : typeof rankingNames [ number ][ 'key' ]) {
2026-10-13 18:47:56 +08:00
if ( key === 'hotSkus' ) {
if ( ! data . value ) return
drill ( 'sales' , { analysis : 'HOT' , rangePreset : 'CUSTOM' , startDate : data . value . hotSkus . windowStartDate , endDate : previousDate ( data . value . hotSkus . windowEndDateExclusive ) })
}
2026-10-13 00:45:41 +08:00
else if ( key === 'longCoverageSkus' ) drill ( 'inventory' , { coverageSelection : 'FINITE_POSITIVE' , sortBy : 'COVERAGE_DAYS_DESC' })
2026-10-14 11:02:56 +08:00
else drill ( 'inventory' , { attention : 'NO_OUTBOUND_IN_WINDOW' , sortBy : 'PHYSICAL_DESC' })
2026-10-13 00:45:41 +08:00
}
2026-10-13 18:47:56 +08:00
async function showTrend ( item : DashboardItem , ranking : DashboardRanking ) {
2026-10-13 00:45:41 +08:00
if ( ! context . value || expired ( context . value )) { error . value = '原图查询已过期,请整体刷新经营总览' ; return }
const fixed = context . value
2026-10-13 18:47:56 +08:00
if ( ! ranking . windowStartDate || ! ranking . windowEndDateExclusive ) { error . value = '榜单日期范围尚不可用' ; return }
2026-10-13 00:45:41 +08:00
trendOpen . value = true ; trendLoading . value = true ; trend . value = null ; trendError . value = ''
const request = trendFence . begin ()
try {
2026-10-13 18:47:56 +08:00
const result = await analyticsApi . daily ({ sourceAccount , warehouseCode : warehouseCode . value , customerCode : item . customerCode , sku : item . sku , current : 1 , size : 20 , ... contextParams ( fixed ), rangePreset : 'CUSTOM' , startDate : ranking . windowStartDate , endDate : previousDate ( ranking . windowEndDateExclusive ) }, request . signal )
2026-10-13 00:45:41 +08:00
if ( ! request . current ()) return
if ( ! sameContext ( fixed , result . context )) { trendError . value = '趋势版本与总览不一致,请刷新总览' ; return }
trend . value = result
} catch ( cause ) { if ( request . current ()) trendError . value = getApiErrorDetails ( cause , 'SKU趋势读取失败' ). message }
finally { if ( request . current ()) trendLoading . value = false }
}
2026-10-13 11:06:20 +08:00
async function reset () {
2026-10-13 13:43:16 +08:00
filters . value = { warehouseCode : warehouseCode . value || selectedWarehouse || runtime . value ? . warehouses . find ( item => item . warehouseCode ) ? . warehouseCode || '' , rangePreset : 'LAST_15_DAYS' , dateRange : [] }
2026-10-13 11:06:20 +08:00
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 ) }
})
2026-10-13 13:43:16 +08:00
watch (() => [ filters . value . rangePreset , filters . value . dateRange ], () => {
if ( rangeValidationError . value ) {
if ( error . value === rangeValidationError . value ) error . value = ''
rangeValidationError . value = ''
}
}, { deep : true })
2026-10-13 00:45:41 +08:00
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 ) })
</ script >
< template >
< AdminPage class = "admin-density dashboard-page" >
< p v-if = "!permitted" role="alert" > 没有自营经营数据查看权限 。 </ p >
< template v-else >
2026-10-13 11:06:20 +08:00
< DataTable appearance = "integrated" :data = "[]" :columns = "[]" :results-visible = "false" column -settings-mode =" hide " >
2026-10-14 11:22:59 +08:00
< 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 ? '固定版本 · 北京时间' : error ? '查询未完成' : '正在准备数据' }}</ span ></ div ></ template >
2026-10-13 11:06:20 +08:00
< 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 >
2026-10-13 16:52:09 +08:00
< template # toolbar -end >
< el-popover placement = "bottom-end" trigger = "click" :width = "460" title = "统计口径与采集时间" >
< template # reference >< el-button circle aria -label =" 统计口径与采集时间 " title = "统计口径与采集时间" >< Info :size = "16" aria -hidden =" true " /></ el-button ></ template >
2026-10-14 11:02:56 +08:00
< div class = "dashboard-data-notes admin-density" >< p > 时间筛选统一影响出库量 、 日均 、 趋势 、 库存消化分布和三个榜单 。 日均按所选区间天数计算 ; 未采齐或含今日时 , 热销展示已采集量 , 库存消化和无出库评估保持待核验 。 库存使用本次查询的有效库存快照 , 不代表所选日期的历史库存 。 </ p >< p v-if = "data" > 销量覆盖截至 {{ beijingTime ( data.context.salesCoveredUntil ) }} ; 库存采集于 {{ beijingTime ( data.context.inventoryObservedFrom ) }} 至 {{ beijingTime ( data.context.inventoryObservedUntil ) }} ( 北京时间 , 本仓一轮完整分页观测 )。 </ p >< p v-else > 查询仓库数据后显示对应采集时间 。 </ p ></ div >
2026-10-13 16:52:09 +08:00
</ el -popover >
</ template >
2026-10-13 11:06:20 +08:00
< 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 >
2026-10-13 00:45:41 +08:00
< 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 >
< p v-if = "runtimeError" role="status" >{{ runtimeError }}</ p >
< p v-if = "newData" role="status" > 有新数据 , 可整体刷新 ; 当前图表仍使用原查询版本 。 </ p >
< p v-if = "contextExpired" role="alert" > 查询上下文已过期 , 以下图表为历史结果 , 请整体刷新 。 </ p >
< p v-else-if = "stockExpired && data" role="status" > 原图库存已过期或不可用 , 库存指标为历史 / 待刷新资料 , 严格库存下钻已停用 ; 出库趋势与热销仍可查看 。 </ p >
2026-10-13 11:06:20 +08:00
< 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 >
2026-10-13 00:45:41 +08:00
< template v-if = "data" >
2026-10-13 11:06:20 +08:00
2026-10-13 00:45:41 +08:00
< div class = "dashboard-kpis" aria -label =" 本仓经营指标 " >
2026-10-14 11:02:56 +08:00
< 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 === 'averageDailyOutboundQuantity' ? '件/日' : 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 >
2026-10-13 00:45:41 +08:00
</ div >
< div class = "dashboard-main-charts" >
2026-10-13 20:44:31 +08:00
< section class = "dashboard-chart" >
< DashboardTrend :points = "data.salesTrend.points" :disabled = "contextExpired" @select ="date => drill('sales', { analysis: 'SKU', rangePreset: 'CUSTOM', startDate: date, endDate: date })" >
< template # intro >< p >{{ data . context . rangeStartDate }} 至 {{ data . context . rangeEndDateExclusive }} ( 截止日不含 ) </ p ></ template >
</ DashboardTrend >
</ section >
< section class = "dashboard-chart" >
< DashboardBars : rows = "data.coverageDistribution.candidateSkuCount == null ? [] : bucketRows" title = "库存消化天数分布" unit = "SKU" : disabled = "stockExpired || contextExpired" @select ="code => drill('inventory', { coverageBucket: code, bucketRuleVersion: 'COVERAGE_DISTRIBUTION_V1' })" >
< template # actions >
< ChartDetailsDialog inline button -label =" 分组说明 " title = "分组说明与待核验原因" width = "760px" :reset-on = "data" >< p > 30 / 60 / 90 仅为图形分组 , 不是经营评级 ; 无可售库存不代表周转良好 。 零出库不计无限天数 。 </ p >< p v-for = "(count, reason) in data.coverageDistribution.unknownReasons" :key="reason" >{{ reasonsText ([ String ( reason )]) }} : {{ quantityText ( count ) }} SKU </ p ></ ChartDetailsDialog >
</ template >
2026-10-14 11:02:56 +08:00
< template # intro >< p > 有实物库存或库存待核验 SKU ; {{ data . context . rangeStartDate }} 至 {{ data . context . rangeEndDateExclusive }} ( 截止日不含 )。 </ p >< p > 候选 {{ quantityText ( data . coverageDistribution . candidateSkuCount ) }} ; 已排除确定零实物 {{ quantityText ( data . coverageDistribution . excludedZeroPhysicalSkuCount ) }} SKU 。 {{ label ( data . coverageDistribution . dataStatus ) }} · {{ reasonsText ( data . coverageDistribution . reasonCodes ) }}</ p ></ template >
2026-10-13 20:44:31 +08:00
< template # empty >< p > 无可用库存分布资料 ; 销售候选不作为库存总量 。 </ p ></ template >
</ DashboardBars >
</ section >
2026-10-13 00:45:41 +08:00
</ div >
< div class = "dashboard-rankings" >
2026-10-13 20:44:31 +08:00
< section v-for = "ranking in rankingNames" :key="ranking.key" class="dashboard-chart" >
< DashboardBars ranked : details -width =" ranking.key === ' hotSkus ' ? ' 1280px ' : undefined " :rows = "rankingRows(data[ranking.key])" :title = "ranking.label" :unit = "ranking.unit" :disabled = "contextExpired" @select ="index => showTrend(data![ranking.key].items[Number(index)]!, data![ranking.key])" >
< template # actions >
< ChartDetailsDialog v-if = "ranking.key !== 'hotSkus'" inline button-label="待评估原因" title="采集覆盖与待评估原因" width="1040px" :reset-on="data" >
< InventoryRankingDiagnostics :data = "data" : warehouse = "runtime?.warehouses.find(item => item.warehouseCode === warehouseCode)?.warehouseName || warehouseCode" : historical = "contextExpired || stockExpired" />
</ ChartDetailsDialog >
< el-button link type = "primary" : disabled = "contextExpired || ranking.key !== 'hotSkus' && stockExpired" @click ="allRanking(ranking.key)" >{{ ranking.all }} </ el -button >
</ template >
< template # intro >
< 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 >
< p v-if = "ranking.key === 'hotSkus' && data.hotSkus.dataStatus !== 'COMPLETE'" > 按所选时段已采集量排名 , 数据尚未采齐 , 排名可能随采集变化 ; 未采集日期不计为零出库 。 </ p >
2026-10-14 11:02:56 +08:00
< p v-else-if = "ranking.key !== 'hotSkus' && data[ranking.key].dataStatus !== 'COMPLETE'" > 本榜仍需所选区间完整销量与库存观察资料 ; 资料不足的 SKU 暂不评估 , 未采集日期不计为零出库 。 </ p >
2026-10-13 20:44:31 +08:00
</ template >
< template v-if = "ranking.key === 'hotSkus'" #details="{ select }"><HotSkuDetails :ranking="data.hotSkus" :disabled="contextExpired" @select="index => select(rankingRows(data!.hotSkus)[index]!)" /></ template >
</ DashboardBars >
</ section >
2026-10-13 00:45:41 +08:00
</ div >
</ template >
2026-10-13 20:44:31 +08:00
< AppDialog v-model = "trendOpen" class="admin-density-dialog" title="SKU每日出库趋势" width="1120px" :loading="trendLoading" :show-confirm="false" cancel-text="关闭" fullscreenable append-to-body destroy-on-close><div class="admin-density"><p v-if="trendError" role="alert">{{ trendError }}</p><SkuTrend v-if="trend" :data="trend" /></ div ></ AppDialog >
2026-10-13 00:45:41 +08:00
</ template >
</ AdminPage >
</ template >
< style scoped >
. dashboard - page { display : grid ; gap : var ( -- admin - space - 3 ); }
2026-10-13 11:06:20 +08:00
. dashboard - title { display : flex ; align - items : center ; gap : 16 px ; }
. dashboard - data - notes { font - size : var ( -- admin - font - caption ); color : # 76849 c ; }
. dashboard - data - notes p { margin : 6 px 0 ; }
. dashboard - snapshot { display : inline - flex ; align - items : center ; gap : 8 px ; color : # 65748 d ; font - size : var ( -- admin - font - caption ); }
. dashboard - snapshot i { width : 7 px ; height : 7 px ; border - radius : 50 % ; background : # 5575 ed ; }
. dashboard - kpis { display : grid ; grid - template - columns : repeat ( 6 , minmax ( 0 , 1 fr )); gap : 12 px ; }
. dashboard - kpis section { min - width : 0 ; position : relative ; padding : 16 px ; border : 1 px solid # e5eaf4 ; border - radius : 8 px ; background : white ; border - top : 3 px solid var ( -- metric - color ); animation : dashboard - reveal .5 s ease - out both ; animation - delay : var ( -- reveal - delay ); }
. dashboard - metric - heading { display : flex ; align - items : center ; justify - content : space - between ; gap : 8 px ; }
. dashboard - metric - heading svg { color : var ( -- metric - color ); flex - shrink : 0 ; }
2026-10-13 00:45:41 +08:00
. dashboard - kpis h3 , . dashboard - chart h3 { margin : 0 ; font - size : var ( -- admin - font - section ); }
2026-10-13 11:06:20 +08:00
. dashboard - kpis h3 { color : # 65748 d ; font - size : var ( -- admin - font - caption ); font - weight : 500 ; }
. dashboard - metric - value { margin : 12 px 0 8 px ; display : flex ; flex - wrap : wrap ; align - items : baseline ; gap : 5 px ; }
. dashboard - kpis strong { font - size : 28 px ; font - weight : 700 ; letter - spacing : - .7 px ; font - variant - numeric : tabular - nums ; color : # 24334 d ; overflow - wrap : anywhere ; }
. dashboard - metric - value > span { font - size : var ( -- admin - font - caption ); color : # 8290 a6 ; }
. dashboard - metric - status { color : var ( -- metric - color ); }
2026-10-13 00:45:41 +08:00
. dashboard - kpis p { margin : var ( -- admin - space - 1 ) 0 ; }
. dashboard - main - charts { display : grid ; grid - template - columns : 2 fr 1 fr ; gap : var ( -- admin - space - 4 ); }
. dashboard - rankings { display : grid ; grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); gap : var ( -- admin - space - 4 ); }
2026-10-13 11:06:20 +08:00
. dashboard - chart { min - width : 0 ; padding : 18 px ; background : var ( -- admin - surface ); border : 1 px solid # e5eaf4 ; border - radius : 8 px ; animation : dashboard - reveal .6 s ease - out both ; }
2026-10-13 20:44:31 +08:00
. dashboard - chart p { color : # 76849 c ; font - size : var ( -- admin - font - caption ); line - height : 1.7 ; }
2026-10-13 11:06:20 +08:00
. dashboard - rankings . dashboard - chart : nth - child ( 2 ) { animation - delay : .08 s ; }
. dashboard - rankings . dashboard - chart : nth - child ( 3 ) { animation - delay : .16 s ; }
. dashboard - placeholder { padding : 12 px 0 ; color : # 76849 c ; }
. dashboard - skeleton - kpis { display : grid ; grid - template - columns : repeat ( 6 , 1 fr ); gap : 12 px ; margin : 12 px 0 ; }
. dashboard - skeleton - charts { display : grid ; grid - template - columns : 2 fr 1 fr ; gap : 12 px ; }
. dashboard - placeholder i { display : block ; height : 130 px ; border - radius : 8 px ; background : linear - gradient ( 100 deg , # eaf0f8 30 % , # f8faff 50 % , # eaf0f8 70 % ); background - size : 200 % 100 % ; animation : skeleton - shimmer 1.5 s infinite ; }
. dashboard - skeleton - charts i { height : 320 px ; }
@ keyframes dashboard - reveal { from { opacity : 0 ; transform : translateY ( 8 px ); } 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 ; } }
2026-10-13 00:45:41 +08:00
small { font - size : var ( -- admin - font - caption ); color : var ( -- el - text - color - secondary ); }
</ style >