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 (
);
}
if (error || !localSettings) {
return (
);
}
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: 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 },
previewCard: {
backgroundColor: colors.white,
borderRadius: spacing.card,
padding: spacing.card,
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: spacing.card,
padding: spacing.card,
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: spacing.card,
padding: spacing.card,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.borderLight,
},
templateCardActive: { borderColor: colors.primary },
templateThumb: {
width: '100%',
height: 80,
borderRadius: spacing.card,
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: spacing.card,
padding: spacing.card,
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 },
});