diff --git a/vehicle-data-platform/apps/web/src/styles/global.css b/vehicle-data-platform/apps/web/src/styles/global.css
index 49647d59..ae28d406 100644
--- a/vehicle-data-platform/apps/web/src/styles/global.css
+++ b/vehicle-data-platform/apps/web/src/styles/global.css
@@ -1548,6 +1548,69 @@ button.vp-realtime-command-item:focus-visible {
gap: 16px;
}
+.vp-source-fusion-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.vp-source-fusion-item {
+ min-height: 250px;
+ padding: 14px;
+ border: 1px solid var(--vp-border);
+ border-radius: var(--vp-radius);
+ background: #fbfcff;
+ display: grid;
+ gap: 12px;
+ align-content: space-between;
+}
+
+.vp-source-fusion-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.vp-source-fusion-contribution {
+ min-height: 52px;
+ display: flex;
+ flex-wrap: wrap;
+ align-content: flex-start;
+ gap: 6px;
+}
+
+.vp-source-fusion-facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.vp-source-fusion-fact {
+ min-height: 64px;
+ padding: 9px 10px;
+ border: 1px solid var(--vp-border);
+ border-radius: 6px;
+ background: #fff;
+ display: grid;
+ gap: 5px;
+ align-content: start;
+}
+
+.vp-source-fusion-fact span {
+ color: var(--vp-text-muted);
+ font-size: 12px;
+ line-height: 18px;
+}
+
+.vp-source-fusion-fact strong {
+ color: var(--vp-text);
+ font-size: 13px;
+ line-height: 19px;
+ font-weight: 600;
+ word-break: break-word;
+}
+
.vp-vehicle-detail-map {
height: 300px;
}
@@ -1647,6 +1710,8 @@ button.vp-realtime-command-item:focus-visible {
.vp-bi-publish-board,
.vp-bi-publish-grid,
.vp-trajectory-anomaly-grid,
+ .vp-source-fusion-grid,
+ .vp-source-fusion-facts,
.vp-vehicle-map-layout,
.vp-playback-layout,
.vp-playback-timeline {
diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx
index 7b2ebe6b..9a618050 100644
--- a/vehicle-data-platform/apps/web/src/test/App.test.tsx
+++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx
@@ -10100,6 +10100,11 @@ test('filters vehicle list by service status', async () => {
test('switches vehicle detail to a source from the source matrix', async () => {
window.history.replaceState(null, '', '/#/detail?keyword=VIN001');
+ const writeText = vi.fn(() => Promise.resolve());
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText }
+ });
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
const path = String(input);
if (path.includes('/api/vehicle-service')) {
@@ -10194,10 +10199,20 @@ test('switches vehicle detail to a source from the source matrix', async () => {
expect(screen.getAllByText('GB32960 在线').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('JT808 在线').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('YUTONG_MQTT 未接入').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('多源归并矩阵')).toBeInTheDocument();
+ expect(screen.getByText('主实时来源')).toBeInTheDocument();
+ expect(screen.getAllByText('定位证据').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('统计口径').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('无实时字段')).toBeInTheDocument();
+ expect(screen.getByText('无 RAW 证据')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: '复制归并矩阵' }));
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【单车多源归并矩阵】'));
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('1. GB32960 / 主来源'));
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('贡献:主实时来源、在线心跳、实时字段、定位证据、轨迹回放、RAW证据、统计口径'));
expect(screen.getByText('车辆服务角色')).toBeInTheDocument();
- expect(screen.getByText('主来源')).toBeInTheDocument();
- expect(screen.getByText('在线证据')).toBeInTheDocument();
- expect(screen.getByText('暂无来源')).toBeInTheDocument();
+ expect(screen.getAllByText('主来源').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('在线证据').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('暂无来源').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('最新位置')).toBeInTheDocument();
expect(screen.getAllByText('113.2, 23.1').length).toBeGreaterThan(0);
expect(screen.getAllByText(/100\s*km/).length).toBeGreaterThan(0);