592 lines
40 KiB
TypeScript
592 lines
40 KiB
TypeScript
import { IconChevronDown, IconChevronRight, IconClose, IconDownload, IconMore, IconRefresh, IconSave, IconSearch, IconSetting } from '@douyinfe/semi-icons';
|
||
import { Button, Card, CardGroup, Collapse, Descriptions, Dropdown, Empty, Input, Select, Spin, 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 { 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 { 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 { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
|
||
import { WorkspaceMetricRail, type WorkspaceQueueMetricRailItem } from '../shared/WorkspaceMetricRail';
|
||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||
import { ProtocolTag } from '../shared/ProtocolTag';
|
||
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;
|
||
const protocolThresholdMeta: Record<(typeof PROTOCOLS)[number], { 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 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;
|
||
}
|
||
|
||
const ProtocolState = memo(function ProtocolState({
|
||
status,
|
||
detailed = false,
|
||
protocolLabel,
|
||
expanded = false,
|
||
onToggle
|
||
}: {
|
||
status?: AccessProtocolStatus;
|
||
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 protocol = status?.protocol || '未知协议';
|
||
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={<strong>{protocol}</strong>}
|
||
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}
|
||
<span><i />{label}</span>
|
||
<strong>{status?.connected ? <AccessTime value={status.latestReceivedAt} compact /> : '—'}</strong>
|
||
<small>{status?.provider || (status?.connected ? '接入方未维护' : '未发现来源')}</small>
|
||
</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}`}>
|
||
<ConnectionTag row={row} />
|
||
<span title={summary}>{summary}</span>
|
||
<small>{row.actualProtocols.length ? `${row.actualProtocols.length} 个真实来源 · ${row.actualProtocols.join(' / ')}` : '当前没有真实来源证据'}</small>
|
||
</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: 'oem', width: 140, fixed: 'left' as const, 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>
|
||
},
|
||
...PROTOCOLS.map((protocol) => ({
|
||
title: protocol, dataIndex: protocol, width: 160,
|
||
render: (_: unknown, row: AccessVehicleRow) => <ProtocolState status={statusByProtocol(row, protocol)} />
|
||
})),
|
||
{
|
||
title: '差异摘要', dataIndex: 'connectionState', width: 220,
|
||
render: (_: AccessVehicleRow['connectionState'], row: AccessVehicleRow) => <ConnectionState row={row} />
|
||
}
|
||
], []);
|
||
|
||
return <Table
|
||
className="v2-access-semi-table"
|
||
style={{ width: '100%' }}
|
||
scroll={{ x: 1005 }}
|
||
columns={columns}
|
||
dataSource={rows}
|
||
rowKey="vin"
|
||
pagination={false}
|
||
empty={null}
|
||
onRow={(row) => row ? detailTriggerRow({
|
||
className: selectedVIN === row.vin ? 'is-selected' : '',
|
||
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}
|
||
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 }: { items: AccessUnresolvedIdentity[]; total: number }) {
|
||
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><small>建议动作</small><strong>{item.recommendedAction}</strong></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 }) {
|
||
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}
|
||
{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 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 [mobileToolsOpen, setMobileToolsOpen] = useState(false);
|
||
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) => { 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'] })]); } });
|
||
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 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 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?.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">
|
||
<div className="v2-access-discovery-shell">
|
||
<WorkspaceCommandBar
|
||
className="v2-access-command-bar"
|
||
ariaLabel="接入管理操作"
|
||
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={() => setGovernanceOpen(true)}>治理{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={() => setGovernanceOpen(true)}>接入治理{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> : <WorkspaceFilterPanel
|
||
className="v2-access-filter-card-v3"
|
||
title="接入筛选"
|
||
description="车辆、接入状态、真实协议与品牌"
|
||
mobileSummary={activeFilterCount ? `已启用 ${activeFilterCount} 项` : '全部主车辆'}
|
||
expanded={!filtersCollapsed}
|
||
status={activeFilterCount ? `${activeFilterCount} 项已启用` : '全部主车辆'}
|
||
statusColor={activeFilterCount ? 'blue' : 'grey'}
|
||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||
>
|
||
<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>
|
||
</WorkspaceFilterPanel>}
|
||
</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
|
||
title={scopeLabels[criteria.connectionState] || '车辆接入清单'}
|
||
description="缺席协议仅表示当前未发现;优先核对异常、离线与资料差异"
|
||
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${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} protocolLabel={compactProtocolLabel(protocol)} status={statusByProtocol(row, protocol)} />)}</span><footer>查看接入详情<IconChevronRight /></footer></span></Button></Card>)}</div>
|
||
: <AccessVehicleTable rows={rows} selectedVIN={selectedVIN} onSelect={setSelectedVIN} />}
|
||
{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>
|
||
</Card>
|
||
</div>
|
||
<WorkspaceDetailSideSheet
|
||
className="v2-access-detail-sidesheet"
|
||
visible={Boolean(selected)}
|
||
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}
|
||
badgeColor={selected ? connectionTagColor(selected.connectionState) : 'grey'}
|
||
onCancel={() => setSelectedVIN('')}
|
||
>
|
||
{selected ? <VehicleInspector key={selected.vin} row={selected} onClose={() => setSelectedVIN('')} sheet mobile={mobileLayout} /> : null}
|
||
</WorkspaceDetailSideSheet>
|
||
{editable ? <WorkspaceSideSheet
|
||
className="v2-access-governance-sidesheet"
|
||
variant="config"
|
||
visible={governanceOpen}
|
||
ariaLabel="接入治理配置"
|
||
closeLabel="关闭接入治理配置"
|
||
placement={mobileLayout ? 'bottom' : 'right'}
|
||
width={mobileLayout ? undefined : 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={[
|
||
{
|
||
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="阈值保存后即时生效;关闭工作台不会丢失已保存配置"
|
||
primaryAction={{ label: '完成', onClick: () => setGovernanceOpen(false) }}
|
||
onCancel={() => setGovernanceOpen(false)}
|
||
>
|
||
{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}
|
||
</WorkspaceSideSheet> : null}
|
||
</div>;
|
||
}
|