Files
admin-web/src/modules/self-operated/pages/OverviewView.vue
T

284 lines
28 KiB
Vue
Raw Normal View History

<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()] },
2026-10-14 11:02:56 +08:00
{ 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 }[] = [
2026-10-14 11:02:56 +08:00
{ key: 'intervalOutboundQuantity', label: '区间出库量' }, { key: 'averageDailyOutboundQuantity', label: '所选时段日均出库' },
{ 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 },
]
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
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
2026-10-14 11:02:56 +08:00
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)
2026-10-14 11:02:56 +08:00
})) {
error.value = `经营总览返回的指标结构不完整,请重试或联系管理员${data.value ? ';展示上次查询' : ''}`
2026-10-14 11:02:56 +08:00
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' })
2026-10-14 11:02:56 +08:00
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>
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>
</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="本仓经营指标">
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>
</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>
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>
<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>
2026-10-14 11:02:56 +08:00
<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>