Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
812 lines
57 KiB
TypeScript
812 lines
57 KiB
TypeScript
import './governance-workflows.css';
|
||
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, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
|
||
import { Link, useSearchParams } from 'react-router-dom';
|
||
import { api } from '../../api/client';
|
||
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';
|
||
import { MobileFilterSheet, MobileFilterSheetSection } from '../shared/MobileFilterSheet';
|
||
import { TablePagination } from '../shared/TablePagination';
|
||
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
|
||
import { WorkspaceDetailSideSheet } from '../shared/WorkspaceDetailSideSheet';
|
||
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from './GovernanceStatusStrip';
|
||
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';
|
||
import { QUERY_MEMORY, queryScopeKey, retainPreviousPageWithinScope } from '../queryPolicy';
|
||
import { downloadBlob } from '../domain/download';
|
||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||
|
||
const PROTOCOLS = ['GB32960', 'JT808', 'YUTONG_MQTT'] as const;
|
||
type AccessProtocol = (typeof PROTOCOLS)[number];
|
||
const protocolThresholdMeta: Record<AccessProtocol, { label: string; description: string }> = {
|
||
GB32960: { label: 'GB32960', description: '国标远程服务' },
|
||
JT808: { label: 'JT808', description: '道路运输终端' },
|
||
YUTONG_MQTT: { label: 'YUTONG MQTT', 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: '尚无来源'
|
||
};
|
||
|
||
const scopeLabels: Record<string, string> = {
|
||
'': '全部主车辆',
|
||
attention: '需关注车辆',
|
||
master_data: '资料待维护',
|
||
healthy: '已接来源正常',
|
||
degraded: '部分来源异常',
|
||
offline: '已接来源离线',
|
||
not_connected: '尚无来源'
|
||
};
|
||
|
||
function statusByProtocol(row: AccessVehicleRow, protocol: string) {
|
||
return row.protocolStatuses.find((item) => item.protocol === protocol);
|
||
}
|
||
|
||
function compactProtocolLabel(protocol: string) {
|
||
if (protocol === 'GB32960') return '32960';
|
||
if (protocol === 'JT808') return '808';
|
||
if (protocol === 'YUTONG_MQTT') return '宇通';
|
||
return protocol;
|
||
}
|
||
|
||
function compactTime(value: string) {
|
||
if (!value) return '—';
|
||
const parsed = new Date(value);
|
||
if (Number.isNaN(parsed.getTime())) return '—';
|
||
return compactAccessTimeFormatter.format(parsed).replace(/\//g, '-');
|
||
}
|
||
|
||
function AccessTime({ value, compact = false }: { value?: string; compact?: boolean }) {
|
||
if (!value) return <span className="v2-access-time">—</span>;
|
||
return <time className="v2-access-time" dateTime={value} title={`${formatAccessTime(value)} · 上海时间`}>{compact ? compactTime(value) : formatAccessTime(value)}</time>;
|
||
}
|
||
|
||
function protocolNeedsAttention(status?: AccessProtocolStatus) {
|
||
return Boolean(status?.connected && (status.onlineState !== 'online' || status.delayAbnormal));
|
||
}
|
||
|
||
function protocolPriority(status?: AccessProtocolStatus) {
|
||
if (status?.connected && status.onlineState !== 'online') return 0;
|
||
if (status?.connected && status.delayAbnormal) return 1;
|
||
if (status?.connected) return 2;
|
||
return 3;
|
||
}
|
||
|
||
function AccessProtocolTitle({ protocol, compact = false }: { protocol: AccessProtocol; compact?: boolean }) {
|
||
return <span className={`v2-access-protocol-title${compact ? ' is-compact' : ''}`}>
|
||
<ProtocolTag protocol={protocol} compact={compact} />
|
||
<small>{protocolThresholdMeta[protocol].description}</small>
|
||
</span>;
|
||
}
|
||
|
||
const ProtocolState = memo(function ProtocolState({
|
||
status,
|
||
protocol,
|
||
detailed = false,
|
||
protocolLabel,
|
||
expanded = false,
|
||
onToggle
|
||
}: {
|
||
status?: AccessProtocolStatus;
|
||
protocol: AccessProtocol;
|
||
detailed?: boolean;
|
||
protocolLabel?: string;
|
||
expanded?: boolean;
|
||
onToggle?: (protocol: string) => void;
|
||
}) {
|
||
const state = !status?.connected ? 'missing' : status.onlineState;
|
||
const label = state === 'missing' ? '无来源' : state === 'online' ? '在线' : state === 'offline' ? '离线' : state === 'unknown' ? '未知' : '从未上报';
|
||
const color = state === 'online' ? 'green' : state === 'offline' || state === 'unknown' ? 'orange' : 'grey';
|
||
if (detailed) {
|
||
const evidenceId = `access-protocol-${protocol.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-evidence`;
|
||
const evidence = status?.firstSeenEvidence || '当前未发现该协议来源';
|
||
return <Card
|
||
className={`v2-access-protocol-detail is-${state}${expanded ? ' is-expanded' : ''}`}
|
||
title={<AccessProtocolTitle protocol={protocol} />}
|
||
headerExtraContent={<Tag className={`v2-access-protocol-tag is-${state}`} color={color} type="light" size="small"><i />{label}</Tag>}
|
||
headerLine
|
||
bodyStyle={{ padding: 0 }}
|
||
>
|
||
{status?.connected ? <>
|
||
<div className="v2-access-protocol-glance" role="group" aria-label={`${protocol} 关键状态`}>
|
||
<span><small>接入厂家</small><strong title={status.provider || '接入方未维护'}>{status.provider || '接入方未维护'}</strong></span>
|
||
<span><small>最新上报</small><strong><AccessTime value={status.latestReceivedAt} compact /></strong></span>
|
||
<span><small>当前离线</small><strong>{formatSeconds(status.freshnessSec)}</strong></span>
|
||
<span className={status.delayAbnormal ? 'is-danger' : ''}><small>数据延迟</small><strong>{formatSeconds(status.dataDelaySec)}</strong></span>
|
||
</div>
|
||
<Button
|
||
className={`v2-access-protocol-toggle${expanded ? ' is-expanded' : ''}`}
|
||
theme="borderless"
|
||
type="tertiary"
|
||
icon={<IconChevronDown />}
|
||
iconPosition="right"
|
||
aria-expanded={expanded}
|
||
aria-controls={evidenceId}
|
||
aria-label={`${expanded ? '收起' : '查看'} ${protocol} 完整证据`}
|
||
onClick={() => onToggle?.(protocol)}
|
||
>
|
||
{expanded ? '收起完整证据' : protocolNeedsAttention(status) ? '查看异常证据' : '查看完整证据'}
|
||
</Button>
|
||
{expanded ? <div id={evidenceId} className="v2-access-protocol-expanded" role="region" aria-label={`${protocol} 完整接入证据`}>
|
||
<Descriptions className="v2-access-protocol-descriptions" align="left" size="small" data={[
|
||
{ key: '首次接入', value: <AccessTime value={status.firstSeenAt} /> },
|
||
{ key: '上报间隔', value: formatSeconds(status.reportIntervalSec) }
|
||
]} />
|
||
<p className="v2-access-protocol-evidence" title={evidence}>{evidence}</p>
|
||
</div> : null}
|
||
</> : <div className="v2-access-protocol-missing">
|
||
<span>当前未发现真实接入来源</span>
|
||
<small>无需展开,待收到该协议报文后自动补充证据。</small>
|
||
</div>}
|
||
</Card>;
|
||
}
|
||
return <div className={`v2-access-protocol-cell is-${state}`} title={status?.latestReceivedAt ? `最新上报:${formatAccessTime(status.latestReceivedAt)}` : '当前未发现该协议来源'}>
|
||
{protocolLabel ? <b className="v2-access-protocol-label">{protocolLabel}</b> : null}
|
||
{status?.connected ? <>
|
||
<span className="v2-access-protocol-status-line"><span><i />{label}</span>{status.provider ? <small title={status.provider}>{status.provider}</small> : null}</span>
|
||
<strong><AccessTime value={status.latestReceivedAt} compact /></strong>
|
||
</> : <span className="v2-access-protocol-absent">未发现来源</span>}
|
||
</div>;
|
||
});
|
||
|
||
function ConnectionTag({ row }: { row: AccessVehicleRow }) {
|
||
return <Tag className="v2-access-connection-tag" color={connectionTagColor(row.connectionState)} type="light" size="small">{connectionLabels[row.connectionState]}</Tag>;
|
||
}
|
||
|
||
function connectionTagColor(state: AccessVehicleRow['connectionState']) {
|
||
return state === 'healthy' ? 'green' as const : state === 'not_connected' || state === 'offline' ? 'red' as const : 'orange' as const;
|
||
}
|
||
|
||
const ConnectionState = memo(function ConnectionState({ row }: { row: AccessVehicleRow }) {
|
||
const summary = accessIssueSummary(row);
|
||
return <div className={`v2-access-connection is-${row.connectionState}`}>
|
||
<div className="v2-access-connection-line"><ConnectionTag row={row} /><small>{row.actualProtocols.length} 个来源</small></div>
|
||
{row.connectionState === 'healthy'
|
||
? <span title={row.actualProtocols.join(' / ')}>{row.actualProtocols.join(' / ')}</span>
|
||
: row.connectionState !== 'not_connected' ? <span title={summary}>{summary}</span> : null}
|
||
</div>;
|
||
});
|
||
|
||
function ProtocolCoverage({ summary }: { summary?: AccessSummary }) {
|
||
return <div className="v2-access-protocol-coverage" aria-label="真实协议来源概览">
|
||
<small>真实来源覆盖</small>
|
||
{PROTOCOLS.map((protocol) => {
|
||
const actual = summary?.protocols.find((item) => item.name === protocol);
|
||
return <span key={protocol}><b>{compactProtocolLabel(protocol)}</b><em>{(actual?.total ?? 0).toLocaleString('zh-CN')} 辆</em></span>;
|
||
})}
|
||
</div>;
|
||
}
|
||
|
||
function accessRecommendation(row: AccessVehicleRow) {
|
||
if (row.connectionState === 'healthy') return '无需处理,持续监测来源上报';
|
||
if (row.connectionState === 'incomplete') return '补齐品牌、车型或来源标识';
|
||
if (row.connectionState === 'not_connected') return '核对车端设备与接入映射';
|
||
if (row.connectionState === 'offline') return '优先核对设备供电与接入链路';
|
||
return '优先核对离线或延迟异常来源';
|
||
}
|
||
|
||
const AccessVehicleTable = memo(function AccessVehicleTable({ rows, selectedVIN, onSelect }: { rows: AccessVehicleRow[]; selectedVIN: string; onSelect: (vin: string) => void }) {
|
||
const columns = useMemo(() => [
|
||
{
|
||
title: '车辆', dataIndex: 'plate', width: 165, fixed: 'left' as const, className: 'v2-access-vehicle-column',
|
||
render: (_: string, row: AccessVehicleRow) => <div className="v2-access-primary-cell"><strong>{row.plate || '未绑定车牌'}</strong><span>{row.vin}</span></div>
|
||
},
|
||
{
|
||
title: '差异结论', dataIndex: 'connectionState', width: 220, fixed: 'left' as const, className: 'v2-access-difference-column',
|
||
render: (_: AccessVehicleRow['connectionState'], row: AccessVehicleRow) => <ConnectionState row={row} />
|
||
},
|
||
...PROTOCOLS.map((protocol) => ({
|
||
title: <AccessProtocolTitle protocol={protocol} compact />, dataIndex: protocol, width: 150,
|
||
render: (_: unknown, row: AccessVehicleRow) => <ProtocolState protocol={protocol} status={statusByProtocol(row, protocol)} />
|
||
})),
|
||
{
|
||
title: '品牌 / 车型', dataIndex: 'oem', width: 140, className: 'v2-access-model-column',
|
||
render: (_: string, row: AccessVehicleRow) => <div className="v2-access-primary-cell"><strong>{row.oem || '品牌未维护'}</strong><span>{row.model || row.company || '车型未维护'}</span></div>
|
||
}
|
||
], []);
|
||
|
||
return <Table
|
||
className="v2-access-semi-table"
|
||
style={{ width: '100%' }}
|
||
scroll={{ x: 975 }}
|
||
columns={columns}
|
||
dataSource={rows}
|
||
rowKey="vin"
|
||
pagination={false}
|
||
empty={<></>}
|
||
onRow={(row) => row ? detailTriggerRow({
|
||
className: [`is-state-${row.connectionState}`, selectedVIN === row.vin ? 'is-selected' : ''].filter(Boolean).join(' '),
|
||
expanded: selectedVIN === row.vin,
|
||
label: `查看 ${row.plate || row.vin} 接入详情`,
|
||
testId: `access-row-${row.vin}`,
|
||
onOpen: () => onSelect(row.vin)
|
||
}) : ({})}
|
||
/>;
|
||
});
|
||
|
||
function VehicleInspector({ row, onClose, sheet = false, mobile = false }: { row: AccessVehicleRow; onClose: () => void; sheet?: boolean; mobile?: boolean }) {
|
||
const protocols = useMemo(() => [...PROTOCOLS].sort((left, right) => protocolPriority(statusByProtocol(row, left)) - protocolPriority(statusByProtocol(row, right))), [row]);
|
||
const health = useMemo(() => {
|
||
const connected = row.protocolStatuses.filter((item) => item.connected);
|
||
return {
|
||
connected: connected.length,
|
||
online: connected.filter((item) => item.onlineState === 'online').length,
|
||
attention: connected.filter((item) => protocolNeedsAttention(item)).length
|
||
};
|
||
}, [row.protocolStatuses]);
|
||
const [expandedProtocol, setExpandedProtocol] = useState(() => protocols.find((protocol) => protocolNeedsAttention(statusByProtocol(row, protocol))) || '');
|
||
const toggleProtocol = useCallback((protocol: string) => {
|
||
setExpandedProtocol((current) => current === protocol ? '' : protocol);
|
||
}, []);
|
||
|
||
return <Card className={`v2-access-inspector-v3${sheet ? ' is-sheet' : ''}`} bodyStyle={{ padding: 0 }}>
|
||
{sheet ? null : <WorkspacePanelHeader className="v2-access-inspector-header" title={row.plate || '未绑定车牌'} description={row.vin} actions={<Button theme="borderless" icon={<IconClose />} onClick={onClose} aria-label="关闭车辆接入详情" />} />}
|
||
<Card className={`v2-access-inspector-focus is-${row.connectionState}`} bodyStyle={{ padding: 0 }}>
|
||
<div><small>{row.connectionState === 'healthy' ? '接入结论' : '优先核对'}</small><strong>{accessIssueSummary(row)}</strong><span>{row.actualProtocols.length ? `已发现 ${row.actualProtocols.join(' / ')} ${row.actualProtocols.length} 个真实来源` : '当前没有可用的真实来源证据'}</span><b>处置建议 · {accessRecommendation(row)}</b></div>
|
||
<ConnectionTag row={row} />
|
||
</Card>
|
||
<div className="v2-access-health-strip" role="group" aria-label="接入健康概览">
|
||
<span><small>已接入</small><strong>{health.connected} / {PROTOCOLS.length}</strong></span>
|
||
<span><small>当前在线</small><strong className="is-good">{health.online}</strong></span>
|
||
<span><small>异常来源</small><strong className={health.attention ? 'is-danger' : ''}>{health.attention}</strong></span>
|
||
<span><small>资料状态</small><strong className={row.masterDataIssues.length ? 'is-warning' : ''}>{row.masterDataIssues.length ? `${row.masterDataIssues.length} 项待维护` : '已维护'}</strong></span>
|
||
</div>
|
||
{mobile ? <div className="v2-access-mobile-profile"><small>车辆档案</small><strong>{[row.oem, row.model].filter(Boolean).join(' / ') || '品牌与车型未维护'}</strong></div> : <Descriptions className="v2-access-inspector-summary" align="left" size="small" data={[
|
||
{ key: '品牌 / 车型', value: [row.oem, row.model].filter(Boolean).join(' / ') || '未维护' },
|
||
{ key: '真实接入来源', value: row.actualProtocols.length ? row.actualProtocols.join(' / ') : '尚无来源' },
|
||
{ key: '资料状态', value: row.masterDataIssues.length ? row.masterDataIssues.join(';') : '已维护' }
|
||
]} />}
|
||
<div className="v2-access-protocol-heading">
|
||
<span><strong>协议证据</strong><small>异常优先展示,按需展开完整证据</small></span>
|
||
<Tag color={row.actualProtocols.length ? 'blue' : 'grey'} type="light" size="small">{row.actualProtocols.length} / {PROTOCOLS.length} 已接入</Tag>
|
||
</div>
|
||
<CardGroup className="v2-access-protocol-details" type="grid" spacing={0}>{protocols.map((protocol) => <ProtocolState
|
||
key={protocol}
|
||
protocol={protocol}
|
||
status={statusByProtocol(row, protocol)}
|
||
detailed
|
||
expanded={expandedProtocol === protocol}
|
||
onToggle={toggleProtocol}
|
||
/>)}</CardGroup>
|
||
<footer className="v2-access-inspector-footer">
|
||
<span>{row.expectationEvidence}</span>
|
||
<nav className="v2-access-inspector-actions" aria-label={`${row.plate || row.vin} 数据操作`}>
|
||
<Link to={`/vehicles/${encodeURIComponent(row.vin)}`}>车辆档案</Link>
|
||
<Link to={`/tracks?vin=${encodeURIComponent(row.vin)}`}>轨迹回放</Link>
|
||
<Link to={`/history?vin=${encodeURIComponent(row.vin)}`}>历史数据</Link>
|
||
</nav>
|
||
</footer>
|
||
</Card>;
|
||
}
|
||
|
||
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>}>
|
||
<div className="v2-access-identity-grid">{items.slice(0, 6).map((item) => <Card key={item.id} className="v2-access-identity-card" bodyStyle={{ padding: 0 }}>
|
||
<header><span><small>来源标识</small><b>{item.identifierMasked}</b></span><ProtocolTag protocol={item.protocol} compact /></header>
|
||
<div className="v2-access-identity-facts">
|
||
<span><small>关联车牌</small><strong>{item.plate || '待核对'}</strong></span>
|
||
<span><small>最后出现</small><strong><AccessTime value={item.latestSeenAt} compact /></strong></span>
|
||
</div>
|
||
<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>;
|
||
}
|
||
|
||
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>
|
||
<Input suffix="秒" type="number" value={String(value)} onChange={(next) => update(Number(next))} />
|
||
<em>{formatSeconds(value)}</em>
|
||
</label>;
|
||
return <Collapse className="v2-access-settings" keepDOM={false} lazyRender>
|
||
<Collapse.Panel itemKey="access-thresholds" header={<span className="v2-access-collapse-title"><span><strong>在线判定阈值</strong><small>统一控制来源在线、长离线和协议新鲜度判定</small></span><Tag color="blue" type="light" size="small">v{config?.version ?? '—'}</Tag></span>}>
|
||
<fieldset disabled={!editable}>
|
||
<section className="v2-access-threshold-group">
|
||
<header><strong>通用判定</strong><small>先定义全局兜底,再按协议覆盖</small></header>
|
||
<div className="v2-access-threshold-fields">
|
||
{thresholdField('全局在线阈值', '未单独配置协议时使用', draft.defaultThresholdSec, (value) => onChange({ ...draft, defaultThresholdSec: value }))}
|
||
{thresholdField('数据延迟异常', '事件时间与接收时间差值', draft.delayThresholdSec, (value) => onChange({ ...draft, delayThresholdSec: value }))}
|
||
{thresholdField('长离线阈值', '进入长期离线治理范围', draft.longOfflineSec, (value) => onChange({ ...draft, longOfflineSec: value }))}
|
||
</div>
|
||
</section>
|
||
<section className="v2-access-threshold-group">
|
||
<header><strong>协议在线阈值</strong><small>匹配不同协议的真实上报周期</small></header>
|
||
<div className="v2-access-threshold-fields">
|
||
{PROTOCOLS.map((protocol) => {
|
||
const value = draft.protocols.find((item) => item.protocol === protocol)?.thresholdSec ?? draft.defaultThresholdSec;
|
||
const meta = protocolThresholdMeta[protocol];
|
||
return <span key={protocol}>{thresholdField(meta.label, meta.description, value, (next) => onChange({ ...draft, protocols: updateProtocolThreshold(draft.protocols, protocol, next) }))}</span>;
|
||
})}
|
||
</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>
|
||
</Collapse>;
|
||
}
|
||
|
||
function downloadRows(rows: AccessVehicleRow[]) {
|
||
const blob = new Blob([accessRowsToCSV(rows)], { type: 'text/csv;charset=utf-8' });
|
||
downloadBlob(blob, `vehicle-access-${new Date().toISOString().slice(0, 10)}.csv`);
|
||
}
|
||
|
||
export default function AccessPage() {
|
||
const { session } = usePlatformSession(); const editable = canAdminister(session);
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const mobileLayout = useMobileLayout();
|
||
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]);
|
||
const summaryQuery = useQuery({ queryKey: ['access-summary'], queryFn: ({ signal }) => api.accessSummary({}, signal), staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||
const vehiclesQuery = useQuery<Page<AccessVehicleRow>>({ queryKey: ['access-vehicles', vehicleScope, limit, offset], queryFn: ({ signal }) => api.accessVehicles({ ...baseQuery, limit, offset }, signal), placeholderData: retainPreviousPageWithinScope<Page<AccessVehicleRow>>(vehicleScope), gcTime: QUERY_MEMORY.highVolumeGcTime });
|
||
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) => { 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);
|
||
}
|
||
}, [mobileLayout]);
|
||
const rows = vehiclesQuery.data?.items ?? []; const selected = rows.find((row) => row.vin === selectedVIN);
|
||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||
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 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 = (
|
||
label: string,
|
||
value: number,
|
||
connectionState: string,
|
||
options: Pick<WorkspaceQueueMetricRailItem, 'note' | 'tone' | 'emphasis'>
|
||
): WorkspaceQueueMetricRailItem => {
|
||
const formattedValue = value.toLocaleString('zh-CN');
|
||
return {
|
||
label,
|
||
value: formattedValue,
|
||
...options,
|
||
active: criteria.connectionState === connectionState,
|
||
ariaLabel: `筛选${label},共 ${formattedValue} 辆`,
|
||
onClick: () => apply({ ...criteria, connectionState })
|
||
};
|
||
};
|
||
const attentionVehicles = Math.max(0, (summary?.totalVehicles ?? 0) - (summary?.healthyVehicles ?? 0));
|
||
const accessMetrics: WorkspaceQueueMetricRailItem[] = [
|
||
accessMetric('需关注', attentionVehicles, 'attention', {
|
||
note: mobileLayout ? '异常与缺失' : '异常、离线或缺失',
|
||
tone: 'warning',
|
||
emphasis: 'primary'
|
||
}),
|
||
accessMetric('来源正常', summary?.healthyVehicles ?? 0, 'healthy', { tone: 'success' }),
|
||
accessMetric('来源异常', summary?.degradedVehicles ?? 0, 'degraded', { tone: 'warning' }),
|
||
accessMetric('尚无来源', summary?.neverReported ?? 0, 'not_connected', { tone: 'neutral' }),
|
||
accessMetric('资料待维护', summary?.masterDataIncompleteVehicles ?? 0, 'master_data', { tone: 'neutral' }),
|
||
accessMetric('全部车辆', summary?.totalVehicles ?? 0, '', {
|
||
note: mobileLayout ? '权威主车辆' : '权威主车辆口径',
|
||
tone: 'neutral',
|
||
emphasis: 'primary'
|
||
})
|
||
];
|
||
const scrollAccessTable = (event: KeyboardEvent<HTMLDivElement>) => {
|
||
if (mobileLayout || event.currentTarget !== event.target || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return;
|
||
const tableScroller = event.currentTarget.scrollWidth > event.currentTarget.clientWidth
|
||
? event.currentTarget
|
||
: event.currentTarget.querySelector<HTMLElement>('.semi-table-body');
|
||
if (!tableScroller) return;
|
||
event.preventDefault();
|
||
tableScroller.scrollBy({ left: event.key === 'ArrowRight' ? 260 : -260, behavior: 'smooth' });
|
||
};
|
||
const filterFields = <>
|
||
<label className="is-search"><span>车辆</span><Input aria-label="车辆" prefix={<IconSearch />} value={draft.keyword} onChange={(value) => setDraft({ ...draft, keyword: value })} placeholder="车牌 / VIN" /></label>
|
||
<label><span id="access-connection-label">接入状态</span><Select aria-labelledby="access-connection-label" value={draft.connectionState} onChange={(value) => setDraft({ ...draft, connectionState: String(value) })} optionList={[{ value: '', label: '全部状态' }, { value: 'attention', label: '需关注' }, { value: 'healthy', label: '已接来源正常' }, { value: 'master_data', label: '资料待维护' }, { value: 'degraded', label: '部分来源异常' }, { value: 'offline', label: '已接来源离线' }, { value: 'not_connected', label: '尚无来源' }]} /></label>
|
||
<label><span id="access-protocol-label">真实协议</span><Select aria-labelledby="access-protocol-label" value={draft.protocol} onChange={(value) => setDraft({ ...draft, protocol: String(value) })} optionList={[{ value: '', label: '全部协议' }, ...PROTOCOLS.map((item) => ({ value: item, label: item }))]} /></label>
|
||
<label><span id="access-oem-label">车辆品牌</span><Select aria-labelledby="access-oem-label" value={draft.oem} onChange={(value) => setDraft({ ...draft, oem: String(value) })} optionList={[{ value: '', label: '全部品牌' }, ...(summary?.oems.map((item) => ({ value: item.name, label: item.name })) ?? [])]} /></label>
|
||
</>;
|
||
|
||
return <div className="v2-access-page v2-access-page-v3 governance-workspace">
|
||
<div className="v2-access-discovery-shell">
|
||
<WorkspaceCommandBar
|
||
className="v2-access-command-bar"
|
||
ariaLabel="接入管理操作"
|
||
icon={<IconConnectionPoint1 />}
|
||
title="接入健康工作台"
|
||
description={mobileLayout
|
||
? summary ? `截至 ${compactTime(summary.asOf)} · ${summary.totalVehicles.toLocaleString('zh-CN')} 辆主车辆` : '正在读取最新接入状态'
|
||
: '核对真实来源、在线状态与车辆档案差异'}
|
||
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={openGovernance}>治理{unresolvedQuery.data?.total ? ` · ${unresolvedQuery.data.total}` : ''}</Button> : null}
|
||
<Dropdown
|
||
trigger="click"
|
||
position="bottomRight"
|
||
visible={mobileToolsOpen}
|
||
onVisibleChange={setMobileToolsOpen}
|
||
contentClassName="v2-workspace-mobile-tools-menu"
|
||
render={<Dropdown.Menu>
|
||
<Dropdown.Item disabled={!rows.length} icon={<IconDownload />} onClick={() => { setMobileToolsOpen(false); downloadRows(rows); }}>导出当前页</Dropdown.Item>
|
||
<Dropdown.Item icon={<IconRefresh />} onClick={() => { setMobileToolsOpen(false); void refresh(); }}>刷新数据</Dropdown.Item>
|
||
</Dropdown.Menu>}
|
||
>
|
||
<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={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
|
||
title="接入筛选"
|
||
summary={activeFilterCount ? `已启用 ${activeFilterCount} 项` : '全部主车辆'}
|
||
expanded={mobileFiltersOpen}
|
||
controls="v2-access-mobile-filters"
|
||
expandedLabel="关闭"
|
||
collapsedLabel="修改"
|
||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||
/>
|
||
<MobileFilterSheet
|
||
className="v2-access-mobile-filter-sidesheet"
|
||
visible={mobileFiltersOpen}
|
||
height="min(76dvh, 640px)"
|
||
ariaLabel="接入车辆筛选"
|
||
dialogId="v2-access-mobile-filters"
|
||
closeLabel="关闭接入车辆筛选"
|
||
title="筛选接入车辆"
|
||
description="车辆、接入状态、真实协议与品牌"
|
||
onCancel={() => setFiltersCollapsed(true)}
|
||
secondaryAction={{ label: '重置条件', onClick: resetMobileDraft }}
|
||
primaryAction={{ label: '应用并查询', onClick: applyDraft }}
|
||
>
|
||
<form className="v2-access-mobile-filter-form" onSubmit={submit}>
|
||
<MobileFilterSheetSection ariaLabel="接入范围" title="接入范围" description="优先定位异常、离线、尚无来源和资料差异车辆">
|
||
<div>{filterFields}</div>
|
||
</MobileFilterSheetSection>
|
||
</form>
|
||
</MobileFilterSheet>
|
||
</div> : <section className="governance-query-bar" aria-label="接入筛选">
|
||
<form className="v2-access-filter-v3" onSubmit={submit}>
|
||
{filterFields}
|
||
<div className="v2-access-filter-actions">
|
||
<Button className="v2-primary-button" theme="solid" htmlType="submit">查询</Button>
|
||
<Button className="v2-secondary-button" theme="light" htmlType="button" onClick={resetFilters}>重置</Button>
|
||
</div>
|
||
</form>
|
||
</section>}
|
||
</div>
|
||
{summaryQuery.isError ? <InlineError message={summaryQuery.error instanceof Error ? summaryQuery.error.message : '接入汇总读取失败'} onRetry={() => summaryQuery.refetch()} /> : null}
|
||
{vehiclesQuery.isError ? <InlineError message={vehiclesQuery.error instanceof Error ? vehiclesQuery.error.message : '接入车辆读取失败'} onRetry={() => vehiclesQuery.refetch()} /> : null}
|
||
<div className="v2-access-workspace-v3">
|
||
<Card className="v2-access-table-v3" bodyStyle={{ padding: 0 }}>
|
||
<WorkspacePanelHeader
|
||
tone="primary"
|
||
title="接入差异清单"
|
||
description={`当前范围:${scopeLabels[criteria.connectionState] || '全部主车辆'}`}
|
||
actionsClassName="v2-access-table-actions"
|
||
actions={mobileLayout ? null : <Button theme="light" icon={<IconDownload />} onClick={() => downloadRows(rows)} disabled={!rows.length}>导出当前页</Button>}
|
||
/>
|
||
<nav className="v2-access-status-tabs" aria-label="接入差异筛选">
|
||
<WorkspaceMetricRail
|
||
variant="queue"
|
||
className="v2-access-metric-rail"
|
||
ariaLabel="接入差异概览"
|
||
items={accessMetrics}
|
||
context={!mobileLayout ? <ProtocolCoverage summary={summary} /> : undefined}
|
||
/>
|
||
</nav>
|
||
<div
|
||
className={`v2-access-table-scroll-v3${mobileLayout ? ' is-mobile-scroll' : ''}`}
|
||
tabIndex={0}
|
||
aria-label={mobileLayout ? '车辆接入列表,可上下滚动' : '车辆接入表格,可使用左右方向键浏览协议来源'}
|
||
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={() => 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) => 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(selectedVIN)}
|
||
ariaLabel="车辆接入详情"
|
||
placement={mobileLayout ? 'bottom' : 'right'}
|
||
width={mobileLayout ? undefined : 520}
|
||
height={mobileLayout ? 'min(84dvh, 740px)' : undefined}
|
||
title="车辆接入详情"
|
||
description={selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : selectedVIN ? `正在定位 ${selectedVIN}` : '来源、在线状态与接入证据'}
|
||
badge={selected ? connectionLabels[selected.connectionState] : selectedVIN ? '定位中' : undefined}
|
||
badgeColor={selected ? connectionTagColor(selected.connectionState) : 'grey'}
|
||
onCancel={() => setRouteView({ selectedVIN: '' })}
|
||
>
|
||
{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={governanceMode === 'claim' ? '来源身份认领' : '接入治理配置'}
|
||
closeLabel={governanceMode === 'claim' ? '关闭来源身份认领' : '关闭接入治理配置'}
|
||
placement={mobileLayout ? 'bottom' : 'right'}
|
||
width={mobileLayout ? undefined : governanceMode === 'claim' ? 640 : 560}
|
||
height={mobileLayout ? 'min(88dvh, 780px)' : undefined}
|
||
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'),
|
||
detail: (unresolvedQuery.data?.total ?? 0) > 0 ? '需要核对权威 VIN' : '当前没有身份待办',
|
||
tone: (unresolvedQuery.data?.total ?? 0) > 0 ? 'warning' : 'success'
|
||
},
|
||
{
|
||
label: '阈值版本',
|
||
value: `v${thresholdQuery.data?.version ?? '—'}`,
|
||
detail: '在线与延迟统一口径',
|
||
tone: 'primary'
|
||
},
|
||
{
|
||
label: '主车辆口径',
|
||
value: (summary?.totalVehicles ?? 0).toLocaleString('zh-CN'),
|
||
detail: '仅统计权威主车辆',
|
||
tone: 'neutral'
|
||
}
|
||
]}
|
||
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 ? 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>;
|
||
}
|