import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { ActivityIndicator, Alert, Modal, Pressable, ScrollView, StyleSheet, Switch, Text, TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { RESUME_EXPORT_FORMAT_NOTICE, RESUME_EXPORT_PASSWORD_NOTICE, RESUME_FILE_NAME_HINT, RESUME_ORIENTATIONS, RESUME_PAPER_SIZES, RESUME_TEMPLATES, } from '../../src/constants'; import { useResume, useResumeExport } from '../../src/hooks'; import {colors, templateAccentColors, spacing} from '../../src/theme'; import type { ResumeExportFormat, ResumeExportSettings } from '../../src/types'; const FORMATS: { key: ResumeExportFormat; label: string; icon: keyof typeof Ionicons.glyphMap }[] = [ { key: 'pdf', label: 'PDF', icon: 'document-outline' }, { key: 'word', label: 'Word', icon: 'document-text-outline' }, { key: 'image', label: '图片', icon: 'image-outline' }, ]; /* ===================== 子组件:顶部导航 ===================== */ /** * 顶部导航。onSave 可选:加载中 / 加载失败时设置尚未就绪(localSettings 为 null), * 此时不渲染「保存」按钮,避免出现点了无反应的入口(比照 progress.tsx 移除无效按钮的处理)。 * 该态下仍渲染同尺寸占位,维持标题居中。 */ function Header({ onBack, onSave }: { onBack: () => void; onSave?: () => void }) { return ( 导出简历 {onSave ? ( 保存 ) : ( )} ); } /* ===================== 子组件:预览入口 ===================== */ function PreviewCard({ onOpen }: { onOpen: () => void }) { return ( 实时预览你的简历效果 打开预览 ); } /* ===================== 子组件:格式选择 ===================== */ function FormatSelect({ value, onChange, }: { value: ResumeExportFormat; onChange: (v: ResumeExportFormat) => void; }) { return ( 选择导出格式 {FORMATS.map((f) => { const selected = value === f.key; return ( onChange(f.key)} > {f.label} 高清 · 格式友好 {selected && ( )} ); })} {/* 非 PDF 选中的那一刻就说清能做到什么:等到导出后才说,用户已经白等一次 */} {value !== 'pdf' && ( {RESUME_EXPORT_FORMAT_NOTICE} )} ); } /* ===================== 子组件:模板选择 ===================== */ function TemplateSelect({ value, onChange, onMore, }: { value: string; onChange: (v: string) => void; onMore: () => void; }) { return ( 选择简历模板 全部模板 > {RESUME_TEMPLATES.map((t) => { const selected = value === t.id; return ( onChange(t.id)} > {/* TODO(素材):需 UI 出 3 张模板预览图(简约经典 / 创意设计 / 技术专业), 当前用模板强调色色块 + 线条占位 */} {t.name} {selected && } ); })} ); } /* ===================== 子组件:开关行 ===================== */ function ToggleRow({ label, desc, notice, value, onChange, }: { label: string; desc?: string; notice?: string; value: boolean; onChange: (v: boolean) => void; }) { return ( {label} {desc && {desc}} {notice && {notice}} ); } /* ===================== 子组件:输入行 / 选择行 ===================== */ function InputRow({ label, value, onChange, placeholder, hint, }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; hint?: string; }) { return ( {label} {hint && {hint}} ); } /** 点开弹层选择的行:纸型 / 方向用(原先两行的 onChange 是空函数,点下去没反应) */ function SelectRow({ label, value, onPress, }: { label: string; value: string; onPress: () => void; }) { return ( {label} {value} ); } /* ===================== 子组件:选择弹层 ===================== */ interface SheetOption { value: string; label: string; /** 模板缩略色块(纸型 / 方向不需要) */ accent?: string; } interface SheetState { title: string; hint?: string; value: string; options: SheetOption[]; onSelect: (value: string) => void; } /** 底部选择弹层:模板 / 纸型 / 方向共用(三者的交互完全一致,只差选项与标题) */ function OptionSheet({ sheet, onClose }: { sheet: SheetState; onClose: () => void }) { return ( {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */} {}}> {sheet.title} {sheet.options.map((option) => { const selected = option.value === sheet.value; return ( sheet.onSelect(option.value)} > {option.accent !== undefined && ( )} {option.label} {selected && } ); })} {sheet.hint !== undefined && {sheet.hint}} 取消 ); } /* ===================== 子组件:四态 ===================== */ function Loading() { return ( 正在加载设置… ); } function ErrorState({ onRetry }: { onRetry: () => void }) { return ( 设置加载失败 点击重试 ); } /* ===================== 工具:storage 值 → 合法设置值 ===================== */ /** 弹层回传字符串,白名单校验后才写入设置(storage 里的值不保证是这两个之一) */ function pickPaperSize(value: string): ResumeExportSettings['paperSize'] { return value === 'letter' ? 'letter' : 'A4'; } function pickOrientation(value: string): ResumeExportSettings['orientation'] { return value === 'landscape' ? 'landscape' : 'portrait'; } /* ===================== 页面 ===================== */ export default function ResumeExportScreen() { const router = useRouter(); const { settings, loading, error, refresh, save, exportFile, share, exporting, sharing } = useResumeExport(); /** * AI 定向优化走简历域的 hook(页面不直连 API)。 * 这里只取 `optimizeWithJD`,本页不渲染简历数据。 */ const { optimizeWithJD } = useResume(); const [localSettings, setLocalSettings] = useState(null); const [sheet, setSheet] = useState(null); const [optimizing, setOptimizing] = useState(false); useEffect(() => { if (settings && !localSettings) { setLocalSettings(settings); } }, [settings, localSettings]); const update = (key: K, value: ResumeExportSettings[K]) => { setLocalSettings((prev) => (prev ? { ...prev, [key]: value } : prev)); }; /** 只落盘不带提示:导出 / 分享前先把设置保存好,避免导出用的是一份没保存的配置 */ const persist = async () => { if (localSettings) await save(localSettings); }; const handleSave = async () => { await persist(); Alert.alert('已保存', '导出设置已保存在本机,下次进入仍是这份设置。'); }; const handleExport = async () => { if (!localSettings) return; await persist(); const outcome = await exportFile(localSettings); if (!outcome.ok) { Alert.alert('导出失败', outcome.message); return; } Alert.alert( '导出成功', [ `已生成 ${outcome.fileName}(共 ${outcome.pages} 页)。`, '文件在本机缓存中,点「分享」可保存到「文件」或发送给他人;缓存可能被系统清理,需要长期留存请先分享出去。', localSettings.format === 'pdf' ? '' : RESUME_EXPORT_FORMAT_NOTICE, ] .filter((line) => line !== '') .join('\n\n'), [ { text: '知道了', style: 'cancel' }, { text: '分享', onPress: () => void handleShare() }, ], ); }; const handleShare = async () => { if (!localSettings) return; await persist(); const outcome = await share(localSettings); // 成功不弹提示:系统分享面板本身就是反馈;失败必须说清原因 if (!outcome.ok) Alert.alert('无法分享', outcome.message); }; const jd = (localSettings?.targetJobDescription ?? '').trim(); const handleOptimize = async () => { if (jd === '' || optimizing) return; setOptimizing(true); try { const result = await optimizeWithJD(jd); if (result.available) { Alert.alert('已完成', result.message); } else { Alert.alert('暂未开放', result.message); } } finally { setOptimizing(false); } }; // 保存设置会 refresh,refresh 会把 loading 置真;此时 localSettings 已有值, // 不能因为刷新再闪一次整页加载态(首次加载才显示 loading) if (loading && localSettings === null) { return (
router.back()} /> ); } if (error || !localSettings) { return (
router.back()} /> ); } return (
router.back()} onSave={handleSave} /> router.push('/resume/preview')} /> update('format', v)} /> update('templateId', v)} onMore={() => setSheet({ title: '选择简历模板', hint: '更多模板将在后端模板库接入后提供', value: localSettings.templateId, options: RESUME_TEMPLATES.map((t) => ({ value: t.id, label: t.name, accent: templateAccentColors[t.color] ?? colors.primary, })), onSelect: (v) => update('templateId', v), }) } /> 导出设置 update('hideContact', v)} /> update('exportPassword', v)} /> p.value === localSettings.paperSize)?.label ?? 'A4' } onPress={() => setSheet({ title: '选择纸型', value: localSettings.paperSize, options: RESUME_PAPER_SIZES.map((p) => ({ value: p.value, label: p.label })), onSelect: (v) => update('paperSize', pickPaperSize(v)), }) } /> o.value === localSettings.orientation)?.label ?? '纵向' } onPress={() => setSheet({ title: '选择方向', value: localSettings.orientation, options: RESUME_ORIENTATIONS.map((o) => ({ value: o.value, label: o.label })), onSelect: (v) => update('orientation', pickOrientation(v)), }) } /> update('fileName', v)} placeholder="请输入文件名" hint={RESUME_FILE_NAME_HINT} /> update('includeCover', v)} /> update('includeRecommendation', v)} /> 定向优化(可选) 粘贴岗位描述,AI 自动优化简历匹配度 update('targetJobDescription', v)} placeholder="输入岗位 JD…" placeholderTextColor={colors.textHint} multiline numberOfLines={4} textAlignVertical="top" /> {optimizing ? ( ) : ( <> AI 智能优化 )} {/* 按钮置灰时把原因写在旁边:灰按钮不给理由,用户只会以为坏了 */} {jd === '' && 请先粘贴岗位描述,再点优化} void handleShare()} disabled={sharing} > {sharing ? ( ) : ( 分享简历 )} {exporting ? ( ) : ( 立即导出 )} {sheet !== null && ( setSheet(null)} /> )} ); } 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: 60, justifyContent: 'center' }, headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary }, headerSave: { fontSize: 14, color: colors.primary, textAlign: 'right' }, body: { padding: 8, paddingBottom: 100 }, 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 }, previewCard: { backgroundColor: colors.white, borderRadius: 8, padding: 8, alignItems: 'center', marginBottom: 8, borderWidth: 1, borderColor: colors.borderLight, }, previewThumb: { width: 80, height: 80, borderRadius: 40, backgroundColor: colors.primaryLight, alignItems: 'center', justifyContent: 'center', marginBottom: 10, }, previewText: { fontSize: 14, color: colors.textSecondary }, previewLink: { flexDirection: 'row', alignItems: 'center', marginTop: 8 }, previewLinkText: { fontSize: 13, color: colors.primary, marginRight: 2 }, section: { marginBottom: 16 }, sectionHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12, }, sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 }, moreLink: { fontSize: 13, color: colors.primary }, formatRow: { flexDirection: 'row', gap: 10 }, formatCard: { flex: 1, backgroundColor: colors.white, borderRadius: 8, padding: 8, alignItems: 'center', borderWidth: 1, borderColor: colors.borderLight, }, formatCardActive: { borderColor: colors.primary, backgroundColor: colors.primaryLight }, formatLabel: { fontSize: 13, fontWeight: '700', color: colors.textPrimary, marginTop: 8 }, formatLabelActive: { color: colors.primaryText }, formatHint: { fontSize: 10, color: colors.textHint, marginTop: 2 }, formatCheck: { position: 'absolute', top: 8, right: 8 }, noticeRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 10 }, noticeText: { fontSize: 12, color: colors.textHint, lineHeight: 17, marginTop: 4 }, templateRow: { flexDirection: 'row', gap: 10 }, templateCard: { flex: 1, backgroundColor: colors.white, borderRadius: 8, padding: 8, alignItems: 'center', borderWidth: 1, borderColor: colors.borderLight, }, templateCardActive: { borderColor: colors.primary }, templateThumb: { width: '100%', height: 80, borderRadius: 8, backgroundColor: colors.primary, padding: 12, marginBottom: 8, }, templateLines: { gap: 8, marginTop: 8 }, templateLine: { height: 8, borderRadius: 4, backgroundColor: colors.white, opacity: 0.9 }, templateLineShort: { width: '60%', height: 8, borderRadius: 4, backgroundColor: colors.white, opacity: 0.7 }, templateName: { fontSize: 12, color: colors.textPrimary }, templateNameActive: { color: colors.primaryText, fontWeight: '700' }, card: { backgroundColor: colors.white, borderRadius: 8, padding: 8, borderWidth: 1, borderColor: colors.borderLight, }, toggleRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12, borderTopWidth: 1, borderTopColor: colors.borderLight, }, toggleText: { flex: 1, marginRight: 12 }, toggleLabel: { fontSize: 14, color: colors.textPrimary }, toggleDesc: { fontSize: 12, color: colors.textHint, marginTop: 2 }, inputRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 12, borderTopWidth: 1, borderTopColor: colors.borderLight, }, inputLabelBox: { flex: 1, marginRight: 12 }, inputLabel: { fontSize: 14, color: colors.textPrimary, width: 80 }, inputHint: { fontSize: 11, color: colors.textHint, marginTop: 2 }, input: { flex: 1, fontSize: 14, color: colors.textPrimary, textAlign: 'right', }, selectValue: { flexDirection: 'row', alignItems: 'center', gap: 4 }, selectValueText: { fontSize: 14, color: colors.textSecondary }, targetLabel: { fontSize: 13, color: colors.textSecondary, marginBottom: 10 }, targetInput: { height: 100, borderRadius: 10, backgroundColor: colors.pageBg, padding: 12, fontSize: 14, color: colors.textPrimary, }, targetBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', backgroundColor: colors.primaryBtn, borderRadius: 12, paddingVertical: 12, marginTop: 12, }, targetBtnDisabled: { backgroundColor: colors.textDisabled }, targetBtnText: { fontSize: 14, fontWeight: '700', color: colors.white, marginLeft: 6 }, scrim: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'flex-end' }, sheet: { backgroundColor: colors.white, borderTopLeftRadius: 18, borderTopRightRadius: 18, paddingHorizontal: 20, paddingTop: 18, paddingBottom: 28, }, sheetTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, marginBottom: 8 }, sheetRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: colors.borderLight, }, sheetSwatch: { width: 22, height: 22, borderRadius: 6, marginRight: 12 }, sheetLabel: { flex: 1, fontSize: 15, color: colors.textPrimary }, sheetLabelActive: { color: colors.primaryText, fontWeight: '700' }, sheetHint: { fontSize: 12, color: colors.textHint, marginTop: 12, lineHeight: 18 }, sheetCancel: { marginTop: 16, paddingVertical: 12, borderRadius: 24, alignItems: 'center', backgroundColor: colors.pageBg, }, sheetCancelText: { fontSize: 15, fontWeight: '600', color: colors.textSecondary }, footer: { position: 'absolute', left: 0, right: 0, bottom: 0, flexDirection: 'row', paddingHorizontal: spacing.framework, paddingVertical: 12, backgroundColor: colors.white, borderTopWidth: 1, borderTopColor: colors.borderLight, }, shareBtn: { flex: 1, backgroundColor: colors.white, borderRadius: 24, paddingVertical: 14, alignItems: 'center', borderWidth: 1, borderColor: colors.border, marginRight: 12, }, shareBtnDisabled: { borderColor: colors.borderLight }, shareBtnText: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, exportBtn: { flex: 2, backgroundColor: colors.primaryBtn, borderRadius: 24, paddingVertical: 14, alignItems: 'center', }, exportBtnDisabled: { backgroundColor: colors.textDisabled }, exportBtnText: { fontSize: 15, fontWeight: '700', color: colors.white }, });