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

767 lines
20 KiB

import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Rect } from 'react-native-svg';
import { useRankData } from '../../src/hooks';
import { buildRankReportText } from '../../src/api';
import { shareText } from '../../src/lib/shareFile';
import { colors } from '../../src/theme';
import { formatThousands } from '../../src/utils';
import type { DimensionPercentile, RankData, RankEmptyState, TopCandidate } from '../../src/types';
/** 分布条刻度(0/25/50/75/100) */
const DISTRIBUTION_TICKS = [0, 25, 50, 75, 100];
/* ===================== 子组件:顶部导航 ===================== */
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 RankSummary({ rank, onExport }: { rank: RankData; onExport: () => void }) {
return (
<View style={styles.rankCard}>
<Text style={styles.rankNumber}>第 {rank.rank} 名</Text>
<Text style={styles.rankPercentile}>超越 {rank.percentile}% 的候选人</Text>
<View style={styles.scoreRow}>
<Text style={styles.scoreLabel}>综合评分</Text>
<Text style={styles.scoreValue}>{rank.overallScore} 分</Text>
<View style={styles.levelBadge}>
<Text style={styles.levelText}>{rank.scoreLevel}</Text>
</View>
</View>
<Text style={styles.rankPosition}>百分位排名:{rank.rankPosition}</Text>
{/* 本页已把维度百分位、TOP 候选人与全部提升建议都渲染出来了,
没有「更详细」的隐藏内容可跳,因此这里是导出/分享而非跳转 —— 文案与图标如实反映 */}
<Pressable style={styles.detailLink} onPress={onExport} hitSlop={6}>
<Text style={styles.detailLinkText}>导出分析报告</Text>
<Ionicons name="share-outline" size={14} color={colors.primary} />
</Pressable>
</View>
);
}
/* ===================== 子组件:百分位分布图(SVG) ===================== */
function DistributionChart({ percentile }: { percentile: number }) {
const clamped = Math.max(0, Math.min(100, percentile));
const width = 300;
const height = 40;
const padding = 20;
const trackWidth = width - padding * 2;
const fillWidth = (trackWidth * clamped) / 100;
const dotX = padding + fillWidth;
return (
<View>
<Svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`}>
{/* 轨道 */}
<Rect
x={padding}
y={height / 2 - 3}
width={trackWidth}
height={6}
rx={3}
fill={colors.border}
/>
{/* 已超越填充 */}
<Rect
x={padding}
y={height / 2 - 3}
width={fillWidth}
height={6}
rx={3}
fill={colors.primary}
/>
{/* 当前所在位置圆点 */}
<Circle
cx={dotX}
cy={height / 2}
r={8}
fill={colors.primary}
stroke={colors.white}
strokeWidth={2}
/>
</Svg>
<View style={styles.distributionLabels}>
{DISTRIBUTION_TICKS.map((tick) => (
<Text key={tick} style={styles.distributionLabel}>
{tick}%
</Text>
))}
</View>
</View>
);
}
/* ===================== 子组件:维度百分位行 ===================== */
function DimensionRow({ dim, isLast }: { dim: DimensionPercentile; isLast: boolean }) {
return (
<View style={[styles.dimensionItem, isLast && styles.dimensionItemLast]}>
<Text style={styles.dimensionName}>{dim.name}</Text>
<View style={styles.dimensionRight}>
<Text style={styles.dimensionPercentile}>{dim.percentile}</Text>
<Text style={styles.dimensionScore}>{dim.score} 分</Text>
</View>
</View>
);
}
/* ===================== 子组件:TOP 候选人行 ===================== */
function CandidateRow({ candidate, rank }: { candidate: TopCandidate; rank: number }) {
return (
<View style={styles.candidateItem}>
<View style={styles.candidateRank}>
<Text style={styles.candidateRankText}>{rank}</Text>
</View>
<View style={styles.candidateInfo}>
<View style={styles.candidateHeader}>
<Text style={styles.candidateName}>{candidate.name}</Text>
<Text style={styles.candidateScore}>{candidate.score}分</Text>
</View>
<Text style={styles.candidateTagLine}>{candidate.tags.join(' · ')}</Text>
</View>
</View>
);
}
/* ===================== 子组件:空状态 ===================== */
function EmptyState({ state, onAction }: { state: RankEmptyState; onAction: () => void }) {
return (
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.emptyContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.emptyCard}>
<View style={styles.emptyIconWrap}>
<Ionicons name="trophy-outline" size={48} color={colors.primary} />
</View>
<Text style={styles.emptyTitle}>{state.title}</Text>
<Text style={styles.emptySubtitle}>{state.subtitle}</Text>
<Pressable style={styles.emptyBtn} onPress={onAction} hitSlop={6}>
<Text style={styles.emptyBtnText}>{state.actionText}</Text>
</Pressable>
</View>
<View style={styles.progressCard}>
<Text style={styles.progressTitle}>简历完善进度</Text>
{state.resumeProgress.map((step, index) => (
<View key={step.id} style={styles.progressRow}>
<View style={styles.progressLeft}>
<View
style={[
styles.progressDot,
step.completed ? styles.progressDotDone : styles.progressDotTodo,
index === 0 && styles.progressDotFirst,
]}
>
{step.completed ? (
<Ionicons name="checkmark" size={12} color={colors.white} />
) : (
<Text style={styles.progressDotText}>{index + 1}</Text>
)}
</View>
{index < state.resumeProgress.length - 1 && (
<View
style={[
styles.progressLine,
step.completed && state.resumeProgress[index + 1]?.completed
? styles.progressLineDone
: null,
]}
/>
)}
</View>
<View style={styles.progressBody}>
<Text style={styles.progressLabel}>{step.label}</Text>
{step.tip ? <Text style={styles.progressTip}>{step.tip}</Text> : null}
</View>
</View>
))}
</View>
</ScrollView>
);
}
/* ===================== 子组件:提升建议 ===================== */
function TipItem({
title,
description,
type,
onAction,
}: {
title: string;
description: string;
type: 'tip' | 'action';
onAction?: () => void;
}) {
if (type === 'action') {
return (
<Pressable style={styles.tipAction} onPress={onAction} hitSlop={6}>
<Text style={styles.tipActionText}>{title}</Text>
<Ionicons name="chevron-forward" size={16} color={colors.primary} />
</Pressable>
);
}
return (
<View style={styles.tipItem}>
<Text style={styles.tipTitle}>{title}</Text>
{description ? <Text style={styles.tipDesc}>{description}</Text> : null}
</View>
);
}
/* ===================== 页面 ===================== */
export default function RankScreen() {
const router = useRouter();
const { jobId } = useLocalSearchParams<{ jobId: string }>();
const { rankData, dimensions, candidates, tips, emptyState, hasData, loading, error, refresh } =
useRankData(jobId || '1');
/**
* 导出竞争力分析报告:把本页已展示的排名、维度百分位与提升建议拼成文本经系统分享面板发出。
* `ok` 只代表面板已拉起,不能据此提示「导出成功」,只在失败时说明原因(见 lib/shareFile)。
*/
const handleExport = async () => {
if (!rankData) return;
const outcome = await shareText(
buildRankReportText({ hasData: true, rankData, dimensions, tips }),
'竞争力分析报告',
);
if (!outcome.ok) Alert.alert('无法导出', outcome.message);
};
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<View style={styles.centerContainer}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error) {
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<View style={styles.centerContainer}>
<Ionicons name="alert-circle-outline" size={48} color={colors.danger} />
<Text style={styles.errorText}>加载失败</Text>
<Pressable style={styles.retryButton} onPress={refresh}>
<Text style={styles.retryButtonText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
if (!hasData && emptyState) {
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<EmptyState state={emptyState} onAction={() => router.push('/resume')} />
</SafeAreaView>
);
}
if (!rankData) {
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<View style={styles.centerContainer}>
<Ionicons name="alert-circle-outline" size={48} color={colors.danger} />
<Text style={styles.errorText}>暂无数据</Text>
<Pressable style={styles.retryButton} onPress={refresh}>
<Text style={styles.retryButtonText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 模块2:岗位信息 */}
<View style={styles.jobInfoWrap}>
<Text style={styles.jobTitle}>
{rankData.jobTitle} · {rankData.company}
</Text>
<Text style={styles.candidateCount}>
共 {formatThousands(rankData.totalCandidates)} 位候选人申请
</Text>
</View>
{/* 模块3:综合排名卡片 */}
<RankSummary rank={rankData} onExport={handleExport} />
{/* 模块4:竞争力百分位分布图 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>| 竞争力百分位分布</Text>
<DistributionChart percentile={rankData.percentile} />
</View>
{/* 模块5:各维度百分位排名 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>| 各维度百分位排名</Text>
{dimensions.map((dim, index) => (
<DimensionRow key={dim.id} dim={dim} isLast={index === dimensions.length - 1} />
))}
</View>
{/* 模块6:TOP 10 候选人 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>TOP 10 候选人</Text>
{candidates.map((candidate, index) => (
<CandidateRow key={candidate.id} candidate={candidate} rank={index + 1} />
))}
</View>
{/* 模块7:提升建议 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>提升建议</Text>
{tips.map((tip) =>
tip.type === 'action' && tip.route ? (
<TipItem
key={tip.id}
title={tip.title}
description={tip.description}
type="action"
onAction={() => router.push(tip.route as string)}
/>
) : (
<TipItem
key={tip.id}
title={tip.title}
description={tip.description}
type="tip"
/>
),
)}
</View>
<View style={styles.bottomSpacer} />
</ScrollView>
</SafeAreaView>
);
}
/* ===================== 样式 ===================== */
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
/* ---- 顶部导航 ---- */
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
paddingVertical: 4,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
},
/* ---- 状态 ---- */
centerContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
errorText: {
marginTop: 12,
fontSize: 16,
color: colors.danger,
},
retryButton: {
marginTop: 16,
backgroundColor: colors.primary,
paddingHorizontal: 32,
paddingVertical: 10,
borderRadius: 24,
},
retryButtonText: {
color: colors.white,
fontSize: 16,
fontWeight: '600',
},
/* ---- 滚动内容 ---- */
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 40,
},
bottomSpacer: {
height: 20,
},
/* ---- 岗位信息 ---- */
jobInfoWrap: {
paddingVertical: 12,
alignItems: 'center',
},
jobTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
candidateCount: {
marginTop: 4,
fontSize: 13,
color: colors.textSecondary,
},
/* ---- 综合排名卡片 ---- */
rankCard: {
backgroundColor: colors.primaryLight,
borderRadius: 8,
padding: 16,
borderWidth: 1,
borderColor: colors.primaryBorder,
},
rankNumber: {
fontSize: 28,
fontWeight: '700',
color: colors.primary,
marginBottom: 4,
},
rankPercentile: {
fontSize: 20,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: 8,
},
scoreRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
},
scoreLabel: {
fontSize: 14,
color: colors.textSecondary,
},
scoreValue: {
fontSize: 16,
fontWeight: '600',
color: colors.textPrimary,
marginLeft: 8,
},
levelBadge: {
backgroundColor: colors.successLight,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 4,
marginLeft: 8,
},
levelText: {
fontSize: 12,
color: colors.successText,
fontWeight: '600',
},
rankPosition: {
marginTop: 8,
fontSize: 13,
color: colors.textSecondary,
},
detailLink: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
},
detailLinkText: {
fontSize: 14,
color: colors.primary,
fontWeight: '500',
marginRight: 2,
},
/* ---- 分区 ---- */
section: {
backgroundColor: colors.white,
borderRadius: 8,
padding: 16,
marginTop: 16,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: 12,
},
/* ---- 百分位分布图 ---- */
distributionLabels: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 4,
},
distributionLabel: {
fontSize: 11,
color: colors.textSecondary,
},
/* ---- 维度行 ---- */
dimensionItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
dimensionItemLast: {
borderBottomWidth: 0,
},
dimensionName: {
fontSize: 14,
color: colors.textPrimary,
},
dimensionRight: {
flexDirection: 'row',
alignItems: 'center',
},
dimensionPercentile: {
fontSize: 13,
color: colors.primary,
fontWeight: '500',
marginRight: 12,
},
dimensionScore: {
fontSize: 14,
color: colors.textSecondary,
width: 42,
textAlign: 'right',
},
/* ---- TOP 候选人 ---- */
candidateItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
candidateRank: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.pageBg,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
candidateRankText: {
fontSize: 12,
fontWeight: '600',
color: colors.textSecondary,
},
candidateInfo: {
flex: 1,
},
candidateHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
candidateName: {
fontSize: 14,
fontWeight: '600',
color: colors.textPrimary,
},
candidateScore: {
fontSize: 14,
fontWeight: '700',
color: colors.primary,
},
candidateTagLine: {
marginTop: 4,
fontSize: 12,
color: colors.textSecondary,
},
/* ---- 空状态 ---- */
emptyContent: {
paddingHorizontal: 16,
paddingTop: 24,
paddingBottom: 40,
},
emptyCard: {
backgroundColor: colors.white,
borderRadius: 8,
padding: 28,
alignItems: 'center',
},
emptyIconWrap: {
width: 88,
height: 88,
borderRadius: 44,
backgroundColor: colors.primaryLight,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
emptyTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 8,
},
emptySubtitle: {
fontSize: 13,
color: colors.textSecondary,
textAlign: 'center',
lineHeight: 20,
marginBottom: 20,
},
emptyBtn: {
backgroundColor: colors.primaryBtn,
paddingHorizontal: 32,
paddingVertical: 12,
borderRadius: 24,
},
emptyBtnText: {
color: colors.white,
fontSize: 15,
fontWeight: '600',
},
progressCard: {
backgroundColor: colors.white,
borderRadius: 8,
padding: 20,
marginTop: 16,
},
progressTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 16,
},
progressRow: {
flexDirection: 'row',
minHeight: 56,
},
progressLeft: {
width: 24,
alignItems: 'center',
},
progressDot: {
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
progressDotDone: {
backgroundColor: colors.success,
borderColor: colors.success,
},
progressDotTodo: {
backgroundColor: colors.white,
borderColor: colors.borderStrong,
},
progressDotFirst: {
borderColor: colors.success,
},
progressDotText: {
fontSize: 12,
color: colors.textSecondary,
},
progressLine: {
width: 2,
flex: 1,
backgroundColor: colors.border,
marginVertical: 4,
},
progressLineDone: {
backgroundColor: colors.success,
},
progressBody: {
flex: 1,
marginLeft: 12,
paddingBottom: 16,
},
progressLabel: {
fontSize: 14,
fontWeight: '600',
color: colors.textPrimary,
},
progressTip: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 4,
lineHeight: 18,
},
/* ---- 提升建议 ---- */
tipItem: {
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
tipTitle: {
fontSize: 14,
fontWeight: '500',
color: colors.textPrimary,
},
tipDesc: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 4,
},
tipAction: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: colors.primaryLight,
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 12,
marginTop: 8,
},
tipActionText: {
fontSize: 14,
color: colors.primary,
fontWeight: '500',
},
});