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

498 lines
18 KiB

import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import { useEffect, useMemo, useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useJobProgress } from '../src/hooks';
import { colors, headerGradientShort , spacing } from '../src/theme';
import type { JobProgressCompany, JobProgressStage } from '../src/types';
const TABS: { key: 'all' | JobProgressStage; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: '待反馈', label: '待反馈' },
{ key: '面试中', label: '面试中' },
{ key: 'Offer', label: 'Offer' },
{ key: '已结束', label: '已结束' },
];
const STAGE_THEME: Record<JobProgressStage, { bg: string; text: string }> = {
投递: { bg: colors.primaryLight, text: colors.primaryText },
待反馈: { bg: colors.infoLight, text: colors.infoText },
面试中: { bg: colors.actionLight, text: colors.actionText },
Offer: { bg: colors.successLight, text: colors.successText },
已结束: { bg: colors.pageBg, text: colors.textSecondary },
};
/* ===================== 子组件:顶部导航 ===================== */
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>
{/* 原右侧漏斗筛选按钮与页内 FilterTabs 功能重复且点击无反应,已移除。
此处保留同尺寸占位以维持标题居中。 */}
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 子组件:统计卡片 ===================== */
function StatsCard({ stats }: { stats: import('../src/types').JobProgressStats }) {
const items = [
{ label: '投递', value: stats.total },
{ label: '待反馈', value: stats.pending },
{ label: '面试中', value: stats.interviewing },
{ label: 'Offer', value: stats.offer },
{ label: '已结束', value: stats.ended },
];
return (
<LinearGradient
colors={headerGradientShort}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.statsCard}
>
<View style={styles.statsRow}>
{items.map((it) => (
<View key={it.label} style={styles.statItem}>
<Text style={styles.statValue}>{it.value}</Text>
<Text style={styles.statLabel}>{it.label}</Text>
</View>
))}
</View>
</LinearGradient>
);
}
/* ===================== 子组件:Offer 即将到期提醒 ===================== */
function OfferAlert({ count }: { count: number }) {
if (count <= 0) return null;
return (
<View style={styles.alertCard}>
<Ionicons name="alert-circle" size={20} color={colors.action} />
<Text style={styles.alertText}>你有 {count} 个 Offer 即将到期</Text>
<Ionicons name="chevron-forward" size={18} color={colors.textHint} />
</View>
);
}
/* ===================== 子组件:筛选标签 ===================== */
function FilterTabs({
active,
onChange,
}: {
active: 'all' | JobProgressStage;
onChange: (k: 'all' | JobProgressStage) => void;
}) {
return (
<View style={styles.tabs}>
{TABS.map((tab) => {
const selected = active === tab.key;
return (
<Pressable
key={tab.key}
style={[styles.tab, selected && styles.tabActive]}
onPress={() => onChange(tab.key)}
>
<Text style={[styles.tabText, selected && styles.tabTextActive]}>{tab.label}</Text>
</Pressable>
);
})}
</View>
);
}
/* ===================== 子组件:公司条目 ===================== */
function CompanyRow({ company }: { company: JobProgressCompany }) {
const theme = STAGE_THEME[company.stage];
return (
<View style={styles.companyCard}>
<View style={styles.companyTop}>
<View style={styles.companyLeft}>
<View style={styles.logo}>
<Text style={styles.logoText}>{company.companyName.slice(0, 1)}</Text>
</View>
<View>
<Text style={styles.companyName}>{company.companyName}</Text>
<Text style={styles.positionName}>{company.positionName}</Text>
</View>
</View>
<View style={[styles.stageBadge, { backgroundColor: theme.bg }]}>
<Text style={[styles.stageText, { color: theme.text }]}>{company.stageLabel}</Text>
</View>
</View>
<View style={styles.companyBottom}>
<Text style={styles.salary}>{company.salary}</Text>
<Text style={styles.nextAction}>{company.nextAction}</Text>
</View>
</View>
);
}
/* ===================== 子组件:Offer 卡片 ===================== */
function OfferCard({ offer }: { offer: import('../src/types').JobProgressOffer }) {
return (
<View style={styles.offerCard}>
<View style={styles.offerHeader}>
<Text style={styles.offerTitle}>{offer.companyName}</Text>
<View style={styles.offerTag}>
<Text style={styles.offerTagText}>Offer</Text>
</View>
</View>
<Text style={styles.offerPosition}>{offer.positionName}</Text>
<Text style={styles.offerSalary}>{offer.salary}</Text>
<View style={styles.offerFooter}>
<Ionicons name="time-outline" size={14} color={colors.action} />
<Text style={styles.offerDeadline}>{offer.deadline} · 剩余 {offer.daysLeft} 天</Text>
</View>
</View>
);
}
/* ===================== 子组件:月度统计 ===================== */
function MonthlyStats({ stats }: { stats: import('../src/types').JobProgressMonthly[] }) {
const max = Math.max(1, ...stats.map((s) => Math.max(s.deliveries, s.interviews, s.offers)));
return (
<View style={styles.card}>
<Text style={styles.cardTitle}>本月统计</Text>
<View style={styles.monthGrid}>
<View style={styles.monthHeader}>
<Text style={styles.monthLabel}>月份</Text>
<Text style={styles.monthLegend}>投递</Text>
<Text style={styles.monthLegend}>面试</Text>
<Text style={styles.monthLegend}>Offer</Text>
</View>
{stats.slice(-3).map((s) => (
<View key={s.month} style={styles.monthRow}>
<Text style={styles.monthLabel}>{s.month}</Text>
<Bar value={s.deliveries} max={max} color={colors.primary} />
<Bar value={s.interviews} max={max} color={colors.action} />
<Bar value={s.offers} max={max} color={colors.success} />
</View>
))}
</View>
</View>
);
}
function Bar({ value, max, color }: { value: number; max: number; color: string }) {
return (
<View style={styles.barCell}>
<View style={styles.barTrack}>
<View style={[styles.barFill, { width: `${(value / max) * 100}%`, backgroundColor: color }]} />
</View>
<Text style={styles.barValue}>{value}</Text>
</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="list-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 ProgressScreen() {
const router = useRouter();
const { progress, loading, error, refresh } = useJobProgress();
// 公司列表首屏抽样条数:列表过长时只列前 N 家,并提供「查看全部」入口(移动端长列表标准做法)
const PREVIEW_COUNT = 5;
const [activeTab, setActiveTab] = useState<'all' | JobProgressStage>('all');
const [showAll, setShowAll] = useState(false);
const totalCompanies = progress?.companies.length ?? 0;
const companies = useMemo(() => {
if (!progress) return [];
const list =
activeTab === 'all'
? progress.companies
: progress.companies.filter((c) => c.stage === activeTab);
// 「全部」页首屏只抽样前 N 家;切到具体阶段或展开后展示全量
if (activeTab === 'all' && !showAll) return list.slice(0, PREVIEW_COUNT);
return list;
}, [progress, activeTab, showAll]);
// 切换 Tab 时重置「展开全部」,回到抽样视图
useEffect(() => {
setShowAll(false);
}, [activeTab]);
// 标题文字跟随当前 Tab,与下方列表同源(见渲染处注释)
const activeLabel = TABS.find((tab) => tab.key === activeTab)?.label ?? '全部';
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} />
<Loading />
</SafeAreaView>
);
}
if (error || !progress) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} />
<ErrorState onRetry={refresh} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<StatsCard stats={progress.stats} />
<OfferAlert count={progress.offers.length} />
<FilterTabs active={activeTab} onChange={setActiveTab} />
<View style={styles.sectionHeader}>
{/* 这里原先写「求职中 {stats.interviewing}」,是「面试中几家」的第三个副本
(统计卡一份、本标题一份、下方 Tab 列表一份),而它的正下方却是按 Tab 过滤的
`companies` —— mock 里只有 4 条,是 28 条投递的抽样,撑不起「2」这个数:
标题写 2,下面列 4 行;切到「面试中」Tab 只剩 1 行,卡片仍写 2。
去掉这份副本,标题只说明「这是哪一段的列表」,数字以统计卡为唯一来源。 */}
<Text style={styles.sectionTitle}>
{activeTab === 'all' ? '求职中' : activeLabel}
</Text>
</View>
{/* 标题是常驻的,切到没有公司的 Tab 时下方会是一片空白 —— 这里给出空态。
同一文件下方对 offers 就做了保护,companies 此前漏了 */}
{companies.length === 0 ? (
<View style={styles.emptyBox}>
<Ionicons name="briefcase-outline" size={32} color={colors.textHint} />
<Text style={styles.emptyText}>该阶段暂无投递记录</Text>
<Text style={styles.emptyHint}>投递岗位后,进度会在这里按阶段更新</Text>
</View>
) : (
<>
{companies.map((company) => (
<CompanyRow key={company.id} company={company} />
))}
{/* 全部页且公司数超过抽样条数:提供「查看全部 N 家」入口,展开后渲染完整列表 */}
{activeTab === 'all' && totalCompanies > PREVIEW_COUNT && !showAll && (
<Pressable style={styles.viewAllBtn} onPress={() => setShowAll(true)} hitSlop={4}>
<Text style={styles.viewAllText}>查看全部 {totalCompanies} 家</Text>
<Ionicons name="chevron-forward" size={16} color={colors.primary} />
</Pressable>
)}
</>
)}
{progress.offers.length > 0 && (
<>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Offer {progress.offers.length} 个</Text>
</View>
{progress.offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</>
)}
<MonthlyStats stats={progress.monthlyStats} />
</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: spacing.card, paddingBottom: 32 },
viewAllBtn: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
paddingVertical: 12,
},
viewAllText: {
fontSize: 14,
fontWeight: '600',
color: colors.primary,
marginRight: 2,
},
statsCard: {
borderRadius: spacing.card,
padding: spacing.card,
marginBottom: 8,
},
statsRow: { flexDirection: 'row', justifyContent: 'space-between' },
statItem: { alignItems: 'center', flex: 1 },
statValue: { fontSize: 26, fontWeight: '800', color: colors.textInverse },
statLabel: { fontSize: 12, color: colors.textInverse, opacity: 0.9, marginTop: 4 },
alertCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: spacing.card,
padding: spacing.card,
marginBottom: 8,
borderWidth: 1,
borderColor: colors.actionBorder,
},
alertText: { flex: 1, fontSize: 14, color: colors.actionText, marginLeft: 8 },
tabs: {
flexDirection: 'row',
gap: 8,
marginBottom: 16,
},
tab: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
tabActive: { backgroundColor: colors.primary, borderColor: colors.primary },
tabText: { fontSize: 13, color: colors.textSecondary },
tabTextActive: { color: colors.white, fontWeight: '600' },
emptyBox: {
backgroundColor: colors.surface,
borderRadius: spacing.card,
padding: spacing.card,
alignItems: 'center',
gap: 8,
},
emptyText: { fontSize: 14, fontWeight: '600', color: colors.textSecondary },
emptyHint: { fontSize: 12, color: colors.textHint, textAlign: 'center' },
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 10,
marginTop: 8,
},
sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
companyCard: {
backgroundColor: colors.white,
borderRadius: spacing.card,
padding: spacing.card,
marginBottom: 8,
borderWidth: 1,
borderColor: colors.borderLight,
},
companyTop: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
companyLeft: { flexDirection: 'row', alignItems: 'center', flex: 1 },
logo: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primaryLight,
alignItems: 'center',
justifyContent: 'center',
},
logoText: { fontSize: 16, fontWeight: '700', color: colors.primary },
companyName: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
positionName: { fontSize: 13, color: colors.textSecondary, marginTop: 2 },
stageBadge: { borderRadius: 10, paddingHorizontal: 10, paddingVertical: 4 },
stageText: { fontSize: 12, fontWeight: '600' },
companyBottom: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 12,
},
salary: { fontSize: 14, fontWeight: '700', color: colors.danger },
nextAction: { fontSize: 12, color: colors.textSecondary },
offerCard: {
backgroundColor: colors.successLight,
borderRadius: spacing.card,
padding: spacing.card,
marginBottom: 8,
borderWidth: 1,
borderColor: colors.successBorder,
},
offerHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
offerTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
offerTag: {
backgroundColor: colors.success,
borderRadius: 10,
paddingHorizontal: spacing.card,
paddingVertical: 2,
},
offerTagText: { fontSize: 11, fontWeight: '700', color: colors.white },
offerPosition: { fontSize: 13, color: colors.textSecondary, marginTop: 4 },
offerSalary: { fontSize: 16, fontWeight: '800', color: colors.successText, marginTop: 8 },
offerFooter: { flexDirection: 'row', alignItems: 'center', marginTop: 8 },
offerDeadline: { fontSize: 12, color: colors.actionText, marginLeft: 4 },
card: {
backgroundColor: colors.white,
borderRadius: spacing.card,
padding: spacing.card,
marginTop: 8,
borderWidth: 1,
borderColor: colors.borderLight,
},
cardTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 },
monthGrid: { gap: 10 },
monthHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 4 },
monthLabel: { width: 40, fontSize: 12, color: colors.textHint },
monthLegend: { flex: 1, fontSize: 11, color: colors.textHint, textAlign: 'center' },
monthRow: { flexDirection: 'row', alignItems: 'center' },
barCell: { flex: 1, flexDirection: 'row', alignItems: 'center', paddingHorizontal: 4 },
barTrack: {
flex: 1,
height: 8,
borderRadius: 4,
backgroundColor: colors.borderLight,
marginRight: 6,
},
barFill: { height: 8, borderRadius: 4 },
barValue: { width: 20, fontSize: 11, color: colors.textSecondary, textAlign: 'right' },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 40, gap: 12 },
tip: { fontSize: 14, color: colors.textSecondary },
retryBtn: { paddingHorizontal: 18, paddingVertical: spacing.card, borderRadius: 16, backgroundColor: colors.primaryLight },
retryText: { fontSize: 13, fontWeight: '600', color: colors.primaryText },
});