轻松聘
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

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 keyof typeof Ionicons.glyphMap} 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: 8 },
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: 8,
padding: 8,
},
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: 8,
padding: 8,
marginBottom: 8,
},
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: 8,
padding: 8,
},
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 },
});