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

602 lines
20 KiB

import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
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 { buildChecklistText } from '../src/api';
import { useOnboardingChecklist } from '../src/hooks';
import { shareText } from '../src/lib/shareFile';
import { colors, headerGradientShort } from '../src/theme';
import type { ChecklistGroup, OnboardingChecklist, OnboardingTip } from '../src/types';
/** 分组图标配色轮转(全部来自主题 token) */
const GROUP_ICON_THEME: Record<string, { bg: string; color: string }> = {
material: { bg: colors.primaryLight, color: colors.primary },
procedure: { bg: colors.infoLight, color: colors.info },
study: { bg: colors.successLight, color: colors.success },
life: { bg: colors.actionLight, color: colors.action },
daily: { bg: colors.primaryLight, color: colors.primaryText },
};
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack, onShare }: { onBack: () => void; onShare: () => 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.headerBtn} onPress={onShare} hitSlop={8}>
<Ionicons name="share-outline" size={22} color={colors.textPrimary} />
</Pressable>
</View>
);
}
/* ===================== 子组件:倒计时卡片 ===================== */
function CountdownCard({
daysLeft,
company,
position,
onboardingDate,
completed,
total,
}: {
daysLeft: number;
company: string;
position: string;
onboardingDate: string;
completed: number;
total: number;
}) {
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
return (
<LinearGradient
colors={headerGradientShort}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.countdownCard}
>
<View style={styles.countdownTop}>
<View>
<Text style={styles.countdownLabel}>入职倒计时</Text>
<View style={styles.countdownValueRow}>
<Text style={styles.countdownValue}>{daysLeft}</Text>
<Text style={styles.countdownUnit}>天</Text>
</View>
<Text style={styles.countdownCompany}>
{company} · {position}
</Text>
<Text style={styles.countdownDate}>入职日期 {onboardingDate}</Text>
</View>
<View style={styles.countdownIcon}>
<Ionicons name="briefcase" size={32} color={colors.primary} />
</View>
</View>
<View style={styles.countdownProgress}>
<View style={styles.countdownProgressHead}>
<Text style={styles.countdownProgressLabel}>总体进度</Text>
<Text style={styles.countdownProgressValue}>
{completed}/{total}
</Text>
</View>
<View style={styles.countdownTrack}>
<View style={[styles.countdownFill, { width: `${percent}%` }]} />
</View>
</View>
</LinearGradient>
);
}
/* ===================== 子组件:清单分组 ===================== */
function ChecklistGroupCard({
group,
onToggleItem,
onRemoveItem,
togglingId,
expanded,
onToggleExpand,
}: {
group: ChecklistGroup;
onToggleItem: (itemId: string, done: boolean) => void;
onRemoveItem: (itemId: string) => void;
togglingId: string | null;
expanded: boolean;
onToggleExpand: () => void;
}) {
const theme = GROUP_ICON_THEME[group.id] ?? GROUP_ICON_THEME.material;
const doneCount = group.items.filter((i) => i.done).length;
// 只有「我的自定义待办」这一组可以删条目:模板待办删了下次进页面还会回来
const removable = group.id === 'custom';
return (
<View style={styles.groupCard}>
<Pressable style={styles.groupHead} onPress={onToggleExpand}>
<View style={[styles.groupIcon, { backgroundColor: theme.bg }]}>
<Ionicons name={group.icon as keyof typeof Ionicons.glyphMap} size={20} color={theme.color} />
</View>
<View style={styles.groupTitleWrap}>
<Text style={styles.groupTitle}>{group.title}</Text>
<Text style={styles.groupCount}>
{doneCount}/{group.items.length}
</Text>
</View>
<Ionicons
name={expanded ? 'chevron-up' : 'chevron-down'}
size={20}
color={colors.textHint}
/>
</Pressable>
{expanded && (
<View style={styles.groupBody}>
{group.items.map((item) => (
<Pressable
key={item.id}
style={styles.itemRow}
onPress={() => onToggleItem(item.id, !item.done)}
disabled={togglingId === item.id}
>
<Ionicons
name={item.done ? 'checkbox' : 'square-outline'}
size={20}
color={item.done ? colors.success : colors.borderStrong}
/>
<Text style={[styles.itemTitle, item.done && styles.itemTitleDone]}>
{item.title}
</Text>
{item.required && !item.done && (
<View style={styles.requiredTag}>
<Text style={styles.requiredText}>必填</Text>
</View>
)}
{removable && (
<Pressable onPress={() => onRemoveItem(item.id)} hitSlop={8}>
<Ionicons name="close-circle-outline" size={18} color={colors.textHint} />
</Pressable>
)}
</Pressable>
))}
</View>
)}
</View>
);
}
/* ===================== 子组件:每日提醒 ===================== */
function DailyReminderCard({
enabled,
time,
onToggle,
}: {
enabled: boolean;
time: string;
onToggle: (v: boolean) => void;
}) {
return (
<View style={styles.card}>
<View style={styles.reminderRow}>
<View style={styles.reminderText}>
<Text style={styles.reminderTitle}>入职每日提醒</Text>
<Text style={styles.reminderDesc}>每天 {time} 提醒你完成待办</Text>
</View>
<Switch
value={enabled}
onValueChange={onToggle}
trackColor={{ false: colors.border, true: colors.primaryBorder }}
thumbColor={enabled ? colors.primary : colors.white}
/>
</View>
</View>
);
}
/* ===================== 子组件:入职小贴士 ===================== */
function TipsCard({ tips }: { tips: OnboardingTip[] }) {
return (
<View style={styles.card}>
<Text style={styles.cardTitle}>入职小贴士</Text>
{tips.map((tip) => (
<View key={tip.id} style={styles.tipRow}>
<Ionicons name={tip.icon as keyof typeof Ionicons.glyphMap} size={18} color={colors.primary} />
<View style={styles.tipBody}>
<Text style={styles.tipTitle}>{tip.title}</Text>
<Text style={styles.tipDesc}>{tip.desc}</Text>
</View>
</View>
))}
</View>
);
}
/* ===================== 子组件:四态 ===================== */
function Loading() {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.tip}>正在加载清单…</Text>
</View>
);
}
function ErrorState({ onRetry }: { onRetry: () => void }) {
return (
<View style={styles.center}>
<Ionicons name="list-outline" size={40} color={colors.textHint} />
<Text style={styles.tip}>清单加载失败</Text>
<Pressable style={styles.retryBtn} onPress={onRetry} hitSlop={6}>
<Text style={styles.retryText}>点击重试</Text>
</Pressable>
</View>
);
}
/* ===================== 页面 ===================== */
export default function OnboardingScreen() {
const router = useRouter();
const { checklist, loading, error, refresh, toggle, togglingId, addItem, removeItem, mutating } =
useOnboardingChecklist();
/** 本地清单状态(勾选后即时更新,避免等待接口往返) */
const [localChecklist, setLocalChecklist] = useState<OnboardingChecklist | null>(null);
const [expandedIds, setExpandedIds] = useState<string[]>([]);
const [reminderEnabled, setReminderEnabled] = useState(false);
/** 「添加自定义待办」弹窗 */
const [addVisible, setAddVisible] = useState(false);
const [draftTitle, setDraftTitle] = useState('');
useEffect(() => {
if (checklist && !localChecklist) {
setLocalChecklist(checklist);
setExpandedIds(checklist.groups.filter((g) => g.expanded).map((g) => g.id));
setReminderEnabled(checklist.dailyReminder.enabled);
}
}, [checklist, localChecklist]);
const display = localChecklist ?? checklist;
const handleToggleItem = async (itemId: string, done: boolean) => {
setLocalChecklist((prev) => {
if (!prev) return prev;
const groups = prev.groups.map((g) => ({
...g,
items: g.items.map((item) => (item.id === itemId ? { ...item, done } : item)),
}));
const completed = groups.reduce((sum, g) => sum + g.items.filter((i) => i.done).length, 0);
return { ...prev, groups, completed };
});
await toggle(itemId, done);
};
/** 添加自定义待办:接口层负责落库并返回新清单,页面直接换成返回值 */
const handleAddItem = async () => {
try {
const updated = await addItem(draftTitle);
setLocalChecklist(updated);
setDraftTitle('');
setAddVisible(false);
} catch (e) {
Alert.alert('添加失败', e instanceof Error ? e.message : '请稍后重试');
}
};
/** 删除自定义待办(模板条目接口层会拒绝) */
const handleRemoveItem = async (itemId: string) => {
try {
const updated = await removeItem(itemId);
setLocalChecklist(updated);
} catch (e) {
Alert.alert('删除失败', e instanceof Error ? e.message : '请稍后重试');
}
};
/**
* 导出 / 分享清单:把清单拼成文本交给系统分享面板。
* `ok` 只代表面板已拉起,不能据此提示「分享成功」(见 lib/shareFile 的说明)。
*/
const handleExport = async () => {
if (!display) return;
const outcome = await shareText(buildChecklistText(display), '入职准备清单');
if (!outcome.ok) Alert.alert('无法导出', outcome.message);
};
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={() => void handleExport()} />
<Loading />
</SafeAreaView>
);
}
if (error || !display) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={() => void handleExport()} />
<ErrorState onRetry={refresh} />
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={() => void handleExport()} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<CountdownCard
daysLeft={display.daysLeft}
company={display.company}
position={display.position}
onboardingDate={display.onboardingDate}
completed={display.completed}
total={display.total}
/>
{display.groups.map((group) => (
<ChecklistGroupCard
key={group.id}
group={group}
onToggleItem={handleToggleItem}
onRemoveItem={handleRemoveItem}
togglingId={togglingId}
expanded={expandedIds.includes(group.id)}
onToggleExpand={() =>
setExpandedIds((prev) =>
prev.includes(group.id)
? prev.filter((id) => id !== group.id)
: [...prev, group.id],
)
}
/>
))}
<DailyReminderCard
enabled={reminderEnabled}
time={display.dailyReminder.time}
onToggle={setReminderEnabled}
/>
<TipsCard tips={display.tips} />
</ScrollView>
<View style={styles.footer}>
<Pressable
style={[styles.addBtn, mutating && styles.footerBtnDisabled]}
onPress={() => setAddVisible(true)}
disabled={mutating}
>
<Ionicons name="add" size={20} color={colors.primary} />
<Text style={styles.addBtnText}>添加自定义待办</Text>
</Pressable>
<Pressable style={styles.exportBtn} onPress={() => void handleExport()}>
<Text style={styles.exportBtnText}>导出清单</Text>
</Pressable>
</View>
{/* 添加自定义待办:Alert.prompt 只有 iOS 有,这里用自绘弹窗(与岗位评价回复一致) */}
<Modal visible={addVisible} transparent animationType="fade" onRequestClose={() => setAddVisible(false)}>
<View style={styles.modalMask}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>添加自定义待办</Text>
<TextInput
style={styles.modalInput}
value={draftTitle}
onChangeText={setDraftTitle}
placeholder="例如:办理落户材料"
placeholderTextColor={colors.textHint}
maxLength={30}
autoFocus
/>
<View style={styles.modalActions}>
<Pressable style={styles.modalCancel} onPress={() => setAddVisible(false)}>
<Text style={styles.modalCancelText}>取消</Text>
</Pressable>
<Pressable
style={[styles.modalConfirm, (draftTitle.trim() === '' || mutating) && styles.footerBtnDisabled]}
onPress={() => void handleAddItem()}
disabled={draftTitle.trim() === '' || mutating}
>
<Text style={styles.modalConfirmText}>{mutating ? '添加中…' : '添加'}</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
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: 40, alignItems: 'center' },
headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
body: { paddingHorizontal: 16, paddingBottom: 100, paddingTop: 12 },
center: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 40, gap: 12 },
tip: { fontSize: 14, color: colors.textSecondary },
retryBtn: {
paddingHorizontal: 18,
paddingVertical: 8,
borderRadius: 16,
backgroundColor: colors.primaryLight,
},
retryText: { fontSize: 13, fontWeight: '600', color: colors.primaryText },
countdownCard: { borderRadius: 16, padding: 18, marginBottom: 12 },
countdownTop: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
countdownLabel: { fontSize: 13, color: colors.textInverse, opacity: 0.9 },
countdownValueRow: { flexDirection: 'row', alignItems: 'baseline', marginTop: 6 },
countdownValue: { fontSize: 44, fontWeight: '800', color: colors.textInverse },
countdownUnit: { fontSize: 15, color: colors.textInverse, marginLeft: 4 },
countdownCompany: { fontSize: 14, color: colors.textInverse, marginTop: 8, fontWeight: '600' },
countdownDate: { fontSize: 12, color: colors.textInverse, opacity: 0.85, marginTop: 2 },
countdownIcon: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: colors.white,
alignItems: 'center',
justifyContent: 'center',
},
countdownProgress: { marginTop: 18 },
countdownProgressHead: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
countdownProgressLabel: { fontSize: 13, color: colors.textInverse, opacity: 0.9 },
countdownProgressValue: { fontSize: 13, fontWeight: '700', color: colors.textInverse },
countdownTrack: {
height: 8,
borderRadius: 4,
backgroundColor: colors.onBrandTrack,
overflow: 'hidden',
},
countdownFill: { height: 8, borderRadius: 4, backgroundColor: colors.white },
groupCard: {
backgroundColor: colors.white,
borderRadius: 12,
marginBottom: 10,
borderWidth: 1,
borderColor: colors.borderLight,
overflow: 'hidden',
},
groupHead: {
flexDirection: 'row',
alignItems: 'center',
padding: 14,
},
groupIcon: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
},
groupTitleWrap: { flex: 1, marginLeft: 12 },
groupTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
groupCount: { fontSize: 12, color: colors.textHint, marginTop: 2 },
groupBody: { paddingHorizontal: 14, paddingBottom: 12 },
itemRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 10,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
itemTitle: { flex: 1, fontSize: 14, color: colors.textPrimary, marginLeft: 10 },
itemTitleDone: { textDecorationLine: 'line-through', color: colors.textHint },
requiredTag: {
backgroundColor: colors.dangerLight,
borderRadius: 8,
paddingHorizontal: 6,
paddingVertical: 2,
},
requiredText: { fontSize: 10, color: colors.dangerText, fontWeight: '600' },
card: {
backgroundColor: colors.white,
borderRadius: 12,
padding: 16,
marginTop: 8,
borderWidth: 1,
borderColor: colors.borderLight,
},
cardTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 },
reminderRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
reminderText: { flex: 1 },
reminderTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
reminderDesc: { fontSize: 12, color: colors.textHint, marginTop: 2 },
tipRow: {
flexDirection: 'row',
paddingVertical: 10,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
tipBody: { flex: 1, marginLeft: 10 },
tipTitle: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
tipDesc: { fontSize: 12, color: colors.textSecondary, marginTop: 2, lineHeight: 18 },
footer: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
flexDirection: 'row',
paddingHorizontal: 16,
paddingVertical: 12,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
addBtn: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.white,
borderRadius: 24,
paddingVertical: 14,
borderWidth: 1,
borderColor: colors.border,
marginRight: 12,
},
addBtnText: { fontSize: 14, fontWeight: '700', color: colors.primary, marginLeft: 4 },
exportBtn: {
flex: 1,
backgroundColor: colors.primaryBtn,
borderRadius: 24,
paddingVertical: 14,
alignItems: 'center',
},
exportBtnText: { fontSize: 15, fontWeight: '700', color: colors.white },
footerBtnDisabled: { opacity: 0.5 },
modalMask: {
flex: 1,
backgroundColor: colors.scrim,
justifyContent: 'center',
paddingHorizontal: 32,
},
modalCard: { backgroundColor: colors.white, borderRadius: 16, padding: 20 },
modalTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
modalInput: {
marginTop: 14,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
color: colors.textPrimary,
},
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 12, marginTop: 18 },
modalCancel: { paddingHorizontal: 16, paddingVertical: 8 },
modalCancelText: { fontSize: 14, color: colors.textSecondary },
modalConfirm: {
backgroundColor: colors.primaryBtn,
borderRadius: 18,
paddingHorizontal: 20,
paddingVertical: 8,
},
modalConfirmText: { fontSize: 14, fontWeight: '700', color: colors.white },
});