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

478 lines
15 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 { useHREvaluation } from '../src/hooks';
import { colors, levelLabelTextColors } from '../src/theme';
import type { EvaluationDimension, EvaluationReview, EvaluationTrendPoint } from '../src/types';
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack, onAppeal }: { onBack: () => void; onAppeal: () => 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}>HR评价详情</Text>
<Pressable style={styles.headerAppealBtn} onPress={onAppeal} hitSlop={8}>
<Text style={styles.headerAppealText}>申诉</Text>
</Pressable>
</View>
);
}
/* ===================== 子组件:综合评分卡 ===================== */
function ScoreCard({
score,
percentile,
change,
}: {
score: number;
percentile: number;
change: number;
}) {
return (
<View style={styles.scoreCard}>
<View style={styles.scoreLeft}>
<Text style={styles.scoreLabel}>综合评分</Text>
<View style={styles.scoreValueRow}>
<Text style={styles.scoreValue}>{score}</Text>
<Text style={styles.scoreUnit}>分</Text>
<View style={styles.levelBadge}>
<Text style={styles.levelText}>良好</Text>
</View>
</View>
<Text style={styles.scoreMeta}>
超过 {percentile}% 的求职者
<Text style={change >= 0 ? styles.changeUp : styles.changeDown}>
{' '}
较上期 {change >= 0 ? '+' : ''}
{change}
</Text>
</Text>
</View>
<View style={styles.scoreIcon}>
<Ionicons name="ribbon-outline" size={40} color={colors.primary} />
</View>
</View>
);
}
/* ===================== 子组件:评分趋势折线图 ===================== */
function TrendChart({ trend }: { trend: EvaluationTrendPoint[] }) {
const width = 320;
const height = 150;
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 max = 100;
const min = 75;
const points = trend.map((t, i) => ({
x: padding.left + (i / (trend.length - 1)) * chartW,
y: padding.top + (1 - (t.score - min) / (max - min)) * chartH,
score: t.score,
month: t.month,
}));
const d = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
const last = points[points.length - 1];
return (
<View style={styles.chartCard}>
<Text style={styles.cardTitle}>评分趋势</Text>
<Svg width={width} height={height}>
{[80, 85, 90, 95, 100].map((v) => {
const y = padding.top + (1 - (v - min) / (max - min)) * chartH;
return (
<Line
key={v}
x1={padding.left}
y1={y}
x2={width - padding.right}
y2={y}
stroke={colors.borderLight}
strokeWidth={1}
/>
);
})}
{[80, 90, 100].map((v) => {
const y = padding.top + (1 - (v - min) / (max - min)) * chartH;
return (
<SvgText key={`label-${v}`} x={padding.left - 6} y={y + 3} fill={colors.textHint} fontSize="10" textAnchor="end">
{v}
</SvgText>
);
})}
<Path d={d} fill="none" stroke={colors.primary} strokeWidth={2} />
{points.map((p, i) => (
<Circle key={`dot-${i}`} cx={p.x} cy={p.y} r={3} fill={colors.primary} />
))}
<Circle cx={last.x} cy={last.y} r={5} fill={colors.primary} />
{points.map((p) => (
<SvgText
key={`month-${p.month}`}
x={p.x}
y={height - 8}
fill={colors.textHint}
fontSize="10"
textAnchor="middle"
>
{p.month}
</SvgText>
))}
</Svg>
</View>
);
}
/* ===================== 子组件:六维能力评分 ===================== */
function DimensionCard({ dimensions }: { dimensions: EvaluationDimension[] }) {
return (
<View style={styles.card}>
<Text style={styles.cardTitle}>六维能力评分</Text>
{dimensions.map((dim) => (
<View key={dim.id} style={styles.dimRow}>
<View style={styles.dimHead}>
<Text style={styles.dimName}>{dim.name}</Text>
<View style={styles.dimRight}>
<Text style={styles.dimScore}>{dim.score}</Text>
<View style={styles.dimTag}>
<Text
style={[
styles.dimTagText,
{ color: levelLabelTextColors[dim.level] ?? colors.textSecondary },
]}
>
{dim.level}
</Text>
</View>
{dim.change !== undefined && (
<Text style={dim.change >= 0 ? styles.changeUp : styles.changeDown}>
{dim.change >= 0 ? '+' : ''}
{dim.change}
</Text>
)}
</View>
</View>
<View style={styles.dimTrack}>
<View style={[styles.dimFill, { width: `${dim.score}%` }]} />
</View>
</View>
))}
</View>
);
}
/* ===================== 子组件:HR 评价列表 ===================== */
function ReviewCard({ review }: { review: EvaluationReview }) {
return (
<View style={styles.reviewCard}>
<View style={styles.reviewHead}>
<Text style={styles.reviewCompany}>{review.company}</Text>
<View style={styles.reviewRating}>
<Ionicons name="star" size={14} color={colors.action} />
<Text style={styles.reviewRatingText}>{review.rating}.0</Text>
</View>
</View>
<Text style={styles.reviewDate}>{review.date}</Text>
<Text style={styles.reviewContent}>{review.content}</Text>
<View style={styles.reviewTags}>
{review.tags.map((tag) => (
<View key={tag} style={styles.reviewTag}>
<Text style={styles.reviewTagText}>{tag}</Text>
</View>
))}
</View>
</View>
);
}
/* ===================== 子组件:评价说明 ===================== */
function RulesCard({ rules }: { rules: string[] }) {
return (
<View style={styles.card}>
<Text style={styles.cardTitle}>评价说明</Text>
{rules.map((rule, i) => (
<View key={i} style={styles.ruleRow}>
<View style={styles.ruleDot} />
<Text style={styles.ruleText}>{rule}</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="ribbon-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 HREvaluationScreen() {
const router = useRouter();
const { evaluation, loading, error, refresh } = useHREvaluation();
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onAppeal={() => router.push('/appeal')} />
<Loading />
</SafeAreaView>
);
}
if (error || !evaluation) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onAppeal={() => router.push('/appeal')} />
<ErrorState onRetry={refresh} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onAppeal={() => router.push('/appeal')} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<ScoreCard
score={evaluation.overallScore}
percentile={evaluation.percentile}
change={3.2}
/>
<TrendChart trend={evaluation.trend} />
<DimensionCard dimensions={evaluation.dimensions} />
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>HR评价({evaluation.reviews.length})</Text>
</View>
{/* 标题里带着数量,为 0 时下方不能是一片空白 */}
{evaluation.reviews.length === 0 ? (
<View style={styles.emptyBox}>
<Ionicons name="chatbubble-ellipses-outline" size={28} color={colors.textHint} />
<Text style={styles.emptyText}>暂时还没有 HR 留下评价</Text>
</View>
) : (
evaluation.reviews.map((review) => <ReviewCard key={review.id} review={review} />)
)}
<RulesCard rules={evaluation.rules} />
</ScrollView>
<View style={styles.footer}>
<Text style={styles.footerQuestion}>对评分有疑问?</Text>
<Pressable style={styles.footerBtn} onPress={() => router.push('/appeal')}>
<Text style={styles.footerBtnText}>发起申诉</Text>
</Pressable>
</View>
</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 },
headerAppealBtn: {
paddingHorizontal: 12,
paddingVertical: 5,
borderRadius: 14,
backgroundColor: colors.primaryLight,
},
headerAppealText: { fontSize: 13, fontWeight: '600', color: colors.primaryText },
body: { paddingHorizontal: 16, paddingBottom: 100, paddingTop: 12 },
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 },
scoreCard: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: 14,
padding: 18,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.borderLight,
},
scoreLeft: { flex: 1 },
scoreLabel: { fontSize: 13, color: colors.textSecondary },
scoreValueRow: { flexDirection: 'row', alignItems: 'center', marginTop: 8 },
scoreValue: { fontSize: 44, fontWeight: '800', color: colors.textPrimary },
scoreUnit: { fontSize: 14, color: colors.textSecondary, marginTop: 16, marginLeft: 4 },
levelBadge: {
marginLeft: 10,
backgroundColor: colors.primaryLight,
borderRadius: 10,
paddingHorizontal: 10,
paddingVertical: 4,
},
levelText: { fontSize: 12, fontWeight: '700', color: colors.primaryDark },
scoreMeta: { fontSize: 13, color: colors.successText, marginTop: 10 },
changeUp: { color: colors.successText },
changeDown: { color: colors.danger },
scoreIcon: { alignItems: 'center', justifyContent: 'center' },
card: {
backgroundColor: colors.white,
borderRadius: 14,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.borderLight,
},
chartCard: {
backgroundColor: colors.white,
borderRadius: 14,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.borderLight,
},
cardTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 },
dimRow: { marginBottom: 14 },
dimHead: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 6,
},
dimName: { fontSize: 14, color: colors.textPrimary },
dimRight: { flexDirection: 'row', alignItems: 'center' },
dimScore: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginRight: 8 },
dimTag: {
backgroundColor: colors.pageBg,
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 2,
marginRight: 8,
},
dimTagText: { fontSize: 11, fontWeight: '600' },
dimTrack: {
height: 8,
borderRadius: 4,
backgroundColor: colors.borderLight,
overflow: 'hidden',
},
dimFill: { height: 8, borderRadius: 4, backgroundColor: colors.primary },
sectionHeader: { marginBottom: 10, marginTop: 4 },
emptyBox: {
backgroundColor: colors.surface,
borderRadius: 12,
paddingVertical: 28,
paddingHorizontal: 16,
alignItems: 'center',
gap: 8,
},
emptyText: { fontSize: 13, color: colors.textSecondary, textAlign: 'center' },
sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
reviewCard: {
backgroundColor: colors.white,
borderRadius: 12,
padding: 14,
marginBottom: 10,
borderWidth: 1,
borderColor: colors.borderLight,
},
reviewHead: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
reviewCompany: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
reviewRating: { flexDirection: 'row', alignItems: 'center' },
reviewRatingText: {
fontSize: 13,
fontWeight: '700',
color: colors.actionText,
marginLeft: 4,
},
reviewDate: { fontSize: 12, color: colors.textHint, marginTop: 2 },
reviewContent: { fontSize: 13, color: colors.textSecondary, marginTop: 10, lineHeight: 20 },
reviewTags: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 10 },
reviewTag: {
backgroundColor: colors.primaryLight,
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 2,
},
reviewTagText: { fontSize: 11, color: colors.primaryText },
ruleRow: { flexDirection: 'row', alignItems: 'flex-start', marginBottom: 10 },
ruleDot: {
width: 5,
height: 5,
borderRadius: 3,
backgroundColor: colors.primary,
marginTop: 7,
marginRight: 8,
},
ruleText: { flex: 1, fontSize: 13, color: colors.textSecondary, lineHeight: 20 },
footer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
footerQuestion: { fontSize: 14, color: colors.textPrimary },
footerBtn: {
backgroundColor: colors.primaryBtn,
borderRadius: 18,
paddingHorizontal: 20,
paddingVertical: 9,
},
footerBtnText: { fontSize: 14, fontWeight: '700', color: colors.white },
});