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