diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx
index a76c74e4..680beb62 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx
@@ -170,17 +170,23 @@ test('reconciles service identities with bound and identity-required vehicles',
}
expect(screen.getByText('服务身份 / 在线')).toBeInTheDocument();
expect(screen.getByText('已绑定 1024 · 待绑定 11')).toBeInTheDocument();
- expect(screen.getByText('服务身份 / 在线').closest('.semi-card')).toHaveClass('v2-ops-kpi-card');
+ expect(screen.getByRole('heading', { name: '运行概览', level: 5 })).toBeInTheDocument();
+ expect(screen.getByText('服务身份 / 在线').closest('.v2-ops-metric')).toBeInTheDocument();
+ expect(screen.getByRole('list', { name: '运行健康指标' })).toHaveClass('v2-ops-metric-rail');
+ expect(screen.getByText('全部正常')).toBeInTheDocument();
expect(screen.getByText('运行时安全').closest('.semi-card')).toHaveClass('v2-ops-runtime');
expect(screen.getByText('协议来源就绪度').closest('.semi-card')).toHaveClass('v2-ops-sources');
expect(screen.getByRole('heading', { name: '数据链路', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '运行时安全', level: 5 })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '协议来源就绪度', level: 5 })).toBeInTheDocument();
- expect(document.querySelector('.v2-ops-link-card.semi-card')).toHaveTextContent('MySQL主库连接正常');
+ expect(document.querySelector('.v2-ops-link-card')).toHaveTextContent('MySQL主库连接正常');
+ expect(document.querySelector('.v2-ops-link-card.semi-card')).not.toBeInTheDocument();
expect(document.querySelector('.v2-ops-runtime-descriptions.semi-descriptions')).toBeInTheDocument();
expect(screen.getByText('容量检查通过').closest('.semi-empty')).toHaveClass('v2-ops-capacity-empty');
expect(document.querySelector('.v2-ops-source-list.semi-card-group')).toBeInTheDocument();
expect(screen.getByText('GB32960').closest('.semi-card')).toHaveClass('v2-ops-source-card');
+ expect(screen.getByLabelText('GB32960 在线覆盖率')).toBeInTheDocument();
+ expect(screen.getByText('22.9% 在线')).toBeInTheDocument();
expect(screen.getByText('验收标准').closest('.semi-card')).toHaveClass('v2-ops-source-card');
expect(screen.queryByText('单车多来源诊断')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx
index 76881fca..2d0288b4 100644
--- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx
+++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx
@@ -1,5 +1,5 @@
import { IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
-import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
+import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useDeferredValue, useEffect, useState } from 'react';
import { api } from '../../api/client';
@@ -36,6 +36,24 @@ function number(value?: number, digits = 1) {
return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits });
}
+function OpsMetric({ label, value, detail, tone = 'neutral' }: {
+ label: string;
+ value: string;
+ detail: string;
+ tone?: 'neutral' | 'success' | 'warning' | 'danger';
+}) {
+ return
+ {label}
+ {value}
+ {detail}
+ ;
+}
+
+function sourceOnlineRate(online: number, total: number, onlineRate?: number) {
+ if (onlineRate != null && Number.isFinite(onlineRate)) return Math.max(0, Math.min(100, onlineRate));
+ return total > 0 ? Math.max(0, Math.min(100, (online / total) * 100)) : 0;
+}
+
function SourcePolicyEditor({ vin, source, diagnostic, editable, onSaved }: {
vin: string;
source: VehicleLocationSourceEvidence;
@@ -316,6 +334,11 @@ export default function OperationsPage() {
const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: ({ signal }) => api.sourceReadiness(signal), refetchInterval: 30_000, staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY });
const data = health.data; const sources = readiness.data;
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
+ const linkIssueCount = data?.linkHealth.filter((item) => item.status !== 'ok').length ?? 0;
+ const runtimeIssueCount = data ? Number(!data.mysqlWritable) + Number(!data.tdengineWritable) + Number(data.runtime.dataMode !== 'production') : 0;
+ const capacityIssueCount = data?.capacityFindings.length ?? 0;
+ const healthIssueCount = linkIssueCount + runtimeIssueCount + capacityIssueCount + Number((data?.kafkaLag ?? 0) > 0);
+ const overallStatus = !data ? 'unknown' : healthIssueCount > 0 ? 'warning' : 'ok';
return
{health.isError ? : null}
{readiness.isError ? readiness.refetch()} /> : null}
-
- 运行模式{data?.runtime.dataMode === 'production' ? '生产数据' : '待确认'}{data?.runtime.platformRelease ? '版本已注入' : '缺少版本'}
- 活跃连接{data?.activeConnections?.toLocaleString('zh-CN') ?? '—'}网关实时连接
- Kafka Lag{data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'}{data?.kafkaLag === 0 ? '已回零' : '需检查'}
- Redis 在线 Key{data?.redisOnlineKeys?.toLocaleString('zh-CN') ?? '—'}在线状态快照
- 服务身份 / 在线{sources ? `${sources.totalVehicles} / ${sources.onlineVehicles}` : '—'}{sources ? `已绑定 ${sources.boundVehicles} · 待绑定 ${sources.identityRequiredVehicles}` : '档案与快照并集'}
-
- {data?.linkHealth.length ? data.linkHealth.map((item) =>
{item.name}{item.detail || '无补充信息'}
) :
}
-
+ {!data ? '正在读取' : healthIssueCount > 0 ? `${healthIssueCount} 项需关注` : '全部正常'}15 秒自动刷新}
+ />
+
+ 0 ? '需要处理' : '运行正常'} detail={!data ? '等待服务端健康证据' : healthIssueCount > 0 ? `${healthIssueCount} 项证据需要关注` : '链路与写入探针均正常'} tone={!data ? 'neutral' : healthIssueCount > 0 ? 'warning' : 'success'} />
+
+
+
+
+
+
+
0 ? 'warning' : 'ok'}>{!data ? '读取中' : linkIssueCount > 0 ? `${linkIssueCount} 条异常` : '链路正常'}} />{data?.linkHealth.length ? data.linkHealth.map((item) =>
{item.name}{item.detail || '无补充信息'}
) :
}
+
0 ? 'warning' : 'ok'}>{!data ? '读取中' : runtimeIssueCount + capacityIssueCount > 0 ? `${runtimeIssueCount + capacityIssueCount} 项关注` : '配置正常'}} />{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'} },
{ key: 'MySQL 写探针', value: {data?.mysqlWritable ? '正常' : '异常'} },
{ key: 'TDengine 写探针', value: {data?.tdengineWritable ? '正常' : '异常'} },
{ key: '请求超时', value: `${data?.runtime.requestTimeoutMs ?? '—'} ms` },
{ key: '高德安全代理', value: {data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'} }
]} />{data?.capacityFindings?.length ? {data.capacityFindings.map((item) =>
待处理{item}
)}
: } title="容量检查通过" description="当前没有需要处理的容量风险。" />}
-
{sources ? sources.sources.length ? {sources.sources.map((source) => {source.protocol}{source.role}} headerExtraContent={{`${source.online} / ${source.total} 在线`}} headerLine>
+ {sources ? `${sources.sources.length} 个协议` : '正在读取'}} />{sources ? sources.sources.length ? {sources.sources.map((source) => {
+ const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate);
+ return {source.protocol}{source.role}} headerExtraContent={{source.status || statusLabel(source.severity)}} headerLine>
+ 在线覆盖{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}{number(onlineRate)}% 在线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}
- )} : :
}
+ ;
+ })} : :
}
> : null}
;
diff --git a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
index 7fdef85d..bbcd9c35 100644
--- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
+++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
@@ -244,9 +244,11 @@ describe('V2 production entry', () => {
expect(corePageSources.OperationsPage).toContain(' .semi-card-body > .v2-workspace-panel-header {
+ min-height: 64px;
+ border-bottom: 1px solid #e7edf4;
+ padding: 11px 16px;
+}
+
+.v2-ops-overview-meta {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.v2-ops-overview-meta > .semi-typography {
+ font-size: 11px;
+ white-space: nowrap;
+}
+
+.v2-ops-metric-rail {
+ display: grid;
+ grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr));
+ background: #fff;
+}
+
+.v2-ops-metric {
+ position: relative;
+ min-width: 0;
+ min-height: 112px;
+ padding: 17px 18px 15px;
+}
+
+.v2-ops-metric + .v2-ops-metric {
+ border-left: 1px solid #e7edf4;
+}
+
+.v2-ops-metric::after {
+ position: absolute;
+ right: 18px;
+ bottom: 0;
+ left: 18px;
+ height: 3px;
+ border-radius: 999px 999px 0 0;
+ background: #dce5ef;
+ content: "";
+}
+
+.v2-ops-metric.is-success::after { background: var(--v2-green); }
+.v2-ops-metric.is-warning::after { background: var(--v2-orange); }
+.v2-ops-metric.is-danger::after { background: var(--v2-red); }
+
+.v2-ops-metric small {
+ display: block;
+ overflow: hidden;
+ color: #6b7c92;
+ font-size: 11px;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-ops-metric strong {
+ display: block;
+ margin: 10px 0 8px;
+ overflow: hidden;
+ color: #20364f;
+ font-size: 24px;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -.025em;
+ line-height: 1;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-ops-metric.is-success strong { color: #16835d; }
+.v2-ops-metric.is-warning strong { color: #a66500; }
+.v2-ops-metric.is-danger strong { color: #cf3f3b; }
+
+.v2-ops-metric span {
+ display: block;
+ overflow: hidden;
+ color: #8390a2;
+ font-size: 10px;
+ line-height: 1.45;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-ops-panel > .semi-card-body > .v2-workspace-panel-header {
+ min-height: 64px;
+ border-bottom: 1px solid #e7edf4;
+ padding: 11px 15px;
+}
+
+.v2-ops-link-card {
+ display: grid;
+ min-height: 62px;
+ grid-template-columns: minmax(140px, .7fr) minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 12px;
+ border-bottom: 1px solid #e8edf3;
+ background: #fff;
+ padding: 10px 15px;
+}
+
+.v2-ops-link-card:last-child { border-bottom: 0; }
+
+.v2-ops-source-list.semi-card-group {
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+}
+
+.v2-ops-source-card.semi-card {
+ box-shadow: inset 0 3px #d6e0ec, 0 5px 16px rgba(31, 53, 80, .045);
+}
+
+.v2-ops-source-card.is-ok { box-shadow: inset 0 3px var(--v2-green), 0 5px 16px rgba(31, 53, 80, .045); }
+.v2-ops-source-card.is-warning { box-shadow: inset 0 3px var(--v2-orange), 0 5px 16px rgba(31, 53, 80, .045); }
+.v2-ops-source-card.is-error { box-shadow: inset 0 3px var(--v2-red), 0 5px 16px rgba(31, 53, 80, .045); }
+
+.v2-ops-source-coverage {
+ display: grid;
+ gap: 7px;
+ border-bottom: 1px solid #e9eef4;
+ padding: 13px;
+ background: #fff;
+}
+
+.v2-ops-source-coverage > span {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.v2-ops-source-coverage strong {
+ color: #41556d;
+ font-size: 11px;
+}
+
+.v2-ops-source-coverage b {
+ color: #263d57;
+ font-size: 17px;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -.02em;
+}
+
+.v2-ops-source-coverage .semi-progress {
+ width: 100%;
+ margin: 0;
+}
+
+.v2-ops-source-coverage small {
+ color: #748398;
+ font-size: 10px;
+}
+
+@media (max-width: 900px) {
+ .v2-ops-metric-rail {
+ display: flex;
+ overflow-x: auto;
+ scroll-snap-type: x proximity;
+ scrollbar-width: thin;
+ }
+
+ .v2-ops-metric {
+ min-width: 190px;
+ flex: 0 0 190px;
+ scroll-snap-align: start;
+ }
+
+ .v2-ops-metric:first-child {
+ min-width: 218px;
+ flex-basis: 218px;
+ }
+}
+
+@media (max-width: 680px) {
+ .v2-ops-overview > .semi-card-body > .v2-workspace-panel-header {
+ min-height: 58px;
+ padding: 9px 12px;
+ }
+
+ .v2-ops-overview-meta > .semi-typography { display: none; }
+
+ .v2-ops-metric {
+ min-width: 156px;
+ min-height: 96px;
+ flex-basis: 156px;
+ padding: 14px 13px 12px;
+ }
+
+ .v2-ops-metric:first-child {
+ min-width: 184px;
+ flex-basis: 184px;
+ }
+
+ .v2-ops-metric strong {
+ margin: 8px 0 7px;
+ font-size: 21px;
+ }
+
+ .v2-ops-metric span { font-size: 10px; }
+
+ .v2-ops-link-card {
+ min-height: 72px;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 5px 10px;
+ padding: 10px 12px;
+ }
+
+ .v2-ops-link-card p {
+ grid-column: 1 / -1;
+ white-space: normal;
+ }
+
+ .v2-ops-source-list.semi-card-group {
+ display: flex;
+ grid-template-columns: none;
+ }
+
+ .v2-ops-source-coverage b { font-size: 16px; }
+}
+
/* Unified Semi UI state language: page, panel and inline recovery surfaces. */
.v2-state-surface {
box-sizing: border-box;