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

368 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, { Circle, Line, Path, Text as SvgText } from 'react-native-svg';
import { useAbilityAssessment } from '../src/hooks';
import { colors } from '../src/theme';
import { JOB_LEVEL_LABEL } from '../src/constants';
import type { AbilityDimension, AbilityTrendPoint, JobLevel } from '../src/types';
const LEVEL_TEXT_COLOR: Record<JobLevel, string> = {
excellent: colors.primaryDark,
good: colors.primary,
medium: colors.textSecondary,
};
const DIM_COLORS = [colors.primary, colors.info, colors.success, colors.action];
const DIM_LABELS = ['专业能力', '项目经验', '技能广度', '成长潜力'];
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack, onRefresh }: { onBack: () => void; onRefresh: () => 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>
<Pressable style={styles.headerBtn} onPress={onRefresh} hitSlop={8}>
<Ionicons name="refresh-outline" size={22} color={colors.textPrimary} />
</Pressable>
</View>
);
}
/* ===================== 子组件:综合评分卡 ===================== */
function ScoreCard({
score,
level,
percentile,
}: {
score: number;
level: JobLevel;
percentile?: number;
}) {
return (
<View style={styles.scoreCard}>
<View style={styles.scoreLeft}>
<Text style={styles.scoreLabel}>四大核心能力综合测评</Text>
<View style={styles.scoreRow}>
<Text style={styles.scoreValue}>{score}</Text>
<Text style={styles.scoreUnit}>分</Text>
<View style={[styles.levelTag, { backgroundColor: colors.primaryLight }]}>
<Text style={[styles.levelText, { color: LEVEL_TEXT_COLOR[level] }]}>
{JOB_LEVEL_LABEL[level]}
</Text>
</View>
</View>
<Text style={styles.percentile}>超过 {percentile ?? 0}% 同岗位求职者</Text>
</View>
<View style={styles.scoreIcon}>
<Ionicons name="cube-outline" size={40} color={colors.primary} />
</View>
</View>
);
}
/* ===================== 子组件:能力维度卡片 ===================== */
function DimensionCard({ dim, index }: { dim: AbilityDimension; index: number }) {
const color = DIM_COLORS[index % DIM_COLORS.length];
return (
<View style={styles.dimCard}>
<View style={styles.dimHeader}>
<View style={[styles.dimIcon, { backgroundColor: `${color}20` }]}>
<Ionicons name="stats-chart-outline" size={20} color={color} />
</View>
<View style={styles.dimTitleWrap}>
<Text style={styles.dimTitle}>{dim.label}</Text>
<Text style={styles.dimPercentile}>{dim.percentileText}</Text>
</View>
<View style={styles.dimScoreWrap}>
<Text style={styles.dimScore}>{dim.value}</Text>
<Text style={[styles.dimScoreLabel, { color }]}>良好</Text>
</View>
</View>
<View style={styles.dimTrack}>
<View style={[styles.dimFill, { width: `${dim.value}%`, backgroundColor: color }]} />
</View>
{dim.subItems && (
<View style={styles.subItems}>
{dim.subItems.map((item) => (
<View key={item.id} style={styles.subItem}>
<Text style={styles.subItemLabel}>{item.label}</Text>
<View style={styles.subItemTrack}>
<View
style={[
styles.subItemFill,
{ width: `${(item.score / item.maxScore) * 100}%`, backgroundColor: color },
]}
/>
</View>
<Text style={styles.subItemScore}>{item.score}分</Text>
</View>
))}
</View>
)}
</View>
);
}
/* ===================== 子组件:趋势折线图 ===================== */
function TrendChart({ trends }: { trends: AbilityTrendPoint[] }) {
const width = 320;
const height = 160;
const padding = { top: 20, right: 20, bottom: 30, left: 30 };
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
const max = 100;
const months = trends.map((t) => t.month);
const lines = DIM_LABELS.map((label, dimIndex) => {
const key = (['professional', 'project', 'skill', 'growth'] as const)[dimIndex];
const pts = trends.map((t, i) => ({
x: padding.left + (i / (trends.length - 1)) * chartW,
y: padding.top + (1 - (t[key] / max)) * chartH,
}));
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
return { label, color: DIM_COLORS[dimIndex], d, last: pts[pts.length - 1] };
});
return (
<View style={styles.chartCard}>
<Text style={styles.chartTitle}>能力成长趋势</Text>
<Svg width={width} height={height}>
{/* 网格线 */}
{[0, 25, 50, 75, 100].map((v, i) => {
const y = padding.top + (1 - v / max) * chartH;
return (
<Line key={i} x1={padding.left} y1={y} x2={width - padding.right} y2={y} stroke={colors.borderLight} strokeWidth={1} />
);
})}
{/* X 轴标签 */}
{months.map((m, i) => {
const x = padding.left + (i / (months.length - 1)) * chartW;
return (
<SvgText key={m} x={x} y={height - 8} fill={colors.textHint} fontSize="10" textAnchor="middle">
{m}
</SvgText>
);
})}
{/* 折线 */}
{lines.map((line) => (
<Path key={line.label} d={line.d} fill="none" stroke={line.color} strokeWidth={2} />
))}
{/* 端点 */}
{lines.map((line) => (
<Circle key={`dot-${line.label}`} cx={line.last.x} cy={line.last.y} r={3} fill={line.color} />
))}
</Svg>
<View style={styles.legend}>
{lines.map((line) => (
<View key={line.label} style={styles.legendItem}>
<View style={[styles.legendDot, { backgroundColor: line.color }]} />
<Text style={styles.legendText}>{line.label}</Text>
</View>
))}
</View>
</View>
);
}
/* ===================== 子组件:提升建议 ===================== */
function ImprovementCard({ item }: { item: import('../src/types').AbilityImprovement }) {
return (
<View style={styles.improveCard}>
<Ionicons name={item.icon as keyof typeof Ionicons.glyphMap} size={22} color={colors.primary} />
<View style={styles.improveBody}>
<Text style={styles.improveTitle}>{item.title}</Text>
<Text style={styles.improveDesc}>{item.description}</Text>
</View>
</View>
);
}
/* ===================== 子组件:四态 ===================== */
function Loading() {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.tip}>正在加载测评…</Text>
</View>
);
}
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<View style={styles.center}>
<Ionicons name="alert-circle-outline" size={40} color={colors.textHint} />
<Text style={styles.tip}>测评加载失败</Text>
<Pressable style={styles.retryBtn} onPress={onRetry} hitSlop={6}>
<Text style={styles.retryText}>点击重试</Text>
</Pressable>
</View>
);
}
/* ===================== 页面 ===================== */
export default function AbilityScreen() {
const router = useRouter();
const { ability, loading, error, refresh } = useAbilityAssessment();
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onRefresh={refresh} />
<Loading />
</SafeAreaView>
);
}
if (error || !ability) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onRefresh={refresh} />
<ErrorState onRetry={refresh} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onRefresh={refresh} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<ScoreCard score={ability.overallScore} level={ability.level} percentile={ability.percentile} />
<View style={styles.section}>
<Text style={styles.sectionTitle}>四维能力详解</Text>
{ability.dimensions.map((dim, i) => (
<DimensionCard key={dim.label} dim={dim} index={i} />
))}
</View>
<TrendChart trends={ability.trends} />
<View style={styles.section}>
<Text style={styles.sectionTitle}>针对性提升建议</Text>
{ability.improvements.map((item) => (
<ImprovementCard key={item.id} item={item} />
))}
</View>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.pageBg },
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingVertical: 12,
backgroundColor: colors.white,
},
headerBtn: { width: 40, alignItems: 'center' },
headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
body: { padding: 16, paddingBottom: 32 },
scoreCard: {
flexDirection: 'row',
backgroundColor: colors.white,
borderRadius: 14,
padding: 18,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.borderLight,
},
scoreLeft: { flex: 1 },
scoreLabel: { fontSize: 14, color: colors.textSecondary },
scoreRow: { flexDirection: 'row', alignItems: 'center', marginTop: 8 },
scoreValue: { fontSize: 48, fontWeight: '800', color: colors.textPrimary },
scoreUnit: { fontSize: 14, color: colors.textSecondary, marginTop: 16, marginRight: 8 },
levelTag: { borderRadius: 10, paddingHorizontal: 10, paddingVertical: 4 },
levelText: { fontSize: 12, fontWeight: '700' },
percentile: { fontSize: 13, color: colors.successText, marginTop: 8 },
scoreIcon: { justifyContent: 'center' },
section: { marginTop: 16 },
sectionTitle: { fontSize: 18, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 },
dimCard: {
backgroundColor: colors.white,
borderRadius: 12,
padding: 16,
marginBottom: 10,
borderWidth: 1,
borderColor: colors.borderLight,
},
dimHeader: { flexDirection: 'row', alignItems: 'center' },
dimIcon: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
dimTitleWrap: { flex: 1, marginLeft: 10 },
dimTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
dimPercentile: { fontSize: 12, color: colors.textSecondary, marginTop: 2 },
dimScoreWrap: { alignItems: 'flex-end' },
dimScore: { fontSize: 24, fontWeight: '800', color: colors.textPrimary },
dimScoreLabel: { fontSize: 11, fontWeight: '600', marginTop: 2 },
dimTrack: {
height: 8,
borderRadius: 4,
backgroundColor: colors.borderLight,
marginTop: 12,
overflow: 'hidden',
},
dimFill: { height: 8, borderRadius: 4 },
subItems: { marginTop: 12, gap: 10 },
subItem: { flexDirection: 'row', alignItems: 'center' },
subItemLabel: { width: 90, fontSize: 12, color: colors.textSecondary },
subItemTrack: { flex: 1, height: 6, borderRadius: 3, backgroundColor: colors.borderLight, overflow: 'hidden' },
subItemFill: { height: 6, borderRadius: 3 },
subItemScore: { width: 50, fontSize: 12, color: colors.textPrimary, textAlign: 'right' },
chartCard: {
backgroundColor: colors.white,
borderRadius: 12,
padding: 16,
marginTop: 16,
borderWidth: 1,
borderColor: colors.borderLight,
},
chartTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginBottom: 10 },
legend: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, marginTop: 8 },
legendItem: { flexDirection: 'row', alignItems: 'center' },
legendDot: { width: 8, height: 8, borderRadius: 4, marginRight: 4 },
legendText: { fontSize: 11, color: colors.textSecondary },
improveCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: 12,
padding: 14,
marginBottom: 10,
borderWidth: 1,
borderColor: colors.borderLight,
},
improveBody: { flex: 1, marginLeft: 12 },
improveTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary },
improveDesc: { fontSize: 12, color: colors.textSecondary, marginTop: 2, lineHeight: 18 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 40, gap: 12 },
tip: { fontSize: 14, color: colors.textSecondary },
retryBtn: { paddingHorizontal: 18, paddingVertical: 8, borderRadius: 16, backgroundColor: colors.primaryLight },
retryText: { fontSize: 13, fontWeight: '600', color: colors.primaryText },
});