import { Ionicons } from '@expo/vector-icons'; import { useFocusEffect, useRouter } from 'expo-router'; import { useCallback, useState } from 'react'; import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useResume } from '../src/hooks'; import { RESUME_EVAL_MAX_LENGTH, UNSET_LABEL } from '../src/constants'; import {colors, spacing} from '../src/theme'; import type { Resume as ResumeType, ResumeExperience, ResumeOptimizePoint, ResumeProject } from '../src/types'; import { formatDateTime } from '../src/utils'; /* ===================== 子组件:顶部导航 ===================== */ /** * 顶部导航。`onSave` 可选:加载中 / 加载失败时简历尚未就绪,此时按钮置灰而不是给一个 * 点了没反应的入口(同 `app/resume/export.tsx` 的处理)。 */ function Header({ onBack, onSave, onExport, }: { onBack: () => void; /** 传 undefined = 简历未就绪,按钮置灰 */ onSave?: () => void; onExport: () => void; }) { const saveDisabled = onSave === undefined; return ( AI简历优化 ); } /* ===================== 子组件:评分卡 ===================== */ function ScoreCard({ score, maxScore, onOptimize, optimizing, }: { score: number; maxScore: number; onOptimize: () => void; optimizing: boolean; }) { const pct = Math.round((score / maxScore) * 100); return ( 简历评分 {score} 分 {optimizing ? ( ) : ( 优化简历 )} ); } /* ===================== 子组件:优化点列表 ===================== */ function OptimizeCard({ points, onOptimizeAll, optimizing, }: { points: ResumeOptimizePoint[]; onOptimizeAll: () => void; optimizing: boolean; }) { return ( AI 检测到 {points.length} 项可优化内容 {points.map((p) => ( {p.title} +{p.score}分 ))} {optimizing ? '优化中…' : '一键全部优化'} ); } /* ===================== 子组件:基本信息 ===================== */ function BasicInfoCard({ info, onEdit }: { info: ResumeType['basicInfo']; onEdit: () => void }) { // 引导未强制填姓名(P6/P16 可跳过直接进入),头像字取展示姓名的首字,保持一致 const name = info.name || UNSET_LABEL; const rows = [ { key: 'phone', icon: 'call-outline' as const, value: info.phone }, { key: 'email', icon: 'mail-outline' as const, value: info.email }, { key: 'location', icon: 'location-outline' as const, value: info.location }, ].filter((row) => row.value !== ''); return ( 基本信息 编辑基本信息 > {name.slice(0, 1)} {name} {/* 空值不渲染空行:手机号 / 邮箱引导未采集,画一行空白比不画更让人困惑 */} {rows.map((row) => ( {row.value} ))} ); } /* ===================== 子组件:求职意向 ===================== */ function JobIntentionCard({ intention }: { intention: ResumeType['jobIntention'] }) { const router = useRouter(); return ( 求职意向 {/* 指向已存在的求职意向编辑页(此前是无 onPress 的死按钮) */} router.push('/job-preference')} hitSlop={6}> 编辑 > ); } function IntentionItem({ label, value, tag, }: { label: string; value: string; tag?: boolean; }) { const text = value || UNSET_LABEL; return ( {label} {tag ? ( {text} ) : ( {text} )} ); } /* ===================== 子组件:教育背景 ===================== */ function EducationCard({ education, onAdd, onEdit, onItem, }: { education: ResumeType['education']; onAdd: () => void; onEdit: () => void; onItem: (id: string) => void; }) { return ( 教育背景 编辑 > 添加教育经历 {education.length === 0 && 未填写教育背景} {education.map((edu) => { // 专业 / 年份引导未采集时为空串,拼出来会剩一个孤零零的「· 本科」或「-」 const majorDegree = [edu.major, edu.degree].filter(Boolean).join(' · '); const years = [edu.startYear, edu.endYear].filter(Boolean).join('-'); return ( onItem(edu.id)}> {edu.school} {majorDegree !== '' && {majorDegree}} {years !== '' && {years}} ); })} ); } /* ===================== 子组件:工作经历 ===================== */ function ExperienceCard({ experiences, onAdd, onEdit, onItem, onOptimize, aiBusy, }: { experiences: ResumeExperience[]; onAdd: () => void; onEdit: () => void; onItem: (id: string) => void; onOptimize: (id: string) => void; /** AI 请求进行中:按钮禁用,避免连点堆起多个请求 */ aiBusy: boolean; }) { return ( 工作经历 编辑 > 添加工作经历 {experiences.length === 0 && 未填写工作经历} {experiences.map((exp) => ( onItem(exp.id)}> {exp.company} {/* 离职时结束时间未采集(留空),只展示已采集到的那一端,不补「-」 */} {[exp.startDate, exp.endDate].filter(Boolean).length > 0 && ( {[exp.startDate, exp.endDate].filter(Boolean).join(' - ')} )} {exp.position !== '' && {exp.position}} {exp.description !== '' && {exp.description}} {!exp.aiOptimized && ( AI 建议优化 onOptimize(exp.id)} disabled={aiBusy} accessibilityLabel={`AI 优化 ${exp.company} 的工作内容`} > AI 优化 )} ))} ); } /* ===================== 子组件:项目经历 ===================== */ function ProjectCard({ projects, onAdd, onEdit, onItem, }: { projects: ResumeProject[]; onAdd: () => void; onEdit: () => void; onItem: (id: string) => void; }) { return ( 项目经历 编辑 > 添加项目经历 {projects.length === 0 && 未填写项目经历} {projects.map((proj) => ( onItem(proj.id)}> {proj.name} {proj.role} {proj.description} {proj.aiOptimized && ( 已量化 )} ))} ); } /* ===================== 子组件:技能特长 ===================== */ function SkillsCard({ skills, onEdit }: { skills: ResumeType['skills']; onEdit: () => void }) { return ( 技能特长 添加技能 {skills.length === 0 && 未填写技能特长} {skills.map((skill) => ( {skill.name} ))} ); } /* ===================== 子组件:技能编辑弹窗 ===================== */ /* * 技能只有「名词」一个字段,为它单开一个路由不划算,就地弹层编辑: * 已添加的标签点 × 即时删除,输入框 +「添加」追加。 * 去重的权威规则在 API 层(`saveResumeSkills`),这里只做即时门槛让按钮状态诚实。 */ function SkillsModal({ visible, skills, draft, onDraftChange, onClose, onAdd, onRemove, saving, }: { visible: boolean; skills: ResumeType['skills']; /** 草稿由页面持有(与 `SaveSheet` 同款):关掉再打开时页面已经把它清掉了,弹层不必自己同步 */ draft: string; onDraftChange: (v: string) => void; onClose: () => void; onAdd: (name: string) => void; onRemove: (name: string) => void; saving: boolean; }) { const name = draft.trim(); const duplicated = name !== '' && skills.some((s) => s.name === name); const canAdd = name !== '' && !duplicated && !saving; return ( {/* 内层 Pressable 吞掉冒泡:点内容区不该关闭弹层 */} {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */} {}}> 编辑技能 已添加(点 × 删除) {skills.length === 0 ? ( 还没有添加技能 ) : ( {skills.map((skill) => ( onRemove(skill.name)} disabled={saving} accessibilityLabel={`删除技能 ${skill.name}`} > {skill.name} ))} )} canAdd && onAdd(name)} returnKeyType="done" /> onAdd(name)} disabled={!canAdd} > 添加 {duplicated && 已添加过该技能} ); } /* ===================== 子组件:自我评价编辑弹窗 ===================== */ function EvaluationModal({ visible, draft, onDraftChange, onClose, onSave, saving, }: { visible: boolean; draft: string; onDraftChange: (v: string) => void; onClose: () => void; onSave: () => void; saving: boolean; }) { return ( {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */} {}}> 编辑自我评价 {draft.length}/{RESUME_EVAL_MAX_LENGTH} 取消 {saving ? '保存中…' : '保存'} ); } /* ===================== 子组件:自我评价 ===================== */ function EvaluationCard({ content, onEdit, onPolish, polishing, }: { content: string; onEdit: () => void; onPolish: () => void; polishing: boolean; }) { return ( 自我评价 {content === '' ? '去填写' : '编辑'} > AI 润色 {content === '' ? ( 未填写自我评价 ) : ( {content} )} ); } /* ===================== 子组件:四态 ===================== */ function Loading() { return ( 正在加载简历… ); } function ErrorState({ onRetry }: { onRetry: () => void }) { return ( 简历加载失败 点击重试 ); } /* ===================== 页面 ===================== */ export default function ResumeScreen() { const router = useRouter(); const { resume, savedAt, loading, error, refresh, saving, optimize, save, saveSelfEvaluation, saveSkills, optimizeExperience, polishSelfEvaluation, } = useResume(); /** * 「AI 一键优化」只留下评分——它返回的正文与当前简历一致(mock 阶段不改写用户内容, * 见 `optimizeResume`),把整份结果塞进 state 会让此后每一次编辑都显示旧数据。 */ const [optimizedScore, setOptimizedScore] = useState(null); const [optimizing, setOptimizing] = useState(false); /** AI 单点动作进行中:按钮禁用,避免连点堆请求 */ const [aiBusy, setAiBusy] = useState(false); const [skillsModal, setSkillsModal] = useState(false); const [skillDraft, setSkillDraft] = useState(''); const [evalModal, setEvalModal] = useState(false); const [evalDraft, setEvalDraft] = useState(''); // 简历永远取自 storage(唯一真相源),会话内的优化只覆盖评分那一块 const displayResume = resume === null ? null : { ...resume, score: optimizedScore ?? resume.score }; // 从编辑页返回时重新取数:各编辑页写入的是覆盖层,不刷新就显示旧值 // (useAsyncData 只在挂载时取数) useFocusEffect( useCallback(() => { refresh(); }, [refresh]), ); const handleOptimize = async () => { if (optimizing) return; setOptimizing(true); try { const optimized = await optimize(); setOptimizedScore(optimized.score); } finally { setOptimizing(false); } }; const handleSave = async () => { if (displayResume === null || saving) return; try { const { savedAt: at } = await save(displayResume); Alert.alert('已保存到本机', `保存时间 ${formatDateTime(at)}`); } catch { Alert.alert('保存失败', '请稍后重试'); } }; /* ---------- AI 动作:能力未接入时只如实说明,不动简历 ---------- */ const runAIAction = async (action: () => Promise<{ message: string }>) => { if (aiBusy) return; setAiBusy(true); try { const result = await action(); Alert.alert('暂未开放', result.message); } finally { setAiBusy(false); } }; /* ---------- 技能:删除即时落盘,添加是一次写入 ---------- */ const skills = displayResume?.skills ?? []; const handleAddSkill = async (name: string) => { if (saving) return; await saveSkills([...skills, { name }]); setSkillDraft(''); }; const handleRemoveSkill = (name: string) => { if (saving) return; void saveSkills(skills.filter((s) => s.name !== name)); }; /* ---------- 自我评价 ---------- */ const openEvalModal = () => { setEvalDraft(displayResume?.selfEvaluation.content ?? ''); setEvalModal(true); }; const handleSaveEvaluation = async () => { if (saving) return; await saveSelfEvaluation(evalDraft); setEvalModal(false); }; /* * 只在「还没有任何数据」时显示加载态。 * 本页每次获得焦点都会 refresh(编辑页可能写过覆盖层), * 若按 `loading` 直接渲染骨架屏,从编辑页返回时会整页白闪一次 —— 刚改完的内容, * 用户看到的却是一次全屏加载。 */ if (loading && displayResume === null) { return (
router.back()} onExport={() => router.push('/resume/export')} /> ); } if (error !== null || displayResume === null) { return (
router.back()} onExport={() => router.push('/resume/export')} /> ); } return (
router.back()} onSave={handleSave} onExport={() => router.push('/resume/export')} /> {/* 「保存」不是装饰:这里要如实显示存到了什么时候,否则用户不知道头部那个图标干了什么 */} {savedAt !== '' && 上次保存:{formatDateTime(savedAt)}} router.push('/resume/basic')} /> router.push('/resume/education?new=1')} onEdit={() => router.push('/resume/education')} onItem={(id) => router.push('/resume/education?id=' + id)} /> router.push('/resume/experience?new=1')} onEdit={() => router.push('/resume/experience')} onItem={(id) => router.push('/resume/experience?id=' + id)} onOptimize={(id) => void runAIAction(() => optimizeExperience(id))} aiBusy={aiBusy} /> router.push('/resume/project?new=1')} onEdit={() => router.push('/resume/project')} onItem={(id) => router.push('/resume/project?id=' + id)} /> setSkillsModal(true)} /> void runAIAction(() => polishSelfEvaluation(displayResume.selfEvaluation.content))} polishing={aiBusy} /> router.push('/resume/preview')} hitSlop={6} > 预览 {optimizing ? ( ) : ( AI 一键优化 )} { setSkillDraft(''); setSkillsModal(false); }} onAdd={(name) => void handleAddSkill(name)} onRemove={handleRemoveSkill} saving={saving} /> setEvalModal(false)} onSave={() => void handleSaveEvaluation()} saving={saving} /> ); } 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 }, headerActions: { flexDirection: 'row', gap: 4 }, headerActionBtn: { width: 36, alignItems: 'center' }, body: { padding: spacing.card, paddingBottom: 100 }, 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 }, card: { backgroundColor: colors.white, borderRadius: spacing.card, padding: spacing.card, marginBottom: 8, borderWidth: 1, borderColor: colors.borderLight, }, scoreCard: { backgroundColor: colors.white, borderRadius: spacing.card, padding: spacing.card, marginBottom: 8, borderWidth: 1, borderColor: colors.borderLight, }, scoreRow: { flexDirection: 'row', alignItems: 'center' }, scoreLabel: { fontSize: 13, color: colors.textSecondary }, scoreValueRow: { flexDirection: 'row', alignItems: 'baseline', marginTop: 6 }, scoreValue: { fontSize: 42, fontWeight: '800', color: colors.textPrimary }, scoreUnit: { fontSize: 14, color: colors.textSecondary, marginLeft: 4 }, scoreBarWrap: { flex: 1, marginLeft: 20 }, scoreTrack: { height: 10, borderRadius: 5, backgroundColor: colors.borderLight, overflow: 'hidden', }, scoreFill: { height: 10, borderRadius: 5, backgroundColor: colors.primary }, optimizeBtn: { marginTop: 12, alignSelf: 'flex-end', backgroundColor: colors.primaryBtn, borderRadius: 16, paddingHorizontal: 18, paddingVertical: spacing.card, }, optimizeBtnDisabled: { backgroundColor: colors.textDisabled }, optimizeBtnText: { fontSize: 14, fontWeight: '700', color: colors.white }, cardHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 }, cardHeaderTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginLeft: 8 }, optRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 10, borderTopWidth: 1, borderTopColor: colors.borderLight, }, optTitle: { flex: 1, fontSize: 14, color: colors.textPrimary, marginLeft: 10 }, optTitleDone: { textDecorationLine: 'line-through', color: colors.textHint }, optScore: { fontSize: 13, color: colors.successText, fontWeight: '600' }, optScoreDone: { color: colors.textHint }, optAllBtn: { marginTop: 12, backgroundColor: colors.primaryLight, borderRadius: 12, paddingVertical: 12, alignItems: 'center', }, optAllText: { fontSize: 14, fontWeight: '700', color: colors.primaryText }, optAllBtnDisabled: { opacity: 0.7 }, sectionTitleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, }, sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, sectionActions: { flexDirection: 'row', alignItems: 'center', gap: 14 }, editLink: { fontSize: 13, color: colors.primary }, /** 「上次保存」那行:只在真的保存过之后出现 */ savedAt: { fontSize: 12, color: colors.textHint, marginBottom: 12, marginTop: -4 }, basicRow: { flexDirection: 'row', alignItems: 'center' }, avatar: { width: 60, height: 60, borderRadius: 30, backgroundColor: colors.primary, alignItems: 'center', justifyContent: 'center', }, avatarText: { fontSize: 24, fontWeight: '700', color: colors.white }, basicBody: { marginLeft: 14, flex: 1 }, nameRow: { flexDirection: 'row', alignItems: 'center' }, name: { fontSize: 18, fontWeight: '700', color: colors.textPrimary }, contactRow: { flexDirection: 'row', alignItems: 'center', marginTop: 6 }, contactText: { fontSize: 13, color: colors.textSecondary, marginLeft: 6 }, intentionGrid: { flexDirection: 'row', flexWrap: 'wrap' }, intentionItem: { width: '50%', marginBottom: 14 }, intentionLabel: { fontSize: 12, color: colors.textHint, marginBottom: 4 }, intentionValue: { fontSize: 14, fontWeight: '700', color: colors.textPrimary }, intentionTag: { alignSelf: 'flex-start', backgroundColor: colors.successLight, borderRadius: 10, paddingHorizontal: spacing.card, paddingVertical: 2, }, intentionTagText: { fontSize: 12, color: colors.successText, fontWeight: '600' }, addBtn: { flexDirection: 'row', alignItems: 'center' }, addText: { fontSize: 13, color: colors.primary, marginLeft: 2 }, eduRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10, borderTopWidth: 1, borderTopColor: colors.borderLight, }, eduSchool: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, eduMajor: { fontSize: 13, color: colors.textSecondary, marginTop: 2 }, eduYear: { fontSize: 13, color: colors.textHint }, expRow: { paddingVertical: 12, borderTopWidth: 1, borderTopColor: colors.borderLight }, expHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, expCompany: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, expDate: { fontSize: 12, color: colors.textHint }, expPosition: { fontSize: 13, color: colors.textSecondary, marginTop: 2 }, expDesc: { fontSize: 13, color: colors.textSecondary, marginTop: 8, lineHeight: 20 }, aiTip: { flexDirection: 'row', alignItems: 'center', marginTop: 8, backgroundColor: colors.infoLight, borderRadius: spacing.card, paddingHorizontal: spacing.card, paddingVertical: spacing.card, }, aiTipText: { flex: 1, fontSize: 12, color: colors.infoText, marginLeft: 6 }, aiOptimizeMiniBtn: { backgroundColor: colors.info, borderRadius: 10, paddingHorizontal: 10, paddingVertical: 4, }, aiOptimizeMiniText: { fontSize: 12, fontWeight: '600', color: colors.white }, optimizedTag: { flexDirection: 'row', alignItems: 'center', alignSelf: 'flex-start', marginTop: 8, backgroundColor: colors.successLight, borderRadius: 10, paddingHorizontal: spacing.card, paddingVertical: 2, }, optimizedText: { fontSize: 12, color: colors.successText, marginLeft: 4 }, projRow: { paddingVertical: 12, borderTopWidth: 1, borderTopColor: colors.borderLight }, skillsWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, skillTag: { backgroundColor: colors.pageBg, borderRadius: 12, paddingHorizontal: 12, paddingVertical: 6, }, /** 弹层里的标签可点删除:补一个 × 图标,光靠「可点击」没有视觉线索 */ skillTagEditable: { flexDirection: 'row', alignItems: 'center', gap: 6 }, skillText: { fontSize: 13, color: colors.textPrimary }, evalText: { fontSize: 13, color: colors.textSecondary, lineHeight: 22 }, /** 引导未采集该板块时的空态文案(不伪装成已填写) */ emptyText: { fontSize: 13, color: colors.textHint, marginTop: 4 }, footer: { position: 'absolute', left: 0, right: 0, bottom: 0, flexDirection: 'row', paddingHorizontal: spacing.framework, paddingVertical: 12, backgroundColor: colors.white, borderTopWidth: 1, borderTopColor: colors.borderLight, }, previewBtn: { flex: 1, backgroundColor: colors.white, borderRadius: 24, paddingVertical: 14, alignItems: 'center', borderWidth: 1, borderColor: colors.border, marginRight: 12, }, previewBtnText: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, optimizeFooterBtn: { flex: 2, backgroundColor: colors.primaryBtn, borderRadius: 24, paddingVertical: 14, alignItems: 'center', }, optimizeFooterBtnText: { fontSize: 15, fontWeight: '700', color: colors.white }, /* ---------- 技能 / 自我评价弹层 ---------- */ modalMask: { flex: 1, justifyContent: 'flex-end', backgroundColor: colors.scrim }, modalSheet: { backgroundColor: colors.white, borderTopLeftRadius: 20, borderTopRightRadius: 20, paddingHorizontal: 20, paddingTop: 16, paddingBottom: 28, maxHeight: '80%', }, modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, }, modalTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary }, modalBody: { maxHeight: 180 }, modalLabel: { fontSize: 13, color: colors.textHint, marginBottom: 10 }, modalInputRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 16 }, modalInput: { flex: 1, height: 46, borderRadius: 12, borderWidth: 1, borderColor: colors.border, paddingHorizontal: 14, fontSize: 15, color: colors.textPrimary, }, modalAddBtn: { backgroundColor: colors.primaryBtn, borderRadius: 12, paddingHorizontal: 18, paddingVertical: 13, }, modalAddBtnDisabled: { backgroundColor: colors.border }, modalAddText: { fontSize: 14, fontWeight: '700', color: colors.white }, modalError: { fontSize: 12, color: colors.dangerText, marginTop: 8 }, evalInput: { height: 140, borderRadius: 12, borderWidth: 1, borderColor: colors.border, paddingHorizontal: 14, paddingTop: 12, fontSize: 15, lineHeight: 22, color: colors.textPrimary, textAlignVertical: 'top', }, evalCounter: { fontSize: 12, color: colors.textHint, textAlign: 'right', marginTop: 6 }, modalFooter: { flexDirection: 'row', gap: 12, marginTop: 18 }, modalCancelBtn: { flex: 1, borderRadius: 24, paddingVertical: 14, alignItems: 'center', borderWidth: 1, borderColor: colors.border, }, modalCancelText: { fontSize: 15, fontWeight: '700', color: colors.textSecondary }, modalSaveBtn: { flex: 2, backgroundColor: colors.primaryBtn, borderRadius: 24, paddingVertical: 14, alignItems: 'center', }, modalSaveBtnDisabled: { backgroundColor: colors.textDisabled }, modalSaveText: { fontSize: 15, fontWeight: '700', color: colors.white }, });