284 lines
28 KiB
Vue
284 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import '@/styles/adminDensity.css'
|
||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { Activity, ArrowUpRight, Boxes, Package, Layers, Clock3, Info } 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'
|
||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||
import { analyticsApi } from '../api/analytics'
|
||
import type { Dashboard, DashboardItem, DashboardRanking, DailyResponse, DashboardQuery, Overview, QueryContext, RangePreset } from '../types'
|
||
import { beijingTime, contextParams, label, quantityText, reasonsText, RequestFence, RuntimePoller, sameContext, validateCustomRange } from '../state'
|
||
import { buckets, expired, inventoryExpired, parseContext, parseOverviewReturn, previousDate, rangeOptions, type OverviewReturn } from '../dashboardNavigation'
|
||
import HotSkuDetails from '../components/HotSkuDetails.vue'
|
||
import InventoryRankingDiagnostics from '../components/InventoryRankingDiagnostics.vue'
|
||
import ChartDetailsDialog from '../components/ChartDetailsDialog.vue'
|
||
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'
|
||
const filters = ref<QueryModel>({ warehouseCode: typeof route.query.warehouseCode === 'string' ? route.query.warehouseCode : '', rangePreset: 'LAST_15_DAYS', dateRange: [] })
|
||
let selectedWarehouse = String(filters.value.warehouseCode)
|
||
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('')
|
||
const rangeValidationError = ref('')
|
||
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
|
||
let initialWarehouseResolved = false
|
||
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)
|
||
Object.assign(filters.value, { rangePreset: appliedRange.rangePreset, dateRange: appliedRange.startDate && appliedRange.endDate ? [appliedRange.startDate, appliedRange.endDate] : [] })
|
||
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[]>(() => [
|
||
{ 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: 'dateRange', type: 'daterange' as const, label: '日期范围', width: 300 }] : []),
|
||
])
|
||
const metricNames: { key: keyof Dashboard['kpis']; label: string; inventory?: boolean }[] = [
|
||
{ key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity', label: '所选时段日均出库' },
|
||
{ key: 'physicalQuantity', label: '实物库存', inventory: true }, { key: 'availableQuantity', label: '正品可售', inventory: true },
|
||
{ key: 'physicalSkuCount', label: '有库存SKU', inventory: true }, { key: 'noOutboundSkuCount', label: '所选时段无出库SKU', inventory: true },
|
||
]
|
||
const metricIcons = [ArrowUpRight, Activity, Boxes, Package, Layers, Clock3]
|
||
const metricColors = ['#5575ed', '#3aa9ba', '#8871ce', '#35a987', '#c6923e', '#d67b91']
|
||
const rankingNames = [{ key: 'hotSkus', label: '所选时段热销Top10', unit: '件', all: '查看Top100' }, { key: 'longCoverageSkus', label: '预计消化时间最长Top10', unit: '天', all: '查看全部' }, { key: 'noOutboundSkus', label: '所选时段无出库库存Top10', unit: '件', all: '查看全部' }] as const
|
||
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` }
|
||
}))
|
||
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)} 天` })) }
|
||
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 }
|
||
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 } }
|
||
const pinned = refresh ? null : context.value
|
||
const request = dataFence.begin()
|
||
trendOpen.value = false
|
||
loading.value = true; error.value = ''; rangeValidationError.value = ''; errorDetails.value = null
|
||
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
|
||
if (!result?.context || metricNames.some(({ key }) => {
|
||
const metric = result?.kpis?.[key]
|
||
// Unknown quantities may be omitted by the server's NON_NULL serialization.
|
||
return metric == null || typeof metric !== 'object' || !['COMPLETE', 'PARTIAL', 'UNKNOWN'].includes(metric.dataStatus)
|
||
})) {
|
||
error.value = `经营总览返回的指标结构不完整,请重试或联系管理员${data.value ? ';展示上次查询' : ''}`
|
||
return
|
||
}
|
||
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 = ''
|
||
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
|
||
}
|
||
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']) {
|
||
if (key === 'hotSkus') {
|
||
if (!data.value) return
|
||
drill('sales', { analysis: 'HOT', rangePreset: 'CUSTOM', startDate: data.value.hotSkus.windowStartDate, endDate: previousDate(data.value.hotSkus.windowEndDateExclusive) })
|
||
}
|
||
else if (key === 'longCoverageSkus') drill('inventory', { coverageSelection: 'FINITE_POSITIVE', sortBy: 'COVERAGE_DAYS_DESC' })
|
||
else drill('inventory', { attention: 'NO_OUTBOUND_IN_WINDOW', sortBy: 'PHYSICAL_DESC' })
|
||
}
|
||
async function showTrend(item: DashboardItem, ranking: DashboardRanking) {
|
||
if (!context.value || expired(context.value)) { error.value = '原图查询已过期,请整体刷新经营总览'; return }
|
||
const fixed = context.value
|
||
if (!ranking.windowStartDate || !ranking.windowEndDateExclusive) { error.value = '榜单日期范围尚不可用'; return }
|
||
trendOpen.value = true; trendLoading.value = true; trend.value = null; trendError.value = ''
|
||
const request = trendFence.begin()
|
||
try {
|
||
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)
|
||
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 }
|
||
}
|
||
async function reset() {
|
||
filters.value = { warehouseCode: warehouseCode.value || selectedWarehouse || runtime.value?.warehouses.find(item => item.warehouseCode)?.warehouseCode || '', rangePreset: 'LAST_15_DAYS', dateRange: [] }
|
||
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(() => [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 } })
|
||
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>
|
||
<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 ? '固定版本 · 北京时间' : error ? '查询未完成' : '正在准备数据' }}</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 #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>
|
||
<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>
|
||
</el-popover>
|
||
</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>
|
||
<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>
|
||
<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">
|
||
|
||
<div class="dashboard-kpis" aria-label="本仓经营指标">
|
||
<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>
|
||
</div>
|
||
<div class="dashboard-main-charts">
|
||
<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>
|
||
<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>
|
||
<template #empty><p>无可用库存分布资料;销售候选不作为库存总量。</p></template>
|
||
</DashboardBars>
|
||
</section>
|
||
</div>
|
||
<div class="dashboard-rankings">
|
||
<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>
|
||
<p v-else-if="ranking.key !== 'hotSkus' && data[ranking.key].dataStatus !== 'COMPLETE'">本榜仍需所选区间完整销量与库存观察资料;资料不足的 SKU 暂不评估,未采集日期不计为零出库。</p>
|
||
</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>
|
||
</div>
|
||
</template>
|
||
<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>
|
||
</template>
|
||
</AdminPage>
|
||
</template>
|
||
<style scoped>
|
||
.dashboard-page { display: grid; 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 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 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: 18px; background: var(--admin-surface); border: 1px solid #e5eaf4; border-radius: 8px; animation: dashboard-reveal .6s ease-out both; }
|
||
.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-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>
|