feat: expand vehicle data platform capabilities
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user