2026-08-05 15:54:59 +08:00
< script setup lang = "ts" >
2026-09-11 17:20:58 +08:00
import dayjs from 'dayjs'
2026-08-05 15:54:59 +08:00
import { computed , onBeforeUnmount , onMounted , ref } from 'vue'
import {
ArrowUpRight ,
Clock3 ,
Expand ,
Minimize ,
2026-09-11 17:20:58 +08:00
RefreshCw ,
2026-08-05 15:54:59 +08:00
Ship ,
} from '@lucide/vue'
2026-08-10 16:24:54 +08:00
import AdminPage from '@/shared/components/AdminPage.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
2026-09-11 17:20:58 +08:00
import {
getActiveCustomerCount ,
getDashboardOperationalMetrics ,
getTodayNewOrderCount ,
type DashboardOperationalMetrics ,
} from '../api/dashboard'
2026-08-05 15:54:59 +08:00
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 )
2026-09-11 17:20:58 +08:00
const metricsLoading = ref ( true )
const metricsError = ref ( false )
const todayNewOrderCount = ref < number | null >( null )
const activeCustomerCount = ref < number | null >( null )
const operationalMetrics = ref < DashboardOperationalMetrics | null >( null )
const metricsUpdatedAt = ref < string | null >( null )
2026-08-05 15:54:59 +08:00
2026-09-11 17:20:58 +08:00
function displayNumber ( value : number | null | undefined , maximumFractionDigits = 0 ) {
if ( metricsLoading . value ) return '…'
if ( value === null || value === undefined ) return '—'
return new Intl . NumberFormat ( 'zh-CN' , { maximumFractionDigits }). format ( value )
}
const metrics = computed (() => [
{ label : '今日新运单' , value : displayNumber ( todayNewOrderCount . value ), unit : '票' , note : '按今日创建时间统计' , tone : 'blue' },
{
label : '在途柜量' , value : displayNumber ( operationalMetrics . value ? . inTransitTeu , 2 ), unit : 'TEU' ,
note : operationalMetrics . value
? `已开船未到仓 · ${ operationalMetrics . value . inTransitContainerCount } 柜 ${ operationalMetrics . value . unknownContainerTypeCount ? ` · ${ operationalMetrics . value . unknownContainerTypeCount } 柜箱型未知` : '' } `
: '已开船且尚未到仓' ,
tone : 'green' ,
},
{ label : '活跃客户' , value : displayNumber ( activeCustomerCount . value ), unit : '家' , note : '客户状态为启用' , tone : 'purple' },
{ label : '待清关票数' , value : displayNumber ( operationalMetrics . value ? . pendingCustomsOrderCount ), unit : '票' , note : '已进入清关且尚未完成' , tone : 'rose' },
])
2026-08-05 15:54:59 +08:00
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
}
2026-09-11 17:20:58 +08:00
async function loadMetrics () {
metricsLoading . value = true
metricsError . value = false
const [ orders , customers , operations ] = await Promise . allSettled ([
getTodayNewOrderCount (),
getActiveCustomerCount (),
getDashboardOperationalMetrics (),
])
todayNewOrderCount . value = orders . status === 'fulfilled' ? orders . value : null
activeCustomerCount . value = customers . status === 'fulfilled' ? customers . value : null
operationalMetrics . value = operations . status === 'fulfilled' ? operations . value : null
metricsError . value = [ orders , customers , operations ]. some ( result => result . status === 'rejected' )
metricsUpdatedAt . value = operations . status === 'fulfilled' && operations . value . asOf
? operations . value . asOf
: dayjs (). format ( 'YYYY-MM-DDTHH:mm:ss' )
metricsLoading . value = false
}
onMounted (() => {
document . addEventListener ( 'fullscreenchange' , syncFullscreenState )
void loadMetrics ()
})
2026-08-05 15:54:59 +08:00
onBeforeUnmount (() => document . removeEventListener ( 'fullscreenchange' , syncFullscreenState ))
</ script >
< template >
<!--
THESIS : 用一屏可扫描的经营驾驶舱承载平台概览 , 拒绝把数据拆成互不关联的装饰卡片 。
OWN - WORLD : 延续 Admin 冷灰工作面 、 白色数据面板和柏悦蓝主色 ; 状态色只承担分类与告警 。
STORY : 运营人员先识别核心体量 , 再查看趋势与构成 , 最后定位待处理事项 。
2026-09-11 17:27:30 +08:00
FIRST VIEWPORT : 顶部状态栏与四项核心指标占据首屏上半部 , 主趋势图和状态环图紧随其后 。
2026-08-05 15:54:59 +08:00
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" >
2026-08-10 16:24:54 +08:00
< AdminPage as = "div" spacing = "normal" >
< section class = "dashboard-context" aria -label =" 数据看板说明 " >
2026-09-11 17:20:58 +08:00
< p >{{ metricsError ? '部分核心指标加载失败,可刷新重试;下方图表及待办仍为静态演示数据。' : '四项核心指标已接入实时业务数据;下方图表及待办仍为静态演示数据。' }}</ p >
2026-08-10 16:24:54 +08:00
< div class = "dashboard-head-actions" >
2026-09-11 17:20:58 +08:00
< span > 数据更新时间 : {{ metricsUpdatedAt ? dayjs ( metricsUpdatedAt ). format ( 'YYYY-MM-DD HH:mm' ) : '加载中' }}</ span >
< button type = "button" class = "dashboard-outline-button" :disabled = "metricsLoading" @click ="loadMetrics" >
< RefreshCw :size = "16" aria -hidden =" true " />
刷新数据
</ button >
2026-08-10 16:24:54 +08:00
< 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 >
2026-08-05 15:54:59 +08:00
2026-08-10 16:24:54 +08:00
< 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 >
2026-09-11 17:20:58 +08:00
< span >{{ metric . note }}</ span >
2026-08-10 16:24:54 +08:00
</ article >
2026-08-05 15:54:59 +08:00
</ div >
2026-08-10 16:24:54 +08:00
</ template >
2026-08-05 15:54:59 +08:00
2026-08-10 16:24:54 +08:00
< 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 >
2026-08-05 15:54:59 +08:00
</ div >
2026-08-10 16:24:54 +08:00
< 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 >
2026-08-05 15:54:59 +08:00
</ div >
2026-08-10 16:24:54 +08:00
< 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 >
2026-08-05 15:54:59 +08:00
</ div >
2026-08-10 16:24:54 +08:00
</ ListPageLayout >
</ AdminPage >
2026-08-05 15:54:59 +08:00
</ section >
</ template >
< style scoped >
2026-08-10 16:24:54 +08:00
. dashboard - view { -- dashboard - panel : var ( -- cc - surface , # fff ); display : block ; min - width : 0 ; color : var ( -- cc - text , # 172033 ); }
. dashboard - view : fullscreen { overflow : auto ; padding : 24 px ; background : var ( -- cc - page , # f4f6f8 ); }
. dashboard - content { display : flex ; min - width : 0 ; flex - direction : column ; gap : 18 px ; }
. dashboard - context { display : flex ; min - width : 0 ; align - items : center ; justify - content : space - between ; gap : 16 px ; }
. dashboard - context p { min - width : 0 ; margin : 0 ; color : var ( -- cc - muted , # 667085 ); font - size : 12 px ; line - height : 1.55 ; }
. dashboard - head - actions { display : flex ; align - items : center ; gap : 14 px ; color : var ( -- cc - muted , # 667085 ); font - size : 12 px ; white - space : nowrap ; }
. dashboard - outline - button , . dashboard - tabs button , . dashboard - task - action { display : inline - flex ; min - height : 36 px ; align - items : center ; justify - content : center ; gap : 6 px ; border : 1 px solid var ( -- cc - border - strong , # d8deea ); border - radius : var ( -- cc - radius - md , 6 px ); background : var ( -- cc - surface , # fff ); color : var ( -- cc - text - secondary , # 344054 ); cursor : pointer ; transition : border - color .18 s ease , background .18 s ease , color .18 s ease ; }
2026-08-05 15:54:59 +08:00
. dashboard - outline - button { padding : 0 13 px ; }
2026-09-11 17:20:58 +08:00
. dashboard - outline - button : hover : not ( : disabled ) { border - color : var ( -- cc - primary ); color : var ( -- cc - primary ); }
. dashboard - outline - button : disabled { cursor : wait ; opacity : .65 ; }
. dashboard - metrics { display : grid ; grid - template - columns : repeat ( 4 , minmax ( 0 , 1 fr )); gap : 14 px ; }
2026-08-05 15:54:59 +08:00
. dashboard - metric { position : relative ; min - width : 0 ; overflow : hidden ; padding : 20 px ; border : 1 px solid var ( -- cc - border ); border - radius : 8 px ; background : var ( -- dashboard - panel ); }
. dashboard - metric - mark { position : absolute ; top : - 38 px ; right : - 30 px ; width : 110 px ; height : 110 px ; border - radius : 50 % ; background : # edf1ff ; }
. dashboard - metric . is - green . dashboard - metric - mark { background : # e8f7ee ; }
. 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 25 px ; color : # 5 f6573 ; font - size : 13 px ; font - weight : 700 ; }
. dashboard - metric strong { position : relative ; display : block ; overflow - wrap : anywhere ; font - size : 31 px ; line - height : 1 ; letter - spacing : - .02 em ; }
. dashboard - metric strong small { color : # 525866 ; font - size : 12 px ; font - weight : 600 ; letter - spacing : 0 ; }
. dashboard - metric > span : last - child { position : relative ; display : block ; margin - top : 17 px ; color : # 8 a91a0 ; font - size : 11 px ; }
. dashboard - grid { display : grid ; min - width : 0 ; gap : 18 px ; }
. dashboard - grid -- primary { grid - template - columns : minmax ( 0 , 1.8 fr ) minmax ( 300 px , .9 fr ); }
. dashboard - grid -- equal { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); }
. dashboard - panel { min - width : 0 ; padding : 20 px ; border : 1 px solid var ( -- cc - border ); border - radius : 8 px ; background : var ( -- dashboard - panel ); }
. dashboard - panel - head { display : flex ; min - height : 36 px ; align - items : center ; justify - content : space - between ; gap : 16 px ; }
. dashboard - panel - head h2 { display : flex ; align - items : center ; gap : 8 px ; margin : 0 ; font - size : 16 px ; line - height : 1.4 ; }
. dashboard - panel - head > span { color : # 8 a91a0 ; font - size : 11 px ; }
. dashboard - tabs { display : flex ; gap : 7 px ; }
. dashboard - tabs button { min - width : 58 px ; padding : 0 12 px ; }
2026-08-10 16:24:54 +08:00
. 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 ; }
2026-08-05 15:54:59 +08:00
. dashboard - legend { display : flex ; flex - wrap : wrap ; gap : 8 px 18 px ; margin : 12 px 0 ; color : # 667085 ; font - size : 11 px ; }
. dashboard - legend span { display : inline - flex ; align - items : center ; gap : 6 px ; }
. dashboard - legend i { width : 8 px ; height : 8 px ; flex : 0 0 auto ; border - radius : 50 % ; }
. dashboard - legend -- centered { justify - content : center ; margin - top : 18 px ; }
. dashboard - line - chart { width : 100 % ; min - height : 240 px ; }
. dashboard - line - chart svg { display : block ; width : 100 % ; height : auto ; }
. dashboard - chart - grid line { stroke : # e8ebf1 ; stroke - width : 1 ; }
. dashboard - axis - labels { fill : # 8 a91a0 ; font - size : 10 px ; }
. dashboard - donut - wrap { display : grid ; min - height : 268 px ; align - content : center ; justify - items : center ; padding - top : 10 px ; }
. dashboard - donut { position : relative ; display : grid ; width : 166 px ; height : 166 px ; place - items : center ; border - radius : 50 % ; transform : rotate ( - 90 deg ); }
. dashboard - donut :: after { position : absolute ; width : 92 px ; height : 92 px ; border - radius : 50 % ; background : # fff ; content : '' ; }
. dashboard - donut > span { z - index : 1 ; display : flex ; align - items : center ; flex - direction : column ; color : # 667085 ; transform : rotate ( 90 deg ); }
. dashboard - donut > span svg { color : # 667 eea ; }
. dashboard - donut > span strong { margin - top : 3 px ; color : # 172033 ; font - size : 18 px ; }
. dashboard - donut > span small { font - size : 10 px ; }
. dashboard - donut - wrap -- revenue { min - height : 320 px ; }
. dashboard - donut -- solid { width : 210 px ; height : 210 px ; }
. dashboard - donut -- solid :: after { display : none ; }
. dashboard - bars { display : grid ; gap : 8 px ; margin - top : 16 px ; }
. dashboard - bar - row { display : grid ; grid - template - columns : 104 px minmax ( 80 px , 1 fr ) 32 px ; align - items : center ; gap : 9 px ; color : # 667085 ; font - size : 11 px ; }
. dashboard - bar - row > span { overflow : hidden ; text - align : right ; text - overflow : ellipsis ; white - space : nowrap ; }
. dashboard - bar - row > i { height : 15 px ; overflow : hidden ; border - radius : 4 px ; background : # f0f3f8 ; }
. dashboard - bar - row > i b { display : block ; height : 100 % ; border - radius : inherit ; background : # 3478 f6 ; }
. dashboard - bar - row > strong { color : # 475467 ; font - size : 10 px ; }
. dashboard - task - panel { padding : 0 ; overflow : hidden ; }
. dashboard - task - panel . dashboard - panel - head { padding : 16 px 20 px ; }
. dashboard - table - wrap { overflow - x : auto ; border - top : 1 px solid # e8ebf1 ; }
. dashboard - table - wrap table { width : 100 % ; min - width : 760 px ; border - collapse : collapse ; font - size : 12 px ; }
. dashboard - table - wrap th , . dashboard - table - wrap td { padding : 13 px 16 px ; border - bottom : 1 px 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 : 30 px ; padding : 0 9 px ; font - size : 11 px ; }
. dashboard - task - action : disabled { border - color : # e4e7ec ; background : # f9fafb ; color : # 98 a2b3 ; cursor : not - allowed ; }
. dashboard - priority { display : inline - flex ; min - height : 24 px ; align - items : center ; padding : 2 px 8 px ; border - radius : 999 px ; background : # fff3e8 ; color : # c54d0a ; font - size : 11 px ; font - weight : 700 ; }
. dashboard - priority . urgent { background : # fef3f2 ; color : # b42318 ; }
@ media ( max - width : 1180 px ) {
. dashboard - metrics { grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr )); }
. dashboard - grid -- primary { grid - template - columns : minmax ( 0 , 1 fr ); }
. dashboard - donut - wrap { min - height : 240 px ; }
}
@ media ( max - width : 760 px ) {
2026-08-10 16:24:54 +08:00
. dashboard - context { align - items : flex - start ; flex - direction : column ; }
2026-08-05 15:54:59 +08:00
. dashboard - head - actions { align - items : flex - start ; flex - direction : column ; gap : 9 px ; white - space : normal ; }
. dashboard - metrics { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); }
. dashboard - grid -- equal { grid - template - columns : minmax ( 0 , 1 fr ); }
. dashboard - panel - head { align - items : flex - start ; flex - direction : column ; }
. dashboard - tabs { width : 100 % ; overflow - x : auto ; }
. dashboard - tabs button { min - height : 44 px ; flex : 1 ; }
}
@ media ( max - width : 420 px ) {
2026-08-10 16:24:54 +08:00
. dashboard - content { gap : 14 px ; }
2026-08-05 15:54:59 +08:00
. dashboard - metrics { grid - template - columns : repeat ( 2 , minmax ( 0 , 1 fr )); gap : 8 px ; }
. dashboard - metric { min - height : 116 px ; padding : 13 px ; }
. dashboard - metric - mark { top : - 30 px ; right : - 24 px ; width : 80 px ; height : 80 px ; }
. dashboard - metric p { margin - bottom : 12 px ; font - size : 11 px ; }
. dashboard - metric strong { font - size : 23 px ; }
. dashboard - metric strong small { font - size : 10 px ; }
. dashboard - metric > span : last - child { margin - top : 10 px ; font - size : 10 px ; }
. dashboard - panel { padding : 16 px ; }
. dashboard - task - panel { padding : 0 ; }
. dashboard - bar - row { grid - template - columns : 90 px minmax ( 72 px , 1 fr ) 28 px ; gap : 7 px ; }
}
@ media ( prefers - reduced - motion : reduce ) {
. dashboard - outline - button , . dashboard - tabs button , . dashboard - task - action { transition : none ; }
}
</ style >