Files
admin-web/src/modules/dashboard/pages/DashboardView.vue
T
2026-10-20 23:34:11 +08:00

153 lines
8.0 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 dayjs from 'dayjs'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
ClipboardCheck,
Expand,
FileText,
Minimize,
RefreshCw,
Ship,
Users,
} from '@lucide/vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import InlineHelp from '@/shared/components/InlineHelp.vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import '@/styles/adminDensity.css'
import '@/styles/adminWorkbench.css'
import {
getActiveCustomerCount,
getDashboardOperationalMetrics,
getTodayNewOrderCount,
type DashboardOperationalMetrics,
} from '../api/dashboard'
const dashboardRef = ref<HTMLElement | null>(null)
const isFullscreen = ref(false)
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)
const access = useAccessPreviewStore()
const canReadOrders = computed(() => access.can('platform:order:list'))
const canReadCustomers = computed(() => access.can('platform:customer:list'))
const canReadOperations = computed(() => access.can('platform:firstleg:container:list') && access.can('platform:firstleg:customs:list'))
const hasMetrics = computed(() => canReadOrders.value || canReadCustomers.value || canReadOperations.value)
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: '按今日创建时间统计', warning: '', icon: FileText, tone: 'blue', visible: canReadOrders.value },
{
label: '在途柜量', value: displayNumber(operationalMetrics.value?.inTransitTeu, 2), unit: 'TEU',
note: operationalMetrics.value
? `已开船未到仓 · ${operationalMetrics.value.inTransitContainerCount} 柜${operationalMetrics.value.unknownContainerTypeCount ? ` · ${operationalMetrics.value.unknownContainerTypeCount} 柜箱型未知` : ''}`
: '已开船且尚未到仓',
warning: operationalMetrics.value?.unknownContainerTypeCount ? `${operationalMetrics.value.unknownContainerTypeCount} 柜箱型未知` : '',
icon: Ship,
tone: 'green',
visible: canReadOperations.value,
},
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用', warning: '', icon: Users, tone: 'purple', visible: canReadCustomers.value },
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', warning: '', icon: ClipboardCheck, tone: 'rose', visible: canReadOperations.value },
].filter(metric => metric.visible))
async function toggleFullscreen() {
if (!document.fullscreenElement) await dashboardRef.value?.requestFullscreen()
else await document.exitFullscreen()
}
function syncFullscreenState() {
isFullscreen.value = document.fullscreenElement === dashboardRef.value
}
async function loadMetrics() {
metricsLoading.value = true
metricsError.value = false
const [orders, customers, operations] = await Promise.allSettled([
canReadOrders.value ? getTodayNewOrderCount() : Promise.resolve(null),
canReadCustomers.value ? getActiveCustomerCount() : Promise.resolve(null),
canReadOperations.value ? getDashboardOperationalMetrics() : Promise.resolve(null),
])
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
: hasMetrics.value ? dayjs().format('YYYY-MM-DDTHH:mm:ss') : null
metricsLoading.value = false
}
onMounted(() => {
document.addEventListener('fullscreenchange', syncFullscreenState)
void loadMetrics()
})
onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFullscreenState))
</script>
<template>
<section ref="dashboardRef" class="dashboard-view admin-workbench">
<AdminPage as="div" spacing="normal" class="admin-density admin-workbench">
<section class="dashboard-context" aria-label="数据看板操作">
<p v-if="!hasMetrics || metricsError" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p>
<div class="dashboard-head-actions">
<span v-if="hasMetrics" class="workbench-meta">数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
<el-button v-if="hasMetrics" native-type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
<RefreshCw :size="16" aria-hidden="true" />
刷新数据
</el-button>
<el-button native-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 ? '退出全屏' : '全屏展示' }}
</el-button>
</div>
</section>
<div v-if="hasMetrics" class="dashboard-metrics" aria-label="核心业务指标">
<article v-for="metric in metrics" :key="metric.label" class="dashboard-metric workbench-metric workbench-tone" :data-tone="metric.tone">
<div class="workbench-metric-heading">
<span class="workbench-icon" aria-hidden="true"><component :is="metric.icon" /></span>
<p class="workbench-metric-label">{{ metric.label }}</p>
<InlineHelp :title="metric.label" :teleported="false"><p>{{ metric.note }}</p></InlineHelp>
</div>
<div class="workbench-metric-value"><strong>{{ metric.value }}</strong> <span>{{ metric.unit }}</span></div>
<span v-if="metric.warning" class="workbench-badge is-toned workbench-tone" data-tone="amber">{{ metric.warning }}</span>
</article>
</div>
</AdminPage>
</section>
</template>
<style scoped>
.dashboard-view { display: block; min-width: 0; }
.dashboard-view:fullscreen { overflow: auto; background: var(--workbench-surface); }
.dashboard-context { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: var(--workbench-section-gap); }
.dashboard-head-actions { display: flex; margin-left: auto; align-items: center; gap: 12px; white-space: nowrap; }
.dashboard-outline-button + .dashboard-outline-button { margin-left: 0; }
.dashboard-outline-button :deep(> span) { gap: 6px; }
.dashboard-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--workbench-grid-gap); }
.dashboard-metric .workbench-metric-label { flex: 1; }
@media (max-width: 1180px) {
.dashboard-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@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)); }
}
@media (max-width: 420px) {
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.dashboard-metric { min-height: 116px; padding: 13px; }
.dashboard-metric .workbench-metric-value > strong { font-size: 23px; }
.dashboard-metric .workbench-metric-heading { flex-wrap: wrap; gap: 8px; }
}
</style>