feat: expand vehicle data platform capabilities
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f5f5f7" />
|
||||
<title>车辆数据中台 · 多页面设计实验室</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="platform-design-lab-root"></div>
|
||||
<script type="module" src="/src/design-lab/platform-main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg viewBox="5 4 140 28" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="brand-title">
|
||||
<title id="brand-title">灵牛智能</title>
|
||||
<title id="brand-title">羚牛智能</title>
|
||||
<path d="M18.0459 4.69253L24.4084 4.6875L17.3427 16.9334C16.0108 19.2369 14.67 21.5335 13.357 23.8492C12.8279 24.6836 12.7967 25.5367 14.0379 25.5663C17.6611 25.6526 21.3268 25.5356 24.9495 25.6042C24.4224 26.7246 23.2874 28.5534 22.6381 29.6706L21.6984 31.3061L13.8032 31.3074C11.2823 31.3084 7.42295 31.774 6.15339 28.9623C5.10923 26.6498 6.73472 24.2701 7.86608 22.3109L10.1604 18.3408L18.0459 4.69253Z" fill="#2F2828"/>
|
||||
<path d="M30.7029 4.69293L38.1664 4.6931C40.8704 4.68656 45.5224 4.1044 46.4284 7.56236C47.0408 9.9001 45.3543 12.286 44.21 14.218C42.2951 14.125 39.809 14.1965 37.8457 14.1908C38.3321 13.3838 40.2747 10.8182 38.8199 10.5324C37.4531 10.2639 35.1947 10.4548 33.7653 10.4209C32.0072 13.3847 30.3349 16.4036 28.5844 19.3723C28.4964 19.5215 28.3602 19.7716 28.257 19.9038L21.9204 19.9023L30.7029 4.69293Z" fill="#2F2828"/>
|
||||
<path d="M89.1232 4.67032C89.7835 4.62775 90.9099 4.66078 91.6053 4.65867L90.978 7.75816L98.7808 7.75999C98.6607 8.46499 98.5297 9.16816 98.3877 9.86921L90.5769 9.86774C90.1934 11.0614 89.853 13.7315 89.4731 15.1689L97.9522 15.1851C97.8321 15.8946 97.6872 16.5697 97.5364 17.2721C94.9175 17.1811 91.7297 17.2538 89.0954 17.2677C88.594 19.3603 88.1541 21.9308 87.7303 24.0635C86.9061 24.0335 86.0812 24.0264 85.2563 24.0421C85.7525 22.0904 86.18 19.3243 86.6177 17.2638L85.2372 17.257L77.0092 17.2648C77.1446 16.5758 77.2727 15.8857 77.3957 15.1944C79.8624 15.1112 82.3687 15.2077 84.839 15.1714C85.5417 15.161 86.2942 15.1588 86.9939 15.2057C87.447 13.5537 87.6608 11.5742 88.1087 9.87872C86.2986 9.82772 84.3142 9.86591 82.4909 9.86569C81.7604 11.226 81.0028 12.4816 80.2657 13.8163L77.5362 13.8114C79.6816 10.4975 80.6808 8.44223 82.2311 4.86287C83.0545 4.83579 84.0075 4.85801 84.8405 4.85771C84.4152 5.82928 83.998 6.81499 83.512 7.75669C85.1516 7.77967 86.8351 7.76247 88.4769 7.76452C88.7163 6.8407 88.9417 5.61995 89.1232 4.67032Z" fill="#2F2828"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -40,6 +40,93 @@ test('history export downloads use bearer authentication and retain the server f
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer customer-export-token');
|
||||
});
|
||||
|
||||
test('reconciliation directory and current-filter export use dedicated scoped contracts', async () => {
|
||||
const payload = new Blob(['quality'], { type: 'text/csv' });
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ data: [] }) } as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
headers: new Headers({ 'X-Export-Name': encodeURIComponent('质量差异_20260723.csv') }),
|
||||
blob: async () => payload
|
||||
} as Response);
|
||||
const controller = new AbortController();
|
||||
|
||||
await api.reconciliationAssignees('定位 运维', controller.signal);
|
||||
const exported = await api.downloadReconciliationIssues({ status: 'active', owner: '定位运维组' }, controller.signal);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/v2/reconciliation/assignees?search=%E5%AE%9A%E4%BD%8D+%E8%BF%90%E7%BB%B4', boundedRequest());
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v2/reconciliation/issues/export', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: 'active', owner: '定位运维组' }),
|
||||
signal: expect.any(AbortSignal)
|
||||
}));
|
||||
expect(exported.filename).toBe('质量差异_20260723.csv');
|
||||
expect(exported.blob.size).toBe(7);
|
||||
});
|
||||
|
||||
test('history export lifecycle actions use explicit scoped endpoints', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { succeeded: [], skipped: [] } }) } as Response);
|
||||
|
||||
await api.rebuildHistoryExport('expired task');
|
||||
await api.archiveHistoryExport('completed task');
|
||||
await api.restoreHistoryExport('completed task');
|
||||
await api.batchHistoryExports({ ids: ['completed task', 'failed-task'], action: 'archive' });
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/v2/exports/expired%20task/rebuild', boundedRequest({ method: 'POST' }));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v2/exports/completed%20task/archive', boundedRequest({ method: 'POST' }));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/v2/exports/completed%20task/restore', boundedRequest({ method: 'POST' }));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(4, '/api/v2/exports/batch', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: ['completed task', 'failed-task'], action: 'archive' })
|
||||
}));
|
||||
});
|
||||
|
||||
test('history cleanup automation uses versioned policy and approval endpoints', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: { policy: {}, runs: [] } }) } as Response);
|
||||
|
||||
await api.historyExportCleanupAutomation();
|
||||
await api.updateHistoryExportCleanupAutomation({ enabled: true, olderThanDays: 180, intervalDays: 7, approvalWindowHours: 48, expectedRevision: 3 });
|
||||
await api.approveHistoryExportCleanupAutomation('cleanup run', { expectedRevision: 4, reason: '已核对影响范围' });
|
||||
await api.rejectHistoryExportCleanupAutomation('cleanup run', { expectedRevision: 5, reason: '需要延后复核' });
|
||||
await api.retryHistoryExportCleanupAutomation('cleanup run', { expectedRevision: 6, reason: '故障已修复,恢复执行' });
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/v2/exports/cleanup/automation', boundedRequest());
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v2/exports/cleanup/automation', boundedRequest({
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, olderThanDays: 180, intervalDays: 7, approvalWindowHours: 48, expectedRevision: 3 })
|
||||
}));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/v2/exports/cleanup/automation/cleanup%20run/approve', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedRevision: 4, reason: '已核对影响范围' })
|
||||
}));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(4, '/api/v2/exports/cleanup/automation/cleanup%20run/reject', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedRevision: 5, reason: '需要延后复核' })
|
||||
}));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(5, '/api/v2/exports/cleanup/automation/cleanup%20run/retry', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ expectedRevision: 6, reason: '故障已修复,恢复执行' })
|
||||
}));
|
||||
});
|
||||
|
||||
test('history export pagination and account preferences use durable v2 contracts', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response);
|
||||
const controller = new AbortController();
|
||||
const params = new URLSearchParams({ search: 'VIN 001', status: 'failed', limit: '20', offset: '20' });
|
||||
|
||||
await api.historyExportPage(params, controller.signal);
|
||||
await api.historyPreferences(controller.signal);
|
||||
await api.updateHistoryPreferences({
|
||||
retentionDays: 30,
|
||||
fieldViews: [{ id: 'location:交付核验', name: '交付核验', category: 'location', keys: ['speedKmh'] }]
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/v2/exports/page?search=VIN+001&status=failed&limit=20&offset=20', boundedRequest());
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v2/history/preferences', boundedRequest());
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/v2/history/preferences', boundedRequest({
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
|
||||
retentionDays: 30,
|
||||
fieldViews: [{ id: 'location:交付核验', name: '交付核验', category: 'location', keys: ['speedKmh'] }]
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
test('a protected 401 terminates the client session but login validation errors stay local', async () => {
|
||||
window.sessionStorage.setItem('vehicle-platform.access-token', 'expired-token');
|
||||
const unauthorized = vi.fn();
|
||||
@@ -124,6 +211,11 @@ test('access APIs post one shared filter contract and version threshold updates'
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(unresolved)
|
||||
}));
|
||||
|
||||
await api.claimAccessIdentity('identity 1', { vin: 'LNXNEGRR7SR318212', note: '来源证据核对通过' });
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/unresolved-identities/identity%201/claim', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vin: 'LNXNEGRR7SR318212', note: '来源证据核对通过' })
|
||||
}));
|
||||
|
||||
await api.updateAccessThresholds({ version: 3, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, protocols: [{ protocol: 'JT808', thresholdSec: 60 }] });
|
||||
expect(fetchMock).toHaveBeenLastCalledWith('/api/v2/access/thresholds', boundedRequest({
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 3, defaultThresholdSec: 300, delayThresholdSec: 30, longOfflineSec: 1800, protocols: [{ protocol: 'JT808', thresholdSec: 60 }] })
|
||||
@@ -147,13 +239,14 @@ test('latest telemetry uses the encoded vehicle identity path', async () => {
|
||||
|
||||
test('vehicle profile sync posts an explicit dry-run and conflict policy contract', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {}, traceId: 'trace-profile-sync', timestamp: 1 }) } as Response);
|
||||
const controller = new AbortController();
|
||||
const input = {
|
||||
sourceSystem: 'oem-tsp', sourceVersion: 'snapshot-1', conflictPolicy: 'preserve' as const, dryRun: true,
|
||||
items: [{ vin: 'VIN001', modelName: '车型一', vehicleType: '重卡', companyName: '示范物流', operationStatus: 'active' as const, accessProvider: '车厂平台', firstAccessAt: '', runtimeSeconds: null }]
|
||||
};
|
||||
await api.syncVehicleProfiles(input);
|
||||
await api.syncVehicleProfiles(input, controller.signal);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/v2/vehicle-profiles/sync', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), signal: controller.signal
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -188,10 +281,17 @@ test('high-volume history, track, and mileage requests forward AbortSignal', asy
|
||||
await api.trackPlayback(params, controller.signal);
|
||||
await api.historyData(params, controller.signal);
|
||||
await api.historySeries(params, controller.signal);
|
||||
await api.dailyMileage(params, controller.signal);
|
||||
await api.mileageStatistics(params, controller.signal);
|
||||
const mileageQuery = { dateFrom: '2026-07-01', dateTo: '2026-07-31', vins: ['VIN001'], protocols: ['GB32960'] };
|
||||
await api.dailyMileage(mileageQuery, controller.signal);
|
||||
await api.mileageStatistics(mileageQuery, controller.signal);
|
||||
|
||||
for (const [, init] of fetchMock.mock.calls) expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(4, '/api/mileage/daily', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mileageQuery)
|
||||
}));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(5, '/api/v2/statistics/mileage', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(mileageQuery)
|
||||
}));
|
||||
});
|
||||
|
||||
test('route-scoped platform reads forward AbortSignal on navigation cleanup', async () => {
|
||||
@@ -211,6 +311,10 @@ test('route-scoped platform reads forward AbortSignal on navigation cleanup', as
|
||||
await api.alertEventsV2({ limit: 20, offset: 0 }, signal);
|
||||
await api.alertEventV2('event-1', signal);
|
||||
await api.alertRulesV2(signal);
|
||||
await api.alertRuleLibraryV2(new URLSearchParams({ lifecycle: 'current', limit: '10', offset: '0' }), signal);
|
||||
await api.alertRuleRevisionsV2('rule 1', signal);
|
||||
await api.alertNotificationConfigV2(signal);
|
||||
await api.alertNotificationDeliveryHealthV2(signal);
|
||||
await api.alertNotificationsV2(new URLSearchParams({ limit: '100' }), signal);
|
||||
await api.vehicleDetail(new URLSearchParams({ keyword: 'VIN001' }), signal);
|
||||
await api.latestTelemetry('VIN001', signal);
|
||||
@@ -218,10 +322,37 @@ test('route-scoped platform reads forward AbortSignal on navigation cleanup', as
|
||||
await api.opsHealth(signal);
|
||||
await api.sourceReadiness(signal);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(18);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(22);
|
||||
for (const [, init] of fetchMock.mock.calls) expect(init?.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
test('automation revision reads and rollbacks use encoded durable routes', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response);
|
||||
await api.alertRuleRevisionsV2('rule / 1');
|
||||
await api.rollbackAlertRuleV2('rule / 1', { targetVersion: 2, currentVersion: 7 });
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/v2/alerts/rules/rule%20%2F%201/revisions', boundedRequest());
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v2/alerts/rules/rule%20%2F%201/rollback', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ targetVersion: 2, currentVersion: 7 })
|
||||
}));
|
||||
});
|
||||
|
||||
test('automation governance uses server-side library and reversible lifecycle routes', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ data: {} }) } as Response);
|
||||
const query = new URLSearchParams({ lifecycle: 'archived', keyword: '旧规则', status: 'disabled', protocol: 'GB32960', limit: '20', offset: '20' });
|
||||
|
||||
await api.alertRuleLibraryV2(query);
|
||||
await api.archiveAlertRuleV2('rule / 1', { version: 7, reason: '旧规则已经由新版替代' });
|
||||
await api.restoreAlertRuleV2('rule / 1', { version: 8, reason: '恢复后重新复核车辆范围' });
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/v2/alerts/rules/library?lifecycle=archived&keyword=%E6%97%A7%E8%A7%84%E5%88%99&status=disabled&protocol=GB32960&limit=20&offset=20', boundedRequest());
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/v2/alerts/rules/rule%20%2F%201/archive', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 7, reason: '旧规则已经由新版替代' })
|
||||
}));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/v2/alerts/rules/rule%20%2F%201/restore', boundedRequest({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: 8, reason: '恢复后重新复核车辆范围' })
|
||||
}));
|
||||
});
|
||||
|
||||
test('turns a stalled route query into a bounded retryable timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
const controller = new AbortController();
|
||||
@@ -341,6 +472,25 @@ test('vehicleResolve sends keyword to the identity resolution endpoint', async (
|
||||
expect(result.vin).toBe('LB9A32A24R0LS1426');
|
||||
});
|
||||
|
||||
test('vehicleBusinessFilters reads permission-scoped filter options', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
departments: [{ value: '40001', label: '业务一部', count: 3 }],
|
||||
responsibleUsers: [], customers: [], statuses: []
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response);
|
||||
|
||||
const result = await api.vehicleBusinessFilters();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/vehicles/business-filters', boundedRequest());
|
||||
expect(result.departments[0]).toEqual({ value: '40001', label: '业务一部', count: 3 });
|
||||
});
|
||||
|
||||
test('vehicleServiceOverview sends keyword to the lightweight overview endpoint', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -5,15 +5,23 @@ import type {
|
||||
AccessSummary,
|
||||
AccessThresholdConfig,
|
||||
AccessThresholdUpdate,
|
||||
AccessIdentityClaimInput,
|
||||
AccessIdentityClaimResult,
|
||||
AccessUnresolvedIdentity,
|
||||
AccessUnresolvedIdentityQuery,
|
||||
AccessVehicleRow,
|
||||
AlertAction,
|
||||
AlertEvent,
|
||||
AlertNotification,
|
||||
AlertNotificationConfig,
|
||||
AlertNotificationDeliveryHealth,
|
||||
AlertNotificationRetryAudit,
|
||||
AlertNotificationRetryResult,
|
||||
AlertQuery,
|
||||
AlertRule,
|
||||
AlertRulePage,
|
||||
AlertRuleInput,
|
||||
AlertRuleRevision,
|
||||
AlertSummary,
|
||||
DailyMileageRow,
|
||||
DashboardSummary,
|
||||
@@ -21,7 +29,16 @@ import type {
|
||||
HistoryDataResponse,
|
||||
HistorySeriesResponse,
|
||||
HistoryExportJob,
|
||||
HistoryExportBatchAction, HistoryExportBatchResult,
|
||||
HistoryExportCleanupPreview,
|
||||
HistoryExportCleanupRecord,
|
||||
HistoryExportCleanupResult,
|
||||
HistoryExportCleanupAutomationState,
|
||||
HistoryExportCleanupAutomationPolicyInput,
|
||||
HistoryExportCleanupAutomationActionInput,
|
||||
HistoryExportPage,
|
||||
HistoryExportRequest,
|
||||
HistoryPreferences,
|
||||
HistoryMetricCatalog,
|
||||
MetricCatalog,
|
||||
LatestTelemetryResponse,
|
||||
@@ -39,18 +56,29 @@ import type {
|
||||
QualitySummary,
|
||||
QualityIssueRow,
|
||||
ReconciliationIssue,
|
||||
ReconciliationAssignee,
|
||||
ReconciliationAssignmentRequest,
|
||||
ReconciliationBatchAssignmentRequest,
|
||||
ReconciliationBatchActionRequest,
|
||||
ReconciliationBatchActionResult,
|
||||
ReconciliationQuery,
|
||||
ReconciliationLifecycleRequest,
|
||||
ReconciliationBatchLifecycleRequest,
|
||||
ReconciliationSummary,
|
||||
RawFrameRow,
|
||||
RealtimeLocationRow,
|
||||
SourceReadinessPlan,
|
||||
SessionInfo,
|
||||
LoginResponse,
|
||||
OneOSExchangeResponse,
|
||||
CustomerUserInput,
|
||||
CustomerUserBatchItem,
|
||||
CustomerUserBatchResult,
|
||||
TrackPlaybackResponse,
|
||||
VehicleRealtimeRow,
|
||||
VehicleCoverageRow,
|
||||
VehicleCoverageSummary,
|
||||
VehicleBusinessFilters,
|
||||
VehicleDetail,
|
||||
VehicleProfile,
|
||||
VehicleProfileInput,
|
||||
@@ -85,6 +113,19 @@ export type VehicleOverviewBatchQuery = {
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type MileageQuery = {
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
keyword?: string;
|
||||
vins?: string[];
|
||||
vehicleScope?: 'bound';
|
||||
protocol?: string;
|
||||
protocols?: string[];
|
||||
deduplicate?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
type ApiErrorEnvelope = {
|
||||
error?: {
|
||||
code?: string;
|
||||
@@ -146,16 +187,18 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestBlob(path: string, signal?: AbortSignal) {
|
||||
async function requestBlob(path: string, signal?: AbortSignal, requestInit?: RequestInit, fallbackFilename = 'history-export.csv') {
|
||||
const token = getAccessToken();
|
||||
const init: RequestInit = token ? { signal, headers: withAuthorization(undefined, token) } : { signal };
|
||||
const init: RequestInit = token
|
||||
? { ...requestInit, signal, headers: withAuthorization(requestInit?.headers, token) }
|
||||
: { ...requestInit, signal };
|
||||
const response = await fetch(path, init);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && token) notifyUnauthorizedSession(token);
|
||||
throw new Error(await responseErrorMessage(response));
|
||||
}
|
||||
const encodedFilename = response.headers.get('X-Export-Name')?.trim();
|
||||
let filename = 'history-export.csv';
|
||||
let filename = fallbackFilename;
|
||||
if (encodedFilename) {
|
||||
try {
|
||||
filename = decodeURIComponent(encodedFilename);
|
||||
@@ -221,6 +264,9 @@ export const api = {
|
||||
login: (credentials: { username: string; password: string }) => request<LoginResponse>('/api/v2/auth/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials)
|
||||
}),
|
||||
exchangeOneOSTicket: (ticket: string) => request<OneOSExchangeResponse>('/api/v2/auth/oneos/exchange', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket })
|
||||
}),
|
||||
logout: () => request<{ loggedOut: boolean }>('/api/v2/auth/logout', { method: 'POST' }),
|
||||
changePassword: (input: { currentPassword: string; newPassword: string }) => request<{ changed: boolean }>('/api/v2/auth/password', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
@@ -229,6 +275,9 @@ export const api = {
|
||||
createCustomerUser: (input: CustomerUserInput) => request<{ id: number }>('/api/v2/admin/users', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
batchCustomerUsers: (mode: 'preview' | 'create', items: CustomerUserBatchItem[]) => request<CustomerUserBatchResult>('/api/v2/admin/users/batch', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode, items })
|
||||
}),
|
||||
updateCustomerUser: (id: number, input: CustomerUserInput) => request<{ id: number }>(`/api/v2/admin/users/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
@@ -241,9 +290,42 @@ export const api = {
|
||||
historyData: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistoryDataResponse>(`/api/v2/history/query?${params.toString()}`, signal ? { signal } : undefined),
|
||||
historySeries: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistorySeriesResponse>(`/api/v2/history/series?${params.toString()}`, signal ? { signal } : undefined),
|
||||
historyExports: (signal?: AbortSignal) => request<HistoryExportJob[]>('/api/v2/exports', withSignal(undefined, signal)),
|
||||
historyExportPage: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistoryExportPage>(`/api/v2/exports/page?${params.toString()}`, withSignal(undefined, signal)),
|
||||
historyExportCleanupPreview: (params = new URLSearchParams(), signal?: AbortSignal) => request<HistoryExportCleanupPreview>(`/api/v2/exports/cleanup/preview?${params.toString()}`, withSignal(undefined, signal)),
|
||||
historyExportCleanupAudit: (signal?: AbortSignal) => request<HistoryExportCleanupRecord[]>('/api/v2/exports/cleanup/audit?limit=20', withSignal(undefined, signal)),
|
||||
cleanupHistoryExports: (input: { olderThanDays: number; ownerScope: 'all' | 'mine'; previewToken: string }) => request<HistoryExportCleanupResult>('/api/v2/exports/cleanup', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
historyExportCleanupAutomation: (signal?: AbortSignal) => request<HistoryExportCleanupAutomationState>('/api/v2/exports/cleanup/automation', withSignal(undefined, signal)),
|
||||
updateHistoryExportCleanupAutomation: (input: HistoryExportCleanupAutomationPolicyInput) => request<HistoryExportCleanupAutomationState>('/api/v2/exports/cleanup/automation', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
approveHistoryExportCleanupAutomation: (id: string, input: HistoryExportCleanupAutomationActionInput) => request<HistoryExportCleanupAutomationState>(`/api/v2/exports/cleanup/automation/${encodeURIComponent(id)}/approve`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
rejectHistoryExportCleanupAutomation: (id: string, input: HistoryExportCleanupAutomationActionInput) => request<HistoryExportCleanupAutomationState>(`/api/v2/exports/cleanup/automation/${encodeURIComponent(id)}/reject`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
retryHistoryExportCleanupAutomation: (id: string, input: HistoryExportCleanupAutomationActionInput) => request<HistoryExportCleanupAutomationState>(`/api/v2/exports/cleanup/automation/${encodeURIComponent(id)}/retry`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
updateHistoryExportCleanupProtection: (id: string, input: { protected: boolean; reason: string }) => request<HistoryExportJob>(`/api/v2/exports/${encodeURIComponent(id)}/cleanup-protection`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
historyPreferences: (signal?: AbortSignal) => request<HistoryPreferences>('/api/v2/history/preferences', withSignal(undefined, signal)),
|
||||
updateHistoryPreferences: (input: Pick<HistoryPreferences, 'retentionDays' | 'fieldViews'>) => request<HistoryPreferences>('/api/v2/history/preferences', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
createHistoryExport: (query: HistoryExportRequest) => request<HistoryExportJob>('/api/v2/exports', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}),
|
||||
cancelHistoryExport: (id: string) => request<HistoryExportJob>(`/api/v2/exports/${encodeURIComponent(id)}/cancel`, { method: 'POST' }),
|
||||
rebuildHistoryExport: (id: string) => request<HistoryExportJob>(`/api/v2/exports/${encodeURIComponent(id)}/rebuild`, { method: 'POST' }),
|
||||
archiveHistoryExport: (id: string) => request<HistoryExportJob>(`/api/v2/exports/${encodeURIComponent(id)}/archive`, { method: 'POST' }),
|
||||
restoreHistoryExport: (id: string) => request<HistoryExportJob>(`/api/v2/exports/${encodeURIComponent(id)}/restore`, { method: 'POST' }),
|
||||
batchHistoryExports: (input: { ids: string[]; action: HistoryExportBatchAction }) => request<HistoryExportBatchResult>('/api/v2/exports/batch', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
downloadHistoryExport: (id: string, signal?: AbortSignal) => requestBlob(`/api/v2/exports/${encodeURIComponent(id)}/download`, signal),
|
||||
accessSummary: (query: AccessQuery, signal?: AbortSignal) => request<AccessSummary>('/api/v2/access/summary', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
@@ -254,6 +336,9 @@ export const api = {
|
||||
accessUnresolvedIdentities: (query: AccessUnresolvedIdentityQuery, signal?: AbortSignal) => request<Page<AccessUnresolvedIdentity>>('/api/v2/access/unresolved-identities', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}, signal)),
|
||||
claimAccessIdentity: (id: string, input: AccessIdentityClaimInput) => request<AccessIdentityClaimResult>(`/api/v2/access/unresolved-identities/${encodeURIComponent(id)}/claim`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
accessThresholds: (signal?: AbortSignal) => request<AccessThresholdConfig>('/api/v2/access/thresholds', withSignal(undefined, signal)),
|
||||
updateAccessThresholds: (update: AccessThresholdUpdate) => request<AccessThresholdConfig>('/api/v2/access/thresholds', {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update)
|
||||
@@ -269,21 +354,39 @@ export const api = {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(action)
|
||||
}),
|
||||
alertRulesV2: (signal?: AbortSignal) => request<AlertRule[]>('/api/v2/alerts/rules', withSignal(undefined, signal)),
|
||||
alertRuleLibraryV2: (params = new URLSearchParams(), signal?: AbortSignal) => request<AlertRulePage>(`/api/v2/alerts/rules/library?${params.toString()}`, withSignal(undefined, signal)),
|
||||
alertRuleRevisionsV2: (id: string, signal?: AbortSignal) => request<AlertRuleRevision[]>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/revisions`, withSignal(undefined, signal)),
|
||||
saveAlertRuleV2: (input: AlertRuleInput) => request<AlertRule>(input.version > 0 ? `/api/v2/alerts/rules/${encodeURIComponent(input.id)}` : '/api/v2/alerts/rules', {
|
||||
method: input.version > 0 ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
setAlertRuleEnabledV2: (id: string, update: { version: number; enabled: boolean; actor?: string }) => request<AlertRule>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/enabled`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(update)
|
||||
}),
|
||||
rollbackAlertRuleV2: (id: string, input: { targetVersion: number; currentVersion: number; actor?: string }) => request<AlertRule>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/rollback`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
archiveAlertRuleV2: (id: string, input: { version: number; reason: string; actor?: string }) => request<AlertRule>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/archive`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
restoreAlertRuleV2: (id: string, input: { version: number; reason: string; actor?: string }) => request<AlertRule>(`/api/v2/alerts/rules/${encodeURIComponent(id)}/restore`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
alertNotificationConfigV2: (signal?: AbortSignal) => request<AlertNotificationConfig>('/api/v2/alerts/notification-config', withSignal(undefined, signal)),
|
||||
alertNotificationDeliveryHealthV2: (signal?: AbortSignal) => request<AlertNotificationDeliveryHealth>('/api/v2/alerts/notifications/health', withSignal(undefined, signal)),
|
||||
alertNotificationsV2: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<AlertNotification>>(`/api/v2/alerts/notifications?${params.toString()}`, withSignal(undefined, signal)),
|
||||
readAlertNotificationsV2: (ids: number[]) => request<{ updated: number }>('/api/v2/alerts/notifications/read', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids })
|
||||
}),
|
||||
retryAlertNotificationV2: (id: number, input: { expectedAttemptCount: number; reason: string; idempotencyKey: string }) => request<AlertNotificationRetryResult>(`/api/v2/alerts/notifications/${encodeURIComponent(id)}/retry`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
alertNotificationRetryAuditsV2: (id: number, signal?: AbortSignal) => request<AlertNotificationRetryAudit[]>(`/api/v2/alerts/notifications/${encodeURIComponent(id)}/retry-audit`, withSignal(undefined, signal)),
|
||||
dashboardSummary: () => request<DashboardSummary>('/api/dashboard/summary'),
|
||||
vehicles: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleRow>>(`/api/vehicles?${params.toString()}`, signal ? { signal } : undefined),
|
||||
vehicleResolve: (params = new URLSearchParams()) => request<VehicleIdentityResolution>(`/api/vehicles/resolve?${params.toString()}`),
|
||||
vehicleCoverage: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<VehicleCoverageRow>>(`/api/vehicles/coverage?${params.toString()}`, signal ? { signal } : undefined),
|
||||
vehicleCoverageSummary: (params = new URLSearchParams()) => request<VehicleCoverageSummary>(`/api/vehicles/coverage/summary?${params.toString()}`),
|
||||
vehicleBusinessFilters: (signal?: AbortSignal) => request<VehicleBusinessFilters>('/api/vehicles/business-filters', withSignal(undefined, signal)),
|
||||
vehicleDetail: (params = new URLSearchParams(), signal?: AbortSignal) => request<VehicleDetail>(`/api/vehicle-service?${params.toString()}`, withSignal(undefined, signal)),
|
||||
vehicleProfile: (vin: string) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`),
|
||||
latestTelemetry: (vin: string, signal?: AbortSignal) => request<LatestTelemetryResponse>(`/api/v2/vehicles/${encodeURIComponent(vin)}/telemetry/latest`, withSignal(undefined, signal)),
|
||||
@@ -320,6 +423,16 @@ export const api = {
|
||||
'/api/v2/reconciliation/issues',
|
||||
withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }, signal)
|
||||
),
|
||||
reconciliationAssignees: (search = '', signal?: AbortSignal) => request<ReconciliationAssignee[]>(
|
||||
`/api/v2/reconciliation/assignees?${new URLSearchParams(search ? { search } : {}).toString()}`,
|
||||
withSignal(undefined, signal)
|
||||
),
|
||||
downloadReconciliationIssues: (query: ReconciliationQuery, signal?: AbortSignal) => requestBlob(
|
||||
'/api/v2/reconciliation/issues/export',
|
||||
signal,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) },
|
||||
'quality-issues.csv'
|
||||
),
|
||||
reconciliationIssue: (id: string, signal?: AbortSignal) => request<ReconciliationIssue>(
|
||||
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}`,
|
||||
withSignal(undefined, signal)
|
||||
@@ -328,12 +441,32 @@ export const api = {
|
||||
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}/actions`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
batchUpdateReconciliationIssues: (input: ReconciliationBatchActionRequest) => request<ReconciliationBatchActionResult>(
|
||||
'/api/v2/reconciliation/issues/batch-actions',
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
assignReconciliationIssue: (id: string, input: ReconciliationAssignmentRequest) => request<ReconciliationIssue>(
|
||||
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}/assignment`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
batchAssignReconciliationIssues: (input: ReconciliationBatchAssignmentRequest) => request<ReconciliationBatchActionResult>(
|
||||
'/api/v2/reconciliation/issues/batch-assignments',
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
setReconciliationIssueArchived: (id: string, archived: boolean, input: ReconciliationLifecycleRequest) => request<ReconciliationIssue>(
|
||||
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}/${archived ? 'archive' : 'restore'}`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
batchSetReconciliationIssuesArchived: (archived: boolean, input: ReconciliationBatchLifecycleRequest) => request<ReconciliationBatchActionResult>(
|
||||
`/api/v2/reconciliation/issues/${archived ? 'batch-archive' : 'batch-restore'}`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
syncVehicleProfiles: (input: VehicleProfileSyncRequest) => request<VehicleProfileSyncResult>('/api/v2/vehicle-profiles/sync', {
|
||||
syncVehicleProfiles: (input: VehicleProfileSyncRequest, signal?: AbortSignal) => request<VehicleProfileSyncResult>('/api/v2/vehicle-profiles/sync', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
}, signal)),
|
||||
vehicleServiceSummary: () => request<VehicleServiceSummary>('/api/vehicle-service/summary'),
|
||||
vehicleServiceOverview: (params = new URLSearchParams()) => request<VehicleServiceOverview>(`/api/vehicle-service/overview?${params.toString()}`),
|
||||
vehicleServiceOverviews: (query: VehicleOverviewBatchQuery) => request<Page<VehicleServiceOverview>>('/api/vehicle-service/overviews', {
|
||||
@@ -351,8 +484,12 @@ export const api = {
|
||||
body: JSON.stringify(query)
|
||||
}),
|
||||
mileageSummary: (params = new URLSearchParams()) => request<MileageSummary>(`/api/mileage/summary?${params.toString()}`),
|
||||
dailyMileage: (params = new URLSearchParams(), signal?: AbortSignal) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`, signal ? { signal } : undefined),
|
||||
mileageStatistics: (params = new URLSearchParams(), signal?: AbortSignal) => request<MileageStatistics>(`/api/v2/statistics/mileage?${params.toString()}`, signal ? { signal } : undefined),
|
||||
dailyMileage: (query: MileageQuery, signal?: AbortSignal) => request<Page<DailyMileageRow>>('/api/mileage/daily', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}, signal)),
|
||||
mileageStatistics: (query: MileageQuery, signal?: AbortSignal) => request<MileageStatistics>('/api/v2/statistics/mileage', withSignal({
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query)
|
||||
}, signal)),
|
||||
onlineStatisticsSummary: (params = new URLSearchParams()) => request<OnlineStatisticsSummary>(`/api/statistics/online-summary?${params.toString()}`),
|
||||
onlineVehicleStatuses: (params = new URLSearchParams()) => request<Page<OnlineVehicleStatusRow>>(`/api/statistics/online-vehicles?${params.toString()}`),
|
||||
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),
|
||||
|
||||
@@ -15,6 +15,9 @@ export interface SessionInfo {
|
||||
vehicleCount?: number;
|
||||
customerRef?: string;
|
||||
tenantRef?: string;
|
||||
businessScopeLevel?: 'department' | 'responsible';
|
||||
departmentIds?: string[];
|
||||
responsibleUserId?: string;
|
||||
authMode: 'disabled' | 'enforce';
|
||||
}
|
||||
|
||||
@@ -24,6 +27,10 @@ export interface LoginResponse {
|
||||
session: Omit<SessionInfo, 'authMode'>;
|
||||
}
|
||||
|
||||
export interface OneOSExchangeResponse extends LoginResponse {
|
||||
returnTo: string;
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -77,6 +84,27 @@ export interface CustomerUserInput {
|
||||
vehicleGrants: CustomerVehicleGrantInput[];
|
||||
}
|
||||
|
||||
export interface CustomerUserBatchItem {
|
||||
row: number;
|
||||
input: CustomerUserInput & { username: string; password: string };
|
||||
}
|
||||
|
||||
export interface CustomerUserBatchResultItem {
|
||||
row: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
status: 'ready' | 'created' | 'invalid' | 'conflict' | 'failed';
|
||||
code?: string;
|
||||
message: string;
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export interface CustomerUserBatchResult {
|
||||
mode: 'preview' | 'create';
|
||||
summary: { received: number; ready: number; created: number; failed: number };
|
||||
items: CustomerUserBatchResultItem[];
|
||||
}
|
||||
|
||||
export interface MonitorSummary {
|
||||
totalVehicles: number;
|
||||
locationVehicles?: number;
|
||||
@@ -271,15 +299,33 @@ export interface MetricDefinition {
|
||||
export interface HistoryDataCategory { key: 'location' | 'raw' | 'mileage' | string; label: string; }
|
||||
export interface HistoryMetricDefinition { key: string; label: string; description?: string; unit: string; category: string; valueType: string; valueMappings?: MetricValueMapping[]; defaultVisible: boolean; }
|
||||
export interface HistoryDataRow { id: string; vin: string; plate: string; protocol: string; deviceTime: string; serverTime: string; quality: string; qualityReason: string; evidenceId?: string; values: Record<string, unknown>; }
|
||||
export interface HistoryDataSummary { resultRows: number; vehicleCount: number; sources: string[]; queryDurationMs: number; }
|
||||
export interface HistoryDataResponse { category: string; columns: HistoryMetricDefinition[]; rows: HistoryDataRow[]; summary: HistoryDataSummary; total: number; limit: number; offset: number; asOf: string; }
|
||||
export interface HistoryQueryVehicleResult { keyword: string; vin?: string; plate?: string; rowCount: number; status: 'matched' | 'no_data'; }
|
||||
export interface HistoryDataSummary { resultRows: number; vehicleCount: number; requestedVehicleCount?: number; vehicles?: HistoryQueryVehicleResult[]; sources: string[]; queryDurationMs: number; }
|
||||
export interface HistoryDataResponse { category: string; columns: HistoryMetricDefinition[]; segments?: LatestTelemetryCategory[]; segmentCountsExact?: boolean; allTotal?: number; rows: HistoryDataRow[]; summary: HistoryDataSummary; total: number; limit: number; offset: number; asOf: string; }
|
||||
export interface HistorySeriesPoint { time: string; value: number | null; min: number | null; max: number | null; count: number; }
|
||||
export interface HistorySeries { vin: string; plate: string; protocol: string; metric: string; label: string; unit: string; aggregation: 'avg' | 'last' | string; points: HistorySeriesPoint[]; }
|
||||
export interface HistorySeriesSummary { rawPointCount: number; bucketCount: number; returnedPointCount: number; seriesCount: number; grainSeconds: number; targetPoints: number; expectedBucketCount: number; missingBucketCount: number; queryDurationMs: number; complete: boolean; evidence: string; }
|
||||
export interface HistorySeriesResponse { metrics: HistoryMetricDefinition[]; series: HistorySeries[]; summary: HistorySeriesSummary; dateFrom: string; dateTo: string; asOf: string; }
|
||||
|
||||
export interface HistoryExportRequest { keywords: string[]; category: string; protocol?: string; dateFrom?: string; dateTo?: string; metrics: string[]; format: 'csv'; }
|
||||
export interface HistoryExportJob { id: string; name: string; status: 'queued' | 'running' | 'completed' | 'failed'; progress: number; format: string; category: string; protocol?: string; keywords: string[]; metrics?: string[]; vehicleVins?: string[]; dateFrom?: string; dateTo?: string; ownerId?: string; ownerName?: string; ownerUsername?: string; ownerRole?: string; ownerUserType?: string; customerRef?: string; tenantRef?: string; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; evidence: string; }
|
||||
export interface HistoryExportRequest { keywords: string[]; category: string; protocol?: string; dateFrom?: string; dateTo?: string; metrics: string[]; format: 'csv'; retentionDays?: number; }
|
||||
export type HistoryExportStatus = 'queued' | 'running' | 'completed' | 'failed' | 'expired' | 'cancelled';
|
||||
export interface HistoryExportJob { id: string; name: string; status: HistoryExportStatus; progress: number; format: string; category: string; protocol?: string; keywords: string[]; metrics?: string[]; vehicleVins?: string[]; dateFrom?: string; dateTo?: string; ownerId?: string; ownerName?: string; ownerUsername?: string; ownerRole?: string; ownerUserType?: string; customerRef?: string; tenantRef?: string; rowCount: number; totalRows: number; processedRows: number; fileSizeBytes: number; error?: string; downloadUrl?: string; createdAt: string; updatedAt: string; completedAt?: string; expiresAt?: string; expiredAt?: string; rebuiltFrom?: string; cancelledAt?: string; cancelledBy?: string; archivedAt?: string; archivedBy?: string; cleanupProtectedAt?: string; cleanupProtectedBy?: string; cleanupProtectionReason?: string; retentionDays?: number; evidence: string; }
|
||||
export type HistoryExportBatchAction = 'cancel' | 'rebuild' | 'archive' | 'restore';
|
||||
export interface HistoryExportBatchResult { action: HistoryExportBatchAction; requested: number; succeeded: HistoryExportJob[]; skipped: { id: string; code: string; message: string }[]; }
|
||||
export interface HistoryExportSummary { total: number; active: number; completed: number; expiring: number; recoverable: number; cancelled: number; current: number; archived: number; }
|
||||
export interface HistoryExportPage { items: HistoryExportJob[]; total: number; limit: number; offset: number; summary: HistoryExportSummary; }
|
||||
export interface HistoryExportCleanupCandidate { id: string; name: string; ownerUsername: string; status: HistoryExportStatus; archivedAt: string; fileSizeBytes: number; protectedAt?: string; protectedBy?: string; protectionReason?: string; }
|
||||
export interface HistoryExportCleanupPreview { olderThanDays: number; ownerScope: 'all' | 'mine'; cutoff: string; generatedAt: string; archivedCount: number; candidateCount: number; plannedCount: number; protectedCount: number; fileCount: number; fileSizeBytes: number; candidates: HistoryExportCleanupCandidate[]; protected: HistoryExportCleanupCandidate[]; previewToken: string; }
|
||||
export interface HistoryExportCleanupRecord { id: string; actor: string; ownerScope: 'all' | 'mine'; olderThanDays: number; cutoff: string; requested: number; cleaned: number; protected: number; fileCount: number; fileSizeBytes: number; candidateDigest: string; executedAt: string; }
|
||||
export interface HistoryExportCleanupResult { record: HistoryExportCleanupRecord; deleted: HistoryExportCleanupCandidate[]; }
|
||||
export type HistoryExportCleanupAutomationRunStatus = 'awaiting_approval' | 'needs_review' | 'approved' | 'running' | 'completed' | 'failed' | 'rejected' | 'expired' | 'no_candidates';
|
||||
export interface HistoryExportCleanupAutomationPolicy { enabled: boolean; olderThanDays: 30 | 90 | 180 | 365; intervalDays: 7 | 14 | 30; approvalWindowHours: 24 | 48 | 72; nextReviewAt?: string; revision: number; updatedAt?: string; updatedBy?: string; }
|
||||
export interface HistoryExportCleanupAutomationRun { id: string; status: HistoryExportCleanupAutomationRunStatus; revision: number; policyRevision: number; olderThanDays: number; cutoff: string; candidateCount: number; plannedCount: number; protectedCount: number; fileCount: number; fileSizeBytes: number; previewToken: string; createdAt: string; approvalDeadline?: string; approvedAt?: string; approvedBy?: string; approvalReason?: string; rejectedAt?: string; rejectedBy?: string; rejectionReason?: string; startedAt?: string; completedAt?: string; leaseOwner?: string; leaseExpiresAt?: string; attemptCount: number; maxAttempts: number; nextRetryAt?: string; error?: string; cleanupRecordId?: string; cleanedCount?: number; executionWarning?: string; lastActionAt?: string; lastActionBy?: string; lastActionReason?: string; }
|
||||
export interface HistoryExportCleanupAutomationState { policy: HistoryExportCleanupAutomationPolicy; runs: HistoryExportCleanupAutomationRun[]; serverTime: string; }
|
||||
export interface HistoryExportCleanupAutomationPolicyInput { enabled: boolean; olderThanDays: number; intervalDays: number; approvalWindowHours: number; expectedRevision: number; }
|
||||
export interface HistoryExportCleanupAutomationActionInput { expectedRevision: number; reason: string; }
|
||||
export interface HistoryFieldView { id: string; name: string; category: string; keys: string[]; }
|
||||
export interface HistoryPreferences { revision: number; retentionDays: number; fieldViews: HistoryFieldView[]; updatedAt?: string; }
|
||||
|
||||
export interface AccessQuery { keyword?: string; protocol?: string; oem?: string; model?: string; provider?: string; firstSeenFrom?: string; firstSeenTo?: string; latestSeenFrom?: string; latestSeenTo?: string; onlineState?: string; delayState?: string; connectionState?: string; limit?: number; offset?: number; }
|
||||
export interface AccessUnresolvedIdentityQuery { keyword?: string; protocol?: string; limit?: number; offset?: number; }
|
||||
@@ -288,6 +334,11 @@ export interface AccessUnresolvedIdentity {
|
||||
firstRegisteredAt: string; latestRegisteredAt: string; latestAuthenticatedAt: string; latestSeenAt: string;
|
||||
freshnessSec: number; issueCode: string; recommendedAction: string;
|
||||
}
|
||||
export interface AccessIdentityClaimInput { vin: string; note?: string; actor?: string; }
|
||||
export interface AccessIdentityClaimResult {
|
||||
identityId: string; protocol: string; identifierMasked: string; vin: string; plate: string;
|
||||
profileComplete: boolean; profileMissingFields: string[]; claimedBy: string; claimedAt: string; auditId: number;
|
||||
}
|
||||
export interface AccessVehicleRow {
|
||||
vin: string; plate: string; oem: string; model: string; company: string; protocol: string; provider: string; source: string;
|
||||
firstSeenAt: string; latestEventAt: string; latestReceivedAt: string; reportIntervalSec: number | null;
|
||||
@@ -325,23 +376,52 @@ export interface AccessThresholdUpdate {
|
||||
|
||||
export type AlertSeverity = 'critical' | 'major' | 'minor';
|
||||
export type AlertStatus = 'unprocessed' | 'processing' | 'recovered' | 'closed' | 'ignored';
|
||||
export type AlertTriggerType = 'metric' | 'geofence' | 'stationary' | 'offline';
|
||||
export interface AlertQuery { keyword?: string; severity?: string; status?: string; ruleId?: string; protocol?: string; dateFrom?: string; dateTo?: string; limit?: number; offset?: number; }
|
||||
export interface AlertSummary { active: number; unprocessed: number; processing: number; recovered: number; closed: number; ignored: number; unreadNotifications: number; asOf: string; }
|
||||
export interface AlertAction { id: number; action: string; fromStatus: string; toStatus: string; actor: string; note: string; createdAt: string; }
|
||||
export interface AlertEvent {
|
||||
id: string; ruleId: string; ruleName: string; ruleVersion: number; severity: AlertSeverity; status: AlertStatus;
|
||||
id: string; eventType?: string; eventCategory?: string; executionState?: 'pending' | 'processing' | 'recovered' | 'completed' | 'ignored'; ruleId: string; ruleName: string; ruleVersion: number; severity: AlertSeverity; triggerType?: AlertTriggerType; status: AlertStatus;
|
||||
vin: string; plate: string; protocol: string; metric: string; operator: string; triggerValue: number; threshold: number; thresholdHigh: number;
|
||||
unit: string; durationSec: number; location: string; longitude?: number; latitude?: number; sourceEventId: string;
|
||||
eventAt: string; receivedAt: string; triggeredAt: string; recoveredAt: string; handler: string; version: number; actions?: AlertAction[];
|
||||
}
|
||||
export interface AlertRule {
|
||||
id: string; name: string; description: string; severity: AlertSeverity; valueType: 'numeric' | 'boolean'; metric: string;
|
||||
id: string; name: string; description: string; triggerType?: AlertTriggerType; fenceName?: string; fenceLongitude?: number; fenceLatitude?: number; fenceRadiusM?: number;
|
||||
severity: AlertSeverity; valueType: 'numeric' | 'boolean'; metric: string;
|
||||
operator: string; threshold: number; thresholdHigh: number; booleanThreshold?: boolean; durationSec: number; recoveryOperator: string;
|
||||
recoveryThreshold: number; repeatIntervalSec: number; scopeProtocols: string[]; scopeVins: string[]; scopeOems: string[]; scopeModels: string[]; scopeCompanies: string[];
|
||||
notificationChannels: string[]; enabled: boolean; version: number; createdBy: string; updatedBy: string; createdAt: string; updatedAt: string;
|
||||
notificationChannels: string[]; notificationTargets?: AlertNotificationTarget[]; enabled: boolean; version: number; createdBy: string; updatedBy: string; createdAt: string; updatedAt: string;
|
||||
archivedBy?: string; archivedAt?: string; archiveReason?: string;
|
||||
}
|
||||
export interface AlertRuleInput extends Omit<AlertRule, 'notificationTargets' | 'createdBy' | 'updatedBy' | 'createdAt' | 'updatedAt'> { notificationTargets?: AlertNotificationTarget[]; actor?: string; }
|
||||
export interface AlertRuleLibrarySummary { current: number; enabled: number; disabled: number; archived: number; }
|
||||
export interface AlertRulePage extends Page<AlertRule> { summary: AlertRuleLibrarySummary; }
|
||||
export interface AlertNotificationTarget { channel: string; recipientId: string; label: string; }
|
||||
export interface AlertNotificationTargetOption { id: string; label: string; channels: string[]; }
|
||||
export interface AlertNotificationChannelCapability { channel: string; label: string; configured: boolean; }
|
||||
export interface AlertNotificationConfig { targets: AlertNotificationTargetOption[]; channels: AlertNotificationChannelCapability[]; }
|
||||
export interface AlertRuleRevision {
|
||||
ruleId: string; version: number; actor: string; action: 'create' | 'update' | 'enable' | 'disable' | 'rollback' | 'archive' | 'restore' | string; reason?: string; createdAt: string; snapshot: AlertRule;
|
||||
}
|
||||
export interface AlertNotification {
|
||||
id: number; eventId: string; title: string; content: string; severity: AlertSeverity; channel: string;
|
||||
recipient?: string; recipientId?: string; deliveryStatus?: 'queued' | 'sent' | 'delivered' | 'failed' | string; attemptCount?: number; providerMessageId?: string;
|
||||
vehiclePlate?: string; vehicleVin?: string; protocol?: string; read: boolean; createdAt: string; deliveredAt?: string; readAt: string;
|
||||
lastError?: string; lastAttemptAt?: string; retryRequestedBy?: string; retryRequestedAt?: string; retryAvailable?: boolean; maxAttempts?: number;
|
||||
}
|
||||
export interface AlertNotificationChannelHealth {
|
||||
channel: string; label: string; configured: boolean; queued: number; failed: number; deadLetter: number; activeLeases: number; oldestQueuedAt: string;
|
||||
}
|
||||
export interface AlertNotificationDeliveryHealth {
|
||||
queued: number; failed: number; deadLetter: number; activeLeases: number; oldestQueuedAt: string; channels: AlertNotificationChannelHealth[]; asOf: string;
|
||||
}
|
||||
export interface AlertNotificationRetryAudit {
|
||||
id: number; notificationId: number; actor: string; reason: string; previousStatus: string; nextStatus: string; attemptCount: number; requestedAt: string;
|
||||
}
|
||||
export interface AlertNotificationRetryResult {
|
||||
notification: AlertNotification; receipt: AlertNotificationRetryAudit; idempotent: boolean;
|
||||
}
|
||||
export interface AlertRuleInput extends Omit<AlertRule, 'createdBy' | 'updatedBy' | 'createdAt' | 'updatedAt'> { actor?: string; }
|
||||
export interface AlertNotification { id: number; eventId: string; title: string; content: string; severity: AlertSeverity; channel: string; read: boolean; createdAt: string; readAt: string; }
|
||||
|
||||
export interface ProtocolStat {
|
||||
protocol: string;
|
||||
@@ -414,6 +494,19 @@ export interface VehicleCoverageSummary {
|
||||
archiveMissingFields: ArchiveMissingFieldStat[];
|
||||
}
|
||||
|
||||
export interface VehicleBusinessFilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface VehicleBusinessFilters {
|
||||
departments: VehicleBusinessFilterOption[];
|
||||
responsibleUsers: VehicleBusinessFilterOption[];
|
||||
customers: VehicleBusinessFilterOption[];
|
||||
statuses: VehicleBusinessFilterOption[];
|
||||
}
|
||||
|
||||
export interface VehicleIdentityResolution {
|
||||
lookupKey: string;
|
||||
resolved: boolean;
|
||||
@@ -434,6 +527,7 @@ export interface VehicleDetail {
|
||||
resolution?: VehicleIdentityResolution;
|
||||
identity?: VehicleRow;
|
||||
profile?: VehicleProfile;
|
||||
businessRelation?: VehicleBusinessRelation;
|
||||
realtimeSummary?: VehicleRealtimeRow;
|
||||
serviceStatus?: VehicleServiceStatus;
|
||||
serviceOverview?: VehicleServiceOverview;
|
||||
@@ -447,6 +541,27 @@ export interface VehicleDetail {
|
||||
quality: Page<QualityIssueRow>;
|
||||
}
|
||||
|
||||
export interface VehicleBusinessRelation {
|
||||
sourceSystem: string;
|
||||
sourceVersion: string;
|
||||
vehicleId: string;
|
||||
vin: string;
|
||||
plateNumber: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
contractId: string;
|
||||
contractCode: string;
|
||||
projectName: string;
|
||||
departmentId: string;
|
||||
departmentName: string;
|
||||
responsibleUserId: string;
|
||||
responsibleUserName: string;
|
||||
operationStatus: string;
|
||||
scopeStartAt: string;
|
||||
sourceUpdatedAt: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
export interface VehicleProfile {
|
||||
vin: string;
|
||||
brandName: string;
|
||||
@@ -790,6 +905,9 @@ export interface DailyMileageRow {
|
||||
startMileageKm: number;
|
||||
endMileageKm: number;
|
||||
dailyMileageKm: number;
|
||||
pureHydrogenMileageKm?: number;
|
||||
hydrogenConsumptionKg?: number | null;
|
||||
hydrogenConsumptionKgPer100Km?: number | null;
|
||||
source: string;
|
||||
anomalySeverity?: string;
|
||||
}
|
||||
@@ -799,14 +917,26 @@ export interface MileageSummary {
|
||||
recordCount: number;
|
||||
sourceCount: number;
|
||||
totalMileageKm: number;
|
||||
totalPureHydrogenMileageKm: number;
|
||||
averageMileagePerVin: number;
|
||||
}
|
||||
|
||||
export interface MileageTrendPoint { date: string; mileageKm: number; vehicles: number; }
|
||||
export interface MileageTrendPoint {
|
||||
date: string;
|
||||
mileageKm: number;
|
||||
pureHydrogenMileageKm: number;
|
||||
hydrogenMatchedMileageKm?: number;
|
||||
hydrogenDataDays?: number;
|
||||
hydrogenConsumptionKg?: number | null;
|
||||
hydrogenConsumptionKgPer100Km?: number | null;
|
||||
vehicles: number;
|
||||
}
|
||||
export interface MileageVehicleRank { vin: string; plate: string; mileageKm: number; latestMileageKm: number; activeDays: number; }
|
||||
export interface MileageStatistics {
|
||||
dateFrom: string; dateTo: string; vehicleCount: number; recordCount: number; sourceCount: number;
|
||||
periodMileageKm: number; fleetLatestMileageKm: number; averageMileagePerVin: number; averageDailyMileageKm: number;
|
||||
periodMileageKm: number; periodPureHydrogenMileageKm: number; hydrogenMatchedMileageKm?: number;
|
||||
hydrogenDataDays?: number; periodHydrogenConsumptionKg?: number; hydrogenConsumptionKgPer100Km?: number | null;
|
||||
fleetLatestMileageKm: number; averageMileagePerVin: number; averageDailyMileageKm: number;
|
||||
trend: MileageTrendPoint[]; ranking: MileageVehicleRank[]; asOf: string; evidence: string;
|
||||
}
|
||||
|
||||
@@ -970,10 +1100,13 @@ export interface RuntimeInfo {
|
||||
|
||||
export interface ReconciliationQuery {
|
||||
keyword?: string;
|
||||
scope?: 'current' | 'archived';
|
||||
ruleCode?: string;
|
||||
category?: string;
|
||||
severity?: string;
|
||||
status?: string;
|
||||
owner?: string;
|
||||
sla?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
@@ -1007,10 +1140,66 @@ export interface ReconciliationIssue {
|
||||
recoveredAt: string;
|
||||
resolutionNote: string;
|
||||
resolvedBy: string;
|
||||
assignee: string;
|
||||
assignedBy: string;
|
||||
assignedAt: string;
|
||||
dueAt: string;
|
||||
archivedAt: string;
|
||||
archivedBy: string;
|
||||
archiveReason: string;
|
||||
version: number;
|
||||
actions?: ReconciliationAction[];
|
||||
}
|
||||
|
||||
export interface ReconciliationAssignmentRequest {
|
||||
version: number;
|
||||
assignee: string;
|
||||
dueAt: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationAssignee {
|
||||
name: string;
|
||||
username?: string;
|
||||
source: 'account' | 'history' | 'current';
|
||||
activeCount: number;
|
||||
lastAssignedAt?: string;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
export interface ReconciliationBatchAssignmentRequest {
|
||||
items: Array<{ id: string; version: number }>;
|
||||
assignee: string;
|
||||
dueAt: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationBatchActionRequest {
|
||||
items: Array<{ id: string; version: number }>;
|
||||
status: 'pending' | 'no_action' | 'fixed';
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationLifecycleRequest {
|
||||
version: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationBatchLifecycleRequest {
|
||||
items: Array<{ id: string; version: number }>;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationBatchActionFailure {
|
||||
id: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationBatchActionResult {
|
||||
requested: number;
|
||||
succeeded: ReconciliationIssue[];
|
||||
skipped: ReconciliationBatchActionFailure[];
|
||||
}
|
||||
|
||||
export interface ReconciliationBucket {
|
||||
name: string;
|
||||
count: number;
|
||||
@@ -1025,6 +1214,8 @@ export interface ReconciliationTrendPoint {
|
||||
}
|
||||
|
||||
export interface ReconciliationSummary {
|
||||
current: number;
|
||||
archived: number;
|
||||
active: number;
|
||||
pending: number;
|
||||
confirmed: number;
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconBarChartVStroked,
|
||||
IconBox,
|
||||
IconCalendar,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconClose,
|
||||
IconDownload,
|
||||
IconFilter,
|
||||
IconHelpCircle,
|
||||
IconHome,
|
||||
IconList,
|
||||
IconMapPin,
|
||||
IconMore,
|
||||
IconPause,
|
||||
IconPlay,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconRoute,
|
||||
IconSearch,
|
||||
IconSetting,
|
||||
IconTickCircle,
|
||||
IconUser
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type PageKey = 'monitor' | 'tracks' | 'history' | 'statistics' | 'alerts' | 'access' | 'users';
|
||||
type Tone = 'blue' | 'green' | 'amber' | 'red' | 'neutral';
|
||||
|
||||
type NavItem = {
|
||||
key: PageKey | 'vehicles';
|
||||
label: string;
|
||||
icon: typeof IconHome;
|
||||
};
|
||||
|
||||
const workspaceNavigation: NavItem[] = [
|
||||
{ key: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ key: 'vehicles', label: '车辆查询', icon: IconSearch },
|
||||
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
|
||||
{ key: 'history', label: '历史数据', icon: IconClock },
|
||||
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked }
|
||||
];
|
||||
|
||||
const governanceNavigation: NavItem[] = [
|
||||
{ key: 'alerts', label: '事件中心', icon: IconAlertCircle },
|
||||
{ key: 'access', label: '接入管理', icon: IconBox },
|
||||
{ key: 'users', label: '账号管理', icon: IconUser }
|
||||
];
|
||||
|
||||
const pageMeta: Record<PageKey, { title: string; subtitle: string }> = {
|
||||
monitor: { title: '全局监控', subtitle: '实时掌握授权车辆的位置、状态与数据活跃度。' },
|
||||
tracks: { title: '轨迹回放', subtitle: '按时间窗还原车辆行程、停留点与轨迹事件。' },
|
||||
history: { title: '历史数据', subtitle: '查询原始上报、协议字段和可追溯数据证据。' },
|
||||
statistics: { title: '里程查询', subtitle: '比较车辆每日里程、区间汇总与来源覆盖。' },
|
||||
alerts: { title: '事件中心', subtitle: '集中处置车辆告警、自动化规则与证据链。' },
|
||||
access: { title: '接入管理', subtitle: '管理协议来源、终端接入和数据链路健康度。' },
|
||||
users: { title: '账号管理', subtitle: '管理客户账号、菜单权限与车辆授权范围。' }
|
||||
};
|
||||
|
||||
const liveVehicles = [
|
||||
{ plate: '粤AG18312', vin: 'LB9A32A24R0LS1426', brand: 'G7s', speed: 62, status: '行驶中', time: '20:12:12', protocol: 'JT/T 808', tone: 'green' as Tone },
|
||||
{ plate: '川AHTW01', vin: 'LNXNEGRR7SR318212', brand: 'Hyundai', speed: 55, status: '行驶中', time: '20:12:12', protocol: 'GB/T 32960', tone: 'green' as Tone },
|
||||
{ plate: '豫A88888', vin: 'LMRKH9AC2R1004087', brand: '宇通', speed: 48, status: '行驶中', time: '20:11:11', protocol: '宇通 MQTT', tone: 'green' as Tone },
|
||||
{ plate: '粤AFF7936', vin: 'LB9A32A24P0LS1230', brand: '广安车联', speed: 0, status: '离线', time: '19:58:58', protocol: 'JT/T 808', tone: 'neutral' as Tone }
|
||||
];
|
||||
|
||||
const accountRows = [
|
||||
{ name: '羚牛示范车队', account: 'demo.team@lingniu.com', source: '平台本地', menus: 18, vehicles: 128, state: '启用', tone: 'green' as Tone },
|
||||
{ name: '广州运营中心', account: 'gz.ops@lingniu.com', source: '平台本地', menus: 16, vehicles: 86, state: '启用', tone: 'green' as Tone },
|
||||
{ name: '深圳车队', account: 'sz.fleet@lingniu.com', source: '平台本地', menus: 15, vehicles: 72, state: '启用', tone: 'green' as Tone },
|
||||
{ name: '上海物流车队', account: 'sh.logistics@lingniu.com', source: 'LDAP', menus: 14, vehicles: 64, state: '启用', tone: 'green' as Tone },
|
||||
{ name: '北京测试车队', account: 'bj.test@lingniu.com', source: '平台本地', menus: 8, vehicles: 22, state: '待完善', tone: 'amber' as Tone },
|
||||
{ name: '西安车队', account: 'xa.fleet@lingniu.com', source: '钉钉', menus: 10, vehicles: 18, state: '停用', tone: 'neutral' as Tone }
|
||||
];
|
||||
|
||||
const eventRows = [
|
||||
{ id: 'EVT-240703-031', plate: '粤AG18312', title: '急加速事件', level: '一般', time: '20:12:08', state: '待确认', tone: 'amber' as Tone },
|
||||
{ id: 'EVT-240703-030', plate: '豫A88888', title: '离线超过 10 分钟', level: '重要', time: '20:06:22', state: '处理中', tone: 'red' as Tone },
|
||||
{ id: 'EVT-240703-029', plate: '川AHTW01', title: '进入电子围栏', level: '提示', time: '19:58:41', state: '已归档', tone: 'blue' as Tone },
|
||||
{ id: 'EVT-240703-028', plate: '粤AFF7936', title: '终端电压偏低', level: '一般', time: '19:47:16', state: '已确认', tone: 'green' as Tone },
|
||||
{ id: 'EVT-240703-027', plate: '湘B56789', title: '超速持续 3 分钟', level: '重要', time: '19:38:02', state: '已通知', tone: 'red' as Tone }
|
||||
];
|
||||
|
||||
const sourceRows = [
|
||||
{ source: 'JT/T 808', endpoint: 'tcp://ingest-808.lingniu.local:6808', vehicles: 82, today: '1.86M', latency: '42 ms', state: '运行正常', tone: 'green' as Tone },
|
||||
{ source: 'GB/T 32960', endpoint: 'tcp://ingest-32960.lingniu.local:32960', vehicles: 38, today: '962K', latency: '58 ms', state: '运行正常', tone: 'green' as Tone },
|
||||
{ source: '宇通 MQTT', endpoint: 'mqtt://bus-gateway.lingniu.local', vehicles: 8, today: '118K', latency: '126 ms', state: '轻微延迟', tone: 'amber' as Tone },
|
||||
{ source: '补录 CSV', endpoint: '对象存储 / 手工导入', vehicles: 14, today: '3', latency: '—', state: '按需运行', tone: 'blue' as Tone }
|
||||
];
|
||||
|
||||
const historyRows = [
|
||||
{ time: '20:12:12.486', source: 'JT/T 808', event: '位置上报', speed: '62 km/h', mileage: '128,642.8 km', result: '已入库' },
|
||||
{ time: '20:12:02.212', source: 'GB/T 32960', event: '整车数据', speed: '61 km/h', mileage: '128,642.6 km', result: '已融合' },
|
||||
{ time: '20:11:42.936', source: 'JT/T 808', event: '位置上报', speed: '58 km/h', mileage: '128,642.1 km', result: '已入库' },
|
||||
{ time: '20:11:32.501', source: 'GB/T 32960', event: '驱动电机', speed: '57 km/h', mileage: '128,641.9 km', result: '已融合' },
|
||||
{ time: '20:11:12.184', source: 'JT/T 808', event: '位置上报', speed: '51 km/h', mileage: '128,641.4 km', result: '已入库' }
|
||||
];
|
||||
|
||||
const mileageRows = [
|
||||
{ plate: '粤AG18312', vin: 'LB9A32A24R0LS1426', days: [118, 142, 96, 165, 128, 152, 136], total: 937, source: 'JT/T 808' },
|
||||
{ plate: '川AHTW01', vin: 'LNXNEGRR7SR318212', days: [86, 103, 74, 112, 97, 126, 109], total: 707, source: 'GB/T 32960' },
|
||||
{ plate: '豫A88888', vin: 'LMRKH9AC2R1004087', days: [172, 184, 165, 198, 176, 208, 191], total: 1294, source: '宇通 MQTT' },
|
||||
{ plate: '粤AFF7936', vin: 'LB9A32A24P0LS1230', days: [34, 61, 0, 48, 72, 65, 0], total: 280, source: 'JT/T 808' }
|
||||
];
|
||||
|
||||
function pageFromHash(): PageKey {
|
||||
const value = window.location.hash.replace(/^#\/?/, '') as PageKey;
|
||||
return value in pageMeta ? value : 'monitor';
|
||||
}
|
||||
|
||||
function StatusPill({ children, tone = 'neutral' }: { children: ReactNode; tone?: Tone }) {
|
||||
return <span className={`platform-status is-${tone}`}>{children}</span>;
|
||||
}
|
||||
|
||||
function Sidebar({ page, onNavigate }: { page: PageKey; onNavigate: (page: PageKey | 'vehicles') => void }) {
|
||||
const renderGroup = (items: NavItem[]) => items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
className={item.key === page ? 'is-active' : ''}
|
||||
type="button"
|
||||
key={item.key}
|
||||
onClick={() => onNavigate(item.key)}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<aside className="lab-sidebar" aria-label="车辆数据中台导航">
|
||||
<div className="lab-brand"><img src="/brand-logo.svg" alt="羚牛智能" /></div>
|
||||
<nav>
|
||||
<p>车辆工作台</p>
|
||||
{renderGroup(workspaceNavigation)}
|
||||
<p>平台治理</p>
|
||||
{renderGroup(governanceNavigation)}
|
||||
</nav>
|
||||
<button className="lab-collapse" type="button"><IconChevronLeft aria-hidden="true" /><span>收起</span></button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ page, onOpenMenu }: { page: PageKey; onOpenMenu: () => void }) {
|
||||
const meta = pageMeta[page];
|
||||
return (
|
||||
<header className="lab-topbar">
|
||||
<div className="lab-topbar-title">
|
||||
<span><strong>{meta.title}</strong><small>{meta.subtitle}</small></span>
|
||||
</div>
|
||||
<div className="lab-topbar-actions">
|
||||
<button type="button" className="lab-help-button"><IconHelpCircle aria-hidden="true" /><span>页面帮助</span></button>
|
||||
<button type="button" className="platform-mobile-menu-trigger" onClick={onOpenMenu} aria-label="打开页面导航"><IconMore aria-hidden="true" /></button>
|
||||
<button type="button" className="lab-account-button" aria-label="账号菜单">
|
||||
<span className="lab-avatar">I</span><b>local-developer</b><em>管理员</em><IconChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricStrip({ items }: { items: Array<{ label: string; value: string | number; unit?: string; tone?: Tone; note?: string }> }) {
|
||||
return (
|
||||
<section className="platform-metrics" aria-label="页面摘要">
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<span>{item.label}</span>
|
||||
<strong className={`is-${item.tone ?? 'blue'}`}>{item.value}<small>{item.unit}</small></strong>
|
||||
{item.note ? <em>{item.note}</em> : null}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandRail({
|
||||
children,
|
||||
onSubmit,
|
||||
primaryLabel,
|
||||
onPrimary
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onSubmit?: (event: FormEvent) => void;
|
||||
primaryLabel?: string;
|
||||
onPrimary?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<form className="platform-command-rail" onSubmit={onSubmit}>
|
||||
{children}
|
||||
{primaryLabel ? <button className="platform-primary-button" type={onSubmit ? 'submit' : 'button'} onClick={onPrimary}>{primaryLabel}<IconChevronRight aria-hidden="true" /></button> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchField({ value, onChange, placeholder, label }: { value: string; onChange: (value: string) => void; placeholder: string; label: string }) {
|
||||
return (
|
||||
<label className="platform-search-field">
|
||||
<IconSearch aria-hidden="true" />
|
||||
<input value={value} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} aria-label={label} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function MapCanvas({
|
||||
selected,
|
||||
onSelect,
|
||||
route = false,
|
||||
children
|
||||
}: {
|
||||
selected: string;
|
||||
onSelect: (plate: string) => void;
|
||||
route?: boolean;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const markers = [
|
||||
{ plate: '粤AG18312', x: 47, y: 46 },
|
||||
{ plate: '川AHTW01', x: 67, y: 34 },
|
||||
{ plate: '豫A88888', x: 72, y: 61 },
|
||||
{ plate: '粤AFF7936', x: 29, y: 66 },
|
||||
{ plate: '湘B56789', x: 55, y: 76 }
|
||||
];
|
||||
return (
|
||||
<div className="platform-map" aria-label="车辆地图">
|
||||
{route ? <div className="platform-route" aria-hidden="true"><i /><i /><i /><i /><i /></div> : null}
|
||||
{markers.map((marker) => (
|
||||
<button
|
||||
type="button"
|
||||
className={`platform-map-marker${selected === marker.plate ? ' is-selected' : ''}`}
|
||||
style={{ left: `${marker.x}%`, top: `${marker.y}%` }}
|
||||
aria-label={`选择地图车辆 ${marker.plate}`}
|
||||
onClick={() => onSelect(marker.plate)}
|
||||
key={marker.plate}
|
||||
>
|
||||
<IconRoute aria-hidden="true" /><span>{marker.plate}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="platform-map-tools" aria-label="地图工具">
|
||||
<button type="button" aria-label="回到车辆范围"><IconMapPin aria-hidden="true" /></button>
|
||||
<button type="button" aria-label="刷新地图"><IconRefresh aria-hidden="true" /></button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonitorPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState(liveVehicles[0].plate);
|
||||
const [view, setView] = useState<'map' | 'list'>('map');
|
||||
const selectedVehicle = liveVehicles.find((vehicle) => vehicle.plate === selected) ?? liveVehicles[0];
|
||||
const rows = liveVehicles.filter((vehicle) => `${vehicle.plate}${vehicle.vin}${vehicle.brand}`.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<main className="platform-page platform-monitor-page">
|
||||
<CommandRail onSubmit={(event) => event.preventDefault()}>
|
||||
<SearchField value={query} onChange={setQuery} placeholder="输入车牌 / VIN / 终端手机号" label="搜索实时车辆" />
|
||||
<label className="platform-select-field"><span>通信协议</span><select aria-label="通信协议"><option>全部协议</option><option>JT/T 808</option><option>GB/T 32960</option></select></label>
|
||||
<label className="platform-select-field"><span>车辆状态</span><select aria-label="车辆状态"><option>全部状态</option><option>在线</option><option>离线</option></select></label>
|
||||
<button type="button" className="platform-refresh"><IconRefresh aria-hidden="true" />刷新</button>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '车辆总数', value: 128, unit: '辆' },
|
||||
{ label: '当前在线', value: 96, unit: '辆', tone: 'green' },
|
||||
{ label: '行驶车辆', value: 34, unit: '辆' },
|
||||
{ label: '当前离线', value: 32, unit: '辆', tone: 'neutral' },
|
||||
{ label: '告警车辆', value: 3, unit: '辆', tone: 'red' }
|
||||
]} />
|
||||
<section className="platform-map-workspace">
|
||||
<aside className={`platform-live-list${view === 'list' ? ' is-mobile-visible' : ''}`} aria-label="实时车辆列表">
|
||||
<header>
|
||||
<span><strong>实时车辆</strong><small>{rows.length} 辆示例车辆</small></span>
|
||||
<div className="platform-view-toggle" role="group" aria-label="地图或列表视图">
|
||||
<button type="button" className={view === 'map' ? 'is-active' : ''} onClick={() => setView('map')} aria-label="地图视图"><IconMapPin aria-hidden="true" /></button>
|
||||
<button type="button" className={view === 'list' ? 'is-active' : ''} onClick={() => setView('list')} aria-label="列表视图"><IconList aria-hidden="true" /></button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="platform-live-list-body">
|
||||
{rows.map((vehicle) => (
|
||||
<button type="button" className={selected === vehicle.plate ? 'is-selected' : ''} onClick={() => { setSelected(vehicle.plate); setView('map'); }} key={vehicle.vin}>
|
||||
<i className={`is-${vehicle.tone}`} />
|
||||
<span><strong>{vehicle.plate}</strong><small>{vehicle.brand} · {vehicle.vin}</small><em>{vehicle.time}</em></span>
|
||||
<b>{vehicle.speed} km/h</b>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<footer><button type="button">查看全部车辆<IconChevronRight aria-hidden="true" /></button></footer>
|
||||
</aside>
|
||||
<MapCanvas selected={selected} onSelect={setSelected}>
|
||||
<div className="platform-mobile-map-tabs" role="group" aria-label="监控视图">
|
||||
<button type="button" className={view === 'map' ? 'is-active' : ''} onClick={() => setView('map')}>地图</button>
|
||||
<button type="button" className={view === 'list' ? 'is-active' : ''} onClick={() => setView('list')}>列表</button>
|
||||
</div>
|
||||
</MapCanvas>
|
||||
<aside className="platform-map-inspector" aria-label={`${selectedVehicle.plate} 实时详情`}>
|
||||
<header><span><strong>{selectedVehicle.plate}</strong><StatusPill tone={selectedVehicle.tone}>{selectedVehicle.status}</StatusPill></span><small>{selectedVehicle.brand} · {selectedVehicle.vin}</small></header>
|
||||
<dl>
|
||||
<div><dt>实时速度</dt><dd className="is-emphasis">{selectedVehicle.speed} <small>km/h</small></dd></div>
|
||||
<div><dt>最后上报时间</dt><dd>07-03 {selectedVehicle.time}</dd></div>
|
||||
<div><dt>通信协议</dt><dd><StatusPill tone="blue">{selectedVehicle.protocol}</StatusPill></dd></div>
|
||||
<div><dt>定位方式</dt><dd>北斗 / GPS</dd></div>
|
||||
<div><dt>当前位置</dt><dd>广东省广州市天河区科韵路附近</dd></div>
|
||||
<div><dt>今日里程</dt><dd>128.6 km</dd></div>
|
||||
</dl>
|
||||
<footer><button type="button">车辆档案</button><button type="button" className="is-primary">轨迹回放<IconChevronRight aria-hidden="true" /></button></footer>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function TrackPage() {
|
||||
const [selected, setSelected] = useState('粤AG18312');
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [progress, setProgress] = useState(36);
|
||||
const [railOpen, setRailOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
const timer = window.setInterval(() => setProgress((value) => value >= 100 ? 0 : value + 1), 240);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [playing]);
|
||||
|
||||
return (
|
||||
<main className="platform-page platform-track-page">
|
||||
<section className="platform-track-stage">
|
||||
<aside className={`platform-track-rail${railOpen ? ' is-open' : ''}`}>
|
||||
<header><span><strong>轨迹查询</strong><small>最长支持连续 7 天</small></span><button type="button" onClick={() => setRailOpen(false)} aria-label="收起轨迹查询"><IconChevronLeft /></button></header>
|
||||
<SearchField value={selected} onChange={setSelected} placeholder="输入车牌 / VIN" label="搜索轨迹车辆" />
|
||||
<div className="platform-date-presets" role="group" aria-label="轨迹时间范围">
|
||||
<button className="is-active" type="button">今天</button><button type="button">昨天</button><button type="button">近 3 天</button>
|
||||
</div>
|
||||
<label className="platform-field-stack"><span>时间范围</span><div><IconCalendar /><input defaultValue="2026-07-27 00:00 — 23:59" aria-label="轨迹时间范围" /></div></label>
|
||||
<label className="platform-field-stack"><span>里程来源</span><select aria-label="里程来源"><option>自动选择最佳来源</option><option>JT/T 808 · GPS 里程</option><option>GB/T 32960 · 仪表盘里程</option></select></label>
|
||||
<button type="button" className="platform-query-button">生成轨迹</button>
|
||||
<MetricStrip items={[
|
||||
{ label: '行程里程', value: '128.6', unit: 'km' },
|
||||
{ label: '停留点', value: 4, unit: '个', tone: 'green' },
|
||||
{ label: '事件点', value: 3, unit: '个', tone: 'amber' }
|
||||
]} />
|
||||
<div className="platform-track-evidence">
|
||||
<header><strong>行程明细</strong><StatusPill tone="green">完整点集</StatusPill></header>
|
||||
{['08:42 科韵路出发', '10:16 琶洲停留 18 分钟', '14:08 急加速事件', '20:12 天河软件园'].map((item, index) => <button type="button" key={item}><i>{index + 1}</i><span>{item}</span><IconChevronRight /></button>)}
|
||||
</div>
|
||||
</aside>
|
||||
<MapCanvas route selected={selected} onSelect={setSelected}>
|
||||
{!railOpen ? <button type="button" className="platform-open-rail" onClick={() => setRailOpen(true)}><IconList />查询与明细</button> : null}
|
||||
<div className="platform-track-float-card">
|
||||
<span><StatusPill tone="green">查询完整</StatusPill><small>07-27 14:08:36</small></span>
|
||||
<strong>{selected}</strong>
|
||||
<p>当前 58 km/h · 累计里程 128,642.1 km</p>
|
||||
</div>
|
||||
</MapCanvas>
|
||||
<section className="platform-playback" aria-label="轨迹播放控制">
|
||||
<button type="button" onClick={() => setPlaying((value) => !value)} aria-label={playing ? '暂停轨迹播放' : '开始轨迹播放'}>{playing ? <IconPause /> : <IconPlay />}</button>
|
||||
<span><b>08:42</b><input type="range" min="0" max="100" value={progress} onChange={(event) => setProgress(Number(event.target.value))} aria-label="轨迹播放进度" /><b>20:12</b></span>
|
||||
<em>{progress}%</em>
|
||||
<select aria-label="回放倍速"><option>1×</option><option>2×</option><option>4×</option></select>
|
||||
<button type="button" aria-label="导出轨迹"><IconDownload /></button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function HistoryPage() {
|
||||
const [keyword, setKeyword] = useState('粤AG18312');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const selected = historyRows[selectedIndex];
|
||||
return (
|
||||
<main className="platform-page platform-analytic-page">
|
||||
<CommandRail onSubmit={(event) => event.preventDefault()} primaryLabel="查询历史">
|
||||
<SearchField value={keyword} onChange={setKeyword} placeholder="车牌 / VIN" label="搜索历史数据车辆" />
|
||||
<label className="platform-select-field"><span>日期</span><input type="date" defaultValue="2026-07-27" aria-label="历史日期" /></label>
|
||||
<label className="platform-select-field"><span>协议来源</span><select aria-label="历史协议来源"><option>全部来源</option><option>JT/T 808</option><option>GB/T 32960</option></select></label>
|
||||
<label className="platform-select-field"><span>数据类型</span><select aria-label="历史数据类型"><option>全部类型</option><option>位置上报</option><option>整车数据</option></select></label>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '上报记录', value: '8,642', unit: '条' },
|
||||
{ label: '协议来源', value: 2, unit: '个', tone: 'green' },
|
||||
{ label: '有效覆盖', value: '99.8', unit: '%', tone: 'green' },
|
||||
{ label: '异常间隔', value: 3, unit: '处', tone: 'amber' }
|
||||
]} />
|
||||
<section className="platform-split-workspace">
|
||||
<div className="platform-table-panel">
|
||||
<header className="platform-panel-header"><span><strong>历史上报记录</strong><small>按设备时间倒序排列,可追溯原始协议证据</small></span><button type="button"><IconDownload />导出当前结果</button></header>
|
||||
<div className="platform-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>设备时间</th><th>来源</th><th>数据类型</th><th>速度</th><th>累计里程</th><th>处理结果</th></tr></thead>
|
||||
<tbody>{historyRows.map((row, index) => (
|
||||
<tr className={selectedIndex === index ? 'is-selected' : ''} onClick={() => setSelectedIndex(index)} tabIndex={0} key={`${row.time}-${row.source}`}>
|
||||
<td><strong>{row.time}</strong><small>2026-07-27</small></td><td><StatusPill tone="blue">{row.source}</StatusPill></td><td>{row.event}</td><td>{row.speed}</td><td>{row.mileage}</td><td><StatusPill tone="green">{row.result}</StatusPill></td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<footer className="platform-pagination"><span>共 8,642 条 · 当前 1–5</span><div><button disabled type="button"><IconChevronLeft /></button><b>1</b><button type="button"><IconChevronRight /></button></div></footer>
|
||||
</div>
|
||||
<aside className="platform-evidence-panel" aria-label="原始数据证据">
|
||||
<header><span><small>原始数据证据</small><strong>{selected.time}</strong></span><button type="button" aria-label="刷新证据"><IconRefresh /></button></header>
|
||||
<dl>
|
||||
<div><dt>协议来源</dt><dd>{selected.source}</dd></div>
|
||||
<div><dt>消息类型</dt><dd>{selected.event}</dd></div>
|
||||
<div><dt>处理结果</dt><dd><StatusPill tone="green">{selected.result}</StatusPill></dd></div>
|
||||
<div><dt>数据质量</dt><dd>字段完整 · 时序正常</dd></div>
|
||||
</dl>
|
||||
<section className="platform-code-block" aria-label="协议字段">
|
||||
<header><strong>协议字段</strong><button type="button">复制 JSON</button></header>
|
||||
<pre>{`{\n "latitude": 23.12908,\n "longitude": 113.35512,\n "speedKmh": ${selected.speed.split(' ')[0]},\n "mileageKm": 128642.8,\n "alarm": 0\n}`}</pre>
|
||||
</section>
|
||||
<button type="button" className="platform-inspector-action">打开完整证据链<IconChevronRight /></button>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function StatisticsPage() {
|
||||
const [mode, setMode] = useState<'daily' | 'vehicle'>('daily');
|
||||
const maxMileage = Math.max(...mileageRows.flatMap((row) => row.days));
|
||||
return (
|
||||
<main className="platform-page platform-analytic-page">
|
||||
<CommandRail onSubmit={(event) => event.preventDefault()} primaryLabel="计算里程">
|
||||
<SearchField value="" onChange={() => undefined} placeholder="车牌 / VIN,可留空查询授权范围" label="搜索里程车辆" />
|
||||
<label className="platform-select-field"><span>开始日期</span><input type="date" defaultValue="2026-07-21" aria-label="里程开始日期" /></label>
|
||||
<label className="platform-select-field"><span>结束日期</span><input type="date" defaultValue="2026-07-27" aria-label="里程结束日期" /></label>
|
||||
<label className="platform-select-field"><span>里程来源</span><select aria-label="里程来源"><option>自动优选</option><option>JT/T 808</option><option>GB/T 32960</option></select></label>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '车辆数量', value: 4, unit: '辆' },
|
||||
{ label: '区间总里程', value: '3,218', unit: 'km', tone: 'green' },
|
||||
{ label: '日均里程', value: '114.9', unit: 'km' },
|
||||
{ label: '完整覆盖', value: 3, unit: '辆', tone: 'green' },
|
||||
{ label: '缺失天数', value: 2, unit: '天', tone: 'amber' }
|
||||
]} />
|
||||
<section className="platform-mileage-workspace">
|
||||
<header className="platform-panel-header">
|
||||
<span><strong>每日与区间里程</strong><small>2026-07-21 — 2026-07-27 · 自动选择每辆车最佳来源</small></span>
|
||||
<div className="platform-segmented" role="tablist" aria-label="里程视图">
|
||||
<button type="button" role="tab" aria-selected={mode === 'daily'} className={mode === 'daily' ? 'is-active' : ''} onClick={() => setMode('daily')}>每日趋势</button>
|
||||
<button type="button" role="tab" aria-selected={mode === 'vehicle'} className={mode === 'vehicle' ? 'is-active' : ''} onClick={() => setMode('vehicle')}>车辆汇总</button>
|
||||
</div>
|
||||
<button type="button"><IconDownload />导出 Excel</button>
|
||||
</header>
|
||||
<div className={`platform-mileage-chart is-${mode}`} aria-label="里程图表">
|
||||
{mileageRows.map((row) => (
|
||||
<div className="platform-mileage-series" key={row.vin}>
|
||||
<header><span><strong>{row.plate}</strong><small>{row.source}</small></span><b>{row.total} km</b></header>
|
||||
<div>{row.days.map((value, index) => <span style={{ height: mode === 'daily' ? `${Math.max(8, value / maxMileage * 100)}%` : `${row.total / 13}%` }} title={`07-${21 + index}:${value} km`} key={`${row.vin}-${index}`}><i>{value || '—'}</i></span>)}</div>
|
||||
</div>
|
||||
))}
|
||||
<footer>{['07-21', '07-22', '07-23', '07-24', '07-25', '07-26', '07-27'].map((day) => <span key={day}>{day}</span>)}</footer>
|
||||
</div>
|
||||
<div className="platform-mileage-summary">
|
||||
{mileageRows.map((row) => <button type="button" key={row.vin}><span><strong>{row.plate}</strong><small>{row.vin}</small></span><StatusPill tone="blue">{row.source}</StatusPill><b>{row.total}<small> km</small></b><IconChevronRight /></button>)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsPage() {
|
||||
const [scope, setScope] = useState('全部事件');
|
||||
const [selectedId, setSelectedId] = useState(eventRows[0].id);
|
||||
const selected = eventRows.find((row) => row.id === selectedId) ?? eventRows[0];
|
||||
const rows = scope === '重要事件' ? eventRows.filter((row) => row.level === '重要') : eventRows;
|
||||
return (
|
||||
<main className="platform-page platform-governance-page">
|
||||
<CommandRail onSubmit={(event) => event.preventDefault()}>
|
||||
<SearchField value="" onChange={() => undefined} placeholder="事件编号 / 车牌 / VIN / 规则名称" label="搜索事件" />
|
||||
<div className="platform-tabs" role="tablist" aria-label="事件范围">{['全部事件', '重要事件', '待处理', '已归档'].map((item) => <button type="button" role="tab" aria-selected={scope === item} className={scope === item ? 'is-active' : ''} onClick={() => setScope(item)} key={item}>{item}</button>)}</div>
|
||||
<button type="button" className="platform-secondary-button"><IconSetting />自动化规则</button>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '今日事件', value: 38, unit: '条' },
|
||||
{ label: '待确认', value: 6, unit: '条', tone: 'amber' },
|
||||
{ label: '处理中', value: 2, unit: '条', tone: 'red' },
|
||||
{ label: '自动归档', value: 27, unit: '条', tone: 'green' }
|
||||
]} />
|
||||
<section className="platform-directory-workspace">
|
||||
<div className="platform-directory">
|
||||
<header className="platform-directory-columns"><span>事件 / 车辆</span><span>等级</span><span>发生时间</span><span>处置状态</span></header>
|
||||
{rows.map((event) => (
|
||||
<button type="button" className={selectedId === event.id ? 'is-selected' : ''} onClick={() => setSelectedId(event.id)} key={event.id}>
|
||||
<span><strong>{event.title}</strong><small>{event.plate} · {event.id}</small></span>
|
||||
<StatusPill tone={event.tone}>{event.level}</StatusPill><time>{event.time}</time><StatusPill tone={event.state === '待确认' ? 'amber' : event.state === '处理中' ? 'red' : 'green'}>{event.state}</StatusPill><IconChevronRight />
|
||||
</button>
|
||||
))}
|
||||
<footer className="platform-pagination"><span>共 {rows.length} 条示例事件</span><div><button disabled type="button"><IconChevronLeft /></button><b>1</b><button disabled type="button"><IconChevronRight /></button></div></footer>
|
||||
</div>
|
||||
<aside className="platform-governance-inspector" aria-label={`${selected.id} 事件详情`}>
|
||||
<header><span><small>{selected.id}</small><strong>{selected.title}</strong></span><StatusPill tone={selected.tone}>{selected.level}</StatusPill></header>
|
||||
<section className="platform-inspector-summary"><div><small>关联车辆</small><strong>{selected.plate}</strong></div><div><small>发生时间</small><strong>07-03 {selected.time}</strong></div></section>
|
||||
<dl><div><dt>当前状态</dt><dd>{selected.state}</dd></div><div><dt>触发规则</dt><dd>{selected.title} · 生产版本 v4</dd></div><div><dt>证据来源</dt><dd>JT/T 808 · 位置与速度</dd></div><div><dt>通知结果</dt><dd>运营负责人已送达</dd></div></dl>
|
||||
<section className="platform-timeline"><strong>处置记录</strong><p><i />20:12 系统识别并创建事件</p><p><i />20:13 自动通知运营负责人</p><p><i />等待人工确认</p></section>
|
||||
<footer><button type="button">标记误报</button><button type="button" className="is-primary">确认并归档</button></footer>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessPage() {
|
||||
const [selectedSource, setSelectedSource] = useState(sourceRows[0].source);
|
||||
const selected = sourceRows.find((row) => row.source === selectedSource) ?? sourceRows[0];
|
||||
return (
|
||||
<main className="platform-page platform-governance-page">
|
||||
<CommandRail>
|
||||
<SearchField value="" onChange={() => undefined} placeholder="协议、端点或终端标识" label="搜索接入来源" />
|
||||
<div className="platform-tabs" role="tablist" aria-label="接入范围"><button type="button" className="is-active" role="tab" aria-selected="true">全部来源</button><button type="button" role="tab">运行中</button><button type="button" role="tab">需关注</button></div>
|
||||
<button type="button" className="platform-primary-button"><IconPlus />新建接入</button>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '协议来源', value: 4, unit: '个' },
|
||||
{ label: '在线终端', value: 128, unit: '个', tone: 'green' },
|
||||
{ label: '今日上报', value: '2.94M', unit: '条' },
|
||||
{ label: '需关注来源', value: 1, unit: '个', tone: 'amber' }
|
||||
]} />
|
||||
<section className="platform-directory-workspace">
|
||||
<div className="platform-directory">
|
||||
<header className="platform-directory-columns platform-access-columns"><span>协议来源 / 接入端点</span><span>车辆</span><span>今日上报</span><span>延迟</span><span>状态</span></header>
|
||||
{sourceRows.map((source) => (
|
||||
<button type="button" className={`${selectedSource === source.source ? 'is-selected' : ''} platform-access-row`} onClick={() => setSelectedSource(source.source)} key={source.source}>
|
||||
<span><strong>{source.source}</strong><small>{source.endpoint}</small></span><b>{source.vehicles}</b><b>{source.today}</b><time>{source.latency}</time><StatusPill tone={source.tone}>{source.state}</StatusPill><IconChevronRight />
|
||||
</button>
|
||||
))}
|
||||
<section className="platform-health-band">
|
||||
<header><span><strong>数据链路健康</strong><small>最近 15 分钟 · 端到端处理快照</small></span><StatusPill tone="green">整体正常</StatusPill></header>
|
||||
<div>{['连接接收', '协议解析', '去重融合', '时序入库'].map((item, index) => <span key={item}><IconTickCircle /><b>{item}</b><small>{[99.99, 99.98, 99.97, 99.99][index]}%</small></span>)}</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside className="platform-governance-inspector" aria-label={`${selected.source} 接入详情`}>
|
||||
<header><span><small>接入来源</small><strong>{selected.source}</strong></span><StatusPill tone={selected.tone}>{selected.state}</StatusPill></header>
|
||||
<section className="platform-inspector-summary"><div><small>车辆覆盖</small><strong>{selected.vehicles} 辆</strong></div><div><small>当前延迟</small><strong>{selected.latency}</strong></div></section>
|
||||
<dl><div><dt>接入端点</dt><dd>{selected.endpoint}</dd></div><div><dt>鉴权方式</dt><dd>终端白名单 + 密钥</dd></div><div><dt>最后接收</dt><dd>07-03 20:12:12</dd></div><div><dt>今日异常帧</dt><dd>3 条 · 0.0002%</dd></div></dl>
|
||||
<section className="platform-timeline"><strong>最近活动</strong><p><i />20:12 接收 1,284 个实时帧</p><p><i />20:10 配置健康检查通过</p><p><i />18:30 密钥轮换完成</p></section>
|
||||
<footer><button type="button">查看原始帧</button><button type="button" className="is-primary">编辑接入</button></footer>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersPage() {
|
||||
const [scope, setScope] = useState('全部状态');
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedName, setSelectedName] = useState(accountRows[0].name);
|
||||
const [notice, setNotice] = useState('');
|
||||
const selected = accountRows.find((row) => row.name === selectedName) ?? accountRows[0];
|
||||
const rows = accountRows.filter((row) => {
|
||||
const matchesQuery = `${row.name}${row.account}${row.source}`.toLowerCase().includes(query.toLowerCase());
|
||||
const matchesScope = scope === '全部状态' || (scope === '启用账号' && row.state === '启用') || (scope === '停用账号' && row.state === '停用') || (scope === '待完善权限' && row.state === '待完善');
|
||||
return matchesQuery && matchesScope;
|
||||
});
|
||||
return (
|
||||
<main className="platform-page platform-governance-page">
|
||||
<CommandRail>
|
||||
<SearchField value={query} onChange={setQuery} placeholder="名称、账号、客户标识或身份源" label="搜索客户账号" />
|
||||
<div className="platform-tabs" role="tablist" aria-label="账号范围">{['全部状态', '启用账号', '停用账号', '待完善权限'].map((item) => <button type="button" role="tab" aria-selected={scope === item} className={scope === item ? 'is-active' : ''} onClick={() => setScope(item)} key={item}>{item}</button>)}</div>
|
||||
<button type="button" className="platform-primary-button" onClick={() => setNotice('已打开新建客户账号草稿')}><IconPlus />新建客户账号</button>
|
||||
</CommandRail>
|
||||
<MetricStrip items={[
|
||||
{ label: '当前账号', value: 18, unit: '个' },
|
||||
{ label: '待完善', value: 3, unit: '个', tone: 'amber', note: '待补全权限设置' },
|
||||
{ label: '权限就绪', value: 14, unit: '个', tone: 'green', note: '可正常使用' },
|
||||
{ label: '外部身份', value: 5, unit: '个', note: '通过外部身份源' }
|
||||
]} />
|
||||
<section className="platform-directory-workspace">
|
||||
<div className="platform-directory">
|
||||
<header className="platform-directory-columns platform-account-columns"><span>客户名称</span><span>登录账号</span><span>身份源</span><span>菜单</span><span>车辆</span><span>状态</span></header>
|
||||
{rows.map((account) => (
|
||||
<button type="button" className={`${selectedName === account.name ? 'is-selected' : ''} platform-account-row`} onClick={() => setSelectedName(account.name)} key={account.account}>
|
||||
<strong>{account.name}</strong><span>{account.account}</span><span>{account.source}</span><b>{account.menus}</b><b>{account.vehicles}</b><StatusPill tone={account.tone}>{account.state}</StatusPill><IconChevronRight />
|
||||
</button>
|
||||
))}
|
||||
<footer className="platform-pagination"><span>共 {rows.length} 个示例账号</span><div><button disabled type="button"><IconChevronLeft /></button><b>1</b><button type="button"><IconChevronRight /></button></div></footer>
|
||||
</div>
|
||||
<aside className="platform-governance-inspector platform-account-inspector" aria-label={`${selected.name} 账号详情`}>
|
||||
<header><span><small>客户账号</small><strong>{selected.name}</strong></span><StatusPill tone={selected.tone}>{selected.state}</StatusPill></header>
|
||||
<dl><div><dt>登录账号</dt><dd>{selected.account}</dd></div><div><dt>身份来源</dt><dd>{selected.source}</dd></div><div><dt>最近登录</dt><dd>2026-07-27 19:48</dd></div></dl>
|
||||
<section className="platform-permission-section"><header><strong>菜单权限</strong><button type="button">编辑</button></header><div><b>{selected.menus} 个菜单</b><small>车辆工作台与平台治理</small></div></section>
|
||||
<section className="platform-permission-section"><header><strong>车辆权限与有效期</strong><button type="button">编辑</button></header><div><b>{selected.vehicles} 辆授权车</b><small>持续有效 · 账号范围</small></div></section>
|
||||
<footer><button type="button">停用账号</button><button type="button" className="is-primary" onClick={() => setNotice(`${selected.name} 权限已保存`)}>保存权限</button></footer>
|
||||
</aside>
|
||||
</section>
|
||||
{notice ? <div className="platform-notice" role="status">{notice}<button type="button" onClick={() => setNotice('')} aria-label="关闭提示"><IconClose /></button></div> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomNavigation({ page, onNavigate, onMore }: { page: PageKey; onNavigate: (page: PageKey | 'vehicles') => void; onMore: () => void }) {
|
||||
const items: Array<NavItem | { key: 'more'; label: string; icon: typeof IconMore }> = [
|
||||
{ key: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ key: 'vehicles', label: '车辆查询', icon: IconSearch },
|
||||
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
|
||||
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked },
|
||||
{ key: 'more', label: '更多', icon: IconMore }
|
||||
];
|
||||
return (
|
||||
<nav className="lab-bottom-nav" aria-label="移动端主导航">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = item.key === page || (item.key === 'more' && ['history', 'alerts', 'access', 'users'].includes(page));
|
||||
return <button type="button" className={active ? 'is-active' : ''} onClick={() => item.key === 'more' ? onMore() : onNavigate(item.key)} key={item.key}><Icon /><span>{item.label}</span></button>;
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileMenu({ page, onNavigate, onClose }: { page: PageKey; onNavigate: (page: PageKey | 'vehicles') => void; onClose: () => void }) {
|
||||
return (
|
||||
<div className="lab-sheet-backdrop platform-mobile-menu-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
|
||||
<section className="platform-mobile-menu" role="dialog" aria-modal="true" aria-label="页面导航">
|
||||
<header><span><strong>切换页面</strong><small>车辆工作台与平台治理</small></span><button type="button" onClick={onClose} aria-label="关闭页面导航"><IconClose /></button></header>
|
||||
<div>{[...workspaceNavigation, ...governanceNavigation].map((item) => {
|
||||
const Icon = item.icon;
|
||||
return <button type="button" className={item.key === page ? 'is-active' : ''} onClick={() => onNavigate(item.key)} key={item.key}><Icon /><span><strong>{item.label}</strong><small>{item.key === 'vehicles' ? '车辆目录与数字档案' : item.key in pageMeta ? pageMeta[item.key as PageKey].subtitle : ''}</small></span><IconChevronRight /></button>;
|
||||
})}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPage(page: PageKey) {
|
||||
switch (page) {
|
||||
case 'monitor': return <MonitorPage />;
|
||||
case 'tracks': return <TrackPage />;
|
||||
case 'history': return <HistoryPage />;
|
||||
case 'statistics': return <StatisticsPage />;
|
||||
case 'alerts': return <AlertsPage />;
|
||||
case 'access': return <AccessPage />;
|
||||
case 'users': return <UsersPage />;
|
||||
}
|
||||
}
|
||||
|
||||
export function PlatformDesignLab() {
|
||||
const [page, setPage] = useState<PageKey>(() => pageFromHash());
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => setPage(pageFromHash());
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
|
||||
const navigate = (target: PageKey | 'vehicles') => {
|
||||
setMobileMenuOpen(false);
|
||||
if (target === 'vehicles') {
|
||||
window.location.href = '/vehicle-design-lab.html';
|
||||
return;
|
||||
}
|
||||
window.location.hash = target;
|
||||
setPage(target);
|
||||
};
|
||||
|
||||
const content = useMemo(() => renderPage(page), [page]);
|
||||
|
||||
return (
|
||||
<div className="vehicle-design-lab platform-design-lab">
|
||||
<Sidebar page={page} onNavigate={navigate} />
|
||||
<div className="lab-stage">
|
||||
<TopBar page={page} onOpenMenu={() => setMobileMenuOpen(true)} />
|
||||
{content}
|
||||
<BottomNavigation page={page} onNavigate={navigate} onMore={() => setMobileMenuOpen(true)} />
|
||||
{mobileMenuOpen ? <MobileMenu page={page} onNavigate={navigate} onClose={() => setMobileMenuOpen(false)} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
import {
|
||||
IconBarChartVStroked,
|
||||
IconBox,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconClock,
|
||||
IconClose,
|
||||
IconCopy,
|
||||
IconFilter,
|
||||
IconHelpCircle,
|
||||
IconHome,
|
||||
IconMore,
|
||||
IconRefresh,
|
||||
IconRoute,
|
||||
IconSearch
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
|
||||
type DirectoryView = 'all' | 'online' | 'offline' | 'multi';
|
||||
type VehicleStatus = 'online' | 'offline';
|
||||
type ProtocolKind = 'JT/T 808' | 'GB/T 32960' | '宇通 MQTT';
|
||||
|
||||
type VehicleRecord = {
|
||||
plate: string;
|
||||
vin: string;
|
||||
brand: string;
|
||||
terminal: string;
|
||||
status: VehicleStatus;
|
||||
updatedAt: string;
|
||||
protocols: ProtocolKind[];
|
||||
department: string;
|
||||
owner: string;
|
||||
customer: string;
|
||||
};
|
||||
|
||||
const vehicles: VehicleRecord[] = [
|
||||
{
|
||||
plate: '粤AG18312',
|
||||
vin: 'LB9A32A24R0LS1426',
|
||||
brand: 'G7s',
|
||||
terminal: '13307795425',
|
||||
status: 'online',
|
||||
updatedAt: '07-03 20:12',
|
||||
protocols: ['JT/T 808', 'GB/T 32960'],
|
||||
department: '华南运营部',
|
||||
owner: '陈思远',
|
||||
customer: '羚牛示范车队'
|
||||
},
|
||||
{
|
||||
plate: '川AHTW01',
|
||||
vin: 'LNXNEGRR7SR318212',
|
||||
brand: 'Hyundai',
|
||||
terminal: '暂无终端手机号',
|
||||
status: 'online',
|
||||
updatedAt: '07-03 20:12',
|
||||
protocols: ['GB/T 32960'],
|
||||
department: '西南交付部',
|
||||
owner: '王凌',
|
||||
customer: '川渝联合车队'
|
||||
},
|
||||
{
|
||||
plate: '豫A88888',
|
||||
vin: 'LMRKH9AC2R1004087',
|
||||
brand: '宇通',
|
||||
terminal: '暂无终端手机号',
|
||||
status: 'online',
|
||||
updatedAt: '07-03 20:11',
|
||||
protocols: ['宇通 MQTT'],
|
||||
department: '中原运营部',
|
||||
owner: '刘真',
|
||||
customer: '宇通测试车队'
|
||||
},
|
||||
{
|
||||
plate: '粤AFF7936',
|
||||
vin: 'LB9A32A24P0LS1230',
|
||||
brand: '广安车联',
|
||||
terminal: '13307795426',
|
||||
status: 'offline',
|
||||
updatedAt: '07-03 19:58',
|
||||
protocols: ['JT/T 808'],
|
||||
department: '华南运营部',
|
||||
owner: '陈思远',
|
||||
customer: '羚牛示范车队'
|
||||
}
|
||||
];
|
||||
|
||||
const navigation = [
|
||||
{ key: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ key: 'vehicles', label: '车辆查询', icon: IconSearch, active: true },
|
||||
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
|
||||
{ key: 'history', label: '历史数据', icon: IconClock },
|
||||
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked }
|
||||
];
|
||||
|
||||
const governanceNavigation = [
|
||||
{ key: 'alerts', label: '事件中心', icon: IconClock },
|
||||
{ key: 'access', label: '接入管理', icon: IconBox },
|
||||
{ key: 'users', label: '账号管理', icon: IconMore }
|
||||
];
|
||||
|
||||
const viewOptions: Array<{ key: DirectoryView; label: string }> = [
|
||||
{ key: 'all', label: '全部车辆' },
|
||||
{ key: 'online', label: '当前在线' },
|
||||
{ key: 'offline', label: '当前离线' },
|
||||
{ key: 'multi', label: '多源车辆' }
|
||||
];
|
||||
|
||||
function ProtocolTag({ protocol }: { protocol: ProtocolKind }) {
|
||||
const tone = protocol === 'JT/T 808' ? 'cyan' : protocol === 'GB/T 32960' ? 'blue' : 'violet';
|
||||
return <span className={`lab-protocol-tag is-${tone}`}>{protocol}</span>;
|
||||
}
|
||||
|
||||
function StatusTag({ status }: { status: VehicleStatus }) {
|
||||
return <span className={`lab-status-tag is-${status}`}>{status === 'online' ? '在线' : '离线'}</span>;
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const navigate = (key: string) => {
|
||||
if (key !== 'vehicles') window.location.href = `/platform-design-lab.html#${key}`;
|
||||
};
|
||||
return (
|
||||
<aside className="lab-sidebar" aria-label="车辆数据中台导航">
|
||||
<div className="lab-brand">
|
||||
<img src="/brand-logo.svg" alt="羚牛智能" />
|
||||
</div>
|
||||
<nav>
|
||||
<p>车辆工作台</p>
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button className={item.active ? 'is-active' : ''} type="button" key={item.label} onClick={() => navigate(item.key)}>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<p>平台治理</p>
|
||||
{governanceNavigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button type="button" key={item.label} onClick={() => navigate(item.key)}>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<button className="lab-collapse" type="button">
|
||||
<IconChevronLeft aria-hidden="true" />
|
||||
<span>收起</span>
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ onHelp }: { onHelp: () => void }) {
|
||||
return (
|
||||
<header className="lab-topbar">
|
||||
<div className="lab-topbar-title">
|
||||
<img src="/brand-mark.svg" alt="" aria-hidden="true" />
|
||||
<span>
|
||||
<strong>车辆查询</strong>
|
||||
<small>按车牌或 VIN 查询车辆档案和实时遥测。</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="lab-topbar-actions">
|
||||
<button type="button" className="lab-help-button" onClick={onHelp}>
|
||||
<IconHelpCircle aria-hidden="true" />
|
||||
<span>页面帮助</span>
|
||||
</button>
|
||||
<button type="button" className="lab-account-button" aria-label="账号菜单">
|
||||
<span className="lab-avatar">I</span>
|
||||
<b>local-developer</b>
|
||||
<em>管理员</em>
|
||||
<IconChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
type CommandSurfaceProps = {
|
||||
keyword: string;
|
||||
view: DirectoryView;
|
||||
department: string;
|
||||
owner: string;
|
||||
customer: string;
|
||||
status: string;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onViewChange: (view: DirectoryView) => void;
|
||||
onDepartmentChange: (value: string) => void;
|
||||
onOwnerChange: (value: string) => void;
|
||||
onCustomerChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
};
|
||||
|
||||
function CommandSurface({
|
||||
keyword,
|
||||
view,
|
||||
department,
|
||||
owner,
|
||||
customer,
|
||||
status,
|
||||
onKeywordChange,
|
||||
onViewChange,
|
||||
onDepartmentChange,
|
||||
onOwnerChange,
|
||||
onCustomerChange,
|
||||
onStatusChange,
|
||||
onSubmit
|
||||
}: CommandSurfaceProps) {
|
||||
return (
|
||||
<section className="lab-command-surface" aria-label="车辆查询命令栏">
|
||||
<div className="lab-command-primary">
|
||||
<form className="lab-search-form" onSubmit={onSubmit}>
|
||||
<IconSearch aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={keyword}
|
||||
onChange={(event) => onKeywordChange(event.target.value)}
|
||||
placeholder="输入车牌 / VIN / 终端手机号"
|
||||
aria-label="输入车牌、VIN 或终端手机号"
|
||||
/>
|
||||
<button type="submit">
|
||||
<span>查询车辆</span>
|
||||
<IconChevronRight aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
<div className="lab-view-tabs" role="tablist" aria-label="车辆目录视图">
|
||||
{viewOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === option.key}
|
||||
className={view === option.key ? 'is-active' : ''}
|
||||
onClick={() => onViewChange(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="lab-batch-button" type="button">
|
||||
<IconBox aria-hidden="true" />
|
||||
<span>批量同步主档</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="lab-filter-row">
|
||||
<label>
|
||||
<span>部门</span>
|
||||
<select value={department} onChange={(event) => onDepartmentChange(event.target.value)}>
|
||||
<option value="">全部部门</option>
|
||||
<option>华南运营部</option>
|
||||
<option>西南交付部</option>
|
||||
<option>中原运营部</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>业务负责人</span>
|
||||
<select value={owner} onChange={(event) => onOwnerChange(event.target.value)}>
|
||||
<option value="">全部负责人</option>
|
||||
<option>陈思远</option>
|
||||
<option>王凌</option>
|
||||
<option>刘真</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>客户</span>
|
||||
<select value={customer} onChange={(event) => onCustomerChange(event.target.value)}>
|
||||
<option value="">全部客户</option>
|
||||
<option>羚牛示范车队</option>
|
||||
<option>川渝联合车队</option>
|
||||
<option>宇通测试车队</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select value={status} onChange={(event) => onStatusChange(event.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCommandSurface({
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
onSubmit,
|
||||
activeFilterCount,
|
||||
onOpenFilters,
|
||||
onBatch
|
||||
}: {
|
||||
keyword: string;
|
||||
onKeywordChange: (value: string) => void;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
activeFilterCount: number;
|
||||
onOpenFilters: () => void;
|
||||
onBatch: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<form className="lab-mobile-search" onSubmit={onSubmit}>
|
||||
<IconSearch aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
value={keyword}
|
||||
onChange={(event) => onKeywordChange(event.target.value)}
|
||||
placeholder="车牌 / VIN / 终端手机号"
|
||||
aria-label="输入车牌、VIN 或终端手机号"
|
||||
/>
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
<section className="lab-mobile-scope" aria-label="车辆范围">
|
||||
<button className="lab-mobile-filter" type="button" onClick={onOpenFilters} aria-label="打开车辆筛选">
|
||||
<IconFilter aria-hidden="true" />
|
||||
{activeFilterCount > 0 ? <b>{activeFilterCount}</b> : null}
|
||||
</button>
|
||||
<span>
|
||||
<strong>车辆范围</strong>
|
||||
<small>4 辆授权车辆</small>
|
||||
</span>
|
||||
<button className="lab-mobile-modify" type="button" onClick={onOpenFilters}>
|
||||
修改
|
||||
<IconChevronDown aria-hidden="true" />
|
||||
</button>
|
||||
<button className="lab-mobile-batch" type="button" onClick={onBatch}>
|
||||
<IconBox aria-hidden="true" />
|
||||
<span>批量同步</span>
|
||||
</button>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricRail({ records }: { records: VehicleRecord[] }) {
|
||||
const online = records.filter((vehicle) => vehicle.status === 'online').length;
|
||||
const multi = records.filter((vehicle) => vehicle.protocols.length > 1).length;
|
||||
const metrics = [
|
||||
{ label: '授权车辆', value: records.length, tone: 'primary' },
|
||||
{ label: '本页在线', value: online, tone: 'success' },
|
||||
{ label: '本页多源', value: multi, tone: 'primary' }
|
||||
];
|
||||
return (
|
||||
<section className="lab-metric-rail" aria-label="车辆目录摘要">
|
||||
{metrics.map((metric) => (
|
||||
<div key={metric.label}>
|
||||
<span>{metric.label}</span>
|
||||
<strong className={`is-${metric.tone}`}>{metric.value}<small>辆</small></strong>
|
||||
</div>
|
||||
))}
|
||||
<aside>
|
||||
<span>
|
||||
<strong>授权车辆目录</strong>
|
||||
<small>按照最新上报时间排序,分页浏览全部授权车辆</small>
|
||||
</span>
|
||||
<button type="button">第 1 / 1 页</button>
|
||||
<button type="button" className="lab-refresh-button" aria-label="刷新车辆目录">
|
||||
<IconRefresh aria-hidden="true" />
|
||||
<span>刷新</span>
|
||||
</button>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DesktopDirectory({
|
||||
records,
|
||||
selectedVin,
|
||||
onSelect
|
||||
}: {
|
||||
records: VehicleRecord[];
|
||||
selectedVin?: string;
|
||||
onSelect: (vehicle: VehicleRecord) => void;
|
||||
}) {
|
||||
const selectByKeyboard = (event: KeyboardEvent<HTMLTableRowElement>, vehicle: VehicleRecord) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onSelect(vehicle);
|
||||
};
|
||||
return (
|
||||
<table className="lab-vehicle-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>车辆</th>
|
||||
<th>品牌 / 终端</th>
|
||||
<th>实时状态</th>
|
||||
<th>协议来源</th>
|
||||
<th>最后上报时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((vehicle) => (
|
||||
<tr
|
||||
key={vehicle.vin}
|
||||
tabIndex={0}
|
||||
aria-selected={vehicle.vin === selectedVin}
|
||||
className={vehicle.vin === selectedVin ? 'is-selected' : ''}
|
||||
onClick={() => onSelect(vehicle)}
|
||||
onKeyDown={(event) => selectByKeyboard(event, vehicle)}
|
||||
>
|
||||
<td>
|
||||
<strong>{vehicle.plate}</strong>
|
||||
<small>{vehicle.vin}</small>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{vehicle.brand}</strong>
|
||||
<small>{vehicle.terminal}</small>
|
||||
</td>
|
||||
<td><StatusTag status={vehicle.status} /></td>
|
||||
<td>
|
||||
<span className="lab-protocol-list">
|
||||
{vehicle.protocols.map((protocol) => <ProtocolTag key={protocol} protocol={protocol} />)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{vehicle.updatedAt}:12</td>
|
||||
<td>
|
||||
<button type="button" onClick={(event) => { event.stopPropagation(); onSelect(vehicle); }}>
|
||||
查看档案
|
||||
<IconChevronRight aria-hidden="true" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileDirectory({
|
||||
records,
|
||||
selectedVin,
|
||||
onSelect
|
||||
}: {
|
||||
records: VehicleRecord[];
|
||||
selectedVin?: string;
|
||||
onSelect: (vehicle: VehicleRecord) => void;
|
||||
}) {
|
||||
return (
|
||||
<ul className="lab-mobile-directory" aria-label="车辆目录">
|
||||
{records.map((vehicle) => (
|
||||
<li key={vehicle.vin}>
|
||||
<button
|
||||
type="button"
|
||||
className={vehicle.vin === selectedVin ? 'is-selected' : ''}
|
||||
aria-pressed={vehicle.vin === selectedVin}
|
||||
onClick={() => onSelect(vehicle)}
|
||||
>
|
||||
<span className={`lab-status-dot is-${vehicle.status}`} aria-hidden="true" />
|
||||
<span className="lab-mobile-identity">
|
||||
<strong>{vehicle.plate}</strong>
|
||||
<small>{vehicle.vin}</small>
|
||||
<span className="lab-protocol-list">
|
||||
{vehicle.protocols.map((protocol) => <ProtocolTag key={protocol} protocol={protocol} />)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="lab-mobile-state">
|
||||
<StatusTag status={vehicle.status} />
|
||||
<small>{vehicle.updatedAt} 更新</small>
|
||||
</span>
|
||||
<IconChevronRight className="lab-mobile-row-chevron" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function VehicleInspector({
|
||||
vehicle,
|
||||
mobile,
|
||||
sheetOffset,
|
||||
onClose,
|
||||
onCopyVin,
|
||||
onDragStart,
|
||||
onDragMove,
|
||||
onDragEnd
|
||||
}: {
|
||||
vehicle: VehicleRecord;
|
||||
mobile?: boolean;
|
||||
sheetOffset?: number;
|
||||
onClose: () => void;
|
||||
onCopyVin: () => void;
|
||||
onDragStart?: (event: PointerEvent<HTMLButtonElement>) => void;
|
||||
onDragMove?: (event: PointerEvent<HTMLButtonElement>) => void;
|
||||
onDragEnd?: (event: PointerEvent<HTMLButtonElement>) => void;
|
||||
}) {
|
||||
const style = mobile
|
||||
? ({ '--lab-sheet-offset': `${sheetOffset ?? 0}px` } as CSSProperties)
|
||||
: undefined;
|
||||
return (
|
||||
<aside
|
||||
className={mobile ? 'lab-mobile-inspector' : 'lab-desktop-inspector'}
|
||||
aria-label={`${vehicle.plate} 车辆档案`}
|
||||
style={style}
|
||||
>
|
||||
{mobile ? (
|
||||
<button
|
||||
type="button"
|
||||
className="lab-sheet-handle"
|
||||
aria-label="拖动或关闭车辆详情"
|
||||
onPointerDown={onDragStart}
|
||||
onPointerMove={onDragMove}
|
||||
onPointerUp={onDragEnd}
|
||||
onPointerCancel={onDragEnd}
|
||||
>
|
||||
<span />
|
||||
</button>
|
||||
) : null}
|
||||
<header>
|
||||
<span>
|
||||
<small>{mobile ? '车辆详情' : '车辆档案'}</small>
|
||||
<strong>{vehicle.plate}</strong>
|
||||
</span>
|
||||
<button type="button" onClick={onClose} aria-label="关闭车辆档案">
|
||||
<IconClose aria-hidden="true" />
|
||||
{mobile ? <span>关闭</span> : null}
|
||||
</button>
|
||||
</header>
|
||||
<div className="lab-inspector-status">
|
||||
<StatusTag status={vehicle.status} />
|
||||
<small>最近更新 {vehicle.updatedAt}</small>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>VIN</dt>
|
||||
<dd>
|
||||
<span>{vehicle.vin}</span>
|
||||
<button type="button" onClick={onCopyVin} aria-label="复制 VIN"><IconCopy aria-hidden="true" /></button>
|
||||
</dd>
|
||||
</div>
|
||||
<div><dt>品牌 / 车型</dt><dd>{vehicle.brand}</dd></div>
|
||||
<div><dt>终端编号</dt><dd>{vehicle.terminal}</dd></div>
|
||||
<div><dt>业务负责人</dt><dd>{vehicle.owner}</dd></div>
|
||||
<div><dt>所属部门</dt><dd>{vehicle.department}</dd></div>
|
||||
<div><dt>授权状态</dt><dd>已授权</dd></div>
|
||||
</dl>
|
||||
<section>
|
||||
<strong>协议来源</strong>
|
||||
{vehicle.protocols.map((protocol) => (
|
||||
<div className="lab-inspector-protocol" key={protocol}>
|
||||
<span className="lab-protocol-dot" aria-hidden="true" />
|
||||
<span>
|
||||
<b>{protocol}</b>
|
||||
<small>最后上报 {vehicle.updatedAt}:12</small>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
<button type="button" className="lab-inspector-primary">查看完整档案<IconChevronRight aria-hidden="true" /></button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileFilterSheet({
|
||||
view,
|
||||
status,
|
||||
department,
|
||||
onViewChange,
|
||||
onStatusChange,
|
||||
onDepartmentChange,
|
||||
onReset,
|
||||
onClose
|
||||
}: {
|
||||
view: DirectoryView;
|
||||
status: string;
|
||||
department: string;
|
||||
onViewChange: (view: DirectoryView) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onDepartmentChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="lab-sheet-backdrop" role="presentation" onMouseDown={(event) => {
|
||||
if (event.currentTarget === event.target) onClose();
|
||||
}}>
|
||||
<section className="lab-filter-sheet" role="dialog" aria-modal="true" aria-labelledby="lab-filter-title">
|
||||
<header>
|
||||
<span><strong id="lab-filter-title">筛选车辆</strong><small>范围、状态与业务归属</small></span>
|
||||
<button type="button" onClick={onClose} aria-label="关闭筛选"><IconClose aria-hidden="true" /></button>
|
||||
</header>
|
||||
<fieldset>
|
||||
<legend>车辆范围</legend>
|
||||
<div className="lab-filter-options">
|
||||
{viewOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={view === option.key ? 'is-active' : ''}
|
||||
onClick={() => onViewChange(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label>
|
||||
<span>在线状态</span>
|
||||
<select value={status} onChange={(event) => onStatusChange(event.target.value)}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>所属部门</span>
|
||||
<select value={department} onChange={(event) => onDepartmentChange(event.target.value)}>
|
||||
<option value="">全部部门</option>
|
||||
<option>华南运营部</option>
|
||||
<option>西南交付部</option>
|
||||
<option>中原运营部</option>
|
||||
</select>
|
||||
</label>
|
||||
<footer>
|
||||
<button type="button" onClick={onReset}>重置</button>
|
||||
<button type="button" className="is-primary" onClick={onClose}>查看结果</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomNavigation() {
|
||||
const items = [
|
||||
{ key: 'monitor', label: '全局监控', icon: IconHome },
|
||||
{ key: 'vehicles', label: '车辆查询', icon: IconSearch, active: true },
|
||||
{ key: 'tracks', label: '轨迹回放', icon: IconRoute },
|
||||
{ key: 'statistics', label: '里程查询', icon: IconBarChartVStroked },
|
||||
{ key: 'alerts', label: '更多', icon: IconMore }
|
||||
];
|
||||
return (
|
||||
<nav className="lab-bottom-nav" aria-label="移动端主导航">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={item.active ? 'is-active' : ''}
|
||||
key={item.label}
|
||||
onClick={() => item.key !== 'vehicles' && (window.location.href = `/platform-design-lab.html#${item.key}`)}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function VehicleDesignLab() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const deferredKeyword = useDeferredValue(keyword.trim().toLowerCase());
|
||||
const [view, setView] = useState<DirectoryView>('all');
|
||||
const [department, setDepartment] = useState('');
|
||||
const [owner, setOwner] = useState('');
|
||||
const [customer, setCustomer] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [selectedVin, setSelectedVin] = useState(vehicles[0].vin);
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [notice, setNotice] = useState('');
|
||||
const [sheetOffset, setSheetOffset] = useState(0);
|
||||
const dragStartRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const filteredVehicles = useMemo(() => vehicles.filter((vehicle) => {
|
||||
const matchesKeyword = !deferredKeyword || [
|
||||
vehicle.plate,
|
||||
vehicle.vin,
|
||||
vehicle.brand,
|
||||
vehicle.terminal
|
||||
].some((value) => value.toLowerCase().includes(deferredKeyword));
|
||||
const matchesView = view === 'all'
|
||||
|| view === 'online' && vehicle.status === 'online'
|
||||
|| view === 'offline' && vehicle.status === 'offline'
|
||||
|| view === 'multi' && vehicle.protocols.length > 1;
|
||||
return matchesKeyword
|
||||
&& matchesView
|
||||
&& (!department || vehicle.department === department)
|
||||
&& (!owner || vehicle.owner === owner)
|
||||
&& (!customer || vehicle.customer === customer)
|
||||
&& (!status || vehicle.status === status);
|
||||
}), [customer, deferredKeyword, department, owner, status, view]);
|
||||
|
||||
const selectedVehicle = vehicles.find((vehicle) => vehicle.vin === selectedVin);
|
||||
const activeFilterCount = [view !== 'all', !!department, !!owner, !!customer, !!status].filter(Boolean).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timeout = window.setTimeout(() => setNotice(''), 2400);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [notice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterOpen) return;
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setFilterOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [filterOpen]);
|
||||
|
||||
const submitSearch = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setNotice(`已找到 ${filteredVehicles.length} 辆车辆`);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setView('all');
|
||||
setDepartment('');
|
||||
setOwner('');
|
||||
setCustomer('');
|
||||
setStatus('');
|
||||
};
|
||||
|
||||
const copySelectedVin = async () => {
|
||||
if (!selectedVehicle) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedVehicle.vin);
|
||||
setNotice('VIN 已复制');
|
||||
} catch {
|
||||
setNotice('当前浏览器未开放剪贴板权限');
|
||||
}
|
||||
};
|
||||
|
||||
const onDragStart = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
dragStartRef.current = event.clientY;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const onDragMove = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (dragStartRef.current === undefined) return;
|
||||
setSheetOffset(Math.max(0, event.clientY - dragStartRef.current));
|
||||
};
|
||||
|
||||
const onDragEnd = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
dragStartRef.current = undefined;
|
||||
if (sheetOffset > 110) setSelectedVin('');
|
||||
setSheetOffset(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`vehicle-design-lab${selectedVehicle ? ' has-inspector' : ''}`}>
|
||||
<Sidebar />
|
||||
<div className="lab-stage">
|
||||
<TopBar onHelp={() => setNotice('帮助:搜索车辆后选择一行查看档案')} />
|
||||
<main>
|
||||
<CommandSurface
|
||||
keyword={keyword}
|
||||
view={view}
|
||||
department={department}
|
||||
owner={owner}
|
||||
customer={customer}
|
||||
status={status}
|
||||
onKeywordChange={setKeyword}
|
||||
onViewChange={setView}
|
||||
onDepartmentChange={setDepartment}
|
||||
onOwnerChange={setOwner}
|
||||
onCustomerChange={setCustomer}
|
||||
onStatusChange={setStatus}
|
||||
onSubmit={submitSearch}
|
||||
/>
|
||||
<MobileCommandSurface
|
||||
keyword={keyword}
|
||||
onKeywordChange={setKeyword}
|
||||
onSubmit={submitSearch}
|
||||
activeFilterCount={activeFilterCount}
|
||||
onOpenFilters={() => setFilterOpen(true)}
|
||||
onBatch={() => setNotice('已创建 4 辆车辆的主档同步预览')}
|
||||
/>
|
||||
<MetricRail records={vehicles} />
|
||||
<div className="lab-directory-layout">
|
||||
<section className="lab-directory-surface" aria-label="授权车辆目录">
|
||||
<header className="lab-directory-header">
|
||||
<span>
|
||||
<strong>授权车辆目录</strong>
|
||||
<small>按照最新上报时间排序</small>
|
||||
</span>
|
||||
<em>{filteredVehicles.length} 辆</em>
|
||||
</header>
|
||||
{filteredVehicles.length > 0 ? (
|
||||
<>
|
||||
<DesktopDirectory records={filteredVehicles} selectedVin={selectedVin} onSelect={(vehicle) => setSelectedVin(vehicle.vin)} />
|
||||
<MobileDirectory records={filteredVehicles} selectedVin={selectedVin} onSelect={(vehicle) => setSelectedVin(vehicle.vin)} />
|
||||
</>
|
||||
) : (
|
||||
<div className="lab-empty-state">
|
||||
<IconSearch aria-hidden="true" />
|
||||
<strong>没有匹配车辆</strong>
|
||||
<span>调整关键词或清除筛选后重试。</span>
|
||||
<button type="button" onClick={resetFilters}>清除筛选</button>
|
||||
</div>
|
||||
)}
|
||||
<footer className="lab-directory-footer">
|
||||
<span>共 {filteredVehicles.length} 辆 · 本页 {filteredVehicles.length} 辆</span>
|
||||
<div>
|
||||
<button type="button" disabled aria-label="上一页"><IconChevronLeft aria-hidden="true" /></button>
|
||||
<span>1 / 1</span>
|
||||
<button type="button" disabled aria-label="下一页"><IconChevronRight aria-hidden="true" /></button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
{selectedVehicle ? (
|
||||
<VehicleInspector
|
||||
vehicle={selectedVehicle}
|
||||
onClose={() => setSelectedVin('')}
|
||||
onCopyVin={copySelectedVin}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
<BottomNavigation />
|
||||
{selectedVehicle ? (
|
||||
<VehicleInspector
|
||||
mobile
|
||||
vehicle={selectedVehicle}
|
||||
sheetOffset={sheetOffset}
|
||||
onClose={() => setSelectedVin('')}
|
||||
onCopyVin={copySelectedVin}
|
||||
onDragStart={onDragStart}
|
||||
onDragMove={onDragMove}
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
) : null}
|
||||
{filterOpen ? (
|
||||
<MobileFilterSheet
|
||||
view={view}
|
||||
status={status}
|
||||
department={department}
|
||||
onViewChange={setView}
|
||||
onStatusChange={setStatus}
|
||||
onDepartmentChange={setDepartment}
|
||||
onReset={resetFilters}
|
||||
onClose={() => setFilterOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{notice ? <div className="lab-notice" role="status">{notice}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { VehicleDesignLab } from './VehicleDesignLab';
|
||||
import './vehicle-design-lab.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('vehicle-design-lab-root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<VehicleDesignLab />
|
||||
</React.StrictMode>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { PlatformDesignLab } from './PlatformDesignLab';
|
||||
import './vehicle-design-lab.css';
|
||||
import './platform-design-lab.css';
|
||||
|
||||
createRoot(document.getElementById('platform-design-lab-root')!).render(
|
||||
<StrictMode>
|
||||
<PlatformDesignLab />
|
||||
</StrictMode>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,11 @@
|
||||
import { getAMapConfig, isAMapConfigured } from '../config/appConfig';
|
||||
|
||||
export type AMapPlugin = 'AMap.Scale' | 'AMap.Geocoder' | 'AMap.ToolBar';
|
||||
export type AMapPlugin = 'AMap.Scale' | 'AMap.Geocoder' | 'AMap.ToolBar' | 'AMap.MouseTool';
|
||||
|
||||
export type AMapLngLat = {
|
||||
getLng: () => number;
|
||||
getLat: () => number;
|
||||
};
|
||||
|
||||
export type AMapMap = {
|
||||
add: (overlay: AMapOverlay | AMapOverlay[]) => void;
|
||||
@@ -28,6 +33,18 @@ export type AMapOverlay = {
|
||||
setPath?: (path: [number, number][]) => void;
|
||||
};
|
||||
|
||||
export type AMapCircleOverlay = AMapOverlay & {
|
||||
getCenter: () => AMapLngLat;
|
||||
getRadius: () => number;
|
||||
};
|
||||
|
||||
export type AMapMouseTool = {
|
||||
circle: (options?: Record<string, unknown>) => void;
|
||||
close: (clear?: boolean) => void;
|
||||
on: (eventName: 'draw', handler: (event: { obj?: AMapCircleOverlay }) => void) => void;
|
||||
off?: (eventName: 'draw', handler: (event: { obj?: AMapCircleOverlay }) => void) => void;
|
||||
};
|
||||
|
||||
export type AMapMassMarks = {
|
||||
setMap: (map: AMapMap | null) => void;
|
||||
setData: (data: AMapMassPoint[]) => void;
|
||||
@@ -107,6 +124,8 @@ export type AMapLike = {
|
||||
Map: new (container: HTMLDivElement, options: Record<string, unknown>) => AMapMap;
|
||||
Marker: new (options: Record<string, unknown>) => AMapOverlay;
|
||||
Polyline: new (options: Record<string, unknown>) => AMapOverlay;
|
||||
Circle?: new (options: Record<string, unknown>) => AMapCircleOverlay;
|
||||
MouseTool?: new (map: AMapMap) => AMapMouseTool;
|
||||
Scale: new () => unknown;
|
||||
ToolBar?: new (options?: Record<string, unknown>) => unknown;
|
||||
Size: new (width: number, height: number) => unknown;
|
||||
|
||||
@@ -4,6 +4,9 @@ import { AppV2 } from './v2/AppV2';
|
||||
import './v2/styles/semi-theme.scss';
|
||||
import './v2/styles/v2.css';
|
||||
import './v2/styles/workspace.css';
|
||||
import './v2/styles/foundation.css';
|
||||
import './v2/styles/experience.css';
|
||||
import './v2/styles/event-center.css';
|
||||
|
||||
if ('scrollRestoration' in window.history) {
|
||||
window.history.scrollRestoration = 'manual';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Card, Select, Space, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { api, type MileageQuery } from '../api/client';
|
||||
import type { DailyMileageRow, MileageStatistics, MileageTrendPoint } from '../api/types';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
|
||||
@@ -46,13 +46,13 @@ function initialMileageFilters(initialVin: string, initialProtocol = '', initial
|
||||
};
|
||||
}
|
||||
|
||||
function queryParams(filters: MileageFilters, pageSize?: number, offset?: number) {
|
||||
const params = new URLSearchParams({ dateFrom: filters.dateFrom, dateTo: filters.dateTo });
|
||||
if (filters.keyword.trim()) params.set('keyword', filters.keyword.trim());
|
||||
if (filters.protocol) params.set('protocol', filters.protocol);
|
||||
if (pageSize != null) params.set('limit', String(pageSize));
|
||||
if (offset != null) params.set('offset', String(offset));
|
||||
return params;
|
||||
function mileageQuery(filters: MileageFilters, pageSize?: number, offset?: number): MileageQuery {
|
||||
const query: MileageQuery = { dateFrom: filters.dateFrom, dateTo: filters.dateTo };
|
||||
if (filters.keyword.trim()) query.keyword = filters.keyword.trim();
|
||||
if (filters.protocol) query.protocol = filters.protocol;
|
||||
if (pageSize != null) query.limit = pageSize;
|
||||
if (offset != null) query.offset = offset;
|
||||
return query;
|
||||
}
|
||||
|
||||
function sharedFilters(filters: MileageFilters): Record<string, string> {
|
||||
@@ -142,8 +142,8 @@ export function Mileage({
|
||||
setLoading(true);
|
||||
try {
|
||||
const [stats, detail] = await Promise.all([
|
||||
api.mileageStatistics(queryParams(nextFilters)),
|
||||
api.dailyMileage(queryParams(nextFilters, pageSize, (page - 1) * pageSize))
|
||||
api.mileageStatistics(mileageQuery(nextFilters)),
|
||||
api.dailyMileage(mileageQuery(nextFilters, pageSize, (page - 1) * pageSize))
|
||||
]);
|
||||
setStatistics(stats);
|
||||
setRows(detail.items ?? []);
|
||||
|
||||
@@ -1026,7 +1026,7 @@ test('dashboard renders vehicle service summary metrics', async () => {
|
||||
expect(screen.getByText('档案不完整')).toBeInTheDocument();
|
||||
expect(screen.getByText('366')).toBeInTheDocument();
|
||||
expect(screen.queryByText('NaN%')).not.toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/vehicle-service/summary', expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('dashboard presents one vehicle service operating posture', async () => {
|
||||
@@ -3159,7 +3159,7 @@ test('opens dashboard coverage from service action queue', async () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '补齐 YUTONG_MQTT 来源 983' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3212,7 +3212,7 @@ test('opens vehicle list filtered by service summary KPI', async () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '告警车辆 7' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&serviceStatus=degraded'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&serviceStatus=degraded'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(window.location.hash).toBe('#/vehicles?serviceStatus=degraded');
|
||||
});
|
||||
@@ -3731,7 +3731,7 @@ test('filters vehicle list from recommended action', async () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '补齐 YUTONG_MQTT 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&missingProtocol=YUTONG_MQTT'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&missingProtocol=YUTONG_MQTT'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(window.location.hash).toBe('#/vehicles?missingProtocol=YUTONG_MQTT');
|
||||
});
|
||||
@@ -3783,7 +3783,7 @@ test('filters vehicle list from action queue', async () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '确认平台转发 4' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&serviceStatus=no_data'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&serviceStatus=no_data'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(window.location.hash).toBe('#/vehicles?serviceStatus=no_data');
|
||||
});
|
||||
@@ -3851,12 +3851,12 @@ test('shows and clears current vehicle service filters', async () => {
|
||||
expect(screen.getByText('绑定:已绑定')).toBeInTheDocument();
|
||||
expect(screen.getByText('档案:不完整')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('archiveStatus=incomplete'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('archiveStatus=incomplete'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空筛选' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(window.location.hash).toBe('#/vehicles');
|
||||
});
|
||||
@@ -3921,9 +3921,9 @@ test('filters vehicle list from result summary actions', async () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^在线车辆 73$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&online=online'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage?limit=20&offset=0&online=online'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage/summary?online=online'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/coverage/summary?online=online'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(window.location.hash).toBe('#/vehicles?online=online');
|
||||
});
|
||||
|
||||
@@ -4007,7 +4007,7 @@ test('filters vehicle list by missing source evidence', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '缺 YUTONG_MQTT' }));
|
||||
expect(window.location.hash).toBe('#/vehicles?missingProtocol=YUTONG_MQTT');
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('missing-protocol-filter'));
|
||||
const mqttOptions = await screen.findAllByText('缺 YUTONG_MQTT');
|
||||
@@ -4015,7 +4015,7 @@ test('filters vehicle list by missing source evidence', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4184,8 +4184,8 @@ test('shows resolved vehicle service status after topbar search', async () => {
|
||||
expect(await screen.findByText('当前车辆:数据通道不完整')).toBeInTheDocument();
|
||||
expect(screen.getByText('粤AG18312 / LB9A32A24R0LS1426')).toBeInTheDocument();
|
||||
expect(screen.getByText('一致性:数据通道不完整')).toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service/overview?keyword=%E7%B2%A4AG18312'), undefined);
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/resolve'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service/overview?keyword=%E7%B2%A4AG18312'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(fetchMock).not.toHaveBeenCalledWith(expect.stringContaining('/api/vehicles/resolve'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '顶部地图态势' }));
|
||||
expect(window.location.hash).toBe('#/map?keyword=LB9A32A24R0LS1426&protocol=JT808');
|
||||
@@ -4435,9 +4435,9 @@ test('filters dashboard coverage from source consistency diagnosis', async () =>
|
||||
fireEvent.click(screen.getByRole('button', { name: '单一来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('serviceStatus=degraded'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('serviceStatus=degraded'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=JT808'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=JT808'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(screen.getByText('当前筛选:来源不完整 / 缺 JT808')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -4542,7 +4542,7 @@ test('opens full vehicle service list from dashboard coverage filters', async ()
|
||||
expect(await screen.findByText('VIN-DASH-FULL-LIST')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '单一来源' }));
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=JT808'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=JT808'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看全部车辆' }));
|
||||
@@ -4780,7 +4780,7 @@ test('filters dashboard coverage by service status', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '筛选' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('serviceStatus=degraded'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('serviceStatus=degraded'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(screen.getByText('当前筛选:来源不完整')).toBeInTheDocument();
|
||||
});
|
||||
@@ -4826,7 +4826,7 @@ test('filters dashboard coverage by missing source evidence', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '筛选' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(screen.getByText('当前筛选:缺 YUTONG_MQTT')).toBeInTheDocument();
|
||||
});
|
||||
@@ -4874,7 +4874,7 @@ test('opens dashboard coverage from service status distribution', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '筛选' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('serviceStatus=degraded'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('serviceStatus=degraded'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5348,7 +5348,7 @@ test('uses backend quality notification plan on quality page', async () => {
|
||||
expect(screen.getAllByText('10 分钟确认').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('后端无来源规则')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('后端 P0 通知策略').length).toBeGreaterThan(0);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/notification-plan?limit=20'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/notification-plan?limit=20'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('renders notification rules as a standalone operations page', async () => {
|
||||
@@ -5500,7 +5500,7 @@ test('renders notification rules as a standalone operations page', async () => {
|
||||
expect(screen.getAllByText('对象:接入运维 + 业务责任人;渠道:邮件 / 企业微信;升级:30 分钟升级').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('立即升级责任人并同步业务侧')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '复制通知可达性检查' })).toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/notification-plan?limit=50'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/notification-plan?limit=50'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制通知规则Runbook' }));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【通知规则Runbook】'));
|
||||
@@ -5718,8 +5718,8 @@ test('renders ops quality as a separated evidence layer for customer vehicle ser
|
||||
expect(screen.getByText('整车与氢能实时数据主来源')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('覆盖不足').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('在线 73/340,缺失车辆 693,Kafka Lag 42').length).toBeGreaterThanOrEqual(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/ops/health'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/ops/source-readiness'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/ops/health'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/ops/source-readiness'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制容量交接' }));
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆数据中台运维容量交接】'));
|
||||
@@ -7188,9 +7188,9 @@ test('drills into quality issues by issue type', async () => {
|
||||
fireEvent.click(issueSummaryButton as HTMLElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?issueType=NO_SOURCE&limit=20&offset=0'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?issueType=NO_SOURCE&limit=20&offset=0'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/summary?issueType=NO_SOURCE'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/summary?issueType=NO_SOURCE'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('drills into quality issues by protocol bucket', async () => {
|
||||
@@ -7257,9 +7257,9 @@ test('drills into quality issues by protocol bucket', async () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看 JT808 质量问题' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?protocol=JT808&limit=20&offset=0'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?protocol=JT808&limit=20&offset=0'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/summary?protocol=JT808'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/summary?protocol=JT808'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(window.location.hash).toBe('#/alert-events?protocol=JT808');
|
||||
});
|
||||
|
||||
@@ -7327,9 +7327,9 @@ test('drills into quality issues by issue type bucket', async () => {
|
||||
fireEvent.click(await screen.findByRole('button', { name: '查看 VIN 缺失质量问题' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?issueType=VIN_MISSING&limit=20&offset=0'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?issueType=VIN_MISSING&limit=20&offset=0'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/summary?issueType=VIN_MISSING'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/summary?issueType=VIN_MISSING'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(window.location.hash).toBe('#/alert-events?issueType=VIN_MISSING');
|
||||
});
|
||||
|
||||
@@ -7387,9 +7387,9 @@ test('applies shareable quality filters from hash', async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE&limit=20&offset=0'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE&limit=20&offset=0'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/summary?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events/summary?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('shows and clears current quality filters', async () => {
|
||||
@@ -7452,7 +7452,7 @@ test('shows and clears current quality filters', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空筛选' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?limit=20&offset=0'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?limit=20&offset=0'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(window.location.hash).toBe('#/alert-events');
|
||||
});
|
||||
@@ -7824,11 +7824,11 @@ test('applies protocol from shareable history hash to API requests', async () =>
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/history/locations?'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/history/locations?'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('protocol=JT808'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateTo=2026-07-03'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('protocol=JT808'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateTo=2026-07-03'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/history/raw-frames/query', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"protocol":"JT808"')
|
||||
@@ -8183,7 +8183,7 @@ test('shows and clears current history filters while keeping vehicle scope', asy
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空筛选' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/history/locations?limit=10&offset=0&keyword=VIN-HISTORY-001'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/history/locations?limit=10&offset=0&keyword=VIN-HISTORY-001'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(window.location.hash).toBe('#/history-query?keyword=VIN-HISTORY-001&tab=raw');
|
||||
});
|
||||
@@ -8482,8 +8482,8 @@ test('updates history hash when vehicle history filters are submitted', async ()
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/history?keyword=%E7%B2%A4AG18312&protocol=JT808&dateFrom=2026-07-01+00%3A00%3A00&dateTo=2026-07-01+23%3A59%3A59');
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/history/locations?'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01+00%3A00%3A00'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/history/locations?'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01+00%3A00%3A00'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/history/raw-frames/query', expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: expect.stringContaining('"dateFrom":"2026-07-01 00:00:00"')
|
||||
@@ -8536,7 +8536,7 @@ test('prevents unscoped raw parsed-field query from history form', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'search 查询' }));
|
||||
|
||||
expect((await screen.findAllByText('字段明细查询需要车辆、时间范围或字段裁剪')).length).toBeGreaterThanOrEqual(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/history/locations?'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/history/locations?'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
const unscopedRawQueries = fetchMock.mock.calls.filter(([url, init]) => {
|
||||
if (url !== '/api/history/raw-frames/query') return false;
|
||||
const body = JSON.parse(String((init as RequestInit | undefined)?.body ?? '{}')) as Record<string, unknown>;
|
||||
@@ -9259,10 +9259,10 @@ test('applies mileage date range from shareable hash to API requests', async ()
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/mileage/summary?'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/mileage/summary?'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateTo=2026-07-03'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateTo=2026-07-03'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(screen.getByText('当前车辆:VIN-MILEAGE-002')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('数据通道:JT808').length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -9311,7 +9311,7 @@ test('shows and clears current mileage filters while keeping vehicle scope', asy
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空筛选' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/mileage/summary?keyword=VIN-MILEAGE-002'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/mileage/summary?keyword=VIN-MILEAGE-002'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(window.location.hash).toBe('#/mileage?keyword=VIN-MILEAGE-002');
|
||||
});
|
||||
@@ -9363,9 +9363,9 @@ test('updates mileage hash when mileage filters are submitted', async () => {
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/mileage?keyword=%E7%B2%A4AG18312&protocol=JT808&dateFrom=2026-07-01&dateTo=2026-07-03');
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/mileage/summary?'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateTo=2026-07-03'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/mileage/summary?'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateTo=2026-07-03'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('opens current vehicle service from mileage header with current source evidence', async () => {
|
||||
@@ -10918,7 +10918,7 @@ test('opens vehicle service from realtime vehicles with primary source evidence'
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/detail?keyword=VIN-MQTT-001&protocol=YUTONG_MQTT');
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service/overview?keyword=VIN-MQTT-001&protocol=YUTONG_MQTT'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service/overview?keyword=VIN-MQTT-001&protocol=YUTONG_MQTT'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('opens vehicle service from realtime vehicles with current source filter', async () => {
|
||||
@@ -11001,7 +11001,7 @@ test('opens vehicle service from realtime vehicles with current source filter',
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/detail?keyword=VIN-RT-FILTER&protocol=JT808');
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service/overview?keyword=VIN-RT-FILTER&protocol=JT808'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service/overview?keyword=VIN-RT-FILTER&protocol=JT808'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('shows canonical service status in realtime vehicles', async () => {
|
||||
@@ -12635,7 +12635,7 @@ test('loads realtime vehicles from shareable source filter hash', async () => {
|
||||
render(<App />);
|
||||
|
||||
expect((await screen.findAllByText('VIN-RT-FILTERED')).length).toBeGreaterThan(0);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/realtime/vehicles?limit=50&offset=0&keyword=%E7%B2%A4ART002&protocol=JT808&online=online&serviceStatus=degraded'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/realtime/vehicles?limit=50&offset=0&keyword=%E7%B2%A4ART002&protocol=JT808&online=online&serviceStatus=degraded'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('opens realtime status from quality issue row with source evidence', async () => {
|
||||
@@ -12795,7 +12795,7 @@ test('shows and clears current realtime service filters', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空筛选' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/realtime/vehicles?limit=50&offset=0'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/realtime/vehicles?limit=50&offset=0'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
expect(window.location.hash).toBe('#/realtime');
|
||||
});
|
||||
@@ -12833,7 +12833,7 @@ test('updates realtime hash when source filters are submitted', async () => {
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/realtime?keyword=%E7%B2%A4ART002&protocol=JT808');
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/realtime/vehicles?limit=50&offset=0&keyword=%E7%B2%A4ART002&protocol=JT808'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/realtime/vehicles?limit=50&offset=0&keyword=%E7%B2%A4ART002&protocol=JT808'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('shows vehicle service status in vehicle list', async () => {
|
||||
@@ -13056,8 +13056,8 @@ test('filters vehicle list from source consistency diagnosis', async () => {
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/vehicles?serviceStatus=degraded&missingProtocol=YUTONG_MQTT');
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('serviceStatus=degraded'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('serviceStatus=degraded'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=YUTONG_MQTT'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('filters vehicle list from single-source consistency diagnosis', async () => {
|
||||
@@ -13142,7 +13142,7 @@ test('filters vehicle list from single-source consistency diagnosis', async () =
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/vehicles?serviceStatus=degraded&missingProtocol=JT808');
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=JT808'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('missingProtocol=JT808'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('opens vehicle service from source-filtered vehicle list with source evidence', async () => {
|
||||
@@ -13424,7 +13424,7 @@ test('filters vehicle list by service status', async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('serviceStatus=degraded'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('serviceStatus=degraded'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13554,7 +13554,7 @@ test('switches vehicle detail to a source from the source matrix', async () => {
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/detail?keyword=VIN001&protocol=JT808');
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('shows unified service overview on vehicle detail', async () => {
|
||||
@@ -13732,7 +13732,7 @@ test('opens quality governance from vehicle detail overview', async () => {
|
||||
expect(await screen.findByRole('heading', { name: '告警事件' })).toBeInTheDocument();
|
||||
expect(screen.getByText('告警事件闭环')).toBeInTheDocument();
|
||||
expect(screen.getByText('事件触发')).toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?keyword=VIN001&limit=20&offset=0'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/alert-events?keyword=VIN001&limit=20&offset=0'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('quality health storage card stays pending before ops health loads', async () => {
|
||||
@@ -14904,7 +14904,7 @@ test('shows selected source scope on vehicle detail', async () => {
|
||||
expect(screen.getAllByText('单一数据通道:JT808').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('车辆服务状态')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('数据通道不完整').length).toBeGreaterThan(0);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), undefined);
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/vehicle-service?keyword=VIN001&protocol=JT808'), expect.objectContaining({ signal: expect.any(AbortSignal) }));
|
||||
});
|
||||
|
||||
test('shows cross-source consistency for one vehicle service', async () => {
|
||||
|
||||
@@ -41,6 +41,10 @@ test('renders date-range mileage summary, trend and daily detail list', async ()
|
||||
expect((await screen.findAllByText('210.5 km')).length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('粤A12345')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('80.5').length).toBeGreaterThanOrEqual(1);
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('dateFrom=2026-07-01'), { signal: expect.any(AbortSignal) }));
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('keyword=%E7%B2%A4A12345'), { signal: expect.any(AbortSignal) });
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
for (const [path, init] of fetchMock.mock.calls) {
|
||||
expect(['/api/v2/statistics/mileage', '/api/mileage/daily']).toContain(path);
|
||||
expect(init).toMatchObject({ method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: expect.any(AbortSignal) });
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({ dateFrom: '2026-07-01', dateTo: '2026-07-03', keyword: '粤A12345', protocol: 'JT808' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ export function AppV2() {
|
||||
<Route path="/history" element={<ProtectedRoute menu="history"><RoutePage page={RoutePages.History} recreatePage={RoutePageFactories.History} label="历史数据" /></ProtectedRoute>} />
|
||||
<Route path="/statistics" element={<ProtectedRoute menu="statistics"><RoutePage page={RoutePages.Statistics} recreatePage={RoutePageFactories.Statistics} label="里程查询" /></ProtectedRoute>} />
|
||||
<Route path="/access" element={<ProtectedRoute menu="access"><RoutePage page={RoutePages.Access} recreatePage={RoutePageFactories.Access} label="接入管理" /></ProtectedRoute>} />
|
||||
<Route path="/alerts/*" element={<ProtectedRoute menu="alerts"><RoutePage page={RoutePages.Alerts} recreatePage={RoutePageFactories.Alerts} label="告警中心" /></ProtectedRoute>} />
|
||||
<Route path="/alerts/*" element={<ProtectedRoute menu="alerts"><RoutePage page={RoutePages.Alerts} recreatePage={RoutePageFactories.Alerts} label="事件中心" /></ProtectedRoute>} />
|
||||
<Route path="/operations" element={<ProtectedRoute menu="operations"><RoutePage page={RoutePages.Operations} recreatePage={RoutePageFactories.Operations} label="运维质量" /></ProtectedRoute>} />
|
||||
<Route path="/users" element={<ProtectedRoute menu="users"><RoutePage page={RoutePages.Users} recreatePage={RoutePageFactories.Users} label="账号管理" /></ProtectedRoute>} />
|
||||
<Route path="*" element={<Navigate to="/monitor" replace />} />
|
||||
|
||||
@@ -4,8 +4,8 @@ import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, setAccessToken } from './session';
|
||||
import { AuthGate, usePlatformSession } from './AuthGate';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ session: vi.fn(), login: vi.fn(), logout: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ api: { session: mocks.session, login: mocks.login, logout: mocks.logout } }));
|
||||
const mocks = vi.hoisted(() => ({ session: vi.fn(), login: vi.fn(), logout: vi.fn(), exchangeOneOSTicket: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ api: { session: mocks.session, login: mocks.login, logout: mocks.logout, exchangeOneOSTicket: mocks.exchangeOneOSTicket } }));
|
||||
|
||||
function ProtectedWorkspace({ onAbort }: { onAbort: () => void }) {
|
||||
const { session, logout } = usePlatformSession();
|
||||
@@ -23,8 +23,24 @@ afterEach(() => {
|
||||
mocks.session.mockReset();
|
||||
mocks.login.mockReset();
|
||||
mocks.logout.mockReset();
|
||||
mocks.exchangeOneOSTicket.mockReset();
|
||||
mocks.logout.mockResolvedValue({ loggedOut: true });
|
||||
window.sessionStorage.clear();
|
||||
window.history.replaceState({}, '', '/');
|
||||
});
|
||||
|
||||
test('exchanges a OneOS callback ticket without exposing it after the page is mounted', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
mocks.exchangeOneOSTicket.mockRejectedValue(new Error('票据已过期'));
|
||||
window.history.replaceState({}, '', '/auth/oneos/callback?ticket=single-use-ticket-value');
|
||||
|
||||
render(<QueryClientProvider client={client}><AuthGate><div>受保护工作台</div></AuthGate></QueryClientProvider>);
|
||||
|
||||
await waitFor(() => expect(mocks.exchangeOneOSTicket).toHaveBeenCalledWith('single-use-ticket-value'));
|
||||
expect(window.location.pathname).toBe('/auth/oneos/callback');
|
||||
expect(window.location.search).toBe('');
|
||||
expect(await screen.findByText('票据已过期')).toBeInTheDocument();
|
||||
expect(mocks.session).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('uses a branded Semi UI session state while restoring authentication', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IconAlertTriangle, IconLock, IconSafe, IconShield, IconUser } from '@douyinfe/semi-icons';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Banner, Button, Card, Input, Spin, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, ReactNode, useCallback, useContext, useEffect, useState, createContext } from 'react';
|
||||
import { FormEvent, ReactNode, useCallback, useContext, useEffect, useRef, useState, createContext } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import { PasswordInput } from '../shared/PasswordInput';
|
||||
import { clearAccessToken, getAccessToken, PLATFORM_UNAUTHORIZED_EVENT, PlatformSession, setAccessToken } from './session';
|
||||
@@ -56,6 +56,49 @@ function AuthLoadingState() {
|
||||
</main>;
|
||||
}
|
||||
|
||||
function OneOSCallback() {
|
||||
const started = useRef(false);
|
||||
const [error, setError] = useState('');
|
||||
const errorPresentation = authErrorPresentation(error);
|
||||
useEffect(() => {
|
||||
if (started.current) return;
|
||||
started.current = true;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const ticket = params.get('ticket')?.trim() ?? '';
|
||||
window.history.replaceState({}, document.title, '/auth/oneos/callback');
|
||||
if (!ticket) {
|
||||
setError('登录票据缺失,请从 OneOS 重新进入车辆数据中台。');
|
||||
return;
|
||||
}
|
||||
void api.exchangeOneOSTicket(ticket).then((result) => {
|
||||
setAccessToken(result.accessToken);
|
||||
window.location.replace(result.returnTo || '/vehicles');
|
||||
}).catch((reason) => {
|
||||
clearAccessToken();
|
||||
setError(reason instanceof Error ? reason.message : 'OneOS 登录未完成,请重新进入。');
|
||||
});
|
||||
}, []);
|
||||
return <main className="v2-auth-screen is-session-loading">
|
||||
<Card className="v2-auth-card v2-auth-loading" bodyStyle={{ padding: 0 }} aria-label="正在登录车辆数据中台" data-support-trace={errorPresentation.traceId || undefined}>
|
||||
<div className="v2-auth-loading-inner">
|
||||
<header className="v2-auth-loading-header">
|
||||
<img src="/brand-logo.svg" alt="羚牛智能" />
|
||||
<Tag color={error ? 'red' : 'blue'} prefixIcon={error ? <IconAlertTriangle /> : <IconSafe />}>OneOS 安全登录</Tag>
|
||||
</header>
|
||||
<div className="v2-auth-loading-status" role="status" aria-live="polite">
|
||||
{!error ? <span><Spin size="large" /></span> : <span><IconAlertTriangle size="extra-large" /></span>}
|
||||
<div>
|
||||
<Title heading={4}>{error ? '登录未完成' : '正在同步账号与车辆权限'}</Title>
|
||||
<Text type={error ? 'danger' : 'tertiary'}>{error ? errorPresentation.message : '正在核验一次性票据,完成后会自动进入车辆查询。'}</Text>
|
||||
</div>
|
||||
</div>
|
||||
{error ? <Button theme="solid" type="primary" onClick={() => window.location.assign('/')}>返回登录页</Button> : <div className="v2-auth-loading-skeleton" aria-hidden="true"><i /><i /><i /></div>}
|
||||
<footer><IconLock /><Text type="tertiary" size="small">OneOS 登录令牌不会写入地址,票据使用一次后立即失效。</Text></footer>
|
||||
</div>
|
||||
</Card>
|
||||
</main>;
|
||||
}
|
||||
|
||||
function authErrorPresentation(message: string) {
|
||||
const match = message.match(TRACE_ID_SUFFIX);
|
||||
return {
|
||||
@@ -85,6 +128,7 @@ export function usePlatformSession() {
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const isOneOSCallback = window.location.pathname === '/auth/oneos/callback';
|
||||
const queryClient = useQueryClient();
|
||||
const [tokenVersion, setTokenVersion] = useState(0);
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -96,7 +140,8 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
queryKey: ['platform-session', tokenVersion],
|
||||
queryFn: ({ signal }) => api.session(signal),
|
||||
retry: false,
|
||||
staleTime: Infinity
|
||||
staleTime: Infinity,
|
||||
enabled: !isOneOSCallback
|
||||
});
|
||||
const clearClientSession = useCallback(() => {
|
||||
void queryClient.cancelQueries();
|
||||
@@ -140,6 +185,9 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
setLoginPending(false);
|
||||
}
|
||||
};
|
||||
if (isOneOSCallback) {
|
||||
return <OneOSCallback />;
|
||||
}
|
||||
if (session.isPending) {
|
||||
return <AuthLoadingState />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import { accountBatchImportCSVHeader, parseAccountBatchImportCSV } from './accountBatchImport';
|
||||
|
||||
test('parses account batch CSV with quoted names and normalized scopes', () => {
|
||||
const items = parseAccountBatchImportCSV(`${accountBatchImportCSVHeader}\ncustomer-east,"华东,物流",ChangeMe2026!,enabled,CUS-1,T-1,monitor|VEHICLES,vin001|VIN002\n`);
|
||||
expect(items).toEqual([{
|
||||
row: 2,
|
||||
input: {
|
||||
username: 'customer-east', displayName: '华东,物流', password: 'ChangeMe2026!', status: 'enabled',
|
||||
customerRef: 'CUS-1', tenantRef: 'T-1', menuKeys: ['monitor', 'vehicles'], vehicleVins: ['VIN001', 'VIN002'], vehicleGrants: []
|
||||
}
|
||||
}]);
|
||||
});
|
||||
|
||||
test('rejects invalid headers, status and oversized batches', () => {
|
||||
expect(() => parseAccountBatchImportCSV('username,displayName\na,b')).toThrow('CSV 表头必须为');
|
||||
expect(() => parseAccountBatchImportCSV(`${accountBatchImportCSVHeader}\na,b,c,paused,,,,`)).toThrow('status 只能是');
|
||||
const rows = Array.from({ length: 51 }, (_, index) => `customer-${index},客户 ${index},ChangeMe2026!,enabled,,,monitor,VIN${index}`).join('\n');
|
||||
expect(() => parseAccountBatchImportCSV(`${accountBatchImportCSVHeader}\n${rows}`)).toThrow('每次最多导入 50');
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { CustomerUserInput } from '../../api/types';
|
||||
|
||||
export const accountBatchImportCSVHeader = 'username,displayName,password,status,customerRef,tenantRef,menuKeys,vehicleVins';
|
||||
export const accountBatchImportCSVTemplate = `${accountBatchImportCSVHeader}\ncustomer-huadong,华东客户,ChangeMe2026!,enabled,CUS-001,TENANT-001,monitor|vehicles,VIN001|VIN002\n`;
|
||||
|
||||
export type AccountBatchImportItem = {
|
||||
row: number;
|
||||
input: CustomerUserInput & { username: string; password: string };
|
||||
};
|
||||
|
||||
export function parseAccountBatchImportCSV(text: string): AccountBatchImportItem[] {
|
||||
const rows = parseCSVRows(text.replace(/^\uFEFF/, ''));
|
||||
if (!rows.length) throw new Error('CSV 文件为空');
|
||||
const header = rows[0].map((value) => value.trim());
|
||||
const expected = accountBatchImportCSVHeader.split(',');
|
||||
if (header.length !== expected.length || header.some((value, index) => value !== expected[index])) {
|
||||
throw new Error(`CSV 表头必须为:${accountBatchImportCSVHeader}`);
|
||||
}
|
||||
const items = rows.slice(1).map((values, index) => {
|
||||
if (values.length !== expected.length) throw new Error(`第 ${index + 2} 行列数与表头不一致`);
|
||||
const [username, displayName, password, rawStatus, customerRef, tenantRef, rawMenus, rawVINs] = values.map((value) => value.trim());
|
||||
const status = rawStatus || 'enabled';
|
||||
if (status !== 'enabled' && status !== 'disabled') throw new Error(`第 ${index + 2} 行 status 只能是 enabled 或 disabled`);
|
||||
return {
|
||||
row: index + 2,
|
||||
input: {
|
||||
username,
|
||||
displayName,
|
||||
password,
|
||||
status,
|
||||
customerRef,
|
||||
tenantRef,
|
||||
menuKeys: splitPipeValues(rawMenus).map((value) => value.toLowerCase()),
|
||||
vehicleVins: splitPipeValues(rawVINs).map((value) => value.toUpperCase()),
|
||||
vehicleGrants: []
|
||||
}
|
||||
} satisfies AccountBatchImportItem;
|
||||
}).filter((item) => Object.values(item.input).some((value) => Array.isArray(value) ? value.length : String(value).trim()));
|
||||
if (!items.length) throw new Error('CSV 中没有可导入的账号');
|
||||
if (items.length > 50) throw new Error('每次最多导入 50 个客户账号');
|
||||
return items;
|
||||
}
|
||||
|
||||
function splitPipeValues(value: string) {
|
||||
return value.split('|').map((part) => part.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseCSVRows(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let value = '';
|
||||
let quoted = false;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
if (character === '"') {
|
||||
if (quoted && text[index + 1] === '"') {
|
||||
value += '"';
|
||||
index += 1;
|
||||
} else {
|
||||
quoted = !quoted;
|
||||
}
|
||||
} else if (character === ',' && !quoted) {
|
||||
row.push(value);
|
||||
value = '';
|
||||
} else if ((character === '\n' || character === '\r') && !quoted) {
|
||||
if (character === '\r' && text[index + 1] === '\n') index += 1;
|
||||
row.push(value);
|
||||
if (row.some((cell) => cell.trim())) rows.push(row);
|
||||
row = [];
|
||||
value = '';
|
||||
} else {
|
||||
value += character;
|
||||
}
|
||||
}
|
||||
if (quoted) throw new Error('CSV 存在未闭合的引号');
|
||||
row.push(value);
|
||||
if (row.some((cell) => cell.trim())) rows.push(row);
|
||||
return rows;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { alertDeltaText, alertValue, canAct, formatAlertTime, ruleCondition, thresholdText } from './alert';
|
||||
import { alertDeltaText, alertValue, canAct, formatAlertDuration, formatAlertTime, ruleCondition, thresholdText } from './alert';
|
||||
|
||||
describe('alert domain helpers', () => {
|
||||
it('keeps trigger evidence and duration explicit', () => {
|
||||
@@ -30,7 +30,17 @@ describe('alert domain helpers', () => {
|
||||
expect(ruleCondition({ valueType: 'boolean', metric: 'alarm_active', operator: 'changed', durationSec: 0 } as never)).toBe('协议告警位 状态变化');
|
||||
});
|
||||
|
||||
it('keeps automation summaries human-readable with units and durations', () => {
|
||||
expect(formatAlertDuration(43_200)).toBe('12 小时');
|
||||
expect(ruleCondition({ valueType: 'numeric', metric: 'freshness_sec', operator: 'gt', threshold: 43_200, thresholdHigh: 0, durationSec: 0 }, { freshness_sec: '离线时长' }, { freshness_sec: '秒' })).toBe('离线时长 > 12 小时');
|
||||
expect(ruleCondition({ valueType: 'numeric', metric: 'speed_kmh', operator: 'gt', threshold: 80, thresholdHigh: 0, durationSec: 60 }, { speed_kmh: '速度' }, { speed_kmh: 'km/h' })).toBe('速度 > 80 km/h · 持续 1 分钟');
|
||||
expect(ruleCondition({ valueType: 'numeric', metric: 'hydrogen_concentration_percent', operator: 'gt', threshold: 0.5, thresholdHigh: 0, durationSec: 30 }, { hydrogen_concentration_percent: '最高氢浓度' }, { hydrogen_concentration_percent: '%' }, false)).toBe('最高氢浓度 > 0.5 %');
|
||||
expect(ruleCondition({ valueType: 'numeric', metric: 'hydrogen_concentration_percent', operator: 'gt', threshold: Number.NaN, thresholdHigh: 0, durationSec: 0 }, { hydrogen_concentration_percent: '最高氢浓度' }, { hydrogen_concentration_percent: '%' }, false)).toBe('最高氢浓度 > 请设置阈值');
|
||||
});
|
||||
|
||||
it('renders operational timestamps in the shared Shanghai timezone', () => {
|
||||
expect(formatAlertTime('2026-07-18T02:33:00Z')).toBe('07-18 10:33:00');
|
||||
expect(formatAlertTime('2026-07-20T14:38:49.040000+08:00')).toBe('07-20 14:38:49');
|
||||
expect(formatAlertTime('2026-07-20T15:17:48.984000Z')).toBe('07-20 15:17:48');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,16 +10,38 @@ const alertTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
hour12: false,
|
||||
timeZone: 'Asia/Shanghai'
|
||||
});
|
||||
const LEGACY_LOCAL_SQL_TIMESTAMP = /\.\d{6}Z$/;
|
||||
|
||||
export const severityLabels: Record<AlertSeverity, string> = { critical: '紧急', major: '重要', minor: '一般' };
|
||||
export const statusLabels: Record<AlertStatus, string> = { unprocessed: '未处理', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
|
||||
export const actionLabels: Record<string, string> = { trigger: '触发', acknowledge: '已确认', close: '已关闭', ignore: '已忽略', recover: '已恢复', processing: '处理中', recovered: '已恢复', closed: '已关闭', ignored: '已忽略' };
|
||||
export const metricLabels: Record<string, string> = { speed_kmh: '速度', soc_percent: 'SOC', alarm_active: '协议告警位', freshness_sec: '离线时长', data_delay_sec: '数据延迟' };
|
||||
export const operatorLabels: Record<string, string> = { gt: '>', gte: '≥', lt: '<', lte: '≤', eq: '=', neq: '≠', between: '区间内', outside: '区间外', changed: '状态变化' };
|
||||
export const triggerTypeLabels: Record<string, string> = { metric: '数值触发', geofence: '电子围栏', stationary: '长时间静止', offline: '长时间离线' };
|
||||
|
||||
type AlertRuleCondition = Pick<AlertRule, 'triggerType' | 'fenceName' | 'fenceRadiusM' | 'metric' | 'operator' | 'threshold' | 'thresholdHigh' | 'valueType' | 'booleanThreshold' | 'durationSec'>;
|
||||
|
||||
export function formatAlertDuration(value: number) {
|
||||
if (!value) return '立即';
|
||||
if (value % 86_400 === 0) return `${value / 86_400} 天`;
|
||||
if (value % 3_600 === 0) return `${value / 3_600} 小时`;
|
||||
if (value % 60 === 0) return `${value / 60} 分钟`;
|
||||
return `${value} 秒`;
|
||||
}
|
||||
|
||||
function ruleThresholdValue(metric: string, value: number, unit = '') {
|
||||
if (!Number.isFinite(value)) return '请设置阈值';
|
||||
if (metric === 'freshness_sec' || metric === 'data_delay_sec') return formatAlertDuration(value);
|
||||
return `${formatZhNumber(Number(value.toFixed(2)), 2)}${unit ? ` ${unit}` : ''}`;
|
||||
}
|
||||
|
||||
export function formatAlertTime(value: string) {
|
||||
if (!value) return '—';
|
||||
const date = new Date(value);
|
||||
// Older alert APIs appended Z to MySQL DATETIME values that were already in
|
||||
// Asia/Shanghai. Their six-digit SQL fractional seconds make the legacy
|
||||
// shape distinguishable from genuine UTC timestamps.
|
||||
const normalized = LEGACY_LOCAL_SQL_TIMESTAMP.test(value) ? value.replace(/Z$/, '+08:00') : value;
|
||||
const date = new Date(normalized);
|
||||
if (Number.isNaN(date.getTime())) return value.replace('T', ' ').slice(0, 19);
|
||||
return alertTimeFormatter.format(date).replace(/\//g, '-');
|
||||
}
|
||||
@@ -28,7 +50,8 @@ export function alertValue(event: Pick<AlertEvent, 'triggerValue' | 'unit'>) {
|
||||
return `${formatZhNumber(Number(event.triggerValue.toFixed(2)), 2)} ${event.unit}`.trim();
|
||||
}
|
||||
|
||||
export function thresholdText(event: Pick<AlertEvent, 'operator' | 'threshold' | 'thresholdHigh' | 'unit' | 'durationSec'>) {
|
||||
export function thresholdText(event: Pick<AlertEvent, 'triggerType' | 'operator' | 'threshold' | 'thresholdHigh' | 'unit' | 'durationSec'>) {
|
||||
if (event.triggerType === 'geofence') return `${{ enter: '进入围栏', exit: '离开围栏', inside: '位于围栏内', outside: '位于围栏外' }[event.operator] ?? '电子围栏条件'}${event.durationSec ? `,持续 ${formatAlertDuration(event.durationSec)}` : ''}`;
|
||||
const duration = event.durationSec > 0 ? `,持续 ${event.durationSec} 秒` : '';
|
||||
if (event.operator === 'between' || event.operator === 'outside') return `${operatorLabels[event.operator]} ${event.threshold}–${event.thresholdHigh} ${event.unit}${duration}`.trim();
|
||||
if (event.operator === 'changed') return `状态发生变化${duration}`;
|
||||
@@ -46,9 +69,27 @@ export function alertDeltaText(event: Pick<AlertEvent, 'operator' | 'triggerValu
|
||||
return `+${formatZhNumber(Number(Math.max(0, delta).toFixed(2)), 2)} ${event.unit}`.trim();
|
||||
}
|
||||
|
||||
export function ruleCondition(rule: AlertRule, labels: Record<string, string> = metricLabels) {
|
||||
const threshold = rule.operator === 'changed' ? '状态变化' : rule.operator === 'between' || rule.operator === 'outside' ? `${operatorLabels[rule.operator]} ${rule.threshold}–${rule.thresholdHigh}` : rule.valueType === 'boolean' ? (rule.booleanThreshold ? '是' : '否') : `${operatorLabels[rule.operator] ?? rule.operator} ${rule.threshold}`;
|
||||
return `${labels[rule.metric] ?? rule.metric} ${threshold}${rule.durationSec ? ` · ${rule.durationSec} 秒` : ''}`;
|
||||
export function ruleCondition(
|
||||
rule: AlertRuleCondition,
|
||||
labels: Record<string, string> = metricLabels,
|
||||
units: Record<string, string> = {},
|
||||
includeDuration = true
|
||||
) {
|
||||
if (rule.triggerType === 'geofence') {
|
||||
const mode = { enter: '进入', exit: '离开', inside: '位于围栏内', outside: '位于围栏外' }[rule.operator] ?? '满足围栏条件';
|
||||
return `${mode}“${rule.fenceName || '未命名围栏'}” · 半径 ${formatZhNumber(rule.fenceRadiusM ?? rule.threshold, 0)} m${includeDuration && rule.durationSec ? ` · 持续 ${formatAlertDuration(rule.durationSec)}` : ''}`;
|
||||
}
|
||||
if (rule.triggerType === 'offline') return `离线超过 ${formatAlertDuration(rule.threshold)}`;
|
||||
if (rule.triggerType === 'stationary') return `速度 ${operatorLabels[rule.operator] ?? rule.operator} ${ruleThresholdValue(rule.metric, rule.threshold, units[rule.metric] ?? 'km/h')}${includeDuration && rule.durationSec ? ` · 持续 ${formatAlertDuration(rule.durationSec)}` : ''}`;
|
||||
const unit = units[rule.metric] ?? '';
|
||||
const threshold = rule.operator === 'changed'
|
||||
? '状态变化'
|
||||
: rule.operator === 'between' || rule.operator === 'outside'
|
||||
? `${operatorLabels[rule.operator]} ${ruleThresholdValue(rule.metric, rule.threshold, unit)}–${ruleThresholdValue(rule.metric, rule.thresholdHigh, unit)}`
|
||||
: rule.valueType === 'boolean'
|
||||
? (rule.booleanThreshold ? '是' : '否')
|
||||
: `${operatorLabels[rule.operator] ?? rule.operator} ${ruleThresholdValue(rule.metric, rule.threshold, unit)}`;
|
||||
return `${labels[rule.metric] ?? rule.metric} ${threshold}${includeDuration && rule.durationSec ? ` · 持续 ${formatAlertDuration(rule.durationSec)}` : ''}`;
|
||||
}
|
||||
|
||||
export function canAct(status: AlertStatus, action: 'acknowledge' | 'close' | 'ignore') {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { AlertEvent, AlertRule } from '../../api/types';
|
||||
import { automationEventType, automationSource, eventContract, eventEvidenceHistoryPath, protocolEventSources, toVehicleEvent } from './event';
|
||||
|
||||
function event(overrides: Partial<AlertEvent> = {}): AlertEvent {
|
||||
return {
|
||||
id: 'event-1', ruleId: 'automation-1', ruleName: '车辆驶出运营围栏', ruleVersion: 3,
|
||||
triggerType: 'geofence', severity: 'critical', status: 'unprocessed', vin: 'VIN001', plate: '粤A001', protocol: 'JT808',
|
||||
metric: 'geofence_distance_m', operator: 'exit', triggerValue: 501, threshold: 500, thresholdHigh: 0, unit: 'm', durationSec: 0,
|
||||
location: '深圳运营区', sourceEventId: 'source-1', eventAt: '2026-07-22T09:00:00+08:00', receivedAt: '2026-07-22T09:00:01+08:00',
|
||||
triggeredAt: '2026-07-22T09:00:01+08:00', recoveredAt: '', handler: '', version: 1,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('unified vehicle events', () => {
|
||||
it('keeps all three protocols behind one event-source contract', () => {
|
||||
expect(protocolEventSources.map((item) => item.protocol)).toEqual(['GB32960', 'JT808', 'YUTONG_MQTT']);
|
||||
expect(new Set(protocolEventSources.map((item) => item.contract)).size).toBe(3);
|
||||
});
|
||||
|
||||
it('normalizes an alert record into an event envelope and execution state', () => {
|
||||
const normalized = toVehicleEvent(event());
|
||||
expect(normalized.type).toBe('vehicle.geofence.changed');
|
||||
expect(normalized.categoryLabel).toBe('地理围栏');
|
||||
expect(normalized.source).toEqual({ protocol: 'JT808', eventId: 'source-1' });
|
||||
expect(normalized.execution).toEqual({ state: 'attention', label: '待处理', requiresAttention: true });
|
||||
});
|
||||
|
||||
it('treats recovery as an automated outcome rather than another alert status', () => {
|
||||
expect(toVehicleEvent(event({ triggerType: 'offline', metric: 'freshness_sec', status: 'recovered' })).execution.label).toBe('已恢复');
|
||||
});
|
||||
|
||||
it('exposes a stable event contract independent of the protocol payload', () => {
|
||||
expect(eventContract(event()).map((item) => item.key)).toEqual(['event.type', 'source.protocol', 'subject.vin', 'occurred_at', 'received_at']);
|
||||
});
|
||||
|
||||
it('treats a null protocol scope from legacy data as all three protocols', () => {
|
||||
expect(automationSource({ scopeProtocols: null } as unknown as Pick<AlertRule, 'scopeProtocols'>)).toBe('三类协议');
|
||||
});
|
||||
|
||||
it('uses one canonical event type in automation and event details', () => {
|
||||
const rule = { triggerType: 'geofence', metric: 'geofence_distance_m', operator: 'exit' } as Pick<AlertRule, 'triggerType' | 'metric' | 'operator'>;
|
||||
expect(automationEventType(rule)).toBe('vehicle.geofence.exited');
|
||||
expect(toVehicleEvent(event({ eventType: automationEventType(rule) })).type).toBe('vehicle.geofence.exited');
|
||||
});
|
||||
|
||||
it('opens historical RAW evidence around the exact event context', () => {
|
||||
const path = eventEvidenceHistoryPath(event(), 3);
|
||||
const url = new URL(path, 'https://vehicle-platform.invalid');
|
||||
expect(url.pathname).toBe('/history');
|
||||
expect(Object.fromEntries(url.searchParams)).toEqual(expect.objectContaining({
|
||||
keywords: 'VIN001',
|
||||
category: 'raw',
|
||||
protocol: 'JT808',
|
||||
dateFrom: '2026-07-22T08:57',
|
||||
dateTo: '2026-07-22T09:03',
|
||||
eventId: 'event-1',
|
||||
eventTitle: '车辆驶出运营围栏',
|
||||
eventVin: 'VIN001',
|
||||
eventPlate: '粤A001',
|
||||
eventAt: '2026-07-22T09:00:00+08:00'
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { AlertEvent, AlertRule, AlertStatus, AlertTriggerType } from '../../api/types';
|
||||
import { formatShanghaiDateTime } from './formatters';
|
||||
|
||||
export type EventCategory = 'safety' | 'geofence' | 'connectivity' | 'telemetry' | 'business';
|
||||
export type EventExecutionState = 'attention' | 'handling' | 'automated' | 'completed' | 'ignored';
|
||||
|
||||
export interface ProtocolEventSource {
|
||||
protocol: 'GB32960' | 'JT808' | 'YUTONG_MQTT';
|
||||
label: string;
|
||||
contract: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface VehicleEventView {
|
||||
id: string;
|
||||
type: string;
|
||||
category: EventCategory;
|
||||
categoryLabel: string;
|
||||
title: string;
|
||||
subject: { type: 'vehicle'; vin: string; plate: string };
|
||||
source: { protocol: string; eventId: string };
|
||||
occurredAt: string;
|
||||
receivedAt: string;
|
||||
automation: { id: string; name: string; version: number };
|
||||
severity?: AlertEvent['severity'];
|
||||
execution: { state: EventExecutionState; label: string; requiresAttention: boolean };
|
||||
}
|
||||
|
||||
export const protocolEventSources: ProtocolEventSource[] = [
|
||||
{ protocol: 'GB32960', label: 'GB/T 32960', contract: 'vehicle.telemetry.reported', role: '整车与新能源遥测' },
|
||||
{ protocol: 'JT808', label: 'JT/T 808', contract: 'vehicle.location.reported', role: '位置、行驶与终端状态' },
|
||||
{ protocol: 'YUTONG_MQTT', label: '宇通 MQTT', contract: 'vehicle.oem.telemetry.reported', role: '厂商扩展遥测' }
|
||||
];
|
||||
|
||||
export const eventCategoryLabels: Record<EventCategory, string> = {
|
||||
safety: '安全异常',
|
||||
geofence: '地理围栏',
|
||||
connectivity: '连接状态',
|
||||
telemetry: '遥测变化',
|
||||
business: '业务事件'
|
||||
};
|
||||
|
||||
export const eventExecutionLabels: Record<EventExecutionState, string> = {
|
||||
attention: '待处理',
|
||||
handling: '处理中',
|
||||
automated: '已恢复',
|
||||
completed: '已完成',
|
||||
ignored: '已忽略'
|
||||
};
|
||||
|
||||
const statusToExecution: Record<AlertStatus, EventExecutionState> = {
|
||||
unprocessed: 'attention',
|
||||
processing: 'handling',
|
||||
recovered: 'automated',
|
||||
closed: 'completed',
|
||||
ignored: 'ignored'
|
||||
};
|
||||
|
||||
function eventCategory(event: Pick<AlertEvent, 'triggerType' | 'metric'>): EventCategory {
|
||||
if (event.triggerType === 'geofence') return 'geofence';
|
||||
if (event.triggerType === 'offline' || event.metric === 'freshness_sec') return 'connectivity';
|
||||
if (event.metric === 'alarm_active' || event.metric.includes('hydrogen')) return 'safety';
|
||||
if (event.metric.includes('mileage') || event.metric.includes('daily_')) return 'business';
|
||||
return 'telemetry';
|
||||
}
|
||||
|
||||
function normalizedEventType(triggerType: AlertTriggerType | undefined, metric: string, category: EventCategory) {
|
||||
if (triggerType === 'geofence') return 'vehicle.geofence.changed';
|
||||
if (triggerType === 'offline' || metric === 'freshness_sec') return 'vehicle.connectivity.offline';
|
||||
if (triggerType === 'stationary') return 'vehicle.motion.stationary';
|
||||
const detail = (metric || 'changed').replace(/[^a-z0-9_]+/gi, '_').toLowerCase();
|
||||
return category === 'business' ? `vehicle.business.${detail}` : `vehicle.${category}.${detail}`;
|
||||
}
|
||||
|
||||
export function automationEventType(rule: Pick<AlertRule, 'triggerType' | 'metric' | 'operator'>) {
|
||||
if (rule.triggerType === 'geofence') return `vehicle.geofence.${({ enter: 'entered', exit: 'exited', inside: 'inside', outside: 'outside' } as Record<string, string>)[rule.operator] || 'changed'}`;
|
||||
if (rule.triggerType === 'offline' || rule.metric === 'freshness_sec') return 'vehicle.connectivity.offline';
|
||||
if (rule.triggerType === 'stationary') return 'vehicle.motion.stationary';
|
||||
if (rule.metric === 'soc_percent' && ['lt', 'lte'].includes(rule.operator)) return 'vehicle.telemetry.soc_low';
|
||||
if (rule.metric === 'speed_kmh' && ['gt', 'gte'].includes(rule.operator)) return 'vehicle.motion.speed_high';
|
||||
if (rule.metric === 'alarm_active') return 'vehicle.safety.alarm_activated';
|
||||
if (rule.metric === 'hydrogen_concentration_percent') return 'vehicle.safety.hydrogen_concentration_high';
|
||||
if (rule.metric === 'daily_mileage_km') return 'vehicle.mileage.daily_completed';
|
||||
return normalizedEventType(rule.triggerType, rule.metric, eventCategory({ triggerType: rule.triggerType, metric: rule.metric }));
|
||||
}
|
||||
|
||||
export function toVehicleEvent(event: AlertEvent): VehicleEventView {
|
||||
const category = eventCategory(event);
|
||||
const state = statusToExecution[event.status];
|
||||
return {
|
||||
id: event.id,
|
||||
type: event.eventType || normalizedEventType(event.triggerType, event.metric, category),
|
||||
category: (event.eventCategory as EventCategory | undefined) || category,
|
||||
categoryLabel: eventCategoryLabels[(event.eventCategory as EventCategory | undefined) || category],
|
||||
title: event.ruleName,
|
||||
subject: { type: 'vehicle', vin: event.vin, plate: event.plate },
|
||||
source: { protocol: event.protocol, eventId: event.sourceEventId },
|
||||
occurredAt: event.eventAt || event.triggeredAt,
|
||||
receivedAt: event.receivedAt,
|
||||
automation: { id: event.ruleId, name: event.ruleName, version: event.ruleVersion },
|
||||
severity: category === 'business' ? undefined : event.severity,
|
||||
execution: { state, label: eventExecutionLabels[state], requiresAttention: state === 'attention' || state === 'handling' }
|
||||
};
|
||||
}
|
||||
|
||||
export function eventContract(event: AlertEvent) {
|
||||
const normalized = toVehicleEvent(event);
|
||||
return [
|
||||
{ key: 'event.type', value: normalized.type },
|
||||
{ key: 'source.protocol', value: normalized.source.protocol },
|
||||
{ key: 'subject.vin', value: normalized.subject.vin },
|
||||
{ key: 'occurred_at', value: normalized.occurredAt },
|
||||
{ key: 'received_at', value: normalized.receivedAt }
|
||||
];
|
||||
}
|
||||
|
||||
function shanghaiMinute(value: Date) {
|
||||
return formatShanghaiDateTime(value.toISOString()).replace(' ', 'T').slice(0, 16);
|
||||
}
|
||||
|
||||
export function eventEvidenceHistoryPath(event: AlertEvent, windowMinutes = 3) {
|
||||
const normalized = toVehicleEvent(event);
|
||||
const params = new URLSearchParams({
|
||||
keywords: normalized.subject.vin,
|
||||
category: 'raw',
|
||||
eventId: normalized.id,
|
||||
eventTitle: normalized.title,
|
||||
eventVin: normalized.subject.vin,
|
||||
eventAt: normalized.occurredAt
|
||||
});
|
||||
if (normalized.subject.plate) params.set('eventPlate', normalized.subject.plate);
|
||||
if (normalized.source.protocol) params.set('protocol', normalized.source.protocol);
|
||||
const occurredAt = new Date(normalized.occurredAt);
|
||||
if (Number.isFinite(occurredAt.getTime())) {
|
||||
const safeWindow = Math.min(60, Math.max(1, Math.round(windowMinutes)));
|
||||
params.set('dateFrom', shanghaiMinute(new Date(occurredAt.getTime() - safeWindow * 60_000)));
|
||||
params.set('dateTo', shanghaiMinute(new Date(occurredAt.getTime() + safeWindow * 60_000)));
|
||||
}
|
||||
return `/history?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function automationSource(rule: Pick<AlertRule, 'scopeProtocols'>) {
|
||||
const protocols = rule.scopeProtocols ?? [];
|
||||
if (!protocols.length) return '三类协议';
|
||||
if (protocols.length === 1) return protocolEventSources.find((item) => item.protocol === protocols[0])?.label ?? protocols[0];
|
||||
return `${protocols.length} 类协议`;
|
||||
}
|
||||
@@ -1,12 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildHistoryChartSeries, buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, HISTORY_EXPORT_POLL_MS, historyExportPollInterval, parseHistoryKeywords } from './history';
|
||||
import { buildHistoryChartSeries, buildHistorySeriesPanels, formatExportFileSize, formatHistoryValue, HISTORY_EXPORT_POLL_MS, historyExportPollInterval, historyKeywordCandidates, historyQueryPlan, historySeriesGrainForWindow, parseHistoryKeywords } from './history';
|
||||
|
||||
describe('history domain', () => {
|
||||
it('parses and bounds multi-vehicle input', () => {
|
||||
expect(parseHistoryKeywords('粤A1, 粤A1;VIN2\nVIN3')).toEqual(['粤A1', 'VIN2', 'VIN3']);
|
||||
expect(historyKeywordCandidates('1,2,3,4,5,6')).toHaveLength(6);
|
||||
expect(parseHistoryKeywords('1,2,3,4,5,6')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('plans long multi-vehicle windows with the same bounded grain as the API', () => {
|
||||
const plan = historyQueryPlan({
|
||||
keywords: 'VIN1,VIN2,VIN3,VIN4,VIN5',
|
||||
dateFrom: '2026-06-23T00:00',
|
||||
dateTo: '2026-07-23T00:00',
|
||||
category: 'location'
|
||||
});
|
||||
expect(plan).toEqual(expect.objectContaining({
|
||||
vehicleCount: 5,
|
||||
durationLabel: '30 天',
|
||||
grainSeconds: 21600,
|
||||
bucketsPerVehicle: 120,
|
||||
totalVehicleBuckets: 600,
|
||||
scaled: true,
|
||||
tooLarge: false
|
||||
}));
|
||||
expect(historySeriesGrainForWindow(24 * 60 * 60_000)).toBe(900);
|
||||
expect(historyQueryPlan({ keywords: 'VIN1', dateFrom: '2026-06-01T00:00', dateTo: '2026-07-03T00:00', category: 'location' }).tooLarge).toBe(true);
|
||||
});
|
||||
|
||||
it('formats units without fabricating missing values', () => {
|
||||
expect(formatHistoryValue(undefined)).toBe('—');
|
||||
expect(formatHistoryValue(42.5, { unit: 'km/h' } as never)).toBe('42.5 km/h');
|
||||
|
||||
@@ -12,14 +12,69 @@ export function historyExportPollInterval(jobs?: Array<Pick<HistoryExportJob, 's
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseHistoryKeywords(value: string) {
|
||||
export function historyKeywordCandidates(value: string) {
|
||||
const seen = new Set<string>();
|
||||
return value.split(/[,;\n]/).map((item) => item.trim()).filter((item) => {
|
||||
const key = item.toLowerCase();
|
||||
if (!item || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
}).slice(0, 5);
|
||||
});
|
||||
}
|
||||
|
||||
export function parseHistoryKeywords(value: string) {
|
||||
return historyKeywordCandidates(value).slice(0, 5);
|
||||
}
|
||||
|
||||
export const HISTORY_MAX_WINDOW_MS = 31 * 24 * 60 * 60_000;
|
||||
const HISTORY_SERIES_GRAINS = [1, 5, 10, 30, 60, 300, 900, 1800, 3600, 21600, 86400] as const;
|
||||
|
||||
export type HistoryQueryPlan = {
|
||||
category: string;
|
||||
vehicleCount: number;
|
||||
durationMs: number;
|
||||
durationLabel: string;
|
||||
grainSeconds: number;
|
||||
bucketsPerVehicle: number;
|
||||
totalVehicleBuckets: number;
|
||||
scaled: boolean;
|
||||
tooLarge: boolean;
|
||||
};
|
||||
|
||||
export function historySeriesGrainForWindow(durationMs: number, targetPoints = 240) {
|
||||
const safeTarget = Math.max(60, Math.min(600, Math.floor(targetPoints) || 240));
|
||||
const required = Math.ceil(Math.max(0, durationMs) / 1000 / safeTarget);
|
||||
return HISTORY_SERIES_GRAINS.find((grain) => required <= grain) ?? 86400;
|
||||
}
|
||||
|
||||
export function formatHistoryWindowDuration(durationMs: number) {
|
||||
if (!Number.isFinite(durationMs) || durationMs <= 0) return '时间待确认';
|
||||
const minutes = Math.ceil(durationMs / 60_000);
|
||||
if (minutes < 60) return `${minutes} 分钟`;
|
||||
const hours = Math.ceil(minutes / 60);
|
||||
if (hours < 48) return `${hours} 小时`;
|
||||
const days = durationMs / (24 * 60 * 60_000);
|
||||
return Number.isInteger(days) ? `${days} 天` : `${Math.ceil(days * 10) / 10} 天`;
|
||||
}
|
||||
|
||||
export function historyQueryPlan(input: { keywords: string | string[]; dateFrom: string; dateTo: string; category: string }, targetPoints = 240): HistoryQueryPlan {
|
||||
const vehicleCount = Array.isArray(input.keywords) ? new Set(input.keywords.filter(Boolean).map((item) => item.trim().toLocaleLowerCase('zh-CN'))).size : historyKeywordCandidates(input.keywords).length;
|
||||
const start = new Date(input.dateFrom).getTime();
|
||||
const end = new Date(input.dateTo).getTime();
|
||||
const durationMs = Number.isFinite(start) && Number.isFinite(end) && end > start ? end - start : 0;
|
||||
const grainSeconds = historySeriesGrainForWindow(durationMs, targetPoints);
|
||||
const bucketsPerVehicle = durationMs ? Math.ceil(durationMs / 1000 / grainSeconds) : 0;
|
||||
return {
|
||||
category: input.category,
|
||||
vehicleCount,
|
||||
durationMs,
|
||||
durationLabel: formatHistoryWindowDuration(durationMs),
|
||||
grainSeconds,
|
||||
bucketsPerVehicle,
|
||||
totalVehicleBuckets: bucketsPerVehicle * vehicleCount,
|
||||
scaled: vehicleCount > 1 || durationMs > 24 * 60 * 60_000,
|
||||
tooLarge: durationMs > HISTORY_MAX_WINDOW_MS
|
||||
};
|
||||
}
|
||||
|
||||
export function formatHistoryValue(value: unknown, metric?: HistoryMetricDefinition) {
|
||||
|
||||
@@ -14,8 +14,8 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
|
||||
{ vin: 'LTEST000000000002', plate: '粤A54321' }
|
||||
],
|
||||
mileageRows: [
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, source: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, source: 'GB32960' }
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 100, endMileageKm: 188.7, dailyMileageKm: 88.7, pureHydrogenMileageKm: 56.2, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 5.5, source: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 188.7, endMileageKm: 293.3, dailyMileageKm: 104.6, pureHydrogenMileageKm: 72.2, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 5.8, source: 'GB32960' }
|
||||
],
|
||||
sources: [
|
||||
{ protocol: 'GB32960', label: '国标 GB32960', mileageType: '仪表盘里程' },
|
||||
@@ -37,6 +37,14 @@ test('creates a styled numeric mileage workbook with formulas and frozen panes',
|
||||
expect(sheet.views[0]).toMatchObject({ state: 'frozen', xSplit: 2, ySplit: 6, showGridLines: false });
|
||||
expect(sheet.autoFilter).toEqual({ from: { row: 6, column: 1 }, to: { row: 8, column: 5 } });
|
||||
expect((sheet as unknown as { conditionalFormattings: unknown[] }).conditionalFormattings).toHaveLength(1);
|
||||
const hydrogenSheet = workbook.getWorksheet('氢能明细')!;
|
||||
expect(hydrogenSheet.getRow(1).values).toEqual([
|
||||
undefined, '日期', '车牌', 'VIN', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源'
|
||||
]);
|
||||
expect(hydrogenSheet.getRow(2).values).toEqual([
|
||||
undefined, '2026-07-13', '粤A12345', 'LTEST000000000001', 88.7, 56.2, 3.1, 5.5, 'GB32960'
|
||||
]);
|
||||
expect(hydrogenSheet.autoFilter).toEqual({ from: { row: 1, column: 1 }, to: { row: 3, column: 8 } });
|
||||
expect((await workbook.xlsx.writeBuffer()).byteLength).toBeGreaterThan(5_000);
|
||||
});
|
||||
|
||||
@@ -87,7 +95,15 @@ test('terminates the workbook worker when an active export is cancelled', async
|
||||
|
||||
expect(postMessage).toHaveBeenCalledTimes(3);
|
||||
expect(postMessage.mock.calls.map(([message]) => message.type)).toEqual(['start', 'rows', 'finish']);
|
||||
expect(postMessage.mock.calls[1][0].rows).toEqual([{ vin: 'LTEST000000000001', date: '2026-07-13', dailyMileageKm: 88.7 }]);
|
||||
expect(postMessage.mock.calls[1][0].rows).toEqual([{
|
||||
vin: 'LTEST000000000001',
|
||||
date: '2026-07-13',
|
||||
dailyMileageKm: 88.7,
|
||||
pureHydrogenMileageKm: undefined,
|
||||
hydrogenConsumptionKg: undefined,
|
||||
hydrogenConsumptionKgPer100Km: undefined,
|
||||
source: 'GB32960'
|
||||
}]);
|
||||
controller.abort();
|
||||
await expect(exportPromise).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(terminate).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -81,7 +81,18 @@ export function createMileageExportStream(input: Omit<MileageExportInput, 'vehic
|
||||
return {
|
||||
appendRows(rows) {
|
||||
if (disposed || finishing) return;
|
||||
post({ type: 'rows', rows: rows.map((row) => ({ vin: row.vin, date: row.date, dailyMileageKm: row.dailyMileageKm })) });
|
||||
post({
|
||||
type: 'rows',
|
||||
rows: rows.map((row) => ({
|
||||
vin: row.vin,
|
||||
date: row.date,
|
||||
dailyMileageKm: row.dailyMileageKm,
|
||||
pureHydrogenMileageKm: row.pureHydrogenMileageKm,
|
||||
hydrogenConsumptionKg: row.hydrogenConsumptionKg,
|
||||
hydrogenConsumptionKgPer100Km: row.hydrogenConsumptionKgPer100Km,
|
||||
source: row.source
|
||||
}))
|
||||
});
|
||||
},
|
||||
finish(vehicles) {
|
||||
if (disposed) return Promise.reject(earlyError ?? abortError());
|
||||
|
||||
@@ -130,5 +130,48 @@ export async function createMileageWorkbook(input: MileageExportInput) {
|
||||
});
|
||||
}
|
||||
sheet.headerFooter.oddFooter = '&L灵牛车辆数据中台&C第 &P / &N 页&R导出于 ' + localDateTime(exportedAt);
|
||||
|
||||
const hydrogenSheet = workbook.addWorksheet('氢能明细', {
|
||||
views: [{ state: 'frozen', ySplit: 1, activeCell: 'A2', showGridLines: false }],
|
||||
properties: { defaultRowHeight: 21 }
|
||||
});
|
||||
hydrogenSheet.addRow(['日期', '车牌', 'VIN', '总里程 (km)', '纯氢里程 (km)', '耗氢 (kg)', '百公里纯氢耗 (kg/100km)', '来源']);
|
||||
const hydrogenHeader = hydrogenSheet.getRow(1);
|
||||
hydrogenHeader.height = 30;
|
||||
hydrogenHeader.eachCell((cell) => {
|
||||
cell.font = { name: 'Microsoft YaHei', size: 10, bold: true, color: { argb: 'FF304158' } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEAF0F7' } };
|
||||
cell.border = { bottom: { style: 'medium', color: { argb: 'FFC3D0DF' } } };
|
||||
});
|
||||
const plateByVIN = new Map(input.vehicles.map((vehicle) => [vehicle.vin, vehicle.plate || '未绑定']));
|
||||
const energyRows = [...input.mileageRows].sort((left, right) => left.date.localeCompare(right.date) || left.vin.localeCompare(right.vin));
|
||||
energyRows.forEach((value, index) => {
|
||||
const row = hydrogenSheet.addRow([
|
||||
value.date,
|
||||
plateByVIN.get(value.vin) ?? value.plate ?? '未绑定',
|
||||
value.vin,
|
||||
value.dailyMileageKm,
|
||||
value.pureHydrogenMileageKm ?? null,
|
||||
value.hydrogenConsumptionKg ?? null,
|
||||
value.hydrogenConsumptionKgPer100Km ?? null,
|
||||
value.source ?? ''
|
||||
]);
|
||||
row.height = 24;
|
||||
row.eachCell({ includeEmpty: true }, (cell, columnNumber) => {
|
||||
cell.font = { name: columnNumber === 3 ? 'Consolas' : 'Microsoft YaHei', size: columnNumber === 3 ? 9 : 10, color: { argb: 'FF34445A' } };
|
||||
cell.alignment = { vertical: 'middle', horizontal: columnNumber >= 4 && columnNumber <= 7 ? 'right' : 'left' };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: index % 2 ? 'FFF9FBFD' : 'FFFFFFFF' } };
|
||||
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE6ECF3' } } };
|
||||
if (columnNumber >= 4 && columnNumber <= 7) cell.numFmt = '#,##0.0';
|
||||
});
|
||||
});
|
||||
[13, 15, 24, 15, 17, 14, 27, 16].forEach((width, index) => {
|
||||
hydrogenSheet.getColumn(index + 1).width = width;
|
||||
});
|
||||
if (energyRows.length) {
|
||||
hydrogenSheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: energyRows.length + 1, column: 8 } };
|
||||
}
|
||||
hydrogenSheet.headerFooter.oddFooter = sheet.headerFooter.oddFooter;
|
||||
return workbook;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from './profileSync';
|
||||
import type { VehicleProfileSyncResult } from '../../api/types';
|
||||
import {
|
||||
chunkVehicleProfileSyncItems, emptyVehicleProfileSyncResult, loadVehicleProfileSyncDraft,
|
||||
mergeVehicleProfileSyncResults, parseVehicleProfileSyncCSV, saveVehicleProfileSyncDraft,
|
||||
VEHICLE_PROFILE_SYNC_CHUNK_SIZE, VEHICLE_PROFILE_SYNC_DRAFT_KEY, vehicleProfileSyncCSVHeader,
|
||||
vehicleProfileSyncIssuesCSV
|
||||
} from './profileSync';
|
||||
|
||||
describe('parseVehicleProfileSyncCSV', () => {
|
||||
it('parses quoted values, BOM, CRLF and normalizes VIN/status', () => {
|
||||
@@ -14,4 +20,36 @@ describe('parseVehicleProfileSyncCSV', () => {
|
||||
expect(() => parseVehicleProfileSyncCSV('vin,modelName\nVIN001,车型')).toThrow(/表头/);
|
||||
expect(() => parseVehicleProfileSyncCSV(`${vehicleProfileSyncCSVHeader}\nVIN001,宇通,"车型"x,,,active,,,`)).toThrow(/引号结束/);
|
||||
});
|
||||
|
||||
it('accepts a five-thousand-row file and splits it into server-safe chunks', () => {
|
||||
const rows = Array.from({ length: 1_001 }, (_, index) => `VIN${String(index).padStart(5, '0')},,,,,active,,,${index}`);
|
||||
const items = parseVehicleProfileSyncCSV([vehicleProfileSyncCSVHeader, ...rows].join('\n'));
|
||||
const chunks = chunkVehicleProfileSyncItems(items);
|
||||
expect(chunks).toHaveLength(3);
|
||||
expect(chunks[0]).toHaveLength(VEHICLE_PROFILE_SYNC_CHUNK_SIZE);
|
||||
expect(chunks[2]).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vehicle profile sync task continuity', () => {
|
||||
it('aggregates chunk results and exports only actionable rows', () => {
|
||||
const first = { ...emptyVehicleProfileSyncResult('oem-tsp', 'v1', true), received: 2, updated: 1, conflicted: 1, items: [{ vin: 'VIN1', status: 'updated' }, { vin: 'VIN2', status: 'conflict_source', previousSource: 'manual' }] } as VehicleProfileSyncResult;
|
||||
const second = { ...emptyVehicleProfileSyncResult('oem-tsp', 'v1', true), received: 1, missing: 1, items: [{ vin: 'VIN3', status: 'missing_vehicle' }] } as VehicleProfileSyncResult;
|
||||
const merged = mergeVehicleProfileSyncResults(first, second);
|
||||
expect(merged).toMatchObject({ received: 3, updated: 1, conflicted: 1, missing: 1 });
|
||||
expect(vehicleProfileSyncIssuesCSV(merged)).toContain('VIN2,conflict_source,manual');
|
||||
expect(vehicleProfileSyncIssuesCSV(merged)).toContain('VIN3,missing_vehicle');
|
||||
expect(vehicleProfileSyncIssuesCSV(merged)).not.toContain('VIN1,updated');
|
||||
});
|
||||
|
||||
it('restores only a versioned, bounded session draft', () => {
|
||||
const values = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value)
|
||||
};
|
||||
saveVehicleProfileSyncDraft({ version: 2, sourceSystem: 'oem-tsp', sourceVersion: 'v1', conflictPolicy: 'preserve', fileName: 'fleet.csv', items: [], phase: 'configured', completedChunks: 0, savedAt: '2026-07-23T00:00:00Z' }, storage);
|
||||
expect(values.has(VEHICLE_PROFILE_SYNC_DRAFT_KEY)).toBe(true);
|
||||
expect(loadVehicleProfileSyncDraft(storage)).toMatchObject({ sourceSystem: 'oem-tsp', fileName: 'fleet.csv' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
import type { VehicleProfileSyncItem } from '../../api/types';
|
||||
import type { VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types';
|
||||
|
||||
const headers = ['vin', 'brandName', 'modelName', 'vehicleType', 'companyName', 'operationStatus', 'accessProvider', 'firstAccessAt', 'runtimeSeconds'] as const;
|
||||
const allowedStatuses = new Set(['', 'unknown', 'active', 'inactive', 'maintenance', 'retired']);
|
||||
|
||||
export const vehicleProfileSyncCSVHeader = headers.join(',');
|
||||
export const VEHICLE_PROFILE_SYNC_CHUNK_SIZE = 500;
|
||||
export const VEHICLE_PROFILE_SYNC_MAX_ROWS = 5_000;
|
||||
export const VEHICLE_PROFILE_SYNC_DRAFT_KEY = 'vehicle-profile-sync-draft-v2';
|
||||
|
||||
export type VehicleProfileSyncTaskPhase = 'configured' | 'previewing' | 'previewed' | 'applying' | 'interrupted' | 'applied';
|
||||
|
||||
export type VehicleProfileSyncDraft = {
|
||||
version: 2;
|
||||
sourceSystem: string;
|
||||
sourceVersion: string;
|
||||
conflictPolicy: 'preserve' | 'overwrite';
|
||||
fileName: string;
|
||||
items: VehicleProfileSyncItem[];
|
||||
phase: VehicleProfileSyncTaskPhase;
|
||||
completedChunks: number;
|
||||
result?: VehicleProfileSyncResult;
|
||||
savedAt: string;
|
||||
};
|
||||
|
||||
export function parseVehicleProfileSyncCSV(text: string): VehicleProfileSyncItem[] {
|
||||
const rows = parseCSVRows(text.replace(/^\uFEFF/, ''));
|
||||
@@ -13,7 +31,7 @@ export function parseVehicleProfileSyncCSV(text: string): VehicleProfileSyncItem
|
||||
throw new Error(`CSV 表头必须为:${vehicleProfileSyncCSVHeader}`);
|
||||
}
|
||||
const dataRows = rows.slice(1).filter((row) => row.some((value) => value.trim() !== ''));
|
||||
if (dataRows.length === 0 || dataRows.length > 500) throw new Error('单个 CSV 必须包含 1 至 500 辆车');
|
||||
if (dataRows.length === 0 || dataRows.length > VEHICLE_PROFILE_SYNC_MAX_ROWS) throw new Error(`单个 CSV 必须包含 1 至 ${VEHICLE_PROFILE_SYNC_MAX_ROWS.toLocaleString('zh-CN')} 辆车`);
|
||||
const seen = new Set<string>();
|
||||
return dataRows.map((row, index) => {
|
||||
const line = index + 2;
|
||||
@@ -35,6 +53,67 @@ export function parseVehicleProfileSyncCSV(text: string): VehicleProfileSyncItem
|
||||
});
|
||||
}
|
||||
|
||||
export function chunkVehicleProfileSyncItems(items: VehicleProfileSyncItem[]) {
|
||||
const chunks: VehicleProfileSyncItem[][] = [];
|
||||
for (let index = 0; index < items.length; index += VEHICLE_PROFILE_SYNC_CHUNK_SIZE) {
|
||||
chunks.push(items.slice(index, index + VEHICLE_PROFILE_SYNC_CHUNK_SIZE));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function emptyVehicleProfileSyncResult(sourceSystem: string, sourceVersion: string, dryRun: boolean): VehicleProfileSyncResult {
|
||||
return { sourceSystem, sourceVersion, dryRun, received: 0, created: 0, updated: 0, unchanged: 0, conflicted: 0, missing: 0, items: [] };
|
||||
}
|
||||
|
||||
export function mergeVehicleProfileSyncResults(current: VehicleProfileSyncResult, next: VehicleProfileSyncResult): VehicleProfileSyncResult {
|
||||
return {
|
||||
sourceSystem: next.sourceSystem || current.sourceSystem,
|
||||
sourceVersion: next.sourceVersion || current.sourceVersion,
|
||||
dryRun: next.dryRun,
|
||||
received: current.received + next.received,
|
||||
created: current.created + next.created,
|
||||
updated: current.updated + next.updated,
|
||||
unchanged: current.unchanged + next.unchanged,
|
||||
conflicted: current.conflicted + next.conflicted,
|
||||
missing: current.missing + next.missing,
|
||||
items: [...current.items, ...next.items]
|
||||
};
|
||||
}
|
||||
|
||||
export function loadVehicleProfileSyncDraft(storage: Pick<Storage, 'getItem'> = sessionStorage): VehicleProfileSyncDraft | undefined {
|
||||
try {
|
||||
const raw = storage.getItem(VEHICLE_PROFILE_SYNC_DRAFT_KEY);
|
||||
if (!raw) return undefined;
|
||||
const value = JSON.parse(raw) as Partial<VehicleProfileSyncDraft>;
|
||||
if (value.version !== 2 || !Array.isArray(value.items) || value.items.length > VEHICLE_PROFILE_SYNC_MAX_ROWS) return undefined;
|
||||
if (!['preserve', 'overwrite'].includes(String(value.conflictPolicy)) || !['configured', 'previewing', 'previewed', 'applying', 'interrupted', 'applied'].includes(String(value.phase))) return undefined;
|
||||
return value as VehicleProfileSyncDraft;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVehicleProfileSyncDraft(draft: VehicleProfileSyncDraft, storage: Pick<Storage, 'setItem'> = sessionStorage) {
|
||||
storage.setItem(VEHICLE_PROFILE_SYNC_DRAFT_KEY, JSON.stringify(draft));
|
||||
}
|
||||
|
||||
export function clearVehicleProfileSyncDraft(storage: Pick<Storage, 'removeItem'> = sessionStorage) {
|
||||
storage.removeItem(VEHICLE_PROFILE_SYNC_DRAFT_KEY);
|
||||
}
|
||||
|
||||
function csvCell(value: unknown) {
|
||||
const text = String(value ?? '');
|
||||
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
export function vehicleProfileSyncIssuesCSV(result: VehicleProfileSyncResult) {
|
||||
const headers = ['VIN', '状态', '原来源', '原版本', '档案版本'];
|
||||
const rows = result.items
|
||||
.filter((item) => item.status.startsWith('conflict_') || item.status === 'missing_vehicle')
|
||||
.map((item) => [item.vin, item.status, item.previousSource, item.previousVersion, item.profileVersion]);
|
||||
return `\uFEFF${[headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\n')}`;
|
||||
}
|
||||
|
||||
function parseCSVRows(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import type { ReconciliationIssue } from '../../api/types';
|
||||
import { reconciliationBatchResultCSV } from './reconciliationExport';
|
||||
|
||||
const issue = {
|
||||
id: 'issue-1', plate: '=危险车牌', vin: 'VIN001', title: '位置漂移', assignee: '定位运维组', dueAt: '2026-07-24 10:00:00', status: 'pending', version: 1
|
||||
} as ReconciliationIssue;
|
||||
|
||||
test('builds an auditable batch result csv and neutralizes spreadsheet formulas', () => {
|
||||
const csv = reconciliationBatchResultCSV('resolution', {
|
||||
requested: 1,
|
||||
succeeded: [],
|
||||
skipped: [{ id: 'issue-1', code: 'RECONCILIATION_VERSION_CONFLICT', message: '记录已更新,请刷新后重试' }]
|
||||
}, [issue]);
|
||||
|
||||
expect(csv).toContain('差异编号,执行结果,结果说明');
|
||||
expect(csv).toContain("issue-1,跳过,记录已更新,请刷新后重试,'=危险车牌");
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ReconciliationBatchActionResult, ReconciliationIssue } from '../../api/types';
|
||||
import { downloadBlob } from './download';
|
||||
|
||||
export type ReconciliationBatchExportKind = 'assignment' | 'resolution';
|
||||
|
||||
function csvCell(value: unknown) {
|
||||
let text = String(value ?? '').trim();
|
||||
if (/^[=+\-@]/.test(text)) text = `'${text}`;
|
||||
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
export function reconciliationBatchResultCSV(
|
||||
kind: ReconciliationBatchExportKind,
|
||||
result: ReconciliationBatchActionResult,
|
||||
selectedIssues: ReconciliationIssue[]
|
||||
) {
|
||||
const succeeded = new Map(result.succeeded.map((issue) => [issue.id, issue]));
|
||||
const skipped = new Map(result.skipped.map((failure) => [failure.id, failure]));
|
||||
const headers = ['差异编号', '执行结果', '结果说明', '车牌', 'VIN', '问题', '负责人', '处理期限', '状态', '版本'];
|
||||
const rows = selectedIssues.map((selected) => {
|
||||
const updated = succeeded.get(selected.id);
|
||||
const failure = skipped.get(selected.id);
|
||||
const issue = updated ?? selected;
|
||||
return [
|
||||
selected.id,
|
||||
updated ? '成功' : failure ? '跳过' : '未返回',
|
||||
updated ? (kind === 'assignment' ? '责任交接已写入履历' : '处置结论已写入履历') : failure?.message ?? '服务端未返回逐项结果',
|
||||
issue.plate,
|
||||
issue.vin,
|
||||
issue.title,
|
||||
issue.assignee,
|
||||
issue.dueAt,
|
||||
issue.status,
|
||||
issue.version
|
||||
];
|
||||
});
|
||||
return `\uFEFF${[headers, ...rows].map((row) => row.map(csvCell).join(',')).join('\n')}`;
|
||||
}
|
||||
|
||||
export function downloadReconciliationBatchResult(
|
||||
kind: ReconciliationBatchExportKind,
|
||||
result: ReconciliationBatchActionResult,
|
||||
selectedIssues: ReconciliationIssue[],
|
||||
now = new Date()
|
||||
) {
|
||||
const stamp = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0')].join('');
|
||||
const label = kind === 'assignment' ? '批量交接结果' : '批量处置结果';
|
||||
downloadBlob(new Blob([reconciliationBatchResultCSV(kind, result, selectedIssues)], { type: 'text/csv;charset=utf-8' }), `${label}_${stamp}.csv`);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ test('renders only explicitly assigned customer menus', () => {
|
||||
expect(screen.getByRole('link', { name: '轨迹回放' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '里程查询' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '历史数据' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '告警中心' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '事件中心' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '接入管理' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '账号管理' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: '运维质量' })).not.toBeInTheDocument();
|
||||
@@ -56,7 +56,7 @@ test('groups desktop workflows with distinct Semi navigation icons and the curre
|
||||
|
||||
expect(screen.getByLabelText('羚牛智能车辆数据中台')).toContainElement(screen.getByAltText('羚牛智能'));
|
||||
expect(view.container.querySelector('.v2-nav-group-start.is-workspace')).toHaveTextContent('全局监控');
|
||||
expect(view.container.querySelector('.v2-nav-group-start.is-governance')).toHaveTextContent('告警中心');
|
||||
expect(view.container.querySelector('.v2-nav-group-start.is-governance')).toHaveTextContent('事件中心');
|
||||
expect(screen.getByRole('link', { name: '轨迹回放' })?.querySelector('.semi-icon-route')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '历史数据' })?.querySelector('.semi-icon-history')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: '里程查询' })?.querySelector('.semi-icon-bar_chart_v_stroked')).toBeInTheDocument();
|
||||
@@ -211,10 +211,10 @@ test('uses the compact mobile navigation and opens secondary modules in a Semi S
|
||||
expect(moreSheet).toHaveTextContent('数据分析、业务处置与平台治理');
|
||||
expect(screen.getByRole('navigation', { name: '分析与处置' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('navigation', { name: '平台治理' })).toBeInTheDocument();
|
||||
expect(moreSheet).toHaveTextContent('历史证据与业务告警');
|
||||
expect(moreSheet).toHaveTextContent('历史证据与事件自动化');
|
||||
expect(moreSheet).toHaveTextContent('接入、账号与运维质量');
|
||||
expect(screen.getByRole('link', { name: '历史数据' })).toHaveTextContent('按时间回溯原始上报');
|
||||
expect(screen.getByRole('link', { name: '告警中心' })).toHaveTextContent('集中查看并闭环风险');
|
||||
expect(screen.getByRole('link', { name: '事件中心' })).toHaveTextContent('统一查看事件与执行轨迹');
|
||||
expect(screen.getByRole('link', { name: '接入管理' })).toHaveTextContent('核对身份、协议与差异');
|
||||
expect(screen.getByRole('link', { name: '账号管理' })).toHaveTextContent('管理客户与车辆权限');
|
||||
expect(screen.getByRole('link', { name: '运维质量' })).toHaveTextContent('诊断链路与数据质量');
|
||||
@@ -250,7 +250,7 @@ test('lets customer mobile navigation use the full width when no more menu is ne
|
||||
|
||||
test('automatically collapses the Semi sidebar at tablet width', () => {
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
|
||||
matches: query === '(max-width: 900px)',
|
||||
matches: query === '(max-width: 1100px)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
@@ -265,6 +265,8 @@ test('automatically collapses the Semi sidebar at tablet width', () => {
|
||||
</MemoryRouter>);
|
||||
|
||||
expect(screen.getByRole('link', { name: '全局监控' })).toHaveAttribute('title', '全局监控');
|
||||
expect(screen.getByRole('complementary', { name: '主导航' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-mobile-navigation')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '收起侧栏' })).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-sidebar')).toHaveClass('is-collapsed');
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ const navigation = [
|
||||
{ to: '/tracks', menu: 'tracks', label: '轨迹回放', icon: IconRoute, group: 'workspace' },
|
||||
{ to: '/history', menu: 'history', label: '历史数据', icon: IconHistory, group: 'workspace' },
|
||||
{ to: '/statistics', menu: 'statistics', label: '里程查询', icon: IconBarChartVStroked, group: 'workspace' },
|
||||
{ to: '/alerts', menu: 'alerts', label: '告警中心', icon: IconAlarm, group: 'governance' },
|
||||
{ to: '/alerts', menu: 'alerts', label: '事件中心', icon: IconAlarm, group: 'governance' },
|
||||
{ to: '/access', menu: 'access', label: '接入管理', icon: IconConnectionPoint1, group: 'governance' },
|
||||
{ to: '/users', menu: 'users', label: '账号管理', icon: IconUserGroup, group: 'governance' }
|
||||
];
|
||||
@@ -51,7 +51,7 @@ const pageNames: Record<string, string> = {
|
||||
tracks: '轨迹回放',
|
||||
history: '历史数据',
|
||||
statistics: '里程查询',
|
||||
alerts: '告警中心',
|
||||
alerts: '事件中心',
|
||||
access: '接入管理',
|
||||
operations: '运维质量',
|
||||
users: '账号管理'
|
||||
@@ -63,7 +63,7 @@ const pageHelp: Record<string, { summary: string; tips: string[] }> = {
|
||||
tracks: { summary: '按车辆和时间范围回放历史轨迹。', tips: ['先提交查询条件,再使用播放轴定位具体时刻。', '手动拖动地图会暂停跟随,避免操作与播放动画冲突。'] },
|
||||
history: { summary: '查询车辆原始历史数据并导出证据。', tips: ['最多同时查询 5 台车辆,缩小时间范围可提高响应速度。', '导出任务在后台执行,完成前可继续使用其他页面。'] },
|
||||
statistics: { summary: '按日期区间比较车辆每日里程与总里程。', tips: ['未选择车牌时按车队分页展示。', '可配置 JT808、GB32960、YUTONG_MQTT 的启用状态与优先级。'] },
|
||||
alerts: { summary: '查看、确认和关闭车辆业务告警。', tips: ['筛选条件会限定列表和统计口径。', '处置前请核对证据与版本,避免覆盖其他人员的操作。'] },
|
||||
alerts: { summary: '统一处理车辆事件与自动化。', tips: ['事件流包含异常、状态变化和业务结果,只有待处理事件需要人工介入。', '自动化按“事件、条件、动作”组织,协议来源与动作策略彼此独立。', '处置前核对原始事件、自动化版本与执行轨迹。'] },
|
||||
access: { summary: '核对车辆接入覆盖、身份差异和协议质量。', tips: ['差异列表是主要工作区,可从统计卡片快速下钻。', '阈值配置仅对有权限的账号开放。'] },
|
||||
operations: { summary: '查看数据源、查询链路和服务健康状态。', tips: ['优先处理红色异常,再检查数据新鲜度和来源就绪状态。', '页面会自动刷新,也可以手动触发即时检查。'] },
|
||||
users: { summary: '创建客户账号并分配菜单和车辆数据范围。', tips: ['客户只能使用四个对外菜单中的已分配项。', '停用账号或重置密码会立即撤销其旧会话。', '车辆权限修改最多在 30 秒内对活跃会话生效。'] }
|
||||
@@ -284,10 +284,10 @@ const mobileMoreGroups = [
|
||||
{
|
||||
key: 'insight',
|
||||
title: '分析与处置',
|
||||
description: '历史证据与业务告警',
|
||||
description: '历史证据与事件自动化',
|
||||
items: [
|
||||
{ ...navigation[3], description: '按时间回溯原始上报' },
|
||||
{ ...navigation[5], description: '集中查看并闭环风险' }
|
||||
{ ...navigation[5], description: '统一查看事件与执行轨迹' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -358,7 +358,7 @@ function Sidebar({ activePath }: { activePath: string }) {
|
||||
const { session } = usePlatformSession();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const compactLayout = useMobileLayout(900);
|
||||
const compactLayout = useMobileLayout(1100);
|
||||
const effectiveCollapsed = collapsed || compactLayout;
|
||||
const warmRoute = (path: string) => { if (shouldPreloadRouteOnIntent()) void preloadRoute(path); };
|
||||
const visibleNavigation = [
|
||||
|
||||
@@ -325,6 +325,7 @@ test('keeps dense province totals visible with deterministic pixel-space avoidan
|
||||
});
|
||||
|
||||
test('recovers in place after a transient AMap failure and renders data that arrived while retrying', async () => {
|
||||
const openList = vi.fn();
|
||||
let resolveAMap!: (value: AMapLike) => void;
|
||||
const delayedAMap = new Promise<AMapLike>((resolve) => {
|
||||
resolveAMap = resolve;
|
||||
@@ -340,10 +341,13 @@ test('recovers in place after a transient AMap failure and renders data that arr
|
||||
vehicles={[]}
|
||||
monitorMap={monitorMap}
|
||||
onSelect={() => undefined}
|
||||
onOpenList={openList}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/地图加载失败/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '使用车辆列表' }));
|
||||
expect(openList).toHaveBeenCalledTimes(1);
|
||||
const retry = screen.getByRole('button', { name: /重新加载地图/ });
|
||||
expect(retry).toHaveClass('semi-button', 'v2-map-retry-action');
|
||||
fireEvent.click(retry);
|
||||
@@ -364,6 +368,16 @@ test('recovers in place after a transient AMap failure and renders data that arr
|
||||
expect(screen.queryByText(/地图加载失败/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps the paged vehicle list available when the map is not configured', async () => {
|
||||
const openList = vi.fn();
|
||||
|
||||
render(<FleetMap vehicles={[]} monitorMap={monitorMap} onSelect={() => undefined} onOpenList={openList} />);
|
||||
|
||||
expect(await screen.findByText(/地图未配置/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '使用车辆列表' }));
|
||||
expect(openList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('restores the saved monitor viewport without forcing the selected vehicle zoom', async () => {
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
|
||||
|
||||
@@ -471,7 +471,7 @@ function densePlatePlacements(points: PlateLabelPoint[]) {
|
||||
return placements;
|
||||
}
|
||||
|
||||
export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelectVin, onViewportChange, initialViewport }: {
|
||||
export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelectVin, onViewportChange, initialViewport, onOpenList }: {
|
||||
vehicles: VehicleRealtimeRow[];
|
||||
selectedVin?: string;
|
||||
onSelect: (vehicle: VehicleRealtimeRow) => void;
|
||||
@@ -479,6 +479,7 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
onSelectVin?: (vin: string) => void;
|
||||
onViewportChange?: (viewport: MonitorViewport) => void;
|
||||
initialViewport?: MonitorViewport;
|
||||
onOpenList?: () => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<AMapMap | null>(null);
|
||||
@@ -1054,8 +1055,17 @@ export function FleetMap({ vehicles, selectedVin, onSelect, monitorMap, onSelect
|
||||
{state !== 'ready' ? (
|
||||
<div className={`v2-map-state is-${state}`}>
|
||||
{state === 'loading' ? <><span className="v2-spinner" />高德地图加载中</> : null}
|
||||
{state === 'fallback' ? `地图未配置,当前已载入 ${renderedPointCount} 个有效坐标` : null}
|
||||
{state === 'error' ? <><span>地图加载失败,请检查高德 Key、域名白名单和网络</span><MapRetryAction onRetry={() => setLoadAttempt((value) => value + 1)} /></> : null}
|
||||
{state === 'fallback' ? <>
|
||||
<span>地图未配置,当前已载入 {renderedPointCount} 个有效坐标</span>
|
||||
{onOpenList ? <Button className="v2-map-list-action" theme="light" type="tertiary" onClick={onOpenList}>使用车辆列表</Button> : null}
|
||||
</> : null}
|
||||
{state === 'error' ? <>
|
||||
<span>地图加载失败,请检查高德 Key、域名白名单和网络</span>
|
||||
<div className="v2-map-recovery-actions">
|
||||
<MapRetryAction onRetry={() => setLoadAttempt((value) => value + 1)} />
|
||||
{onOpenList ? <Button className="v2-map-list-action" theme="light" type="tertiary" onClick={onOpenList}>使用车辆列表</Button> : null}
|
||||
</div>
|
||||
</> : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="v2-map-legend" aria-label="车辆状态图例">
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import type { AMapLike } from '../../integrations/amap';
|
||||
import GeofenceMapEditor from './GeofenceMapEditor';
|
||||
|
||||
const mapResize = vi.fn();
|
||||
const mapDestroy = vi.fn();
|
||||
const mouseClose = vi.fn();
|
||||
const mouseCircle = vi.fn();
|
||||
const mapOptions: Record<string, unknown>[] = [];
|
||||
|
||||
class TestMap {
|
||||
constructor(_container: HTMLDivElement, options: Record<string, unknown>) {
|
||||
mapOptions.push(options);
|
||||
}
|
||||
add = vi.fn();
|
||||
addControl = vi.fn();
|
||||
destroy = mapDestroy;
|
||||
resize = mapResize;
|
||||
setFitView = vi.fn();
|
||||
}
|
||||
|
||||
class TestMouseTool {
|
||||
close = mouseClose;
|
||||
circle = mouseCircle;
|
||||
on = vi.fn();
|
||||
off = vi.fn();
|
||||
}
|
||||
|
||||
class TestCircle {
|
||||
getCenter = () => ({ getLng: () => 114, getLat: () => 22 });
|
||||
getRadius = () => 500;
|
||||
setMap = vi.fn();
|
||||
}
|
||||
|
||||
class EmptyOverlay {
|
||||
setMap = vi.fn();
|
||||
}
|
||||
|
||||
function amapMock(): AMapLike {
|
||||
return {
|
||||
Map: TestMap as unknown as AMapLike['Map'],
|
||||
Marker: EmptyOverlay as unknown as AMapLike['Marker'],
|
||||
Polyline: EmptyOverlay as unknown as AMapLike['Polyline'],
|
||||
Circle: TestCircle as unknown as NonNullable<AMapLike['Circle']>,
|
||||
MouseTool: TestMouseTool as unknown as NonNullable<AMapLike['MouseTool']>,
|
||||
Scale: class {},
|
||||
ToolBar: class {},
|
||||
Size: class {} as unknown as AMapLike['Size'],
|
||||
Pixel: class {} as unknown as AMapLike['Pixel'],
|
||||
MassMarks: class {} as unknown as AMapLike['MassMarks']
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete window.__LINGNIU_APP_CONFIG__;
|
||||
delete window.AMapLoader;
|
||||
mapResize.mockReset();
|
||||
mapDestroy.mockReset();
|
||||
mouseClose.mockReset();
|
||||
mouseCircle.mockReset();
|
||||
mapOptions.length = 0;
|
||||
});
|
||||
|
||||
test('resizes the map hit surface before starting a geofence drawing', async () => {
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
window.AMapLoader = { load: vi.fn(async () => amapMock()) };
|
||||
|
||||
render(<GeofenceMapEditor value={{ longitude: 0, latitude: 0, radiusM: 0 }} onChange={() => undefined} />);
|
||||
|
||||
const draw = (await screen.findByText('开始绘制')).closest('button');
|
||||
expect(draw).not.toBeNull();
|
||||
expect(mapOptions[0]).toMatchObject({ viewMode: '2D', resizeEnable: true });
|
||||
fireEvent.click(draw!);
|
||||
|
||||
expect(mapResize).toHaveBeenCalled();
|
||||
expect(mouseClose).toHaveBeenCalledWith(true);
|
||||
expect(mouseCircle).toHaveBeenCalledWith(expect.objectContaining({ cursor: 'crosshair' }));
|
||||
expect(screen.getByRole('region', { name: '地图绘制电子围栏' })).toHaveClass('is-drawing');
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { IconMapPin, IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { Button, Tag } from '@douyinfe/semi-ui';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
|
||||
import {
|
||||
gcj02ToWgs84,
|
||||
isValidAMapCoordinate,
|
||||
loadAMap,
|
||||
wgs84ToGcj02,
|
||||
type AMapCircleOverlay,
|
||||
type AMapLike,
|
||||
type AMapMap,
|
||||
type AMapMouseTool
|
||||
} from '../../integrations/amap';
|
||||
|
||||
type GeofenceValue = { longitude: number; latitude: number; radiusM: number };
|
||||
|
||||
type GeofenceRuntime = {
|
||||
AMap: AMapLike;
|
||||
map: AMapMap;
|
||||
mouseTool: AMapMouseTool;
|
||||
};
|
||||
|
||||
const DEFAULT_CENTER: [number, number] = [114.057868, 22.543099];
|
||||
|
||||
function validFence(value: GeofenceValue) {
|
||||
return isValidAMapCoordinate(value.longitude, value.latitude) && Number.isFinite(value.radiusM) && value.radiusM >= 50;
|
||||
}
|
||||
|
||||
export default function GeofenceMapEditor({ value, onChange }: { value: GeofenceValue; onChange: (value: GeofenceValue) => void }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const runtimeRef = useRef<GeofenceRuntime>();
|
||||
const overlayRef = useRef<AMapCircleOverlay>();
|
||||
const onChangeRef = useRef(onChange);
|
||||
const valueRef = useRef(value);
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'drawing' | 'error'>(() => isAMapConfigured(getAMapConfig()) ? 'loading' : 'error');
|
||||
const [error, setError] = useState(() => isAMapConfigured(getAMapConfig()) ? '' : '高德地图尚未配置,请先使用下方坐标与半径输入。');
|
||||
|
||||
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
|
||||
useEffect(() => { valueRef.current = value; }, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
let resizeTimer: number | undefined;
|
||||
let initialResizeFrame: number | undefined;
|
||||
if (!containerRef.current || !isAMapConfigured(getAMapConfig())) return undefined;
|
||||
loadAMap(['AMap.Scale', 'AMap.ToolBar', 'AMap.MouseTool']).then((AMap) => {
|
||||
if (cancelled || !containerRef.current) return;
|
||||
if (!AMap.MouseTool || !AMap.Circle) throw new Error('高德地图绘制组件不可用');
|
||||
const initial = validFence(valueRef.current) ? wgs84ToGcj02(valueRef.current.longitude, valueRef.current.latitude) : wgs84ToGcj02(...DEFAULT_CENTER);
|
||||
const map = new AMap.Map(containerRef.current, {
|
||||
zoom: validFence(valueRef.current) ? 14 : 10,
|
||||
center: initial,
|
||||
viewMode: '2D',
|
||||
resizeEnable: true
|
||||
});
|
||||
map.addControl(new AMap.Scale());
|
||||
if (AMap.ToolBar) map.addControl(new AMap.ToolBar({ position: { right: '12px', top: '52px' } }));
|
||||
const mouseTool = new AMap.MouseTool(map);
|
||||
const handleDraw = (event: { obj?: AMapCircleOverlay }) => {
|
||||
const circle = event.obj;
|
||||
if (!circle) return;
|
||||
const center = circle.getCenter();
|
||||
const [longitude, latitude] = gcj02ToWgs84(center.getLng(), center.getLat());
|
||||
const radiusM = Math.max(50, Math.min(100_000, Math.round(circle.getRadius())));
|
||||
mouseTool.close(false);
|
||||
overlayRef.current = circle;
|
||||
setStatus('ready');
|
||||
onChangeRef.current({ longitude: Number(longitude.toFixed(6)), latitude: Number(latitude.toFixed(6)), radiusM });
|
||||
};
|
||||
mouseTool.on('draw', handleDraw);
|
||||
runtimeRef.current = { AMap, map, mouseTool };
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
window.clearTimeout(resizeTimer);
|
||||
resizeTimer = window.setTimeout(() => map.resize?.(), 80);
|
||||
});
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
initialResizeFrame = window.requestAnimationFrame(() => map.resize?.());
|
||||
setStatus('ready');
|
||||
}).catch((reason: unknown) => {
|
||||
if (cancelled) return;
|
||||
setError(reason instanceof Error ? reason.message : '地图加载失败');
|
||||
setStatus('error');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
resizeObserver?.disconnect();
|
||||
window.clearTimeout(resizeTimer);
|
||||
if (initialResizeFrame !== undefined) window.cancelAnimationFrame(initialResizeFrame);
|
||||
const runtime = runtimeRef.current;
|
||||
runtime?.mouseTool.close(true);
|
||||
overlayRef.current?.setMap?.(null);
|
||||
runtime?.map.destroy();
|
||||
runtimeRef.current = undefined;
|
||||
overlayRef.current = undefined;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (status !== 'ready' || !runtime?.AMap.Circle || !validFence(value)) return;
|
||||
overlayRef.current?.setMap?.(null);
|
||||
const circle = new runtime.AMap.Circle({
|
||||
map: runtime.map,
|
||||
center: wgs84ToGcj02(value.longitude, value.latitude),
|
||||
radius: value.radiusM,
|
||||
strokeColor: '#1268f3',
|
||||
strokeWeight: 3,
|
||||
strokeOpacity: 0.9,
|
||||
fillColor: '#1268f3',
|
||||
fillOpacity: 0.16,
|
||||
bubble: true
|
||||
});
|
||||
overlayRef.current = circle;
|
||||
runtime.map.setFitView([circle], false, [72, 72, 72, 72]);
|
||||
}, [status, value.latitude, value.longitude, value.radiusM]);
|
||||
|
||||
const startDrawing = () => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime) return;
|
||||
runtime.map.resize?.();
|
||||
runtime.mouseTool.close(true);
|
||||
overlayRef.current?.setMap?.(null);
|
||||
overlayRef.current = undefined;
|
||||
setStatus('drawing');
|
||||
runtime.mouseTool.circle({
|
||||
strokeColor: '#1268f3',
|
||||
strokeWeight: 3,
|
||||
fillColor: '#1268f3',
|
||||
fillOpacity: 0.16,
|
||||
cursor: 'crosshair'
|
||||
});
|
||||
};
|
||||
|
||||
const locateFence = () => {
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime || !validFence(value)) return;
|
||||
runtime.map.setZoomAndCenter?.(14, wgs84ToGcj02(value.longitude, value.latitude));
|
||||
};
|
||||
|
||||
return <section className={`v2-geofence-map-editor is-${status}`} aria-label="地图绘制电子围栏">
|
||||
<header>
|
||||
<span><strong>在地图上绘制范围</strong><small>{status === 'drawing' ? '按住鼠标从中心向外拖动,松开完成绘制' : '绘制圆形范围后自动回填 WGS-84 中心与半径'}</small></span>
|
||||
<Tag color={status === 'error' ? 'red' : status === 'drawing' ? 'orange' : 'blue'} type="light" size="small">{status === 'loading' ? '地图加载中' : status === 'drawing' ? '正在绘制' : status === 'error' ? '地图不可用' : validFence(value) ? '范围已设置' : '等待绘制'}</Tag>
|
||||
</header>
|
||||
<div className="v2-geofence-map-canvas" ref={containerRef} aria-label="电子围栏地图画布" />
|
||||
{status === 'error' ? <div className="v2-geofence-map-error" role="alert"><IconMapPin /><span>{error}</span></div> : null}
|
||||
<footer>
|
||||
<span>{validFence(value) ? `中心 ${value.longitude.toFixed(6)}, ${value.latitude.toFixed(6)} · 半径 ${Math.round(value.radiusM).toLocaleString('zh-CN')} m` : '尚未设置有效范围'}</span>
|
||||
<div><Button htmlType="button" theme="light" type="tertiary" icon={<IconMapPin />} disabled={status === 'loading' || status === 'error' || !validFence(value)} onClick={locateFence}>定位范围</Button><Button htmlType="button" theme="solid" type="primary" icon={<IconRefresh />} disabled={status === 'loading' || status === 'error'} onClick={startDrawing}>{validFence(value) ? '重新绘制' : '开始绘制'}</Button></div>
|
||||
</footer>
|
||||
</section>;
|
||||
}
|
||||
@@ -104,6 +104,7 @@ test('defers the map runtime until valid data exists, retries a transient failur
|
||||
.mockRejectedValueOnce(new Error('temporary map network failure'))
|
||||
.mockResolvedValueOnce(amapMock());
|
||||
window.AMapLoader = { load };
|
||||
const openEvidence = vi.fn();
|
||||
const view = render(<TrackMap
|
||||
points={[]}
|
||||
stops={[]}
|
||||
@@ -112,6 +113,7 @@ test('defers the map runtime until valid data exists, retries a transient failur
|
||||
follow
|
||||
onSelectIndex={() => undefined}
|
||||
onFollowChange={() => undefined}
|
||||
onOpenEvidence={openEvidence}
|
||||
/>);
|
||||
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
@@ -126,8 +128,12 @@ test('defers the map runtime until valid data exists, retries a transient failur
|
||||
follow
|
||||
onSelectIndex={() => undefined}
|
||||
onFollowChange={() => undefined}
|
||||
onOpenEvidence={openEvidence}
|
||||
/>);
|
||||
expect(await screen.findByText(/地图加载失败/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/轨迹证据仍可使用/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看轨迹证据' }));
|
||||
expect(openEvidence).toHaveBeenCalledTimes(1);
|
||||
const retry = screen.getByRole('button', { name: /重新加载地图/ });
|
||||
expect(retry).toHaveClass('semi-button', 'v2-map-retry-action');
|
||||
fireEvent.click(retry);
|
||||
@@ -143,11 +149,31 @@ test('defers the map runtime until valid data exists, retries a transient failur
|
||||
follow
|
||||
onSelectIndex={() => undefined}
|
||||
onFollowChange={() => undefined}
|
||||
onOpenEvidence={openEvidence}
|
||||
/>);
|
||||
await waitFor(() => expect(mapDestroy).toHaveBeenCalledTimes(1));
|
||||
expect(mapInstances).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('keeps track evidence reachable when the map SDK is not configured', async () => {
|
||||
const openEvidence = vi.fn();
|
||||
render(<TrackMap
|
||||
points={points}
|
||||
stops={stops}
|
||||
activeIndex={0}
|
||||
showStops
|
||||
follow
|
||||
onSelectIndex={() => undefined}
|
||||
onFollowChange={() => undefined}
|
||||
onOpenEvidence={openEvidence}
|
||||
/>);
|
||||
|
||||
expect(await screen.findByText('地图暂不可用')).toBeInTheDocument();
|
||||
expect(screen.getByText('已保留 2 个有效轨迹点,可继续查看时间、来源与事件证据。')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看轨迹证据' }));
|
||||
expect(openEvidence).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not initialize the map for rows without a valid coordinate', () => {
|
||||
window.__LINGNIU_APP_CONFIG__ = { amapWebJsKey: 'amap-web-key' };
|
||||
const load = vi.fn(async () => amapMock());
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { HistoryLocationRow, TrackStop } from '../../api/types';
|
||||
import { getAMapConfig, isAMapConfigured } from '../../config/appConfig';
|
||||
import { isValidAMapCoordinate, loadAMap, wgs84ToGcj02, type AMapLike, type AMapMap, type AMapOverlay } from '../../integrations/amap';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { IconList } from '@douyinfe/semi-icons';
|
||||
import { MapRetryAction } from '../shared/RecoveryActions';
|
||||
|
||||
function markerContent(kind: 'start' | 'end' | 'stop' | 'current', label?: string) {
|
||||
@@ -9,7 +11,7 @@ function markerContent(kind: 'start' | 'end' | 'stop' | 'current', label?: strin
|
||||
return `<div class="v2-track-marker is-${kind}"><span>${label ?? ''}</span></div>`;
|
||||
}
|
||||
|
||||
export function TrackMap({ points, stops, activeIndex, showStops, follow, followDurationMs = 180, onSelectIndex, onFollowChange }: {
|
||||
export function TrackMap({ points, stops, activeIndex, showStops, follow, followDurationMs = 180, onSelectIndex, onFollowChange, onOpenEvidence }: {
|
||||
points: HistoryLocationRow[];
|
||||
stops: TrackStop[];
|
||||
activeIndex: number;
|
||||
@@ -18,6 +20,7 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, follow
|
||||
followDurationMs?: number;
|
||||
onSelectIndex: (index: number) => void;
|
||||
onFollowChange: (follow: boolean) => void;
|
||||
onOpenEvidence?: () => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<AMapMap | null>(null);
|
||||
@@ -152,8 +155,8 @@ export function TrackMap({ points, stops, activeIndex, showStops, follow, follow
|
||||
{state !== 'ready' && (state !== 'idle' || points.length > 0) ? <div className={`v2-map-state is-${state}`}>
|
||||
{state === 'loading' ? <><span className="v2-spinner" />轨迹地图加载中</> : null}
|
||||
{state === 'idle' ? '当前轨迹没有可展示的有效坐标' : null}
|
||||
{state === 'fallback' ? `地图未配置,已载入 ${valid.length} 个有效轨迹点` : null}
|
||||
{state === 'error' ? <><span>地图加载失败,请检查高德地图配置或网络</span><MapRetryAction onRetry={() => setLoadAttempt((value) => value + 1)} /></> : null}
|
||||
{state === 'fallback' ? <><strong>地图暂不可用</strong><span>已保留 {valid.length} 个有效轨迹点,可继续查看时间、来源与事件证据。</span>{onOpenEvidence ? <Button className="v2-map-evidence-action" theme="light" type="primary" icon={<IconList />} aria-label="查看轨迹证据" onClick={onOpenEvidence}>查看轨迹证据</Button> : null}</> : null}
|
||||
{state === 'error' ? <><strong>地图加载失败</strong><span>轨迹证据仍可使用;可重试地图或直接查看来源与事件。</span><div className="v2-map-state-actions"><MapRetryAction onRetry={() => setLoadAttempt((value) => value + 1)} />{onOpenEvidence ? <Button className="v2-map-evidence-action" theme="light" type="primary" icon={<IconList />} aria-label="查看轨迹证据" onClick={onOpenEvidence}>查看轨迹证据</Button> : null}</div></> : null}
|
||||
</div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import type { AccessVehicleRow, Page } from '../../api/types';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
import AccessPage from './AccessPage';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
accessSummary: vi.fn(), accessVehicles: vi.fn(), accessUnresolvedIdentities: vi.fn(),
|
||||
accessThresholds: vi.fn(), updateAccessThresholds: vi.fn()
|
||||
accessThresholds: vi.fn(), updateAccessThresholds: vi.fn(), vehicles: vi.fn(), claimAccessIdentity: vi.fn()
|
||||
}));
|
||||
const auth = vi.hoisted(() => ({ role: 'admin' }));
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
@@ -40,6 +40,81 @@ function prepareBaseData() {
|
||||
mocks.accessThresholds.mockResolvedValue({ version: 1, defaultThresholdSec: 300, delayThresholdSec: 60, longOfflineSec: 86_400, protocols: [], updatedBy: 'test', updatedAt: '2026-07-16T04:00:00Z', audit: [] });
|
||||
}
|
||||
|
||||
function AccessLocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output data-testid="access-location">{location.pathname}{location.search}</output>;
|
||||
}
|
||||
|
||||
test('restores filter, pagination and selected vehicle from a shareable access URL', async () => {
|
||||
prepareBaseData();
|
||||
mocks.accessVehicles.mockResolvedValue({ items: [accessRow('VIN021', '粤A00021')], total: 45, limit: 20, offset: 20 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access?keyword=粤A&accessLimit=20&accessPage=2&accessVin=VIN021']}><AccessPage /><AccessLocationProbe /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const detailDialog = await screen.findByRole('dialog', { name: '车辆接入详情' });
|
||||
expect(await within(detailDialog).findByText('粤A00021 · VIN021')).toBeInTheDocument();
|
||||
expect(mocks.accessVehicles).toHaveBeenCalledWith(expect.objectContaining({ keyword: '粤A', limit: 20, offset: 20 }), expect.anything());
|
||||
expect(screen.getByRole('textbox', { name: '车辆' })).toHaveValue('粤A');
|
||||
expect(screen.getByTestId('access-location').textContent).toMatch(/accessLimit=20.*accessPage=2.*accessVin=VIN021/);
|
||||
|
||||
fireEvent.click(within(detailDialog).getByRole('button', { name: '关闭车辆接入详情' }));
|
||||
await waitFor(() => expect(screen.getByTestId('access-location').textContent).toMatch(/accessLimit=20.*accessPage=2/));
|
||||
expect(screen.getByTestId('access-location')).not.toHaveTextContent('accessVin');
|
||||
});
|
||||
|
||||
test('keeps governance open and confirms the threshold version after saving', async () => {
|
||||
prepareBaseData();
|
||||
mocks.accessVehicles.mockResolvedValue({ items: [accessRow('VIN001', '粤A00001')], total: 1, limit: 50, offset: 0 });
|
||||
mocks.updateAccessThresholds.mockResolvedValue({ version: 2, defaultThresholdSec: 300, delayThresholdSec: 60, longOfflineSec: 86_400, protocols: [], updatedBy: '平台管理员', updatedAt: '2026-07-23T01:00:00Z', audit: [] });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access']}><AccessPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /接入治理/ }));
|
||||
const dialog = await screen.findByRole('dialog', { name: '接入治理配置' });
|
||||
const thresholdTitle = within(dialog).getByText('在线判定阈值');
|
||||
fireEvent.click(thresholdTitle.closest('.semi-collapse-header')!);
|
||||
fireEvent.click(within(dialog).getByText('保存阈值').closest('button')!);
|
||||
|
||||
expect(await within(dialog).findByRole('status')).toHaveTextContent('阈值已保存,v2 已生效');
|
||||
expect(within(dialog).getByText('接入治理工作台')).toBeInTheDocument();
|
||||
expect(mocks.updateAccessThresholds).toHaveBeenCalledWith(expect.objectContaining({ version: 1, defaultThresholdSec: 300 }), expect.anything());
|
||||
});
|
||||
|
||||
test('turns an unresolved source into a deep-linked, audited vehicle claim and exposes profile follow-up', async () => {
|
||||
prepareBaseData();
|
||||
const unresolved = { id: 'identity-1', identifierMasked: '138****0001', protocol: 'JT808', plate: '粤A00001', manufacturer: '测试终端', sourceEndpoint: 'gateway-a', firstRegisteredAt: '', latestRegisteredAt: '', latestAuthenticatedAt: '', latestSeenAt: '2026-07-16T04:00:00Z', freshnessSec: 10, issueCode: 'missing_vin_jt808', recommendedAction: '核对 VIN 后绑定' };
|
||||
mocks.accessVehicles.mockResolvedValue({ items: [accessRow('VIN001', '粤A00001')], total: 1, limit: 50, offset: 0 });
|
||||
mocks.accessUnresolvedIdentities.mockReset().mockResolvedValueOnce({ items: [unresolved], total: 1, limit: 20, offset: 0 }).mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0 });
|
||||
mocks.vehicles.mockResolvedValue({ items: [{ vin: 'VIN001', plate: '粤A00001', phone: '', oem: '测试品牌', protocol: 'JT808', online: true, lastSeen: '2026-07-16T04:00:00Z', locationText: '', bindingScore: 100 }], total: 1, limit: 10, offset: 0 });
|
||||
mocks.claimAccessIdentity.mockResolvedValue({ identityId: 'identity-1', protocol: 'JT808', identifierMasked: '138****0001', vin: 'VIN001', plate: '粤A00001', profileComplete: false, profileMissingFields: ['所属企业', '首次接入'], claimedBy: 'admin-test', claimedAt: '2026-07-23T08:00:00+08:00', auditId: 12 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/access?accessGovernance=overview']}><AccessPage /><AccessLocationProbe /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const governance = await screen.findByRole('dialog', { name: '接入治理配置' });
|
||||
fireEvent.click(await within(governance).findByRole('button', { name: '认领来源 138****0001' }));
|
||||
expect(await screen.findByRole('dialog', { name: '来源身份认领' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('access-location')).toHaveTextContent('accessGovernance=claim');
|
||||
expect(screen.getByTestId('access-location')).toHaveTextContent('accessIdentity=identity-1');
|
||||
|
||||
const candidate = await screen.findByRole('option', { name: '粤A00001 VIN001 JT/T 808 选择' });
|
||||
fireEvent.click(candidate);
|
||||
const confirmAction = screen.getByRole('button', { name: '确认认领' });
|
||||
expect(confirmAction).toBeDisabled();
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '认领说明' }), { target: { value: '设备交付单与车牌核对通过' } });
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /我已核对来源证据与目标车辆/ }));
|
||||
expect(confirmAction).toBeEnabled();
|
||||
fireEvent.click(confirmAction);
|
||||
|
||||
expect(await screen.findByRole('status', { name: '来源身份认领成功' })).toHaveTextContent('138****0001 已绑定至 粤A00001');
|
||||
expect(screen.getByText('所属企业、首次接入')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /继续补全车辆档案/ })).toHaveAttribute('href', '/vehicles/VIN001#vehicle-archive-panel');
|
||||
expect(mocks.claimAccessIdentity).toHaveBeenCalledWith('identity-1', { vin: 'VIN001', note: '设备交付单与车牌核对通过' });
|
||||
fireEvent.click(screen.getByRole('button', { name: /返回治理/ }));
|
||||
expect(await screen.findByRole('dialog', { name: '接入治理配置' })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('access-location')).toHaveTextContent('accessGovernance=overview');
|
||||
expect(screen.getByTestId('access-location')).not.toHaveTextContent('accessIdentity');
|
||||
});
|
||||
|
||||
test('removes old access rows immediately when the vehicle filter scope changes', async () => {
|
||||
prepareBaseData();
|
||||
let resolveNew!: (value: Page<AccessVehicleRow>) => void;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { IconChevronDown, IconChevronRight, IconClose, IconConnectionPoint1, IconDownload, IconMore, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||||
import { IconChevronDown, IconChevronLeft, IconChevronRight, IconClose, IconConnectionPoint1, IconDownload, IconMore, IconRefresh, IconSave, IconSearch, IconSetting, IconTickCircle } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, CardGroup, Collapse, Descriptions, Dropdown, Input, Select, Table, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, KeyboardEvent, memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FormEvent, KeyboardEvent, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page } from '../../api/types';
|
||||
import type { AccessIdentityClaimResult, AccessProtocolStatus, AccessQuery, AccessSummary, AccessThresholdConfig, AccessThresholdUpdate, AccessUnresolvedIdentity, AccessVehicleRow, Page, VehicleRow } from '../../api/types';
|
||||
import { accessIssueSummary, accessRowsToCSV, formatAccessTime, formatSeconds, updateProtocolThreshold } from '../domain/access';
|
||||
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
@@ -17,6 +17,7 @@ import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../share
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||||
import { ProtocolTag } from '../shared/ProtocolTag';
|
||||
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
||||
import { detailTriggerRow } from '../shared/detailTriggerRow';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister } from '../auth/session';
|
||||
@@ -34,6 +35,20 @@ const protocolThresholdMeta: Record<AccessProtocol, { label: string; description
|
||||
const compactAccessTimeFormatter = new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'Asia/Shanghai' });
|
||||
const EMPTY_FILTERS = { keyword: '', protocol: '', oem: '', connectionState: '', onlineState: '', model: '', provider: '', firstSeenFrom: '', firstSeenTo: '', latestSeenFrom: '', latestSeenTo: '', delayState: '' };
|
||||
type Filters = typeof EMPTY_FILTERS;
|
||||
const ACCESS_FILTER_KEYS = Object.keys(EMPTY_FILTERS) as Array<keyof Filters>;
|
||||
type AccessRouteView = { filters: Filters; page: number; limit: number; selectedVIN: string };
|
||||
|
||||
export function accessRouteViewFromParams(params: URLSearchParams, defaultLimit = 50): AccessRouteView {
|
||||
const requestedLimit = Number(params.get('accessLimit'));
|
||||
const limit = requestedLimit === 20 || requestedLimit === 50 || requestedLimit === 100 ? requestedLimit : defaultLimit;
|
||||
const requestedPage = Number(params.get('accessPage'));
|
||||
return {
|
||||
filters: Object.fromEntries(ACCESS_FILTER_KEYS.map((key) => [key, (params.get(key) || '').slice(0, 160)])) as Filters,
|
||||
page: Number.isSafeInteger(requestedPage) && requestedPage > 0 && requestedPage <= 100_000 ? requestedPage : 1,
|
||||
limit,
|
||||
selectedVIN: (params.get('accessVin') || '').slice(0, 160)
|
||||
};
|
||||
}
|
||||
|
||||
const connectionLabels: Record<AccessVehicleRow['connectionState'], string> = {
|
||||
healthy: '已接来源正常', degraded: '部分来源异常', incomplete: '资料待维护', offline: '已接来源离线', not_connected: '尚无来源'
|
||||
@@ -288,7 +303,7 @@ function VehicleInspector({ row, onClose, sheet = false, mobile = false }: { row
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; total: number }) {
|
||||
function IdentityQueue({ items, total, onClaim }: { items: AccessUnresolvedIdentity[]; total: number; onClaim: (item: AccessUnresolvedIdentity) => void }) {
|
||||
if (!total) return null;
|
||||
return <Collapse id="access-identity-queue" className="v2-access-identity-queue-v3" defaultActiveKey="unresolved-identities" keepDOM={false} lazyRender>
|
||||
<Collapse.Panel itemKey="unresolved-identities" header={<span className="v2-access-collapse-title"><span><strong>来源身份待绑定</strong><small>这些来源不计入主车辆,绑定权威 VIN 后再归档</small></span><Tag color="orange" type="light" size="small">{total.toLocaleString('zh-CN')} 条</Tag></span>}>
|
||||
@@ -298,14 +313,108 @@ function IdentityQueue({ items, total }: { items: AccessUnresolvedIdentity[]; to
|
||||
<span><small>关联车牌</small><strong>{item.plate || '待核对'}</strong></span>
|
||||
<span><small>最后出现</small><strong><AccessTime value={item.latestSeenAt} compact /></strong></span>
|
||||
</div>
|
||||
<footer><small>建议动作</small><strong>{item.recommendedAction}</strong></footer>
|
||||
<footer><span><small>建议动作</small><strong>{item.recommendedAction}</strong></span><Button theme="light" type="primary" icon={<IconChevronRight />} iconPosition="right" aria-label={`认领来源 ${item.identifierMasked}`} onClick={() => onClaim(item)}>开始认领</Button></footer>
|
||||
</Card>)}</div>
|
||||
{total > 6 ? <p className="v2-access-identity-more">当前展示最近 6 条,剩余 {(total - 6).toLocaleString('zh-CN')} 条可通过来源检索继续核对。</p> : null}
|
||||
</Collapse.Panel>
|
||||
</Collapse>;
|
||||
}
|
||||
|
||||
function ThresholdSettings({ config, draft, editable, saving, error, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; editable: boolean; saving: boolean; error?: string; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) {
|
||||
type AccessClaimCandidate = VehicleRow & { protocols: string[] };
|
||||
|
||||
function accessClaimCandidates(rows: VehicleRow[]) {
|
||||
const byVIN = new Map<string, AccessClaimCandidate>();
|
||||
for (const row of rows) {
|
||||
const current = byVIN.get(row.vin);
|
||||
const protocols = Array.from(new Set([...(current?.protocols ?? []), row.protocol].filter(Boolean)));
|
||||
if (!current || (!current.online && row.online)) byVIN.set(row.vin, { ...row, protocols });
|
||||
else current.protocols = protocols;
|
||||
}
|
||||
return Array.from(byVIN.values());
|
||||
}
|
||||
|
||||
function AccessIdentityClaimPanel({ identity, selected, confirmed, note, error, onSelect, onConfirm, onNote }: {
|
||||
identity: AccessUnresolvedIdentity;
|
||||
selected?: AccessClaimCandidate;
|
||||
confirmed: boolean;
|
||||
note: string;
|
||||
error?: string;
|
||||
onSelect: (vehicle: AccessClaimCandidate) => void;
|
||||
onConfirm: (confirmed: boolean) => void;
|
||||
onNote: (note: string) => void;
|
||||
}) {
|
||||
const initialSearch = identity.plate && identity.plate !== '待维护' ? identity.plate : '';
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
const deferredSearch = useDeferredValue(search.trim());
|
||||
const candidateParams = useMemo(() => {
|
||||
const params = new URLSearchParams({ limit: '10', offset: '0' });
|
||||
if (deferredSearch) params.set('keyword', deferredSearch);
|
||||
return params;
|
||||
}, [deferredSearch]);
|
||||
const candidates = useQuery({
|
||||
queryKey: ['access-identity-claim-candidates', identity.id, deferredSearch],
|
||||
queryFn: ({ signal }) => api.vehicles(candidateParams, signal),
|
||||
staleTime: 30_000,
|
||||
gcTime: QUERY_MEMORY.summaryGcTime
|
||||
});
|
||||
const options = useMemo(() => accessClaimCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
|
||||
const selectedVINs = useMemo(() => new Set(selected ? [selected.vin] : []), [selected]);
|
||||
const plateComparable = identity.plate && identity.plate !== '待维护';
|
||||
const plateMatches = Boolean(plateComparable && selected?.plate && identity.plate === selected.plate);
|
||||
|
||||
return <div className="v2-access-claim-workflow">
|
||||
<ol className="v2-access-claim-steps" aria-label="来源身份认领步骤">
|
||||
<li className="is-complete"><i>1</i><span><strong>核对来源</strong><small>证据已保留</small></span></li>
|
||||
<li className={selected ? 'is-complete' : 'is-current'}><i>2</i><span><strong>选择主车辆</strong><small>{selected ? '已选择权威 VIN' : '车牌或 VIN 搜索'}</small></span></li>
|
||||
<li className={selected && confirmed ? 'is-current' : ''}><i>3</i><span><strong>确认并留痕</strong><small>保存后生成审计记录</small></span></li>
|
||||
</ol>
|
||||
<section className="v2-access-claim-source" aria-labelledby="access-claim-source-title">
|
||||
<header><span><small>待认领来源</small><strong id="access-claim-source-title">{identity.identifierMasked}</strong></span><ProtocolTag protocol={identity.protocol} compact /></header>
|
||||
<div>
|
||||
<span><small>来源车牌</small><strong>{identity.plate || '待核对'}</strong></span>
|
||||
<span><small>终端厂家</small><strong>{identity.manufacturer || '未提供'}</strong></span>
|
||||
<span><small>最后出现</small><strong><AccessTime value={identity.latestSeenAt} compact /></strong></span>
|
||||
</div>
|
||||
<p>{identity.recommendedAction}</p>
|
||||
</section>
|
||||
<section className="v2-access-claim-select" aria-labelledby="access-claim-select-title">
|
||||
<header><span><strong id="access-claim-select-title">选择权威主车辆</strong><small>只从现有主车辆中选择,不允许根据来源标识猜测 VIN</small></span>{selected ? <Tag color="blue" type="light" size="small">已选 1 辆</Tag> : null}</header>
|
||||
<Input prefix={<IconSearch />} showClear value={search} onChange={setSearch} aria-label="搜索要认领的权威车辆" placeholder="搜索车牌 / VIN" />
|
||||
<VehicleCandidateList
|
||||
className="v2-access-claim-candidates"
|
||||
items={options}
|
||||
loading={candidates.isPending || candidates.isFetching}
|
||||
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '主车辆读取失败') : undefined}
|
||||
onRetry={() => candidates.refetch()}
|
||||
emptyText={deferredSearch ? '没有匹配的权威主车辆' : '当前没有可认领的主车辆'}
|
||||
selectedVins={selectedVINs}
|
||||
selectedLabel="已选择"
|
||||
actionLabel="选择"
|
||||
showProtocols
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</section>
|
||||
{selected ? <section className="v2-access-claim-review" aria-labelledby="access-claim-review-title">
|
||||
<header><span><strong id="access-claim-review-title">认领预览</strong><small>保存前再次核对来源与目标车辆</small></span><Tag color={plateMatches ? 'green' : 'orange'} type="light" size="small">{plateMatches ? '车牌一致' : plateComparable ? '车牌需复核' : '来源无权威车牌'}</Tag></header>
|
||||
<div className="v2-access-claim-link"><span><small>来源</small><strong>{identity.identifierMasked}</strong><em>{identity.plate || '待核对车牌'}</em></span><IconChevronRight /><span><small>权威车辆</small><strong>{selected.plate || '未绑定车牌'}</strong><em>{selected.vin}</em></span></div>
|
||||
<label className="v2-access-claim-confirm"><input type="checkbox" checked={confirmed} onChange={(event) => onConfirm(event.target.checked)} /><span><strong>我已核对来源证据与目标车辆</strong><small>认领会写入 phone→VIN 权威绑定并生成不可变审计记录。</small></span></label>
|
||||
<label className="v2-access-claim-note"><span>认领说明 <small>可选,最多 500 字</small></span><textarea aria-label="认领说明" maxLength={500} value={note} onChange={(event) => onNote(event.target.value)} placeholder="例如:根据设备交付单与车牌核对通过" /></label>
|
||||
</section> : null}
|
||||
{error ? <p className="v2-access-claim-error" role="alert">{error}</p> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function AccessIdentityClaimSuccess({ result }: { result: AccessIdentityClaimResult }) {
|
||||
return <section className="v2-access-claim-success" role="status" aria-label="来源身份认领成功">
|
||||
<IconTickCircle />
|
||||
<span><small>来源身份已归档</small><strong>{result.identifierMasked} 已绑定至 {result.plate || result.vin}</strong><p>审计记录 #{result.auditId} · {formatAccessTime(result.claimedAt)} · {result.claimedBy}</p></span>
|
||||
<div className="v2-access-claim-success-route"><span><small>权威 VIN</small><strong>{result.vin}</strong></span><IconChevronRight /><span><small>车辆档案</small><strong>{result.profileComplete ? '资料已完整' : `${result.profileMissingFields.length} 项待补全`}</strong></span></div>
|
||||
{!result.profileComplete ? <div className="v2-access-claim-profile-gap"><strong>继续补全车辆档案</strong><p>{result.profileMissingFields.join('、')}</p></div> : null}
|
||||
<Link to={`/vehicles/${encodeURIComponent(result.vin)}#vehicle-archive-panel`}><Button theme="solid" type="primary" icon={<IconChevronRight />} iconPosition="right">{result.profileComplete ? '查看车辆档案' : '继续补全车辆档案'}</Button></Link>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ThresholdSettings({ config, draft, editable, saving, error, success, onChange, onSave }: { config?: AccessThresholdConfig; draft?: AccessThresholdUpdate; editable: boolean; saving: boolean; error?: string; success?: string; onChange: (next: AccessThresholdUpdate) => void; onSave: () => void }) {
|
||||
if (!draft) return null;
|
||||
const thresholdField = (label: string, description: string, value: number, update: (next: number) => void) => <label>
|
||||
<span><b>{label}</b><small>{description}</small></span>
|
||||
@@ -334,6 +443,7 @@ function ThresholdSettings({ config, draft, editable, saving, error, onChange, o
|
||||
</div>
|
||||
</section>
|
||||
{error ? <p className="v2-access-threshold-error" role="alert">{error}</p> : null}
|
||||
{success ? <p className="v2-access-threshold-success" role="status">{success}</p> : null}
|
||||
{editable ? <footer className="v2-access-threshold-footer"><span><strong>保存后立即生效</strong><small>列表在线状态与治理统计会按新版本重新计算</small></span><Button theme="solid" icon={<IconSave />} onClick={onSave} disabled={saving}>{saving ? '保存中' : '保存阈值'}</Button></footer> : <small>当前账户为只读角色</small>}
|
||||
</fieldset>
|
||||
</Collapse.Panel>
|
||||
@@ -348,13 +458,26 @@ function downloadRows(rows: AccessVehicleRow[]) {
|
||||
export default function AccessPage() {
|
||||
const { session } = usePlatformSession(); const editable = canAdminister(session);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initial = Object.fromEntries(Object.keys(EMPTY_FILTERS).map((key) => [key, searchParams.get(key) ?? ''])) as Filters;
|
||||
const [draft, setDraft] = useState(initial); const [criteria, setCriteria] = useState(initial);
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [offset, setOffset] = useState(0); const [limit, setLimit] = useState(() => mobileLayout ? 20 : 50); const [selectedVIN, setSelectedVIN] = useState('');
|
||||
const [governanceOpen, setGovernanceOpen] = useState(false);
|
||||
const routeView = useMemo(() => accessRouteViewFromParams(searchParams, mobileLayout ? 20 : 50), [mobileLayout, searchParams]);
|
||||
const { filters: criteria, page, limit, selectedVIN } = routeView;
|
||||
const offset = (page - 1) * limit;
|
||||
const filterSignature = ACCESS_FILTER_KEYS.map((key) => criteria[key]).join('\u001f');
|
||||
const [draft, setDraft] = useState(criteria);
|
||||
const previousFilterSignature = useRef(filterSignature);
|
||||
useEffect(() => {
|
||||
if (previousFilterSignature.current === filterSignature) return;
|
||||
previousFilterSignature.current = filterSignature;
|
||||
setDraft(criteria);
|
||||
}, [criteria, filterSignature]);
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
const governanceMode = searchParams.get('accessGovernance') === 'claim' ? 'claim' : searchParams.get('accessGovernance') === 'overview' ? 'overview' : '';
|
||||
const governanceOpen = Boolean(governanceMode);
|
||||
const selectedIdentityID = governanceMode === 'claim' ? (searchParams.get('accessIdentity') || '').slice(0, 128) : '';
|
||||
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
|
||||
const [claimVehicle, setClaimVehicle] = useState<AccessClaimCandidate>();
|
||||
const [claimConfirmed, setClaimConfirmed] = useState(false);
|
||||
const [claimNote, setClaimNote] = useState('');
|
||||
const [thresholdDraft, setThresholdDraft] = useState<AccessThresholdUpdate>(); const queryClient = useQueryClient();
|
||||
const baseQuery = useMemo(() => Object.fromEntries(Object.entries(criteria).filter(([, value]) => value)) as AccessQuery, [criteria]);
|
||||
const vehicleScope = useMemo(() => queryScopeKey(baseQuery), [baseQuery]);
|
||||
@@ -363,24 +486,69 @@ export default function AccessPage() {
|
||||
const unresolvedQuery = useQuery({ queryKey: ['access-unresolved-identities', criteria.keyword, criteria.protocol], queryFn: ({ signal }) => api.accessUnresolvedIdentities({ keyword: criteria.keyword || undefined, protocol: criteria.protocol || undefined, limit: 20, offset: 0 }, signal), enabled: editable, staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const thresholdQuery = useQuery({ queryKey: ['access-thresholds'], queryFn: ({ signal }) => api.accessThresholds(signal), enabled: editable, staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
useEffect(() => { if (thresholdQuery.data && !thresholdDraft) setThresholdDraft({ version: thresholdQuery.data.version, defaultThresholdSec: thresholdQuery.data.defaultThresholdSec, delayThresholdSec: thresholdQuery.data.delayThresholdSec, longOfflineSec: thresholdQuery.data.longOfflineSec, protocols: thresholdQuery.data.protocols }); }, [thresholdDraft, thresholdQuery.data]);
|
||||
const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } });
|
||||
const updateThreshold = useMutation({ mutationFn: api.updateAccessThresholds, onSuccess: async (config) => { queryClient.setQueryData(['access-thresholds'], config); setThresholdDraft({ version: config.version, defaultThresholdSec: config.defaultThresholdSec, delayThresholdSec: config.delayThresholdSec, longOfflineSec: config.longOfflineSec, protocols: config.protocols }); await Promise.all([queryClient.invalidateQueries({ queryKey: ['access-summary'] }), queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })]); } });
|
||||
const selectedIdentity = unresolvedQuery.data?.items.find((item) => item.id === selectedIdentityID);
|
||||
const claimIdentity = useMutation({
|
||||
mutationFn: ({ identityID, vin, note }: { identityID: string; vin: string; note: string }) => api.claimAccessIdentity(identityID, { vin, note: note || undefined }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['access-unresolved-identities'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['access-summary'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['access-vehicles'] })
|
||||
]);
|
||||
}
|
||||
});
|
||||
const setGovernanceRoute = useCallback((mode: '' | 'overview' | 'claim', identityID = '') => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (mode) next.set('accessGovernance', mode); else next.delete('accessGovernance');
|
||||
if (mode === 'claim' && identityID) next.set('accessIdentity', identityID); else next.delete('accessIdentity');
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
const resetClaim = useCallback(() => {
|
||||
claimIdentity.reset();
|
||||
setClaimVehicle(undefined);
|
||||
setClaimConfirmed(false);
|
||||
setClaimNote('');
|
||||
}, [claimIdentity]);
|
||||
const openGovernance = useCallback(() => { resetClaim(); setGovernanceRoute('overview'); }, [resetClaim, setGovernanceRoute]);
|
||||
const openIdentityClaim = useCallback((item: AccessUnresolvedIdentity) => { resetClaim(); setGovernanceRoute('claim', item.id); }, [resetClaim, setGovernanceRoute]);
|
||||
const closeGovernance = useCallback(() => { setGovernanceRoute(''); resetClaim(); }, [resetClaim, setGovernanceRoute]);
|
||||
const backToGovernance = useCallback(() => { resetClaim(); setGovernanceRoute('overview'); }, [resetClaim, setGovernanceRoute]);
|
||||
useEffect(() => {
|
||||
setClaimVehicle(undefined);
|
||||
setClaimConfirmed(false);
|
||||
setClaimNote('');
|
||||
claimIdentity.reset();
|
||||
}, [selectedIdentityID]);
|
||||
useEffect(() => {
|
||||
if (!mobileLayout) {
|
||||
setMobileToolsOpen(false);
|
||||
return;
|
||||
}
|
||||
setLimit(20);
|
||||
setOffset(0);
|
||||
}, [mobileLayout]);
|
||||
const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN);
|
||||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||
const syncURL = (filters: Filters) => { const next = new URLSearchParams(); Object.entries(filters).forEach(([key, value]) => { if (value) next.set(key, value); }); setSearchParams(next, { replace: true }); };
|
||||
const apply = (next: Filters) => { setDraft(next); setCriteria(next); setOffset(0); setSelectedVIN(''); syncURL(next); };
|
||||
const setRouteView = useCallback((patch: Partial<AccessRouteView>) => {
|
||||
const nextView = { ...accessRouteViewFromParams(searchParams, mobileLayout ? 20 : 50), ...patch };
|
||||
const next = new URLSearchParams(searchParams);
|
||||
ACCESS_FILTER_KEYS.forEach((key) => {
|
||||
const value = nextView.filters[key].trim();
|
||||
if (value) next.set(key, value); else next.delete(key);
|
||||
});
|
||||
if (nextView.page > 1) next.set('accessPage', String(nextView.page)); else next.delete('accessPage');
|
||||
if (nextView.limit !== (mobileLayout ? 20 : 50)) next.set('accessLimit', String(nextView.limit)); else next.delete('accessLimit');
|
||||
if (nextView.selectedVIN) next.set('accessVin', nextView.selectedVIN); else next.delete('accessVin');
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [mobileLayout, searchParams, setSearchParams]);
|
||||
const apply = (next: Filters) => { setDraft(next); setRouteView({ filters: next, page: 1, selectedVIN: '' }); };
|
||||
const applyDraft = () => { apply(draft); setFiltersCollapsed(true); };
|
||||
const resetMobileDraft = () => setDraft(EMPTY_FILTERS);
|
||||
const resetFilters = () => { apply(EMPTY_FILTERS); setFiltersCollapsed(true); };
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); applyDraft(); };
|
||||
const page = Math.floor(offset / limit) + 1; const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data;
|
||||
const totalPages = Math.max(1, Math.ceil((vehiclesQuery.data?.total ?? 0) / limit)); const summary = summaryQuery.data;
|
||||
useEffect(() => {
|
||||
if (!vehiclesQuery.isSuccess || page <= totalPages) return;
|
||||
setRouteView({ page: totalPages, selectedVIN: '' });
|
||||
}, [page, setRouteView, totalPages, vehiclesQuery.isSuccess]);
|
||||
const activeFilterCount = Object.values(criteria).filter(Boolean).length;
|
||||
const refresh = () => Promise.all([summaryQuery.refetch(), vehiclesQuery.refetch(), ...(editable ? [unresolvedQuery.refetch(), thresholdQuery.refetch()] : [])]);
|
||||
const accessMetric = (
|
||||
@@ -442,7 +610,7 @@ export default function AccessPage() {
|
||||
status={summary ? `${summary.totalVehicles.toLocaleString('zh-CN')} 辆主车辆` : '正在读取车辆'}
|
||||
meta={<Typography.Text type="tertiary">数据时间 <AccessTime value={summary?.asOf} /></Typography.Text>}
|
||||
actions={mobileLayout ? <>
|
||||
{editable ? <Button className="v2-workspace-mobile-tool-button is-muted" theme="light" icon={<IconSetting />} aria-label="打开接入治理" aria-haspopup="dialog" aria-controls="v2-access-governance" aria-expanded={governanceOpen} onClick={() => setGovernanceOpen(true)}>治理{unresolvedQuery.data?.total ? ` · ${unresolvedQuery.data.total}` : ''}</Button> : null}
|
||||
{editable ? <Button className="v2-workspace-mobile-tool-button is-muted" theme="light" icon={<IconSetting />} aria-label="打开接入治理" aria-haspopup="dialog" aria-controls="v2-access-governance" aria-expanded={governanceOpen} onClick={openGovernance}>治理{unresolvedQuery.data?.total ? ` · ${unresolvedQuery.data.total}` : ''}</Button> : null}
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
@@ -456,7 +624,7 @@ export default function AccessPage() {
|
||||
>
|
||||
<Button className="v2-workspace-mobile-tool-button is-muted" theme="light" aria-label="打开接入管理工具" aria-haspopup="menu" aria-expanded={mobileToolsOpen} icon={<IconMore />}>工具</Button>
|
||||
</Dropdown>
|
||||
</> : <>{editable ? <Button theme="light" icon={<IconSetting />} aria-haspopup="dialog" aria-controls="v2-access-governance" aria-expanded={governanceOpen} onClick={() => setGovernanceOpen(true)}>接入治理{unresolvedQuery.data?.total ? ` · ${unresolvedQuery.data.total}` : ''}</Button> : null}<Button theme="light" icon={<IconRefresh />} onClick={() => void refresh()}>刷新</Button></>}
|
||||
</> : <>{editable ? <Button theme="light" icon={<IconSetting />} aria-haspopup="dialog" aria-controls="v2-access-governance" aria-expanded={governanceOpen} onClick={openGovernance}>接入治理{unresolvedQuery.data?.total ? ` · ${unresolvedQuery.data.total}` : ''}</Button> : null}<Button theme="light" icon={<IconRefresh />} onClick={() => void refresh()}>刷新</Button></>}
|
||||
/>
|
||||
{mobileLayout ? <div className="v2-access-mobile-discovery">
|
||||
<MobileFilterToggle
|
||||
@@ -534,43 +702,62 @@ export default function AccessPage() {
|
||||
onKeyDown={scrollAccessTable}
|
||||
>
|
||||
{mobileLayout
|
||||
? <div className="v2-access-mobile-list">{rows.map((row) => <Card key={row.vin} className={`v2-access-mobile-card is-state-${row.connectionState}${selected?.vin === row.vin ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" aria-pressed={selected?.vin === row.vin} aria-expanded={selected?.vin === row.vin} aria-label={`查看 ${row.plate || row.vin} 接入详情`} className="v2-access-mobile-action" onClick={() => setSelectedVIN(row.vin)}><span className="v2-access-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><ConnectionState row={row} /></header><p className={row.connectionState === 'healthy' ? '' : 'is-issue'} title={row.connectionState === 'healthy' ? undefined : accessIssueSummary(row)}>{row.connectionState === 'healthy' ? `${row.oem || '品牌未维护'} · ${row.model || row.company || '车型未维护'}` : `${accessIssueSummary(row)} · ${row.oem || '品牌未维护'} ${row.model || row.company || '车型未维护'}`}</p><span className="v2-access-mobile-protocols">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} protocol={protocol} protocolLabel={compactProtocolLabel(protocol)} status={statusByProtocol(row, protocol)} />)}</span><footer>查看接入详情<IconChevronRight /></footer></span></Button></Card>)}</div>
|
||||
: <AccessVehicleTable rows={rows} selectedVIN={selectedVIN} onSelect={setSelectedVIN} />}
|
||||
? <div className="v2-access-mobile-list">{rows.map((row) => <Card key={row.vin} className={`v2-access-mobile-card is-state-${row.connectionState}${selected?.vin === row.vin ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}><Button theme="borderless" type="tertiary" aria-pressed={selected?.vin === row.vin} aria-expanded={selected?.vin === row.vin} aria-label={`查看 ${row.plate || row.vin} 接入详情`} className="v2-access-mobile-action" onClick={() => setRouteView({ selectedVIN: row.vin })}><span className="v2-access-mobile-card-content"><header><span><strong>{row.plate || '未绑定车牌'}</strong><small>{row.vin}</small></span><ConnectionState row={row} /></header><p className={row.connectionState === 'healthy' ? '' : 'is-issue'} title={row.connectionState === 'healthy' ? undefined : accessIssueSummary(row)}>{row.connectionState === 'healthy' ? `${row.oem || '品牌未维护'} · ${row.model || row.company || '车型未维护'}` : `${accessIssueSummary(row)} · ${row.oem || '品牌未维护'} ${row.model || row.company || '车型未维护'}`}</p><span className="v2-access-mobile-protocols">{PROTOCOLS.map((protocol) => <ProtocolState key={protocol} protocol={protocol} protocolLabel={compactProtocolLabel(protocol)} status={statusByProtocol(row, protocol)} />)}</span><footer>查看接入详情<IconChevronRight /></footer></span></Button></Card>)}</div>
|
||||
: <AccessVehicleTable rows={rows} selectedVIN={selectedVIN} onSelect={(vin) => setRouteView({ selectedVIN: vin })} />}
|
||||
{vehiclesQuery.isFetching ? <PanelLoading className="v2-access-loading" title="正在更新车辆接入状态…" description="当前列表返回后会自动替换。" compact={Boolean(rows.length)} /> : null}
|
||||
{!vehiclesQuery.isFetching && !rows.length ? <PanelEmpty className="v2-access-empty" title="没有匹配车辆" description="调整车牌、协议或接入状态筛选后重试。" /> : null}
|
||||
</div>
|
||||
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆`} onPageChange={(next) => setOffset((next - 1) * limit)} pageSize={mobileLayout ? undefined : limit} pageSizeLabel="每页车辆数" onPageSizeChange={(next) => { setLimit(next); setOffset(0); }} pageSizeOptions={mobileLayout ? undefined : [{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
|
||||
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${(vehiclesQuery.data?.total ?? 0).toLocaleString('zh-CN')} 辆主车辆`} onPageChange={(next) => setRouteView({ page: next, selectedVIN: '' })} pageSize={mobileLayout ? undefined : limit} pageSizeLabel="每页车辆数" onPageSizeChange={(next) => setRouteView({ limit: next, page: 1, selectedVIN: '' })} pageSizeOptions={mobileLayout ? undefined : [{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
|
||||
</Card>
|
||||
</div>
|
||||
<WorkspaceDetailSideSheet
|
||||
className="v2-access-detail-sidesheet"
|
||||
visible={Boolean(selected)}
|
||||
visible={Boolean(selectedVIN)}
|
||||
ariaLabel="车辆接入详情"
|
||||
placement={mobileLayout ? 'bottom' : 'right'}
|
||||
width={mobileLayout ? undefined : 520}
|
||||
height={mobileLayout ? 'min(84dvh, 740px)' : undefined}
|
||||
title="车辆接入详情"
|
||||
description={selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '来源、在线状态与接入证据'}
|
||||
badge={selected ? connectionLabels[selected.connectionState] : undefined}
|
||||
description={selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : selectedVIN ? `正在定位 ${selectedVIN}` : '来源、在线状态与接入证据'}
|
||||
badge={selected ? connectionLabels[selected.connectionState] : selectedVIN ? '定位中' : undefined}
|
||||
badgeColor={selected ? connectionTagColor(selected.connectionState) : 'grey'}
|
||||
onCancel={() => setSelectedVIN('')}
|
||||
onCancel={() => setRouteView({ selectedVIN: '' })}
|
||||
>
|
||||
{selected ? <VehicleInspector key={selected.vin} row={selected} onClose={() => setSelectedVIN('')} sheet mobile={mobileLayout} /> : null}
|
||||
{selected ? <VehicleInspector key={selected.vin} row={selected} onClose={() => setRouteView({ selectedVIN: '' })} sheet mobile={mobileLayout} /> : vehiclesQuery.isFetching ? <PanelLoading title="正在定位接入车辆" description="正在恢复链接中保存的接入工作位置。" /> : selectedVIN ? <PanelEmpty tone="warning" title="当前页未找到指定车辆" description="车辆可能已不在当前筛选或分页范围,可清除失效详情后继续查看列表。" action={<Button theme="light" type="primary" icon={<IconClose />} onClick={() => setRouteView({ selectedVIN: '' })}>清除失效详情</Button>} /> : null}
|
||||
</WorkspaceDetailSideSheet>
|
||||
{editable ? <WorkspaceSideSheet
|
||||
className="v2-access-governance-sidesheet"
|
||||
variant="config"
|
||||
visible={governanceOpen}
|
||||
ariaLabel="接入治理配置"
|
||||
closeLabel="关闭接入治理配置"
|
||||
ariaLabel={governanceMode === 'claim' ? '来源身份认领' : '接入治理配置'}
|
||||
closeLabel={governanceMode === 'claim' ? '关闭来源身份认领' : '关闭接入治理配置'}
|
||||
placement={mobileLayout ? 'bottom' : 'right'}
|
||||
width={mobileLayout ? undefined : 560}
|
||||
width={mobileLayout ? undefined : governanceMode === 'claim' ? 640 : 560}
|
||||
height={mobileLayout ? 'min(88dvh, 780px)' : undefined}
|
||||
title="接入治理工作台"
|
||||
description="核对待绑定来源,统一维护协议在线判定阈值"
|
||||
badge={(unresolvedQuery.data?.total ?? 0) > 0 ? `${unresolvedQuery.data?.total.toLocaleString('zh-CN')} 项待办` : '治理正常'}
|
||||
badgeColor={(unresolvedQuery.data?.total ?? 0) > 0 ? 'orange' : 'green'}
|
||||
summaryItems={[
|
||||
title={governanceMode === 'claim' ? claimIdentity.data ? '来源身份已认领' : '认领来源身份' : '接入治理工作台'}
|
||||
description={governanceMode === 'claim' ? claimIdentity.data ? '权威绑定已生效,继续检查车辆档案完整度' : '核对来源证据,选择权威主车辆并生成审计记录' : '核对待绑定来源,统一维护协议在线判定阈值'}
|
||||
badge={governanceMode === 'claim' ? claimIdentity.data ? '已归档' : claimVehicle ? '待确认' : '选择主车辆' : (unresolvedQuery.data?.total ?? 0) > 0 ? `${unresolvedQuery.data?.total.toLocaleString('zh-CN')} 项待办` : '治理正常'}
|
||||
badgeColor={governanceMode === 'claim' ? claimIdentity.data ? 'green' : claimVehicle ? 'orange' : 'blue' : (unresolvedQuery.data?.total ?? 0) > 0 ? 'orange' : 'green'}
|
||||
summaryItems={governanceMode === 'claim' ? [
|
||||
{
|
||||
label: '来源标识',
|
||||
value: claimIdentity.data?.identifierMasked || selectedIdentity?.identifierMasked || '—',
|
||||
detail: claimIdentity.data?.protocol || selectedIdentity?.protocol || '等待来源证据',
|
||||
tone: claimIdentity.data ? 'success' : 'warning'
|
||||
},
|
||||
{
|
||||
label: '权威车辆',
|
||||
value: claimIdentity.data?.plate || claimVehicle?.plate || '待选择',
|
||||
detail: claimIdentity.data?.vin || claimVehicle?.vin || '从主车辆中选择',
|
||||
tone: claimIdentity.data || claimVehicle ? 'primary' : 'neutral'
|
||||
},
|
||||
{
|
||||
label: '档案状态',
|
||||
value: claimIdentity.data ? claimIdentity.data.profileComplete ? '已完整' : `${claimIdentity.data.profileMissingFields.length} 项待补` : '认领后检查',
|
||||
detail: claimIdentity.data?.profileComplete ? '无需补充主档' : '身份与主档连续处置',
|
||||
tone: claimIdentity.data?.profileComplete ? 'success' : claimIdentity.data ? 'warning' : 'neutral'
|
||||
}
|
||||
] : [
|
||||
{
|
||||
label: '待绑定来源',
|
||||
value: (unresolvedQuery.data?.total ?? 0).toLocaleString('zh-CN'),
|
||||
@@ -590,16 +777,40 @@ export default function AccessPage() {
|
||||
tone: 'neutral'
|
||||
}
|
||||
]}
|
||||
footerNote="阈值保存后即时生效;关闭工作台不会丢失已保存配置"
|
||||
primaryAction={{ label: '完成', onClick: () => setGovernanceOpen(false) }}
|
||||
onCancel={() => setGovernanceOpen(false)}
|
||||
footerNote={governanceMode === 'claim' ? claimIdentity.data ? '认领已写入权威绑定与审计记录;可继续补全车辆档案' : '只有核对证据并明确选择权威 VIN 后才能认领' : '阈值保存后即时生效;关闭工作台不会丢失已保存配置'}
|
||||
secondaryActions={governanceMode === 'claim' ? [{ label: '返回治理', icon: <IconChevronLeft />, onClick: backToGovernance }] : []}
|
||||
primaryAction={governanceMode === 'claim' ? claimIdentity.data
|
||||
? { label: '完成', onClick: closeGovernance }
|
||||
: {
|
||||
label: '确认认领',
|
||||
onClick: () => selectedIdentityID && claimVehicle && claimIdentity.mutate({ identityID: selectedIdentityID, vin: claimVehicle.vin, note: claimNote }),
|
||||
disabled: !selectedIdentityID || !claimVehicle || !claimConfirmed,
|
||||
loading: claimIdentity.isPending
|
||||
}
|
||||
: { label: '完成', onClick: closeGovernance }}
|
||||
onCancel={closeGovernance}
|
||||
>
|
||||
{governanceOpen ? <div className="v2-access-governance">
|
||||
{unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '待绑定身份读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
|
||||
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} />
|
||||
{thresholdQuery.isError ? <InlineError message={thresholdQuery.error instanceof Error ? thresholdQuery.error.message : '接入阈值读取失败'} onRetry={() => thresholdQuery.refetch()} /> : null}
|
||||
<ThresholdSettings config={thresholdQuery.data} draft={thresholdDraft} editable saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} onChange={setThresholdDraft} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} />
|
||||
</div> : null}
|
||||
{governanceOpen ? governanceMode === 'claim'
|
||||
? claimIdentity.data ? <AccessIdentityClaimSuccess result={claimIdentity.data} />
|
||||
: selectedIdentity ? <AccessIdentityClaimPanel
|
||||
key={selectedIdentity.id}
|
||||
identity={selectedIdentity}
|
||||
selected={claimVehicle}
|
||||
confirmed={claimConfirmed}
|
||||
note={claimNote}
|
||||
error={claimIdentity.error instanceof Error ? claimIdentity.error.message : undefined}
|
||||
onSelect={(vehicle) => { claimIdentity.reset(); setClaimVehicle(vehicle); setClaimConfirmed(false); }}
|
||||
onConfirm={setClaimConfirmed}
|
||||
onNote={setClaimNote}
|
||||
/>
|
||||
: unresolvedQuery.isFetching ? <PanelLoading title="正在恢复来源身份" description="正在读取链接中保存的待认领来源。" />
|
||||
: <PanelEmpty tone="warning" title="待认领来源已失效" description="该来源可能已被其他管理员认领,返回治理列表刷新后继续。" action={<Button theme="light" icon={<IconChevronLeft />} onClick={backToGovernance}>返回治理列表</Button>} />
|
||||
: <div className="v2-access-governance">
|
||||
{unresolvedQuery.isError ? <InlineError message={unresolvedQuery.error instanceof Error ? unresolvedQuery.error.message : '待绑定身份读取失败'} onRetry={() => unresolvedQuery.refetch()} /> : null}
|
||||
<IdentityQueue items={unresolvedQuery.data?.items ?? []} total={unresolvedQuery.data?.total ?? 0} onClaim={openIdentityClaim} />
|
||||
{thresholdQuery.isError ? <InlineError message={thresholdQuery.error instanceof Error ? thresholdQuery.error.message : '接入阈值读取失败'} onRetry={() => thresholdQuery.refetch()} /> : null}
|
||||
<ThresholdSettings config={thresholdQuery.data} draft={thresholdDraft} editable saving={updateThreshold.isPending} error={updateThreshold.error instanceof Error ? updateThreshold.error.message : undefined} success={updateThreshold.isSuccess ? `阈值已保存,v${thresholdDraft?.version ?? thresholdQuery.data?.version ?? '—'} 已生效` : undefined} onChange={(next) => { updateThreshold.reset(); setThresholdDraft(next); }} onSave={() => thresholdDraft && updateThreshold.mutate(thresholdDraft)} />
|
||||
</div> : null}
|
||||
</WorkspaceSideSheet> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { TextArea } from '@douyinfe/semi-ui';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { MAX_MONITOR_SEARCH_TERMS, parseMonitorSearchTerms } from '../hooks/useMonitorData';
|
||||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||||
|
||||
export default function BatchVehicleSearchDialog({ initialValue, mobile, onApply, onClose }: {
|
||||
initialValue: string;
|
||||
mobile: boolean;
|
||||
onApply: (value: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(initialValue);
|
||||
const terms = useMemo(() => parseMonitorSearchTerms(draft), [draft]);
|
||||
|
||||
return <WorkspaceSideSheet
|
||||
className="v2-monitor-batch-sidesheet"
|
||||
variant="editor"
|
||||
visible
|
||||
ariaLabel="批量搜索车辆"
|
||||
closeLabel="关闭批量搜索车辆"
|
||||
dialogId="v2-monitor-batch-search"
|
||||
placement={mobile ? 'bottom' : 'right'}
|
||||
width={mobile ? undefined : 520}
|
||||
height={mobile ? 'min(86dvh, 700px)' : undefined}
|
||||
title="批量搜索车辆"
|
||||
description="从 Excel、文本或聊天记录中直接粘贴车牌"
|
||||
icon={<IconSearch />}
|
||||
badge={`${terms.length} 辆`}
|
||||
badgeColor={terms.length ? 'blue' : 'grey'}
|
||||
summaryItems={[
|
||||
{ label: '已识别', value: terms.length.toLocaleString('zh-CN'), detail: '可直接应用到监控筛选', tone: terms.length ? 'primary' : 'neutral' },
|
||||
{ label: '重复处理', value: '自动去重', detail: '相同车牌只保留一次', tone: 'success' },
|
||||
{ label: '单次上限', value: `${MAX_MONITOR_SEARCH_TERMS} 辆`, detail: '超出部分不会进入查询' }
|
||||
]}
|
||||
footerNote="支持换行、空格、逗号或分号分隔。"
|
||||
secondaryActions={[{ label: '取消', onClick: onClose }]}
|
||||
primaryAction={{ label: `应用搜索(${terms.length})`, ariaLabel: `应用搜索(${terms.length})`, disabled: !terms.length, icon: <IconSearch />, onClick: () => onApply(terms.join(',')) }}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="v2-batch-search-dialog">
|
||||
<label htmlFor="batch-vehicle-search">每行一个车牌,也支持空格、逗号或分号分隔</label>
|
||||
<TextArea id="batch-vehicle-search" autoFocus value={draft} onChange={setDraft} autosize={{ minRows: 8, maxRows: 14 }} resize="vertical" placeholder={'粤A12345\n粤B67890\n粤C24680'} />
|
||||
<div className="v2-batch-search-summary" role="status"><span>已识别 <strong>{terms.length}</strong> 辆,重复项已自动去除</span><em>最多 {MAX_MONITOR_SEARCH_TERMS} 辆</em></div>
|
||||
</div>
|
||||
</WorkspaceSideSheet>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
import { Button, Card, Tag } from '@douyinfe/semi-ui';
|
||||
|
||||
export default function MonitorCoverageWarning({ processed, total, onOpenList }: {
|
||||
processed: string;
|
||||
total: string;
|
||||
onOpenList: () => void;
|
||||
}) {
|
||||
return <Card className="v2-monitor-coverage-warning" bodyStyle={{ padding: 0 }}>
|
||||
<div className="v2-monitor-coverage-warning-content" role="status" aria-label="地图与统计载荷已达到上限">
|
||||
<div>
|
||||
<Tag color="orange" type="light" size="small">部分口径</Tag>
|
||||
<span><strong>已处理前 {processed} 辆</strong><small>当前筛选共 {total} 辆;状态统计与地图可能不完整,请收窄筛选或使用分页列表核对。</small></span>
|
||||
</div>
|
||||
<Button theme="light" type="tertiary" onClick={onOpenList}>查看完整车辆列表</Button>
|
||||
</div>
|
||||
</Card>;
|
||||
}
|
||||
@@ -18,12 +18,13 @@ const vehicles = [{
|
||||
socAvailable: false, socPercent: 0, mileageAvailable: true, totalMileageKm: 2234, todayMileageAvailable: false, todayMileageKm: 0,
|
||||
lastSeen: '2026-07-14T01:00:00Z', online: true, sourceCount: 1, onlineSourceCount: 1
|
||||
}] as VehicleRealtimeRow[];
|
||||
const monitorMap = { clusters: [], points: [], total: 2 };
|
||||
const monitorMap = { mode: 'provinces', clusters: [], points: [], total: 2, truncated: false };
|
||||
const fleetMapRenderSpy = vi.hoisted(() => vi.fn());
|
||||
const vehicleCardArgsSpy = vi.hoisted(() => vi.fn());
|
||||
const monitorDataArgsSpy = vi.hoisted(() => vi.fn());
|
||||
const qrToDataURLSpy = vi.hoisted(() => vi.fn());
|
||||
const monitorQueryFlags = vi.hoisted(() => ({ isLoading: false, isFetching: false, isPlaceholderData: false }));
|
||||
const monitorFleetFixture = vi.hoisted(() => ({ total: 2 }));
|
||||
const monitorSummaryFixture = vi.hoisted(() => ({
|
||||
totalVehicles: 2,
|
||||
locationVehicles: 2,
|
||||
@@ -32,9 +33,16 @@ const monitorSummaryFixture = vi.hoisted(() => ({
|
||||
offlineVehicles: 0,
|
||||
drivingVehicles: 1,
|
||||
idleVehicles: 1,
|
||||
frameToday: 10
|
||||
frameToday: 10,
|
||||
truncated: false
|
||||
}));
|
||||
const addressRefetchSpy = vi.hoisted(() => vi.fn());
|
||||
const vehicleCardFixture = vi.hoisted(() => ({
|
||||
detail: undefined as unknown,
|
||||
addressData: undefined as unknown,
|
||||
addressError: false,
|
||||
addressFetching: false
|
||||
}));
|
||||
const vehicleCardFixture = vi.hoisted(() => ({ detail: undefined as unknown }));
|
||||
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
|
||||
function mockMobileViewport() {
|
||||
@@ -54,10 +62,11 @@ function mockMobileViewport() {
|
||||
}
|
||||
|
||||
vi.mock('../map/FleetMap', () => ({
|
||||
FleetMap: ({ selectedVin, onSelectVin, initialViewport }: { selectedVin?: string; onSelectVin?: (vin: string) => void; initialViewport?: { zoom: number; bounds: string } }) => {
|
||||
FleetMap: ({ selectedVin, onSelectVin, initialViewport, onOpenList }: { selectedVin?: string; onSelectVin?: (vin: string) => void; initialViewport?: { zoom: number; bounds: string }; onOpenList?: () => void }) => {
|
||||
fleetMapRenderSpy(selectedVin, initialViewport);
|
||||
return <div data-testid="fleet-map" data-selected-vin={selectedVin ?? ''} data-initial-zoom={initialViewport?.zoom ?? ''} data-initial-bounds={initialViewport?.bounds ?? ''}>
|
||||
<button type="button" onClick={() => onSelectVin?.('LTEST000000000002')}>选择地图车辆</button>
|
||||
<button type="button" onClick={onOpenList}>使用车辆列表</button>
|
||||
</div>;
|
||||
}
|
||||
}));
|
||||
@@ -88,14 +97,23 @@ vi.mock('../hooks/useMonitorData', () => ({
|
||||
monitorDataArgsSpy(...args);
|
||||
return {
|
||||
summary: { data: monitorSummaryFixture },
|
||||
vehicles: { data: { items: vehicles, total: 2 }, isError: false, ...monitorQueryFlags },
|
||||
vehicles: { data: { items: vehicles, total: monitorFleetFixture.total }, isError: false, ...monitorQueryFlags },
|
||||
map: { data: monitorMap, isPlaceholderData: monitorQueryFlags.isPlaceholderData },
|
||||
selectedVehicle: { data: { items: [] } }
|
||||
};
|
||||
},
|
||||
useMonitorVehicleCard: (...args: unknown[]) => {
|
||||
vehicleCardArgsSpy(...args);
|
||||
return { detail: { data: vehicleCardFixture.detail }, activeAlerts: {}, address: {} };
|
||||
return {
|
||||
detail: { data: vehicleCardFixture.detail },
|
||||
activeAlerts: {},
|
||||
address: {
|
||||
data: vehicleCardFixture.addressData,
|
||||
isError: vehicleCardFixture.addressError,
|
||||
isFetching: vehicleCardFixture.addressFetching,
|
||||
refetch: addressRefetchSpy
|
||||
}
|
||||
};
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -105,7 +123,16 @@ afterEach(() => {
|
||||
monitorQueryFlags.isFetching = false;
|
||||
monitorQueryFlags.isPlaceholderData = false;
|
||||
monitorSummaryFixture.frameToday = 10;
|
||||
monitorSummaryFixture.totalVehicles = 2;
|
||||
monitorSummaryFixture.truncated = false;
|
||||
monitorFleetFixture.total = 2;
|
||||
monitorMap.truncated = false;
|
||||
monitorMap.total = 2;
|
||||
vehicleCardFixture.detail = undefined;
|
||||
vehicleCardFixture.addressData = undefined;
|
||||
vehicleCardFixture.addressError = false;
|
||||
vehicleCardFixture.addressFetching = false;
|
||||
addressRefetchSpy.mockReset();
|
||||
vehicleCardArgsSpy.mockClear();
|
||||
monitorDataArgsSpy.mockClear();
|
||||
qrToDataURLSpy.mockReset();
|
||||
@@ -135,6 +162,50 @@ test('never labels the latest historical mileage row as today when today has no
|
||||
expect(todayMetric).not.toHaveTextContent('134');
|
||||
});
|
||||
|
||||
test('keeps a large daily report count readable in the desktop summary rail', () => {
|
||||
monitorSummaryFixture.frameToday = 2_861_323;
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const dailyReports = screen.getByTitle('今日上报 2,861,323 条');
|
||||
expect(dailyReports).toHaveTextContent('今日上报286.1万条数据活跃度');
|
||||
expect(dailyReports).not.toHaveTextContent('2,861,323');
|
||||
});
|
||||
|
||||
test('makes a truncated ten-thousand-vehicle map scope explicit and offers the complete paged list', async () => {
|
||||
monitorSummaryFixture.totalVehicles = 10_000;
|
||||
monitorSummaryFixture.truncated = true;
|
||||
monitorFleetFixture.total = 12_480;
|
||||
monitorMap.total = 8_720;
|
||||
monitorMap.truncated = true;
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 12_480, limit: 50, offset: 0 });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const coverage = await screen.findByRole('status', { name: '地图与统计载荷已达到上限' });
|
||||
expect(coverage).toHaveTextContent('已处理前 10,000 辆');
|
||||
expect(coverage).toHaveTextContent('当前筛选共 12,480 辆');
|
||||
expect(screen.getByLabelText('实时数据状态')).toHaveTextContent('地图覆盖8,720 / 12,480 辆有定位 · 省级聚合');
|
||||
expect(screen.getByText('车辆总数').parentElement).toHaveTextContent('12,480辆');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看完整车辆列表' }));
|
||||
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('status', { name: '地图与统计载荷已达到上限' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('keeps coordinates usable and exposes retry when selected-vehicle address lookup fails', async () => {
|
||||
vehicleCardFixture.addressError = true;
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ }));
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('地址解析失败,坐标仍可用');
|
||||
expect(screen.getByRole('button', { name: '查看全部位置来源' })).toHaveTextContent('113.260000, 23.130000');
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试' }));
|
||||
expect(addressRefetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('starts without a selection and supports expand, collapse, reselection, and clear', async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
@@ -147,7 +218,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
|
||||
expect(view.container.querySelector('.v2-filterbar')).toHaveClass('semi-card');
|
||||
const kpis = screen.getByRole('list', { name: '车辆整体统计' });
|
||||
expect(kpis.closest('.v2-monitor-summary-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue');
|
||||
expect(Array.from(kpis.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual([
|
||||
expect(Array.from(kpis.querySelectorAll(':scope > [role="listitem"] small')).map((item) => item.textContent)).toEqual([
|
||||
'车辆总数', '当前在线', '行驶车辆', '当前离线', '静止车辆', '告警车辆'
|
||||
]);
|
||||
expect(kpis.querySelectorAll(':scope > [role="listitem"]')).toHaveLength(6);
|
||||
@@ -163,7 +234,7 @@ test('starts without a selection and supports expand, collapse, reselection, and
|
||||
const liveStatus = screen.getByLabelText('实时数据状态');
|
||||
expect(liveStatus).toHaveTextContent('数据已同步');
|
||||
expect(liveStatus).toHaveTextContent('实时数据2 辆已载入');
|
||||
expect(liveStatus).toHaveTextContent('地图载荷0 个车辆点');
|
||||
expect(liveStatus).toHaveTextContent('地图覆盖2 / 2 辆有定位 · 省级聚合');
|
||||
expect(liveStatus).toHaveTextContent('智能刷新重点 10s · 车队 15s · 统计 30s');
|
||||
expect(liveStatus).toHaveTextContent('最近同步等待数据');
|
||||
expect(workspace).not.toHaveClass('is-detail-open');
|
||||
@@ -196,10 +267,12 @@ test('starts without a selection and supports expand, collapse, reselection, and
|
||||
expect(view.container.querySelector('.v2-report-summary')).toHaveTextContent('平台接收时间');
|
||||
expect(view.container.querySelector('.v2-report-summary')).toHaveTextContent('主协议JT/T 808');
|
||||
expect(view.container.querySelector('.v2-report-summary .v2-protocol-tag')).toHaveClass('semi-tag-cyan-light');
|
||||
expect(view.container.querySelectorAll('.v2-detail-actions a')).toHaveLength(4);
|
||||
expect(view.container.querySelectorAll('.v2-detail-actions a')).toHaveLength(6);
|
||||
expect(Array.from(view.container.querySelectorAll('.v2-detail-actions a')).map((item) => item.textContent)).toEqual([
|
||||
'单车详情', '轨迹回放', '历史数据', '里程查询'
|
||||
'单车详情', '轨迹回放', '历史数据', '里程查询', '事件流', '质量差异'
|
||||
]);
|
||||
expect(new URL(screen.getByRole('link', { name: '事件流' }).getAttribute('href')!, 'https://vehicle-platform.invalid').searchParams.get('keyword')).toBe('LTEST000000000001');
|
||||
expect(new URL(screen.getByRole('link', { name: '质量差异' }).getAttribute('href')!, 'https://vehicle-platform.invalid').searchParams.get('reconcileKeyword')).toBe('LTEST000000000001');
|
||||
expect(view.container.querySelector('.v2-detail-protocols .v2-protocol-tag')).toHaveTextContent('JT/T 808');
|
||||
expect(screen.getByRole('list', { name: '车辆实时状态指标' })).toHaveClass('v2-workspace-metric-list');
|
||||
expect(view.container.querySelector('.v2-monitor-detail-metric-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue');
|
||||
@@ -277,7 +350,7 @@ test('restores URL-backed monitor context and carries it into every vehicle work
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开车辆详情' }));
|
||||
expect(await screen.findByRole('navigation', { name: '车辆快捷操作' })).toBeInTheDocument();
|
||||
for (const name of ['单车详情', '轨迹回放', '历史数据', '里程查询']) {
|
||||
for (const name of ['单车详情', '轨迹回放', '历史数据', '里程查询', '事件流', '质量差异']) {
|
||||
const target = new URL(screen.getByRole('link', { name }).getAttribute('href')!, 'https://vehicle-platform.invalid');
|
||||
const monitorReturn = target.searchParams.get('monitorReturn');
|
||||
expect(monitorReturn).toContain('/monitor?');
|
||||
@@ -286,6 +359,36 @@ test('restores URL-backed monitor context and carries it into every vehicle work
|
||||
}
|
||||
});
|
||||
|
||||
test('turns the fleet summary into consistent status filters and clears stale vehicle selection', async () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /粤A12345 LTEST000000000001/ }));
|
||||
expect(await screen.findByRole('region', { name: '粤A12345车辆详情' })).toBeInTheDocument();
|
||||
|
||||
const drivingFilter = screen.getByRole('button', { name: '筛选行驶车辆,共 1 辆' });
|
||||
fireEvent.click(drivingFilter);
|
||||
|
||||
expect(screen.queryByRole('region', { name: '粤A12345车辆详情' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: '在线状态' })).toHaveTextContent('行驶');
|
||||
expect(drivingFilter).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByTestId('fleet-map')).toHaveAttribute('data-selected-vin', '');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '筛选无实时位置车辆,共 0 辆' }));
|
||||
expect(screen.getByRole('combobox', { name: '在线状态' })).toHaveTextContent('无实时位置');
|
||||
expect(screen.getByRole('button', { name: '筛选无实时位置车辆,共 0 辆' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test('keeps the current map selection visible when reviewing the realtime list', async () => {
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor?selectedVin=LTEST000000000001&detail=collapsed']}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
|
||||
expect(await screen.findByTitle('当前地图选中,点击返回地图')).toHaveClass('is-selected');
|
||||
expect(view.container.querySelector('.v2-monitor-table .semi-table-row.is-selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows active filter count and clears all filter fields in one action', () => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/monitor?keyword=%E7%B2%A4A12345&protocol=JT808&status=online']}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
@@ -305,7 +408,7 @@ test('shows active filter count and clears all filter fields in one action', ()
|
||||
|
||||
test('switches to a lightweight realtime list and resolves addresses only on demand', async () => {
|
||||
const row = vehicles[0];
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 });
|
||||
const realtime = vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [row], total: 1, limit: 50, offset: 0 });
|
||||
const reverseGeocode = vi.spyOn(api, 'reverseGeocode').mockResolvedValue({ provider: 'AMap', longitude: 113.26, latitude: 23.13, formattedAddress: '广东省广州市天河区测试路' });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
@@ -326,6 +429,7 @@ test('switches to a lightweight realtime list and resolves addresses only on dem
|
||||
expect(view.container.querySelector('.v2-monitor-table-scroll > table')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('覆盖全部授权车辆;缺失值显示“—”,地址按需解析')).toBeInTheDocument();
|
||||
expect(await screen.findAllByText('粤A12345')).toHaveLength(1);
|
||||
expect(realtime.mock.calls[realtime.mock.calls.length - 1]?.[0]?.get('sort')).toBe('identity');
|
||||
expect(screen.getAllByText('42')).toHaveLength(1);
|
||||
expect(screen.getAllByText(/18\.6/)).toHaveLength(1);
|
||||
expect(screen.getAllByText(/1,234/)).toHaveLength(1);
|
||||
@@ -481,17 +585,28 @@ test('mounts only the mobile list representation and removes its viewport listen
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [vehicles[0]], total: 1, limit: 50, offset: 0 });
|
||||
monitorSummaryFixture.frameToday = 2_861_323;
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(
|
||||
<main className="v2-content" data-testid="mobile-monitor-scroll-owner">
|
||||
<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>
|
||||
</main>
|
||||
);
|
||||
|
||||
const filterFields = view.container.querySelector('#monitor-filter-fields');
|
||||
const scrollOwner = screen.getByTestId('mobile-monitor-scroll-owner');
|
||||
expect(filterFields).toHaveClass('is-mobile-collapsed');
|
||||
expect(screen.queryByRole('button', { name: '打开手机端入口' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: '车辆整体统计' }).querySelectorAll(':scope > [role="listitem"]')).toHaveLength(6);
|
||||
const expandFilters = screen.getByRole('button', { name: '展开车辆筛选,当前 0 个条件' });
|
||||
expect(expandFilters).toHaveAttribute('aria-expanded', 'false');
|
||||
scrollOwner.scrollTop = 180;
|
||||
fireEvent.click(expandFilters);
|
||||
expect(scrollOwner.scrollTop).toBe(0);
|
||||
expect(filterFields).not.toHaveClass('is-mobile-collapsed');
|
||||
const collapseFilters = screen.getByRole('button', { name: '收起车辆筛选,当前 0 个条件' });
|
||||
expect(collapseFilters).toHaveAttribute('aria-expanded', 'true');
|
||||
scrollOwner.scrollTop = 120;
|
||||
fireEvent.click(collapseFilters);
|
||||
expect(scrollOwner.scrollTop).toBe(0);
|
||||
expect(filterFields).toHaveClass('is-mobile-collapsed');
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
|
||||
@@ -512,6 +627,52 @@ test('mounts only the mobile list representation and removes its viewport listen
|
||||
expect(removeEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
});
|
||||
|
||||
test('returns a mobile list-origin vehicle directly to its selected list card', async () => {
|
||||
mockMobileViewport();
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: vehicles, total: 2, limit: 50, offset: 0 });
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /列表/ }));
|
||||
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: '在地图中定位粤B67890' })).toBeInTheDocument();
|
||||
const scrollOwner = screen.getByLabelText('车辆实时列表');
|
||||
scrollOwner.scrollTop = 180;
|
||||
fireEvent.scroll(scrollOwner);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '在地图中定位粤B67890' }));
|
||||
expect(await screen.findByRole('region', { name: '粤B67890车辆详情' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '返回车辆列表' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回车辆列表' }));
|
||||
expect(await screen.findByText('车辆实时列表')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-monitor-mobile-card.is-selected')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('粤B67890 实时数据,当前地图选中')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opens an exact mobile search match directly and does not reopen it after dismissal', async () => {
|
||||
mockMobileViewport();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<main className="v2-content" data-testid="mobile-exact-match-scroll-owner">
|
||||
<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>
|
||||
</main>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '展开车辆筛选,当前 0 个条件' }));
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '搜索车辆' }), { target: { value: '粤A12345' } });
|
||||
|
||||
expect(await screen.findByRole('region', { name: '粤A12345车辆详情' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '展开车辆筛选,当前 1 个条件' })).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
const scrollOwner = screen.getByTestId('mobile-exact-match-scroll-owner');
|
||||
scrollOwner.scrollTop = 120;
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消选择车辆' }));
|
||||
expect(screen.queryByRole('region', { name: '粤A12345车辆详情' })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(scrollOwner.scrollTop).toBe(0));
|
||||
expect(screen.queryByRole('region', { name: '粤A12345车辆详情' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders missing and multiple runtime protocol sources on mobile without crashing', async () => {
|
||||
mockMobileViewport();
|
||||
const runtimeRows = [{
|
||||
@@ -557,7 +718,7 @@ test('opens an explicit batch editor and applies copied plate rows', async () =>
|
||||
render(<QueryClientProvider client={queryClient}><MemoryRouter future={ROUTER_FUTURE}><MonitorPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量' }));
|
||||
const editor = screen.getByRole('textbox', { name: /每行一个车牌/ });
|
||||
const editor = await screen.findByRole('textbox', { name: /每行一个车牌/ });
|
||||
fireEvent.change(editor, { target: { value: '粤a12345\n粤B67890\n粤A12345' } });
|
||||
expect(screen.getByText('2', { selector: '.v2-batch-search-summary strong' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用搜索(2)' }));
|
||||
|
||||
@@ -2,10 +2,10 @@ import {
|
||||
IconChevronLeft, IconFilter, IconList, IconMapPin,
|
||||
IconQrCode, IconRefresh, IconSearch
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Input, Select, Spin, Table, Tag, TextArea } from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Input, Select, Spin, Table, Tag } from '@douyinfe/semi-ui';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { lazy, memo, Suspense, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { lazy, memo, Suspense, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { Page, VehicleRealtimeRow } from '../../api/types';
|
||||
import { FleetMap } from '../map/FleetMap';
|
||||
@@ -20,7 +20,7 @@ import { formatNumber, statusLabel, vehicleStatus } from '../domain/monitor';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { MAX_MONITOR_SEARCH_TERMS, MONITOR_REFRESH, monitorFilterScope, monitorQueryParams, parseMonitorSearchTerms, useMonitorData, type MonitorViewport } from '../hooks/useMonitorData';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { buildMonitorPath, parseMonitorRouteContext } from '../routing/monitorContext';
|
||||
import { buildMonitorPath, parseMonitorRouteContext, withMonitorReturn } from '../routing/monitorContext';
|
||||
|
||||
const protocols = ['', 'GB32960', 'JT808', 'YUTONG_MQTT'];
|
||||
const statuses = ['', 'online', 'offline', 'driving', 'idle', 'no_location'];
|
||||
@@ -30,43 +30,8 @@ const MONITOR_VIEW_ITEMS = [
|
||||
{ key: 'list', label: '列表', icon: <IconList aria-hidden="true" /> }
|
||||
] as const;
|
||||
const VehicleDetailCard = lazy(() => import('./MonitorVehicleDetailCard'));
|
||||
|
||||
function BatchVehicleSearchDialog({ initialValue, mobile, onApply, onClose }: { initialValue: string; mobile: boolean; onApply: (value: string) => void; onClose: () => void }) {
|
||||
const [draft, setDraft] = useState(initialValue);
|
||||
const terms = useMemo(() => parseMonitorSearchTerms(draft), [draft]);
|
||||
|
||||
return <WorkspaceSideSheet
|
||||
className="v2-monitor-batch-sidesheet"
|
||||
variant="editor"
|
||||
visible
|
||||
ariaLabel="批量搜索车辆"
|
||||
closeLabel="关闭批量搜索车辆"
|
||||
dialogId="v2-monitor-batch-search"
|
||||
placement={mobile ? 'bottom' : 'right'}
|
||||
width={mobile ? undefined : 520}
|
||||
height={mobile ? 'min(86dvh, 700px)' : undefined}
|
||||
title="批量搜索车辆"
|
||||
description="从 Excel、文本或聊天记录中直接粘贴车牌"
|
||||
icon={<IconSearch />}
|
||||
badge={`${terms.length} 辆`}
|
||||
badgeColor={terms.length ? 'blue' : 'grey'}
|
||||
summaryItems={[
|
||||
{ label: '已识别', value: terms.length.toLocaleString('zh-CN'), detail: '可直接应用到监控筛选', tone: terms.length ? 'primary' : 'neutral' },
|
||||
{ label: '重复处理', value: '自动去重', detail: '相同车牌只保留一次', tone: 'success' },
|
||||
{ label: '单次上限', value: `${MAX_MONITOR_SEARCH_TERMS} 辆`, detail: '超出部分不会进入查询' }
|
||||
]}
|
||||
footerNote="支持换行、空格、逗号或分号分隔。"
|
||||
secondaryActions={[{ label: '取消', onClick: onClose }]}
|
||||
primaryAction={{ label: `应用搜索(${terms.length})`, ariaLabel: `应用搜索(${terms.length})`, disabled: !terms.length, icon: <IconSearch />, onClick: () => onApply(terms.join(',')) }}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="v2-batch-search-dialog">
|
||||
<label htmlFor="batch-vehicle-search">每行一个车牌,也支持空格、逗号或分号分隔</label>
|
||||
<TextArea id="batch-vehicle-search" autoFocus value={draft} onChange={setDraft} autosize={{ minRows: 8, maxRows: 14 }} resize="vertical" placeholder={'粤A12345\n粤B67890\n粤C24680'} />
|
||||
<div className="v2-batch-search-summary" role="status"><span>已识别 <strong>{terms.length}</strong> 辆,重复项已自动去除</span><em>最多 {MAX_MONITOR_SEARCH_TERMS} 辆</em></div>
|
||||
</div>
|
||||
</WorkspaceSideSheet>;
|
||||
}
|
||||
const MonitorCoverageWarning = lazy(() => import('./MonitorCoverageWarning'));
|
||||
const BatchVehicleSearchDialog = lazy(() => import('./BatchVehicleSearchDialog'));
|
||||
|
||||
type AddressCoordinate = { longitude: number; latitude: number; key: string };
|
||||
|
||||
@@ -116,8 +81,7 @@ function vehicleProtocolSignature(vehicle: VehicleRealtimeRow) {
|
||||
return vehicleProtocols(vehicle).join('|');
|
||||
}
|
||||
|
||||
function formatSupportCount(value: number, compact: boolean) {
|
||||
if (!compact) return formatNumber(value);
|
||||
function formatSupportCount(value: number) {
|
||||
const absolute = Math.abs(value);
|
||||
if (absolute >= 100_000_000) return `${Number((value / 100_000_000).toFixed(1))}亿`;
|
||||
if (absolute >= 10_000) return `${Number((value / 10_000).toFixed(1))}万`;
|
||||
@@ -163,15 +127,16 @@ const MonitorAddressCell = memo(function MonitorAddressCell({ vehicle }: { vehic
|
||||
|
||||
type MonitorMobileVehicleCardProps = {
|
||||
row: VehicleRealtimeRow;
|
||||
selected: boolean;
|
||||
onSelect: (vin: string) => void;
|
||||
};
|
||||
|
||||
const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, onSelect }: MonitorMobileVehicleCardProps) {
|
||||
const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, selected, onSelect }: MonitorMobileVehicleCardProps) {
|
||||
const identity = row.plate || '未绑定车牌';
|
||||
const location = hasRealtimeLocation(row);
|
||||
const sourceProtocols = vehicleProtocols(row);
|
||||
const primaryProtocol = vehiclePrimaryProtocol(row, sourceProtocols);
|
||||
return <Card className="v2-monitor-mobile-card" bodyStyle={{ padding: 0 }} aria-label={`${identity} 实时数据`}>
|
||||
return <Card className={`v2-monitor-mobile-card${selected ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }} aria-label={`${identity} 实时数据${selected ? ',当前地图选中' : ''}`}>
|
||||
<header className="v2-monitor-mobile-card-header">
|
||||
<div className="v2-monitor-mobile-identity"><strong>{identity}</strong><span>{row.vin}</span></div>
|
||||
<div className="v2-monitor-card-heading-actions">
|
||||
@@ -193,6 +158,7 @@ const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, o
|
||||
const before = previous.row;
|
||||
const after = next.row;
|
||||
return previous.onSelect === next.onSelect
|
||||
&& previous.selected === next.selected
|
||||
&& before.vin === after.vin
|
||||
&& before.plate === after.plate
|
||||
&& before.speedAvailable === after.speedAvailable
|
||||
@@ -208,9 +174,10 @@ const MonitorMobileVehicleCard = memo(function MonitorMobileVehicleCard({ row, o
|
||||
&& before.latitude === after.latitude;
|
||||
});
|
||||
|
||||
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, error, mobile, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; error: boolean; mobile: boolean; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
|
||||
function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, error, mobile, selectedVin, mobileScrollTop, onMobileScroll, onSelect, onPage, onLimit }: { rows: VehicleRealtimeRow[]; total: number; page: number; totalPages: number; limit: number; loading: boolean; error: boolean; mobile: boolean; selectedVin: string; mobileScrollTop: number; onMobileScroll: (scrollTop: number) => void; onSelect: (vin: string) => void; onPage: (page: number) => void; onLimit: (limit: number) => void }) {
|
||||
const mobileCardsRef = useRef<HTMLDivElement>(null);
|
||||
const columns = useMemo(() => [
|
||||
{ title: '车辆', dataIndex: 'plate', width: 210, className: 'v2-monitor-table-vehicle', render: (_value: string, row: VehicleRealtimeRow) => <Button className="v2-monitor-vehicle-action" theme="borderless" type="tertiary" title="在地图中定位" onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></Button> },
|
||||
{ title: '车辆', dataIndex: 'plate', width: 210, className: 'v2-monitor-table-vehicle', render: (_value: string, row: VehicleRealtimeRow) => <Button className={`v2-monitor-vehicle-action${row.vin === selectedVin ? ' is-selected' : ''}`} theme="borderless" type="tertiary" title={row.vin === selectedVin ? '当前地图选中,点击返回地图' : '在地图中定位'} onClick={() => onSelect(row.vin)}><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></Button> },
|
||||
{ title: '速度', dataIndex: 'speedKmh', width: 120, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeSpeed(row) ? formatNumber(row.speedKmh, 1) : '—'}</strong>{hasRealtimeSpeed(row) ? <small className="v2-monitor-live-unit">km/h</small> : null}</> },
|
||||
{ title: '当日里程', dataIndex: 'todayMileageKm', width: 140, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value is-today">{hasTodayMileage(row) ? formatNumber(row.todayMileageKm, 1) : '—'}</strong>{hasTodayMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
|
||||
{ title: '总里程', dataIndex: 'totalMileageKm', width: 170, render: (_value: number, row: VehicleRealtimeRow) => <><strong className="v2-monitor-live-value">{hasRealtimeMileage(row) ? formatNumber(row.totalMileageKm, 1) : '—'}</strong>{hasRealtimeMileage(row) ? <small className="v2-monitor-live-unit">km</small> : null}</> },
|
||||
@@ -220,16 +187,20 @@ function MonitorVehicleTable({ rows, total, page, totalPages, limit, loading, er
|
||||
} },
|
||||
{ title: '经纬度', dataIndex: 'longitude', width: 190, render: (_value: number, row: VehicleRealtimeRow) => hasRealtimeLocation(row) ? <code className="v2-monitor-coordinate">{row.longitude.toFixed(6)}<br />{row.latitude.toFixed(6)}</code> : <span className="v2-monitor-unavailable">—</span> },
|
||||
{ title: '地理位置', dataIndex: 'vin', render: (_value: string, row: VehicleRealtimeRow) => <MonitorAddressCell vehicle={row} /> }
|
||||
], [onSelect]);
|
||||
], [onSelect, selectedVin]);
|
||||
useLayoutEffect(() => {
|
||||
if (!mobile || !mobileCardsRef.current) return;
|
||||
mobileCardsRef.current.scrollTop = mobileScrollTop;
|
||||
}, [mobile, mobileScrollTop, selectedVin]);
|
||||
return <Card className="v2-monitor-table-panel" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
className="v2-monitor-list-header"
|
||||
title="车辆实时列表"
|
||||
description={mobile ? '实时数据 · 地址按需解析' : '覆盖全部授权车辆;缺失值显示“—”,地址按需解析'}
|
||||
meta={`${total.toLocaleString('zh-CN')} 辆${mobile ? '' : '车辆'}`}
|
||||
description={mobile ? undefined : '覆盖全部授权车辆;缺失值显示“—”,地址按需解析'}
|
||||
meta={`${total.toLocaleString('zh-CN')} 辆`}
|
||||
/>
|
||||
{!mobile ? <div className={`v2-monitor-table-scroll${error ? ' is-error' : ''}`}><Table className="v2-monitor-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} empty={null} />{loading ? <PanelLoading className="v2-monitor-table-loading" compact title="正在更新车辆实时数据" description={rows.length ? '保留当前列表,完成后平滑替换。' : '首批实时车辆返回后会自动显示。'} /> : null}{!loading && !error && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
|
||||
{mobile ? <div className="v2-monitor-mobile-cards">{rows.map((row) => <MonitorMobileVehicleCard row={row} onSelect={onSelect} key={row.vin} />)}{loading ? <PanelLoading className="v2-monitor-table-loading" compact title="正在更新车辆实时数据" description={rows.length ? '当前车辆卡片会保留到新数据就绪。' : '首批实时车辆返回后会自动显示。'} /> : null}{!loading && !error && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
|
||||
{!mobile ? <div className={`v2-monitor-table-scroll${error ? ' is-error' : ''}`}><Table className="v2-monitor-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} empty={null} onRow={(row) => row ? ({ className: row.vin === selectedVin ? 'is-selected' : '' }) : ({})} />{loading ? <PanelLoading className="v2-monitor-table-loading" compact title="正在更新车辆实时数据" description={rows.length ? '保留当前列表,完成后平滑替换。' : '首批实时车辆返回后会自动显示。'} /> : null}{!loading && !error && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
|
||||
{mobile ? <div ref={mobileCardsRef} className="v2-monitor-mobile-cards" tabIndex={0} aria-label="车辆实时列表" onScroll={(event) => onMobileScroll(event.currentTarget.scrollTop)}>{rows.map((row) => <MonitorMobileVehicleCard row={row} selected={row.vin === selectedVin} onSelect={onSelect} key={row.vin} />)}{loading && !rows.length ? <PanelLoading className="v2-monitor-table-loading" compact /> : null}{!loading && !error && !rows.length ? <PanelEmpty className="v2-monitor-table-empty" tone="primary" icon={<IconSearch />} title="暂无符合条件的车辆" description="调整搜索、协议或在线状态筛选后重试。" /> : null}</div> : null}
|
||||
<footer><TablePagination page={page} totalPages={totalPages} info={`共 ${total.toLocaleString('zh-CN')} 辆车辆`} onPageChange={onPage} pageSize={limit} pageSizeLabel="每页车辆数" onPageSizeChange={onLimit} pageSizeOptions={[{ value: 20, label: '20 辆/页' }, { value: 50, label: '50 辆/页' }, { value: 100, label: '100 辆/页' }]} /></footer>
|
||||
</Card>;
|
||||
}
|
||||
@@ -315,6 +286,7 @@ const MemoFleetMap = memo(FleetMap);
|
||||
|
||||
export default function MonitorPage() {
|
||||
const [routeParams, setRouteParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const initialContextRef = useRef(parseMonitorRouteContext(routeParams));
|
||||
const initialContext = initialContextRef.current;
|
||||
@@ -332,6 +304,10 @@ export default function MonitorPage() {
|
||||
const [status, setStatus] = useState(initialContext.status);
|
||||
const [selectedVin, setSelectedVin] = useState(initialContext.selectedVin);
|
||||
const [detailOpen, setDetailOpen] = useState(initialContext.detailOpen);
|
||||
const mobileAutoSelectionRef = useRef('');
|
||||
const mobileInitialFleetReadyRef = useRef(false);
|
||||
const listReturnPendingRef = useRef(false);
|
||||
const listScrollTopRef = useRef(0);
|
||||
const [viewport, setViewport] = useState<MonitorViewport>(initialContext.viewport);
|
||||
const updateViewport = useCallback((next: MonitorViewport) => {
|
||||
setViewport((current) => current.zoom === next.zoom && current.bounds === next.bounds ? current : next);
|
||||
@@ -348,6 +324,7 @@ export default function MonitorPage() {
|
||||
const listParams = useMemo(() => {
|
||||
const params = monitorQueryParams(filters, listLimit);
|
||||
params.set('offset', String(listOffset));
|
||||
params.set('sort', 'identity');
|
||||
return params;
|
||||
}, [filters, listLimit, listOffset]);
|
||||
const trackedVin = mode === 'map' ? selectedVin : '';
|
||||
@@ -396,88 +373,170 @@ export default function MonitorPage() {
|
||||
const scrollOwner = pageRef.current?.closest<HTMLElement>('.v2-content');
|
||||
if (scrollOwner) scrollOwner.scrollTop = 0;
|
||||
}, []);
|
||||
const selectVehicle = useCallback((vin: string) => {
|
||||
useLayoutEffect(() => {
|
||||
if (!mobileLayout) return;
|
||||
resetMonitorScroll();
|
||||
const frame = window.requestAnimationFrame(resetMonitorScroll);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [detailOpen, filtersCollapsed, mobileLayout, resetMonitorScroll, selectedVin]);
|
||||
useLayoutEffect(() => {
|
||||
if (!mobileLayout) {
|
||||
mobileInitialFleetReadyRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!vehicles.data || mobileInitialFleetReadyRef.current) return;
|
||||
mobileInitialFleetReadyRef.current = true;
|
||||
resetMonitorScroll();
|
||||
const frame = window.requestAnimationFrame(resetMonitorScroll);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [mobileLayout, resetMonitorScroll, vehicles.data]);
|
||||
useLayoutEffect(() => {
|
||||
resetMonitorScroll();
|
||||
}, [resetMonitorScroll, status]);
|
||||
const selectVehicle = useCallback((vin: string, origin: 'map' | 'list' = 'map') => {
|
||||
listReturnPendingRef.current = mobileLayout && origin === 'list';
|
||||
resetMonitorScroll();
|
||||
setSelectedVin(vin);
|
||||
setDetailOpen(true);
|
||||
setMode('map');
|
||||
}, [resetMonitorScroll]);
|
||||
}, [mobileLayout, resetMonitorScroll]);
|
||||
const selectListVehicle = useCallback((vin: string) => selectVehicle(vin, 'list'), [selectVehicle]);
|
||||
const clearSelection = useCallback(() => {
|
||||
listReturnPendingRef.current = false;
|
||||
setSelectedVin('');
|
||||
setDetailOpen(false);
|
||||
}, []);
|
||||
const applyStatusFilter = useCallback((nextStatus: string) => {
|
||||
setStatus(nextStatus);
|
||||
setListOffset(0);
|
||||
clearSelection();
|
||||
}, [clearSelection]);
|
||||
const selectMapVehicle = useCallback((vehicle: VehicleRealtimeRow) => selectVehicle(vehicle.vin), [selectVehicle]);
|
||||
const collapseDetail = useCallback(() => setDetailOpen(false), []);
|
||||
const collapseDetail = useCallback(() => {
|
||||
setDetailOpen(false);
|
||||
if (mobileLayout && listReturnPendingRef.current) {
|
||||
listReturnPendingRef.current = false;
|
||||
resetMonitorScroll();
|
||||
setMode('list');
|
||||
}
|
||||
}, [mobileLayout, resetMonitorScroll]);
|
||||
const clearDetail = useCallback(() => {
|
||||
const returnToList = mobileLayout && listReturnPendingRef.current;
|
||||
clearSelection();
|
||||
if (returnToList) {
|
||||
resetMonitorScroll();
|
||||
setMode('list');
|
||||
}
|
||||
}, [clearSelection, mobileLayout, resetMonitorScroll]);
|
||||
const expandDetail = useCallback(() => setDetailOpen(true), []);
|
||||
const changeMonitorMode = useCallback((nextMode: 'map' | 'list') => {
|
||||
listReturnPendingRef.current = false;
|
||||
resetMonitorScroll();
|
||||
setMode(nextMode);
|
||||
if (nextMode === 'list') setDetailOpen(false);
|
||||
}, [resetMonitorScroll]);
|
||||
useEffect(() => {
|
||||
const term = searchTerms.length === 1 ? searchTerms[0] : '';
|
||||
if (!mobileLayout || !term || filterTransitionPending) {
|
||||
if (!term) mobileAutoSelectionRef.current = '';
|
||||
return;
|
||||
}
|
||||
const exactVehicle = rows.find((vehicle) => vehicle.vin.toLocaleUpperCase() === term || vehicle.plate?.toLocaleUpperCase() === term);
|
||||
if (!exactVehicle || mobileAutoSelectionRef.current === term) return;
|
||||
mobileAutoSelectionRef.current = term;
|
||||
setFiltersCollapsed(true);
|
||||
selectVehicle(exactVehicle.vin);
|
||||
}, [filterTransitionPending, mobileLayout, rows, searchTerms, selectVehicle]);
|
||||
const openVehicleList = useCallback(() => changeMonitorMode('list'), [changeMonitorMode]);
|
||||
const clearFilters = useCallback(() => {
|
||||
setKeyword('');
|
||||
setProtocol('');
|
||||
setStatus('');
|
||||
setListOffset(0);
|
||||
}, []);
|
||||
clearSelection();
|
||||
}, [clearSelection]);
|
||||
const activeFilterCount = Number(Boolean(keyword.trim())) + Number(Boolean(protocol)) + Number(Boolean(status));
|
||||
const driving = rows.filter((vehicle) => vehicleStatus(vehicle) === 'driving').length;
|
||||
const idle = rows.filter((vehicle) => vehicleStatus(vehicle) === 'idle').length;
|
||||
const offline = rows.filter((vehicle) => vehicleStatus(vehicle) === 'offline').length;
|
||||
const totalVehicleCount = summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length;
|
||||
const totalVehicleCount = summary.data?.truncated
|
||||
? vehicles.data?.total ?? summary.data.totalVehicles
|
||||
: summary.data?.totalVehicles ?? vehicles.data?.total ?? rows.length;
|
||||
const onlineVehicleCount = summary.data?.onlineVehicles ?? rows.length - offline;
|
||||
const offlineVehicleCount = summary.data?.offlineVehicles ?? offline;
|
||||
const drivingVehicleCount = summary.data?.drivingVehicles ?? driving;
|
||||
const idleVehicleCount = summary.data?.idleVehicles ?? idle;
|
||||
const alertVehicleCount = summary.data?.alertDataAvailable ? summary.data.alertVehicles : undefined;
|
||||
const monitorCoverageTruncated = Boolean(summary.data?.truncated || map.data?.truncated);
|
||||
const processedVehicleCount = summary.data?.totalVehicles ?? visibleRows.length;
|
||||
const monitorMetrics: WorkspaceQueueMetricRailItem[] = [
|
||||
{
|
||||
label: '车辆总数',
|
||||
label: activeFilterCount ? '筛选结果' : '车辆总数',
|
||||
value: <>{formatNumber(totalVehicleCount)}<i className="v2-monitor-summary-unit">辆</i></>,
|
||||
note: '授权车辆',
|
||||
note: activeFilterCount ? `${activeFilterCount} 个条件` : '授权车辆',
|
||||
tone: 'primary',
|
||||
emphasis: 'primary'
|
||||
emphasis: 'primary',
|
||||
active: !status,
|
||||
ariaLabel: status ? '清除状态筛选' : '查看全部状态车辆',
|
||||
onClick: () => applyStatusFilter('')
|
||||
},
|
||||
{
|
||||
label: '当前在线',
|
||||
value: <>{formatNumber(onlineVehicleCount)}<i className="v2-monitor-summary-unit">辆</i></>,
|
||||
note: '当前可用',
|
||||
tone: 'success',
|
||||
emphasis: 'primary'
|
||||
emphasis: 'primary',
|
||||
active: status === 'online',
|
||||
ariaLabel: `筛选当前在线车辆,共 ${formatNumber(onlineVehicleCount)} 辆`,
|
||||
onClick: () => applyStatusFilter('online')
|
||||
},
|
||||
{
|
||||
label: '行驶车辆',
|
||||
value: <>{formatNumber(drivingVehicleCount)}<i className="v2-monitor-summary-unit">辆</i></>,
|
||||
note: '实时行驶',
|
||||
tone: 'primary',
|
||||
emphasis: 'primary'
|
||||
emphasis: 'primary',
|
||||
active: status === 'driving',
|
||||
ariaLabel: `筛选行驶车辆,共 ${formatNumber(drivingVehicleCount)} 辆`,
|
||||
onClick: () => applyStatusFilter('driving')
|
||||
},
|
||||
{
|
||||
label: '当前离线',
|
||||
value: <>{formatNumber(offlineVehicleCount)}<i className="v2-monitor-summary-unit">辆</i></>,
|
||||
note: '无在线来源',
|
||||
tone: 'neutral',
|
||||
emphasis: 'secondary'
|
||||
emphasis: 'secondary',
|
||||
active: status === 'offline',
|
||||
ariaLabel: `筛选当前离线车辆,共 ${formatNumber(offlineVehicleCount)} 辆`,
|
||||
onClick: () => applyStatusFilter('offline')
|
||||
},
|
||||
{
|
||||
label: '静止车辆',
|
||||
value: <>{formatNumber(idleVehicleCount)}<i className="v2-monitor-summary-unit">辆</i></>,
|
||||
note: '在线静止',
|
||||
tone: 'success',
|
||||
emphasis: 'secondary'
|
||||
emphasis: 'secondary',
|
||||
active: status === 'idle',
|
||||
ariaLabel: `筛选静止车辆,共 ${formatNumber(idleVehicleCount)} 辆`,
|
||||
onClick: () => applyStatusFilter('idle')
|
||||
},
|
||||
{
|
||||
label: '告警车辆',
|
||||
value: <>{alertVehicleCount == null ? '—' : formatNumber(alertVehicleCount)}<i className="v2-monitor-summary-unit">辆</i></>,
|
||||
note: alertVehicleCount == null ? '数据未开放' : '需要关注',
|
||||
tone: alertVehicleCount ? 'danger' : 'neutral',
|
||||
emphasis: 'primary'
|
||||
emphasis: 'primary',
|
||||
ariaLabel: alertVehicleCount == null ? '打开事件中心' : `打开事件中心核对告警车辆,共 ${formatNumber(alertVehicleCount)} 辆`,
|
||||
onClick: () => navigate(withMonitorReturn('/alerts?status=unprocessed', monitorReturn))
|
||||
}
|
||||
];
|
||||
const mapCoverage = map.data?.mode === 'provinces'
|
||||
? `${formatNumber(map.data.total)} / ${formatNumber(totalVehicleCount)} 辆有定位 · 省级聚合`
|
||||
: map.data?.mode === 'clusters'
|
||||
? `${formatNumber(map.data.total)} / ${formatNumber(totalVehicleCount)} 辆有定位 · ${map.data.clusters.length} 个聚合`
|
||||
: `${formatNumber(map.data?.total ?? 0)} / ${formatNumber(totalVehicleCount)} 辆有定位 · ${map.data?.points.length ?? 0} 个车辆点`;
|
||||
const viewportLoad = mode === 'map'
|
||||
? (map.data?.clusters.length
|
||||
? `${map.data.clusters.length} 个聚合 · ${map.data.points.length} 个车辆点`
|
||||
: `${map.data?.points.length ?? 0} 个车辆点`)
|
||||
? mapCoverage
|
||||
: `${realtimeListQuery.data?.items.length ?? 0} / ${realtimeListQuery.data?.total ?? 0} 辆`;
|
||||
const lastSyncAt = vehicles.dataUpdatedAt ? new Date(vehicles.dataUpdatedAt) : undefined;
|
||||
|
||||
@@ -490,13 +549,14 @@ export default function MonitorPage() {
|
||||
aria-label="搜索车辆"
|
||||
prefix={<IconSearch />}
|
||||
value={keyword}
|
||||
onChange={(value) => { setKeyword(value); setListOffset(0); }}
|
||||
onChange={(value) => { setKeyword(value); setListOffset(0); clearSelection(); }}
|
||||
onPaste={(event) => {
|
||||
const pastedTerms = parseMonitorSearchTerms(event.clipboardData.getData('text'));
|
||||
if (pastedTerms.length <= 1) return;
|
||||
event.preventDefault();
|
||||
setKeyword(pastedTerms.join(','));
|
||||
setListOffset(0);
|
||||
clearSelection();
|
||||
}}
|
||||
placeholder="车牌 / VIN;可批量粘贴车牌"
|
||||
suffix={<Button className="v2-search-batch-action" size="small" theme="borderless" onClick={() => setBatchSearchOpen(true)}>批量</Button>}
|
||||
@@ -504,9 +564,9 @@ export default function MonitorPage() {
|
||||
{searchTerms.length > 1 ? <span className={`v2-search-batch-count${batchMatch.missing.length && !batchSearchPending ? ' has-missing' : ''}`} aria-live="polite" title={searchTerms.length === MAX_MONITOR_SEARCH_TERMS ? `最多支持 ${MAX_MONITOR_SEARCH_TERMS} 条;${batchStatusTitle}` : batchStatusTitle}>{batchSearchPending ? `已识别 ${searchTerms.length} 辆` : `已找到 ${batchMatch.matched}/${searchTerms.length}`}</span> : null}
|
||||
</div>
|
||||
<span className="v2-sr-only" id="monitor-protocol-filter-label">协议</span>
|
||||
<Select value={protocol} onChange={(value) => { setProtocol(String(value)); setListOffset(0); }} aria-labelledby="monitor-protocol-filter-label" optionList={protocols.map((item) => ({ value: item, label: item || '全部协议' }))} />
|
||||
<Select value={protocol} onChange={(value) => { setProtocol(String(value)); setListOffset(0); clearSelection(); }} aria-labelledby="monitor-protocol-filter-label" optionList={protocols.map((item) => ({ value: item, label: item || '全部协议' }))} />
|
||||
<span className="v2-sr-only" id="monitor-status-filter-label">在线状态</span>
|
||||
<Select value={status} onChange={(value) => { setStatus(String(value)); setListOffset(0); }} aria-labelledby="monitor-status-filter-label" optionList={statuses.map((item) => ({ value: item, label: item === 'no_location' ? '无实时位置' : item ? statusLabel(item as never) : '全部状态' }))} />
|
||||
<Select value={status} onChange={(value) => applyStatusFilter(String(value))} aria-labelledby="monitor-status-filter-label" optionList={statuses.map((item) => ({ value: item, label: item === 'no_location' ? '无实时位置' : item ? statusLabel(item as never) : '全部状态' }))} />
|
||||
<Button className="v2-filter-reset" icon={<IconRefresh />} disabled={activeFilterCount === 0} onClick={clearFilters}>清空</Button>
|
||||
</div>
|
||||
<div className="v2-monitor-filter-actions" aria-label="监控操作">
|
||||
@@ -538,10 +598,10 @@ export default function MonitorPage() {
|
||||
onChange={changeMonitorMode}
|
||||
variant="filled"
|
||||
/>
|
||||
<Button className="v2-monitor-mobile-entry" theme="light" icon={<IconQrCode />} aria-label="打开手机端入口" onClick={() => setMobileEntryOpen(true)}><span className="v2-monitor-mobile-entry-label">手机端</span></Button>
|
||||
{!mobileLayout ? <Button className="v2-monitor-mobile-entry" theme="light" icon={<IconQrCode />} aria-label="打开手机端入口" onClick={() => setMobileEntryOpen(true)}><span className="v2-monitor-mobile-entry-label">手机端</span></Button> : null}
|
||||
</div>
|
||||
</Card>
|
||||
{batchSearchOpen ? <BatchVehicleSearchDialog initialValue={searchTerms.join('\n')} mobile={mobileLayout} onClose={() => setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); setBatchSearchOpen(false); }} /> : null}
|
||||
{batchSearchOpen ? <Suspense fallback={null}><BatchVehicleSearchDialog initialValue={searchTerms.join('\n')} mobile={mobileLayout} onClose={() => setBatchSearchOpen(false)} onApply={(value) => { setKeyword(value); setListOffset(0); clearSelection(); setBatchSearchOpen(false); }} /></Suspense> : null}
|
||||
|
||||
<WorkspaceMetricRail
|
||||
variant="queue"
|
||||
@@ -549,13 +609,14 @@ export default function MonitorPage() {
|
||||
className="v2-monitor-summary-rail"
|
||||
items={monitorMetrics}
|
||||
context={<div className="v2-monitor-summary-support">
|
||||
<span title={`今日上报 ${formatNumber(summary.data?.frameToday ?? 0)} 条`}><small>今日上报</small><strong>{formatSupportCount(summary.data?.frameToday ?? 0, mobileLayout)}<i>条</i></strong><em>数据活跃度</em></span>
|
||||
<span><small>无实时位置</small><strong>{formatNumber(summary.data?.noLocationVehicles ?? 0)}<i>辆</i></strong><em>辅助排查</em></span>
|
||||
<span title={`今日上报 ${formatNumber(summary.data?.frameToday ?? 0)} 条`}><small>今日上报</small><strong>{formatSupportCount(summary.data?.frameToday ?? 0)}<i>条</i></strong><em>数据活跃度</em></span>
|
||||
<Button className={`v2-monitor-summary-support-action${status === 'no_location' ? ' is-active' : ''}`} theme="borderless" type="tertiary" aria-label={`筛选无实时位置车辆,共 ${formatNumber(summary.data?.noLocationVehicles ?? 0)} 辆`} aria-pressed={status === 'no_location'} onClick={() => applyStatusFilter('no_location')}><small>无实时位置</small><strong>{formatNumber(summary.data?.noLocationVehicles ?? 0)}<i>辆</i></strong><em>辅助排查</em></Button>
|
||||
</div>}
|
||||
/>
|
||||
|
||||
{vehicles.isError ? <InlineError message={vehicles.error instanceof Error ? vehicles.error.message : '车辆数据加载失败'} onRetry={() => vehicles.refetch()} /> : null}
|
||||
{mode === 'list' && realtimeListQuery.isError ? <InlineError message={realtimeListQuery.error instanceof Error ? realtimeListQuery.error.message : '车辆列表加载失败'} onRetry={() => realtimeListQuery.refetch()} /> : null}
|
||||
{mode === 'map' && monitorCoverageTruncated ? <Suspense fallback={null}><MonitorCoverageWarning processed={formatNumber(processedVehicleCount)} total={formatNumber(vehicles.data?.total ?? totalVehicleCount)} onOpenList={openVehicleList} /></Suspense> : null}
|
||||
{mode === 'map' ? <section className={`v2-monitor-workspace${selected && detailOpen ? ' is-detail-open' : ''}${selected && !detailOpen ? ' is-detail-collapsed' : ''}`}>
|
||||
<div className="v2-vehicle-rail">
|
||||
<header><strong>车辆列表</strong><span>{batchSearchPending ? '查询中' : `${formatNumber(vehicles.data?.total ?? visibleRows.length)} 辆`}</span></header>
|
||||
@@ -575,6 +636,7 @@ export default function MonitorPage() {
|
||||
onSelectVin={selectVehicle}
|
||||
onViewportChange={updateViewport}
|
||||
initialViewport={initialContext.hasViewport ? initialContext.viewport : undefined}
|
||||
onOpenList={openVehicleList}
|
||||
/>
|
||||
{selected && detailOpen ? (
|
||||
<Suspense fallback={<Card className="v2-vehicle-detail v2-detail-loading-card" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在加载车辆详情" description="车辆状态和最新上报即将就绪。" /></Card>}>
|
||||
@@ -582,7 +644,8 @@ export default function MonitorPage() {
|
||||
vehicle={selected}
|
||||
monitorReturn={monitorReturn}
|
||||
onCollapse={collapseDetail}
|
||||
onClear={clearSelection}
|
||||
onClear={clearDetail}
|
||||
collapseLabel={mobileLayout && listReturnPendingRef.current ? '返回车辆列表' : undefined}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
@@ -595,14 +658,14 @@ export default function MonitorPage() {
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
</section> : <MonitorVehicleTable rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} error={realtimeListQuery.isError} mobile={mobileLayout} onSelect={selectVehicle} onPage={(page) => setListOffset((page - 1) * listLimit)} onLimit={(next) => { setListLimit(next); setListOffset(0); }} />}
|
||||
</section> : <MonitorVehicleTable rows={visibleListRows} total={visibleListTotal} page={Math.floor(listOffset / listLimit) + 1} totalPages={Math.max(1, Math.ceil(visibleListTotal / listLimit))} limit={listLimit} loading={filterTransitionPending || realtimeListQuery.isFetching} error={realtimeListQuery.isError} mobile={mobileLayout} selectedVin={selectedVin} mobileScrollTop={listScrollTopRef.current} onMobileScroll={(scrollTop) => { listScrollTopRef.current = scrollTop; }} onSelect={selectListVehicle} onPage={(page) => { listScrollTopRef.current = 0; setListOffset((page - 1) * listLimit); }} onLimit={(next) => { listScrollTopRef.current = 0; setListLimit(next); setListOffset(0); }} />}
|
||||
|
||||
<Card className="v2-event-strip" bodyStyle={{ padding: 0 }} aria-label="实时数据状态">
|
||||
<div className="v2-event-sync">
|
||||
<Tag className="v2-monitor-live-tag" color="green" type="light" size="small"><IconRefresh aria-hidden="true" />{vehicles.isFetching ? '正在同步' : '数据已同步'}</Tag>
|
||||
<span><strong>实时数据</strong><small>{visibleRows.length.toLocaleString('zh-CN')} 辆已载入</small></span>
|
||||
</div>
|
||||
<div className="v2-event-load"><small>{mode === 'map' ? '地图载荷' : '列表载荷'}</small><span>{viewportLoad}</span></div>
|
||||
<div className={`v2-event-load${monitorCoverageTruncated && mode === 'map' ? ' is-truncated' : ''}`}><small>{mode === 'map' ? '地图覆盖' : '列表载荷'}</small><span>{viewportLoad}</span></div>
|
||||
<div className="v2-refresh-cadence">
|
||||
<Tag color="blue" type="light" size="small"><IconRefresh aria-hidden="true" />智能刷新</Tag>
|
||||
<span>重点 {MONITOR_REFRESH.selected / 1000}s · 车队 {MONITOR_REFRESH.fleet / 1000}s · 统计 {MONITOR_REFRESH.summary / 1000}s</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconChevronRight, IconClose } from '@douyinfe/semi-icons';
|
||||
import { IconChevronRight, IconClose, IconList } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Tag } from '@douyinfe/semi-ui';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -36,12 +36,14 @@ export default function MonitorVehicleDetailCard({
|
||||
vehicle,
|
||||
monitorReturn,
|
||||
onCollapse,
|
||||
onClear
|
||||
onClear,
|
||||
collapseLabel = '收起车辆详情'
|
||||
}: {
|
||||
vehicle: VehicleRealtimeRow;
|
||||
monitorReturn: string;
|
||||
onCollapse: () => void;
|
||||
onClear: () => void;
|
||||
collapseLabel?: string;
|
||||
}) {
|
||||
const [sourceEvidenceOpen, setSourceEvidenceOpen] = useState(false);
|
||||
const card = useMonitorVehicleCard(vehicle.vin, vehicle, true);
|
||||
@@ -49,6 +51,14 @@ export default function MonitorVehicleDetailCard({
|
||||
const activeAlerts = card.activeAlerts.data;
|
||||
const telemetry = card.telemetry?.data;
|
||||
const address = card.address.data;
|
||||
const addressContent = !hasRealtimeLocation(vehicle)
|
||||
? '暂无实时位置'
|
||||
: card.address.isError
|
||||
? <span className="v2-detail-address-error" role="alert">
|
||||
<span>地址解析失败,坐标仍可用</span>
|
||||
<Button size="small" theme="borderless" type="danger" onClick={() => card.address.refetch()}>重试</Button>
|
||||
</span>
|
||||
: address?.formattedAddress || (card.address.isFetching ? '位置解析中' : '暂无地址结果');
|
||||
const status = vehicleStatus(vehicle);
|
||||
// The realtime row is joined to vehicle_daily_mileage with stat_date = CURDATE().
|
||||
// Vehicle detail mileage is ordered by recency and may start with yesterday, so it
|
||||
@@ -60,18 +70,20 @@ export default function MonitorVehicleDetailCard({
|
||||
const accessSource = detail?.profile?.accessProvider || vehicle.locationSource;
|
||||
const accessSourceLabel = protocolSourceLabel(accessSource) || '来源待补充';
|
||||
const availableProtocols = detail?.sources?.length ? detail.sources : vehicle.protocols;
|
||||
const alertCount = activeAlerts?.total ?? 0;
|
||||
const workflowLinks = [
|
||||
['单车详情', `/vehicles/${encodedVin}`],
|
||||
['轨迹回放', `/tracks?vin=${encodedVin}`],
|
||||
['历史数据', `/history?vin=${encodedVin}`],
|
||||
['里程查询', `/statistics?vins=${encodedVin}`]
|
||||
] as const;
|
||||
{ label: '单车详情', path: `/vehicles/${encodedVin}` },
|
||||
{ label: '轨迹回放', path: `/tracks?vin=${encodedVin}` },
|
||||
{ label: '历史数据', path: `/history?vin=${encodedVin}` },
|
||||
{ label: '里程查询', path: `/statistics?vins=${encodedVin}` },
|
||||
{ label: alertCount > 0 ? `事件流 ${alertCount}` : '事件流', path: `/alerts?keyword=${encodedVin}`, attention: alertCount > 0 },
|
||||
{ label: '质量差异', path: `/operations?reconcileKeyword=${encodedVin}` }
|
||||
];
|
||||
const gbHighlights = (telemetry?.values ?? []).filter((item) => item.protocol === 'GB32960' && [
|
||||
'fuel_cell_voltage_v', 'fuel_cell_current_a', 'hydrogen_consumption_kg_per_100km',
|
||||
'hydrogen_concentration_percent', 'hydrogen_pressure_mpa', 'hydrogen_temperature_c',
|
||||
'engine_speed_rpm', 'total_voltage_v', 'total_current_a'
|
||||
].includes(item.key)).slice(0, 9);
|
||||
const alertCount = activeAlerts?.total ?? 0;
|
||||
const metricItems: WorkspaceQueueMetricRailItem[] = [
|
||||
{
|
||||
label: '速度',
|
||||
@@ -105,12 +117,13 @@ export default function MonitorVehicleDetailCard({
|
||||
emphasis: 'secondary'
|
||||
}
|
||||
];
|
||||
const returnsToList = collapseLabel === '返回车辆列表';
|
||||
|
||||
return <Card className="v2-vehicle-detail" bodyStyle={{ padding: 0 }}>
|
||||
<div className="v2-detail-body" role="region" aria-label={`${vehicle.plate || vehicle.vin}车辆详情`}>
|
||||
<div className="v2-detail-shell-header">
|
||||
<div className="v2-detail-controls">
|
||||
<Button size="small" theme="light" type="tertiary" icon={<IconChevronRight />} aria-label="收起车辆详情" title="收起到地图右侧" onClick={onCollapse} />
|
||||
<Button size="small" theme="light" type="tertiary" icon={returnsToList ? <IconList /> : <IconChevronRight />} aria-label={collapseLabel} title={returnsToList ? '返回并定位到车辆列表中的当前车辆' : '收起到地图右侧'} onClick={onCollapse} />
|
||||
<Button size="small" theme="light" type="danger" icon={<IconClose />} aria-label="取消选择车辆" title="取消选择车辆" onClick={onClear} />
|
||||
</div>
|
||||
<div className="v2-detail-title">
|
||||
@@ -118,7 +131,7 @@ export default function MonitorVehicleDetailCard({
|
||||
<small>{vehicle.vin}</small>
|
||||
</div>
|
||||
<nav className="v2-detail-actions" aria-label="车辆快捷操作">
|
||||
{workflowLinks.map(([label, path]) => <Link key={label} to={withMonitorReturn(path, monitorReturn)}><span>{label}</span><IconChevronRight aria-hidden="true" /></Link>)}
|
||||
{workflowLinks.map(({ label, path, attention }) => <Link className={attention ? 'is-attention' : undefined} key={path} to={withMonitorReturn(path, monitorReturn)}><span>{label}</span><IconChevronRight aria-hidden="true" /></Link>)}
|
||||
</nav>
|
||||
</div>
|
||||
<section className="v2-detail-section v2-detail-report">
|
||||
@@ -135,7 +148,7 @@ export default function MonitorVehicleDetailCard({
|
||||
</div>
|
||||
<dl className="v2-detail-list">
|
||||
<div><dt>坐标</dt><dd><Button theme="borderless" type="tertiary" className="v2-detail-source-link" aria-label="查看全部位置来源" title="展开全部位置来源" onClick={() => setSourceEvidenceOpen(true)}>{hasRealtimeLocation(vehicle) ? `${vehicle.longitude.toFixed(6)}, ${vehicle.latitude.toFixed(6)}` : '—'}</Button></dd></div>
|
||||
<div><dt>位置</dt><dd>{hasRealtimeLocation(vehicle) ? address?.formattedAddress || '位置解析中' : '暂无实时位置'}</dd></div>
|
||||
<div><dt>位置</dt><dd>{addressContent}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section className="v2-detail-section">
|
||||
|
||||
@@ -7,11 +7,14 @@ import OperationsPage from './OperationsPage';
|
||||
const mocks = vi.hoisted(() => ({
|
||||
opsHealth: vi.fn(), sourceReadiness: vi.fn(), session: vi.fn(), vehicleCoverage: vi.fn(),
|
||||
vehicleSourceDiagnostic: vi.fn(), updateVehicleSourcePolicy: vi.fn(),
|
||||
reconciliationSummary: vi.fn(), reconciliationIssues: vi.fn(), reconciliationIssue: vi.fn(), updateReconciliationIssue: vi.fn()
|
||||
reconciliationSummary: vi.fn(), reconciliationIssues: vi.fn(), reconciliationIssue: vi.fn(), updateReconciliationIssue: vi.fn(), batchUpdateReconciliationIssues: vi.fn(),
|
||||
reconciliationAssignees: vi.fn(), downloadReconciliationIssues: vi.fn(),
|
||||
assignReconciliationIssue: vi.fn(), batchAssignReconciliationIssues: vi.fn()
|
||||
}));
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
|
||||
vi.mock('../domain/download', () => ({ downloadBlob: vi.fn() }));
|
||||
|
||||
afterEach(() => { cleanup(); layout.mobile = false; Object.values(mocks).forEach((mock) => mock.mockReset()); });
|
||||
|
||||
@@ -26,6 +29,11 @@ function renderOperations(client: QueryClient, initialEntries = ['/operations'])
|
||||
|
||||
function seedSession() {
|
||||
mocks.session.mockResolvedValue({ name: '平台管理员', role: 'admin', userType: 'admin', authMode: 'enforce', menuKeys: ['operations'] });
|
||||
mocks.reconciliationAssignees.mockResolvedValue([
|
||||
{ name: '平台管理员', username: 'admin', source: 'account', activeCount: 0, current: true },
|
||||
{ name: '定位运维组', source: 'history', activeCount: 3 }
|
||||
]);
|
||||
mocks.downloadReconciliationIssues.mockResolvedValue({ blob: new Blob(['export']), filename: '质量差异.csv' });
|
||||
mocks.reconciliationSummary.mockResolvedValue({
|
||||
active: 1, pending: 1, confirmed: 0, recovered: 2, overSla: 1,
|
||||
byRule: [{ name: 'POSITION_DRIFT', count: 1 }], bySeverity: [{ name: 'major', count: 1 }],
|
||||
@@ -144,6 +152,111 @@ test('renders reconciliation queue, loads evidence on demand and records review
|
||||
}));
|
||||
});
|
||||
|
||||
test('keeps a visible return path when quality review starts from global monitor', async () => {
|
||||
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: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const monitorPath = '/monitor?selectedVin=VIN001&detail=open&zoom=13';
|
||||
renderOperations(client, [`/operations?reconcileKeyword=VIN001&monitorReturn=${encodeURIComponent(monitorPath)}`]);
|
||||
|
||||
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', expect.stringContaining('selectedVin=VIN001'));
|
||||
expect(screen.getByRole('textbox', { name: '搜索差异车辆或规则' })).toHaveValue('VIN001');
|
||||
});
|
||||
|
||||
test('batch handles selected reconciliation issues and reports per-item outcome', async () => {
|
||||
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: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
|
||||
mocks.batchUpdateReconciliationIssues.mockResolvedValue({
|
||||
requested: 1,
|
||||
succeeded: [{ ...(await mocks.reconciliationIssue()), status: 'fixed', version: 2, resolutionNote: '已完成设备复测', resolvedBy: '平台管理员' }],
|
||||
skipped: []
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderOperations(client);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '进入批量处置' }));
|
||||
expect(screen.getByRole('region', { name: '批量处置工具栏' })).toHaveTextContent('已选 0 项');
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: '选择差异:粤A00001' }));
|
||||
expect(screen.getByRole('region', { name: '批量处置工具栏' })).toHaveTextContent('已选 1 项');
|
||||
fireEvent.click(screen.getByRole('button', { name: '处置选中' }));
|
||||
const confirm = await screen.findByRole('dialog', { name: '确认批量处置' });
|
||||
expect(within(confirm).getByText('确认影响范围')).toBeInTheDocument();
|
||||
expect(within(confirm).getByText(/单项冲突不会阻断其他记录/)).toBeInTheDocument();
|
||||
fireEvent.change(within(confirm).getByRole('textbox', { name: '批量处置说明(必填)' }), { target: { value: '已完成设备复测' } });
|
||||
fireEvent.click(within(confirm).getByRole('button', { name: '确认处置 1 项' }));
|
||||
await waitFor(() => expect(mocks.batchUpdateReconciliationIssues).toHaveBeenCalledWith({
|
||||
items: [{ id: 'issue-1', version: 1 }], status: 'fixed', note: '已完成设备复测'
|
||||
}));
|
||||
expect(await screen.findByText('批量处置已全部完成')).toBeInTheDocument();
|
||||
expect(screen.getByText('成功 1 项,跳过 0 项;每项均按提交时版本独立校验。')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '导出结果' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回队列' }));
|
||||
expect(screen.queryByRole('region', { name: '批量处置工具栏' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('assigns responsibility with a deadline and restores owner and SLA filters', async () => {
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({ linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5, tdengineWritable: true, mysqlWritable: true, runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000 } });
|
||||
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
|
||||
mocks.assignReconciliationIssue.mockImplementation(async (_id, input) => ({ ...(await mocks.reconciliationIssue()), assignee: input.assignee, assignedBy: '平台管理员', assignedAt: '2026-07-16 10:10:00', dueAt: '2026-07-17 10:10:00', version: 2 }));
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderOperations(client, ['/operations?reconcileOwner=unassigned&reconcileSla=overdue&reconcileIssue=issue-1']);
|
||||
|
||||
await waitFor(() => expect(mocks.reconciliationIssues).toHaveBeenCalledWith(expect.objectContaining({ owner: 'unassigned', sla: 'overdue' }), expect.any(AbortSignal)));
|
||||
const detail = await screen.findByRole('dialog', { name: '差异证据与处置' });
|
||||
expect(within(detail).getAllByText('责任与时限').length).toBeGreaterThan(0);
|
||||
const assigneeSelect = within(detail).getByRole('combobox', { name: '差异负责人' });
|
||||
fireEvent.click(assigneeSelect);
|
||||
fireEvent.click(await screen.findByText('定位运维组 · 3 项活跃'));
|
||||
fireEvent.click(within(detail).getByRole('button', { name: '分配责任' }));
|
||||
await waitFor(() => expect(mocks.assignReconciliationIssue).toHaveBeenCalledWith('issue-1', expect.objectContaining({ version: 1, assignee: '定位运维组' })));
|
||||
expect(screen.getByTestId('operations-location')).toHaveTextContent('reconcileOwner=unassigned');
|
||||
expect(screen.getByTestId('operations-location')).toHaveTextContent('reconcileSla=overdue');
|
||||
});
|
||||
|
||||
test('restores a large queue page size, exact owner filter and exports the full current filter', async () => {
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({ linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5, tdengineWritable: true, mysqlWritable: true, runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000 } });
|
||||
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 128, boundVehicles: 128, identityRequiredVehicles: 0, onlineVehicles: 121, sources: [] });
|
||||
mocks.reconciliationIssues.mockResolvedValue({ items: [await mocks.reconciliationIssue()], total: 128, limit: 100, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderOperations(client, ['/operations?reconcileOwner=%E5%AE%9A%E4%BD%8D%E8%BF%90%E7%BB%B4%E7%BB%84&reconcileLimit=100']);
|
||||
|
||||
expect(await screen.findByText('质量问题队列')).toBeInTheDocument();
|
||||
await waitFor(() => expect(mocks.reconciliationIssues).toHaveBeenCalledWith(expect.objectContaining({ owner: '定位运维组', limit: 100, offset: 0 }), expect.any(AbortSignal)));
|
||||
expect(screen.getByTestId('operations-location')).toHaveTextContent('reconcileOwner=%E5%AE%9A%E4%BD%8D%E8%BF%90%E7%BB%B4%E7%BB%84');
|
||||
expect(screen.getByTestId('operations-location')).toHaveTextContent('reconcileLimit=100');
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出当前筛选' }));
|
||||
await waitFor(() => expect(mocks.downloadReconciliationIssues).toHaveBeenCalledWith(expect.objectContaining({ owner: '定位运维组', status: 'active' })));
|
||||
});
|
||||
|
||||
test('restores reconciliation filters, page and selected issue from the URL', async () => {
|
||||
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: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderOperations(client, ['/operations?reconcileStatus=pending&reconcileSeverity=major&reconcilePage=2&reconcileIssue=issue-1']);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '差异证据与处置' })).toBeInTheDocument();
|
||||
await waitFor(() => expect(mocks.reconciliationIssues).toHaveBeenCalledWith(expect.objectContaining({ status: 'pending', severity: 'major', offset: 50, limit: 50 }), expect.any(AbortSignal)));
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭差异证据与处置' }));
|
||||
await waitFor(() => expect(screen.getByTestId('operations-location')).toHaveTextContent('/operations?reconcileStatus=pending&reconcileSeverity=major&reconcilePage=2'));
|
||||
});
|
||||
|
||||
test('renders only the compact mobile reconciliation surface and defers secondary controls', async () => {
|
||||
layout.mobile = true;
|
||||
seedSession();
|
||||
@@ -385,6 +498,41 @@ test('keeps health evidence visible when source readiness fails and retries that
|
||||
expect(mocks.sourceReadiness).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('opens a shared diagnostic URL directly and preserves candidate pagination in the route', async () => {
|
||||
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: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
|
||||
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'VIN001', plate: '粤A00001', protocols: ['JT808'], missingProtocols: [], sourceStatus: [], sourceCount: 1, onlineSourceCount: 1, online: true, lastSeen: '', bindingStatus: 'bound' }], total: 41, limit: 20, offset: 0 });
|
||||
mocks.vehicleSourceDiagnostic.mockResolvedValue({
|
||||
evidence: {
|
||||
vin: 'VIN001', plate: '粤A00001', mileageDate: '', recommendedLocationProtocol: 'JT808', recommendedLocationLabel: 'G7', locationConflict: false,
|
||||
locationSources: [], mileageSources: [], comparison: { locationMaxDistanceM: 0, totalMileageDeltaKm: 0, dailyMileageDeltaKm: 0, reportTimeDeltaSeconds: 0 }, asOf: ''
|
||||
},
|
||||
policy: { vin: 'VIN001', version: 3, updatedBy: 'system', updatedAt: '', audit: [] },
|
||||
recommendationReason: '共享链接已恢复来源诊断', refreshHint: '按需刷新'
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderOperations(client, ['/operations?view=diagnostic&diagnosticSearch=粤A00001&diagnosticVin=VIN001']);
|
||||
|
||||
expect(await screen.findByText('共享链接已恢复来源诊断')).toBeInTheDocument();
|
||||
expect(mocks.vehicleSourceDiagnostic).toHaveBeenCalledWith('VIN001', expect.any(AbortSignal));
|
||||
expect(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆')).toHaveValue('粤A00001');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '其他车辆' } });
|
||||
const nextPage = await screen.findByRole('button', { name: '下一页' });
|
||||
fireEvent.click(nextPage);
|
||||
await waitFor(() => expect((mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0] as URLSearchParams).get('offset')).toBe('20'));
|
||||
expect(screen.getByTestId('operations-location').textContent).toMatch(/view=diagnostic.*diagnosticSearch=.*diagnosticVin=VIN001.*diagnosticPage=2/);
|
||||
expect(mocks.vehicleCoverage).toHaveBeenLastCalledWith(expect.any(URLSearchParams), expect.any(AbortSignal));
|
||||
const latestParams = mocks.vehicleCoverage.mock.calls[mocks.vehicleCoverage.mock.calls.length - 1]?.[0] as URLSearchParams;
|
||||
expect(latestParams.get('keyword')).toBe('其他车辆');
|
||||
expect(latestParams.get('offset')).toBe('20');
|
||||
});
|
||||
|
||||
test('fuzzy searches a vehicle and renders all source diagnosis evidence', async () => {
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({
|
||||
@@ -411,7 +559,14 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
|
||||
recommendationReason: '当前推荐 G7', refreshHint: '下一次有效上报后重新选举'
|
||||
};
|
||||
mocks.vehicleSourceDiagnostic.mockResolvedValue(diagnostic);
|
||||
mocks.updateVehicleSourcePolicy.mockResolvedValue(diagnostic);
|
||||
mocks.updateVehicleSourcePolicy.mockResolvedValue({
|
||||
...diagnostic,
|
||||
evidence: {
|
||||
...diagnostic.evidence,
|
||||
locationSources: diagnostic.evidence.locationSources.map((source) => ({ ...source, providerOverride: 'G7s' }))
|
||||
},
|
||||
policy: { ...diagnostic.policy, version: 2 }
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderOperations(client);
|
||||
fireEvent.click(screen.getByRole('tab', { name: '单车诊断' }));
|
||||
@@ -461,6 +616,9 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
|
||||
priority: 20,
|
||||
remark: '保持当前优先级'
|
||||
}));
|
||||
const policyDialog = screen.getByRole('dialog', { name: '车辆来源策略' });
|
||||
expect(await within(policyDialog).findByRole('status')).toHaveTextContent('策略已保存,v2 已生效并重新计算推荐来源');
|
||||
expect(policyDialog).toBeInTheDocument();
|
||||
await waitFor(() => expect(mocks.vehicleSourceDiagnostic).toHaveBeenCalledWith('VIN001', expect.any(AbortSignal)));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IconAlertTriangle, IconPulse, IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
|
||||
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 { FormEvent, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { LinkHealth, SourceReadinessRow, VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
|
||||
@@ -16,6 +16,7 @@ import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
|
||||
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import ReconciliationCenter from './ReconciliationCenter';
|
||||
@@ -199,28 +200,34 @@ function SourcePolicySheet({ vin, source, diagnostic, editable, onClose, onSaved
|
||||
onSaved: (next: VehicleSourceDiagnostic) => void;
|
||||
}) {
|
||||
const mobileLayout = useMobileLayout();
|
||||
const currentSource = source ? diagnostic.evidence.locationSources.find((item) => sourceRowKey(item) === sourceRowKey(source)) ?? source : undefined;
|
||||
const [savedVersion, setSavedVersion] = useState<number>();
|
||||
useEffect(() => setSavedVersion(undefined), [source?.sourceRef]);
|
||||
return <WorkspaceSideSheet
|
||||
className="v2-source-policy-sidesheet"
|
||||
visible={Boolean(source)}
|
||||
visible={Boolean(currentSource)}
|
||||
ariaLabel="车辆来源策略"
|
||||
closeLabel="关闭车辆来源策略"
|
||||
placement={mobileLayout ? 'bottom' : 'right'}
|
||||
width={mobileLayout ? undefined : 460}
|
||||
height={mobileLayout ? 'min(76dvh, 660px)' : undefined}
|
||||
title={source?.sourceLabel ?? '来源策略'}
|
||||
description={source ? `${source.terminalLabel || source.protocol} · ${source.sourceKind === 'CANONICAL' ? '仅维护提供方' : '来源策略'}` : '来源策略'}
|
||||
badge={source ? source.enabled ? '策略已启用' : '策略已停用' : undefined}
|
||||
badgeColor={source?.enabled ? 'green' : 'grey'}
|
||||
summaryItems={source ? [
|
||||
{ label: '实时状态', value: source.online ? '在线' : '离线', detail: source.qualityReason || source.qualityStatus || '等待质量判定', tone: source.online ? 'success' : 'warning' },
|
||||
{ label: '当前优先级', value: source.priority, detail: source.enabled ? '参与来源选举' : '不参与来源选举', tone: source.enabled ? 'primary' : 'neutral' },
|
||||
{ label: '选举结论', value: source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源', detail: source.selectionReason || '等待选举说明' }
|
||||
title={currentSource?.sourceLabel ?? '来源策略'}
|
||||
description={currentSource ? `${currentSource.terminalLabel || currentSource.protocol} · ${currentSource.sourceKind === 'CANONICAL' ? '仅维护提供方' : '来源策略'}` : '来源策略'}
|
||||
badge={currentSource ? currentSource.enabled ? '策略已启用' : '策略已停用' : undefined}
|
||||
badgeColor={currentSource?.enabled ? 'green' : 'grey'}
|
||||
summaryItems={currentSource ? [
|
||||
{ label: '实时状态', value: currentSource.online ? '在线' : '离线', detail: currentSource.qualityReason || currentSource.qualityStatus || '等待质量判定', tone: currentSource.online ? 'success' : 'warning' },
|
||||
{ label: '当前优先级', value: currentSource.priority, detail: currentSource.enabled ? '参与来源选举' : '不参与来源选举', tone: currentSource.enabled ? 'primary' : 'neutral' },
|
||||
{ label: '选举结论', value: currentSource.recommended ? '当前推荐' : currentSource.selectedWithinProtocol ? '协议首选' : '备用来源', detail: currentSource.selectionReason || '等待选举说明' }
|
||||
] : []}
|
||||
onCancel={onClose}
|
||||
footerNote="策略修改需点击表单内的保存按钮后才会生效。"
|
||||
primaryAction={{ label: '完成', onClick: onClose }}
|
||||
>
|
||||
{source ? <div className="v2-source-policy-sheet-content"><SourcePolicyEditor vin={vin} source={source} diagnostic={diagnostic} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => { onSaved(next); onClose(); }} /></div> : null}
|
||||
{currentSource ? <div className="v2-source-policy-sheet-content">
|
||||
{savedVersion ? <p className="v2-source-policy-success" role="status">策略已保存,v{savedVersion} 已生效并重新计算推荐来源</p> : null}
|
||||
<SourcePolicyEditor vin={vin} source={currentSource} diagnostic={diagnostic} editable={editable && Boolean(currentSource.sourceRef)} onSaved={(next) => { setSavedVersion(next.policy.version); onSaved(next); }} />
|
||||
</div> : null}
|
||||
</WorkspaceSideSheet>;
|
||||
}
|
||||
|
||||
@@ -282,12 +289,25 @@ function SourceDiagnosticCards({ vin, sources, diagnostic, editable, onSaved }:
|
||||
function SourceDiagnosticWorkspace() {
|
||||
const queryClient = useQueryClient();
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const keyword = (searchParams.get('diagnosticSearch') || '').slice(0, 160);
|
||||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||||
const [selected, setSelected] = useState<VehicleCoverageRow>();
|
||||
const [candidateOffset, setCandidateOffset] = useState(0);
|
||||
const selectedVIN = (searchParams.get('diagnosticVin') || '').slice(0, 160);
|
||||
const requestedPage = Number(searchParams.get('diagnosticPage'));
|
||||
const candidatePage = Number.isSafeInteger(requestedPage) && requestedPage > 0 && requestedPage <= 100_000 ? requestedPage : 1;
|
||||
const candidateOffset = (candidatePage - 1) * 20;
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(() => mobileLayout);
|
||||
useEffect(() => setCandidateOffset(0), [deferredKeyword]);
|
||||
const setDiagnosticView = useCallback((patch: { search?: string; vin?: string; page?: number }) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set('view', 'diagnostic');
|
||||
const search = patch.search ?? keyword;
|
||||
const vin = patch.vin ?? selectedVIN;
|
||||
const page = patch.page ?? candidatePage;
|
||||
if (search.trim()) next.set('diagnosticSearch', search.slice(0, 160)); else next.delete('diagnosticSearch');
|
||||
if (vin) next.set('diagnosticVin', vin.slice(0, 160)); else next.delete('diagnosticVin');
|
||||
if (page > 1) next.set('diagnosticPage', String(page)); else next.delete('diagnosticPage');
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [candidatePage, keyword, searchParams, selectedVIN, setSearchParams]);
|
||||
const candidates = useQuery({
|
||||
queryKey: ['ops-source-candidates', deferredKeyword, candidateOffset],
|
||||
queryFn: ({ signal }) => api.vehicleCoverage(new URLSearchParams({ keyword: deferredKeyword, bindingStatus: 'bound', limit: '20', offset: String(candidateOffset) }), signal),
|
||||
@@ -297,15 +317,14 @@ function SourceDiagnosticWorkspace() {
|
||||
});
|
||||
const session = useQuery({ queryKey: ['ops-session'], queryFn: ({ signal }) => api.session(signal), staleTime: 60_000 });
|
||||
const diagnostic = useQuery({
|
||||
queryKey: ['ops-source-diagnostic', selected?.vin],
|
||||
queryFn: ({ signal }) => api.vehicleSourceDiagnostic(selected!.vin, signal),
|
||||
enabled: Boolean(selected?.vin),
|
||||
queryKey: ['ops-source-diagnostic', selectedVIN],
|
||||
queryFn: ({ signal }) => api.vehicleSourceDiagnostic(selectedVIN, signal),
|
||||
enabled: Boolean(selectedVIN),
|
||||
staleTime: 5_000,
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||
});
|
||||
const choose = (vehicle: VehicleCoverageRow) => {
|
||||
setSelected(vehicle);
|
||||
setKeyword(vehicle.plate || vehicle.vin);
|
||||
setDiagnosticView({ search: vehicle.plate || vehicle.vin, vin: vehicle.vin, page: 1 });
|
||||
if (mobileLayout) setFiltersCollapsed(true);
|
||||
};
|
||||
const chooseFirst = () => {
|
||||
@@ -317,17 +336,17 @@ function SourceDiagnosticWorkspace() {
|
||||
chooseFirst();
|
||||
};
|
||||
const clearSelection = () => {
|
||||
setKeyword('');
|
||||
setSelected(undefined);
|
||||
setDiagnosticView({ search: '', vin: '', page: 1 });
|
||||
setFiltersCollapsed(true);
|
||||
};
|
||||
const data = diagnostic.data;
|
||||
const editable = session.data?.role === 'admin';
|
||||
const selectedLabel = selected ? selected.plate || selected.vin : '';
|
||||
const candidate = candidates.data?.items.find((item) => item.vin === selectedVIN);
|
||||
const selectedLabel = selectedVIN ? data?.evidence.plate || candidate?.plate || keyword || selectedVIN : '';
|
||||
const browsingCandidates = Boolean(deferredKeyword) && deferredKeyword !== selectedLabel;
|
||||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||
const closeMobileFilters = () => {
|
||||
if (selectedLabel) setKeyword(selectedLabel);
|
||||
if (selectedLabel) setDiagnosticView({ search: selectedLabel, page: 1 });
|
||||
setFiltersCollapsed(true);
|
||||
};
|
||||
const filterStatus = selectedLabel
|
||||
@@ -347,7 +366,7 @@ function SourceDiagnosticWorkspace() {
|
||||
aria-label="按车牌或 VIN 搜索诊断车辆"
|
||||
prefix={<IconSearch />}
|
||||
value={keyword}
|
||||
onChange={setKeyword}
|
||||
onChange={(value) => setDiagnosticView({ search: value, page: 1 })}
|
||||
placeholder="输入车牌或 VIN,支持模糊搜索"
|
||||
/>;
|
||||
const sourceCandidateList = browsingCandidates ? <VehicleCandidateList
|
||||
@@ -359,7 +378,7 @@ function SourceDiagnosticWorkspace() {
|
||||
actionLabel="诊断"
|
||||
showProtocols
|
||||
onSelect={choose}
|
||||
footer={(candidates.data?.total ?? 0) > 20 ? <><span>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页</span><div><Button theme="light" size="small" disabled={candidateOffset === 0} onClick={() => setCandidateOffset(Math.max(0, candidateOffset - 20))}>上一页</Button><Button theme="light" size="small" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setCandidateOffset(candidateOffset + 20)}>下一页</Button></div></> : undefined}
|
||||
footer={(candidates.data?.total ?? 0) > 20 ? <><span>第 {candidatePage} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页</span><div><Button theme="light" size="small" disabled={candidatePage === 1} onClick={() => setDiagnosticView({ page: Math.max(1, candidatePage - 1) })}>上一页</Button><Button theme="light" size="small" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setDiagnosticView({ page: candidatePage + 1 })}>下一页</Button></div></> : undefined}
|
||||
/> : null;
|
||||
const sourceSummaryItems: WorkspaceQueueMetricRailItem[] = data ? [
|
||||
{
|
||||
@@ -378,7 +397,7 @@ function SourceDiagnosticWorkspace() {
|
||||
},
|
||||
{
|
||||
label: '车辆',
|
||||
value: data.evidence.plate || selected?.plate || '未绑定车牌',
|
||||
value: data.evidence.plate || candidate?.plate || '未绑定车牌',
|
||||
note: data.evidence.vin,
|
||||
tone: 'neutral',
|
||||
emphasis: 'secondary'
|
||||
@@ -412,7 +431,7 @@ function SourceDiagnosticWorkspace() {
|
||||
title="选择诊断车辆"
|
||||
description="按车牌或 VIN 搜索已绑定车辆"
|
||||
onCancel={closeMobileFilters}
|
||||
secondaryAction={{ label: '清除选择', disabled: !selected && !keyword, onClick: clearSelection }}
|
||||
secondaryAction={{ label: '清除选择', disabled: !selectedVIN && !keyword, onClick: clearSelection }}
|
||||
primaryAction={{ label: '诊断首个结果', disabled: !browsingCandidates || !candidates.data?.items.length, onClick: chooseFirst }}
|
||||
>
|
||||
<form className="v2-source-mobile-filter-form" onSubmit={submit}>
|
||||
@@ -446,13 +465,13 @@ function SourceDiagnosticWorkspace() {
|
||||
<Card className="v2-source-diagnostic" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="单车多来源诊断"
|
||||
description={selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '协议、同协议多终端、推荐原因与可审计策略'}
|
||||
meta={<Tag color={data ? 'blue' : 'grey'} type="light" size="small">{data ? `${data.evidence.locationSources.length} 个来源` : selected ? '正在读取证据' : '等待选择车辆'}</Tag>}
|
||||
actions={selected ? <Button theme="light" icon={<IconRefresh />} onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}>刷新该车</Button> : null}
|
||||
description={data ? `${data.evidence.plate || '未绑定车牌'} · ${data.evidence.vin}` : selectedVIN ? `${selectedLabel} · ${selectedVIN}` : '协议、同协议多终端、推荐原因与可审计策略'}
|
||||
meta={<Tag color={data ? 'blue' : 'grey'} type="light" size="small">{data ? `${data.evidence.locationSources.length} 个来源` : selectedVIN ? '正在读取证据' : '等待选择车辆'}</Tag>}
|
||||
actions={selectedVIN ? <Button theme="light" icon={<IconRefresh />} onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}>刷新该车</Button> : null}
|
||||
/>
|
||||
{diagnostic.isError ? <InlineError message={diagnostic.error.message} onRetry={() => diagnostic.refetch()} /> : null}
|
||||
{!selected ? <PanelEmpty className="v2-source-empty" tone="primary" icon={<IconSearch />} title="先选择一辆车" description="将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。" /> : null}
|
||||
{selected && diagnostic.isPending ? <PanelLoading className="v2-source-loading" title="正在读取来源证据" description="只查询当前车辆,不会扫描整车队。" /> : null}
|
||||
{!selectedVIN ? <PanelEmpty className="v2-source-empty" tone="primary" icon={<IconSearch />} title="先选择一辆车" description="将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。" /> : null}
|
||||
{selectedVIN && diagnostic.isPending ? <PanelLoading className="v2-source-loading" title="正在读取来源证据" description="只查询当前车辆,不会扫描整车队。" /> : null}
|
||||
{data ? <>
|
||||
<section className="v2-source-summary-region" role="group" aria-label="车辆来源诊断概览">
|
||||
<WorkspaceMetricRail
|
||||
@@ -575,6 +594,7 @@ export default function OperationsPage() {
|
||||
color: healthEvidencePending ? 'grey' as const : readinessUnavailable || sourceErrorCount > 0 ? 'red' as const : healthIssueCount > 0 ? 'orange' as const : 'green' as const
|
||||
};
|
||||
return <div className={`v2-ops-page is-${workspace}`}>
|
||||
<MonitorReturnBar />
|
||||
<WorkspaceCommandBar
|
||||
className="v2-ops-command-bar"
|
||||
ariaLabel="运维质量操作"
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { IconAlertTriangle, IconChevronDown, IconChevronRight, IconChevronUp, IconClose, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Input, RadioGroup, Select, Table, Tag, TextArea } from '@douyinfe/semi-ui';
|
||||
import { IconAlertTriangle, IconBox, IconChevronDown, IconChevronRight, IconChevronUp, IconClose, IconDownload, IconHistory, IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Checkbox, Input, RadioGroup, Select, Table, Tag, TextArea, Toast } from '@douyinfe/semi-ui';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useDeferredValue, useEffect, useId, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { ReconciliationIssue, ReconciliationSummary } from '../../api/types';
|
||||
import type { ReconciliationAssignee, ReconciliationBatchActionResult, ReconciliationIssue, ReconciliationSummary } from '../../api/types';
|
||||
import { downloadBlob } from '../domain/download';
|
||||
import { downloadReconciliationBatchResult } from '../domain/reconciliationExport';
|
||||
import { InlineError, PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { PlatformTime } from '../shared/PlatformTime';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||
import { WorkspaceDialog } from '../shared/WorkspaceDialog';
|
||||
import { WorkspaceMetricRail } from '../shared/WorkspaceMetricRail';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||||
@@ -18,6 +23,7 @@ import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
const DESKTOP_PAGE_SIZE = 50;
|
||||
const MOBILE_PAGE_SIZE = 20;
|
||||
const activeStatuses = new Set(['pending', 'confirmed_source_a', 'confirmed_source_b']);
|
||||
const archiveableStatuses = new Set(['fixed', 'no_action', 'recovered']);
|
||||
function statusLabel(status: string) {
|
||||
return {
|
||||
pending: '待处理',
|
||||
@@ -43,6 +49,38 @@ function ReconciliationStatusTag({ status }: { status: string }) {
|
||||
return <Tag className={`v2-reconcile-status is-${status}`} color={color} type="light" size="small">{statusLabel(status)}</Tag>;
|
||||
}
|
||||
|
||||
function reconciliationDueState(issue: ReconciliationIssue) {
|
||||
if (!issue.dueAt || !activeStatuses.has(issue.status)) return 'none';
|
||||
const due = new Date(issue.dueAt.includes('T') ? issue.dueAt : `${issue.dueAt.replace(' ', 'T')}Z`).getTime();
|
||||
if (!Number.isFinite(due)) return 'none';
|
||||
if (due < Date.now()) return 'overdue';
|
||||
if (due < Date.now() + 8 * 60 * 60 * 1000) return 'due_soon';
|
||||
return 'scheduled';
|
||||
}
|
||||
|
||||
function ReconciliationOwnership({ item, compact = false }: { item: ReconciliationIssue; compact?: boolean }) {
|
||||
const dueState = reconciliationDueState(item);
|
||||
return <span className={`v2-reconcile-ownership is-${dueState}${compact ? ' is-compact' : ''}`}>
|
||||
<strong>{item.assignee || '未分配'}</strong>
|
||||
<small>{item.dueAt ? <>{dueState === 'overdue' ? '已超时 · ' : dueState === 'due_soon' ? '即将到期 · ' : '期限 · '}<PlatformTime className="v2-ops-time" value={item.dueAt} sourceZone="utc" /></> : '尚未设置处理期限'}</small>
|
||||
</span>;
|
||||
}
|
||||
|
||||
function ReconciliationMobileContent({ item, batchSelected = false }: { item: ReconciliationIssue; batchSelected?: boolean }) {
|
||||
return <span className="v2-reconcile-mobile-content">
|
||||
<header><strong>{item.plate || '平台级差异'}</strong><span><ReconciliationSeverityTag severity={item.severity} /><ReconciliationStatusTag status={item.status} /></span></header>
|
||||
<p>{item.title}</p>
|
||||
<span className="v2-reconcile-mobile-meta"><span>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</span>{item.archivedAt ? <span className="v2-reconcile-ownership is-compact"><strong>{item.archivedBy || '未知管理员'}</strong><small><PlatformTime value={item.archivedAt} sourceZone="utc" /></small></span> : <ReconciliationOwnership item={item} compact />}</span>
|
||||
<footer><span>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</span><span>{batchSelected ? '已选择' : item.archivedAt ? '审计证据' : '证据与处置'}{batchSelected ? <IconTickCircle /> : <IconChevronRight />}</span></footer>
|
||||
</span>;
|
||||
}
|
||||
|
||||
const reconciliationRuleCodes = [
|
||||
'DUPLICATE_PLATE', 'DUPLICATE_PHONE', 'UNBOUND_SOURCE', 'SOURCE_MISSING', 'POSITION_DRIFT',
|
||||
'MILEAGE_REVERSE', 'MILEAGE_JUMP', 'MILEAGE_SOURCE_DIVERGENCE', 'FLEET_COUNT_MISMATCH',
|
||||
'BUSINESS_SCOPE_UNBOUND', 'AUTH_SCOPE_BUSINESS_MISMATCH'
|
||||
];
|
||||
|
||||
function ruleLabel(rule: string) {
|
||||
return {
|
||||
DUPLICATE_PLATE: '重复车牌',
|
||||
@@ -70,6 +108,48 @@ function categoryLabel(category: string) {
|
||||
}[category] ?? category;
|
||||
}
|
||||
|
||||
function useReconciliationAssigneeDirectory() {
|
||||
return useQuery({
|
||||
queryKey: ['reconciliation-assignees'],
|
||||
queryFn: ({ signal }) => api.reconciliationAssignees('', signal),
|
||||
staleTime: 60_000,
|
||||
gcTime: QUERY_MEMORY.optionGcTime
|
||||
});
|
||||
}
|
||||
|
||||
function reconciliationAssigneeOptions(items: ReconciliationAssignee[] = []) {
|
||||
return items.map((item) => ({
|
||||
value: item.name,
|
||||
label: `${item.name}${item.username ? ` · ${item.username}` : ''}${item.current ? ' · 当前账号' : item.activeCount ? ` · ${item.activeCount} 项活跃` : ''}`
|
||||
}));
|
||||
}
|
||||
|
||||
function ReconciliationAssigneeSelect({ value, onChange, ariaLabel, placeholder = '选择负责人或运维组' }: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
ariaLabel: string;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const labelID = `reconcile-assignee-${useId().replace(/:/g, '')}`;
|
||||
const directory = useReconciliationAssigneeDirectory();
|
||||
const options = useMemo(() => reconciliationAssigneeOptions(directory.data), [directory.data]);
|
||||
return <>
|
||||
<span className="v2-sr-only" id={labelID}>{ariaLabel}</span>
|
||||
<Select
|
||||
aria-labelledby={labelID}
|
||||
value={value || undefined}
|
||||
filter
|
||||
showClear
|
||||
loading={directory.isPending}
|
||||
emptyContent={directory.isError ? '负责人目录加载失败' : '没有匹配的负责人'}
|
||||
optionList={options}
|
||||
placeholder={placeholder}
|
||||
onChange={(next) => onChange(String(next ?? ''))}
|
||||
renderSelectedItem={(option: Record<string, unknown>) => String(option.value ?? '')}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
const evidenceLabels: Record<string, string> = {
|
||||
date: '统计日期',
|
||||
protocol: '协议来源',
|
||||
@@ -189,14 +269,23 @@ function ReconciliationTrend({ data, maxTrend }: {
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: ReconciliationIssue; onClose: () => void; sheet?: boolean }) {
|
||||
function ReconciliationDetail({ issue, onClose, onLifecycle, administrator, sheet = false }: {
|
||||
issue: ReconciliationIssue;
|
||||
onClose: () => void;
|
||||
onLifecycle: (issue: ReconciliationIssue, archived: boolean) => void;
|
||||
administrator: boolean;
|
||||
sheet?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState(issue.status === 'recovered' ? 'pending' : issue.status);
|
||||
const [note, setNote] = useState(issue.resolutionNote ?? '');
|
||||
const [assignee, setAssignee] = useState(issue.assignee ?? '');
|
||||
const [dueHours, setDueHours] = useState('24');
|
||||
useEffect(() => {
|
||||
setStatus(issue.status === 'recovered' ? 'pending' : issue.status);
|
||||
setNote(issue.resolutionNote ?? '');
|
||||
}, [issue.id, issue.resolutionNote, issue.status]);
|
||||
setAssignee(issue.assignee ?? '');
|
||||
}, [issue.assignee, issue.id, issue.resolutionNote, issue.status]);
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.updateReconciliationIssue(issue.id, { version: issue.version, status, note: note.trim() }),
|
||||
onSuccess: async (updated) => {
|
||||
@@ -207,9 +296,21 @@ function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: Reconc
|
||||
]);
|
||||
}
|
||||
});
|
||||
const assign = useMutation({
|
||||
mutationFn: () => api.assignReconciliationIssue(issue.id, {
|
||||
version: issue.version,
|
||||
assignee: assignee.trim(),
|
||||
dueAt: new Date(Date.now() + Number(dueHours) * 60 * 60 * 1000).toISOString()
|
||||
}),
|
||||
onSuccess: async (updated) => {
|
||||
queryClient.setQueryData(['reconciliation-detail', issue.id], updated);
|
||||
await queryClient.invalidateQueries({ queryKey: ['reconciliation-issues'] });
|
||||
}
|
||||
});
|
||||
const requiresNote = status !== 'pending';
|
||||
const evidenceEntries = Object.entries(issue.evidence ?? {});
|
||||
const statusOptions = reviewStatusOptions(issue);
|
||||
const archived = Boolean(issue.archivedAt);
|
||||
return <Card className={`v2-reconcile-detail${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }} aria-label="差异证据与处置">
|
||||
{!sheet ? <WorkspacePanelHeader
|
||||
className="v2-reconcile-detail-heading"
|
||||
@@ -225,9 +326,15 @@ function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: Reconc
|
||||
<div><small>时间</small><strong><PlatformTime className="v2-ops-time" value={issue.lastSeenAt} sourceZone="utc" /></strong><span>首次 <PlatformTime className="v2-ops-time" value={issue.firstSeenAt} sourceZone="utc" /></span></div>
|
||||
</section> : null}
|
||||
<section className="v2-reconcile-decision-guide">
|
||||
<IconAlertTriangle />
|
||||
<div><strong>复核提示</strong><p>{reviewGuidance(issue)}</p></div>
|
||||
{archived ? <IconHistory /> : <IconAlertTriangle />}
|
||||
<div><strong>{archived ? '审计归档' : '复核提示'}</strong><p>{archived ? '该记录保留原始证据、结论和完整处理履历;归档范围只读,不参与日常责任分配和处置。' : reviewGuidance(issue)}</p></div>
|
||||
</section>
|
||||
{archived ? <section className="v2-reconcile-archive-receipt" aria-label="归档回执">
|
||||
<span><small>归档人</small><strong>{issue.archivedBy || '未知管理员'}</strong></span>
|
||||
<span><small>归档时间</small><strong><PlatformTime className="v2-ops-time" value={issue.archivedAt} sourceZone="utc" /></strong></span>
|
||||
<p><small>归档原因</small><strong>{issue.archiveReason || '未记录原因'}</strong></p>
|
||||
{administrator ? <Button theme="solid" type="primary" icon={<IconRefresh />} onClick={() => onLifecycle(issue, false)}>恢复到当前队列</Button> : <Tag color="grey" type="light">只读权限</Tag>}
|
||||
</section> : null}
|
||||
<p className="v2-reconcile-summary"><strong>命中说明</strong><span>{issue.summary}</span></p>
|
||||
<Card className="v2-reconcile-evidence" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
@@ -241,6 +348,18 @@ function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: Reconc
|
||||
<dd><EvidenceValue name={key} value={value} /></dd>
|
||||
</div>)}</dl>
|
||||
</Card>
|
||||
{!archived ? <><Card className="v2-reconcile-assignment" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title="责任与时限" description="交接会写入履历,并按当前版本避免覆盖他人更新" meta={issue.assignee ? '已分配' : '待认领'} />
|
||||
<div className="v2-reconcile-assignment-current"><ReconciliationOwnership item={issue} /><span>{issue.assignedBy ? `由 ${issue.assignedBy} 分配` : '尚未形成责任交接'}</span></div>
|
||||
<div className="v2-reconcile-assignment-form">
|
||||
<label><span>负责人</span><ReconciliationAssigneeSelect ariaLabel="差异负责人" value={assignee} onChange={setAssignee} /></label>
|
||||
<label><span id="reconcile-due-hours-label">处理时限</span><Select aria-labelledby="reconcile-due-hours-label" value={dueHours} onChange={(value) => setDueHours(String(value))} optionList={[
|
||||
{ value: '4', label: '4 小时内' }, { value: '8', label: '8 小时内' }, { value: '24', label: '24 小时内' }, { value: '72', label: '3 天内' }
|
||||
]} /></label>
|
||||
<Button theme="solid" type="primary" disabled={!activeStatuses.has(issue.status) || !assignee.trim() || assign.isPending} loading={assign.isPending} onClick={() => assign.mutate()}>{activeStatuses.has(issue.status) ? issue.assignee ? '重新交接' : '分配责任' : '差异已结束'}</Button>
|
||||
</div>
|
||||
{assign.isError ? <p role="alert">{assign.error instanceof Error ? assign.error.message : '责任交接失败'}</p> : null}
|
||||
</Card>
|
||||
<Card className="v2-reconcile-review" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title="复核结论" description="结论会写入处理履历,不改写原始证据" meta={`版本 v${issue.version}`} />
|
||||
<label><span id="reconcile-review-status-label">选择结论</span><RadioGroup aria-labelledby="reconcile-review-status-label" type="button" buttonSize="small" value={status} options={statusOptions} onChange={(event) => setStatus(String(event.target.value))} /></label>
|
||||
@@ -248,33 +367,304 @@ function ReconciliationDetail({ issue, onClose, sheet = false }: { issue: Reconc
|
||||
<Button theme="solid" disabled={save.isPending || (requiresNote && !note.trim())} loading={save.isPending} onClick={() => save.mutate()}>保存结论并记录履历</Button>
|
||||
{save.isError ? <p role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</p> : null}
|
||||
</Card>
|
||||
{administrator && archiveableStatuses.has(issue.status) ? <section className="v2-reconcile-archive-entry">
|
||||
<span><IconBox /><span><strong>整理到审计归档</strong><small>保留原始证据、结论和履历,从日常队列移出;之后仍可恢复。</small></span></span>
|
||||
<Button theme="light" type="tertiary" icon={<IconBox />} onClick={() => onLifecycle(issue, true)}>移入审计归档</Button>
|
||||
</section> : null}</> : null}
|
||||
<Card className="v2-reconcile-actions" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title="处理履历" meta={`${issue.actions?.length ?? 0} 条`} />
|
||||
{issue.actions?.length ? <ol>{issue.actions.map((action) => <li key={action.id}><i /><div><strong>{statusLabel(action.toStatus)}</strong><p>{action.note || action.action}</p><span>{action.actor} · <PlatformTime className="v2-ops-time" value={action.createdAt} sourceZone="utc" /></span></div></li>)}</ol> : <p>暂无处理记录。</p>}
|
||||
{issue.actions?.length ? <ol>{issue.actions.map((action) => <li key={action.id}><i /><div><strong>{action.action === 'assign' ? '责任交接' : action.action === 'archive' ? '移入审计归档' : action.action === 'restore' ? '恢复到当前队列' : statusLabel(action.toStatus)}</strong><p>{action.note || action.action}</p><span>{action.actor} · <PlatformTime className="v2-ops-time" value={action.createdAt} sourceZone="utc" /></span></div></li>)}</ol> : <p>暂无处理记录。</p>}
|
||||
</Card>
|
||||
</div>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function ReconciliationLifecycleDialog({ issues, archived, batch, visible, onCancel, onComplete }: {
|
||||
issues: ReconciliationIssue[];
|
||||
archived: boolean;
|
||||
batch: boolean;
|
||||
visible: boolean;
|
||||
onCancel: () => void;
|
||||
onComplete: (result: ReconciliationBatchActionResult) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [reason, setReason] = useState('');
|
||||
const [result, setResult] = useState<ReconciliationBatchActionResult>();
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setReason('');
|
||||
setResult(undefined);
|
||||
}, [visible]);
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (batch) {
|
||||
return api.batchSetReconciliationIssuesArchived(archived, {
|
||||
items: issues.map((issue) => ({ id: issue.id, version: issue.version })),
|
||||
reason: reason.trim()
|
||||
});
|
||||
}
|
||||
const updated = await api.setReconciliationIssueArchived(issues[0].id, archived, {
|
||||
version: issues[0].version,
|
||||
reason: reason.trim()
|
||||
});
|
||||
return { requested: 1, succeeded: [updated], skipped: [] } satisfies ReconciliationBatchActionResult;
|
||||
},
|
||||
onSuccess: async (nextResult) => {
|
||||
setResult(nextResult);
|
||||
nextResult.succeeded.forEach((issue) => queryClient.setQueryData(['reconciliation-detail', issue.id], issue));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['reconciliation-summary'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['reconciliation-issues'] })
|
||||
]);
|
||||
}
|
||||
});
|
||||
const successCount = result?.succeeded.length ?? 0;
|
||||
const skippedCount = result?.skipped.length ?? 0;
|
||||
const title = archived ? batch ? `归档 ${issues.length} 条已结束差异` : '移入审计归档' : batch ? `恢复 ${issues.length} 条归档差异` : '恢复到当前队列';
|
||||
const description = archived
|
||||
? '原始证据、结论和履历将完整保留;记录会从日常队列移入只读审计范围。'
|
||||
: '记录将回到当前队列并保持原结论,不会自动重新分配责任或改写证据。';
|
||||
return <WorkspaceDialog
|
||||
visible={visible}
|
||||
ariaLabel={result ? `${archived ? '归档' : '恢复'}差异结果` : title}
|
||||
closeLabel={`关闭${title}`}
|
||||
className="v2-reconcile-lifecycle-dialog"
|
||||
title={result ? `${archived ? '归档' : '恢复'}结果` : title}
|
||||
description={result ? '逐项版本校验结果已写入处理履历。' : description}
|
||||
icon={archived ? <IconBox /> : <IconRefresh />}
|
||||
badge={result ? `${successCount} 成功 · ${skippedCount} 跳过` : archived ? '可逆归档' : '安全恢复'}
|
||||
badgeColor={result ? skippedCount ? 'orange' : 'green' : archived ? 'orange' : 'blue'}
|
||||
width={620}
|
||||
closeOnEsc={!save.isPending}
|
||||
maskClosable={!save.isPending}
|
||||
closeDisabled={save.isPending}
|
||||
onCancel={() => result ? onComplete(result) : onCancel()}
|
||||
footer={<>
|
||||
<span className="v2-workspace-dialog-footer-note"><IconHistory aria-hidden="true" />原因、操作者和当前版本都会写入不可变履历。</span>
|
||||
<span className="v2-workspace-dialog-footer-actions">
|
||||
{!result ? <Button type="tertiary" disabled={save.isPending} onClick={onCancel}>继续核对</Button> : null}
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
loading={save.isPending}
|
||||
disabled={!result && (issues.length === 0 || reason.trim().length < 4)}
|
||||
onClick={() => result ? onComplete(result) : save.mutate()}
|
||||
>{result ? skippedCount ? '保留失败项并返回' : '返回队列' : `${archived ? '确认归档' : '确认恢复'} ${issues.length} 条`}</Button>
|
||||
</span>
|
||||
</>}
|
||||
>
|
||||
{result ? <div className="v2-reconcile-lifecycle-result" role="status">
|
||||
<section className={skippedCount ? 'has-skipped' : 'is-success'}>
|
||||
{skippedCount ? <IconAlertTriangle /> : <IconTickCircle />}
|
||||
<span><strong>{skippedCount ? '整理已完成,部分记录被跳过' : '整理已全部完成'}</strong><small>成功 {successCount} 条,跳过 {skippedCount} 条;跳过项未发生变化。</small></span>
|
||||
</section>
|
||||
{result.skipped.length ? <ul>{result.skipped.map((item) => <li key={item.id}><strong>{item.id}</strong><span>{item.message}</span></li>)}</ul> : null}
|
||||
</div> : <div className="v2-reconcile-lifecycle-form">
|
||||
<div className="v2-workspace-dialog-summary" role="list" aria-label="生命周期操作影响">
|
||||
<span className="is-primary" role="listitem"><small>影响记录</small><strong>{issues.length}</strong><em>单次最多 20 条</em></span>
|
||||
<span className={archived ? 'is-warning' : 'is-success'} role="listitem"><small>目标范围</small><strong>{archived ? '审计归档' : '当前队列'}</strong><em>{archived ? '只读、可恢复' : '保留原结论'}</em></span>
|
||||
<span className="is-neutral" role="listitem"><small>证据处理</small><strong>完整保留</strong><em>原始证据与履历不删除</em></span>
|
||||
</div>
|
||||
<label><span>{archived ? '归档原因' : '恢复原因'}(必填)</span><TextArea
|
||||
aria-label={`${archived ? '归档' : '恢复'}原因(必填)`}
|
||||
value={reason}
|
||||
maxCount={500}
|
||||
autosize={{ minRows: 4, maxRows: 7 }}
|
||||
onChange={setReason}
|
||||
placeholder={archived ? '例如:已完成复核并超过日常关注周期,转入长期审计留存' : '例如:需要重新核对责任归属和最新证据'}
|
||||
/></label>
|
||||
<p className="v2-reconcile-lifecycle-hint">{reason.trim().length < 4 ? '至少填写 4 个字符,便于后续审计理解本次整理原因。' : description}</p>
|
||||
{save.isError ? <p role="alert">{save.error instanceof Error ? save.error.message : '差异整理失败'}</p> : null}
|
||||
</div>}
|
||||
</WorkspaceDialog>;
|
||||
}
|
||||
|
||||
function ReconciliationBatchAssignment({ issues, visible, mobileLayout, onCancel, onComplete }: {
|
||||
issues: ReconciliationIssue[]; visible: boolean; mobileLayout: boolean; onCancel: () => void; onComplete: (result: ReconciliationBatchActionResult) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [assignee, setAssignee] = useState('');
|
||||
const [dueHours, setDueHours] = useState('24');
|
||||
const [result, setResult] = useState<ReconciliationBatchActionResult>();
|
||||
useEffect(() => { if (visible) { setAssignee(''); setDueHours('24'); setResult(undefined); } }, [visible]);
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.batchAssignReconciliationIssues({
|
||||
items: issues.map((issue) => ({ id: issue.id, version: issue.version })), assignee: assignee.trim(),
|
||||
dueAt: new Date(Date.now() + Number(dueHours) * 60 * 60 * 1000).toISOString()
|
||||
}),
|
||||
onSuccess: async (nextResult) => { setResult(nextResult); await queryClient.invalidateQueries({ queryKey: ['reconciliation-issues'] }); }
|
||||
});
|
||||
const successCount = result?.succeeded.length ?? 0;
|
||||
const skippedCount = result?.skipped.length ?? 0;
|
||||
return <WorkspaceSideSheet className="v2-reconcile-batch-sidesheet" variant="action" visible={visible} placement={mobileLayout ? 'bottom' : 'right'} width={mobileLayout ? undefined : 560} height={mobileLayout ? 'min(92dvh, 820px)' : undefined}
|
||||
ariaLabel={result ? '批量交接结果' : '确认批量交接'} closeLabel={result ? '关闭批量交接结果' : '关闭批量交接确认'}
|
||||
title={result ? '批量交接结果' : `交接 ${issues.length} 项差异`} description={result ? '成功项已获得统一负责人和期限,冲突项保留原责任状态。' : '统一分配负责人和处理期限,每项独立校验版本。'}
|
||||
badge={result ? `${successCount} 成功 · ${skippedCount} 跳过` : '责任交接'} badgeColor={result ? skippedCount ? 'orange' : 'green' : 'blue'}
|
||||
summaryItems={result ? [
|
||||
{ label: '交接成功', value: successCount, detail: '已写入履历', tone: 'success' }, { label: '跳过', value: skippedCount, detail: skippedCount ? '保留原责任状态' : '没有跳过', tone: skippedCount ? 'warning' : 'success' }
|
||||
] : [
|
||||
{ label: '影响记录', value: issues.length, detail: '最多 20 项', tone: 'primary' }, { label: '负责人', value: assignee || '待填写', detail: `${dueHours} 小时内`, tone: assignee ? 'success' : 'warning' }
|
||||
]}
|
||||
footerNote={result ? '筛选、页码与失败项选择均会保留。' : '仅更新责任字段,不改变差异结论或原始证据。'}
|
||||
secondaryActions={result ? [{ label: '导出结果', ariaLabel: '导出结果', icon: <IconDownload />, onClick: () => downloadReconciliationBatchResult('assignment', result, issues) }] : [{ label: '继续选择', onClick: onCancel }]}
|
||||
primaryAction={result ? { label: skippedCount ? '保留失败项并返回' : '返回队列', onClick: () => onComplete(result) } : { label: `确认交接 ${issues.length} 项`, onClick: () => save.mutate(), loading: save.isPending, disabled: !issues.length || !assignee.trim() }}
|
||||
onCancel={() => result ? onComplete(result) : onCancel()}>
|
||||
{result ? <div className="v2-reconcile-batch-result"><section className={`v2-reconcile-batch-result-summary${skippedCount ? ' has-skipped' : ''}`} role="status">{skippedCount ? <IconAlertTriangle /> : <IconTickCircle />}<span><strong>{skippedCount ? '批量交接完成,部分记录被跳过' : '批量交接已全部完成'}</strong><small>成功 {successCount} 项,跳过 {skippedCount} 项。</small></span></section></div> : <div className="v2-reconcile-batch-review">
|
||||
<section className="v2-reconcile-batch-impact"><IconAlertTriangle /><span><strong>形成明确责任</strong><small>选中记录将交给同一负责人;版本冲突项不会覆盖他人的更新。</small></span></section>
|
||||
<label><span>统一负责人</span><ReconciliationAssigneeSelect ariaLabel="批量交接负责人" value={assignee} onChange={setAssignee} /></label>
|
||||
<label><span id="reconcile-batch-due-label">统一处理时限</span><Select aria-labelledby="reconcile-batch-due-label" value={dueHours} onChange={(value) => setDueHours(String(value))} optionList={[{ value: '4', label: '4 小时内' }, { value: '8', label: '8 小时内' }, { value: '24', label: '24 小时内' }, { value: '72', label: '3 天内' }]} /></label>
|
||||
{save.isError ? <p className="v2-reconcile-batch-error" role="alert">{save.error instanceof Error ? save.error.message : '批量交接失败'}</p> : null}
|
||||
<section className="v2-reconcile-batch-preview"><header><strong>本次选中</strong><span>{issues.length} 项 · 独立校验版本</span></header><ol>{issues.map((issue) => <li key={issue.id}><span><strong>{issue.plate || '平台级差异'}</strong><small>{issue.title}</small></span><Tag type="light" size="small">v{issue.version}</Tag></li>)}</ol></section>
|
||||
</div>}
|
||||
</WorkspaceSideSheet>;
|
||||
}
|
||||
|
||||
function ReconciliationBatchReview({ issues, visible, mobileLayout, onCancel, onComplete }: {
|
||||
issues: ReconciliationIssue[];
|
||||
visible: boolean;
|
||||
mobileLayout: boolean;
|
||||
onCancel: () => void;
|
||||
onComplete: (result: ReconciliationBatchActionResult) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState<'pending' | 'no_action' | 'fixed'>('fixed');
|
||||
const [note, setNote] = useState('');
|
||||
const [result, setResult] = useState<ReconciliationBatchActionResult>();
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setStatus('fixed');
|
||||
setNote('');
|
||||
setResult(undefined);
|
||||
}, [visible]);
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.batchUpdateReconciliationIssues({
|
||||
items: issues.map((issue) => ({ id: issue.id, version: issue.version })),
|
||||
status,
|
||||
note: note.trim()
|
||||
}),
|
||||
onSuccess: async (nextResult) => {
|
||||
setResult(nextResult);
|
||||
nextResult.succeeded.forEach((issue) => queryClient.setQueryData(['reconciliation-detail', issue.id], issue));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['reconciliation-summary'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['reconciliation-issues'] })
|
||||
]);
|
||||
}
|
||||
});
|
||||
const requiresNote = status !== 'pending';
|
||||
const successCount = result?.succeeded.length ?? 0;
|
||||
const skippedCount = result?.skipped.length ?? 0;
|
||||
return <WorkspaceSideSheet
|
||||
className="v2-reconcile-batch-sidesheet"
|
||||
variant="action"
|
||||
visible={visible}
|
||||
placement={mobileLayout ? 'bottom' : 'right'}
|
||||
width={mobileLayout ? undefined : 560}
|
||||
height={mobileLayout ? 'min(92dvh, 820px)' : undefined}
|
||||
ariaLabel={result ? '批量处置结果' : '确认批量处置'}
|
||||
closeLabel={result ? '关闭批量处置结果' : '关闭批量处置确认'}
|
||||
title={result ? '批量处置结果' : `确认处置 ${issues.length} 项差异`}
|
||||
description={result ? '逐项记录成功与跳过原因,失败项可保留后继续处理。' : '同一结论将逐项写入履历;单项冲突不会阻断其他记录。'}
|
||||
badge={result ? `${successCount} 成功 · ${skippedCount} 跳过` : '写入处理履历'}
|
||||
badgeColor={result ? skippedCount ? 'orange' : 'green' : 'blue'}
|
||||
summaryItems={result ? [
|
||||
{ label: '请求处置', value: result.requested, detail: '本次选中记录', tone: 'primary' },
|
||||
{ label: '处置成功', value: successCount, detail: '已写入履历', tone: 'success' },
|
||||
{ label: '跳过', value: skippedCount, detail: skippedCount ? '保留原状态' : '没有跳过', tone: skippedCount ? 'warning' : 'success' }
|
||||
] : [
|
||||
{ label: '影响记录', value: issues.length, detail: '最多 20 项', tone: 'primary' },
|
||||
{ label: '处置结论', value: statusLabel(status), detail: '逐项独立校验版本', tone: status === 'pending' ? 'warning' : 'success' },
|
||||
{ label: '失败策略', value: '继续执行', detail: '逐项返回结果', tone: 'neutral' }
|
||||
]}
|
||||
footerNote={result ? (skippedCount ? '成功项已从活跃队列更新;跳过项仍保持原状态。' : '全部记录已更新,筛选、页码与滚动位置仍保留。') : `将逐项处置 ${issues.length} 条记录,不会改写原始证据。`}
|
||||
secondaryActions={result ? [{ label: '导出结果', ariaLabel: '导出结果', icon: <IconDownload />, onClick: () => downloadReconciliationBatchResult('resolution', result, issues) }] : [{ label: '继续选择', onClick: onCancel }]}
|
||||
primaryAction={result ? {
|
||||
label: skippedCount ? '保留失败项并返回' : '返回队列',
|
||||
onClick: () => onComplete(result)
|
||||
} : {
|
||||
label: `确认处置 ${issues.length} 项`,
|
||||
onClick: () => save.mutate(),
|
||||
loading: save.isPending,
|
||||
disabled: !issues.length || (requiresNote && !note.trim())
|
||||
}}
|
||||
onCancel={() => result ? onComplete(result) : onCancel()}
|
||||
>
|
||||
{result ? <div className="v2-reconcile-batch-result">
|
||||
<section className={`v2-reconcile-batch-result-summary${skippedCount ? ' has-skipped' : ''}`} role="status">
|
||||
{skippedCount ? <IconAlertTriangle /> : <IconTickCircle />}
|
||||
<span><strong>{skippedCount ? '批量处置已完成,部分记录被跳过' : '批量处置已全部完成'}</strong><small>成功 {successCount} 项,跳过 {skippedCount} 项;每项均按提交时版本独立校验。</small></span>
|
||||
</section>
|
||||
{result.succeeded.length ? <section className="v2-reconcile-batch-result-list is-success"><header><strong>处置成功</strong><Tag color="green" type="light" size="small">{successCount} 项</Tag></header><ul>{result.succeeded.map((issue) => <li key={issue.id}><IconTickCircle /><span><strong>{issue.plate || issue.title}</strong><small>{issue.title} · v{issue.version}</small></span><ReconciliationStatusTag status={issue.status} /></li>)}</ul></section> : null}
|
||||
{result.skipped.length ? <section className="v2-reconcile-batch-result-list is-skipped"><header><strong>已跳过</strong><Tag color="orange" type="light" size="small">{skippedCount} 项</Tag></header><ul>{result.skipped.map((failure) => {
|
||||
const issue = issues.find((item) => item.id === failure.id);
|
||||
return <li key={failure.id}><IconAlertTriangle /><span><strong>{issue?.plate || issue?.title || failure.id}</strong><small>{failure.message}</small></span><Tag color="orange" type="light" size="small">未变更</Tag></li>;
|
||||
})}</ul></section> : null}
|
||||
</div> : <div className="v2-reconcile-batch-review">
|
||||
<section className="v2-reconcile-batch-impact"><IconAlertTriangle /><span><strong>确认影响范围</strong><small>将处置当前选中的 {issues.length} 项差异。若某项已被他人更新,该项会跳过,其余项目继续执行。</small></span></section>
|
||||
<label><span id="reconcile-batch-status-label">统一处置结论</span><Select aria-labelledby="reconcile-batch-status-label" value={status} onChange={(value) => setStatus(value as 'pending' | 'no_action' | 'fixed')} optionList={[
|
||||
{ value: 'fixed', label: '已修复' },
|
||||
{ value: 'no_action', label: '无需处理' },
|
||||
{ value: 'pending', label: '退回待复核' }
|
||||
]} /></label>
|
||||
<label><span>处置说明{requiresNote ? '(必填)' : '(可选)'}</span><TextArea aria-label={`批量处置说明${requiresNote ? '(必填)' : '(可选)'}`} value={note} maxCount={500} autosize={{ minRows: 4, maxRows: 7 }} onChange={setNote} placeholder="记录共同核对依据、修复动作、责任人或验收结果" /></label>
|
||||
{save.isError ? <p className="v2-reconcile-batch-error" role="alert">{save.error instanceof Error ? save.error.message : '批量处置失败'}</p> : null}
|
||||
<section className="v2-reconcile-batch-preview"><header><strong>本次选中</strong><span>{issues.length} 项 · 按当前提交版本处理</span></header><ol>{issues.map((issue) => <li key={issue.id}><span><strong>{issue.plate || '平台级差异'}</strong><small>{issue.title}</small></span><Tag color={issue.severity === 'critical' ? 'red' : issue.severity === 'major' ? 'orange' : 'grey'} type="light" size="small">v{issue.version}</Tag></li>)}</ol></section>
|
||||
</div>}
|
||||
</WorkspaceSideSheet>;
|
||||
}
|
||||
|
||||
export default function ReconciliationCenter() {
|
||||
const mobileLayout = useMobileLayout();
|
||||
const pageSize = mobileLayout ? MOBILE_PAGE_SIZE : DESKTOP_PAGE_SIZE;
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialScope = searchParams.get('reconcileScope') === 'archived' ? 'archived' : 'current';
|
||||
const defaultPageSize = mobileLayout ? MOBILE_PAGE_SIZE : DESKTOP_PAGE_SIZE;
|
||||
const [pageSize, setPageSize] = useState(() => {
|
||||
const requested = Number(searchParams.get('reconcileLimit'));
|
||||
return [20, 50, 100].includes(requested) ? requested : defaultPageSize;
|
||||
});
|
||||
const [keyword, setKeyword] = useState(() => searchParams.get('reconcileKeyword') ?? '');
|
||||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||||
const [status, setStatus] = useState('active');
|
||||
const [severity, setSeverity] = useState('all');
|
||||
const [ruleCode, setRuleCode] = useState('all');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [selectedID, setSelectedID] = useState('');
|
||||
const [scope, setScope] = useState<'current' | 'archived'>(initialScope);
|
||||
const [status, setStatus] = useState(() => searchParams.get('reconcileStatus') || (initialScope === 'archived' ? 'all' : 'active'));
|
||||
const [severity, setSeverity] = useState(() => searchParams.get('reconcileSeverity') || 'all');
|
||||
const [ruleCode, setRuleCode] = useState(() => searchParams.get('reconcileRule') || 'all');
|
||||
const [owner, setOwner] = useState(() => searchParams.get('reconcileOwner') || 'all');
|
||||
const [sla, setSLA] = useState(() => searchParams.get('reconcileSla') || 'all');
|
||||
const [offset, setOffset] = useState(() => Math.max(0, ((Number(searchParams.get('reconcilePage')) || 1) - 1) * pageSize));
|
||||
const [selectedID, setSelectedID] = useState(() => searchParams.get('reconcileIssue') ?? '');
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
const [trendExpanded, setTrendExpanded] = useState(false);
|
||||
const [batchMode, setBatchMode] = useState(false);
|
||||
const [batchPurpose, setBatchPurpose] = useState<'operate' | 'lifecycle'>('operate');
|
||||
const [batchVisible, setBatchVisible] = useState(false);
|
||||
const [batchIntent, setBatchIntent] = useState<'resolve' | 'assign'>('resolve');
|
||||
const [selectedIssues, setSelectedIssues] = useState<ReconciliationIssue[]>([]);
|
||||
const [selectionHint, setSelectionHint] = useState('');
|
||||
const [lifecycleTarget, setLifecycleTarget] = useState<{ issues: ReconciliationIssue[]; archived: boolean; batch: boolean }>();
|
||||
const session = useQuery({ queryKey: ['ops-session'], queryFn: ({ signal }) => api.session(signal), staleTime: 60_000 });
|
||||
const administrator = session.data?.role === 'admin';
|
||||
const resetFilters = () => {
|
||||
setKeyword('');
|
||||
setStatus('active');
|
||||
setStatus(scope === 'archived' ? 'all' : 'active');
|
||||
setSeverity('all');
|
||||
setRuleCode('all');
|
||||
setOwner('all');
|
||||
setSLA('all');
|
||||
setOffset(0);
|
||||
};
|
||||
const changeScope = (next: 'current' | 'archived') => {
|
||||
setScope(next);
|
||||
setStatus(next === 'archived' ? 'all' : 'active');
|
||||
setOwner('all');
|
||||
setSLA('all');
|
||||
setOffset(0);
|
||||
setSelectedID('');
|
||||
setTrendExpanded(false);
|
||||
setBatchMode(false);
|
||||
setBatchVisible(false);
|
||||
setSelectedIssues([]);
|
||||
setSelectionHint('');
|
||||
};
|
||||
const closeDetail = () => setSelectedID('');
|
||||
const openDetail = (id: string) => {
|
||||
setTrendExpanded(false);
|
||||
@@ -284,7 +674,23 @@ export default function ReconciliationCenter() {
|
||||
setSelectedID('');
|
||||
setTrendExpanded((value) => !value);
|
||||
};
|
||||
useEffect(() => setOffset(0), [deferredKeyword, mobileLayout, ruleCode, severity, status]);
|
||||
useEffect(() => {
|
||||
setSearchParams((current) => {
|
||||
const copy = new URLSearchParams(current);
|
||||
const setOptional = (key: string, value: string, fallback: string) => value && value !== fallback ? copy.set(key, value) : copy.delete(key);
|
||||
setOptional('reconcileScope', scope, 'current');
|
||||
setOptional('reconcileKeyword', deferredKeyword, '');
|
||||
setOptional('reconcileStatus', status, scope === 'archived' ? 'all' : 'active');
|
||||
setOptional('reconcileSeverity', severity, 'all');
|
||||
setOptional('reconcileRule', ruleCode, 'all');
|
||||
setOptional('reconcileOwner', owner, 'all');
|
||||
setOptional('reconcileSla', sla, 'all');
|
||||
setOptional('reconcileLimit', String(pageSize), String(defaultPageSize));
|
||||
setOptional('reconcilePage', String(Math.floor(offset / pageSize) + 1), '1');
|
||||
setOptional('reconcileIssue', selectedID, '');
|
||||
return copy.toString() === current.toString() ? current : copy;
|
||||
}, { replace: true });
|
||||
}, [defaultPageSize, deferredKeyword, offset, owner, pageSize, ruleCode, scope, selectedID, setSearchParams, severity, sla, status]);
|
||||
|
||||
const summary = useQuery({
|
||||
queryKey: ['reconciliation-summary', 30],
|
||||
@@ -293,13 +699,30 @@ export default function ReconciliationCenter() {
|
||||
gcTime: QUERY_MEMORY.summaryGcTime
|
||||
});
|
||||
const issues = useQuery({
|
||||
queryKey: ['reconciliation-issues', deferredKeyword, ruleCode, severity, status, pageSize, offset],
|
||||
queryKey: ['reconciliation-issues', scope, deferredKeyword, ruleCode, severity, status, owner, sla, pageSize, offset],
|
||||
queryFn: ({ signal }) => api.reconciliationIssues({
|
||||
keyword: deferredKeyword, ruleCode, severity, status, limit: pageSize, offset
|
||||
scope, keyword: deferredKeyword, ruleCode, severity, status, owner, sla, limit: pageSize, offset
|
||||
}, signal),
|
||||
staleTime: 20_000,
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||
});
|
||||
const assigneeDirectory = useReconciliationAssigneeDirectory();
|
||||
const ownerOptions = useMemo(() => [
|
||||
{ value: 'all', label: '全部负责人' },
|
||||
{ value: 'unassigned', label: '未分配' },
|
||||
{ value: 'assigned', label: '已分配' },
|
||||
...reconciliationAssigneeOptions(assigneeDirectory.data)
|
||||
], [assigneeDirectory.data]);
|
||||
const ruleOptions = useMemo(() => {
|
||||
const counts = new Map((summary.data?.byRule ?? []).map((item) => [item.name, item.count]));
|
||||
return [
|
||||
{ value: 'all', label: '全部规则' },
|
||||
...reconciliationRuleCodes.map((value) => ({
|
||||
value,
|
||||
label: `${ruleLabel(value)}${counts.has(value) ? ` · ${counts.get(value)}` : ''}`
|
||||
}))
|
||||
];
|
||||
}, [summary.data?.byRule]);
|
||||
const detail = useQuery({
|
||||
queryKey: ['reconciliation-detail', selectedID],
|
||||
queryFn: ({ signal }) => api.reconciliationIssue(selectedID, signal),
|
||||
@@ -312,30 +735,110 @@ export default function ReconciliationCenter() {
|
||||
for (const item of summary.data?.trend ?? []) result = Math.max(result, item.active, item.new, item.recovered);
|
||||
return result;
|
||||
}, [summary.data?.trend]);
|
||||
const refresh = () => Promise.all([summary.refetch(), issues.refetch(), selectedID ? detail.refetch() : Promise.resolve()]);
|
||||
const refresh = () => Promise.all([summary.refetch(), issues.refetch(), assigneeDirectory.refetch(), selectedID ? detail.refetch() : Promise.resolve()]);
|
||||
const exportIssues = useMutation({
|
||||
mutationFn: () => api.downloadReconciliationIssues({
|
||||
scope, keyword: deferredKeyword, ruleCode, severity, status, owner, sla
|
||||
}),
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
downloadBlob(blob, filename);
|
||||
Toast.success(`已导出当前筛选的 ${(page?.total ?? 0).toLocaleString('zh-CN')} 条差异`);
|
||||
}
|
||||
});
|
||||
const data = summary.data;
|
||||
const page = issues.data;
|
||||
const activeCount = page?.items.filter((item) => activeStatuses.has(item.status)).length ?? 0;
|
||||
const currentPage = Math.floor(offset / pageSize) + 1;
|
||||
const totalPages = Math.max(1, Math.ceil((page?.total ?? 0) / pageSize));
|
||||
const issueRows = page?.items ?? [];
|
||||
const activeFilterCount = Number(Boolean(deferredKeyword)) + Number(severity !== 'all') + Number(ruleCode !== 'all');
|
||||
const filterSummary = `${status === 'active' ? '活跃差异' : statusLabel(status)} · ${(page?.total ?? 0).toLocaleString('zh-CN')} 条${activeFilterCount ? ` · 另 ${activeFilterCount} 项` : ''}`;
|
||||
const issueRows = useMemo(() => page?.items ?? [], [page?.items]);
|
||||
const eligibleRows = useMemo(() => issueRows.filter((item) => batchPurpose === 'operate'
|
||||
? scope === 'current' && activeStatuses.has(item.status)
|
||||
: scope === 'archived' ? Boolean(item.archivedAt) : archiveableStatuses.has(item.status) && !item.archivedAt), [batchPurpose, issueRows, scope]);
|
||||
const eligibleIDs = useMemo(() => new Set(eligibleRows.map((item) => item.id)), [eligibleRows]);
|
||||
const selectedIDs = useMemo(() => new Set(selectedIssues.map((item) => item.id)), [selectedIssues]);
|
||||
const selectedCurrentPageCount = eligibleRows.filter((item) => selectedIDs.has(item.id)).length;
|
||||
const allCurrentPageSelected = Boolean(eligibleRows.length) && selectedCurrentPageCount === eligibleRows.length;
|
||||
const toggleBatchIssue = useCallback((issue: ReconciliationIssue, checked = !selectedIDs.has(issue.id)) => {
|
||||
if (!eligibleIDs.has(issue.id)) return;
|
||||
if (!checked) {
|
||||
setSelectedIssues((current) => current.filter((item) => item.id !== issue.id));
|
||||
setSelectionHint('');
|
||||
return;
|
||||
}
|
||||
if (selectedIDs.has(issue.id)) return;
|
||||
if (selectedIssues.length >= 20) {
|
||||
setSelectionHint('单次最多批量处理 20 项,请先处理已选记录。');
|
||||
return;
|
||||
}
|
||||
setSelectedIssues((current) => [...current, issue]);
|
||||
setSelectionHint('');
|
||||
}, [eligibleIDs, selectedIDs, selectedIssues.length]);
|
||||
const toggleCurrentPage = useCallback(() => {
|
||||
if (allCurrentPageSelected) {
|
||||
const currentIDs = new Set(eligibleRows.map((item) => item.id));
|
||||
setSelectedIssues((current) => current.filter((item) => !currentIDs.has(item.id)));
|
||||
setSelectionHint('');
|
||||
return;
|
||||
}
|
||||
setSelectedIssues((current) => {
|
||||
const known = new Set(current.map((item) => item.id));
|
||||
const available = Math.max(0, 20 - current.length);
|
||||
const additions = eligibleRows.filter((item) => !known.has(item.id)).slice(0, available);
|
||||
if (additions.length < eligibleRows.filter((item) => !known.has(item.id)).length) setSelectionHint('已达到单次 20 项上限,其余记录未选择。');
|
||||
else setSelectionHint('');
|
||||
return [...current, ...additions];
|
||||
});
|
||||
}, [allCurrentPageSelected, eligibleRows]);
|
||||
const enterBatchMode = (purpose: 'operate' | 'lifecycle') => {
|
||||
setSelectedID('');
|
||||
setTrendExpanded(false);
|
||||
setBatchPurpose(purpose);
|
||||
setBatchMode(true);
|
||||
setSelectionHint('');
|
||||
};
|
||||
const exitBatchMode = () => {
|
||||
setBatchMode(false);
|
||||
setBatchVisible(false);
|
||||
setSelectedIssues([]);
|
||||
setSelectionHint('');
|
||||
};
|
||||
const finishBatch = (result: ReconciliationBatchActionResult) => {
|
||||
const skippedIDs = new Set(result.skipped.map((item) => item.id));
|
||||
const skippedIssues = selectedIssues.filter((item) => skippedIDs.has(item.id));
|
||||
setBatchVisible(false);
|
||||
setSelectedIssues(skippedIssues);
|
||||
setBatchMode(Boolean(skippedIssues.length));
|
||||
setSelectionHint(skippedIssues.length ? `${skippedIssues.length} 项因版本冲突或记录变化被保留,可刷新后重新选择。` : '');
|
||||
};
|
||||
const activeFilterCount = Number(Boolean(deferredKeyword)) + Number(severity !== 'all') + Number(ruleCode !== 'all') + Number(owner !== 'all') + Number(sla !== 'all');
|
||||
const filterSummary = `${scope === 'archived' ? '审计归档' : status === 'active' ? '活跃差异' : statusLabel(status)} · ${(page?.total ?? 0).toLocaleString('zh-CN')} 条${activeFilterCount ? ` · 另 ${activeFilterCount} 项` : ''}`;
|
||||
const selectedIssue = detail.data ?? issueRows.find((item) => item.id === selectedID);
|
||||
const detailPanel = selectedID && detail.isPending
|
||||
? <Card className="v2-reconcile-detail is-sheet" bodyStyle={{ padding: 0 }}><PanelLoading className="v2-reconcile-detail-loading" title="正在读取差异证据" description="原始证据和处理履历就绪后会自动显示。" /></Card>
|
||||
: selectedID && detail.isError
|
||||
? <Card className="v2-reconcile-detail is-sheet" bodyStyle={{ padding: 0 }}><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></Card>
|
||||
: detail.data
|
||||
? <ReconciliationDetail issue={detail.data} onClose={closeDetail} sheet />
|
||||
? <ReconciliationDetail issue={detail.data} onClose={closeDetail} onLifecycle={(issue, archived) => setLifecycleTarget({ issues: [issue], archived, batch: false })} administrator={administrator} sheet />
|
||||
: null;
|
||||
const columns = [
|
||||
const baseColumns = useMemo(() => [
|
||||
{ title: '问题 / 等级', dataIndex: 'title', width: 286, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell v2-reconcile-issue-cell"><span><strong>{item.title}</strong><ReconciliationSeverityTag severity={item.severity} /></span><small>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</small></span> },
|
||||
{ title: '车辆', dataIndex: 'plate', width: 176, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{item.plate || '非单车差异'}</strong><small>{item.vin || '平台级口径'}</small></span> },
|
||||
{ title: '证据来源', dataIndex: 'protocolA', width: 164, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><small>{categoryLabel(item.category)}</small></span> },
|
||||
{ title: '最近发现', dataIndex: 'lastSeenAt', width: 190, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong><PlatformTime className="v2-ops-time" value={item.lastSeenAt} sourceZone="utc" /></strong><small>首次 <PlatformTime className="v2-ops-time" value={item.firstSeenAt} sourceZone="utc" /></small></span> },
|
||||
{ title: scope === 'archived' ? '归档人 / 原因' : '责任 / 时限', dataIndex: 'assignee', width: 206, render: (_value: string, item: ReconciliationIssue) => scope === 'archived' ? <span className="v2-reconcile-cell"><strong>{item.archivedBy || '未知管理员'}</strong><small>{item.archiveReason || '未记录归档原因'}</small></span> : <ReconciliationOwnership item={item} /> },
|
||||
{ title: scope === 'archived' ? '归档时间' : '最近发现', dataIndex: 'lastSeenAt', width: 190, render: (_value: string, item: ReconciliationIssue) => <span className="v2-reconcile-cell"><strong><PlatformTime className="v2-ops-time" value={scope === 'archived' ? item.archivedAt : item.lastSeenAt} sourceZone="utc" /></strong><small>{scope === 'archived' ? <>最近发现 <PlatformTime className="v2-ops-time" value={item.lastSeenAt} sourceZone="utc" /></> : <>首次 <PlatformTime className="v2-ops-time" value={item.firstSeenAt} sourceZone="utc" /></>}</small></span> },
|
||||
{ title: '状态', dataIndex: 'status', width: 112, render: (value: string) => <ReconciliationStatusTag status={value} /> }
|
||||
];
|
||||
], [scope]);
|
||||
const columns = useMemo(() => batchMode ? [{
|
||||
title: <Checkbox aria-label="选择当前页可处置差异" checked={allCurrentPageSelected} indeterminate={selectedCurrentPageCount > 0 && !allCurrentPageSelected} disabled={!eligibleRows.length} onChange={toggleCurrentPage} />,
|
||||
dataIndex: 'batchSelection',
|
||||
width: 54,
|
||||
render: (_value: unknown, item: ReconciliationIssue) => <span className="v2-reconcile-row-checkbox" onClick={(event) => event.stopPropagation()}><Checkbox
|
||||
aria-label={`选择差异:${item.plate || item.title}`}
|
||||
checked={selectedIDs.has(item.id)}
|
||||
disabled={!eligibleIDs.has(item.id) || (!selectedIDs.has(item.id) && selectedIssues.length >= 20)}
|
||||
onChange={(event) => toggleBatchIssue(item, Boolean(event.target.checked))}
|
||||
/></span>
|
||||
}, ...baseColumns] : baseColumns, [allCurrentPageSelected, baseColumns, batchMode, eligibleIDs, eligibleRows.length, selectedCurrentPageCount, selectedIDs, selectedIssues.length, toggleBatchIssue, toggleCurrentPage]);
|
||||
|
||||
return <><Card className="v2-reconcile-center" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
@@ -346,7 +849,10 @@ export default function ReconciliationCenter() {
|
||||
description="自动发现并合并重复问题;复核人员基于原始证据形成结论,不直接改写数据。"
|
||||
meta="每日自动对账"
|
||||
actions={<>
|
||||
<Button
|
||||
{!batchMode && scope === 'current' ? <Button className="v2-reconcile-batch-entry v2-workspace-mobile-tool-button is-muted" theme="light" aria-label="进入批量处置" onClick={() => enterBatchMode('operate')}>{mobileLayout ? '批量' : '批量处置'}</Button> : null}
|
||||
{!batchMode && administrator ? <Button className="v2-workspace-mobile-tool-button is-muted" theme="light" type="tertiary" icon={scope === 'archived' ? <IconRefresh /> : <IconBox />} aria-label={scope === 'archived' ? '进入批量恢复' : '进入批量归档'} onClick={() => enterBatchMode('lifecycle')}>{mobileLayout ? scope === 'archived' ? '恢复' : '归档' : scope === 'archived' ? '批量恢复' : '批量归档'}</Button> : null}
|
||||
{!batchMode ? <Button className="v2-reconcile-export v2-workspace-mobile-tool-button is-muted" theme="light" icon={<IconDownload />} aria-label="导出当前筛选" loading={exportIssues.isPending} disabled={!page?.total} onClick={() => exportIssues.mutate()}>{mobileLayout ? '导出' : '导出当前筛选'}</Button> : null}
|
||||
{scope === 'current' ? <Button
|
||||
className="v2-reconcile-trend-open v2-workspace-mobile-tool-button is-muted"
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
@@ -355,16 +861,56 @@ export default function ReconciliationCenter() {
|
||||
aria-expanded={trendExpanded}
|
||||
aria-controls="v2-reconcile-trend"
|
||||
onClick={toggleTrend}
|
||||
>30 天趋势</Button>
|
||||
>30 天趋势</Button> : null}
|
||||
<Button className="v2-workspace-mobile-tool-button is-muted" theme="light" icon={<IconRefresh />} aria-label="刷新结果" loading={summary.isFetching || issues.isFetching} onClick={refresh}>刷新结果</Button>
|
||||
</>}
|
||||
/>
|
||||
{summary.isError ? <InlineError message={summary.error.message} onRetry={() => summary.refetch()} /> : null}
|
||||
<nav className="v2-reconcile-scope-nav" aria-label="差异记录范围">
|
||||
<SegmentedTabs
|
||||
className="v2-reconcile-scope-tabs"
|
||||
variant="filled"
|
||||
ariaLabel="当前与归档差异"
|
||||
value={scope}
|
||||
onChange={(value) => changeScope(value as 'current' | 'archived')}
|
||||
items={[
|
||||
{ key: 'current', label: '当前队列', count: data?.current },
|
||||
{ key: 'archived', label: '审计归档', count: data?.archived }
|
||||
]}
|
||||
/>
|
||||
<span aria-live="polite">{scope === 'archived' ? '只读审计范围 · 可按归档人和原因搜索' : '日常复核与责任处置范围'}</span>
|
||||
</nav>
|
||||
<WorkspaceMetricRail
|
||||
variant="queue"
|
||||
className="v2-reconcile-metric-rail"
|
||||
ariaLabel="差异处置队列概览"
|
||||
items={[
|
||||
items={scope === 'archived' ? [
|
||||
{
|
||||
label: '审计归档',
|
||||
value: data?.archived.toLocaleString('zh-CN') ?? '—',
|
||||
note: '完整保留证据与履历',
|
||||
tone: 'primary'
|
||||
},
|
||||
{
|
||||
label: '当前队列',
|
||||
value: data?.current.toLocaleString('zh-CN') ?? '—',
|
||||
note: '日常复核范围',
|
||||
emphasis: 'secondary'
|
||||
},
|
||||
{
|
||||
label: '当前筛选',
|
||||
value: (page?.total ?? 0).toLocaleString('zh-CN'),
|
||||
note: '按归档时间倒序',
|
||||
emphasis: 'secondary'
|
||||
},
|
||||
{
|
||||
label: '恢复策略',
|
||||
value: '保留结论',
|
||||
note: '恢复后不自动分配责任',
|
||||
tone: 'success',
|
||||
emphasis: 'secondary'
|
||||
}
|
||||
] : [
|
||||
{
|
||||
label: '待复核',
|
||||
value: data?.pending.toLocaleString('zh-CN') ?? '—',
|
||||
@@ -374,7 +920,7 @@ export default function ReconciliationCenter() {
|
||||
{
|
||||
label: 'SLA 超时',
|
||||
value: data?.overSla.toLocaleString('zh-CN') ?? '—',
|
||||
note: data?.overSla ? '超过 24 小时' : '当前无超时',
|
||||
note: data?.overSla ? '超过处理期限' : '当前无超时',
|
||||
tone: data?.overSla ? 'danger' : 'success'
|
||||
},
|
||||
{
|
||||
@@ -391,10 +937,23 @@ export default function ReconciliationCenter() {
|
||||
emphasis: 'secondary'
|
||||
}
|
||||
]}
|
||||
context={<span aria-live="polite"><small>当前筛选</small><strong>{(page?.total ?? 0).toLocaleString('zh-CN')}</strong><em>按严重程度、最近发现时间排序</em></span>}
|
||||
context={<span aria-live="polite"><small>{scope === 'archived' ? '归档筛选' : '当前筛选'}</small><strong>{(page?.total ?? 0).toLocaleString('zh-CN')}</strong><em>{scope === 'archived' ? '按归档时间倒序' : '按严重程度、最近发现时间排序'}</em></span>}
|
||||
/>
|
||||
<div className="v2-reconcile-layout">
|
||||
<div className="v2-reconcile-main">
|
||||
{batchMode ? <section className="v2-reconcile-batch-toolbar" aria-label={batchPurpose === 'lifecycle' ? scope === 'archived' ? '批量恢复工具栏' : '批量归档工具栏' : '批量处置工具栏'}>
|
||||
<span className="v2-reconcile-batch-count"><strong>已选 {selectedIssues.length} 项</strong><small>{selectedIssues.length > selectedCurrentPageCount ? `已跨页选择 · 当前页 ${selectedCurrentPageCount}` : `当前页 ${selectedCurrentPageCount}`} / {eligibleRows.length} 项可{batchPurpose === 'lifecycle' ? scope === 'archived' ? '恢复' : '归档' : '处置'}</small></span>
|
||||
<div>
|
||||
<Button theme="borderless" type="tertiary" disabled={!eligibleRows.length} onClick={toggleCurrentPage}>{allCurrentPageSelected ? '取消本页' : '选择本页'}</Button>
|
||||
<Button theme="borderless" type="tertiary" disabled={!selectedIssues.length} onClick={() => { setSelectedIssues([]); setSelectionHint(''); }}>清空</Button>
|
||||
<Button theme="light" type="tertiary" onClick={exitBatchMode}>退出</Button>
|
||||
{batchPurpose === 'operate' ? <>
|
||||
<Button theme="light" type="primary" disabled={!selectedIssues.length} onClick={() => { setBatchIntent('assign'); setBatchVisible(true); }}>交接选中</Button>
|
||||
<Button theme="solid" type="primary" disabled={!selectedIssues.length} onClick={() => { setBatchIntent('resolve'); setBatchVisible(true); }}>处置选中</Button>
|
||||
</> : <Button theme="solid" type="primary" icon={scope === 'archived' ? <IconRefresh /> : <IconBox />} disabled={!selectedIssues.length} onClick={() => setLifecycleTarget({ issues: selectedIssues, archived: scope !== 'archived', batch: true })}>{scope === 'archived' ? '恢复选中' : '归档选中'}</Button>}
|
||||
</div>
|
||||
{selectionHint ? <p role="status">{selectionHint}</p> : null}
|
||||
</section> : null}
|
||||
<MobileFilterToggle
|
||||
title="筛选差异"
|
||||
summary={filterSummary}
|
||||
@@ -403,23 +962,26 @@ export default function ReconciliationCenter() {
|
||||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||||
/>
|
||||
<div className={`v2-reconcile-toolbar${filtersCollapsed ? ' is-mobile-collapsed' : ''}`}>
|
||||
<Input className="v2-reconcile-search" prefix={<IconSearch />} aria-label="搜索差异车辆或规则" value={keyword} onChange={setKeyword} placeholder="车牌、VIN、标题或说明" />
|
||||
<Input className="v2-reconcile-search" prefix={<IconSearch />} aria-label={scope === 'archived' ? '搜索归档差异与审计原因' : '搜索差异车辆或规则'} value={keyword} onChange={(value) => { setKeyword(value); setOffset(0); }} placeholder={scope === 'archived' ? '车牌、VIN、归档人或原因' : '车牌、VIN、标题或说明'} />
|
||||
<span className="v2-sr-only" id="reconcile-status-filter-label">筛选差异状态</span>
|
||||
<Select aria-labelledby="reconcile-status-filter-label" value={status} onChange={(value) => setStatus(String(value))} optionList={[
|
||||
<Select aria-labelledby="reconcile-status-filter-label" value={status} onChange={(value) => { setStatus(String(value)); setOffset(0); }} optionList={[
|
||||
{ value: 'active', label: '活跃差异' }, { value: 'pending', label: '待处理' },
|
||||
{ value: 'confirmed_source_a', label: '确认来源 A' }, { value: 'confirmed_source_b', label: '确认来源 B' },
|
||||
{ value: 'recovered', label: '已恢复' }, { value: 'fixed', label: '已修复' },
|
||||
{ value: 'no_action', label: '无需处理' }, { value: 'all', label: '全部状态' }
|
||||
]} />
|
||||
<span className="v2-sr-only" id="reconcile-severity-filter-label">筛选严重程度</span>
|
||||
<Select aria-labelledby="reconcile-severity-filter-label" value={severity} onChange={(value) => setSeverity(String(value))} optionList={[
|
||||
<Select aria-labelledby="reconcile-severity-filter-label" value={severity} onChange={(value) => { setSeverity(String(value)); setOffset(0); }} optionList={[
|
||||
{ value: 'all', label: '全部等级' }, { value: 'critical', label: '严重' },
|
||||
{ value: 'major', label: '重要' }, { value: 'minor', label: '一般' }
|
||||
]} />
|
||||
<span className="v2-sr-only" id="reconcile-rule-filter-label">筛选差异规则</span>
|
||||
<Select aria-labelledby="reconcile-rule-filter-label" value={ruleCode} onChange={(value) => setRuleCode(String(value))} optionList={[
|
||||
{ value: 'all', label: '全部规则' },
|
||||
...(data?.byRule.map((item) => ({ value: item.name, label: `${ruleLabel(item.name)} · ${item.count}` })) ?? [])
|
||||
<Select aria-labelledby="reconcile-rule-filter-label" value={ruleCode} onChange={(value) => { setRuleCode(String(value)); setOffset(0); }} optionList={ruleOptions} />
|
||||
<span className="v2-sr-only" id="reconcile-owner-filter-label">筛选责任状态</span>
|
||||
<Select aria-labelledby="reconcile-owner-filter-label" value={owner} filter onChange={(value) => { setOwner(String(value)); setOffset(0); }} optionList={ownerOptions} />
|
||||
<span className="v2-sr-only" id="reconcile-sla-filter-label">筛选处理时限</span>
|
||||
<Select aria-labelledby="reconcile-sla-filter-label" value={sla} onChange={(value) => { setSLA(String(value)); setOffset(0); }} optionList={[
|
||||
{ value: 'all', label: '全部时限' }, { value: 'overdue', label: '已超时' }, { value: 'due_soon', label: '8 小时内到期' }
|
||||
]} />
|
||||
</div>
|
||||
{issues.isError ? <InlineError message={issues.error.message} onRetry={() => issues.refetch()} /> : null}
|
||||
@@ -429,36 +991,73 @@ export default function ReconciliationCenter() {
|
||||
aria-label="差异队列,可上下滚动"
|
||||
tabIndex={0}
|
||||
>
|
||||
{!mobileLayout ? <Table className="v2-reconcile-table" columns={columns} dataSource={issueRows} rowKey="id" pagination={false} scroll={{ x: 934 }} onRow={(item) => item ? detailTriggerRow({
|
||||
className: selectedID === item.id ? 'is-selected' : '',
|
||||
expanded: selectedID === item.id,
|
||||
label: `查看 ${item.plate || item.title} 差异证据`,
|
||||
testId: `reconcile-row-${item.id}`,
|
||||
onOpen: () => openDetail(item.id)
|
||||
}) : ({})} /> : <div className="v2-reconcile-mobile-list">{issueRows.map((item) => <Card key={item.id} className={`v2-reconcile-mobile-card${selectedID === item.id ? ' is-selected' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
<Button theme="borderless" type="tertiary" className="v2-reconcile-mobile-action" aria-pressed={selectedID === item.id} aria-expanded={selectedID === item.id} aria-label={`查看 ${item.plate || item.title} 差异证据`} onClick={() => openDetail(item.id)}>
|
||||
<span className="v2-reconcile-mobile-content">
|
||||
<header><strong>{item.plate || '平台级差异'}</strong><span><ReconciliationSeverityTag severity={item.severity} /><ReconciliationStatusTag status={item.status} /></span></header>
|
||||
<p>{item.title}</p>
|
||||
<span className="v2-reconcile-mobile-meta"><span>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</span><PlatformTime className="v2-ops-time" value={item.lastSeenAt} sourceZone="utc" /></span>
|
||||
<footer><span>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</span><span>证据与处置<IconChevronRight /></span></footer>
|
||||
</span>
|
||||
</Button>
|
||||
{!mobileLayout ? <Table className="v2-reconcile-table" columns={columns} dataSource={issueRows} rowKey="id" pagination={false} scroll={{ x: batchMode ? 1194 : 1140 }} onRow={(item) => {
|
||||
if (!item) return {} as never;
|
||||
if (batchMode) return {
|
||||
className: selectedIDs.has(item.id) ? 'is-batch-selected' : '',
|
||||
role: 'button' as const,
|
||||
tabIndex: 0,
|
||||
'aria-pressed': selectedIDs.has(item.id),
|
||||
'aria-label': `${selectedIDs.has(item.id) ? '取消选择' : '选择'} ${item.plate || item.title} 差异`,
|
||||
onClick: () => toggleBatchIssue(item),
|
||||
onKeyDown: (event: { key: string; preventDefault: () => void }) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
toggleBatchIssue(item);
|
||||
}
|
||||
} as never;
|
||||
return detailTriggerRow({
|
||||
className: selectedID === item.id ? 'is-selected' : '',
|
||||
expanded: selectedID === item.id,
|
||||
label: `查看 ${item.plate || item.title} 差异证据`,
|
||||
testId: `reconcile-row-${item.id}`,
|
||||
onOpen: () => openDetail(item.id)
|
||||
}) as never;
|
||||
}} /> : <div className="v2-reconcile-mobile-list">{issueRows.map((item) => <Card key={item.id} className={`v2-reconcile-mobile-card${selectedID === item.id ? ' is-selected' : ''}${selectedIDs.has(item.id) ? ' is-batch-selected' : ''}`} bodyStyle={{ padding: 0 }}>
|
||||
{batchMode ? <div className="v2-reconcile-mobile-batch-row"><span className="v2-reconcile-mobile-checkbox"><Checkbox aria-label={`选择差异:${item.plate || item.title}`} checked={selectedIDs.has(item.id)} disabled={!eligibleIDs.has(item.id) || (!selectedIDs.has(item.id) && selectedIssues.length >= 20)} onChange={(event) => toggleBatchIssue(item, Boolean(event.target.checked))} /></span><Button theme="borderless" type="tertiary" className="v2-reconcile-mobile-action" aria-pressed={selectedIDs.has(item.id)} aria-label={`${selectedIDs.has(item.id) ? '取消选择' : '选择'} ${item.plate || item.title} 差异`} onClick={() => toggleBatchIssue(item)}><ReconciliationMobileContent item={item} batchSelected={selectedIDs.has(item.id)} /></Button></div> : <Button theme="borderless" type="tertiary" className="v2-reconcile-mobile-action" aria-pressed={selectedID === item.id} aria-expanded={selectedID === item.id} aria-label={`查看 ${item.plate || item.title} 差异证据`} onClick={() => openDetail(item.id)}><ReconciliationMobileContent item={item} /></Button>}
|
||||
</Card>)}</div>}
|
||||
{issues.isPending ? <PanelLoading className="v2-reconcile-loading" compact title="正在读取差异队列" description="筛选范围已保留,结果就绪后会自动显示。" /> : null}
|
||||
{!issues.isPending && !issueRows.length ? <PanelEmpty
|
||||
className="v2-reconcile-empty"
|
||||
tone="success"
|
||||
icon={<IconSearch />}
|
||||
title="当前筛选条件没有差异"
|
||||
description="当前范围没有待复核证据,可重置筛选返回活跃差异。"
|
||||
icon={scope === 'archived' ? <IconBox /> : <IconSearch />}
|
||||
title={scope === 'archived' ? '当前筛选没有归档记录' : '当前筛选条件没有差异'}
|
||||
description={scope === 'archived' ? '可调整归档原因、操作者、状态或规则筛选,或返回当前队列。' : '当前范围没有待复核证据,可重置筛选返回活跃差异。'}
|
||||
action={<Button theme="light" type="primary" icon={<IconRefresh />} onClick={resetFilters}>重置筛选</Button>}
|
||||
/> : null}
|
||||
</div>
|
||||
<footer className="v2-reconcile-pagination"><TablePagination page={currentPage} totalPages={totalPages} info={`共 ${(page?.total ?? 0).toLocaleString('zh-CN')} 条 · 本页 ${activeCount} 条活跃`} onPageChange={(next) => setOffset((next - 1) * pageSize)} /></footer>
|
||||
<footer className="v2-reconcile-pagination"><TablePagination page={currentPage} totalPages={totalPages} info={`共 ${(page?.total ?? 0).toLocaleString('zh-CN')} 条 · 本页 ${scope === 'archived' ? `${issueRows.length} 条归档` : `${activeCount} 条活跃`}`} disabled={issues.isFetching} onPageChange={(next) => setOffset((next - 1) * pageSize)} pageSize={pageSize} pageSizeLabel="每页差异数" pageSizeOptions={mobileLayout ? [{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }] : [{ value: 20, label: '20 条/页' }, { value: 50, label: '50 条/页' }, { value: 100, label: '100 条/页' }]} onPageSizeChange={(next) => { setPageSize(next); setOffset(0); }} /></footer>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<ReconciliationBatchReview
|
||||
issues={selectedIssues}
|
||||
visible={batchVisible && batchIntent === 'resolve'}
|
||||
mobileLayout={mobileLayout}
|
||||
onCancel={() => setBatchVisible(false)}
|
||||
onComplete={finishBatch}
|
||||
/>
|
||||
<ReconciliationBatchAssignment issues={selectedIssues} visible={batchVisible && batchIntent === 'assign'} mobileLayout={mobileLayout} onCancel={() => setBatchVisible(false)} onComplete={finishBatch} />
|
||||
<ReconciliationLifecycleDialog
|
||||
issues={lifecycleTarget?.issues ?? []}
|
||||
archived={lifecycleTarget?.archived ?? false}
|
||||
batch={lifecycleTarget?.batch ?? false}
|
||||
visible={Boolean(lifecycleTarget)}
|
||||
onCancel={() => setLifecycleTarget(undefined)}
|
||||
onComplete={(result) => {
|
||||
const target = lifecycleTarget;
|
||||
setLifecycleTarget(undefined);
|
||||
if (!target) return;
|
||||
if (target.batch) {
|
||||
finishBatch(result);
|
||||
return;
|
||||
}
|
||||
const updated = result.succeeded[0];
|
||||
if (!updated) return;
|
||||
changeScope(target.archived ? 'archived' : 'current');
|
||||
setSelectedID(updated.id);
|
||||
}}
|
||||
/>
|
||||
<WorkspaceSideSheet
|
||||
className="v2-reconcile-detail-sidesheet"
|
||||
variant="detail"
|
||||
@@ -469,8 +1068,8 @@ export default function ReconciliationCenter() {
|
||||
ariaLabel="差异证据与处置"
|
||||
closeLabel="关闭差异证据与处置"
|
||||
title={selectedIssue?.title ?? '差异证据与处置'}
|
||||
description={selectedIssue ? `${ruleLabel(selectedIssue.ruleCode)} · 原始证据、复核结论与处理履历` : '正在加载规则证据与处置履历'}
|
||||
badge={selectedIssue ? `${severityLabel(selectedIssue.severity)} · ${statusLabel(selectedIssue.status)}` : '读取中'}
|
||||
description={selectedIssue ? `${ruleLabel(selectedIssue.ruleCode)} · ${selectedIssue.archivedAt ? '只读审计证据与处理履历' : '原始证据、复核结论与处理履历'}` : '正在加载规则证据与处置履历'}
|
||||
badge={selectedIssue ? `${selectedIssue.archivedAt ? '审计归档 · ' : ''}${severityLabel(selectedIssue.severity)} · ${statusLabel(selectedIssue.status)}` : '读取中'}
|
||||
badgeColor={selectedIssue?.severity === 'critical' ? 'red' : selectedIssue?.severity === 'major' ? 'orange' : 'grey'}
|
||||
summaryItems={selectedIssue ? [
|
||||
{
|
||||
@@ -486,10 +1085,10 @@ export default function ReconciliationCenter() {
|
||||
tone: selectedIssue.severity === 'critical' ? 'danger' : 'warning'
|
||||
},
|
||||
{
|
||||
label: '最近发现',
|
||||
value: <PlatformTime className="v2-ops-time" value={selectedIssue.lastSeenAt} sourceZone="utc" />,
|
||||
detail: <span>首次 <PlatformTime className="v2-ops-time" value={selectedIssue.firstSeenAt} sourceZone="utc" /></span>,
|
||||
tone: 'neutral'
|
||||
label: selectedIssue.archivedAt ? '归档回执' : '责任与时限',
|
||||
value: selectedIssue.archivedAt ? selectedIssue.archivedBy || '未知管理员' : selectedIssue.assignee || '未分配',
|
||||
detail: selectedIssue.archivedAt ? <PlatformTime className="v2-ops-time" value={selectedIssue.archivedAt} sourceZone="utc" /> : selectedIssue.dueAt ? <PlatformTime className="v2-ops-time" value={selectedIssue.dueAt} sourceZone="utc" /> : '尚未设置期限',
|
||||
tone: selectedIssue.archivedAt ? 'neutral' : reconciliationDueState(selectedIssue) === 'overdue' ? 'danger' : selectedIssue.assignee ? 'success' : 'warning'
|
||||
}
|
||||
] : []}
|
||||
onCancel={closeDetail}
|
||||
@@ -519,7 +1118,7 @@ export default function ReconciliationCenter() {
|
||||
{
|
||||
label: 'SLA 超时',
|
||||
value: data?.overSla.toLocaleString('zh-CN') ?? '—',
|
||||
detail: '超过 24 小时',
|
||||
detail: '超过处理期限',
|
||||
tone: data?.overSla ? 'danger' : 'success'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,18 +2,21 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import StatisticsPage, { mileageRangeContainsDate } from './StatisticsPage';
|
||||
import StatisticsPage, { mileageRangeContainsDate, parseMileageVehicleIdentifiers } from './StatisticsPage';
|
||||
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
import { buildVehicleDetailPath, withVehicleReturn } from '../routing/vehicleContext';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn() }));
|
||||
const mocks = vi.hoisted(() => ({ mileageStatistics: vi.fn(), dailyMileage: vi.fn(), vehicles: vi.fn(), vehicleCoverage: vi.fn(), vehicleServiceOverviews: vi.fn() }));
|
||||
const exportMocks = vi.hoisted(() => ({ createMileageExportStream: vi.fn(), appendRows: vi.fn(), finish: vi.fn(), dispose: vi.fn() }));
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
const auth = vi.hoisted(() => ({ role: 'admin' as 'admin' | 'customer', userType: 'admin' as 'admin' | 'customer' }));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../domain/mileageExport', () => ({ createMileageExportStream: exportMocks.createMileageExportStream }));
|
||||
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
|
||||
vi.mock('../auth/AuthGate', () => ({ usePlatformSession: () => ({ session: { name: '测试账号', role: auth.role, userType: auth.userType, authMode: 'enforce' } }) }));
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); layout.mobile = false; window.localStorage.clear(); Object.values(mocks).forEach((mock) => mock.mockReset()); Object.values(exportMocks).forEach((mock) => mock.mockReset()); });
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); layout.mobile = false; auth.role = 'admin'; auth.userType = 'admin'; window.localStorage.clear(); Object.values(mocks).forEach((mock) => mock.mockReset()); Object.values(exportMocks).forEach((mock) => mock.mockReset()); });
|
||||
|
||||
function renderPage(initialEntry = '/statistics') {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -25,18 +28,27 @@ function prepareData() {
|
||||
exportMocks.createMileageExportStream.mockReturnValue({ appendRows: exportMocks.appendRows, finish: exportMocks.finish, dispose: exportMocks.dispose });
|
||||
mocks.mileageStatistics.mockResolvedValue({
|
||||
dateFrom: '2026-07-01', dateTo: '2026-07-14', vehicleCount: 1, recordCount: 2, sourceCount: 2,
|
||||
periodMileageKm: 193.3, fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65,
|
||||
periodMileageKm: 193.3, periodPureHydrogenMileageKm: 128.4,
|
||||
hydrogenMatchedMileageKm: 193.3, hydrogenDataDays: 2, periodHydrogenConsumptionKg: 7.3, hydrogenConsumptionKgPer100Km: 3.8,
|
||||
fleetLatestMileageKm: 119925, averageMileagePerVin: 193.3, averageDailyMileageKm: 96.65,
|
||||
trend: [], ranking: [{ vin: 'LTEST000000000001', plate: '粤A12345', mileageKm: 193.3, latestMileageKm: 119925, activeDays: 2 }],
|
||||
asOf: '2026-07-14 13:20:00', evidence: 'production mileage evidence'
|
||||
});
|
||||
mocks.dailyMileage.mockResolvedValue({ items: [
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, source: 'GB32960' }
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, hydrogenConsumptionKg: 3.1, hydrogenConsumptionKgPer100Km: 3.5, source: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, hydrogenConsumptionKg: 4.2, hydrogenConsumptionKgPer100Km: 4, source: 'GB32960' }
|
||||
], total: 2, limit: 10000, offset: 0 });
|
||||
mocks.vehicles.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345', phone: '', oem: '', protocol: 'GB32960', online: true, lastSeen: '', locationText: '', bindingScore: 100 }], total: 1, limit: 12, offset: 0 });
|
||||
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 20, offset: 0 });
|
||||
mocks.vehicleServiceOverviews.mockResolvedValue({ items: [], total: 0, limit: 200, offset: 0 });
|
||||
}
|
||||
|
||||
test('parses Excel cells and common separators while removing headers and duplicates', () => {
|
||||
expect(parseMileageVehicleIdentifiers('车牌\tVIN\n粤A12345\tLTEST000000000001,粤A12345;粤B67890|粤C24680')).toEqual([
|
||||
'粤A12345', 'LTEST000000000001', '粤B67890', '粤C24680'
|
||||
]);
|
||||
});
|
||||
|
||||
test('refreshes only ranges that include the current business date', () => {
|
||||
expect(mileageRangeContainsDate({ dateFrom: '2026-07-13', dateTo: '2026-07-19' }, '2026-07-19')).toBe(true);
|
||||
expect(mileageRangeContainsDate({ dateFrom: '2026-07-19', dateTo: '2026-07-19' }, '2026-07-19')).toBe(true);
|
||||
@@ -68,9 +80,9 @@ test('renders only the desktop matrix with dates as columns and a period total',
|
||||
expect(screen.queryByText('数据来源')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('img', { name: '每日行驶里程趋势图' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('车辆里程排名')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('已绑定主车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('已选择 1 辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('已绑定主车辆').closest('[role="listitem"]')).toHaveClass('is-primary', 'is-success');
|
||||
expect(screen.getByText('有里程车辆')).toBeInTheDocument();
|
||||
expect(screen.getByText('100% 覆盖 · 未上报不计为 0')).toBeInTheDocument();
|
||||
expect(screen.getByText('有里程车辆').closest('[role="listitem"]')).toHaveClass('is-primary', 'is-success');
|
||||
expect(screen.getByText('车辆每日里程').closest('.semi-card')).toHaveClass('v2-mileage-results');
|
||||
expect(view.container.querySelector('.v2-mileage-query-panel.semi-card')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-discovery-shell > .v2-mileage-command-bar')).not.toBeInTheDocument();
|
||||
@@ -78,15 +90,18 @@ test('renders only the desktop matrix with dates as columns and a period total',
|
||||
expect(view.container.querySelector('.v2-mileage-summary')).toHaveClass('v2-workspace-metric-rail', 'is-queue');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveAttribute('aria-label', '里程查询统计信息');
|
||||
expect(view.container.querySelectorAll('.v2-mileage-summary .v2-workspace-metric-list > span')).toHaveLength(3);
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list > span.is-primary')).toHaveTextContent('区间总里程193.3 km2 条车辆日记录');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('已绑定主车辆1 辆已选择 1 辆');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('日均里程96.7 km按有效车辆日平均');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list > span.is-primary')).toHaveTextContent('区间总里程193.3 km2 条有效车辆日');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('日均里程96.7 km2 个自然日');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('有里程车辆1 / 1 辆100% 覆盖 · 未上报不计为 0');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).not.toHaveTextContent('区间氢耗');
|
||||
expect(screen.queryByText('3.1 kg')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-summary-window')).toHaveTextContent('统计窗口2 天2026/07/13 — 07/14');
|
||||
expect(view.container.querySelector('.v2-mileage-table-wrap')).toBeInTheDocument();
|
||||
expect(screen.getByRole('note', { name: '里程矩阵读表说明' })).toHaveTextContent('0 km已上报、无里程增量');
|
||||
expect(screen.getByRole('note', { name: '里程矩阵读表说明' })).toHaveTextContent('本页 1 / 1 辆有数据');
|
||||
expect(screen.getByRole('note', { name: '里程矩阵读表说明' })).toHaveTextContent('—无可用里程');
|
||||
expect(screen.getByRole('note', { name: '里程矩阵读表说明' })).toHaveTextContent('颜色越深、里程越高');
|
||||
expect(screen.getByLabelText('移动端里程矩阵读表说明')).toHaveTextContent('左右滑动日期 · 车牌与总里程固定');
|
||||
expect(screen.getByLabelText('移动端里程矩阵读表说明')).toHaveTextContent('本页 1/1 辆有数据 · 左右滑动日期');
|
||||
expect(screen.getByLabelText('0 公里表示已上报、无里程增量')).toHaveTextContent('0 km无增量');
|
||||
expect(screen.getByLabelText('横线表示无可用里程')).toHaveTextContent('—无数据');
|
||||
expect(screen.getByLabelText('颜色越深、里程越高')).toHaveTextContent('色阶深色更高');
|
||||
@@ -101,13 +116,62 @@ test('renders only the desktop matrix with dates as columns and a period total',
|
||||
});
|
||||
fireEvent.keyDown(matrixRegion, { key: 'ArrowRight' });
|
||||
expect(matrixScroller!.scrollLeft).toBe(96);
|
||||
expect(view.container.querySelector('[aria-label="2026-07-13,88.7 公里,来源 GB32960"]')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('[aria-label="2026-07-13,里程 88.7 公里,来源 GB32960"]')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-table-wrap > table')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-mobile-list')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '氢耗' }));
|
||||
expect(await screen.findByRole('heading', { name: '车辆每日氢耗' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('note', { name: '氢耗矩阵读表说明' })).toHaveTextContent('每日耗氢量');
|
||||
expect(screen.getByRole('region', { name: '车辆每日氢耗矩阵,可横向滚动查看日期' })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('氢耗匹配里程193.3 km按有氢耗数据的车辆日总里程计算');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('区间氢耗7.3 kg3.8 kg/100km · 2 个有效车辆日');
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('百公里氢耗3.8 kg/100km2 个有效车辆日');
|
||||
expect(screen.getAllByText('3.1 kg').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('4.2 kg').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('7.3 kg').length).toBeGreaterThan(0);
|
||||
await waitFor(() => expect(mocks.dailyMileage).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.dailyMileage.mock.calls[0][0].get('limit')).toBe('10000');
|
||||
expect(mocks.dailyMileage.mock.calls[0][0].get('protocols')).toBe('GB32960,JT808,YUTONG_MQTT');
|
||||
expect(mocks.dailyMileage.mock.calls[0][0].limit).toBe(10000);
|
||||
expect(mocks.dailyMileage.mock.calls[0][0].protocols).toEqual(['GB32960', 'JT808', 'YUTONG_MQTT']);
|
||||
});
|
||||
|
||||
test('distinguishes missing mileage from a reported zero and explains partial coverage', async () => {
|
||||
prepareData();
|
||||
mocks.mileageStatistics.mockResolvedValue({
|
||||
dateFrom: '2026-07-13', dateTo: '2026-07-14', vehicleCount: 0, recordCount: 0, sourceCount: 0,
|
||||
periodMileageKm: 0, periodPureHydrogenMileageKm: 0, fleetLatestMileageKm: 0, averageMileagePerVin: 0, averageDailyMileageKm: 0,
|
||||
trend: [], ranking: [], asOf: '2026-07-14 13:20:00', evidence: 'no mileage evidence'
|
||||
});
|
||||
mocks.dailyMileage.mockResolvedValue({ items: [], total: 0, limit: 10000, offset: 0 });
|
||||
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
|
||||
expect(await screen.findByText('0% 覆盖 · 未上报不计为 0')).toBeInTheDocument();
|
||||
expect(screen.getByRole('note', { name: '里程矩阵读表说明' })).toHaveTextContent('本页 0 / 1 辆有数据');
|
||||
expect(screen.getByRole('gridcell', { name: '区间总里程,无可用里程' })).toHaveTextContent('—');
|
||||
});
|
||||
|
||||
test('does not expose the hydrogen consumption view to business customers', async () => {
|
||||
auth.role = 'customer';
|
||||
auth.userType = 'customer';
|
||||
prepareData();
|
||||
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '车辆每日里程' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '氢耗' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('group', { name: '数据视图' })).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).not.toHaveTextContent('区间氢耗');
|
||||
expect(screen.queryByText('纯氢里程 56.2 km')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows query failures inside the result panel without a misleading empty matrix', async () => {
|
||||
prepareData();
|
||||
mocks.dailyMileage.mockRejectedValue(new Error('日里程服务超时'));
|
||||
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('日里程服务超时');
|
||||
expect(view.container.querySelector('.v2-mileage-results [role="alert"]')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('region', { name: '车辆每日里程矩阵,可横向滚动查看日期' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('note', { name: '里程矩阵读表说明' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('defaults to seven days and supports today and yesterday shortcuts', async () => {
|
||||
@@ -119,24 +183,24 @@ test('defaults to seven days and supports today and yesterday shortcuts', async
|
||||
const sevenDaysAgo = localDate(new Date(Date.now() - 6 * 86_400_000));
|
||||
|
||||
await waitFor(() => expect(mocks.mileageStatistics).toHaveBeenCalled());
|
||||
expect(mocks.mileageStatistics.mock.calls[0][0].get('dateFrom')).toBe(sevenDaysAgo);
|
||||
expect(mocks.mileageStatistics.mock.calls[0][0].get('dateTo')).toBe(today);
|
||||
expect(mocks.mileageStatistics.mock.calls[0][0].dateFrom).toBe(sevenDaysAgo);
|
||||
expect(mocks.mileageStatistics.mock.calls[0][0].dateTo).toBe(today);
|
||||
expect(screen.getByRole('button', { name: '近 7 天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: '今天' })).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '昨天' }));
|
||||
await waitFor(() => {
|
||||
const params = mocks.mileageStatistics.mock.calls[mocks.mileageStatistics.mock.calls.length - 1]?.[0];
|
||||
expect(params.get('dateFrom')).toBe(yesterday);
|
||||
expect(params.get('dateTo')).toBe(yesterday);
|
||||
expect(params.dateFrom).toBe(yesterday);
|
||||
expect(params.dateTo).toBe(yesterday);
|
||||
});
|
||||
expect(screen.getByRole('button', { name: '昨天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '今天' }));
|
||||
await waitFor(() => {
|
||||
const params = mocks.mileageStatistics.mock.calls[mocks.mileageStatistics.mock.calls.length - 1]?.[0];
|
||||
expect(params.get('dateFrom')).toBe(today);
|
||||
expect(params.get('dateTo')).toBe(today);
|
||||
expect(params.dateFrom).toBe(today);
|
||||
expect(params.dateTo).toBe(today);
|
||||
});
|
||||
expect(screen.getByRole('button', { name: '今天' })).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
@@ -155,6 +219,17 @@ test('preserves the exact monitor return after a mileage date shortcut', async (
|
||||
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
|
||||
});
|
||||
|
||||
test('preserves the nearest vehicle detail return after a mileage date shortcut', async () => {
|
||||
prepareData();
|
||||
const vehiclePath = buildVehicleDetailPath('LTEST000000000001', { directoryReturn: '/vehicles?vehicleView=online&vehiclePage=2' });
|
||||
renderPage(withVehicleReturn('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14', vehiclePath));
|
||||
|
||||
expect(await screen.findByRole('link', { name: /返回车辆档案/ })).toHaveAttribute('href', vehiclePath);
|
||||
fireEvent.click(screen.getByRole('button', { name: '今天' }));
|
||||
|
||||
expect(await screen.findByRole('link', { name: /返回车辆档案/ })).toHaveAttribute('href', vehiclePath);
|
||||
});
|
||||
|
||||
test('rejects overlong mileage ranges before querying or rendering a huge matrix', async () => {
|
||||
prepareData();
|
||||
const view = renderPage('/statistics?vins=LTEST000000000001&dateFrom=2025-01-01&dateTo=2026-07-14');
|
||||
@@ -252,12 +327,42 @@ test('supports searching and selecting license plates before querying exact VINs
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() => {
|
||||
const calls = mocks.mileageStatistics.mock.calls;
|
||||
expect(calls[calls.length - 1]?.[0].get('vins')).toBe('LTEST000000000001');
|
||||
expect(calls[calls.length - 1]?.[0].vins).toEqual(['LTEST000000000001']);
|
||||
});
|
||||
expect(document.querySelector('.v2-mileage-chip')).toHaveClass('semi-tag');
|
||||
expect(document.querySelector('.v2-mileage-chip')).toHaveTextContent('粤A12345');
|
||||
});
|
||||
|
||||
test('resolves Excel-pasted plates in batches and queries the resulting VIN array', async () => {
|
||||
prepareData();
|
||||
mocks.vehicleServiceOverviews.mockResolvedValue({
|
||||
items: [
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345' },
|
||||
{ vin: 'LTEST000000000002', plate: '粤B67890' }
|
||||
],
|
||||
total: 2,
|
||||
limit: 200,
|
||||
offset: 0
|
||||
});
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '批量粘贴' }));
|
||||
const textarea = await screen.findByLabelText('从 Excel 复制后直接粘贴,无需整理格式');
|
||||
fireEvent.change(textarea, { target: { value: '车牌\t备注\n粤A12345,粤B67890;粤A12345' } });
|
||||
expect(screen.getByRole('button', { name: '解析并添加(2)' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '解析并添加(2)' }));
|
||||
|
||||
await waitFor(() => expect(mocks.vehicleServiceOverviews).toHaveBeenCalledWith({
|
||||
keywords: ['粤A12345', '粤B67890'], limit: 200, offset: 0
|
||||
}));
|
||||
expect(await screen.findByText(/已新增 2 辆/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() => {
|
||||
const query = mocks.mileageStatistics.mock.calls[mocks.mileageStatistics.mock.calls.length - 1]?.[0];
|
||||
expect(query.vins).toEqual(['LTEST000000000001', 'LTEST000000000002']);
|
||||
});
|
||||
});
|
||||
|
||||
test('shows and recovers from a failed license plate candidate query', async () => {
|
||||
prepareData();
|
||||
mocks.vehicles.mockRejectedValueOnce(new Error('车辆目录查询超时')).mockResolvedValueOnce({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 12, offset: 0 });
|
||||
@@ -273,11 +378,16 @@ test('shows and recovers from a failed license plate candidate query', async ()
|
||||
expect(mocks.vehicles).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('lets users disable mileage sources and persists the source priority', async () => {
|
||||
test('keeps mileage source strategy anchored to its desktop trigger and persists priority changes', async () => {
|
||||
prepareData();
|
||||
renderPage('/statistics?vins=LTEST000000000001&dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
fireEvent.click(screen.getByRole('button', { name: /数据源/ }));
|
||||
expect(screen.getByRole('dialog', { name: '数据源策略配置' })).toBeInTheDocument();
|
||||
const trigger = screen.getByRole('button', { name: /数据源/ });
|
||||
fireEvent.click(trigger);
|
||||
const strategyPanel = screen.getByRole('dialog', { name: '数据源策略' });
|
||||
expect(strategyPanel).toHaveClass('v2-mileage-source-popover');
|
||||
expect(strategyPanel).toHaveAttribute('aria-modal', 'false');
|
||||
expect(strategyPanel).toHaveFocus();
|
||||
expect(document.querySelector('.v2-mileage-source-sidesheet')).not.toBeInTheDocument();
|
||||
expect(document.querySelectorAll('.v2-mileage-source-card.semi-card')).toHaveLength(3);
|
||||
expect(screen.getByText('优先级 1').closest('.semi-tag')).toHaveClass('v2-mileage-source-priority');
|
||||
expect(screen.getByText('GPS 里程')).toBeInTheDocument();
|
||||
@@ -289,10 +399,13 @@ test('lets users disable mileage sources and persists the source priority', asyn
|
||||
fireEvent.click(screen.getByRole('button', { name: '查询' }));
|
||||
await waitFor(() => {
|
||||
const calls = mocks.dailyMileage.mock.calls;
|
||||
expect(calls[calls.length - 1]?.[0].get('protocols')).toBe('YUTONG_MQTT,JT808');
|
||||
expect(calls[calls.length - 1]?.[0].protocols).toEqual(['YUTONG_MQTT', 'JT808']);
|
||||
});
|
||||
expect(window.localStorage.getItem('vehicle-platform:mileage-source-strategy')).toContain('YUTONG_MQTT');
|
||||
expect(screen.getByText('来源优先级:YUTONG_MQTT > JT808')).toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog', { name: '数据源策略' })).not.toBeInTheDocument();
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
|
||||
test('uses a compact bottom sheet for mileage source strategy on mobile', async () => {
|
||||
@@ -351,9 +464,10 @@ test('paginates all unique vehicles when no license plate is selected', async ()
|
||||
expect((await screen.findAllByText('粤A00001')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('共 32 辆 · 每页 20 辆')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-table-pagination .semi-page-item-small')).toHaveTextContent('1/2');
|
||||
expect(screen.getByText('档案口径 · 1 辆有里程')).toBeInTheDocument();
|
||||
expect(screen.getByText('3% 覆盖 · 未上报不计为 0')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-mileage-summary .v2-workspace-metric-list')).toHaveTextContent('有里程车辆1 / 32 辆3% 覆盖 · 未上报不计为 0');
|
||||
expect(mocks.vehicleCoverage.mock.calls[0][0].get('bindingStatus')).toBe('bound');
|
||||
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].get('vins')).toContain('VIN00000000000001'));
|
||||
await waitFor(() => expect(mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1]?.[0].vins).toContain('VIN00000000000001'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
expect((await screen.findAllByText('粤A00021')).length).toBeGreaterThan(0);
|
||||
@@ -368,9 +482,9 @@ test('exports the full fleet mileage with one paginated query instead of VIN bat
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-13', startMileageKm: 119731.7, endMileageKm: 119820.4, dailyMileageKm: 88.7, source: 'GB32960' },
|
||||
{ vin: 'LTEST000000000001', plate: '粤A12345', date: '2026-07-14', startMileageKm: 119820.4, endMileageKm: 119925, dailyMileageKm: 104.6, source: 'GB32960' }
|
||||
];
|
||||
mocks.dailyMileage.mockImplementation((params: URLSearchParams) => {
|
||||
if (params.get('vehicleScope') !== 'bound') return Promise.resolve({ items: exportRows, total: 2, limit: 10000, offset: 0 });
|
||||
const offset = Number(params.get('offset') ?? 0);
|
||||
mocks.dailyMileage.mockImplementation((query: { vehicleScope?: string; offset?: number }) => {
|
||||
if (query.vehicleScope !== 'bound') return Promise.resolve({ items: exportRows, total: 2, limit: 10000, offset: 0 });
|
||||
const offset = query.offset ?? 0;
|
||||
return Promise.resolve({ items: exportRows.slice(offset, offset + 1), total: 2, limit: 1, offset });
|
||||
});
|
||||
renderPage('/statistics?dateFrom=2026-07-13&dateTo=2026-07-14');
|
||||
@@ -379,9 +493,9 @@ test('exports the full fleet mileage with one paginated query instead of VIN bat
|
||||
fireEvent.click(screen.getByRole('button', { name: /导出 Excel/ }));
|
||||
|
||||
await waitFor(() => expect(exportMocks.finish).toHaveBeenCalledTimes(1));
|
||||
const exportMileageCall = mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][0] as URLSearchParams;
|
||||
expect(exportMileageCall.get('vins')).toBeNull();
|
||||
expect(exportMileageCall.get('vehicleScope')).toBe('bound');
|
||||
const exportMileageCall = mocks.dailyMileage.mock.calls[mocks.dailyMileage.mock.calls.length - 1][0];
|
||||
expect(exportMileageCall.vins).toBeUndefined();
|
||||
expect(exportMileageCall.vehicleScope).toBe('bound');
|
||||
expect(exportMocks.appendRows).toHaveBeenCalledTimes(2);
|
||||
expect(exportMocks.appendRows.mock.calls.map(([rows]) => rows.map((row: { date: string }) => row.date))).toEqual([['2026-07-13'], ['2026-07-14']]);
|
||||
expect(exportMocks.finish.mock.calls[0][0]).toHaveLength(1);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IconArrowDown, IconArrowUp, IconClose, IconDownload, IconInfoCircle, IconRefresh, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||||
import { Button, ButtonGroup, Card, DatePicker, Input, Progress, Spin, Switch, Table, Tag } from '@douyinfe/semi-ui';
|
||||
import { Button, ButtonGroup, Card, DatePicker, Input, Progress, Spin, Switch, Table, Tag, TextArea } from '@douyinfe/semi-ui';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { type CSSProperties, FormEvent, type KeyboardEvent, memo, type RefObject, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import { api, type MileageQuery } from '../../api/client';
|
||||
import type { DailyMileageRow, MileageStatistics, Page, VehicleRow } from '../../api/types';
|
||||
import { createMileageExportStream, type MileageExportStream } from '../domain/mileageExport';
|
||||
import { formatZhNumber } from '../domain/formatters';
|
||||
@@ -20,11 +20,15 @@ import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||||
import { QUERY_MEMORY, retainPreviousPageWithinScope } from '../queryPolicy';
|
||||
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
|
||||
import { preserveVehicleReturn, vehicleReturnFromParams } from '../routing/vehicleContext';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const DETAIL_LIMIT = 10_000;
|
||||
const MAX_SELECTED_VEHICLES = 20;
|
||||
const MAX_MILEAGE_VEHICLES = 50_000;
|
||||
const LEGACY_URL_VEHICLE_LIMIT = 20;
|
||||
const BATCH_RESOLVE_SIZE = 200;
|
||||
const PAGE_SIZE = 20;
|
||||
const EXPORT_VEHICLE_PAGE_SIZE = 2_000;
|
||||
const EXPORT_VIN_BATCH_SIZE = 50;
|
||||
@@ -36,6 +40,7 @@ type MileageProtocol = 'GB32960' | 'JT808' | 'YUTONG_MQTT';
|
||||
type MileageSourceOption = { protocol: MileageProtocol; label: string; mileageType: string; enabled: boolean };
|
||||
type Criteria = { vehicles: VehicleOption[]; dateFrom: string; dateTo: string; sources: MileageSourceOption[] };
|
||||
type ExportProgress = { label: string; completed?: number; total?: number };
|
||||
type MetricView = 'mileage' | 'hydrogen';
|
||||
|
||||
const SOURCE_STORAGE_KEY = 'vehicle-platform:mileage-source-strategy';
|
||||
const DEFAULT_SOURCES: MileageSourceOption[] = [
|
||||
@@ -93,7 +98,7 @@ export function mileageRangeContainsDate(
|
||||
return Boolean(range.dateFrom && range.dateTo && range.dateFrom <= date && range.dateTo >= date);
|
||||
}
|
||||
|
||||
function formatKm(value?: number) {
|
||||
function formatKm(value?: number | null) {
|
||||
if (value == null || !Number.isFinite(value)) return '—';
|
||||
return formatZhNumber(value, 1);
|
||||
}
|
||||
@@ -126,22 +131,32 @@ function datePickerRange(dateValue?: Date | Date[] | string | string[], formatte
|
||||
return { dateFrom: normalized[0], dateTo: normalized[1] };
|
||||
}
|
||||
|
||||
function mileageParams(criteria: Criteria, offset = 0) {
|
||||
const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
|
||||
if (criteria.vehicles.length) params.set('vins', criteria.vehicles.map((vehicle) => vehicle.vin).join(','));
|
||||
else params.set('vehicleScope', 'bound');
|
||||
params.set('protocols', criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(','));
|
||||
function mileageQuery(criteria: Criteria, offset = 0): MileageQuery {
|
||||
const query: MileageQuery = {
|
||||
dateFrom: criteria.dateFrom,
|
||||
dateTo: criteria.dateTo,
|
||||
protocols: criteria.sources.filter((source) => source.enabled).map((source) => source.protocol)
|
||||
};
|
||||
if (criteria.vehicles.length) query.vins = criteria.vehicles.map((vehicle) => vehicle.vin);
|
||||
else query.vehicleScope = 'bound';
|
||||
if (offset >= 0) {
|
||||
params.set('deduplicate', '1');
|
||||
params.set('limit', String(DETAIL_LIMIT));
|
||||
params.set('offset', String(offset));
|
||||
query.deduplicate = true;
|
||||
query.limit = DETAIL_LIMIT;
|
||||
query.offset = offset;
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
function mileageRouteParams(criteria: Criteria) {
|
||||
const params = new URLSearchParams({ dateFrom: criteria.dateFrom, dateTo: criteria.dateTo });
|
||||
params.set('protocols', criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(','));
|
||||
if (criteria.vehicles.length <= LEGACY_URL_VEHICLE_LIMIT) params.set('vins', criteria.vehicles.map((vehicle) => vehicle.vin).join(','));
|
||||
return params;
|
||||
}
|
||||
|
||||
function initialCriteria(searchParams: URLSearchParams): Criteria {
|
||||
const defaults = defaultWindow();
|
||||
const vins = (searchParams.get('vins') ?? '').split(',').map((vin) => vin.trim()).filter(Boolean).slice(0, MAX_SELECTED_VEHICLES);
|
||||
const vins = (searchParams.get('vins') ?? '').split(',').map((vin) => vin.trim()).filter(Boolean).slice(0, LEGACY_URL_VEHICLE_LIMIT);
|
||||
const requestedProtocols = (searchParams.get('protocols') ?? '').split(',').filter((protocol): protocol is MileageProtocol => DEFAULT_SOURCES.some((source) => source.protocol === protocol));
|
||||
let sources = DEFAULT_SOURCES.map((source) => ({ ...source }));
|
||||
if (requestedProtocols.length) {
|
||||
@@ -167,8 +182,15 @@ function initialCriteria(searchParams: URLSearchParams): Criteria {
|
||||
function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onChange: (sources: MileageSourceOption[]) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const mobileLayout = useMobileLayout();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const enabled = value.filter((source) => source.enabled);
|
||||
const primarySource = enabled[0];
|
||||
const focusTrigger = () => rootRef.current?.querySelector<HTMLButtonElement>('.v2-mileage-source-trigger')?.focus();
|
||||
const closeDesktopPanel = () => {
|
||||
setOpen(false);
|
||||
window.requestAnimationFrame(focusTrigger);
|
||||
};
|
||||
const update = (sources: MileageSourceOption[]) => {
|
||||
onChange(sources);
|
||||
try { window.localStorage.setItem(SOURCE_STORAGE_KEY, JSON.stringify(sources)); } catch { /* preference persistence is optional */ }
|
||||
@@ -186,7 +208,34 @@ function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onC
|
||||
update(value.map((source) => source.protocol === protocol ? { ...source, enabled: !source.enabled } : source));
|
||||
};
|
||||
|
||||
return <div className="v2-mileage-source-strategy">
|
||||
useEffect(() => {
|
||||
if (!open || mobileLayout) return;
|
||||
popoverRef.current?.focus();
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const closeOnEscape = (event: globalThis.KeyboardEvent) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
focusTrigger();
|
||||
};
|
||||
document.addEventListener('mousedown', closeOnOutsideClick);
|
||||
document.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', closeOnOutsideClick);
|
||||
document.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [mobileLayout, open]);
|
||||
|
||||
const sourceList = <div className="v2-mileage-source-list">{value.map((source, index) => <Card key={source.protocol} className={`v2-mileage-source-card${source.enabled ? '' : ' is-disabled'}`} bodyStyle={{ padding: 0 }}>
|
||||
<Switch className="v2-mileage-source-switch" checked={source.enabled} aria-label={`${source.enabled ? '禁用' : '启用'} ${source.label}`} onChange={() => toggle(source.protocol)} />
|
||||
<div className="v2-mileage-source-copy"><strong>{source.label}</strong><small><Tag color="blue" type="light" size="small">{source.mileageType}</Tag><code>{source.protocol}</code></small></div>
|
||||
<Tag className="v2-mileage-source-priority" color={source.enabled ? 'blue' : 'grey'} type="light" size="small">{source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'}</Tag>
|
||||
<div className="v2-mileage-source-order"><Button theme="borderless" aria-label={`上移 ${source.label}`} icon={<IconArrowUp />} disabled={index === 0} onClick={() => move(index, -1)} /><Button theme="borderless" aria-label={`下移 ${source.label}`} icon={<IconArrowDown />} disabled={index === value.length - 1} onClick={() => move(index, 1)} /></div>
|
||||
</Card>)}</div>;
|
||||
|
||||
return <div ref={rootRef} className="v2-mileage-source-strategy">
|
||||
<Button
|
||||
className="v2-mileage-source-trigger"
|
||||
theme="light"
|
||||
@@ -200,14 +249,42 @@ function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onC
|
||||
>
|
||||
<span>数据源</span><em>{primarySource?.protocol ?? '未配置'} 优先</em><b>{enabled.length}/3</b>
|
||||
</Button>
|
||||
<WorkspaceSideSheet
|
||||
{!mobileLayout && open ? <div
|
||||
ref={popoverRef}
|
||||
id="v2-mileage-source-strategy"
|
||||
className="v2-mileage-source-popover"
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-labelledby="v2-mileage-source-popover-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header className="v2-mileage-source-popover-header">
|
||||
<span className="v2-mileage-source-popover-icon" aria-hidden="true"><IconSetting /></span>
|
||||
<div>
|
||||
<strong id="v2-mileage-source-popover-title">数据源策略</strong>
|
||||
<small>排序决定同车同日的里程取值优先级</small>
|
||||
</div>
|
||||
<Tag color={enabled.length === 3 ? 'green' : 'blue'} type="light" size="small">{enabled.length}/3 已启用</Tag>
|
||||
<Button theme="borderless" aria-label="关闭数据源策略" icon={<IconClose />} onClick={closeDesktopPanel} />
|
||||
</header>
|
||||
<div className="v2-mileage-source-popover-summary" aria-live="polite">
|
||||
<span><small>当前首选</small><strong>{primarySource?.protocol ?? '未配置'}</strong><em>{primarySource?.mileageType ?? '至少保留一个来源'}</em></span>
|
||||
<p><IconInfoCircle />首选无有效增量时,自动尝试下一来源</p>
|
||||
</div>
|
||||
{sourceList}
|
||||
<footer className="v2-mileage-source-popover-footer">
|
||||
<span>更改即时生效,并保存在此浏览器</span>
|
||||
<Button theme="borderless" type="tertiary" onClick={closeDesktopPanel}>收起</Button>
|
||||
</footer>
|
||||
</div> : null}
|
||||
{mobileLayout ? <WorkspaceSideSheet
|
||||
className="v2-mileage-source-sidesheet"
|
||||
visible={open}
|
||||
ariaLabel="数据源策略配置"
|
||||
closeLabel="关闭数据源策略"
|
||||
placement={mobileLayout ? 'bottom' : 'right'}
|
||||
width={mobileLayout ? undefined : 430}
|
||||
height={mobileLayout ? 'min(58dvh, 500px)' : undefined}
|
||||
dialogId="v2-mileage-source-strategy"
|
||||
placement="bottom"
|
||||
height="min(58dvh, 500px)"
|
||||
title="数据源策略"
|
||||
description="同车同日优先采用靠前且有有效增量的里程来源"
|
||||
badge={`${enabled.length}/3 已启用`}
|
||||
@@ -221,20 +298,130 @@ function SourceStrategy({ value, onChange }: { value: MileageSourceOption[]; onC
|
||||
footerNote="首选来源为 0、其他来源有有效增量时会自动降级;全部为 0 时仍按优先级选择。"
|
||||
primaryAction={{ label: '完成', onClick: () => setOpen(false) }}
|
||||
>
|
||||
<div className="v2-mileage-source-list">{value.map((source, index) => <Card key={source.protocol} className={`v2-mileage-source-card${source.enabled ? '' : ' is-disabled'}`} bodyStyle={{ padding: 0 }}>
|
||||
<Switch className="v2-mileage-source-switch" checked={source.enabled} aria-label={`${source.enabled ? '禁用' : '启用'} ${source.label}`} onChange={() => toggle(source.protocol)} />
|
||||
<div className="v2-mileage-source-copy"><strong>{source.label}</strong><small><Tag color="blue" type="light" size="small">{source.mileageType}</Tag><code>{source.protocol}</code></small></div>
|
||||
<Tag className="v2-mileage-source-priority" color={source.enabled ? 'blue' : 'grey'} type="light" size="small">{source.enabled ? `优先级 ${enabled.findIndex((item) => item.protocol === source.protocol) + 1}` : '已禁用'}</Tag>
|
||||
<div className="v2-mileage-source-order"><Button theme="borderless" aria-label={`上移 ${source.label}`} icon={<IconArrowUp />} disabled={index === 0} onClick={() => move(index, -1)} /><Button theme="borderless" aria-label={`下移 ${source.label}`} icon={<IconArrowDown />} disabled={index === value.length - 1} onClick={() => move(index, 1)} /></div>
|
||||
</Card>)}</div>
|
||||
</WorkspaceSideSheet>
|
||||
{sourceList}
|
||||
</WorkspaceSideSheet> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function parseMileageVehicleIdentifiers(value: string) {
|
||||
const ignoredHeaders = new Set(['车牌', '车牌号', '车牌号码', 'vin', 'vin码', '车架号', '车辆识别代号', '序号', '备注']);
|
||||
const identifiers: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const raw of value.split(/[\s,,、;;|]+/u)) {
|
||||
const identifier = raw.trim();
|
||||
const key = identifier.toLocaleUpperCase();
|
||||
if (!identifier || ignoredHeaders.has(key.toLocaleLowerCase()) || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
identifiers.push(key);
|
||||
if (identifiers.length === MAX_MILEAGE_VEHICLES) break;
|
||||
}
|
||||
return identifiers;
|
||||
}
|
||||
|
||||
function isVIN(value: string) {
|
||||
return /^[A-HJ-NPR-Z0-9]{17}$/i.test(value);
|
||||
}
|
||||
|
||||
async function resolveMileageVehicles(identifiers: string[]) {
|
||||
const vehicles: VehicleOption[] = [];
|
||||
const missing: string[] = [];
|
||||
const seenVINs = new Set<string>();
|
||||
const plates = identifiers.filter((identifier) => !isVIN(identifier));
|
||||
for (const vin of identifiers.filter(isVIN)) {
|
||||
const normalized = vin.toLocaleUpperCase();
|
||||
if (!seenVINs.has(normalized)) {
|
||||
seenVINs.add(normalized);
|
||||
vehicles.push({ vin: normalized, plate: '' });
|
||||
}
|
||||
}
|
||||
for (let start = 0; start < plates.length; start += BATCH_RESOLVE_SIZE * 4) {
|
||||
const group = Array.from({ length: 4 }, (_, index) => plates.slice(start + index * BATCH_RESOLVE_SIZE, start + (index + 1) * BATCH_RESOLVE_SIZE)).filter((batch) => batch.length);
|
||||
const pages = await Promise.all(group.map((keywords) => api.vehicleServiceOverviews({ keywords, limit: BATCH_RESOLVE_SIZE, offset: 0 })));
|
||||
pages.forEach((page, pageIndex) => {
|
||||
const keywords = group[pageIndex];
|
||||
keywords.forEach((keyword, index) => {
|
||||
const match = page.items[index];
|
||||
const vin = match?.vin?.trim().toLocaleUpperCase();
|
||||
if (!vin) {
|
||||
missing.push(keyword);
|
||||
return;
|
||||
}
|
||||
if (!seenVINs.has(vin)) {
|
||||
seenVINs.add(vin);
|
||||
vehicles.push({ vin, plate: match.plate || keyword });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
return { vehicles, missing };
|
||||
}
|
||||
|
||||
function MileageBatchVehicleDialog({ visible, value, mobile, onApply, onClose }: { visible: boolean; value: VehicleOption[]; mobile: boolean; onApply: (vehicles: VehicleOption[], feedback: string) => void; onClose: () => void }) {
|
||||
const [draft, setDraft] = useState('');
|
||||
const [resolving, setResolving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const identifiers = useMemo(() => parseMileageVehicleIdentifiers(draft), [draft]);
|
||||
const apply = async () => {
|
||||
if (!identifiers.length || resolving) return;
|
||||
setResolving(true);
|
||||
setError('');
|
||||
try {
|
||||
const resolved = await resolveMileageVehicles(identifiers);
|
||||
const merged = [...value];
|
||||
const seen = new Set(value.map((vehicle) => vehicle.vin));
|
||||
for (const vehicle of resolved.vehicles) if (!seen.has(vehicle.vin)) { seen.add(vehicle.vin); merged.push(vehicle); }
|
||||
const added = merged.length - value.length;
|
||||
onApply(merged, resolved.missing.length
|
||||
? `已新增 ${added.toLocaleString('zh-CN')} 辆,${resolved.missing.length.toLocaleString('zh-CN')} 项未识别`
|
||||
: `已新增 ${added.toLocaleString('zh-CN')} 辆,重复项已自动去除`);
|
||||
onClose();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '批量车牌解析失败,请稍后重试');
|
||||
} finally {
|
||||
setResolving(false);
|
||||
}
|
||||
};
|
||||
return <WorkspaceSideSheet
|
||||
className="v2-monitor-batch-sidesheet v2-mileage-batch-sidesheet"
|
||||
variant="editor"
|
||||
visible={visible}
|
||||
ariaLabel="批量添加里程查询车辆"
|
||||
closeLabel="关闭批量添加车辆"
|
||||
dialogId="v2-mileage-batch-search"
|
||||
placement={mobile ? 'bottom' : 'right'}
|
||||
width={mobile ? undefined : 520}
|
||||
height={mobile ? 'min(86dvh, 700px)' : undefined}
|
||||
title="批量添加车辆"
|
||||
description="直接粘贴 Excel 单列、多列或文本中的车牌 / VIN"
|
||||
icon={<IconSearch />}
|
||||
badge={`${identifiers.length.toLocaleString('zh-CN')} 项`}
|
||||
badgeColor={identifiers.length ? 'blue' : 'grey'}
|
||||
summaryItems={[
|
||||
{ label: '已解析', value: identifiers.length.toLocaleString('zh-CN'), detail: '车牌与 VIN 可混合', tone: identifiers.length ? 'primary' : 'neutral' },
|
||||
{ label: '重复处理', value: '自动去重', detail: '含已有车辆与重复行', tone: 'success' },
|
||||
{ label: '单次容量', value: '50,000 辆', detail: '查询使用 POST 请求体' }
|
||||
]}
|
||||
footerNote="支持换行、Tab、空格、中英文逗号、分号、顿号和竖线。"
|
||||
secondaryActions={[{ label: '取消', onClick: onClose }]}
|
||||
primaryAction={{ label: `解析并添加(${identifiers.length.toLocaleString('zh-CN')})`, loading: resolving, disabled: !identifiers.length, onClick: apply }}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<div className="v2-batch-search-dialog">
|
||||
<label htmlFor="mileage-batch-vehicles">从 Excel 复制后直接粘贴,无需整理格式</label>
|
||||
<TextArea id="mileage-batch-vehicles" autoFocus value={draft} onChange={setDraft} autosize={{ minRows: 8, maxRows: 14 }} resize="vertical" placeholder={'车牌\tVIN\n粤A12345\tLTEST000000000001\n粤B67890'} />
|
||||
<div className="v2-batch-search-summary" role="status"><span>已解析 <strong>{identifiers.length.toLocaleString('zh-CN')}</strong> 项,重复项已去除</span><em>最多 50,000 辆</em></div>
|
||||
{error ? <p className="v2-mileage-batch-error" role="alert">{error}</p> : null}
|
||||
</div>
|
||||
</WorkspaceSideSheet>;
|
||||
}
|
||||
|
||||
function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onChange: (vehicles: VehicleOption[]) => void }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [batchFeedback, setBatchFeedback] = useState('');
|
||||
const mobile = useMobileLayout();
|
||||
const closeTimerRef = useRef<number>();
|
||||
useEffect(() => { const timer = window.setTimeout(() => setDebounced(search.trim()), 220); return () => window.clearTimeout(timer); }, [search]);
|
||||
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
|
||||
@@ -262,17 +449,19 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
|
||||
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
|
||||
|
||||
const add = (vehicle: VehicleRow) => {
|
||||
if (selected.has(vehicle.vin) || value.length >= MAX_SELECTED_VEHICLES) return;
|
||||
if (selected.has(vehicle.vin) || value.length >= MAX_MILEAGE_VEHICLES) return;
|
||||
onChange([...value, { vin: vehicle.vin, plate: vehicle.plate }]);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
return <label className="v2-mileage-vehicle-field">
|
||||
const visibleVehicles = value.slice(0, 3);
|
||||
|
||||
return <div className="v2-mileage-vehicle-field">
|
||||
<span>车牌</span>
|
||||
<div className={`v2-mileage-multiselect${open ? ' is-open' : ''}`}>
|
||||
<IconSearch />
|
||||
<div className="v2-mileage-selection">
|
||||
{value.map((vehicle) => <Tag
|
||||
{visibleVehicles.map((vehicle) => <Tag
|
||||
key={vehicle.vin}
|
||||
className="v2-mileage-chip"
|
||||
color="blue"
|
||||
@@ -285,8 +474,10 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
|
||||
>
|
||||
{vehicle.plate || vehicle.vin}
|
||||
</Tag>)}
|
||||
{value.length > visibleVehicles.length ? <Tag className="v2-mileage-chip is-overflow" color="blue" type="light">+{(value.length - visibleVehicles.length).toLocaleString('zh-CN')} 辆</Tag> : null}
|
||||
<Input borderless value={search} onFocus={openPicker} onBlur={closePicker} onChange={(next) => { setSearch(next); setOpen(true); }} placeholder={value.length ? '继续添加车牌' : '输入车牌搜索,可多选'} aria-label="搜索车牌" />
|
||||
</div>
|
||||
<button className="v2-mileage-batch-trigger" type="button" aria-expanded={batchOpen} aria-controls="v2-mileage-batch-search" onClick={() => { setOpen(false); setBatchOpen(true); }}>批量粘贴</button>
|
||||
{open ? <VehicleCandidateList
|
||||
className="v2-mileage-options"
|
||||
items={options}
|
||||
@@ -297,45 +488,84 @@ function VehicleMultiSelect({ value, onChange }: { value: VehicleOption[]; onCha
|
||||
selectedVins={selected}
|
||||
disableSelected
|
||||
header="车牌候选"
|
||||
meta={`${value.length}/${MAX_SELECTED_VEHICLES} 已选`}
|
||||
meta={`${value.length.toLocaleString('zh-CN')} 已选`}
|
||||
showProtocols
|
||||
onSelect={add}
|
||||
/> : null}
|
||||
</div>
|
||||
<small>支持车牌关键字搜索,最多选择 {MAX_SELECTED_VEHICLES} 辆</small>
|
||||
</label>;
|
||||
<small>{batchFeedback || '支持 Excel 粘贴与常见分隔符,最多 50,000 辆'}</small>
|
||||
<MileageBatchVehicleDialog visible={batchOpen} value={value} mobile={mobile} onClose={() => setBatchOpen(false)} onApply={(vehicles, feedback) => { onChange(vehicles); setBatchFeedback(feedback); }} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SummaryRail({ data, criteria, fleetTotal, loading }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number; loading: boolean }) {
|
||||
function MetricViewSwitch({ value, onChange }: { value: MetricView; onChange: (value: MetricView) => void }) {
|
||||
return <div className="v2-mileage-view-switch" role="group" aria-label="数据视图">
|
||||
<span>
|
||||
<strong>{value === 'mileage' ? '里程视图' : '氢耗视图'}</strong>
|
||||
<small>{value === 'mileage' ? '只看车辆行驶里程' : '按当日总里程计算氢耗'}</small>
|
||||
</span>
|
||||
<ButtonGroup aria-label="切换里程或氢耗视图">
|
||||
<Button theme={value === 'mileage' ? 'solid' : 'light'} aria-pressed={value === 'mileage'} onClick={() => onChange('mileage')}>里程</Button>
|
||||
<Button theme={value === 'hydrogen' ? 'solid' : 'light'} aria-pressed={value === 'hydrogen'} onClick={() => onChange('hydrogen')}>氢耗</Button>
|
||||
</ButtonGroup>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function SummaryRail({ data, criteria, fleetTotal, loading, view, unavailable = false }: { data?: MileageStatistics; criteria: Criteria; fleetTotal?: number; loading: boolean; view: MetricView; unavailable?: boolean }) {
|
||||
const days = inclusiveDays(criteria.dateFrom, criteria.dateTo);
|
||||
const vehicleCount = criteria.vehicles.length ? data?.vehicleCount ?? 0 : fleetTotal ?? 0;
|
||||
const scopeVehicleCount = criteria.vehicles.length || fleetTotal || 0;
|
||||
const measuredVehicleCount = data?.vehicleCount ?? 0;
|
||||
const coveragePercent = scopeVehicleCount ? Math.round(measuredVehicleCount / scopeVehicleCount * 100) : 0;
|
||||
const pending = loading || unavailable;
|
||||
const summaryRange = criteria.dateFrom.slice(0, 4) === criteria.dateTo.slice(0, 4)
|
||||
? `${criteria.dateFrom.replace(/-/g, '/')} — ${criteria.dateTo.slice(5).replace('-', '/')}`
|
||||
: `${criteria.dateFrom.replace(/-/g, '/')} — ${criteria.dateTo.replace(/-/g, '/')}`;
|
||||
const withUnit = (value: string | number, unit: string) => <>{value}<i className="v2-mileage-summary-unit"> {unit}</i></>;
|
||||
const items: WorkspaceQueueMetricRailItem[] = [
|
||||
{
|
||||
const items: WorkspaceQueueMetricRailItem[] = view === 'mileage' ? [{
|
||||
label: '区间总里程',
|
||||
value: loading ? '—' : withUnit(formatKm(data?.periodMileageKm), 'km'),
|
||||
note: loading ? '正在计算区间汇总' : `${data?.recordCount ?? 0} 条车辆日记录`,
|
||||
value: pending ? '—' : withUnit(formatKm(data?.periodMileageKm), 'km'),
|
||||
note: unavailable ? '汇总暂不可用' : loading ? '正在计算区间汇总' : `${data?.recordCount ?? 0} 条有效车辆日`,
|
||||
tone: 'primary',
|
||||
emphasis: 'primary'
|
||||
},
|
||||
{
|
||||
label: '已绑定主车辆',
|
||||
value: loading ? '—' : withUnit(vehicleCount, '辆'),
|
||||
note: loading ? '正在按新筛选范围查询' : criteria.vehicles.length ? `已选择 ${criteria.vehicles.length} 辆` : `档案口径 · ${data?.vehicleCount ?? 0} 辆有里程`,
|
||||
tone: 'success',
|
||||
emphasis: 'primary'
|
||||
},
|
||||
{
|
||||
label: '日均里程',
|
||||
value: loading ? '—' : withUnit(formatKm(data?.averageDailyMileageKm), 'km'),
|
||||
note: loading ? '正在计算有效车辆日' : '按有效车辆日平均',
|
||||
value: pending ? '—' : withUnit(formatKm(data?.averageDailyMileageKm), 'km'),
|
||||
note: unavailable ? '日均里程暂不可用' : loading ? '正在计算每日均值' : `${days} 个自然日`,
|
||||
tone: 'primary',
|
||||
emphasis: 'secondary'
|
||||
}
|
||||
];
|
||||
emphasis: 'primary'
|
||||
},
|
||||
{
|
||||
label: '有里程车辆',
|
||||
value: pending ? '—' : withUnit(`${measuredVehicleCount} / ${scopeVehicleCount}`, '辆'),
|
||||
note: unavailable ? '覆盖情况暂不可用' : loading ? '正在按新筛选范围查询' : `${coveragePercent}% 覆盖 · 未上报不计为 0`,
|
||||
tone: 'success',
|
||||
emphasis: 'primary'
|
||||
}] : [{
|
||||
label: '氢耗匹配里程',
|
||||
value: pending ? '—' : withUnit(formatKm(data?.hydrogenMatchedMileageKm), 'km'),
|
||||
note: unavailable ? '里程汇总暂不可用' : loading ? '正在汇总车辆日总里程' : '按有氢耗数据的车辆日总里程计算',
|
||||
tone: 'success',
|
||||
emphasis: 'primary'
|
||||
}, {
|
||||
label: '区间氢耗',
|
||||
value: pending || !data?.hydrogenDataDays ? '—' : withUnit(formatKm(data.periodHydrogenConsumptionKg), 'kg'),
|
||||
note: unavailable
|
||||
? '氢耗汇总暂不可用'
|
||||
: loading
|
||||
? '正在汇总质量下降'
|
||||
: data?.hydrogenDataDays
|
||||
? `${formatKm(data.hydrogenConsumptionKgPer100Km)} kg/100km · ${data.hydrogenDataDays} 个有效车辆日`
|
||||
: '暂无通过质量校验的氢耗数据',
|
||||
tone: 'warning',
|
||||
emphasis: 'primary'
|
||||
}, {
|
||||
label: '百公里氢耗',
|
||||
value: pending || !data?.hydrogenDataDays ? '—' : withUnit(formatKm(data.hydrogenConsumptionKgPer100Km), 'kg/100km'),
|
||||
note: unavailable ? '氢耗效率暂不可用' : loading ? '正在计算氢耗效率' : `${data?.hydrogenDataDays ?? 0} 个有效车辆日`,
|
||||
tone: 'warning',
|
||||
emphasis: 'primary'
|
||||
}];
|
||||
return <WorkspaceMetricRail
|
||||
variant="queue"
|
||||
className="v2-mileage-summary"
|
||||
@@ -349,7 +579,14 @@ function SummaryRail({ data, criteria, fleetTotal, loading }: { data?: MileageSt
|
||||
/>;
|
||||
}
|
||||
|
||||
type VehicleMileageMatrix = VehicleOption & { days: Map<string, number>; sources: Map<string, string>; totalMileageKm: number };
|
||||
type VehicleMileageMatrix = VehicleOption & {
|
||||
days: Map<string, number>;
|
||||
hydrogenDays: Map<string, { consumptionKg: number; rateKgPer100Km?: number }>;
|
||||
sources: Map<string, string>;
|
||||
totalMileageKm?: number;
|
||||
totalHydrogenConsumptionKg?: number;
|
||||
totalHydrogenRateKgPer100Km?: number;
|
||||
};
|
||||
|
||||
function rangeDates(dateFrom: string, dateTo: string) {
|
||||
const dates: string[] = [];
|
||||
@@ -367,15 +604,23 @@ function dateLabel(date: string) {
|
||||
return `${Number(month)}/${Number(day)}`;
|
||||
}
|
||||
|
||||
function MileageMatrixGuide() {
|
||||
function MileageMatrixGuide({ rows, view }: { rows: VehicleMileageMatrix[]; view: MetricView }) {
|
||||
const vehiclesWithMileage = rows.filter((row) => row.days.size > 0).length;
|
||||
if (view === 'hydrogen') return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide is-hydrogen" role="note" aria-label="氢耗矩阵读表说明">
|
||||
<span className="v2-mileage-matrix-guide-title"><IconInfoCircle /><strong>本页 {vehiclesWithMileage} / {rows.length} 辆有数据</strong></span>
|
||||
<span><Tag color="orange" type="light" size="small">kg</Tag>每日耗氢量</span>
|
||||
<span><Tag color="green" type="light" size="small">km</Tag>当日总里程</span>
|
||||
<span><Tag color="amber" type="light" size="small">kg/100km</Tag>百公里氢耗</span>
|
||||
<span className="is-mobile-hint">左右滑动查看日期 · 车牌与区间合计固定</span>
|
||||
</aside>;
|
||||
return <aside id="v2-mileage-matrix-guide" className="v2-mileage-matrix-guide" role="note" aria-label="里程矩阵读表说明">
|
||||
<span className="v2-mileage-matrix-guide-title is-desktop-guide"><IconInfoCircle /><strong>读表说明</strong></span>
|
||||
<span className="v2-mileage-matrix-guide-title is-desktop-guide"><IconInfoCircle /><strong>本页 {vehiclesWithMileage} / {rows.length} 辆有数据</strong></span>
|
||||
<span className="is-desktop-guide"><Tag color="blue" type="light" size="small">0 km</Tag>已上报、无里程增量</span>
|
||||
<span className="is-desktop-guide"><Tag color="grey" type="light" size="small">—</Tag>无可用里程</span>
|
||||
<span className="is-desktop-guide"><Tag color="blue" type="light" size="small">色阶</Tag>颜色越深、里程越高</span>
|
||||
<span className="is-desktop-guide is-mobile-hint">左右滑动查看日期 · 车牌与总里程固定</span>
|
||||
<span className="v2-mileage-matrix-guide-mobile" aria-label="移动端里程矩阵读表说明">
|
||||
<span className="is-mobile-hint"><IconInfoCircle /><strong>左右滑动日期</strong> · 车牌与总里程固定</span>
|
||||
<span className="is-mobile-hint"><IconInfoCircle /><strong>本页 {vehiclesWithMileage}/{rows.length} 辆有数据</strong> · 左右滑动日期</span>
|
||||
<span aria-label="0 公里表示已上报、无里程增量"><Tag color="blue" type="light" size="small">0 km</Tag>无增量</span>
|
||||
<span aria-label="横线表示无可用里程"><Tag color="grey" type="light" size="small">—</Tag>无数据</span>
|
||||
<span aria-label="颜色越深、里程越高"><Tag color="blue" type="light" size="small">色阶</Tag>深色更高</span>
|
||||
@@ -393,56 +638,98 @@ function scrollMileageMatrixFromKeyboard(event: KeyboardEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const MileageTable = memo(function MileageTable({ rows, dates, scrollRef }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement> }) {
|
||||
const MileageTable = memo(function MileageTable({ rows, dates, scrollRef, view }: { rows: VehicleMileageMatrix[]; dates: string[]; scrollRef: RefObject<HTMLDivElement>; view: MetricView }) {
|
||||
const { columns, tableWidth } = useMemo(() => {
|
||||
let maxDailyMileage = 1;
|
||||
for (const row of rows) {
|
||||
for (const mileage of row.days.values()) maxDailyMileage = Math.max(maxDailyMileage, mileage);
|
||||
}
|
||||
const dateColumnWidth = view === 'hydrogen' ? 128 : 96;
|
||||
const totalColumnWidth = view === 'hydrogen' ? 148 : 128;
|
||||
return {
|
||||
columns: [
|
||||
{ title: '车牌', dataIndex: 'plate', className: 'is-plate', width: 120, render: (_value: string, row: VehicleMileageMatrix) => <strong>{row.plate || '未绑定'}</strong> },
|
||||
...dates.map((date) => ({
|
||||
title: dateLabel(date), dataIndex: date, className: 'is-number is-date', width: 96,
|
||||
onHeaderCell: () => ({ title: date, 'aria-label': `${date} 每日里程` }),
|
||||
title: dateLabel(date), dataIndex: date, className: 'is-number is-date', width: dateColumnWidth,
|
||||
onHeaderCell: () => ({ title: date, 'aria-label': `${date} 每日${view === 'mileage' ? '里程' : '氢耗'}` }),
|
||||
onCell: (row?: VehicleMileageMatrix) => {
|
||||
const mileage = row?.days.get(date);
|
||||
const hydrogen = row?.hydrogenDays.get(date);
|
||||
const source = row?.sources.get(date);
|
||||
const intensity = mileage && mileage > 0 ? .035 + mileage / maxDailyMileage * .13 : 0;
|
||||
if (view === 'hydrogen') return {
|
||||
className: `is-number is-date is-hydrogen-daily${mileage != null ? ' is-daily' : ' is-empty'}`,
|
||||
title: mileage != null
|
||||
? `耗氢 ${formatKm(hydrogen?.consumptionKg)} kg · 当日总里程 ${formatKm(mileage)} km · 百公里氢耗 ${formatKm(hydrogen?.rateKgPer100Km)} kg/100km · 来源:${source || '—'}`
|
||||
: '无可用氢耗数据',
|
||||
'aria-label': mileage != null
|
||||
? `${date},耗氢 ${formatKm(hydrogen?.consumptionKg)} 千克,当日总里程 ${formatKm(mileage)} 公里,百公里氢耗 ${formatKm(hydrogen?.rateKgPer100Km)} 千克,来源 ${source || '未知'}`
|
||||
: `${date},无可用氢耗数据`
|
||||
};
|
||||
return {
|
||||
className: `is-number is-date${mileage != null ? ' is-daily' : ' is-empty'}`,
|
||||
title: mileage != null ? `来源:${source || '—'}` : '无可用里程',
|
||||
'aria-label': mileage != null ? `${date},${formatKm(mileage)} 公里,来源 ${source || '未知'}` : `${date},无可用里程`,
|
||||
title: mileage != null ? `里程 ${formatKm(mileage)} km · 来源:${source || '—'}` : '无可用里程',
|
||||
'aria-label': mileage != null
|
||||
? `${date},里程 ${formatKm(mileage)} 公里,来源 ${source || '未知'}`
|
||||
: `${date},无可用里程`,
|
||||
style: intensity ? { backgroundColor: `rgba(37, 99, 235, ${intensity.toFixed(3)})` } : undefined
|
||||
};
|
||||
},
|
||||
render: (_value: unknown, row: VehicleMileageMatrix) => {
|
||||
const mileage = row.days.get(date);
|
||||
return mileage != null ? `${formatKm(mileage)} km` : '—';
|
||||
const hydrogen = row.hydrogenDays.get(date);
|
||||
if (mileage == null) return '—';
|
||||
if (view === 'hydrogen') return <span className="v2-mileage-cell-value is-hydrogen">
|
||||
<strong>{formatKm(hydrogen?.consumptionKg)} kg</strong>
|
||||
<small>总里程 {formatKm(mileage)} km</small>
|
||||
<small>百公里 {formatKm(hydrogen?.rateKgPer100Km)} kg</small>
|
||||
</span>;
|
||||
return <strong>{formatKm(mileage)} km</strong>;
|
||||
}
|
||||
})),
|
||||
{ title: '区间总里程', dataIndex: 'totalMileageKm', className: 'is-number is-total', width: 128, onCell: () => ({ className: 'is-number is-period is-total' }), render: (value: number) => `${formatKm(value)} km` }
|
||||
{
|
||||
title: view === 'mileage' ? '区间总里程' : '区间氢耗', dataIndex: 'totalMileageKm', className: 'is-number is-total', width: totalColumnWidth,
|
||||
onCell: (row?: VehicleMileageMatrix) => ({
|
||||
className: `is-number is-period is-total${view === 'hydrogen' ? ' is-hydrogen-total' : ''}${row?.totalMileageKm == null ? ' is-empty' : ''}`,
|
||||
'aria-label': row?.totalMileageKm == null
|
||||
? view === 'mileage' ? '区间总里程,无可用里程' : '区间氢耗,无可用氢耗数据'
|
||||
: view === 'mileage'
|
||||
? `区间总里程,${formatKm(row.totalMileageKm)} 公里`
|
||||
: `区间耗氢 ${formatKm(row.totalHydrogenConsumptionKg)} 千克,总里程 ${formatKm(row.totalMileageKm)} 公里,百公里氢耗 ${formatKm(row.totalHydrogenRateKgPer100Km)} 千克`
|
||||
}),
|
||||
render: (value: number | undefined, row: VehicleMileageMatrix) => {
|
||||
if (value == null) return '—';
|
||||
if (view === 'hydrogen') return <span className="v2-mileage-cell-value is-hydrogen">
|
||||
<strong>{formatKm(row.totalHydrogenConsumptionKg)} kg</strong>
|
||||
<small>总里程 {formatKm(row.totalMileageKm)} km</small>
|
||||
<small>百公里 {formatKm(row.totalHydrogenRateKgPer100Km)} kg</small>
|
||||
</span>;
|
||||
return <strong>{formatKm(value)} km</strong>;
|
||||
}
|
||||
}
|
||||
],
|
||||
tableWidth: 120 + dates.length * 96 + 128
|
||||
tableWidth: 120 + dates.length * dateColumnWidth + totalColumnWidth
|
||||
};
|
||||
}, [dates, rows]);
|
||||
}, [dates, rows, view]);
|
||||
return <div
|
||||
className="v2-mileage-table-wrap"
|
||||
className={`v2-mileage-table-wrap${view === 'hydrogen' ? ' is-hydrogen-view' : ''}`}
|
||||
ref={scrollRef}
|
||||
role="region"
|
||||
aria-label="车辆每日里程矩阵,可横向滚动查看日期"
|
||||
aria-label={`车辆每日${view === 'mileage' ? '里程' : '氢耗'}矩阵,可横向滚动查看日期`}
|
||||
aria-describedby="v2-mileage-matrix-guide"
|
||||
tabIndex={0}
|
||||
onKeyDown={scrollMileageMatrixFromKeyboard}
|
||||
style={{ '--v2-mileage-mobile-table-width': `${96 + dates.length * 78 + 104}px` } as CSSProperties}
|
||||
style={{ '--v2-mileage-mobile-table-width': `${96 + dates.length * (view === 'hydrogen' ? 112 : 78) + (view === 'hydrogen' ? 132 : 104)}px` } as CSSProperties}
|
||||
>
|
||||
<Table className="v2-mileage-table" columns={columns} dataSource={rows} rowKey="vin" pagination={false} scroll={{ x: Math.max(556, tableWidth) }} />
|
||||
</div>;
|
||||
});
|
||||
|
||||
export default function StatisticsPage() {
|
||||
const { session } = usePlatformSession();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const monitorReturn = monitorReturnFromParams(searchParams);
|
||||
const vehicleReturn = vehicleReturnFromParams(searchParams);
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [draft, setDraft] = useState<Criteria>(() => initialCriteria(searchParams));
|
||||
const [criteria, setCriteria] = useState<Criteria>(() => initialCriteria(searchParams));
|
||||
@@ -452,6 +739,8 @@ export default function StatisticsPage() {
|
||||
const [exportProgress, setExportProgress] = useState<ExportProgress>();
|
||||
const [validationError, setValidationError] = useState('');
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
const [metricView, setMetricView] = useState<MetricView>('mileage');
|
||||
const hydrogenViewAllowed = session.role !== 'customer' && session.userType !== 'customer';
|
||||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||
const exportControllerRef = useRef<AbortController | null>(null);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
@@ -464,6 +753,9 @@ export default function StatisticsPage() {
|
||||
exportControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!hydrogenViewAllowed && metricView !== 'mileage') setMetricView('mileage');
|
||||
}, [hydrogenViewAllowed, metricView]);
|
||||
useEffect(() => {
|
||||
const page = pageRef.current;
|
||||
if (!page) return;
|
||||
@@ -492,17 +784,17 @@ export default function StatisticsPage() {
|
||||
gcTime: QUERY_MEMORY.summaryGcTime
|
||||
});
|
||||
const displayVehicles = useMemo<VehicleOption[]>(() => hasVehicles
|
||||
? criteria.vehicles
|
||||
: (fleetVehicles.data?.items ?? []).map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })), [criteria.vehicles, fleetVehicles.data?.items, hasVehicles]);
|
||||
const statisticsParams = useMemo(() => mileageParams(criteria, -1), [criteria]);
|
||||
const statisticsScope = statisticsParams.toString();
|
||||
? criteria.vehicles.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
||||
: (fleetVehicles.data?.items ?? []).map((vehicle) => ({ vin: vehicle.vin, plate: vehicle.plate })), [criteria.vehicles, fleetVehicles.data?.items, hasVehicles, page]);
|
||||
const statisticsQuery = useMemo(() => mileageQuery(criteria, -1), [criteria]);
|
||||
const statisticsScope = JSON.stringify(statisticsQuery);
|
||||
const rowsCriteria = useMemo(() => ({ ...criteria, vehicles: displayVehicles }), [criteria, displayVehicles]);
|
||||
const rowsParams = useMemo(() => mileageParams(rowsCriteria, 0), [rowsCriteria]);
|
||||
const rowsQuery = useMemo(() => mileageQuery(rowsCriteria, 0), [rowsCriteria]);
|
||||
const currentDayRange = !criteriaError && mileageRangeContainsDate(criteria);
|
||||
const currentDayRefreshInterval = currentDayRange ? CURRENT_DAY_REFRESH_MS : false;
|
||||
const statistics = useQuery({
|
||||
queryKey: ['mileage-statistics', statisticsScope],
|
||||
queryFn: ({ signal }) => api.mileageStatistics(statisticsParams, signal),
|
||||
queryFn: ({ signal }) => api.mileageStatistics(statisticsQuery, signal),
|
||||
enabled: !criteriaError,
|
||||
staleTime: CURRENT_DAY_REFRESH_MS,
|
||||
gcTime: QUERY_MEMORY.summaryGcTime,
|
||||
@@ -510,8 +802,8 @@ export default function StatisticsPage() {
|
||||
refetchIntervalInBackground: false
|
||||
});
|
||||
const mileage = useQuery<Page<DailyMileageRow>>({
|
||||
queryKey: ['daily-mileage-query', statisticsScope, rowsParams.toString()],
|
||||
queryFn: ({ signal }) => api.dailyMileage(rowsParams, signal),
|
||||
queryKey: ['daily-mileage-query', statisticsScope, rowsQuery],
|
||||
queryFn: ({ signal }) => api.dailyMileage(rowsQuery, signal),
|
||||
enabled: !criteriaError && displayVehicles.length > 0,
|
||||
staleTime: CURRENT_DAY_REFRESH_MS,
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime,
|
||||
@@ -521,14 +813,25 @@ export default function StatisticsPage() {
|
||||
});
|
||||
const dates = useMemo(() => criteriaError ? [] : rangeDates(criteria.dateFrom, criteria.dateTo), [criteria.dateFrom, criteria.dateTo, criteriaError]);
|
||||
const mileageByVin = useMemo(() => {
|
||||
const index = new Map<string, { plate: string; days: Map<string, number>; sources: Map<string, string> }>();
|
||||
const index = new Map<string, {
|
||||
plate: string;
|
||||
days: Map<string, number>;
|
||||
hydrogenDays: Map<string, { consumptionKg: number; rateKgPer100Km?: number }>;
|
||||
sources: Map<string, string>;
|
||||
}>();
|
||||
for (const row of mileage.data?.items ?? []) {
|
||||
let entry = index.get(row.vin);
|
||||
if (!entry) {
|
||||
entry = { plate: row.plate || '', days: new Map(), sources: new Map() };
|
||||
entry = { plate: row.plate || '', days: new Map(), hydrogenDays: new Map(), sources: new Map() };
|
||||
index.set(row.vin, entry);
|
||||
} else if (!entry.plate && row.plate) entry.plate = row.plate;
|
||||
entry.days.set(row.date, row.dailyMileageKm);
|
||||
if (row.hydrogenConsumptionKg != null) {
|
||||
entry.hydrogenDays.set(row.date, {
|
||||
consumptionKg: row.hydrogenConsumptionKg,
|
||||
rateKgPer100Km: row.hydrogenConsumptionKgPer100Km ?? undefined
|
||||
});
|
||||
}
|
||||
entry.sources.set(row.date, row.source);
|
||||
}
|
||||
return index;
|
||||
@@ -538,10 +841,26 @@ export default function StatisticsPage() {
|
||||
const daily = mileageByVin.get(vehicle.vin);
|
||||
const ranking = rankingByVin.get(vehicle.vin);
|
||||
const days = daily?.days ?? new Map<string, number>();
|
||||
const hydrogenDays = daily?.hydrogenDays ?? new Map<string, { consumptionKg: number; rateKgPer100Km?: number }>();
|
||||
const sources = daily?.sources ?? new Map<string, string>();
|
||||
let dailyTotal = 0;
|
||||
let hydrogenTotal = 0;
|
||||
let hydrogenRatedMileage = 0;
|
||||
for (const value of days.values()) dailyTotal += value;
|
||||
return { ...vehicle, plate: vehicle.plate || daily?.plate || ranking?.plate || '', days, sources, totalMileageKm: ranking?.mileageKm ?? dailyTotal };
|
||||
for (const [date, value] of hydrogenDays) {
|
||||
hydrogenTotal += value.consumptionKg;
|
||||
hydrogenRatedMileage += days.get(date) ?? 0;
|
||||
}
|
||||
return {
|
||||
...vehicle,
|
||||
plate: vehicle.plate || daily?.plate || ranking?.plate || '',
|
||||
days,
|
||||
hydrogenDays,
|
||||
sources,
|
||||
totalMileageKm: days.size ? ranking?.mileageKm ?? dailyTotal : undefined,
|
||||
totalHydrogenConsumptionKg: hydrogenDays.size ? hydrogenTotal : undefined,
|
||||
totalHydrogenRateKgPer100Km: hydrogenDays.size && hydrogenRatedMileage > 0 ? hydrogenTotal * 100 / hydrogenRatedMileage : undefined
|
||||
};
|
||||
}), [displayVehicles, mileageByVin, rankingByVin]);
|
||||
const totalVehicles = hasVehicles ? criteria.vehicles.length : fleetVehicles.data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalVehicles / PAGE_SIZE));
|
||||
@@ -549,14 +868,14 @@ export default function StatisticsPage() {
|
||||
const error = mileageDateRangeError(draft);
|
||||
setValidationError(error);
|
||||
if (error) return;
|
||||
setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(preserveMonitorReturn(mileageParams(draft, -1), monitorReturn), { replace: true }); setFiltersCollapsed(true);
|
||||
setPage(1); setExportFeedback(''); setCriteria(draft); setSearchParams(preserveVehicleReturn(preserveMonitorReturn(mileageRouteParams(draft), monitorReturn), vehicleReturn), { replace: true }); setFiltersCollapsed(true);
|
||||
};
|
||||
const resetDraft = () => {
|
||||
setDraft(initialCriteria(new URLSearchParams()));
|
||||
setValidationError('');
|
||||
};
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); applyDraft(); };
|
||||
const setRange = (range: { dateFrom: string; dateTo: string }) => { const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(preserveMonitorReturn(mileageParams(next, -1), monitorReturn), { replace: true }); setFiltersCollapsed(true); };
|
||||
const setRange = (range: { dateFrom: string; dateTo: string }) => { const next = { ...draft, ...range }; setPage(1); setExportFeedback(''); setValidationError(''); setDraft(next); setCriteria(next); setSearchParams(preserveVehicleReturn(preserveMonitorReturn(mileageRouteParams(next), monitorReturn), vehicleReturn), { replace: true }); setFiltersCollapsed(true); };
|
||||
const closeMobileFilters = () => {
|
||||
setDraft(criteria);
|
||||
setValidationError('');
|
||||
@@ -589,6 +908,7 @@ export default function StatisticsPage() {
|
||||
];
|
||||
const refreshing = statistics.isFetching || mileage.isFetching || fleetVehicles.isFetching;
|
||||
const resultsLoading = fleetVehicles.isLoading || mileage.isLoading || mileage.isPlaceholderData;
|
||||
const resultsError = statistics.error ?? mileage.error ?? (!hasVehicles ? fleetVehicles.error : undefined);
|
||||
const exportPercent = exportProgress?.total
|
||||
? Math.min(100, Math.round((exportProgress.completed ?? 0) / exportProgress.total * 100))
|
||||
: undefined;
|
||||
@@ -636,8 +956,8 @@ export default function StatisticsPage() {
|
||||
: '正在读取全部车辆里程';
|
||||
reportProgress({ label: mileageLabel });
|
||||
while (true) {
|
||||
const params = mileageParams({ ...criteria, vehicles: vehicleBatch }, offset);
|
||||
const result = await api.dailyMileage(params, controller.signal);
|
||||
const query = mileageQuery({ ...criteria, vehicles: vehicleBatch }, offset);
|
||||
const result = await api.dailyMileage(query, controller.signal);
|
||||
for (const row of result.items) if (row.plate) plateByVin.set(row.vin, row.plate);
|
||||
exportStream.appendRows(result.items);
|
||||
offset += result.items.length;
|
||||
@@ -712,7 +1032,8 @@ export default function StatisticsPage() {
|
||||
collapsedLabel="修改"
|
||||
onToggle={toggleFilters}
|
||||
/>
|
||||
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />
|
||||
{hydrogenViewAllowed ? <MetricViewSwitch value={metricView} onChange={setMetricView} /> : null}
|
||||
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} view={metricView} unavailable={statistics.isError} />
|
||||
<MobileFilterSheet
|
||||
className="v2-mileage-filter-sidesheet"
|
||||
visible={mobileFiltersOpen}
|
||||
@@ -726,7 +1047,7 @@ export default function StatisticsPage() {
|
||||
secondaryAction={{ label: '重置条件', onClick: resetDraft }}
|
||||
primaryAction={{ label: '应用并查询', onClick: applyDraft }}
|
||||
>
|
||||
<MobileFilterSheetSection ariaLabel="里程查询条件" title="查询范围" description="最多 20 台车辆,按自然日比较优先数据源">
|
||||
<MobileFilterSheetSection ariaLabel="里程查询条件" title="查询范围" description="支持批量粘贴最多 50,000 辆,按自然日比较优先数据源">
|
||||
{filterForm}
|
||||
</MobileFilterSheetSection>
|
||||
{filterValidation}
|
||||
@@ -744,15 +1065,15 @@ export default function StatisticsPage() {
|
||||
>
|
||||
{filterForm}
|
||||
{filterValidation}
|
||||
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} />
|
||||
{hydrogenViewAllowed ? <MetricViewSwitch value={metricView} onChange={setMetricView} /> : null}
|
||||
<SummaryRail data={statistics.data} criteria={criteria} fleetTotal={fleetVehicles.data?.total} loading={statistics.isLoading} view={metricView} unavailable={statistics.isError} />
|
||||
</WorkspaceFilterPanel>}
|
||||
</div>
|
||||
{statistics.isError || mileage.isError || fleetVehicles.isError ? <InlineError message={(statistics.error ?? mileage.error ?? fleetVehicles.error) instanceof Error ? (statistics.error ?? mileage.error ?? fleetVehicles.error as Error).message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
|
||||
<Card className="v2-mileage-results" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
eyebrow="每日统计"
|
||||
tone="primary"
|
||||
title="车辆每日里程"
|
||||
eyebrow={metricView === 'mileage' ? '每日里程' : '每日氢耗'}
|
||||
tone={metricView === 'mileage' ? 'primary' : 'health'}
|
||||
title={metricView === 'mileage' ? '车辆每日里程' : '车辆每日氢耗'}
|
||||
description={`${criteria.dateFrom} 至 ${criteria.dateTo}`}
|
||||
meta={<span className="v2-workspace-result-meta"><Tag color="blue" type="light" size="small">{hasVehicles ? `${totalVehicles} 辆` : `${displayVehicles.length} / ${totalVehicles} 辆`}</Tag><span>{dates.length} 个自然日</span></span>}
|
||||
actionsClassName="v2-mileage-result-actions"
|
||||
@@ -764,10 +1085,11 @@ export default function StatisticsPage() {
|
||||
? <Spin className="v2-mileage-export-spinner" size="small" />
|
||||
: <Progress className="v2-mileage-export-bar" percent={exportPercent} showInfo={false} size="small" strokeLinecap="round" aria-label={`${exportProgress.label} ${exportPercent}%`} />}
|
||||
</div> : null}
|
||||
{!resultsLoading && displayVehicles.length ? <MileageMatrixGuide /> : null}
|
||||
{resultsLoading ? <PanelLoading className="v2-mileage-loading" title="正在查询里程" description="新筛选范围返回前不会展示上一范围的数据。" /> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} /> : null}
|
||||
{!resultsLoading && !displayVehicles.length ? <PanelEmpty className="v2-mileage-empty" title="当前没有可展示的车辆" description="选择车牌或调整车辆授权范围后重试。" /> : null}
|
||||
<footer>{!hasVehicles && totalVehicles ? <TablePagination page={page} totalPages={totalPages} info={`共 ${totalVehicles.toLocaleString('zh-CN')} 辆 · 每页 ${PAGE_SIZE} 辆${exportFeedback ? ` · ${exportFeedback}` : ''}`} disabled={fleetVehicles.isFetching} onPageChange={setPage} /> : <span className="v2-table-pagination-info">已选择 {totalVehicles.toLocaleString('zh-CN')} 辆车辆{exportFeedback ? ` · ${exportFeedback}` : ''}</span>}</footer>
|
||||
{resultsError ? <InlineError message={resultsError instanceof Error ? resultsError.message : '里程数据加载失败'} onRetry={() => { statistics.refetch(); mileage.refetch(); if (!hasVehicles) fleetVehicles.refetch(); }} /> : null}
|
||||
{!resultsError && !resultsLoading && displayVehicles.length ? <MileageMatrixGuide rows={matrixRows} view={metricView} /> : null}
|
||||
{!resultsError && (resultsLoading ? <PanelLoading className="v2-mileage-loading" title={`正在查询${metricView === 'mileage' ? '里程' : '氢耗'}`} description="新筛选范围返回前不会展示上一范围的数据。" /> : displayVehicles.length ? <MileageTable rows={matrixRows} dates={dates} scrollRef={tableScrollRef} view={metricView} /> : null)}
|
||||
{!resultsError && !resultsLoading && !displayVehicles.length ? <PanelEmpty className="v2-mileage-empty" title="当前没有可展示的车辆" description="选择车牌或调整车辆授权范围后重试。" /> : null}
|
||||
<footer>{totalVehicles > PAGE_SIZE ? <TablePagination page={page} totalPages={totalPages} info={`${hasVehicles ? '已选择' : '共'} ${totalVehicles.toLocaleString('zh-CN')} 辆 · 每页 ${PAGE_SIZE} 辆${exportFeedback ? ` · ${exportFeedback}` : ''}`} disabled={mileage.isFetching || (!hasVehicles && fleetVehicles.isFetching)} onPageChange={setPage} /> : <span className="v2-table-pagination-info">{hasVehicles ? '已选择' : '共'} {totalVehicles.toLocaleString('zh-CN')} 辆车辆{exportFeedback ? ` · ${exportFeedback}` : ''}</span>}</footer>
|
||||
</Card>
|
||||
<footer className="v2-mileage-evidence"><span>数据更新时间:{statistics.data?.asOf || '—'}</span><span>来源优先级:{criteria.sources.filter((source) => source.enabled).map((source) => source.protocol).join(' > ')}</span><span>当前筛选复用 1 分钟 · 离开后释放明细</span></footer>
|
||||
</div>;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { TrackPlaybackResponse } from '../../api/types';
|
||||
import TrackPage from './TrackPage';
|
||||
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
import { buildVehicleDetailPath, withVehicleReturn } from '../routing/vehicleContext';
|
||||
|
||||
const mocks = vi.hoisted(() => ({ trackPlayback: vi.fn(), reverseGeocode: vi.fn(), vehicles: vi.fn() }));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
@@ -21,9 +22,11 @@ vi.mock('@douyinfe/semi-ui', async (importOriginal) => {
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
'aria-label'?: string;
|
||||
'aria-valuetext'?: string;
|
||||
}) => <input
|
||||
type="range"
|
||||
aria-label={props['aria-label']}
|
||||
aria-valuetext={props['aria-valuetext']}
|
||||
min={min}
|
||||
max={max}
|
||||
value={value ?? min}
|
||||
@@ -98,6 +101,20 @@ test('preserves the exact monitor return after querying another track range', as
|
||||
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
|
||||
});
|
||||
|
||||
test('preserves the nearest vehicle detail return after refining a track query', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const vehiclePath = buildVehicleDetailPath('LTEST000000000001', { directoryReturn: '/vehicles?vehicleSearch=%E7%B2%A4A&vehicleView=offline&vehiclePage=2' });
|
||||
const initialEntry = withVehicleReturn('/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59', vehiclePath);
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByRole('link', { name: /返回车辆档案/ })).toHaveAttribute('href', vehiclePath);
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改条件' }));
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '数据来源' }), { target: { value: 'JT808' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /查询轨迹/ }));
|
||||
|
||||
expect(await screen.findByRole('link', { name: /返回车辆档案/ })).toHaveAttribute('href', vehiclePath);
|
||||
});
|
||||
|
||||
test('keeps committed track criteria authoritative to same-route navigation and browser history', async () => {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackNavigationHarness /></MemoryRouter></QueryClientProvider>);
|
||||
@@ -106,7 +123,7 @@ test('keeps committed track criteria authoritative to same-route navigation and
|
||||
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-point-count', '3');
|
||||
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/07-15 00:00.*自动来源/)).toBeInTheDocument();
|
||||
expect(screen.getByText('00:00–23:59 · 自动来源')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改条件' }));
|
||||
expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toHaveValue('LTEST000000000001');
|
||||
|
||||
@@ -126,6 +143,25 @@ test('keeps committed track criteria authoritative to same-route navigation and
|
||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-point-count', '0');
|
||||
});
|
||||
|
||||
test('explains an empty selected-vehicle time window without asking to select the vehicle again', async () => {
|
||||
mocks.trackPlayback.mockResolvedValue({
|
||||
...track,
|
||||
total: 0,
|
||||
points: [],
|
||||
events: [],
|
||||
segments: [],
|
||||
stops: [],
|
||||
summary: { ...track.summary, pointCount: 0, distanceKm: 0 }
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('当前时间窗没有可回放轨迹')).toBeInTheDocument();
|
||||
expect(screen.getByText('车辆已选定,可调整时间范围或数据来源后重新查询。')).toBeInTheDocument();
|
||||
expect(screen.queryByText('先选择车辆,再开始轨迹回放')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '修改查询' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('distinguishes a failed vehicle lookup from an empty result and retries in place', async () => {
|
||||
mocks.vehicles.mockRejectedValueOnce(new Error('车辆目录暂时不可用')).mockResolvedValueOnce({ items: [{ vin: 'LTEST000000000001', plate: '粤A12345' }], total: 1, limit: 10, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -180,17 +216,17 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
|
||||
expect(screen.getByLabelText('轨迹查询任务栏')).toHaveTextContent('轨迹回放');
|
||||
expect(screen.getByLabelText('轨迹查询任务栏').querySelector('.v2-workspace-command-icon.semi-avatar')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('轨迹查询任务栏')).toHaveTextContent('粤A12345');
|
||||
expect(screen.getByLabelText('轨迹查询任务栏')).toHaveTextContent('3 点');
|
||||
expect(screen.getByLabelText('轨迹查询任务栏')).not.toHaveTextContent('3 点');
|
||||
expect(view.container.querySelector('.v2-track-query-panel')).toHaveClass('is-collapsed');
|
||||
expect(view.container.querySelector('.v2-track-query-summary')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-track-rail-result')).toBeInTheDocument();
|
||||
const tripSummary = screen.getByRole('list', { name: '轨迹行程摘要' });
|
||||
expect(tripSummary.closest('.v2-track-result-metric-rail')).toHaveClass('semi-card', 'v2-workspace-metric-rail', 'is-queue');
|
||||
expect(Array.from(tripSummary.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual(['协议总里程差', '停留点', '事件点', '数据点']);
|
||||
expect(Array.from(tripSummary.querySelectorAll(':scope > [role="listitem"] > small')).map((item) => item.textContent)).toEqual(['协议总里程差', '停留点', '事件点', '回放点']);
|
||||
expect(tripSummary).toHaveTextContent('协议总里程差8 km00:20:00');
|
||||
expect(tripSummary).toHaveTextContent('停留点1超过 3 分钟');
|
||||
expect(tripSummary).toHaveTextContent('事件点3轨迹事件');
|
||||
expect(tripSummary).toHaveTextContent('数据点3时间窗完整');
|
||||
expect(tripSummary).toHaveTextContent('回放点3完整点集');
|
||||
expect(view.container.querySelector('.v2-track-result-header')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '修改条件' })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
|
||||
@@ -200,7 +236,7 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
|
||||
expect(view.container.querySelector('.v2-track-current-metrics')).toHaveTextContent('设备累计值非区间100 km');
|
||||
expect(view.container.querySelector('.v2-track-current-metrics > span:last-child small > b')).toHaveTextContent('非区间');
|
||||
expect(view.container.querySelector('.v2-track-current-metrics > span:last-child')).toHaveAttribute('title', '当前轨迹点设备上报的累计总里程,不是所选时间窗里程或今日里程');
|
||||
expect(view.container.querySelector('.v2-track-current-evidence')).toHaveTextContent('JT/T 808时间窗完整3 → 3 点报警 —');
|
||||
expect(view.container.querySelector('.v2-track-current-evidence')).toHaveTextContent('JT/T 808查询完整3 源点 → 3 回放点报警 —');
|
||||
expect(view.container.querySelector('.v2-track-current-evidence .v2-track-protocol-tag')).toHaveClass('semi-tag-cyan-light');
|
||||
expect(view.container.querySelector('.v2-track-coverage-float')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('toolbar', { name: '地图轨迹工具' })).toBeInTheDocument();
|
||||
@@ -211,8 +247,10 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
|
||||
expect(screen.getByRole('button', { name: /停留 00:05:00/ }).closest('.semi-list-item')).toHaveClass('v2-track-evidence-item');
|
||||
expect(view.container.querySelector('.v2-track-evidence-list.semi-list')).toBeInTheDocument();
|
||||
expect(screen.getByRole('slider', { name: '轨迹播放进度' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('slider', { name: '轨迹播放进度' })).toHaveAttribute('aria-valuetext', '第 1 / 3 个回放点,08:00:00');
|
||||
expect(screen.getByText('轨迹活动共 1 段:行驶 1 段,停留 0 段,数据间隔 0 段')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-track-dock-summary')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-track-progress-meta')).toHaveTextContent('已暂停 · 数据点 1 / 3');
|
||||
expect(view.container.querySelector('.v2-track-progress-meta')).toHaveTextContent('已暂停 · 回放点 1 / 3');
|
||||
expect(screen.getByRole('button', { name: '开始轨迹播放' })).toHaveTextContent('播放');
|
||||
expect(screen.queryByRole('button', { name: '暂停轨迹播放' })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(mocks.reverseGeocode).toHaveBeenCalledTimes(1));
|
||||
@@ -235,12 +273,12 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
|
||||
expect(view.container.querySelectorAll('.v2-track-source-item.semi-list-item')).toHaveLength(1);
|
||||
expect(view.container.querySelector('.v2-track-source-item .v2-track-protocol-tag')).toHaveTextContent('JT/T 808');
|
||||
expect(view.container.querySelector('.v2-track-source-item .v2-track-protocol-tag')).toHaveClass('semi-tag-cyan-light');
|
||||
expect(screen.getByText('完整点集').closest('.semi-tag')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-track-overview-card .semi-tag')).toHaveTextContent('完整点集');
|
||||
expect(screen.getByText('通过').closest('.semi-tag')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '速度' }), { target: { value: '4' } });
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '倍速' }), { target: { value: '4' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始轨迹播放' }));
|
||||
expect(view.container.querySelector('.v2-track-progress-meta')).toHaveTextContent(/播放中 · 数据点 [12] \/ 3/);
|
||||
expect(view.container.querySelector('.v2-track-progress-meta')).toHaveTextContent(/播放中 · 回放点 [12] \/ 3/);
|
||||
expect(screen.getByRole('button', { name: '暂停轨迹播放' })).toHaveTextContent('暂停');
|
||||
expect(screen.getByTestId('track-map')).toHaveAttribute('data-follow-duration', '65');
|
||||
fireEvent.click(screen.getByRole('button', { name: '暂停轨迹播放' }));
|
||||
@@ -252,8 +290,11 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
|
||||
expect(screen.getByRole('option', { name: 'JT/T 808 · GPS 里程' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: '宇通 MQTT · 仪表盘里程' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '收起条件' })).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.change(screen.getByRole('combobox', { name: '数据来源' }), { target: { value: 'JT808' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '收起条件' }));
|
||||
expect(screen.queryByRole('textbox', { name: '搜索轨迹车辆' })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('轨迹查询任务栏')).toHaveTextContent('自动来源');
|
||||
expect(screen.getByLabelText('轨迹查询任务栏')).not.toHaveTextContent('JT/T 808');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '隐藏查询与明细' }));
|
||||
const expandRail = screen.getByRole('button', { name: '打开轨迹详情' });
|
||||
@@ -271,6 +312,62 @@ test('renders a map-first replay workspace and connects stop, event, and panel i
|
||||
await waitFor(() => expect(client.getQueryCache().findAll({ queryKey: ['track-address'] })).toHaveLength(0));
|
||||
});
|
||||
|
||||
test('explains thousand-point sampling and multi-source continuity without implying data loss', async () => {
|
||||
mocks.trackPlayback.mockResolvedValue({
|
||||
...track,
|
||||
sampled: true,
|
||||
total: 12_480,
|
||||
points: Array.from({ length: 1_600 }, (_, index) => ({ ...track.points[index % track.points.length], deviceTime: new Date(Date.UTC(2026, 6, 15, 0, 0, index)).toISOString() })),
|
||||
sources: [
|
||||
{ protocol: 'JT808', pointCount: 9_600, startTime: '2026-07-15T00:00:00Z', endTime: '2026-07-15T20:00:00Z' },
|
||||
{ protocol: 'GB32960', pointCount: 2_880, startTime: '2026-07-15T03:00:00Z', endTime: '2026-07-15T23:59:00Z' }
|
||||
],
|
||||
summary: { ...track.summary, pointCount: 12_480 },
|
||||
coverage: { ...track.coverage, totalPoints: 12_480, fetchedPoints: 12_480, processedPoints: 12_480, returnedPoints: 1_600, complete: true, evidence: '查询窗源点完整,地图等距抽稀' },
|
||||
quality: { ...track.quality, alternateSourcePoints: 2_880, sourceSwitches: 3 }
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByRole('status', { name: '轨迹数据覆盖说明' })).toHaveTextContent('查询完整,地图已做流畅抽稀12,480 个源点完整参与计算,地图回放 1,600 点');
|
||||
expect(screen.queryByRole('button', { name: '修改范围' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: '轨迹行程摘要' })).toHaveTextContent('回放点1,600源点 12,480 · 已抽稀');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看口径' }));
|
||||
expect(view.container.querySelector('.v2-track-source-summary')).toHaveTextContent('自动融合 2 个来源,主来源 JT/T 808,轨迹内切换 3 次。');
|
||||
expect(view.container.querySelector('.v2-track-source-list')).toHaveTextContent('JT/T 8089,600 点主来源');
|
||||
expect(view.container.querySelector('.v2-track-source-list')).toHaveTextContent('GB/T 329602,880 点备选来源');
|
||||
});
|
||||
|
||||
test('offers a direct range recovery when the server returns a partial track', async () => {
|
||||
mocks.trackPlayback.mockResolvedValue({
|
||||
...track,
|
||||
sampled: true,
|
||||
truncated: true,
|
||||
total: 12_480,
|
||||
summary: { ...track.summary, pointCount: 12_480 },
|
||||
coverage: { ...track.coverage, totalPoints: 12_480, fetchedPoints: 2_000, processedPoints: 2_000, returnedPoints: 1_600, complete: false, limitReasons: ['service_limit'], evidence: '服务端读取上限' }
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByRole('status', { name: '轨迹数据覆盖说明' })).toHaveTextContent('当前仅返回部分轨迹1,600 / 12,480 个源点可用');
|
||||
fireEvent.click(screen.getByRole('button', { name: '修改范围' }));
|
||||
expect(screen.getByRole('textbox', { name: '搜索轨迹车辆' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '收起条件' })).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('keeps coordinates visible and retries a failed address lookup', async () => {
|
||||
mocks.reverseGeocode.mockRejectedValueOnce(new Error('geocoder unavailable')).mockResolvedValueOnce({ formattedAddress: '广东省广州市恢复后的地址' });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={['/tracks?keyword=LTEST000000000001&dateFrom=2026-07-15T00:00&dateTo=2026-07-15T23:59']}><TrackPage /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('地址暂不可用 · 113.100000, 23.100000')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重试地址' }));
|
||||
expect(await screen.findByText('广东省广州市恢复后的地址')).toBeInTheDocument();
|
||||
expect(mocks.reverseGeocode).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('uses the shared Semi editor SideSheet for mobile track criteria and evidence', async () => {
|
||||
Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() })) });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
@@ -279,6 +376,10 @@ test('uses the shared Semi editor SideSheet for mobile track criteria and eviden
|
||||
expect((await screen.findAllByText('粤A12345')).length).toBeGreaterThan(0);
|
||||
expect(view.container.querySelector('.v2-track-page')).toHaveClass('is-mobile-layout', 'is-rail-collapsed');
|
||||
expect(view.container.querySelector('.v2-track-page > .v2-track-stage')).toBeInTheDocument();
|
||||
const mobileQueryLauncher = screen.getByRole('button', { name: /打开轨迹查询,粤A12345,/ });
|
||||
expect(mobileQueryLauncher).toHaveTextContent('粤A1234500:00–23:59 · 自动来源');
|
||||
expect(mobileQueryLauncher).not.toHaveTextContent('LTEST000000000001');
|
||||
expect(screen.getByRole('combobox', { name: '倍速' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开轨迹详情' }));
|
||||
|
||||
const sheet = await waitFor(() => {
|
||||
@@ -298,7 +399,7 @@ test('uses the shared Semi editor SideSheet for mobile track criteria and eviden
|
||||
expect(mobileSummary).toHaveTextContent('协议总里程差8 km00:20:00');
|
||||
expect(mobileSummary).toHaveTextContent('停留点1超过 3 分钟');
|
||||
expect(mobileSummary).toHaveTextContent('事件点3轨迹事件');
|
||||
expect(mobileSummary).toHaveTextContent('数据点3时间窗完整');
|
||||
expect(mobileSummary).toHaveTextContent('回放点3完整点集');
|
||||
expect(mobileSummary.querySelectorAll(':scope > [role="listitem"]')).toHaveLength(4);
|
||||
expect(screen.getByRole('button', { name: '修改轨迹查询' })).toHaveTextContent('修改查询');
|
||||
expect(screen.getByRole('button', { name: /查看地图$/ })).toBeInTheDocument();
|
||||
@@ -355,7 +456,8 @@ test('keeps one clear mobile query entry before a vehicle is selected', async ()
|
||||
expect(screen.queryByRole('button', { name: '打开轨迹详情' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('toolbar', { name: '地图轨迹工具' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('slider', { name: '轨迹播放进度' })).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-track-playback-dock')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('slider', { name: '轨迹播放进度,等待查询结果' })).toBeDisabled();
|
||||
expect(view.container.querySelector('.v2-track-playback-dock.is-empty')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-workspace-empty-guide-eyebrow')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /选择车辆/ }));
|
||||
expect(await screen.findByRole('dialog', { name: '轨迹查询与明细' })).toBeInTheDocument();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
|
||||
IconCalendar, IconChevronLeft, IconChevronRight, IconClose, IconDownload, IconEyeClosed,
|
||||
IconEyeOpened, IconList, IconMapPin, IconPause, IconPlay, IconRefresh, IconRoute, IconSearch
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Descriptions, Input, List, Select, Slider, Tag } from '@douyinfe/semi-ui';
|
||||
@@ -24,6 +24,7 @@ import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
import { monitorReturnFromParams, preserveMonitorReturn } from '../routing/monitorContext';
|
||||
import { preserveVehicleReturn, vehicleReturnFromParams } from '../routing/vehicleContext';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
|
||||
const speedOptions = [0.5, 1, 2, 4] as const;
|
||||
@@ -154,15 +155,43 @@ function trackSummaryItems(track?: TrackPlaybackResponse): WorkspaceQueueMetricR
|
||||
emphasis: 'secondary'
|
||||
},
|
||||
{
|
||||
label: '数据点',
|
||||
label: '回放点',
|
||||
value: track ? track.points.length.toLocaleString('zh-CN') : '—',
|
||||
note: track ? track.coverage.complete ? '时间窗完整' : '已返回切片' : '等待查询',
|
||||
note: track
|
||||
? track.sampled
|
||||
? `源点 ${track.coverage.totalPoints.toLocaleString('zh-CN')} · 已抽稀`
|
||||
: track.coverage.complete ? '完整点集' : '部分点集'
|
||||
: '等待查询',
|
||||
tone: track?.coverage.complete ? 'success' : track ? 'warning' : 'neutral',
|
||||
emphasis: 'secondary'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function TrackCoverageNotice({ track, onModify, onOverview }: {
|
||||
track: TrackPlaybackResponse;
|
||||
onModify: () => void;
|
||||
onOverview: () => void;
|
||||
}) {
|
||||
if (track.coverage.complete && !track.sampled && !track.truncated) return null;
|
||||
const sampledComplete = track.coverage.complete && track.sampled && !track.truncated;
|
||||
const returned = track.coverage.returnedPoints || track.points.length;
|
||||
const total = track.coverage.totalPoints || track.summary.pointCount || returned;
|
||||
return <section className={`v2-track-coverage-notice${sampledComplete ? ' is-sampled' : ' is-limited'}`} role="status" aria-label="轨迹数据覆盖说明">
|
||||
<span className="v2-track-coverage-notice-icon"><IconRoute /></span>
|
||||
<span className="v2-track-coverage-notice-copy">
|
||||
<strong>{sampledComplete ? '查询完整,地图已做流畅抽稀' : '当前仅返回部分轨迹'}</strong>
|
||||
<small>{sampledComplete
|
||||
? `${total.toLocaleString('zh-CN')} 个源点完整参与计算,地图回放 ${returned.toLocaleString('zh-CN')} 点`
|
||||
: `${returned.toLocaleString('zh-CN')} / ${total.toLocaleString('zh-CN')} 个源点可用,缩短时间范围可恢复完整证据`}</small>
|
||||
</span>
|
||||
<span className="v2-track-coverage-notice-actions">
|
||||
<Button theme="borderless" type="tertiary" size="small" onClick={onOverview}>查看口径</Button>
|
||||
{!sampledComplete ? <Button theme="light" type="primary" size="small" onClick={onModify}>修改范围</Button> : null}
|
||||
</span>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange: (value: string) => void; onSelect: (vehicle: VehicleRow) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [debounced, setDebounced] = useState(value.trim());
|
||||
@@ -209,6 +238,10 @@ function VehiclePicker({ value, onChange, onSelect }: { value: string; onChange:
|
||||
|
||||
function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
|
||||
const distanceLabel = track.summary.distanceMethod === 'coordinates' ? 'GPS 轨迹累计' : track.summary.distanceMethod === 'odometer' ? '协议总里程差' : '行驶里程';
|
||||
const selectedSource = protocolDisplayLabel(track.quality.selectedProtocol);
|
||||
const sourceSummary = track.sources.length > 1
|
||||
? `自动融合 ${track.sources.length} 个来源,主来源 ${selectedSource},轨迹内切换 ${track.quality.sourceSwitches} 次。`
|
||||
: `${selectedSource} 提供当前时间窗的轨迹点,轨迹内无来源切换。`;
|
||||
return <div className="v2-track-overview-panel">
|
||||
<Card className="v2-track-overview-section v2-track-overview-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title="行程概览" meta={<Tag color={track.sampled ? 'orange' : 'green'} type="light" size="small">{track.sampled ? '地图已抽稀' : '完整点集'}</Tag>} />
|
||||
@@ -222,8 +255,8 @@ function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
|
||||
]} /></div>
|
||||
</Card>
|
||||
<Card className="v2-track-overview-section v2-track-overview-card v2-track-source-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title="数据来源" meta={<Tag color="blue" type="light" size="small">{track.coverage.totalPoints.toLocaleString('zh-CN')} 个源点</Tag>} />
|
||||
<div className="v2-track-overview-card-body"><List className="v2-track-source-list">{track.sources.map((source) => <List.Item className="v2-track-source-item" key={source.protocol}><ProtocolTag className="v2-track-protocol-tag" protocol={source.protocol} compact /><strong>{source.pointCount.toLocaleString('zh-CN')} 点</strong><small>{time(source.startTime)}–{time(source.endTime)}</small></List.Item>)}</List></div>
|
||||
<WorkspacePanelHeader variant="compact" title="数据来源" meta={<Tag color="blue" type="light" size="small">{track.sources.length > 1 ? `${track.sources.length} 个来源` : `${track.coverage.totalPoints.toLocaleString('zh-CN')} 个源点`}</Tag>} />
|
||||
<div className="v2-track-overview-card-body"><p className="v2-track-source-summary">{sourceSummary}</p><List className="v2-track-source-list">{track.sources.map((source) => <List.Item className="v2-track-source-item" key={source.protocol}><ProtocolTag className="v2-track-protocol-tag" protocol={source.protocol} compact /><strong>{source.pointCount.toLocaleString('zh-CN')} 点</strong><small>{source.protocol === track.quality.selectedProtocol ? '主来源' : '备选来源'} · {time(source.startTime)}–{time(source.endTime)}</small></List.Item>)}</List></div>
|
||||
</Card>
|
||||
<Card className={`v2-track-overview-section v2-track-overview-card v2-track-quality-card is-${track.quality.status}`} bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title="轨迹质量" meta={<Tag color={track.quality.status === 'good' ? 'green' : 'orange'} type="light" size="small">{track.quality.status === 'good' ? '通过' : '需关注'}</Tag>} />
|
||||
@@ -232,8 +265,9 @@ function OverviewPanel({ track }: { track: TrackPlaybackResponse }) {
|
||||
</div>;
|
||||
}
|
||||
|
||||
const TrackRail = memo(function TrackRail({ draft, track, summaryItems, loading, activeStopIndexes, activeEventIndexes, tab, queryCollapsed, showCommandBar, onDraft, onSubmit, onTab, onSelectIndex, onToggleQuery, onCollapse }: {
|
||||
const TrackRail = memo(function TrackRail({ draft, applied, track, summaryItems, loading, activeStopIndexes, activeEventIndexes, tab, queryCollapsed, showCommandBar, onDraft, onSubmit, onTab, onSelectIndex, onToggleQuery, onCollapse }: {
|
||||
draft: Draft;
|
||||
applied: Draft;
|
||||
track?: TrackPlaybackResponse;
|
||||
summaryItems: WorkspaceQueueMetricRailItem[];
|
||||
loading: boolean;
|
||||
@@ -255,9 +289,9 @@ const TrackRail = memo(function TrackRail({ draft, track, summaryItems, loading,
|
||||
{ key: 'yesterday', label: '昨天', range: workspaceDateTimePresetWindow('yesterday') },
|
||||
{ key: 'three-days', label: '近 3 天', range: workspaceDateTimePresetWindow('last3Days') }
|
||||
], []).map((preset) => ({ ...preset, active: activePreset === preset.key }));
|
||||
const appliedWindow = `${draft.dateFrom.replace('T', ' ').slice(5)} – ${draft.dateTo.replace('T', ' ').slice(5)}`;
|
||||
const appliedSource = draft.protocol ? protocolDisplayLabel(draft.protocol) : '自动来源';
|
||||
const contextTitle = queryCollapsed ? track?.plate || draft.keyword || '轨迹查询' : '轨迹查询';
|
||||
const compactAppliedWindow = compactTrackWindowLabel(applied.dateFrom, applied.dateTo);
|
||||
const appliedSource = applied.protocol ? protocolDisplayLabel(applied.protocol) : '自动来源';
|
||||
const contextTitle = queryCollapsed ? track?.plate || applied.keyword || '轨迹查询' : '轨迹查询';
|
||||
return <aside className="v2-track-rail">
|
||||
<Card className="v2-track-rail-shell" bodyStyle={{ padding: 0 }}>
|
||||
<section className={`v2-track-query-panel${queryCollapsed ? ' is-collapsed' : ''}${showCommandBar ? '' : ' is-command-hidden'}`}>
|
||||
@@ -267,8 +301,8 @@ const TrackRail = memo(function TrackRail({ draft, track, summaryItems, loading,
|
||||
eyebrow="轨迹回放"
|
||||
icon={<IconRoute />}
|
||||
title={contextTitle}
|
||||
description={queryCollapsed ? `${appliedWindow} · ${appliedSource}` : '最长支持连续 7 天'}
|
||||
status={loading ? '读取中' : track ? `${track.points.length.toLocaleString('zh-CN')} 点` : '等待查询'}
|
||||
description={queryCollapsed ? `${compactAppliedWindow} · ${appliedSource}` : '最长支持连续 7 天'}
|
||||
status={loading ? '读取中' : track ? undefined : '等待查询'}
|
||||
statusColor={loading ? 'blue' : track ? 'green' : 'grey'}
|
||||
actions={<>
|
||||
<Button
|
||||
@@ -308,6 +342,7 @@ const TrackRail = memo(function TrackRail({ draft, track, summaryItems, loading,
|
||||
className="v2-track-result-metric-rail"
|
||||
items={summaryItems}
|
||||
/>
|
||||
{track ? <TrackCoverageNotice track={track} onModify={onToggleQuery} onOverview={() => onTab('overview')} /> : null}
|
||||
<SegmentedTabs className="v2-track-rail-tabs" ariaLabel="轨迹明细分类" value={tab} onChange={onTab} items={[{ key: 'stops', label: '停留点', count: track?.stops.length ?? 0 }, { key: 'events', label: '事件点', count: track?.events.length ?? 0 }, { key: 'overview', label: '概览' }]} />
|
||||
<div className="v2-track-rail-scroll">
|
||||
{!track ? <PanelEmpty className="v2-track-rail-empty" compact tone="primary" icon={<IconMapPin />} title="选择车辆后查询轨迹" description="停留点、轨迹事件与行程证据会在这里统一呈现。" /> : null}
|
||||
@@ -323,18 +358,21 @@ const TrackRail = memo(function TrackRail({ draft, track, summaryItems, loading,
|
||||
const SegmentRail = memo(function SegmentRail({ track }: { track: TrackPlaybackResponse }) {
|
||||
const segments = track.segments.slice(0, 160);
|
||||
const total = Math.max(1, segments.reduce((sum, segment) => sum + Math.max(1, segment.durationSeconds), 0));
|
||||
return <div className="v2-track-segment-rail" aria-label="轨迹活动分段">{segments.map((segment) => <span
|
||||
aria-label={`${segment.title} ${time(segment.startTime)} 至 ${time(segment.endTime)}`}
|
||||
const movingSegments = segments.filter((segment) => segment.type === 'moving').length;
|
||||
const stoppedSegments = segments.filter((segment) => segment.type === 'stopped').length;
|
||||
const gapSegments = segments.filter((segment) => segment.type === 'gap').length;
|
||||
return <><span className="v2-sr-only">轨迹活动共 {segments.length} 段:行驶 {movingSegments} 段,停留 {stoppedSegments} 段,数据间隔 {gapSegments} 段</span><div className="v2-track-segment-rail" aria-hidden="true">{segments.map((segment) => <span
|
||||
className={`is-${segment.type}`} key={`${segment.index}-${segment.startTime}`}
|
||||
style={{ flexGrow: Math.max(1, segment.durationSeconds) / total }}
|
||||
title={`${segment.title} · ${formatDuration(segment.durationSeconds)} · ${number(segment.distanceKm)} km`}
|
||||
/>)}</div>;
|
||||
/>)}</div></>;
|
||||
});
|
||||
|
||||
export default function TrackPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const mobileLayout = useMobileLayout();
|
||||
const monitorReturn = monitorReturnFromParams(searchParams);
|
||||
const vehicleReturn = vehicleReturnFromParams(searchParams);
|
||||
const fallback = useMemo(defaultTrackWindow, []);
|
||||
const routeKey = searchParams.toString();
|
||||
const criteria = useMemo<Draft>(() => {
|
||||
@@ -432,8 +470,8 @@ export default function TrackPage() {
|
||||
if (next.dateTo) url.set('dateTo', next.dateTo);
|
||||
if (next.protocol) url.set('protocol', next.protocol);
|
||||
setQueryCollapsed(true);
|
||||
setSearchParams(preserveMonitorReturn(url, monitorReturn), { replace: true });
|
||||
}, [draft, monitorReturn, setSearchParams]);
|
||||
setSearchParams(preserveVehicleReturn(preserveMonitorReturn(url, monitorReturn), vehicleReturn), { replace: true });
|
||||
}, [draft, monitorReturn, setSearchParams, vehicleReturn]);
|
||||
const selectIndex = useCallback((index: number) => { setPlaying(false); setActiveIndex(Math.max(0, Math.min(points.length - 1, index))); }, [points.length]);
|
||||
const selectRailIndex = useCallback((index: number) => {
|
||||
selectIndex(index);
|
||||
@@ -449,11 +487,11 @@ export default function TrackPage() {
|
||||
};
|
||||
const progress = points.length > 1 ? boundedIndex / (points.length - 1) * 100 : 0;
|
||||
const tripSummaryItems = useMemo(() => trackSummaryItems(track), [track]);
|
||||
const trackRail = <TrackRail draft={draft} track={track} summaryItems={tripSummaryItems} loading={query.isFetching} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} queryCollapsed={queryCollapsed} showCommandBar={!mobileLayout} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectRailIndex} onToggleQuery={toggleQuery} onCollapse={collapseRail} />;
|
||||
const appliedWindow = `${draft.dateFrom.replace('T', ' ').slice(5)} – ${draft.dateTo.replace('T', ' ').slice(5)}`;
|
||||
const mobileAppliedWindow = compactTrackWindowLabel(draft.dateFrom, draft.dateTo);
|
||||
const appliedSource = draft.protocol ? protocolDisplayLabel(draft.protocol) : '自动来源';
|
||||
const mobileSheetTitle = track?.plate || draft.keyword.trim() || '轨迹查询与明细';
|
||||
const trackRail = <TrackRail draft={draft} applied={criteria} track={track} summaryItems={tripSummaryItems} loading={query.isFetching} activeStopIndexes={activeStopIndexes} activeEventIndexes={activeEventIndexes} tab={panelTab} queryCollapsed={queryCollapsed} showCommandBar={!mobileLayout} onDraft={setDraft} onSubmit={submit} onTab={setPanelTab} onSelectIndex={selectRailIndex} onToggleQuery={toggleQuery} onCollapse={collapseRail} />;
|
||||
const appliedWindow = `${criteria.dateFrom.replace('T', ' ').slice(5)} – ${criteria.dateTo.replace('T', ' ').slice(5)}`;
|
||||
const mobileAppliedWindow = compactTrackWindowLabel(criteria.dateFrom, criteria.dateTo);
|
||||
const appliedSource = criteria.protocol ? protocolDisplayLabel(criteria.protocol) : '自动来源';
|
||||
const mobileSheetTitle = track?.plate || criteria.keyword.trim() || '轨迹查询与明细';
|
||||
const mobileSheetDescription = track
|
||||
? `${appliedWindow} · ${appliedSource}`
|
||||
: '选择车辆、时间范围与数据来源';
|
||||
@@ -472,8 +510,14 @@ export default function TrackPage() {
|
||||
{ label: '时间范围', value: mobileAppliedWindow, detail: '最长连续 7 天' },
|
||||
{ label: '数据来源', value: appliedSource, detail: '支持自动优选' }
|
||||
], [appliedSource, mobileAppliedWindow, track, tripSummaryItems]);
|
||||
const emptyTrackHasVehicle = Boolean(track && criteria.keyword.trim());
|
||||
const emptyTrackTitle = emptyTrackHasVehicle ? '当前时间窗没有可回放轨迹' : '先选择车辆,再开始轨迹回放';
|
||||
const emptyTrackDescription = emptyTrackHasVehicle
|
||||
? '车辆已选定,可调整时间范围或数据来源后重新查询。'
|
||||
: '地图会呈现完整路径、停留点、事件点与逐帧播放位置。';
|
||||
const activeVehicleLabel = track?.plate || criteria.keyword.trim() || '选择车辆';
|
||||
|
||||
return <div className={`v2-track-page${mobileLayout ? ' is-mobile-layout' : ''}${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn ? ' has-monitor-return' : ''}`}>
|
||||
return <div className={`v2-track-page${mobileLayout ? ' is-mobile-layout' : ''}${railCollapsed ? ' is-rail-collapsed' : ''}${monitorReturn || vehicleReturn ? ' has-monitor-return' : ''}`}>
|
||||
<MonitorReturnBar />
|
||||
{mobileLayout ? <WorkspaceSideSheet
|
||||
className="v2-track-detail-sidesheet"
|
||||
@@ -501,9 +545,23 @@ export default function TrackPage() {
|
||||
onCancel={collapseRail}
|
||||
>{trackRail}</WorkspaceSideSheet> : trackRail}
|
||||
<section className="v2-track-stage">
|
||||
<TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} followDurationMs={playing ? Math.max(40, trackPlaybackInterval(playbackSpeed) - 10) : 180} onSelectIndex={selectIndex} onFollowChange={setFollow} />
|
||||
<TrackMap points={points} stops={track?.stops ?? []} activeIndex={boundedIndex} showStops={showStops} follow={follow} followDurationMs={playing ? Math.max(40, trackPlaybackInterval(playbackSpeed) - 10) : 180} onSelectIndex={selectIndex} onFollowChange={setFollow} onOpenEvidence={() => { setPanelTab('overview'); setRailCollapsed(false); }} />
|
||||
|
||||
{railCollapsed && track?.points.length ? <Button className="v2-track-rail-expand" theme="light" aria-label="打开轨迹详情" icon={<IconList />} onClick={() => setRailCollapsed(false)}>
|
||||
{mobileLayout ? <Button
|
||||
className="v2-track-mobile-query-launcher"
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
aria-label={`打开轨迹查询,${activeVehicleLabel},${mobileAppliedWindow}`}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="v2-track-mobile-workbench"
|
||||
aria-expanded={!railCollapsed}
|
||||
onClick={() => setRailCollapsed(false)}
|
||||
>
|
||||
<span className="v2-track-mobile-query-copy"><IconSearch /><span><b>{activeVehicleLabel}</b><small>{criteria.keyword.trim() ? `${mobileAppliedWindow} · ${appliedSource}` : '选择时间与数据来源'}</small></span></span>
|
||||
<IconChevronRight aria-hidden="true" />
|
||||
</Button> : null}
|
||||
|
||||
{!mobileLayout && railCollapsed && track?.points.length ? <Button className="v2-track-rail-expand" theme="light" aria-label="打开轨迹详情" icon={<IconList />} onClick={() => setRailCollapsed(false)}>
|
||||
<span className="v2-track-rail-expand-copy">
|
||||
<strong>轨迹详情</strong>
|
||||
<small>{number(track.summary.distanceKm)} km · {track.stops.length} 个停留</small>
|
||||
@@ -512,12 +570,12 @@ export default function TrackPage() {
|
||||
{track?.points.length ? <div className="v2-track-stage-tools" role="toolbar" aria-label="地图轨迹工具">
|
||||
<Button theme="borderless" aria-label={follow ? '关闭车辆跟随' : '开启车辆跟随'} className={follow ? 'is-active' : ''} icon={<IconMapPin />} disabled={!points.length} onClick={() => setFollow((value) => !value)}>{follow ? '跟随车辆' : '自由浏览'}</Button>
|
||||
<Button theme="borderless" aria-label={showStops ? '隐藏停留点' : '显示停留点'} className={showStops ? 'is-active' : ''} icon={showStops ? <IconEyeOpened /> : <IconEyeClosed />} disabled={!track?.stops.length} onClick={() => setShowStops((value) => !value)}>停留点</Button>
|
||||
<Button theme="borderless" aria-label="导出轨迹 CSV" icon={<IconDownload />} disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}>导出</Button>
|
||||
<Button theme="borderless" aria-label="导出当前回放点 CSV" icon={<IconDownload />} disabled={!track?.points.length} onClick={() => track && downloadTrackCsv(track)}>导出</Button>
|
||||
</div> : null}
|
||||
|
||||
{track?.points.length ? <>
|
||||
<Card className="v2-track-current-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader variant="compact" title={track.plate || track.vin} description={dateTime(current?.deviceTime)} meta={<Tag color="blue" type="light" size="small">{number(progress, 0)}%</Tag>} />
|
||||
<WorkspacePanelHeader variant="compact" title={track.plate || track.vin} description={dateTime(current?.deviceTime)} meta={<span className="v2-track-current-meta"><Tag color="blue" type="light" size="small">{number(progress, 0)}%</Tag>{mobileLayout ? <Button className="v2-track-current-detail" theme="borderless" type="tertiary" size="small" icon={<IconList />} aria-label="打开轨迹详情" onClick={() => setRailCollapsed(false)}>明细</Button> : null}</span>} />
|
||||
<div className="v2-track-current-metrics">
|
||||
<span><small>速度</small><strong>{number(current?.speedKmh ?? 0)}<em> km/h</em></strong></span>
|
||||
<span><small>方向</small><strong>{direction(current?.directionDeg)}</strong></span>
|
||||
@@ -529,23 +587,27 @@ export default function TrackPage() {
|
||||
</div>
|
||||
<div className={`v2-track-current-evidence v2-track-current-coverage${track.coverage.complete ? '' : ' is-warning'}`}>
|
||||
<ProtocolTag className="v2-track-protocol-tag" protocol={current?.protocol} compact />
|
||||
<span title={track.coverage.evidence}><i /><strong>{track.coverage.complete ? '时间窗完整' : '最新切片'}</strong></span>
|
||||
<em>{track.coverage.processedPoints.toLocaleString('zh-CN')} → {track.coverage.returnedPoints.toLocaleString('zh-CN')} 点</em>
|
||||
<span title={track.coverage.evidence}><i /><strong>{track.coverage.complete ? '查询完整' : '查询不完整'}</strong></span>
|
||||
<em>{track.coverage.processedPoints.toLocaleString('zh-CN')} 源点 → {track.coverage.returnedPoints.toLocaleString('zh-CN')} 回放点</em>
|
||||
<small>{alarm(current?.alarmFlag)}</small>
|
||||
</div>
|
||||
<p title={addressSettling ? undefined : addressQuery.data?.formattedAddress}>{playing ? '播放中,暂停地址解析' : addressSettling ? '位置已变化,等待地址解析…' : addressQuery.isFetching ? '地址解析中…' : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}</p>
|
||||
<p className={addressQuery.isError ? 'is-address-error' : undefined} title={addressSettling || addressQuery.isError ? undefined : addressQuery.data?.formattedAddress}>{playing ? '播放中,暂停地址解析' : addressSettling ? '位置已变化,等待地址解析…' : addressQuery.isFetching ? '地址解析中…' : addressQuery.isError ? <><span>地址暂不可用 · {current?.longitude.toFixed(6)}, {current?.latitude.toFixed(6)}</span><Button className="v2-track-address-retry" theme="borderless" type="tertiary" size="small" onClick={() => addressQuery.refetch()}>重试地址</Button></> : addressQuery.data?.formattedAddress || `${current?.longitude.toFixed(6)}, ${current?.latitude.toFixed(6)}`}</p>
|
||||
</Card>
|
||||
</> : <Card className="v2-track-empty-state" bodyStyle={{ padding: 0 }}><WorkspaceEmptyGuide
|
||||
icon={<IconMapPin />}
|
||||
title="先选择车辆,再开始轨迹回放"
|
||||
description="地图会呈现完整路径、停留点、事件点与逐帧播放位置。"
|
||||
steps={[
|
||||
{ icon: <IconSearch />, title: '选择车辆', description: '车牌或 VIN 精确定位' },
|
||||
{ icon: <IconMapPin />, title: '确认来源', description: '自动选择最佳定位来源' },
|
||||
{ icon: <IconPlay />, title: '播放核验', description: '逐点查看速度、里程与事件' }
|
||||
]}
|
||||
action={<Button theme="solid" icon={<IconSearch />} onClick={() => setRailCollapsed(false)}>选择车辆</Button>}
|
||||
/></Card>}
|
||||
</> : mobileLayout ? <div className="v2-track-mobile-empty"><WorkspaceEmptyGuide
|
||||
icon={<IconMapPin />}
|
||||
title={emptyTrackTitle}
|
||||
description={emptyTrackDescription}
|
||||
/></div> : <Card className="v2-track-empty-state" bodyStyle={{ padding: 0 }}><WorkspaceEmptyGuide
|
||||
icon={<IconMapPin />}
|
||||
title={emptyTrackTitle}
|
||||
description={emptyTrackDescription}
|
||||
steps={emptyTrackHasVehicle ? undefined : [
|
||||
{ icon: <IconSearch />, title: '选择车辆', description: '车牌或 VIN 精确定位' },
|
||||
{ icon: <IconMapPin />, title: '确认来源', description: '自动选择最佳定位来源' },
|
||||
{ icon: <IconPlay />, title: '播放核验', description: '逐点查看速度、里程与事件' }
|
||||
]}
|
||||
action={<Button theme="solid" icon={<IconSearch />} aria-label={emptyTrackHasVehicle ? '修改查询' : '选择车辆'} onClick={() => setRailCollapsed(false)}>{emptyTrackHasVehicle ? '修改查询' : '选择车辆'}</Button>}
|
||||
/></Card>}
|
||||
|
||||
{query.isFetching ? <PanelLoading className="v2-track-loading" compact title="正在生成轨迹" description="正在校验来源覆盖并整理停留与事件证据。" /> : null}
|
||||
{query.isError ? <div className="v2-track-error"><InlineError message={query.error instanceof Error ? query.error.message : '轨迹查询失败'} onRetry={() => query.refetch()} /></div> : null}
|
||||
@@ -553,8 +615,9 @@ export default function TrackPage() {
|
||||
{track?.points.length ? <Card className="v2-track-playback-dock" bodyStyle={{ padding: 0 }}>
|
||||
<div className="v2-track-dock-progress">
|
||||
{track ? <SegmentRail track={track} /> : <div className="v2-track-segment-placeholder" />}
|
||||
<Slider key={`track-progress-${points.length}`} className="v2-track-progress-slider" aria-label="轨迹播放进度" min={0} max={Math.max(0, points.length - 1)} step={1} value={boundedIndex} onChange={(value) => selectIndex(Number(value))} disabled={!points.length} showBoundary={false} tipFormatter={(value) => `第 ${Number(value) + 1} / ${points.length} 个数据点`} />
|
||||
<div className="v2-track-progress-meta"><time>{time(current?.deviceTime)}</time><span className="v2-track-progress-status">{playing ? '播放中' : '已暂停'} · 数据点 {points.length ? boundedIndex + 1 : 0} / {points.length}</span><time>{time(track?.summary.endTime)}</time></div>
|
||||
<Slider key={`track-progress-${points.length}`} className="v2-track-progress-slider" aria-label="轨迹播放进度" aria-valuetext={`第 ${boundedIndex + 1} / ${points.length} 个回放点,${time(current?.deviceTime)}`} min={0} max={Math.max(0, points.length - 1)} step={1} value={boundedIndex} onChange={(value) => selectIndex(Number(value))} disabled={!points.length} showBoundary={false} tipFormatter={(value) => `第 ${Number(value) + 1} / ${points.length} 个回放点`} />
|
||||
<div className="v2-track-progress-meta"><time>{time(current?.deviceTime)}</time><span className="v2-track-progress-status">{playing ? '播放中' : '已暂停'} · 回放点 {points.length ? boundedIndex + 1 : 0} / {points.length}</span><time>{time(track?.summary.endTime)}</time></div>
|
||||
<span className="v2-sr-only" role="status" aria-live="polite">{playing ? `正在以 ${playbackSpeed} 倍速度播放轨迹` : `轨迹已暂停在第 ${boundedIndex + 1} 个回放点`}</span>
|
||||
</div>
|
||||
<div className="v2-track-dock-controls">
|
||||
<Button theme="borderless" aria-label="上一个轨迹点" icon={<IconChevronLeft />} onClick={() => selectIndex(boundedIndex - 1)} disabled={!boundedIndex} />
|
||||
@@ -562,9 +625,20 @@ export default function TrackPage() {
|
||||
<span className="v2-track-playback-primary-label">{playing ? '暂停' : '播放'}</span>
|
||||
</Button>
|
||||
<Button theme="borderless" aria-label="下一个轨迹点" icon={<IconChevronRight />} onClick={() => selectIndex(boundedIndex + 1)} disabled={!points.length || boundedIndex >= points.length - 1} />
|
||||
<label><span id="track-playback-speed-label">速度</span><Select aria-labelledby="track-playback-speed-label" value={playbackSpeed} onChange={(value) => setPlaybackSpeed(Number(value) as PlaybackSpeed)} optionList={speedOptions.map((speed) => ({ value: speed, label: `${speed}×` }))} /></label>
|
||||
<label><span id="track-playback-speed-label">倍速</span><Select aria-labelledby="track-playback-speed-label" value={playbackSpeed} onChange={(value) => setPlaybackSpeed(Number(value) as PlaybackSpeed)} optionList={speedOptions.map((speed) => ({ value: speed, label: `${speed}×` }))} /></label>
|
||||
<Button theme="borderless" aria-label="回到起点" icon={<IconRefresh />} onClick={() => selectIndex(0)} disabled={!boundedIndex} />
|
||||
</div>
|
||||
</Card> : mobileLayout ? <Card className="v2-track-playback-dock is-empty" bodyStyle={{ padding: 0 }} aria-label="轨迹播放控制,等待查询结果">
|
||||
<div className="v2-track-dock-progress">
|
||||
<Slider className="v2-track-progress-slider" aria-label="轨迹播放进度,等待查询结果" min={0} max={1} value={0} disabled showBoundary={false} />
|
||||
<div className="v2-track-progress-meta"><time>00:00:00</time><span className="v2-track-progress-status">等待轨迹数据</span><time>00:00:00</time></div>
|
||||
</div>
|
||||
<div className="v2-track-dock-controls">
|
||||
<Button theme="borderless" aria-label="上一个轨迹点" icon={<IconChevronLeft />} disabled />
|
||||
<Button theme="solid" className="is-primary" aria-label="开始轨迹播放" icon={<IconPlay />} disabled><span className="v2-track-playback-primary-label">播放</span></Button>
|
||||
<Button theme="borderless" aria-label="下一个轨迹点" icon={<IconChevronRight />} disabled />
|
||||
<label><span id="track-empty-playback-speed-label">倍速</span><Select aria-labelledby="track-empty-playback-speed-label" value={1} disabled optionList={[{ value: 1, label: '1×' }]} /></label>
|
||||
</div>
|
||||
</Card> : null}
|
||||
</section>
|
||||
</div>;
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import UsersPage from './UsersPage';
|
||||
import UsersPage, { customerAccessDraftDiff, userDirectoryRouteFromParams } from './UsersPage';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
adminUsers: vi.fn(),
|
||||
vehicleCoverage: vi.fn(),
|
||||
createCustomerUser: vi.fn(),
|
||||
updateCustomerUser: vi.fn()
|
||||
updateCustomerUser: vi.fn(),
|
||||
batchCustomerUsers: vi.fn()
|
||||
}));
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <output data-testid="users-location">{location.pathname}{location.search}</output>;
|
||||
}
|
||||
|
||||
function renderPage(client: QueryClient, initialEntry = '/users', withLocation = false) {
|
||||
return render(<QueryClientProvider client={client}><MemoryRouter future={ROUTER_FUTURE} initialEntries={[initialEntry]}><UsersPage />{withLocation ? <LocationProbe /> : null}</MemoryRouter></QueryClientProvider>);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.querySelectorAll('.semi-portal').forEach((portal) => portal.remove());
|
||||
@@ -21,6 +33,106 @@ afterEach(() => {
|
||||
Object.values(mocks).forEach((mock) => mock.mockReset());
|
||||
});
|
||||
|
||||
test('summarizes account, menu and vehicle authorization differences', () => {
|
||||
const baseline = {
|
||||
username: 'customer-east', displayName: '华东客户', password: '', status: 'enabled' as const,
|
||||
customerRef: '', tenantRef: '', menuKeys: ['monitor', 'vehicles'],
|
||||
vehicleGrants: [
|
||||
{ vin: 'VIN001', validFrom: '2026-07-01T00:00', validTo: '' },
|
||||
{ vin: 'VIN002', validFrom: '2026-07-01T00:00', validTo: '' }
|
||||
]
|
||||
};
|
||||
const draft = {
|
||||
...baseline,
|
||||
displayName: '华东客户(更新)',
|
||||
status: 'disabled' as const,
|
||||
menuKeys: ['monitor', 'statistics'],
|
||||
vehicleGrants: [
|
||||
{ vin: 'VIN001', validFrom: '2026-07-01T00:00', validTo: '2026-08-01T00:00' },
|
||||
{ vin: 'VIN003', validFrom: '2026-07-20T00:00', validTo: '' }
|
||||
]
|
||||
};
|
||||
|
||||
const diff = customerAccessDraftDiff(draft, baseline);
|
||||
expect(diff.addedMenus).toEqual(['statistics']);
|
||||
expect(diff.removedMenus).toEqual(['vehicles']);
|
||||
expect(diff.addedVehicles.map((grant) => grant.vin)).toEqual(['VIN003']);
|
||||
expect(diff.removedVehicles.map((grant) => grant.vin)).toEqual(['VIN002']);
|
||||
expect(diff.changedVehicles.map((grant) => grant.vin)).toEqual(['VIN001']);
|
||||
expect(diff).toMatchObject({ identityChanged: true, statusChanged: true, disablesAccount: true, requiresConfirmation: true });
|
||||
});
|
||||
|
||||
test('normalizes shareable account directory route state', () => {
|
||||
expect(userDirectoryRouteFromParams(new URLSearchParams('userSearch=%E5%8D%8E%E4%B8%9C&userStatus=attention&userPage=3&userLimit=50&userId=12&userSection=vehicles'))).toEqual({
|
||||
keyword: '华东', status: 'attention', page: 3, limit: 50, userID: 12, mode: '', importOpen: false, section: 'vehicles'
|
||||
});
|
||||
expect(userDirectoryRouteFromParams(new URLSearchParams('userStatus=unknown&userPage=-2&userLimit=7&userId=nope&userMode=create'), 10)).toEqual({
|
||||
keyword: '', status: 'all', page: 1, limit: 10, userID: null, mode: 'create', importOpen: false, section: 'identity'
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps the account directory URL while batch import preflights, repairs and creates ready rows', async () => {
|
||||
mocks.adminUsers.mockResolvedValue([]);
|
||||
mocks.batchCustomerUsers
|
||||
.mockResolvedValueOnce({
|
||||
mode: 'preview', summary: { received: 1, ready: 0, created: 0, failed: 1 },
|
||||
items: [{ row: 2, username: 'customer-east', displayName: '华东客户', status: 'conflict', code: 'USERNAME_EXISTS', message: '用户名已存在' }]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
mode: 'preview', summary: { received: 1, ready: 1, created: 0, failed: 0 },
|
||||
items: [{ row: 2, username: 'customer-east-new', displayName: '华东客户', status: 'ready', message: '校验通过,可以创建' }]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
mode: 'create', summary: { received: 1, ready: 0, created: 1, failed: 0 },
|
||||
items: [{ row: 2, username: 'customer-east-new', displayName: '华东客户', status: 'created', message: '账号与权限已创建', id: 31 }]
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
renderPage(client, '/users?userSearch=%E5%8D%8E%E4%B8%9C&userStatus=attention&userPage=3&userImport=open', true);
|
||||
|
||||
await screen.findByText('1. 选择账号清单');
|
||||
expect(screen.getByTestId('users-location')).toHaveTextContent('userSearch=%E5%8D%8E%E4%B8%9C');
|
||||
expect(screen.getByTestId('users-location')).toHaveTextContent('userStatus=attention');
|
||||
const csv = 'username,displayName,password,status,customerRef,tenantRef,menuKeys,vehicleVins\ncustomer-east,华东客户,ChangeMe2026!,enabled,CUS-1,T-1,monitor|vehicles,VIN001';
|
||||
const fileInput = document.querySelector<HTMLInputElement>('.v2-user-import-sidesheet input[type="file"]');
|
||||
expect(fileInput).toBeInTheDocument();
|
||||
fireEvent.change(fileInput!, { target: { files: [new File([csv], 'customers.csv', { type: 'text/csv' })] } });
|
||||
expect(await screen.findByText('已读取 1 个客户账号')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始预检' }));
|
||||
expect(await screen.findByText('用户名冲突')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByDisplayValue('customer-east'), { target: { value: 'customer-east-new' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始预检' }));
|
||||
expect(await screen.findByText('可创建')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建 1 个账号' }));
|
||||
expect(await screen.findByText('已创建')).toBeInTheDocument();
|
||||
expect(mocks.batchCustomerUsers).toHaveBeenNthCalledWith(3, 'create', expect.arrayContaining([expect.objectContaining({ input: expect.objectContaining({ username: 'customer-east-new' }) })]));
|
||||
});
|
||||
|
||||
test('restores directory page, selected account and editor section from a deep link', async () => {
|
||||
const customers = Array.from({ length: 24 }, (_, index) => ({
|
||||
id: index + 1, username: `customer-${index + 1}`, displayName: `客户 ${index + 1}`, userType: 'customer' as const,
|
||||
status: 'enabled' as const, customerRef: '', tenantRef: '', authProvider: 'local', menuKeys: ['monitor'], vehicleVins: [], vehicles: [], grantHistory: [],
|
||||
createdAt: '2026-07-01T00:00:00Z', updatedAt: '2026-07-01T00:00:00Z'
|
||||
}));
|
||||
mocks.adminUsers.mockResolvedValue(customers);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderPage(client, '/users?userPage=2&userLimit=10&userId=12&userSection=vehicles', true);
|
||||
|
||||
await waitFor(() => expect(document.querySelector('.v2-user-editor-sidesheet')).toHaveTextContent('客户 12'));
|
||||
expect(await screen.findByRole('tab', { name: /车辆权限/ })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByText('第 11–20 个,共 24 个账号')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('users-location')).toHaveTextContent('userId=12');
|
||||
});
|
||||
|
||||
test('shows a repair action for an account deep link that no longer exists', async () => {
|
||||
mocks.adminUsers.mockResolvedValue([]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderPage(client, '/users?userId=999&userSection=menus', true);
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('账号链接已失效');
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除失效链接' }));
|
||||
await waitFor(() => expect(screen.getByTestId('users-location')).not.toHaveTextContent('userId='));
|
||||
});
|
||||
|
||||
test('deduplicates vehicle candidates by VIN and renders granted vehicles plate first', async () => {
|
||||
mocks.adminUsers.mockResolvedValue([{
|
||||
id: 7,
|
||||
@@ -42,7 +154,7 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
|
||||
mocks.vehicleCoverage.mockResolvedValue({ items: [duplicatedVehicle, duplicatedVehicle], total: 1, limit: 20, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
const view = renderPage(client);
|
||||
|
||||
const customer = await screen.findByRole('button', { name: /选择客户 华东客户/ });
|
||||
expect(screen.getByLabelText('账号管理操作')).toHaveClass('has-identity');
|
||||
@@ -53,7 +165,7 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
|
||||
expect(view.container.querySelector('.v2-page-heading')).not.toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-user-discovery-shell')).toContainElement(screen.getByLabelText('账号管理操作'));
|
||||
expect(screen.getByRole('heading', { name: '账号范围', level: 5 })).toBeInTheDocument();
|
||||
expect(screen.getByText('按客户名称、登录账号与权限可用状态查找')).toBeInTheDocument();
|
||||
expect(screen.getByText('按客户名称、登录账号、权限状态与身份来源查找')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 / 1 个账号')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-user-list.semi-card')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-user-filter-panel.v2-workspace-filter-panel.semi-card')).toBeInTheDocument();
|
||||
@@ -63,9 +175,9 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
|
||||
expect(directoryMetrics.closest('.v2-user-directory-metric-rail')).toHaveClass('v2-workspace-metric-rail', 'is-queue');
|
||||
expect(directoryMetrics.querySelectorAll(':scope > [role="listitem"]')).toHaveLength(4);
|
||||
expect(directoryMetrics).toHaveTextContent('当前账号1 / 1全部客户账号');
|
||||
expect(directoryMetrics).toHaveTextContent('待完善0缺少菜单或车辆');
|
||||
expect(directoryMetrics).toHaveTextContent('待完善0身份、菜单或车辆待完善');
|
||||
expect(directoryMetrics).toHaveTextContent('权限就绪1启用且权限完整');
|
||||
expect(directoryMetrics).toHaveTextContent('车辆授权1当前范围合计');
|
||||
expect(directoryMetrics).toHaveTextContent('外部身份0当前范围已连接');
|
||||
expect(customer).toHaveClass('semi-button', 'v2-user-list-item');
|
||||
expect(customer.closest('.semi-list-item')).toHaveClass('v2-user-list-row');
|
||||
expect(customer.querySelector('.v2-user-provider-tag')).toHaveTextContent('本地');
|
||||
@@ -75,18 +187,17 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
|
||||
fireEvent.click(customer);
|
||||
await waitFor(() => expect(customer).toHaveAttribute('aria-expanded', 'true'));
|
||||
const accountSummary = screen.getByRole('list', { name: '客户账号详情摘要' });
|
||||
expect(accountSummary).toHaveTextContent('登录账号@customer-east登录身份');
|
||||
expect(accountSummary).toHaveTextContent('登录账号@customer-east平台本地账号 · 凭据可维护');
|
||||
expect(accountSummary).toHaveTextContent('权限范围1 菜单 · 1 辆最小必要权限');
|
||||
expect(accountSummary).toHaveTextContent('账号状态启用保存后最多 30 秒生效');
|
||||
const editorSheet = document.querySelector<HTMLElement>('.v2-user-editor-sidesheet .semi-sidesheet-inner');
|
||||
expect(editorSheet).toHaveAttribute('aria-label', '客户账号详情');
|
||||
expect(editorSheet).toHaveStyle({ width: 'min(840px, 100vw)' });
|
||||
expect(editorSheet).toHaveStyle({ width: 'min(820px, 100vw)' });
|
||||
expect(editorSheet?.closest('.semi-sidesheet')).toHaveClass('v2-workspace-editor-sidesheet');
|
||||
expect(document.querySelector('.v2-user-editor-sidesheet .v2-user-editor-tabs.semi-tabs')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-user-editor-form')).toHaveAttribute('id', 'v2-user-editor-form');
|
||||
expect(screen.getAllByRole('tab')).toHaveLength(3);
|
||||
expect(document.querySelector('.v2-user-vehicle-section.semi-card')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: '登录身份' }));
|
||||
expect(screen.getByRole('tab', { name: '登录身份' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByLabelText('登录身份摘要')).toHaveTextContent('登录账号customer-east不可修改');
|
||||
expect(screen.getByLabelText('登录身份摘要')).toHaveTextContent('客户显示名称华东客户已设置');
|
||||
expect(screen.getByRole('group', { name: '基础身份' })).toHaveTextContent('用于客户登录和平台内展示');
|
||||
@@ -98,6 +209,7 @@ test('deduplicates vehicle candidates by VIN and renders granted vehicles plate
|
||||
expect(screen.getAllByText('未开放')).toHaveLength(3);
|
||||
fireEvent.click(screen.getByRole('tab', { name: /车辆权限/ }));
|
||||
expect(document.querySelector('.v2-user-vehicle-section .v2-user-section-title .semi-tag')).toHaveTextContent('1 辆');
|
||||
expect(screen.getByText('添加授权车辆')).toBeInTheDocument();
|
||||
const granted = await screen.findByRole('button', { name: '移除 粤A11111' });
|
||||
expect(granted).toHaveClass('semi-button');
|
||||
expect(granted.closest('.v2-assigned-vehicle-card')).toHaveClass('semi-card');
|
||||
@@ -164,13 +276,14 @@ test('pages and filters large vehicle grants without nesting a vehicle-list scro
|
||||
}]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
renderPage(client);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: /车辆权限/ }));
|
||||
|
||||
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(10);
|
||||
expect(screen.getByText('第 1–10 条,共 12 辆')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-assigned-pagination')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
||||
fireEvent.click(within(document.querySelector('.v2-assigned-pagination') as HTMLElement).getByRole('button', { name: 'Next' }));
|
||||
expect(await screen.findByRole('button', { name: '移除 粤A00012' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: /^移除 / })).toHaveLength(2);
|
||||
|
||||
@@ -205,7 +318,7 @@ test('makes draft state explicit, protects bulk removal and preserves edits acro
|
||||
};
|
||||
mocks.adminUsers.mockResolvedValue([customer]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
renderPage(client);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
|
||||
expect(screen.getByText('已同步')).toBeInTheDocument();
|
||||
@@ -216,6 +329,10 @@ test('makes draft state explicit, protects bulk removal and preserves edits acro
|
||||
fireEvent.change(displayName, { target: { value: '华东客户(更新)' } });
|
||||
expect(screen.getByText('待保存')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '保存权限' })).toBeEnabled();
|
||||
expect(screen.getByLabelText('待保存变更')).toHaveTextContent('账号资料已修改');
|
||||
const leaveEvent = new Event('beforeunload', { cancelable: true });
|
||||
window.dispatchEvent(leaveEvent);
|
||||
expect(leaveEvent.defaultPrevented).toBe(true);
|
||||
|
||||
client.setQueryData(['admin-users'], [{ ...customer }]);
|
||||
await waitFor(() => expect(screen.getByDisplayValue('华东客户(更新)')).toBeInTheDocument());
|
||||
@@ -236,6 +353,76 @@ test('makes draft state explicit, protects bulk removal and preserves edits acro
|
||||
expect(screen.getByRole('button', { name: '保存权限' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('protects an unsaved customer draft before switching accounts', async () => {
|
||||
const customer = (id: number, username: string, displayName: string) => ({
|
||||
id, username, displayName, userType: 'customer', status: 'enabled', customerRef: '', tenantRef: '', authProvider: 'local',
|
||||
menuKeys: ['monitor'], vehicleVins: [], vehicles: [], grantHistory: [], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z'
|
||||
});
|
||||
mocks.adminUsers.mockResolvedValue([
|
||||
customer(7, 'customer-east', '华东客户'),
|
||||
customer(8, 'customer-west', '西部客户')
|
||||
]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderPage(client);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
|
||||
fireEvent.change(screen.getByDisplayValue('华东客户'), { target: { value: '华东客户(草稿)' } });
|
||||
fireEvent.click(screen.getByRole('combobox', { name: '切换客户' }));
|
||||
const westOption = await waitFor(() => {
|
||||
const option = [...document.querySelectorAll<HTMLElement>('.semi-select-option')]
|
||||
.find((item) => item.textContent?.includes('西部客户 · @customer-west'));
|
||||
expect(option).toBeInTheDocument();
|
||||
return option!;
|
||||
});
|
||||
fireEvent.click(westOption);
|
||||
|
||||
const confirm = await screen.findByRole('dialog', { name: '确认切换客户账号' });
|
||||
expect(confirm).toHaveTextContent('当前草稿华东客户1 菜单 · 0 辆');
|
||||
expect(confirm).toHaveTextContent('即将打开西部客户读取已保存权限');
|
||||
expect(screen.getByDisplayValue('华东客户(草稿)')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
|
||||
expect(screen.getByDisplayValue('华东客户(草稿)')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /选择客户 西部客户/ }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '放弃并切换' }));
|
||||
expect(await screen.findByDisplayValue('西部客户')).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('华东客户(草稿)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('previews access reductions and requires confirmation before saving', async () => {
|
||||
mocks.adminUsers.mockResolvedValue([{
|
||||
id: 7, username: 'customer-east', displayName: '华东客户', userType: 'customer', status: 'enabled',
|
||||
customerRef: '', tenantRef: '', authProvider: 'local', menuKeys: ['monitor', 'vehicles'], vehicleVins: ['VIN001', 'VIN002'],
|
||||
vehicles: [
|
||||
{ vin: 'VIN001', plate: '粤A11111', validFrom: '2026-06-05T00:00:00+08:00', sourceSystem: 'manual', grantedBy: '平台管理员' },
|
||||
{ vin: 'VIN002', plate: '粤A22222', validFrom: '2026-06-05T00:00:00+08:00', sourceSystem: 'manual', grantedBy: '平台管理员' }
|
||||
],
|
||||
grantHistory: [], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z'
|
||||
}]);
|
||||
mocks.updateCustomerUser.mockResolvedValue({ id: 7 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderPage(client);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: /菜单权限/ }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /车辆查询/ }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: /车辆权限/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '移除 粤A22222' }));
|
||||
|
||||
expect(screen.getByLabelText('待保存变更')).toHaveTextContent('收回菜单 -1');
|
||||
expect(screen.getByLabelText('待保存变更')).toHaveTextContent('移除车辆 -1');
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存权限' }));
|
||||
expect(mocks.updateCustomerUser).not.toHaveBeenCalled();
|
||||
const confirm = await screen.findByRole('dialog', { name: '确认保存权限收回' });
|
||||
expect(confirm).toHaveTextContent('菜单权限收回 1 个');
|
||||
expect(confirm).toHaveTextContent('车辆权限移除 1 辆');
|
||||
expect(confirm).toHaveTextContent('账号详情会保持打开并显示同步结果');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认并保存' }));
|
||||
await waitFor(() => expect(mocks.updateCustomerUser).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.updateCustomerUser.mock.calls[0][1]).toMatchObject({ menuKeys: ['monitor'], vehicleVins: ['VIN001'] });
|
||||
});
|
||||
|
||||
test('keeps the customer directory visible on mobile and opens details on demand', async () => {
|
||||
layout.mobile = true;
|
||||
mocks.adminUsers.mockResolvedValue([{
|
||||
@@ -244,7 +431,7 @@ test('keeps the customer directory visible on mobile and opens details on demand
|
||||
vehicles: [], grantHistory: [], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z'
|
||||
}]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
const view = renderPage(client);
|
||||
|
||||
expect(await screen.findByText('客户权限目录')).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: '客户权限目录统计' }).querySelectorAll(':scope > [role="listitem"].is-primary')).toHaveLength(2);
|
||||
@@ -266,6 +453,7 @@ test('keeps the customer directory visible on mobile and opens details on demand
|
||||
fireEvent.click(customer);
|
||||
expect(await screen.findByRole('button', { name: '关闭账号详情' })).toBeInTheDocument();
|
||||
expect(customer).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(screen.getByRole('tab', { name: /车辆权限/ }));
|
||||
expect(screen.getByText('尚未分配车辆')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-user-vehicle-empty.v2-state-surface.tone-warning')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '添加授权车辆' })).toBeInTheDocument();
|
||||
@@ -275,6 +463,50 @@ test('keeps the customer directory visible on mobile and opens details on demand
|
||||
expect(screen.getAllByRole('button', { name: '新建客户账号' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('guides new customers through an explicit least-privilege creation flow', async () => {
|
||||
layout.mobile = true;
|
||||
mocks.adminUsers.mockResolvedValue([]);
|
||||
mocks.createCustomerUser.mockResolvedValue({ id: 9 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderPage(client);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '创建第一个客户账号' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '客户账号详情' })).toHaveTextContent('0 菜单 · 0 辆');
|
||||
expect(screen.getByText('待开放菜单')).toBeInTheDocument();
|
||||
expect(screen.getByText('新建草稿')).toBeInTheDocument();
|
||||
expect(screen.getByText('先填写登录身份,再确认菜单和车辆权限')).toBeInTheDocument();
|
||||
const identityNext = screen.getByRole('button', { name: '下一步:菜单权限' });
|
||||
expect(identityNext).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '用户名' }), { target: { value: 'customer-north' } });
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '客户名称' }), { target: { value: '北方客户' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('至少 10 位,包含三类字符'), { target: { value: 'weak' } });
|
||||
expect(identityNext).toBeDisabled();
|
||||
fireEvent.change(screen.getByPlaceholderText('至少 10 位,包含三类字符'), { target: { value: 'Customer@123' } });
|
||||
expect(identityNext).toBeEnabled();
|
||||
fireEvent.click(identityNext);
|
||||
|
||||
expect(screen.getByRole('tab', { name: /菜单权限 0/ })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
|
||||
screen.getAllByRole('checkbox').forEach((checkbox) => expect(checkbox).not.toBeChecked());
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /全局监控/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一步:车辆权限' }));
|
||||
|
||||
expect(screen.getByRole('tab', { name: /车辆权限 0/ })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('button', { name: '创建账号' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '创建账号' }));
|
||||
|
||||
await waitFor(() => expect(mocks.createCustomerUser).toHaveBeenCalled());
|
||||
expect(mocks.createCustomerUser.mock.calls[0][0]).toMatchObject({
|
||||
username: 'customer-north',
|
||||
displayName: '北方客户',
|
||||
menuKeys: ['monitor'],
|
||||
vehicleVins: [],
|
||||
vehicleGrants: []
|
||||
});
|
||||
});
|
||||
|
||||
test('applies mobile customer filters explicitly and discards unconfirmed draft changes', async () => {
|
||||
layout.mobile = true;
|
||||
mocks.adminUsers.mockResolvedValue([
|
||||
@@ -290,7 +522,7 @@ test('applies mobile customer filters explicitly and discards unconfirmed draft
|
||||
}
|
||||
]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
renderPage(client);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument();
|
||||
@@ -338,9 +570,10 @@ test('keeps granted vehicles primary and reveals mobile assignment tools on dema
|
||||
createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z'
|
||||
}]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
renderPage(client);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: /车辆权限/ }));
|
||||
|
||||
const toggle = await screen.findByRole('button', { name: '添加授权车辆' });
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
@@ -374,7 +607,7 @@ test('filters the customer directory by status and exposes the active result sco
|
||||
}
|
||||
]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
const view = renderPage(client);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /选择客户 西部客户/ })).toBeInTheDocument();
|
||||
@@ -392,7 +625,7 @@ test('filters the customer directory by status and exposes the active result sco
|
||||
expect(screen.getByText('1 / 2 个账号')).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: '客户权限目录统计' })).toHaveTextContent('当前账号1 / 2筛选结果 / 全部客户');
|
||||
expect(screen.getByRole('list', { name: '客户权限目录统计' })).toHaveTextContent('权限就绪0启用且权限完整');
|
||||
expect(screen.getByRole('list', { name: '客户权限目录统计' })).toHaveTextContent('车辆授权0当前范围合计');
|
||||
expect(screen.getByRole('list', { name: '客户权限目录统计' })).toHaveTextContent('外部身份0当前范围已连接');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '清空账号筛选' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument());
|
||||
@@ -403,7 +636,7 @@ test('filters the customer directory by status and exposes the active result sco
|
||||
// a stale portal when the full suite is under load.
|
||||
view.unmount();
|
||||
document.querySelectorAll('.semi-portal').forEach((portal) => portal.remove());
|
||||
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
renderPage(client);
|
||||
expect(await screen.findByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('combobox', { name: '访问状态' }));
|
||||
@@ -419,7 +652,38 @@ test('filters the customer directory by status and exposes the active result sco
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: /选择客户 西部客户/ })).not.toBeInTheDocument());
|
||||
expect(screen.getByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('待分配车辆')).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: '客户权限目录统计' })).toHaveTextContent('待完善1缺少菜单或车辆');
|
||||
expect(screen.getByRole('list', { name: '客户权限目录统计' })).toHaveTextContent('待完善1身份、菜单或车辆待完善');
|
||||
});
|
||||
|
||||
test('shows external identity ownership and prevents local credential changes', async () => {
|
||||
mocks.adminUsers.mockResolvedValue([{
|
||||
id: 12, username: 'oneos-east', displayName: '华东数据客户', userType: 'customer', status: 'enabled',
|
||||
customerRef: 'CUS-EAST', tenantRef: 'tenant-east', authProvider: 'OneOS', externalSubject: 'oneos:tenant-east:customer-002',
|
||||
menuKeys: ['monitor', 'vehicles'], vehicleVins: ['VIN001'],
|
||||
vehicles: [{ vin: 'VIN001', plate: '粤A11111', validFrom: '2026-06-05T00:00:00+08:00', sourceSystem: 'manual', grantedBy: '平台管理员' }],
|
||||
grantHistory: [], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z'
|
||||
}]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderPage(client, '/users?userId=12');
|
||||
|
||||
const identityStatus = await screen.findByRole('status', { name: '身份来源状态' });
|
||||
expect(identityStatus).toHaveTextContent('OneOS');
|
||||
expect(identityStatus).toHaveTextContent('映射已连接');
|
||||
expect(identityStatus).toHaveTextContent('one••••-002');
|
||||
expect(identityStatus).toHaveTextContent('登录密码、锁定与多因素认证由 OneOS 管理');
|
||||
expect(screen.getByLabelText('外部登录凭据')).toBeDisabled();
|
||||
expect(screen.getByRole('group', { name: '安全与系统映射' })).toHaveTextContent('外部凭据只读');
|
||||
});
|
||||
|
||||
test('does not disguise an account directory failure as an empty directory', async () => {
|
||||
mocks.adminUsers.mockRejectedValue(new Error('HTTP 404'));
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
renderPage(client);
|
||||
|
||||
expect(await screen.findByText('账号目录暂时不可用')).toBeInTheDocument();
|
||||
expect(screen.getByText('这不是空目录。保留当前筛选,恢复连接后可原位重试。')).toBeInTheDocument();
|
||||
expect(screen.queryByText('还没有客户账号')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /重新读取账号/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('uses an actionable semantic empty state for unmatched customer filters', async () => {
|
||||
@@ -429,7 +693,7 @@ test('uses an actionable semantic empty state for unmatched customer filters', a
|
||||
vehicles: [], grantHistory: [], createdAt: '2026-07-16T00:00:00Z', updatedAt: '2026-07-16T00:00:00Z'
|
||||
}]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
const view = renderPage(client);
|
||||
|
||||
expect(await screen.findByRole('button', { name: /选择客户 华东客户/ })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '搜索客户账号' }), { target: { value: '不存在的账号' } });
|
||||
@@ -446,7 +710,7 @@ test('uses an actionable semantic empty state for unmatched customer filters', a
|
||||
test('uses a standard Semi empty state when no customer account exists', async () => {
|
||||
mocks.adminUsers.mockResolvedValue([]);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
const view = renderPage(client);
|
||||
|
||||
expect(await screen.findByText('还没有客户账号')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-user-list-empty.semi-empty')).toBeInTheDocument();
|
||||
@@ -463,8 +727,9 @@ test('submits per-vehicle authorization interval instead of only a VIN list', as
|
||||
}]);
|
||||
mocks.updateCustomerUser.mockResolvedValue({ id: 7 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><UsersPage /></QueryClientProvider>);
|
||||
renderPage(client);
|
||||
fireEvent.click(await screen.findByRole('button', { name: /选择客户 华东客户/ }));
|
||||
fireEvent.click(screen.getByRole('tab', { name: /车辆权限/ }));
|
||||
expect(document.querySelectorAll('input[type="datetime-local"]')).toHaveLength(0);
|
||||
fireEvent.click(screen.getByRole('button', { name: '调整 粤A11111 有效期' }));
|
||||
expect(await screen.findByRole('textbox', { name: '粤A11111 启用时间' })).toHaveValue('2026-07-16 14:08');
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { IconChevronRight, IconDelete, IconPlus, IconRefresh, IconSearch, IconUserGroup } from '@douyinfe/semi-icons';
|
||||
import { Avatar, Button, Card, Checkbox, Collapse, Input, List, Select, Switch, Tabs, Tag, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconChevronRight, IconDelete, IconDownload, IconPlus, IconRefresh, IconSearch, IconUserGroup } from '@douyinfe/semi-icons';
|
||||
import { Avatar, Button, Card, Checkbox, Collapse, Input, List, Select, Switch, Tabs, Tag, Typography, Upload } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { AdminUser, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types';
|
||||
import type { AdminUser, CustomerUserBatchResult, CustomerUserInput, CustomerVehicleGrantInput } from '../../api/types';
|
||||
import { accountBatchImportCSVHeader, accountBatchImportCSVTemplate, parseAccountBatchImportCSV, type AccountBatchImportItem } from '../domain/accountBatchImport';
|
||||
import { downloadBlob } from '../domain/download';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
|
||||
@@ -18,6 +21,7 @@ import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared
|
||||
import { WorkspaceDateTimeField } from '../shared/WorkspaceDateTimeField';
|
||||
import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
|
||||
import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||||
import '../styles/users.css';
|
||||
|
||||
const customerMenus = [
|
||||
{ key: 'monitor', label: '全局监控', description: '查看授权车辆的实时位置与状态' },
|
||||
@@ -26,7 +30,7 @@ const customerMenus = [
|
||||
{ key: 'statistics', label: '里程查询', description: '查询授权车辆的每日与区间里程' }
|
||||
];
|
||||
|
||||
type Draft = {
|
||||
export type CustomerAccessDraft = {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
@@ -37,10 +41,22 @@ type Draft = {
|
||||
vehicleGrants: CustomerVehicleGrantInput[];
|
||||
};
|
||||
|
||||
type Draft = CustomerAccessDraft;
|
||||
|
||||
type EditorSection = 'identity' | 'menus' | 'vehicles';
|
||||
type CustomerScopeFilter = 'all' | Draft['status'] | 'attention';
|
||||
type UserDirectoryRoute = {
|
||||
keyword: string;
|
||||
status: CustomerScopeFilter;
|
||||
page: number;
|
||||
limit: number;
|
||||
userID: number | null;
|
||||
mode: 'create' | '';
|
||||
importOpen: boolean;
|
||||
section: EditorSection;
|
||||
};
|
||||
type CustomerAccessState = {
|
||||
key: 'ready' | 'missing-menus' | 'missing-vehicles' | 'disabled';
|
||||
key: 'ready' | 'missing-menus' | 'missing-vehicles' | 'identity-required' | 'disabled';
|
||||
label: string;
|
||||
shortLabel: string;
|
||||
color: 'green' | 'amber' | 'grey';
|
||||
@@ -54,6 +70,26 @@ const customerScopeOptions: Array<{ value: CustomerScopeFilter; label: string }>
|
||||
{ value: 'attention', label: '待完善权限' }
|
||||
];
|
||||
|
||||
const userDirectoryPageSizes = [10, 20, 50];
|
||||
|
||||
export function userDirectoryRouteFromParams(params: URLSearchParams, defaultLimit = 20): UserDirectoryRoute {
|
||||
const statusParam = params.get('userStatus');
|
||||
const sectionParam = params.get('userSection');
|
||||
const userID = Number(params.get('userId'));
|
||||
const limit = Number(params.get('userLimit'));
|
||||
const page = Number(params.get('userPage'));
|
||||
return {
|
||||
keyword: (params.get('userSearch') || '').slice(0, 160),
|
||||
status: statusParam === 'enabled' || statusParam === 'disabled' || statusParam === 'attention' ? statusParam : 'all',
|
||||
page: Number.isInteger(page) && page > 0 ? page : 1,
|
||||
limit: userDirectoryPageSizes.includes(limit) ? limit : defaultLimit,
|
||||
userID: Number.isInteger(userID) && userID > 0 ? userID : null,
|
||||
mode: params.get('userMode') === 'create' ? 'create' : '',
|
||||
importOpen: params.get('userImport') === 'open',
|
||||
section: sectionParam === 'menus' || sectionParam === 'vehicles' ? sectionParam : 'identity'
|
||||
};
|
||||
}
|
||||
|
||||
const shanghaiDateTimeFormatter = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false
|
||||
});
|
||||
@@ -65,7 +101,17 @@ function dateTimeInput(value?: string) {
|
||||
return shanghaiDateTimeFormatter.format(parsed).replace(' ', 'T');
|
||||
}
|
||||
|
||||
const emptyDraft: Draft = { username: '', displayName: '', password: '', status: 'enabled', customerRef: '', tenantRef: '', menuKeys: ['monitor', 'vehicles', 'tracks', 'statistics'], vehicleGrants: [] };
|
||||
const emptyDraft: Draft = { username: '', displayName: '', password: '', status: 'enabled', customerRef: '', tenantRef: '', menuKeys: [], vehicleGrants: [] };
|
||||
|
||||
function passwordMeetsPolicy(value: string) {
|
||||
const categoryCount = [
|
||||
/[a-z]/.test(value),
|
||||
/[A-Z]/.test(value),
|
||||
/\d/.test(value),
|
||||
/[^A-Za-z0-9]/.test(value)
|
||||
].filter(Boolean).length;
|
||||
return value.length >= 10 && value.length <= 128 && categoryCount >= 3;
|
||||
}
|
||||
|
||||
function draftFromUser(user?: AdminUser): Draft {
|
||||
if (!user) return { ...emptyDraft, menuKeys: [...emptyDraft.menuKeys], vehicleGrants: [] };
|
||||
@@ -84,6 +130,36 @@ function draftSignature(draft: Draft) {
|
||||
});
|
||||
}
|
||||
|
||||
export function customerAccessDraftDiff(draft: CustomerAccessDraft, baseline: CustomerAccessDraft) {
|
||||
const draftMenus = new Set(draft.menuKeys);
|
||||
const baselineMenus = new Set(baseline.menuKeys);
|
||||
const draftGrants = new Map(draft.vehicleGrants.map((grant) => [grant.vin, grant]));
|
||||
const baselineGrants = new Map(baseline.vehicleGrants.map((grant) => [grant.vin, grant]));
|
||||
const addedMenus = draft.menuKeys.filter((key) => !baselineMenus.has(key));
|
||||
const removedMenus = baseline.menuKeys.filter((key) => !draftMenus.has(key));
|
||||
const addedVehicles = draft.vehicleGrants.filter((grant) => !baselineGrants.has(grant.vin));
|
||||
const removedVehicles = baseline.vehicleGrants.filter((grant) => !draftGrants.has(grant.vin));
|
||||
const changedVehicles = draft.vehicleGrants.filter((grant) => {
|
||||
const saved = baselineGrants.get(grant.vin);
|
||||
return saved && (saved.validFrom !== grant.validFrom || saved.validTo !== grant.validTo);
|
||||
});
|
||||
const identityChanged = ['displayName', 'password', 'customerRef', 'tenantRef']
|
||||
.some((key) => draft[key as keyof Pick<Draft, 'displayName' | 'password' | 'customerRef' | 'tenantRef'>]
|
||||
!== baseline[key as keyof Pick<Draft, 'displayName' | 'password' | 'customerRef' | 'tenantRef'>]);
|
||||
const statusChanged = draft.status !== baseline.status;
|
||||
return {
|
||||
addedMenus,
|
||||
removedMenus,
|
||||
addedVehicles,
|
||||
removedVehicles,
|
||||
changedVehicles,
|
||||
identityChanged,
|
||||
statusChanged,
|
||||
disablesAccount: baseline.status === 'enabled' && draft.status === 'disabled',
|
||||
requiresConfirmation: removedMenus.length > 0 || removedVehicles.length > 0 || (baseline.status === 'enabled' && draft.status === 'disabled')
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(value?: string) {
|
||||
if (!value) return '尚未登录';
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false });
|
||||
@@ -94,8 +170,9 @@ function formatGrantTime(value?: string) {
|
||||
return value.replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
function customerAccessState(status: Draft['status'], menuCount: number, vehicleCount: number): CustomerAccessState {
|
||||
function customerAccessState(status: Draft['status'], menuCount: number, vehicleCount: number, identityRequired = false): CustomerAccessState {
|
||||
if (status === 'disabled') return { key: 'disabled', label: '账号已停用', shortLabel: '停用', color: 'grey', attention: false };
|
||||
if (identityRequired) return { key: 'identity-required', label: '身份待关联', shortLabel: '待身份', color: 'amber', attention: true };
|
||||
if (menuCount === 0) return { key: 'missing-menus', label: '待开放菜单', shortLabel: '待菜单', color: 'amber', attention: true };
|
||||
if (vehicleCount === 0) return { key: 'missing-vehicles', label: '待分配车辆', shortLabel: '待车辆', color: 'amber', attention: true };
|
||||
return { key: 'ready', label: '权限就绪', shortLabel: '就绪', color: 'green', attention: false };
|
||||
@@ -113,34 +190,64 @@ function accessBadgeColor(color: CustomerAccessState['color']): WorkspaceSideShe
|
||||
return color;
|
||||
}
|
||||
|
||||
function authProviderMeta(provider?: string) {
|
||||
function authProviderMeta(provider?: string, externalSubject?: string) {
|
||||
const normalized = provider?.trim();
|
||||
if (!normalized || normalized === 'local') return { label: '本地', color: 'grey' as const, title: '本地账号' };
|
||||
return { label: normalized, color: 'cyan' as const, title: `外部身份源:${normalized}` };
|
||||
if (!normalized || normalized === 'local') return {
|
||||
label: '本地', color: 'grey' as const, title: '本地账号', external: false, mappingComplete: true,
|
||||
sourceLabel: '平台本地账号', credentialOwner: '本平台', stateLabel: '凭据可维护'
|
||||
};
|
||||
const mappingComplete = Boolean(externalSubject?.trim());
|
||||
return {
|
||||
label: normalized, color: mappingComplete ? 'cyan' as const : 'amber' as const,
|
||||
title: mappingComplete ? `外部身份源:${normalized},映射已连接` : `外部身份源:${normalized},缺少身份映射`,
|
||||
external: true, mappingComplete, sourceLabel: normalized, credentialOwner: normalized,
|
||||
stateLabel: mappingComplete ? '映射已连接' : '映射待补全'
|
||||
};
|
||||
}
|
||||
|
||||
function maskExternalSubject(value?: string) {
|
||||
const normalized = value?.trim() ?? '';
|
||||
if (!normalized) return '未建立映射';
|
||||
if (normalized.length <= 6) return `${normalized.slice(0, 1)}***${normalized.slice(-1)}`;
|
||||
return `${normalized.slice(0, 3)}••••${normalized.slice(-4)}`;
|
||||
}
|
||||
|
||||
function identityNeedsAttention(user: Pick<AdminUser, 'authProvider' | 'externalSubject'>) {
|
||||
const provider = authProviderMeta(user.authProvider, user.externalSubject);
|
||||
return provider.external && !provider.mappingComplete;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [routeParams, setRouteParams] = useSearchParams();
|
||||
const defaultDirectoryLimit = mobileLayout ? 10 : 20;
|
||||
const routeView = useMemo(() => userDirectoryRouteFromParams(routeParams, defaultDirectoryLimit), [defaultDirectoryLimit, routeParams]);
|
||||
const users = useQuery({ queryKey: ['admin-users'], queryFn: ({ signal }) => api.adminUsers(signal), staleTime: 10_000 });
|
||||
const customers = useMemo(() => (users.data ?? []).filter((user) => user.userType === 'customer'), [users.data]);
|
||||
const [customerKeyword, setCustomerKeyword] = useState('');
|
||||
const [customerKeyword, setCustomerKeyword] = useState(routeView.keyword);
|
||||
const deferredCustomerKeyword = useDeferredValue(customerKeyword.trim());
|
||||
const [customerStatus, setCustomerStatus] = useState<CustomerScopeFilter>('all');
|
||||
const [customerStatus, setCustomerStatus] = useState<CustomerScopeFilter>(routeView.status);
|
||||
const [draftCustomerKeyword, setDraftCustomerKeyword] = useState('');
|
||||
const [draftCustomerStatus, setDraftCustomerStatus] = useState<CustomerScopeFilter>('all');
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(true);
|
||||
const visibleCustomers = useMemo(() => {
|
||||
const keyword = deferredCustomerKeyword.toLocaleLowerCase('zh-CN');
|
||||
return customers.filter((user) => {
|
||||
const accessState = customerAccessState(user.status, user.menuKeys.length, user.vehicles.length);
|
||||
const accessState = customerAccessState(user.status, user.menuKeys.length, user.vehicles.length, identityNeedsAttention(user));
|
||||
if (customerStatus === 'attention' && !accessState.attention) return false;
|
||||
if (customerStatus !== 'all' && customerStatus !== 'attention' && user.status !== customerStatus) return false;
|
||||
if (!keyword) return true;
|
||||
return [user.displayName, user.username, user.customerRef, user.tenantRef]
|
||||
return [user.displayName, user.username, user.customerRef, user.tenantRef, user.authProvider]
|
||||
.some((value) => value?.toLocaleLowerCase('zh-CN').includes(keyword));
|
||||
});
|
||||
}, [customerStatus, customers, deferredCustomerKeyword]);
|
||||
const directoryTotalPages = Math.max(1, Math.ceil(visibleCustomers.length / routeView.limit));
|
||||
const safeDirectoryPage = Math.min(routeView.page, directoryTotalPages);
|
||||
const pagedCustomers = useMemo(() => {
|
||||
const offset = (safeDirectoryPage - 1) * routeView.limit;
|
||||
return visibleCustomers.slice(offset, offset + routeView.limit);
|
||||
}, [routeView.limit, safeDirectoryPage, visibleCustomers]);
|
||||
const [selectedID, setSelectedID] = useState<number | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState<EditorSection>('identity');
|
||||
@@ -157,11 +264,21 @@ export default function UsersPage() {
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [editingGrantVIN, setEditingGrantVIN] = useState('');
|
||||
const [grantEndEnabled, setGrantEndEnabled] = useState(false);
|
||||
const [confirmation, setConfirmation] = useState<'clear-vehicles' | 'discard-editor' | null>(null);
|
||||
const attentionCustomerCount = useMemo(() => customers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).attention).length, [customers]);
|
||||
const readyCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).key === 'ready').length, [visibleCustomers]);
|
||||
const attentionCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length).attention).length, [visibleCustomers]);
|
||||
const grantedVehicles = useMemo(() => visibleCustomers.reduce((total, user) => total + user.vehicles.length, 0), [visibleCustomers]);
|
||||
const [confirmation, setConfirmation] = useState<'clear-vehicles' | 'discard-editor' | 'save-access' | 'switch-customer' | null>(null);
|
||||
const [pendingCustomerID, setPendingCustomerID] = useState<number | null>(null);
|
||||
const [importItems, setImportItems] = useState<AccountBatchImportItem[]>([]);
|
||||
const [importFileName, setImportFileName] = useState('');
|
||||
const [importParseError, setImportParseError] = useState('');
|
||||
const [importResult, setImportResult] = useState<CustomerUserBatchResult | null>(null);
|
||||
const [repairRow, setRepairRow] = useState<number | null>(null);
|
||||
const attentionCustomerCount = useMemo(() => customers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length, identityNeedsAttention(user)).attention).length, [customers]);
|
||||
const disabledCustomerCount = useMemo(() => customers.filter((user) => user.status === 'disabled').length, [customers]);
|
||||
const readyCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length, identityNeedsAttention(user)).key === 'ready').length, [visibleCustomers]);
|
||||
const attentionCustomers = useMemo(() => visibleCustomers.filter((user) => customerAccessState(user.status, user.menuKeys.length, user.vehicles.length, identityNeedsAttention(user)).attention).length, [visibleCustomers]);
|
||||
const disabledCustomers = useMemo(() => visibleCustomers.filter((user) => user.status === 'disabled').length, [visibleCustomers]);
|
||||
const externalCustomers = useMemo(() => visibleCustomers.filter((user) => authProviderMeta(user.authProvider, user.externalSubject).external), [visibleCustomers]);
|
||||
const externalIdentityPending = useMemo(() => externalCustomers.filter(identityNeedsAttention).length, [externalCustomers]);
|
||||
const localCustomerCount = customers.length - customers.filter((user) => authProviderMeta(user.authProvider, user.externalSubject).external).length;
|
||||
const directoryMetrics: WorkspaceQueueMetricRailItem[] = [
|
||||
{
|
||||
label: '当前账号',
|
||||
@@ -173,7 +290,7 @@ export default function UsersPage() {
|
||||
{
|
||||
label: '待完善',
|
||||
value: attentionCustomers,
|
||||
note: '缺少菜单或车辆',
|
||||
note: '身份、菜单或车辆待完善',
|
||||
tone: attentionCustomers ? 'warning' : 'success',
|
||||
emphasis: 'primary'
|
||||
},
|
||||
@@ -185,10 +302,10 @@ export default function UsersPage() {
|
||||
emphasis: 'secondary'
|
||||
},
|
||||
{
|
||||
label: '车辆授权',
|
||||
value: grantedVehicles,
|
||||
note: '当前范围合计',
|
||||
tone: 'neutral',
|
||||
label: '外部身份',
|
||||
value: externalCustomers.length,
|
||||
note: externalIdentityPending ? `${externalIdentityPending} 个待关联` : '当前范围已连接',
|
||||
tone: externalIdentityPending ? 'warning' : 'neutral',
|
||||
emphasis: 'secondary'
|
||||
}
|
||||
];
|
||||
@@ -203,7 +320,41 @@ export default function UsersPage() {
|
||||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||
const vehicleComposerVisible = !mobileLayout || creating || draft.vehicleGrants.length === 0 || vehicleComposerOpen;
|
||||
const hasUnsavedChanges = useMemo(() => draftSignature(draft) !== draftSignature(baselineDraft), [baselineDraft, draft]);
|
||||
const draftAccessState = customerAccessState(draft.status, draft.menuKeys.length, draft.vehicleGrants.length);
|
||||
const draftDiff = useMemo(() => customerAccessDraftDiff(draft, baselineDraft), [baselineDraft, draft]);
|
||||
const selectedIdentityProvider = authProviderMeta(selected?.authProvider, selected?.externalSubject);
|
||||
const draftAccessState = customerAccessState(draft.status, draft.menuKeys.length, draft.vehicleGrants.length, Boolean(!creating && selected && selectedIdentityProvider.external && !selectedIdentityProvider.mappingComplete));
|
||||
const invalidLinkedUser = users.isSuccess && routeView.userID != null && !customers.some((user) => user.id === routeView.userID);
|
||||
const linkedUserOutsideScope = selected && !visibleCustomers.some((user) => user.id === selected.id);
|
||||
const repairingImportItem = importItems.find((item) => item.row === repairRow);
|
||||
const importReadyCount = importResult?.mode === 'preview' ? importResult.summary.ready : 0;
|
||||
const importCreatedCount = importResult?.mode === 'create' ? importResult.summary.created : 0;
|
||||
const importFailedCount = importResult?.summary.failed ?? 0;
|
||||
|
||||
const setUserRoute = (patch: Partial<UserDirectoryRoute>, replace = false) => {
|
||||
setRouteParams((current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
const view = { ...userDirectoryRouteFromParams(current, defaultDirectoryLimit), ...patch };
|
||||
const setOptional = (key: string, value: string, empty: string) => value === empty ? next.delete(key) : next.set(key, value);
|
||||
setOptional('userSearch', view.keyword.trimStart(), '');
|
||||
setOptional('userStatus', view.status, 'all');
|
||||
setOptional('userPage', String(view.page), '1');
|
||||
setOptional('userLimit', String(view.limit), String(defaultDirectoryLimit));
|
||||
setOptional('userId', view.userID ? String(view.userID) : '', '');
|
||||
setOptional('userMode', view.mode, '');
|
||||
setOptional('userImport', view.importOpen ? 'open' : '', '');
|
||||
setOptional('userSection', view.section, 'identity');
|
||||
return next;
|
||||
}, { replace });
|
||||
};
|
||||
|
||||
const clearEditorRoute = (replace = false) => setUserRoute({ userID: null, mode: '', section: 'identity' }, replace);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return;
|
||||
const protectDraft = (event: BeforeUnloadEvent) => event.preventDefault();
|
||||
window.addEventListener('beforeunload', protectDraft);
|
||||
return () => window.removeEventListener('beforeunload', protectDraft);
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!creating && selected) {
|
||||
@@ -259,6 +410,7 @@ export default function UsersPage() {
|
||||
setFeedback(creating ? '客户账号已创建' : '账号与权限已更新');
|
||||
setCreating(false);
|
||||
setSelectedID(result.id);
|
||||
setUserRoute({ userID: result.id, mode: '', section: activeSection }, true);
|
||||
setDraft((value) => {
|
||||
const savedDraft = { ...value, password: '' };
|
||||
setBaselineDraft(savedDraft);
|
||||
@@ -269,7 +421,72 @@ export default function UsersPage() {
|
||||
onError: (error) => setFeedback(error instanceof Error ? error.message : '保存失败')
|
||||
});
|
||||
|
||||
const startCreate = () => {
|
||||
const batchImport = useMutation<CustomerUserBatchResult, Error, 'preview' | 'create'>({
|
||||
mutationFn: (mode) => api.batchCustomerUsers(mode, importItems),
|
||||
onSuccess: async (result) => {
|
||||
setImportResult(result);
|
||||
setRepairRow(result.items.find((item) => item.status === 'invalid' || item.status === 'conflict' || item.status === 'failed')?.row ?? null);
|
||||
if (result.mode === 'create' && result.summary.created) await queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
}
|
||||
});
|
||||
|
||||
const resetBatchImport = () => {
|
||||
batchImport.reset();
|
||||
setImportItems([]);
|
||||
setImportFileName('');
|
||||
setImportParseError('');
|
||||
setImportResult(null);
|
||||
setRepairRow(null);
|
||||
};
|
||||
const openBatchImport = () => {
|
||||
if (hasUnsavedChanges) {
|
||||
setFeedback('请先保存或关闭当前账号草稿,再批量导入');
|
||||
return;
|
||||
}
|
||||
closeEditor(false);
|
||||
resetBatchImport();
|
||||
setUserRoute({ userID: null, mode: '', importOpen: true, section: 'identity' });
|
||||
};
|
||||
const closeBatchImport = () => {
|
||||
setUserRoute({ importOpen: false }, true);
|
||||
resetBatchImport();
|
||||
};
|
||||
const readImportFile = async (file?: File) => {
|
||||
batchImport.reset();
|
||||
setImportResult(null);
|
||||
setRepairRow(null);
|
||||
setImportItems([]);
|
||||
setImportFileName(file?.name ?? '');
|
||||
setImportParseError('');
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = typeof file.text === 'function' ? await file.text() : await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error ?? new Error('无法读取 CSV 文件'));
|
||||
reader.onload = () => resolve(String(reader.result ?? ''));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
setImportItems(parseAccountBatchImportCSV(text));
|
||||
} catch (error) {
|
||||
setImportParseError(error instanceof Error ? error.message : 'CSV 解析失败');
|
||||
}
|
||||
};
|
||||
const updateImportItem = (row: number, patch: Partial<AccountBatchImportItem['input']>) => {
|
||||
setImportItems((current) => current.map((item) => item.row === row ? { ...item, input: { ...item.input, ...patch } } : item));
|
||||
setImportResult(null);
|
||||
batchImport.reset();
|
||||
};
|
||||
const retryFailedImportItems = () => {
|
||||
if (!importResult) return;
|
||||
const failedRows = new Set(importResult.items.filter((item) => item.status !== 'created').map((item) => item.row));
|
||||
setImportItems((current) => current.filter((item) => failedRows.has(item.row)));
|
||||
setImportResult(null);
|
||||
setRepairRow(null);
|
||||
batchImport.reset();
|
||||
};
|
||||
const downloadImportTemplate = () => downloadBlob(new Blob([`\uFEFF${accountBatchImportCSVTemplate}`], { type: 'text/csv;charset=utf-8' }), 'customer-account-import-template.csv');
|
||||
|
||||
const startCreate = (syncRoute = true) => {
|
||||
const nextDraft = draftFromUser();
|
||||
setCreating(true);
|
||||
setSelectedID(null);
|
||||
@@ -286,8 +503,10 @@ export default function UsersPage() {
|
||||
setEditingGrantVIN('');
|
||||
setGrantEndEnabled(false);
|
||||
setConfirmation(null);
|
||||
setPendingCustomerID(null);
|
||||
if (syncRoute) setUserRoute({ userID: null, mode: 'create', section: 'identity' });
|
||||
};
|
||||
const closeEditor = () => {
|
||||
const closeEditor = (syncRoute = true) => {
|
||||
const nextDraft = draftFromUser();
|
||||
setCreating(false);
|
||||
setSelectedID(null);
|
||||
@@ -303,6 +522,8 @@ export default function UsersPage() {
|
||||
setEditingGrantVIN('');
|
||||
setGrantEndEnabled(false);
|
||||
setConfirmation(null);
|
||||
setPendingCustomerID(null);
|
||||
if (syncRoute) clearEditorRoute();
|
||||
};
|
||||
const requestCloseEditor = () => {
|
||||
if (!hasUnsavedChanges) {
|
||||
@@ -311,11 +532,11 @@ export default function UsersPage() {
|
||||
}
|
||||
setConfirmation('discard-editor');
|
||||
};
|
||||
const selectCustomer = (user: AdminUser) => {
|
||||
const loadCustomer = (user: AdminUser, section: EditorSection = 'identity', syncRoute = true) => {
|
||||
const nextDraft = draftFromUser(user);
|
||||
setCreating(false);
|
||||
setSelectedID(user.id);
|
||||
setActiveSection('vehicles');
|
||||
setActiveSection(section);
|
||||
setDraft(nextDraft);
|
||||
setBaselineDraft(nextDraft);
|
||||
setVehicleKeyword('');
|
||||
@@ -326,6 +547,18 @@ export default function UsersPage() {
|
||||
setFeedback('');
|
||||
setEditingGrantVIN('');
|
||||
setGrantEndEnabled(false);
|
||||
setConfirmation(null);
|
||||
setPendingCustomerID(null);
|
||||
if (syncRoute) setUserRoute({ userID: user.id, mode: '', section });
|
||||
};
|
||||
const selectCustomer = (user: AdminUser) => {
|
||||
if (!creating && selectedID === user.id) return;
|
||||
if (hasUnsavedChanges) {
|
||||
setPendingCustomerID(user.id);
|
||||
setConfirmation('switch-customer');
|
||||
return;
|
||||
}
|
||||
loadCustomer(user);
|
||||
};
|
||||
const toggleVIN = (vin: string) => {
|
||||
const isAssigned = assigned.has(vin);
|
||||
@@ -381,23 +614,85 @@ export default function UsersPage() {
|
||||
setAssignedPage(1);
|
||||
setBulkVINs('');
|
||||
};
|
||||
const persistDraft = () => {
|
||||
const persistDraft = (confirmed = false) => {
|
||||
if (save.isPending) return;
|
||||
setFeedback('');
|
||||
if (!draft.displayName.trim() || (creating && (!draft.username.trim() || !draft.password))) {
|
||||
setActiveSection('identity');
|
||||
setUserRoute({ section: 'identity' }, true);
|
||||
setFeedback('请先完善登录身份中的必填信息');
|
||||
return;
|
||||
}
|
||||
const invalidGrant = draft.vehicleGrants.find((grant) => !grant.validFrom || (grant.validTo && grant.validTo <= grant.validFrom));
|
||||
if (invalidGrant) {
|
||||
setActiveSection('vehicles');
|
||||
setUserRoute({ section: 'vehicles' }, true);
|
||||
openGrantEditor(invalidGrant.vin);
|
||||
setFeedback('请检查车辆授权有效期:停用时间必须晚于启用时间');
|
||||
return;
|
||||
}
|
||||
if (!confirmed && draftDiff.requiresConfirmation) {
|
||||
setConfirmation('save-access');
|
||||
return;
|
||||
}
|
||||
save.mutate();
|
||||
};
|
||||
const creatingIdentityComplete = Boolean(
|
||||
draft.username.trim()
|
||||
&& draft.displayName.trim()
|
||||
&& passwordMeetsPolicy(draft.password)
|
||||
);
|
||||
const editorPrimaryLabel = creating
|
||||
? activeSection === 'identity'
|
||||
? '下一步:菜单权限'
|
||||
: activeSection === 'menus'
|
||||
? '下一步:车辆权限'
|
||||
: '创建账号'
|
||||
: '保存权限';
|
||||
const editorPrimaryDisabled = save.isPending || (creating
|
||||
? activeSection === 'identity'
|
||||
? !creatingIdentityComplete
|
||||
: !hasUnsavedChanges
|
||||
: !hasUnsavedChanges);
|
||||
const editorSaveState = save.isError
|
||||
? { color: 'red' as const, label: '保存失败', copy: feedback }
|
||||
: creating
|
||||
? {
|
||||
color: hasUnsavedChanges ? 'amber' as const : 'blue' as const,
|
||||
label: '新建草稿',
|
||||
copy: feedback || (hasUnsavedChanges
|
||||
? '内容仅保存在当前草稿,完成三步后再创建账号'
|
||||
: '先填写登录身份,再确认菜单和车辆权限')
|
||||
}
|
||||
: hasUnsavedChanges
|
||||
? { color: 'amber' as const, label: '待保存', copy: feedback || '更改仅保存在当前草稿,保存后最多 30 秒生效' }
|
||||
: { color: 'green' as const, label: '已同步', copy: feedback || '账号与权限已和服务器同步' };
|
||||
const handleEditorPrimaryAction = () => {
|
||||
if (!creating) {
|
||||
persistDraft();
|
||||
return;
|
||||
}
|
||||
setFeedback('');
|
||||
if (activeSection === 'identity') {
|
||||
if (!creatingIdentityComplete) {
|
||||
setFeedback('请填写用户名、客户名称,并设置符合安全规则的初始密码');
|
||||
return;
|
||||
}
|
||||
setActiveSection('menus');
|
||||
setUserRoute({ section: 'menus' }, true);
|
||||
setFeedback('登录身份已完成,请确认客户可以访问的菜单');
|
||||
return;
|
||||
}
|
||||
if (activeSection === 'menus') {
|
||||
setActiveSection('vehicles');
|
||||
setUserRoute({ section: 'vehicles' }, true);
|
||||
setFeedback(draft.menuKeys.length
|
||||
? `已选择 ${draft.menuKeys.length} 个菜单,请继续分配车辆`
|
||||
: '当前尚未开放菜单;可继续分配车辆,账号将标记为待完善');
|
||||
return;
|
||||
}
|
||||
persistDraft();
|
||||
};
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
persistDraft();
|
||||
@@ -415,6 +710,7 @@ export default function UsersPage() {
|
||||
const applyMobileFilters = () => {
|
||||
setCustomerKeyword(draftCustomerKeyword);
|
||||
setCustomerStatus(draftCustomerStatus);
|
||||
setUserRoute({ keyword: draftCustomerKeyword, status: draftCustomerStatus, page: 1 }, true);
|
||||
setFiltersCollapsed(true);
|
||||
};
|
||||
const resetMobileDraft = () => {
|
||||
@@ -426,6 +722,33 @@ export default function UsersPage() {
|
||||
applyMobileFilters();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (routeView.keyword !== customerKeyword) setCustomerKeyword(routeView.keyword);
|
||||
if (routeView.status !== customerStatus) setCustomerStatus(routeView.status);
|
||||
}, [routeView.keyword, routeView.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!users.isSuccess || hasUnsavedChanges) return;
|
||||
if (routeView.mode === 'create') {
|
||||
if (!creating) startCreate(false);
|
||||
if (activeSection !== routeView.section) setActiveSection(routeView.section);
|
||||
return;
|
||||
}
|
||||
if (routeView.userID) {
|
||||
const routeCustomer = customers.find((user) => user.id === routeView.userID);
|
||||
if (!routeCustomer) return;
|
||||
if (creating || selectedID !== routeCustomer.id) loadCustomer(routeCustomer, routeView.section, false);
|
||||
else if (activeSection !== routeView.section) setActiveSection(routeView.section);
|
||||
return;
|
||||
}
|
||||
if (creating || selectedID) closeEditor(false);
|
||||
}, [routeView.mode, routeView.section, routeView.userID, users.isSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!users.isSuccess || routeView.page <= directoryTotalPages) return;
|
||||
setUserRoute({ page: directoryTotalPages }, true);
|
||||
}, [directoryTotalPages, routeView.page, users.isSuccess]);
|
||||
|
||||
return <div className="v2-user-admin">
|
||||
<div className="v2-user-discovery-shell">
|
||||
<WorkspaceCommandBar
|
||||
@@ -436,9 +759,9 @@ export default function UsersPage() {
|
||||
tone="authority"
|
||||
title="客户访问治理"
|
||||
description="菜单与车辆按最小权限开放,变更最多 30 秒生效"
|
||||
status={`${customers.length} 个客户账号${attentionCustomerCount ? ` · ${attentionCustomerCount} 待完善` : ' · 权限就绪'}`}
|
||||
meta={<Typography.Text type="tertiary">本地账号 · 可扩展外部身份源</Typography.Text>}
|
||||
actions={<Button className="v2-workspace-mobile-tool-button is-primary" theme="solid" icon={<IconPlus />} aria-label="新建客户账号" onClick={startCreate}>新建客户账号</Button>}
|
||||
status={`${customers.length} 个客户账号${attentionCustomerCount ? ` · ${attentionCustomerCount} 待完善` : disabledCustomerCount ? ` · ${disabledCustomerCount} 已停用` : ' · 权限就绪'}`}
|
||||
meta={<Typography.Text type="tertiary">{localCustomerCount} 个本地账号 · {customers.length - localCustomerCount} 个外部身份</Typography.Text>}
|
||||
actions={<><Button className="v2-workspace-mobile-tool-button" theme="light" aria-label="批量导入客户账号" onClick={openBatchImport}>批量导入</Button><Button className="v2-workspace-mobile-tool-button is-primary" theme="solid" icon={<IconPlus />} aria-label="新建客户账号" onClick={() => startCreate()}>新建客户账号</Button></>}
|
||||
/>
|
||||
{mobileLayout ? <div className="v2-user-mobile-discovery">
|
||||
<MobileFilterToggle
|
||||
@@ -458,7 +781,7 @@ export default function UsersPage() {
|
||||
dialogId="v2-user-mobile-filters"
|
||||
closeLabel="关闭客户账号筛选"
|
||||
title="筛选客户账号"
|
||||
description="按账号信息与权限状态缩小客户范围"
|
||||
description="按账号、权限状态与身份来源缩小客户范围"
|
||||
onCancel={closeMobileFilters}
|
||||
secondaryAction={{ label: '重置条件', onClick: resetMobileDraft }}
|
||||
primaryAction={{ label: '应用筛选', onClick: applyMobileFilters }}
|
||||
@@ -468,7 +791,7 @@ export default function UsersPage() {
|
||||
<div>
|
||||
<label>
|
||||
<span>客户账号</span>
|
||||
<Input aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={draftCustomerKeyword} onChange={setDraftCustomerKeyword} placeholder="搜索名称、账号或客户标识" />
|
||||
<Input aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={draftCustomerKeyword} onChange={setDraftCustomerKeyword} placeholder="名称、账号、客户标识或身份源" />
|
||||
</label>
|
||||
<label>
|
||||
<span id="v2-user-mobile-status-filter-label">访问状态</span>
|
||||
@@ -486,7 +809,7 @@ export default function UsersPage() {
|
||||
</div> : <WorkspaceFilterPanel
|
||||
className="v2-user-filter-panel"
|
||||
title="账号范围"
|
||||
description="按客户名称、登录账号与权限可用状态查找"
|
||||
description="按客户名称、登录账号、权限状态与身份来源查找"
|
||||
mobileSummary={`${customerScopeLabel(customerStatus)}${customerKeyword.trim() ? ` · ${customerKeyword.trim()}` : ''}`}
|
||||
expanded
|
||||
status={users.isPending ? '正在读取账号' : `${visibleCustomers.length} / ${customers.length} 个账号`}
|
||||
@@ -497,14 +820,21 @@ export default function UsersPage() {
|
||||
<div className="v2-user-filter-form">
|
||||
<label className="v2-user-filter-keyword">
|
||||
<span>客户账号</span>
|
||||
<Input className="v2-user-list-search" aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={customerKeyword} onChange={setCustomerKeyword} placeholder="搜索名称、账号或客户标识" />
|
||||
<Input className="v2-user-list-search" aria-label="搜索客户账号" prefix={<IconSearch />} showClear value={customerKeyword} onChange={(value) => {
|
||||
setCustomerKeyword(value);
|
||||
setUserRoute({ keyword: value, page: 1 }, true);
|
||||
}} placeholder="名称、账号、客户标识或身份源" />
|
||||
</label>
|
||||
<label>
|
||||
<span id="v2-user-status-filter-label">访问状态</span>
|
||||
<Select
|
||||
aria-labelledby="v2-user-status-filter-label"
|
||||
value={customerStatus}
|
||||
onChange={(value) => setCustomerStatus(String(value) as typeof customerStatus)}
|
||||
onChange={(value) => {
|
||||
const status = String(value) as typeof customerStatus;
|
||||
setCustomerStatus(status);
|
||||
setUserRoute({ status, page: 1 }, true);
|
||||
}}
|
||||
optionList={customerScopeOptions}
|
||||
/>
|
||||
</label>
|
||||
@@ -518,6 +848,7 @@ export default function UsersPage() {
|
||||
onClick={() => {
|
||||
setCustomerKeyword('');
|
||||
setCustomerStatus('all');
|
||||
setUserRoute({ keyword: '', status: 'all', page: 1 }, true);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
@@ -531,6 +862,11 @@ export default function UsersPage() {
|
||||
className="v2-user-directory-header"
|
||||
title="客户权限目录"
|
||||
description="当前范围内的账号、菜单与车辆授权摘要"
|
||||
meta={<span className="v2-user-directory-glance" aria-label={`共 ${visibleCustomers.length} 个账号,${readyCustomers} 个权限就绪,${disabledCustomers} 个已停用`}>
|
||||
<b>{visibleCustomers.length}</b> 个账号
|
||||
<i aria-hidden="true" />
|
||||
<span className={attentionCustomers ? 'has-attention' : ''}>{attentionCustomers ? `${attentionCustomers} 个待完善` : disabledCustomers ? `${readyCustomers} 就绪 · ${disabledCustomers} 停用` : '全部权限就绪'}</span>
|
||||
</span>}
|
||||
/>
|
||||
<WorkspaceMetricRail
|
||||
variant="queue"
|
||||
@@ -539,13 +875,31 @@ export default function UsersPage() {
|
||||
items={directoryMetrics}
|
||||
/>
|
||||
<div className="v2-user-directory-body">
|
||||
{users.isPending ? <PanelLoading className="v2-user-list-loading" title="正在加载客户账号" description="账号目录和授权摘要就绪后会自动显示。" /> : customers.length === 0 ? <PanelEmpty
|
||||
{invalidLinkedUser ? <div className="v2-user-route-notice is-warning" role="alert">
|
||||
<span><strong>账号链接已失效</strong><small>账号 #{routeView.userID} 可能已被删除,目录筛选仍可继续使用。</small></span>
|
||||
<Button theme="light" type="warning" size="small" onClick={() => clearEditorRoute(true)}>清除失效链接</Button>
|
||||
</div> : linkedUserOutsideScope ? <div className="v2-user-route-notice" role="status">
|
||||
<span><strong>正在配置筛选范围外的账号</strong><small>{selected.displayName} 不在当前目录结果中,详情仍保持打开。</small></span>
|
||||
<Button theme="light" type="primary" size="small" onClick={() => {
|
||||
setCustomerKeyword('');
|
||||
setCustomerStatus('all');
|
||||
setUserRoute({ keyword: '', status: 'all', page: 1 }, true);
|
||||
}}>显示所在目录</Button>
|
||||
</div> : null}
|
||||
{users.isPending ? <PanelLoading className="v2-user-list-loading" title="正在加载客户账号" description="账号目录和授权摘要就绪后会自动显示。" /> : users.isError ? <PanelEmpty
|
||||
className="v2-user-list-empty"
|
||||
tone="warning"
|
||||
icon={<IconUserGroup />}
|
||||
title="账号目录暂时不可用"
|
||||
description="这不是空目录。保留当前筛选,恢复连接后可原位重试。"
|
||||
action={<Button theme="light" type="warning" icon={<IconRefresh />} onClick={() => users.refetch()}>重新读取账号</Button>}
|
||||
/> : customers.length === 0 ? <PanelEmpty
|
||||
className="v2-user-list-empty"
|
||||
tone="primary"
|
||||
icon={<IconUserGroup />}
|
||||
title="还没有客户账号"
|
||||
description="创建客户账号后,可在这里配置菜单和车辆范围。"
|
||||
action={<Button theme="solid" icon={<IconPlus />} aria-label="创建第一个客户账号" onClick={startCreate}>创建第一个客户账号</Button>}
|
||||
action={<Button theme="solid" icon={<IconPlus />} aria-label="创建第一个客户账号" onClick={() => startCreate()}>创建第一个客户账号</Button>}
|
||||
/> : <>
|
||||
{visibleCustomers.length ? <div className="v2-user-directory-columns" role="row" aria-label="客户账号目录列">
|
||||
<span>客户账号</span>
|
||||
@@ -557,7 +911,7 @@ export default function UsersPage() {
|
||||
<div className="v2-customer-list-scroll" role="region" aria-label="客户账号目录,可上下滚动" tabIndex={0}>
|
||||
<List
|
||||
className="v2-customer-list"
|
||||
dataSource={visibleCustomers}
|
||||
dataSource={pagedCustomers}
|
||||
emptyContent={<PanelEmpty
|
||||
className="v2-user-filter-empty"
|
||||
tone="primary"
|
||||
@@ -572,18 +926,19 @@ export default function UsersPage() {
|
||||
onClick={() => {
|
||||
setCustomerKeyword('');
|
||||
setCustomerStatus('all');
|
||||
setUserRoute({ keyword: '', status: 'all', page: 1 }, true);
|
||||
}}
|
||||
>清除筛选</Button>}
|
||||
/>}
|
||||
renderItem={(user) => {
|
||||
const accessState = customerAccessState(user.status, user.menuKeys.length, user.vehicles.length);
|
||||
const authProvider = authProviderMeta(user.authProvider);
|
||||
const accessState = customerAccessState(user.status, user.menuKeys.length, user.vehicles.length, identityNeedsAttention(user));
|
||||
const authProvider = authProviderMeta(user.authProvider, user.externalSubject);
|
||||
return <List.Item key={user.id} className={`v2-user-list-row is-access-${accessState.key}${accessState.attention ? ' has-attention' : ''}${selectedID === user.id && !creating ? ' is-active' : ''}`}>
|
||||
<Button
|
||||
className="v2-user-list-item"
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
aria-label={`选择客户 ${user.displayName},账号 ${user.username},${user.vehicles.length} 辆授权车,${accessState.label}`}
|
||||
aria-label={`选择客户 ${user.displayName},账号 ${user.username},${authProvider.title},${user.vehicles.length} 辆授权车,${accessState.label}`}
|
||||
aria-pressed={selectedID === user.id && !creating}
|
||||
aria-expanded={selectedID === user.id && !creating}
|
||||
onClick={() => selectCustomer(user)}
|
||||
@@ -591,26 +946,113 @@ export default function UsersPage() {
|
||||
<Avatar className="v2-user-avatar" color={user.status === 'enabled' ? 'light-blue' : 'grey'} shape="square" size="small">{user.displayName.slice(0, 1)}</Avatar>
|
||||
<span className="v2-user-list-identity">
|
||||
<span className="v2-user-list-name"><b>{user.displayName}</b><span className="v2-user-provider-hint" title={authProvider.title}><Tag className="v2-user-provider-tag" color={authProvider.color} type="light" size="small">{authProvider.label}</Tag></span></span>
|
||||
<small>@{user.username}{user.customerRef ? ` · ${user.customerRef}` : ''}</small>
|
||||
<span className="v2-user-list-secondary">
|
||||
<small>@{user.username}{user.customerRef ? ` · ${user.customerRef}` : ''}</small>
|
||||
<span className="v2-user-list-access-summary" aria-hidden="true"><b>{user.menuKeys.length}</b> 菜单 · <b>{user.vehicles.length}</b> 辆车</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="v2-user-list-facts is-menu"><small>菜单权限</small><b>{user.menuKeys.length}<i> / {customerMenus.length}</i></b></span>
|
||||
<span className="v2-user-list-facts is-vehicle"><small>车辆权限</small><b>{user.vehicles.length} 辆</b></span>
|
||||
<span className="v2-user-list-facts is-login"><small>最近登录</small><b>{formatTime(user.lastLoginAt)}</b></span>
|
||||
<span className="v2-user-list-trailing"><Tag className={`v2-user-status-tag is-${accessState.key}`} color={accessState.color} type="light" size="small"><span className="v2-user-status-tag-desktop">{accessState.label}</span><span className="v2-user-status-tag-mobile">{accessState.shortLabel}</span></Tag><IconChevronRight /></span>
|
||||
<span className="v2-user-list-trailing"><Tag className={`v2-user-status-tag is-${accessState.key}`} color={accessState.color} type="light" size="small"><span className="v2-user-status-tag-desktop">{accessState.label}</span><span className="v2-user-status-tag-mobile">{accessState.shortLabel}</span></Tag><span className="v2-user-config-label">配置</span><IconChevronRight /></span>
|
||||
</Button>
|
||||
</List.Item>;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{visibleCustomers.length ? <footer className="v2-user-directory-pagination">
|
||||
<TablePagination
|
||||
page={safeDirectoryPage}
|
||||
totalPages={directoryTotalPages}
|
||||
info={<>第 {(safeDirectoryPage - 1) * routeView.limit + 1}–{Math.min(safeDirectoryPage * routeView.limit, visibleCustomers.length)} 个,共 {visibleCustomers.length} 个账号</>}
|
||||
onPageChange={(page) => setUserRoute({ page })}
|
||||
pageSize={mobileLayout ? undefined : routeView.limit}
|
||||
pageSizeLabel="每页账号数"
|
||||
onPageSizeChange={(limit) => setUserRoute({ limit, page: 1 })}
|
||||
pageSizeOptions={mobileLayout ? undefined : userDirectoryPageSizes.map((value) => ({ value, label: `${value} 个/页` }))}
|
||||
/>
|
||||
</footer> : null}
|
||||
</>}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<WorkspaceSideSheet
|
||||
className="v2-user-import-sidesheet"
|
||||
variant="task"
|
||||
visible={routeView.importOpen}
|
||||
placement={mobileLayout ? 'bottom' : 'right'}
|
||||
width={mobileLayout ? undefined : 'min(900px, 100vw)'}
|
||||
height={mobileLayout ? 'min(94dvh, 860px)' : undefined}
|
||||
ariaLabel="批量导入客户账号"
|
||||
closeLabel="关闭批量导入客户账号"
|
||||
title="批量导入客户账号"
|
||||
description="先预检账号、菜单和车辆,再只创建校验通过项"
|
||||
icon={<IconUserGroup />}
|
||||
badge={importResult?.mode === 'create' ? `${importCreatedCount} 个已创建` : importResult?.mode === 'preview' ? `${importReadyCount} 个可创建` : importItems.length ? `${importItems.length} 行待预检` : 'CSV 导入'}
|
||||
badgeColor={importFailedCount ? 'orange' : importCreatedCount ? 'green' : 'blue'}
|
||||
summaryItems={[
|
||||
{ label: '已读取', value: importItems.length || '—', detail: importFileName || '等待选择 CSV', tone: 'primary' },
|
||||
{ label: '可创建', value: importReadyCount || '—', detail: importResult?.mode === 'preview' ? '服务端校验通过' : '完成预检后显示', tone: importReadyCount ? 'success' : 'neutral' },
|
||||
{ label: '失败项', value: importFailedCount || '—', detail: importFailedCount ? '可在当前窗口修复并重试' : '暂无待处理错误', tone: importFailedCount ? 'warning' : 'neutral' }
|
||||
]}
|
||||
onCancel={closeBatchImport}
|
||||
footerNote={batchImport.isError ? batchImport.error.message : importResult?.mode === 'create' ? `已创建 ${importCreatedCount} 个;${importFailedCount} 个未创建` : '预检不会写入账号,确认创建后逐项提交'}
|
||||
secondaryActions={[{ label: '下载模板', icon: <IconDownload />, onClick: downloadImportTemplate }]}
|
||||
primaryAction={importResult?.mode === 'create' && !importFailedCount
|
||||
? { label: '完成', onClick: closeBatchImport }
|
||||
: importResult?.mode === 'create' && importFailedCount
|
||||
? { label: `仅保留 ${importFailedCount} 个失败项`, onClick: retryFailedImportItems }
|
||||
: importResult?.mode === 'preview'
|
||||
? { label: `创建 ${importReadyCount} 个账号`, onClick: () => batchImport.mutate('create'), loading: batchImport.isPending, disabled: !importReadyCount }
|
||||
: { label: '开始预检', onClick: () => batchImport.mutate('preview'), loading: batchImport.isPending, disabled: !importItems.length || Boolean(importParseError) }}
|
||||
>
|
||||
<div className="v2-user-import-workspace">
|
||||
<section className="v2-user-import-source" aria-labelledby="v2-user-import-source-title">
|
||||
<header><span><strong id="v2-user-import-source-title">1. 选择账号清单</strong><small>每次最多 50 个;支持带引号的标准 CSV</small></span><Tag color={importItems.length ? 'green' : 'blue'} type="light" size="small">{importItems.length ? '已读取' : '未选择'}</Tag></header>
|
||||
<div className="v2-user-import-upload">
|
||||
<Upload action="" accept=".csv,text/csv" limit={1} uploadTrigger="custom" showUploadList={false} onFileChange={(files) => { const selectedFile = files[0] as File & { fileInstance?: File }; void readImportFile(selectedFile?.fileInstance ?? selectedFile); }}>
|
||||
<Button theme="light" type="primary">{importFileName ? '重新选择 CSV' : '选择 CSV 文件'}</Button>
|
||||
</Upload>
|
||||
<span><strong>{importFileName || '尚未选择文件'}</strong><small>{importItems.length ? `已读取 ${importItems.length} 个客户账号` : '密码、菜单和车辆范围会在浏览器中解析后送往服务端预检'}</small></span>
|
||||
</div>
|
||||
<p className="v2-user-import-format"><span>表头</span><code>{accountBatchImportCSVHeader}</code></p>
|
||||
{importParseError ? <p className="v2-user-import-error" role="alert">{importParseError}</p> : null}
|
||||
</section>
|
||||
|
||||
{importItems.length ? <section className="v2-user-import-review" aria-labelledby="v2-user-import-review-title">
|
||||
<header><span><strong id="v2-user-import-review-title">2. 预检与修复</strong><small>{importResult ? '按 CSV 行号定位问题;修复后需重新预检' : '服务端将校验用户名冲突、密码策略、菜单和车辆接入状态'}</small></span><Tag color={importResult ? (importFailedCount ? 'orange' : 'green') : 'grey'} type="light" size="small">{importResult ? (importFailedCount ? `${importFailedCount} 项待处理` : '全部通过') : '等待预检'}</Tag></header>
|
||||
<div className="v2-user-import-list" role="list" aria-label="批量账号预检结果">
|
||||
{importItems.map((item) => {
|
||||
const result = importResult?.items.find((candidate) => candidate.row === item.row);
|
||||
const failed = result && result.status !== 'ready' && result.status !== 'created';
|
||||
return <article key={item.row} role="listitem" className={`v2-user-import-row${failed ? ' has-error' : result?.status === 'created' ? ' is-created' : result?.status === 'ready' ? ' is-ready' : ''}`}>
|
||||
<span className="v2-user-import-row-number">{item.row}</span>
|
||||
<span className="v2-user-import-row-copy"><strong>{item.input.displayName || '未填写客户名称'}</strong><small>@{item.input.username || '未填写用户名'} · {item.input.menuKeys.length} 菜单 · {item.input.vehicleVins?.length ?? 0} 辆车</small></span>
|
||||
<Tag color={result?.status === 'created' || result?.status === 'ready' ? 'green' : failed ? 'red' : 'grey'} type="light" size="small">{result?.status === 'created' ? '已创建' : result?.status === 'ready' ? '可创建' : result?.status === 'conflict' ? '用户名冲突' : result?.status === 'invalid' ? '配置无效' : result?.status === 'failed' ? '创建失败' : '待预检'}</Tag>
|
||||
<span className="v2-user-import-row-result"><small>{result?.message || '等待服务端预检'}</small>{failed ? <Button theme="borderless" type="primary" size="small" aria-label={`修复第 ${item.row} 行`} onClick={() => setRepairRow(repairRow === item.row ? null : item.row)}>{repairRow === item.row ? '收起' : '修复'}</Button> : null}</span>
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
</section> : null}
|
||||
|
||||
{repairingImportItem ? <section className="v2-user-import-repair" aria-labelledby="v2-user-import-repair-title">
|
||||
<header><span><strong id="v2-user-import-repair-title">修复第 {repairingImportItem.row} 行</strong><small>修改会保留在当前导入清单;完成后重新预检</small></span><Button theme="borderless" type="tertiary" size="small" onClick={() => setRepairRow(null)}>收起</Button></header>
|
||||
<div className="v2-user-import-repair-fields">
|
||||
<label><span>用户名</span><Input value={repairingImportItem.input.username} onChange={(username) => updateImportItem(repairingImportItem.row, { username })} /></label>
|
||||
<label><span>客户名称</span><Input value={repairingImportItem.input.displayName} onChange={(displayName) => updateImportItem(repairingImportItem.row, { displayName })} /></label>
|
||||
<label><span>初始密码</span><Input mode="password" autoComplete="new-password" value={repairingImportItem.input.password} onChange={(password) => updateImportItem(repairingImportItem.row, { password })} /></label>
|
||||
<label><span>账号状态</span><Select value={repairingImportItem.input.status} optionList={[{ value: 'enabled', label: '启用' }, { value: 'disabled', label: '停用' }]} onChange={(status) => updateImportItem(repairingImportItem.row, { status: String(status) as 'enabled' | 'disabled' })} /></label>
|
||||
<label className="is-wide"><span>菜单权限(用 | 分隔)</span><Input value={repairingImportItem.input.menuKeys.join('|')} onChange={(value) => updateImportItem(repairingImportItem.row, { menuKeys: value.split('|').map((part) => part.trim().toLowerCase()).filter(Boolean) })} /></label>
|
||||
<label className="is-wide"><span>车辆 VIN(用 | 分隔)</span><Input value={(repairingImportItem.input.vehicleVins ?? []).join('|')} onChange={(value) => updateImportItem(repairingImportItem.row, { vehicleVins: value.split('|').map((part) => part.trim().toUpperCase()).filter(Boolean) })} /></label>
|
||||
</div>
|
||||
</section> : null}
|
||||
</div>
|
||||
</WorkspaceSideSheet>
|
||||
<WorkspaceSideSheet
|
||||
className="v2-user-editor-sidesheet"
|
||||
variant="editor"
|
||||
visible={editorVisible}
|
||||
width={mobileLayout ? '100%' : 'min(840px, 100vw)'}
|
||||
width={mobileLayout ? '100%' : 'min(820px, 100vw)'}
|
||||
ariaLabel="客户账号详情"
|
||||
closeLabel="关闭账号详情"
|
||||
title={creating ? '创建客户账号' : selected?.displayName || '客户账号'}
|
||||
@@ -618,7 +1060,7 @@ export default function UsersPage() {
|
||||
badge={draftAccessState.label}
|
||||
badgeColor={accessBadgeColor(draftAccessState.color)}
|
||||
summaryItems={[
|
||||
{ label: '登录账号', value: draft.username ? `@${draft.username}` : '待设置', detail: creating ? '创建后不可修改' : '登录身份', tone: 'primary' },
|
||||
{ label: '登录账号', value: draft.username ? `@${draft.username}` : '待设置', detail: creating ? '创建后不可修改 · 本地身份' : `${selectedIdentityProvider.sourceLabel} · ${selectedIdentityProvider.stateLabel}`, tone: selectedIdentityProvider.mappingComplete ? 'primary' : 'warning' },
|
||||
{ label: '权限范围', value: `${draft.menuKeys.length} 菜单 · ${draft.vehicleGrants.length} 辆`, detail: '最小必要权限', tone: draftAccessState.attention ? 'warning' : 'success' },
|
||||
{
|
||||
label: '账号状态',
|
||||
@@ -630,14 +1072,42 @@ export default function UsersPage() {
|
||||
onCancel={requestCloseEditor}
|
||||
closeOnEsc={!confirmation}
|
||||
footerNote={<span className="v2-user-save-state" role="status">
|
||||
<Tag color={save.isError ? 'red' : hasUnsavedChanges ? 'amber' : 'green'} type="light" size="small">{save.isError ? '保存失败' : hasUnsavedChanges ? '待保存' : '已同步'}</Tag>
|
||||
<Typography.Text className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} type={save.isError ? 'danger' : 'tertiary'}>{feedback || (hasUnsavedChanges ? '更改仅保存在当前草稿,保存后最多 30 秒生效' : '账号与权限已和服务器同步')}</Typography.Text>
|
||||
<Tag color={editorSaveState.color} type="light" size="small">{editorSaveState.label}</Tag>
|
||||
<Typography.Text className={`v2-user-feedback${save.isError ? ' is-error' : ''}`} type={save.isError ? 'danger' : 'tertiary'}>{editorSaveState.copy}</Typography.Text>
|
||||
</span>}
|
||||
primaryAction={{ label: creating ? '创建账号' : '保存权限', onClick: persistDraft, disabled: save.isPending || !hasUnsavedChanges, loading: save.isPending }}
|
||||
primaryAction={{ label: editorPrimaryLabel, onClick: handleEditorPrimaryAction, disabled: editorPrimaryDisabled, loading: save.isPending }}
|
||||
>
|
||||
{editorVisible ? <form id="v2-user-editor-form" className="v2-user-editor-form" onSubmit={submit}>
|
||||
<Tabs className="v2-user-editor-tabs" activeKey={activeSection} onChange={(key) => setActiveSection(String(key) as EditorSection)}>
|
||||
<Tabs.TabPane tab="登录身份" itemKey="identity">
|
||||
{!creating && selectedID !== null && customers.length > 1 ? <section className="v2-user-editor-switcher" aria-label="客户账号切换">
|
||||
<span><strong id="v2-user-editor-switch-label">切换客户</strong><small>未保存草稿会先确认</small></span>
|
||||
<Select
|
||||
aria-labelledby="v2-user-editor-switch-label"
|
||||
value={selectedID}
|
||||
onChange={(value) => {
|
||||
const nextCustomer = customers.find((user) => user.id === Number(value));
|
||||
if (nextCustomer) selectCustomer(nextCustomer);
|
||||
}}
|
||||
optionList={customers.map((user) => ({ value: user.id, label: `${user.displayName} · @${user.username}` }))}
|
||||
/>
|
||||
</section> : null}
|
||||
{hasUnsavedChanges ? <section className="v2-user-change-preview" aria-label="待保存变更">
|
||||
<span><strong>待保存变更</strong><small>仅影响当前草稿</small></span>
|
||||
<div>
|
||||
{draftDiff.identityChanged ? <Tag color="blue" type="light" size="small">账号资料已修改</Tag> : null}
|
||||
{draftDiff.statusChanged ? <Tag color={draftDiff.disablesAccount ? 'red' : 'green'} type="light" size="small">账号将{draft.status === 'enabled' ? '启用' : '停用'}</Tag> : null}
|
||||
{draftDiff.addedMenus.length ? <Tag color="green" type="light" size="small">开放菜单 +{draftDiff.addedMenus.length}</Tag> : null}
|
||||
{draftDiff.removedMenus.length ? <Tag color="red" type="light" size="small">收回菜单 -{draftDiff.removedMenus.length}</Tag> : null}
|
||||
{draftDiff.addedVehicles.length ? <Tag color="green" type="light" size="small">新增车辆 +{draftDiff.addedVehicles.length}</Tag> : null}
|
||||
{draftDiff.removedVehicles.length ? <Tag color="red" type="light" size="small">移除车辆 -{draftDiff.removedVehicles.length}</Tag> : null}
|
||||
{draftDiff.changedVehicles.length ? <Tag color="orange" type="light" size="small">调整有效期 {draftDiff.changedVehicles.length}</Tag> : null}
|
||||
</div>
|
||||
</section> : null}
|
||||
<Tabs className="v2-user-editor-tabs" activeKey={activeSection} onChange={(key) => {
|
||||
const section = String(key) as EditorSection;
|
||||
setActiveSection(section);
|
||||
setUserRoute({ section }, true);
|
||||
}}>
|
||||
<Tabs.TabPane tab={<span className="v2-user-tab-label"><i aria-hidden="true">1</i>登录身份</span>} itemKey="identity">
|
||||
<Card className="v2-user-editor-section v2-user-identity-section" title="登录身份" headerLine>
|
||||
<div className="v2-user-identity-overview" aria-label="登录身份摘要">
|
||||
<div>
|
||||
@@ -649,10 +1119,30 @@ export default function UsersPage() {
|
||||
<Tag color={draft.displayName ? 'green' : 'amber'} type="light" size="small">{draft.displayName ? '已设置' : '待完善'}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<span><small>身份来源</small><strong>{selected?.authProvider && selected.authProvider !== 'local' ? selected.authProvider : '本地账号'}</strong></span>
|
||||
<Tag color="blue" type="light" size="small">可扩展</Tag>
|
||||
<span><small>身份来源</small><strong>{creating ? '平台本地账号' : selectedIdentityProvider.sourceLabel}</strong></span>
|
||||
<Tag color={selectedIdentityProvider.mappingComplete ? (selectedIdentityProvider.external ? 'cyan' : 'grey') : 'amber'} type="light" size="small">{creating ? '创建本地凭据' : selectedIdentityProvider.stateLabel}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<section
|
||||
className={`v2-user-identity-source${selectedIdentityProvider.external ? ' is-external' : ' is-local'}${!selectedIdentityProvider.mappingComplete ? ' has-warning' : ''}`}
|
||||
role={!selectedIdentityProvider.mappingComplete ? 'alert' : 'status'}
|
||||
aria-label="身份来源状态"
|
||||
>
|
||||
<header>
|
||||
<span><strong>{creating ? '平台本地身份' : selectedIdentityProvider.sourceLabel}</strong><small>{selectedIdentityProvider.external ? '外部身份只负责登录,平台权限仍在此维护' : '登录凭据和账号状态由本平台维护'}</small></span>
|
||||
<Tag color={!selectedIdentityProvider.mappingComplete ? 'amber' : selectedIdentityProvider.external ? 'cyan' : 'green'} type="light" size="small">{creating ? '待创建' : selectedIdentityProvider.stateLabel}</Tag>
|
||||
</header>
|
||||
<div>
|
||||
<span><small>身份源</small><strong>{creating ? '本平台' : selectedIdentityProvider.sourceLabel}</strong></span>
|
||||
<span><small>身份映射</small><strong>{creating ? '创建后生成' : selectedIdentityProvider.external ? maskExternalSubject(selected?.externalSubject) : `@${draft.username || '待设置'}`}</strong></span>
|
||||
<span><small>凭据管理</small><strong>{creating ? '本平台' : selectedIdentityProvider.credentialOwner}</strong></span>
|
||||
</div>
|
||||
<p>{!selectedIdentityProvider.mappingComplete
|
||||
? `该账号缺少 ${selectedIdentityProvider.sourceLabel} 身份映射,当前无法通过外部身份源登录。请在上游目录或受信任身份适配器中补齐映射;菜单与车辆权限可继续维护。`
|
||||
: selectedIdentityProvider.external
|
||||
? `登录密码、锁定与多因素认证由 ${selectedIdentityProvider.sourceLabel} 管理;本平台只维护启停状态、菜单和车辆数据范围。`
|
||||
: creating ? '新账号将使用平台本地密码登录。' : '管理员可以在下方重置本地密码;重置或停用会撤销现有会话。'}</p>
|
||||
</section>
|
||||
<div className="v2-user-field-group" role="group" aria-labelledby="v2-user-primary-identity">
|
||||
<header>
|
||||
<span><strong id="v2-user-primary-identity">基础身份</strong><small>用于客户登录和平台内展示</small></span>
|
||||
@@ -665,17 +1155,17 @@ export default function UsersPage() {
|
||||
</div>
|
||||
<div className="v2-user-field-group" role="group" aria-labelledby="v2-user-security-mapping">
|
||||
<header>
|
||||
<span><strong id="v2-user-security-mapping">安全与系统映射</strong><small>{creating ? '设置初始密码,并按需关联外部客户标识' : '密码留空则保持不变,客户标识用于后续系统对接'}</small></span>
|
||||
<Tag color="grey" type="light" size="small">{creating ? '密码必填' : '按需修改'}</Tag>
|
||||
<span><strong id="v2-user-security-mapping">安全与系统映射</strong><small>{creating ? '设置初始密码,并按需关联外部客户标识' : selectedIdentityProvider.external ? `登录凭据由 ${selectedIdentityProvider.sourceLabel} 管理,平台保留业务映射` : '密码留空则保持不变,客户标识用于后续系统对接'}</small></span>
|
||||
<Tag color={selectedIdentityProvider.external ? 'cyan' : 'grey'} type="light" size="small">{creating ? '密码必填' : selectedIdentityProvider.external ? '外部凭据只读' : '按需修改'}</Tag>
|
||||
</header>
|
||||
<div className="v2-user-fields is-secondary">
|
||||
<label><span>{creating ? '初始密码' : '重置密码(可选)'}</span><Input required={creating} mode="password" autoComplete="new-password" value={draft.password} onChange={(value) => setDraft((current) => ({ ...current, password: value }))} placeholder="至少 10 位,包含三类字符" /></label>
|
||||
<label><span>{creating ? '初始密码' : selectedIdentityProvider.external ? '外部登录凭据' : '重置密码(可选)'}</span><Input required={creating} disabled={!creating && selectedIdentityProvider.external} mode="password" autoComplete="new-password" value={draft.password} onChange={(value) => setDraft((current) => ({ ...current, password: value }))} placeholder={selectedIdentityProvider.external ? `由 ${selectedIdentityProvider.sourceLabel} 管理` : '至少 10 位,包含三类字符'} /></label>
|
||||
<label><span>客户标识(可选)</span><Input value={draft.customerRef} onChange={(value) => setDraft((current) => ({ ...current, customerRef: value }))} placeholder="为 OneOS / RuoYi 映射预留" /></label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<span className="v2-user-tab-label">菜单权限<Tag color="blue" type="light" size="small">{draft.menuKeys.length}</Tag></span>} itemKey="menus">
|
||||
<Tabs.TabPane tab={<span className="v2-user-tab-label"><i aria-hidden="true">2</i>菜单权限<Tag color="blue" type="light" size="small">{draft.menuKeys.length}</Tag></span>} itemKey="menus">
|
||||
<Card className="v2-user-editor-section v2-user-menu-section" title={<span className="v2-user-section-title">菜单权限 <Tag color="blue" type="light" size="small">客户开放菜单</Tag></span>} headerLine>
|
||||
<div className="v2-user-menu-overview" aria-label={`已开放 ${draft.menuKeys.length} 个菜单,共 ${customerMenus.length} 个`}>
|
||||
<span><small>当前开放</small><strong>{draft.menuKeys.length}<i> / {customerMenus.length}</i></strong></span>
|
||||
@@ -692,7 +1182,7 @@ export default function UsersPage() {
|
||||
})}</div>
|
||||
</Card>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={<span className="v2-user-tab-label">车辆权限<Tag color={draft.vehicleGrants.length ? 'blue' : 'grey'} type="light" size="small">{draft.vehicleGrants.length}</Tag></span>} itemKey="vehicles">
|
||||
<Tabs.TabPane tab={<span className="v2-user-tab-label"><i aria-hidden="true">3</i>车辆权限<Tag color={draft.vehicleGrants.length ? 'blue' : 'grey'} type="light" size="small">{draft.vehicleGrants.length}</Tag></span>} itemKey="vehicles">
|
||||
<Card
|
||||
className={`v2-user-editor-section v2-user-vehicle-section${mobileLayout && !vehicleComposerVisible ? ' is-composer-collapsed' : ''}`}
|
||||
title={<span className="v2-user-section-title">车辆权限与有效期 <Tag color={draft.vehicleGrants.length ? 'blue' : 'grey'} type="light" size="small">{draft.vehicleGrants.length} 辆</Tag></span>}
|
||||
@@ -713,6 +1203,10 @@ export default function UsersPage() {
|
||||
</div> : null}
|
||||
>
|
||||
{vehicleComposerVisible ? <div id="v2-user-vehicle-composer" className="v2-user-vehicle-composer">
|
||||
<header className="v2-user-vehicle-composer-heading">
|
||||
<span><strong>添加授权车辆</strong><small>搜索选择车辆,或批量粘贴 VIN</small></span>
|
||||
<Tag color="blue" type="light" size="small">按需添加</Tag>
|
||||
</header>
|
||||
<div className="v2-vehicle-permission-tools"><label><span>按车牌或 VIN 搜索</span><Input aria-label="按车牌或 VIN 搜索" value={vehicleKeyword} onChange={setVehicleKeyword} placeholder="输入后显示候选车辆" /></label><label><span>批量粘贴 VIN</span><span className="v2-bulk-vin"><Input value={bulkVINs} onChange={setBulkVINs} placeholder="空格、逗号或换行分隔" /><Button theme="light" disabled={!bulkVINs.trim()} onClick={addBulkVINs}>加入</Button></span></label></div>
|
||||
{deferredVehicleKeyword ? <VehicleCandidateList
|
||||
className="v2-vehicle-candidates"
|
||||
@@ -859,7 +1353,54 @@ export default function UsersPage() {
|
||||
confirmLabel="放弃更改"
|
||||
note="放弃后无法恢复当前草稿。"
|
||||
onCancel={() => setConfirmation(null)}
|
||||
onConfirm={closeEditor}
|
||||
onConfirm={() => closeEditor()}
|
||||
/>
|
||||
<WorkspaceConfirmDialog
|
||||
visible={confirmation === 'switch-customer'}
|
||||
ariaLabel="确认切换客户账号"
|
||||
className="v2-user-confirm-dialog"
|
||||
title="切换客户并放弃当前草稿?"
|
||||
description="当前账号的登录身份、菜单或车辆权限尚未保存,切换后将载入另一客户的已保存配置。"
|
||||
summaryItems={[
|
||||
{ label: '当前草稿', value: selected?.displayName || draft.displayName || '新建账号', detail: `${draft.menuKeys.length} 菜单 · ${draft.vehicleGrants.length} 辆`, tone: 'warning' },
|
||||
{ label: '即将打开', value: customers.find((user) => user.id === pendingCustomerID)?.displayName || '另一客户', detail: '读取已保存权限', tone: 'primary' },
|
||||
{ label: '服务器数据', value: '不受影响', detail: '不会发送保存请求', tone: 'success' }
|
||||
]}
|
||||
cancelLabel="继续编辑"
|
||||
confirmLabel="放弃并切换"
|
||||
note="未保存的当前草稿无法在切换后恢复。"
|
||||
onCancel={() => {
|
||||
setConfirmation(null);
|
||||
setPendingCustomerID(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
const nextCustomer = customers.find((user) => user.id === pendingCustomerID);
|
||||
if (nextCustomer) loadCustomer(nextCustomer);
|
||||
else {
|
||||
setConfirmation(null);
|
||||
setPendingCustomerID(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<WorkspaceConfirmDialog
|
||||
visible={confirmation === 'save-access'}
|
||||
ariaLabel="确认保存权限收回"
|
||||
className="v2-user-confirm-dialog"
|
||||
title="确认保存权限收回?"
|
||||
description="此次保存包含访问范围缩减。生效后,客户将无法继续访问被收回的菜单或车辆。"
|
||||
summaryItems={[
|
||||
{ label: '账号状态', value: draftDiff.disablesAccount ? '将停用' : '保持启用', detail: draftDiff.disablesAccount ? '全部登录访问将停止' : '仅应用下方权限差异', tone: draftDiff.disablesAccount ? 'danger' : 'neutral' },
|
||||
{ label: '菜单权限', value: draftDiff.removedMenus.length ? `收回 ${draftDiff.removedMenus.length} 个` : '不收回', detail: draftDiff.addedMenus.length ? `同时开放 ${draftDiff.addedMenus.length} 个` : '没有新增菜单', tone: draftDiff.removedMenus.length ? 'danger' : 'success' },
|
||||
{ label: '车辆权限', value: draftDiff.removedVehicles.length ? `移除 ${draftDiff.removedVehicles.length} 辆` : '不移除', detail: draftDiff.addedVehicles.length ? `同时新增 ${draftDiff.addedVehicles.length} 辆` : '没有新增车辆', tone: draftDiff.removedVehicles.length ? 'danger' : 'success' }
|
||||
]}
|
||||
cancelLabel="返回核对"
|
||||
confirmLabel="确认并保存"
|
||||
note="保存后最多 30 秒生效;账号详情会保持打开并显示同步结果。"
|
||||
onCancel={() => setConfirmation(null)}
|
||||
onConfirm={() => {
|
||||
setConfirmation(null);
|
||||
persistDraft(true);
|
||||
}}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { IconChevronRight, IconMore } from '@douyinfe/semi-icons';
|
||||
import { IconAlarm, IconCalendar, IconChevronRight, IconClock, IconMapPin, IconMore, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||||
import { buildVehicleDetailPath, withVehicleReturn } from '../routing/vehicleContext';
|
||||
|
||||
export type VehicleAction = {
|
||||
key: string;
|
||||
@@ -13,15 +14,37 @@ export type VehicleAction = {
|
||||
};
|
||||
|
||||
export default function VehicleActions({
|
||||
actions,
|
||||
vin,
|
||||
switchTo,
|
||||
directoryReturn,
|
||||
monitorReturn,
|
||||
tracks,
|
||||
history,
|
||||
statistics,
|
||||
alerts,
|
||||
mobile,
|
||||
onSelect
|
||||
}: {
|
||||
actions: VehicleAction[];
|
||||
vin: string;
|
||||
switchTo: string;
|
||||
directoryReturn: string;
|
||||
monitorReturn: string;
|
||||
tracks: boolean;
|
||||
history: boolean;
|
||||
statistics: boolean;
|
||||
alerts: boolean;
|
||||
mobile: boolean;
|
||||
onSelect: (to: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const vehicleReturn = buildVehicleDetailPath(vin, { directoryReturn, monitorReturn });
|
||||
const actions: VehicleAction[] = [
|
||||
{ key: 'switch', label: '切换车辆', icon: <IconSearch />, to: switchTo, type: 'primary' },
|
||||
...(tracks ? [{ key: 'tracks', label: '轨迹回放', icon: <IconMapPin />, to: withVehicleReturn(`/tracks?vin=${encodeURIComponent(vin)}`, vehicleReturn), type: 'tertiary' as const }] : []),
|
||||
...(history ? [{ key: 'history', label: '历史数据', icon: <IconCalendar />, to: withVehicleReturn(`/history?vin=${encodeURIComponent(vin)}`, vehicleReturn), type: 'tertiary' as const }] : []),
|
||||
...(statistics ? [{ key: 'statistics', label: '里程查询', icon: <IconClock />, to: withVehicleReturn(`/statistics?vins=${encodeURIComponent(vin)}`, vehicleReturn), type: 'tertiary' as const }] : []),
|
||||
...(alerts ? [{ key: 'alerts', label: '告警事件', icon: <IconAlarm />, to: withVehicleReturn(`/alerts?vin=${encodeURIComponent(vin)}`, vehicleReturn), type: 'tertiary' as const }] : [])
|
||||
];
|
||||
const primaryActions = actions.filter((action) => action.key === 'switch' || action.key === 'tracks');
|
||||
const overflowActions = actions.filter((action) => action.key !== 'switch' && action.key !== 'tracks');
|
||||
const selectAction = (to: string) => {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { IconBox, IconTickCircle } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Descriptions, Input, Select, Tag } from '@douyinfe/semi-ui';
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleDetail } from '../../api/types';
|
||||
import { formatZhNumber, vehicleOperationStatusLabels } from '../domain/formatters';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { WorkspaceConfirmDialog } from '../shared/WorkspaceDialog';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||||
|
||||
function fmt(value?: string) { return value?.trim() || '—'; }
|
||||
function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${formatZhNumber(seconds / 3600, 1)} 小时`; }
|
||||
function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; }
|
||||
|
||||
export default function VehicleArchiveCard({ detail, editable, onUpdated }: { detail: VehicleDetail; editable: boolean; onUpdated: () => void }) {
|
||||
const profile = detail.profile;
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState({ brandName: '', modelName: '', vehicleType: '', companyName: '', operationStatus: 'unknown', accessProvider: '', firstAccessAt: '', runtimeHours: '' });
|
||||
const [baseDraft, setBaseDraft] = useState(draft);
|
||||
const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false);
|
||||
const [savedNotice, setSavedNotice] = useState('');
|
||||
const draftDirty = editing && JSON.stringify(draft) !== JSON.stringify(baseDraft);
|
||||
const runtimeValid = draft.runtimeHours.trim() === '' || (Number.isFinite(Number(draft.runtimeHours)) && Number(draft.runtimeHours) >= 0);
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.updateVehicleProfile(detail.vin, {
|
||||
brandName: draft.brandName.trim(), modelName: draft.modelName.trim(), vehicleType: draft.vehicleType.trim(), companyName: draft.companyName.trim(),
|
||||
operationStatus: draft.operationStatus as NonNullable<typeof profile>['operationStatus'], accessProvider: draft.accessProvider.trim(), firstAccessAt: draft.firstAccessAt,
|
||||
runtimeSeconds: draft.runtimeHours.trim() === '' ? null : Math.round(Number(draft.runtimeHours) * 3600), version: profile?.version ?? 0
|
||||
}),
|
||||
onSuccess: (updated) => {
|
||||
setEditing(false);
|
||||
setDiscardConfirmOpen(false);
|
||||
setSavedNotice(`主档已保存 · v${updated.version} · ${updated.updatedBy || '当前账号'} 更新`);
|
||||
onUpdated();
|
||||
}
|
||||
});
|
||||
const startEditing = () => {
|
||||
const nextDraft = { brandName: profile?.brandName ?? '', modelName: profile?.modelName ?? '', vehicleType: profile?.vehicleType ?? '', companyName: profile?.companyName ?? '', operationStatus: profile?.operationStatus ?? 'unknown', accessProvider: profile?.accessProvider ?? '', firstAccessAt: localDateTime(profile?.firstAccessAt), runtimeHours: profile?.runtimeSeconds == null ? '' : String(profile.runtimeSeconds / 3600) };
|
||||
setDraft(nextDraft);
|
||||
setBaseDraft(nextDraft);
|
||||
setSavedNotice('');
|
||||
save.reset();
|
||||
setEditing(true);
|
||||
};
|
||||
const updateDraft = (patch: Partial<typeof draft>) => {
|
||||
save.reset();
|
||||
setDraft((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
const submitDraft = () => { if (runtimeValid && !save.isPending) save.mutate(); };
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); submitDraft(); };
|
||||
const requestClose = () => {
|
||||
if (draftDirty) {
|
||||
setDiscardConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
const plate = fmt(detail.identity?.plate || detail.realtimeSummary?.plate);
|
||||
const sourceLabel = profile?.sourceSystem || '未配置';
|
||||
|
||||
return <>
|
||||
<Card className="v2-record-card v2-archive-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="车辆主档"
|
||||
description="身份与运营属性"
|
||||
meta={`完整度 ${profile?.completeness ?? 0}%`}
|
||||
actions={editable ? <Button theme="borderless" size="small" aria-haspopup="dialog" aria-expanded={editing} onClick={startEditing}>{editing ? '维护中' : '维护档案'}</Button> : null}
|
||||
/>
|
||||
<Descriptions className="v2-record-descriptions" align="left" size="small" data={[
|
||||
{ key: '车辆品牌', value: fmt(profile?.brandName) },
|
||||
{ key: '车型 / 类型', value: [profile?.modelName, profile?.vehicleType].filter(Boolean).join(' / ') || '—' },
|
||||
{ key: '所属企业', value: fmt(profile?.companyName) },
|
||||
{ key: '运营状态', value: <Tag color={profile?.operationStatus === 'active' ? 'green' : profile?.operationStatus === 'maintenance' ? 'orange' : 'grey'} type="light" size="small">{vehicleOperationStatusLabels[profile?.operationStatus ?? 'unknown']}</Tag> },
|
||||
{ key: '接入服务商', value: fmt(profile?.accessProvider) },
|
||||
{ key: '首次接入', value: fmt(profile?.firstAccessAt) },
|
||||
{ key: '累计运行', value: durationHours(profile?.runtimeSeconds) }
|
||||
]} />
|
||||
<p className="v2-record-note">身份字段来自网关;补充主档来源 {sourceLabel}{profile?.updatedAt ? ` · v${profile.version} · ${profile.updatedBy} 更新` : ''}</p>
|
||||
{savedNotice ? <p className="v2-profile-saved-notice" role="status"><IconTickCircle />{savedNotice}</p> : null}
|
||||
</Card>
|
||||
|
||||
{editing ? <WorkspaceSideSheet
|
||||
className="v2-profile-editor-sidesheet"
|
||||
variant="editor"
|
||||
visible
|
||||
ariaLabel="车辆主档维护"
|
||||
closeLabel="关闭车辆主档维护"
|
||||
placement={mobileLayout ? 'bottom' : 'right'}
|
||||
width={mobileLayout ? undefined : 680}
|
||||
height={mobileLayout ? 'min(92dvh, 860px)' : undefined}
|
||||
title={`${plate} 主档维护`}
|
||||
description="维护运营属性,不改变网关身份与遥测证据"
|
||||
icon={<IconBox />}
|
||||
badge={draftDirty ? '草稿未保存' : `v${profile?.version ?? 0}`}
|
||||
badgeColor={draftDirty ? 'orange' : 'blue'}
|
||||
summaryItems={[
|
||||
{ label: '车辆身份', value: plate, detail: detail.vin, tone: 'primary' },
|
||||
{ label: '当前来源', value: sourceLabel, detail: profile?.sourceVersion ? `来源版本 ${profile.sourceVersion}` : '手工维护', tone: sourceLabel === 'manual' ? 'warning' : 'neutral' },
|
||||
{ label: '主档完整度', value: `${profile?.completeness ?? 0}%`, detail: profile?.missingFields?.length ? `缺少 ${profile.missingFields.length} 项` : '当前字段完整', tone: (profile?.completeness ?? 0) >= 80 ? 'success' : 'warning' }
|
||||
]}
|
||||
footerNote={save.isError ? '保存失败,草稿仍保留,可修改后重试。' : draftDirty ? '草稿未保存;关闭前会再次确认。' : '尚未修改,关闭不会影响当前主档。'}
|
||||
secondaryActions={[{ label: '取消', onClick: requestClose, disabled: save.isPending }]}
|
||||
primaryAction={{ label: '保存主档', onClick: submitDraft, disabled: !runtimeValid || !draftDirty || save.isPending, loading: save.isPending }}
|
||||
closeOnEsc={!save.isPending}
|
||||
onCancel={requestClose}
|
||||
>
|
||||
<form className="v2-profile-editor-form" onSubmit={submit}>
|
||||
<section className="v2-profile-editor-boundary">
|
||||
<IconBox />
|
||||
<span><strong>网关身份保持只读</strong><small>VIN、车牌与协议来源由接入链路维护;本次只更新品牌、车型、企业与运营信息。</small></span>
|
||||
</section>
|
||||
<section className="v2-profile-editor-section" aria-labelledby="profile-editor-operation-title">
|
||||
<header><span><strong id="profile-editor-operation-title">运营属性</strong><small>用于车辆目录、运营筛选和档案展示。</small></span></header>
|
||||
<div className="v2-profile-editor-grid">
|
||||
<label><span>车辆品牌</span><Input aria-label="车辆品牌" maxLength={128} value={draft.brandName} onChange={(value) => updateDraft({ brandName: value })} placeholder="例如 飞驰" /></label>
|
||||
<label><span>车型</span><Input aria-label="车型" maxLength={128} value={draft.modelName} onChange={(value) => updateDraft({ modelName: value })} placeholder="例如 新能源运营车" /></label>
|
||||
<label><span>车辆类型</span><Input aria-label="车辆类型" maxLength={64} value={draft.vehicleType} onChange={(value) => updateDraft({ vehicleType: value })} placeholder="例如 乘用车" /></label>
|
||||
<label><span>所属企业</span><Input aria-label="所属企业" maxLength={128} value={draft.companyName} onChange={(value) => updateDraft({ companyName: value })} placeholder="填写运营主体" /></label>
|
||||
<label><span>运营状态</span><Select aria-label="运营状态" value={draft.operationStatus} onChange={(value) => updateDraft({ operationStatus: String(value) })} optionList={Object.entries(vehicleOperationStatusLabels).map(([value, label]) => ({ value, label }))} /></label>
|
||||
<label><span>接入服务商</span><Input aria-label="接入服务商" maxLength={128} value={draft.accessProvider} onChange={(value) => updateDraft({ accessProvider: value })} placeholder="填写服务商名称" /></label>
|
||||
</div>
|
||||
</section>
|
||||
<section className="v2-profile-editor-section" aria-labelledby="profile-editor-lifecycle-title">
|
||||
<header><span><strong id="profile-editor-lifecycle-title">生命周期</strong><small>日期与累计运行时间用于运营分析和维护判断。</small></span></header>
|
||||
<div className="v2-profile-editor-grid">
|
||||
<label><span>首次接入</span><Input aria-label="首次接入" type="datetime-local" value={draft.firstAccessAt} onChange={(value) => updateDraft({ firstAccessAt: value })} /></label>
|
||||
<label><span>累计运行(小时)</span><Input aria-label="累计运行(小时)" type="number" min="0" step="0.1" value={draft.runtimeHours} onChange={(value) => updateDraft({ runtimeHours: value })} /></label>
|
||||
</div>
|
||||
{!runtimeValid ? <p className="v2-profile-editor-error" role="alert">累计运行时间必须是大于或等于 0 的数字。</p> : null}
|
||||
{save.isError ? <p className="v2-profile-editor-error" role="alert">{save.error.message}</p> : null}
|
||||
</section>
|
||||
</form>
|
||||
</WorkspaceSideSheet> : null}
|
||||
<WorkspaceConfirmDialog
|
||||
visible={discardConfirmOpen}
|
||||
ariaLabel="确认放弃主档草稿"
|
||||
title="放弃未保存的主档更改?"
|
||||
description="当前运营属性尚未保存,放弃后会恢复到服务器上的最新主档。"
|
||||
summaryItems={[
|
||||
{ label: '车辆', value: plate, detail: detail.vin, tone: 'primary' },
|
||||
{ label: '草稿状态', value: '尚未保存', detail: `基于主档 v${profile?.version ?? 0}`, tone: 'warning' },
|
||||
{ label: '服务器数据', value: '不受影响', detail: '未保存不会修改正式主档', tone: 'success' }
|
||||
]}
|
||||
confirmLabel="放弃更改"
|
||||
cancelLabel="继续编辑"
|
||||
note="放弃后无法恢复当前主档草稿。"
|
||||
onConfirm={() => { setDiscardConfirmOpen(false); setEditing(false); save.reset(); }}
|
||||
onCancel={() => setDiscardConfirmOpen(false)}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { IconUserGroup } from '@douyinfe/semi-icons';
|
||||
import { Card, Descriptions, Tag } from '@douyinfe/semi-ui';
|
||||
import type { VehicleBusinessRelation } from '../../api/types';
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
|
||||
function fmt(value?: string) {
|
||||
return value?.trim() || '—';
|
||||
}
|
||||
|
||||
function sourceLabel(source?: string) {
|
||||
return source?.toLowerCase() === 'oneos' ? 'OneOS' : fmt(source);
|
||||
}
|
||||
|
||||
export default function VehicleBusinessRelationCard({ relation }: { relation?: VehicleBusinessRelation }) {
|
||||
if (!relation) {
|
||||
return <Card className="v2-record-card v2-business-relation-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="业务关联"
|
||||
description="客户、合同与运营责任"
|
||||
meta={<Tag color="grey" type="light" size="small">暂无当前关系</Tag>}
|
||||
/>
|
||||
<div className="v2-business-relation-empty">
|
||||
<IconUserGroup />
|
||||
<span><strong>未发现当前有效业务关联</strong><small>仅展示已完成交车且尚未完成还车的 OneOS 只读快照,不根据车辆主档推测客户归属。</small></span>
|
||||
</div>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
return <Card className="v2-record-card v2-business-relation-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="业务关联"
|
||||
description="客户、合同与运营责任"
|
||||
meta={<Tag color="blue" type="light" size="small">{sourceLabel(relation.sourceSystem)} 快照</Tag>}
|
||||
/>
|
||||
<Descriptions className="v2-record-descriptions v2-business-relation-descriptions" align="left" size="small" data={[
|
||||
{ key: '业务客户', value: fmt(relation.customerName) },
|
||||
{ key: '项目', value: fmt(relation.projectName) },
|
||||
{ key: '合同编号', value: fmt(relation.contractCode) },
|
||||
{ key: '业务部门', value: fmt(relation.departmentName) },
|
||||
{ key: '业务负责人', value: fmt(relation.responsibleUserName) },
|
||||
{ key: '业务运营状态', value: <Tag color={relation.operationStatus ? 'green' : 'grey'} type="light" size="small">{fmt(relation.operationStatus)}</Tag> },
|
||||
{ key: '当前关系起始', value: fmt(relation.scopeStartAt) }
|
||||
]} />
|
||||
<p className="v2-record-note">
|
||||
来源 {sourceLabel(relation.sourceSystem)} · 只读业务快照
|
||||
{relation.publishedAt ? ` · ${relation.publishedAt} 发布` : ''}
|
||||
</p>
|
||||
</Card>;
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleDetail, VehicleRealtimeRow } from '../../api/types';
|
||||
import { buildMonitorPath, withMonitorReturn } from '../routing/monitorContext';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
import VehiclePage from './VehiclePage';
|
||||
|
||||
function RouteState() {
|
||||
const location = useLocation();
|
||||
return <output data-testid="vehicle-route-state">{location.pathname}{location.search}</output>;
|
||||
}
|
||||
|
||||
const fleetMapVehicles = vi.hoisted(() => vi.fn());
|
||||
const layout = vi.hoisted(() => ({ mobile: false }));
|
||||
const auth = vi.hoisted(() => ({ role: 'viewer' as 'viewer' | 'admin' }));
|
||||
|
||||
vi.mock('../map/FleetMap', () => ({
|
||||
FleetMap: ({ vehicles }: { vehicles: VehicleRealtimeRow[] }) => {
|
||||
@@ -19,7 +25,7 @@ vi.mock('../map/FleetMap', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../auth/AuthGate', () => ({
|
||||
usePlatformSession: () => ({ session: { name: 'test-viewer', role: 'viewer', authMode: 'enforce' } })
|
||||
usePlatformSession: () => ({ session: { name: 'test-viewer', role: auth.role, authMode: 'enforce' } })
|
||||
}));
|
||||
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => layout.mobile }));
|
||||
|
||||
@@ -35,6 +41,26 @@ const detail = {
|
||||
lookupKey: initialRealtime.vin,
|
||||
lookupResolved: true,
|
||||
realtimeSummary: initialRealtime,
|
||||
businessRelation: {
|
||||
sourceSystem: 'oneos',
|
||||
sourceVersion: 'oneos-v1:test',
|
||||
vehicleId: '9223372036854775806',
|
||||
vin: initialRealtime.vin,
|
||||
plateNumber: initialRealtime.plate,
|
||||
customerId: '9223372036854775805',
|
||||
customerName: '示例物流客户',
|
||||
contractId: '9223372036854775804',
|
||||
contractCode: 'HT-2026-001',
|
||||
projectName: '氢能物流示范项目',
|
||||
departmentId: '20',
|
||||
departmentName: '华南运营部',
|
||||
responsibleUserId: '30',
|
||||
responsibleUserName: '张经理',
|
||||
operationStatus: '运营中',
|
||||
scopeStartAt: '2026-07-01 08:00:00',
|
||||
sourceUpdatedAt: '2026-07-24 08:00:00',
|
||||
publishedAt: '2026-07-24 08:02:00'
|
||||
},
|
||||
sources: ['JT808'],
|
||||
sourceStatus: [],
|
||||
realtime: [],
|
||||
@@ -47,6 +73,7 @@ const detail = {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
layout.mobile = false;
|
||||
auth.role = 'viewer';
|
||||
vi.restoreAllMocks();
|
||||
fleetMapVehicles.mockReset();
|
||||
});
|
||||
@@ -98,7 +125,13 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
|
||||
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(screen.getByRole('heading', { name: '车辆主档', level: 5 })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: '业务关联', level: 5 })).toBeInTheDocument();
|
||||
expect(screen.getByText('示例物流客户')).toBeInTheDocument();
|
||||
expect(screen.getByText('HT-2026-001')).toBeInTheDocument();
|
||||
expect(screen.getByText('华南运营部')).toBeInTheDocument();
|
||||
expect(screen.getByText('张经理')).toBeInTheDocument();
|
||||
expect(screen.getByText('OneOS 快照')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('heading', { name: '车辆主档', level: 5 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('navigation', { name: '单车详情导航' })).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-vehicle-record-page > .v2-source-evidence')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '来源证据' })).toBeInTheDocument();
|
||||
@@ -108,6 +141,12 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
|
||||
fireEvent.click(screen.getByRole('button', { name: '跳转到实时遥测' }));
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
|
||||
expect(telemetryPanel).toHaveFocus();
|
||||
const businessPanel = view.container.querySelector<HTMLElement>('#vehicle-business-panel')!;
|
||||
const businessScrollIntoView = vi.fn();
|
||||
Object.defineProperty(businessPanel, 'scrollIntoView', { configurable: true, value: businessScrollIntoView });
|
||||
fireEvent.click(screen.getByRole('button', { name: '跳转到业务关联' }));
|
||||
expect(businessScrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' });
|
||||
expect(businessPanel).toHaveFocus();
|
||||
expect(view.container.querySelector('.v2-record-descriptions.semi-descriptions')).toBeInTheDocument();
|
||||
expect(screen.getByText('待维护').closest('.semi-tag')).toBeInTheDocument();
|
||||
expect(sourceEvidence).not.toHaveBeenCalled();
|
||||
@@ -122,6 +161,57 @@ test('polls lightweight single-vehicle realtime data and passes its report inter
|
||||
await waitFor(() => expect(sourceEvidence).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('edits the vehicle master record in a protected task sheet and reports the saved version', async () => {
|
||||
auth.role = 'admin';
|
||||
const profile = {
|
||||
vin: initialRealtime.vin,
|
||||
brandName: '飞驰',
|
||||
modelName: '新能源运营车',
|
||||
vehicleType: '乘用车',
|
||||
companyName: '岭牛示范车队',
|
||||
operationStatus: 'active' as const,
|
||||
accessProvider: 'G7',
|
||||
firstAccessAt: '2026-03-01T08:00:00+08:00',
|
||||
runtimeSeconds: 351 * 3600,
|
||||
sourceSystem: 'manual',
|
||||
sourceVersion: 'v1',
|
||||
syncedAt: '2026-07-16T10:00:00+08:00',
|
||||
version: 1,
|
||||
updatedBy: 'demo-admin',
|
||||
updatedAt: '2026-07-16T10:00:00+08:00',
|
||||
completeness: 100,
|
||||
missingFields: []
|
||||
};
|
||||
const detailWithProfile = { ...detail, profile } satisfies VehicleDetail;
|
||||
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detailWithProfile);
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 });
|
||||
vi.spyOn(api, 'latestTelemetry').mockResolvedValue({ values: [], categories: [], scannedFrames: 0, asOf: '2026-07-16T02:00:00Z' } as never);
|
||||
const update = vi.spyOn(api, 'updateVehicleProfile').mockResolvedValue({ ...profile, brandName: '飞驰汽车', version: 2, updatedBy: 'test-admin' });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
|
||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '维护档案' }));
|
||||
const editor = await screen.findByRole('dialog', { name: '车辆主档维护' });
|
||||
expect(editor).toHaveTextContent('粤A12345 主档维护');
|
||||
expect(editor).toHaveTextContent('网关身份保持只读');
|
||||
expect(screen.getByRole('list', { name: '车辆主档维护摘要' })).toHaveTextContent('当前来源manual来源版本 v1');
|
||||
expect(screen.getByRole('button', { name: '保存主档' })).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '车辆品牌' }), { target: { value: '飞驰汽车' } });
|
||||
expect(editor).toHaveTextContent('草稿未保存');
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭车辆主档维护' }));
|
||||
const discard = await screen.findByRole('dialog', { name: '确认放弃主档草稿' });
|
||||
expect(discard).toHaveTextContent('服务器数据不受影响');
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
|
||||
expect(screen.getByRole('dialog', { name: '车辆主档维护' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存主档' }));
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith(initialRealtime.vin, expect.objectContaining({ brandName: '飞驰汽车', version: 1 })));
|
||||
expect(await screen.findByText('主档已保存 · v2 · test-admin 更新')).toHaveAttribute('role', 'status');
|
||||
expect(screen.queryByRole('dialog', { name: '车辆主档维护' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('holds the vehicle identity at the top through late layout shifts and yields to deliberate scrolling', async () => {
|
||||
vi.spyOn(api, 'vehicleDetail').mockResolvedValue(detail);
|
||||
vi.spyOn(api, 'vehicleRealtime').mockResolvedValue({ items: [initialRealtime], total: 1, limit: 1, offset: 0 });
|
||||
@@ -235,7 +325,7 @@ test('shows deduplicated Semi vehicle candidates and opens the selected VIN', as
|
||||
await waitFor(() => expect(vehicles).toHaveBeenCalledTimes(2));
|
||||
expect(input).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toBeInTheDocument();
|
||||
const option = await screen.findByRole('option', { name: `${initialRealtime.plate} ${initialRealtime.vin} JT/T 808 选择` });
|
||||
const option = await screen.findByRole('option', { name: `${initialRealtime.plate} ${initialRealtime.vin} JT/T 808 打开` });
|
||||
expect(view.container.querySelector('#v2-vehicle-search-options.v2-vehicle-candidate-list')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('#v2-vehicle-search-options')).toHaveAttribute('aria-busy', 'false');
|
||||
expect(screen.getAllByRole('option')).toHaveLength(1);
|
||||
@@ -270,9 +360,15 @@ test('keeps the mobile vehicle directory primary and applies search from a Semi
|
||||
bindingStatus: 'bound'
|
||||
}));
|
||||
vi.spyOn(api, 'vehicleCoverage').mockResolvedValue({ items, total: 10, limit: 8, offset: 0 });
|
||||
vi.spyOn(api, 'vehicleBusinessFilters').mockResolvedValue({
|
||||
departments: [{ value: '40001', label: '业务一部', count: 8 }],
|
||||
responsibleUsers: [{ value: '50001', label: '张经理', count: 4 }],
|
||||
customers: [{ value: '20001', label: '示例客户', count: 6 }],
|
||||
statuses: [{ value: '运营中', label: '运营中', count: 7 }]
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /></Routes><RouteState /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
const mobileDirectoryMetrics = await screen.findByRole('list', { name: '车辆目录摘要' });
|
||||
await waitFor(() => expect(mobileDirectoryMetrics).toHaveTextContent('本页在线6辆本页 8 辆'));
|
||||
@@ -286,11 +382,16 @@ test('keeps the mobile vehicle directory primary and applies search from a Semi
|
||||
expect(screen.getByRole('tab', { name: '全部车辆' })).toHaveAttribute('aria-selected', 'true');
|
||||
const toggle = screen.getByRole('button', { name: '打开车辆搜索:10 辆授权车辆' });
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
fireEvent.click(toggle);
|
||||
fireEvent.click(screen.getByRole('button', { name: '搜索车辆:10 辆授权车辆' }));
|
||||
const input = screen.getByRole('textbox', { name: '搜索车辆' });
|
||||
expect(input).toBeVisible();
|
||||
expect(input).toHaveFocus();
|
||||
expect(document.querySelector('.v2-vehicle-mobile-filter-sidesheet')).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: '车辆搜索摘要' })).toHaveTextContent('授权范围10 辆当前账号可见当前条件全部车辆浏览完整目录快速结果待输入最多展示 6 辆');
|
||||
expect(screen.getByRole('list', { name: '车辆搜索摘要' })).toHaveTextContent('授权范围10 辆当前账号可见当前条件全部车辆部门 / 负责人 / 客户 / 状态快速结果待输入最多展示 4 辆');
|
||||
expect(screen.getByText('业务关联')).toBeInTheDocument();
|
||||
for (const label of ['部门', '业务负责人', '客户', '状态']) {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.getAllByRole('button', { name: /打开 .* 车辆档案/ })).toHaveLength(8);
|
||||
fireEvent.change(input, { target: { value: items[0].plate } });
|
||||
expect(await screen.findByRole('listbox')).toHaveAttribute('id', 'v2-vehicle-mobile-search-options');
|
||||
@@ -386,13 +487,81 @@ test('keeps a partial vehicle query in the directory instead of navigating to an
|
||||
fireEvent.change(input, { target: { value: '粤A' } });
|
||||
expect(await screen.findByRole('listbox')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /查询车辆/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^查看结果/ }));
|
||||
|
||||
expect(view.container.querySelector('.v2-vehicle-search-options')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toBeInTheDocument();
|
||||
expect(vehicleDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('keeps broad desktop vehicle searches out of a duplicate quick-results overlay', async () => {
|
||||
const candidates = Array.from({ length: 10 }, (_, index) => ({
|
||||
vin: `LTEST${String(index).padStart(12, '0')}`,
|
||||
plate: `浙F${String(index).padStart(5, '0')}F`,
|
||||
phone: '',
|
||||
oem: '测试品牌',
|
||||
protocols: ['GB32960'],
|
||||
missingProtocols: [],
|
||||
sourceStatus: [],
|
||||
sourceCount: 1,
|
||||
onlineSourceCount: 1,
|
||||
online: true,
|
||||
lastSeen: initialRealtime.lastSeen,
|
||||
locationText: '浙江省嘉兴市',
|
||||
bindingScore: 100,
|
||||
bindingStatus: 'bound'
|
||||
}));
|
||||
vi.spyOn(api, 'vehicleCoverage').mockResolvedValue({ items: candidates, total: 211, limit: 10, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
await screen.findByRole('heading', { name: '授权车辆目录', level: 5 });
|
||||
const input = screen.getByRole('textbox', { name: '搜索车辆' });
|
||||
fireEvent.change(input, { target: { value: '浙F' } });
|
||||
|
||||
expect(await screen.findByText('211 辆匹配')).toHaveAttribute('role', 'status');
|
||||
expect(input).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^查看列表/ }));
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: '匹配车辆', level: 5 })).toHaveFocus());
|
||||
});
|
||||
|
||||
test('opens an exact unique vehicle from the desktop search action', async () => {
|
||||
const candidate = {
|
||||
vin: initialRealtime.vin,
|
||||
plate: initialRealtime.plate,
|
||||
phone: '13800000000',
|
||||
oem: '测试品牌',
|
||||
protocols: ['JT808'],
|
||||
missingProtocols: [],
|
||||
sourceStatus: [],
|
||||
sourceCount: 1,
|
||||
onlineSourceCount: 1,
|
||||
online: true,
|
||||
lastSeen: initialRealtime.lastSeen,
|
||||
locationText: '广东省广州市',
|
||||
bindingScore: 100,
|
||||
bindingStatus: 'bound'
|
||||
};
|
||||
vi.spyOn(api, 'vehicleCoverage').mockResolvedValue({ items: [candidate], total: 1, limit: 10, offset: 0 });
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes>
|
||||
<Route path="/vehicles" element={<VehiclePage />} />
|
||||
<Route path="/vehicles/:vin" element={<RouteState />} />
|
||||
</Routes></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
await screen.findByRole('heading', { name: '授权车辆目录', level: 5 });
|
||||
const input = screen.getByRole('textbox', { name: '搜索车辆' });
|
||||
fireEvent.change(input, { target: { value: initialRealtime.plate } });
|
||||
await waitFor(() => expect(input).toHaveAttribute('aria-expanded', 'false'));
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /打开档案/ }));
|
||||
|
||||
expect(await screen.findByTestId('vehicle-route-state')).toHaveTextContent(`/vehicles/${initialRealtime.vin}`);
|
||||
});
|
||||
|
||||
test('paginates the complete desktop authorized vehicle directory', async () => {
|
||||
const firstPage = Array.from({ length: 10 }, (_, index) => ({
|
||||
vin: `LTEST${String(index).padStart(12, '0')}`,
|
||||
@@ -422,7 +591,7 @@ test('paginates the complete desktop authorized vehicle directory', async () =>
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
const view = render(<QueryClientProvider client={client}><MemoryRouter initialEntries={['/vehicles']} future={ROUTER_FUTURE}><Routes><Route path="/vehicles" element={<VehiclePage />} /></Routes><RouteState /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('粤A00000')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-vehicle-directory-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
@@ -435,6 +604,7 @@ test('paginates the complete desktop authorized vehicle directory', async () =>
|
||||
expect(await screen.findByText('粤A00010')).toBeInTheDocument();
|
||||
expect(screen.getByText('共 11 辆 · 本页 1 辆')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-table-pagination .semi-page-item-small')).toHaveTextContent('2/2');
|
||||
expect(screen.getByTestId('vehicle-route-state')).toHaveTextContent('/vehicles?vehiclePage=2');
|
||||
});
|
||||
|
||||
test('keeps the monitor return on the vehicle page and its nested investigation actions', async () => {
|
||||
@@ -449,7 +619,7 @@ test('keeps the monitor return on the vehicle page and its nested investigation
|
||||
});
|
||||
const initialEntry = withMonitorReturn(`/vehicles/${initialRealtime.vin}`, monitorPath);
|
||||
|
||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[initialEntry]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[initialEntry]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes><RouteState /></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByRole('link', { name: /返回全局监控/ })).toHaveAttribute('href', monitorPath);
|
||||
const actionGroup = screen.getByRole('group', { name: '车辆快捷操作' });
|
||||
@@ -458,9 +628,6 @@ test('keeps the monitor return on the vehicle page and its nested investigation
|
||||
expect(actionGroup).toHaveTextContent('历史数据');
|
||||
expect(actionGroup).toHaveTextContent('里程查询');
|
||||
expect(actionGroup).toHaveTextContent('告警事件');
|
||||
expect(withMonitorReturn(`/tracks?vin=${encodeURIComponent(initialRealtime.vin)}`, monitorPath)).toContain(`monitorReturn=${encodeURIComponent(monitorPath)}`);
|
||||
expect(withMonitorReturn(`/history?vin=${encodeURIComponent(initialRealtime.vin)}`, monitorPath)).toContain(`monitorReturn=${encodeURIComponent(monitorPath)}`);
|
||||
expect(withMonitorReturn(`/statistics?vins=${encodeURIComponent(initialRealtime.vin)}`, monitorPath)).toContain(`monitorReturn=${encodeURIComponent(monitorPath)}`);
|
||||
|
||||
const liveOverview = screen.getByText('最新上报').closest('.semi-card');
|
||||
const archive = screen.getByRole('heading', { name: '车辆主档', level: 5 }).closest('.semi-card');
|
||||
@@ -476,6 +643,13 @@ test('keeps the monitor return on the vehicle page and its nested investigation
|
||||
fireEvent.click(addressAction);
|
||||
expect(await screen.findByText('广东省广州市测试道路')).toBeInTheDocument();
|
||||
expect(addressSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '轨迹回放' }));
|
||||
await waitFor(() => expect(screen.getByTestId('vehicle-route-state')).toHaveTextContent('/tracks?vin=LTEST000000000001&vehicleReturn='));
|
||||
const childURL = new URL(screen.getByTestId('vehicle-route-state').textContent || '', 'https://vehicle-platform.invalid');
|
||||
const vehicleReturn = childURL.searchParams.get('vehicleReturn') || '';
|
||||
expect(vehicleReturn).toContain('/vehicles/LTEST000000000001?');
|
||||
expect(new URL(vehicleReturn, 'https://vehicle-platform.invalid').searchParams.get('monitorReturn')).toBe(monitorPath);
|
||||
});
|
||||
|
||||
test('shows unavailable live fields as dashes instead of fabricated zeroes', async () => {
|
||||
@@ -545,17 +719,17 @@ test('keeps protocol telemetry independent and explains mileage semantics', asyn
|
||||
render(<QueryClientProvider client={client}><MemoryRouter initialEntries={[`/vehicles/${initialRealtime.vin}`]} future={ROUTER_FUTURE}><Routes><Route path="/vehicles/:vin" element={<VehiclePage />} /></Routes></MemoryRouter></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByRole('tab', { name: /GB\/T 32960/, selected: true })).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-telemetry-table.semi-table-wrapper')).toBeInTheDocument();
|
||||
expect(document.querySelectorAll('.v2-telemetry-table [role="columnheader"]')).toHaveLength(5);
|
||||
expect(document.querySelector('.v2-telemetry-mobile-list')).not.toBeInTheDocument();
|
||||
const metricGrid = screen.getByRole('list', { name: '整车数据遥测字段' });
|
||||
expect(metricGrid).toHaveClass('v2-telemetry-metric-grid');
|
||||
expect(within(metricGrid).getAllByRole('listitem')).toHaveLength(2);
|
||||
expect(document.querySelector('.v2-telemetry-table')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-event-list.semi-list')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-event-empty.semi-empty')).toBeInTheDocument();
|
||||
expect(screen.getByText('仪表盘总里程')).toBeInTheDocument();
|
||||
expect(screen.queryByText('GPS 总里程')).not.toBeInTheDocument();
|
||||
const complexValue = screen.getByRole('button', { name: '查看 单体电压列表 完整数据' });
|
||||
expect(complexValue).toHaveClass('semi-button', 'v2-telemetry-value-trigger');
|
||||
const complexValue = screen.getByRole('button', { name: '查看 单体电压列表 字段详情' });
|
||||
expect(complexValue).toHaveClass('semi-button', 'v2-telemetry-metric-card');
|
||||
expect(complexValue).toHaveTextContent('4 个值');
|
||||
expect(complexValue).toHaveTextContent('3.21,3.22,3.23');
|
||||
fireEvent.click(complexValue);
|
||||
expect(await screen.findByRole('dialog', { name: '遥测字段详情' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-telemetry-evidence-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet');
|
||||
@@ -599,9 +773,10 @@ test('uses compact Semi telemetry evidence cards on mobile', async () => {
|
||||
|
||||
expect(await screen.findByText('GPS 速度')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-telemetry-table')).not.toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-telemetry-mobile-list.semi-list')).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-telemetry-mobile-item.semi-list-item')).toHaveTextContent('36km/h正常');
|
||||
expect(screen.getByRole('button', { name: '查看 GPS 速度 字段详情' })).toHaveClass('v2-telemetry-mobile-action');
|
||||
expect(document.querySelector('.v2-telemetry-mobile-list')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: '整车数据遥测字段' })).toHaveClass('v2-telemetry-metric-grid');
|
||||
expect(screen.getByRole('button', { name: '查看 GPS 速度 字段详情' })).toHaveClass('v2-telemetry-metric-card');
|
||||
expect(screen.getByRole('button', { name: '查看 GPS 速度 字段详情' })).toHaveTextContent('36km/h正常');
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看 GPS 速度 字段详情' }));
|
||||
expect(await screen.findByRole('dialog', { name: '遥测字段详情' })).toBeInTheDocument();
|
||||
expect(document.querySelector('.v2-telemetry-evidence-sidesheet')).toHaveClass('v2-workspace-detail-sidesheet', 'semi-sidesheet-bottom');
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconAlarm, IconBox, IconCalendar, IconClock, IconCopy,
|
||||
IconChevronRight, IconMapPin, IconSearch, IconTickCircle
|
||||
IconAlarm, IconBox, IconClock, IconCopy,
|
||||
IconChevronRight, IconMapPin, IconSearch, IconTickCircle, IconUserGroup
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Descriptions, Input, List, Select, Table, Tag } from '@douyinfe/semi-ui';
|
||||
import { FormEvent, lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Card, Descriptions, List, Tag } from '@douyinfe/semi-ui';
|
||||
import { lazy, Suspense, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { LatestTelemetryResponse, LatestTelemetryValue, QualityIssueRow, VehicleDetail, VehicleRealtimeRow } from '../../api/types';
|
||||
@@ -12,7 +12,7 @@ import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister, hasMenu } from '../auth/session';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
|
||||
import { formatTelemetryTime, formatTelemetryValue, telemetryQualityColor, telemetryQualityLabel, telemetryValueTypeLabel } from '../domain/telemetry';
|
||||
import { formatZhNumber, vehicleOperationStatusLabels } from '../domain/formatters';
|
||||
import { formatZhNumber } from '../domain/formatters';
|
||||
import { protocolDisplayLabel, protocolSourceLabel } from '../domain/protocols';
|
||||
import { isValidAMapCoordinate } from '../../integrations/amap';
|
||||
import { FleetMap } from '../map/FleetMap';
|
||||
@@ -27,17 +27,19 @@ import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../share
|
||||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||||
import { WorkspaceSideSheet, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet';
|
||||
import { monitorReturnFromParams, withMonitorReturn } from '../routing/monitorContext';
|
||||
import { vehicleDirectoryReturnFromParams } from '../routing/vehicleContext';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
|
||||
function fmt(value?: string) { return value?.trim() || '—'; }
|
||||
function metric(value: number | undefined) { return Number.isFinite(value as number) ? formatZhNumber(value!, 1) : '—'; }
|
||||
function availableMetric(value: number | undefined, available?: boolean) { return available === false ? '—' : metric(value); }
|
||||
function issueTone(issue: QualityIssueRow) { return issue.severity === 'error' ? 'error' : 'warning'; }
|
||||
function durationHours(seconds?: number | null) { return seconds == null ? '—' : `${formatZhNumber(seconds / 3600, 1)} 小时`; }
|
||||
function localDateTime(value?: string) { return value ? value.slice(0, 16) : ''; }
|
||||
const SINGLE_VEHICLE_REFRESH_MS = 10_000;
|
||||
const LIVE_UNIT_CLASS = 'v2-vehicle-live-unit';
|
||||
const VehicleSearch = lazy(() => import('./VehicleSearchWorkspace'));
|
||||
const VehicleActions = lazy(() => import('./VehicleActions'));
|
||||
const VehicleArchiveCard = lazy(() => import('./VehicleArchiveCard'));
|
||||
const VehicleBusinessRelationCard = lazy(() => import('./VehicleBusinessRelationCard'));
|
||||
|
||||
function resetWorkspaceScroll(routeRoot?: HTMLElement | null) {
|
||||
const targets = new Set<Element | null>([
|
||||
@@ -72,52 +74,6 @@ function eventTimeLabel(value?: string) {
|
||||
return normalized.length >= 16 ? normalized.slice(5, 16) : normalized;
|
||||
}
|
||||
|
||||
function Archive({ detail, editable, onUpdated }: { detail: VehicleDetail; editable: boolean; onUpdated: () => void }) {
|
||||
const profile = detail.profile;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState({ brandName: '', modelName: '', vehicleType: '', companyName: '', operationStatus: 'unknown', accessProvider: '', firstAccessAt: '', runtimeHours: '' });
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.updateVehicleProfile(detail.vin, {
|
||||
brandName: draft.brandName.trim(), modelName: draft.modelName.trim(), vehicleType: draft.vehicleType.trim(), companyName: draft.companyName.trim(),
|
||||
operationStatus: draft.operationStatus as NonNullable<typeof profile>['operationStatus'], accessProvider: draft.accessProvider.trim(), firstAccessAt: draft.firstAccessAt,
|
||||
runtimeSeconds: draft.runtimeHours.trim() === '' ? null : Math.round(Number(draft.runtimeHours) * 3600), version: profile?.version ?? 0
|
||||
}),
|
||||
onSuccess: () => { setEditing(false); onUpdated(); }
|
||||
});
|
||||
const startEditing = () => {
|
||||
setDraft({ brandName: profile?.brandName ?? '', modelName: profile?.modelName ?? '', vehicleType: profile?.vehicleType ?? '', companyName: profile?.companyName ?? '', operationStatus: profile?.operationStatus ?? 'unknown', accessProvider: profile?.accessProvider ?? '', firstAccessAt: localDateTime(profile?.firstAccessAt), runtimeHours: profile?.runtimeSeconds == null ? '' : String(profile.runtimeSeconds / 3600) });
|
||||
save.reset(); setEditing(true);
|
||||
};
|
||||
const submit = (event: FormEvent) => { event.preventDefault(); if (draft.runtimeHours === '' || Number.isFinite(Number(draft.runtimeHours))) save.mutate(); };
|
||||
return <Card className="v2-record-card v2-archive-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="车辆主档"
|
||||
description="身份与运营属性"
|
||||
meta={`完整度 ${profile?.completeness ?? 0}%`}
|
||||
actions={editable && !editing ? <Button theme="borderless" size="small" onClick={startEditing}>维护档案</Button> : null}
|
||||
/>
|
||||
{editing ? <form className="v2-profile-form" onSubmit={submit}>
|
||||
<label><span>车辆品牌</span><Input maxLength={128} value={draft.brandName} onChange={(value) => setDraft({ ...draft, brandName: value })} /></label>
|
||||
<label><span>车型</span><Input maxLength={128} value={draft.modelName} onChange={(value) => setDraft({ ...draft, modelName: value })} /></label>
|
||||
<label><span>车辆类型</span><Input maxLength={64} value={draft.vehicleType} onChange={(value) => setDraft({ ...draft, vehicleType: value })} /></label>
|
||||
<label><span>所属企业</span><Input maxLength={128} value={draft.companyName} onChange={(value) => setDraft({ ...draft, companyName: value })} /></label>
|
||||
<label><span>运营状态</span><Select value={draft.operationStatus} onChange={(value) => setDraft({ ...draft, operationStatus: String(value) })} optionList={Object.entries(vehicleOperationStatusLabels).map(([value, label]) => ({ value, label }))} /></label>
|
||||
<label><span>接入服务商</span><Input maxLength={128} value={draft.accessProvider} onChange={(value) => setDraft({ ...draft, accessProvider: value })} /></label>
|
||||
<label><span>首次接入</span><Input aria-label="首次接入" type="datetime-local" value={draft.firstAccessAt} onChange={(value) => setDraft({ ...draft, firstAccessAt: value })} /></label>
|
||||
<label><span>累计运行(小时)</span><Input type="number" min="0" step="0.1" value={draft.runtimeHours} onChange={(value) => setDraft({ ...draft, runtimeHours: value })} /></label>
|
||||
{save.isError ? <p>{save.error.message}</p> : null}<footer><Button theme="light" onClick={() => setEditing(false)}>取消</Button><Button theme="solid" htmlType="submit" loading={save.isPending}>保存档案</Button></footer>
|
||||
</form> : <><Descriptions className="v2-record-descriptions" align="left" size="small" data={[
|
||||
{ key: '车辆品牌', value: fmt(profile?.brandName) },
|
||||
{ key: '车型 / 类型', value: [profile?.modelName, profile?.vehicleType].filter(Boolean).join(' / ') || '—' },
|
||||
{ key: '所属企业', value: fmt(profile?.companyName) },
|
||||
{ key: '运营状态', value: <Tag color={profile?.operationStatus === 'active' ? 'green' : profile?.operationStatus === 'maintenance' ? 'orange' : 'grey'} type="light" size="small">{vehicleOperationStatusLabels[profile?.operationStatus ?? 'unknown']}</Tag> },
|
||||
{ key: '接入服务商', value: fmt(profile?.accessProvider) },
|
||||
{ key: '首次接入', value: fmt(profile?.firstAccessAt) },
|
||||
{ key: '累计运行', value: durationHours(profile?.runtimeSeconds) }
|
||||
]} /><p className="v2-record-note">身份字段来自网关;补充主档来源 {profile?.sourceSystem || '未配置'}{profile?.updatedAt ? ` · v${profile.version} · ${profile.updatedBy} 更新` : ''}</p></>}
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function Events({ detail }: { detail: VehicleDetail }) {
|
||||
const events = [
|
||||
...detail.quality.items.slice(0, 3).map((item) => ({ tone: issueTone(item), title: item.severity === 'error' ? '质量异常' : '质量提醒', detail: item.detail, time: item.lastSeen })),
|
||||
@@ -134,7 +90,7 @@ function Events({ detail }: { detail: VehicleDetail }) {
|
||||
className="v2-event-list"
|
||||
dataSource={events}
|
||||
split={false}
|
||||
emptyContent={<PanelEmpty className="v2-event-empty" compact tone="warning" icon={<IconAlarm />} title="暂无可用事件证据" description="车辆产生质量提醒或来源状态变化后会显示在这里。" />}
|
||||
emptyContent={<PanelEmpty className="v2-event-empty" compact tone="warning" icon={<IconAlarm />} title="暂无可用事件证据" description="质量提醒或来源变化后显示。" />}
|
||||
renderItem={(event, index) => <List.Item className={`v2-event-row is-${event.tone}`} key={`${event.title}-${event.time}-${index}`}>
|
||||
<span className="v2-event-icon">{event.tone === 'success' ? <IconTickCircle /> : <IconAlarm />}</span>
|
||||
<div><strong>{event.title}</strong><p>{event.detail}</p></div><time title={fmt(event.time)}>{eventTimeLabel(event.time)}</time>
|
||||
@@ -143,10 +99,6 @@ function Events({ detail }: { detail: VehicleDetail }) {
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function TelemetryFieldCell({ item }: { item: LatestTelemetryValue }) {
|
||||
return <div className="v2-telemetry-field"><strong title={item.description}>{item.label}</strong><small title={item.sourceField}>{item.sourceField}</small></div>;
|
||||
}
|
||||
|
||||
type TelemetryValuePresentation = {
|
||||
complex: boolean;
|
||||
preview: string;
|
||||
@@ -199,37 +151,6 @@ function telemetryValueDetail(item: LatestTelemetryValue) {
|
||||
return String(formatTelemetryValue(item.value, item.displayValue));
|
||||
}
|
||||
|
||||
function TelemetryValueCell({ item, onInspect }: { item: LatestTelemetryValue; onInspect?: (item: LatestTelemetryValue) => void }) {
|
||||
const presentation = telemetryValuePresentation(item);
|
||||
if (presentation.complex) {
|
||||
if (!onInspect) {
|
||||
return <div className="v2-telemetry-value is-complex is-static"><strong>{presentation.summary}</strong><small>{presentation.preview}</small></div>;
|
||||
}
|
||||
return <div className="v2-telemetry-value is-complex"><Button
|
||||
className="v2-telemetry-value-trigger"
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
aria-label={`查看 ${item.label} 完整数据`}
|
||||
onClick={() => onInspect?.(item)}
|
||||
>
|
||||
<span><strong>{presentation.summary}</strong><small>{presentation.preview}</small></span><IconChevronRight />
|
||||
</Button></div>;
|
||||
}
|
||||
return <div className="v2-telemetry-value"><strong>{presentation.summary}</strong>{item.unit ? <span>{item.unit}</span> : null}</div>;
|
||||
}
|
||||
|
||||
function TelemetryQualityCell({ item }: { item: LatestTelemetryValue }) {
|
||||
return <div className="v2-telemetry-quality"><Tag color={telemetryQualityColor(item.quality)} type="light" size="small">{telemetryQualityLabel(item.quality)}</Tag><small title={item.qualityReason}>{item.qualityReason || '未提供质量说明'} · {formatZhNumber(item.freshnessSeconds, 0)}s</small></div>;
|
||||
}
|
||||
|
||||
function TelemetryTimeCell({ item }: { item: LatestTelemetryValue }) {
|
||||
return <div className="v2-telemetry-time"><span><small>设备</small>{formatTelemetryTime(item.deviceTime)}</span><span><small>接收</small>{formatTelemetryTime(item.serverTime)}</span></div>;
|
||||
}
|
||||
|
||||
function TelemetrySourceCell({ item }: { item: LatestTelemetryValue }) {
|
||||
return <div className="v2-telemetry-source"><ProtocolTag protocol={item.protocol} compact /><small title={item.sourceEndpoint}>{item.sourceEndpoint || '协议默认来源'}</small></div>;
|
||||
}
|
||||
|
||||
function telemetryRowKey(item?: LatestTelemetryValue) {
|
||||
return [
|
||||
item?.protocol ?? '',
|
||||
@@ -238,6 +159,23 @@ function telemetryRowKey(item?: LatestTelemetryValue) {
|
||||
].join('\u0000');
|
||||
}
|
||||
|
||||
function TelemetryMetricCard({ item, onInspect }: { item: LatestTelemetryValue; onInspect: (item: LatestTelemetryValue) => void }) {
|
||||
const presentation = telemetryValuePresentation(item);
|
||||
return <Button
|
||||
className={`v2-telemetry-metric-card is-quality-${item.quality}`}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
aria-label={`查看 ${item.label} 字段详情`}
|
||||
onClick={() => onInspect(item)}
|
||||
>
|
||||
<span className="v2-telemetry-metric-card-content">
|
||||
<span className="v2-telemetry-metric-heading"><strong>{item.label}</strong><small title={item.sourceField}>{item.sourceField}</small></span>
|
||||
<span className={`v2-telemetry-metric-value${presentation.complex ? ' is-complex' : ''}`}><strong>{presentation.summary}</strong>{item.unit ? <small>{item.unit}</small> : null}</span>
|
||||
<span className="v2-telemetry-metric-meta"><Tag color={telemetryQualityColor(item.quality)} type="light" size="small">{telemetryQualityLabel(item.quality)}</Tag><small>{formatZhNumber(item.freshnessSeconds, 0)}s · {formatTelemetryTime(item.serverTime)}</small><IconChevronRight /></span>
|
||||
</span>
|
||||
</Button>;
|
||||
}
|
||||
|
||||
function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryResponse; pending: boolean; error?: string }) {
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [selectedProtocol, setSelectedProtocol] = useState('');
|
||||
@@ -272,37 +210,15 @@ function TelemetryPanel({ data, pending, error }: { data?: LatestTelemetryRespon
|
||||
.map((key) => ({ key, label: categoryLabels.get(key) ?? key, count: indexed.valuesByProtocolCategory.get(`${activeProtocol}\u0000${key}`)?.length ?? 0 }));
|
||||
const activeCategory = indexed.valuesByProtocolCategory.has(`${activeProtocol}\u0000${selectedCategory}`) ? selectedCategory : categories[0]?.key ?? '';
|
||||
const visibleMetrics = indexed.valuesByProtocolCategory.get(`${activeProtocol}\u0000${activeCategory}`) ?? [];
|
||||
const columns = useMemo(() => [
|
||||
{ title: '字段 / 协议映射', dataIndex: 'label', width: 260, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryFieldCell item={item} /> },
|
||||
{ title: '当前值', dataIndex: 'value', width: 170, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryValueCell item={item} onInspect={setInspectedValue} /> },
|
||||
{ title: '质量 / 新鲜度', dataIndex: 'quality', width: 170, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryQualityCell item={item} /> },
|
||||
{ title: '设备 / 接收时间', dataIndex: 'deviceTime', width: 230, render: (_: unknown, item: LatestTelemetryValue) => <TelemetryTimeCell item={item} /> },
|
||||
{ title: '数据来源', dataIndex: 'protocol', width: 180, render: (_: unknown, item: LatestTelemetryValue) => <TelemetrySourceCell item={item} /> }
|
||||
], []);
|
||||
const state = pending
|
||||
? <PanelLoading className="v2-telemetry-loading" compact title="正在读取最新遥测" description="按协议整理最新标量证据。" />
|
||||
: error
|
||||
? <div className="v2-telemetry-state is-error" role="alert"><strong>最新遥测不可用</strong><span>{error}</span></div>
|
||||
: visibleMetrics.length === 0
|
||||
? <PanelEmpty className="v2-telemetry-empty" compact icon={<IconBox />} title="暂无可展示字段" description={`该协议最近 ${data?.scannedFrames ?? 0} 帧没有可展示的标量遥测。`} />
|
||||
: mobileLayout
|
||||
? <List className="v2-telemetry-mobile-list" dataSource={visibleMetrics} split={false} renderItem={(item) => <List.Item className="v2-telemetry-mobile-item" key={telemetryRowKey(item)}>
|
||||
<header><TelemetryFieldCell item={item} /><TelemetryValueCell item={item} /></header>
|
||||
<div><TelemetryQualityCell item={item} /><TelemetryTimeCell item={item} /></div>
|
||||
<footer className="v2-telemetry-mobile-source">
|
||||
<TelemetrySourceCell item={item} />
|
||||
<Button className="v2-telemetry-mobile-action" theme="borderless" type="primary" size="small" icon={<IconChevronRight />} iconPosition="right" aria-label={`查看 ${item.label} 字段详情`} onClick={() => setInspectedValue(item)}>查看详情</Button>
|
||||
</footer>
|
||||
</List.Item>} />
|
||||
: <div className="v2-telemetry-table-wrap"><Table
|
||||
className="v2-telemetry-table"
|
||||
columns={columns}
|
||||
dataSource={visibleMetrics}
|
||||
rowKey={telemetryRowKey}
|
||||
pagination={false}
|
||||
scroll={{ x: 1010 }}
|
||||
empty={null}
|
||||
/></div>;
|
||||
: <div className="v2-telemetry-metric-grid" role="list" aria-label={`${categoryLabels.get(activeCategory) ?? '当前分类'}遥测字段`}>
|
||||
{visibleMetrics.map((item) => <span role="listitem" key={telemetryRowKey(item)}><TelemetryMetricCard item={item} onInspect={setInspectedValue} /></span>)}
|
||||
</div>;
|
||||
return <Card className="v2-record-card v2-telemetry-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="实时遥测"
|
||||
@@ -420,6 +336,7 @@ const vehicleSectionIDs = {
|
||||
location: 'vehicle-location-panel',
|
||||
events: 'vehicle-events-panel',
|
||||
telemetry: 'vehicle-telemetry-panel',
|
||||
business: 'vehicle-business-panel',
|
||||
archive: 'vehicle-archive-panel'
|
||||
} as const;
|
||||
|
||||
@@ -436,6 +353,7 @@ function VehicleRecordNavigation({ onOpenSources }: { onOpenSources: () => void
|
||||
['location', '实时位置', <IconMapPin />],
|
||||
['events', '最近事件', <IconAlarm />],
|
||||
['telemetry', '实时遥测', <IconClock />],
|
||||
['business', '业务关联', <IconUserGroup />],
|
||||
['archive', '车辆主档', <IconBox />]
|
||||
] as const;
|
||||
return <Card className="v2-record-card v2-vehicle-record-nav" bodyStyle={{ padding: 0 }}>
|
||||
@@ -451,7 +369,7 @@ function VehicleRecordNavigation({ onOpenSources }: { onOpenSources: () => void
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, monitorReturn, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; monitorReturn: string; onUpdated: () => void }) {
|
||||
function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, telemetryError, monitorReturn, directoryReturn, onUpdated }: { detail: VehicleDetail; liveRealtime?: VehicleRealtimeRow; telemetry?: LatestTelemetryResponse; telemetryPending: boolean; telemetryError?: string; monitorReturn: string; directoryReturn: string; onUpdated: () => void }) {
|
||||
const { session } = usePlatformSession();
|
||||
const navigate = useNavigate();
|
||||
const mobileLayout = useMobileLayout();
|
||||
@@ -461,31 +379,25 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
|
||||
const identity = detail.identity;
|
||||
const hasLocation = !!(realtime && realtime.locationAvailable !== false && isValidAMapCoordinate(realtime.longitude, realtime.latitude));
|
||||
const mapVehicles = hasLocation && realtime ? [realtime] : [];
|
||||
const actions = [
|
||||
{ key: 'switch', label: '切换车辆', icon: <IconSearch />, to: '/vehicles', type: 'primary' as const },
|
||||
...(hasMenu(session, 'tracks') ? [{ key: 'tracks', label: '轨迹回放', icon: <IconMapPin />, to: withMonitorReturn(`/tracks?vin=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
|
||||
...(hasMenu(session, 'history') ? [{ key: 'history', label: '历史数据', icon: <IconCalendar />, to: withMonitorReturn(`/history?vin=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
|
||||
...(hasMenu(session, 'statistics') ? [{ key: 'statistics', label: '里程查询', icon: <IconClock />, to: withMonitorReturn(`/statistics?vins=${encodeURIComponent(detail.vin)}`, monitorReturn), type: 'tertiary' as const }] : []),
|
||||
...(hasMenu(session, 'alerts') ? [{ key: 'alerts', label: '告警事件', icon: <IconAlarm />, to: `/alerts?vin=${encodeURIComponent(detail.vin)}`, type: 'tertiary' as const }] : [])
|
||||
];
|
||||
const switchVehiclePath = directoryReturn || (monitorReturn ? withMonitorReturn('/vehicles', monitorReturn) : '/vehicles');
|
||||
const liveMetrics: WorkspaceQueueMetricRailItem[] = [
|
||||
{
|
||||
label: '速度',
|
||||
value: <>{availableMetric(realtime?.speedKmh, realtime?.speedAvailable)}<em className="v2-vehicle-live-unit">km/h</em></>,
|
||||
value: <>{availableMetric(realtime?.speedKmh, realtime?.speedAvailable)}<em className={LIVE_UNIT_CLASS}>km/h</em></>,
|
||||
note: '实时车速',
|
||||
tone: 'primary',
|
||||
emphasis: 'primary'
|
||||
},
|
||||
{
|
||||
label: 'SOC',
|
||||
value: <>{availableMetric(realtime?.socPercent, realtime?.socAvailable)}<em className="v2-vehicle-live-unit">%</em></>,
|
||||
value: <>{availableMetric(realtime?.socPercent, realtime?.socAvailable)}<em className={LIVE_UNIT_CLASS}>%</em></>,
|
||||
note: '剩余电量',
|
||||
tone: 'success',
|
||||
emphasis: 'secondary'
|
||||
},
|
||||
{
|
||||
label: '总里程',
|
||||
value: <>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}<em className="v2-vehicle-live-unit">km</em></>,
|
||||
value: <>{availableMetric(realtime?.totalMileageKm, realtime?.mileageAvailable)}<em className={LIVE_UNIT_CLASS}>km</em></>,
|
||||
note: <span className="v2-vehicle-live-action-note">推荐口径<IconChevronRight /></span>,
|
||||
emphasis: 'secondary',
|
||||
ariaLabel: '查看总里程全部来源',
|
||||
@@ -493,7 +405,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
|
||||
},
|
||||
{
|
||||
label: '当日里程',
|
||||
value: <>{availableMetric(realtime?.todayMileageKm, realtime?.todayMileageAvailable)}<em className="v2-vehicle-live-unit">km</em></>,
|
||||
value: <>{availableMetric(realtime?.todayMileageKm, realtime?.todayMileageAvailable)}<em className={LIVE_UNIT_CLASS}>km</em></>,
|
||||
note: <span className="v2-vehicle-live-action-note">今日累计<IconChevronRight /></span>,
|
||||
tone: 'primary',
|
||||
emphasis: 'primary',
|
||||
@@ -502,7 +414,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
|
||||
},
|
||||
{
|
||||
label: '来源状态',
|
||||
value: <>{realtime?.onlineSourceCount ?? 0}<em className="v2-vehicle-live-unit">/ {detail.sourceStatus.length}</em></>,
|
||||
value: <>{realtime?.onlineSourceCount ?? 0}<em className={LIVE_UNIT_CLASS}>/ {detail.sourceStatus.length}</em></>,
|
||||
note: '在线来源 / 全部',
|
||||
tone: (realtime?.onlineSourceCount ?? 0) > 0 ? 'success' : 'neutral',
|
||||
emphasis: 'secondary'
|
||||
@@ -565,12 +477,12 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
|
||||
<span className="v2-identity-model"><small>车辆品牌 / 车型</small><strong title={[detail.profile?.brandName, detail.profile?.modelName].filter(Boolean).join(' / ') || fmt(identity?.oem || realtime?.oem)}>{[detail.profile?.brandName, detail.profile?.modelName].filter(Boolean).join(' / ') || fmt(identity?.oem || realtime?.oem)}</strong></span>
|
||||
<span className="v2-identity-source-context"><small>可用数据来源</small><span className="v2-identity-sources">{detail.sources.length ? detail.sources.map((source) => <ProtocolTag key={source} protocol={source} compact />) : <Tag color="grey" type="light" size="small">暂无来源</Tag>}</span></span>
|
||||
</span>}
|
||||
actions={<Suspense fallback={null}><VehicleActions actions={actions} mobile={mobileLayout} onSelect={navigate} /></Suspense>}
|
||||
actions={<Suspense fallback={null}><VehicleActions vin={detail.vin} switchTo={switchVehiclePath} directoryReturn={directoryReturn} monitorReturn={monitorReturn} tracks={hasMenu(session, 'tracks')} history={hasMenu(session, 'history')} statistics={hasMenu(session, 'statistics')} alerts={hasMenu(session, 'alerts')} mobile={mobileLayout} onSelect={navigate} /></Suspense>}
|
||||
/>
|
||||
<section className="v2-vehicle-live-section" aria-labelledby="vehicle-live-heading">
|
||||
<WorkspacePanelHeader
|
||||
title={<span id="vehicle-live-heading">最新上报</span>}
|
||||
description="关键运行指标与推荐数据来源"
|
||||
description="关键指标与来源"
|
||||
meta={<span className="v2-live-report-meta"><i className={realtime?.online ? 'is-online' : ''} />{realtime?.online ? '实时在线' : '当前离线'} · <IconClock /><PlatformTime value={realtime?.lastSeen || identity?.lastSeen} /></span>}
|
||||
/>
|
||||
<WorkspaceMetricRail
|
||||
@@ -596,7 +508,7 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
|
||||
<Card className="v2-single-map-card" bodyStyle={{ padding: 0 }}>
|
||||
<WorkspacePanelHeader
|
||||
title="实时位置"
|
||||
description="按上报周期平滑移动"
|
||||
description="平滑移动"
|
||||
meta={realtime?.online ? '实时跟随' : '保留最后位置'}
|
||||
/>
|
||||
<FleetMap vehicles={mapVehicles} selectedVin={hasLocation ? detail.vin : undefined} onSelect={() => undefined} />
|
||||
@@ -605,7 +517,8 @@ function VehicleRecord({ detail, liveRealtime, telemetry, telemetryPending, tele
|
||||
</div>
|
||||
<div id={vehicleSectionIDs.events} tabIndex={-1} className="v2-record-section-anchor"><Events detail={detail} /></div>
|
||||
<div id={vehicleSectionIDs.telemetry} tabIndex={-1} className="v2-record-section-anchor"><TelemetryPanel data={telemetry} pending={telemetryPending} error={telemetryError} /></div>
|
||||
<div id={vehicleSectionIDs.archive} tabIndex={-1} className="v2-record-section-anchor"><Archive detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} /></div>
|
||||
<div id={vehicleSectionIDs.business} tabIndex={-1} className="v2-record-section-anchor"><Suspense fallback={<Card className="v2-record-card v2-business-relation-card" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在读取业务关联" description="客户、合同与运营责任就绪后会自动显示。" /></Card>}><VehicleBusinessRelationCard relation={detail.businessRelation} /></Suspense></div>
|
||||
<div id={vehicleSectionIDs.archive} tabIndex={-1} className="v2-record-section-anchor"><Suspense fallback={<Card className="v2-record-card v2-archive-card" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在读取车辆主档" description="主档与维护权限就绪后会自动显示。" /></Card>}><VehicleArchiveCard detail={detail} editable={canAdminister(session)} onUpdated={onUpdated} /></Suspense></div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -614,6 +527,7 @@ export default function VehiclePage() {
|
||||
const { vin } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const monitorReturn = monitorReturnFromParams(searchParams);
|
||||
const directoryReturn = vehicleDirectoryReturnFromParams(searchParams);
|
||||
const query = useQuery({ queryKey: ['vehicle-detail', vin], enabled: !!vin, queryFn: ({ signal }) => api.vehicleDetail(new URLSearchParams({ keyword: vin!, limit: '20' }), signal), gcTime: QUERY_MEMORY.summaryGcTime });
|
||||
const resolvedVin = query.data?.lookupResolved ? query.data.vin : '';
|
||||
const realtime = useQuery({
|
||||
@@ -651,7 +565,7 @@ export default function VehiclePage() {
|
||||
icon={<IconSearch />}
|
||||
title="未找到车辆"
|
||||
description={`没有匹配“${vin}”的车牌、VIN 或终端记录。`}
|
||||
action={<Link to="/vehicles"><Button theme="solid" icon={<IconSearch />} aria-label="重新查询车辆">重新查询</Button></Link>}
|
||||
action={<Link to={directoryReturn || (monitorReturn ? withMonitorReturn('/vehicles', monitorReturn) : '/vehicles')}><Button theme="solid" icon={<IconSearch />} aria-label="重新查询车辆">重新查询</Button></Link>}
|
||||
/></Card>;
|
||||
return <VehicleRecord detail={query.data} liveRealtime={realtime.data?.items[0]} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} monitorReturn={monitorReturn} onUpdated={() => { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />;
|
||||
return <VehicleRecord detail={query.data} liveRealtime={realtime.data?.items[0]} telemetry={telemetry.data} telemetryPending={telemetry.isPending} telemetryError={telemetry.isError ? (telemetry.error instanceof Error ? telemetry.error.message : '请求失败') : undefined} monitorReturn={monitorReturn} directoryReturn={directoryReturn} onUpdated={() => { void query.refetch(); void realtime.refetch(); void telemetry.refetch(); }} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types';
|
||||
import { vehicleProfileSyncCSVHeader } from '../domain/profileSync';
|
||||
import VehicleProfileSyncPanel from './VehicleProfileSyncPanel';
|
||||
|
||||
vi.mock('../hooks/useMobileLayout', () => ({ useMobileLayout: () => false }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
sessionStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderPanel(onClose = vi.fn(), onApplied = vi.fn()) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><VehicleProfileSyncPanel onClose={onClose} onApplied={onApplied} /></QueryClientProvider>);
|
||||
return { ...view, onClose, onApplied };
|
||||
}
|
||||
|
||||
function csvFile(rowCount: number, name = 'fleet.csv') {
|
||||
const rows = Array.from({ length: rowCount }, (_, index) => `LTEST${String(index).padStart(12, '0')},飞驰,新能源运营车,乘用车,岭牛示范车队,active,G7,2026-03-01T08:00:00+08:00,${index}`);
|
||||
const csv = [vehicleProfileSyncCSVHeader, ...rows].join('\n');
|
||||
const file = new File([csv], name, { type: 'text/csv' });
|
||||
if (!file.text) Object.defineProperty(file, 'text', { value: async () => csv });
|
||||
return file;
|
||||
}
|
||||
|
||||
function configure(rowCount: number) {
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '来源系统标识' }), { target: { value: 'oem-tsp' } });
|
||||
fireEvent.change(screen.getByRole('textbox', { name: '来源版本' }), { target: { value: 'snapshot-20260723-01' } });
|
||||
const fileInput = document.querySelector<HTMLInputElement>('input[type="file"]');
|
||||
expect(fileInput).not.toBeNull();
|
||||
fireEvent.change(fileInput!, { target: { files: [csvFile(rowCount)] } });
|
||||
}
|
||||
|
||||
function resultFor(items: VehicleProfileSyncItem[], dryRun: boolean): VehicleProfileSyncResult {
|
||||
return {
|
||||
sourceSystem: 'oem-tsp', sourceVersion: 'snapshot-20260723-01', dryRun,
|
||||
received: items.length, created: 0, updated: items.length, unchanged: 0, conflicted: 0, missing: 0,
|
||||
items: items.map((item) => ({ vin: item.vin, status: 'updated', profileVersion: 2 }))
|
||||
};
|
||||
}
|
||||
|
||||
test('keeps the three-step task sheet and completes a confirmed batch write', async () => {
|
||||
const sync = vi.spyOn(api, 'syncVehicleProfiles').mockImplementation(async (request) => resultFor(request.items, request.dryRun));
|
||||
const { onClose, onApplied } = renderPanel();
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '车辆主档批量同步' });
|
||||
expect(dialog).toHaveTextContent('大文件自动分批,离开页面后仍可续办');
|
||||
const steps = screen.getByRole('list', { name: '主档同步步骤' });
|
||||
expect(steps).toHaveTextContent('校验文件');
|
||||
expect(steps).toHaveTextContent('分批预演');
|
||||
expect(steps).toHaveTextContent('写入进度');
|
||||
expect(screen.getByRole('button', { name: '预演全部批次' })).toBeDisabled();
|
||||
|
||||
configure(1);
|
||||
expect(await screen.findByText(/fleet\.csv · 1 辆 · 1 批/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '预演全部批次' }));
|
||||
await waitFor(() => expect(sync).toHaveBeenLastCalledWith(expect.objectContaining({ dryRun: true, items: [expect.objectContaining({ vin: 'LTEST000000000000' })] }), expect.any(AbortSignal)));
|
||||
expect(await screen.findByRole('list', { name: '主档同步影响统计' })).toHaveTextContent('更新1');
|
||||
expect(screen.getByText('全部批次预演通过')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认分批写入' }));
|
||||
await waitFor(() => expect(sync).toHaveBeenLastCalledWith(expect.objectContaining({ dryRun: false }), expect.any(AbortSignal)));
|
||||
await waitFor(() => expect(onApplied).toHaveBeenCalledTimes(1));
|
||||
expect(await screen.findByText('全部批次写入完成')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '完成' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('splits 1,001 vehicles into three preview and write batches', async () => {
|
||||
const sync = vi.spyOn(api, 'syncVehicleProfiles').mockImplementation(async (request) => resultFor(request.items, request.dryRun));
|
||||
const { onApplied } = renderPanel();
|
||||
configure(1_001);
|
||||
|
||||
expect(await screen.findByText(/1,001 辆 · 3 批/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '预演全部批次' }));
|
||||
await waitFor(() => expect(sync).toHaveBeenCalledTimes(3));
|
||||
expect(screen.getByRole('list', { name: '主档同步影响统计' })).toHaveTextContent('已处理1,001');
|
||||
expect(sync.mock.calls.map(([request]) => request.items.length)).toEqual([500, 500, 1]);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认分批写入' }));
|
||||
await waitFor(() => expect(sync).toHaveBeenCalledTimes(6));
|
||||
await waitFor(() => expect(onApplied).toHaveBeenCalledTimes(1));
|
||||
expect(sync.mock.calls.slice(3).map(([request]) => request.items.length)).toEqual([500, 500, 1]);
|
||||
});
|
||||
|
||||
test('persists configuration on close and restores it without a discard prompt', async () => {
|
||||
const first = renderPanel();
|
||||
configure(1);
|
||||
expect(await screen.findByText(/fleet\.csv · 1 辆 · 1 批/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭车辆主档批量同步' }));
|
||||
expect(first.onClose).toHaveBeenCalledTimes(1);
|
||||
first.unmount();
|
||||
|
||||
renderPanel();
|
||||
expect(screen.getByRole('textbox', { name: '来源系统标识' })).toHaveValue('oem-tsp');
|
||||
expect(screen.getByText('已恢复上次任务')).toBeInTheDocument();
|
||||
expect(screen.getByText('来源、文件内容和已完成批次已从当前浏览器恢复。')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('continues from the next batch after a write interruption', async () => {
|
||||
let applyCalls = 0;
|
||||
const appliedFirstVINs: string[] = [];
|
||||
const sync = vi.spyOn(api, 'syncVehicleProfiles').mockImplementation(async (request) => {
|
||||
if (!request.dryRun) {
|
||||
applyCalls += 1;
|
||||
if (applyCalls === 2) throw new Error('网关连接暂时中断');
|
||||
appliedFirstVINs.push(request.items[0].vin);
|
||||
}
|
||||
return resultFor(request.items, request.dryRun);
|
||||
});
|
||||
const { onApplied } = renderPanel();
|
||||
configure(1_001);
|
||||
await screen.findByText(/1,001 辆 · 3 批/);
|
||||
fireEvent.click(screen.getByRole('button', { name: '预演全部批次' }));
|
||||
await waitFor(() => expect(sync).toHaveBeenCalledTimes(3));
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认分批写入' }));
|
||||
|
||||
expect(await screen.findByText('批量写入已中断')).toBeInTheDocument();
|
||||
expect(screen.getByText('网关连接暂时中断')).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 \/ 3 批完成/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续写入' }));
|
||||
await waitFor(() => expect(onApplied).toHaveBeenCalledTimes(1));
|
||||
expect(appliedFirstVINs).toEqual(['LTEST000000000000', 'LTEST000000000500', 'LTEST000000001000']);
|
||||
expect(screen.getByText('全部批次写入完成')).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,46 +1,312 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Button, Card, Input, Select, Upload } from '@douyinfe/semi-ui';
|
||||
import { useState } from 'react';
|
||||
import { IconAlertTriangle, IconBox, IconDownload, IconTickCircle } from '@douyinfe/semi-icons';
|
||||
import { Button, Input, Progress, Select, Tag, Upload } from '@douyinfe/semi-ui';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleProfileSyncItem, VehicleProfileSyncResult } from '../../api/types';
|
||||
import { parseVehicleProfileSyncCSV, vehicleProfileSyncCSVHeader } from '../domain/profileSync';
|
||||
import {
|
||||
chunkVehicleProfileSyncItems, clearVehicleProfileSyncDraft, emptyVehicleProfileSyncResult,
|
||||
loadVehicleProfileSyncDraft, mergeVehicleProfileSyncResults, parseVehicleProfileSyncCSV,
|
||||
saveVehicleProfileSyncDraft, VEHICLE_PROFILE_SYNC_CHUNK_SIZE, VEHICLE_PROFILE_SYNC_MAX_ROWS,
|
||||
vehicleProfileSyncCSVHeader, vehicleProfileSyncIssuesCSV, type VehicleProfileSyncTaskPhase
|
||||
} from '../domain/profileSync';
|
||||
import { downloadBlob } from '../domain/download';
|
||||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||||
import { PanelEmpty } from '../shared/AsyncState';
|
||||
import { WorkspaceConfirmDialog } from '../shared/WorkspaceDialog';
|
||||
import { WorkspaceSideSheet, type WorkspaceSideSheetAction, type WorkspaceSideSheetBadgeColor } from '../shared/WorkspaceSideSheet';
|
||||
|
||||
export default function VehicleProfileSyncPanel({ onClose }: { onClose: () => void }) {
|
||||
const [sourceSystem, setSourceSystem] = useState('');
|
||||
const [sourceVersion, setSourceVersion] = useState('');
|
||||
const [conflictPolicy, setConflictPolicy] = useState<'preserve' | 'overwrite'>('preserve');
|
||||
const [items, setItems] = useState<VehicleProfileSyncItem[]>([]);
|
||||
const [fileName, setFileName] = useState('');
|
||||
type VehicleProfileSyncPanelProps = {
|
||||
onClose: () => void;
|
||||
onApplied?: () => void;
|
||||
};
|
||||
|
||||
const VISIBLE_ISSUE_LIMIT = 100;
|
||||
|
||||
export default function VehicleProfileSyncPanel({ onClose, onApplied }: VehicleProfileSyncPanelProps) {
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [initialDraft] = useState(() => loadVehicleProfileSyncDraft());
|
||||
const initialPhase = initialDraft?.phase === 'applying'
|
||||
? 'interrupted'
|
||||
: initialDraft?.phase === 'previewing' ? 'configured' : initialDraft?.phase ?? 'configured';
|
||||
const [sourceSystem, setSourceSystem] = useState(initialDraft?.sourceSystem ?? '');
|
||||
const [sourceVersion, setSourceVersion] = useState(initialDraft?.sourceVersion ?? '');
|
||||
const [conflictPolicy, setConflictPolicy] = useState<'preserve' | 'overwrite'>(initialDraft?.conflictPolicy ?? 'preserve');
|
||||
const [items, setItems] = useState<VehicleProfileSyncItem[]>(initialDraft?.items ?? []);
|
||||
const [fileName, setFileName] = useState(initialDraft?.fileName ?? '');
|
||||
const [parseError, setParseError] = useState('');
|
||||
const sync = useMutation<VehicleProfileSyncResult, Error, boolean>({
|
||||
mutationFn: (dryRun) => api.syncVehicleProfiles({ sourceSystem: sourceSystem.trim(), sourceVersion: sourceVersion.trim(), conflictPolicy, dryRun, items })
|
||||
});
|
||||
const readFile = async (file?: File) => {
|
||||
sync.reset(); setItems([]); setFileName(file?.name ?? ''); setParseError('');
|
||||
if (!file) return;
|
||||
try { setItems(parseVehicleProfileSyncCSV(await file.text())); } catch (error) { setParseError(error instanceof Error ? error.message : 'CSV 解析失败'); }
|
||||
};
|
||||
const ready = sourceSystem.trim() !== '' && sourceVersion.trim() !== '' && items.length > 0 && !sync.isPending;
|
||||
const issues = sync.data?.items.filter((item) => item.status.startsWith('conflict_') || item.status === 'missing_vehicle').slice(0, 20) ?? [];
|
||||
const applied = sync.data && !sync.data.dryRun;
|
||||
const [phase, setPhase] = useState<VehicleProfileSyncTaskPhase>(initialPhase);
|
||||
const [completedChunks, setCompletedChunks] = useState(initialDraft?.completedChunks ?? 0);
|
||||
const [result, setResult] = useState<VehicleProfileSyncResult | undefined>(initialDraft?.result);
|
||||
const [runError, setRunError] = useState(initialDraft?.phase === 'applying' ? '页面离开时任务已暂停,可从下一批继续写入。' : '');
|
||||
const [fileReading, setFileReading] = useState(false);
|
||||
const [recovered, setRecovered] = useState(Boolean(initialDraft));
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||
const abortRef = useRef<AbortController>();
|
||||
|
||||
return <Card className="v2-profile-sync-panel" aria-label="车辆主档批量同步">
|
||||
<header><div><strong>批量同步车辆主档</strong><p>CSV 最多 500 辆;先预演,再写入。默认保留人工档案和其他来源。</p></div><Button theme="borderless" type="tertiary" onClick={onClose}>关闭</Button></header>
|
||||
<div className="v2-profile-sync-fields">
|
||||
<label><span>来源系统标识</span><Input value={sourceSystem} onChange={(value) => { setSourceSystem(value); sync.reset(); }} placeholder="例如 oem-tsp" maxLength={64} /></label>
|
||||
<label><span>来源版本</span><Input value={sourceVersion} onChange={(value) => { setSourceVersion(value); sync.reset(); }} placeholder="例如 snapshot-20260714-01" maxLength={128} /></label>
|
||||
<label><span>冲突策略</span><Select value={conflictPolicy} onChange={(value) => { setConflictPolicy(String(value) as 'preserve' | 'overwrite'); sync.reset(); }} optionList={[{ value: 'preserve', label: '保护现有来源' }, { value: 'overwrite', label: '显式覆盖现有来源' }]} /></label>
|
||||
<label className="is-file"><span>CSV 文件</span><Upload action="" accept=".csv,text/csv" limit={1} uploadTrigger="custom" showUploadList={false} onFileChange={(files) => { void readFile(files[0]); }}><Button theme="light">选择 CSV 文件</Button></Upload></label>
|
||||
</div>
|
||||
<p className="v2-profile-sync-format">表头:<code>{vehicleProfileSyncCSVHeader}</code></p>
|
||||
{fileName ? <p className="v2-profile-sync-file">{fileName} · 已读取 {items.length} 辆</p> : null}
|
||||
{parseError ? <p className="v2-profile-sync-error">{parseError}</p> : null}
|
||||
{sync.isError ? <p className="v2-profile-sync-error">{sync.error.message}</p> : null}
|
||||
{sync.data ? <div className="v2-profile-sync-result">
|
||||
<div><span>收到<strong>{sync.data.received}</strong></span><span>新增<strong>{sync.data.created}</strong></span><span>更新<strong>{sync.data.updated}</strong></span><span>未变化<strong>{sync.data.unchanged}</strong></span><span>冲突<strong>{sync.data.conflicted}</strong></span><span>身份缺失<strong>{sync.data.missing}</strong></span></div>
|
||||
{issues.length ? <ul>{issues.map((item) => <li key={item.vin}><b>{item.vin}</b><span>{item.status === 'missing_vehicle' ? '网关身份不存在' : item.status === 'conflict_source_version' ? '同来源版本内容不一致' : `现有来源 ${item.previousSource || '未知'} 已保护`}</span></li>)}</ul> : <p>未发现来源冲突或身份缺失。</p>}
|
||||
</div> : null}
|
||||
{conflictPolicy === 'overwrite' ? <p className="v2-profile-sync-warning">覆盖模式会接管人工或其他系统维护的补充主档,请先确认预演结果。</p> : null}
|
||||
<footer><Button theme="light" onClick={() => sync.mutate(true)} disabled={!ready} loading={sync.isPending}>预演同步</Button><Button theme="solid" onClick={() => sync.mutate(false)} disabled={!ready || !sync.data?.dryRun}>{applied ? '已完成写入' : '确认写入'}</Button></footer>
|
||||
</Card>;
|
||||
const chunks = useMemo(() => chunkVehicleProfileSyncItems(items), [items]);
|
||||
const totalChunks = chunks.length;
|
||||
const running = phase === 'previewing' || phase === 'applying';
|
||||
const applied = phase === 'applied';
|
||||
const previewed = phase === 'previewed';
|
||||
const interrupted = phase === 'interrupted';
|
||||
const configured = Boolean(sourceSystem.trim() || sourceVersion.trim() || fileName || items.length || result);
|
||||
const ready = sourceSystem.trim() !== '' && sourceVersion.trim() !== '' && items.length > 0 && !running && !fileReading;
|
||||
const issues = useMemo(() => result?.items.filter((item) => item.status.startsWith('conflict_') || item.status === 'missing_vehicle') ?? [], [result?.items]);
|
||||
const visibleIssues = useMemo(() => issues.slice(0, VISIBLE_ISSUE_LIMIT), [issues]);
|
||||
const processedRows = Math.min(items.length, completedChunks * VEHICLE_PROFILE_SYNC_CHUNK_SIZE);
|
||||
const progress = items.length ? Math.min(100, Math.round(processedRows / items.length * 100)) : 0;
|
||||
|
||||
const persistDraft = useCallback((nextPhase = phase, nextCompleted = completedChunks, nextResult = result) => {
|
||||
if (!configured) {
|
||||
clearVehicleProfileSyncDraft();
|
||||
return;
|
||||
}
|
||||
saveVehicleProfileSyncDraft({
|
||||
version: 2,
|
||||
sourceSystem,
|
||||
sourceVersion,
|
||||
conflictPolicy,
|
||||
fileName,
|
||||
items,
|
||||
phase: nextPhase,
|
||||
completedChunks: nextCompleted,
|
||||
result: nextResult,
|
||||
savedAt: new Date().toISOString()
|
||||
});
|
||||
}, [completedChunks, configured, conflictPolicy, fileName, items, phase, result, sourceSystem, sourceVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => persistDraft(), 180);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [persistDraft]);
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
const resetRun = () => {
|
||||
abortRef.current?.abort();
|
||||
setPhase('configured');
|
||||
setCompletedChunks(0);
|
||||
setResult(undefined);
|
||||
setRunError('');
|
||||
setRecovered(false);
|
||||
};
|
||||
|
||||
const readFile = async (file?: File) => {
|
||||
resetRun();
|
||||
setItems([]);
|
||||
setFileName(file?.name ?? '');
|
||||
setParseError('');
|
||||
if (!file) return;
|
||||
setFileReading(true);
|
||||
try {
|
||||
setItems(parseVehicleProfileSyncCSV(await file.text()));
|
||||
} catch (error) {
|
||||
setParseError(error instanceof Error ? error.message : 'CSV 解析失败');
|
||||
} finally {
|
||||
setFileReading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runBatches = async (dryRun: boolean, resume = false) => {
|
||||
if (!ready || !chunks.length) return;
|
||||
const controller = new AbortController();
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = controller;
|
||||
const startChunk = !dryRun && resume ? Math.min(completedChunks, chunks.length) : 0;
|
||||
let aggregate = !dryRun && resume && result
|
||||
? result
|
||||
: emptyVehicleProfileSyncResult(sourceSystem.trim(), sourceVersion.trim(), dryRun);
|
||||
let finishedChunks = startChunk;
|
||||
const activePhase: VehicleProfileSyncTaskPhase = dryRun ? 'previewing' : 'applying';
|
||||
setRecovered(false);
|
||||
setRunError('');
|
||||
setPhase(activePhase);
|
||||
setCompletedChunks(startChunk);
|
||||
setResult(startChunk ? aggregate : undefined);
|
||||
persistDraft(activePhase, startChunk, startChunk ? aggregate : undefined);
|
||||
try {
|
||||
for (let index = startChunk; index < chunks.length; index += 1) {
|
||||
const batch = await api.syncVehicleProfiles({
|
||||
sourceSystem: sourceSystem.trim(), sourceVersion: sourceVersion.trim(), conflictPolicy, dryRun, items: chunks[index]
|
||||
}, controller.signal);
|
||||
aggregate = mergeVehicleProfileSyncResults(aggregate, batch);
|
||||
const nextCompleted = index + 1;
|
||||
finishedChunks = nextCompleted;
|
||||
setResult(aggregate);
|
||||
setCompletedChunks(nextCompleted);
|
||||
persistDraft(activePhase, nextCompleted, aggregate);
|
||||
}
|
||||
const completePhase: VehicleProfileSyncTaskPhase = dryRun ? 'previewed' : 'applied';
|
||||
setPhase(completePhase);
|
||||
persistDraft(completePhase, chunks.length, aggregate);
|
||||
if (!dryRun) onApplied?.();
|
||||
} catch (error) {
|
||||
if (dryRun) {
|
||||
setPhase('configured');
|
||||
setCompletedChunks(0);
|
||||
setResult(undefined);
|
||||
setRunError(controller.signal.aborted ? '预演已暂停,可重新开始。' : error instanceof Error ? error.message : '预演失败');
|
||||
persistDraft('configured', 0, undefined);
|
||||
} else {
|
||||
setPhase('interrupted');
|
||||
setRunError(controller.signal.aborted ? '写入已暂停,可从下一批继续。' : error instanceof Error ? error.message : '批量写入中断');
|
||||
persistDraft('interrupted', finishedChunks, aggregate);
|
||||
}
|
||||
} finally {
|
||||
if (abortRef.current === controller) abortRef.current = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const clearTask = () => {
|
||||
abortRef.current?.abort();
|
||||
clearVehicleProfileSyncDraft();
|
||||
setSourceSystem('');
|
||||
setSourceVersion('');
|
||||
setConflictPolicy('preserve');
|
||||
setItems([]);
|
||||
setFileName('');
|
||||
setParseError('');
|
||||
setPhase('configured');
|
||||
setCompletedChunks(0);
|
||||
setResult(undefined);
|
||||
setRunError('');
|
||||
setRecovered(false);
|
||||
setClearConfirmOpen(false);
|
||||
};
|
||||
|
||||
const requestClose = () => {
|
||||
if (running) {
|
||||
abortRef.current?.abort();
|
||||
persistDraft(phase === 'applying' ? 'interrupted' : 'configured', phase === 'applying' ? completedChunks : 0, phase === 'applying' ? result : undefined);
|
||||
} else {
|
||||
persistDraft();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
clearVehicleProfileSyncDraft();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const downloadIssues = () => {
|
||||
if (!result) return;
|
||||
downloadBlob(new Blob([vehicleProfileSyncIssuesCSV(result)], { type: 'text/csv;charset=utf-8' }), `vehicle-profile-sync-issues-${sourceVersion.trim() || 'latest'}.csv`);
|
||||
};
|
||||
|
||||
const status = applied ? '写入完成' : interrupted ? '可继续' : phase === 'applying' ? '写入中' : phase === 'previewing' ? '预演中' : previewed ? '预演完成' : items.length ? '待预演' : '待配置';
|
||||
const statusTone: WorkspaceSideSheetBadgeColor = applied ? 'green' : running ? 'blue' : interrupted ? 'orange' : previewed ? 'blue' : configured ? 'orange' : 'grey';
|
||||
const policyLabel = conflictPolicy === 'preserve' ? '保护现有来源' : '显式覆盖';
|
||||
const batchLabel = totalChunks ? `${totalChunks.toLocaleString('zh-CN')} 批 · 每批最多 ${VEHICLE_PROFILE_SYNC_CHUNK_SIZE}` : `最多 ${VEHICLE_PROFILE_SYNC_MAX_ROWS.toLocaleString('zh-CN')} 辆`;
|
||||
const footerNote = applied
|
||||
? `已完成 ${result?.received.toLocaleString('zh-CN') ?? 0} 辆写入校验,车辆目录正在刷新。`
|
||||
: running
|
||||
? `${phase === 'previewing' ? '正在预演' : '正在写入'}第 ${Math.min(totalChunks, completedChunks + 1)} / ${totalChunks} 批;关闭后可从已完成批次继续。`
|
||||
: interrupted
|
||||
? `已完成 ${completedChunks} / ${totalChunks} 批,继续写入不会重复修改已完成车辆。`
|
||||
: previewed
|
||||
? issues.length ? `发现 ${issues.length.toLocaleString('zh-CN')} 条需复核项;确认后按当前策略分批写入。` : '全部批次预演通过;确认后将按批次写入。'
|
||||
: configured ? '草稿已保存在当前浏览器,可关闭后继续。' : '先完成来源与 CSV 配置,再预演全部批次。';
|
||||
|
||||
const secondaryActions: WorkspaceSideSheetAction[] = [];
|
||||
if (!running && !applied && configured) secondaryActions.push({ label: '清空任务', type: 'tertiary', theme: 'borderless', onClick: () => setClearConfirmOpen(true) });
|
||||
if (!running && (previewed || interrupted)) secondaryActions.push({ label: '重新预演', onClick: () => { void runBatches(true); }, disabled: !ready });
|
||||
|
||||
const primaryAction: WorkspaceSideSheetAction = applied
|
||||
? { label: '完成', onClick: finish }
|
||||
: running
|
||||
? { label: phase === 'previewing' ? `预演中 ${completedChunks}/${totalChunks}` : `写入中 ${completedChunks}/${totalChunks}`, onClick: () => undefined, disabled: true, loading: true }
|
||||
: interrupted
|
||||
? { label: '继续写入', onClick: () => { void runBatches(false, true); }, disabled: !ready }
|
||||
: previewed
|
||||
? { label: '确认分批写入', onClick: () => { void runBatches(false); }, disabled: !ready }
|
||||
: { label: '预演全部批次', onClick: () => { void runBatches(true); }, disabled: !ready };
|
||||
|
||||
return <>
|
||||
<WorkspaceSideSheet
|
||||
className="v2-profile-sync-sidesheet"
|
||||
variant="task"
|
||||
visible
|
||||
ariaLabel="车辆主档批量同步"
|
||||
closeLabel="关闭车辆主档批量同步"
|
||||
placement={mobileLayout ? 'bottom' : 'right'}
|
||||
width={mobileLayout ? undefined : 720}
|
||||
height={mobileLayout ? 'min(92dvh, 860px)' : undefined}
|
||||
title="批量同步车辆主档"
|
||||
description="大文件自动分批,离开页面后仍可续办"
|
||||
icon={<IconBox />}
|
||||
badge={status}
|
||||
badgeColor={statusTone}
|
||||
summaryItems={[
|
||||
{ label: '当前步骤', value: status, detail: applied ? '目录已刷新' : running || interrupted ? `${completedChunks} / ${totalChunks} 批` : previewed ? '等待确认写入' : items.length ? '核对全部批次' : '填写来源与文件', tone: applied ? 'success' : running ? 'primary' : interrupted ? 'warning' : previewed ? 'primary' : configured ? 'warning' : 'neutral' },
|
||||
{ label: 'CSV 车辆', value: items.length ? `${items.length.toLocaleString('zh-CN')} 辆` : '未读取', detail: fileName ? `${fileName} · ${batchLabel}` : batchLabel, tone: items.length ? 'primary' : 'neutral' },
|
||||
{ label: '冲突策略', value: policyLabel, detail: conflictPolicy === 'preserve' ? '人工与其他来源不被接管' : '写入前必须复核冲突', tone: conflictPolicy === 'overwrite' ? 'warning' : 'success' }
|
||||
]}
|
||||
footerNote={footerNote}
|
||||
secondaryActions={secondaryActions}
|
||||
primaryAction={primaryAction}
|
||||
onCancel={requestClose}
|
||||
>
|
||||
<div className="v2-profile-sync-workflow">
|
||||
<ol className="v2-profile-sync-steps" aria-label="主档同步步骤">
|
||||
<li className={configured ? 'is-complete' : 'is-active'}><span>1</span><strong>校验文件</strong><small>确认来源与规模</small></li>
|
||||
<li className={previewed || phase === 'applying' || interrupted || applied ? 'is-complete' : phase === 'previewing' || configured ? 'is-active' : ''}><span>2</span><strong>分批预演</strong><small>逐批检查影响</small></li>
|
||||
<li className={applied ? 'is-complete' : phase === 'applying' || interrupted ? 'is-active' : ''}><span>3</span><strong>写入进度</strong><small>支持暂停与续办</small></li>
|
||||
</ol>
|
||||
|
||||
{recovered || running || interrupted ? <section className={`v2-profile-sync-progress${interrupted ? ' is-interrupted' : ''}`} role="status" aria-live="polite">
|
||||
<header><span><strong>{recovered && !running ? '已恢复上次任务' : interrupted ? '任务已暂停,可安全续办' : phase === 'previewing' ? '正在预演全部批次' : '正在分批写入主档'}</strong><small>{items.length.toLocaleString('zh-CN')} 辆 · {completedChunks} / {totalChunks} 批完成</small></span><b>{progress}%</b></header>
|
||||
<Progress aria-label={`主档同步进度 ${progress}%`} percent={progress} showInfo={false} size="small" strokeLinecap="round" />
|
||||
<p>{interrupted ? '点击“继续写入”从下一批开始;相同来源版本重复提交会安全识别为未变化。' : recovered && !running ? '来源、文件内容和已完成批次已从当前浏览器恢复。' : '每完成一批都会保存进度,刷新或离开页面不会丢失已完成位置。'}</p>
|
||||
</section> : null}
|
||||
|
||||
<section className="v2-profile-sync-section" aria-labelledby="profile-sync-source-title">
|
||||
<header><span><strong id="profile-sync-source-title">同步来源</strong><small>来源系统与版本会写入每辆车的主档审计信息。</small></span><Tag color="blue" type="light" size="small">必填</Tag></header>
|
||||
<div className="v2-profile-sync-fields">
|
||||
<label><span>来源系统标识</span><Input aria-label="来源系统标识" value={sourceSystem} onChange={(value) => { setSourceSystem(value); resetRun(); }} placeholder="例如 oem-tsp" maxLength={64} /></label>
|
||||
<label><span>来源版本</span><Input aria-label="来源版本" value={sourceVersion} onChange={(value) => { setSourceVersion(value); resetRun(); }} placeholder="例如 snapshot-20260723-01" maxLength={128} /></label>
|
||||
<label><span>冲突策略</span><Select aria-label="冲突策略" value={conflictPolicy} onChange={(value) => { setConflictPolicy(String(value) as 'preserve' | 'overwrite'); resetRun(); }} optionList={[{ value: 'preserve', label: '保护现有来源' }, { value: 'overwrite', label: '显式覆盖现有来源' }]} /></label>
|
||||
<label className="is-file"><span>CSV 文件</span><Upload action="" accept=".csv,text/csv" limit={1} uploadTrigger="custom" showUploadList={false} onFileChange={(files) => { void readFile(files[0]); }}><Button theme="light" type="primary" loading={fileReading}>{fileName ? '重新选择 CSV' : '选择 CSV 文件'}</Button></Upload></label>
|
||||
</div>
|
||||
<div className={`v2-profile-sync-file-state${parseError ? ' is-error' : fileName ? ' is-ready' : ''}`} role={parseError ? 'alert' : 'status'}>
|
||||
{parseError ? <IconAlertTriangle /> : fileName ? <IconTickCircle /> : <IconBox />}
|
||||
<span><strong>{parseError ? 'CSV 读取失败' : fileReading ? '正在校验 CSV…' : fileName ? `${fileName} · ${items.length.toLocaleString('zh-CN')} 辆 · ${batchLabel}` : '等待选择 CSV 文件'}</strong><small>{parseError || `需要表头:${vehicleProfileSyncCSVHeader};单文件最多 ${VEHICLE_PROFILE_SYNC_MAX_ROWS.toLocaleString('zh-CN')} 辆`}</small></span>
|
||||
</div>
|
||||
{conflictPolicy === 'overwrite' ? <p className="v2-profile-sync-warning" role="status"><IconAlertTriangle />覆盖模式会接管人工或其他系统维护的补充主档;请以全部批次预演结果为最终确认依据。</p> : null}
|
||||
</section>
|
||||
|
||||
<section className="v2-profile-sync-section is-result" aria-labelledby="profile-sync-result-title">
|
||||
<header><span><strong id="profile-sync-result-title">批次影响</strong><small>汇总全部批次的新增、更新、冲突和身份缺失。</small></span>{result ? <Tag color={applied ? 'green' : issues.length ? 'orange' : 'blue'} type="light" size="small">{applied ? '正式结果' : interrupted ? '阶段结果' : '完整预演'}</Tag> : null}</header>
|
||||
{runError ? <div className="v2-profile-sync-error" role="alert"><IconAlertTriangle /><span><strong>{interrupted ? '批量写入已中断' : '批量预演未完成'}</strong><small>{runError}</small></span></div> : null}
|
||||
{result ? <div className="v2-profile-sync-result">
|
||||
<div role="list" aria-label="主档同步影响统计">
|
||||
<span role="listitem">已处理<strong>{result.received.toLocaleString('zh-CN')}</strong><small>CSV 记录</small></span>
|
||||
<span role="listitem">新增<strong>{result.created.toLocaleString('zh-CN')}</strong><small>新建主档</small></span>
|
||||
<span role="listitem">更新<strong>{result.updated.toLocaleString('zh-CN')}</strong><small>字段变化</small></span>
|
||||
<span role="listitem">未变化<strong>{result.unchanged.toLocaleString('zh-CN')}</strong><small>无需写入</small></span>
|
||||
<span role="listitem" className={result.conflicted ? 'is-warning' : ''}>冲突<strong>{result.conflicted.toLocaleString('zh-CN')}</strong><small>来源受保护</small></span>
|
||||
<span role="listitem" className={result.missing ? 'is-danger' : ''}>身份缺失<strong>{result.missing.toLocaleString('zh-CN')}</strong><small>网关无车辆</small></span>
|
||||
</div>
|
||||
{issues.length ? <section className="v2-profile-sync-issues"><header><span><strong>需要复核</strong><small>{issues.length.toLocaleString('zh-CN')} 条记录{issues.length > VISIBLE_ISSUE_LIMIT ? ` · 页面显示前 ${VISIBLE_ISSUE_LIMIT} 条` : ''}</small></span><Button theme="borderless" type="tertiary" size="small" icon={<IconDownload />} aria-label="下载全部需复核记录 CSV" onClick={downloadIssues}>下载全部</Button></header><ul>{visibleIssues.map((item) => <li key={item.vin}><b>{item.vin}</b><span>{item.status === 'missing_vehicle' ? '网关身份不存在' : item.status === 'conflict_source_version' ? '同来源版本内容不一致' : `现有来源 ${item.previousSource || '未知'} 已保护`}</span></li>)}</ul></section> : <div className="v2-profile-sync-success" role="status"><IconTickCircle /><span><strong>{applied ? '全部批次写入完成' : interrupted ? '已完成批次没有阻断项' : '全部批次预演通过'}</strong><small>{applied ? '车辆目录已触发刷新,可安全关闭任务。' : interrupted ? '可继续处理剩余批次。' : '确认来源、批次数和记录数后即可写入。'}</small></span></div>}
|
||||
</div> : <PanelEmpty compact tone="primary" icon={<IconBox />} title="等待分批预演" description="完成来源配置并选择 CSV 后,系统会按每批 500 辆预演全部记录。" />}
|
||||
</section>
|
||||
</div>
|
||||
</WorkspaceSideSheet>
|
||||
<WorkspaceConfirmDialog
|
||||
visible={clearConfirmOpen}
|
||||
ariaLabel="确认清空主档同步任务"
|
||||
title="清空当前可恢复任务?"
|
||||
description="来源、CSV 内容、预演结果和续办进度都会从当前浏览器移除。"
|
||||
summaryItems={[
|
||||
{ label: 'CSV 车辆', value: items.length ? `${items.length.toLocaleString('zh-CN')} 辆` : '未读取', detail: fileName || '尚未选择文件', tone: items.length ? 'warning' : 'neutral' },
|
||||
{ label: '已完成批次', value: `${completedChunks} / ${totalChunks}`, detail: interrupted ? '剩余批次尚未写入' : applied ? '已正式写入' : '服务器数据未改变', tone: applied ? 'success' : 'warning' },
|
||||
{ label: '关闭面板', value: '不会清空', detail: '仅此按钮会删除恢复信息', tone: 'success' }
|
||||
]}
|
||||
confirmLabel="清空任务"
|
||||
cancelLabel="保留任务"
|
||||
note="清空后无法恢复当前文件内容与批次位置。"
|
||||
onConfirm={clearTask}
|
||||
onCancel={() => setClearConfirmOpen(false)}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { IconArrowRight, IconBox, IconChevronRight, IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { Button, Card, Input, Table, Tag } from '@douyinfe/semi-ui';
|
||||
import { type FormEvent, lazy, Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button, Card, Input, Select, Table, Tag } from '@douyinfe/semi-ui';
|
||||
import { type ComponentType, type FormEvent, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../../api/client';
|
||||
import type { Page, VehicleCoverageRow } from '../../api/types';
|
||||
import type { Page, VehicleBusinessFilterOption, VehicleCoverageRow } from '../../api/types';
|
||||
import { usePlatformSession } from '../auth/AuthGate';
|
||||
import { canAdminister } from '../auth/session';
|
||||
import { formatZhNumber } from '../domain/formatters';
|
||||
@@ -13,6 +13,7 @@ import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../q
|
||||
import { PanelEmpty, PanelLoading } from '../shared/AsyncState';
|
||||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||||
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
|
||||
import { MonitorReturnBar } from '../shared/MonitorReturnBar';
|
||||
import { ProtocolTag } from '../shared/ProtocolTag';
|
||||
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||||
import { TablePagination } from '../shared/TablePagination';
|
||||
@@ -21,10 +22,11 @@ import { mergeVehicleCandidates } from '../shared/vehicleCandidates';
|
||||
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
|
||||
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
|
||||
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
|
||||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||||
import { monitorReturnFromParams } from '../routing/monitorContext';
|
||||
import { buildVehicleDirectoryPath, parseVehicleDirectoryContext, type VehicleDirectoryView, withVehicleDirectoryReturn } from '../routing/vehicleContext';
|
||||
|
||||
const VehicleProfileSyncPanel = lazy(() => import('./VehicleProfileSyncPanel'));
|
||||
|
||||
type VehicleDirectoryView = 'all' | 'online' | 'offline' | 'multi';
|
||||
type VehicleProfileSyncPanelComponent = ComponentType<{ onClose: () => void; onApplied?: () => void }>;
|
||||
|
||||
const vehicleDirectoryViews = [
|
||||
{ key: 'all', label: '全部车辆' },
|
||||
@@ -33,11 +35,29 @@ const vehicleDirectoryViews = [
|
||||
{ key: 'multi', label: '多源车辆' }
|
||||
] satisfies { key: VehicleDirectoryView; label: string }[];
|
||||
|
||||
const desktopQuickPickLimit = 6;
|
||||
|
||||
function applyDirectoryView(params: URLSearchParams, view: VehicleDirectoryView) {
|
||||
if (view === 'online' || view === 'offline') params.set('online', view);
|
||||
if (view === 'multi') params.set('coverage', 'multi');
|
||||
}
|
||||
|
||||
function applyBusinessFilters(params: URLSearchParams, filters: {
|
||||
departmentIds: string[];
|
||||
responsibleUserIds: string[];
|
||||
customerIds: string[];
|
||||
operationStatuses: string[];
|
||||
}) {
|
||||
if (filters.departmentIds.length) params.set('departmentIds', filters.departmentIds.join(','));
|
||||
if (filters.responsibleUserIds.length) params.set('responsibleUserIds', filters.responsibleUserIds.join(','));
|
||||
if (filters.customerIds.length) params.set('customerIds', filters.customerIds.join(','));
|
||||
if (filters.operationStatuses.length) params.set('operationStatuses', filters.operationStatuses.join(','));
|
||||
}
|
||||
|
||||
function selectOptions(items: VehicleBusinessFilterOption[] = []) {
|
||||
return items.map((item) => ({ value: item.value, label: `${item.label}(${item.count})` }));
|
||||
}
|
||||
|
||||
function resetWorkspaceScroll() {
|
||||
const targets = [
|
||||
document.scrollingElement,
|
||||
@@ -63,27 +83,51 @@ function vehicleLastSeenLabel(value?: string) {
|
||||
|
||||
export default function VehicleSearchWorkspace() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { session } = usePlatformSession();
|
||||
const mobileLayout = useMobileLayout();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [draftKeyword, setDraftKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const routeContext = useMemo(() => parseVehicleDirectoryContext(searchParams), [searchParams]);
|
||||
const { search: keyword, view: directoryView, page, departmentIds, responsibleUserIds, customerIds, operationStatuses } = routeContext;
|
||||
const [draftKeyword, setDraftKeyword] = useState(routeContext.search);
|
||||
const [draftBusinessFilters, setDraftBusinessFilters] = useState({
|
||||
departmentIds, responsibleUserIds, customerIds, operationStatuses
|
||||
});
|
||||
const [syncOpen, setSyncOpen] = useState(false);
|
||||
const [ProfileSyncPanel, setProfileSyncPanel] = useState<VehicleProfileSyncPanelComponent>();
|
||||
const [syncLoadError, setSyncLoadError] = useState<string>();
|
||||
const [candidatesOpen, setCandidatesOpen] = useState(false);
|
||||
const [filtersCollapsed, setFiltersCollapsed] = useState(mobileLayout);
|
||||
const [directoryView, setDirectoryView] = useState<VehicleDirectoryView>('all');
|
||||
const [mobileSearchIntent, setMobileSearchIntent] = useState(false);
|
||||
const closeTimerRef = useRef<number>();
|
||||
const directoryHeadingRef = useRef<HTMLHeadingElement>(null);
|
||||
const setDirectoryRoute = useCallback((patch: Partial<typeof routeContext>) => {
|
||||
const current = parseVehicleDirectoryContext(searchParams);
|
||||
const target = new URL(buildVehicleDirectoryPath({ ...current, ...patch }, monitorReturnFromParams(searchParams)), 'https://vehicle-platform.invalid');
|
||||
setSearchParams(target.searchParams, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
const setKeyword = useCallback((value: string) => setDirectoryRoute({ search: value, page: 1 }), [setDirectoryRoute]);
|
||||
const setPage = useCallback((value: number) => setDirectoryRoute({ page: value }), [setDirectoryRoute]);
|
||||
const setDirectoryView = useCallback((value: VehicleDirectoryView) => setDirectoryRoute({ view: value, page: 1 }), [setDirectoryRoute]);
|
||||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||||
const deferredDraftKeyword = useDeferredValue(draftKeyword.trim());
|
||||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||||
const pageSize = mobileLayout ? 8 : 10;
|
||||
const directoryScope = useMemo(() => queryScopeKey({ keyword: deferredKeyword, view: directoryView }), [deferredKeyword, directoryView]);
|
||||
const businessFilters = useQuery({
|
||||
queryKey: ['vehicle-business-filters'],
|
||||
queryFn: ({ signal }) => api.vehicleBusinessFilters(signal),
|
||||
staleTime: 60_000,
|
||||
gcTime: QUERY_MEMORY.optionGcTime
|
||||
});
|
||||
const directoryScope = useMemo(() => queryScopeKey({
|
||||
keyword: deferredKeyword, view: directoryView, departmentIds, responsibleUserIds, customerIds, operationStatuses
|
||||
}), [customerIds, deferredKeyword, departmentIds, directoryView, operationStatuses, responsibleUserIds]);
|
||||
const candidateParams = useMemo(() => {
|
||||
const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) });
|
||||
if (deferredKeyword) params.set('keyword', deferredKeyword);
|
||||
applyDirectoryView(params, directoryView);
|
||||
applyBusinessFilters(params, { departmentIds, responsibleUserIds, customerIds, operationStatuses });
|
||||
return params;
|
||||
}, [deferredKeyword, directoryView, page, pageSize]);
|
||||
}, [customerIds, deferredKeyword, departmentIds, directoryView, operationStatuses, page, pageSize, responsibleUserIds]);
|
||||
const candidates = useQuery<Page<VehicleCoverageRow>>({
|
||||
queryKey: ['vehicle-directory', directoryScope, pageSize, page],
|
||||
queryFn: ({ signal }) => api.vehicleCoverage(candidateParams, signal),
|
||||
@@ -92,31 +136,71 @@ export default function VehicleSearchWorkspace() {
|
||||
gcTime: QUERY_MEMORY.optionGcTime
|
||||
});
|
||||
const mobileCandidateParams = useMemo(() => {
|
||||
const params = new URLSearchParams({ limit: '6', offset: '0' });
|
||||
const params = new URLSearchParams({ limit: '4', offset: '0' });
|
||||
if (deferredDraftKeyword) params.set('keyword', deferredDraftKeyword);
|
||||
applyDirectoryView(params, directoryView);
|
||||
applyBusinessFilters(params, draftBusinessFilters);
|
||||
return params;
|
||||
}, [deferredDraftKeyword, directoryView]);
|
||||
}, [deferredDraftKeyword, directoryView, draftBusinessFilters]);
|
||||
const mobileCandidates = useQuery<Page<VehicleCoverageRow>>({
|
||||
queryKey: ['vehicle-mobile-candidates', queryScopeKey({ keyword: deferredDraftKeyword, view: directoryView })],
|
||||
queryKey: ['vehicle-mobile-candidates', queryScopeKey({ keyword: deferredDraftKeyword, view: directoryView, ...draftBusinessFilters })],
|
||||
queryFn: ({ signal }) => api.vehicleCoverage(mobileCandidateParams, signal),
|
||||
enabled: mobileFiltersOpen && Boolean(deferredDraftKeyword),
|
||||
enabled: mobileFiltersOpen,
|
||||
staleTime: 30_000,
|
||||
gcTime: QUERY_MEMORY.optionGcTime
|
||||
});
|
||||
const options = useMemo(() => mergeVehicleCandidates(candidates.data?.items ?? []), [candidates.data?.items]);
|
||||
const mobileOptions = useMemo(() => mergeVehicleCandidates(mobileCandidates.data?.items ?? []).slice(0, 6), [mobileCandidates.data?.items]);
|
||||
const mobileOptions = useMemo(() => mergeVehicleCandidates(mobileCandidates.data?.items ?? []).slice(0, 4), [mobileCandidates.data?.items]);
|
||||
const pageVehicles = useMemo(() => options.slice(0, pageSize), [options, pageSize]);
|
||||
const total = candidates.data?.total ?? 0;
|
||||
const exactCandidate = useMemo(() => {
|
||||
const normalizedKeyword = keyword.trim().toLocaleUpperCase('zh-CN');
|
||||
if (!normalizedKeyword || candidates.isFetching || candidates.isError || total !== 1 || options.length !== 1) return undefined;
|
||||
const candidate = options[0];
|
||||
const identities = [candidate.plate, candidate.vin, candidate.phone]
|
||||
.map((value) => value?.trim().toLocaleUpperCase('zh-CN'))
|
||||
.filter(Boolean);
|
||||
return identities.includes(normalizedKeyword) ? candidate : undefined;
|
||||
}, [candidates.isError, candidates.isFetching, keyword, options, total]);
|
||||
const desktopQuickPickAvailable = candidates.isFetching || candidates.isError || total <= desktopQuickPickLimit;
|
||||
const desktopCandidatesVisible = !mobileLayout && candidatesOpen && Boolean(deferredKeyword) && !exactCandidate && desktopQuickPickAvailable;
|
||||
const desktopBroadResultVisible = !mobileLayout
|
||||
&& candidatesOpen
|
||||
&& Boolean(deferredKeyword)
|
||||
&& !candidates.isFetching
|
||||
&& !candidates.isError
|
||||
&& !exactCandidate
|
||||
&& total > desktopQuickPickLimit;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
useEffect(() => {
|
||||
if (!candidates.data || page <= totalPages) return;
|
||||
setPage(totalPages);
|
||||
}, [candidates.data, page, setPage, totalPages]);
|
||||
const onlineOnPage = useMemo(() => pageVehicles.filter((vehicle) => vehicle.online).length, [pageVehicles]);
|
||||
const multiSourceOnPage = useMemo(() => pageVehicles.filter((vehicle) => vehicle.protocols.length > 1).length, [pageVehicles]);
|
||||
const administrator = canAdminister(session);
|
||||
const openProfileSync = async () => {
|
||||
setSyncOpen(true);
|
||||
setSyncLoadError(undefined);
|
||||
if (ProfileSyncPanel) return;
|
||||
try {
|
||||
const module = await import('./VehicleProfileSyncPanel');
|
||||
setProfileSyncPanel(() => module.default);
|
||||
} catch (error) {
|
||||
setSyncLoadError(error instanceof Error ? error.message : '同步工具加载失败');
|
||||
}
|
||||
};
|
||||
useEffect(() => () => window.clearTimeout(closeTimerRef.current), []);
|
||||
useEffect(() => {
|
||||
setFiltersCollapsed(mobileLayout);
|
||||
setCandidatesOpen(false);
|
||||
}, [mobileLayout]);
|
||||
useEffect(() => {
|
||||
if (!mobileFiltersOpen) {
|
||||
setDraftKeyword(keyword);
|
||||
setDraftBusinessFilters({ departmentIds, responsibleUserIds, customerIds, operationStatuses });
|
||||
}
|
||||
}, [customerIds, departmentIds, keyword, mobileFiltersOpen, operationStatuses, responsibleUserIds]);
|
||||
const openCandidates = () => {
|
||||
window.clearTimeout(closeTimerRef.current);
|
||||
if (!mobileLayout) setCandidatesOpen(Boolean(keyword.trim()));
|
||||
@@ -131,35 +215,46 @@ export default function VehicleSearchWorkspace() {
|
||||
setCandidatesOpen(false);
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||
resetWorkspaceScroll();
|
||||
navigate(`/vehicles/${encodeURIComponent(normalized)}`);
|
||||
const directoryReturn = buildVehicleDirectoryPath(routeContext, monitorReturnFromParams(searchParams));
|
||||
navigate(withVehicleDirectoryReturn(`/vehicles/${encodeURIComponent(normalized)}`, directoryReturn));
|
||||
window.queueMicrotask(resetWorkspaceScroll);
|
||||
window.requestAnimationFrame(resetWorkspaceScroll);
|
||||
window.setTimeout(resetWorkspaceScroll, 120);
|
||||
};
|
||||
const openMobileFilters = () => {
|
||||
const openMobileFilters = (focusSearch = false) => {
|
||||
setDraftKeyword(keyword);
|
||||
setDraftBusinessFilters({ departmentIds, responsibleUserIds, customerIds, operationStatuses });
|
||||
setCandidatesOpen(false);
|
||||
setMobileSearchIntent(focusSearch);
|
||||
setFiltersCollapsed(false);
|
||||
};
|
||||
const closeMobileFilters = () => {
|
||||
setDraftKeyword(keyword);
|
||||
setDraftBusinessFilters({ departmentIds, responsibleUserIds, customerIds, operationStatuses });
|
||||
setCandidatesOpen(false);
|
||||
setMobileSearchIntent(false);
|
||||
setFiltersCollapsed(true);
|
||||
};
|
||||
const applyMobileFilters = () => {
|
||||
setKeyword(draftKeyword.trim());
|
||||
setPage(1);
|
||||
setDirectoryRoute({ search: draftKeyword.trim(), ...draftBusinessFilters, page: 1 });
|
||||
setCandidatesOpen(false);
|
||||
setMobileSearchIntent(false);
|
||||
setFiltersCollapsed(true);
|
||||
};
|
||||
const resetMobileDraft = () => {
|
||||
setDraftKeyword('');
|
||||
setDraftBusinessFilters({ departmentIds: [], responsibleUserIds: [], customerIds: [], operationStatuses: [] });
|
||||
setCandidatesOpen(false);
|
||||
};
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setCandidatesOpen(false);
|
||||
if (exactCandidate) {
|
||||
openVehicle(exactCandidate.vin);
|
||||
return;
|
||||
}
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||
window.requestAnimationFrame(() => directoryHeadingRef.current?.focus({ preventScroll: true }));
|
||||
};
|
||||
const submitMobileFilters = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -167,15 +262,20 @@ export default function VehicleSearchWorkspace() {
|
||||
};
|
||||
const selectDirectoryView = (nextView: VehicleDirectoryView) => {
|
||||
setDirectoryView(nextView);
|
||||
setPage(1);
|
||||
setCandidatesOpen(false);
|
||||
};
|
||||
const directoryViewLabel = vehicleDirectoryViews.find((item) => item.key === directoryView)?.label ?? '全部车辆';
|
||||
const activeBusinessFilterCount = departmentIds.length + responsibleUserIds.length + customerIds.length + operationStatuses.length;
|
||||
const desktopSearchAction = keyword.trim()
|
||||
? candidates.isFetching ? '查询中' : exactCandidate ? '打开档案' : desktopBroadResultVisible ? '查看列表' : '查看结果'
|
||||
: '查询车辆';
|
||||
const desktopCandidateMeta = candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆匹配` : '授权范围';
|
||||
const vehicleScope = candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权` : '授权范围';
|
||||
const businessFilterSuffix = activeBusinessFilterCount ? ` · ${activeBusinessFilterCount} 项业务筛选` : '';
|
||||
const mobileFilterSummary = deferredKeyword
|
||||
? `${deferredKeyword}${directoryView === 'all' ? '' : ` · ${directoryViewLabel}`} · ${candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆匹配` : '正在查找'}`
|
||||
? `${deferredKeyword}${directoryView === 'all' ? '' : ` · ${directoryViewLabel}`}${businessFilterSuffix} · ${candidates.data ? `${candidates.data.total.toLocaleString('zh-CN')} 辆匹配` : '正在查找'}`
|
||||
: candidates.data
|
||||
? directoryView === 'all' ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆` : `${directoryViewLabel} · ${candidates.data.total.toLocaleString('zh-CN')} 辆`
|
||||
? directoryView === 'all' ? `${candidates.data.total.toLocaleString('zh-CN')} 辆授权车辆${businessFilterSuffix}` : `${directoryViewLabel}${businessFilterSuffix} · ${candidates.data.total.toLocaleString('zh-CN')} 辆`
|
||||
: '正在读取授权范围';
|
||||
const directoryMetrics: WorkspaceQueueMetricRailItem[] = [
|
||||
{
|
||||
@@ -210,46 +310,72 @@ export default function VehicleSearchWorkspace() {
|
||||
: directoryView === 'all'
|
||||
? '按照最新上报时间排列,分页浏览全部授权车辆'
|
||||
: `仅展示${directoryViewLabel},按最新上报时间排列`;
|
||||
return <section className={`v2-vehicle-search-page v2-vehicle-directory-v3${syncOpen ? ' has-sync-panel' : ''}${!mobileLayout && candidatesOpen ? ' has-candidates' : ''}`}>
|
||||
return <section className={`v2-vehicle-search-page v2-vehicle-directory-v3${desktopCandidatesVisible ? ' has-candidates' : ''}`}>
|
||||
<MonitorReturnBar />
|
||||
<div className="v2-vehicle-discovery-shell">
|
||||
{!mobileLayout || administrator ? <WorkspaceCommandBar
|
||||
className={`v2-vehicle-command-bar${mobileLayout ? ' is-mobile-admin' : ''}`}
|
||||
ariaLabel={mobileLayout ? '车辆主档操作' : '查车操作'}
|
||||
eyebrow={mobileLayout ? '车辆治理' : undefined}
|
||||
icon={mobileLayout ? <IconBox /> : <IconSearch />}
|
||||
title={mobileLayout ? '车辆主档' : '查找车辆'}
|
||||
description={mobileLayout ? '批量维护品牌、车型与运营属性' : '车牌、VIN 或手机号快速查车'}
|
||||
status={mobileLayout ? undefined : candidates.isPending ? '正在读取授权范围' : vehicleScope}
|
||||
{!mobileLayout ? <WorkspaceCommandBar
|
||||
className="v2-vehicle-command-bar"
|
||||
ariaLabel="查车操作"
|
||||
icon={<IconSearch />}
|
||||
title="查找车辆"
|
||||
description="车牌、VIN 或手机号快速查车"
|
||||
status={candidates.isPending ? '正在读取授权范围' : vehicleScope}
|
||||
statusColor={candidates.isError ? 'red' : 'blue'}
|
||||
actions={<div className="v2-vehicle-command-actions">
|
||||
{!mobileLayout ? <SegmentedTabs
|
||||
<SegmentedTabs
|
||||
className="v2-vehicle-directory-view-tabs"
|
||||
ariaLabel="车辆目录视图"
|
||||
value={directoryView}
|
||||
items={vehicleDirectoryViews}
|
||||
onChange={selectDirectoryView}
|
||||
variant="filled"
|
||||
/> : null}
|
||||
{administrator ? <Button theme="light" type="tertiary" icon={mobileLayout ? <IconBox /> : undefined} onClick={() => setSyncOpen((value) => !value)}>{syncOpen ? '收起同步' : mobileLayout ? '主档同步' : '批量同步主档'}</Button> : null}
|
||||
/>
|
||||
{administrator ? <Button theme="light" type="tertiary" aria-haspopup="dialog" aria-expanded={syncOpen} onClick={() => { void openProfileSync(); }}>{syncOpen ? '同步配置中' : '批量同步主档'}</Button> : null}
|
||||
</div>}
|
||||
/> : null}
|
||||
{mobileLayout ? <div className="v2-vehicle-mobile-discovery">
|
||||
<MobileFilterToggle
|
||||
title="查找车辆"
|
||||
summary={mobileFilterSummary}
|
||||
expanded={mobileFiltersOpen}
|
||||
controls="v2-vehicle-mobile-filter-dialog"
|
||||
expandedLabel="关闭"
|
||||
collapsedLabel="查找"
|
||||
ariaLabel={`${mobileFiltersOpen ? '关闭' : '打开'}车辆搜索:${mobileFilterSummary}`}
|
||||
onToggle={mobileFiltersOpen ? closeMobileFilters : openMobileFilters}
|
||||
/>
|
||||
<Button
|
||||
className="v2-vehicle-mobile-search-launcher"
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
icon={<IconSearch />}
|
||||
aria-expanded={mobileFiltersOpen}
|
||||
aria-controls="v2-vehicle-mobile-filter-dialog"
|
||||
aria-label={`搜索车辆:${mobileFilterSummary}`}
|
||||
onClick={mobileFiltersOpen ? closeMobileFilters : () => openMobileFilters(true)}
|
||||
>
|
||||
<span>车牌 / VIN / 终端手机号</span>
|
||||
<b>搜索</b>
|
||||
</Button>
|
||||
<div className="v2-vehicle-mobile-scope-row">
|
||||
<MobileFilterToggle
|
||||
title="车辆范围"
|
||||
summary={mobileFilterSummary}
|
||||
expanded={mobileFiltersOpen}
|
||||
controls="v2-vehicle-mobile-filter-dialog"
|
||||
expandedLabel="关闭"
|
||||
collapsedLabel="修改"
|
||||
ariaLabel={`${mobileFiltersOpen ? '关闭' : '打开'}车辆搜索:${mobileFilterSummary}`}
|
||||
onToggle={mobileFiltersOpen ? closeMobileFilters : () => openMobileFilters(false)}
|
||||
/>
|
||||
{administrator ? <Button
|
||||
className="v2-vehicle-mobile-sync"
|
||||
theme="light"
|
||||
type="tertiary"
|
||||
icon={<IconBox />}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={syncOpen}
|
||||
aria-label="打开批量主档同步"
|
||||
onClick={() => { void openProfileSync(); }}
|
||||
>批量同步</Button> : null}
|
||||
</div>
|
||||
<MobileFilterSheet
|
||||
className="v2-vehicle-mobile-filter-sidesheet"
|
||||
visible={mobileFiltersOpen}
|
||||
height="min(72dvh, 610px)"
|
||||
ariaLabel="车辆搜索"
|
||||
dialogId="v2-vehicle-mobile-filter-dialog"
|
||||
initialFocusId={mobileSearchIntent ? 'v2-vehicle-mobile-search-input' : undefined}
|
||||
closeLabel="关闭车辆搜索"
|
||||
title="查找车辆"
|
||||
description="车牌优先,支持 VIN 与终端手机号"
|
||||
@@ -262,13 +388,13 @@ export default function VehicleSearchWorkspace() {
|
||||
},
|
||||
{
|
||||
label: '当前条件',
|
||||
value: draftKeyword.trim() || '全部车辆',
|
||||
detail: draftKeyword.trim() ? '车牌 / VIN / 终端' : '浏览完整目录'
|
||||
value: draftKeyword.trim() || (Object.values(draftBusinessFilters).flat().length ? `${Object.values(draftBusinessFilters).flat().length} 项业务筛选` : '全部车辆'),
|
||||
detail: draftKeyword.trim() ? '车牌 / VIN / 终端' : '部门 / 负责人 / 客户 / 状态'
|
||||
},
|
||||
{
|
||||
label: '快速结果',
|
||||
value: deferredDraftKeyword ? mobileCandidates.isFetching ? '查找中' : `${mobileCandidates.data?.total ?? 0} 辆` : '待输入',
|
||||
detail: '最多展示 6 辆',
|
||||
detail: '最多展示 4 辆',
|
||||
tone: deferredDraftKeyword ? 'success' : 'neutral'
|
||||
}
|
||||
]}
|
||||
@@ -281,9 +407,11 @@ export default function VehicleSearchWorkspace() {
|
||||
<label>
|
||||
<span>车牌 / VIN / 终端手机号</span>
|
||||
<Input
|
||||
id="v2-vehicle-mobile-search-input"
|
||||
aria-label="搜索车辆"
|
||||
aria-controls="v2-vehicle-mobile-search-options"
|
||||
aria-expanded={Boolean(deferredDraftKeyword)}
|
||||
aria-autocomplete="list"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
value={draftKeyword}
|
||||
@@ -309,6 +437,14 @@ export default function VehicleSearchWorkspace() {
|
||||
onSelect={(vehicle) => openVehicle(vehicle.vin)}
|
||||
/> : <div className="v2-vehicle-mobile-filter-hint"><IconSearch /><span><strong>快速定位车辆</strong><small>输入完整或部分车牌即可查看候选。</small></span></div>}
|
||||
</MobileFilterSheetSection>
|
||||
<MobileFilterSheetSection ariaLabel="车辆业务筛选" title="业务关联" description="可多选;候选项已按当前账号的数据权限收敛。">
|
||||
<div className="v2-vehicle-business-filter-grid">
|
||||
<label><span>部门</span><Select multiple showClear value={draftBusinessFilters.departmentIds} onChange={(value) => setDraftBusinessFilters((current) => ({ ...current, departmentIds: Array.isArray(value) ? value.map(String) : [] }))} optionList={selectOptions(businessFilters.data?.departments)} placeholder="全部部门" /></label>
|
||||
<label><span>业务负责人</span><Select multiple showClear value={draftBusinessFilters.responsibleUserIds} onChange={(value) => setDraftBusinessFilters((current) => ({ ...current, responsibleUserIds: Array.isArray(value) ? value.map(String) : [] }))} optionList={selectOptions(businessFilters.data?.responsibleUsers)} placeholder="全部负责人" /></label>
|
||||
<label><span>客户</span><Select multiple showClear value={draftBusinessFilters.customerIds} onChange={(value) => setDraftBusinessFilters((current) => ({ ...current, customerIds: Array.isArray(value) ? value.map(String) : [] }))} optionList={selectOptions(businessFilters.data?.customers)} placeholder="全部客户" /></label>
|
||||
<label><span>状态</span><Select multiple showClear value={draftBusinessFilters.operationStatuses} onChange={(value) => setDraftBusinessFilters((current) => ({ ...current, operationStatuses: Array.isArray(value) ? value.map(String) : [] }))} optionList={selectOptions(businessFilters.data?.statuses)} placeholder="全部状态" /></label>
|
||||
</div>
|
||||
</MobileFilterSheetSection>
|
||||
</form>
|
||||
</MobileFilterSheet>
|
||||
</div> : <WorkspaceFilterPanel
|
||||
@@ -327,32 +463,49 @@ export default function VehicleSearchWorkspace() {
|
||||
<Input
|
||||
aria-label="搜索车辆"
|
||||
aria-controls="v2-vehicle-search-options"
|
||||
aria-expanded={candidatesOpen}
|
||||
aria-expanded={desktopCandidatesVisible}
|
||||
aria-autocomplete="list"
|
||||
aria-describedby={desktopBroadResultVisible ? 'v2-vehicle-search-result-status' : undefined}
|
||||
prefix={<IconSearch />}
|
||||
suffix={desktopBroadResultVisible ? <span id="v2-vehicle-search-result-status" className="v2-vehicle-search-result-status" role="status">{total.toLocaleString('zh-CN')} 辆匹配</span> : undefined}
|
||||
value={keyword}
|
||||
onChange={(value) => { setKeyword(value); setPage(1); setCandidatesOpen(Boolean(value.trim())); }}
|
||||
onChange={(value) => { setKeyword(value); setCandidatesOpen(Boolean(value.trim())); }}
|
||||
onFocus={openCandidates}
|
||||
onBlur={closeCandidates}
|
||||
placeholder="输入车牌 / VIN / 终端手机号"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{desktopCandidatesVisible ? <VehicleCandidateList
|
||||
id="v2-vehicle-search-options"
|
||||
className="v2-vehicle-search-options"
|
||||
items={options}
|
||||
loading={candidates.isFetching}
|
||||
loadingText="正在搜索授权车辆"
|
||||
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
|
||||
onRetry={() => candidates.refetch()}
|
||||
emptyText="没有匹配的授权车辆"
|
||||
header="快速结果"
|
||||
meta={desktopCandidateMeta}
|
||||
showProtocols
|
||||
layout="grid"
|
||||
actionLabel="打开"
|
||||
onSelect={(vehicle) => openVehicle(vehicle.vin)}
|
||||
/> : null}
|
||||
</div>
|
||||
<Button
|
||||
theme="solid"
|
||||
htmlType="submit"
|
||||
icon={<IconArrowRight />}
|
||||
iconPosition="right"
|
||||
loading={Boolean(keyword.trim()) && candidates.isFetching}
|
||||
disabled={!keyword.trim()}
|
||||
>{desktopSearchAction}</Button>
|
||||
<div className="v2-vehicle-business-filter-grid">
|
||||
<label><span>部门</span><Select multiple showClear value={departmentIds} onChange={(value) => setDirectoryRoute({ departmentIds: Array.isArray(value) ? value.map(String) : [], page: 1 })} optionList={selectOptions(businessFilters.data?.departments)} placeholder="全部部门" /></label>
|
||||
<label><span>业务负责人</span><Select multiple showClear value={responsibleUserIds} onChange={(value) => setDirectoryRoute({ responsibleUserIds: Array.isArray(value) ? value.map(String) : [], page: 1 })} optionList={selectOptions(businessFilters.data?.responsibleUsers)} placeholder="全部负责人" /></label>
|
||||
<label><span>客户</span><Select multiple showClear value={customerIds} onChange={(value) => setDirectoryRoute({ customerIds: Array.isArray(value) ? value.map(String) : [], page: 1 })} optionList={selectOptions(businessFilters.data?.customers)} placeholder="全部客户" /></label>
|
||||
<label><span>状态</span><Select multiple showClear value={operationStatuses} onChange={(value) => setDirectoryRoute({ operationStatuses: Array.isArray(value) ? value.map(String) : [], page: 1 })} optionList={selectOptions(businessFilters.data?.statuses)} placeholder="全部状态" /></label>
|
||||
</div>
|
||||
<Button theme="solid" htmlType="submit" icon={<IconArrowRight />} iconPosition="right">查询车辆</Button>
|
||||
{candidatesOpen && deferredKeyword ? <VehicleCandidateList
|
||||
id="v2-vehicle-search-options"
|
||||
className="v2-vehicle-search-options"
|
||||
items={options}
|
||||
loading={candidates.isFetching}
|
||||
loadingText="正在搜索授权车辆"
|
||||
error={candidates.isError ? (candidates.error instanceof Error ? candidates.error.message : '车辆候选加载失败') : undefined}
|
||||
onRetry={() => candidates.refetch()}
|
||||
emptyText="没有匹配的授权车辆"
|
||||
header="车辆候选"
|
||||
meta="车牌优先 · VIN 辅助"
|
||||
showProtocols
|
||||
layout={mobileLayout ? 'list' : 'grid'}
|
||||
onSelect={(vehicle) => openVehicle(vehicle.vin)}
|
||||
/> : null}
|
||||
</form>
|
||||
</WorkspaceFilterPanel>}
|
||||
{mobileLayout ? <SegmentedTabs
|
||||
@@ -371,7 +524,7 @@ export default function VehicleSearchWorkspace() {
|
||||
className="v2-vehicle-directory-metric-rail"
|
||||
items={directoryMetrics}
|
||||
context={<div className="v2-vehicle-directory-context">
|
||||
<span><h5>{directoryTitle}</h5><small>{directoryDescription}</small></span>
|
||||
<span><h5 ref={directoryHeadingRef} tabIndex={-1}>{directoryTitle}</h5><small>{directoryDescription}</small></span>
|
||||
<span className="v2-vehicle-directory-context-actions">
|
||||
<Tag color="blue" type="light" size="small">第 {page} / {totalPages} 页</Tag>
|
||||
<Button theme="borderless" type="tertiary" size="small" icon={<IconRefresh />} loading={candidates.isFetching} aria-label="刷新车辆目录" onClick={() => candidates.refetch()}>刷新</Button>
|
||||
@@ -464,11 +617,34 @@ export default function VehicleSearchWorkspace() {
|
||||
title={deferredKeyword ? '没有匹配车辆' : '暂无授权车辆'}
|
||||
description={deferredKeyword ? '当前范围没有结果,可清除搜索返回完整车辆目录。' : '管理员分配车辆权限后会显示在这里。'}
|
||||
action={deferredKeyword
|
||||
? <Button theme="light" type="primary" icon={<IconRefresh />} onClick={() => { setKeyword(''); setDraftKeyword(''); setPage(1); setCandidatesOpen(false); }}>清除搜索</Button>
|
||||
? <Button theme="light" type="primary" icon={<IconRefresh />} onClick={() => { setKeyword(''); setDraftKeyword(''); setCandidatesOpen(false); }}>清除搜索</Button>
|
||||
: <Button theme="light" type="primary" icon={<IconRefresh />} loading={candidates.isFetching} onClick={() => candidates.refetch()}>刷新车辆目录</Button>}
|
||||
/>}
|
||||
{total > 0 ? <footer className="v2-vehicle-directory-pagination"><TablePagination page={page} totalPages={totalPages} info={`共 ${total.toLocaleString('zh-CN')} 辆 · 本页 ${pageVehicles.length.toLocaleString('zh-CN')} 辆`} disabled={candidates.isFetching} onPageChange={(next) => { setPage(next); setCandidatesOpen(false); }} /></footer> : null}
|
||||
</Card>
|
||||
{syncOpen ? <Suspense fallback={<Card className="v2-profile-sync-panel v2-profile-sync-loading" bodyStyle={{ padding: 0 }}><PanelLoading compact title="正在加载主档同步工具" description="同步配置就绪后会自动显示。" /></Card>}><VehicleProfileSyncPanel onClose={() => setSyncOpen(false)} /></Suspense> : null}
|
||||
{syncOpen ? ProfileSyncPanel ? <ProfileSyncPanel onClose={() => setSyncOpen(false)} onApplied={() => { void candidates.refetch(); }} /> : <WorkspaceSideSheet
|
||||
className="v2-profile-sync-sidesheet"
|
||||
variant="task"
|
||||
visible
|
||||
ariaLabel="车辆主档批量同步"
|
||||
closeLabel="关闭车辆主档批量同步"
|
||||
placement={mobileLayout ? 'bottom' : 'right'}
|
||||
width={mobileLayout ? undefined : 720}
|
||||
height={mobileLayout ? 'min(92dvh, 860px)' : undefined}
|
||||
title="批量同步车辆主档"
|
||||
description="正在准备来源配置与预演工具"
|
||||
icon={<IconBox />}
|
||||
badge="加载中"
|
||||
badgeColor="grey"
|
||||
onCancel={() => setSyncOpen(false)}
|
||||
>{syncLoadError
|
||||
? <PanelEmpty
|
||||
tone="danger"
|
||||
icon={<IconBox />}
|
||||
title="同步工具加载失败"
|
||||
description={syncLoadError}
|
||||
action={<Button theme="solid" type="primary" onClick={() => { void openProfileSync(); }}>重新加载</Button>}
|
||||
/>
|
||||
: <PanelLoading compact title="正在加载主档同步工具" description="配置就绪后会自动显示。" />}</WorkspaceSideSheet> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -7,14 +7,19 @@ import mainSource from '../main.tsx?raw';
|
||||
|
||||
const v2Styles = readFileSync(resolve(process.cwd(), 'src/v2/styles/v2.css'), 'utf8');
|
||||
const workspaceStyles = readFileSync(resolve(process.cwd(), 'src/v2/styles/workspace.css'), 'utf8');
|
||||
const foundationStyles = readFileSync(resolve(process.cwd(), 'src/v2/styles/foundation.css'), 'utf8');
|
||||
const experienceStyles = readFileSync(resolve(process.cwd(), 'src/v2/styles/experience.css'), 'utf8');
|
||||
const userStyles = readFileSync(resolve(process.cwd(), 'src/v2/styles/users.css'), 'utf8');
|
||||
const corePageSources = Object.fromEntries(['MonitorPage', 'VehiclePage', 'TrackPage', 'HistoryPage', 'StatisticsPage', 'AlertsPage', 'AccessPage', 'UsersPage', 'OperationsPage'].map((name) => [
|
||||
name,
|
||||
readFileSync(resolve(process.cwd(), `src/v2/pages/${name}.tsx`), 'utf8')
|
||||
]));
|
||||
const vehicleSearchSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleSearchWorkspace.tsx'), 'utf8');
|
||||
const monitorVehicleDetailSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/MonitorVehicleDetailCard.tsx'), 'utf8');
|
||||
const batchVehicleSearchSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/BatchVehicleSearchDialog.tsx'), 'utf8');
|
||||
const reconciliationSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/ReconciliationCenter.tsx'), 'utf8');
|
||||
const profileSyncPanelSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleProfileSyncPanel.tsx'), 'utf8');
|
||||
const vehicleArchiveSource = readFileSync(resolve(process.cwd(), 'src/v2/pages/VehicleArchiveCard.tsx'), 'utf8');
|
||||
const sourceEvidenceSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/VehicleSourceEvidencePanel.tsx'), 'utf8');
|
||||
const mobileFilterToggleSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/MobileFilterToggle.tsx'), 'utf8');
|
||||
const mobileFilterSheetSource = readFileSync(resolve(process.cwd(), 'src/v2/shared/MobileFilterSheet.tsx'), 'utf8');
|
||||
@@ -44,8 +49,16 @@ describe('V2 production entry', () => {
|
||||
expect(mainSource).toContain("./v2/styles/semi-theme.scss");
|
||||
expect(mainSource).toContain("./v2/styles/v2.css");
|
||||
expect(mainSource).toContain("./v2/styles/workspace.css");
|
||||
expect(mainSource).toContain("./v2/styles/foundation.css");
|
||||
expect(mainSource).toContain("./v2/styles/experience.css");
|
||||
expect(mainSource).toContain("window.history.scrollRestoration = 'manual'");
|
||||
expect(workspaceStyles).toContain('.v2-navigation.semi-navigation .semi-navigation-item-icon-toggle-right:empty');
|
||||
expect(experienceStyles).toContain('.v2-navigation.semi-navigation .semi-navigation-item-icon-toggle-right:empty');
|
||||
expect(experienceStyles).toContain('display: none !important;');
|
||||
expect(experienceStyles).toContain('flex: 0 0 0;');
|
||||
expect(experienceStyles).toContain('grid-template-rows: 14px 44px;');
|
||||
expect(experienceStyles).toContain('grid-template-rows: 11px 28px 10px;');
|
||||
expect(experienceStyles).toContain('line-height: 36px;');
|
||||
expect(workspaceStyles).toContain('.v2-mileage-source-trigger.semi-button .semi-button-content-right > b');
|
||||
expect(workspaceStyles).toContain('.semi-table-row > .semi-table-row-cell:first-child::before');
|
||||
expect(workspaceStyles).not.toContain('.semi-table-row::before');
|
||||
@@ -78,8 +91,6 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources[name]).toContain('<WorkspaceCommandBar');
|
||||
}
|
||||
for (const [name, eyebrow, tone] of [
|
||||
['StatisticsPage', '每日统计', 'primary'],
|
||||
['AlertsPage', '实时队列', 'warning'],
|
||||
['AccessPage', '接入差异', 'primary'],
|
||||
['HistoryPage', '证据结果', 'primary'],
|
||||
['OperationsPage', '运行快照', 'health']
|
||||
@@ -87,6 +98,10 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources[name]).toContain(`eyebrow="${eyebrow}"`);
|
||||
expect(corePageSources[name]).toContain(`tone="${tone}"`);
|
||||
}
|
||||
expect(corePageSources.StatisticsPage).toContain("eyebrow={metricView === 'mileage' ? '每日里程' : '每日氢耗'}");
|
||||
expect(corePageSources.StatisticsPage).toContain("tone={metricView === 'mileage' ? 'primary' : 'health'}");
|
||||
expect(corePageSources.AlertsPage).toContain('aria-label="事件执行状态"');
|
||||
expect(corePageSources.AlertsPage).toContain('aria-label="事件中心工作区"');
|
||||
expect(reconciliationSource).toContain('eyebrow="自动对账"');
|
||||
expect(reconciliationSource).toContain('tone="health"');
|
||||
expect(workspaceStyles).toContain('.v2-workspace-panel-header.has-eyebrow');
|
||||
@@ -99,7 +114,8 @@ describe('V2 production entry', () => {
|
||||
expect(vehicleSearchSource).toContain('<WorkspaceMetricRail');
|
||||
expect(vehicleSearchSource).toContain('className="v2-vehicle-directory-metric-rail"');
|
||||
expect(vehicleSearchSource).not.toContain('className="v2-vehicle-directory-summary"');
|
||||
expect(corePageSources.VehiclePage).toContain('<Select');
|
||||
expect(corePageSources.VehiclePage).toContain("lazy(() => import('./VehicleArchiveCard'))");
|
||||
expect(vehicleArchiveSource).toContain('<Select');
|
||||
expect(corePageSources.VehiclePage).toContain('<Card className="v2-identity-band v2-record-card v2-live-card v2-live-overview v2-vehicle-command-card"');
|
||||
expect(corePageSources.VehiclePage).toContain("from '../shared/WorkspaceMetricRail'");
|
||||
expect(corePageSources.VehiclePage).toContain('<WorkspaceMetricRail');
|
||||
@@ -112,7 +128,7 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.VehiclePage).not.toContain('<section className="v2-single-map-card');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<section className="v2-identity-band"');
|
||||
expect(corePageSources.VehiclePage).toContain('<SegmentedTabs');
|
||||
expect(corePageSources.VehiclePage).toContain('<Descriptions className="v2-record-descriptions"');
|
||||
expect(vehicleArchiveSource).toContain('<Descriptions className="v2-record-descriptions"');
|
||||
expect(corePageSources.VehiclePage).toContain('<PanelEmpty');
|
||||
expect(corePageSources.VehiclePage).toContain('<PanelLoading className="v2-telemetry-loading"');
|
||||
expect(corePageSources.VehiclePage).toContain('className="v2-vehicle-not-found-state"');
|
||||
@@ -120,18 +136,17 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.VehiclePage).not.toContain('<dl className="v2-record-list"');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<section className="v2-not-found"');
|
||||
expect(corePageSources.VehiclePage).toContain('v2-telemetry-protocols');
|
||||
expect(corePageSources.VehiclePage).toContain('<Table');
|
||||
expect(corePageSources.VehiclePage).toContain('className="v2-telemetry-table"');
|
||||
expect(corePageSources.VehiclePage).toContain('<List className="v2-telemetry-mobile-list"');
|
||||
expect(corePageSources.VehiclePage).toContain('<List.Item className="v2-telemetry-mobile-item"');
|
||||
expect(corePageSources.VehiclePage).toContain('className="v2-telemetry-mobile-action"');
|
||||
expect(corePageSources.VehiclePage).toContain('className="v2-telemetry-metric-grid"');
|
||||
expect(corePageSources.VehiclePage).toContain('className={`v2-telemetry-metric-card');
|
||||
expect(corePageSources.VehiclePage).toContain('role="listitem"');
|
||||
expect(corePageSources.VehiclePage).not.toContain('className="v2-telemetry-table"');
|
||||
expect(corePageSources.VehiclePage).not.toContain('className="v2-telemetry-mobile-list"');
|
||||
expect(corePageSources.VehiclePage).toContain("placement={mobileLayout ? 'bottom' : 'right'}");
|
||||
expect(corePageSources.VehiclePage).toContain("from '../shared/WorkspaceSideSheet'");
|
||||
expect(corePageSources.VehiclePage).toContain('<WorkspaceSideSheet');
|
||||
expect(corePageSources.VehiclePage).toContain('className="v2-telemetry-evidence-sidesheet"');
|
||||
expect(corePageSources.VehiclePage).not.toContain('useSideSheetA11y(!!inspectedValue');
|
||||
expect(corePageSources.VehiclePage).toContain("item?.sourceEndpoint ?? ''");
|
||||
expect(corePageSources.VehiclePage).toContain('const columns = useMemo(() => [');
|
||||
expect(corePageSources.VehiclePage).toContain('<List');
|
||||
expect(corePageSources.VehiclePage).toContain('className="v2-event-list"');
|
||||
expect(corePageSources.VehiclePage).toContain('useMobileLayout');
|
||||
@@ -139,15 +154,18 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.VehiclePage).not.toContain('<nav className="v2-telemetry-protocols"');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<input');
|
||||
expect(corePageSources.VehiclePage).not.toContain('<button');
|
||||
expect(vehicleSearchSource).toContain("lazy(() => import('./VehicleProfileSyncPanel'))");
|
||||
expect(vehicleSearchSource).toContain("await import('./VehicleProfileSyncPanel')");
|
||||
expect(vehicleSearchSource).toContain('<PanelLoading className="v2-vehicle-directory-loading"');
|
||||
expect(vehicleSearchSource).toContain('<Card className="v2-profile-sync-panel v2-profile-sync-loading"');
|
||||
expect(vehicleSearchSource).toContain('className="v2-profile-sync-sidesheet"');
|
||||
expect(vehicleSearchSource).toContain('variant="task"');
|
||||
expect(vehicleSearchSource).toContain('<PanelLoading compact title="正在加载主档同步工具"');
|
||||
expect(profileSyncPanelSource).toContain('<Upload');
|
||||
expect(profileSyncPanelSource).toContain('<Card className="v2-profile-sync-panel"');
|
||||
expect(profileSyncPanelSource).toContain('<WorkspaceSideSheet');
|
||||
expect(profileSyncPanelSource).toContain('ariaLabel="车辆主档批量同步"');
|
||||
expect(profileSyncPanelSource).toContain('<WorkspaceConfirmDialog');
|
||||
expect(profileSyncPanelSource).not.toContain('<input');
|
||||
expect(profileSyncPanelSource).not.toContain('<section className="v2-profile-sync-panel"');
|
||||
expect(vehicleSearchSource).toContain('v2-profile-sync-panel');
|
||||
expect(vehicleSearchSource).toContain('v2-profile-sync-sidesheet');
|
||||
expect(corePageSources.MonitorPage).toContain("lazy(() => import('./MonitorVehicleDetailCard'))");
|
||||
expect(monitorVehicleDetailSource).toContain('<Card className="v2-vehicle-detail"');
|
||||
expect(monitorVehicleDetailSource).toContain("from '../shared/WorkspaceMetricRail'");
|
||||
@@ -232,13 +250,13 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.AlertsPage).not.toContain('<Radio');
|
||||
expect(corePageSources.AlertsPage).toContain('is-severity-${event.severity}');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card key={event.id} className={`v2-alert-mobile-card');
|
||||
expect(corePageSources.AlertsPage).toContain('<WorkspaceMetricRail');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-metric-rail"');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-event-status-tabs"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('className="v2-alert-metric-rail"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('v2-alert-kpis-card v2-alert-context-card');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className="v2-alert-table-card"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className={`v2-alert-inspector');
|
||||
expect(corePageSources.AlertsPage).toContain('<WorkspaceCommandBar');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-navigation v2-alert-command-bar"');
|
||||
expect(corePageSources.AlertsPage).toContain('<nav className="v2-alert-navigation"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<WorkspaceCommandBar');
|
||||
expect(corePageSources.AlertsPage).toContain('<WorkspaceSideSheet\n className="v2-alert-filter-sidesheet"');
|
||||
expect(corePageSources.AlertsPage).toContain('variant="filter"');
|
||||
expect(corePageSources.AlertsPage).toContain('<WorkspaceDetailSideSheet\n className="v2-alert-detail-sidesheet"');
|
||||
@@ -258,20 +276,23 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.AlertsPage).not.toContain('<input');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<button');
|
||||
expect(corePageSources.AlertsPage).toContain('<Descriptions className="v2-alert-descriptions"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Timeline className="v2-alert-timeline"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Timeline className="v2-alert-timeline v2-event-execution-trace"');
|
||||
expect(corePageSources.AlertsPage).toContain('<PanelEmpty className="v2-alert-inspector-empty"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className="v2-alert-detail-card v2-alert-focus-card"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Collapse className="v2-alert-technical-collapse"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<section><h3>事件信息</h3>');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<div className="v2-alert-timeline">');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className="v2-alert-rule-list"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className="v2-alert-rule-list v2-alert-automation-library"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className="v2-alert-rule-editor"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className="v2-alert-notifications"');
|
||||
expect(corePageSources.AlertsPage).toContain('<CardGroup className="v2-alert-notification-cards"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Card className={`v2-alert-notification-card');
|
||||
expect(corePageSources.AlertsPage).toContain('<Descriptions className="v2-alert-channel-descriptions"');
|
||||
expect(corePageSources.AlertsPage).toContain('aria-label="自动化工具栏"');
|
||||
expect(corePageSources.AlertsPage).toContain('aria-label="搜索自动化"');
|
||||
expect(corePageSources.AlertsPage).toContain('aria-label="自动化列表"');
|
||||
expect(corePageSources.AlertsPage).toContain('<AutomationFlow rule={selectedRule}');
|
||||
expect(corePageSources.AlertsPage).toContain('className={`v2-alert-delivery-workspace');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-delivery-table"');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-delivery-inspector"');
|
||||
expect(corePageSources.AlertsPage).toContain('<PanelEmpty className="v2-alert-notification-empty"');
|
||||
expect(corePageSources.AlertsPage).toContain('<PanelEmpty className="v2-alert-empty"');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-empty"');
|
||||
expect(corePageSources.AlertsPage).toContain('<PanelLoading className="v2-alert-loading"');
|
||||
expect(corePageSources.AlertsPage).toContain('<PanelLoading className="v2-alert-notification-loading"');
|
||||
expect(corePageSources.AlertsPage).not.toContain('<Spin');
|
||||
@@ -368,6 +389,14 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.UsersPage).not.toContain('<details');
|
||||
expect(corePageSources.UsersPage).not.toContain('<summary');
|
||||
expect(corePageSources.UsersPage).not.toContain('<button');
|
||||
expect(corePageSources.UsersPage).toContain("import '../styles/users.css'");
|
||||
expect(userStyles).toContain('--v2-user-row-columns: 40px');
|
||||
expect(userStyles).toContain('.v2-customer-list .v2-user-list-row.semi-list-item');
|
||||
expect(userStyles).toContain('.v2-user-list > .semi-card-body > .v2-user-directory-header {\n display: none;');
|
||||
expect(userStyles).toContain('.v2-customer-list-scroll::-webkit-scrollbar');
|
||||
expect(userStyles).toContain('grid-template-columns: repeat(2, minmax(0, 1fr));');
|
||||
expect(userStyles).toContain('.v2-user-editor-sidesheet .v2-workspace-config-summary > span:first-child');
|
||||
expect(userStyles).toContain('.v2-user-identity-overview {\n display: none;');
|
||||
expect(workspaceStyles).toContain('Short landscape account governance.');
|
||||
expect(workspaceStyles).toContain('.v2-user-discovery-shell {');
|
||||
expect(workspaceStyles).toContain('grid-template-columns: minmax(260px, 1.08fr) minmax(220px, .92fr);');
|
||||
@@ -458,7 +487,7 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.TrackPage).toContain('<List className="v2-track-evidence-list v2-track-event-list"');
|
||||
expect(corePageSources.TrackPage).toContain('<List.Item className="v2-track-evidence-item"');
|
||||
expect(corePageSources.TrackPage).toContain('<WorkspaceEmptyGuide');
|
||||
expect(corePageSources.TrackPage).toContain('title="先选择车辆,再开始轨迹回放"');
|
||||
expect(corePageSources.TrackPage).toContain("'先选择车辆,再开始轨迹回放'");
|
||||
expect(corePageSources.TrackPage).toContain("from '../shared/WorkspaceSideSheet'");
|
||||
expect(corePageSources.TrackPage).toContain('<WorkspaceSideSheet');
|
||||
expect(corePageSources.TrackPage).toContain('variant="editor"');
|
||||
@@ -502,6 +531,7 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.StatisticsPage).toContain('<Tag');
|
||||
expect(corePageSources.StatisticsPage).toContain('<WorkspaceSideSheet');
|
||||
expect(corePageSources.StatisticsPage).toContain('v2-mileage-source-sidesheet');
|
||||
expect(corePageSources.StatisticsPage).toContain('v2-mileage-source-popover');
|
||||
expect(corePageSources.StatisticsPage).toContain('className="v2-mileage-filter-sidesheet"');
|
||||
expect(corePageSources.StatisticsPage).toContain('<MobileFilterToggle');
|
||||
expect(corePageSources.StatisticsPage).toContain('<MobileFilterSheet');
|
||||
@@ -531,7 +561,6 @@ describe('V2 production entry', () => {
|
||||
expect(appShellSource).toContain('<WorkspaceDialog');
|
||||
expect(appShellSource).not.toContain('<Modal');
|
||||
expect(vehicleSearchSource).toContain('<MobileFilterSheet');
|
||||
expect(corePageSources.StatisticsPage).not.toContain('v2-mileage-source-popover');
|
||||
expect(corePageSources.StatisticsPage).toContain('<Table className="v2-mileage-table"');
|
||||
expect(v2Styles).toContain('.v2-mileage-table.semi-table-wrapper :is(.semi-table-row-head,.semi-table-row-cell).is-plate');
|
||||
expect(v2Styles).toContain('.v2-mileage-table.semi-table-wrapper :is(.semi-table-row-head,.semi-table-row-cell).is-total');
|
||||
@@ -551,11 +580,12 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.MonitorPage).toContain('<Input');
|
||||
expect(corePageSources.MonitorPage).toContain('<Select');
|
||||
expect(corePageSources.MonitorPage).toContain('<WorkspaceSideSheet');
|
||||
expect(corePageSources.MonitorPage).toContain('v2-monitor-batch-sidesheet');
|
||||
expect(corePageSources.MonitorPage).toContain("lazy(() => import('./BatchVehicleSearchDialog'))");
|
||||
expect(batchVehicleSearchSource).toContain('v2-monitor-batch-sidesheet');
|
||||
expect(corePageSources.MonitorPage).toContain('v2-monitor-qr-sidesheet');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<Modal');
|
||||
expect(corePageSources.MonitorPage).not.toContain('v2-monitor-qr-backdrop');
|
||||
expect(corePageSources.MonitorPage).toContain('<TextArea');
|
||||
expect(batchVehicleSearchSource).toContain('<TextArea');
|
||||
expect(corePageSources.MonitorPage).toContain('<SegmentedTabs');
|
||||
expect(corePageSources.MonitorPage).toContain('ariaLabel="监控视图"');
|
||||
expect(corePageSources.MonitorPage).toContain('variant="filled"');
|
||||
@@ -589,7 +619,7 @@ describe('V2 production entry', () => {
|
||||
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-filterbar"');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-kpis"');
|
||||
expect(corePageSources.MonitorPage).not.toContain('<section className="v2-monitor-table-panel"');
|
||||
expect(corePageSources.MonitorPage).toContain('<Card className="v2-monitor-mobile-card"');
|
||||
expect(corePageSources.MonitorPage).toContain('<Card className={`v2-monitor-mobile-card');
|
||||
expect(reconciliationSource).toContain("from '@douyinfe/semi-ui'");
|
||||
expect(reconciliationSource).toContain('<RadioGroup');
|
||||
expect(reconciliationSource).toContain('<Select');
|
||||
@@ -657,17 +687,17 @@ describe('V2 production entry', () => {
|
||||
}
|
||||
expect(corePageSources.AccessPage).toContain("from '../shared/WorkspaceMetricRail'");
|
||||
expect(corePageSources.AccessPage).toContain('<WorkspaceMetricRail');
|
||||
expect(corePageSources.AlertsPage).toContain("from '../shared/WorkspaceMetricRail'");
|
||||
expect(corePageSources.AlertsPage).toContain('<WorkspaceMetricRail');
|
||||
expect(corePageSources.AlertsPage).not.toContain("from '../shared/WorkspaceMetricRail'");
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-event-status-tabs"');
|
||||
expect(corePageSources.OperationsPage).toContain("from '../shared/WorkspaceMetricRail'");
|
||||
expect(corePageSources.OperationsPage).toContain('<WorkspaceMetricRail');
|
||||
expect(corePageSources.HistoryPage).toContain('className="v2-history-result-meta"');
|
||||
expect(corePageSources.AlertsPage).toContain('<Tag className={`v2-alert-severity');
|
||||
expect(corePageSources.AlertsPage).toContain('<Tag className={`v2-alert-status');
|
||||
expect(corePageSources.AlertsPage).toContain('function EventExecutionTag');
|
||||
expect(corePageSources.AlertsPage).toContain('className={`v2-event-execution');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-inspector-heading"');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-rule-list-heading"');
|
||||
expect(corePageSources.AlertsPage).toContain('className="v2-alert-rule-editor-heading"');
|
||||
expect(corePageSources.AlertsPage).toContain('variant="filled"');
|
||||
expect(corePageSources.AlertsPage).toContain('variant="line"');
|
||||
expect(corePageSources.AccessPage).toContain('<Tag className="v2-access-connection-tag"');
|
||||
expect(corePageSources.OperationsPage).toContain('<Tag className={`v2-ops-health-tag');
|
||||
expect(reconciliationSource).toContain('<Tag className={`v2-reconcile-severity');
|
||||
@@ -678,7 +708,7 @@ describe('V2 production entry', () => {
|
||||
expect(workspaceStyles).toContain('bottom: calc(264px + env(safe-area-inset-bottom));');
|
||||
expect(v2Styles).toContain('grid-template-rows: minmax(0, 1fr);');
|
||||
expect(appShellSource).toContain("section === 'tracks' ? ' is-track-workspace' : ''");
|
||||
expect(corePageSources.TrackPage).toContain("monitorReturn ? ' has-monitor-return' : ''");
|
||||
expect(corePageSources.TrackPage).toContain("monitorReturn || vehicleReturn ? ' has-monitor-return' : ''");
|
||||
expect(v2Styles).toContain('.v2-track-page.has-monitor-return { grid-template-rows: auto minmax(0, 1fr); }');
|
||||
expect(v2Styles).toContain('.v2-content.is-track-workspace { min-height: 0; overflow: hidden; }');
|
||||
expect(workspaceStyles).toContain('.v2-workspace-panel-header.is-inverted > .v2-workspace-panel-copy > h5.semi-typography');
|
||||
@@ -730,6 +760,63 @@ describe('V2 production entry', () => {
|
||||
expect(appShellSource).not.toContain('v2-mobile-more-sheet');
|
||||
});
|
||||
|
||||
test('keeps short-landscape password login readable and ordered', () => {
|
||||
const landscapeStart = experienceStyles.indexOf(
|
||||
'@media (max-width: 900px) and (max-height: 480px) and (orientation: landscape)'
|
||||
);
|
||||
expect(landscapeStart).toBeGreaterThanOrEqual(0);
|
||||
const landscapeStyles = experienceStyles.slice(landscapeStart);
|
||||
|
||||
expect(landscapeStyles).toContain('grid-template-columns: minmax(208px, .62fr) minmax(0, 1fr);');
|
||||
expect(landscapeStyles).toContain('grid-template-rows: 1fr auto auto auto auto 1fr;');
|
||||
expect(landscapeStyles).toContain(':root .v2-auth-fields > label {');
|
||||
expect(landscapeStyles).toContain('font-size: 11px;');
|
||||
expect(landscapeStyles).toContain(':root .v2-auth-fields > label .semi-input-wrapper {');
|
||||
expect(landscapeStyles).toContain('height: 44px;');
|
||||
expect(landscapeStyles).toContain(':root .v2-auth-form > .semi-button,');
|
||||
expect(landscapeStyles).toContain('grid-row: 4;');
|
||||
});
|
||||
|
||||
test('reserves the compact navigation rail across the intermediate shell breakpoint', () => {
|
||||
expect(foundationStyles).toContain('--v2-sidebar-width-compact: 72px;');
|
||||
expect(appShellSource).toContain('const compactLayout = useMobileLayout(1100);');
|
||||
expect(experienceStyles).toContain('@media (min-width: 681px) and (max-width: 1100px)');
|
||||
expect(experienceStyles).toContain(':root .v2-sidebar.is-collapsed + .v2-main');
|
||||
expect(experienceStyles).toContain('width: calc(100% - var(--v2-sidebar-width-compact));');
|
||||
expect(experienceStyles).toContain('margin-left: var(--v2-sidebar-width-compact);');
|
||||
});
|
||||
|
||||
test('keeps the mobile operations command surfaces and review metrics inside the viewport', () => {
|
||||
expect(experienceStyles).toContain('.v2-ops-page > .v2-ops-command-bar,');
|
||||
expect(experienceStyles).toContain('.v2-ops-page > .v2-ops-navigation {');
|
||||
expect(experienceStyles).toContain('width: auto;');
|
||||
expect(experienceStyles).toContain('.v2-reconcile-metric-rail.is-queue.has-context > .semi-card-body {');
|
||||
expect(experienceStyles).toContain('grid-template-columns: minmax(0, 1fr);');
|
||||
expect(experienceStyles).toContain('.v2-reconcile-metric-rail .v2-workspace-metric-list {');
|
||||
expect(experienceStyles).toContain('grid-template-columns: repeat(2, minmax(0, 1fr));');
|
||||
expect(experienceStyles).toContain('grid-template-rows: 14px 27px 14px;');
|
||||
});
|
||||
|
||||
test('keeps compact-shell actions and the short-landscape operations queue usable', () => {
|
||||
expect(experienceStyles).toContain(':root .v2-topbar-actions > .v2-help-trigger.semi-button,');
|
||||
expect(experienceStyles).toContain(':root .v2-help-trigger-label,');
|
||||
expect(experienceStyles).toContain(':root .v2-current-user .semi-button-content {');
|
||||
expect(experienceStyles).toContain('.v2-ops-workspace.is-reconciliation .v2-reconcile-heading {');
|
||||
expect(experienceStyles).toContain('.v2-ops-workspace.is-reconciliation .v2-reconcile-toolbar {');
|
||||
expect(experienceStyles).toContain('grid-template-columns: minmax(210px, 1.45fr) repeat(3, minmax(104px, .8fr));');
|
||||
expect(experienceStyles).toContain('.v2-ops-workspace.is-reconciliation .v2-reconcile-pagination {');
|
||||
expect(experienceStyles).toContain('height: 36px;');
|
||||
});
|
||||
|
||||
test('reserves the portrait access viewport for the evidence list', () => {
|
||||
expect(experienceStyles).toContain('Portrait access evidence viewport.');
|
||||
expect(experienceStyles).toContain('@media (max-width: 680px) and (orientation: portrait)');
|
||||
expect(experienceStyles).toContain('.v2-access-page-v3 {\n padding-bottom: 0;');
|
||||
expect(experienceStyles).toContain('.v2-access-mobile-discovery > .v2-mobile-filter-toggle.semi-button');
|
||||
expect(experienceStyles).toContain('.v2-access-table-v3 > .semi-card-body > .v2-workspace-panel-header {\n display: none;');
|
||||
expect(experienceStyles).toContain('.v2-access-metric-rail.is-queue.semi-card');
|
||||
});
|
||||
|
||||
test('applies rendering containment to off-screen vehicle items instead of the visible scroller', () => {
|
||||
const ruleBody = (selector: string) => {
|
||||
const start = v2Styles.indexOf(`${selector} {`);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { buildMonitorPath } from './monitorContext';
|
||||
import {
|
||||
buildVehicleDetailPath,
|
||||
buildVehicleDirectoryPath,
|
||||
parseVehicleDirectoryContext,
|
||||
validatedVehicleDirectoryReturn,
|
||||
validatedVehicleReturn,
|
||||
withVehicleDirectoryReturn,
|
||||
withVehicleReturn
|
||||
} from './vehicleContext';
|
||||
|
||||
describe('vehicle workflow route context', () => {
|
||||
test('normalizes a shareable vehicle directory position', () => {
|
||||
expect(parseVehicleDirectoryContext(new URLSearchParams('vehicleSearch=%E7%B2%A4A&vehicleView=offline&vehiclePage=3&vehicleDepartments=40001%2C40002&vehicleStatuses=%E8%BF%90%E8%90%A5%E4%B8%AD'))).toEqual({
|
||||
search: '粤A', view: 'offline', page: 3,
|
||||
focusVin: '',
|
||||
departmentIds: ['40001', '40002'], responsibleUserIds: [], customerIds: [], operationStatuses: ['运营中']
|
||||
});
|
||||
expect(parseVehicleDirectoryContext(new URLSearchParams('vehicleView=bad&vehiclePage=-1'))).toEqual({
|
||||
search: '', view: 'all', page: 1, focusVin: '', departmentIds: [], responsibleUserIds: [], customerIds: [], operationStatuses: []
|
||||
});
|
||||
expect(buildVehicleDirectoryPath({
|
||||
search: '粤A', view: 'offline', page: 3, focusVin: 'VIN-1', departmentIds: [], responsibleUserIds: [], customerIds: [], operationStatuses: []
|
||||
})).toBe('/vehicles?vehicleSearch=%E7%B2%A4A&vehicleView=offline&vehiclePage=3&vehicleFocus=VIN-1');
|
||||
});
|
||||
|
||||
test('keeps the monitor context nested behind the exact directory position', () => {
|
||||
const monitor = buildMonitorPath({
|
||||
mode: 'list', keyword: '粤A', protocol: 'JT808', status: 'online', selectedVin: 'VIN-1', detailOpen: false,
|
||||
viewport: { zoom: 13, bounds: '' }, listOffset: 100, listLimit: 50, hasViewport: true
|
||||
});
|
||||
const directory = buildVehicleDirectoryPath({
|
||||
search: '粤A', view: 'online', page: 4, focusVin: 'VIN-1', departmentIds: ['40001'], responsibleUserIds: [], customerIds: [], operationStatuses: []
|
||||
}, monitor);
|
||||
const detail = withVehicleDirectoryReturn('/vehicles/VIN-1', directory);
|
||||
const investigation = withVehicleReturn('/history?vin=VIN-1', buildVehicleDetailPath('VIN-1', { directoryReturn: directory }));
|
||||
|
||||
expect(validatedVehicleDirectoryReturn(directory)).toBe(directory);
|
||||
expect(detail).toContain(`vehicleDirectoryReturn=${encodeURIComponent(directory)}`);
|
||||
const vehicleReturn = validatedVehicleReturn(new URL(investigation, 'https://vehicle-platform.invalid').searchParams.get('vehicleReturn'));
|
||||
expect(vehicleReturn).toContain('/vehicles/VIN-1?');
|
||||
expect(new URL(vehicleReturn, 'https://vehicle-platform.invalid').searchParams.get('vehicleDirectoryReturn')).toBe(directory);
|
||||
});
|
||||
|
||||
test('rejects external and unrelated return targets', () => {
|
||||
expect(validatedVehicleDirectoryReturn('https://evil.example/vehicles?vehiclePage=2')).toBe('');
|
||||
expect(validatedVehicleDirectoryReturn('/history?vehiclePage=2')).toBe('');
|
||||
expect(validatedVehicleReturn('https://evil.example/vehicles/VIN-1')).toBe('');
|
||||
expect(validatedVehicleReturn('/vehicles')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { MONITOR_RETURN_PARAM, monitorReturnFromParams, validatedMonitorReturn } from './monitorContext';
|
||||
|
||||
export const VEHICLE_RETURN_PARAM = 'vehicleReturn';
|
||||
export const VEHICLE_DIRECTORY_RETURN_PARAM = 'vehicleDirectoryReturn';
|
||||
|
||||
export type VehicleDirectoryView = 'all' | 'online' | 'offline' | 'multi';
|
||||
|
||||
export type VehicleDirectoryRouteContext = {
|
||||
search: string;
|
||||
view: VehicleDirectoryView;
|
||||
page: number;
|
||||
focusVin: string;
|
||||
departmentIds: string[];
|
||||
responsibleUserIds: string[];
|
||||
customerIds: string[];
|
||||
operationStatuses: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_DIRECTORY_CONTEXT: VehicleDirectoryRouteContext = {
|
||||
search: '',
|
||||
view: 'all',
|
||||
page: 1,
|
||||
focusVin: '',
|
||||
departmentIds: [],
|
||||
responsibleUserIds: [],
|
||||
customerIds: [],
|
||||
operationStatuses: []
|
||||
};
|
||||
|
||||
function directoryView(value: string | null): VehicleDirectoryView {
|
||||
return value === 'online' || value === 'offline' || value === 'multi' ? value : 'all';
|
||||
}
|
||||
|
||||
function filterValues(value: string | null) {
|
||||
return Array.from(new Set((value || '').split(',').map((item) => item.trim()).filter(Boolean))).slice(0, 100);
|
||||
}
|
||||
|
||||
export function parseVehicleDirectoryContext(params: URLSearchParams): VehicleDirectoryRouteContext {
|
||||
const requestedPage = Number(params.get('vehiclePage'));
|
||||
return {
|
||||
search: (params.get('vehicleSearch') || '').slice(0, 4_000),
|
||||
view: directoryView(params.get('vehicleView')),
|
||||
page: Number.isInteger(requestedPage) && requestedPage > 0 && requestedPage <= 100_000 ? requestedPage : 1,
|
||||
focusVin: (params.get('vehicleFocus') || '').trim().slice(0, 64),
|
||||
departmentIds: filterValues(params.get('vehicleDepartments')),
|
||||
responsibleUserIds: filterValues(params.get('vehicleResponsibles')),
|
||||
customerIds: filterValues(params.get('vehicleCustomers')),
|
||||
operationStatuses: filterValues(params.get('vehicleStatuses'))
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVehicleDirectoryPath(context: VehicleDirectoryRouteContext, monitorReturn = '') {
|
||||
const params = new URLSearchParams();
|
||||
if (context.search.trim()) params.set('vehicleSearch', context.search.slice(0, 4_000));
|
||||
if (context.view !== 'all') params.set('vehicleView', context.view);
|
||||
if (context.page > 1) params.set('vehiclePage', String(Math.floor(context.page)));
|
||||
if (context.focusVin) params.set('vehicleFocus', context.focusVin);
|
||||
if (context.departmentIds.length) params.set('vehicleDepartments', context.departmentIds.join(','));
|
||||
if (context.responsibleUserIds.length) params.set('vehicleResponsibles', context.responsibleUserIds.join(','));
|
||||
if (context.customerIds.length) params.set('vehicleCustomers', context.customerIds.join(','));
|
||||
if (context.operationStatuses.length) params.set('vehicleStatuses', context.operationStatuses.join(','));
|
||||
const validatedMonitor = validatedMonitorReturn(monitorReturn);
|
||||
if (validatedMonitor) params.set(MONITOR_RETURN_PARAM, validatedMonitor);
|
||||
const query = params.toString();
|
||||
return query ? `/vehicles?${query}` : '/vehicles';
|
||||
}
|
||||
|
||||
export function validatedVehicleDirectoryReturn(value?: string | null) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const parsed = new URL(value, 'https://vehicle-platform.invalid');
|
||||
if (parsed.origin !== 'https://vehicle-platform.invalid' || parsed.pathname !== '/vehicles') return '';
|
||||
return buildVehicleDirectoryPath(parseVehicleDirectoryContext(parsed.searchParams), monitorReturnFromParams(parsed.searchParams));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function withVehicleDirectoryReturn(path: string, directoryPath: string) {
|
||||
const target = new URL(path, 'https://vehicle-platform.invalid');
|
||||
const validated = validatedVehicleDirectoryReturn(directoryPath);
|
||||
if (validated) target.searchParams.set(VEHICLE_DIRECTORY_RETURN_PARAM, validated);
|
||||
return `${target.pathname}${target.search}${target.hash}`;
|
||||
}
|
||||
|
||||
export function vehicleDirectoryReturnFromParams(params: URLSearchParams) {
|
||||
return validatedVehicleDirectoryReturn(params.get(VEHICLE_DIRECTORY_RETURN_PARAM));
|
||||
}
|
||||
|
||||
export function validatedVehicleReturn(value?: string | null) {
|
||||
if (!value) return '';
|
||||
try {
|
||||
const parsed = new URL(value, 'https://vehicle-platform.invalid');
|
||||
if (parsed.origin !== 'https://vehicle-platform.invalid' || !/^\/vehicles\/[^/]+$/.test(parsed.pathname)) return '';
|
||||
const vin = decodeURIComponent(parsed.pathname.slice('/vehicles/'.length)).trim();
|
||||
if (!vin) return '';
|
||||
const params = new URLSearchParams();
|
||||
const directoryReturn = vehicleDirectoryReturnFromParams(parsed.searchParams);
|
||||
const monitorReturn = monitorReturnFromParams(parsed.searchParams);
|
||||
if (directoryReturn) params.set(VEHICLE_DIRECTORY_RETURN_PARAM, directoryReturn);
|
||||
if (monitorReturn) params.set(MONITOR_RETURN_PARAM, monitorReturn);
|
||||
const query = params.toString();
|
||||
return `/vehicles/${encodeURIComponent(vin)}${query ? `?${query}` : ''}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function withVehicleReturn(path: string, vehiclePath: string) {
|
||||
const target = new URL(path, 'https://vehicle-platform.invalid');
|
||||
const validated = validatedVehicleReturn(vehiclePath);
|
||||
if (validated) target.searchParams.set(VEHICLE_RETURN_PARAM, validated);
|
||||
return `${target.pathname}${target.search}${target.hash}`;
|
||||
}
|
||||
|
||||
export function vehicleReturnFromParams(params: URLSearchParams) {
|
||||
return validatedVehicleReturn(params.get(VEHICLE_RETURN_PARAM));
|
||||
}
|
||||
|
||||
export function preserveVehicleReturn(params: URLSearchParams, vehicleReturn: string) {
|
||||
if (vehicleReturn) params.set(VEHICLE_RETURN_PARAM, vehicleReturn);
|
||||
else params.delete(VEHICLE_RETURN_PARAM);
|
||||
return params;
|
||||
}
|
||||
|
||||
export function buildVehicleDetailPath(vin: string, context: { directoryReturn?: string; monitorReturn?: string } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const directoryReturn = validatedVehicleDirectoryReturn(context.directoryReturn);
|
||||
const monitorReturn = validatedMonitorReturn(context.monitorReturn);
|
||||
if (directoryReturn) params.set(VEHICLE_DIRECTORY_RETURN_PARAM, directoryReturn);
|
||||
if (monitorReturn) params.set(MONITOR_RETURN_PARAM, monitorReturn);
|
||||
const query = params.toString();
|
||||
return `/vehicles/${encodeURIComponent(vin)}${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export function vehicleReturnOrigin(vehicleReturn: string) {
|
||||
const parsed = new URL(vehicleReturn, 'https://vehicle-platform.invalid');
|
||||
if (vehicleDirectoryReturnFromParams(parsed.searchParams)) return '车辆目录位置已保留';
|
||||
if (monitorReturnFromParams(parsed.searchParams)) return '全局监控位置已保留';
|
||||
return '返回该车辆数字档案';
|
||||
}
|
||||
|
||||
export const DEFAULT_VEHICLE_DIRECTORY_CONTEXT = DEFAULT_DIRECTORY_CONTEXT;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { EmptyState, InlineError, PageLoading, PanelEmpty, PanelLoading, WorkspaceEmptyGuide } from './AsyncState';
|
||||
import { EmptyState, InlineError, PageLoading, PanelEmpty, PanelError, PanelLoading, WorkspaceEmptyGuide } from './AsyncState';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -9,11 +9,13 @@ test('uses one semantic loading language for page and panel scopes', () => {
|
||||
const view = render(<>
|
||||
<PageLoading label="正在加载轨迹回放" />
|
||||
<PanelLoading title="正在查询里程" description="结果返回后会自动显示。" />
|
||||
<PanelError title="事件详情无法加载" description="请求超时" action={<button type="button">重新加载</button>} />
|
||||
</>);
|
||||
|
||||
expect(screen.getAllByRole('status')).toHaveLength(2);
|
||||
expect(screen.getByText('正在加载轨迹回放')).toBeInTheDocument();
|
||||
expect(screen.getByText('正在查询里程')).toBeInTheDocument();
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('事件详情无法加载请求超时重新加载');
|
||||
expect(view.container.querySelector('.v2-state-surface.is-page.is-loading')).toBeInTheDocument();
|
||||
expect(view.container.querySelector('.v2-state-surface.is-panel.is-loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -101,6 +101,31 @@ export function PanelEmpty({
|
||||
/>;
|
||||
}
|
||||
|
||||
export function PanelError({
|
||||
title = '数据暂时无法加载',
|
||||
description,
|
||||
className = '',
|
||||
action,
|
||||
compact = false
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
action?: ReactNode;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return <StateSurface
|
||||
className={className}
|
||||
kind="error"
|
||||
scope="panel"
|
||||
title={title}
|
||||
description={description}
|
||||
action={action}
|
||||
compact={compact}
|
||||
tone="danger"
|
||||
/>;
|
||||
}
|
||||
|
||||
export type WorkspaceEmptyGuideStep = {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -119,7 +144,7 @@ export function WorkspaceEmptyGuide({
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
steps: WorkspaceEmptyGuideStep[];
|
||||
steps?: WorkspaceEmptyGuideStep[];
|
||||
icon?: ReactNode;
|
||||
eyebrow?: string;
|
||||
action?: ReactNode;
|
||||
@@ -136,12 +161,12 @@ export function WorkspaceEmptyGuide({
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
<CardGroup className="v2-workspace-empty-guide-steps" type="grid" spacing={0}>
|
||||
{steps?.length ? <CardGroup className="v2-workspace-empty-guide-steps" type="grid" spacing={0}>
|
||||
{steps.map((step, index) => <Card key={`${step.title}-${index}`} className="v2-workspace-empty-guide-step" bodyStyle={{ padding: 0 }}>
|
||||
<span className="v2-workspace-empty-guide-step-index">{step.icon ?? index + 1}</span>
|
||||
<span><Typography.Text strong>{step.title}</Typography.Text><Typography.Text type="tertiary" size="small">{step.description}</Typography.Text></span>
|
||||
</Card>)}
|
||||
</CardGroup>
|
||||
</CardGroup> : null}
|
||||
{action || secondaryAction ? <div className="v2-workspace-empty-guide-actions">{action}{secondaryAction}</div> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type MobileFilterSheetProps = {
|
||||
height?: string | number;
|
||||
ariaLabel: string;
|
||||
dialogId?: string;
|
||||
initialFocusId?: string;
|
||||
closeLabel?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -31,6 +32,7 @@ export function MobileFilterSheet({
|
||||
height = 'min(82dvh, 700px)',
|
||||
ariaLabel,
|
||||
dialogId,
|
||||
initialFocusId,
|
||||
closeLabel,
|
||||
title,
|
||||
description,
|
||||
@@ -48,6 +50,7 @@ export function MobileFilterSheet({
|
||||
visible={visible}
|
||||
ariaLabel={ariaLabel}
|
||||
dialogId={dialogId}
|
||||
initialFocusId={initialFocusId}
|
||||
closeLabel={closeLabel}
|
||||
placement="bottom"
|
||||
height={height}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from 'vitest';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ROUTER_FUTURE } from '../routing/routerConfig';
|
||||
import { withMonitorReturn } from '../routing/monitorContext';
|
||||
import { buildVehicleDetailPath, buildVehicleDirectoryPath, withVehicleDirectoryReturn, withVehicleReturn } from '../routing/vehicleContext';
|
||||
import { MonitorReturnBar } from './MonitorReturnBar';
|
||||
|
||||
test('shows a deterministic monitor return only for monitor-originated child routes', () => {
|
||||
@@ -17,3 +18,25 @@ test('shows a deterministic monitor return only for monitor-originated child rou
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={['/history?vin=LTEST000000000001']}><MonitorReturnBar /></MemoryRouter>);
|
||||
expect(screen.queryByRole('link', { name: /返回全局监控/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('prioritizes the nearest vehicle workflow return over its nested origin', () => {
|
||||
const directory = buildVehicleDirectoryPath({
|
||||
search: '粤A', view: 'offline', page: 3,
|
||||
focusVin: 'LTEST000000000001',
|
||||
departmentIds: [], responsibleUserIds: [], customerIds: [], operationStatuses: []
|
||||
});
|
||||
const vehicle = buildVehicleDetailPath('LTEST000000000001', { directoryReturn: directory });
|
||||
const child = withVehicleReturn('/history?vin=LTEST000000000001', vehicle);
|
||||
const view = render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={[child]}><MonitorReturnBar /></MemoryRouter>);
|
||||
|
||||
expect(screen.getByRole('link', { name: /返回车辆档案/ })).toHaveAttribute('href', expect.stringContaining('/vehicles/LTEST000000000001'));
|
||||
expect(screen.getByText('车辆目录位置已保留')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: /返回车辆目录/ })).not.toBeInTheDocument();
|
||||
|
||||
view.unmount();
|
||||
const detail = withVehicleDirectoryReturn('/vehicles/LTEST000000000001', directory);
|
||||
render(<MemoryRouter future={ROUTER_FUTURE} initialEntries={[detail]}><MonitorReturnBar /></MemoryRouter>);
|
||||
expect(screen.getByRole('link', { name: /返回车辆目录/ })).toHaveAttribute('href', directory);
|
||||
expect(screen.getByText(/粤A · 当前离线/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/第 3 页/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
import { IconArrowLeft, IconMapPin } from '@douyinfe/semi-icons';
|
||||
import { IconArrowLeft, IconBox, IconMapPin, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { monitorReturnFromParams, parseMonitorRouteContext } from '../routing/monitorContext';
|
||||
import { parseVehicleDirectoryContext, vehicleDirectoryReturnFromParams, vehicleReturnFromParams, vehicleReturnOrigin } from '../routing/vehicleContext';
|
||||
|
||||
const directoryViewLabels = {
|
||||
all: '全部车辆',
|
||||
online: '当前在线',
|
||||
offline: '当前离线',
|
||||
multi: '多源车辆'
|
||||
};
|
||||
|
||||
export function MonitorReturnBar() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const vehicleReturn = vehicleReturnFromParams(searchParams);
|
||||
if (vehicleReturn) {
|
||||
const target = new URL(vehicleReturn, 'https://vehicle-platform.invalid');
|
||||
const vin = decodeURIComponent(target.pathname.slice('/vehicles/'.length));
|
||||
return <section className="v2-monitor-return is-vehicle-return" aria-label="车辆档案返回上下文">
|
||||
<Link to={vehicleReturn} replace><IconArrowLeft />返回车辆档案</Link>
|
||||
<span><IconBox />{vin}</span>
|
||||
<em>{vehicleReturnOrigin(vehicleReturn)}</em>
|
||||
</section>;
|
||||
}
|
||||
const directoryReturn = vehicleDirectoryReturnFromParams(searchParams);
|
||||
if (directoryReturn) {
|
||||
const target = new URL(directoryReturn, 'https://vehicle-platform.invalid');
|
||||
const context = parseVehicleDirectoryContext(target.searchParams);
|
||||
const scope = [context.search || '', directoryViewLabels[context.view]].filter(Boolean).join(' · ');
|
||||
return <section className="v2-monitor-return is-directory-return" aria-label="车辆目录返回上下文">
|
||||
<Link to={directoryReturn} replace><IconArrowLeft />返回车辆目录</Link>
|
||||
<span><IconSearch />{scope}</span>
|
||||
<em>第 {context.page} 页 · 搜索与视图已保留</em>
|
||||
</section>;
|
||||
}
|
||||
const returnTo = monitorReturnFromParams(searchParams);
|
||||
if (!returnTo) return null;
|
||||
const context = parseMonitorRouteContext(new URL(returnTo, 'https://vehicle-platform.invalid').searchParams);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { IconClose, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
|
||||
import { Button, Input, Tag } from '@douyinfe/semi-ui';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useDeferredValue, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleRow } from '../../api/types';
|
||||
import { InlineError, PanelLoading } from './AsyncState';
|
||||
|
||||
const VEHICLE_SELECTION_LIMIT = 500;
|
||||
|
||||
function uniqueVehicles(rows: VehicleRow[]) {
|
||||
const byVIN = new Map<string, VehicleRow>();
|
||||
for (const row of rows) {
|
||||
const current = byVIN.get(row.vin);
|
||||
if (!current || (!current.online && row.online)) byVIN.set(row.vin, row);
|
||||
}
|
||||
return Array.from(byVIN.values());
|
||||
}
|
||||
|
||||
export function VehicleQuickSelector({ value, protocol, onChange }: { value: string[]; protocol?: string; onChange: (value: string[]) => void }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const deferredSearch = useDeferredValue(search.trim());
|
||||
const params = useMemo(() => {
|
||||
const next = new URLSearchParams({ limit: '12', offset: '0' });
|
||||
if (deferredSearch) next.set('keyword', deferredSearch);
|
||||
if (protocol) next.set('protocol', protocol);
|
||||
return next;
|
||||
}, [deferredSearch, protocol]);
|
||||
const vehicles = useQuery({
|
||||
queryKey: ['automation-vehicle-options', protocol ?? '', deferredSearch],
|
||||
queryFn: ({ signal }) => api.vehicles(params, signal),
|
||||
staleTime: 30_000
|
||||
});
|
||||
const options = useMemo(() => uniqueVehicles(vehicles.data?.items ?? []), [vehicles.data?.items]);
|
||||
const selected = useMemo(() => new Set(value), [value]);
|
||||
|
||||
const toggle = (vin: string) => {
|
||||
if (selected.has(vin)) {
|
||||
onChange(value.filter((item) => item !== vin));
|
||||
return;
|
||||
}
|
||||
onChange([...value, vin].slice(0, VEHICLE_SELECTION_LIMIT));
|
||||
};
|
||||
const selectOnline = () => {
|
||||
const next = new Set(value);
|
||||
for (const vehicle of options) if (vehicle.online) next.add(vehicle.vin);
|
||||
onChange(Array.from(next).slice(0, VEHICLE_SELECTION_LIMIT));
|
||||
};
|
||||
|
||||
return <section className="is-wide v2-vehicle-quick-selector" aria-label="车辆快捷选择">
|
||||
<header><span><strong>快速选择车辆</strong><small>按车牌或 VIN 搜索;不选择表示覆盖当前协议下的全部授权车辆</small></span><Tag color={value.length ? 'blue' : 'grey'} type="light" size="small">{value.length ? `已选 ${value.length} 辆` : '全部车辆'}</Tag></header>
|
||||
<div className="v2-vehicle-quick-search"><Input prefix={<IconSearch />} showClear value={search} onChange={setSearch} aria-label="搜索要应用自动化的车辆" placeholder="搜索车牌 / VIN / 终端号" /><Button htmlType="button" theme="light" type="tertiary" disabled={!options.some((item) => item.online)} onClick={selectOnline}>选择当前在线</Button>{value.length ? <Button htmlType="button" theme="borderless" type="danger" onClick={() => onChange([])}>清空</Button> : null}</div>
|
||||
{value.length ? <div className="v2-vehicle-quick-selected" aria-label="已选择车辆">{value.map((vin) => <Tag key={vin} color="blue" type="light" closable onClose={() => toggle(vin)}>{vin}</Tag>)}</div> : null}
|
||||
{vehicles.isPending ? <PanelLoading compact title="正在读取最近活跃车辆" description="可继续填写其他规则配置。" /> : vehicles.isError ? <InlineError message={vehicles.error instanceof Error ? vehicles.error.message : '车辆读取失败'} onRetry={() => vehicles.refetch()} /> : <div className="v2-vehicle-quick-results" role="listbox" aria-label={deferredSearch ? '车辆搜索结果' : '最近活跃车辆'}>
|
||||
{options.map((vehicle) => <Button htmlType="button" key={vehicle.vin} className={selected.has(vehicle.vin) ? 'is-selected' : ''} theme="light" type={selected.has(vehicle.vin) ? 'primary' : 'tertiary'} role="option" aria-selected={selected.has(vehicle.vin)} onClick={() => toggle(vehicle.vin)}><span className="v2-vehicle-quick-status" aria-hidden="true">{selected.has(vehicle.vin) ? <IconTickCircle /> : <i className={vehicle.online ? 'is-online' : ''} />}</span><span><strong>{vehicle.plate || '未绑定车牌'}</strong><small>{vehicle.vin} · {vehicle.protocol}{vehicle.online ? ' · 在线' : ' · 离线'}</small></span>{selected.has(vehicle.vin) ? <IconClose className="v2-vehicle-quick-remove" aria-hidden="true" /> : null}</Button>)}
|
||||
{!options.length ? <p>{deferredSearch ? '没有匹配车辆,请换一个车牌或 VIN。' : '当前协议下没有可快捷选择的车辆。'}</p> : null}
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -17,6 +17,8 @@ test('normalizes Semi values and creates stable operational presets', () => {
|
||||
expect(workspaceDateTimePresetWindow('last6Hours', now)).toEqual({ dateFrom: '2026-07-19T08:30', dateTo: '2026-07-19T14:30' });
|
||||
expect(workspaceDateTimePresetWindow('last24Hours', now)).toEqual({ dateFrom: '2026-07-18T14:30', dateTo: '2026-07-19T14:30' });
|
||||
expect(workspaceDateTimePresetWindow('last3Days', now)).toEqual({ dateFrom: '2026-07-17T00:00', dateTo: '2026-07-19T14:30' });
|
||||
expect(workspaceDateTimePresetWindow('last7Days', now)).toEqual({ dateFrom: '2026-07-12T14:30', dateTo: '2026-07-19T14:30' });
|
||||
expect(workspaceDateTimePresetWindow('last30Days', now)).toEqual({ dateFrom: '2026-06-19T14:30', dateTo: '2026-07-19T14:30' });
|
||||
});
|
||||
|
||||
test('renders one Semi range with accessible quick actions', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, ButtonGroup, DatePicker } from '@douyinfe/semi-ui';
|
||||
import { Button, DatePicker } from '@douyinfe/semi-ui';
|
||||
|
||||
export type WorkspaceDateTimeRangeValue = {
|
||||
dateFrom: string;
|
||||
@@ -10,7 +10,9 @@ export type WorkspaceDateTimePresetKey =
|
||||
| 'yesterday'
|
||||
| 'last6Hours'
|
||||
| 'last24Hours'
|
||||
| 'last3Days';
|
||||
| 'last3Days'
|
||||
| 'last7Days'
|
||||
| 'last30Days';
|
||||
|
||||
export type WorkspaceDateTimePreset = {
|
||||
key: string;
|
||||
@@ -34,8 +36,8 @@ export function workspaceDateTimePresetWindow(
|
||||
dateTo: localMinute(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59))
|
||||
};
|
||||
}
|
||||
if (preset === 'last6Hours' || preset === 'last24Hours') {
|
||||
const hours = preset === 'last6Hours' ? 6 : 24;
|
||||
if (preset === 'last6Hours' || preset === 'last24Hours' || preset === 'last7Days' || preset === 'last30Days') {
|
||||
const hours = preset === 'last6Hours' ? 6 : preset === 'last24Hours' ? 24 : preset === 'last7Days' ? 7 * 24 : 30 * 24;
|
||||
return {
|
||||
dateFrom: localMinute(new Date(now.getTime() - hours * 60 * 60_000)),
|
||||
dateTo: localMinute(now)
|
||||
@@ -95,7 +97,7 @@ export function WorkspaceDateTimeRange({
|
||||
const labelId = `${id}-label`;
|
||||
const classes = ['v2-workspace-date-time-range', className].filter(Boolean).join(' ');
|
||||
const pickerClasses = ['v2-workspace-date-time-picker', pickerClassName].filter(Boolean).join(' ');
|
||||
const presetClasses = ['v2-workspace-date-time-presets', presetsClassName].filter(Boolean).join(' ');
|
||||
const presetClasses = ['semi-button-group', 'v2-workspace-date-time-presets', presetsClassName].filter(Boolean).join(' ');
|
||||
const pickerValue = value.dateFrom && value.dateTo
|
||||
? [new Date(value.dateFrom), new Date(value.dateTo)]
|
||||
: [];
|
||||
@@ -120,7 +122,7 @@ export function WorkspaceDateTimeRange({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{presets.length ? <ButtonGroup className={presetClasses} aria-label={presetsAriaLabel || `${label}快捷范围`}>
|
||||
{presets.length ? <div className={presetClasses} role="group" aria-label={presetsAriaLabel || `${label}快捷范围`}>
|
||||
{presets.map((preset) => {
|
||||
const active = preset.active ?? (
|
||||
value.dateFrom === preset.range.dateFrom
|
||||
@@ -134,6 +136,6 @@ export function WorkspaceDateTimeRange({
|
||||
onClick={() => onChange(preset.range)}
|
||||
>{preset.label}</Button>;
|
||||
})}
|
||||
</ButtonGroup> : null}
|
||||
</div> : null}
|
||||
</fieldset>;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ export type WorkspaceConfirmDialogProps = {
|
||||
cancelLabel: string;
|
||||
note?: string;
|
||||
pending?: boolean;
|
||||
confirmType?: 'danger' | 'primary';
|
||||
className?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -96,6 +97,7 @@ export function WorkspaceConfirmDialog({
|
||||
cancelLabel,
|
||||
note = '确认前请再次核对影响范围。',
|
||||
pending = false,
|
||||
confirmType = 'danger',
|
||||
className = '',
|
||||
onConfirm,
|
||||
onCancel
|
||||
@@ -118,7 +120,7 @@ export function WorkspaceConfirmDialog({
|
||||
<span className="v2-workspace-dialog-footer-note"><IconAlertTriangle aria-hidden="true" />{note}</span>
|
||||
<span className="v2-workspace-dialog-footer-actions">
|
||||
<Button type="tertiary" disabled={pending} onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Button theme="solid" type="danger" loading={pending} disabled={pending} onClick={onConfirm}>{confirmLabel}</Button>
|
||||
<Button theme="solid" type={confirmType} loading={pending} disabled={pending} onClick={onConfirm}>{confirmLabel}</Button>
|
||||
</span>
|
||||
</>}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
import { WorkspaceSideSheet } from './WorkspaceSideSheet';
|
||||
|
||||
@@ -110,3 +111,32 @@ test('exposes task, editor, help, action and filter variants without changing th
|
||||
expect(view.baseElement).toHaveTextContent('查看生成进度');
|
||||
expect(view.baseElement).toHaveTextContent('保存后生效');
|
||||
});
|
||||
|
||||
test('opens when first mounted visible and returns focus to its trigger after closing', async () => {
|
||||
function Harness() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return <>
|
||||
<button type="button" onClick={() => setOpen(true)}>打开任务</button>
|
||||
{open ? <WorkspaceSideSheet
|
||||
visible
|
||||
variant="task"
|
||||
ariaLabel="条件挂载任务"
|
||||
title="批量任务"
|
||||
description="挂载后立即打开"
|
||||
onCancel={() => setOpen(false)}
|
||||
>
|
||||
<span>任务内容</span>
|
||||
</WorkspaceSideSheet> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
const trigger = screen.getByRole('button', { name: '打开任务' });
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole('dialog', { name: '条件挂载任务' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '关闭条件挂载任务' })).toHaveFocus();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭条件挂载任务' }));
|
||||
expect(screen.queryByRole('dialog', { name: '条件挂载任务' })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button, SideSheet, Tag } from '@douyinfe/semi-ui';
|
||||
import { useId, useLayoutEffect, type ReactNode } from 'react';
|
||||
import { useCallback, useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
export type WorkspaceSideSheetAction = {
|
||||
label: string;
|
||||
@@ -28,6 +28,7 @@ export type WorkspaceSideSheetProps = {
|
||||
ariaLabel: string;
|
||||
closeLabel?: string;
|
||||
dialogId?: string;
|
||||
initialFocusId?: string;
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
icon?: ReactNode;
|
||||
@@ -52,6 +53,7 @@ export function WorkspaceSideSheet({
|
||||
ariaLabel,
|
||||
closeLabel = `关闭${ariaLabel}`,
|
||||
dialogId,
|
||||
initialFocusId,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
@@ -69,31 +71,74 @@ export function WorkspaceSideSheet({
|
||||
children
|
||||
}: WorkspaceSideSheetProps) {
|
||||
const sheetId = useId();
|
||||
const [renderVisible, setRenderVisible] = useState(false);
|
||||
const returnFocusRef = useRef<HTMLElement>();
|
||||
const sheetClassName = [`v2-workspace-${variant}-sidesheet`, className].filter(Boolean).join(' ');
|
||||
const hasFooter = Boolean(footerNote || secondaryActions.length || primaryAction);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setRenderVisible(visible);
|
||||
}, [visible]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!visible) return;
|
||||
const apply = () => {
|
||||
const dialog = document.querySelector(`[data-workspace-sheet-id="${sheetId}"] .semi-sidesheet-inner`);
|
||||
dialog?.setAttribute('aria-label', ariaLabel);
|
||||
if (dialogId) dialog?.setAttribute('id', dialogId);
|
||||
dialog?.querySelector('.semi-sidesheet-close')?.setAttribute('aria-label', closeLabel);
|
||||
returnFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : undefined;
|
||||
return () => {
|
||||
const returnTarget = returnFocusRef.current;
|
||||
window.queueMicrotask(() => {
|
||||
if (returnTarget?.isConnected) returnTarget.focus();
|
||||
});
|
||||
returnFocusRef.current = undefined;
|
||||
};
|
||||
apply();
|
||||
const frame = window.requestAnimationFrame(apply);
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [ariaLabel, closeLabel, dialogId, sheetId, visible]);
|
||||
}, [visible]);
|
||||
|
||||
const applyAccessibility = useCallback(() => {
|
||||
const root = document.querySelector(`[data-workspace-sheet-id="${sheetId}"]`);
|
||||
const dialog = root?.matches('.semi-sidesheet-inner')
|
||||
? root
|
||||
: root?.querySelector('.semi-sidesheet-inner');
|
||||
dialog?.setAttribute('aria-label', ariaLabel);
|
||||
if (dialogId) dialog?.setAttribute('id', dialogId);
|
||||
const closeButton = dialog?.querySelector<HTMLElement>('.semi-sidesheet-close');
|
||||
closeButton?.setAttribute('aria-label', closeLabel);
|
||||
const requestedFocusTarget = initialFocusId ? document.getElementById(initialFocusId) : null;
|
||||
const initialFocusTarget = requestedFocusTarget instanceof HTMLElement && dialog?.contains(requestedFocusTarget)
|
||||
? requestedFocusTarget
|
||||
: undefined;
|
||||
if (dialog && initialFocusTarget && (!dialog.contains(document.activeElement) || document.activeElement === closeButton)) {
|
||||
initialFocusTarget.focus();
|
||||
} else if (dialog && closeButton && !dialog.contains(document.activeElement)) {
|
||||
closeButton.focus();
|
||||
}
|
||||
return Boolean(dialog);
|
||||
}, [ariaLabel, closeLabel, dialogId, initialFocusId, sheetId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!visible) return;
|
||||
applyAccessibility();
|
||||
const frame = window.requestAnimationFrame(applyAccessibility);
|
||||
const timeout = window.setTimeout(applyAccessibility, 300);
|
||||
const observer = new MutationObserver(() => {
|
||||
if (applyAccessibility()) observer.disconnect();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.clearTimeout(timeout);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [applyAccessibility, visible]);
|
||||
|
||||
return <SideSheet
|
||||
className={sheetClassName}
|
||||
data-workspace-sheet-id={sheetId}
|
||||
visible={visible}
|
||||
visible={renderVisible}
|
||||
aria-label={ariaLabel}
|
||||
placement={placement}
|
||||
closeOnEsc={closeOnEsc}
|
||||
width={placement === 'left' || placement === 'right' ? width : undefined}
|
||||
height={placement === 'top' || placement === 'bottom' ? height : undefined}
|
||||
afterVisibleChange={(nextVisible) => { if (nextVisible) applyAccessibility(); }}
|
||||
title={<div className="v2-workspace-config-title">
|
||||
{icon ? <i className="v2-workspace-config-title-icon" aria-hidden="true">{icon}</i> : null}
|
||||
<span><strong>{title}</strong><small>{description}</small></span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* Vehicle platform design foundation.
|
||||
*
|
||||
* Keep product-wide color, typography, spacing, control and motion decisions
|
||||
* here. Page files should consume these tokens instead of introducing nearby
|
||||
* one-off values.
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* Color */
|
||||
--v2-color-canvas: #f6f8fb;
|
||||
--v2-color-surface: #ffffff;
|
||||
--v2-color-surface-subtle: #fafbfd;
|
||||
--v2-color-text: #172033;
|
||||
--v2-color-text-secondary: #66758a;
|
||||
--v2-color-text-tertiary: #8a96a7;
|
||||
--v2-color-border: #dfe5ed;
|
||||
--v2-color-border-strong: #cfd7e2;
|
||||
--v2-color-primary: #1467e8;
|
||||
--v2-color-primary-hover: #0f5dcc;
|
||||
--v2-color-primary-soft: #edf4ff;
|
||||
--v2-color-success: #15965f;
|
||||
--v2-color-danger: #dc3f3f;
|
||||
--v2-color-warning: #d98900;
|
||||
|
||||
/* Typography */
|
||||
--v2-font-sans: Inter, "SF Pro Text", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--v2-type-caption: 11px;
|
||||
--v2-type-meta: 12px;
|
||||
--v2-type-control: 14px;
|
||||
--v2-type-body: 14px;
|
||||
--v2-type-row-title: 16px;
|
||||
--v2-type-page-title: 24px;
|
||||
--v2-leading-compact: 1.25;
|
||||
--v2-leading-body: 1.5;
|
||||
|
||||
/* Spacing and geometry */
|
||||
--v2-space-1: 4px;
|
||||
--v2-space-2: 8px;
|
||||
--v2-space-3: 12px;
|
||||
--v2-space-4: 16px;
|
||||
--v2-space-5: 20px;
|
||||
--v2-space-6: 24px;
|
||||
--v2-space-7: 28px;
|
||||
--v2-space-8: 32px;
|
||||
--v2-radius-control: 8px;
|
||||
--v2-radius-surface: 10px;
|
||||
--v2-control-height: 40px;
|
||||
--v2-control-height-large: 44px;
|
||||
--v2-touch-target: 44px;
|
||||
--v2-sidebar-width: 228px;
|
||||
--v2-sidebar-width-compact: 72px;
|
||||
--v2-page-gutter: clamp(18px, 1.7vw, 28px);
|
||||
--v2-page-gap: 12px;
|
||||
|
||||
/* Motion */
|
||||
--v2-motion-fast: 140ms;
|
||||
--v2-motion-base: 220ms;
|
||||
--v2-ease-out: cubic-bezier(.2, .8, .2, 1);
|
||||
--v2-focus-ring: 0 0 0 3px rgba(20, 103, 232, .2);
|
||||
|
||||
/* Compatibility aliases consumed by existing shared components */
|
||||
--v2-bg: var(--v2-color-canvas);
|
||||
--v2-surface: var(--v2-color-surface);
|
||||
--v2-text: var(--v2-color-text);
|
||||
--v2-muted: var(--v2-color-text-secondary);
|
||||
--v2-border: var(--v2-color-border);
|
||||
--v2-blue: var(--v2-color-primary);
|
||||
--v2-blue-soft: var(--v2-color-primary-soft);
|
||||
--v2-green: var(--v2-color-success);
|
||||
--v2-red: var(--v2-color-danger);
|
||||
--v2-shadow: 0 1px 2px rgba(23, 32, 51, .025);
|
||||
--v2-radius: var(--v2-radius-surface);
|
||||
--v2-surface-border: var(--v2-color-border);
|
||||
--v2-surface-shadow: none;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--v2-color-canvas);
|
||||
font-family: var(--v2-font-sans);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--v2-color-canvas);
|
||||
color: var(--v2-color-text);
|
||||
font-family: var(--v2-font-sans);
|
||||
font-size: var(--v2-type-body);
|
||||
line-height: var(--v2-leading-body);
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(20, 103, 232, .16);
|
||||
color: var(--v2-color-text);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
.semi-button,
|
||||
.semi-input,
|
||||
.semi-select {
|
||||
font-family: inherit;
|
||||
font-size: var(--v2-type-control);
|
||||
}
|
||||
|
||||
:where(button, a, input, select, textarea, [role="button"], [role="tab"]):focus-visible {
|
||||
outline: 0;
|
||||
box-shadow: var(--v2-focus-ring);
|
||||
}
|
||||
|
||||
.v2-content {
|
||||
background: var(--v2-color-canvas);
|
||||
scrollbar-color: #b8c3d1 transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.v2-content > :where(section, main, article) {
|
||||
animation: v2-page-arrive var(--v2-motion-base) var(--v2-ease-out) both;
|
||||
}
|
||||
|
||||
.v2-content .semi-card {
|
||||
border-color: var(--v2-color-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.v2-content .semi-button,
|
||||
.v2-topbar .semi-button {
|
||||
transition:
|
||||
color var(--v2-motion-fast) ease,
|
||||
border-color var(--v2-motion-fast) ease,
|
||||
background-color var(--v2-motion-fast) ease,
|
||||
box-shadow var(--v2-motion-fast) ease,
|
||||
transform var(--v2-motion-fast) ease;
|
||||
}
|
||||
|
||||
.v2-content .semi-button:active,
|
||||
.v2-topbar .semi-button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
@keyframes v2-page-arrive {
|
||||
from {
|
||||
opacity: .35;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
:root {
|
||||
--v2-page-gutter: 14px;
|
||||
--v2-type-page-title: 21px;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
.v2-content {
|
||||
background: var(--v2-color-surface);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
.v2-content > :where(section, main, article) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.v2-content .semi-button,
|
||||
.v2-topbar .semi-button,
|
||||
.v2-navigation.semi-navigation .semi-navigation-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -219,8 +219,16 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-kpi.is-offline strong, .v2-kpi.is-missing strong { color: #7b8798; }
|
||||
|
||||
.v2-monitor-workspace { display: grid; min-height: 0; flex: 1; grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr); grid-template-rows: minmax(0, 1fr); overflow: hidden; border: 1px solid #dce4ee; border-radius: 8px; background: #fff; box-shadow: 0 4px 16px rgba(21,32,51,.04); }
|
||||
.v2-monitor-workspace.is-detail-open { grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr) clamp(340px, 20vw, 420px); }
|
||||
.v2-monitor-workspace.is-detail-open { grid-template-columns: clamp(244px, 14vw, 300px) minmax(360px, 1fr) clamp(340px, 20vw, 420px); }
|
||||
.v2-monitor-workspace.is-detail-collapsed { grid-template-columns: clamp(244px, 14vw, 300px) minmax(480px, 1fr) 44px; }
|
||||
.v2-monitor-coverage-warning.semi-card { border: 1px solid #f2d5a5; border-radius: 8px; background: #fffbf3; box-shadow: 0 3px 12px rgba(180,109,0,.05); }
|
||||
.v2-monitor-coverage-warning > .semi-card-body { padding: 0; }
|
||||
.v2-monitor-coverage-warning-content { display: flex; min-height: 50px; align-items: center; justify-content: space-between; gap: 16px; padding: 7px 10px 7px 12px; }
|
||||
.v2-monitor-coverage-warning-content > div { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||||
.v2-monitor-coverage-warning span { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.v2-monitor-coverage-warning strong { color: #7c4a03; font-size: 11px; }
|
||||
.v2-monitor-coverage-warning small { color: #936421; font-size: 9px; line-height: 1.45; }
|
||||
.v2-monitor-coverage-warning .semi-button { flex: 0 0 auto; height: 32px; border: 1px solid #e8c381; border-radius: 7px; background: #fff; color: #9a5d05; font-size: 10px; font-weight: 700; }
|
||||
.v2-vehicle-rail { display: flex; min-width: 0; min-height: 0; flex-direction: column; border-right: 1px solid var(--v2-border); }
|
||||
.v2-vehicle-rail > header { display: flex; height: 46px; align-items: center; justify-content: space-between; padding: 0 12px; }
|
||||
.v2-vehicle-rail > header strong { font-size: 13px; }
|
||||
@@ -257,6 +265,9 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-monitor-table-scroll tbody tr:hover { background: #f8fbff; }
|
||||
.v2-monitor-table-scroll td > strong, .v2-monitor-table-scroll td > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-monitor-table-vehicle > .v2-monitor-vehicle-action.semi-button { display: flex; width: 100%; max-width: 100%; height: auto; justify-content: flex-start; border: 0; background: transparent; padding: 0; color: inherit; cursor: pointer; text-align: left; }.v2-monitor-table-vehicle > .v2-monitor-vehicle-action.semi-button:hover strong { color: var(--v2-blue); }
|
||||
.v2-monitor-table .semi-table-tbody > .semi-table-row.is-selected { background: #f2f7ff; box-shadow: inset 3px 0 var(--v2-blue); }
|
||||
.v2-monitor-vehicle-action.is-selected strong { color: var(--v2-blue); }
|
||||
.v2-monitor-mobile-card.is-selected.semi-card { border-color: #9fc2f5; background: #f7faff; box-shadow: inset 3px 0 var(--v2-blue), 0 5px 16px rgba(18,104,223,.08); }
|
||||
.v2-monitor-vehicle-action .semi-button-content { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; }
|
||||
.v2-monitor-table-vehicle strong, .v2-monitor-table-vehicle span { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.v2-monitor-table-vehicle strong { color: #263448; font-size: 13px; transition: color .14s ease; }.v2-monitor-table-vehicle span { margin-top: 4px; color: #8996a8; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; font-size: 9px; }
|
||||
.v2-monitor-live-value { display: inline !important; color: #213044; font-size: 14px !important; font-variant-numeric: tabular-nums; }
|
||||
@@ -373,9 +384,13 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-province-marker > strong { color: #1268f3; font-size: 20px; font-variant-numeric: tabular-nums; letter-spacing: -.5px; line-height: 1; }
|
||||
.v2-map-state { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 9px; background: #eef3f7; color: #637083; font-size: 12px; }
|
||||
.v2-map-state.is-loading { background: rgba(255,255,255,.82); backdrop-filter: blur(2px); }
|
||||
.v2-map-state.is-error { flex-direction: column; padding: 20px; color: #b42318; text-align: center; }
|
||||
.v2-map-state.is-error, .v2-map-state.is-fallback { flex-direction: column; padding: 20px; text-align: center; }
|
||||
.v2-map-state.is-error { color: #b42318; }
|
||||
.v2-map-state.is-error .v2-map-retry-action.semi-button { height: 32px; border: 1px solid #f2b8b5; border-radius: 7px; background: #fff; padding: 0 11px; color: #a61b13; font-size: 11px; font-weight: 700; box-shadow: 0 3px 10px rgba(180,35,24,.08); }
|
||||
.v2-map-state.is-error .v2-map-retry-action.semi-button:hover { border-color: #df8a85; background: #fff8f7; }
|
||||
.v2-map-recovery-actions { display: flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
.v2-map-list-action.semi-button { height: 32px; border: 1px solid #cbd9e8; border-radius: 7px; background: #fff; padding: 0 11px; color: #3568a9; font-size: 11px; font-weight: 700; }
|
||||
.v2-map-list-action.semi-button:hover { border-color: #9bb8dd; background: #f4f8fe; }
|
||||
.v2-map-legend { position: absolute; bottom: 12px; left: 50%; display: flex; width: max-content; max-width: calc(100% - 28px); height: 36px; align-items: center; justify-content: center; gap: 18px; border: 1px solid #d8e2ed; border-radius: 8px; background: #fff; padding: 0 16px; color: #5f6e82; box-shadow: 0 6px 18px rgba(21,32,51,.1); font-size: 9px; transform: translateX(-50%); }
|
||||
.v2-map-legend span { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.v2-map-legend i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }
|
||||
@@ -414,6 +429,8 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-detail-list > div { display: grid; grid-template-columns: 78px minmax(0, 1fr); gap: 8px; padding: 5px 0; font-size: 9px; }
|
||||
.v2-detail-list dt { color: var(--v2-muted); }
|
||||
.v2-detail-list dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #3d4a5e; }
|
||||
.v2-detail-address-error { display: inline-flex; align-items: center; gap: 5px; color: #b42318; }
|
||||
.v2-detail-address-error .semi-button { height: auto; min-height: 24px; padding: 0 5px; font-size: 9px; font-weight: 700; }
|
||||
.v2-metric-grid { display: grid; grid-template-columns: repeat(2, 1fr); border: 1px solid var(--v2-border); border-radius: 7px; }
|
||||
.v2-metric-grid > div { min-width: 0; padding: 9px; }
|
||||
.v2-metric-grid > div:nth-child(even) { border-left: 1px solid var(--v2-border); }
|
||||
@@ -1110,6 +1127,8 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-source-empty { display: grid; min-height: 110px; place-content: center; gap: 6px; color: var(--v2-muted); text-align: center; }.v2-source-empty strong { color: #42556e; font-size: 13px; }.v2-source-empty span { font-size: 10px; }.v2-source-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); border-block: 1px solid #e7edf4; background: #fafcff; }.v2-source-summary article { min-width: 0; padding: 11px 14px; }.v2-source-summary article + article { border-left: 1px solid #e7edf4; }.v2-source-summary small, .v2-source-summary span { display: block; overflow: hidden; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.v2-source-summary strong { display: block; margin: 5px 0; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.v2-source-recommendation { margin: 12px 14px 8px; border-left: 3px solid #2f6fe4; border-radius: 6px; background: #f3f7ff; padding: 9px 11px; }.v2-source-recommendation strong { font-size: 10px; }.v2-source-recommendation p { margin: 4px 0; color: #405774; font-size: 9px; line-height: 1.5; }.v2-source-recommendation span, .v2-source-readonly { color: #728299; font-size: 8px; }.v2-source-readonly { margin: 0 14px 8px; border-radius: 6px; background: #fff8e8; padding: 7px 9px; color: #966500; }
|
||||
.v2-source-table-wrap { margin: 0 14px 12px; overflow: auto; border: 1px solid #dfe7f0; border-radius: 8px; }.v2-source-table { width: 100%; min-width: 1320px; border-collapse: collapse; table-layout: fixed; }.v2-source-table th { height: 34px; background: #f6f8fb; color: #63748a; font-size: 8px; text-align: left; }.v2-source-table th, .v2-source-table td { border-bottom: 1px solid #e8edf3; padding: 7px 9px; vertical-align: top; }.v2-source-table tbody tr:last-child td { border-bottom: 0; }.v2-source-table tbody tr.is-recommended { background: #f4fbf8; }.v2-source-table td { color: #3e5067; font-size: 9px; line-height: 1.45; }.v2-source-table td > strong, .v2-source-table td > span { display: block; }.v2-source-table td > span { margin-top: 3px; color: var(--v2-muted); }.v2-source-table td > i { display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: #9aa7b8; }.v2-source-table td > i.is-online { background: var(--v2-green); }.v2-source-table td > i.is-offline { background: #94a3b8; }.v2-source-reason span { max-width: 230px; white-space: normal !important; }.v2-source-policy-cell { display: grid; grid-template-columns: auto 64px; gap: 5px; }.v2-source-policy-cell label { display: flex; align-items: center; gap: 4px; }.v2-source-policy-cell input[type=number], .v2-source-policy-cell input[type=text], .v2-source-policy-cell > input:not([type]) { min-width: 0; height: 26px; border: 1px solid #ccd7e5; border-radius: 5px; padding: 0 6px; font-size: 9px; }.v2-source-policy-cell > .v2-source-provider-input, .v2-source-policy-cell > .v2-source-provider-evidence-input, .v2-source-policy-cell > .v2-source-policy-remark-input { grid-column: 1 / -1; }.v2-source-policy-cell button { grid-column: 1 / -1; height: 27px; }.v2-source-policy-cell em { grid-column: 1 / -1; color: var(--v2-red); font-size: 8px; font-style: normal; }.v2-source-audit { margin: 0 14px 14px; border: 1px solid #e0e7f0; border-radius: 8px; }.v2-source-audit > header { display: flex; height: 34px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e7edf4; padding: 0 10px; }.v2-source-audit > header strong { font-size: 10px; }.v2-source-audit > header span, .v2-source-audit > p { color: var(--v2-muted); font-size: 8px; }.v2-source-audit > p { margin: 0; padding: 12px; }.v2-source-audit ol { max-height: 170px; margin: 0; overflow: auto; padding: 0; list-style: none; }.v2-source-audit li { display: grid; min-height: 34px; grid-template-columns: 38px minmax(0,1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #edf1f6; padding: 5px 10px; font-size: 9px; }.v2-source-audit li em { color: var(--v2-muted); font-size: 8px; font-style: normal; }
|
||||
|
||||
.v2-source-policy-success { margin: 0; border: 1px solid #bfe2d3; border-radius: 8px; background: #f1fbf7; padding: 9px 11px; color: #187a56; font-size: 10px; }
|
||||
|
||||
.v2-vehicle-search-page, .v2-not-found { display: grid; min-height: 100%; place-items: center; padding: 28px; }
|
||||
.v2-vehicle-search-card { width: min(660px, 100%); border: 1px solid var(--v2-border); border-radius: 16px; background: #fff; padding: 54px; text-align: center; box-shadow: var(--v2-shadow); }
|
||||
.v2-search-hero-icon { display: grid; width: 54px; height: 54px; margin: 0 auto 18px; place-items: center; border-radius: 15px; background: var(--v2-blue-soft); color: var(--v2-blue); }
|
||||
@@ -1759,6 +1778,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-mileage-filter > .v2-primary-button { height: 40px; margin-top: 18px; border-radius: 7px; padding: 0 22px; box-shadow: 0 4px 10px rgba(37,99,235,.16); font-size: 12px; }
|
||||
.v2-mileage-validation { margin: -5px 20px 14px; border: 1px solid #f1c5c8; border-radius: 7px; background: #fff7f7; padding: 9px 11px; color: #b54046; font-size: 11px; line-height: 1.5; }
|
||||
.v2-mileage-vehicle-field { position: relative; }
|
||||
.v2-mileage-filter > .v2-mileage-vehicle-field { display: flex; min-width: 0; flex-direction: column; gap: 7px; color: #4f5f75; font-size: 11px; font-weight: 650; }
|
||||
.v2-mileage-vehicle-field > small { color: #96a1b0; font-size: 9px; font-weight: 400; }
|
||||
.v2-mileage-multiselect { position: relative; display: flex; min-height: 40px; align-items: flex-start; gap: 8px; border: 1px solid #d7e0ea; border-radius: 7px; background: #fff; padding: 6px 10px; color: #8a98aa; transition: border-color .16s ease, box-shadow .16s ease; }
|
||||
.v2-mileage-multiselect > svg { flex: 0 0 auto; margin-top: 5px; }
|
||||
@@ -7843,6 +7863,10 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
}
|
||||
.v2-mileage-table .semi-table-row-head.is-total { z-index: 5; background: #f1f6fe; color: #315f9f; }
|
||||
.v2-mileage-table .semi-table-row-cell.is-daily { color: #28496f; font-size: 10px; font-weight: 650; }
|
||||
.v2-mileage-cell-value { display: inline-flex; flex-direction: column; gap: 2px; line-height: 1.15; white-space: nowrap; }
|
||||
.v2-mileage-cell-value > strong { font-size: inherit; font-weight: 750; }
|
||||
.v2-mileage-cell-value > small { color: #17845b; font-size: 9px; font-weight: 650; }
|
||||
.v2-mileage-cell-value > small.is-hydrogen { color: #a15c08; }
|
||||
.v2-mileage-table .semi-table-row-cell.is-empty { color: #b2bcc9; text-align: center; }
|
||||
.v2-mileage-table .semi-table-row-cell.is-period { color: #135fcb; font-size: 11px; font-weight: 800; }
|
||||
.v2-mileage-table .semi-table-row-cell strong { color: #263448; }
|
||||
@@ -8878,6 +8902,112 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-telemetry-value-footer .semi-button { width: 100%; justify-content: center; }
|
||||
}
|
||||
|
||||
/* Compact telemetry metrics: shared desktop/mobile scan pattern with detail-on-demand. */
|
||||
.v2-telemetry-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3,minmax(0,1fr));
|
||||
gap: 8px;
|
||||
margin: 12px 14px 5px;
|
||||
}
|
||||
.v2-telemetry-metric-grid > [role="listitem"] { min-width: 0; content-visibility: auto; contain-intrinsic-size: 128px; }
|
||||
.v2-telemetry-metric-card.semi-button {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 116px;
|
||||
justify-content: stretch;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dce5ef;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(150deg,#fff 0%,#fbfcfe 100%);
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
box-shadow: 0 3px 12px rgba(31,53,80,.035);
|
||||
transition: border-color .16s ease, box-shadow .16s ease, transform .16s ease;
|
||||
}
|
||||
.v2-telemetry-metric-card.semi-button:hover {
|
||||
border-color: #b9cee8;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 22px rgba(31,73,124,.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.v2-telemetry-metric-card.semi-button:focus-visible { outline: 2px solid #2f76d2; outline-offset: 2px; }
|
||||
.v2-telemetry-metric-card .semi-button-content { display: block; width: 100%; min-width: 0; }
|
||||
.v2-telemetry-metric-card-content { display: grid; min-width: 0; gap: 9px; padding: 11px 12px 10px; }
|
||||
.v2-telemetry-metric-heading { display: grid; min-width: 0; gap: 3px; }
|
||||
.v2-telemetry-metric-heading strong {
|
||||
overflow: hidden;
|
||||
color: #344a62;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.v2-telemetry-metric-heading small {
|
||||
overflow: hidden;
|
||||
color: #8b98a8;
|
||||
font: 8px ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.v2-telemetry-metric-value { display: flex; min-width: 0; align-items: baseline; gap: 5px; }
|
||||
.v2-telemetry-metric-value strong {
|
||||
overflow: hidden;
|
||||
color: #1f3c5b;
|
||||
font-size: 19px;
|
||||
line-height: 1.1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.v2-telemetry-metric-value.is-complex strong { font-size: 15px; }
|
||||
.v2-telemetry-metric-value > small { color: #73849a; font-size: 9px; }
|
||||
.v2-telemetry-metric-meta { display: grid; min-width: 0; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 6px; }
|
||||
.v2-telemetry-metric-meta .semi-tag { min-height: 20px; border-radius: 999px; padding-inline: 6px; font-size: 8px; font-weight: 700; }
|
||||
.v2-telemetry-metric-meta > small { overflow: hidden; color: #8391a3; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-telemetry-metric-meta > svg { color: #8aa2be; font-size: 11px; }
|
||||
|
||||
.v2-history-raw-segments {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(170px,.7fr) minmax(320px,1.8fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
border-top: 1px solid #e5ebf2;
|
||||
border-bottom: 1px solid #e5ebf2;
|
||||
background: linear-gradient(90deg,#f7faff,#fbfcfe);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.v2-history-raw-segments > div:first-child { display: grid; min-width: 0; gap: 2px; }
|
||||
.v2-history-raw-segments > div:first-child strong { color: #344a62; font-size: 11px; }
|
||||
.v2-history-raw-segments > div:first-child small { color: #7f8ea1; font-size: 8px; line-height: 1.45; }
|
||||
.v2-history-raw-segments > span { color: #718299; font-size: 9px; white-space: nowrap; }
|
||||
.v2-history-raw-segments .v2-segmented-tabs { min-width: 0; }
|
||||
|
||||
@media (min-width: 1500px) {
|
||||
.v2-telemetry-metric-grid { grid-template-columns: repeat(4,minmax(0,1fr)); }
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.v2-telemetry-metric-grid { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||
.v2-history-raw-segments { grid-template-columns: 1fr; gap: 7px; }
|
||||
.v2-history-raw-segments > span { justify-self: start; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.v2-telemetry-metric-grid { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 6px; margin: 9px; }
|
||||
.v2-telemetry-metric-card.semi-button { min-height: 108px; border-radius: 9px; }
|
||||
.v2-telemetry-metric-card-content { gap: 8px; padding: 9px; }
|
||||
.v2-telemetry-metric-heading strong { font-size: 10px; }
|
||||
.v2-telemetry-metric-value strong { font-size: 17px; }
|
||||
.v2-telemetry-metric-value.is-complex strong { font-size: 13px; }
|
||||
.v2-telemetry-metric-meta { grid-template-columns: auto minmax(0,1fr); }
|
||||
.v2-telemetry-metric-meta > svg { display: none; }
|
||||
.v2-history-raw-segments { gap: 8px; padding: 9px; }
|
||||
.v2-history-raw-segments > div:first-child small { font-size: 9px; }
|
||||
.v2-history-raw-segments .v2-segmented-tabs { overflow-x: auto; }
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.v2-telemetry-metric-grid { grid-template-columns: minmax(0,1fr); }
|
||||
}
|
||||
|
||||
/* Semi UI track evidence: one list language for stops, events and source provenance. */
|
||||
.v2-track-evidence-list.semi-list {
|
||||
min-height: 100%;
|
||||
@@ -9433,6 +9563,15 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
}
|
||||
.v2-mileage-table-wrap::-webkit-scrollbar-thumb:hover { background-color: #617a99; }
|
||||
.v2-mileage-table-wrap::-webkit-scrollbar-corner { background: #eef3f8; }
|
||||
.v2-mileage-table .semi-table-row-cell.is-period.is-empty {
|
||||
color: #9aa7b7;
|
||||
font-weight: 650;
|
||||
}
|
||||
.v2-mileage-results > .semi-card-body > .v2-inline-state {
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
margin: 12px;
|
||||
}
|
||||
.v2-mileage-results > .semi-card-body > footer { min-height: 40px; height: 40px; padding: 4px 12px; }
|
||||
.v2-mileage-results > .semi-card-body > footer .v2-table-pagination-button,
|
||||
.v2-mileage-results > .semi-card-body > footer .v2-table-pagination-current { height: 28px; min-height: 28px; }
|
||||
@@ -11458,10 +11597,43 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
#vehicle-archive-panel {
|
||||
#vehicle-business-panel {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
}
|
||||
#vehicle-archive-panel {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 4;
|
||||
}
|
||||
.v2-business-relation-card .v2-record-descriptions .semi-descriptions-item {
|
||||
min-width: 0;
|
||||
}
|
||||
.v2-business-relation-empty {
|
||||
display: flex;
|
||||
min-height: 118px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 22px;
|
||||
color: #8190a3;
|
||||
}
|
||||
.v2-business-relation-empty > svg {
|
||||
flex: 0 0 auto;
|
||||
font-size: 28px;
|
||||
}
|
||||
.v2-business-relation-empty > span {
|
||||
display: grid;
|
||||
max-width: 560px;
|
||||
gap: 5px;
|
||||
}
|
||||
.v2-business-relation-empty strong {
|
||||
color: #465b73;
|
||||
font-size: 12px;
|
||||
}
|
||||
.v2-business-relation-empty small {
|
||||
font-size: 9px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.v2-record-section-anchor > .semi-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f5f5f7" />
|
||||
<link rel="icon" type="image/svg+xml" href="/brand-mark.svg" />
|
||||
<title>车辆查询 · 响应式设计实验室</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="vehicle-design-lab-root"></div>
|
||||
<script type="module" src="/src/design-lab/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user