This commit is contained in:
2026-09-15 09:58:15 +08:00
parent 3f998bdb88
commit 4bf66d9571
90 changed files with 1599 additions and 1388 deletions
@@ -1,6 +1,7 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import DashboardView from './DashboardView.vue'
import { getTodayNewOrderCount } from '../api/dashboard'
vi.mock('../api/dashboard', () => ({
getTodayNewOrderCount: vi.fn().mockResolvedValue(12),
@@ -14,6 +15,8 @@ vi.mock('../api/dashboard', () => ({
}),
}))
enableAutoUnmount(afterEach)
describe('DashboardView', () => {
beforeEach(() => vi.clearAllMocks())
@@ -24,6 +27,7 @@ describe('DashboardView', () => {
expect(wrapper.text()).toContain('核心指标来自实时业务数据')
expect(wrapper.text()).toContain('数据更新时间:2026-09-11 12:00')
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(4)
expect(wrapper.findAll('.density-fields dt').map(label => label.text())).toEqual(['今日新运单', '在途柜量', '活跃客户', '待清关票数'])
expect(wrapper.text()).toContain('12 票')
expect(wrapper.text()).toContain('4.25 TEU')
expect(wrapper.text()).toContain('8 家')
@@ -36,4 +40,50 @@ describe('DashboardView', () => {
expect(wrapper.text()).not.toContain('待办事项')
expect(wrapper.text()).not.toContain('静态演示数据')
})
it('keeps successful metrics when one request fails and refreshes through the visible control', async () => {
vi.mocked(getTodayNewOrderCount).mockRejectedValueOnce(new Error('指标加载失败'))
const wrapper = mount(DashboardView)
await flushPromises()
expect(wrapper.text()).toContain('部分核心指标加载失败')
expect(wrapper.get('.dashboard-metric dd').text()).toContain('—')
expect(wrapper.text()).toContain('4.25 TEU')
await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click')
await flushPromises()
expect(getTodayNewOrderCount).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('核心指标来自实时业务数据')
expect(wrapper.text()).toContain('12 票')
})
it('uses the same density-enabled root when entering and exiting fullscreen', async () => {
const wrapper = mount(DashboardView)
await flushPromises()
let fullscreen: Element | null = null
const previousElement = Object.getOwnPropertyDescriptor(document, 'fullscreenElement')
const previousExit = Object.getOwnPropertyDescriptor(document, 'exitFullscreen')
const enter = vi.fn(async () => { fullscreen = wrapper.element; document.dispatchEvent(new Event('fullscreenchange')) })
const exit = vi.fn(async () => { fullscreen = null; document.dispatchEvent(new Event('fullscreenchange')) })
Object.defineProperty(wrapper.element, 'requestFullscreen', { configurable: true, value: enter })
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => fullscreen })
Object.defineProperty(document, 'exitFullscreen', { configurable: true, value: exit })
try {
expect(wrapper.classes()).toContain('admin-density')
const button = wrapper.get('button[aria-pressed]')
await button.trigger('click')
await flushPromises()
expect(enter).toHaveBeenCalledOnce()
expect(button.text()).toBe('退出全屏')
expect(button.attributes('aria-pressed')).toBe('true')
await button.trigger('click')
await flushPromises()
expect(exit).toHaveBeenCalledOnce()
expect(button.text()).toBe('全屏展示')
expect(button.attributes('aria-pressed')).toBe('false')
} finally {
if (previousElement) Object.defineProperty(document, 'fullscreenElement', previousElement)
else Reflect.deleteProperty(document, 'fullscreenElement')
if (previousExit) Object.defineProperty(document, 'exitFullscreen', previousExit)
else Reflect.deleteProperty(document, 'exitFullscreen')
}
})
})
+32 -56
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import '@/styles/adminDensity.css'
import { ElButton } from 'element-plus'
import dayjs from 'dayjs'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
@@ -30,16 +32,15 @@ function displayNumber(value: number | null | undefined, maximumFractionDigits =
}
const metrics = computed(() => [
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue' },
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计' },
{
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' },
{ label: '活跃客户', value: displayNumber(activeCustomerCount.value), unit: '家', note: '客户状态为启用' },
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成' },
])
async function toggleFullscreen() {
@@ -77,82 +78,57 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
</script>
<template>
<!--
THESIS: 首页只承载已经接通真实业务数据的四项核心指标。
OWN-WORLD: 延续 Admin 冷灰工作面、白色数据面板和柏悦蓝主色;状态色只承担分类与告警。
STORY: 运营人员进入首页后直接识别当前核心体量。
FIRST VIEWPORT: 顶部状态栏与四项核心指标完整占据首屏。
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">
<section ref="dashboardRef" class="dashboard-view admin-density">
<AdminPage as="div" spacing="normal">
<section class="dashboard-context" aria-label="数据看板说明">
<p>{{ metricsError ? '部分核心指标加载失败,可刷新重试。' : '核心指标来自实时业务数据。' }}</p>
<div class="dashboard-head-actions">
<span>数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
<button type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
<el-button class="dashboard-control" :disabled="metricsLoading" @click="loadMetrics">
<RefreshCw :size="16" aria-hidden="true" />
刷新数据
</button>
<button type="button" class="dashboard-outline-button" :aria-pressed="isFullscreen" @click="toggleFullscreen">
</el-button>
<el-button class="dashboard-control" :aria-pressed="isFullscreen" @click="toggleFullscreen">
<Minimize v-if="isFullscreen" :size="16" aria-hidden="true" />
<Expand v-else :size="16" aria-hidden="true" />
{{ isFullscreen ? '退出全屏' : '全屏展示' }}
</button>
</el-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 }}</span>
</article>
</div>
<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>
</AdminPage>
</section>
</template>
<style scoped>
.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-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 { 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: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, 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; }
.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 25px; color: #5f6573; font-size: 13px; font-weight: 700; }
.dashboard-metric strong { position: relative; display: block; overflow-wrap: anywhere; font-size: 31px; line-height: 1; letter-spacing: -.02em; }
.dashboard-metric strong small { color: #525866; font-size: 12px; font-weight: 600; letter-spacing: 0; }
.dashboard-metric > span:last-child { position: relative; display: block; margin-top: 17px; color: #8a91a0; font-size: 11px; }
.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; }
@media (max-width: 1180px) {
.dashboard-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.dashboard-metrics.density-fields { 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)); }
.dashboard-metrics.density-fields { 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-mark { top: -30px; right: -24px; width: 80px; height: 80px; }
.dashboard-metric p { margin-bottom: 12px; font-size: 11px; }
.dashboard-metric strong { font-size: 23px; }
.dashboard-metric strong small { font-size: 10px; }
.dashboard-metric > span:last-child { margin-top: 10px; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.dashboard-outline-button { transition: none; }
.dashboard-metrics.density-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-metrics .dashboard-metric { grid-template-columns: minmax(0, 1fr); }
}
</style>