feat(platform): add mileage publish audit

This commit is contained in:
lingniu
2026-07-04 22:10:36 +08:00
parent c27767bc5e
commit ea18a058ac
3 changed files with 215 additions and 3 deletions

View File

@@ -228,6 +228,64 @@ function mileageBIPublishText({
].join('\n');
}
type MileagePublishAuditItem = {
key: string;
label: string;
status: 'pass' | 'review' | 'blocked';
value: string;
detail: string;
action: string;
};
function auditStatusColor(status: MileagePublishAuditItem['status']): 'green' | 'orange' | 'red' {
if (status === 'pass') return 'green';
if (status === 'blocked') return 'red';
return 'orange';
}
function auditStatusLabel(status: MileagePublishAuditItem['status']) {
if (status === 'pass') return '通过';
if (status === 'blocked') return '阻断';
return '复核';
}
function mileagePublishAuditReport({
filters,
items,
summary,
statisticsURL,
vehicleURL
}: {
filters: Record<string, string>;
items: MileagePublishAuditItem[];
summary: MileageSummary;
statisticsURL: string;
vehicleURL?: string;
}) {
const blocked = items.filter((item) => item.status === 'blocked').length;
const review = items.filter((item) => item.status === 'review').length;
const pass = items.filter((item) => item.status === 'pass').length;
const keyword = filters.keyword?.trim() || '全部车辆';
const protocol = filters.protocol?.trim() || '全部来源';
return [
'【统计发布审计】',
`发布结论:${blocked > 0 ? '阻断发布' : review > 0 ? '复核后发布' : '可发布'}`,
`车辆范围:${keyword}`,
`数据来源:${protocol}`,
`统计规模:${summary.vehicleCount.toLocaleString()} 车 / ${summary.recordCount.toLocaleString()} 条 / ${summary.sourceCount.toLocaleString()} 来源`,
`审计结果:通过 ${pass.toLocaleString()} / 复核 ${review.toLocaleString()} / 阻断 ${blocked.toLocaleString()}`,
'',
...items.map((item, index) => [
`${index + 1}. [${auditStatusLabel(item.status)}] ${item.label}${item.value}`,
` 说明:${item.detail}`,
` 动作:${item.action}`
].join('\n')),
'',
`统计查询:${statisticsURL}`,
...(vehicleURL ? [`车辆服务:${vehicleURL}`] : [])
].join('\n');
}
function mileageEvidencePackageText(row: DailyMileageRow) {
const dateFrom = row.date?.trim() ?? '';
const dateTo = nextDate(dateFrom);
@@ -368,6 +426,52 @@ export function Mileage({
redisOnlineKeyText,
onlineSummary.evidence
].filter(Boolean).join('');
const publishAuditItems: MileagePublishAuditItem[] = [
{
key: 'closure',
label: '区间闭合',
status: closureDeltaKm == null ? 'review' : closurePassed ? 'pass' : 'blocked',
value: closureStatus,
detail: closureDeltaKm == null ? '当前分页未覆盖全量记录,无法直接证明区间总值等于每日之和。' : `闭合差值 ${formatKm(Math.abs(closureDeltaKm))} km。`,
action: closurePassed ? '保留当前统计证据,可继续审计异常和来源。' : '缩小筛选范围、翻页或导出后重新核验闭合。'
},
{
key: 'coverage',
label: '记录覆盖',
status: pageCoverageRate >= 100 ? 'pass' : summary.recordCount > rows.length ? 'review' : 'pass',
value: formatPercent(pageCoverageRate),
detail: `${rows.length.toLocaleString()} / ${summary.recordCount.toLocaleString()} 条明细在当前页。`,
action: pageCoverageRate >= 100 ? '当前明细已覆盖统计范围。' : '发布前需要导出或拉齐全量明细。'
},
{
key: 'anomaly',
label: '异常记录',
status: anomalyRows.length > 0 ? 'review' : 'pass',
value: anomalyRows.length.toLocaleString(),
detail: anomalyRows.length > 0 ? '存在日里程异常标记,需核对首末总里程、补传和断链。' : '当前筛选范围未发现异常里程记录。',
action: anomalyRows.length > 0 ? '优先打开异常行的轨迹和 RAW 证据复核。' : '继续检查来源一致性和在线证据。'
},
{
key: 'source',
label: '来源一致性',
status: summary.sourceCount > 1 ? 'pass' : summary.sourceCount === 1 ? 'review' : 'blocked',
value: sourceConsistencyText,
detail: summary.sourceCount > 1 ? '可使用多协议来源交叉验证车辆统计。' : summary.sourceCount === 1 ? '只有单一来源,统计可用但可信度低于多来源。' : '当前统计缺少来源证据。',
action: summary.sourceCount > 1 ? '保留来源贡献作为审计附件。' : '核对 32960、808、MQTT 是否有缺失来源。'
},
{
key: 'online',
label: '在线证据',
status: onlineVehicleCount > 0 ? 'pass' : offlineVehicleCount > 0 ? 'review' : 'review',
value: `${onlineVehicleCount.toLocaleString()} 在线 / ${offlineVehicleCount.toLocaleString()} 离线`,
detail: onlineStatsDetail || '等待在线统计证据。',
action: onlineVehicleCount > 0 ? '实时在线证据可辅助统计发布。' : '统计发布前建议检查离线时长和平台转发状态。'
}
];
const publishBlockedCount = publishAuditItems.filter((item) => item.status === 'blocked').length;
const publishReviewCount = publishAuditItems.filter((item) => item.status === 'review').length;
const publishAuditConclusion = publishBlockedCount > 0 ? '阻断发布' : publishReviewCount > 0 ? '复核后发布' : '可发布';
const publishAuditColor = publishBlockedCount > 0 ? 'red' as const : publishReviewCount > 0 ? 'orange' as const : 'green' as const;
const maxDateMileage = Math.max(...dateSeries.map((item) => item.value), 0);
const maxSourceMileage = Math.max(...sourceSeries.map((item) => item.value), 0);
const filterSummary = [
@@ -491,6 +595,21 @@ export function Mileage({
'BI里程发布口径'
);
};
const copyPublishAudit = () => {
const vehicleURL = currentVehicleKeyword
? appURL(buildAppHash({ page: 'detail', keyword: currentVehicleKeyword, protocol: currentProtocol }))
: undefined;
copyText(
mileagePublishAuditReport({
filters,
items: publishAuditItems,
summary,
statisticsURL: appURL(window.location.hash || buildAppHash({ page: 'mileage', keyword: currentVehicleKeyword, protocol: currentProtocol })),
vehicleURL
}),
'统计发布审计'
);
};
const copyOnlineStatusHandoff = () => {
copyText(
onlineStatusHandoffText({
@@ -677,6 +796,36 @@ export function Mileage({
</Space>
</div>
</Card>
<Card bordered title="统计发布审计" style={{ marginTop: 16 }}>
<div className="vp-stat-audit-board">
<div className="vp-stat-audit-summary">
<Tag color={publishAuditColor}>{publishAuditConclusion}</Tag>
<div className="vp-monitor-metric-value">
{publishAuditItems.length - publishReviewCount - publishBlockedCount} / {publishReviewCount} / {publishBlockedCount}
</div>
<Typography.Text type="secondary">
线 RAW
</Typography.Text>
<Space wrap>
<Button size="small" onClick={copyPublishAudit}></Button>
<Button size="small" disabled={!currentVehicleKeyword} onClick={() => onOpenVehicle(currentVehicleKeyword, currentProtocol)}></Button>
</Space>
</div>
<div className="vp-stat-audit-grid">
{publishAuditItems.map((item) => (
<div key={item.key} className="vp-stat-audit-item">
<Space wrap>
<Tag color={auditStatusColor(item.status)}>{auditStatusLabel(item.status)}</Tag>
<Tag color="blue">{item.label}</Tag>
</Space>
<strong>{item.value}</strong>
<Typography.Text type="secondary">{item.detail}</Typography.Text>
<div className="vp-stat-audit-action">{item.action}</div>
</div>
))}
</div>
</div>
</Card>
<div className="vp-stat-workspace">
<Card bordered title="区间趋势">
<div className="vp-stat-chart">