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 c7264afa..7c64146d 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
@@ -312,6 +312,52 @@ test('includes protocol readiness in the global health verdict and prioritizes i
expect(Boolean(sourcePanel!.compareDocumentPosition(infrastructureGrid!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true);
});
+test('keeps every protocol scannable on mobile and expands evidence on demand', async () => {
+ layout.mobile = true;
+ seedSession();
+ mocks.opsHealth.mockResolvedValue({
+ linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
+ tdengineWritable: true, mysqlWritable: true,
+ runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
+ });
+ mocks.sourceReadiness.mockResolvedValue({
+ totalVehicles: 1024, boundVehicles: 1024, identityRequiredVehicles: 0, onlineVehicles: 234,
+ sources: [
+ {
+ protocol: 'YUTONG_MQTT', role: '宇通补充来源', total: 50, online: 12, missingVehicles: 38, severity: 'error',
+ status: '链路异常', evidence: '投影停滞', action: '恢复投影与回补', acceptance: '日里程恢复'
+ },
+ {
+ protocol: 'GB32960', role: '仪表盘里程与整车状态', total: 494, online: 123, missingVehicles: 371, severity: 'warning',
+ status: '覆盖不足', evidence: '在线覆盖偏低', action: '核对接入范围', acceptance: '缺失车辆下降'
+ },
+ {
+ protocol: 'JT808', role: 'GPS 里程与位置', total: 480, online: 99, missingVehicles: 381, severity: 'ok',
+ status: '生产可用', evidence: '实时来源稳定', action: '保持巡检', acceptance: '消费积压持续为零'
+ }
+ ]
+ });
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ renderOperations(client, ['/operations?view=health']);
+
+ expect(await screen.findByText('先看覆盖与状态,点击协议展开证据和处置口径')).toBeInTheDocument();
+ const yutong = await screen.findByRole('button', { name: /YUTONG_MQTT/ });
+ expect(document.querySelector('.v2-ops-source-collapse.semi-collapse')).toBeInTheDocument();
+ expect(document.querySelector('.v2-ops-source-list.semi-card-group')).not.toBeInTheDocument();
+ for (const protocol of ['YUTONG_MQTT', 'GB32960', 'JT808']) {
+ expect(screen.getByRole('button', { name: new RegExp(protocol) })).toHaveAttribute('aria-expanded', 'false');
+ }
+ expect(screen.queryByText('投影停滞')).not.toBeInTheDocument();
+ fireEvent.click(yutong);
+ expect(yutong).toHaveAttribute('aria-expanded', 'true');
+ expect(await screen.findByText('投影停滞')).toBeInTheDocument();
+ expect(screen.getByText('恢复投影与回补')).toBeInTheDocument();
+ expect(screen.getByText('日里程恢复')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: /GB32960/ }));
+ expect(yutong).toHaveAttribute('aria-expanded', 'false');
+ expect(screen.getByRole('button', { name: /GB32960/ })).toHaveAttribute('aria-expanded', 'true');
+});
+
test('keeps health evidence visible when source readiness fails and retries that section', async () => {
seedSession();
mocks.opsHealth.mockResolvedValue({
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 cb1196ef..b0668cb2 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 { IconAlertTriangle, IconPulse, IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
-import { Button, Card, CardGroup, Checkbox, Descriptions, Input, Progress, Table, Tag, Typography } from '@douyinfe/semi-ui';
+import { Button, Card, CardGroup, Checkbox, Collapse, Descriptions, Input, Progress, Table, Tag, Typography } from '@douyinfe/semi-ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
@@ -58,6 +58,49 @@ function sourceOnlineRate(online: number, total: number, onlineRate?: number) {
return total > 0 ? Math.max(0, Math.min(100, (online / total) * 100)) : 0;
}
+function SourceReadinessDetails({ source, showCoverage = true }: { source: SourceReadinessRow; showCoverage?: boolean }) {
+ const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate);
+ return <>
+ {showCoverage ?
在线覆盖{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}{number(onlineRate)}% 在线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''} : null}
+
+
+
+ >;
+}
+
+function SourceReadinessCard({ source }: { source: SourceReadinessRow }) {
+ return {source.protocol}{source.role}}
+ headerExtraContent={{source.status || statusLabel(source.severity)}}
+ headerLine
+ >
+
+ ;
+}
+
+function SourceReadinessCollapseHeader({ source }: { source: SourceReadinessRow }) {
+ const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate);
+ return
+ {source.protocol}{source.role}
+ {source.status || statusLabel(source.severity)}
+ {source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}{number(onlineRate)}% 在线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}
+ ;
+}
+
+function SourceReadinessCollapse({ sources }: { sources: SourceReadinessRow[] }) {
+ return
+ {sources.map((source) => }
+ >
+
+ )}
+ ;
+}
+
function SourcePolicyEditor({ vin, source, diagnostic, editable, onSaved }: {
vin: string;
source: VehicleLocationSourceEvidence;
@@ -433,6 +476,7 @@ function SourceDiagnosticWorkspace() {
}
export default function OperationsPage() {
+ const mobileLayout = useMobileLayout();
const [searchParams, setSearchParams] = useSearchParams();
const requestedWorkspace = searchParams.get('view') as OperationsWorkspace | null;
const workspace = requestedWorkspace && operationsWorkspaces.has(requestedWorkspace) ? requestedWorkspace : 'reconciliation';
@@ -588,13 +632,10 @@ export default function OperationsPage() {
}
/>
- 0 ? 'warning' : sourceRows.length ? 'ok' : 'unknown'}>{readinessUnavailable ? '证据不可用' : sources ? sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需关注` : sourceRows.length ? `${sourceRows.length} 个协议正常` : '暂无协议来源' : '正在读取'}} />{sources ? sortedSources.length ? {sortedSources.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')} 辆待恢复` : ''}
-
- ;
- })} : } title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : readinessUnavailable ? 0 ? 'warning' : sourceRows.length ? 'ok' : 'unknown'}>{readinessUnavailable ? '证据不可用' : sources ? sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需关注` : sourceRows.length ? `${sourceRows.length} 个协议正常` : '暂无协议来源' : '正在读取'}} />{sources ? sortedSources.length ? mobileLayout
+ ?
+ : {sortedSources.map((source) => )}
+ : } title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : readinessUnavailable ? }
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 0b6c7947..53362bb0 100644
--- a/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
+++ b/vehicle-data-platform/apps/web/src/v2/productionEntry.test.ts
@@ -396,7 +396,8 @@ describe('V2 production entry', () => {
expect(corePageSources.OperationsPage).toContain('生产数据模式');
diff --git a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
index f97dbd8e..9f050ce4 100644
--- a/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
+++ b/vehicle-data-platform/apps/web/src/v2/styles/workspace.css
@@ -26385,7 +26385,159 @@
min-width: 0;
}
+.v2-ops-sources > .semi-card-body > .v2-ops-source-collapse.semi-collapse {
+ display: grid;
+ border: 0;
+ background: #f7f9fc;
+ padding: 9px;
+}
+
+.v2-ops-source-collapse > .semi-collapse-item {
+ overflow: hidden;
+ border: 1px solid #dfe6ef;
+ border-radius: 10px;
+ background: #fff;
+ box-shadow: 0 3px 12px rgba(31, 53, 80, .035);
+}
+
+.v2-ops-source-collapse > .semi-collapse-item + .semi-collapse-item {
+ margin-top: 7px;
+}
+
+.v2-ops-source-collapse > .semi-collapse-item.is-warning {
+ border-color: #ecd7ad;
+}
+
+.v2-ops-source-collapse > .semi-collapse-item.is-error {
+ border-color: #efc9c7;
+}
+
+.v2-ops-source-collapse > .semi-collapse-item.is-ok {
+ border-color: #cce8dd;
+}
+
+.v2-ops-source-collapse > .semi-collapse-item > .semi-collapse-header {
+ min-height: 72px;
+ gap: 8px;
+ padding: 9px 11px;
+}
+
+.v2-ops-source-collapse > .semi-collapse-item > .semi-collapse-header:focus-visible {
+ outline: 2px solid rgba(18, 104, 243, .32);
+ outline-offset: -2px;
+}
+
+.v2-ops-source-collapse .semi-collapse-header-icon {
+ width: 28px;
+ height: 28px;
+ flex: 0 0 28px;
+ border-radius: 8px;
+ background: #f1f5fa;
+ color: #5f7289;
+}
+
+.v2-ops-source-collapse-header {
+ display: grid;
+ min-width: 0;
+ flex: 1;
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 6px 8px;
+}
+
+.v2-ops-source-collapse-identity {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: 7px;
+}
+
+.v2-ops-source-collapse-identity > .semi-tag {
+ min-height: 22px;
+ flex: 0 0 auto;
+ border-radius: 6px;
+ font-size: 9px;
+}
+
+.v2-ops-source-collapse-identity > small {
+ overflow: hidden;
+ color: #6f8094;
+ font-size: 9px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-ops-source-collapse-header > .v2-ops-health-tag.semi-tag {
+ min-height: 22px;
+ border-radius: 999px;
+ font-size: 8px;
+}
+
+.v2-ops-source-collapse-coverage {
+ display: flex;
+ min-width: 0;
+ grid-column: 1 / -1;
+ align-items: baseline;
+ gap: 8px;
+}
+
+.v2-ops-source-collapse-coverage > strong {
+ color: #283f59;
+ font-size: 14px;
+ font-variant-numeric: tabular-nums;
+}
+
+.v2-ops-source-collapse-coverage > small {
+ overflow: hidden;
+ color: #79889b;
+ font-size: 9px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.v2-ops-source-collapse .semi-collapse-content-wrapper {
+ padding: 0;
+}
+
+.v2-ops-source-collapse-detail {
+ display: grid;
+ border-top: 1px solid #e7edf4;
+}
+
+.v2-ops-source-collapse-detail > :is(.v2-ops-source-evidence, .v2-ops-source-action) {
+ min-height: 0;
+ padding: 11px 12px;
+}
+
+.v2-ops-source-collapse-detail > footer {
+ display: grid;
+ min-height: 0;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: flex-start;
+ gap: 8px;
+ border-top: 1px solid #e9eef4;
+ background: #f5faf8;
+ padding: 11px 12px;
+}
+
+.v2-ops-source-collapse-detail > footer > .semi-tag {
+ min-height: 22px;
+ border-radius: 999px;
+ font-size: 8px;
+}
+
+.v2-ops-source-collapse-detail > footer > span {
+ color: #557567;
+ font-size: 10px;
+ line-height: 1.55;
+}
+
@media (max-width: 680px) {
+ .v2-ops-sources > .semi-card-body > .v2-workspace-panel-header {
+ min-height: 56px;
+ padding: 8px 11px;
+ }
+
.v2-ops-health-metric-rail.is-queue.has-context > .semi-card-body {
min-height: 0;
grid-template-columns: minmax(0, 1fr);