Files
admin-web/src/modules/dashboard/pages/DashboardView.vue
T

135 lines
6.4 KiB
Vue
Raw Normal View History

2026-08-05 15:54:59 +08:00
<script setup lang="ts">
2026-09-15 09:58:15 +08:00
import '@/styles/adminDensity.css'
import { ElButton } from 'element-plus'
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 {
Expand,
Minimize,
2026-09-11 17:20:58 +08:00
RefreshCw,
2026-08-05 15:54:59 +08:00
} from '@lucide/vue'
2026-08-10 16:24:54 +08:00
import AdminPage from '@/shared/components/AdminPage.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
const dashboardRef = ref<HTMLElement | null>(null)
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(() => [
2026-09-15 09:58:15 +08:00
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计' },
2026-09-11 17:20:58 +08:00
{
label: '在途柜量', value: displayNumber(operationalMetrics.value?.inTransitTeu, 2), unit: 'TEU',
note: operationalMetrics.value
? `已开船未到仓 · ${operationalMetrics.value.inTransitContainerCount} 柜${operationalMetrics.value.unknownContainerTypeCount ? ` · ${operationalMetrics.value.unknownContainerTypeCount} 柜箱型未知` : ''}`
: '已开船且尚未到仓',
},
2026-09-15 09:58:15 +08:00
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用' },
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成' },
2026-09-11 17:20:58 +08:00
])
2026-08-05 15:54:59 +08:00
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>
2026-09-15 09:58:15 +08:00
<section ref="dashboardRef" class="dashboard-view admin-density">
2026-08-10 16:24:54 +08:00
<AdminPage as="div" spacing="normal">
<section class="dashboard-context" aria-label="数据看板说明">
2026-09-14 13:48:15 +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>
2026-09-15 09:58:15 +08:00
<el-button class="dashboard-control" :disabled="metricsLoading" @click="loadMetrics">
2026-09-11 17:20:58 +08:00
<RefreshCw :size="16" aria-hidden="true" />
刷新数据
2026-09-15 09:58:15 +08:00
</el-button>
<el-button class="dashboard-control" :aria-pressed="isFullscreen" @click="toggleFullscreen">
2026-08-10 16:24:54 +08:00
<Minimize v-if="isFullscreen" :size="16" aria-hidden="true" />
<Expand v-else :size="16" aria-hidden="true" />
{{ isFullscreen ? '退出全屏' : '全屏展示' }}
2026-09-15 09:58:15 +08:00
</el-button>
2026-08-10 16:24:54 +08:00
</div>
</section>
2026-08-05 15:54:59 +08:00
2026-09-15 09:58:15 +08:00
<dl class="dashboard-metrics density-fields" aria-label="核心业务指标">
<div v-for="metric in metrics" :key="metric.label" class="dashboard-metric">
<dt>{{ metric.label }}</dt>
<dd>
<strong>{{ metric.value }} <small>{{ metric.unit }}</small></strong>
<span class="dashboard-metric-note">{{ metric.note }}</span>
</dd>
</div>
</dl>
2026-08-10 16:24:54 +08:00
</AdminPage>
2026-08-05 15:54:59 +08:00
</section>
</template>
<style scoped>
2026-09-15 09:58:15 +08:00
.dashboard-view { min-width: 0; color: var(--cc-text); }
.dashboard-view:fullscreen { overflow: auto; padding: var(--admin-space-7); background: var(--cc-page, #f4f6f8); }
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--admin-space-6); }
.dashboard-context p { min-width: 0; margin: 0; color: var(--cc-muted); font-size: var(--admin-font-caption); line-height: 1.55; }
.dashboard-head-actions { display: flex; align-items: center; gap: var(--admin-space-5); color: var(--cc-muted); font-size: var(--admin-font-caption); white-space: nowrap; }
.dashboard-head-actions .el-button + .el-button { margin-left: 0; }
.dashboard-metrics .dashboard-metric dd { display: flex; flex-direction: column; align-items: flex-start; gap: var(--admin-space-2); }
.dashboard-metric strong { font-size: var(--admin-font-title); font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.dashboard-metric strong small, .dashboard-metric-note { color: var(--cc-muted); font-size: var(--admin-font-caption); font-weight: normal; }
2026-08-05 15:54:59 +08:00
@media (max-width: 1180px) {
2026-09-15 09:58:15 +08:00
.dashboard-metrics.density-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
2026-08-05 15:54:59 +08:00
}
@media (max-width: 760px) {
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: 9px; white-space: normal; }
2026-09-15 09:58:15 +08:00
.dashboard-metrics.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2026-08-05 15:54:59 +08:00
}
@media (max-width: 420px) {
2026-09-15 09:58:15 +08:00
.dashboard-metrics.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-metrics .dashboard-metric { grid-template-columns: minmax(0, 1fr); }
2026-08-05 15:54:59 +08:00
}
</style>