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 {
|
2026-10-20 23:34:11 +08:00
|
|
|
|
ClipboardCheck,
|
2026-08-05 15:54:59 +08:00
|
|
|
|
Expand,
|
2026-10-20 23:34:11 +08:00
|
|
|
|
FileText,
|
2026-08-05 15:54:59 +08:00
|
|
|
|
Minimize,
|
2026-09-11 17:20:58 +08:00
|
|
|
|
RefreshCw,
|
2026-10-20 23:34:11 +08:00
|
|
|
|
Ship,
|
|
|
|
|
|
Users,
|
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-10-20 23:34:11 +08:00
|
|
|
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
2026-10-07 18:21:03 +08:00
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
2026-10-20 23:34:11 +08:00
|
|
|
|
import '@/styles/adminDensity.css'
|
|
|
|
|
|
import '@/styles/adminWorkbench.css'
|
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-10-07 18:21:03 +08:00
|
|
|
|
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)
|
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-10-20 23:34:11 +08:00
|
|
|
|
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', warning: '', icon: FileText, tone: 'blue', visible: canReadOrders.value },
|
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-10-20 23:34:11 +08:00
|
|
|
|
warning: operationalMetrics.value?.unknownContainerTypeCount ? `${operationalMetrics.value.unknownContainerTypeCount} 柜箱型未知` : '',
|
|
|
|
|
|
icon: Ship,
|
2026-09-15 10:38:03 +08:00
|
|
|
|
tone: 'green',
|
2026-10-07 18:21:03 +08:00
|
|
|
|
visible: canReadOperations.value,
|
2026-09-11 17:20:58 +08:00
|
|
|
|
},
|
2026-10-20 23:34:11 +08:00
|
|
|
|
{ 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 },
|
2026-10-07 18:21:03 +08:00
|
|
|
|
].filter(metric => metric.visible))
|
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([
|
2026-10-07 18:21:03 +08:00
|
|
|
|
canReadOrders.value ? getTodayNewOrderCount() : Promise.resolve(null),
|
|
|
|
|
|
canReadCustomers.value ? getActiveCustomerCount() : Promise.resolve(null),
|
|
|
|
|
|
canReadOperations.value ? getDashboardOperationalMetrics() : Promise.resolve(null),
|
2026-09-11 17:20:58 +08:00
|
|
|
|
])
|
|
|
|
|
|
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')
|
2026-10-07 18:21:03 +08:00
|
|
|
|
metricsUpdatedAt.value = operations.status === 'fulfilled' && operations.value?.asOf
|
2026-09-11 17:20:58 +08:00
|
|
|
|
? operations.value.asOf
|
2026-10-07 18:21:03 +08:00
|
|
|
|
: hasMetrics.value ? dayjs().format('YYYY-MM-DDTHH:mm:ss') : null
|
2026-09-11 17:20:58 +08:00
|
|
|
|
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-10-20 23:34:11 +08:00
|
|
|
|
<section ref="dashboardRef" class="dashboard-view admin-workbench">
|
2026-10-27 17:44:49 +08:00
|
|
|
|
<AdminPage as="div" spacing="compact" class="admin-density admin-workbench">
|
2026-10-20 10:11:31 +08:00
|
|
|
|
<section class="dashboard-context" aria-label="数据看板操作">
|
2026-10-20 23:34:11 +08:00
|
|
|
|
<p v-if="!hasMetrics || metricsError" class="workbench-notice workbench-tone" data-tone="amber" role="status">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p>
|
2026-08-10 16:24:54 +08:00
|
|
|
|
<div class="dashboard-head-actions">
|
2026-10-20 23:34:11 +08:00
|
|
|
|
<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">
|
2026-09-11 17:20:58 +08:00
|
|
|
|
<RefreshCw :size="16" aria-hidden="true" />
|
|
|
|
|
|
刷新数据
|
2026-10-20 23:34:11 +08:00
|
|
|
|
</el-button>
|
|
|
|
|
|
<el-button native-type="button" class="dashboard-outline-button" :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-10-20 23:34:11 +08:00
|
|
|
|
</el-button>
|
2026-08-10 16:24:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2026-08-05 15:54:59 +08:00
|
|
|
|
|
2026-10-07 18:21:03 +08:00
|
|
|
|
<div v-if="hasMetrics" class="dashboard-metrics" aria-label="核心业务指标">
|
2026-10-20 23:34:11 +08:00
|
|
|
|
<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>
|
2026-09-15 10:38:03 +08:00
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
2026-08-10 16:24:54 +08:00
|
|
|
|
</AdminPage>
|
2026-08-05 15:54:59 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-10-20 23:34:11 +08:00
|
|
|
|
.dashboard-view { display: block; min-width: 0; }
|
2026-10-27 17:44:49 +08:00
|
|
|
|
.dashboard-view > .admin-page.is-spacing-compact { gap: var(--admin-space-2); }
|
2026-10-20 23:34:11 +08:00
|
|
|
|
.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; }
|
2026-08-05 15:54:59 +08:00
|
|
|
|
@media (max-width: 1180px) {
|
2026-09-15 10:38:03 +08:00
|
|
|
|
.dashboard-metrics { 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 10:38:03 +08:00
|
|
|
|
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
2026-08-05 15:54:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
@media (max-width: 420px) {
|
2026-09-15 10:38:03 +08:00
|
|
|
|
.dashboard-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
|
|
|
|
|
.dashboard-metric { min-height: 116px; padding: 13px; }
|
2026-10-20 23:34:11 +08:00
|
|
|
|
.dashboard-metric .workbench-metric-value > strong { font-size: 23px; }
|
|
|
|
|
|
.dashboard-metric .workbench-metric-heading { flex-wrap: wrap; gap: 8px; }
|
2026-08-05 15:54:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|