You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
464 lines
12 KiB
464 lines
12 KiB
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,
|
|
},
|
|
});
|
|
|