feat: expand vehicle data platform capabilities

This commit is contained in:
lingniu
2026-07-27 16:46:15 +08:00
parent e3a1f80f86
commit 3c4bece72c
650 changed files with 62155 additions and 2552 deletions
@@ -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>;
}