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
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,
|
|
},
|
|
});
|
|
|