import { Button, Card, Col, Form, Row, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui'; import { IconCopy } from '@douyinfe/semi-icons'; import { useEffect, useState } from 'react'; import { api } from '../api/client'; import type { OpsHealth, QualitySummary, QualityIssueRow } from '../api/types'; import { PageHeader } from '../components/PageHeader'; import { qualityIssueLabel, qualityIssueOptions, qualityProtocolLabel, qualityProtocolOptions } from '../domain/qualityIssue'; import { qualityIssueVehicleLookup } from '../domain/vehicleLookup'; const statusColor: Record = { ok: 'green', warning: 'orange', error: 'red' }; function formatLag(value?: number | null) { return value == null ? '未接入' : value.toLocaleString(); } function storageReadStatus(health: OpsHealth | null) { if (!health) return 'pending'; return health.tdengineWritable && health.mysqlWritable ? 'ok' : 'error'; } function formatRequestTimeout(health: OpsHealth | null) { const value = health?.runtime?.requestTimeoutMs; return value == null || value <= 0 ? '未限制' : `${value.toLocaleString()} ms`; } const emptySummary: QualitySummary = { issueVehicleCount: 0, issueRecordCount: 0, errorCount: 0, warningCount: 0, protocols: [], issueTypes: [] }; const alertRuleTemplates = [ { issueType: 'NO_SOURCE', level: 'P0', owner: '平台接入', trigger: '绑定车辆连续无 GB32960、JT808、MQTT 来源证据', notify: '立即通知接入运维,30 分钟未恢复升级给业务责任人', sla: '30 分钟确认' }, { issueType: 'VIN_MISSING', level: 'P0', owner: '车辆档案', trigger: '来源有数据但无法归并到 VIN', notify: '通知档案维护人补齐车牌、手机号和 VIN 映射', sla: '2 小时修复' }, { issueType: 'LINK_GAP', level: 'P1', owner: '链路运维', trigger: '车辆或平台来源上报间断、Redis 在线状态过期', notify: '通知平台转发和网关值班人,持续异常进入日报', sla: '1 小时恢复' }, { issueType: 'FIELD_MISSING', level: 'P1', owner: '协议解析', trigger: '核心字段缺失导致定位、里程或统计不可用', notify: '通知解析负责人核对字段映射和 RAW 样本', sla: '当日闭环' } ]; const notificationPolicies = [ { name: 'P0 实时中断', target: '接入运维 + 业务责任人', channel: '站内告警 / 邮件 / 企业微信', condition: '无来源、VIN 缺失、存储不可写' }, { name: 'P1 数据质量', target: '协议解析 + 数据治理', channel: '站内告警 / 每日汇总邮件', condition: '字段缺失、链路间断、容量风险' }, { name: 'P2 趋势关注', target: '数智中心', channel: '周报 / 趋势看板', condition: '单源车辆、档案缺项、来源覆盖下降' } ]; type AlertRuleRow = { issueType: string; level: string; owner: string; trigger: string; notify: string; sla: string; count: number; }; function qualityParams(values: Record) { const params = new URLSearchParams(); if (values?.keyword) params.set('keyword', values.keyword); if (values?.protocol) params.set('protocol', values.protocol); if (values?.issueType) params.set('issueType', values.issueType); return params; } function qualityShareURL() { return `${window.location.origin}${window.location.pathname}${window.location.hash}`; } function qualityActionRecommendation(row: QualityIssueRow) { if (row.issueType === 'NO_SOURCE') { return { label: '确认平台转发', color: 'orange' as const, detail: '车辆已绑定但没有任何来源证据,先确认平台转发、端口和订阅。' }; } if (row.issueType === 'VIN_MISSING' || !row.vin?.trim()) { return { label: '维护身份绑定', color: 'red' as const, detail: '数据已有来源但无法归并到 VIN,优先用车牌/手机号补齐绑定。' }; } if (row.issueType === 'LINK_GAP') { return { label: '排查来源链路', color: 'orange' as const, detail: '来源存在上报间断,优先检查平台转发、网络和消费延迟。' }; } if (row.issueType === 'FIELD_MISSING') { return { label: '核对解析字段', color: 'orange' as const, detail: '字段缺失会影响统计和展示,优先核对解析映射和原始 RAW。' }; } return { label: '查看车辆服务', color: 'blue' as const, detail: '进入车辆服务详情,结合来源证据继续排查。' }; } function issueCount(summary: QualitySummary, issueType: string) { return summary.issueTypes.find((item) => item.name === issueType)?.count ?? 0; } function alertRuleRows(summary: QualitySummary, health: OpsHealth | null): AlertRuleRow[] { const storageWritable = health == null || (health.tdengineWritable && health.mysqlWritable); const capacityCount = health?.capacityFindings?.length ?? 0; const rows = alertRuleTemplates.map((item) => ({ ...item, count: issueCount(summary, item.issueType) })); rows.push({ issueType: 'CAPACITY_RISK', level: storageWritable && capacityCount === 0 ? 'P2' : 'P0', owner: '基础设施', trigger: 'Kafka Lag、连接容量、Redis 在线 Key 或存储读写异常', notify: storageWritable ? '容量风险进入运维日报,超过阈值升级' : '立即通知基础设施值班人处理存储不可写', sla: storageWritable ? '当日评估' : '15 分钟恢复', count: capacityCount + (storageWritable ? 0 : 1) }); return rows; } function ruleStatusColor(count: number, level: string): 'green' | 'orange' | 'red' | 'grey' { if (count <= 0) return 'green'; if (level === 'P0') return 'red'; if (level === 'P1') return 'orange'; return 'grey'; } async function copyText(value: string, label: string) { const text = value.trim(); if (!text) { Toast.warning(`${label}为空`); return; } try { await navigator.clipboard.writeText(text); Toast.success(`已复制${label}`); } catch { Toast.error(`复制${label}失败`); } } async function copyQualityShareURL() { await copyText(qualityShareURL(), '筛选链接'); } export function Quality({ onOpenVehicle, onHealthLoaded, onFiltersChange, initialFilters = {} }: { onOpenVehicle: (vin: string, protocol?: string) => void; onHealthLoaded?: (health: OpsHealth) => void; onFiltersChange?: (filters: Record) => void; initialFilters?: Record; }) { const [issues, setIssues] = useState([]); const [summary, setSummary] = useState(emptySummary); const [health, setHealth] = useState(null); const [loadingIssues, setLoadingIssues] = useState(true); const [loadingSummary, setLoadingSummary] = useState(true); const [loadingHealth, setLoadingHealth] = useState(true); const [filters, setFilters] = useState>(initialFilters); const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 }); const primaryIssueType = summary.issueTypes[0]?.name; const rules = alertRuleRows(summary, health); const activeRuleCount = rules.filter((item) => item.count > 0).length; const p0RuleCount = rules.filter((item) => item.count > 0 && item.level === 'P0').length; const loadIssues = (values: Record = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => { setLoadingIssues(true); const params = qualityParams(values); params.set('limit', String(pageSize)); params.set('offset', String((page - 1) * pageSize)); api.qualityIssues(params) .then((nextPage) => { setIssues(nextPage.items); setPagination({ currentPage: page, pageSize, total: nextPage.total }); }) .catch((error: Error) => Toast.error(error.message)) .finally(() => setLoadingIssues(false)); }; const loadSummary = (values: Record = filters) => { setLoadingSummary(true); api.qualitySummary(qualityParams(values)) .then(setSummary) .catch((error: Error) => Toast.error(error.message)) .finally(() => setLoadingSummary(false)); }; const loadHealth = () => { setLoadingHealth(true); api.opsHealth() .then((nextHealth) => { setHealth(nextHealth); onHealthLoaded?.(nextHealth); }) .catch((error: Error) => Toast.error(error.message)) .finally(() => setLoadingHealth(false)); }; useEffect(() => { setFilters(initialFilters); loadSummary(initialFilters); loadIssues(initialFilters, 1, pagination.pageSize); loadHealth(); }, [JSON.stringify(initialFilters)]); const applyFilters = (nextFilters: Record) => { setFilters(nextFilters); onFiltersChange?.(nextFilters); loadSummary(nextFilters); loadIssues(nextFilters, 1, pagination.pageSize); }; const drillPrimaryIssue = () => { if (!primaryIssueType) { return; } applyFilters({ ...filters, issueType: primaryIssueType }); }; const drillProtocol = (protocol: string) => { applyFilters({ ...filters, protocol }); }; const drillIssueType = (issueType: string) => { applyFilters({ ...filters, issueType }); }; const filterSummary = [ filters.keyword ? `关键词:${filters.keyword}` : '', filters.protocol ? `数据来源:${qualityProtocolLabel(filters.protocol)}` : '', filters.issueType ? `问题类型:${qualityIssueLabel(filters.issueType)}` : '' ].filter(Boolean); return (
{[ { label: '问题车辆', value: summary.issueVehicleCount.toLocaleString() }, { label: '问题记录', value: summary.issueRecordCount.toLocaleString() }, { label: '错误 / 警告', value: `${summary.errorCount}/${summary.warningCount}` }, { label: '主要问题', value: primaryIssueType ? `${qualityIssueLabel(primaryIssueType)} ${summary.issueTypes[0].count}` : '-', onClick: primaryIssueType ? drillPrimaryIssue : undefined } ].map((item) => ( {item.onClick ? ( ) : ( <>
{item.value}
{item.label}
)}
))}
{formatLag(health?.kafkaLag)} {formatLag(health?.activeConnections)} {formatLag(health?.redisOnlineKeys)} {storageReadStatus(health) === 'pending' ? '检测中' : storageReadStatus(health) === 'ok' ? '正常' : '异常'}
{[ { label: '事件触发', value: `${activeRuleCount} 类活跃`, detail: '断链、无来源、VIN 缺失、字段缺失和容量异常进入告警池。' }, { label: '分级通知', value: `${p0RuleCount} 类 P0`, detail: '按错误、警告、关注分层推送给平台、运维和业务责任人。' }, { label: '处置回执', value: `${summary.issueVehicleCount.toLocaleString()} 辆车`, detail: '告警需要关联车辆服务、来源证据和处理结论,形成可追踪闭环。' } ].map((item) => (
{item.label}
{item.value}
{item.detail}
))}
pagination={false} dataSource={rules} rowKey="issueType" columns={[ { title: '规则', render: (_: unknown, row: AlertRuleRow) => row.issueType === 'CAPACITY_RISK' ? '容量与存储风险' : qualityIssueLabel(row.issueType) }, { title: '级别', width: 90, render: (_: unknown, row: AlertRuleRow) => {row.level} }, { title: '当前命中', width: 110, render: (_: unknown, row: AlertRuleRow) => row.count.toLocaleString() }, { title: '触发条件', dataIndex: 'trigger' }, { title: 'SLA', dataIndex: 'sla', width: 110 } ]} />
{notificationPolicies.map((item) => (
{item.name} {item.target}
{item.condition}
{item.channel}
))}
{health?.capacityFindings?.length ? ( {health.capacityFindings.map((item) => ( {item} ))} ) : null} {filterSummary.length > 0 ? ( {filterSummary.map((item) => ( {item} ))} ) : null} qualityProtocolLabel(row.name) }, { title: '问题数', dataIndex: 'count', width: 120 }, { title: '操作', width: 140, render: (_: unknown, row: { name: string }) => ( ) } ]} />
qualityIssueLabel(row.name) }, { title: '问题数', dataIndex: 'count', width: 120 }, { title: '操作', width: 160, render: (_: unknown, row: { name: string }) => ( ) } ]} />
{ const nextFilters = values as Record; applyFilters(nextFilters); }} style={{ marginBottom: 12 }}> {qualityProtocolOptions.map((item) => ( {item.label} ))} {qualityIssueOptions.map((item) => ( {item.label} ))}
`${row?.protocol ?? ''}-${row?.issueType ?? ''}-${row?.lastSeen ?? ''}-${row?.detail ?? ''}`} dataSource={issues} pagination={{ currentPage: pagination.currentPage, pageSize: pagination.pageSize, total: pagination.total, showSizeChanger: true, onPageChange: (page) => loadIssues(filters, page, pagination.pageSize), onPageSizeChange: (pageSize) => loadIssues(filters, 1, pageSize) }} columns={[ { title: 'VIN', dataIndex: 'vin' }, { title: '车牌', dataIndex: 'plate' }, { title: '手机号', dataIndex: 'phone' }, { title: '来源地址', dataIndex: 'sourceEndpoint' }, { title: '数据来源', render: (_: unknown, row: QualityIssueRow) => qualityProtocolLabel(row.protocol) }, { title: '问题', render: (_: unknown, row: QualityIssueRow) => qualityIssueLabel(row.issueType) }, { title: '级别', render: (_: unknown, row: QualityIssueRow) => {row.severity} }, { title: '最后时间', dataIndex: 'lastSeen' }, { title: '说明', dataIndex: 'detail' }, { title: '处置建议', width: 260, render: (_: unknown, row: QualityIssueRow) => { const action = qualityActionRecommendation(row); return (
{action.label}
{action.detail}
); } }, { title: '操作', width: 250, render: (_: unknown, row: QualityIssueRow) => { const lookup = qualityIssueVehicleLookup(row); return ( ); } } ]} /> 链路健康} style={{ marginTop: 16 }} >
( {row.status} ) }, { title: '说明', dataIndex: 'detail' } ]} /> ); }