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

644 lines
17 KiB

import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useMemo, useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useJobManagement } from '../src/hooks';
import { colors } from '../src/theme';
import type { JobManagement, JobManagementJob, JobManagementTab } from '../src/types';
/* ===================== 子组件:顶部导航 ===================== */
function Header({ companyName, onBack }: { companyName: string; onBack: () => void }) {
const router = useRouter();
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>
<Pressable
style={styles.publishBtn}
onPress={() => router.push('/job-publish')}
hitSlop={6}
>
<Ionicons name="add" size={16} color={colors.white} />
<Text style={styles.publishBtnText}>发布</Text>
</Pressable>
</View>
);
}
/* ===================== 子组件:Tab 栏 ===================== */
function TabBar({
tabs,
active,
onChange,
}: {
tabs: JobManagementTab[];
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 StatsOverview({ stats }: { stats: JobManagement['stats'] }) {
return (
<View style={styles.statsGrid}>
{stats.map((stat) => {
const token = stat.color === 'success'
? colors.success
: stat.color === 'action'
? colors.action
: stat.color === 'info'
? colors.info
: colors.primary;
const isUp = stat.change.startsWith('+');
const isDown = stat.change.startsWith('-');
const changeColor = isUp ? colors.success : isDown ? colors.action : colors.textSecondary;
return (
<View key={stat.id} style={styles.statCard}>
<View style={[styles.statIconWrap, { backgroundColor: token + '15' }]}>
<Ionicons name={stat.icon as keyof typeof Ionicons.glyphMap} size={22} color={token} />
</View>
<View style={styles.statBody}>
<Text style={styles.statValue}>{stat.value}</Text>
<Text style={styles.statLabel}>{stat.label}</Text>
</View>
<View style={styles.statChangeWrap}>
<Text style={[styles.statChange, { color: changeColor }]}>{stat.change}</Text>
</View>
</View>
);
})}
</View>
);
}
/* ===================== 子组件:搜索与筛选 ===================== */
function SearchFilterBar() {
return (
<View style={styles.searchBar}>
<View style={styles.searchInputWrap}>
<Ionicons name="search-outline" size={18} color={colors.textHint} />
<TextInput
style={styles.searchInput}
placeholder="搜索岗位名称..."
placeholderTextColor={colors.textHint}
/>
</View>
<Pressable style={styles.filterBtn} onPress={() => console.log('筛选')}>
<Ionicons name="funnel-outline" size={18} color={colors.textPrimary} />
<Text style={styles.filterBtnText}>筛选</Text>
</Pressable>
<Pressable style={styles.sortBtn} onPress={() => console.log('最新发布')}>
<Text style={styles.sortBtnText}>最新发布</Text>
<Ionicons name="chevron-down" size={14} color={colors.textHint} />
</Pressable>
</View>
);
}
/* ===================== 子组件:岗位卡片 ===================== */
function JobCard({ job }: { job: JobManagementJob }) {
return (
<View style={styles.jobCard}>
<View style={styles.jobHeader}>
<View style={styles.jobTitleWrap}>
<Text style={styles.jobTitle}>{job.title}</Text>
{job.status === 'active' ? <View style={styles.activeDot} /> : null}
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textHint} />
</View>
<Text style={styles.jobSalary}>
{job.salary} · {job.salaryMonths}薪
</Text>
<Text style={styles.jobMeta}>{job.city} · {job.experience} · {job.education}</Text>
<View style={styles.jobScoreRow}>
<Text style={styles.jobScoreLabel}>综合评分</Text>
<Text style={styles.jobScoreValue}>{job.overallScore}</Text>
<View style={styles.rankTag}>
<Text style={styles.rankTagText}>{job.rankText}</Text>
</View>
</View>
<View style={styles.jobTags}>
{job.tags.map((tag) => (
<View key={tag} style={styles.jobTag}>
<Text style={styles.jobTagText}>{tag}</Text>
</View>
))}
</View>
<View style={styles.jobStatsRow}>
<View style={styles.jobStat}>
<Text style={styles.jobStatValue}>{job.applicants}</Text>
<Text style={styles.jobStatLabel}>投递</Text>
</View>
<View style={styles.jobStat}>
<Text style={styles.jobStatValue}>{job.views >= 1000 ? `${(job.views / 1000).toFixed(1)}k` : job.views}</Text>
<Text style={styles.jobStatLabel}>查看</Text>
</View>
<View style={styles.jobStat}>
<Text style={styles.jobStatValue}>{job.matches}</Text>
<Text style={styles.jobStatLabel}>匹配</Text>
</View>
</View>
<View style={styles.jobActions}>
<Pressable style={styles.jobActionBtn} onPress={() => console.log('编辑', job.id)}>
<Ionicons name="create-outline" size={16} color={colors.primary} />
<Text style={styles.jobActionText}>编辑</Text>
</Pressable>
<Pressable style={styles.jobActionBtn} onPress={() => console.log('刷新', job.id)}>
<Ionicons name="refresh-outline" size={16} color={colors.primary} />
<Text style={styles.jobActionText}>刷新</Text>
</Pressable>
<Pressable style={styles.jobActionBtn} onPress={() => console.log('下架', job.id)}>
<Ionicons name="download-outline" size={16} color={colors.primary} />
<Text style={styles.jobActionText}>下架</Text>
</Pressable>
</View>
</View>
);
}
/* ===================== 子组件:空状态 ===================== */
function EmptyState({ benefits, onPublish }: { benefits: string[]; onPublish: () => void }) {
return (
<View style={styles.emptyCard}>
<View style={styles.emptyIconWrap}>
<Ionicons name="business-outline" size={48} color={colors.primary} />
</View>
<Text style={styles.emptyTitle}>还没有发布岗位</Text>
<Text style={styles.emptySubtitle}>发布你的第一个岗位,开始招聘优秀人才</Text>
<Pressable style={styles.emptyBtn} onPress={onPublish} hitSlop={6}>
<Text style={styles.emptyBtnText}>发布岗位</Text>
</Pressable>
<View style={styles.benefitCard}>
<Text style={styles.benefitTitle}>发布岗位后你可以</Text>
{benefits.map((benefit, index) => (
<View key={index} style={styles.benefitRow}>
<Ionicons name="checkmark-circle" size={18} color={colors.success} />
<Text style={styles.benefitText}>{benefit}</Text>
</View>
))}
</View>
</View>
);
}
/* ===================== 页面 ===================== */
export default function JobManagementScreen() {
const router = useRouter();
const { management, loading, error, refresh } = useJobManagement();
const [activeTab, setActiveTab] = useState('all');
const filteredJobs = useMemo(() => {
if (!management?.hasData) return [];
if (activeTab === 'all') return management.jobs;
return management.jobs.filter((job) => {
if (activeTab === 'active') return job.status === 'active';
if (activeTab === 'offline') return job.status === 'offline';
if (activeTab === 'draft') return job.status === 'draft';
return true;
});
}, [management, activeTab]);
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header companyName="智联科技" onBack={() => router.back()} />
<View style={styles.center}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
if (error || !management) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header companyName="智联科技" 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={management.companyName} onBack={() => router.back()} />
<TabBar tabs={management.tabs} active={activeTab} onChange={setActiveTab} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{management.hasData ? (
<View>
<StatsOverview stats={management.stats} />
<SearchFilterBar />
{filteredJobs.map((job) => (
<JobCard key={job.id} job={job} />
))}
</View>
) : (
<EmptyState
benefits={management.benefits}
onPublish={() => router.push('/job-publish')}
/>
)}
</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 },
publishBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.primaryBtn,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
publishBtnText: {
color: colors.white,
fontSize: 13,
fontWeight: '600',
marginLeft: 2,
},
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' },
statsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
marginBottom: 12,
},
statCard: {
width: '48%',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: 12,
padding: 12,
},
statIconWrap: {
width: 40,
height: 40,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
marginRight: 10,
},
statBody: { flex: 1 },
statValue: {
fontSize: 18,
fontWeight: '800',
color: colors.textPrimary,
},
statLabel: {
fontSize: 12,
color: colors.textSecondary,
marginTop: 2,
},
statChangeWrap: {
alignSelf: 'flex-start',
},
statChange: {
fontSize: 12,
fontWeight: '600',
},
searchBar: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
gap: 8,
},
searchInputWrap: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
},
searchInput: {
flex: 1,
marginLeft: 8,
fontSize: 14,
color: colors.textPrimary,
},
filterBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
},
filterBtnText: {
fontSize: 13,
color: colors.textPrimary,
marginLeft: 4,
},
sortBtn: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
},
sortBtnText: {
fontSize: 13,
color: colors.textPrimary,
marginRight: 2,
},
jobCard: {
backgroundColor: colors.white,
borderRadius: 12,
padding: 16,
marginBottom: 12,
},
jobHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
jobTitleWrap: {
flexDirection: 'row',
alignItems: 'center',
},
jobTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
activeDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.success,
marginLeft: 8,
},
jobSalary: {
fontSize: 15,
fontWeight: '700',
color: colors.action,
marginTop: 6,
},
jobMeta: {
fontSize: 13,
color: colors.textSecondary,
marginTop: 4,
},
jobScoreRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 10,
},
jobScoreLabel: {
fontSize: 13,
color: colors.textSecondary,
},
jobScoreValue: {
fontSize: 14,
fontWeight: '800',
color: colors.primary,
marginLeft: 6,
},
rankTag: {
backgroundColor: colors.primaryLight,
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 2,
marginLeft: 8,
},
rankTagText: {
fontSize: 11,
color: colors.primaryText,
fontWeight: '600',
},
jobTags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: 10,
},
jobTag: {
backgroundColor: colors.pageBg,
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 3,
},
jobTagText: {
fontSize: 11,
color: colors.textSecondary,
},
jobStatsRow: {
flexDirection: 'row',
marginTop: 14,
paddingTop: 14,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
jobStat: {
flex: 1,
alignItems: 'center',
},
jobStatValue: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
jobStatLabel: {
fontSize: 11,
color: colors.textSecondary,
marginTop: 2,
},
jobActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 12,
gap: 12,
},
jobActionBtn: {
flexDirection: 'row',
alignItems: 'center',
},
jobActionText: {
fontSize: 13,
color: colors.primary,
marginLeft: 4,
},
emptyCard: {
backgroundColor: colors.white,
borderRadius: 16,
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',
},
benefitCard: {
width: '100%',
backgroundColor: colors.primaryLight,
borderRadius: 12,
padding: 16,
alignItems: 'flex-start',
},
benefitTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.primaryText,
marginBottom: 12,
},
benefitRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
benefitText: {
fontSize: 13,
color: colors.textPrimary,
marginLeft: 10,
},
});