优化
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# 首页数据看板逐操作矩阵
|
||||
|
||||
依据:2026-10-20 用户确认全 Admin 推广清爽蓝白风格;视觉参考为工作区 `docs/界面参考/蓝白卡片式工作台风格参考.md` 与其中的用户参考图。当前页面入口为 `pages/DashboardView.vue`,路由 `/dashboard`;本轮只调整展示,不改变指标口径、权限和接口。
|
||||
|
||||
| 页面 | 操作 | 当前承载与依据 | 本轮结论与验证 |
|
||||
| --- | --- | --- | --- |
|
||||
| 首页数据看板 | 查看核心指标 | `DashboardView.vue` 的 `metrics` 与核心业务指标区域;四列指标卡,按读取权限显示 | 接入公共工作台卡片、图标、数值和状态样式;正常统计口径收进可聚焦的信息提示,箱型未知仍可见;加载中、未知和真实零保持原义 |
|
||||
| 首页数据看板 | 刷新数据 | `loadMetrics`,顶部原地只读刷新按钮 | 保留按钮位置、加载禁用、部分请求失败提示与成功结果;不增加轮询或缓存 |
|
||||
| 首页数据看板 | 全屏展示/退出 | `toggleFullscreen` 与 `dashboardRef`,同一页面根进入浏览器全屏 | 保留状态同步和 `aria-pressed`,全屏继续继承公共主题 |
|
||||
| 首页数据看板 | 无指标权限 | `hasMetrics` 与现有授权菜单提示 | 不发无权限指标请求;提示保留,不显示假指标或新增业务入口 |
|
||||
| 首页数据看板 | 详情、新增、编辑、审核、文件预览、删除 | 当前页面无上述操作 | 不新增操作或浮层 |
|
||||
|
||||
自动回归入口:`pages/DashboardView.test.ts`、`api/dashboard.test.ts`。本模块未独立执行检查,统一由本轮总控完成。视觉待人工 `1920×1080`、浏览器缩放 `100%` 截图确认。
|
||||
@@ -1,4 +1,5 @@
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { ElButton, ElTooltip } from 'element-plus'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import DashboardView from './DashboardView.vue'
|
||||
import { getTodayNewOrderCount, getActiveCustomerCount, getDashboardOperationalMetrics } from '../api/dashboard'
|
||||
@@ -20,6 +21,10 @@ vi.mock('../api/dashboard', () => ({
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
function mountDashboard() {
|
||||
return mount(DashboardView, { global: { components: { ElButton, ElTooltip } } })
|
||||
}
|
||||
|
||||
describe('DashboardView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -28,7 +33,7 @@ describe('DashboardView', () => {
|
||||
|
||||
it('makes no metric requests for a minimal pool sales account and gives an authorized-menu hint', async () => {
|
||||
access.permissions = new Set(['platform:fleet:capacity-offer:list', 'platform:fleet:capacity-offer:inquiry-create', 'platform:fleet:capacity-offer:inquiry-edit', 'platform:fleet:capacity-offer:inquiry-cancel'])
|
||||
const wrapper = mount(DashboardView); await flushPromises()
|
||||
const wrapper = mountDashboard(); await flushPromises()
|
||||
expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getActiveCustomerCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('请从左侧菜单进入已授权业务')
|
||||
@@ -37,28 +42,30 @@ describe('DashboardView', () => {
|
||||
|
||||
it('requests only granted metric sources and requires both container and customs permissions', async () => {
|
||||
access.permissions = new Set(['platform:customer:list', 'platform:firstleg:container:list'])
|
||||
const wrapper = mount(DashboardView); await flushPromises()
|
||||
const wrapper = mountDashboard(); await flushPromises()
|
||||
expect(getActiveCustomerCount).toHaveBeenCalledOnce(); expect(getTodayNewOrderCount).not.toHaveBeenCalled(); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
|
||||
expect(wrapper.findAll('.dashboard-metric p').map(node => node.text())).toEqual(['活跃客户'])
|
||||
expect(wrapper.findAll('.workbench-metric-label').map(node => node.text())).toEqual(['活跃客户'])
|
||||
await wrapper.findAll('button').find(button => button.text().trim() === '刷新数据')!.trigger('click'); await flushPromises()
|
||||
expect(getActiveCustomerCount).toHaveBeenCalledTimes(2); expect(getDashboardOperationalMetrics).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders only the four live metrics', async () => {
|
||||
const wrapper = mount(DashboardView)
|
||||
const wrapper = mountDashboard()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.dashboard-context > p').exists()).toBe(false)
|
||||
expect(wrapper.classes()).toContain('admin-workbench')
|
||||
expect(wrapper.text()).toContain('数据更新时间:2026-09-11 12:00')
|
||||
expect(wrapper.findAll('.dashboard-metric')).toHaveLength(4)
|
||||
expect(wrapper.findAll('.dashboard-metric p').map(label => label.text())).toEqual(['今日新运单', '在途柜量', '活跃客户', '待清关票数'])
|
||||
expect(wrapper.findAll('.workbench-metric-label').map(label => label.text())).toEqual(['今日新运单', '在途柜量', '活跃客户', '待清关票数'])
|
||||
expect(wrapper.find('.density-fields').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.dashboard-metric-mark')).toHaveLength(4)
|
||||
expect(wrapper.findAll('.dashboard-metric.workbench-metric .workbench-icon')).toHaveLength(4)
|
||||
expect(wrapper.findAll('button[aria-label^="查看"]')).toHaveLength(4)
|
||||
expect(wrapper.text()).toContain('12 票')
|
||||
expect(wrapper.text()).toContain('4.25 TEU')
|
||||
expect(wrapper.text()).toContain('8 家')
|
||||
expect(wrapper.text()).toContain('7 票')
|
||||
expect(wrapper.text()).toContain('1 柜箱型未知')
|
||||
expect(wrapper.get('.dashboard-metric .workbench-badge[data-tone="amber"]').text()).toBe('1 柜箱型未知')
|
||||
expect(wrapper.text()).not.toContain('近 30 天运单量趋势')
|
||||
expect(wrapper.text()).not.toContain('运单状态分布')
|
||||
expect(wrapper.text()).not.toContain('TOP10 航线货量')
|
||||
@@ -69,7 +76,7 @@ describe('DashboardView', () => {
|
||||
|
||||
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)
|
||||
const wrapper = mountDashboard()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('部分核心指标加载失败')
|
||||
expect(wrapper.get('.dashboard-metric strong').text()).toContain('—')
|
||||
@@ -82,7 +89,7 @@ describe('DashboardView', () => {
|
||||
})
|
||||
|
||||
it('uses the same visualization root when entering and exiting fullscreen', async () => {
|
||||
const wrapper = mount(DashboardView)
|
||||
const wrapper = mountDashboard()
|
||||
await flushPromises()
|
||||
let fullscreen: Element | null = null
|
||||
const previousElement = Object.getOwnPropertyDescriptor(document, 'fullscreenElement')
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
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,
|
||||
@@ -36,17 +43,19 @@ function displayNumber(value: number | null | undefined, maximumFractionDigits =
|
||||
}
|
||||
|
||||
const metrics = computed(() => [
|
||||
{ label: '今日新运单', value: displayNumber(todayNewOrderCount.value), unit: '票', note: '按今日创建时间统计', tone: 'blue', visible: canReadOrders.value },
|
||||
{ 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: '客户状态为启用', tone: 'purple', visible: canReadCustomers.value },
|
||||
{ label: '待清关票数', value: displayNumber(operationalMetrics.value?.pendingCustomsOrderCount), unit: '票', note: '已进入清关且尚未完成', tone: 'rose', 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() {
|
||||
@@ -84,30 +93,33 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section ref="dashboardRef" class="dashboard-view">
|
||||
<AdminPage as="div" spacing="normal">
|
||||
<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">{{ !hasMetrics ? '当前账号暂无可查看的首页指标,请从左侧菜单进入已授权业务。' : '部分核心指标加载失败,可刷新重试。' }}</p>
|
||||
<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">数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
|
||||
<button v-if="hasMetrics" type="button" class="dashboard-outline-button" :disabled="metricsLoading" @click="loadMetrics">
|
||||
<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" />
|
||||
刷新数据
|
||||
</button>
|
||||
<button type="button" class="dashboard-outline-button" :aria-pressed="isFullscreen" @click="toggleFullscreen">
|
||||
</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 ? '退出全屏' : '全屏展示' }}
|
||||
</button>
|
||||
</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" :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 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>
|
||||
@@ -115,24 +127,14 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
</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 { margin-left: auto; 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; padding: 0 13px; 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: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; font-variant-numeric: tabular-nums; 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 { 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)); }
|
||||
}
|
||||
@@ -144,13 +146,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
@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-metric .workbench-metric-value > strong { font-size: 23px; }
|
||||
.dashboard-metric .workbench-metric-heading { flex-wrap: wrap; gap: 8px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user