Release/test 合并上生产 #2
@@ -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<typeof import('@/shared/api/http')>(),
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -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<DashboardOperationalMetrics> {
|
||||
const response = await http.get<ApiEnvelope<RawDashboardOperationalMetrics>, ApiEnvelope<RawDashboardOperationalMetrics>>(
|
||||
'/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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Clock3,
|
||||
Expand,
|
||||
Minimize,
|
||||
RefreshCw,
|
||||
Ship,
|
||||
} from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import {
|
||||
getActiveCustomerCount,
|
||||
getDashboardOperationalMetrics,
|
||||
getTodayNewOrderCount,
|
||||
type DashboardOperationalMetrics,
|
||||
} from '../api/dashboard'
|
||||
|
||||
type TransportMode = 'sea' | 'air' | 'land'
|
||||
|
||||
@@ -22,14 +30,31 @@ interface TrendSeries {
|
||||
const dashboardRef = ref<HTMLElement | null>(null)
|
||||
const activeMode = ref<TransportMode>('sea')
|
||||
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 metrics = [
|
||||
{ label: '今日新运单', value: '128', unit: '票', note: '较昨日', change: '↑ 12.3%', tone: 'blue', warning: false },
|
||||
{ label: '在途柜量', value: '3,842', unit: 'TEU', note: '较上周', change: '↑ 8.5%', tone: 'green', warning: false },
|
||||
{ label: '本月营收', value: '¥856', unit: '万', note: '较上月', change: '↑ 15.2%', tone: 'orange', warning: false },
|
||||
{ label: '活跃客户', value: '368', unit: '家', note: '本周新增', change: '+6 家', tone: 'purple', warning: false },
|
||||
{ label: '待清关票数', value: '47', unit: '票', note: '即将超期', change: '3 票', tone: 'rose', warning: true },
|
||||
] as const
|
||||
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: '按今日创建时间统计', tone: 'blue' },
|
||||
{
|
||||
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' },
|
||||
])
|
||||
|
||||
const trendSeries: Record<TransportMode, TrendSeries[]> = {
|
||||
sea: [
|
||||
@@ -110,7 +135,28 @@ function syncFullscreenState() {
|
||||
isFullscreen.value = document.fullscreenElement === dashboardRef.value
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('fullscreenchange', syncFullscreenState))
|
||||
async function loadMetrics() {
|
||||
metricsLoading.value = true
|
||||
metricsError.value = false
|
||||
const [orders, customers, operations] = await Promise.allSettled([
|
||||
getTodayNewOrderCount(),
|
||||
getActiveCustomerCount(),
|
||||
getDashboardOperationalMetrics(),
|
||||
])
|
||||
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
|
||||
: dayjs().format('YYYY-MM-DDTHH:mm:ss')
|
||||
metricsLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('fullscreenchange', syncFullscreenState)
|
||||
void loadMetrics()
|
||||
})
|
||||
onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFullscreenState))
|
||||
</script>
|
||||
|
||||
@@ -126,9 +172,13 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
<section ref="dashboardRef" class="dashboard-view">
|
||||
<AdminPage as="div" spacing="normal">
|
||||
<section class="dashboard-context" aria-label="数据看板说明">
|
||||
<p>当前为原型占位,以下均为静态演示数据,尚未接入业务接口。</p>
|
||||
<p>{{ metricsError ? '部分核心指标加载失败,可刷新重试;下方图表及待办仍为静态演示数据。' : '四项核心指标已接入实时业务数据;下方图表及待办仍为静态演示数据。' }}</p>
|
||||
<div class="dashboard-head-actions">
|
||||
<span>演示快照:2026-06-02 08:45</span>
|
||||
<span>数据更新时间:{{ metricsUpdatedAt ? dayjs(metricsUpdatedAt).format('YYYY-MM-DD HH:mm') : '加载中' }}</span>
|
||||
<button 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">
|
||||
<Minimize v-if="isFullscreen" :size="16" aria-hidden="true" />
|
||||
<Expand v-else :size="16" aria-hidden="true" />
|
||||
@@ -144,7 +194,7 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
|
||||
<span class="dashboard-metric-mark" aria-hidden="true" />
|
||||
<p>{{ metric.label }}</p>
|
||||
<strong>{{ metric.value }} <small>{{ metric.unit }}</small></strong>
|
||||
<span>{{ metric.note }} <b :class="{ 'is-warning': metric.warning }">{{ metric.change }}</b></span>
|
||||
<span>{{ metric.note }}</span>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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)); }
|
||||
|
||||
Reference in New Issue
Block a user