Browse Source
- api: 新增 resume-versions 全链路 9 个端点(对齐 /profile/me/resume-versions 与 /resume-export-tasks) mock 以模块级可变数组模拟持久态;真实路径含 multipart 上传占位说明 - hook: useResumeVersions 覆盖列表/创建/设默认/删除/导出轮询 - 页面:app/resume-versions.tsx 版本卡(默认徽标/设默认/导出/删除)+ 新建弹窗,四态齐全 - 入口:简历页头部加「版本」入口;路由在 _layout 注册 - 主题:补充 scrimLight 遮罩 token(亮/暗双模式)master
10 changed files with 909 additions and 1 deletions
@ -0,0 +1,464 @@ |
|||||
|
import { Ionicons } from '@expo/vector-icons'; |
||||
|
import { useRouter } from 'expo-router'; |
||||
|
import { useCallback, useState } from 'react'; |
||||
|
import { |
||||
|
ActivityIndicator, |
||||
|
Alert, |
||||
|
KeyboardAvoidingView, |
||||
|
Modal, |
||||
|
Platform, |
||||
|
Pressable, |
||||
|
ScrollView, |
||||
|
StyleSheet, |
||||
|
Text, |
||||
|
TextInput, |
||||
|
View, |
||||
|
} from 'react-native'; |
||||
|
import { SafeAreaView } from 'react-native-safe-area-context'; |
||||
|
import { useResumeVersions } from '../src/hooks'; |
||||
|
import { colors, spacing } from '../src/theme'; |
||||
|
import type { ResumeVersion } from '../src/types'; |
||||
|
import { formatBytes, formatDateTime } from '../src/utils'; |
||||
|
|
||||
|
/* ===================== 子组件:顶部导航 ===================== */ |
||||
|
|
||||
|
function Header({ |
||||
|
onBack, |
||||
|
onNew, |
||||
|
}: { |
||||
|
onBack: () => void; |
||||
|
onNew: () => 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}>简历版本</Text> |
||||
|
<Pressable style={styles.headerActionBtn} onPress={onNew} hitSlop={8}> |
||||
|
<Ionicons name="add" size={22} color={colors.primary} /> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/* ===================== 子组件:版本卡 ===================== */ |
||||
|
|
||||
|
function VersionCard({ |
||||
|
version, |
||||
|
onSetDefault, |
||||
|
onExport, |
||||
|
onDelete, |
||||
|
}: { |
||||
|
version: ResumeVersion; |
||||
|
onSetDefault: () => void; |
||||
|
onExport: () => void; |
||||
|
onDelete: () => void; |
||||
|
}) { |
||||
|
return ( |
||||
|
<View style={[styles.card, version.isDefault && styles.cardDefault]}> |
||||
|
<View style={styles.cardTop}> |
||||
|
<View style={styles.cardTitleWrap}> |
||||
|
<Text style={styles.cardTitle} numberOfLines={1}> |
||||
|
{version.name} |
||||
|
</Text> |
||||
|
{version.isDefault && ( |
||||
|
<View style={styles.defaultBadge}> |
||||
|
<Text style={styles.defaultBadgeText}>默认</Text> |
||||
|
</View> |
||||
|
)} |
||||
|
</View> |
||||
|
<Text style={styles.cardVersion}>v{version.versionNo}</Text> |
||||
|
</View> |
||||
|
|
||||
|
<Text style={styles.cardMeta}> |
||||
|
{version.fileName} · {formatBytes(version.fileSize)} |
||||
|
</Text> |
||||
|
<Text style={styles.cardTime}> |
||||
|
更新于 {formatDateTime(version.updatedAt)} |
||||
|
</Text> |
||||
|
|
||||
|
<View style={styles.cardActions}> |
||||
|
{!version.isDefault && ( |
||||
|
<Pressable style={styles.actionBtn} onPress={onSetDefault}> |
||||
|
<Ionicons name="star-outline" size={16} color={colors.primary} /> |
||||
|
<Text style={[styles.actionText, { color: colors.primary }]}>设为默认</Text> |
||||
|
</Pressable> |
||||
|
)} |
||||
|
<Pressable style={styles.actionBtn} onPress={onExport}> |
||||
|
<Ionicons name="download-outline" size={16} color={colors.textSecondary} /> |
||||
|
<Text style={styles.actionText}>导出</Text> |
||||
|
</Pressable> |
||||
|
{!version.isDefault && ( |
||||
|
<Pressable style={styles.actionBtn} onPress={onDelete}> |
||||
|
<Ionicons name="trash-outline" size={16} color={colors.danger} /> |
||||
|
<Text style={[styles.actionText, { color: colors.danger }]}>删除</Text> |
||||
|
</Pressable> |
||||
|
)} |
||||
|
</View> |
||||
|
</View> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/* ===================== 子组件:新建版本弹窗 ===================== */ |
||||
|
|
||||
|
function NewVersionModal({ |
||||
|
visible, |
||||
|
onClose, |
||||
|
onCreate, |
||||
|
}: { |
||||
|
visible: boolean; |
||||
|
onClose: () => void; |
||||
|
onCreate: (name: string) => void; |
||||
|
}) { |
||||
|
const [name, setName] = useState(''); |
||||
|
|
||||
|
const handleConfirm = () => { |
||||
|
onCreate(name.trim()); |
||||
|
setName(''); |
||||
|
}; |
||||
|
|
||||
|
return ( |
||||
|
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}> |
||||
|
<View style={styles.modalMask}> |
||||
|
<KeyboardAvoidingView |
||||
|
style={styles.modalSheet} |
||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined} |
||||
|
> |
||||
|
<Text style={styles.modalTitle}>新建简历版本</Text> |
||||
|
<TextInput |
||||
|
style={styles.modalInput} |
||||
|
placeholder="版本名(可空,默认按序号命名)" |
||||
|
placeholderTextColor={colors.textHint} |
||||
|
value={name} |
||||
|
onChangeText={setName} |
||||
|
maxLength={30} |
||||
|
/> |
||||
|
{/* affordance-pending-backend:真实文件上传需后端 multipart 接口,当前以占位文件创建 */} |
||||
|
<Text style={styles.modalHint}>文件上传需后端支持,当前以占位文件创建版本</Text> |
||||
|
<View style={styles.modalActions}> |
||||
|
<Pressable style={styles.modalCancel} onPress={onClose}> |
||||
|
<Text style={styles.modalCancelText}>取消</Text> |
||||
|
</Pressable> |
||||
|
<Pressable style={styles.modalConfirm} onPress={handleConfirm}> |
||||
|
<Text style={styles.modalConfirmText}>创建</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
</KeyboardAvoidingView> |
||||
|
</View> |
||||
|
</Modal> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/* ===================== 页面 ===================== */ |
||||
|
|
||||
|
export default function ResumeVersionsScreen() { |
||||
|
const router = useRouter(); |
||||
|
const { versions, loading, error, refresh, busy, create, setDefault, remove, exportAndPoll } = |
||||
|
useResumeVersions(); |
||||
|
const [newVisible, setNewVisible] = useState(false); |
||||
|
|
||||
|
const handleNew = useCallback( |
||||
|
(name: string) => { |
||||
|
setNewVisible(false); |
||||
|
void create({ |
||||
|
name: name || undefined, |
||||
|
fileName: `${name || '简历'}版本.pdf`, |
||||
|
fileSize: 200_000, |
||||
|
}); |
||||
|
}, |
||||
|
[create], |
||||
|
); |
||||
|
|
||||
|
const handleSetDefault = useCallback( |
||||
|
(id: string, name: string) => { |
||||
|
Alert.alert('设为默认', `将「${name}」设为默认简历?`, [ |
||||
|
{ text: '取消', style: 'cancel' }, |
||||
|
{ text: '设为默认', onPress: () => void setDefault(id) }, |
||||
|
]); |
||||
|
}, |
||||
|
[setDefault], |
||||
|
); |
||||
|
|
||||
|
const handleDelete = useCallback( |
||||
|
(id: string, name: string) => { |
||||
|
Alert.alert('删除版本', `确定删除「${name}」吗?默认版本需先设其他版本为默认。`, [ |
||||
|
{ text: '取消', style: 'cancel' }, |
||||
|
{ text: '删除', style: 'destructive', onPress: () => void remove(id) }, |
||||
|
]); |
||||
|
}, |
||||
|
[remove], |
||||
|
); |
||||
|
|
||||
|
const handleExport = useCallback( |
||||
|
async (id: string, name: string) => { |
||||
|
const task = await exportAndPoll(id); |
||||
|
if (!task) { |
||||
|
Alert.alert('导出失败', '导出任务异常,请稍后重试'); |
||||
|
return; |
||||
|
} |
||||
|
if (task.status === 'SUCCESS') { |
||||
|
Alert.alert('导出完成', `「${name}」已导出${task.outputUrl ? `:${task.outputUrl}` : ''}`); |
||||
|
} else if (task.status === 'FAILED') { |
||||
|
Alert.alert('导出失败', task.error || '导出任务失败'); |
||||
|
} else { |
||||
|
Alert.alert('导出中', '任务仍在处理,请稍后到导出记录查看'); |
||||
|
} |
||||
|
}, |
||||
|
[exportAndPoll], |
||||
|
); |
||||
|
|
||||
|
return ( |
||||
|
<SafeAreaView style={styles.safeArea} edges={['top']}> |
||||
|
<Header onBack={() => router.back()} onNew={() => setNewVisible(true)} /> |
||||
|
|
||||
|
{loading ? ( |
||||
|
<View style={styles.stateBox}> |
||||
|
<ActivityIndicator size="large" color={colors.primary} /> |
||||
|
</View> |
||||
|
) : error ? ( |
||||
|
<View style={styles.stateBox}> |
||||
|
<Text style={styles.stateText}>加载失败</Text> |
||||
|
<Pressable style={styles.retryBtn} onPress={refresh}> |
||||
|
<Text style={styles.retryText}>重试</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
) : versions.length === 0 ? ( |
||||
|
<View style={styles.stateBox}> |
||||
|
<Ionicons name="document-outline" size={48} color={colors.textHint} /> |
||||
|
<Text style={styles.stateText}>还没有简历版本</Text> |
||||
|
<Pressable style={styles.retryBtn} onPress={() => setNewVisible(true)}> |
||||
|
<Text style={styles.retryText}>新建版本</Text> |
||||
|
</Pressable> |
||||
|
</View> |
||||
|
) : ( |
||||
|
<ScrollView |
||||
|
style={styles.scroll} |
||||
|
contentContainerStyle={styles.scrollContent} |
||||
|
showsVerticalScrollIndicator={false} |
||||
|
> |
||||
|
{versions.map((v) => ( |
||||
|
<VersionCard |
||||
|
key={v.id} |
||||
|
version={v} |
||||
|
onSetDefault={() => handleSetDefault(v.id, v.name)} |
||||
|
onExport={() => void handleExport(v.id, v.name)} |
||||
|
onDelete={() => handleDelete(v.id, v.name)} |
||||
|
/> |
||||
|
))} |
||||
|
</ScrollView> |
||||
|
)} |
||||
|
|
||||
|
{busy && ( |
||||
|
<View style={styles.busyMask}> |
||||
|
<ActivityIndicator size="small" color={colors.primary} /> |
||||
|
</View> |
||||
|
)} |
||||
|
|
||||
|
<NewVersionModal |
||||
|
visible={newVisible} |
||||
|
onClose={() => setNewVisible(false)} |
||||
|
onCreate={handleNew} |
||||
|
/> |
||||
|
</SafeAreaView> |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
const styles = StyleSheet.create({ |
||||
|
safeArea: { |
||||
|
flex: 1, |
||||
|
backgroundColor: colors.pageBg, |
||||
|
}, |
||||
|
header: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'space-between', |
||||
|
paddingHorizontal: spacing.card, |
||||
|
height: 52, |
||||
|
backgroundColor: colors.white, |
||||
|
borderBottomWidth: 1, |
||||
|
borderBottomColor: colors.borderLight, |
||||
|
}, |
||||
|
headerBtn: { |
||||
|
width: 40, |
||||
|
height: 40, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
headerTitle: { |
||||
|
fontSize: 17, |
||||
|
fontWeight: '700', |
||||
|
color: colors.textPrimary, |
||||
|
}, |
||||
|
headerActionBtn: { |
||||
|
width: 40, |
||||
|
height: 40, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
scroll: { |
||||
|
flex: 1, |
||||
|
}, |
||||
|
scrollContent: { |
||||
|
padding: spacing.card, |
||||
|
paddingBottom: 24, |
||||
|
}, |
||||
|
card: { |
||||
|
backgroundColor: colors.white, |
||||
|
borderRadius: spacing.card, |
||||
|
padding: spacing.card, |
||||
|
marginBottom: spacing.card, |
||||
|
}, |
||||
|
cardDefault: { |
||||
|
borderWidth: 1, |
||||
|
borderColor: colors.primary, |
||||
|
}, |
||||
|
cardTop: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'space-between', |
||||
|
}, |
||||
|
cardTitleWrap: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
flex: 1, |
||||
|
marginRight: 8, |
||||
|
}, |
||||
|
cardTitle: { |
||||
|
fontSize: 15, |
||||
|
fontWeight: '700', |
||||
|
color: colors.textPrimary, |
||||
|
}, |
||||
|
defaultBadge: { |
||||
|
marginLeft: 8, |
||||
|
paddingHorizontal: 6, |
||||
|
paddingVertical: 2, |
||||
|
borderRadius: 4, |
||||
|
backgroundColor: colors.primaryLight, |
||||
|
}, |
||||
|
defaultBadgeText: { |
||||
|
fontSize: 11, |
||||
|
fontWeight: '700', |
||||
|
color: colors.primary, |
||||
|
}, |
||||
|
cardVersion: { |
||||
|
fontSize: 13, |
||||
|
color: colors.textSecondary, |
||||
|
}, |
||||
|
cardMeta: { |
||||
|
marginTop: 6, |
||||
|
fontSize: 13, |
||||
|
color: colors.textSecondary, |
||||
|
}, |
||||
|
cardTime: { |
||||
|
marginTop: 2, |
||||
|
fontSize: 12, |
||||
|
color: colors.textHint, |
||||
|
}, |
||||
|
cardActions: { |
||||
|
flexDirection: 'row', |
||||
|
marginTop: 12, |
||||
|
paddingTop: 12, |
||||
|
borderTopWidth: 1, |
||||
|
borderTopColor: colors.borderLight, |
||||
|
}, |
||||
|
actionBtn: { |
||||
|
flexDirection: 'row', |
||||
|
alignItems: 'center', |
||||
|
marginRight: 20, |
||||
|
}, |
||||
|
actionText: { |
||||
|
marginLeft: 4, |
||||
|
fontSize: 13, |
||||
|
color: colors.textSecondary, |
||||
|
}, |
||||
|
stateBox: { |
||||
|
flex: 1, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
}, |
||||
|
stateText: { |
||||
|
marginTop: 12, |
||||
|
fontSize: 14, |
||||
|
color: colors.textSecondary, |
||||
|
}, |
||||
|
retryBtn: { |
||||
|
marginTop: 16, |
||||
|
paddingHorizontal: 24, |
||||
|
paddingVertical: spacing.card, |
||||
|
borderRadius: 18, |
||||
|
backgroundColor: colors.primaryBtn, |
||||
|
}, |
||||
|
retryText: { |
||||
|
fontSize: 13, |
||||
|
fontWeight: '600', |
||||
|
color: colors.white, |
||||
|
}, |
||||
|
busyMask: { |
||||
|
...StyleSheet.absoluteFill, |
||||
|
alignItems: 'center', |
||||
|
justifyContent: 'center', |
||||
|
backgroundColor: colors.scrimLight, |
||||
|
}, |
||||
|
modalMask: { |
||||
|
flex: 1, |
||||
|
backgroundColor: colors.scrim, |
||||
|
justifyContent: 'flex-end', |
||||
|
}, |
||||
|
modalSheet: { |
||||
|
backgroundColor: colors.white, |
||||
|
borderTopLeftRadius: 16, |
||||
|
borderTopRightRadius: 16, |
||||
|
padding: spacing.card, |
||||
|
paddingBottom: 24, |
||||
|
}, |
||||
|
modalTitle: { |
||||
|
fontSize: 16, |
||||
|
fontWeight: '700', |
||||
|
color: colors.textPrimary, |
||||
|
marginBottom: 12, |
||||
|
}, |
||||
|
modalInput: { |
||||
|
borderWidth: 1, |
||||
|
borderColor: colors.borderLight, |
||||
|
borderRadius: spacing.card, |
||||
|
paddingHorizontal: 12, |
||||
|
paddingVertical: 10, |
||||
|
fontSize: 14, |
||||
|
color: colors.textPrimary, |
||||
|
}, |
||||
|
modalHint: { |
||||
|
marginTop: 8, |
||||
|
fontSize: 12, |
||||
|
color: colors.textHint, |
||||
|
}, |
||||
|
modalActions: { |
||||
|
flexDirection: 'row', |
||||
|
marginTop: 16, |
||||
|
}, |
||||
|
modalCancel: { |
||||
|
flex: 1, |
||||
|
alignItems: 'center', |
||||
|
paddingVertical: 12, |
||||
|
marginRight: 8, |
||||
|
borderRadius: spacing.card, |
||||
|
backgroundColor: colors.pageBg, |
||||
|
}, |
||||
|
modalCancelText: { |
||||
|
fontSize: 14, |
||||
|
color: colors.textSecondary, |
||||
|
}, |
||||
|
modalConfirm: { |
||||
|
flex: 1, |
||||
|
alignItems: 'center', |
||||
|
paddingVertical: 12, |
||||
|
marginLeft: 8, |
||||
|
borderRadius: spacing.card, |
||||
|
backgroundColor: colors.primaryBtn, |
||||
|
}, |
||||
|
modalConfirmText: { |
||||
|
fontSize: 14, |
||||
|
fontWeight: '700', |
||||
|
color: colors.white, |
||||
|
}, |
||||
|
}); |
||||
@ -0,0 +1,118 @@ |
|||||
|
/** |
||||
|
* 简历版本管理 hook:列表 + 创建 / 设默认 / 删除 / 导出任务。 |
||||
|
* 操作后自动刷新列表;导出任务轮询到终态后返回,供页面提示结果。 |
||||
|
*/ |
||||
|
import { useCallback, useState } from 'react'; |
||||
|
import { |
||||
|
createResumeExportTask, |
||||
|
createResumeVersion, |
||||
|
deleteResumeVersion, |
||||
|
fetchResumeExportTask, |
||||
|
fetchResumeVersions, |
||||
|
setDefaultResumeVersion, |
||||
|
} from '../api'; |
||||
|
import type { ResumeExportTask, ResumeVersion } from '../types'; |
||||
|
import { useAsyncData } from './useAsyncData'; |
||||
|
|
||||
|
export interface ResumeVersionsState { |
||||
|
/** 版本列表(versionNo 倒序) */ |
||||
|
versions: ResumeVersion[]; |
||||
|
/** 是否加载中 */ |
||||
|
loading: boolean; |
||||
|
/** 错误对象 */ |
||||
|
error: Error | null; |
||||
|
/** 刷新列表 */ |
||||
|
refresh: () => void; |
||||
|
/** 操作进行中(创建/设默认/删除/导出任一进行) */ |
||||
|
busy: boolean; |
||||
|
/** 创建版本(占位元数据;真实后端走 multipart 上传) */ |
||||
|
create: (input: { |
||||
|
name?: string; |
||||
|
fileName: string; |
||||
|
fileSize: number; |
||||
|
}) => Promise<void>; |
||||
|
/** 设为默认 */ |
||||
|
setDefault: (id: string) => Promise<void>; |
||||
|
/** 删除版本 */ |
||||
|
remove: (id: string) => Promise<void>; |
||||
|
/** 创建导出任务并轮询到终态,返回最终任务(或异常时 null) */ |
||||
|
exportAndPoll: (id: string) => Promise<ResumeExportTask | null>; |
||||
|
} |
||||
|
|
||||
|
export function useResumeVersions(): ResumeVersionsState { |
||||
|
const [busy, setBusy] = useState(false); |
||||
|
|
||||
|
const fetcher = useCallback(() => fetchResumeVersions(), []); |
||||
|
const { data, loading, error, refresh } = useAsyncData(fetcher); |
||||
|
|
||||
|
const create = useCallback( |
||||
|
async (input: { name?: string; fileName: string; fileSize: number }) => { |
||||
|
setBusy(true); |
||||
|
try { |
||||
|
await createResumeVersion(input); |
||||
|
refresh(); |
||||
|
} finally { |
||||
|
setBusy(false); |
||||
|
} |
||||
|
}, |
||||
|
[refresh], |
||||
|
); |
||||
|
|
||||
|
const setDefault = useCallback( |
||||
|
async (id: string) => { |
||||
|
setBusy(true); |
||||
|
try { |
||||
|
await setDefaultResumeVersion(id); |
||||
|
refresh(); |
||||
|
} finally { |
||||
|
setBusy(false); |
||||
|
} |
||||
|
}, |
||||
|
[refresh], |
||||
|
); |
||||
|
|
||||
|
const remove = useCallback( |
||||
|
async (id: string) => { |
||||
|
setBusy(true); |
||||
|
try { |
||||
|
await deleteResumeVersion(id); |
||||
|
refresh(); |
||||
|
} finally { |
||||
|
setBusy(false); |
||||
|
} |
||||
|
}, |
||||
|
[refresh], |
||||
|
); |
||||
|
|
||||
|
const exportAndPoll = useCallback(async (id: string) => { |
||||
|
setBusy(true); |
||||
|
try { |
||||
|
const task = await createResumeExportTask(id); |
||||
|
// 轮询到终态(最多 6 次,2s 间隔);mock 直接 SUCCESS
|
||||
|
let current = task; |
||||
|
for ( |
||||
|
let i = 0; |
||||
|
i < 6 && (current.status === 'PENDING' || current.status === 'RUNNING'); |
||||
|
i++ |
||||
|
) { |
||||
|
await new Promise((r) => setTimeout(r, 2000)); |
||||
|
current = await fetchResumeExportTask(current.id); |
||||
|
} |
||||
|
return current; |
||||
|
} finally { |
||||
|
setBusy(false); |
||||
|
} |
||||
|
}, []); |
||||
|
|
||||
|
return { |
||||
|
versions: data ?? [], |
||||
|
loading, |
||||
|
error, |
||||
|
refresh, |
||||
|
busy, |
||||
|
create, |
||||
|
setDefault, |
||||
|
remove, |
||||
|
exportAndPoll, |
||||
|
}; |
||||
|
} |
||||
Loading…
Reference in new issue