From ce399e41533eea0f4b21e121fd06c33c41edfbef Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 11 Sep 2026 17:20:58 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/dashboard/api/dashboard.test.ts | 47 +++++++++++ src/modules/dashboard/api/dashboard.ts | 55 +++++++++++++ .../dashboard/pages/DashboardView.test.ts | 34 ++++++-- src/modules/dashboard/pages/DashboardView.vue | 80 +++++++++++++++---- 4 files changed, 194 insertions(+), 22 deletions(-) create mode 100644 src/modules/dashboard/api/dashboard.test.ts create mode 100644 src/modules/dashboard/api/dashboard.ts diff --git a/src/modules/dashboard/api/dashboard.test.ts b/src/modules/dashboard/api/dashboard.test.ts new file mode 100644 index 00000000..b6b75877 --- /dev/null +++ b/src/modules/dashboard/api/dashboard.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { listPlatformTenants } from '@/modules/iam' +import { listPlatformUnifiedOrders } from '@/modules/unified-orders' +import { + getActiveCustomerCount, + getDashboardOperationalMetrics, + getTodayNewOrderCount, +} from './dashboard' + +vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) +vi.mock('@/modules/unified-orders', () => ({ listPlatformUnifiedOrders: vi.fn() })) +vi.mock('@/shared/api/http', async (importOriginal) => ({ + ...await importOriginal(), + http: { get: vi.fn() }, +})) + +describe('dashboard metrics api', () => { + beforeEach(() => vi.clearAllMocks()) + + it('normalizes the operational metrics response', async () => { + vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'ok', data: { + inTransitContainerCount: '3', inTransitTeu: '4.25', unknownContainerTypeCount: '1', + pendingCustomsOrderCount: '7', asOf: '2026-09-11T12:00:00', + } }) + + await expect(getDashboardOperationalMetrics()).resolves.toEqual({ + inTransitContainerCount: 3, inTransitTeu: 4.25, unknownContainerTypeCount: 1, + pendingCustomsOrderCount: 7, asOf: '2026-09-11T12:00:00', + }) + expect(http.get).toHaveBeenCalledWith('/platform/v1/first-leg/dashboard/operational-metrics') + }) + + it('uses list totals for today orders and active customers', async () => { + vi.mocked(listPlatformUnifiedOrders).mockResolvedValue({ records: [], page: 1, size: 1, total: 12, pages: 12 }) + vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 1, total: 8, pages: 8 }) + + await expect(getTodayNewOrderCount()).resolves.toBe(12) + await expect(getActiveCustomerCount()).resolves.toBe(8) + + expect(listPlatformUnifiedOrders).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 1 })) + const orderQuery = vi.mocked(listPlatformUnifiedOrders).mock.calls[0]![0] + expect(orderQuery.createdFrom).toMatch(/T00:00:00\.000$/) + expect(orderQuery.createdTo).toMatch(/T23:59:59\.999$/) + expect(listPlatformTenants).toHaveBeenCalledWith({ page: 1, size: 1, status: 'ACTIVE' }) + }) +}) diff --git a/src/modules/dashboard/api/dashboard.ts b/src/modules/dashboard/api/dashboard.ts new file mode 100644 index 00000000..2d168f35 --- /dev/null +++ b/src/modules/dashboard/api/dashboard.ts @@ -0,0 +1,55 @@ +import dayjs from 'dayjs' +import { listPlatformTenants } from '@/modules/iam' +import { listPlatformUnifiedOrders } from '@/modules/unified-orders' +import { http, type ApiEnvelope, unwrapApiResponse } from '@/shared/api/http' + +export interface DashboardOperationalMetrics { + inTransitContainerCount: number + inTransitTeu: number + unknownContainerTypeCount: number + pendingCustomsOrderCount: number + asOf: string | null +} + +interface RawDashboardOperationalMetrics { + inTransitContainerCount: number | string + inTransitTeu: number | string + unknownContainerTypeCount: number | string + pendingCustomsOrderCount: number | string + asOf?: string | null +} + +function numberValue(value: number | string | null | undefined) { + const parsed = Number(value) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0 +} + +export async function getDashboardOperationalMetrics(): Promise { + const response = await http.get, ApiEnvelope>( + '/platform/v1/first-leg/dashboard/operational-metrics', + ) + const data = unwrapApiResponse(response) + return { + inTransitContainerCount: numberValue(data.inTransitContainerCount), + inTransitTeu: numberValue(data.inTransitTeu), + unknownContainerTypeCount: numberValue(data.unknownContainerTypeCount), + pendingCustomsOrderCount: numberValue(data.pendingCustomsOrderCount), + asOf: data.asOf || null, + } +} + +export async function getTodayNewOrderCount() { + const today = dayjs() + const result = await listPlatformUnifiedOrders({ + createdFrom: today.startOf('day').format('YYYY-MM-DDTHH:mm:ss.SSS'), + createdTo: today.endOf('day').format('YYYY-MM-DDTHH:mm:ss.SSS'), + page: 1, + size: 1, + }) + return result.total +} + +export async function getActiveCustomerCount() { + const result = await listPlatformTenants({ page: 1, size: 1, status: 'ACTIVE' }) + return result.total +} diff --git a/src/modules/dashboard/pages/DashboardView.test.ts b/src/modules/dashboard/pages/DashboardView.test.ts index 2c9c5f6e..a6682b51 100644 --- a/src/modules/dashboard/pages/DashboardView.test.ts +++ b/src/modules/dashboard/pages/DashboardView.test.ts @@ -1,13 +1,35 @@ import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' import DashboardView from './DashboardView.vue' -describe('DashboardView', () => { - it('labels placeholder values and keeps task actions non-operational', () => { - const wrapper = mount(DashboardView) +vi.mock('../api/dashboard', () => ({ + getTodayNewOrderCount: vi.fn().mockResolvedValue(12), + getActiveCustomerCount: vi.fn().mockResolvedValue(8), + getDashboardOperationalMetrics: vi.fn().mockResolvedValue({ + inTransitContainerCount: 3, + inTransitTeu: 4.25, + unknownContainerTypeCount: 1, + pendingCustomsOrderCount: 7, + asOf: '2026-09-11T12:00:00', + }), +})) - expect(wrapper.text()).toContain('静态演示数据') - expect(wrapper.text()).toContain('演示快照:2026-06-02 08:45') +describe('DashboardView', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders the four live metrics and keeps unfinished task actions non-operational', async () => { + const wrapper = mount(DashboardView) + await flushPromises() + + expect(wrapper.text()).toContain('四项核心指标已接入实时业务数据') + expect(wrapper.text()).toContain('数据更新时间:2026-09-11 12:00') + expect(wrapper.findAll('.dashboard-metric')).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 柜箱型未知') const taskActions = wrapper.findAll('.dashboard-task-action') expect(taskActions).toHaveLength(3) expect(taskActions.every((button) => button.attributes('disabled') !== undefined)).toBe(true) diff --git a/src/modules/dashboard/pages/DashboardView.vue b/src/modules/dashboard/pages/DashboardView.vue index 4753ab38..b67ee872 100644 --- a/src/modules/dashboard/pages/DashboardView.vue +++ b/src/modules/dashboard/pages/DashboardView.vue @@ -1,14 +1,22 @@ @@ -126,9 +172,13 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
-

当前为原型占位,以下均为静态演示数据,尚未接入业务接口。

+

{{ metricsError ? '部分核心指标加载失败,可刷新重试;下方图表及待办仍为静态演示数据。' : '四项核心指标已接入实时业务数据;下方图表及待办仍为静态演示数据。' }}

- 演示快照:2026-06-02 08:45 + 数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }} +
@@ -250,20 +300,18 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls .dashboard-head-actions { display: flex; align-items: center; gap: 14px; color: var(--cc-muted, #667085); font-size: 12px; white-space: nowrap; } .dashboard-outline-button, .dashboard-tabs button, .dashboard-task-action { 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 { border-color: var(--cc-primary); color: var(--cc-primary); } -.dashboard-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; } +.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-orange .dashboard-metric-mark { background: #fff3e3; } .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-metric > span b { margin-left: 4px; color: #ef4444; font-weight: 700; } -.dashboard-metric > span b.is-warning { color: #d92d20; } .dashboard-grid { display: grid; min-width: 0; gap: 18px; } .dashboard-grid--primary { grid-template-columns: minmax(0, 1.8fr) minmax(300px, .9fr); } .dashboard-grid--equal { grid-template-columns: repeat(2, minmax(0, 1fr)); }