feat(reports): 企业合规报表(后端聚合 + CSV 导出 + 前端页面) (#82)

新增合规报表:ReportService 逐任务聚合备份合规证据(成功率/最近成功/SLA 判定/加密/受保护量)+ JSON/CSV API;前端新增 /reports 页面(汇总卡片+明细表+CSV 导出)。后端 go test、前端 tsc+vite、端到端路由验证均通过。
This commit is contained in:
Wu Qing
2026-05-27 08:14:56 +08:00
committed by GitHub
parent 74e29a0753
commit a0d1e66199
10 changed files with 695 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ import {
IconCloud,
IconDesktop,
IconList,
IconFilePdf,
} from '@arco-design/web-react/icon'
import { useState } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
@@ -106,6 +107,7 @@ interface MenuItemConfig {
const menuItems: MenuItemConfig[] = [
{ key: '/dashboard', label: '仪表盘', icon: <IconDashboard /> },
{ key: '/reports', label: '合规报表', icon: <IconFilePdf /> },
{ key: '/backup/tasks', label: '备份任务', icon: <IconFile /> },
{ key: '/backup/records', label: '备份记录', icon: <IconHistory /> },
{ key: '/restore/records', label: '恢复记录', icon: <IconRefresh /> },

View File

@@ -0,0 +1,185 @@
import { Button, Card, Grid, Message, Select, Space, Statistic, Table, Tag, Typography } from '@arco-design/web-react'
import { IconDownload, IconRefresh } from '@arco-design/web-react/icon'
import { useCallback, useEffect, useState } from 'react'
import { downloadComplianceCSV, fetchComplianceReport } from '../../services/reports'
import type { ComplianceReport, ComplianceRisk, ComplianceTaskRow } from '../../types/reports'
import { resolveErrorMessage } from '../../utils/error'
import { formatBytes, formatDateTime, formatPercent } from '../../utils/format'
const { Row, Col } = Grid
const { Title, Text } = Typography
const rangeOptions = [
{ label: '近 7 天', value: 7 },
{ label: '近 30 天', value: 30 },
{ label: '近 90 天', value: 90 },
{ label: '近 180 天', value: 180 },
{ label: '近 365 天', value: 365 },
]
function riskTag(risk: ComplianceRisk) {
switch (risk) {
case 'ok':
return <Tag color="green"></Tag>
case 'at_risk':
return <Tag color="red"></Tag>
default:
return <Tag color="gray"></Tag>
}
}
export function ReportsPage() {
const [days, setDays] = useState(30)
const [report, setReport] = useState<ComplianceReport | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [exporting, setExporting] = useState(false)
const loadData = useCallback(async (range: number) => {
setLoading(true)
try {
const data = await fetchComplianceReport(range)
setReport(data)
setError('')
} catch (e) {
setError(resolveErrorMessage(e, '加载合规报表失败'))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadData(days)
}, [days, loadData])
async function handleExport() {
setExporting(true)
try {
await downloadComplianceCSV(days)
Message.success('已导出 CSV')
} catch (e) {
Message.error(resolveErrorMessage(e, '导出失败'))
} finally {
setExporting(false)
}
}
const summary = report?.summary
const columns = [
{
title: '任务',
dataIndex: 'taskName',
render: (_: unknown, row: ComplianceTaskRow) => (
<Space direction="vertical" size={2}>
<Text style={{ fontWeight: 600 }}>{row.taskName}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{row.type} · {row.nodeName || '本机'}
</Text>
</Space>
),
},
{ title: '状态', dataIndex: 'risk', render: (_: unknown, row: ComplianceTaskRow) => riskTag(row.risk) },
{
title: '成功率',
dataIndex: 'successRate',
render: (value: number, row: ComplianceTaskRow) => (row.totalRuns > 0 ? formatPercent(value) : '—'),
},
{
title: '周期内(成功/失败)',
dataIndex: 'totalRuns',
render: (_: unknown, row: ComplianceTaskRow) => `${row.successes} / ${row.failures}`,
},
{
title: '最近成功',
dataIndex: 'lastSuccessAt',
render: (value?: string) => (value ? formatDateTime(value) : <Text type="secondary"></Text>),
},
{ title: '保护量', dataIndex: 'protectedBytes', render: (value: number) => formatBytes(value) },
{
title: '加密',
dataIndex: 'encrypted',
render: (value: boolean) =>
value ? <Tag color="arcoblue" size="small"></Tag> : <Text type="secondary"></Text>,
},
{ title: 'SLA(RPO)', dataIndex: 'slaHoursRpo', render: (value: number) => (value > 0 ? `${value}h` : '—') },
]
const statCards = [
{ title: '受保护任务', value: summary?.enabledTasks ?? 0, suffix: `/ ${summary?.totalTasks ?? 0}` },
{ title: '合规任务', value: summary?.compliantTasks ?? 0, color: 'rgb(var(--green-6))' },
{ title: '风险任务', value: summary?.atRiskTasks ?? 0, color: 'rgb(var(--red-6))' },
{ title: '已加密任务', value: summary?.encryptedTasks ?? 0 },
]
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div>
<Title heading={5} style={{ margin: 0 }}>
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
{report
? `生成于 ${formatDateTime(report.generatedAt)} · 统计窗口 ${report.rangeDays}`
: '按任务的备份合规证据,可导出归档以供审计'}
</Text>
</div>
<Space>
<Select value={days} onChange={(value) => setDays(value as number)} options={rangeOptions} style={{ width: 130 }} />
<Button icon={<IconRefresh />} onClick={() => void loadData(days)} loading={loading}>
</Button>
<Button type="primary" icon={<IconDownload />} onClick={() => void handleExport()} loading={exporting} disabled={loading}>
CSV
</Button>
</Space>
</div>
<Row gutter={16}>
{statCards.map((card) => (
<Col span={6} key={card.title}>
<Card>
<Statistic
title={card.title}
value={card.value}
suffix={card.suffix}
groupSeparator
styleValue={card.color ? { color: card.color } : undefined}
/>
</Card>
</Col>
))}
</Row>
<Row gutter={16}>
<Col span={12}>
<Card>
<Statistic title="整体成功率" value={summary ? Number((summary.overallSuccessRate * 100).toFixed(1)) : 0} suffix="%" />
</Card>
</Col>
<Col span={12}>
<Card>
<Statistic title="受保护数据总量" value={formatBytes(summary?.totalProtectedBytes)} />
</Card>
</Col>
</Row>
{error ? (
<Card>
<Text type="error">{error}</Text>
</Card>
) : (
<Card>
<Table
rowKey="taskId"
loading={loading}
columns={columns}
data={report?.tasks ?? []}
pagination={{ pageSize: 20, sizeCanChange: true }}
border={false}
/>
</Card>
)}
</Space>
)
}

View File

@@ -15,6 +15,7 @@ import { GoogleDriveCallbackPage } from '../pages/storage-targets/GoogleDriveCal
import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage'
import { SettingsPage } from '../pages/settings/SettingsPage'
import { AuditLogsPage } from '../pages/audit/AuditLogsPage'
import { ReportsPage } from '../pages/reports/ReportsPage'
import NodesPage from '../pages/nodes/NodesPage'
import { ProtectedRoute } from './ProtectedRoute'
@@ -32,6 +33,7 @@ export function RouterView() {
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="reports" element={<ReportsPage />} />
<Route path="backup/tasks" element={<BackupTasksPage />} />
<Route path="backup/records" element={<BackupRecordsPage />} />
<Route path="restore/records" element={<RestoreRecordsPage />} />

View File

@@ -0,0 +1,24 @@
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
import type { ComplianceReport } from '../types/reports'
export async function fetchComplianceReport(days = 30) {
const response = await http.get<ApiEnvelope<ComplianceReport>>('/reports/compliance', { params: { days } })
return unwrapApiEnvelope(response.data)
}
// downloadComplianceCSV 通过带认证的 http 客户端拉取 CSV blob 并触发浏览器下载。
export async function downloadComplianceCSV(days = 30) {
const response = await http.get('/reports/compliance/export', {
params: { days },
responseType: 'blob',
})
const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `backupx-compliance-${days}d.csv`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}

40
web/src/types/reports.ts Normal file
View File

@@ -0,0 +1,40 @@
export type ComplianceRisk = 'ok' | 'at_risk' | 'not_applicable'
export interface ComplianceTaskRow {
taskId: number
taskName: string
type: string
enabled: boolean
nodeName: string
cronExpr: string
encrypted: boolean
retentionDays: number
slaHoursRpo: number
totalRuns: number
successes: number
failures: number
successRate: number
lastStatus: string
lastRunAt?: string
lastSuccessAt?: string
protectedBytes: number
compliant: boolean
risk: ComplianceRisk
}
export interface ComplianceSummary {
totalTasks: number
enabledTasks: number
compliantTasks: number
atRiskTasks: number
encryptedTasks: number
overallSuccessRate: number
totalProtectedBytes: number
}
export interface ComplianceReport {
generatedAt: string
rangeDays: number
summary: ComplianceSummary
tasks: ComplianceTaskRow[]
}