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.
459 lines
16 KiB
459 lines
16 KiB
import { Ionicons } from '@expo/vector-icons';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
import { useRouter } from 'expo-router';
|
|
import { 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<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();
|
|
const [activeTab, setActiveTab] = useState<'all' | JobProgressStage>('all');
|
|
|
|
const companies = useMemo(() => {
|
|
if (!progress) return [];
|
|
if (activeTab === 'all') return progress.companies;
|
|
return progress.companies.filter((c) => c.stage === activeTab);
|
|
}, [progress, 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} />)
|
|
)}
|
|
|
|
{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: 8, paddingBottom: 32 },
|
|
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 },
|
|
});
|
|
|