轻松聘
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

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,
},
});