diff --git a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx
index 0ca2b029..e48d9399 100644
--- a/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx
+++ b/vehicle-data-platform/apps/web/src/pages/VehicleDetail.tsx
@@ -1,5 +1,5 @@
import { Banner, Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toast, Typography } from '@douyinfe/semi-ui';
-import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
+import { IconCopy, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
import { useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api/client';
import type { QualityIssueRow, VehicleDetail as VehicleDetailData, VehicleSourceStatus } from '../api/types';
@@ -46,6 +46,15 @@ function formatSeconds(value?: number) {
return `${Math.round(value)} 秒`;
}
+async function copyVehicleServiceURL() {
+ try {
+ await navigator.clipboard.writeText(`${window.location.origin}${window.location.pathname}${window.location.hash}`);
+ Toast.success('已复制服务链接');
+ } catch {
+ Toast.error('复制服务链接失败');
+ }
+}
+
export function VehicleDetail({
vin,
protocol,
@@ -187,6 +196,7 @@ export function VehicleDetail({
} htmlType="submit" theme="solid" type="primary">查询车辆
} onClick={() => load(query)} loading={loading}>刷新
+ } onClick={copyVehicleServiceURL}>复制服务链接
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 4028e068..383e0d91 100644
--- a/vehicle-data-platform/apps/web/src/test/App.test.tsx
+++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx
@@ -1940,6 +1940,55 @@ test('shows vehicle service evidence chain before source details', async () => {
expect(screen.getAllByText('MQTT 离线,32960 与 808 仍可支撑车辆服务').length).toBeGreaterThan(0);
});
+test('copies shareable vehicle service detail link', async () => {
+ window.history.replaceState(null, '', '/#/detail?keyword=VIN001&protocol=JT808');
+ const writeText = vi.fn(() => Promise.resolve());
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText }
+ });
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
+ const path = String(input);
+ if (path.includes('/api/vehicle-service')) {
+ return {
+ ok: true,
+ json: async () => ({
+ data: {
+ vin: 'VIN001',
+ lookupKey: 'VIN001',
+ lookupResolved: true,
+ sources: ['GB32960', 'JT808'],
+ sourceStatus: [],
+ realtime: [],
+ history: { items: [], total: 0, limit: 20, offset: 0 },
+ raw: { items: [], total: 0, limit: 10, offset: 0 },
+ mileage: { items: [], total: 0, limit: 20, offset: 0 },
+ quality: { items: [], total: 0, limit: 20, offset: 0 }
+ },
+ traceId: 'trace-test',
+ timestamp: 1783094400000
+ })
+ } as Response;
+ }
+ return {
+ ok: true,
+ json: async () => ({
+ data: path.includes('/api/ops/health')
+ ? { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } }
+ : { items: [], total: 0, limit: 10, offset: 0 },
+ traceId: 'trace-test',
+ timestamp: 1783094400000
+ })
+ } as Response;
+ });
+
+ render();
+
+ fireEvent.click(await screen.findByRole('button', { name: /复制服务链接/ }));
+
+ expect(writeText).toHaveBeenCalledWith(expect.stringContaining('/#/detail?keyword=VIN001&protocol=JT808'));
+});
+
test('opens history with the currently selected vehicle detail source', async () => {
window.history.replaceState(null, '', '/#/detail?keyword=VIN001');
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {