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 } 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 = { 投递: { 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 ( 求职进度 {/* 原右侧漏斗筛选按钮与页内 FilterTabs 功能重复且点击无反应,已移除。 此处保留同尺寸占位以维持标题居中。 */} ); } /* ===================== 子组件:统计卡片 ===================== */ 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 ( {items.map((it) => ( {it.value} {it.label} ))} ); } /* ===================== 子组件:Offer 即将到期提醒 ===================== */ function OfferAlert({ count }: { count: number }) { if (count <= 0) return null; return ( 你有 {count} 个 Offer 即将到期 ); } /* ===================== 子组件:筛选标签 ===================== */ function FilterTabs({ active, onChange, }: { active: 'all' | JobProgressStage; onChange: (k: 'all' | JobProgressStage) => void; }) { return ( {TABS.map((tab) => { const selected = active === tab.key; return ( onChange(tab.key)} > {tab.label} ); })} ); } /* ===================== 子组件:公司条目 ===================== */ function CompanyRow({ company }: { company: JobProgressCompany }) { const theme = STAGE_THEME[company.stage]; return ( {company.companyName.slice(0, 1)} {company.companyName} {company.positionName} {company.stageLabel} {company.salary} {company.nextAction} ); } /* ===================== 子组件:Offer 卡片 ===================== */ function OfferCard({ offer }: { offer: import('../src/types').JobProgressOffer }) { return ( {offer.companyName} Offer {offer.positionName} {offer.salary} {offer.deadline} · 剩余 {offer.daysLeft} 天 ); } /* ===================== 子组件:月度统计 ===================== */ 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 ( 本月统计 月份 投递 面试 Offer {stats.slice(-3).map((s) => ( {s.month} ))} ); } function Bar({ value, max, color }: { value: number; max: number; color: string }) { return ( {value} ); } /* ===================== 子组件:四态 ===================== */ function Loading() { return ( 正在加载进度… ); } function ErrorState({ onRetry }: { onRetry: () => void }) { return ( 进度加载失败 点击重试 ); } /* ===================== 页面 ===================== */ 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 (
router.back()} /> ); } if (error || !progress) { return (
router.back()} /> ); } return (
router.back()} /> {/* 这里原先写「求职中 {stats.interviewing}」,是「面试中几家」的第三个副本 (统计卡一份、本标题一份、下方 Tab 列表一份),而它的正下方却是按 Tab 过滤的 `companies` —— mock 里只有 4 条,是 28 条投递的抽样,撑不起「2」这个数: 标题写 2,下面列 4 行;切到「面试中」Tab 只剩 1 行,卡片仍写 2。 去掉这份副本,标题只说明「这是哪一段的列表」,数字以统计卡为唯一来源。 */} {activeTab === 'all' ? '求职中' : activeLabel} {/* 标题是常驻的,切到没有公司的 Tab 时下方会是一片空白 —— 这里给出空态。 同一文件下方对 offers 就做了保护,companies 此前漏了 */} {companies.length === 0 ? ( 该阶段暂无投递记录 投递岗位后,进度会在这里按阶段更新 ) : ( <> {companies.map((company) => ( ))} {/* 全部页且公司数超过抽样条数:提供「查看全部 N 家」入口,展开后渲染完整列表 */} {activeTab === 'all' && totalCompanies > PREVIEW_COUNT && !showAll && ( setShowAll(true)} hitSlop={4}> 查看全部 {totalCompanies} 家 )} )} {progress.offers.length > 0 && ( <> Offer {progress.offers.length} 个 {progress.offers.map((offer) => ( ))} )} ); } 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: 8, 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: 8, padding: 8, 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: 8, padding: 8, 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: 8, padding: 8, 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: 8, padding: 8, 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: 8, padding: 8, 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: 8, 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: 8, padding: 8, 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: 8, borderRadius: 16, backgroundColor: colors.primaryLight }, retryText: { fontSize: 13, fontWeight: '600', color: colors.primaryText }, });