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

321 lines
12 KiB

import { useLocalSearchParams, useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { PrimaryButton } from '../../components/onboarding/ui/PrimaryButton';
import { YearMonthWheel } from '../../components/onboarding/ui/WheelPicker';
import {
CenterState,
EditHeader,
Field,
FormNote,
IconAction,
PageFooter,
} from '../../components/resume/EditKit';
import { useResume } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { ResumeExperience } from '../../src/types';
import { splitYearMonth, yearMonthValue } from '../../src/utils';
/**
* 工作经历编辑页:一页两态(列表态 / 表单态)。
*
* 时间用 `YearMonthWheel`,存储格式与引导采集一致(`'2023年2月'`),
* 回填靠 `splitYearMonth`;解析不出(空串 /「至今」)时两个滚轮都停在未选中态,
* **不给兜底索引** —— 给索引会静默把用户的入职年月改成别的年份。
*/
export default function ResumeExperienceScreen() {
const router = useRouter();
const params = useLocalSearchParams<{ new?: string; id?: string }>();
const { resume, loading, error, refresh, saving, saveExperience } = useResume();
const [editing, setEditing] = useState<ExpForm | null>(null);
const [entryApplied, setEntryApplied] = useState(false);
useEffect(() => {
if (!resume || entryApplied) return;
setEntryApplied(true);
if (params.new === '1') {
setEditing(EMPTY_FORM);
return;
}
if (params.id !== undefined && params.id !== '') {
const found = resume.experience.find((it) => it.id === params.id);
if (found) setEditing(toForm(found));
}
}, [resume, entryApplied, params.new, params.id]);
const list = resume?.experience ?? [];
const header = (
<EditHeader
title={editing === null ? '工作经历' : editing.id === undefined ? '添加工作经历' : '编辑工作经历'}
onBack={() => (editing === null ? router.back() : setEditing(null))}
/>
);
if (resume === null) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
{error !== null ? (
<CenterState icon="alert-circle-outline" text="加载失败" actionLabel="点击重试" onAction={refresh} />
) : (
<CenterState text={loading ? '正在加载…' : '简历数据为空'} />
)}
</SafeAreaView>
);
}
/* ===================== 表单态 ===================== */
if (editing !== null) {
const company = editing.company.trim();
const startValue = yearMonthValue(editing.startYear, editing.startMonth);
const endValue = yearMonthValue(editing.endYear, editing.endMonth);
const startError = startValue === null ? '请选择开始时间' : '';
const endError = editing.untilNow ? '' : endValue === null ? '请选择结束时间' : '';
const orderError =
startValue !== null && endValue !== null && endValue < startValue ? '结束时间不能早于开始时间' : '';
const canSave = company !== '' && startError === '' && endError === '' && orderError === '';
const update = (patch: Partial<ExpForm>) => setEditing((prev) => ({ ...(prev ?? editing), ...patch }));
const handleSubmit = async () => {
if (!canSave || saving) return;
const fields = {
company,
position: editing.position.trim(),
startDate: `${editing.startYear}${editing.startMonth}`,
// 「至今」才写「至今」;否则存选中的年月。没选完的情况已被上面的校验拦住
endDate: editing.untilNow ? UNTIL_NOW : `${editing.endYear}${editing.endMonth}`,
description: editing.description.trim(),
// AI 改写未接入,用户手改的文字不该被标成「已优化」
aiOptimized: false,
};
const next =
editing.id === undefined
? [...list, fields]
: list.map((it) => (it.id === editing.id ? { ...fields, id: editing.id } : it));
await saveExperience(next);
setEditing(null);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
<ScrollView
contentContainerStyle={styles.body}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<Field
label="公司名称"
required
value={editing.company}
onChangeText={(v) => update({ company: v })}
placeholder="请输入公司名称"
maxLength={40}
autoFocus
/>
<Field
label="职位"
value={editing.position}
onChangeText={(v) => update({ position: v })}
placeholder="如:高级前端工程师"
maxLength={30}
/>
<View style={styles.section}>
<Text style={styles.label}>
开始时间<Text style={styles.requiredMark}> *</Text>
</Text>
<YearMonthWheel
year={editing.startYear}
month={editing.startMonth}
onYearChange={(v) => update({ startYear: v })}
onMonthChange={(v) => update({ startMonth: v })}
/>
{startError !== '' && <Text style={styles.fieldError}>{startError}</Text>}
</View>
<View style={styles.section}>
<View style={styles.switchRow}>
<Text style={styles.label}>至今</Text>
<Switch
value={editing.untilNow}
onValueChange={(v) =>
// 打开「至今」时清掉已选的结束时间:留着会让存进去的 endDate 与开关不一致
update(v ? { untilNow: true, endYear: '', endMonth: '' } : { untilNow: false })
}
trackColor={{ false: colors.border, true: colors.primaryBorder }}
thumbColor={editing.untilNow ? colors.primaryBtn : colors.white}
/>
</View>
{editing.untilNow ? (
<FormNote text="在职中:简历里结束时间显示为「至今」。" />
) : (
<>
<Text style={[styles.label, styles.labelTop]}>
结束时间<Text style={styles.requiredMark}> *</Text>
</Text>
<YearMonthWheel
year={editing.endYear}
month={editing.endMonth}
onYearChange={(v) => update({ endYear: v })}
onMonthChange={(v) => update({ endMonth: v })}
/>
{endError !== '' && <Text style={styles.fieldError}>{endError}</Text>}
</>
)}
</View>
<Field
label="工作内容"
value={editing.description}
onChangeText={(v) => update({ description: v })}
placeholder="负责了什么、做成了什么(AI 改写需后端接入)"
multiline
maxLength={500}
/>
{orderError !== '' && <Text style={styles.formError}>{orderError}</Text>}
<FormNote text="公司名称与起止时间为必填;职位与工作内容没填就不在简历里显示空行。" />
</ScrollView>
<PageFooter>
<PrimaryButton label={saving ? '保存中…' : '保存'} active={canSave} onPress={handleSubmit} loading={saving} />
</PageFooter>
</SafeAreaView>
);
}
/* ===================== 列表态 ===================== */
const handleDelete = (item: ResumeExperience) => {
Alert.alert('删除这条工作经历?', item.company, [
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: () => {
void saveExperience(list.filter((it) => it.id !== item.id));
},
},
]);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
{list.length === 0 ? (
<CenterState icon="briefcase-outline" text="还没有工作经历" />
) : (
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{list.map((exp) => {
const dates = [exp.startDate, exp.endDate].filter(Boolean).join(' - ');
return (
<View key={exp.id} style={styles.row}>
<View style={styles.rowBody}>
<Text style={styles.rowTitle}>{exp.company}</Text>
{exp.position !== '' && <Text style={styles.rowSub}>{exp.position}</Text>}
{dates !== '' && <Text style={styles.rowSub}>{dates}</Text>}
{exp.description !== '' && (
<Text style={styles.rowDesc} numberOfLines={2}>
{exp.description}
</Text>
)}
</View>
<IconAction
icon="create-outline"
label={`编辑 ${exp.company}`}
color={colors.primary}
onPress={() => setEditing(toForm(exp))}
/>
<IconAction
icon="trash-outline"
label={`删除 ${exp.company}`}
color={colors.danger}
onPress={() => handleDelete(exp)}
/>
</View>
);
})}
</ScrollView>
)}
<PageFooter>
<PrimaryButton label="添加工作经历" onPress={() => setEditing(EMPTY_FORM)} />
</PageFooter>
</SafeAreaView>
);
}
/* ===================== 表单与条目互转 ===================== */
interface ExpForm {
/** undefined = 新增项(由 API 层补 id) */
id?: string;
company: string;
position: string;
startYear: string;
startMonth: string;
untilNow: boolean;
endYear: string;
endMonth: string;
description: string;
}
/** 结束时间的「至今」哨兵值,与引导派生出来的格式一致 */
const UNTIL_NOW = '至今';
const EMPTY_FORM: ExpForm = {
company: '',
position: '',
startYear: '',
startMonth: '',
// 默认按「在职」预置不成立:新的一条究竟是哪一段经历只有用户知道,默认 false 由他显式选
untilNow: false,
endYear: '',
endMonth: '',
description: '',
};
const toForm = (exp: ResumeExperience): ExpForm => {
const start = splitYearMonth(exp.startDate);
const untilNow = exp.endDate === UNTIL_NOW;
const end = splitYearMonth(untilNow ? '' : exp.endDate);
return {
id: exp.id,
company: exp.company,
position: exp.position,
startYear: start.year,
startMonth: start.month,
untilNow,
endYear: end.year,
endMonth: end.month,
description: exp.description,
};
};
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.pageBg },
body: { padding: 8, paddingBottom: 24 },
section: { marginBottom: 18 },
label: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
labelTop: { marginTop: 14, marginBottom: 10 },
requiredMark: { color: colors.danger },
fieldError: { fontSize: 12, color: colors.dangerText, marginTop: 6 },
formError: { fontSize: 13, color: colors.dangerText, marginBottom: 8 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
row: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: 8,
padding: 8,
marginBottom: 8,
borderWidth: 1,
borderColor: colors.borderLight,
},
rowBody: { flex: 1, gap: 4 },
rowTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
rowSub: { fontSize: 13, color: colors.textSecondary },
rowDesc: { fontSize: 13, color: colors.textHint, lineHeight: 18 },
});