Files
admin-web/src/modules/dashboard/pages/DashboardView.vue
T
2026-08-10 16:24:54 +08:00

346 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
ArrowUpRight,
Clock3,
Expand,
Minimize,
Ship,
} from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
type TransportMode = 'sea' | 'air' | 'land'
interface TrendSeries {
name: string
color: string
fill: string
values: number[]
}
const dashboardRef = ref<HTMLElement | null>(null)
const activeMode = ref<TransportMode>('sea')
const isFullscreen = ref(false)
const metrics = [
{ label: '今日新运单', value: '128', unit: '票', note: '较昨日', change: '↑ 12.3%', tone: 'blue', warning: false },
{ label: '在途柜量', value: '3,842', unit: 'TEU', note: '较上周', change: '↑ 8.5%', tone: 'green', warning: false },
{ label: '本月营收', value: '¥856', unit: '万', note: '较上月', change: '↑ 15.2%', tone: 'orange', warning: false },
{ label: '活跃客户', value: '368', unit: '家', note: '本周新增', change: '+6 家', tone: 'purple', warning: false },
{ label: '待清关票数', value: '47', unit: '票', note: '即将超期', change: '3 票', tone: 'rose', warning: true },
] as const
const trendSeries: Record<TransportMode, TrendSeries[]> = {
sea: [
{ name: '海运 FCL', color: '#3478f6', fill: 'rgba(52, 120, 246, .13)', values: [28, 32, 25, 38, 35, 42, 30, 36, 40, 45, 33, 48, 38, 42, 50, 35, 40, 45, 38, 52, 42, 48, 55, 40, 45, 50, 38, 42, 48, 55] },
{ name: '海运 LCL', color: '#6d3bd1', fill: 'rgba(109, 59, 209, .09)', values: [12, 15, 10, 18, 14, 20, 16, 15, 18, 22, 14, 20, 16, 18, 25, 15, 18, 20, 16, 22, 18, 20, 24, 16, 18, 22, 15, 18, 20, 24] },
],
air: [
{ name: '空运普货', color: '#f79009', fill: 'rgba(247, 144, 9, .12)', values: [18, 20, 17, 24, 19, 26, 22, 25, 28, 23, 20, 29, 25, 27, 32, 26, 23, 30, 28, 34, 29, 31, 36, 30, 33, 35, 28, 32, 34, 38] },
{ name: '空运敏感货', color: '#d92d20', fill: 'rgba(217, 45, 32, .08)', values: [6, 8, 7, 9, 8, 12, 10, 9, 11, 13, 9, 12, 10, 14, 15, 11, 10, 13, 12, 16, 13, 15, 17, 12, 14, 16, 13, 15, 17, 18] },
],
land: [
{ name: '中欧班列', color: '#079455', fill: 'rgba(7, 148, 85, .11)', values: [12, 14, 13, 18, 16, 20, 17, 19, 22, 20, 18, 23, 21, 24, 27, 22, 20, 25, 23, 28, 25, 27, 30, 26, 28, 31, 25, 29, 32, 34] },
{ name: '跨境卡航', color: '#0891b2', fill: 'rgba(8, 145, 178, .08)', values: [8, 10, 9, 12, 11, 14, 12, 13, 16, 14, 13, 17, 15, 18, 20, 16, 15, 19, 17, 21, 18, 20, 23, 19, 21, 24, 20, 22, 25, 27] },
],
}
const statusSegments = [
{ label: '已确认', value: 16, color: '#3478f6' },
{ label: '已订舱', value: 12, color: '#6d3bd1' },
{ label: '已做柜', value: 9, color: '#f79009' },
{ label: '已清关', value: 11, color: '#55b715' },
{ label: '运输中', value: 23, color: '#168cf0' },
{ label: '已完成', value: 29, color: '#18b7b9' },
] as const
const revenueSegments = [
{ label: '海运费', value: 58, color: '#3478f6' },
{ label: '附加费', value: 16, color: '#6d3bd1' },
{ label: '仓储费', value: 10, color: '#55b715' },
{ label: '清关费', value: 8, color: '#f79009' },
{ label: '陆运费', value: 5, color: '#e72b8c' },
{ label: '其他', value: 3, color: '#18b7b9' },
] as const
const routes = [
['上海 → 洛杉矶', 850], ['宁波 → 长滩', 620], ['深圳 → 汉堡', 480], ['上海 → 纽约', 420],
['深圳 → 洛杉矶', 380], ['青岛 → 鹿特丹', 350], ['上海 → 汉堡', 310], ['天津 → 长滩', 280],
['宁波 → 纽约', 250], ['青岛 → 汉堡', 220],
] as const
const tasks = [
{ type: '订舱确认', content: 'BY20260528003 · 美森快船待确认 S/O', priority: '重要', due: '2026-06-01' },
{ type: '账单到期', content: '3 笔应收账单即将到期,合计 ¥128,500', priority: '重要', due: '2026-06-03' },
{ type: '报关提醒', content: 'BY20260525001 · 截关时间临近,请尽快申报', priority: '紧急', due: '2026-06-01' },
] as const
const currentSeries = computed(() => trendSeries[activeMode.value])
function linePoints(values: number[]) {
const max = 60
return values.map((value, index) => {
const x = 12 + (index * 676) / (values.length - 1)
const y = 206 - (value / max) * 174
return `${x.toFixed(1)},${y.toFixed(1)}`
}).join(' ')
}
function areaPoints(values: number[]) {
return `12,206 ${linePoints(values)} 688,206`
}
function donutBackground(items: ReadonlyArray<{ value: number; color: string }>) {
let offset = 0
const stops = items.map((item) => {
const start = offset
offset += item.value
return `${item.color} ${start}% ${offset}%`
})
return `conic-gradient(${stops.join(', ')})`
}
async function toggleFullscreen() {
if (!document.fullscreenElement) await dashboardRef.value?.requestFullscreen()
else await document.exitFullscreen()
}
function syncFullscreenState() {
isFullscreen.value = document.fullscreenElement === dashboardRef.value
}
onMounted(() => document.addEventListener('fullscreenchange', syncFullscreenState))
onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFullscreenState))
</script>
<template>
<!--
THESIS: 用一屏可扫描的经营驾驶舱承载平台概览,拒绝把数据拆成互不关联的装饰卡片。
OWN-WORLD: 延续 Admin 冷灰工作面、白色数据面板和柏悦蓝主色;状态色只承担分类与告警。
STORY: 运营人员先识别核心体量,再查看趋势与构成,最后定位待处理事项。
FIRST VIEWPORT: 顶部状态栏与五项核心指标占据首屏上半部,主趋势图和状态环图紧随其后。
FORM: 既有后台视觉体系的经营驾驶舱结构,基于用户提供的正式原型直接扩展。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
<section ref="dashboardRef" class="dashboard-view">
<AdminPage as="div" spacing="normal">
<section class="dashboard-context" aria-label="数据看板说明">
<p>当前为原型占位,以下均为静态演示数据,尚未接入业务接口。</p>
<div class="dashboard-head-actions">
<span>演示快照:2026-06-02 08:45</span>
<button type="button" class="dashboard-outline-button" :aria-pressed="isFullscreen" @click="toggleFullscreen">
<Minimize v-if="isFullscreen" :size="16" aria-hidden="true" />
<Expand v-else :size="16" aria-hidden="true" />
{{ isFullscreen ? '退出全屏' : '全屏展示' }}
</button>
</div>
</section>
<ListPageLayout ariaLabel="平台运营工作台">
<template #metrics>
<div class="dashboard-metrics" aria-label="核心业务指标">
<article v-for="metric in metrics" :key="metric.label" class="dashboard-metric" :class="`is-${metric.tone}`">
<span class="dashboard-metric-mark" aria-hidden="true" />
<p>{{ metric.label }}</p>
<strong>{{ metric.value }} <small>{{ metric.unit }}</small></strong>
<span>{{ metric.note }} <b :class="{ 'is-warning': metric.warning }">{{ metric.change }}</b></span>
</article>
</div>
</template>
<div class="dashboard-content">
<div class="dashboard-grid dashboard-grid--primary">
<article class="dashboard-panel dashboard-trend-panel">
<div class="dashboard-panel-head">
<h2>近 30 天运单量趋势</h2>
<div class="dashboard-tabs" aria-label="运输方式">
<button v-for="mode in ([['sea', '海运'], ['air', '空运'], ['land', '陆运']] as const)" :key="mode[0]" type="button" :class="{ active: activeMode === mode[0] }" :aria-pressed="activeMode === mode[0]" @click="activeMode = mode[0]">{{ mode[1] }}</button>
</div>
</div>
<div class="dashboard-legend">
<span v-for="series in currentSeries" :key="series.name"><i :style="{ background: series.color }" />{{ series.name }}</span>
</div>
<div class="dashboard-line-chart">
<svg viewBox="0 0 700 228" role="img" :aria-label="`${activeMode === 'sea' ? '海运' : activeMode === 'air' ? '空运' : '陆运'}近 30 天运单量趋势演示图`">
<g class="dashboard-chart-grid">
<line v-for="y in [32, 61, 90, 119, 148, 177, 206]" :key="`y-${y}`" x1="12" :y1="y" x2="688" :y2="y" />
<line v-for="x in [12, 124, 237, 350, 463, 576, 688]" :key="`x-${x}`" :x1="x" y1="32" :x2="x" y2="206" />
</g>
<template v-for="series in currentSeries" :key="series.name">
<polygon :points="areaPoints(series.values)" :fill="series.fill" />
<polyline :points="linePoints(series.values)" fill="none" :stroke="series.color" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
</template>
<g class="dashboard-axis-labels">
<text x="12" y="224">1 日</text><text x="124" y="224">5 日</text><text x="237" y="224">10 日</text>
<text x="350" y="224">15 日</text><text x="463" y="224">20 日</text><text x="576" y="224">25 日</text><text x="663" y="224">30 日</text>
</g>
</svg>
<table class="sr-only">
<caption>{{ activeMode === 'sea' ? '海运' : activeMode === 'air' ? '空运' : '陆运' }}近 30 天运单量演示数据</caption>
<thead><tr><th>日期</th><th v-for="series in currentSeries" :key="series.name">{{ series.name }}</th></tr></thead>
<tbody><tr v-for="(_, index) in currentSeries[0]?.values" :key="index"><th>{{ index + 1 }} 日</th><td v-for="series in currentSeries" :key="series.name">{{ series.values[index] }}</td></tr></tbody>
</table>
</div>
</article>
<article class="dashboard-panel">
<div class="dashboard-panel-head"><h2>运单状态分布</h2></div>
<div class="dashboard-donut-wrap">
<div class="dashboard-donut" :style="{ background: donutBackground(statusSegments) }"><span><Ship :size="24" /><strong>100%</strong><small>演示样本</small></span></div>
<div class="dashboard-legend dashboard-legend--centered">
<span v-for="item in statusSegments" :key="item.label"><i :style="{ background: item.color }" />{{ item.label }} {{ item.value }}%</span>
</div>
</div>
</article>
</div>
<div class="dashboard-grid dashboard-grid--equal">
<article class="dashboard-panel">
<div class="dashboard-panel-head"><h2>TOP10 航线货量(TEU)</h2></div>
<div class="dashboard-bars" aria-label="TOP10 航线货量静态演示数据">
<div v-for="route in routes" :key="route[0]" class="dashboard-bar-row">
<span>{{ route[0] }}</span><i><b :style="{ width: `${(route[1] / 900) * 100}%` }" /></i><strong>{{ route[1] }}</strong>
</div>
</div>
</article>
<article class="dashboard-panel">
<div class="dashboard-panel-head"><h2>收入构成</h2></div>
<div class="dashboard-donut-wrap dashboard-donut-wrap--revenue">
<div class="dashboard-donut dashboard-donut--solid" :style="{ background: donutBackground(revenueSegments) }" role="img" aria-label="收入构成静态演示饼图" />
<div class="dashboard-legend dashboard-legend--centered">
<span v-for="item in revenueSegments" :key="item.label"><i :style="{ background: item.color }" />{{ item.label }} {{ item.value }}%</span>
</div>
</div>
</article>
</div>
<article class="dashboard-panel dashboard-task-panel">
<div class="dashboard-panel-head">
<h2><Clock3 :size="18" aria-hidden="true" />待办事项</h2>
<span>功能入口待真实业务模块接入</span>
</div>
<div class="dashboard-table-wrap">
<table>
<thead><tr><th>操作</th><th>类型</th><th>内容</th><th>优先级</th><th>截止时间</th></tr></thead>
<tbody>
<tr v-for="task in tasks" :key="task.content">
<td><button type="button" class="dashboard-task-action" disabled>待接入 <ArrowUpRight :size="14" /></button></td>
<td>{{ task.type }}</td><td>{{ task.content }}</td>
<td><span class="dashboard-priority" :class="{ urgent: task.priority === '紧急' }">{{ task.priority }}</span></td>
<td>{{ task.due }}</td>
</tr>
</tbody>
</table>
</div>
</article>
</div>
</ListPageLayout>
</AdminPage>
</section>
</template>
<style scoped>
.dashboard-view { --dashboard-panel: var(--cc-surface, #fff); display: block; min-width: 0; color: var(--cc-text, #172033); }
.dashboard-view:fullscreen { overflow: auto; padding: 24px; background: var(--cc-page, #f4f6f8); }
.dashboard-content { display: flex; min-width: 0; flex-direction: column; gap: 18px; }
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; }
.dashboard-context p { min-width: 0; margin: 0; color: var(--cc-muted, #667085); font-size: 12px; line-height: 1.55; }
.dashboard-head-actions { display: flex; align-items: center; gap: 14px; color: var(--cc-muted, #667085); font-size: 12px; white-space: nowrap; }
.dashboard-outline-button, .dashboard-tabs button, .dashboard-task-action { display: inline-flex; min-height: 36px; align-items: center; justify-content: center; gap: 6px; border: 1px solid var(--cc-border-strong, #d8deea); border-radius: var(--cc-radius-md, 6px); background: var(--cc-surface, #fff); color: var(--cc-text-secondary, #344054); cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease; }
.dashboard-outline-button { padding: 0 13px; }
.dashboard-outline-button:hover { border-color: var(--cc-primary); color: var(--cc-primary); }
.dashboard-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; }
.dashboard-metric { position: relative; min-width: 0; overflow: hidden; padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--dashboard-panel); }
.dashboard-metric-mark { position: absolute; top: -38px; right: -30px; width: 110px; height: 110px; border-radius: 50%; background: #edf1ff; }
.dashboard-metric.is-green .dashboard-metric-mark { background: #e8f7ee; }
.dashboard-metric.is-orange .dashboard-metric-mark { background: #fff3e3; }
.dashboard-metric.is-purple .dashboard-metric-mark { background: #f1eaff; }
.dashboard-metric.is-rose .dashboard-metric-mark { background: #fdeaf1; }
.dashboard-metric p { position: relative; margin: 0 0 25px; color: #5f6573; font-size: 13px; font-weight: 700; }
.dashboard-metric strong { position: relative; display: block; overflow-wrap: anywhere; font-size: 31px; line-height: 1; letter-spacing: -.02em; }
.dashboard-metric strong small { color: #525866; font-size: 12px; font-weight: 600; letter-spacing: 0; }
.dashboard-metric > span:last-child { position: relative; display: block; margin-top: 17px; color: #8a91a0; font-size: 11px; }
.dashboard-metric > span b { margin-left: 4px; color: #ef4444; font-weight: 700; }
.dashboard-metric > span b.is-warning { color: #d92d20; }
.dashboard-grid { display: grid; min-width: 0; gap: 18px; }
.dashboard-grid--primary { grid-template-columns: minmax(0, 1.8fr) minmax(300px, .9fr); }
.dashboard-grid--equal { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-panel { min-width: 0; padding: 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--dashboard-panel); }
.dashboard-panel-head { display: flex; min-height: 36px; align-items: center; justify-content: space-between; gap: 16px; }
.dashboard-panel-head h2 { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 16px; line-height: 1.4; }
.dashboard-panel-head > span { color: #8a91a0; font-size: 11px; }
.dashboard-tabs { display: flex; gap: 7px; }
.dashboard-tabs button { min-width: 58px; padding: 0 12px; }
.dashboard-tabs button:hover { border-color: var(--cc-primary-light, #b8c2ef); color: var(--cc-primary); }
.dashboard-tabs button.active { border-color: var(--cc-primary); background: var(--cc-primary); color: #fff; }
.dashboard-legend { display: flex; flex-wrap: wrap; gap: 8px 18px; margin: 12px 0; color: #667085; font-size: 11px; }
.dashboard-legend span { display: inline-flex; align-items: center; gap: 6px; }
.dashboard-legend i { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; }
.dashboard-legend--centered { justify-content: center; margin-top: 18px; }
.dashboard-line-chart { width: 100%; min-height: 240px; }
.dashboard-line-chart svg { display: block; width: 100%; height: auto; }
.dashboard-chart-grid line { stroke: #e8ebf1; stroke-width: 1; }
.dashboard-axis-labels { fill: #8a91a0; font-size: 10px; }
.dashboard-donut-wrap { display: grid; min-height: 268px; align-content: center; justify-items: center; padding-top: 10px; }
.dashboard-donut { position: relative; display: grid; width: 166px; height: 166px; place-items: center; border-radius: 50%; transform: rotate(-90deg); }
.dashboard-donut::after { position: absolute; width: 92px; height: 92px; border-radius: 50%; background: #fff; content: ''; }
.dashboard-donut > span { z-index: 1; display: flex; align-items: center; flex-direction: column; color: #667085; transform: rotate(90deg); }
.dashboard-donut > span svg { color: #667eea; }
.dashboard-donut > span strong { margin-top: 3px; color: #172033; font-size: 18px; }
.dashboard-donut > span small { font-size: 10px; }
.dashboard-donut-wrap--revenue { min-height: 320px; }
.dashboard-donut--solid { width: 210px; height: 210px; }
.dashboard-donut--solid::after { display: none; }
.dashboard-bars { display: grid; gap: 8px; margin-top: 16px; }
.dashboard-bar-row { display: grid; grid-template-columns: 104px minmax(80px, 1fr) 32px; align-items: center; gap: 9px; color: #667085; font-size: 11px; }
.dashboard-bar-row > span { overflow: hidden; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
.dashboard-bar-row > i { height: 15px; overflow: hidden; border-radius: 4px; background: #f0f3f8; }
.dashboard-bar-row > i b { display: block; height: 100%; border-radius: inherit; background: #3478f6; }
.dashboard-bar-row > strong { color: #475467; font-size: 10px; }
.dashboard-task-panel { padding: 0; overflow: hidden; }
.dashboard-task-panel .dashboard-panel-head { padding: 16px 20px; }
.dashboard-table-wrap { overflow-x: auto; border-top: 1px solid #e8ebf1; }
.dashboard-table-wrap table { width: 100%; min-width: 760px; border-collapse: collapse; font-size: 12px; }
.dashboard-table-wrap th, .dashboard-table-wrap td { padding: 13px 16px; border-bottom: 1px solid #edf0f4; text-align: left; }
.dashboard-table-wrap th { background: #f8fafc; color: #475467; font-weight: 650; }
.dashboard-table-wrap tbody tr:last-child td { border-bottom: 0; }
.dashboard-task-action { min-height: 30px; padding: 0 9px; font-size: 11px; }
.dashboard-task-action:disabled { border-color: #e4e7ec; background: #f9fafb; color: #98a2b3; cursor: not-allowed; }
.dashboard-priority { display: inline-flex; min-height: 24px; align-items: center; padding: 2px 8px; border-radius: 999px; background: #fff3e8; color: #c54d0a; font-size: 11px; font-weight: 700; }
.dashboard-priority.urgent { background: #fef3f2; color: #b42318; }
@media (max-width: 1180px) {
.dashboard-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.dashboard-grid--primary { grid-template-columns: minmax(0, 1fr); }
.dashboard-donut-wrap { min-height: 240px; }
}
@media (max-width: 760px) {
.dashboard-context { align-items: flex-start; flex-direction: column; }
.dashboard-head-actions { align-items: flex-start; flex-direction: column; gap: 9px; white-space: normal; }
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-grid--equal { grid-template-columns: minmax(0, 1fr); }
.dashboard-panel-head { align-items: flex-start; flex-direction: column; }
.dashboard-tabs { width: 100%; overflow-x: auto; }
.dashboard-tabs button { min-height: 44px; flex: 1; }
}
@media (max-width: 420px) {
.dashboard-content { gap: 14px; }
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.dashboard-metric { min-height: 116px; padding: 13px; }
.dashboard-metric:last-child { grid-column: 1 / -1; }
.dashboard-metric-mark { top: -30px; right: -24px; width: 80px; height: 80px; }
.dashboard-metric p { margin-bottom: 12px; font-size: 11px; }
.dashboard-metric strong { font-size: 23px; }
.dashboard-metric strong small { font-size: 10px; }
.dashboard-metric > span:last-child { margin-top: 10px; font-size: 10px; }
.dashboard-panel { padding: 16px; }
.dashboard-task-panel { padding: 0; }
.dashboard-bar-row { grid-template-columns: 90px minmax(72px, 1fr) 28px; gap: 7px; }
}
@media (prefers-reduced-motion: reduce) {
.dashboard-outline-button, .dashboard-tabs button, .dashboard-task-action { transition: none; }
}
</style>