This commit is contained in:
2026-08-10 16:24:54 +08:00
parent e119b48662
commit 820432a381
52 changed files with 1015 additions and 705 deletions
+120 -117
View File
@@ -2,13 +2,13 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
ArrowUpRight,
BarChart3,
Clock3,
Expand,
Info,
Minimize,
Ship,
} from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
type TransportMode = 'sea' | 'air' | 'land'
@@ -124,130 +124,133 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md
-->
<section ref="dashboardRef" class="dashboard-view">
<header class="dashboard-head">
<div>
<div class="dashboard-title"><BarChart3 :size="21" aria-hidden="true" /><h1>实时数据看板</h1></div>
<p><Info :size="14" aria-hidden="true" />当前为原型占位,以下均为静态演示数据,尚未接入业务接口。</p>
</div>
<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>
</header>
<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>
<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>
<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>
<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>
</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>
</template>
<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 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>
</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 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>
</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>
<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>
</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>
</ListPageLayout>
</AdminPage>
</section>
</template>
<style scoped>
.dashboard-view { --dashboard-panel: #fff; display: flex; min-width: 0; flex-direction: column; gap: 18px; color: #172033; }
.dashboard-view:fullscreen { overflow: auto; padding: 24px; background: #f4f6f8; }
.dashboard-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
.dashboard-title { display: flex; align-items: center; gap: 9px; }
.dashboard-title > svg { color: #667eea; }
.dashboard-title h1 { margin: 0; font-size: 20px; line-height: 1.3; }
.dashboard-head p { display: flex; align-items: center; gap: 6px; margin: 7px 0 0; color: #667085; font-size: 12px; line-height: 1.5; }
.dashboard-head-actions { display: flex; align-items: center; gap: 14px; color: #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 #d8deea; border-radius: 8px; background: #fff; color: #344054; cursor: pointer; transition: border-color .18s ease, background .18s ease, color .18s ease; }
.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: #667eea; color: #5366c8; }
.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; }
@@ -270,8 +273,8 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
.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: #b8c2ef; color: #5366c8; }
.dashboard-tabs button.active { border-color: #667eea; background: #667eea; color: #fff; }
.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%; }
@@ -314,7 +317,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
.dashboard-donut-wrap { min-height: 240px; }
}
@media (max-width: 760px) {
.dashboard-head { align-items: stretch; flex-direction: column; }
.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); }
@@ -323,7 +326,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
.dashboard-tabs button { min-height: 44px; flex: 1; }
}
@media (max-width: 420px) {
.dashboard-view { gap: 14px; }
.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; }