feat(operations): add automated reconciliation center
This commit is contained in:
@@ -38,6 +38,9 @@ import type {
|
||||
Page,
|
||||
QualitySummary,
|
||||
QualityIssueRow,
|
||||
ReconciliationIssue,
|
||||
ReconciliationQuery,
|
||||
ReconciliationSummary,
|
||||
RawFrameRow,
|
||||
RealtimeLocationRow,
|
||||
SourceReadinessPlan,
|
||||
@@ -292,6 +295,22 @@ export const api = {
|
||||
})
|
||||
}
|
||||
),
|
||||
reconciliationSummary: (days = 30, signal?: AbortSignal) => request<ReconciliationSummary>(
|
||||
`/api/v2/reconciliation/summary?days=${days}`,
|
||||
withSignal(undefined, signal)
|
||||
),
|
||||
reconciliationIssues: (query: ReconciliationQuery, signal?: AbortSignal) => request<Page<ReconciliationIssue>>(
|
||||
'/api/v2/reconciliation/issues',
|
||||
withSignal({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) }, signal)
|
||||
),
|
||||
reconciliationIssue: (id: string, signal?: AbortSignal) => request<ReconciliationIssue>(
|
||||
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}`,
|
||||
withSignal(undefined, signal)
|
||||
),
|
||||
updateReconciliationIssue: (id: string, input: { version: number; status: string; note: string }) => request<ReconciliationIssue>(
|
||||
`/api/v2/reconciliation/issues/${encodeURIComponent(id)}/actions`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }
|
||||
),
|
||||
updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
|
||||
}),
|
||||
|
||||
@@ -957,6 +957,75 @@ export interface RuntimeInfo {
|
||||
platformRelease?: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationQuery {
|
||||
keyword?: string;
|
||||
ruleCode?: string;
|
||||
category?: string;
|
||||
severity?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ReconciliationAction {
|
||||
id: number;
|
||||
action: string;
|
||||
fromStatus: string;
|
||||
toStatus: string;
|
||||
actor: string;
|
||||
note: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ReconciliationIssue {
|
||||
id: string;
|
||||
ruleCode: string;
|
||||
category: string;
|
||||
severity: string;
|
||||
status: string;
|
||||
vin: string;
|
||||
plate: string;
|
||||
protocolA: string;
|
||||
protocolB: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
evidence: Record<string, unknown>;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
occurrenceCount: number;
|
||||
recoveredAt: string;
|
||||
resolutionNote: string;
|
||||
resolvedBy: string;
|
||||
version: number;
|
||||
actions?: ReconciliationAction[];
|
||||
}
|
||||
|
||||
export interface ReconciliationBucket {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ReconciliationTrendPoint {
|
||||
date: string;
|
||||
detected: number;
|
||||
new: number;
|
||||
active: number;
|
||||
recovered: number;
|
||||
}
|
||||
|
||||
export interface ReconciliationSummary {
|
||||
active: number;
|
||||
pending: number;
|
||||
confirmed: number;
|
||||
recovered: number;
|
||||
overSla: number;
|
||||
byRule: ReconciliationBucket[];
|
||||
bySeverity: ReconciliationBucket[];
|
||||
trend: ReconciliationTrendPoint[];
|
||||
lastRunAt: string;
|
||||
asOf: string;
|
||||
}
|
||||
|
||||
export interface MapReverseGeocode {
|
||||
provider: string;
|
||||
longitude: number;
|
||||
|
||||
@@ -5,7 +5,8 @@ import OperationsPage from './OperationsPage';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
opsHealth: vi.fn(), sourceReadiness: vi.fn(), session: vi.fn(), vehicleCoverage: vi.fn(),
|
||||
vehicleSourceDiagnostic: vi.fn(), updateVehicleSourcePolicy: vi.fn()
|
||||
vehicleSourceDiagnostic: vi.fn(), updateVehicleSourcePolicy: vi.fn(),
|
||||
reconciliationSummary: vi.fn(), reconciliationIssues: vi.fn(), reconciliationIssue: vi.fn(), updateReconciliationIssue: vi.fn()
|
||||
}));
|
||||
vi.mock('../../api/client', () => ({ api: mocks }));
|
||||
|
||||
@@ -13,8 +14,60 @@ afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockRes
|
||||
|
||||
function seedSession() {
|
||||
mocks.session.mockResolvedValue({ name: '平台管理员', role: 'admin', userType: 'admin', authMode: 'enforce', menuKeys: ['operations'] });
|
||||
mocks.reconciliationSummary.mockResolvedValue({
|
||||
active: 1, pending: 1, confirmed: 0, recovered: 2, overSla: 1,
|
||||
byRule: [{ name: 'POSITION_DRIFT', count: 1 }], bySeverity: [{ name: 'major', count: 1 }],
|
||||
trend: [{ date: '2026-07-16', detected: 1, new: 1, active: 1, recovered: 0 }],
|
||||
lastRunAt: '2026-07-16 02:15:00', asOf: '2026-07-16 10:00:00'
|
||||
});
|
||||
mocks.reconciliationIssues.mockResolvedValue({
|
||||
items: [{
|
||||
id: 'issue-1', ruleCode: 'POSITION_DRIFT', category: 'location', severity: 'major', status: 'pending',
|
||||
vin: 'VIN001', plate: '粤A00001', protocolA: 'GB32960', protocolB: 'JT808', title: '多来源实时位置漂移',
|
||||
summary: '两个来源相差 1286 米', evidence: { distanceM: 1286 }, firstSeenAt: '2026-07-16 08:00:00',
|
||||
lastSeenAt: '2026-07-16 10:00:00', occurrenceCount: 3, recoveredAt: '', resolutionNote: '', resolvedBy: '', version: 1
|
||||
}],
|
||||
total: 1, limit: 50, offset: 0
|
||||
});
|
||||
mocks.reconciliationIssue.mockResolvedValue({
|
||||
id: 'issue-1', ruleCode: 'POSITION_DRIFT', category: 'location', severity: 'major', status: 'pending',
|
||||
vin: 'VIN001', plate: '粤A00001', protocolA: 'GB32960', protocolB: 'JT808', title: '多来源实时位置漂移',
|
||||
summary: '两个来源相差 1286 米', evidence: { distanceM: 1286 }, firstSeenAt: '2026-07-16 08:00:00',
|
||||
lastSeenAt: '2026-07-16 10:00:00', occurrenceCount: 3, recoveredAt: '', resolutionNote: '', resolvedBy: '', version: 1,
|
||||
actions: [{ id: 1, action: 'detect', fromStatus: '', toStatus: 'pending', actor: 'reconciliation-evaluator', note: '规则首次发现差异', createdAt: '2026-07-16 08:00:00' }]
|
||||
});
|
||||
}
|
||||
|
||||
test('renders reconciliation queue, loads evidence on demand and records review conclusion', async () => {
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({
|
||||
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
|
||||
tdengineWritable: true, mysqlWritable: true,
|
||||
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
|
||||
});
|
||||
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
|
||||
mocks.updateReconciliationIssue.mockResolvedValue({
|
||||
...(await mocks.reconciliationIssue()),
|
||||
status: 'confirmed_source_a',
|
||||
resolutionNote: '来源 A 原始报文可信',
|
||||
resolvedBy: '平台管理员',
|
||||
version: 2
|
||||
});
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByText('数据差异中心')).toBeInTheDocument();
|
||||
fireEvent.click(await screen.findByText('多来源实时位置漂移'));
|
||||
expect(await screen.findByText('规则证据')).toBeInTheDocument();
|
||||
expect(screen.getByText('1286')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText('处置状态'), { target: { value: 'confirmed_source_a' } });
|
||||
fireEvent.change(screen.getByLabelText('说明(必填)'), { target: { value: '来源 A 原始报文可信' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存复核结论' }));
|
||||
await waitFor(() => expect(mocks.updateReconciliationIssue).toHaveBeenCalledWith('issue-1', {
|
||||
version: 1, status: 'confirmed_source_a', note: '来源 A 原始报文可信'
|
||||
}));
|
||||
});
|
||||
|
||||
test('reconciles service identities with bound and identity-required vehicles', async () => {
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({
|
||||
@@ -92,8 +145,12 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
|
||||
fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '粤A' } });
|
||||
expect(await screen.findByText('粤A00001')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText('粤A00001'));
|
||||
const candidateButton = await waitFor(() => {
|
||||
const button = document.querySelector<HTMLButtonElement>('.v2-source-candidates button');
|
||||
expect(button).toBeTruthy();
|
||||
return button!;
|
||||
});
|
||||
fireEvent.click(candidateButton);
|
||||
expect(await screen.findByText('当前推荐 G7')).toBeInTheDocument();
|
||||
expect(screen.getByText('终端 133****0001')).toBeInTheDocument();
|
||||
expect(screen.getByText('10s')).toBeInTheDocument();
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
import ReconciliationCenter from './ReconciliationCenter';
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status;
|
||||
@@ -142,6 +143,7 @@ export default function OperationsPage() {
|
||||
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>
|
||||
<ReconciliationCenter />
|
||||
<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}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { ReconciliationIssue } from '../../api/types';
|
||||
import { InlineError } from '../shared/AsyncState';
|
||||
import { QUERY_MEMORY } from '../queryPolicy';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
const activeStatuses = new Set(['pending', 'confirmed_source_a', 'confirmed_source_b']);
|
||||
const reviewStatuses = [
|
||||
{ value: 'pending', label: '待处理' },
|
||||
{ value: 'confirmed_source_a', label: '确认来源 A' },
|
||||
{ value: 'confirmed_source_b', label: '确认来源 B' },
|
||||
{ value: 'no_action', label: '无需处理' },
|
||||
{ value: 'fixed', label: '已修复' }
|
||||
];
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return {
|
||||
pending: '待处理',
|
||||
confirmed_source_a: '确认来源 A',
|
||||
confirmed_source_b: '确认来源 B',
|
||||
no_action: '无需处理',
|
||||
fixed: '已修复',
|
||||
recovered: '已恢复'
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
function severityLabel(severity: string) {
|
||||
return { critical: '严重', major: '重要', minor: '一般' }[severity] ?? severity;
|
||||
}
|
||||
|
||||
function ruleLabel(rule: string) {
|
||||
return {
|
||||
DUPLICATE_PLATE: '重复车牌',
|
||||
DUPLICATE_PHONE: '重复终端',
|
||||
UNBOUND_SOURCE: '来源未绑定',
|
||||
SOURCE_MISSING: '主车无来源',
|
||||
POSITION_DRIFT: '位置漂移',
|
||||
MILEAGE_REVERSE: '里程倒退',
|
||||
MILEAGE_JUMP: '里程跳变',
|
||||
MILEAGE_SOURCE_DIVERGENCE: '里程来源差异',
|
||||
FLEET_COUNT_MISMATCH: '车辆总数不一致',
|
||||
BUSINESS_SCOPE_UNBOUND: '业务车辆未绑定',
|
||||
AUTH_SCOPE_BUSINESS_MISMATCH: '授权与业务范围不一致'
|
||||
}[rule] ?? rule;
|
||||
}
|
||||
|
||||
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 evidenceValue(value: unknown) {
|
||||
if (value == null || value === '') return '—';
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function ReconciliationDetail({ issue, onClose }: { issue: ReconciliationIssue; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState(issue.status === 'recovered' ? 'pending' : issue.status);
|
||||
const [note, setNote] = useState(issue.resolutionNote ?? '');
|
||||
useEffect(() => {
|
||||
setStatus(issue.status === 'recovered' ? 'pending' : issue.status);
|
||||
setNote(issue.resolutionNote ?? '');
|
||||
}, [issue.id, issue.resolutionNote, issue.status]);
|
||||
const save = useMutation({
|
||||
mutationFn: () => api.updateReconciliationIssue(issue.id, { version: issue.version, status, note: note.trim() }),
|
||||
onSuccess: async (updated) => {
|
||||
queryClient.setQueryData(['reconciliation-detail', issue.id], updated);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['reconciliation-summary'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['reconciliation-issues'] })
|
||||
]);
|
||||
}
|
||||
});
|
||||
const requiresNote = status !== 'pending';
|
||||
return <aside className="v2-reconcile-detail" aria-label="差异证据与处置">
|
||||
<header>
|
||||
<div><span className={`is-${issue.severity}`}>{severityLabel(issue.severity)}</span><strong>{issue.title}</strong><small>{ruleLabel(issue.ruleCode)}</small></div>
|
||||
<button type="button" onClick={onClose} aria-label="关闭差异详情">×</button>
|
||||
</header>
|
||||
<div className="v2-reconcile-detail-body">
|
||||
<section className="v2-reconcile-identity">
|
||||
<div><small>车辆</small><strong>{issue.plate || '未登记车牌'}</strong><span>{issue.vin || '非单车差异'}</span></div>
|
||||
<div><small>来源</small><strong>{[issue.protocolA, issue.protocolB].filter(Boolean).join(' ↔ ') || '平台口径'}</strong><span>累计发现 {issue.occurrenceCount.toLocaleString('zh-CN')} 次</span></div>
|
||||
<div><small>时间</small><strong>{fmt(issue.lastSeenAt)}</strong><span>首次 {fmt(issue.firstSeenAt)}</span></div>
|
||||
</section>
|
||||
<p className="v2-reconcile-summary">{issue.summary}</p>
|
||||
<section className="v2-reconcile-evidence">
|
||||
<header><strong>规则证据</strong><span>保留来源值、时间与影响对象;未知来源不自动判对错</span></header>
|
||||
<dl>{Object.entries(issue.evidence ?? {}).map(([key, value]) => <div key={key}><dt>{key}</dt><dd><pre>{evidenceValue(value)}</pre></dd></div>)}</dl>
|
||||
</section>
|
||||
<section className="v2-reconcile-review">
|
||||
<header><strong>复核结论</strong><span>版本 v{issue.version}</span></header>
|
||||
<label><span>处置状态</span><select value={status} onChange={(event) => setStatus(event.target.value)}>{reviewStatuses.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</select></label>
|
||||
<label><span>说明{requiresNote ? '(必填)' : '(可选)'}</span><textarea value={note} maxLength={500} onChange={(event) => setNote(event.target.value)} placeholder="记录核对来源、原始值、责任人或修复结果" /></label>
|
||||
<button type="button" disabled={save.isPending || (requiresNote && !note.trim())} onClick={() => save.mutate()}>{save.isPending ? '正在保存…' : '保存复核结论'}</button>
|
||||
{save.isError ? <p role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</p> : null}
|
||||
</section>
|
||||
<section className="v2-reconcile-actions">
|
||||
<header><strong>处理履历</strong><span>{issue.actions?.length ?? 0} 条</span></header>
|
||||
{issue.actions?.length ? <ol>{issue.actions.map((action) => <li key={action.id}><i /><div><strong>{statusLabel(action.toStatus)}</strong><p>{action.note || action.action}</p><span>{action.actor} · {fmt(action.createdAt)}</span></div></li>)}</ol> : <p>暂无处理记录。</p>}
|
||||
</section>
|
||||
</div>
|
||||
</aside>;
|
||||
}
|
||||
|
||||
export default function ReconciliationCenter() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const deferredKeyword = useDeferredValue(keyword.trim());
|
||||
const [status, setStatus] = useState('active');
|
||||
const [severity, setSeverity] = useState('all');
|
||||
const [ruleCode, setRuleCode] = useState('all');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [selectedID, setSelectedID] = useState('');
|
||||
useEffect(() => setOffset(0), [deferredKeyword, ruleCode, severity, status]);
|
||||
|
||||
const summary = useQuery({
|
||||
queryKey: ['reconciliation-summary', 30],
|
||||
queryFn: ({ signal }) => api.reconciliationSummary(30, signal),
|
||||
staleTime: 60_000,
|
||||
gcTime: QUERY_MEMORY.summaryGcTime
|
||||
});
|
||||
const issues = useQuery({
|
||||
queryKey: ['reconciliation-issues', deferredKeyword, ruleCode, severity, status, offset],
|
||||
queryFn: ({ signal }) => api.reconciliationIssues({
|
||||
keyword: deferredKeyword, ruleCode, severity, status, limit: PAGE_SIZE, offset
|
||||
}, signal),
|
||||
staleTime: 20_000,
|
||||
gcTime: QUERY_MEMORY.highVolumeGcTime
|
||||
});
|
||||
const detail = useQuery({
|
||||
queryKey: ['reconciliation-detail', selectedID],
|
||||
queryFn: ({ signal }) => api.reconciliationIssue(selectedID, signal),
|
||||
enabled: Boolean(selectedID),
|
||||
staleTime: 10_000,
|
||||
gcTime: QUERY_MEMORY.optionGcTime
|
||||
});
|
||||
const maxTrend = useMemo(() => {
|
||||
let result = 1;
|
||||
for (const item of summary.data?.trend ?? []) result = Math.max(result, item.active, item.new, item.recovered);
|
||||
return result;
|
||||
}, [summary.data?.trend]);
|
||||
const refresh = () => Promise.all([summary.refetch(), issues.refetch(), selectedID ? detail.refetch() : Promise.resolve()]);
|
||||
const data = summary.data;
|
||||
const page = issues.data;
|
||||
const activeCount = page?.items.filter((item) => activeStatuses.has(item.status)).length ?? 0;
|
||||
|
||||
return <section className="v2-reconcile-center">
|
||||
<header className="v2-reconcile-heading">
|
||||
<div><small>每日自动对账</small><strong>数据差异中心</strong><span>自动发现、相同问题去重、恢复后闭环;复核人员只确认证据,不凭空修改原始数据。</span></div>
|
||||
<button type="button" onClick={refresh} disabled={summary.isFetching || issues.isFetching}><IconRefresh />刷新结果</button>
|
||||
</header>
|
||||
{summary.isError ? <InlineError message={summary.error.message} onRetry={() => summary.refetch()} /> : null}
|
||||
<div className="v2-reconcile-kpis">
|
||||
<article className="is-active"><small>活跃差异</small><strong>{data?.active.toLocaleString('zh-CN') ?? '—'}</strong><span>待处理与已确认来源</span></article>
|
||||
<article><small>待复核</small><strong>{data?.pending.toLocaleString('zh-CN') ?? '—'}</strong><span>尚未形成结论</span></article>
|
||||
<article><small>已确认来源</small><strong>{data?.confirmed.toLocaleString('zh-CN') ?? '—'}</strong><span>保留为活跃差异</span></article>
|
||||
<article><small>自动恢复</small><strong>{data?.recovered.toLocaleString('zh-CN') ?? '—'}</strong><span>检测不再命中</span></article>
|
||||
<article className={data?.overSla ? 'is-overdue' : ''}><small>超过 24 小时</small><strong>{data?.overSla.toLocaleString('zh-CN') ?? '—'}</strong><span>需要优先跟进</span></article>
|
||||
</div>
|
||||
<div className="v2-reconcile-layout">
|
||||
<div className="v2-reconcile-main">
|
||||
<div className="v2-reconcile-toolbar">
|
||||
<label className="v2-reconcile-search"><IconSearch /><input aria-label="搜索差异车辆或规则" value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="车牌、VIN、标题或说明" /></label>
|
||||
<select aria-label="筛选差异状态" value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||
<option value="active">活跃差异</option><option value="pending">待处理</option><option value="confirmed_source_a">确认来源 A</option><option value="confirmed_source_b">确认来源 B</option><option value="recovered">已恢复</option><option value="fixed">已修复</option><option value="no_action">无需处理</option><option value="all">全部状态</option>
|
||||
</select>
|
||||
<select aria-label="筛选严重程度" value={severity} onChange={(event) => setSeverity(event.target.value)}>
|
||||
<option value="all">全部等级</option><option value="critical">严重</option><option value="major">重要</option><option value="minor">一般</option>
|
||||
</select>
|
||||
<select aria-label="筛选差异规则" value={ruleCode} onChange={(event) => setRuleCode(event.target.value)}>
|
||||
<option value="all">全部规则</option>{data?.byRule.map((item) => <option key={item.name} value={item.name}>{ruleLabel(item.name)} · {item.count}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{issues.isError ? <InlineError message={issues.error.message} onRetry={() => issues.refetch()} /> : null}
|
||||
<div className="v2-reconcile-table-wrap">
|
||||
<table className="v2-reconcile-table"><thead><tr><th>等级</th><th>差异 / 规则</th><th>车辆</th><th>来源</th><th>最近发现</th><th>状态</th></tr></thead>
|
||||
<tbody>{page?.items.map((item) => <tr key={item.id} role="button" tabIndex={0} className={selectedID === item.id ? 'is-selected' : ''} onClick={() => setSelectedID(item.id)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') setSelectedID(item.id); }}>
|
||||
<td><span className={`v2-reconcile-severity is-${item.severity}`}>{severityLabel(item.severity)}</span></td>
|
||||
<td><strong>{item.title}</strong><span>{ruleLabel(item.ruleCode)} · 命中 {item.occurrenceCount.toLocaleString('zh-CN')} 次</span></td>
|
||||
<td><strong>{item.plate || '非单车差异'}</strong><span>{item.vin || '平台级口径'}</span></td>
|
||||
<td><strong>{[item.protocolA, item.protocolB].filter(Boolean).join(' / ') || '平台口径'}</strong><span>{item.category}</span></td>
|
||||
<td><strong>{fmt(item.lastSeenAt)}</strong><span>首次 {fmt(item.firstSeenAt)}</span></td>
|
||||
<td><span className={`v2-reconcile-status is-${item.status}`}>{statusLabel(item.status)}</span></td>
|
||||
</tr>)}</tbody>
|
||||
</table>
|
||||
{issues.isPending ? <p className="v2-reconcile-empty">正在读取差异队列…</p> : null}
|
||||
{!issues.isPending && !page?.items.length ? <p className="v2-reconcile-empty">当前筛选条件下没有差异。</p> : null}
|
||||
</div>
|
||||
<footer className="v2-reconcile-pagination"><span>共 {(page?.total ?? 0).toLocaleString('zh-CN')} 条 · 本页 {activeCount} 条活跃</span><div><button type="button" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}>上一页</button><button type="button" disabled={offset + PAGE_SIZE >= (page?.total ?? 0)} onClick={() => setOffset(offset + PAGE_SIZE)}>下一页</button></div></footer>
|
||||
</div>
|
||||
<aside className="v2-reconcile-trend">
|
||||
<header><strong>近 30 天趋势</strong><span>最近运行 {fmt(data?.lastRunAt)}</span></header>
|
||||
<div>{data?.trend.slice(-14).map((item) => <article key={item.date}><time>{item.date.slice(5)}</time><div title={`存量 ${item.active},新增 ${item.new},恢复 ${item.recovered}`}><i className="is-active" style={{ width: `${Math.max(2, item.active / maxTrend * 100)}%` }} /><i className="is-new" style={{ width: `${Math.max(0, item.new / maxTrend * 100)}%` }} /><i className="is-recovered" style={{ width: `${Math.max(0, item.recovered / maxTrend * 100)}%` }} /></div><b>{item.active}</b></article>)}</div>
|
||||
{!data?.trend.length ? <p>完成首次每日检测后显示趋势。</p> : null}
|
||||
<footer><span><i className="is-active" />存量</span><span><i className="is-new" />新增</span><span><i className="is-recovered" />恢复</span></footer>
|
||||
</aside>
|
||||
{selectedID && detail.isPending ? <aside className="v2-reconcile-detail"><div className="v2-reconcile-empty">正在读取证据…</div></aside> : null}
|
||||
{selectedID && detail.isError ? <aside className="v2-reconcile-detail"><InlineError message={detail.error.message} onRetry={() => detail.refetch()} /></aside> : null}
|
||||
{detail.data ? <ReconciliationDetail issue={detail.data} onClose={() => setSelectedID('')} /> : null}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -401,6 +401,18 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-ops-links article { display: grid; min-height: 46px; grid-template-columns: 8px minmax(0,1fr) auto; align-items: center; gap: 9px; border-bottom: 1px solid #eef2f7; padding: 7px 13px; }.v2-ops-links article > i, .v2-ops-sources article i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }.v2-ops-links article > i.is-ok, .v2-ops-sources article i.is-ok { background: var(--v2-green); }.v2-ops-links article > i.is-warning, .v2-ops-sources article i.is-warning { background: var(--v2-orange); }.v2-ops-links article > i.is-error, .v2-ops-sources article i.is-error { background: var(--v2-red); }.v2-ops-links article strong { font-size: 9px; }.v2-ops-links article p { margin: 3px 0 0; color: var(--v2-muted); font-size: 8px; }.v2-ops-links article > span { border-radius: 10px; background: #f1f5f9; padding: 3px 7px; color: #64748b; font-size: 8px; }
|
||||
.v2-ops-runtime dl { margin: 0; padding: 7px 13px; }.v2-ops-runtime dl div { display: flex; min-height: 31px; align-items: center; justify-content: space-between; border-bottom: 1px solid #eef2f7; font-size: 9px; }.v2-ops-runtime dt { color: var(--v2-muted); }.v2-ops-runtime dd { margin: 0; }.v2-ops-clear, .v2-ops-findings { margin: 4px 13px 12px; border-radius: 6px; background: #f1fbf7; padding: 8px; color: #17815d; font-size: 8px; }.v2-ops-findings { background: #fff7ed; color: #b45309; }.v2-ops-findings p { margin: 3px 0; }
|
||||
.v2-ops-sources > div { display: grid; grid-template-columns: repeat(3,1fr); }.v2-ops-sources article { min-width: 0; padding: 12px 14px; }.v2-ops-sources article + article { border-left: 1px solid var(--v2-border); }.v2-ops-sources article > div { display: flex; align-items: center; gap: 7px; }.v2-ops-sources article strong { font-size: 10px; }.v2-ops-sources article span { margin-left: auto; color: var(--v2-muted); font-size: 8px; }.v2-ops-sources article b { display: block; margin-top: 9px; font-size: 14px; }.v2-ops-sources article p { margin: 6px 0 0; color: #68768a; font-size: 8px; line-height: 1.45; }.v2-ops-sources article em { display: block; margin-top: 7px; color: var(--v2-blue); font-size: 8px; font-style: normal; }.is-ok { color: var(--v2-green) !important; }.is-warning { color: #b87900 !important; }.is-error { color: var(--v2-red) !important; }
|
||||
.v2-reconcile-center { position: relative; border: 1px solid #cbd8e8; border-radius: 14px; background: #fff; box-shadow: 0 10px 32px rgba(31,53,80,.09); overflow: hidden; }
|
||||
.v2-reconcile-heading { display: flex; min-height: 68px; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #e2e9f2; padding: 12px 16px; background: linear-gradient(105deg,#f7fbff 0%,#f2f7ff 58%,#eef5ff 100%); }.v2-reconcile-heading > div { display: grid; gap: 3px; }.v2-reconcile-heading small { color: #557494; font-size: 11px; }.v2-reconcile-heading strong { color: #17283b; font-size: 18px; }.v2-reconcile-heading span { color: #64758a; font-size: 12px; }.v2-reconcile-heading button { display: inline-flex; height: 36px; flex: 0 0 auto; align-items: center; gap: 6px; border: 1px solid #c5d4e5; border-radius: 8px; background: #fff; padding: 0 13px; color: #355272; cursor: pointer; font-size: 12px; }
|
||||
.v2-reconcile-kpis { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); border-bottom: 1px solid #e3eaf2; }.v2-reconcile-kpis article { position: relative; min-width: 0; padding: 13px 16px; }.v2-reconcile-kpis article + article::before { position: absolute; inset: 13px auto 13px 0; width: 1px; background: #e4ebf3; content: ''; }.v2-reconcile-kpis small { display: block; color: #748398; font-size: 11px; }.v2-reconcile-kpis strong { display: block; margin: 5px 0 2px; color: #17283b; font-size: 22px; line-height: 1.1; }.v2-reconcile-kpis span { color: #8794a6; font-size: 10px; }.v2-reconcile-kpis article.is-active strong { color: #2464c8; }.v2-reconcile-kpis article.is-overdue strong { color: #c2413d; }
|
||||
.v2-reconcile-layout { position: relative; display: grid; min-height: 480px; grid-template-columns: minmax(0,1fr) 260px; }.v2-reconcile-main { display: flex; min-width: 0; flex-direction: column; border-right: 1px solid #e3eaf2; }.v2-reconcile-toolbar { display: grid; grid-template-columns: minmax(240px,1fr) repeat(3,minmax(130px,auto)); gap: 8px; border-bottom: 1px solid #e7edf4; padding: 10px 12px; }.v2-reconcile-toolbar select, .v2-reconcile-search { height: 36px; border: 1px solid #d7e0eb; border-radius: 8px; background: #fff; color: #33465c; font-size: 12px; }.v2-reconcile-toolbar select { min-width: 0; padding: 0 10px; }.v2-reconcile-search { display: flex; align-items: center; gap: 8px; padding: 0 11px; }.v2-reconcile-search svg { color: #71849a; }.v2-reconcile-search input { width: 100%; min-width: 0; border: 0; outline: 0; color: #23374e; font: inherit; }
|
||||
.v2-reconcile-table-wrap { min-height: 360px; flex: 1; overflow: auto; }.v2-reconcile-table { width: 100%; min-width: 920px; border-collapse: collapse; table-layout: fixed; }.v2-reconcile-table th { position: sticky; z-index: 1; top: 0; height: 38px; background: #f7f9fc; padding: 0 11px; color: #718095; font-size: 11px; font-weight: 600; text-align: left; }.v2-reconcile-table th:nth-child(1) { width: 72px; }.v2-reconcile-table th:nth-child(2) { width: 27%; }.v2-reconcile-table th:nth-child(3) { width: 19%; }.v2-reconcile-table th:nth-child(4) { width: 15%; }.v2-reconcile-table th:nth-child(5) { width: 21%; }.v2-reconcile-table th:nth-child(6) { width: 112px; }.v2-reconcile-table tbody tr { cursor: pointer; border-top: 1px solid #edf1f6; outline: none; }.v2-reconcile-table tbody tr:hover, .v2-reconcile-table tbody tr:focus-visible { background: #f7fbff; }.v2-reconcile-table tbody tr.is-selected { background: #eef6ff; box-shadow: inset 3px 0 #3478d4; }.v2-reconcile-table td { height: 62px; padding: 8px 11px; vertical-align: middle; }.v2-reconcile-table td strong { display: block; overflow: hidden; color: #263a50; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.v2-reconcile-table td span:not(.v2-reconcile-severity):not(.v2-reconcile-status) { display: block; margin-top: 4px; overflow: hidden; color: #8290a2; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.v2-reconcile-severity, .v2-reconcile-status { display: inline-flex; min-height: 24px; align-items: center; border-radius: 999px; padding: 0 8px; font-size: 10px; font-weight: 600; }.v2-reconcile-severity.is-critical { background: #fff0ef; color: #c53c36; }.v2-reconcile-severity.is-major { background: #fff6e5; color: #a96600; }.v2-reconcile-severity.is-minor { background: #eef4fa; color: #60758d; }.v2-reconcile-status { background: #edf3fb; color: #426485; }.v2-reconcile-status.is-pending { background: #fff4df; color: #a45f00; }.v2-reconcile-status.is-confirmed_source_a, .v2-reconcile-status.is-confirmed_source_b { background: #e9f2ff; color: #2464c8; }.v2-reconcile-status.is-recovered, .v2-reconcile-status.is-fixed { background: #eaf8f2; color: #167759; }
|
||||
.v2-reconcile-empty { margin: 0; padding: 34px 16px; color: #77879a; font-size: 12px; text-align: center; }.v2-reconcile-pagination { display: flex; min-height: 46px; align-items: center; justify-content: space-between; border-top: 1px solid #e6edf4; padding: 7px 12px; color: #728197; font-size: 11px; }.v2-reconcile-pagination div { display: flex; gap: 6px; }.v2-reconcile-pagination button { height: 30px; border: 1px solid #d5dfeb; border-radius: 7px; background: #fff; padding: 0 11px; color: #405870; cursor: pointer; font-size: 11px; }.v2-reconcile-pagination button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
.v2-reconcile-trend { padding: 12px; background: #fbfcfe; }.v2-reconcile-trend > header { display: grid; gap: 3px; margin-bottom: 10px; }.v2-reconcile-trend header strong { color: #273b51; font-size: 13px; }.v2-reconcile-trend header span, .v2-reconcile-trend > p { color: #8390a2; font-size: 10px; }.v2-reconcile-trend article { display: grid; min-height: 25px; grid-template-columns: 36px minmax(0,1fr) 28px; align-items: center; gap: 7px; }.v2-reconcile-trend time, .v2-reconcile-trend article b { color: #718197; font-size: 9px; font-weight: 500; }.v2-reconcile-trend article > div { position: relative; height: 12px; border-radius: 3px; background: #edf2f7; overflow: hidden; }.v2-reconcile-trend article i { position: absolute; left: 0; height: 4px; }.v2-reconcile-trend article i.is-active { top: 0; background: #4c7fc4; }.v2-reconcile-trend article i.is-new { top: 4px; background: #e2a237; }.v2-reconcile-trend article i.is-recovered { top: 8px; background: #35a37b; }.v2-reconcile-trend > footer { display: flex; gap: 10px; margin-top: 10px; color: #758398; font-size: 9px; }.v2-reconcile-trend footer span { display: inline-flex; align-items: center; gap: 4px; }.v2-reconcile-trend footer i { width: 7px; height: 7px; border-radius: 2px; }.v2-reconcile-trend footer i.is-active { background: #4c7fc4; }.v2-reconcile-trend footer i.is-new { background: #e2a237; }.v2-reconcile-trend footer i.is-recovered { background: #35a37b; }
|
||||
.v2-reconcile-detail { position: absolute; z-index: 5; top: 0; right: 0; bottom: 0; width: min(460px,52vw); border-left: 1px solid #c9d7e7; background: #fff; box-shadow: -14px 0 34px rgba(25,48,75,.15); overflow: hidden; }.v2-reconcile-detail > header { display: flex; min-height: 66px; align-items: flex-start; justify-content: space-between; gap: 12px; border-bottom: 1px solid #e3eaf2; padding: 12px 14px; background: #f6f9fd; }.v2-reconcile-detail > header div { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 5px 8px; }.v2-reconcile-detail > header span { border-radius: 999px; background: #edf2f7; padding: 3px 7px; font-size: 10px; }.v2-reconcile-detail > header span.is-critical { background: #fff0ef; color: #c53c36; }.v2-reconcile-detail > header span.is-major { background: #fff6e5; color: #a96600; }.v2-reconcile-detail > header strong { width: 100%; color: #21364d; font-size: 15px; }.v2-reconcile-detail > header small { color: #738297; font-size: 10px; }.v2-reconcile-detail > header button { border: 0; background: transparent; color: #6f8093; cursor: pointer; font-size: 24px; line-height: 1; }.v2-reconcile-detail-body { height: calc(100% - 66px); padding: 12px 14px 20px; overflow: auto; }.v2-reconcile-identity { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); border: 1px solid #e1e8f0; border-radius: 9px; overflow: hidden; }.v2-reconcile-identity div { min-width: 0; padding: 10px; }.v2-reconcile-identity div + div { border-left: 1px solid #e5ebf2; }.v2-reconcile-identity small, .v2-reconcile-identity span { display: block; color: #8491a2; font-size: 9px; }.v2-reconcile-identity strong { display: block; margin: 4px 0; overflow: hidden; color: #2b3e54; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }.v2-reconcile-summary { margin: 11px 0; border-radius: 8px; background: #f3f7fb; padding: 10px 12px; color: #4b5e73; font-size: 11px; line-height: 1.55; }
|
||||
.v2-reconcile-evidence, .v2-reconcile-review, .v2-reconcile-actions { margin-top: 12px; border: 1px solid #e0e7ef; border-radius: 9px; overflow: hidden; }.v2-reconcile-evidence > header, .v2-reconcile-review > header, .v2-reconcile-actions > header { display: flex; min-height: 39px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5ebf2; padding: 0 11px; background: #fafbfd; }.v2-reconcile-evidence header strong, .v2-reconcile-review header strong, .v2-reconcile-actions header strong { color: #34495f; font-size: 11px; }.v2-reconcile-evidence header span, .v2-reconcile-review header span, .v2-reconcile-actions header span { color: #8794a4; font-size: 9px; }.v2-reconcile-evidence dl { margin: 0; }.v2-reconcile-evidence dl div { display: grid; grid-template-columns: 118px minmax(0,1fr); border-top: 1px solid #eef2f6; }.v2-reconcile-evidence dl div:first-child { border-top: 0; }.v2-reconcile-evidence dt { padding: 9px 10px; background: #fafbfd; color: #708095; font-size: 9px; }.v2-reconcile-evidence dd { min-width: 0; margin: 0; padding: 8px 10px; }.v2-reconcile-evidence pre { margin: 0; overflow: auto; color: #30475f; font: 10px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; white-space: pre-wrap; word-break: break-word; }
|
||||
.v2-reconcile-review { padding-bottom: 11px; }.v2-reconcile-review > header { margin-bottom: 9px; }.v2-reconcile-review > label { display: grid; gap: 5px; margin: 8px 11px; color: #66778b; font-size: 10px; }.v2-reconcile-review select, .v2-reconcile-review textarea { border: 1px solid #d5dfeb; border-radius: 7px; background: #fff; padding: 8px 9px; color: #30465d; font: inherit; }.v2-reconcile-review textarea { min-height: 78px; resize: vertical; }.v2-reconcile-review > button { height: 33px; margin: 3px 11px 0; border: 0; border-radius: 7px; background: #316fc3; padding: 0 13px; color: #fff; cursor: pointer; font-size: 11px; }.v2-reconcile-review > button:disabled { cursor: not-allowed; opacity: .5; }.v2-reconcile-review > p { margin: 7px 11px 0; color: #c23c37; font-size: 10px; }
|
||||
.v2-reconcile-actions > ol { margin: 0; padding: 9px 12px; list-style: none; }.v2-reconcile-actions li { display: grid; grid-template-columns: 10px minmax(0,1fr); gap: 8px; padding: 7px 0; }.v2-reconcile-actions li > i { width: 7px; height: 7px; margin-top: 4px; border-radius: 50%; background: #5d83b5; }.v2-reconcile-actions li strong { color: #354b61; font-size: 10px; }.v2-reconcile-actions li p { margin: 3px 0; color: #627489; font-size: 10px; line-height: 1.4; }.v2-reconcile-actions li span { color: #8b97a7; font-size: 9px; }.v2-reconcile-actions > p { margin: 0; padding: 14px; color: #8290a1; font-size: 10px; }
|
||||
.v2-source-diagnostic { position: relative; border: 1px solid #cfdbea; border-radius: 12px; background: #fff; box-shadow: 0 8px 28px rgba(31,53,80,.08); overflow: hidden; }.v2-source-diagnostic > header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5ebf3; padding: 9px 14px; background: linear-gradient(100deg,#f8fbff,#f4f8ff); }.v2-source-diagnostic > header div { display: grid; gap: 2px; }.v2-source-diagnostic > header small { color: #547297; font-size: 9px; }.v2-source-diagnostic > header strong { font-size: 15px; }.v2-source-diagnostic > header span { color: var(--v2-muted); font-size: 9px; }.v2-source-diagnostic > header button, .v2-source-search > button, .v2-source-policy-cell button { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 5px; border: 1px solid #cbd8e8; border-radius: 7px; background: #fff; padding: 0 12px; color: #355272; cursor: pointer; font-size: 10px; }.v2-source-diagnostic button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.v2-source-search { position: relative; display: flex; gap: 8px; padding: 12px 14px; }.v2-source-search > label { display: flex; min-width: 280px; flex: 1; height: 36px; align-items: center; gap: 8px; border: 1px solid #cbd8e8; border-radius: 8px; padding: 0 11px; color: #6c7c91; }.v2-source-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font: inherit; font-size: 11px; }.v2-source-search > button { height: 36px; border-color: #2f6fe4; background: #2f6fe4; color: #fff; }.v2-source-candidates { position: absolute; z-index: 12; top: 51px; right: 114px; left: 14px; max-height: 310px; overflow: auto; border: 1px solid #d6e0ec; border-radius: 9px; background: #fff; box-shadow: 0 12px 32px rgba(31,53,80,.18); }.v2-source-candidates > button { display: grid; width: 100%; grid-template-columns: 150px minmax(180px,1fr) auto; gap: 10px; border: 0; border-bottom: 1px solid #edf1f6; background: #fff; padding: 10px 12px; text-align: left; cursor: pointer; }.v2-source-candidates > button:hover { background: #f5f9ff; }.v2-source-candidates strong { font-size: 11px; }.v2-source-candidates span, .v2-source-candidates em, .v2-source-candidates p { color: var(--v2-muted); font-size: 9px; font-style: normal; }.v2-source-candidates p { margin: 0; padding: 14px; }.v2-source-candidates footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid #e5ebf3; background: #fff; padding: 7px 10px; }.v2-source-candidates footer div { display: flex; gap: 5px; }.v2-source-candidates footer button { border: 1px solid #d2dce8; border-radius: 5px; background: #fff; padding: 4px 8px; color: #52657d; font-size: 8px; }
|
||||
.v2-source-empty { display: grid; min-height: 110px; place-content: center; gap: 6px; color: var(--v2-muted); text-align: center; }.v2-source-empty strong { color: #42556e; font-size: 13px; }.v2-source-empty span { font-size: 10px; }.v2-source-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); border-block: 1px solid #e7edf4; background: #fafcff; }.v2-source-summary article { min-width: 0; padding: 11px 14px; }.v2-source-summary article + article { border-left: 1px solid #e7edf4; }.v2-source-summary small, .v2-source-summary span { display: block; overflow: hidden; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.v2-source-summary strong { display: block; margin: 5px 0; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.v2-source-recommendation { margin: 12px 14px 8px; border-left: 3px solid #2f6fe4; border-radius: 6px; background: #f3f7ff; padding: 9px 11px; }.v2-source-recommendation strong { font-size: 10px; }.v2-source-recommendation p { margin: 4px 0; color: #405774; font-size: 9px; line-height: 1.5; }.v2-source-recommendation span, .v2-source-readonly { color: #728299; font-size: 8px; }.v2-source-readonly { margin: 0 14px 8px; border-radius: 6px; background: #fff8e8; padding: 7px 9px; color: #966500; }
|
||||
@@ -1135,6 +1147,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-alert-rules { display: flex; flex-direction: column; }
|
||||
.v2-alert-rule-list { max-height: 300px; flex: none; }
|
||||
.v2-ops-kpis { grid-template-columns: repeat(3,1fr); }.v2-ops-grid { grid-template-columns: 1fr; }.v2-ops-sources > div { grid-template-columns: 1fr; }.v2-ops-sources article + article { border-top: 1px solid var(--v2-border); border-left: 0; }
|
||||
.v2-reconcile-kpis { grid-template-columns: repeat(3,1fr); }.v2-reconcile-layout { grid-template-columns: minmax(0,1fr) 220px; }.v2-reconcile-toolbar { grid-template-columns: minmax(220px,1fr) repeat(2,minmax(120px,auto)); }.v2-reconcile-toolbar select:last-child { grid-column: span 2; }
|
||||
.v2-source-summary { grid-template-columns: repeat(2,1fr); }.v2-source-summary article:nth-child(3) { border-left: 0; }.v2-source-summary article:nth-child(n+3) { border-top: 1px solid #e7edf4; }
|
||||
}
|
||||
|
||||
@@ -1454,6 +1467,7 @@ button, a { -webkit-tap-highlight-color: transparent; }
|
||||
.v2-alert-rule-editor > footer { align-items: stretch; flex-direction: column; }
|
||||
.v2-alert-notifications > footer { flex-direction: column; gap: 5px; }
|
||||
.v2-ops-page { padding: 8px; }.v2-ops-heading { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-ops-kpis { grid-template-columns: 1fr 1fr; }.v2-ops-kpis article + article::before { display: none; }.v2-ops-kpis article { border-bottom: 1px solid var(--v2-border); }
|
||||
.v2-reconcile-heading { align-items: flex-start; flex-direction: column; }.v2-reconcile-heading button { width: 100%; justify-content: center; }.v2-reconcile-kpis { grid-template-columns: 1fr 1fr; }.v2-reconcile-kpis article + article::before { display: none; }.v2-reconcile-kpis article { border-bottom: 1px solid #e5ebf2; }.v2-reconcile-layout { display: block; min-height: 520px; }.v2-reconcile-main { border-right: 0; }.v2-reconcile-toolbar { grid-template-columns: 1fr 1fr; }.v2-reconcile-search { grid-column: 1 / -1; }.v2-reconcile-toolbar select:last-child { grid-column: 1 / -1; }.v2-reconcile-trend { border-top: 1px solid #e3eaf2; }.v2-reconcile-detail { position: fixed; inset: 54px 0 0; width: auto; border-left: 0; }.v2-reconcile-identity { grid-template-columns: 1fr; }.v2-reconcile-identity div + div { border-top: 1px solid #e5ebf2; border-left: 0; }.v2-reconcile-evidence dl div { grid-template-columns: 96px minmax(0,1fr); }
|
||||
.v2-source-diagnostic > header { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-source-diagnostic > header button { width: 100%; }.v2-source-search { flex-direction: column; }.v2-source-search > label { width: auto; min-width: 0; height: 42px; }.v2-source-search > button { width: 100%; }.v2-source-candidates { top: 61px; right: 14px; }.v2-source-candidates > button { grid-template-columns: 1fr; gap: 3px; }.v2-source-summary { grid-template-columns: 1fr; }.v2-source-summary article + article, .v2-source-summary article:nth-child(3) { border-top: 1px solid #e7edf4; border-left: 0; }.v2-source-audit > header { height: auto; align-items: flex-start; flex-direction: column; gap: 3px; padding-block: 7px; }.v2-source-audit li { grid-template-columns: 35px minmax(0,1fr); }.v2-source-audit li em { grid-column: 2; }
|
||||
.v2-mileage-page { gap: 10px; padding: 12px 8px 18px; }
|
||||
.v2-mileage-heading { align-items: flex-start; flex-direction: row; gap: 10px; }
|
||||
|
||||
Reference in New Issue
Block a user