feat(operations): add source diagnosis workspace
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { IconRefresh } from '@douyinfe/semi-icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { FormEvent, useDeferredValue, useEffect, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy';
|
||||
|
||||
@@ -8,13 +10,139 @@ function statusLabel(status: string) {
|
||||
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
|
||||
}
|
||||
|
||||
function fmt(value?: string) {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value.replace(' ', 'T'));
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function number(value?: number, digits = 1) {
|
||||
return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function SourcePolicyRow({ 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 [remark, setRemark] = useState('');
|
||||
useEffect(() => {
|
||||
setEnabled(source.enabled);
|
||||
setPriority(source.priority);
|
||||
setRemark('');
|
||||
}, [source.enabled, source.priority, source.sourceRef]);
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.updateVehicleSourcePolicy(vin, {
|
||||
version: diagnostic.policy.version,
|
||||
sourceRef: source.sourceRef ?? '',
|
||||
enabled,
|
||||
priority,
|
||||
remark
|
||||
}),
|
||||
onSuccess: onSaved
|
||||
});
|
||||
const changed = enabled !== source.enabled || priority !== source.priority || remark.trim() !== '';
|
||||
return <tr className={source.recommended ? 'is-recommended' : ''}>
|
||||
<td><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.sourceKind || '未维护终端'}</span></td>
|
||||
<td><b>{source.protocol}</b><span>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</span></td>
|
||||
<td><i className={source.online ? 'is-online' : 'is-offline'} />{source.online ? '在线' : '离线'}<span>{source.qualityStatus || '未知'}{source.qualityReason ? ` · ${source.qualityReason}` : ''}</span></td>
|
||||
<td><strong>{fmt(source.firstSeenAt)}</strong><span>最近 {fmt(source.receivedAt || source.eventTime)}</span></td>
|
||||
<td><strong>{source.reportIntervalSec == null ? '—' : `${source.reportIntervalSec}s`}</strong><span>{source.reportSampleCount ? `${source.reportSampleCount.toLocaleString('zh-CN')} 个里程样本` : '暂无累计样本'}</span></td>
|
||||
<td><strong>{source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}</strong><span>{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km</span></td>
|
||||
<td className="v2-source-reason"><strong>{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}</strong><span>{source.selectionReason || '等待选举说明'}</span></td>
|
||||
<td className="v2-source-policy-cell">
|
||||
<label><input type="checkbox" checked={enabled} disabled={!editable || save.isPending} onChange={(event) => setEnabled(event.target.checked)} />启用</label>
|
||||
<input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={priority} disabled={!editable || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} />
|
||||
<input aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder="调整原因" />
|
||||
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000} onClick={() => save.mutate()}>{save.isPending ? '保存中' : '保存策略'}</button>
|
||||
{save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
|
||||
</td>
|
||||
</tr>;
|
||||
}
|
||||
|
||||
function SourceDiagnosticWorkspace() {
|
||||
const queryClient = useQueryClient();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||||
const [selected, setSelected] = useState<VehicleCoverageRow>();
|
||||
const [candidateOffset, setCandidateOffset] = useState(0);
|
||||
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);
|
||||
};
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const first = candidates.data?.items[0];
|
||||
if (first) choose(first);
|
||||
};
|
||||
const data = diagnostic.data;
|
||||
const editable = session.data?.role === 'admin';
|
||||
return <section className="v2-source-diagnostic">
|
||||
<header>
|
||||
<div><small>专业运维工作台</small><strong>单车多来源诊断</strong><span>按需查询,不加载全量 RAW;普通客户无权访问。</span></div>
|
||||
{selected ? <button type="button" onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}><IconRefresh />刷新该车</button> : null}
|
||||
</header>
|
||||
<form className="v2-source-search" onSubmit={submit}>
|
||||
<label><IconSearch /><input aria-label="按车牌或 VIN 搜索诊断车辆" value={keyword} onChange={(event) => { setKeyword(event.target.value); setSelected(undefined); }} placeholder="输入车牌或 VIN,支持模糊搜索" /></label>
|
||||
<button type="submit" disabled={!candidates.data?.items.length}>诊断首个结果</button>
|
||||
{deferredKeyword && !selected ? <div className="v2-source-candidates">
|
||||
{candidates.isFetching ? <p>正在查询车辆…</p> : candidates.data?.items.map((vehicle) => <button type="button" key={vehicle.vin} onMouseDown={(event) => event.preventDefault()} onClick={() => choose(vehicle)}>
|
||||
<strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span><em>{vehicle.protocols.join(' / ') || '暂无来源'}</em>
|
||||
</button>)}
|
||||
{!candidates.isFetching && !candidates.data?.items.length ? <p>没有匹配的已绑定车辆</p> : null}
|
||||
{(candidates.data?.total ?? 0) > 20 ? <footer><span>第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页</span><div><button type="button" disabled={candidateOffset === 0} onClick={() => setCandidateOffset(Math.max(0, candidateOffset - 20))}>上一页</button><button type="button" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setCandidateOffset(candidateOffset + 20)}>下一页</button></div></footer> : null}
|
||||
</div> : null}
|
||||
</form>
|
||||
{diagnostic.isError ? <InlineError message={diagnostic.error.message} onRetry={() => diagnostic.refetch()} /> : null}
|
||||
{!selected ? <div className="v2-source-empty"><strong>先选择一辆车</strong><span>将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。</span></div> : null}
|
||||
{selected && diagnostic.isPending ? <div className="v2-source-empty"><strong>正在读取来源证据</strong><span>只查询当前车辆,不会扫描整车队。</span></div> : null}
|
||||
{data ? <>
|
||||
<div className="v2-source-summary">
|
||||
<article><small>车辆</small><strong>{data.evidence.plate || selected?.plate || '未绑定车牌'}</strong><span>{data.evidence.vin}</span></article>
|
||||
<article><small>当前推荐</small><strong>{data.evidence.recommendedLocationLabel || '暂无推荐'}</strong><span>{data.evidence.recommendedLocationProtocol || '—'}</span></article>
|
||||
<article><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></article>
|
||||
<article><small>策略版本</small><strong>v{data.policy.version}</strong><span>{data.policy.updatedAt ? `${data.policy.updatedBy} · ${fmt(data.policy.updatedAt)}` : '尚无人工调整'}</span></article>
|
||||
</div>
|
||||
<div className="v2-source-recommendation"><strong>推荐说明</strong><p>{data.recommendationReason}</p><span>{data.refreshHint}</span></div>
|
||||
{!editable ? <p className="v2-source-readonly">当前账号可查看诊断证据;只有管理员可以调整启停和优先级。</p> : null}
|
||||
<div className="v2-source-table-wrap"><table className="v2-source-table"><thead><tr><th>来源 / 终端</th><th>协议</th><th>在线 / 质量</th><th>首次 / 最近上报</th><th>上报周期</th><th>位置 / 里程</th><th>选举结论</th><th>运维策略</th></tr></thead><tbody>
|
||||
{data.evidence.locationSources.map((source) => <SourcePolicyRow key={source.sourceRef || `${source.protocol}-${source.sourceLabel}-${source.terminalLabel}`} vin={data.evidence.vin} source={source} diagnostic={data} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => queryClient.setQueryData(['ops-source-diagnostic', data.evidence.vin], next)} />)}
|
||||
</tbody></table></div>
|
||||
<section className="v2-source-audit"><header><strong>最近策略审计</strong><span>仅记录实际变更,原始 source_key 不对前端暴露</span></header>
|
||||
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.actor} · {fmt(item.changedAt)}</em></li>)}</ol> : <p>该车辆尚无人工来源策略变更。</p>}
|
||||
</section>
|
||||
</> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
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 data = health.data; const sources = readiness.data;
|
||||
const refresh = () => Promise.all([health.refetch(), readiness.refetch()]);
|
||||
return <div className="v2-ops-page">
|
||||
<header className="v2-ops-heading"><div><h2>运维质量</h2><p>服务身份、主车辆档案和实时位置采用不同可核对口径,不用前端估算。</p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh />刷新证据</button></header>
|
||||
<header className="v2-ops-heading"><div><h2>运维质量</h2><p>先定位单车多来源问题,再核对服务和协议全局健康;所有结论来自服务端证据。</p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh />刷新全局证据</button></header>
|
||||
<SourceDiagnosticWorkspace />
|
||||
{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}
|
||||
<section className="v2-ops-kpis">
|
||||
|
||||
Reference in New Issue
Block a user