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

649 lines
18 KiB

import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useOfferCompareData } from '../../src/hooks';
import { buildOfferCompareText } from '../../src/api';
import { shareText } from '../../src/lib/shareFile';
import { EmptyState } from '../../src/components/StateView';
import { colors, headerGradientSolid , spacing } from '../../src/theme';
import type { OfferBenefitGroup, OfferItem, OfferScore, WorkLifeBalanceRow } from '../../src/types';
/* ===================== 子组件 ===================== */
/** 顶部导航:返回 + 标题。原先右侧还有「编辑」,但页面从未传入 onEdit,是个点了没反应的入口,已摘掉 */
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}>Offer对比</Text>
<View style={styles.headerBtn} />
</View>
);
}
/** 单个 Offer 卡片 */
function OfferCard({ offer, selected }: { offer: OfferItem; selected: boolean }) {
return (
<View style={[styles.offerCard, selected && styles.offerCardSelected]}>
<View style={styles.offerCardTop}>
<Text style={styles.offerCompany} numberOfLines={1}>
{offer.company}
</Text>
<View style={[styles.checkCircle, selected && styles.checkCircleActive]}>
{selected && <Ionicons name="checkmark" size={12} color={colors.white} />}
</View>
</View>
<Text style={styles.offerPosition} numberOfLines={1}>
{offer.position}
</Text>
<Text style={[styles.offerSalary, { color: offer.color }]}>{offer.salary}</Text>
</View>
);
}
/** 星级评分 */
function StarRating({ stars }: { stars: number }) {
return (
<View style={styles.starRow}>
{[1, 2, 3, 4, 5].map((i) => (
<Ionicons
key={i}
name={i <= stars ? 'star' : 'star-outline'}
size={12}
color={i <= stars ? colors.action : colors.borderStrong}
style={styles.starIcon}
/>
))}
</View>
);
}
/** 综合评分对比 */
function ScoreComparison({ offers, scores }: { offers: OfferItem[]; scores: OfferScore[] }) {
return (
<View style={styles.card}>
<View style={styles.sectionHeader}>
<View style={styles.sectionBar} />
<Text style={styles.sectionTitle}>综合评分对比</Text>
</View>
<View style={styles.scoreRow}>
{offers.map((offer, index) => {
const score = scores[index];
if (!score) return null;
const isWinner = score.score === Math.max(...scores.map((s) => s.score));
return (
<View key={offer.id} style={styles.scoreCol}>
<Text style={[styles.scoreNum, isWinner && styles.scoreNumWinner]}>{score.score}</Text>
<View style={styles.scoreMeta}>
<Text style={[styles.scoreLevel, { color: offer.color }]}>{score.level}</Text>
<StarRating stars={score.stars} />
</View>
<Text style={styles.scoreHint}>推荐指数</Text>
</View>
);
})}
</View>
</View>
);
}
/** 分组对比表格 */
function BenefitComparison({ offers, groups }: { offers: OfferItem[]; groups: OfferBenefitGroup[] }) {
return (
<View style={styles.card}>
<View style={styles.sectionHeader}>
<View style={styles.sectionBar} />
<Text style={styles.sectionTitle}>薪资福利对比</Text>
</View>
{/* 表头:公司名 */}
<View style={styles.tableHeaderRow}>
<View style={styles.tableLabelCell} />
{offers.map((offer) => (
<View key={offer.id} style={styles.tableValueCell}>
<View style={[styles.offerDot, { backgroundColor: offer.color }]} />
<Text style={styles.tableHeaderText} numberOfLines={1}>
{offer.company}
</Text>
</View>
))}
</View>
{groups.map((group) => (
<View key={group.title}>
<Text style={styles.groupTitle}>{group.title}</Text>
{group.rows.map((row) => (
<View key={row.label} style={styles.tableRow}>
<Text style={styles.tableLabelText}>{row.label}</Text>
{row.values.map((value, colIndex) => {
const highlight = row.highlights?.[colIndex];
return (
<View
key={colIndex}
style={[
styles.tableValueCell,
highlight && { backgroundColor: colors.primaryLight },
]}
>
<Text style={[styles.tableValueText, highlight && styles.tableValueTextHighlight]}>
{value ?? '-'}
</Text>
</View>
);
})}
</View>
))}
</View>
))}
</View>
);
}
/** 工作生活平衡 */
function WorkLifeBalance({ offers, rows }: { offers: OfferItem[]; rows: WorkLifeBalanceRow[] }) {
return (
<View style={styles.card}>
<View style={styles.sectionHeader}>
<View style={styles.sectionBar} />
<Text style={styles.sectionTitle}>工作生活平衡</Text>
</View>
<View style={styles.tableHeaderRow}>
<View style={styles.tableLabelCell} />
{offers.map((offer) => (
<View key={offer.id} style={styles.tableValueCell}>
<Text style={styles.tableHeaderText} numberOfLines={1}>
{offer.company}
</Text>
</View>
))}
</View>
{rows.map((row) => (
<View key={row.label} style={styles.tableRow}>
<Text style={styles.tableLabelText}>{row.label}</Text>
{row.values.map((value, colIndex) => {
const highlight = row.highlights?.[colIndex];
return (
<View
key={colIndex}
style={[styles.tableValueCell, highlight && { backgroundColor: colors.successLight }]}
>
<Text style={[styles.tableValueText, highlight && styles.tableValueTextSuccess]}>
{value}
</Text>
</View>
);
})}
</View>
))}
</View>
);
}
/**
* AI 智能建议。
* 原先底部挂着「查看完整分析报告」按钮,但建议全文(summary + detail)本卡片已经展示完,
* 点进去没有任何更多内容 —— 属假入口,已摘掉。要留档可用页面底部的「导出对比报告」。
*/
function AISuggestionCard({ summary, detail }: { summary: string; detail: string }) {
return (
<LinearGradient colors={headerGradientSolid} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.aiCard}>
<View style={styles.aiHeader}>
<Ionicons name="sparkles" size={18} color={colors.white} />
<Text style={styles.aiTitle}>AI智能建议</Text>
</View>
<Text style={styles.aiSummary}>{summary}</Text>
<Text style={styles.aiDetail}>{detail}</Text>
</LinearGradient>
);
}
/* ===================== 页面 ===================== */
/**
* Offer 对比页:对比已收到 Offer 的薪资、福利、工作生活平衡与 AI 建议
*/
export default function OfferCompareScreen() {
const router = useRouter();
const { data, offers, scores, benefitGroups, workLifeBalance, aiSuggestion, actions, loading, error, refresh } =
useOfferCompareData();
/**
* 导出对比报告:拼成文本经系统分享面板发出(与项目其它导出入口同一套做法)。
* `ok` 只代表面板已拉起,不能据此提示「导出成功」,只在失败时说明原因(见 lib/shareFile)。
*/
const handleExport = async () => {
if (!data) return;
const outcome = await shareText(buildOfferCompareText(data), 'Offer 对比报告');
if (!outcome.ok) Alert.alert('无法导出', outcome.message);
};
if (error) {
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<View style={styles.stateWrap}>
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
if (loading || !data) {
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<View style={styles.stateWrap}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.stateText}>正在加载 Offer 对比…</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* Offer 卡片头部。原先尾部跟着「添加Offer」卡片,但项目没有新增 Offer 的表单页、
后端也没有 POST /api/offers —— 属假入口,已摘掉 */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.offerList}>
{offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} selected={offer.selected} />
))}
</ScrollView>
{/* 对比结果。offers 为空时下方三个区块会渲染成空壳,故整体给出空态 */}
{offers.length === 0 ? (
<EmptyState
icon="git-compare-outline"
title="暂无可对比的 Offer"
subtitle="收到 Offer 后就能在这里横向对比薪资、福利与工作生活平衡"
/>
) : (
<>
{/* 综合评分对比 */}
<ScoreComparison offers={offers} scores={scores} />
{/* 薪资福利对比 */}
<BenefitComparison offers={offers} groups={benefitGroups} />
{/* 工作生活平衡 */}
<WorkLifeBalance offers={offers} rows={workLifeBalance} />
{/* AI 智能建议 */}
{aiSuggestion && (
<AISuggestionCard summary={aiSuggestion.summary} detail={aiSuggestion.detail} />
)}
</>
)}
<View style={styles.bottomGap} />
</ScrollView>
{/* 底部操作栏 */}
<View style={styles.actionBar}>
<Pressable style={styles.exportBtn} onPress={handleExport}>
<Text style={styles.exportText}>{actions?.exportLabel ?? '导出对比报告'}</Text>
</Pressable>
{/* 「接受 Offer」需要 POST /api/offers/:id/accept,须后端支持;保留入口但如实说明,
不假装成功(沿用 4.4 节 AI 按钮的处理方式) */}
<Pressable
style={styles.acceptBtn}
onPress={() =>
// affordance-pending-backend
Alert.alert('接受 Offer', '该操作需要后端支持,当前版本暂未开放')
}
>
<Text style={styles.acceptText}>{actions?.acceptLabel ?? '接受Offer'}</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
/* ---- 顶部导航 ---- */
header: {
height: 48,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.card,
backgroundColor: colors.pageBg,
},
headerBtn: {
width: 48,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
flex: 1,
textAlign: 'center',
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
/* ---- 状态 ---- */
stateWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
textAlign: 'center',
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: spacing.card,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
/* ---- 页面内容 ---- */
scroll: {
flex: 1,
},
content: {
paddingHorizontal: spacing.card,
paddingTop: 8,
paddingBottom: 12,
},
bottomGap: {
height: 80,
},
/* ---- Offer 卡片 ---- */
offerList: {
gap: 8,
paddingVertical: 4,
},
offerCard: {
width: 150,
padding: spacing.card,
borderRadius: spacing.card,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
offerCardSelected: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
},
offerCardTop: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
},
offerCompany: {
flex: 1,
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
},
checkCircle: {
width: 18,
height: 18,
borderRadius: 9,
borderWidth: 1.5,
borderColor: colors.borderStrong,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 6,
},
checkCircleActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
offerPosition: {
marginTop: 6,
fontSize: 12,
color: colors.textSecondary,
},
offerSalary: {
marginTop: 10,
fontSize: 16,
fontWeight: '800',
},
/* ---- 通用卡片与分区标题 ---- */
card: {
backgroundColor: colors.white,
borderRadius: spacing.card,
padding: spacing.card,
marginTop: 8,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 14,
},
sectionBar: {
width: 4,
height: 16,
borderRadius: 2,
backgroundColor: colors.primary,
marginRight: 8,
},
sectionTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
/* ---- 综合评分对比 ---- */
scoreRow: {
flexDirection: 'row',
justifyContent: 'space-around',
paddingTop: 4,
},
scoreCol: {
alignItems: 'center',
flex: 1,
},
scoreNum: {
fontSize: 44,
fontWeight: '800',
color: colors.textSecondary,
},
scoreNumWinner: {
color: colors.success,
},
scoreMeta: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 6,
gap: 6,
},
scoreLevel: {
fontSize: 13,
fontWeight: '700',
},
starRow: {
flexDirection: 'row',
},
starIcon: {
marginHorizontal: 1,
},
scoreHint: {
marginTop: 6,
fontSize: 11,
color: colors.textHint,
},
/* ---- 表格 ---- */
tableHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
paddingBottom: 10,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
tableRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
tableLabelCell: {
width: 88,
},
tableLabelText: {
width: 88,
fontSize: 13,
color: colors.textSecondary,
},
tableValueCell: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.card,
marginHorizontal: 2,
borderRadius: spacing.card,
},
tableHeaderText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary,
textAlign: 'center',
},
tableValueText: {
fontSize: 13,
color: colors.textPrimary,
textAlign: 'center',
},
tableValueTextHighlight: {
color: colors.primaryText,
fontWeight: '700',
},
tableValueTextSuccess: {
color: colors.successText,
fontWeight: '700',
},
offerDot: {
width: 6,
height: 6,
borderRadius: 3,
marginRight: 4,
},
groupTitle: {
marginTop: 14,
marginBottom: 8,
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
},
/* ---- AI 建议 ---- */
aiCard: {
marginTop: 8,
borderRadius: spacing.card,
padding: spacing.card,
},
aiHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
aiTitle: {
marginLeft: 6,
fontSize: 16,
fontWeight: '700',
color: colors.textInverse,
},
aiSummary: {
fontSize: 14,
fontWeight: '700',
color: colors.textInverse,
lineHeight: 20,
},
aiDetail: {
marginTop: 8,
fontSize: 13,
color: colors.textInverse,
opacity: 0.9,
lineHeight: 19,
},
/* ---- 底部操作栏 ---- */
actionBar: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingTop: 10,
paddingBottom: 16,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
gap: 10,
},
exportBtn: {
flex: 1,
height: 44,
borderRadius: 22,
borderWidth: 1,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
},
exportText: {
fontSize: 14,
fontWeight: '600',
color: colors.textPrimary,
},
acceptBtn: {
flex: 1.4,
height: 44,
borderRadius: 22,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
},
acceptText: {
fontSize: 14,
fontWeight: '700',
color: colors.white,
},
});