You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
371 lines
13 KiB
371 lines
13 KiB
import { Ionicons } from '@expo/vector-icons';
|
|
import { useRouter } from 'expo-router';
|
|
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import Svg, { Line, Path, Rect, Text as SvgText } from 'react-native-svg';
|
|
import { useAuditReport } from '../src/hooks';
|
|
import { colors } from '../src/theme';
|
|
import type { AuditKPI, AuditTrendPoint, RiskDistributionItem } from '../src/types';
|
|
|
|
/* ===================== 子组件:顶部导航 ===================== */
|
|
|
|
function Header({ onBack }: { onBack: () => void }) {
|
|
return (
|
|
<View style={styles.header}>
|
|
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
|
|
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
|
|
</Pressable>
|
|
<Text style={styles.headerTitle}>数据审计报告</Text>
|
|
<View style={styles.headerBtn} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:等级卡 ===================== */
|
|
|
|
function GradeCard({ grade, desc, todayBlocked }: { grade: string; desc: string; todayBlocked: number }) {
|
|
return (
|
|
<View style={styles.gradeCard}>
|
|
<View style={styles.gradeCircle}>
|
|
<Text style={styles.gradeText}>{grade}</Text>
|
|
</View>
|
|
<View style={styles.gradeInfo}>
|
|
<Text style={styles.gradeTitle}>风控审计报告</Text>
|
|
<Text style={styles.gradeDesc}>{desc}</Text>
|
|
<Text style={styles.gradeSub}>今日拦截异常 {todayBlocked} 次</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:核心指标 ===================== */
|
|
|
|
function KPICard({ kpi }: { kpi: AuditKPI }) {
|
|
const isPositive = kpi.change >= 0;
|
|
return (
|
|
<View style={styles.kpiCard}>
|
|
<View style={[styles.kpiIconWrap, { backgroundColor: `${kpi.color}15` }]}>
|
|
<Ionicons name={kpi.icon as any} size={20} color={kpi.color} />
|
|
</View>
|
|
<Text style={styles.kpiValue}>
|
|
{kpi.value}
|
|
<Text style={styles.kpiUnit}>{kpi.unit ?? ''}</Text>
|
|
</Text>
|
|
<Text style={styles.kpiLabel}>{kpi.label}</Text>
|
|
<View style={styles.kpiChangeRow}>
|
|
<Ionicons
|
|
name={isPositive ? 'trending-up-outline' : 'trending-down-outline'}
|
|
size={12}
|
|
color={isPositive ? colors.success : colors.danger}
|
|
/>
|
|
<Text style={[styles.kpiChange, { color: isPositive ? colors.success : colors.danger }]}>
|
|
{isPositive ? '+' : ''}
|
|
{kpi.change}%
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:威胁趋势图 ===================== */
|
|
|
|
function ThreatTrendChart({ data }: { data: AuditTrendPoint[] }) {
|
|
const width = 320;
|
|
const height = 160;
|
|
const padding = { top: 20, right: 20, bottom: 28, left: 32 };
|
|
const chartW = width - padding.left - padding.right;
|
|
const chartH = height - padding.top - padding.bottom;
|
|
|
|
const maxRisk = Math.max(...data.map((d) => d.riskCount), 1);
|
|
const maxHandled = Math.max(...data.map((d) => d.handledCount), 1);
|
|
const maxY = Math.max(maxRisk, maxHandled);
|
|
|
|
const getX = (i: number) => padding.left + (i / (data.length - 1)) * chartW;
|
|
const getY = (v: number) => padding.top + chartH - (v / maxY) * chartH;
|
|
|
|
const riskPath = data
|
|
.map((d, i) => `${i === 0 ? 'M' : 'L'} ${getX(i)} ${getY(d.riskCount)}`)
|
|
.join(' ');
|
|
const handledPath = data
|
|
.map((d, i) => `${i === 0 ? 'M' : 'L'} ${getX(i)} ${getY(d.handledCount)}`)
|
|
.join(' ');
|
|
|
|
return (
|
|
<View style={styles.chartCard}>
|
|
<View style={styles.chartHeader}>
|
|
<Text style={styles.chartTitle}>威胁趋势</Text>
|
|
<View style={styles.legendRow}>
|
|
<View style={styles.legendItem}>
|
|
<View style={[styles.legendDot, { backgroundColor: colors.danger }]} />
|
|
<Text style={styles.legendText}>风险事件</Text>
|
|
</View>
|
|
<View style={styles.legendItem}>
|
|
<View style={[styles.legendDot, { backgroundColor: colors.success }]} />
|
|
<Text style={styles.legendText}>已处理</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
<Svg width={width} height={height}>
|
|
{/* 网格线 */}
|
|
{[0, 0.5, 1].map((ratio, i) => {
|
|
const y = padding.top + chartH * ratio;
|
|
return (
|
|
<Line
|
|
key={`grid-${i}`}
|
|
x1={padding.left}
|
|
y1={y}
|
|
x2={padding.left + chartW}
|
|
y2={y}
|
|
stroke={colors.borderLight}
|
|
strokeWidth={1}
|
|
strokeDasharray="3 3"
|
|
/>
|
|
);
|
|
})}
|
|
{/* Y 轴标签 */}
|
|
<SvgText x={padding.left - 8} y={padding.top + 4} fill={colors.textHint} fontSize="10" textAnchor="end">
|
|
{maxY}
|
|
</SvgText>
|
|
<SvgText x={padding.left - 8} y={padding.top + chartH / 2 + 4} fill={colors.textHint} fontSize="10" textAnchor="end">
|
|
{Math.round(maxY / 2)}
|
|
</SvgText>
|
|
<SvgText x={padding.left - 8} y={padding.top + chartH + 4} fill={colors.textHint} fontSize="10" textAnchor="end">
|
|
0
|
|
</SvgText>
|
|
{/* 折线 */}
|
|
<Path d={riskPath} fill="none" stroke={colors.danger} strokeWidth={2} />
|
|
<Path d={handledPath} fill="none" stroke={colors.success} strokeWidth={2} />
|
|
{/* 数据点 */}
|
|
{data.map((d, i) => (
|
|
<View key={`risk-${i}`}>
|
|
<Rect x={getX(i) - 3} y={getY(d.riskCount) - 3} width={6} height={6} fill={colors.danger} rx={3} />
|
|
<Rect x={getX(i) - 3} y={getY(d.handledCount) - 3} width={6} height={6} fill={colors.success} rx={3} />
|
|
</View>
|
|
))}
|
|
{/* X 轴标签 */}
|
|
{data.map((d, i) => (
|
|
<SvgText
|
|
key={`x-${i}`}
|
|
x={getX(i)}
|
|
y={padding.top + chartH + 16}
|
|
fill={colors.textHint}
|
|
fontSize="9"
|
|
textAnchor="middle"
|
|
>
|
|
{d.date}
|
|
</SvgText>
|
|
))}
|
|
</Svg>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 子组件:风险类型分布 ===================== */
|
|
|
|
function RiskDistribution({ items }: { items: RiskDistributionItem[] }) {
|
|
return (
|
|
<View style={styles.chartCard}>
|
|
<Text style={styles.chartTitle}>风险类型分布</Text>
|
|
<View style={styles.distributionWrap}>
|
|
<View style={styles.piePlaceholder}>
|
|
<View style={[styles.pieCenter, { backgroundColor: colors.white }]}>
|
|
<Text style={styles.pieTotal}>{items.length}</Text>
|
|
<Text style={styles.pieTotalLabel}>类型</Text>
|
|
</View>
|
|
</View>
|
|
<View style={styles.legendCol}>
|
|
{items.map((item) => (
|
|
<View key={item.id} style={styles.distLegendItem}>
|
|
<View style={[styles.legendDot, { backgroundColor: item.color }]} />
|
|
<Text style={styles.distLabel}>{item.label}</Text>
|
|
<Text style={styles.distPercent}>{item.percent}%</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/* ===================== 页面 ===================== */
|
|
|
|
export default function AuditReportScreen() {
|
|
const router = useRouter();
|
|
const { report, loading, error, refresh } = useAuditReport();
|
|
|
|
if (loading) {
|
|
return (
|
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
|
<Header onBack={() => router.back()} />
|
|
<View style={styles.center}>
|
|
<ActivityIndicator size="large" color={colors.primary} />
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
if (error || !report) {
|
|
return (
|
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
|
<Header onBack={() => router.back()} />
|
|
<View style={styles.center}>
|
|
<Text style={styles.tip}>加载失败</Text>
|
|
<Pressable style={styles.retryBtn} onPress={refresh}>
|
|
<Text style={styles.retryText}>重试</Text>
|
|
</Pressable>
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={styles.safeArea} edges={['top']}>
|
|
<Header onBack={() => router.back()} />
|
|
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
|
<View style={styles.content}>
|
|
<GradeCard grade={report.grade} desc={report.gradeDesc} todayBlocked={report.todayBlocked} />
|
|
|
|
<View style={styles.sectionHeader}>
|
|
<View style={styles.sectionBar} />
|
|
<Text style={styles.sectionTitle}>核心数据看板</Text>
|
|
<Text style={styles.sectionPeriod}>{report.period}</Text>
|
|
</View>
|
|
<View style={styles.kpiGrid}>
|
|
{report.kpis.map((kpi) => (
|
|
<KPICard key={kpi.id} kpi={kpi} />
|
|
))}
|
|
</View>
|
|
|
|
<ThreatTrendChart data={report.threatTrend} />
|
|
<View style={styles.chartGap} />
|
|
<RiskDistribution items={report.riskDistribution} />
|
|
</View>
|
|
<View style={styles.bottomPad} />
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
safeArea: { flex: 1, backgroundColor: colors.pageBg },
|
|
header: {
|
|
height: 44,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: 8,
|
|
backgroundColor: colors.white,
|
|
},
|
|
headerBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
|
headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
|
|
scroll: { flex: 1 },
|
|
content: { padding: 12 },
|
|
center: { flex: 1, alignItems: 'center', justifyContent: 'center' },
|
|
tip: { fontSize: 14, color: colors.textSecondary },
|
|
retryBtn: {
|
|
marginTop: 12,
|
|
paddingHorizontal: 18,
|
|
paddingVertical: 8,
|
|
borderRadius: 16,
|
|
backgroundColor: colors.primaryLight,
|
|
},
|
|
retryText: { fontSize: 13, fontWeight: '600', color: colors.primaryText },
|
|
gradeCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
backgroundColor: colors.success,
|
|
borderRadius: 16,
|
|
padding: 20,
|
|
},
|
|
gradeCircle: {
|
|
width: 72,
|
|
height: 72,
|
|
borderRadius: 36,
|
|
backgroundColor: colors.white,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
gradeText: { fontSize: 36, fontWeight: '800', color: colors.success },
|
|
gradeInfo: { flex: 1, marginLeft: 16 },
|
|
gradeTitle: { fontSize: 18, fontWeight: '700', color: colors.textInverse },
|
|
gradeDesc: { fontSize: 13, color: colors.textInverse, opacity: 0.9, marginTop: 4, lineHeight: 18 },
|
|
gradeSub: { fontSize: 12, color: colors.textInverse, opacity: 0.8, marginTop: 8 },
|
|
sectionHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginTop: 24,
|
|
marginBottom: 12,
|
|
},
|
|
sectionBar: { width: 4, height: 16, borderRadius: 2, backgroundColor: colors.primary, marginRight: 8 },
|
|
sectionTitle: { flex: 1, fontSize: 16, fontWeight: '700', color: colors.textPrimary },
|
|
sectionPeriod: { fontSize: 12, color: colors.textHint },
|
|
kpiGrid: {
|
|
flexDirection: 'row',
|
|
flexWrap: 'wrap',
|
|
justifyContent: 'space-between',
|
|
},
|
|
kpiCard: {
|
|
width: '48%',
|
|
backgroundColor: colors.white,
|
|
borderRadius: 12,
|
|
padding: 16,
|
|
marginBottom: 12,
|
|
},
|
|
kpiIconWrap: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 10,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginBottom: 10,
|
|
},
|
|
kpiValue: { fontSize: 24, fontWeight: '700', color: colors.textPrimary },
|
|
kpiUnit: { fontSize: 13, fontWeight: '600', color: colors.textSecondary },
|
|
kpiLabel: { fontSize: 13, color: colors.textSecondary, marginTop: 4 },
|
|
kpiChangeRow: { flexDirection: 'row', alignItems: 'center', marginTop: 8 },
|
|
kpiChange: { fontSize: 12, fontWeight: '600', marginLeft: 3 },
|
|
chartCard: {
|
|
backgroundColor: colors.white,
|
|
borderRadius: 12,
|
|
padding: 16,
|
|
},
|
|
chartGap: { height: 12 },
|
|
chartHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 12,
|
|
},
|
|
chartTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
|
|
legendRow: { flexDirection: 'row' },
|
|
legendItem: { flexDirection: 'row', alignItems: 'center', marginLeft: 12 },
|
|
legendDot: { width: 8, height: 8, borderRadius: 4 },
|
|
legendText: { marginLeft: 4, fontSize: 11, color: colors.textSecondary },
|
|
distributionWrap: { flexDirection: 'row', alignItems: 'center', marginTop: 8 },
|
|
piePlaceholder: {
|
|
width: 120,
|
|
height: 120,
|
|
borderRadius: 60,
|
|
backgroundColor: colors.border,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
pieCenter: {
|
|
width: 80,
|
|
height: 80,
|
|
borderRadius: 40,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
pieTotal: { fontSize: 22, fontWeight: '700', color: colors.textPrimary },
|
|
pieTotalLabel: { fontSize: 11, color: colors.textSecondary },
|
|
legendCol: { flex: 1, marginLeft: 20 },
|
|
distLegendItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingVertical: 6,
|
|
},
|
|
distLabel: { flex: 1, marginLeft: 8, fontSize: 13, color: colors.textPrimary },
|
|
distPercent: { fontSize: 13, fontWeight: '700', color: colors.textPrimary },
|
|
bottomPad: { height: 24 },
|
|
});
|
|
|