551 lines
40 KiB
TypeScript
551 lines
40 KiB
TypeScript
import { IconRefresh, IconSearch, IconTickCircle } from '@douyinfe/semi-icons';
|
||
import { Button, Card, CardGroup, Checkbox, Descriptions, Empty, Input, Progress, SideSheet, Spin, Table, Tag, Typography } from '@douyinfe/semi-ui';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { FormEvent, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||
import { useSearchParams } from 'react-router-dom';
|
||
import { api } from '../../api/client';
|
||
import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
|
||
import { InlineError } from '../shared/AsyncState';
|
||
import { MobileFilterToggle } from '../shared/MobileFilterToggle';
|
||
import { PlatformTime } from '../shared/PlatformTime';
|
||
import { SegmentedTabs } from '../shared/SegmentedTabs';
|
||
import { VehicleCandidateList } from '../shared/VehicleCandidateList';
|
||
import { WorkspaceCommandBar } from '../shared/WorkspaceCommandBar';
|
||
import { WorkspaceFilterPanel } from '../shared/WorkspaceFilterPanel';
|
||
import { WorkspacePanelHeader } from '../shared/WorkspacePanelHeader';
|
||
import { WorkspaceSideSheet } from '../shared/WorkspaceSideSheet';
|
||
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
|
||
import { useMobileLayout } from '../hooks/useMobileLayout';
|
||
import { useSideSheetA11y } from '../hooks/useSideSheetA11y';
|
||
import ReconciliationCenter from './ReconciliationCenter';
|
||
|
||
type OperationsWorkspace = 'reconciliation' | 'diagnostic' | 'health';
|
||
const operationsWorkspaces = new Set<OperationsWorkspace>(['reconciliation', 'diagnostic', 'health']);
|
||
|
||
function statusLabel(status: string) {
|
||
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
|
||
}
|
||
|
||
const opsLinkLabels: Record<string, string> = {
|
||
'MySQL realtime': 'MySQL 实时连接',
|
||
vehicle_realtime_snapshot: '车辆实时快照',
|
||
vehicle_realtime_location: '车辆实时位置',
|
||
'TDengine raw_frames': 'TDengine 原始帧',
|
||
'Capacity check': '容量与消费积压',
|
||
'Redis online keys': 'Redis 在线状态',
|
||
'Alert Kafka stream': '告警消费链路'
|
||
};
|
||
|
||
function opsLinkLabel(name: string) {
|
||
return opsLinkLabels[name] ?? name;
|
||
}
|
||
|
||
function HealthTag({ status, children }: { status: string; children?: string }) {
|
||
const color = status === 'ok' ? 'green' : status === 'warning' ? 'orange' : status === 'error' ? 'red' : 'grey';
|
||
return <Tag className={`v2-ops-health-tag is-${status}`} color={color} type="light" size="small"><i />{children ?? statusLabel(status)}</Tag>;
|
||
}
|
||
|
||
function number(value?: number, digits = 1) {
|
||
return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits });
|
||
}
|
||
|
||
function OpsMetric({ label, value, detail, tone = 'neutral' }: {
|
||
label: string;
|
||
value: string;
|
||
detail: string;
|
||
tone?: 'neutral' | 'success' | 'warning' | 'danger';
|
||
}) {
|
||
return <article className={`v2-ops-metric is-${tone}`} role="listitem">
|
||
<small>{label}</small>
|
||
<strong>{value}</strong>
|
||
<span>{detail}</span>
|
||
</article>;
|
||
}
|
||
|
||
function sourceOnlineRate(online: number, total: number, onlineRate?: number) {
|
||
if (onlineRate != null && Number.isFinite(onlineRate)) return Math.max(0, Math.min(100, onlineRate));
|
||
return total > 0 ? Math.max(0, Math.min(100, (online / total) * 100)) : 0;
|
||
}
|
||
|
||
function SourcePolicyEditor({ vin, source, diagnostic, editable, onSaved }: {
|
||
vin: string;
|
||
source: VehicleLocationSourceEvidence;
|
||
diagnostic: VehicleSourceDiagnostic;
|
||
editable: boolean;
|
||
onSaved: (next: VehicleSourceDiagnostic) => void;
|
||
}) {
|
||
const [enabled, setEnabled] = useState(source.enabled);
|
||
const [priority, setPriority] = useState(source.priority);
|
||
const [providerName, setProviderName] = useState(source.providerOverride || '');
|
||
const [providerEvidence, setProviderEvidence] = useState('');
|
||
const [remark, setRemark] = useState(source.policyRemark || '');
|
||
useEffect(() => {
|
||
setEnabled(source.enabled);
|
||
setPriority(source.priority);
|
||
setProviderName(source.providerOverride || '');
|
||
setProviderEvidence('');
|
||
setRemark(source.policyRemark || '');
|
||
}, [source.enabled, source.priority, source.providerOverride, source.policyRemark, source.sourceRef]);
|
||
const save = useMutation({
|
||
mutationFn: () => api.updateVehicleSourcePolicy(vin, {
|
||
version: diagnostic.policy.version,
|
||
sourceRef: source.sourceRef ?? '',
|
||
providerName,
|
||
providerEvidence,
|
||
enabled,
|
||
priority,
|
||
remark
|
||
}),
|
||
onSuccess: onSaved
|
||
});
|
||
const providerChanged = providerName.trim() !== (source.providerOverride || '');
|
||
const policyEditable = source.sourceKind !== 'CANONICAL';
|
||
const policyChanged = enabled !== source.enabled || priority !== source.priority || remark.trim() !== (source.policyRemark || '');
|
||
const changed = (policyEditable && policyChanged) || providerChanged;
|
||
return <div className="v2-source-policy-cell">
|
||
<Checkbox checked={enabled} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setEnabled(Boolean(event.target.checked))} aria-label="启用">启用</Checkbox>
|
||
<Input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={String(priority)} disabled={!editable || !policyEditable || save.isPending} onChange={(value) => setPriority(Number(value))} />
|
||
<Input className="v2-source-provider-input" aria-label={`${source.sourceLabel} 提供方`} value={providerName} maxLength={128} disabled={!editable || save.isPending} onChange={setProviderName} placeholder="提供方,如 G7s" />
|
||
<Input className="v2-source-provider-evidence-input" aria-label={`${source.sourceLabel} 提供方核验依据`} value={providerEvidence} maxLength={255} disabled={!editable || save.isPending || !providerChanged} onChange={setProviderEvidence} placeholder={providerChanged ? '权威终端清单、厂商确认记录等(必填)' : '修改提供方后填写核验依据'} />
|
||
<Input className="v2-source-policy-remark-input" aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || !policyEditable || save.isPending} onChange={setRemark} placeholder={policyEditable ? '启停或优先级调整原因(可选)' : '协议融合快照不可调整策略'} />
|
||
<Button theme="solid" size="small" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000 || (providerChanged && !providerEvidence.trim())} onClick={() => save.mutate()}>{save.isPending ? '保存中' : policyEditable ? '保存策略' : '保存提供方'}</Button>
|
||
{save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
|
||
</div>;
|
||
}
|
||
|
||
function SourceIdentity({ source }: { source: VehicleLocationSourceEvidence }) {
|
||
return <div className="v2-source-cell v2-source-identity-cell"><span><strong>{source.sourceLabel}</strong>{source.recommended ? <Tag color="green" type="light" size="small">推荐</Tag> : null}</span><small>{source.terminalLabel || source.sourceKind || '未维护终端'}</small></div>;
|
||
}
|
||
|
||
function SourceProtocol({ source }: { source: VehicleLocationSourceEvidence }) {
|
||
return <div className="v2-source-cell"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</small></div>;
|
||
}
|
||
|
||
function SourceHealth({ source }: { source: VehicleLocationSourceEvidence }) {
|
||
return <div className="v2-source-cell v2-source-health-cell"><span><i className={source.online ? 'is-online' : 'is-offline'} /><strong>{source.online ? '在线' : '离线'}</strong></span><small>{source.qualityStatus || '未知'}{source.qualityReason ? ` · ${source.qualityReason}` : ''}</small></div>;
|
||
}
|
||
|
||
function SourceTiming({ source }: { source: VehicleLocationSourceEvidence }) {
|
||
return <div className="v2-source-cell"><strong><PlatformTime className="v2-ops-time" value={source.firstSeenAt} sourceZone="utc" /></strong><small>最近 <PlatformTime className="v2-ops-time" value={source.receivedAt || source.eventTime} sourceZone="utc" /></small></div>;
|
||
}
|
||
|
||
function SourceInterval({ source }: { source: VehicleLocationSourceEvidence }) {
|
||
return <div className="v2-source-cell"><strong>{source.reportIntervalSec == null ? '—' : `${source.reportIntervalSec}s`}</strong><small>{source.reportSampleCount ? `${source.reportSampleCount.toLocaleString('zh-CN')} 个里程样本` : '暂无累计样本'}</small></div>;
|
||
}
|
||
|
||
function SourcePosition({ source }: { source: VehicleLocationSourceEvidence }) {
|
||
return <div className="v2-source-cell"><strong>{source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}</strong><small>{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km</small></div>;
|
||
}
|
||
|
||
function SourceDecision({ source }: { source: VehicleLocationSourceEvidence }) {
|
||
return <div className="v2-source-cell v2-source-reason"><strong>{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}</strong><small>{source.selectionReason || '等待选举说明'}</small></div>;
|
||
}
|
||
|
||
function sourceRowKey(source?: VehicleLocationSourceEvidence) {
|
||
return source?.sourceRef || `${source?.protocol ?? ''}-${source?.sourceLabel ?? ''}-${source?.terminalLabel ?? ''}`;
|
||
}
|
||
|
||
function SourcePolicySummary({ source, onEdit }: {
|
||
source: VehicleLocationSourceEvidence;
|
||
onEdit: () => void;
|
||
}) {
|
||
return <div className="v2-source-policy-summary">
|
||
<span><Tag color={source.enabled ? 'green' : 'grey'} type="light" size="small">{source.enabled ? '已启用' : '已禁用'}</Tag><b>优先级 {source.priority}</b></span>
|
||
<small>{source.providerOverride || '提供方未维护'}</small>
|
||
<Button theme="light" size="small" onClick={onEdit}>{source.sourceKind === 'CANONICAL' ? '维护提供方' : '维护策略'}</Button>
|
||
</div>;
|
||
}
|
||
|
||
function SourcePolicySheet({ vin, source, diagnostic, editable, onClose, onSaved }: {
|
||
vin: string;
|
||
source?: VehicleLocationSourceEvidence;
|
||
diagnostic: VehicleSourceDiagnostic;
|
||
editable: boolean;
|
||
onClose: () => void;
|
||
onSaved: (next: VehicleSourceDiagnostic) => void;
|
||
}) {
|
||
const mobileLayout = useMobileLayout();
|
||
return <WorkspaceSideSheet
|
||
className="v2-source-policy-sidesheet"
|
||
visible={Boolean(source)}
|
||
ariaLabel="车辆来源策略"
|
||
closeLabel="关闭车辆来源策略"
|
||
placement={mobileLayout ? 'bottom' : 'right'}
|
||
width={mobileLayout ? undefined : 460}
|
||
height={mobileLayout ? 'min(76dvh, 660px)' : undefined}
|
||
title={source?.sourceLabel ?? '来源策略'}
|
||
description={source ? `${source.terminalLabel || source.protocol} · ${source.sourceKind === 'CANONICAL' ? '仅维护提供方' : '来源策略'}` : '来源策略'}
|
||
badge={source ? source.enabled ? '策略已启用' : '策略已停用' : undefined}
|
||
badgeColor={source?.enabled ? 'green' : 'grey'}
|
||
summaryItems={source ? [
|
||
{ label: '实时状态', value: source.online ? '在线' : '离线', detail: source.qualityReason || source.qualityStatus || '等待质量判定', tone: source.online ? 'success' : 'warning' },
|
||
{ label: '当前优先级', value: source.priority, detail: source.enabled ? '参与来源选举' : '不参与来源选举', tone: source.enabled ? 'primary' : 'neutral' },
|
||
{ label: '选举结论', value: source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源', detail: source.selectionReason || '等待选举说明' }
|
||
] : []}
|
||
onCancel={onClose}
|
||
footerNote="策略修改需点击表单内的保存按钮后才会生效。"
|
||
primaryAction={{ label: '完成', onClick: onClose }}
|
||
>
|
||
{source ? <div className="v2-source-policy-sheet-content"><SourcePolicyEditor vin={vin} source={source} diagnostic={diagnostic} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => { onSaved(next); onClose(); }} /></div> : null}
|
||
</WorkspaceSideSheet>;
|
||
}
|
||
|
||
function SourceDiagnosticTable({ vin, sources, diagnostic, editable, onSaved }: {
|
||
vin: string;
|
||
sources: VehicleLocationSourceEvidence[];
|
||
diagnostic: VehicleSourceDiagnostic;
|
||
editable: boolean;
|
||
onSaved: (next: VehicleSourceDiagnostic) => void;
|
||
}) {
|
||
const [editingSource, setEditingSource] = useState<VehicleLocationSourceEvidence>();
|
||
const columns = [
|
||
{ title: '来源 / 终端', dataIndex: 'sourceLabel', width: 180, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceIdentity source={source} /> },
|
||
{ title: '协议', dataIndex: 'protocol', width: 120, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceProtocol source={source} /> },
|
||
{ title: '在线 / 质量', dataIndex: 'online', width: 190, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceHealth source={source} /> },
|
||
{ title: '首次 / 最近上报', dataIndex: 'firstSeenAt', width: 220, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceTiming source={source} /> },
|
||
{ title: '上报周期', dataIndex: 'reportIntervalSec', width: 145, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceInterval source={source} /> },
|
||
{ title: '位置 / 里程', dataIndex: 'longitude', width: 230, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourcePosition source={source} /> },
|
||
{ title: '选举结论', dataIndex: 'recommended', width: 220, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourceDecision source={source} /> },
|
||
{ title: '运维策略', dataIndex: 'policy', width: 200, render: (_: unknown, source: VehicleLocationSourceEvidence) => <SourcePolicySummary source={source} onEdit={() => setEditingSource(source)} /> }
|
||
];
|
||
return <><div className="v2-source-table-wrap"><Table
|
||
className="v2-source-table"
|
||
columns={columns}
|
||
dataSource={sources}
|
||
rowKey={sourceRowKey}
|
||
pagination={false}
|
||
scroll={{ x: 1505 }}
|
||
empty={null}
|
||
onRow={(source) => source ? ({ className: source.recommended ? 'is-recommended' : '' }) : ({})}
|
||
/></div>
|
||
<SourcePolicySheet vin={vin} source={editingSource} diagnostic={diagnostic} editable={editable} onClose={() => setEditingSource(undefined)} onSaved={onSaved} />
|
||
</>;
|
||
}
|
||
|
||
function SourceDiagnosticCards({ vin, sources, diagnostic, editable, onSaved }: {
|
||
vin: string;
|
||
sources: VehicleLocationSourceEvidence[];
|
||
diagnostic: VehicleSourceDiagnostic;
|
||
editable: boolean;
|
||
onSaved: (next: VehicleSourceDiagnostic) => void;
|
||
}) {
|
||
const [editingSource, setEditingSource] = useState<VehicleLocationSourceEvidence>();
|
||
return <><div className="v2-source-mobile-list">{sources.map((source) => <Card className={`v2-source-mobile-card${source.recommended ? ' is-recommended' : ''}`} bodyStyle={{ padding: 0 }} key={sourceRowKey(source)}>
|
||
<header><SourceIdentity source={source} /><SourceHealth source={source} /></header>
|
||
<Descriptions className="v2-source-mobile-descriptions" align="left" size="small" data={[
|
||
{ key: '协议状态', value: <SourceProtocol source={source} /> },
|
||
{ key: '首次 / 最近上报', value: <SourceTiming source={source} /> },
|
||
{ key: '上报周期', value: <SourceInterval source={source} /> },
|
||
{ key: '位置 / 里程', value: <SourcePosition source={source} /> },
|
||
{ key: '选举结论', value: <SourceDecision source={source} /> }
|
||
]} />
|
||
<section><strong>运维策略</strong><SourcePolicySummary source={source} onEdit={() => setEditingSource(source)} /></section>
|
||
</Card>)}</div>
|
||
<SourcePolicySheet vin={vin} source={editingSource} diagnostic={diagnostic} editable={editable} onClose={() => setEditingSource(undefined)} onSaved={onSaved} />
|
||
</>;
|
||
}
|
||
|
||
function SourceDiagnosticWorkspace() {
|
||
const queryClient = useQueryClient();
|
||
const mobileLayout = useMobileLayout();
|
||
const [keyword, setKeyword] = useState('');
|
||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||
const [selected, setSelected] = useState<VehicleCoverageRow>();
|
||
const [candidateOffset, setCandidateOffset] = useState(0);
|
||
const [filtersCollapsed, setFiltersCollapsed] = useState(() => mobileLayout);
|
||
useEffect(() => setCandidateOffset(0), [deferredKeyword]);
|
||
const candidates = useQuery({
|
||
queryKey: ['ops-source-candidates', deferredKeyword, candidateOffset],
|
||
queryFn: ({ signal }) => api.vehicleCoverage(new URLSearchParams({ keyword: deferredKeyword, bindingStatus: 'bound', limit: '20', offset: String(candidateOffset) }), signal),
|
||
enabled: deferredKeyword.length > 0,
|
||
staleTime: 15_000,
|
||
gcTime: QUERY_MEMORY.optionGcTime
|
||
});
|
||
const session = useQuery({ queryKey: ['ops-session'], queryFn: ({ signal }) => api.session(signal), staleTime: 60_000 });
|
||
const diagnostic = useQuery({
|
||
queryKey: ['ops-source-diagnostic', selected?.vin],
|
||
queryFn: ({ signal }) => api.vehicleSourceDiagnostic(selected!.vin, signal),
|
||
enabled: Boolean(selected?.vin),
|
||
staleTime: 5_000,
|
||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||
});
|
||
const choose = (vehicle: VehicleCoverageRow) => {
|
||
setSelected(vehicle);
|
||
setKeyword(vehicle.plate || vehicle.vin);
|
||
if (mobileLayout) setFiltersCollapsed(true);
|
||
};
|
||
const chooseFirst = () => {
|
||
const first = candidates.data?.items[0];
|
||
if (first) choose(first);
|
||
};
|
||
const submit = (event: FormEvent) => {
|
||
event.preventDefault();
|
||
chooseFirst();
|
||
};
|
||
const clearSelection = () => {
|
||
setKeyword('');
|
||
setSelected(undefined);
|
||
setFiltersCollapsed(true);
|
||
};
|
||
const data = diagnostic.data;
|
||
const editable = session.data?.role === 'admin';
|
||
const selectedLabel = selected ? selected.plate || selected.vin : '';
|
||
const browsingCandidates = Boolean(deferredKeyword) && deferredKeyword !== selectedLabel;
|
||
const mobileFiltersOpen = mobileLayout && !filtersCollapsed;
|
||
const closeMobileFilters = () => {
|
||
if (selectedLabel) setKeyword(selectedLabel);
|
||
setFiltersCollapsed(true);
|
||
};
|
||
useSideSheetA11y(mobileFiltersOpen, '.v2-source-mobile-filter-sidesheet', 'v2-source-mobile-filter-sheet', '诊断车辆筛选', '关闭诊断车辆筛选');
|
||
const filterStatus = selectedLabel
|
||
? `已选 ${selectedLabel}`
|
||
: deferredKeyword
|
||
? candidates.isFetching ? '正在查找' : `${candidates.data?.total ?? 0} 辆候选`
|
||
: '等待选择车辆';
|
||
const onSourceSaved = (next: VehicleSourceDiagnostic) => {
|
||
queryClient.setQueryData(['ops-source-diagnostic', next.evidence.vin], next);
|
||
void Promise.all([
|
||
queryClient.invalidateQueries({ queryKey: ['access-summary'] }),
|
||
queryClient.invalidateQueries({ queryKey: ['access-vehicles'] }),
|
||
queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] })
|
||
]);
|
||
};
|
||
const sourceSearchInput = <Input
|
||
aria-label="按车牌或 VIN 搜索诊断车辆"
|
||
prefix={<IconSearch />}
|
||
value={keyword}
|
||
onChange={setKeyword}
|
||
placeholder="输入车牌或 VIN,支持模糊搜索"
|
||
/>;
|
||
const sourceCandidateList = browsingCandidates ? <VehicleCandidateList
|
||
className="v2-source-candidates"
|
||
items={candidates.data?.items ?? []}
|
||
loading={candidates.isFetching}
|
||
loadingText="正在查询车辆…"
|
||
emptyText="没有匹配的已绑定车辆"
|
||
actionLabel="诊断"
|
||
showProtocols
|
||
onSelect={choose}
|
||
footer={(candidates.data?.total ?? 0) > 20 ? <><span>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页</span><div><Button theme="light" size="small" disabled={candidateOffset === 0} onClick={() => setCandidateOffset(Math.max(0, candidateOffset - 20))}>上一页</Button><Button theme="light" size="small" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setCandidateOffset(candidateOffset + 20)}>下一页</Button></div></> : undefined}
|
||
/> : null;
|
||
return <>
|
||
{mobileLayout ? <div className="v2-source-mobile-discovery">
|
||
<MobileFilterToggle
|
||
title="诊断车辆"
|
||
summary={selectedLabel ? `已选 ${selectedLabel}` : '尚未选择车辆'}
|
||
expanded={mobileFiltersOpen}
|
||
expandedLabel="关闭"
|
||
collapsedLabel="修改"
|
||
onToggle={() => mobileFiltersOpen ? closeMobileFilters() : setFiltersCollapsed(false)}
|
||
/>
|
||
<SideSheet
|
||
className="v2-source-mobile-filter-sidesheet"
|
||
visible={mobileFiltersOpen}
|
||
placement="bottom"
|
||
height="min(78dvh, 660px)"
|
||
aria-label="诊断车辆筛选"
|
||
title={<div className="v2-source-mobile-filter-title"><strong>选择诊断车辆</strong><span>按车牌或 VIN 搜索已绑定车辆</span></div>}
|
||
onCancel={closeMobileFilters}
|
||
footer={<div className="v2-source-mobile-filter-footer"><Button theme="light" disabled={!selected && !keyword} onClick={clearSelection}>清除选择</Button><Button theme="solid" disabled={!browsingCandidates || !candidates.data?.items.length} onClick={chooseFirst}>诊断首个结果</Button></div>}
|
||
>
|
||
<form className="v2-source-mobile-filter-form" onSubmit={submit}>
|
||
<section className="v2-source-mobile-filter-section" aria-label="诊断车辆搜索">
|
||
<header><strong>车辆范围</strong><span>{selectedLabel ? `当前诊断 ${selectedLabel},选择其他车辆后再切换` : '每次只读取一辆车的来源证据,不扫描整车队'}</span></header>
|
||
{sourceSearchInput}
|
||
</section>
|
||
{sourceCandidateList}
|
||
</form>
|
||
</SideSheet>
|
||
</div> : <WorkspaceFilterPanel
|
||
className="v2-source-filter-panel"
|
||
title="诊断车辆"
|
||
description="按车牌或 VIN 选择一辆车,仅按需加载该车来源证据"
|
||
mobileSummary={selectedLabel ? `已选 ${selectedLabel}` : deferredKeyword ? `正在查找 ${deferredKeyword}` : '尚未选择车辆'}
|
||
expanded={!filtersCollapsed}
|
||
status={filterStatus}
|
||
statusColor={selectedLabel ? 'blue' : 'grey'}
|
||
collapsedLabel="修改"
|
||
onToggle={() => setFiltersCollapsed((value) => !value)}
|
||
>
|
||
<form className={`v2-source-search${filtersCollapsed ? ' is-mobile-collapsed' : ''}`} onSubmit={submit}>
|
||
{sourceSearchInput}
|
||
<Button htmlType="submit" theme="solid" disabled={!browsingCandidates || !candidates.data?.items.length}>诊断首个结果</Button>
|
||
{sourceCandidateList}
|
||
</form>
|
||
</WorkspaceFilterPanel>}
|
||
<Card className="v2-source-diagnostic" bodyStyle={{ padding: 0 }}>
|
||
<WorkspacePanelHeader
|
||
title="单车多来源诊断"
|
||
description={selected ? `${selected.plate || '未绑定车牌'} · ${selected.vin}` : '协议、同协议多终端、推荐原因与可审计策略'}
|
||
meta={<Tag color={data ? 'blue' : 'grey'} type="light" size="small">{data ? `${data.evidence.locationSources.length} 个来源` : selected ? '正在读取证据' : '等待选择车辆'}</Tag>}
|
||
actions={selected ? <Button theme="light" icon={<IconRefresh />} onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}>刷新该车</Button> : null}
|
||
/>
|
||
{diagnostic.isError ? <InlineError message={diagnostic.error.message} onRetry={() => diagnostic.refetch()} /> : null}
|
||
{!selected ? <Empty className="v2-source-empty" title="先选择一辆车" description="将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。" /> : null}
|
||
{selected && diagnostic.isPending ? <div className="v2-source-loading" role="status"><Spin size="large" /><strong>正在读取来源证据</strong><span>只查询当前车辆,不会扫描整车队。</span></div> : null}
|
||
{data ? <>
|
||
<section className="v2-source-summary-region" role="group" aria-label="车辆来源诊断概览">
|
||
<CardGroup className="v2-source-summary" type="grid" spacing={10}>
|
||
<Card className="v2-source-summary-card is-vehicle"><small>车辆</small><strong>{data.evidence.plate || selected?.plate || '未绑定车牌'}</strong><span>{data.evidence.vin}</span></Card>
|
||
<Card className="v2-source-summary-card is-recommended"><small>当前推荐</small><strong>{data.evidence.recommendedLocationLabel || '暂无推荐'}</strong><Tag color="blue" type="light" size="small">{data.evidence.recommendedLocationProtocol || '—'}</Tag></Card>
|
||
<Card className={`v2-source-summary-card is-source-health${data.evidence.locationConflict ? ' is-warning' : ' is-ok'}`}><small>来源状态</small><strong>{data.evidence.locationSources.filter((item) => item.online).length} / {data.evidence.locationSources.length} 在线</strong><span>{data.evidence.locationConflict ? `位置冲突 ${number(data.evidence.conflictDistanceM)}m` : '未发现实时位置冲突'}</span></Card>
|
||
<Card className="v2-source-summary-card is-policy"><small>策略版本</small><strong>v{data.policy.version}</strong><span>{data.policy.updatedAt ? <>{data.policy.updatedBy} · <PlatformTime className="v2-ops-time" value={data.policy.updatedAt} sourceZone="utc" /></> : '尚无人工调整'}</span></Card>
|
||
</CardGroup>
|
||
</section>
|
||
<Card className="v2-source-recommendation"><header><Tag color="blue" type="light" size="small">推荐说明</Tag><span>{data.refreshHint}</span></header><p>{data.recommendationReason}</p></Card>
|
||
{!editable ? <div className="v2-source-readonly"><Tag color="orange" type="light" size="small">只读权限</Tag><span>当前账号可查看诊断证据;只有管理员可以调整启停和优先级。</span></div> : null}
|
||
{mobileLayout
|
||
? <SourceDiagnosticCards vin={data.evidence.vin} sources={data.evidence.locationSources} diagnostic={data} editable={editable} onSaved={onSourceSaved} />
|
||
: <SourceDiagnosticTable vin={data.evidence.vin} sources={data.evidence.locationSources} diagnostic={data} editable={editable} onSaved={onSourceSaved} />}
|
||
<Card className="v2-source-audit" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="最近策略审计" description="仅记录实际变更,原始 source_key 不对前端暴露" />
|
||
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.changeType}-${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} · <PlatformTime className="v2-ops-time" value={item.changedAt} sourceZone="utc" /></em></li>)}</ol> : <p>该车辆尚无人工来源策略或提供方变更。</p>}
|
||
</Card>
|
||
</> : null}
|
||
</Card>
|
||
</>;
|
||
}
|
||
|
||
export default function OperationsPage() {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const requestedWorkspace = searchParams.get('view') as OperationsWorkspace | null;
|
||
const workspace = requestedWorkspace && operationsWorkspaces.has(requestedWorkspace) ? requestedWorkspace : 'reconciliation';
|
||
const setWorkspace = (next: OperationsWorkspace) => {
|
||
const copy = new URLSearchParams(searchParams);
|
||
if (next === 'reconciliation') copy.delete('view');
|
||
else copy.set('view', next);
|
||
setSearchParams(copy, { replace: true });
|
||
};
|
||
const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: ({ signal }) => api.opsHealth(signal), refetchInterval: 15_000, staleTime: 8_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY });
|
||
const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: ({ signal }) => api.sourceReadiness(signal), refetchInterval: 30_000, staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY });
|
||
const reconciliation = useQuery({ queryKey: ['reconciliation-summary', 30], queryFn: ({ signal }) => api.reconciliationSummary(30, signal), staleTime: 60_000, gcTime: QUERY_MEMORY.summaryGcTime });
|
||
const data = health.data; const sources = readiness.data;
|
||
const refresh = () => Promise.all([health.refetch(), readiness.refetch(), reconciliation.refetch()]);
|
||
const linkIssueCount = data?.linkHealth.filter((item) => item.status !== 'ok').length ?? 0;
|
||
const runtimeIssueCount = data ? Number(!data.mysqlWritable) + Number(!data.tdengineWritable) + Number(data.runtime.dataMode !== 'production') : 0;
|
||
const capacityIssueCount = data?.capacityFindings.length ?? 0;
|
||
const infrastructureIssueCount = linkIssueCount + runtimeIssueCount + capacityIssueCount + Number((data?.kafkaLag ?? 0) > 0);
|
||
const sourceIssueCount = sources?.sources.filter((item) => item.severity !== 'ok').length ?? 0;
|
||
const sourceErrorCount = sources?.sources.filter((item) => item.severity === 'error').length ?? 0;
|
||
const sourceHealthyCount = sources?.sources.filter((item) => item.severity === 'ok').length ?? 0;
|
||
const readinessUnavailable = readiness.isError;
|
||
const healthIssueCount = infrastructureIssueCount + sourceIssueCount + Number(readinessUnavailable);
|
||
const healthEvidencePending = !data || readiness.isPending;
|
||
const overallStatus = healthEvidencePending
|
||
? 'unknown'
|
||
: readinessUnavailable || sourceErrorCount > 0
|
||
? 'error'
|
||
: healthIssueCount > 0
|
||
? 'warning'
|
||
: 'ok';
|
||
const overallValue = healthEvidencePending
|
||
? '读取中'
|
||
: readinessUnavailable
|
||
? '证据缺失'
|
||
: healthIssueCount > 0
|
||
? '需要处理'
|
||
: '运行正常';
|
||
const overallDetail = healthEvidencePending
|
||
? '等待运行与协议覆盖证据'
|
||
: readinessUnavailable
|
||
? '协议来源就绪度暂不可用'
|
||
: healthIssueCount > 0
|
||
? `协议覆盖 ${sourceIssueCount} 项 · 链路运行 ${infrastructureIssueCount} 项`
|
||
: '链路、写入与协议覆盖均正常';
|
||
const sortedLinks = useMemo(() => [...(data?.linkHealth ?? [])].sort((left, right) => {
|
||
const priority = { error: 0, warning: 1, ok: 2 } as Record<string, number>;
|
||
return (priority[left.status] ?? 3) - (priority[right.status] ?? 3);
|
||
}), [data?.linkHealth]);
|
||
const sortedSources = useMemo(() => [...(sources?.sources ?? [])].sort((left, right) => {
|
||
const priority = { error: 0, warning: 1, ok: 2 } as Record<string, number>;
|
||
return (priority[left.severity] ?? 3) - (priority[right.severity] ?? 3);
|
||
}), [sources?.sources]);
|
||
const workspaceContext = workspace === 'reconciliation'
|
||
? {
|
||
description: '每日自动对账',
|
||
status: reconciliation.data ? `${reconciliation.data.pending.toLocaleString('zh-CN')} 项待复核` : '读取差异证据',
|
||
color: reconciliation.data?.overSla ? 'orange' as const : 'blue' as const
|
||
}
|
||
: workspace === 'diagnostic'
|
||
? { description: '按需读取单车来源', status: '精确诊断', color: 'cyan' as const }
|
||
: {
|
||
description: '15 秒刷新运行证据',
|
||
status: healthEvidencePending ? '读取中' : readinessUnavailable ? '协议证据缺失' : healthIssueCount > 0 ? `${healthIssueCount} 项关注` : '运行正常',
|
||
color: healthEvidencePending ? 'grey' as const : readinessUnavailable || sourceErrorCount > 0 ? 'red' as const : healthIssueCount > 0 ? 'orange' as const : 'green' as const
|
||
};
|
||
return <div className={`v2-ops-page is-${workspace}`}>
|
||
<WorkspaceCommandBar
|
||
className="v2-ops-command-bar"
|
||
ariaLabel="运维质量操作"
|
||
title="运行与数据质量"
|
||
description="集中处理自动对账问题,按需诊断单车来源,并核对服务与协议健康;所有结论均可追溯。"
|
||
status={data?.runtime.dataMode === 'production' ? '生产模式' : '状态待确认'}
|
||
statusColor={data?.runtime.dataMode === 'production' ? 'green' : 'orange'}
|
||
meta={<Typography.Text type="tertiary">上海时间 · {data?.runtime.platformRelease ? `版本 ${data.runtime.platformRelease}` : '正在读取运行版本'}</Typography.Text>}
|
||
actions={<Button className="v2-workspace-mobile-tool-button is-muted" theme="light" icon={<IconRefresh />} aria-label="刷新全局证据" loading={health.isFetching || readiness.isFetching} onClick={refresh}>刷新全局证据</Button>}
|
||
/>
|
||
<nav className="v2-ops-navigation" aria-label="运维质量视图">
|
||
<SegmentedTabs
|
||
className="v2-ops-tabs"
|
||
variant="filled"
|
||
ariaLabel="运维质量工作区"
|
||
value={workspace}
|
||
onChange={setWorkspace}
|
||
items={[
|
||
{ key: 'reconciliation', label: '差异处置', count: reconciliation.data?.pending || undefined },
|
||
{ key: 'diagnostic', label: '单车诊断' },
|
||
{ key: 'health', label: '全局健康', count: healthIssueCount || undefined }
|
||
]}
|
||
/>
|
||
<div className="v2-ops-navigation-meta" aria-live="polite">
|
||
<Typography.Text type="tertiary">{workspaceContext.description}</Typography.Text>
|
||
<Tag color={workspaceContext.color} type="light" size="small">{workspaceContext.status}</Tag>
|
||
</div>
|
||
</nav>
|
||
<section className={`v2-ops-workspace is-${workspace}`} role="tabpanel" tabIndex={0} aria-label={workspace === 'reconciliation' ? '差异处置' : workspace === 'diagnostic' ? '单车诊断' : '全局健康'}>
|
||
{workspace === 'reconciliation' ? <ReconciliationCenter /> : null}
|
||
{workspace === 'diagnostic' ? <SourceDiagnosticWorkspace /> : null}
|
||
{workspace === 'health' ? <>
|
||
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
|
||
{readiness.isError ? <InlineError message={readiness.error instanceof Error ? readiness.error.message : '协议来源就绪度读取失败'} onRetry={() => readiness.refetch()} /> : null}
|
||
<Card className="v2-ops-overview" bodyStyle={{ padding: 0 }}>
|
||
<WorkspacePanelHeader
|
||
title="运行概览"
|
||
description="关键链路、基础设施与车辆覆盖的同一健康快照"
|
||
meta={<span className="v2-ops-overview-meta"><HealthTag status={overallStatus}>{!data ? '正在读取' : healthIssueCount > 0 ? `${healthIssueCount} 项需关注` : '全部正常'}</HealthTag><Typography.Text type="tertiary">15 秒自动刷新</Typography.Text></span>}
|
||
/>
|
||
<section className="v2-ops-metric-rail" role="list" aria-label="运行健康指标">
|
||
<OpsMetric label="整体状态" value={overallValue} detail={overallDetail} tone={healthEvidencePending ? 'neutral' : readinessUnavailable || sourceErrorCount > 0 ? 'danger' : healthIssueCount > 0 ? 'warning' : 'success'} />
|
||
<OpsMetric
|
||
label="协议就绪 / 总数"
|
||
value={sources?.sources.length ? `${sourceHealthyCount} / ${sources.sources.length}` : readinessUnavailable ? '不可用' : '—'}
|
||
detail={readinessUnavailable ? '来源证据读取失败' : sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需要处置` : sources?.sources.length ? '全部协议达到当前口径' : '等待协议覆盖证据'}
|
||
tone={readinessUnavailable || sourceErrorCount > 0 ? 'danger' : sourceIssueCount > 0 ? 'warning' : sources?.sources.length ? 'success' : 'neutral'}
|
||
/>
|
||
<OpsMetric label="车辆总数 / 在线" value={sources ? `${sources.totalVehicles} / ${sources.onlineVehicles}` : '—'} detail={sources ? `已绑定 ${sources.boundVehicles} · 待绑定 ${sources.identityRequiredVehicles}` : '档案与快照并集'} />
|
||
<OpsMetric label="活跃连接" value={data?.activeConnections?.toLocaleString('zh-CN') ?? '—'} detail="网关实时连接" />
|
||
<OpsMetric label="Kafka Lag" value={data?.kafkaLag?.toLocaleString('zh-CN') ?? '—'} detail={data?.kafkaLag === 0 ? '消费积压已回零' : data ? '存在待消费消息' : '等待消费证据'} tone={data?.kafkaLag === 0 ? 'success' : data ? 'warning' : 'neutral'} />
|
||
</section>
|
||
</Card>
|
||
<Card className={`v2-ops-panel v2-ops-sources${sourceIssueCount || readinessUnavailable ? ' has-attention' : ''}`} bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="协议来源就绪度" description="优先展示覆盖不足的协议,并给出处置动作与验收口径" meta={<HealthTag status={readinessUnavailable ? 'error' : sourceIssueCount > 0 ? 'warning' : sources ? 'ok' : 'unknown'}>{readinessUnavailable ? '证据不可用' : sources ? sourceIssueCount > 0 ? `${sourceIssueCount} 个协议需关注` : `${sources.sources.length} 个协议正常` : '正在读取'}</HealthTag>} />{sources ? sortedSources.length ? <CardGroup className="v2-ops-source-list" type="grid" spacing={0}>{sortedSources.map((source) => {
|
||
const onlineRate = sourceOnlineRate(source.online, source.total, source.onlineRate);
|
||
return <Card className={`v2-ops-source-card is-${source.severity}`} key={source.protocol} title={<span className="v2-ops-source-title"><Tag color="blue" type="light" size="small">{source.protocol}</Tag><small>{source.role}</small></span>} headerExtraContent={<HealthTag status={source.severity}>{source.status || statusLabel(source.severity)}</HealthTag>} headerLine>
|
||
<div className="v2-ops-source-coverage"><span><strong>在线覆盖</strong><b>{source.online.toLocaleString('zh-CN')} / {source.total.toLocaleString('zh-CN')}</b></span><Progress aria-label={`${source.protocol} 在线覆盖率`} percent={onlineRate} showInfo={false} stroke="var(--v2-blue)" /><small>{number(onlineRate)}% 在线{source.missingVehicles ? ` · ${source.missingVehicles.toLocaleString('zh-CN')} 辆待恢复` : ''}</small></div>
|
||
<div className="v2-ops-source-evidence"><strong>当前证据</strong><p>{source.evidence}</p></div><div className="v2-ops-source-action"><strong>建议动作</strong><p>{source.action}</p></div><footer><Tag color="green" type="light" size="small">验收标准</Tag><span>{source.acceptance}</span></footer>
|
||
</Card>;
|
||
})}</CardGroup> : <Empty className="v2-ops-source-empty" title="暂无协议来源" description="当前响应没有可展示的协议来源就绪度。" /> : readinessUnavailable ? <Empty className="v2-ops-source-empty is-error" title="协议来源证据不可用" description="运行证据仍可查看;请使用上方重试恢复协议覆盖判断。" /> : <div className="v2-ops-source-loading" role="status"><Spin size="middle" tip="正在读取协议来源就绪度" /></div>}</Card>
|
||
<div className="v2-ops-grid"><Card className="v2-ops-panel v2-ops-links" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="数据链路" description="按链路查看当前状态与服务端诊断证据" meta={<HealthTag status={!data ? 'unknown' : linkIssueCount > 0 ? 'warning' : 'ok'}>{!data ? '读取中' : linkIssueCount > 0 ? `${linkIssueCount} 条异常` : '链路正常'}</HealthTag>} /><div className="v2-ops-link-list" role="list">{sortedLinks.length ? sortedLinks.map((item) => {
|
||
const label = opsLinkLabel(item.name);
|
||
return <article className={`v2-ops-link-card is-${item.status}`} key={item.name} role="listitem"><span className="v2-ops-link-status"><i /><span><strong>{label}</strong>{label !== item.name ? <small>{item.name}</small> : null}</span></span><p>{item.detail || '无补充信息'}</p><HealthTag status={item.status} /></article>;
|
||
}) : <Empty className="v2-ops-link-empty" title={data ? '暂无链路探针' : '正在读取链路状态'} description={data ? '当前响应没有可展示的数据链路证据。' : '全局健康数据返回后将在这里更新。'} />}</div></Card>
|
||
<Card className="v2-ops-panel v2-ops-runtime" bodyStyle={{ padding: 0 }}><WorkspacePanelHeader title="运行时安全" description="生产模式、写入探针与代理配置" meta={<HealthTag status={!data ? 'unknown' : runtimeIssueCount + capacityIssueCount > 0 ? 'warning' : 'ok'}>{!data ? '读取中' : runtimeIssueCount + capacityIssueCount > 0 ? `${runtimeIssueCount + capacityIssueCount} 项关注` : '配置正常'}</HealthTag>} /><Descriptions className="v2-ops-runtime-descriptions" align="left" size="small" data={[
|
||
{ key: '生产数据模式', value: <HealthTag status={data?.runtime.dataMode === 'production' ? 'ok' : 'error'}>{data?.runtime.dataMode === 'production' ? '已启用' : '未启用'}</HealthTag> },
|
||
{ key: 'MySQL 写探针', value: <HealthTag status={data?.mysqlWritable ? 'ok' : 'error'}>{data?.mysqlWritable ? '正常' : '异常'}</HealthTag> },
|
||
{ key: 'TDengine 写探针', value: <HealthTag status={data?.tdengineWritable ? 'ok' : 'error'}>{data?.tdengineWritable ? '正常' : '异常'}</HealthTag> },
|
||
{ key: '请求超时', value: `${data?.runtime.requestTimeoutMs ?? '—'} ms` },
|
||
{ key: '高德安全代理', value: <HealthTag status={data?.runtime.amapSecurityProxyEnabled && !data?.runtime.amapSecurityCodeExposed ? 'ok' : 'warning'}>{data?.runtime.amapSecurityProxyEnabled ? '服务端代理' : '未启用'}</HealthTag> }
|
||
]} />{data?.capacityFindings?.length ? <div className="v2-ops-capacity-findings">{data.capacityFindings.map((item) => <Card className="v2-ops-capacity-finding" key={item}><HealthTag status="warning">待处理</HealthTag><p>{item}</p></Card>)}</div> : <Empty className="v2-ops-capacity-empty" image={<IconTickCircle />} title="容量检查通过" description="当前没有需要处理的容量风险。" />}</Card></div>
|
||
</> : null}
|
||
</section>
|
||
</div>;
|
||
}
|