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

819 lines
21 KiB

import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useTalentRecommendation } from '../../src/hooks';
import { ENTERPRISE_DISPLAY_NAME } from '../../src/constants';
import { colors } from '../../src/theme';
import type { TalentCandidate, TalentRecommendationTab } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */
function Header({
companyName,
onBack,
searching,
onToggleSearch,
}: {
companyName: string;
onBack: () => void;
/** 是否处于搜索态;加载中/加载失败时没有数据可搜,不传即不渲染搜索按钮 */
searching?: boolean;
onToggleSearch?: () => void;
}) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<View style={styles.headerCenter}>
<Text style={styles.headerCompany}>{companyName}</Text>
<Text style={styles.headerTitle}>人才推荐</Text>
</View>
<View style={styles.headerActions}>
{/* 原先这里是「搜索 + 漏斗」两个图标:漏斗没有对应页面,先摘掉;
搜索改成真的关键词过滤(姓名 / 职位 / 技能 / 学校) */}
{onToggleSearch && (
<Pressable style={styles.headerActionBtn} onPress={onToggleSearch} hitSlop={6}>
<Ionicons
name={searching ? 'close' : 'search-outline'}
size={22}
color={colors.textPrimary}
/>
</Pressable>
)}
</View>
</View>
);
}
/* ===================== 子组件:搜索栏 ===================== */
function SearchBar({
value,
onChange,
onClear,
}: {
value: string;
onChange: (text: string) => void;
onClear: () => void;
}) {
return (
<View style={styles.searchBar}>
<Ionicons name="search-outline" size={18} color={colors.textHint} />
<TextInput
style={styles.searchInput}
value={value}
onChangeText={onChange}
placeholder="搜索姓名 / 职位 / 技能"
placeholderTextColor={colors.textHint}
autoFocus
returnKeyType="search"
/>
{value !== '' && (
<Pressable onPress={onClear} hitSlop={8}>
<Ionicons name="close-circle" size={16} color={colors.textHint} />
</Pressable>
)}
</View>
);
}
/* ===================== 子组件:Tab 栏 ===================== */
function TabBar({
tabs,
active,
onChange,
}: {
tabs: TalentRecommendationTab[];
active: string;
onChange: (key: string) => void;
}) {
return (
<View style={styles.tabBar}>
{tabs.map((tab) => {
const isActive = tab.key === active;
return (
<Pressable key={tab.key} style={styles.tabItem} onPress={() => onChange(tab.key)}>
<Text style={[styles.tabLabel, isActive && styles.tabLabelActive]}>
{tab.label}
<Text style={[styles.tabCount, isActive && styles.tabCountActive]}>({tab.count})</Text>
</Text>
<View style={[styles.tabIndicator, isActive && styles.tabIndicatorActive]} />
</Pressable>
);
})}
</View>
);
}
/* ===================== 子组件:今日推荐头部 ===================== */
/**
* 今日推荐头部。
* 原先右上角有「查看人才市场报告」入口,但项目没有该页面 —— 按「不留假交互」的口径摘掉。
*/
function TodayHeader({ count }: { count: number }) {
return (
<View style={styles.todayHeader}>
<View>
<Text style={styles.todayTitle}>今日为你推荐</Text>
<View style={styles.todayCountRow}>
<Text style={styles.todayCount}>{count}</Text>
<Text style={styles.todayUnit}>位优质候选人</Text>
</View>
</View>
</View>
);
}
/* ===================== 子组件:候选人卡片 ===================== */
function CandidateCard({ candidate }: { candidate: TalentCandidate }) {
const router = useRouter();
return (
<View style={styles.candidateCard}>
<View style={styles.candidateHeader}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{candidate.avatar}</Text>
</View>
<View style={styles.candidateInfo}>
<View style={styles.nameRow}>
<Text style={styles.candidateName}>{candidate.name}</Text>
<View style={styles.matchTag}>
<Text style={styles.matchTagText}>{candidate.matchPercent}%</Text>
</View>
</View>
<Text style={styles.candidateTitle}>{candidate.title}</Text>
<Text style={styles.candidateMeta}>
{candidate.age}岁 · {candidate.school}
</Text>
<Text style={styles.candidateSalary}>{candidate.expectedSalary}</Text>
</View>
</View>
<View style={styles.scoreRow}>
<Text style={styles.scoreLabel}>综合评分</Text>
<Text style={styles.scoreValue}>{candidate.overallScore}</Text>
<Text style={styles.percentileText}>超越 {candidate.percentile}% 候选人</Text>
</View>
<View style={styles.tagRow}>
{candidate.tags.map((tag) => (
<View key={tag} style={styles.tag}>
<Text style={styles.tagText}>{tag}</Text>
</View>
))}
</View>
<View style={styles.candidateActions}>
<Pressable style={styles.chatBtn} onPress={() => router.navigate('/enterprise-chat')}>
<Text style={styles.chatBtnText}>发起沟通</Text>
</Pressable>
<Pressable style={styles.detailBtn} onPress={() => router.push('/candidate-detail?id=' + candidate.id)}>
<Text style={styles.detailBtnText}>查看详情</Text>
</Pressable>
</View>
</View>
);
}
/* ===================== 子组件:人才池数据 ===================== */
function TalentPool({ stats, actions, onPressAction }: { stats: { id: string; value: string; label: string }[]; actions: PoolActionItem[]; onPressAction: (action: PoolActionItem) => void }) {
return (
<View style={styles.poolCard}>
<Text style={styles.poolTitle}>人才池数据</Text>
<View style={styles.poolStatsRow}>
{stats.map((stat) => (
<View key={stat.id} style={styles.poolStat}>
<Text style={styles.poolStatValue}>{stat.value}</Text>
<Text style={styles.poolStatLabel}>{stat.label}</Text>
</View>
))}
</View>
<View style={styles.poolActions}>
{actions.map((action) => (
<Pressable key={action.label} style={styles.poolAction} onPress={() => onPressAction(action)}>
<Ionicons name={action.icon as keyof typeof Ionicons.glyphMap} size={20} color={colors.textSecondary} />
<Text style={styles.poolActionText}>{action.label}</Text>
</Pressable>
))}
</View>
</View>
);
}
/* ===================== 子组件:空状态 ===================== */
function EmptyState({
actions,
dataReference,
onFilter,
}: {
actions: string[];
dataReference: { label: string; value: string }[];
onFilter: () => void;
}) {
return (
<View style={styles.emptyCard}>
<View style={styles.emptyIconWrap}>
<Ionicons name="people-outline" size={48} color={colors.primary} />
</View>
<Text style={styles.emptyTitle}>暂无匹配候选人</Text>
<Text style={styles.emptySubtitle}>当前筛选条件下没有较适合的候选人,试试调整筛选条件</Text>
<Pressable style={styles.emptyBtn} onPress={onFilter} hitSlop={6}>
<Text style={styles.emptyBtnText}>调整筛选条件</Text>
</Pressable>
<View style={styles.actionCard}>
<View style={styles.actionHeader}>
<Ionicons name="bulb-outline" size={18} color={colors.compareOrange} />
<Text style={styles.actionTitle}>推荐操作</Text>
</View>
{actions.map((action, index) => (
<View key={index} style={styles.actionRow}>
<View style={styles.actionDot} />
<Text style={styles.actionText}>{action}</Text>
</View>
))}
</View>
<View style={styles.dataCard}>
<Text style={styles.dataTitle}>数据参考</Text>
{dataReference.map((item, index) => (
<View key={index} style={styles.dataRow}>
<Ionicons name="trending-up-outline" size={16} color={colors.success} />
<Text style={styles.dataText}>
{item.label}
<Text style={styles.dataValue}> {item.value}</Text>
</Text>
</View>
))}
</View>
</View>
);
}
/* ===================== 页面 ===================== */
/**
* 人才池快捷操作项。`route` 必填 —— 每个入口都必须有落点,
* 批量发邀请 / 批量发Offer / 群发通知三项因项目内没有对应页面,已摘掉。
*/
type PoolActionItem = { icon: string; label: string; route: string };
const POOL_ACTIONS: PoolActionItem[] = [
{ icon: 'calendar-outline', label: '面试评分', route: '/interview-score' },
];
export default function TalentRecommendationScreen() {
const router = useRouter();
const {
recommendation,
activeTab,
setActiveTab,
keyword,
setKeyword,
candidates,
isEmptySearch,
loading,
error,
refresh,
} = useTalentRecommendation();
const [searching, setSearching] = useState(false);
const toggleSearch = () => {
if (searching) setKeyword('');
setSearching((prev) => !prev);
};
/** 人才池快捷操作:每个入口都由数据保证有落点,页面不再需要「没页面就弹提示」的分支 */
const handlePoolAction = (action: PoolActionItem) => {
router.push(action.route);
};
/** 空状态里的「调整筛选条件」:把筛选真正复位(切回全部 + 清关键词) */
const handleFilter = () => {
setActiveTab('all');
setKeyword('');
};
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header companyName={ENTERPRISE_DISPLAY_NAME} onBack={() => router.back()} />
<View style={styles.center}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error || !recommendation) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header companyName={ENTERPRISE_DISPLAY_NAME} onBack={() => router.back()} />
<View style={styles.center}>
<Ionicons name="alert-circle-outline" size={40} color={colors.danger} />
<Text style={styles.tip}>加载失败</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<Header
companyName={recommendation.companyName}
onBack={() => router.back()}
searching={searching}
onToggleSearch={toggleSearch}
/>
<TabBar tabs={recommendation.tabs} active={activeTab} onChange={setActiveTab} />
{searching && (
<SearchBar value={keyword} onChange={setKeyword} onClear={() => setKeyword('')} />
)}
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{recommendation.hasData ? (
<View>
<TodayHeader count={recommendation.todayCount} />
{candidates.length === 0 ? (
<View style={styles.emptyList}>
<Ionicons name="search-outline" size={32} color={colors.textHint} />
<Text style={styles.emptyListText}>
{isEmptySearch
? `没有匹配「${keyword.trim()}」的候选人`
: '这个筛选条件下暂时没有候选人'}
</Text>
<Pressable style={styles.emptyListBtn} onPress={handleFilter} hitSlop={6}>
<Text style={styles.emptyListBtnText}>重置筛选</Text>
</Pressable>
</View>
) : (
candidates.map((candidate) => (
<CandidateCard key={candidate.id} candidate={candidate} />
))
)}
<TalentPool
stats={recommendation.poolStats}
actions={POOL_ACTIONS}
onPressAction={handlePoolAction}
/>
</View>
) : (
<EmptyState
actions={recommendation.recommendedActions}
dataReference={recommendation.dataReference}
onFilter={handleFilter}
/>
)}
</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' },
headerCenter: { alignItems: 'center' },
headerCompany: { fontSize: 12, color: colors.textSecondary },
headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary, marginTop: 2 },
headerActions: {
flexDirection: 'row',
alignItems: 'center',
},
headerActionBtn: {
width: 36,
alignItems: 'center',
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: colors.white,
paddingHorizontal: 16,
paddingBottom: 10,
},
searchInput: {
flex: 1,
backgroundColor: colors.pageBg,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 14,
color: colors.textPrimary,
},
emptyList: {
alignItems: 'center',
gap: 10,
paddingVertical: 32,
backgroundColor: colors.white,
borderRadius: 8,
},
emptyListText: { fontSize: 13, color: colors.textSecondary },
emptyListBtn: {
paddingHorizontal: 18,
paddingVertical: 8,
borderRadius: 16,
backgroundColor: colors.primaryLight,
},
emptyListBtnText: { fontSize: 13, fontWeight: '600', color: colors.primaryText },
tabBar: {
flexDirection: 'row',
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
tabItem: {
flex: 1,
alignItems: 'center',
paddingTop: 8,
},
tabLabel: {
fontSize: 14,
color: colors.textSecondary,
paddingBottom: 8,
},
tabLabelActive: {
color: colors.textPrimary,
fontWeight: '600',
},
tabCount: {
fontSize: 12,
color: colors.textHint,
marginLeft: 2,
},
tabCountActive: {
color: colors.primary,
},
tabIndicator: {
height: 2,
width: 24,
borderRadius: 1,
backgroundColor: 'transparent',
marginBottom: -1,
},
tabIndicatorActive: {
backgroundColor: colors.primary,
},
body: {
padding: 16,
paddingBottom: 24,
},
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
},
tip: { fontSize: 14, color: colors.textSecondary },
retryBtn: {
marginTop: 8,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: { color: colors.white, fontSize: 13, fontWeight: '600' },
todayHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
todayTitle: {
fontSize: 13,
color: colors.textSecondary,
},
todayCountRow: {
flexDirection: 'row',
alignItems: 'baseline',
marginTop: 2,
},
todayCount: {
fontSize: 28,
fontWeight: '800',
color: colors.primary,
},
todayUnit: {
fontSize: 13,
color: colors.textSecondary,
marginLeft: 4,
},
reportBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.primaryLight,
borderRadius: 14,
paddingHorizontal: 10,
paddingVertical: 6,
},
reportBtnText: {
fontSize: 12,
color: colors.primary,
fontWeight: '600',
marginRight: 2,
},
candidateCard: {
backgroundColor: colors.white,
borderRadius: 8,
padding: 16,
marginBottom: 12,
},
candidateHeader: {
flexDirection: 'row',
},
avatar: {
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: colors.primaryLight,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
avatarText: {
fontSize: 20,
fontWeight: '700',
color: colors.primary,
},
candidateInfo: {
flex: 1,
},
nameRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
candidateName: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
matchTag: {
backgroundColor: colors.successLight,
borderRadius: 10,
paddingHorizontal: 8,
paddingVertical: 2,
},
matchTagText: {
fontSize: 12,
fontWeight: '800',
color: colors.successText,
},
candidateTitle: {
fontSize: 13,
color: colors.textSecondary,
marginTop: 2,
},
candidateMeta: {
fontSize: 12,
color: colors.textHint,
marginTop: 4,
},
candidateSalary: {
fontSize: 13,
color: colors.action,
fontWeight: '600',
marginTop: 4,
},
scoreRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 12,
},
scoreLabel: {
fontSize: 13,
color: colors.textSecondary,
},
scoreValue: {
fontSize: 14,
fontWeight: '800',
color: colors.primary,
marginLeft: 6,
},
percentileText: {
fontSize: 12,
color: colors.textHint,
marginLeft: 8,
},
tagRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 10,
},
tag: {
backgroundColor: colors.pageBg,
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 3,
},
tagText: {
fontSize: 11,
color: colors.textSecondary,
},
candidateActions: {
flexDirection: 'row',
marginTop: 14,
gap: 10,
},
chatBtn: {
flex: 1,
backgroundColor: colors.primaryBtn,
borderRadius: 18,
paddingVertical: 10,
alignItems: 'center',
},
chatBtnText: {
color: colors.white,
fontSize: 14,
fontWeight: '600',
},
detailBtn: {
flex: 1,
backgroundColor: colors.primaryLight,
borderRadius: 18,
paddingVertical: 10,
alignItems: 'center',
},
detailBtnText: {
color: colors.primary,
fontSize: 14,
fontWeight: '600',
},
poolCard: {
backgroundColor: colors.white,
borderRadius: 8,
padding: 16,
marginTop: 4,
},
poolTitle: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 12,
},
poolStatsRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 16,
},
poolStat: {
flex: 1,
alignItems: 'center',
},
poolStatValue: {
fontSize: 18,
fontWeight: '800',
color: colors.textPrimary,
},
poolStatLabel: {
fontSize: 11,
color: colors.textSecondary,
marginTop: 4,
},
poolActions: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingTop: 14,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
poolAction: {
alignItems: 'center',
},
poolActionText: {
fontSize: 11,
color: colors.textSecondary,
marginTop: 4,
},
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',
marginBottom: 20,
},
emptyBtn: {
backgroundColor: colors.primaryBtn,
paddingHorizontal: 32,
paddingVertical: 12,
borderRadius: 24,
marginBottom: 24,
},
emptyBtnText: {
color: colors.white,
fontSize: 15,
fontWeight: '600',
},
actionCard: {
width: '100%',
backgroundColor: colors.actionLight,
borderRadius: 8,
padding: 16,
marginBottom: 16,
alignItems: 'flex-start',
},
actionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
actionTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.actionText,
marginLeft: 8,
},
actionRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 10,
},
actionDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: colors.action,
marginTop: 6,
marginRight: 10,
},
actionText: {
flex: 1,
fontSize: 13,
color: colors.textPrimary,
lineHeight: 18,
},
dataCard: {
width: '100%',
backgroundColor: colors.primaryLight,
borderRadius: 8,
padding: 16,
alignItems: 'flex-start',
},
dataTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.primaryText,
marginBottom: 12,
},
dataRow: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 10,
},
dataText: {
flex: 1,
fontSize: 13,
color: colors.textPrimary,
marginLeft: 10,
lineHeight: 18,
},
dataValue: {
color: colors.success,
fontWeight: '700',
},
});