Browse Source
一键登录(P2Q)取不到手机号的根因不在取号:页面读的是 data.phone,而该字段 只有短信通道才写入,且 loginByCarrier 返回的 phone 被直接丢弃,于是号码位恒为 占位「本机号码」。现改为进页面即预取(useCarrierPhone + fetchCarrierPhone), 失败时置灰按钮并给出「重新获取 / 改用验证码登录」,成功后把号码写回引导资料 (P2P/P2R 预填与 B3 联系人电话同源)。真实取号是运营商原生 SDK,Expo Go 跑不了, 接入路线图见新增文档《运营商一键登录接入方案.md》。 引导页重做四屏内容与配图,配图位走新增组件 ImageSlot:素材未到位时在页面上标注 「配图待补」+ 该放什么图,补图只改 assets.ts 的 source 一行;设置页补「查看引导页」 入口(此前只有首次启动能进,看过一次就再也进不去),并区分「首次启动」与 「已登录回顾」两种出口,避免把已登录用户甩回注册流程。 企业沟通 / 人才推荐两页的 Tab 与搜索真正生效:Tab 计数与待办数量改由列表推导 (原先写死的数字与示例数据对不上,会出现「Tab 写 3、列表 0 条」),无对应页面的 漏斗图标摘除,筛选复位不再只是弹提示;申诉举证接真实选图,候选人详情「发起沟通」 跳会话页,面试评分支持本地草稿与恢复,入职清单勾选落库并可增删自定义待办、导出文本, 薪资计算器「更多工具」改指真实路由(原先三项都指回自己),技能词云接搜索与学习路径生成。 测试新增 3 个文件 51 例(共 219 例全绿),存储 mock 由「恒返回 null」改为带状态 Map, 使「存了要能读回来」这类行为真正被验证;tsc / eslint / 三个审计脚本(0 失效跳转、 0 死控件、137 个接口预留)均 0 问题。 另:package-lock.json(本项目统一用 yarn.lock)与本地调试截图加入 .gitignore。master
119 changed files with 10568 additions and 1046 deletions
@ -0,0 +1,136 @@ |
|||
import { useRouter } from 'expo-router'; |
|||
import { useEffect, useState } from 'react'; |
|||
import { ScrollView, StyleSheet, Text } from 'react-native'; |
|||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|||
import { PrimaryButton } from '../../components/onboarding/ui/PrimaryButton'; |
|||
import { CenterState, EditHeader, Field, FormNote, PageFooter } from '../../components/resume/EditKit'; |
|||
import { useResume } from '../../src/hooks'; |
|||
import { colors } from '../../src/theme'; |
|||
import { isValidEmail, isValidPhone } from '../../src/utils'; |
|||
|
|||
/** |
|||
* 基本信息编辑页。 |
|||
* |
|||
* 只收简历正文里那四项:姓名 / 手机号 / 邮箱 / 所在城市。 |
|||
* - 期望城市不在这里(属求职意向,归 `/job-preference`,同一份数据不能有两个入口) |
|||
* - 头像不在这里(由 API 层按姓名派生首字,页面从不传头像) |
|||
* - 手机号与邮箱是**填了才校验**:引导流程未采集这两项,空值是「未采集」不是「填错了」 |
|||
*/ |
|||
export default function ResumeBasicScreen() { |
|||
const router = useRouter(); |
|||
const { resume, loading, error, refresh, saving, saveBasicInfo } = useResume(); |
|||
|
|||
/** 表单只在首次取到数据时初始化一次,此后由用户输入驱动(否则每次 refresh 都会把手输的值冲掉) */ |
|||
const [form, setForm] = useState<BasicForm | null>(null); |
|||
const [formError, setFormError] = useState(''); |
|||
|
|||
useEffect(() => { |
|||
if (resume && !form) { |
|||
setForm({ |
|||
name: resume.basicInfo.name, |
|||
phone: resume.basicInfo.phone, |
|||
email: resume.basicInfo.email, |
|||
location: resume.basicInfo.location, |
|||
}); |
|||
} |
|||
}, [resume, form]); |
|||
|
|||
const header = <EditHeader title="编辑基本信息" onBack={() => router.back()} />; |
|||
|
|||
if (form === null) { |
|||
return ( |
|||
<SafeAreaView style={styles.container} edges={['top']}> |
|||
{header} |
|||
{error !== null ? ( |
|||
<CenterState icon="alert-circle-outline" text="加载失败" actionLabel="点击重试" onAction={refresh} /> |
|||
) : ( |
|||
<CenterState text={loading ? '正在加载…' : '简历数据为空'} /> |
|||
)} |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
const phone = form.phone.trim(); |
|||
const email = form.email.trim(); |
|||
const phoneError = phone !== '' && !isValidPhone(phone) ? '请输入 11 位手机号' : ''; |
|||
const emailError = email !== '' && !isValidEmail(email) ? '请输入正确的邮箱地址' : ''; |
|||
const canSave = phoneError === '' && emailError === ''; |
|||
|
|||
const update = (field: keyof BasicForm, text: string) => |
|||
setForm((prev) => ({ ...(prev ?? form), [field]: text })); |
|||
|
|||
const handleSave = async () => { |
|||
if (!canSave || saving) return; |
|||
setFormError(''); |
|||
try { |
|||
await saveBasicInfo({ name: form.name, phone, email, location: form.location }); |
|||
router.back(); |
|||
} catch { |
|||
// 落库失败就留在本页如实说,不装作已经保存(后端接入后换成真实错误文案)
|
|||
setFormError('保存失败,请稍后重试'); |
|||
} |
|||
}; |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.container} edges={['top']}> |
|||
{header} |
|||
<ScrollView |
|||
contentContainerStyle={styles.body} |
|||
showsVerticalScrollIndicator={false} |
|||
keyboardShouldPersistTaps="handled" |
|||
> |
|||
<Field |
|||
label="姓名" |
|||
value={form.name} |
|||
onChangeText={(v) => update('name', v)} |
|||
placeholder="请输入姓名" |
|||
maxLength={20} |
|||
/> |
|||
<Field |
|||
label="手机号" |
|||
value={form.phone} |
|||
onChangeText={(v) => update('phone', v)} |
|||
placeholder="请输入手机号" |
|||
keyboardType="phone-pad" |
|||
maxLength={11} |
|||
error={phoneError} |
|||
/> |
|||
<Field |
|||
label="邮箱" |
|||
value={form.email} |
|||
onChangeText={(v) => update('email', v)} |
|||
placeholder="请输入邮箱" |
|||
keyboardType="email-address" |
|||
error={emailError} |
|||
/> |
|||
<Field |
|||
label="所在城市" |
|||
value={form.location} |
|||
onChangeText={(v) => update('location', v)} |
|||
placeholder="如:北京 · 朝阳区" |
|||
maxLength={30} |
|||
/> |
|||
|
|||
{formError !== '' && <Text style={styles.formError}>{formError}</Text>} |
|||
<FormNote text="手机号与邮箱引导流程未采集,需要在这里补全;简历里没填的项不显示空行。" /> |
|||
</ScrollView> |
|||
|
|||
<PageFooter> |
|||
<PrimaryButton label={saving ? '保存中…' : '保存'} active={canSave} onPress={handleSave} loading={saving} /> |
|||
</PageFooter> |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
interface BasicForm { |
|||
name: string; |
|||
phone: string; |
|||
email: string; |
|||
location: string; |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
container: { flex: 1, backgroundColor: colors.pageBg }, |
|||
body: { padding: 20, paddingBottom: 24 }, |
|||
formError: { fontSize: 13, color: colors.dangerText, marginBottom: 8 }, |
|||
}); |
|||
@ -0,0 +1,272 @@ |
|||
import { useLocalSearchParams, useRouter } from 'expo-router'; |
|||
import { useEffect, useState } from 'react'; |
|||
import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native'; |
|||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|||
import { PrimaryButton } from '../../components/onboarding/ui/PrimaryButton'; |
|||
import { |
|||
CenterState, |
|||
EditHeader, |
|||
Field, |
|||
FormNote, |
|||
IconAction, |
|||
PageFooter, |
|||
} from '../../components/resume/EditKit'; |
|||
import { useResume } from '../../src/hooks'; |
|||
import { colors } from '../../src/theme'; |
|||
import type { ResumeEducation } from '../../src/types'; |
|||
|
|||
/** |
|||
* 教育背景编辑页:一页两态(列表态 / 表单态)。 |
|||
* |
|||
* 入口三种:首页「添加教育经历」带 `?new=1` 直接进表单、条目「编辑」带 `?id=` 进表单、 |
|||
* 首页「编辑 >」不带参数进列表。删除与编辑都在本页完成并**即时落盘**, |
|||
* 不存在「改了没保存就走」的困惑。 |
|||
*/ |
|||
export default function ResumeEducationScreen() { |
|||
const router = useRouter(); |
|||
const params = useLocalSearchParams<{ new?: string; id?: string }>(); |
|||
const { resume, loading, error, refresh, saving, saveEducation } = useResume(); |
|||
|
|||
/** 非空 = 表单态;null = 列表态 */ |
|||
const [editing, setEditing] = useState<EduForm | 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.education.find((it) => it.id === params.id); |
|||
// 找不到就留在列表态:编一条空表单让用户改,等于凭空多出一条教育经历
|
|||
if (found) setEditing(toForm(found)); |
|||
} |
|||
}, [resume, entryApplied, params.new, params.id]); |
|||
|
|||
const list = resume?.education ?? []; |
|||
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 school = editing.school.trim(); |
|||
const startError = editing.startYear !== '' && !YEAR_RE.test(editing.startYear) ? '请输入 4 位年份' : ''; |
|||
const endError = editing.endYear !== '' && !YEAR_RE.test(editing.endYear) ? '请输入 4 位年份' : ''; |
|||
const orderError = |
|||
startError === '' && endError === '' && editing.startYear !== '' && editing.endYear !== '' && editing.startYear > editing.endYear |
|||
? '毕业年份不能早于入学年份' |
|||
: ''; |
|||
const canSave = school !== '' && startError === '' && endError === '' && orderError === ''; |
|||
|
|||
const update = (field: keyof EduForm, text: string) => |
|||
setEditing((prev) => ({ ...(prev ?? editing), [field]: text })); |
|||
|
|||
const handleSubmit = async () => { |
|||
if (!canSave || saving) return; |
|||
const fields = { |
|||
school, |
|||
major: editing.major.trim(), |
|||
degree: editing.degree.trim(), |
|||
startYear: editing.startYear, |
|||
endYear: editing.endYear, |
|||
}; |
|||
// 交回整表。新增项**不带 id 字段**(而不是给空串):API 层用 `?? ` 判缺省,
|
|||
// 空串不是 nullish,会把 id 写成空的
|
|||
const next = |
|||
editing.id === undefined |
|||
? [...list, fields] |
|||
: list.map((it) => (it.id === editing.id ? { ...fields, id: editing.id } : it)); |
|||
await saveEducation(next); |
|||
// 保存后回到列表态,让用户看见结果(不 router.back(),否则看不到自己刚加的那条)
|
|||
setEditing(null); |
|||
}; |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.container} edges={['top']}> |
|||
{header} |
|||
<ScrollView |
|||
contentContainerStyle={styles.body} |
|||
showsVerticalScrollIndicator={false} |
|||
keyboardShouldPersistTaps="handled" |
|||
> |
|||
<Field |
|||
label="学校" |
|||
required |
|||
value={editing.school} |
|||
onChangeText={(v) => update('school', v)} |
|||
placeholder="请输入学校名称" |
|||
maxLength={30} |
|||
autoFocus |
|||
/> |
|||
<Field |
|||
label="专业" |
|||
value={editing.major} |
|||
onChangeText={(v) => update('major', v)} |
|||
placeholder="如:计算机科学与技术" |
|||
maxLength={30} |
|||
/> |
|||
<Field |
|||
label="学历" |
|||
value={editing.degree} |
|||
onChangeText={(v) => update('degree', v)} |
|||
placeholder="如:本科 / 硕士 非全日制" |
|||
maxLength={20} |
|||
hint="引导流程采集到的学历会带学习形式(如「硕士 非全日制」),这里可以照原样填" |
|||
/> |
|||
<View style={styles.yearRow}> |
|||
<View style={styles.yearItem}> |
|||
<Field |
|||
label="入学年份" |
|||
value={editing.startYear} |
|||
onChangeText={(v) => update('startYear', v.replace(/[^\d]/g, ''))} |
|||
placeholder="如:2018" |
|||
keyboardType="number-pad" |
|||
maxLength={4} |
|||
error={startError} |
|||
/> |
|||
</View> |
|||
<View style={styles.yearItem}> |
|||
<Field |
|||
label="毕业年份" |
|||
value={editing.endYear} |
|||
onChangeText={(v) => update('endYear', v.replace(/[^\d]/g, ''))} |
|||
placeholder="如:2022" |
|||
keyboardType="number-pad" |
|||
maxLength={4} |
|||
error={endError} |
|||
/> |
|||
</View> |
|||
</View> |
|||
{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: ResumeEducation) => { |
|||
Alert.alert('删除这条教育经历?', item.school, [ |
|||
{ text: '取消', style: 'cancel' }, |
|||
{ |
|||
text: '删除', |
|||
style: 'destructive', |
|||
// 即时落盘:删掉就走也不用担心没保存
|
|||
onPress: () => { |
|||
void saveEducation(list.filter((it) => it.id !== item.id)); |
|||
}, |
|||
}, |
|||
]); |
|||
}; |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.container} edges={['top']}> |
|||
{header} |
|||
{list.length === 0 ? ( |
|||
<CenterState icon="school-outline" text="还没有教育经历" /> |
|||
) : ( |
|||
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> |
|||
{list.map((edu) => { |
|||
// 专业 / 学历 / 年份可能为空(引导只采集学校和学历),拼出来会剩「· 本科」这种半截值
|
|||
const majorDegree = [edu.major, edu.degree].filter(Boolean).join(' · '); |
|||
const years = [edu.startYear, edu.endYear].filter(Boolean).join('-'); |
|||
return ( |
|||
<View key={edu.id} style={styles.row}> |
|||
<View style={styles.rowBody}> |
|||
<Text style={styles.rowTitle}>{edu.school}</Text> |
|||
{majorDegree !== '' && <Text style={styles.rowSub}>{majorDegree}</Text>} |
|||
{years !== '' && <Text style={styles.rowSub}>{years}</Text>} |
|||
</View> |
|||
<IconAction |
|||
icon="create-outline" |
|||
label={`编辑 ${edu.school}`} |
|||
color={colors.primary} |
|||
onPress={() => setEditing(toForm(edu))} |
|||
/> |
|||
<IconAction |
|||
icon="trash-outline" |
|||
label={`删除 ${edu.school}`} |
|||
color={colors.danger} |
|||
onPress={() => handleDelete(edu)} |
|||
/> |
|||
</View> |
|||
); |
|||
})} |
|||
</ScrollView> |
|||
)} |
|||
<PageFooter> |
|||
<PrimaryButton label="添加教育经历" onPress={() => setEditing(EMPTY_FORM)} /> |
|||
</PageFooter> |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
/* ===================== 表单与条目互转 ===================== */ |
|||
|
|||
interface EduForm { |
|||
/** undefined = 新增项(由 API 层补 id) */ |
|||
id?: string; |
|||
school: string; |
|||
major: string; |
|||
degree: string; |
|||
startYear: string; |
|||
endYear: string; |
|||
} |
|||
|
|||
const EMPTY_FORM: EduForm = { school: '', major: '', degree: '', startYear: '', endYear: '' }; |
|||
|
|||
const YEAR_RE = /^\d{4}$/; |
|||
|
|||
const toForm = (edu: ResumeEducation): EduForm => ({ |
|||
id: edu.id, |
|||
school: edu.school, |
|||
major: edu.major, |
|||
degree: edu.degree, |
|||
startYear: edu.startYear, |
|||
endYear: edu.endYear, |
|||
}); |
|||
|
|||
const styles = StyleSheet.create({ |
|||
container: { flex: 1, backgroundColor: colors.pageBg }, |
|||
body: { padding: 20, paddingBottom: 24 }, |
|||
row: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
backgroundColor: colors.white, |
|||
borderRadius: 14, |
|||
paddingVertical: 14, |
|||
paddingLeft: 16, |
|||
paddingRight: 8, |
|||
marginBottom: 10, |
|||
borderWidth: 1, |
|||
borderColor: colors.borderLight, |
|||
}, |
|||
rowBody: { flex: 1, gap: 4 }, |
|||
rowTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, |
|||
rowSub: { fontSize: 13, color: colors.textSecondary }, |
|||
yearRow: { flexDirection: 'row', gap: 12 }, |
|||
yearItem: { flex: 1 }, |
|||
formError: { fontSize: 13, color: colors.dangerText, marginBottom: 8 }, |
|||
}); |
|||
@ -0,0 +1,323 @@ |
|||
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: 20, 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: 14, |
|||
paddingVertical: 14, |
|||
paddingLeft: 16, |
|||
paddingRight: 8, |
|||
marginBottom: 10, |
|||
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 }, |
|||
}); |
|||
@ -0,0 +1,240 @@ |
|||
import { useRouter } from 'expo-router'; |
|||
import { ScrollView, StyleSheet, Text, View } from 'react-native'; |
|||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|||
import { PrimaryButton } from '../../components/onboarding/ui/PrimaryButton'; |
|||
import { CenterState, EditHeader, PageFooter } from '../../components/resume/EditKit'; |
|||
import { useResume } from '../../src/hooks'; |
|||
import { UNSET_LABEL } from '../../src/constants'; |
|||
import { colors } from '../../src/theme'; |
|||
import type { Resume, ResumeEducation, ResumeExperience, ResumeProject } from '../../src/types'; |
|||
|
|||
/** |
|||
* 简历预览页(只读)。 |
|||
* |
|||
* 只渲染简历**正文**:不含评分卡与优化建议 —— 那些是 App 内的辅助信息,不会出现在 |
|||
* 导出的文件里,混在一起会让人以为导出物里也有。 |
|||
* |
|||
* 空板块**保留标题 + 「未填写 X」**:预览的价值正是让用户看见自己缺什么, |
|||
* 整段隐去反而看不出漏了哪一块。 |
|||
*/ |
|||
export default function ResumePreviewScreen() { |
|||
const router = useRouter(); |
|||
const { resume, loading, error, refresh } = useResume(); |
|||
|
|||
const header = <EditHeader title="简历预览" onBack={() => router.back()} />; |
|||
|
|||
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> |
|||
); |
|||
} |
|||
|
|||
const { basicInfo, jobIntention, education, experience, projects, skills, selfEvaluation } = resume; |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.container} edges={['top']}> |
|||
{header} |
|||
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> |
|||
{/* 纸张:白底 + 描边,与导出效果同一套排版 */} |
|||
<View style={styles.paper}> |
|||
<View style={styles.paperHead}> |
|||
<View style={styles.avatar}> |
|||
{/* TODO(素材):接入后端后这里渲染用户上传的头像图片; |
|||
当前以姓名首字兜底,无姓名时不占位 */} |
|||
<Text style={styles.avatarText}>{basicInfo.avatar !== '' ? basicInfo.avatar : '简'}</Text> |
|||
</View> |
|||
<View style={styles.headBody}> |
|||
<Text style={styles.name}>{basicInfo.name !== '' ? basicInfo.name : UNSET_LABEL}</Text> |
|||
{/* 有值才渲染:空白行比不渲染更让人困惑 */} |
|||
{[basicInfo.phone, basicInfo.email, basicInfo.location].filter((v) => v !== '').length > 0 && ( |
|||
<Text style={styles.contact}> |
|||
{[basicInfo.phone, basicInfo.email, basicInfo.location].filter((v) => v !== '').join(' · ')} |
|||
</Text> |
|||
)} |
|||
</View> |
|||
</View> |
|||
|
|||
<Section title="求职意向"> |
|||
{hasIntention(jobIntention) ? ( |
|||
<Text style={styles.paragraph}> |
|||
{[ |
|||
jobIntention.position, |
|||
jobIntention.city, |
|||
jobIntention.salary, |
|||
jobIntention.statusLabel, |
|||
] |
|||
.filter((v) => v !== '') |
|||
.join(' · ')} |
|||
</Text> |
|||
) : ( |
|||
<Empty text="未填写求职意向" /> |
|||
)} |
|||
</Section> |
|||
|
|||
<Section title="教育背景"> |
|||
{education.length === 0 ? ( |
|||
<Empty text="未填写教育背景" /> |
|||
) : ( |
|||
education.map((edu) => <EducationItem key={edu.id} edu={edu} />) |
|||
)} |
|||
</Section> |
|||
|
|||
<Section title="工作经历"> |
|||
{experience.length === 0 ? ( |
|||
<Empty text="未填写工作经历" /> |
|||
) : ( |
|||
experience.map((exp) => <ExperienceItem key={exp.id} exp={exp} />) |
|||
)} |
|||
</Section> |
|||
|
|||
<Section title="项目经历"> |
|||
{projects.length === 0 ? ( |
|||
<Empty text="未填写项目经历" /> |
|||
) : ( |
|||
projects.map((proj) => <ProjectItem key={proj.id} proj={proj} />) |
|||
)} |
|||
</Section> |
|||
|
|||
<Section title="技能特长"> |
|||
{skills.length === 0 ? ( |
|||
<Empty text="未填写技能特长" /> |
|||
) : ( |
|||
<Text style={styles.paragraph}>{skills.map((s) => s.name).join(' · ')}</Text> |
|||
)} |
|||
</Section> |
|||
|
|||
<Section title="自我评价"> |
|||
{selfEvaluation.content === '' ? ( |
|||
<Empty text="未填写自我评价" /> |
|||
) : ( |
|||
<Text style={styles.paragraph}>{selfEvaluation.content}</Text> |
|||
)} |
|||
</Section> |
|||
</View> |
|||
|
|||
<Text style={styles.tip}> |
|||
预览为简历正文效果,不含评分与优化建议。模板样式、纸张大小需后端导出服务支持,当前按默认版式呈现。 |
|||
</Text> |
|||
</ScrollView> |
|||
|
|||
<PageFooter> |
|||
<PrimaryButton label="去导出设置" onPress={() => router.push('/resume/export')} /> |
|||
</PageFooter> |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
/* ===================== 子组件 ===================== */ |
|||
|
|||
function Section({ title, children }: { title: string; children: React.ReactNode }) { |
|||
return ( |
|||
<View style={styles.section}> |
|||
<Text style={styles.sectionTitle}>{title}</Text> |
|||
{children} |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
function Empty({ text }: { text: string }) { |
|||
return <Text style={styles.emptyText}>{text}</Text>; |
|||
} |
|||
|
|||
function EducationItem({ edu }: { edu: ResumeEducation }) { |
|||
// 专业 / 学历 / 年份可能为空,拼出来会剩半截值
|
|||
const majorDegree = [edu.major, edu.degree].filter(Boolean).join(' · '); |
|||
const years = [edu.startYear, edu.endYear].filter(Boolean).join('-'); |
|||
return ( |
|||
<View style={styles.item}> |
|||
<View style={styles.itemHead}> |
|||
<Text style={styles.itemTitle}>{edu.school}</Text> |
|||
{years !== '' && <Text style={styles.itemMeta}>{years}</Text>} |
|||
</View> |
|||
{majorDegree !== '' && <Text style={styles.itemSub}>{majorDegree}</Text>} |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
function ExperienceItem({ exp }: { exp: ResumeExperience }) { |
|||
const dates = [exp.startDate, exp.endDate].filter(Boolean).join(' - '); |
|||
return ( |
|||
<View style={styles.item}> |
|||
<View style={styles.itemHead}> |
|||
<Text style={styles.itemTitle}>{exp.company}</Text> |
|||
{dates !== '' && <Text style={styles.itemMeta}>{dates}</Text>} |
|||
</View> |
|||
{exp.position !== '' && <Text style={styles.itemSub}>{exp.position}</Text>} |
|||
{exp.description !== '' && <Text style={styles.paragraph}>{exp.description}</Text>} |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
function ProjectItem({ proj }: { proj: ResumeProject }) { |
|||
return ( |
|||
<View style={styles.item}> |
|||
<View style={styles.itemHead}> |
|||
<Text style={styles.itemTitle}>{proj.name}</Text> |
|||
{proj.role !== '' && <Text style={styles.itemMeta}>{proj.role}</Text>} |
|||
</View> |
|||
{proj.description !== '' && <Text style={styles.paragraph}>{proj.description}</Text>} |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
/** 求职意向四项全空才算未填写(引导只采集到其中一两项时也算有内容) */ |
|||
function hasIntention(intention: Resume['jobIntention']): boolean { |
|||
return ( |
|||
[intention.position, intention.city, intention.salary, intention.statusLabel].filter( |
|||
(v) => v !== '', |
|||
).length > 0 |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
container: { flex: 1, backgroundColor: colors.pageBg }, |
|||
body: { padding: 16, paddingBottom: 24 }, |
|||
paper: { |
|||
backgroundColor: colors.white, |
|||
borderRadius: 8, |
|||
padding: 20, |
|||
borderWidth: 1, |
|||
borderColor: colors.borderLight, |
|||
}, |
|||
paperHead: { flexDirection: 'row', alignItems: 'center', marginBottom: 20 }, |
|||
avatar: { |
|||
width: 52, |
|||
height: 52, |
|||
borderRadius: 26, |
|||
backgroundColor: colors.primary, |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
}, |
|||
avatarText: { fontSize: 22, fontWeight: '700', color: colors.white }, |
|||
headBody: { marginLeft: 14, flex: 1 }, |
|||
name: { fontSize: 20, fontWeight: '800', color: colors.textPrimary }, |
|||
contact: { fontSize: 13, color: colors.textSecondary, marginTop: 6 }, |
|||
section: { marginBottom: 18 }, |
|||
sectionTitle: { |
|||
fontSize: 14, |
|||
fontWeight: '700', |
|||
color: colors.textPrimary, |
|||
borderLeftWidth: 3, |
|||
borderLeftColor: colors.primary, |
|||
paddingLeft: 8, |
|||
marginBottom: 10, |
|||
}, |
|||
item: { marginBottom: 12 }, |
|||
itemHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, |
|||
itemTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, flex: 1 }, |
|||
itemMeta: { fontSize: 12, color: colors.textHint }, |
|||
itemSub: { fontSize: 13, color: colors.textSecondary, marginTop: 4 }, |
|||
paragraph: { fontSize: 13, color: colors.textSecondary, lineHeight: 21, marginTop: 6 }, |
|||
emptyText: { fontSize: 13, color: colors.textHint }, |
|||
tip: { fontSize: 12, color: colors.textHint, lineHeight: 18, marginTop: 4 }, |
|||
}); |
|||
@ -0,0 +1,225 @@ |
|||
import { useLocalSearchParams, useRouter } from 'expo-router'; |
|||
import { useEffect, useState } from 'react'; |
|||
import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native'; |
|||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|||
import { PrimaryButton } from '../../components/onboarding/ui/PrimaryButton'; |
|||
import { |
|||
CenterState, |
|||
EditHeader, |
|||
Field, |
|||
FormNote, |
|||
IconAction, |
|||
PageFooter, |
|||
} from '../../components/resume/EditKit'; |
|||
import { useResume } from '../../src/hooks'; |
|||
import { colors } from '../../src/theme'; |
|||
import type { ResumeProject } from '../../src/types'; |
|||
|
|||
/** |
|||
* 项目经历编辑页:一页两态(列表态 / 表单态)。 |
|||
* |
|||
* 本页不提供「AI 优化」按钮:项目经历在首页只有「已量化」徽标, |
|||
* 徽标的置位要等后端真能改写正文,不能让用户点出一个立刻显示「已优化」的假状态。 |
|||
*/ |
|||
export default function ResumeProjectScreen() { |
|||
const router = useRouter(); |
|||
const params = useLocalSearchParams<{ new?: string; id?: string }>(); |
|||
const { resume, loading, error, refresh, saving, saveProjects } = useResume(); |
|||
|
|||
const [editing, setEditing] = useState<ProjForm | 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.projects.find((it) => it.id === params.id); |
|||
if (found) setEditing(toForm(found)); |
|||
} |
|||
}, [resume, entryApplied, params.new, params.id]); |
|||
|
|||
const list = resume?.projects ?? []; |
|||
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 name = editing.name.trim(); |
|||
const canSave = name !== ''; |
|||
|
|||
const update = (patch: Partial<ProjForm>) => setEditing((prev) => ({ ...(prev ?? editing), ...patch })); |
|||
|
|||
const handleSubmit = async () => { |
|||
if (!canSave || saving) return; |
|||
const fields = { |
|||
name, |
|||
role: editing.role.trim(), |
|||
description: editing.description.trim(), |
|||
aiOptimized: false, |
|||
}; |
|||
const next = |
|||
editing.id === undefined |
|||
? [...list, fields] |
|||
: list.map((it) => (it.id === editing.id ? { ...fields, id: editing.id } : it)); |
|||
await saveProjects(next); |
|||
setEditing(null); |
|||
}; |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.container} edges={['top']}> |
|||
{header} |
|||
<ScrollView |
|||
contentContainerStyle={styles.body} |
|||
showsVerticalScrollIndicator={false} |
|||
keyboardShouldPersistTaps="handled" |
|||
> |
|||
<Field |
|||
label="项目名称" |
|||
required |
|||
value={editing.name} |
|||
onChangeText={(v) => update({ name: v })} |
|||
placeholder="请输入项目名称" |
|||
maxLength={40} |
|||
autoFocus |
|||
/> |
|||
<Field |
|||
label="担任角色" |
|||
value={editing.role} |
|||
onChangeText={(v) => update({ role: v })} |
|||
placeholder="如:前端负责人" |
|||
maxLength={20} |
|||
/> |
|||
<Field |
|||
label="项目描述" |
|||
value={editing.description} |
|||
onChangeText={(v) => update({ description: v })} |
|||
placeholder="做了什么、结果如何(有数字更好,如:首屏从 3.5s 降至 1.2s)" |
|||
multiline |
|||
maxLength={500} |
|||
/> |
|||
<FormNote text="项目名称为必填;角色与描述没填就不在简历里显示空行。" /> |
|||
</ScrollView> |
|||
<PageFooter> |
|||
<PrimaryButton label={saving ? '保存中…' : '保存'} active={canSave} onPress={handleSubmit} loading={saving} /> |
|||
</PageFooter> |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
/* ===================== 列表态 ===================== */ |
|||
|
|||
const handleDelete = (item: ResumeProject) => { |
|||
Alert.alert('删除这条项目经历?', item.name, [ |
|||
{ text: '取消', style: 'cancel' }, |
|||
{ |
|||
text: '删除', |
|||
style: 'destructive', |
|||
onPress: () => { |
|||
void saveProjects(list.filter((it) => it.id !== item.id)); |
|||
}, |
|||
}, |
|||
]); |
|||
}; |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.container} edges={['top']}> |
|||
{header} |
|||
{list.length === 0 ? ( |
|||
<CenterState icon="albums-outline" text="还没有项目经历" /> |
|||
) : ( |
|||
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> |
|||
{list.map((proj) => ( |
|||
<View key={proj.id} style={styles.row}> |
|||
<View style={styles.rowBody}> |
|||
<Text style={styles.rowTitle}>{proj.name}</Text> |
|||
{proj.role !== '' && <Text style={styles.rowSub}>{proj.role}</Text>} |
|||
{proj.description !== '' && ( |
|||
<Text style={styles.rowDesc} numberOfLines={2}> |
|||
{proj.description} |
|||
</Text> |
|||
)} |
|||
</View> |
|||
<IconAction |
|||
icon="create-outline" |
|||
label={`编辑 ${proj.name}`} |
|||
color={colors.primary} |
|||
onPress={() => setEditing(toForm(proj))} |
|||
/> |
|||
<IconAction |
|||
icon="trash-outline" |
|||
label={`删除 ${proj.name}`} |
|||
color={colors.danger} |
|||
onPress={() => handleDelete(proj)} |
|||
/> |
|||
</View> |
|||
))} |
|||
</ScrollView> |
|||
)} |
|||
<PageFooter> |
|||
<PrimaryButton label="添加项目经历" onPress={() => setEditing(EMPTY_FORM)} /> |
|||
</PageFooter> |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
/* ===================== 表单与条目互转 ===================== */ |
|||
|
|||
interface ProjForm { |
|||
/** undefined = 新增项(由 API 层补 id) */ |
|||
id?: string; |
|||
name: string; |
|||
role: string; |
|||
description: string; |
|||
} |
|||
|
|||
const EMPTY_FORM: ProjForm = { name: '', role: '', description: '' }; |
|||
|
|||
const toForm = (proj: ResumeProject): ProjForm => ({ |
|||
id: proj.id, |
|||
name: proj.name, |
|||
role: proj.role, |
|||
description: proj.description, |
|||
}); |
|||
|
|||
const styles = StyleSheet.create({ |
|||
container: { flex: 1, backgroundColor: colors.pageBg }, |
|||
body: { padding: 20, paddingBottom: 24 }, |
|||
row: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
backgroundColor: colors.white, |
|||
borderRadius: 14, |
|||
paddingVertical: 14, |
|||
paddingLeft: 16, |
|||
paddingRight: 8, |
|||
marginBottom: 10, |
|||
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 }, |
|||
}); |
|||
@ -1,181 +0,0 @@ |
|||
import { useEffect, useState } from 'react'; |
|||
import { Ionicons } from '@expo/vector-icons'; |
|||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; |
|||
import { SafeAreaView } from 'react-native-safe-area-context'; |
|||
import { StatusBar } from 'expo-status-bar'; |
|||
import { colors } from '../../../src/theme'; |
|||
import { Illustration } from '../ui/Illustration'; |
|||
import { Modal } from '../ui/Modal'; |
|||
|
|||
type Step = 'guide' | 'location' | 'notify' | null; |
|||
|
|||
const JOBS = [ |
|||
{ company: '巴适伙伴', pos: '前端开发工程师', salary: '12-18K', city: '上海' }, |
|||
{ company: '星河科技', pos: '鸿蒙开发工程师', salary: '15-22K', city: '上海' }, |
|||
{ company: '云图网络', pos: '全栈工程师', salary: '13-20K', city: '杭州' }, |
|||
{ company: '启明软件', pos: '数据分析师', salary: '10-16K', city: '深圳' }, |
|||
]; |
|||
|
|||
const TABS = [ |
|||
{ key: 'home', icon: 'home', label: '首页' }, |
|||
{ key: 'msg', icon: 'chatbubble', label: '消息' }, |
|||
{ key: 'me', icon: 'person', label: '我的' }, |
|||
] as const; |
|||
|
|||
export function P23Home() { |
|||
const [tab, setTab] = useState<(typeof TABS)[number]['key']>('home'); |
|||
const [step, setStep] = useState<Step>('guide'); |
|||
|
|||
useEffect(() => { |
|||
setStep('guide'); |
|||
}, []); |
|||
|
|||
return ( |
|||
<SafeAreaView style={styles.screen} edges={['top']}> |
|||
<StatusBar style="dark" /> |
|||
|
|||
<View style={styles.topBar}> |
|||
<Text style={styles.topTitle}>推荐职位</Text> |
|||
<Ionicons name="location" size={16} color={colors.primary} /> |
|||
<Text style={styles.topCity}>上海</Text> |
|||
</View> |
|||
|
|||
<ScrollView style={styles.list} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false}> |
|||
{JOBS.map((j) => ( |
|||
<View key={j.company} style={styles.card}> |
|||
<Illustration name="logo" size={44} radius={10} /> |
|||
<View style={styles.cardBody}> |
|||
<Text style={styles.pos}>{j.pos}</Text> |
|||
<Text style={styles.company}>{j.company}</Text> |
|||
</View> |
|||
<View style={styles.cardRight}> |
|||
<Text style={styles.salary}>{j.salary}</Text> |
|||
<View style={styles.cityTag}> |
|||
<Text style={styles.cityText}>{j.city}</Text> |
|||
</View> |
|||
</View> |
|||
</View> |
|||
))} |
|||
</ScrollView> |
|||
|
|||
<View style={styles.tabBar}> |
|||
{TABS.map((t) => ( |
|||
<Pressable key={t.key} style={styles.tab} onPress={() => setTab(t.key)}> |
|||
<Ionicons name={t.icon} size={22} color={tab === t.key ? colors.primary : colors.textHint} /> |
|||
<Text style={[styles.tabLabel, tab === t.key && styles.tabLabelActive]}>{t.label}</Text> |
|||
</Pressable> |
|||
))} |
|||
</View> |
|||
|
|||
{step === 'guide' && ( |
|||
<View style={styles.guide}> |
|||
<View style={styles.guideCard}> |
|||
<Ionicons name="arrow-down" size={20} color={colors.primary} /> |
|||
<Text style={styles.guideText}>点击查看职位详情</Text> |
|||
</View> |
|||
<Pressable style={styles.guideBtn} onPress={() => setStep('location')}> |
|||
<Text style={styles.guideBtnText}>知道了</Text> |
|||
</Pressable> |
|||
</View> |
|||
)} |
|||
|
|||
<Modal |
|||
visible={step === 'location'} |
|||
title="允许获取位置" |
|||
onClose={() => setStep('notify')} |
|||
footer={ |
|||
<View style={styles.modalBtns}> |
|||
<Pressable style={[styles.modalBtn, styles.modalBtnGhost]} onPress={() => setStep('notify')}> |
|||
<Text style={styles.modalBtnGhostText}>暂不</Text> |
|||
</Pressable> |
|||
<Pressable style={[styles.modalBtn, styles.modalBtnPrimary]} onPress={() => setStep('notify')}> |
|||
<Text style={styles.modalBtnPrimaryText}>允许</Text> |
|||
</Pressable> |
|||
</View> |
|||
} |
|||
> |
|||
<Text style={styles.modalBody}>开启定位可为你推荐附近的职位与机会。</Text> |
|||
</Modal> |
|||
|
|||
<Modal |
|||
visible={step === 'notify'} |
|||
title="开启通知" |
|||
onClose={() => setStep(null)} |
|||
footer={ |
|||
<View style={styles.modalBtns}> |
|||
<Pressable style={[styles.modalBtn, styles.modalBtnGhost]} onPress={() => setStep(null)}> |
|||
<Text style={styles.modalBtnGhostText}>暂不</Text> |
|||
</Pressable> |
|||
<Pressable style={[styles.modalBtn, styles.modalBtnPrimary]} onPress={() => setStep(null)}> |
|||
<Text style={styles.modalBtnPrimaryText}>允许</Text> |
|||
</Pressable> |
|||
</View> |
|||
} |
|||
> |
|||
<Text style={styles.modalBody}>开启通知可及时接收面试邀请与 Boss 消息。</Text> |
|||
</Modal> |
|||
</SafeAreaView> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
screen: { flex: 1, backgroundColor: colors.pageBg }, |
|||
topBar: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 20, paddingVertical: 12, backgroundColor: colors.white }, |
|||
topTitle: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, marginRight: 'auto' }, |
|||
topCity: { fontSize: 13, color: colors.primary, fontWeight: '600' }, |
|||
list: { flex: 1 }, |
|||
listContent: { padding: 16, gap: 12 }, |
|||
card: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
gap: 12, |
|||
backgroundColor: colors.white, |
|||
borderRadius: 14, |
|||
padding: 14, |
|||
borderWidth: 1, |
|||
borderColor: colors.border, |
|||
}, |
|||
cardBody: { flex: 1, gap: 4 }, |
|||
pos: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, |
|||
company: { fontSize: 12, color: colors.textSecondary }, |
|||
cardRight: { alignItems: 'flex-end', gap: 6 }, |
|||
salary: { fontSize: 15, fontWeight: '700', color: colors.info }, |
|||
cityTag: { backgroundColor: colors.primaryLight, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 8 }, |
|||
cityText: { fontSize: 11, color: colors.primaryText }, |
|||
tabBar: { |
|||
flexDirection: 'row', |
|||
borderTopWidth: 1, |
|||
borderColor: colors.borderLight, |
|||
backgroundColor: colors.white, |
|||
paddingVertical: 8, |
|||
}, |
|||
tab: { flex: 1, alignItems: 'center', gap: 2 }, |
|||
tabLabel: { fontSize: 11, color: colors.textHint }, |
|||
tabLabelActive: { color: colors.primary, fontWeight: '700' }, |
|||
guide: { |
|||
...StyleSheet.absoluteFill, |
|||
backgroundColor: colors.scrim, |
|||
alignItems: 'center', |
|||
justifyContent: 'flex-start', |
|||
paddingTop: 150, |
|||
}, |
|||
guideCard: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
gap: 6, |
|||
backgroundColor: colors.white, |
|||
paddingHorizontal: 16, |
|||
paddingVertical: 10, |
|||
borderRadius: 12, |
|||
}, |
|||
guideText: { fontSize: 14, fontWeight: '600', color: colors.textPrimary }, |
|||
guideBtn: { marginTop: 16, backgroundColor: colors.primary, paddingHorizontal: 28, paddingVertical: 10, borderRadius: 20 }, |
|||
guideBtnText: { color: colors.textInverse, fontWeight: '700' }, |
|||
modalBtns: { flexDirection: 'row', gap: 12 }, |
|||
modalBtn: { flex: 1, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center' }, |
|||
modalBtnGhost: { backgroundColor: colors.borderLight }, |
|||
modalBtnGhostText: { color: colors.textSecondary, fontWeight: '600' }, |
|||
modalBtnPrimary: { backgroundColor: colors.primary }, |
|||
modalBtnPrimaryText: { color: colors.textInverse, fontWeight: '700' }, |
|||
modalBody: { fontSize: 14, color: colors.textSecondary, lineHeight: 22, textAlign: 'center' }, |
|||
}); |
|||
@ -0,0 +1,64 @@ |
|||
import { Image, StyleSheet, Text, View } from 'react-native'; |
|||
import { Ionicons } from '@expo/vector-icons'; |
|||
import type { ComponentProps } from 'react'; |
|||
import { colors } from '../../../src/theme'; |
|||
import { ASSET_REGISTRY, type AssetName } from '../assets'; |
|||
|
|||
type IconName = ComponentProps<typeof Ionicons>['name']; |
|||
|
|||
interface Props { |
|||
/** 素材位名(见 assets.ts 的 ASSET_REGISTRY) */ |
|||
name: AssetName; |
|||
width: number; |
|||
height: number; |
|||
radius?: number; |
|||
} |
|||
|
|||
/** |
|||
* 内容配图槽位:素材已提供则渲染真实图,否则画一个**带文字标注**的占位框。 |
|||
* |
|||
* 与 `Illustration` 的分工:那个是「小尺寸图标位」(头像 / Logo),占位只画色块; |
|||
* 这个是「内容大图位」,占位必须让看图的人知道这里要放什么图, |
|||
* 所以把 `assets.ts` 里的 hint 直接标在页面上,补图后标注自动消失。 |
|||
*/ |
|||
export function ImageSlot({ name, width, height, radius = 16 }: Props) { |
|||
const def = ASSET_REGISTRY[name]; |
|||
|
|||
if (def.source) { |
|||
return ( |
|||
<Image source={def.source} style={{ width, height, borderRadius: radius }} resizeMode="cover" /> |
|||
); |
|||
} |
|||
|
|||
return ( |
|||
<View style={[styles.slot, { width, height, borderRadius: radius, backgroundColor: def.tint }]}> |
|||
<Ionicons name={def.icon as IconName} size={26} color={colors.primary} /> |
|||
<Text style={styles.tag}>配图待补</Text> |
|||
<Text style={styles.hint}>{def.hint}</Text> |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
slot: { |
|||
alignItems: 'center', |
|||
justifyContent: 'center', |
|||
paddingHorizontal: 20, |
|||
borderWidth: 1, |
|||
borderStyle: 'dashed', |
|||
borderColor: colors.primaryBorder, |
|||
}, |
|||
tag: { |
|||
marginTop: 8, |
|||
fontSize: 12, |
|||
fontWeight: '700', |
|||
color: colors.primaryText, |
|||
}, |
|||
hint: { |
|||
marginTop: 4, |
|||
fontSize: 12, |
|||
lineHeight: 18, |
|||
textAlign: 'center', |
|||
color: colors.textSecondary, |
|||
}, |
|||
}); |
|||
@ -0,0 +1,43 @@ |
|||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'; |
|||
import { colors } from '../../../src/theme'; |
|||
import { SUPERVISION_PHONE, SUPPORT_HOTLINE } from '../../../src/constants'; |
|||
|
|||
/** |
|||
* 合规页脚:服务热线 / 举报监督电话。 |
|||
* |
|||
* 原先是三行纯文本,没号码也没点击反馈(P1 与 P2Q 各复制一份)。 |
|||
* 这里改为读常量:**号码未配置时整块不渲染** —— 号码属于真实主体信息, |
|||
* 编一个摆上去就是假数据。填入 `SUPPORT_HOTLINE` / `SUPERVISION_PHONE` 后 |
|||
* 页脚自动出现并支持点击拨号。 |
|||
*/ |
|||
export function SupportFooter() { |
|||
const items = [ |
|||
{ key: 'hotline', label: '服务热线', phone: SUPPORT_HOTLINE }, |
|||
{ key: 'supervision', label: '举报监督电话', phone: SUPERVISION_PHONE }, |
|||
].filter((item) => item.phone !== ''); |
|||
|
|||
if (items.length === 0) return null; |
|||
|
|||
/** 设备不支持 tel:(如平板 / 模拟器)时 openURL 会 reject,不弹未捕获异常 */ |
|||
const call = (phone: string) => { |
|||
void Linking.openURL(`tel:${phone}`).catch(() => undefined); |
|||
}; |
|||
|
|||
return ( |
|||
<View style={styles.row}> |
|||
{items.map((item) => ( |
|||
<Pressable key={item.key} onPress={() => call(item.phone)} hitSlop={6}> |
|||
<Text style={styles.item}> |
|||
{item.label} {item.phone} |
|||
</Text> |
|||
</Pressable> |
|||
))} |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
// 两串「标签 + 11 位号码」在窄屏上会挤,允许换行
|
|||
row: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 18 }, |
|||
item: { fontSize: 12, color: colors.textHint }, |
|||
}); |
|||
@ -0,0 +1,209 @@ |
|||
/** |
|||
* 简历编辑页共用件(基本信息 / 教育背景 / 工作经历 / 项目经历四页同一套骨架)。 |
|||
* |
|||
* 抽出来的理由:四页的头部、表单行、四态、页脚完全一致,逐页复制会让 |
|||
* 「错误提示样式」「禁用态按钮」这类细节在四处各自漂移。 |
|||
*/ |
|||
import { Ionicons } from '@expo/vector-icons'; |
|||
import type { ReactNode } from 'react'; |
|||
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; |
|||
import { colors } from '../../src/theme'; |
|||
|
|||
/** 顶部导航:返回 + 居中标题(右侧留同宽占位,保证标题居中) */ |
|||
export function EditHeader({ title, onBack }: { title: string; onBack: () => 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}>{title}</Text> |
|||
<View style={styles.headerBtn} /> |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
/** |
|||
* 表单行。`error` 只在**填了但填错**时传入 —— 空值是「未采集」不是「填错了」, |
|||
* 一进页面就飘红字会让人以为自己做错了什么。 |
|||
*/ |
|||
export function Field({ |
|||
label, |
|||
value, |
|||
onChangeText, |
|||
placeholder, |
|||
keyboardType, |
|||
maxLength, |
|||
error, |
|||
hint, |
|||
multiline, |
|||
autoFocus, |
|||
required, |
|||
}: { |
|||
label: string; |
|||
value: string; |
|||
onChangeText: (v: string) => void; |
|||
placeholder: string; |
|||
keyboardType?: 'default' | 'phone-pad' | 'email-address' | 'number-pad'; |
|||
maxLength?: number; |
|||
error?: string; |
|||
hint?: string; |
|||
multiline?: boolean; |
|||
autoFocus?: boolean; |
|||
/** 必填项在 label 后打一个红色星号(只做视觉标注,拦截规则在页面里) */ |
|||
required?: boolean; |
|||
}) { |
|||
const hasError = error !== undefined && error !== ''; |
|||
return ( |
|||
<View style={styles.section}> |
|||
<Text style={styles.label}> |
|||
{label} |
|||
{required === true && <Text style={styles.requiredMark}> *</Text>} |
|||
</Text> |
|||
<TextInput |
|||
style={[styles.input, multiline === true && styles.inputMultiline, hasError && styles.inputError]} |
|||
value={value} |
|||
onChangeText={onChangeText} |
|||
placeholder={placeholder} |
|||
placeholderTextColor={colors.textHint} |
|||
keyboardType={keyboardType ?? 'default'} |
|||
maxLength={maxLength} |
|||
multiline={multiline} |
|||
autoFocus={autoFocus} |
|||
autoCapitalize="none" |
|||
/> |
|||
{hasError && <Text style={styles.fieldError}>{error}</Text>} |
|||
{!hasError && hint !== undefined && hint !== '' && <Text style={styles.hint}>{hint}</Text>} |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
/** 只读说明行(放在卡片或页脚里,字号小于正文) */ |
|||
export function FormNote({ text }: { text: string }) { |
|||
return <Text style={styles.note}>{text}</Text>; |
|||
} |
|||
|
|||
/** 白色圆角卡片容器(与简历首页的卡片同款) */ |
|||
export function SectionCard({ title, right, children }: { title: string; right?: ReactNode; children: ReactNode }) { |
|||
return ( |
|||
<View style={styles.card}> |
|||
<View style={styles.cardHeader}> |
|||
<Text style={styles.cardTitle}>{title}</Text> |
|||
{right} |
|||
</View> |
|||
{children} |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
/** 页脚容器:上方一条浅描边,主按钮靠它压底 */ |
|||
export function PageFooter({ children }: { children: ReactNode }) { |
|||
return <View style={styles.footer}>{children}</View>; |
|||
} |
|||
|
|||
/** 居中状态块(加载中 / 加载失败 / 空列表都走这一个) */ |
|||
export function CenterState({ |
|||
icon, |
|||
text, |
|||
actionLabel, |
|||
onAction, |
|||
}: { |
|||
icon?: keyof typeof Ionicons.glyphMap; |
|||
text: string; |
|||
actionLabel?: string; |
|||
onAction?: () => void; |
|||
}) { |
|||
return ( |
|||
<View style={styles.center}> |
|||
{icon !== undefined && <Ionicons name={icon} size={40} color={colors.textHint} />} |
|||
<Text style={styles.centerText}>{text}</Text> |
|||
{actionLabel !== undefined && onAction !== undefined && ( |
|||
<Pressable style={styles.centerBtn} onPress={onAction} hitSlop={6}> |
|||
<Text style={styles.centerBtnText}>{actionLabel}</Text> |
|||
</Pressable> |
|||
)} |
|||
</View> |
|||
); |
|||
} |
|||
|
|||
/** 列表行右侧的图标按钮(编辑 / 删除) */ |
|||
export function IconAction({ |
|||
icon, |
|||
onPress, |
|||
color, |
|||
label, |
|||
}: { |
|||
icon: keyof typeof Ionicons.glyphMap; |
|||
onPress: () => void; |
|||
color?: string; |
|||
/** 无障碍标签:图标按钮对读屏用户是没有名字的,必须补一个 */ |
|||
label: string; |
|||
}) { |
|||
return ( |
|||
<Pressable onPress={onPress} hitSlop={8} style={styles.iconBtn} accessibilityLabel={label}> |
|||
<Ionicons name={icon} size={18} color={color ?? colors.textHint} /> |
|||
</Pressable> |
|||
); |
|||
} |
|||
|
|||
const styles = StyleSheet.create({ |
|||
header: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
justifyContent: 'space-between', |
|||
paddingHorizontal: 12, |
|||
paddingVertical: 12, |
|||
backgroundColor: colors.white, |
|||
}, |
|||
headerBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, |
|||
headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary }, |
|||
section: { marginBottom: 18 }, |
|||
label: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginBottom: 10 }, |
|||
requiredMark: { color: colors.danger }, |
|||
input: { |
|||
height: 50, |
|||
borderRadius: 12, |
|||
borderWidth: 1, |
|||
borderColor: colors.border, |
|||
backgroundColor: colors.white, |
|||
paddingHorizontal: 14, |
|||
fontSize: 16, |
|||
color: colors.textPrimary, |
|||
}, |
|||
inputMultiline: { height: 120, paddingTop: 12, textAlignVertical: 'top' }, |
|||
inputError: { borderColor: colors.dangerBorder }, |
|||
fieldError: { fontSize: 12, color: colors.dangerText, marginTop: 6 }, |
|||
hint: { fontSize: 12, color: colors.textHint, marginTop: 6 }, |
|||
note: { fontSize: 12, color: colors.textHint, lineHeight: 18 }, |
|||
card: { |
|||
backgroundColor: colors.white, |
|||
borderRadius: 16, |
|||
padding: 16, |
|||
marginBottom: 12, |
|||
borderWidth: 1, |
|||
borderColor: colors.borderLight, |
|||
}, |
|||
cardHeader: { |
|||
flexDirection: 'row', |
|||
alignItems: 'center', |
|||
justifyContent: 'space-between', |
|||
marginBottom: 10, |
|||
}, |
|||
cardTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, |
|||
footer: { |
|||
paddingHorizontal: 20, |
|||
paddingVertical: 14, |
|||
backgroundColor: colors.white, |
|||
borderTopWidth: 1, |
|||
borderTopColor: colors.borderLight, |
|||
}, |
|||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12, paddingVertical: 40 }, |
|||
centerText: { fontSize: 14, color: colors.textSecondary }, |
|||
centerBtn: { |
|||
paddingHorizontal: 18, |
|||
paddingVertical: 8, |
|||
borderRadius: 16, |
|||
backgroundColor: colors.primaryLight, |
|||
}, |
|||
centerBtnText: { fontSize: 13, fontWeight: '600', color: colors.primaryText }, |
|||
iconBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, |
|||
}); |
|||
@ -0,0 +1,86 @@ |
|||
/** |
|||
* 可点击控件审计:找出「看起来能点、其实没有处理函数」的 Pressable / Touchable*。 |
|||
* |
|||
* 背景:本项目把「不留假入口」当红线(点下去零反馈的控件会把开发中的状态摆给用户看)。 |
|||
* 与 `check-routes.mjs` 的关系:那个查「页面能不能到达」,这个查「按钮点下去有没有反应」。 |
|||
* |
|||
* 判定:控件标签内既没有 onPress / onPressIn / onLongPress / onPressOut, |
|||
* 也没有 `disabled`(禁用态是有意的——视觉上应当置灰)与属性展开({...props})。 |
|||
* |
|||
* 豁免:标签所在行或上一行写了 `affordance-ok` 注释的跳过 —— 底部弹层里 |
|||
* `onPress={() => {}}` 是**有意**用来吞掉冒泡的(不吞就会点内容区也关掉弹层), |
|||
* 与「忘了接线」是两回事,靠注释把两者区分开。 |
|||
* |
|||
* 用法:node scripts/check-affordances.mjs # 列出全部 |
|||
* node scripts/check-affordances.mjs --error # 有命中则 exit 1(可用于 CI) |
|||
*/ |
|||
import { readdirSync, readFileSync, statSync } from 'node:fs'; |
|||
import { join } from 'node:path'; |
|||
|
|||
const ROOTS = ['app', 'components']; |
|||
const HANDLER_PROPS = ['onPress', 'onPressIn', 'onPressOut', 'onLongPress', 'disabled', '{...']; |
|||
|
|||
/** 有意为之的控件(吞冒泡)在行内或上一行标这个,避免与「忘了接线」混在一起 */ |
|||
const EXEMPT_MARKER = 'affordance-ok'; |
|||
|
|||
/** 从 `<Tag` 的起始下标起,找到该开标签的结束 `>`(粗略忽略字符串内的 `>`,对本仓库足够) */ |
|||
function findTagEnd(text, from) { |
|||
let depth = 0; |
|||
for (let i = from; i < text.length; i += 1) { |
|||
const ch = text[i]; |
|||
if (ch === '{') depth += 1; |
|||
else if (ch === '}') depth -= 1; |
|||
else if (ch === '>' && depth === 0) return i; |
|||
} |
|||
return -1; |
|||
} |
|||
|
|||
function walk(dir, out = []) { |
|||
for (const entry of readdirSync(dir)) { |
|||
const full = join(dir, entry); |
|||
if (statSync(full).isDirectory()) walk(full, out); |
|||
else if (/\.tsx$/.test(entry)) out.push(full); |
|||
} |
|||
return out; |
|||
} |
|||
|
|||
const TAG_RE = /<(Pressable|TouchableOpacity|TouchableHighlight|TouchableWithoutFeedback)\b/g; |
|||
|
|||
/** 空处理函数:绑了 onPress 但什么都不做,点下去同样零反馈 */ |
|||
const EMPTY_HANDLER_RE = /onPress=\{\s*\(\s*\)\s*=>\s*(\{\s*\}|undefined|null)\s*\}/; |
|||
|
|||
const findings = []; |
|||
for (const root of ROOTS) { |
|||
for (const file of walk(root)) { |
|||
const text = readFileSync(file, 'utf8'); |
|||
const lines = text.split('\n'); |
|||
for (const match of text.matchAll(TAG_RE)) { |
|||
const end = findTagEnd(text, match.index); |
|||
if (end < 0) continue; |
|||
const tag = text.slice(match.index, end + 1); |
|||
const empty = EMPTY_HANDLER_RE.test(tag); |
|||
if (!empty && HANDLER_PROPS.some((prop) => tag.includes(prop))) continue; |
|||
const line = text.slice(0, match.index).split('\n').length; |
|||
// 豁免标记写在标签行或其上一行(弹层外的说明注释通常写在上一行)
|
|||
if (lines[line - 1].includes(EXEMPT_MARKER) || (lines[line - 2] ?? '').includes(EXEMPT_MARKER)) { |
|||
continue; |
|||
} |
|||
findings.push({ |
|||
file, |
|||
line, |
|||
reason: empty ? '空处理函数' : '无处理函数', |
|||
tag: lines[line - 1].trim(), |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
|
|||
if (findings.length === 0) { |
|||
console.log('✅ 未发现无处理函数的可点击控件'); |
|||
} else { |
|||
console.log(`=== 点下去没有反应的可点击控件 (${findings.length}) ===`); |
|||
for (const f of findings) { |
|||
console.log(`${f.file}:${f.line} [${f.reason}] ${f.tag}`); |
|||
} |
|||
if (process.argv.includes('--error')) process.exitCode = 1; |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
/** |
|||
* 后端接口预留审计:找出「函数体里没有留下任何后端调用痕迹」的接口函数。 |
|||
* |
|||
* 背景:本项目当前是 mock 阶段,数据要能展示,但每个函数都要把真实请求写好、注释起来, |
|||
* 后端就绪时**放开注释**即可接入(见 `src/api/api.ts` 顶部的「后端接口预留」约定)。 |
|||
* 判断标准是「函数体里出现过 fetch(」—— 注释着的也算,因此不依赖注释的写法。 |
|||
* |
|||
* 豁免:确实没有后端接口、本就以本地存储为唯一真相源的函数,在函数体内任意位置写 |
|||
* `backend-stub-ok` 注释即可跳过(约定见 api.ts 顶部)。 |
|||
* |
|||
* 用法:node scripts/check-backend-stubs.mjs # 列出全部 |
|||
* node scripts/check-backend-stubs.mjs --error # 有命中则 exit 1(可用于 CI) |
|||
*/ |
|||
import { readFileSync } from 'node:fs'; |
|||
|
|||
const FILE = 'src/api/api.ts'; |
|||
/** 有意不接后端的函数在函数体内标这个,避免与「忘了留接口」混在一起 */ |
|||
const EXEMPT_MARKER = 'backend-stub-ok'; |
|||
|
|||
const lines = readFileSync(FILE, 'utf8').split('\n'); |
|||
|
|||
/** 顶层 `export async function` 的起始行;同步函数不涉及请求,不参与审计 */ |
|||
const starts = []; |
|||
lines.forEach((line, index) => { |
|||
const match = line.match(/^export async function (\w+)/); |
|||
if (match) starts.push({ name: match[1], index }); |
|||
}); |
|||
starts.push({ name: null, index: lines.length }); |
|||
|
|||
const findings = []; |
|||
for (let i = 0; i < starts.length - 1; i += 1) { |
|||
const { name, index } = starts[i]; |
|||
const body = lines.slice(index, starts[i + 1].index).join('\n'); |
|||
if (body.includes('fetch(') || body.includes(EXEMPT_MARKER)) continue; |
|||
findings.push({ name, line: index + 1 }); |
|||
} |
|||
|
|||
if (findings.length === 0) { |
|||
console.log(`✅ ${starts.length - 1} 个接口函数均已预留后端调用(注释版)`); |
|||
} else { |
|||
console.log(`=== 未预留后端调用的接口函数 (${findings.length}) ===`); |
|||
for (const f of findings) { |
|||
console.log(`${FILE}:${f.line} ${f.name}`); |
|||
} |
|||
console.log('请补上注释版真实请求,或在不接后端的函数里标注 backend-stub-ok'); |
|||
if (process.argv.includes('--error')) process.exitCode = 1; |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
import { fetchCarrierPhone, loginByCarrier, maskPhone } from '../api/api'; |
|||
import { CARRIER_LABEL, CARRIER_TOKEN_TTL_MS } from '../constants'; |
|||
import type { CarrierName, CarrierPhoneResult } from '../types'; |
|||
|
|||
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock(与 password.test.ts 同)
|
|||
jest.mock('../lib/storage', () => ({ |
|||
storageGet: jest.fn(async () => null), |
|||
storageSet: jest.fn(async () => undefined), |
|||
storageRemove: jest.fn(async () => undefined), |
|||
})); |
|||
|
|||
const ALL_CARRIERS: CarrierName[] = ['cmcc', 'cucc', 'ctcc', 'unknown']; |
|||
|
|||
describe('maskPhone(脱敏在接口层,页面不自己切字符串)', () => { |
|||
it('11 位手机号保留前 3 后 4', () => { |
|||
expect(maskPhone('15000009367')).toBe('150****9367'); |
|||
}); |
|||
|
|||
it('非 11 位原样返回,不截断成错的', () => { |
|||
expect(maskPhone('')).toBe(''); |
|||
expect(maskPhone('1500000')).toBe('1500000'); |
|||
expect(maskPhone('150000093671')).toBe('150000093671'); |
|||
}); |
|||
}); |
|||
|
|||
describe('fetchCarrierPhone(取号)', () => { |
|||
it('返回号码、掩码、一次性令牌与过期时间', async () => { |
|||
const before = Date.now(); |
|||
const result: CarrierPhoneResult = await fetchCarrierPhone(); |
|||
|
|||
expect(result.phone).toHaveLength(11); |
|||
expect(result.maskedPhone).toBe(maskPhone(result.phone)); |
|||
expect(result.carrierToken).not.toBe(''); |
|||
expect(ALL_CARRIERS).toContain(result.carrier); |
|||
expect(result.expiresAt).toBeGreaterThanOrEqual(before + CARRIER_TOKEN_TTL_MS); |
|||
}); |
|||
|
|||
it('掩码号不含完整号码,展示用不会泄露', async () => { |
|||
const result = await fetchCarrierPhone(); |
|||
expect(result.maskedPhone).not.toBe(result.phone); |
|||
expect(result.maskedPhone).toContain('****'); |
|||
}); |
|||
}); |
|||
|
|||
describe('loginByCarrier(用取号令牌换登录态)', () => { |
|||
it('拿取号令牌换到登录态与后端核验过的号码', async () => { |
|||
const { phone, carrierToken } = await fetchCarrierPhone(); |
|||
const result = await loginByCarrier(carrierToken); |
|||
|
|||
expect(result.token).not.toBe(''); |
|||
// 号码以后端向运营商换取的结果为准,应与取号展示的是同一个
|
|||
expect(result.phone).toBe(phone); |
|||
}); |
|||
|
|||
it('令牌为空时直接失败,不发请求', async () => { |
|||
await expect(loginByCarrier('')).rejects.toThrow('本机号码未取到'); |
|||
}); |
|||
|
|||
it('每次取号的令牌都不同(一次性令牌不可复用)', async () => { |
|||
const first = await fetchCarrierPhone(); |
|||
const second = await fetchCarrierPhone(); |
|||
expect(first.carrierToken).not.toBe(second.carrierToken); |
|||
}); |
|||
}); |
|||
|
|||
describe('CARRIER_LABEL', () => { |
|||
it('联合类型每个成员都有展示名(新增运营商漏配时失败)', () => { |
|||
ALL_CARRIERS.forEach((carrier) => { |
|||
expect(CARRIER_LABEL[carrier]).toBeTruthy(); |
|||
}); |
|||
expect(Object.keys(CARRIER_LABEL)).toHaveLength(ALL_CARRIERS.length); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,282 @@ |
|||
import { |
|||
fetchEnterpriseCommunication, |
|||
fetchSkillLearningPath, |
|||
fetchTalentRecommendation, |
|||
filterEnterpriseChats, |
|||
filterSkillWords, |
|||
filterTalentCandidates, |
|||
} from '../api/api'; |
|||
|
|||
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock
|
|||
jest.mock('../lib/storage', () => ({ |
|||
storageGet: jest.fn(async () => null), |
|||
storageSet: jest.fn(async () => undefined), |
|||
storageRemove: jest.fn(async () => undefined), |
|||
})); |
|||
|
|||
describe('filterEnterpriseChats(企业沟通 Tab + 关键词过滤)', () => { |
|||
const chats = [ |
|||
{ |
|||
id: 'c1', |
|||
userId: 'u1', |
|||
name: '张明远', |
|||
avatar: '张', |
|||
title: '高级前端工程师', |
|||
lastMessage: '方便下周面试吗?', |
|||
time: '10分钟前', |
|||
unreadCount: 1, |
|||
labels: [], |
|||
status: 'pending' as const, |
|||
}, |
|||
{ |
|||
id: 'c2', |
|||
userId: 'u2', |
|||
name: '李雨桐', |
|||
avatar: '李', |
|||
title: '产品经理', |
|||
lastMessage: '想进一步沟通', |
|||
time: '30分钟前', |
|||
unreadCount: 0, |
|||
labels: ['面试待评分'], |
|||
status: 'interview' as const, |
|||
}, |
|||
]; |
|||
|
|||
it('全部 Tab 且无关键词时原样返回', () => { |
|||
expect(filterEnterpriseChats(chats, 'all', '')).toHaveLength(2); |
|||
}); |
|||
|
|||
it('按状态过滤:只留该 Tab 下的会话', () => { |
|||
expect(filterEnterpriseChats(chats, 'pending', '').map((c) => c.id)).toEqual(['c1']); |
|||
expect(filterEnterpriseChats(chats, 'interview', '').map((c) => c.id)).toEqual(['c2']); |
|||
expect(filterEnterpriseChats(chats, 'offer', '')).toHaveLength(0); |
|||
}); |
|||
|
|||
it('关键词命中姓名 / 职位 / 最后一条消息', () => { |
|||
expect(filterEnterpriseChats(chats, 'all', '张明远').map((c) => c.id)).toEqual(['c1']); |
|||
expect(filterEnterpriseChats(chats, 'all', '产品经理').map((c) => c.id)).toEqual(['c2']); |
|||
expect(filterEnterpriseChats(chats, 'all', '面试').map((c) => c.id)).toEqual(['c1']); |
|||
}); |
|||
|
|||
it('关键词忽略大小写与首尾空格', () => { |
|||
expect(filterEnterpriseChats(chats, 'all', ' 产品经理 ')).toHaveLength(1); |
|||
expect(filterEnterpriseChats(chats, 'all', '产品经理'.toUpperCase())).toHaveLength(1); |
|||
}); |
|||
|
|||
it('Tab 与关键词同时生效,命中不到时返回空数组', () => { |
|||
// 「张明远」不在面试邀请 Tab 下
|
|||
expect(filterEnterpriseChats(chats, 'interview', '张明远')).toHaveLength(0); |
|||
}); |
|||
|
|||
it('关键词为空串时不误伤(不过滤)', () => { |
|||
expect(filterEnterpriseChats(chats, 'all', ' ')).toHaveLength(2); |
|||
}); |
|||
}); |
|||
|
|||
describe('filterTalentCandidates(人才推荐 Tab + 关键词过滤)', () => { |
|||
const candidates = [ |
|||
{ |
|||
id: 'c1', |
|||
name: '张明远', |
|||
avatar: '张', |
|||
title: '高级前端工程师 · 5年经验', |
|||
experience: '5年经验', |
|||
age: 28, |
|||
school: '北京大学', |
|||
city: '北京', |
|||
expectedSalary: '期望薪资 30-35K', |
|||
matchPercent: 96, |
|||
overallScore: 88, |
|||
percentile: 99, |
|||
tags: ['React', 'TypeScript'], |
|||
education: '硕士' as const, |
|||
}, |
|||
{ |
|||
id: 'c2', |
|||
name: '李雨桐', |
|||
avatar: '李', |
|||
title: '产品经理 · 3年经验', |
|||
experience: '3年经验', |
|||
age: 26, |
|||
school: '上海交通大学', |
|||
city: '北京', |
|||
expectedSalary: '期望薪资 25-30K', |
|||
matchPercent: 82, |
|||
overallScore: 75, |
|||
percentile: 75, |
|||
tags: ['大厂背景'], |
|||
education: '本科' as const, |
|||
}, |
|||
{ |
|||
id: 'c3', |
|||
name: '周雨', |
|||
avatar: '周', |
|||
title: 'UI 设计师 · 8年经验', |
|||
experience: '8年经验', |
|||
age: 34, |
|||
school: '中国美术学院', |
|||
city: '杭州', |
|||
expectedSalary: '期望薪资 28-35K', |
|||
matchPercent: 76, |
|||
overallScore: 80, |
|||
percentile: 70, |
|||
tags: ['设计系统'], |
|||
education: '本科' as const, |
|||
}, |
|||
]; |
|||
|
|||
it('全部 Tab 且无关键词时原样返回', () => { |
|||
expect(filterTalentCandidates(candidates, 'all', '')).toHaveLength(3); |
|||
}); |
|||
|
|||
it('匹配度高:只留匹配度 ≥ 90 的候选人', () => { |
|||
expect(filterTalentCandidates(candidates, 'high', '').map((c) => c.id)).toEqual(['c1']); |
|||
}); |
|||
|
|||
it('985/211:按院校名单判定,非名单院校被排除', () => { |
|||
expect(filterTalentCandidates(candidates, '985211', '').map((c) => c.id)).toEqual(['c1', 'c2']); |
|||
}); |
|||
|
|||
it('5年以上:按经验年限数值判定(5 年本身算命中)', () => { |
|||
expect(filterTalentCandidates(candidates, '5years', '').map((c) => c.id)).toEqual(['c1', 'c3']); |
|||
}); |
|||
|
|||
it('硕士及以上:本科被排除,硕士/博士命中', () => { |
|||
expect(filterTalentCandidates(candidates, 'master', '').map((c) => c.id)).toEqual(['c1']); |
|||
}); |
|||
|
|||
it('关键词命中姓名 / 职位 / 学校 / 技能标签', () => { |
|||
expect(filterTalentCandidates(candidates, 'all', '李雨桐').map((c) => c.id)).toEqual(['c2']); |
|||
expect(filterTalentCandidates(candidates, 'all', '设计师').map((c) => c.id)).toEqual(['c3']); |
|||
expect(filterTalentCandidates(candidates, 'all', '北京大学').map((c) => c.id)).toEqual(['c1']); |
|||
expect(filterTalentCandidates(candidates, 'all', 'react').map((c) => c.id)).toEqual(['c1']); |
|||
}); |
|||
|
|||
it('Tab 与关键词同时生效,命中不到时返回空数组', () => { |
|||
expect(filterTalentCandidates(candidates, 'high', '周雨')).toHaveLength(0); |
|||
}); |
|||
}); |
|||
|
|||
describe('企业端 Tab 计数由列表推导(页面数字与列表人数必须一致)', () => { |
|||
it('沟通 Tab 计数与逐条状态统计一致,且各状态之和等于全部', async () => { |
|||
const { chats, tabs } = await fetchEnterpriseCommunication(); |
|||
|
|||
const countOf = (status: string) => chats.filter((chat) => chat.status === status).length; |
|||
const tabOf = (key: string) => tabs.find((tab) => tab.key === key)?.count; |
|||
|
|||
expect(tabOf('all')).toBe(chats.length); |
|||
expect(tabOf('pending')).toBe(countOf('pending')); |
|||
expect(tabOf('interview')).toBe(countOf('interview')); |
|||
expect(tabOf('offer')).toBe(countOf('offer')); |
|||
expect(tabOf('closed')).toBe(countOf('closed')); |
|||
expect( |
|||
(tabOf('pending') ?? 0) + |
|||
(tabOf('interview') ?? 0) + |
|||
(tabOf('offer') ?? 0) + |
|||
(tabOf('closed') ?? 0), |
|||
).toBe(chats.length); |
|||
}); |
|||
|
|||
it('每个 Tab 点到列表都有对应数据,不会出现「数字 3、列表 0 条」', async () => { |
|||
const { chats, tabs } = await fetchEnterpriseCommunication(); |
|||
|
|||
tabs.forEach((tab) => { |
|||
expect(filterEnterpriseChats(chats, tab.key, '')).toHaveLength(tab.count); |
|||
}); |
|||
}); |
|||
|
|||
it('人才推荐 Tab 计数与过滤结果一致', async () => { |
|||
const { candidates, tabs, todayCount } = await fetchTalentRecommendation(); |
|||
|
|||
expect(todayCount).toBe(candidates.length); |
|||
tabs.forEach((tab) => { |
|||
expect(filterTalentCandidates(candidates, tab.key, '')).toHaveLength(tab.count); |
|||
}); |
|||
}); |
|||
|
|||
it('待办任务计数由会话列表推导,且都带可跳转的去向', async () => { |
|||
const { chats, pendingTasks } = await fetchEnterpriseCommunication(); |
|||
|
|||
const reply = pendingTasks.find((task) => task.id === 'reply'); |
|||
const score = pendingTasks.find((task) => task.id === 'score'); |
|||
|
|||
expect(reply?.count).toBe(chats.filter((chat) => chat.unreadCount > 0).length); |
|||
expect(reply?.tabKey).toBe('pending'); |
|||
expect(score?.count).toBe(chats.filter((chat) => chat.labels.includes('面试待评分')).length); |
|||
expect(score?.route).toBe('/interview-score'); |
|||
|
|||
// 带箭头/可点的待办卡片必须指向真实去向,否则就是假入口
|
|||
pendingTasks.forEach((task) => { |
|||
expect(task.tabKey ?? task.route).toBeTruthy(); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('filterSkillWords(技能词云搜索)', () => { |
|||
const words = [ |
|||
{ id: 's1', text: 'React', weight: 96, group: 'tech' as const }, |
|||
{ id: 's2', text: 'TypeScript', weight: 92, group: 'tech' as const }, |
|||
{ id: 's3', text: '前端工程化', weight: 82, group: 'tech' as const }, |
|||
]; |
|||
|
|||
it('空关键词返回全部(不当成「搜不到」)', () => { |
|||
expect(filterSkillWords(words, '')).toEqual(words); |
|||
expect(filterSkillWords(words, ' ')).toEqual(words); |
|||
}); |
|||
|
|||
it('关键词忽略大小写与首尾空格', () => { |
|||
expect(filterSkillWords(words, 'react').map((w) => w.id)).toEqual(['s1']); |
|||
expect(filterSkillWords(words, ' REACT ').map((w) => w.id)).toEqual(['s1']); |
|||
}); |
|||
|
|||
it('中文关键词按子串匹配', () => { |
|||
expect(filterSkillWords(words, '前端').map((w) => w.id)).toEqual(['s3']); |
|||
}); |
|||
|
|||
it('搜不到时返回空数组(页面据此显示空状态)', () => { |
|||
expect(filterSkillWords(words, '不存在的技能')).toEqual([]); |
|||
}); |
|||
}); |
|||
|
|||
describe('fetchSkillLearningPath(技能学习路径)', () => { |
|||
it('按权重从高到低挑待补技能,优先级从 1 递增', async () => { |
|||
const path = await fetchSkillLearningPath('互联网/IT'); |
|||
|
|||
expect(path.items.length).toBeGreaterThanOrEqual(3); |
|||
expect(path.items.length).toBeLessThanOrEqual(6); |
|||
expect(path.items.map((item) => item.priority)).toEqual( |
|||
path.items.map((_, index) => index + 1), |
|||
); |
|||
// 权重降序:先补最硬的要求
|
|||
const weights = path.items.map((item) => item.weight); |
|||
expect(weights).toEqual([...weights].sort((a, b) => b - a)); |
|||
}); |
|||
|
|||
it('补齐后的目标匹配度高于当前匹配度,且不超过 95', async () => { |
|||
const path = await fetchSkillLearningPath('互联网/IT'); |
|||
|
|||
expect(path.targetPercent).toBeGreaterThan(path.matchPercent); |
|||
expect(path.targetPercent).toBeLessThanOrEqual(95); |
|||
}); |
|||
|
|||
it('技能词条数不足 6 条时不会凭空造出条目', async () => { |
|||
const path = await fetchSkillLearningPath('教育'); |
|||
expect(path.items).toHaveLength(3); |
|||
}); |
|||
|
|||
it('未知行业兜底到默认行业,并把实际使用的行业回传(不谎报成入参行业)', async () => { |
|||
const path = await fetchSkillLearningPath('外星行业'); |
|||
|
|||
expect(path.industry).toBe('互联网/IT'); |
|||
expect(path.items.length).toBeGreaterThan(0); |
|||
}); |
|||
|
|||
it('每条待补技能都有可读的推荐理由', async () => { |
|||
const path = await fetchSkillLearningPath('金融'); |
|||
|
|||
path.items.forEach((item) => { |
|||
expect(item.name).not.toBe(''); |
|||
expect(item.reason).toContain(String(item.weight)); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,62 @@ |
|||
import { |
|||
fetchJobEvaluationManagement, |
|||
fetchPendingOfferCount, |
|||
replyJobReview, |
|||
} from '../api/api'; |
|||
|
|||
/** |
|||
* 内存版存储:回复要能跨两次请求读回来,才测得出「刷新后回复还在」。 |
|||
* 变量名必须以 mock 开头,jest.mock 的工厂函数只能引用这类外部变量。 |
|||
*/ |
|||
const mockStore = new Map<string, string>(); |
|||
|
|||
jest.mock('../lib/storage', () => ({ |
|||
storageGet: jest.fn(async (key: string) => mockStore.get(key) ?? null), |
|||
storageSet: jest.fn(async (key: string, value: string) => { |
|||
mockStore.set(key, value); |
|||
}), |
|||
storageRemove: jest.fn(async (key: string) => { |
|||
mockStore.delete(key); |
|||
}), |
|||
})); |
|||
|
|||
beforeEach(() => { |
|||
mockStore.clear(); |
|||
}); |
|||
|
|||
describe('回复岗位评价', () => { |
|||
it('回复成功后返回 replied 与回复内容', async () => { |
|||
const review = await replyJobReview('jr2', '感谢反馈,我们已调整排班'); |
|||
expect(review.replied).toBe(true); |
|||
expect(review.replyContent).toBe('感谢反馈,我们已调整排班'); |
|||
}); |
|||
|
|||
it('回复内容落库,重新拉列表后仍然带着回复(不会刷新一次就变回「回复」)', async () => { |
|||
await replyJobReview('jr2', '感谢反馈'); |
|||
|
|||
const management = await fetchJobEvaluationManagement(); |
|||
const target = management.reviews.find((r) => r.id === 'jr2'); |
|||
expect(target?.replied).toBe(true); |
|||
expect(target?.replyContent).toBe('感谢反馈'); |
|||
}); |
|||
|
|||
it('空回复直接失败,不写入存储', async () => { |
|||
await expect(replyJobReview('jr2', ' ')).rejects.toThrow('回复内容不能为空'); |
|||
|
|||
const management = await fetchJobEvaluationManagement(); |
|||
const target = management.reviews.find((r) => r.id === 'jr2'); |
|||
expect(target?.replied).toBe(false); |
|||
}); |
|||
|
|||
it('回复率不由这 3 条样例算:分母是全部评价,样例算出来比原值更假', async () => { |
|||
const management = await fetchJobEvaluationManagement(); |
|||
// 样例里 3 条有 2 条已回复 = 67%,而看板上的回复率来自全部 128 条
|
|||
expect(management.replyRate).toBe(72); |
|||
}); |
|||
}); |
|||
|
|||
describe('待处理 Offer 数量', () => { |
|||
it('按状态统计,只数 pending', async () => { |
|||
expect(await fetchPendingOfferCount()).toBe(2); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,96 @@ |
|||
import { |
|||
clearInterviewScoreDraft, |
|||
fetchInterviewScore, |
|||
fetchInterviewScoreDraft, |
|||
saveInterviewScoreDraft, |
|||
} from '../api/api'; |
|||
|
|||
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage。
|
|||
// 草稿功能本身就是「存了要能读回来」,所以这里必须是带状态的 mock。
|
|||
jest.mock('../lib/storage', () => { |
|||
const store = new Map<string, string>(); |
|||
return { |
|||
__store: store, |
|||
storageGet: jest.fn(async (key: string) => store.get(key) ?? null), |
|||
storageSet: jest.fn(async (key: string, value: string) => { |
|||
store.set(key, value); |
|||
}), |
|||
storageRemove: jest.fn(async (key: string) => { |
|||
store.delete(key); |
|||
}), |
|||
}; |
|||
}); |
|||
|
|||
const store = (jest.requireMock('../lib/storage') as { __store: Map<string, string> }).__store; |
|||
|
|||
const INTERVIEW_A = 'interview-1'; |
|||
const INTERVIEW_B = 'interview-2'; |
|||
|
|||
beforeEach(() => { |
|||
store.clear(); |
|||
}); |
|||
|
|||
describe('面试评分草稿(本地暂存 + 恢复)', () => { |
|||
it('保存后能原样读回,且带回保存时间', async () => { |
|||
const score = await fetchInterviewScore(INTERVIEW_A); |
|||
const draft = await saveInterviewScoreDraft(INTERVIEW_A, { ...score, comment: '沟通清晰' }); |
|||
|
|||
expect(draft.score.comment).toBe('沟通清晰'); |
|||
expect(Number.isNaN(Date.parse(draft.savedAt))).toBe(false); |
|||
|
|||
const restored = await fetchInterviewScoreDraft(INTERVIEW_A); |
|||
expect(restored?.score.comment).toBe('沟通清晰'); |
|||
expect(restored?.savedAt).toBe(draft.savedAt); |
|||
}); |
|||
|
|||
it('未存过草稿时返回 null(页面据此不显示恢复提示)', async () => { |
|||
expect(await fetchInterviewScoreDraft('从没评过的面试')).toBeNull(); |
|||
}); |
|||
|
|||
it('不同面试的草稿互不串台', async () => { |
|||
const score = await fetchInterviewScore(INTERVIEW_A); |
|||
await saveInterviewScoreDraft(INTERVIEW_A, { ...score, comment: 'A 场的评价' }); |
|||
await saveInterviewScoreDraft(INTERVIEW_B, { ...score, comment: 'B 场的评价' }); |
|||
|
|||
expect((await fetchInterviewScoreDraft(INTERVIEW_A))?.score.comment).toBe('A 场的评价'); |
|||
expect((await fetchInterviewScoreDraft(INTERVIEW_B))?.score.comment).toBe('B 场的评价'); |
|||
}); |
|||
|
|||
it('同一场面试重复保存时覆盖前一份,不是并列存两份', async () => { |
|||
const score = await fetchInterviewScore(INTERVIEW_A); |
|||
await saveInterviewScoreDraft(INTERVIEW_A, { ...score, comment: '第一版' }); |
|||
await saveInterviewScoreDraft(INTERVIEW_A, { ...score, comment: '第二版' }); |
|||
|
|||
expect((await fetchInterviewScoreDraft(INTERVIEW_A))?.score.comment).toBe('第二版'); |
|||
}); |
|||
|
|||
it('存储内容损坏时当作没有草稿,不抛异常到页面', async () => { |
|||
await saveInterviewScoreDraft(INTERVIEW_A, await fetchInterviewScore(INTERVIEW_A)); |
|||
store.set(`interview:score-draft:${INTERVIEW_A}`, '{{坏掉的内容'); |
|||
|
|||
expect(await fetchInterviewScoreDraft(INTERVIEW_A)).toBeNull(); |
|||
}); |
|||
|
|||
it('清除后读不到草稿(提交成功或用户放弃后调用)', async () => { |
|||
const score = await fetchInterviewScore(INTERVIEW_A); |
|||
await saveInterviewScoreDraft(INTERVIEW_A, score); |
|||
|
|||
await clearInterviewScoreDraft(INTERVIEW_A); |
|||
|
|||
expect(await fetchInterviewScoreDraft(INTERVIEW_A)).toBeNull(); |
|||
}); |
|||
|
|||
it('清除只影响该场面试,其他场次的草稿保留', async () => { |
|||
const score = await fetchInterviewScore(INTERVIEW_A); |
|||
await saveInterviewScoreDraft(INTERVIEW_A, score); |
|||
await saveInterviewScoreDraft(INTERVIEW_B, score); |
|||
|
|||
await clearInterviewScoreDraft(INTERVIEW_A); |
|||
|
|||
expect(await fetchInterviewScoreDraft(INTERVIEW_B)).not.toBeNull(); |
|||
}); |
|||
|
|||
it('清除没存过草稿的面试时不报错(幂等)', async () => { |
|||
await expect(clearInterviewScoreDraft('从没评过的面试')).resolves.toBeUndefined(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,255 @@ |
|||
import { |
|||
clearOnboardingProfile, |
|||
fetchJobList, |
|||
fetchJobPreference, |
|||
saveJobPreference, |
|||
saveOnboardingProfile, |
|||
} from '../api/api'; |
|||
import { storageRemove, storageSet } from '../lib/storage'; |
|||
import type { Job, JobPreference, OnboardingProfile } from '../types'; |
|||
|
|||
// 内存版 storage mock(与 salary.test.ts / onboarding-profile.test.ts 同)。
|
|||
// 键是 api 层传入的原始键(无 youzhi: 前缀,前缀由真实 storage 封装补上)。
|
|||
jest.mock('../lib/storage', () => { |
|||
const store: Record<string, string> = {}; |
|||
return { |
|||
storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)), |
|||
storageSet: jest.fn(async (k: string, v: string) => { |
|||
store[k] = v; |
|||
}), |
|||
storageRemove: jest.fn(async (k: string) => { |
|||
delete store[k]; |
|||
}), |
|||
}; |
|||
}); |
|||
|
|||
/** 全部字段留空 / 为 null 的兜底资料,只覆盖测试关心的字段 */ |
|||
const BASE_PROFILE: OnboardingProfile = { |
|||
identity: 'candidate', |
|||
isStudent: null, |
|||
city: '', |
|||
expectedPositions: [], |
|||
expectedSalaryMin: '', |
|||
expectedSalaryMax: '', |
|||
resumeName: '', |
|||
gender: null, |
|||
birthYear: '', |
|||
birthMonth: '', |
|||
jobStatus: null, |
|||
disabledJobseeker: false, |
|||
firstWorkYear: '', |
|||
firstWorkMonth: '', |
|||
currentPosition: null, |
|||
company: '', |
|||
joinYear: '', |
|||
joinMonth: '', |
|||
skills: [], |
|||
workContent: '', |
|||
education: null, |
|||
eduType: null, |
|||
school: '', |
|||
avatar: null, |
|||
recruiter: null, |
|||
}; |
|||
|
|||
const makeProfile = (patch: Partial<OnboardingProfile>): OnboardingProfile => ({ |
|||
...BASE_PROFILE, |
|||
...patch, |
|||
}); |
|||
|
|||
/** |
|||
* mock 岗位(`src/api/api.ts` 的 MOCK_JOBS)固定为 9 条: |
|||
* | id | 职位 | 城市 | 薪资 | 分数 | |
|||
* | 1 | 高级前端工程师 | 北京 | 25-40 | 92 | |
|||
* | 2 | 产品经理 | 深圳 | 20-35 | 78 | |
|||
* | 3 | UI设计师 | 杭州 | 15-25 | 85 | |
|||
* | 4 | Java开发工程师 | 北京 | 18-30 | 65 | |
|||
* | 5 | 前端实习生 | 上海 | 6-9 | 60 | |
|||
* | 6 | 金融分析师 | 深圳 | 30-50 | 88 | |
|||
* | 7 | 课程顾问 | 北京 | 12-20 | 72 | |
|||
* | 8 | 医学代表 | 广州 | 15-25 | 75 | |
|||
* | 9 | 电池工程师 | 宁德 | 28-45 | 90 | |
|||
* 下面所有「决定性输入 → 决定性顺序」的断言都基于这张表。 |
|||
*/ |
|||
const DEFAULT_PARAMS = { sortBy: 'comprehensive' as const }; |
|||
|
|||
const idsOf = (jobs: Job[]): string[] => jobs.map((job) => job.id); |
|||
|
|||
describe('fetchJobList 岗位推荐匹配(默认推荐视图叠加偏好加分)', () => { |
|||
beforeEach(async () => { |
|||
await clearOnboardingProfile(); |
|||
await storageRemove('user:job-preference'); |
|||
}); |
|||
|
|||
it('无任何采集数据时,按岗位分数降序(不凭空加分)', async () => { |
|||
const { jobs, total } = await fetchJobList(DEFAULT_PARAMS); |
|||
expect(total).toBe(9); |
|||
expect(idsOf(jobs)).toEqual(['1', '9', '6', '3', '2', '8', '7', '4', '5']); |
|||
}); |
|||
|
|||
it('命中期望城市 +15:杭州岗位从第 4 位升到第 1 位', async () => { |
|||
await saveOnboardingProfile(makeProfile({ city: '杭州' })); |
|||
const { jobs } = await fetchJobList(DEFAULT_PARAMS); |
|||
// UI设计师(杭州,85)→ 85+15=100,越过原来的第 1 名(92)
|
|||
expect(idsOf(jobs)).toEqual(['3', '1', '9', '6', '2', '8', '7', '4', '5']); |
|||
}); |
|||
|
|||
it('命中期望职位 +20:职位名归一化后再比对', async () => { |
|||
await saveOnboardingProfile(makeProfile({ expectedPositions: ['前端开发工程师'] })); |
|||
const { jobs } = await fetchJobList(DEFAULT_PARAMS); |
|||
// 「前端开发工程师」⇒ 去掉「开发」「工程师」⇒「前端」
|
|||
// 岗位 1「高级前端工程师」⇒「前端」命中(+20 → 112),原本就在第 1 位
|
|||
// 岗位 5「前端实习生」⇒「前端」同样命中(+20 → 80),从末位升到第 5 位
|
|||
expect(idsOf(jobs)).toEqual(['1', '9', '6', '3', '5', '2', '8', '7', '4']); |
|||
}); |
|||
|
|||
it('期望薪资与岗位薪资区间有重叠 +10(含边界值)', async () => { |
|||
await saveOnboardingProfile( |
|||
makeProfile({ expectedSalaryMin: '35K', expectedSalaryMax: '45K' }), |
|||
); |
|||
const { jobs } = await fetchJobList(DEFAULT_PARAMS); |
|||
// 命中(+10):1(25-40) 2(20-35 边界 35>=35) 6(30-50) 9(28-45)
|
|||
// 未命中:3(15-25) 4(18-30) 5(6-9) 7(12-20) 8(15-25)
|
|||
// 结果:产品经理 78+10=88 越过 UI设计师 85
|
|||
expect(idsOf(jobs)).toEqual(['1', '9', '6', '2', '3', '8', '7', '4', '5']); |
|||
}); |
|||
|
|||
it('三项加分可叠加:命中越多排越前', async () => { |
|||
await saveOnboardingProfile( |
|||
makeProfile({ |
|||
city: '杭州', |
|||
expectedPositions: ['UI设计师'], |
|||
expectedSalaryMin: '20K', |
|||
expectedSalaryMax: '35K', |
|||
}), |
|||
); |
|||
const { jobs } = await fetchJobList(DEFAULT_PARAMS); |
|||
// UI设计师:85 + 城市15 + 职位20 + 薪资10(15-25 与 20-35 有交集)= 130
|
|||
expect(idsOf(jobs)[0]).toBe('3'); |
|||
expect(idsOf(jobs)[1]).toBe('1'); // 92 + 薪资10(25-40 与 20-35 有交集)
|
|||
}); |
|||
|
|||
it('无法解析的期望薪资不加分(不凭空命中)', async () => { |
|||
await saveOnboardingProfile( |
|||
makeProfile({ expectedSalaryMin: '面议', expectedSalaryMax: '面议' }), |
|||
); |
|||
const { jobs } = await fetchJobList(DEFAULT_PARAMS); |
|||
expect(idsOf(jobs)).toEqual(['1', '9', '6', '3', '2', '8', '7', '4', '5']); |
|||
}); |
|||
}); |
|||
|
|||
describe('fetchJobList 显式筛选优先(用户主动筛选时不叠加偏好加分)', () => { |
|||
/** |
|||
* 该偏好若被叠加,会把「课程顾问」(72+20+10=102)顶到北京岗位的第 1 位 |
|||
* —— 未叠加时北京岗位按原始分数序为 [1(92), 7(72), 4(65)]。 |
|||
* 城市留空:城市加分对同城岗位一视同仁,加与不加都改不了相对顺序,留着反而干扰断言。 |
|||
*/ |
|||
const PREFERENCE: JobPreference = { |
|||
position: '课程顾问', |
|||
city: '', |
|||
salary: '12K-20K', |
|||
workType: '全职', |
|||
status: '在职-月内到岗', |
|||
}; |
|||
|
|||
beforeEach(async () => { |
|||
await clearOnboardingProfile(); |
|||
await storageRemove('user:job-preference'); |
|||
await saveJobPreference(PREFERENCE); |
|||
}); |
|||
|
|||
it('显式选了城市:只做筛选,不叠加加分', async () => { |
|||
// 北京岗位原始顺序:1(92) / 7(72) / 4(65)
|
|||
const filtered = await fetchJobList({ ...DEFAULT_PARAMS, city: '北京' }); |
|||
expect(idsOf(filtered.jobs)).toEqual(['1', '7', '4']); |
|||
|
|||
// 同一份偏好在默认视图下确实生效(否则上面的断言可能是「偏好没读到」而假通过)
|
|||
const unfiltered = await fetchJobList(DEFAULT_PARAMS); |
|||
expect(idsOf(unfiltered.jobs)[0]).toBe('7'); // 72 + 职位20 + 薪资10 = 102
|
|||
}); |
|||
|
|||
it('显式输入了关键词:只做搜索,不叠加加分', async () => { |
|||
const matched = await fetchJobList({ ...DEFAULT_PARAMS, keyword: '工程师' }); |
|||
expect(idsOf(matched.jobs)).toEqual(['1', '9', '4']); |
|||
|
|||
// 上面这份偏好只对「课程顾问」加权,换成「电池工程师」再验一次默认视图
|
|||
await saveJobPreference({ ...PREFERENCE, position: '电池工程师', salary: '' }); |
|||
const filtered = await fetchJobList({ ...DEFAULT_PARAMS, keyword: '工程师' }); |
|||
expect(idsOf(filtered.jobs)).toEqual(['1', '9', '4']); |
|||
const unfiltered = await fetchJobList(DEFAULT_PARAMS); |
|||
expect(idsOf(unfiltered.jobs)[0]).toBe('9'); // 90 + 职位20 = 110
|
|||
}); |
|||
|
|||
it('城市传「全部城市」哨兵值等同未筛选,偏好生效', async () => { |
|||
const { jobs } = await fetchJobList({ ...DEFAULT_PARAMS, city: '全部城市' }); |
|||
expect(idsOf(jobs)[0]).toBe('7'); |
|||
}); |
|||
}); |
|||
|
|||
describe('fetchJobPreference 取值优先级(显式编辑 > 引导资料推导 > 空值)', () => { |
|||
beforeEach(async () => { |
|||
await clearOnboardingProfile(); |
|||
await storageRemove('user:job-preference'); |
|||
}); |
|||
|
|||
it('无任何数据时返回空值,工作类型补默认「全职」', async () => { |
|||
await expect(fetchJobPreference()).resolves.toEqual({ |
|||
position: '', |
|||
city: '', |
|||
salary: '', |
|||
workType: '全职', |
|||
status: '', |
|||
}); |
|||
}); |
|||
|
|||
it('由引导资料推导:期望职位取第 1 个,薪资拼成区间', async () => { |
|||
await saveOnboardingProfile( |
|||
makeProfile({ |
|||
city: '杭州', |
|||
expectedPositions: ['鸿蒙开发工程师', '前端开发工程师'], |
|||
expectedSalaryMin: '20K', |
|||
expectedSalaryMax: '35K', |
|||
jobStatus: '离职-随时到岗', |
|||
}), |
|||
); |
|||
await expect(fetchJobPreference()).resolves.toEqual({ |
|||
position: '鸿蒙开发工程师', |
|||
city: '杭州', |
|||
salary: '20K-35K', |
|||
workType: '全职', |
|||
status: '离职-随时到岗', |
|||
}); |
|||
}); |
|||
|
|||
it('期望薪资只填了一头时不拼区间(不留「20K-」这种半截值)', async () => { |
|||
await saveOnboardingProfile( |
|||
makeProfile({ expectedSalaryMin: '20K', expectedSalaryMax: '' }), |
|||
); |
|||
await expect(fetchJobPreference()).resolves.toMatchObject({ salary: '' }); |
|||
}); |
|||
|
|||
it('用户在求职意向页显式保存过时,以显式值为准', async () => { |
|||
await saveOnboardingProfile(makeProfile({ city: '杭州', expectedPositions: ['前端开发工程师'] })); |
|||
await saveJobPreference({ |
|||
position: '电池工程师', |
|||
city: '宁德', |
|||
salary: '', |
|||
workType: '全职', |
|||
status: '', |
|||
}); |
|||
|
|||
await expect(fetchJobPreference()).resolves.toMatchObject({ |
|||
position: '电池工程师', |
|||
city: '宁德', |
|||
}); |
|||
// 岗位排序同样按显式偏好:宁德岗位 90+15=105 越过杭州岗位 85+15=100
|
|||
const { jobs } = await fetchJobList(DEFAULT_PARAMS); |
|||
expect(idsOf(jobs)[0]).toBe('9'); |
|||
}); |
|||
|
|||
it('显式偏好存储损坏时回退到引导资料推导', async () => { |
|||
await saveOnboardingProfile(makeProfile({ city: '杭州' })); |
|||
await storageSet('user:job-preference', '{ 这不是合法 JSON'); |
|||
await expect(fetchJobPreference()).resolves.toMatchObject({ city: '杭州' }); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,221 @@ |
|||
import { |
|||
addChecklistItem, |
|||
buildChecklistText, |
|||
fetchOnboardingChecklist, |
|||
removeChecklistItem, |
|||
toggleChecklistItem, |
|||
} from '../api/api'; |
|||
import type { OnboardingChecklist } from '../types'; |
|||
|
|||
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage。
|
|||
// 这里用带状态的 Map 版 mock(不是恒返回 null 的空实现),
|
|||
// 否则「勾选后重新进页面还在」这类落库行为根本没被验证到。
|
|||
jest.mock('../lib/storage', () => { |
|||
const store = new Map<string, string>(); |
|||
return { |
|||
__store: store, |
|||
storageGet: jest.fn(async (key: string) => store.get(key) ?? null), |
|||
storageSet: jest.fn(async (key: string, value: string) => { |
|||
store.set(key, value); |
|||
}), |
|||
storageRemove: jest.fn(async (key: string) => { |
|||
store.delete(key); |
|||
}), |
|||
}; |
|||
}); |
|||
|
|||
const store = (jest.requireMock('../lib/storage') as { __store: Map<string, string> }).__store; |
|||
|
|||
/** 存储键(取自 api.ts,用于构造「存储内容损坏」的降级用例) */ |
|||
const DONE_KEY = 'onboarding:checklist-done'; |
|||
const CUSTOM_KEY = 'onboarding:checklist-custom'; |
|||
|
|||
/** 模板清单的基线:4 组共 18 条,初始已完成 3 条 */ |
|||
const TEMPLATE_TOTAL = 18; |
|||
const TEMPLATE_DONE = 3; |
|||
|
|||
function findItem(checklist: OnboardingChecklist, itemId: string) { |
|||
const found = checklist.groups.flatMap((group) => group.items).find((item) => item.id === itemId); |
|||
if (!found) throw new Error(`清单里找不到条目 ${itemId}`); |
|||
return found; |
|||
} |
|||
|
|||
function findGroup(checklist: OnboardingChecklist, groupId: string) { |
|||
return checklist.groups.find((group) => group.id === groupId); |
|||
} |
|||
|
|||
beforeEach(() => { |
|||
store.clear(); |
|||
}); |
|||
|
|||
describe('fetchOnboardingChecklist(入职准备清单)', () => { |
|||
it('首次拉取:模板四组共 18 条,已完成 3 条,没有自定义分组', async () => { |
|||
const checklist = await fetchOnboardingChecklist(); |
|||
expect(checklist.total).toBe(TEMPLATE_TOTAL); |
|||
expect(checklist.completed).toBe(TEMPLATE_DONE); |
|||
expect(checklist.groups.map((g) => g.id)).toEqual(['material', 'procedure', 'study', 'life']); |
|||
}); |
|||
|
|||
it('存储内容损坏时退回模板值,不把异常抛到页面', async () => { |
|||
store.set(DONE_KEY, '{不是合法 JSON'); |
|||
store.set(CUSTOM_KEY, '["也不是数组里的条目"]"'); |
|||
|
|||
const checklist = await fetchOnboardingChecklist(); |
|||
expect(checklist.total).toBe(TEMPLATE_TOTAL); |
|||
expect(checklist.completed).toBe(TEMPLATE_DONE); |
|||
}); |
|||
|
|||
it('自定义存储里放的不是数组时按没有自定义条目处理', async () => { |
|||
store.set(CUSTOM_KEY, '{"id":"custom-1"}'); |
|||
|
|||
const checklist = await fetchOnboardingChecklist(); |
|||
expect(findGroup(checklist, 'custom')).toBeUndefined(); |
|||
expect(checklist.total).toBe(TEMPLATE_TOTAL); |
|||
}); |
|||
}); |
|||
|
|||
describe('toggleChecklistItem(勾选落库)', () => { |
|||
it('勾选后计数 +1,且重新拉取仍然是勾上的', async () => { |
|||
await toggleChecklistItem('m4', true); |
|||
|
|||
// 换一次全新的读取,模拟退出页面再进来
|
|||
const checklist = await fetchOnboardingChecklist(); |
|||
expect(findItem(checklist, 'm4').done).toBe(true); |
|||
expect(checklist.completed).toBe(TEMPLATE_DONE + 1); |
|||
expect(checklist.total).toBe(TEMPLATE_TOTAL); |
|||
}); |
|||
|
|||
it('取消勾选后计数回落(不会只增不减)', async () => { |
|||
await toggleChecklistItem('m1', false); |
|||
|
|||
const checklist = await fetchOnboardingChecklist(); |
|||
expect(findItem(checklist, 'm1').done).toBe(false); |
|||
expect(checklist.completed).toBe(TEMPLATE_DONE - 1); |
|||
}); |
|||
|
|||
it('只影响被点的那一条,同组其他条目不受牵连', async () => { |
|||
await toggleChecklistItem('m4', true); |
|||
|
|||
const checklist = await fetchOnboardingChecklist(); |
|||
expect(findItem(checklist, 'm1').done).toBe(true); |
|||
expect(findItem(checklist, 'm2').done).toBe(true); |
|||
expect(findItem(checklist, 'm5').done).toBe(false); |
|||
}); |
|||
}); |
|||
|
|||
describe('addChecklistItem(新增自定义待办)', () => { |
|||
it('新增后出现「我的自定义待办」分组,总数 +1,且默认未完成', async () => { |
|||
const checklist = await addChecklistItem('找 HR 确认工位'); |
|||
|
|||
const customGroup = findGroup(checklist, 'custom'); |
|||
expect(customGroup).toBeDefined(); |
|||
expect(customGroup?.items).toHaveLength(1); |
|||
expect(customGroup?.items[0].title).toBe('找 HR 确认工位'); |
|||
expect(customGroup?.items[0].done).toBe(false); |
|||
expect(checklist.total).toBe(TEMPLATE_TOTAL + 1); |
|||
expect(checklist.completed).toBe(TEMPLATE_DONE); |
|||
}); |
|||
|
|||
it('首尾空格被裁掉', async () => { |
|||
const checklist = await addChecklistItem(' 准备入职材料 '); |
|||
expect(findGroup(checklist, 'custom')?.items[0].title).toBe('准备入职材料'); |
|||
}); |
|||
|
|||
it('标题只有空格时 reject,且不写入任何条目', async () => { |
|||
await expect(addChecklistItem(' ')).rejects.toThrow('待办内容不能为空'); |
|||
|
|||
const checklist = await fetchOnboardingChecklist(); |
|||
expect(checklist.total).toBe(TEMPLATE_TOTAL); |
|||
}); |
|||
|
|||
it('自定义条目勾选后也落库(勾选状态存在另一份带标题的存档里)', async () => { |
|||
const added = await addChecklistItem('找 HR 确认工位'); |
|||
const customId = findGroup(added, 'custom')?.items[0].id; |
|||
expect(customId).toBeDefined(); |
|||
|
|||
await toggleChecklistItem(customId as string, true); |
|||
|
|||
const checklist = await fetchOnboardingChecklist(); |
|||
expect(findItem(checklist, customId as string).done).toBe(true); |
|||
expect(checklist.completed).toBe(TEMPLATE_DONE + 1); |
|||
}); |
|||
}); |
|||
|
|||
describe('removeChecklistItem(删除自定义待办)', () => { |
|||
it('删掉唯一一条自定义待办后,自定义分组整个消失且计数回落', async () => { |
|||
const added = await addChecklistItem('找 HR 确认工位'); |
|||
const customId = findGroup(added, 'custom')?.items[0].id as string; |
|||
|
|||
const checklist = await removeChecklistItem(customId); |
|||
|
|||
expect(findGroup(checklist, 'custom')).toBeUndefined(); |
|||
expect(checklist.total).toBe(TEMPLATE_TOTAL); |
|||
expect(checklist.groups.flatMap((g) => g.items).some((i) => i.id === customId)).toBe(false); |
|||
}); |
|||
|
|||
it('删掉已勾选的自定义待办后,勾选记录一并清掉(completed 不虚高)', async () => { |
|||
const added = await addChecklistItem('找 HR 确认工位'); |
|||
const customId = findGroup(added, 'custom')?.items[0].id as string; |
|||
const checked = await toggleChecklistItem(customId, true); |
|||
expect(checked.completed).toBe(TEMPLATE_DONE + 1); |
|||
|
|||
const checklist = await removeChecklistItem(customId); |
|||
|
|||
expect(checklist.completed).toBe(TEMPLATE_DONE); |
|||
expect(checklist.total).toBe(TEMPLATE_TOTAL); |
|||
}); |
|||
|
|||
it('模板条目不允许删除(删了下次进页面又会出现)', async () => { |
|||
await expect(removeChecklistItem('m1')).rejects.toThrow('模板待办不能删除'); |
|||
|
|||
const checklist = await fetchOnboardingChecklist(); |
|||
expect(checklist.total).toBe(TEMPLATE_TOTAL); |
|||
}); |
|||
|
|||
it('删除后不影响其余自定义条目', async () => { |
|||
const first = await addChecklistItem('找 HR 确认工位'); |
|||
const firstId = findGroup(first, 'custom')?.items[0].id as string; |
|||
const second = await addChecklistItem('预约体检'); |
|||
const kept = findGroup(second, 'custom')?.items.find((i) => i.id !== firstId); |
|||
expect(kept).toBeDefined(); |
|||
|
|||
const checklist = await removeChecklistItem(firstId); |
|||
|
|||
const customGroup = findGroup(checklist, 'custom'); |
|||
expect(customGroup?.items).toHaveLength(1); |
|||
expect(customGroup?.items[0].title).toBe('预约体检'); |
|||
}); |
|||
}); |
|||
|
|||
describe('buildChecklistText(导出文本,纯函数)', () => { |
|||
it('含标题行、进度行、分组名与勾选标记', async () => { |
|||
const checklist = await fetchOnboardingChecklist(); |
|||
const text = buildChecklistText(checklist); |
|||
|
|||
expect(text).toContain(`入职准备清单(${checklist.company} · ${checklist.position})`); |
|||
expect(text).toContain(`已完成 ${checklist.completed}/${checklist.total}`); |
|||
checklist.groups.forEach((group) => { |
|||
expect(text).toContain(`【${group.title}】`); |
|||
}); |
|||
expect(text).toContain('[x] 身份证原件及复印件'); |
|||
expect(text).toContain('[ ] 入职体检报告'); |
|||
}); |
|||
|
|||
it('勾选标记与条目 done 一一对应,条数不缺不多', async () => { |
|||
const checklist = await fetchOnboardingChecklist(); |
|||
const text = buildChecklistText(checklist); |
|||
|
|||
const checked = (text.match(/^\[x\] /gm) ?? []).length; |
|||
const unchecked = (text.match(/^\[ \] /gm) ?? []).length; |
|||
expect(checked).toBe(checklist.completed); |
|||
expect(checked + unchecked).toBe(checklist.total); |
|||
}); |
|||
|
|||
it('自定义条目也会进导出文本', async () => { |
|||
const checklist = await addChecklistItem('找 HR 确认工位'); |
|||
const text = buildChecklistText(checklist); |
|||
|
|||
expect(text).toContain('【我的自定义待办】'); |
|||
expect(text).toContain('[ ] 找 HR 确认工位'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,162 @@ |
|||
import { fromOnboardingProfile, toOnboardingProfile } from '../api/api'; |
|||
import type { OnboardingData } from '../types'; |
|||
|
|||
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock(与 password.test.ts 同)
|
|||
jest.mock('../lib/storage', () => ({ |
|||
storageGet: jest.fn(async () => null), |
|||
storageSet: jest.fn(async () => undefined), |
|||
storageRemove: jest.fn(async () => undefined), |
|||
})); |
|||
|
|||
/** |
|||
* 每个字段都给一个「只属于它」的值,且与 BASE 不同。 |
|||
* 这样任何字段没被 fromOnboardingProfile 映射回去,都会回落到 BASE 的值而被断言抓到。 |
|||
*/ |
|||
const DATA: OnboardingData = { |
|||
phone: '13800138000', |
|||
code: '1234', |
|||
agreed: true, |
|||
wechatTicket: { token: 'wx-token', openid: 'wx-openid' }, |
|||
identity: 'candidate', |
|||
isStudent: true, |
|||
city: '杭州', |
|||
expectedPositions: ['鸿蒙开发工程师', '前端开发工程师'], |
|||
expectedSalaryMin: '20K', |
|||
expectedSalaryMax: '35K', |
|||
resumeName: '张小满', |
|||
gender: '男', |
|||
birthYear: '1998年', |
|||
birthMonth: '3月', |
|||
jobStatus: '离职-随时到岗', |
|||
disabledJobseeker: true, |
|||
firstWorkYear: '2021年', |
|||
firstWorkMonth: '6月', |
|||
currentPosition: '高级前端工程师', |
|||
company: '某某科技有限公司', |
|||
joinYear: '2023年', |
|||
joinMonth: '2月', |
|||
skills: ['React', 'TypeScript'], |
|||
workContent: '负责招聘端前端架构', |
|||
education: '硕士', |
|||
eduType: '非全日制', |
|||
school: '某某大学', |
|||
avatar: 'avatarDesigner', |
|||
recruiterCompany: '某某科技有限公司', |
|||
recruiterContact: '王招聘', |
|||
recruiterLicenseUri: 'file:///mock/license.jpg', |
|||
}; |
|||
|
|||
/** 兜底状态:每个字段都与 DATA 不同,用于识别「没被覆盖」的字段 */ |
|||
const BASE: OnboardingData = { |
|||
phone: '', |
|||
code: '', |
|||
agreed: false, |
|||
wechatTicket: null, |
|||
identity: null, |
|||
isStudent: null, |
|||
city: '', |
|||
expectedPositions: [], |
|||
expectedSalaryMin: '', |
|||
expectedSalaryMax: '', |
|||
resumeName: '', |
|||
gender: null, |
|||
birthYear: '', |
|||
birthMonth: '', |
|||
jobStatus: null, |
|||
disabledJobseeker: false, |
|||
firstWorkYear: '', |
|||
firstWorkMonth: '', |
|||
currentPosition: null, |
|||
company: '', |
|||
joinYear: '', |
|||
joinMonth: '', |
|||
skills: [], |
|||
workContent: '', |
|||
education: null, |
|||
eduType: null, |
|||
school: '', |
|||
avatar: null, |
|||
recruiterCompany: '', |
|||
recruiterContact: '', |
|||
recruiterLicenseUri: null, |
|||
}; |
|||
|
|||
describe('toOnboardingProfile(流程状态 → 持久化资料)', () => { |
|||
it('剔除登录瞬时字段,不落库', () => { |
|||
const profile = toOnboardingProfile(DATA); |
|||
const keys = Object.keys(profile); |
|||
expect(keys).not.toContain('phone'); |
|||
expect(keys).not.toContain('code'); |
|||
expect(keys).not.toContain('agreed'); |
|||
expect(keys).not.toContain('wechatTicket'); |
|||
}); |
|||
|
|||
it('求职者身份下不产生企业端认证信息', () => { |
|||
expect(toOnboardingProfile(DATA).recruiter).toBeNull(); |
|||
}); |
|||
|
|||
it('招聘者身份下公司名与联系人收进 recruiter(不再污染毕业学校)', () => { |
|||
const profile = toOnboardingProfile({ ...DATA, identity: 'recruiter' }); |
|||
expect(profile.recruiter).toEqual({ |
|||
company: DATA.recruiterCompany, |
|||
contactName: DATA.recruiterContact, |
|||
licenseUri: DATA.recruiterLicenseUri, |
|||
}); |
|||
expect(profile.school).toBe(DATA.school); |
|||
}); |
|||
}); |
|||
|
|||
describe('fromOnboardingProfile(持久化资料 → 流程状态)', () => { |
|||
it('全部资料字段原样还原', () => { |
|||
const restored = fromOnboardingProfile(toOnboardingProfile(DATA), BASE); |
|||
const { |
|||
phone, |
|||
code, |
|||
agreed, |
|||
wechatTicket, |
|||
recruiterCompany, |
|||
recruiterContact, |
|||
recruiterLicenseUri, |
|||
...profileFields |
|||
} = DATA; |
|||
// 资料字段逐项一致;未映射的字段会回落到 BASE 从而失败
|
|||
Object.entries(profileFields).forEach(([key, expected]) => { |
|||
expect(restored[key as keyof OnboardingData]).toEqual(expected); |
|||
}); |
|||
// 顺带确认这个测试本身是有效的:BASE 与 DATA 确实不同
|
|||
expect(restored.city).not.toBe(BASE.city); |
|||
expect(phone).toBe('13800138000'); |
|||
expect(code).toBe('1234'); |
|||
expect(agreed).toBe(true); |
|||
expect(wechatTicket).not.toBeNull(); |
|||
// 企业端三字段在求职者身份下刻意不落库,由下面的用例专门断言
|
|||
expect(recruiterCompany).toBe('某某科技有限公司'); |
|||
expect(recruiterContact).toBe('王招聘'); |
|||
expect(recruiterLicenseUri).toBe('file:///mock/license.jpg'); |
|||
}); |
|||
|
|||
it('求职者身份下不保留企业端字段', () => { |
|||
const restored = fromOnboardingProfile(toOnboardingProfile(DATA), BASE); |
|||
expect(restored.recruiterCompany).toBe(BASE.recruiterCompany); |
|||
expect(restored.recruiterContact).toBe(BASE.recruiterContact); |
|||
expect(restored.recruiterLicenseUri).toBe(BASE.recruiterLicenseUri); |
|||
}); |
|||
|
|||
it('登录瞬时字段沿用兜底状态,不被资料覆盖', () => { |
|||
const restored = fromOnboardingProfile(toOnboardingProfile(DATA), BASE); |
|||
expect(restored.phone).toBe(BASE.phone); |
|||
expect(restored.code).toBe(BASE.code); |
|||
expect(restored.agreed).toBe(BASE.agreed); |
|||
expect(restored.wechatTicket).toBe(BASE.wechatTicket); |
|||
}); |
|||
|
|||
it('企业端资料还原到 recruiterCompany / recruiterContact / recruiterLicenseUri', () => { |
|||
const restored = fromOnboardingProfile( |
|||
toOnboardingProfile({ ...DATA, identity: 'recruiter' }), |
|||
BASE, |
|||
); |
|||
expect(restored.recruiterCompany).toBe(DATA.recruiterCompany); |
|||
expect(restored.recruiterContact).toBe(DATA.recruiterContact); |
|||
expect(restored.recruiterLicenseUri).toBe(DATA.recruiterLicenseUri); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,240 @@ |
|||
/** |
|||
* 简历导出排版(纯函数)测试。 |
|||
* |
|||
* 只测 `src/utils/resumeHtml.ts`:HTML 生成、纸张尺寸、文件名清洗。 |
|||
* 原生落盘(expo-print)不在此覆盖 —— 它由 P7 的真机 smoke test 验证。 |
|||
*/ |
|||
import { buildResumeExportHtml, exportPageSize, sanitizeFileName } from '../utils'; |
|||
import type { Resume, ResumeExportSettings } from '../types'; |
|||
|
|||
const SETTINGS: ResumeExportSettings = { |
|||
format: 'pdf', |
|||
templateId: 'classic', |
|||
hideContact: false, |
|||
exportPassword: false, |
|||
paperSize: 'A4', |
|||
orientation: 'portrait', |
|||
fileName: '', |
|||
includeCover: false, |
|||
includeRecommendation: false, |
|||
}; |
|||
|
|||
function makeResume(patch: Partial<Resume> = {}): Resume { |
|||
return { |
|||
score: { score: 80, maxScore: 100, optimizePoints: [] }, |
|||
basicInfo: { |
|||
name: '张小满', |
|||
avatar: '张', |
|||
phone: '13800001111', |
|||
email: 'zhang@example.com', |
|||
location: '杭州', |
|||
}, |
|||
jobIntention: { |
|||
position: '鸿蒙开发工程师', |
|||
salary: '20K-35K', |
|||
city: '杭州', |
|||
status: 'active', |
|||
statusLabel: '在职-月内到岗', |
|||
}, |
|||
education: [], |
|||
experience: [], |
|||
projects: [], |
|||
skills: [], |
|||
selfEvaluation: { content: '' }, |
|||
...patch, |
|||
}; |
|||
} |
|||
|
|||
describe('exportPageSize', () => { |
|||
it('A4 纵向 = 595×842 pt', () => { |
|||
expect(exportPageSize('A4', 'portrait')).toEqual({ width: 595, height: 842 }); |
|||
}); |
|||
it('A4 横向把长边给宽度', () => { |
|||
expect(exportPageSize('A4', 'landscape')).toEqual({ width: 842, height: 595 }); |
|||
}); |
|||
it('Letter 纵向 = 612×792 pt', () => { |
|||
expect(exportPageSize('letter', 'portrait')).toEqual({ width: 612, height: 792 }); |
|||
}); |
|||
}); |
|||
|
|||
describe('sanitizeFileName', () => { |
|||
it('剔除路径分隔符与系统保留字符', () => { |
|||
expect(sanitizeFileName('张小满/我的:简历*?.pdf')).toBe('张小满我的简历.pdf'); |
|||
}); |
|||
it('首尾空格被清掉', () => { |
|||
expect(sanitizeFileName(' 我的简历 ')).toBe('我的简历'); |
|||
}); |
|||
it('清完为空回落「我的简历」', () => { |
|||
expect(sanitizeFileName('///')).toBe('我的简历'); |
|||
expect(sanitizeFileName(' ')).toBe('我的简历'); |
|||
}); |
|||
}); |
|||
|
|||
describe('buildResumeExportHtml', () => { |
|||
it('空板块整段略去(导出物里不写「未填写 X」)', () => { |
|||
const html = buildResumeExportHtml(makeResume(), SETTINGS); |
|||
expect(html).not.toContain('教育背景'); |
|||
expect(html).not.toContain('工作经历'); |
|||
expect(html).not.toContain('项目经历'); |
|||
expect(html).not.toContain('技能特长'); |
|||
expect(html).not.toContain('自我评价'); |
|||
// 有内容的板块仍在
|
|||
expect(html).toContain('求职意向'); |
|||
expect(html).toContain('鸿蒙开发工程师'); |
|||
}); |
|||
|
|||
it('隐藏联系方式时手机号与邮箱都不出现,但所在城市保留', () => { |
|||
const html = buildResumeExportHtml(makeResume(), { ...SETTINGS, hideContact: true }); |
|||
expect(html).not.toContain('13800001111'); |
|||
expect(html).not.toContain('zhang@example.com'); |
|||
expect(html).toContain('杭州'); |
|||
}); |
|||
|
|||
it('未隐藏联系方式时三者在同一行', () => { |
|||
const html = buildResumeExportHtml(makeResume(), SETTINGS); |
|||
expect(html).toContain('13800001111 · zhang@example.com · 杭州'); |
|||
}); |
|||
|
|||
it('用户填的 HTML 被转义,不会破坏结构', () => { |
|||
const html = buildResumeExportHtml( |
|||
makeResume({ |
|||
basicInfo: { |
|||
name: '<script>alert(1)</script>', |
|||
avatar: '', |
|||
phone: '', |
|||
email: '', |
|||
location: '', |
|||
}, |
|||
}), |
|||
SETTINGS, |
|||
); |
|||
expect(html).not.toContain('<script>alert(1)</script>'); |
|||
expect(html).toContain('<script>alert(1)</script>'); |
|||
}); |
|||
|
|||
it('姓名与职位都为空时用「未设置」占位,不编造人名', () => { |
|||
const html = buildResumeExportHtml( |
|||
makeResume({ |
|||
basicInfo: { name: '', avatar: '', phone: '', email: '', location: '' }, |
|||
jobIntention: { |
|||
position: '', |
|||
salary: '', |
|||
city: '', |
|||
status: 'active', |
|||
statusLabel: '', |
|||
}, |
|||
}), |
|||
SETTINGS, |
|||
); |
|||
expect(html).toContain('未设置'); |
|||
expect(html).not.toContain('李明'); |
|||
}); |
|||
|
|||
it('模板 id 非法时回落经典版式(不把 storage 里的值拼进 class)', () => { |
|||
const html = buildResumeExportHtml(makeResume(), { |
|||
...SETTINGS, |
|||
templateId: '"><script>x</script>', |
|||
}); |
|||
expect(html).toContain('class="v-classic"'); |
|||
expect(html).not.toContain('<script>x</script>'); |
|||
}); |
|||
|
|||
it('模板强调色进入 HTML:创意设计用 info 色', () => { |
|||
const html = buildResumeExportHtml(makeResume(), { ...SETTINGS, templateId: 'creative' }); |
|||
expect(html).toContain('class="v-creative"'); |
|||
expect(html).toContain('border-bottom: 2px solid #7C5CFC'); |
|||
}); |
|||
|
|||
it('封面页按开关生成', () => { |
|||
const without = buildResumeExportHtml(makeResume(), SETTINGS); |
|||
const withCover = buildResumeExportHtml(makeResume(), { ...SETTINGS, includeCover: true }); |
|||
expect(without).not.toContain('<section class="cover">'); |
|||
expect(withCover).toContain('<section class="cover">'); |
|||
expect(withCover).toContain('<div class="cover-name">张小满</div>'); |
|||
}); |
|||
|
|||
it('自荐信以自我评价为正文', () => { |
|||
const html = buildResumeExportHtml( |
|||
makeResume({ selfEvaluation: { content: '三年鸿蒙开发经验。' } }), |
|||
{ ...SETTINGS, includeRecommendation: true }, |
|||
); |
|||
expect(html).toContain('自荐信'); |
|||
expect(html).toContain('三年鸿蒙开发经验。'); |
|||
}); |
|||
|
|||
it('自我评价为空时不生成自荐信页(不编造内容)', () => { |
|||
const html = buildResumeExportHtml(makeResume(), { |
|||
...SETTINGS, |
|||
includeRecommendation: true, |
|||
}); |
|||
// 只能断言正文里没有自荐信区块:CSS 里三个版式的规则是恒定写死的
|
|||
expect(html).not.toContain('自荐信'); |
|||
expect(html).not.toContain('<section class="page letter">'); |
|||
}); |
|||
|
|||
it('AI 徽标不出现在导出物里(那是 App 内的辅助信息)', () => { |
|||
const html = buildResumeExportHtml( |
|||
makeResume({ |
|||
experience: [ |
|||
{ |
|||
id: 'exp-1', |
|||
company: '某某科技', |
|||
position: '前端工程师', |
|||
startDate: '2023年2月', |
|||
endDate: '至今', |
|||
description: '负责鸿蒙应用开发', |
|||
aiOptimized: true, |
|||
}, |
|||
], |
|||
}), |
|||
SETTINGS, |
|||
); |
|||
expect(html).toContain('某某科技'); |
|||
expect(html).toContain('2023年2月 - 至今'); |
|||
expect(html).not.toContain('AI'); |
|||
}); |
|||
|
|||
it('工作内容的多行换行不被压掉(white-space: pre-line)', () => { |
|||
const html = buildResumeExportHtml( |
|||
makeResume({ |
|||
experience: [ |
|||
{ |
|||
id: 'exp-1', |
|||
company: '某某科技', |
|||
position: '', |
|||
startDate: '', |
|||
endDate: '', |
|||
description: '第一行\n第二行', |
|||
aiOptimized: false, |
|||
}, |
|||
], |
|||
}), |
|||
SETTINGS, |
|||
); |
|||
expect(html).toContain('第一行\n第二行'); |
|||
expect(html).toContain('white-space: pre-line'); |
|||
}); |
|||
|
|||
it('教育条目的专业学历为空时不拼出半截分隔符', () => { |
|||
const html = buildResumeExportHtml( |
|||
makeResume({ |
|||
education: [ |
|||
{ id: 'edu-1', school: '某某大学', major: '', degree: '硕士', startYear: '2018', endYear: '' }, |
|||
], |
|||
}), |
|||
SETTINGS, |
|||
); |
|||
expect(html).toContain('硕士'); |
|||
expect(html).not.toContain('· 硕士'); |
|||
expect(html).toContain('2018'); |
|||
}); |
|||
|
|||
it('纸型与方向写进 @page / 页宽页高', () => { |
|||
const html = buildResumeExportHtml(makeResume(), { |
|||
...SETTINGS, |
|||
paperSize: 'letter', |
|||
orientation: 'landscape', |
|||
}); |
|||
expect(html).toContain('max-width: 612pt; margin: 0 auto;'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,363 @@ |
|||
import { |
|||
clearOnboardingProfile, |
|||
clearResumeOverride, |
|||
fetchResume, |
|||
fetchResumeExportSettings, |
|||
fetchResumeSavedAt, |
|||
optimizeResume, |
|||
optimizeResumeExperience, |
|||
optimizeResumeWithJD, |
|||
polishResumeSelfEvaluation, |
|||
saveOnboardingProfile, |
|||
saveResumeBasicInfo, |
|||
saveResumeEducation, |
|||
saveResumeExperience, |
|||
saveResumeExportSettings, |
|||
saveResumeProjects, |
|||
saveResumeSelfEvaluation, |
|||
saveResumeSkills, |
|||
saveResumeSnapshot, |
|||
} from '../api/api'; |
|||
import { storageRemove, storageSet } from '../lib/storage'; |
|||
import type { OnboardingProfile, ResumeExportSettings } from '../types'; |
|||
|
|||
// 内存版 storage mock(与 resume-profile.test.ts 同):键是 api 层传入的原始键
|
|||
jest.mock('../lib/storage', () => { |
|||
const store: Record<string, string> = {}; |
|||
return { |
|||
storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)), |
|||
storageSet: jest.fn(async (k: string, v: string) => { |
|||
store[k] = v; |
|||
}), |
|||
storageRemove: jest.fn(async (k: string) => { |
|||
delete store[k]; |
|||
}), |
|||
}; |
|||
}); |
|||
|
|||
const EMPTY_PROFILE: OnboardingProfile = { |
|||
identity: 'candidate', |
|||
isStudent: null, |
|||
city: '', |
|||
expectedPositions: [], |
|||
expectedSalaryMin: '', |
|||
expectedSalaryMax: '', |
|||
resumeName: '', |
|||
gender: null, |
|||
birthYear: '', |
|||
birthMonth: '', |
|||
jobStatus: null, |
|||
disabledJobseeker: false, |
|||
firstWorkYear: '', |
|||
firstWorkMonth: '', |
|||
currentPosition: null, |
|||
company: '', |
|||
joinYear: '', |
|||
joinMonth: '', |
|||
skills: [], |
|||
workContent: '', |
|||
education: null, |
|||
eduType: null, |
|||
school: '', |
|||
avatar: null, |
|||
recruiter: null, |
|||
}; |
|||
|
|||
const makeProfile = (patch: Partial<OnboardingProfile>): OnboardingProfile => ({ |
|||
...EMPTY_PROFILE, |
|||
...patch, |
|||
}); |
|||
|
|||
/** 引导采集齐全的一份资料(与 resume-profile.test.ts 的 FULL_PROFILE 同源) */ |
|||
const FULL_PROFILE = makeProfile({ |
|||
city: '杭州', |
|||
expectedPositions: ['鸿蒙开发工程师'], |
|||
expectedSalaryMin: '20K', |
|||
expectedSalaryMax: '35K', |
|||
resumeName: '张小满', |
|||
jobStatus: '在职-月内到岗', |
|||
currentPosition: '高级前端工程师', |
|||
company: '某某科技有限公司', |
|||
joinYear: '2023年', |
|||
joinMonth: '2月', |
|||
skills: ['React', 'TypeScript'], |
|||
workContent: '负责招聘端前端架构与组件库建设', |
|||
education: '硕士', |
|||
eduType: '非全日制', |
|||
school: '某某大学', |
|||
}); |
|||
|
|||
describe('简历覆盖层:用户编辑优先于引导派生', () => { |
|||
beforeEach(async () => { |
|||
await clearOnboardingProfile(); |
|||
await storageRemove('user:job-preference'); |
|||
await clearResumeOverride(); |
|||
await storageRemove('resume:export-settings'); |
|||
}); |
|||
|
|||
it('未编辑任何板块时与引导派生完全一致(合并的默认分支是恒等变换)', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
const resume = await fetchResume(); |
|||
expect(resume.basicInfo).toEqual({ |
|||
name: '张小满', |
|||
avatar: '张', |
|||
phone: '', |
|||
email: '', |
|||
location: '', |
|||
}); |
|||
expect(resume.education).toEqual([ |
|||
{ id: 'edu-onboarding', school: '某某大学', major: '', degree: '硕士 非全日制', startYear: '', endYear: '' }, |
|||
]); |
|||
expect(resume.experience).toEqual([ |
|||
{ |
|||
id: 'exp-onboarding', |
|||
company: '某某科技有限公司', |
|||
position: '高级前端工程师', |
|||
startDate: '2023年2月', |
|||
endDate: '至今', |
|||
description: '负责招聘端前端架构与组件库建设', |
|||
aiOptimized: false, |
|||
}, |
|||
]); |
|||
expect(resume.skills).toEqual([{ name: 'React' }, { name: 'TypeScript' }]); |
|||
}); |
|||
|
|||
it('保存基本信息后以覆盖层为准,头像字仍由接口层按姓名派生', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
await saveResumeBasicInfo({ |
|||
name: '王小二', |
|||
phone: '13800138000', |
|||
email: 'wang@example.com', |
|||
location: '上海', |
|||
}); |
|||
|
|||
const resume = await fetchResume(); |
|||
expect(resume.basicInfo).toEqual({ |
|||
name: '王小二', |
|||
avatar: '王', |
|||
phone: '13800138000', |
|||
email: 'wang@example.com', |
|||
location: '上海', |
|||
}); |
|||
expect(JSON.stringify(resume)).not.toContain('张小满'); |
|||
}); |
|||
|
|||
it('板块级独立:只编辑教育时,工作经历仍来自引导派生', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
await saveResumeEducation([{ id: 'edu-onboarding', school: '某某大学', major: '软件工程', degree: '硕士', startYear: '2018', endYear: '2021' }]); |
|||
|
|||
const resume = await fetchResume(); |
|||
expect(resume.education[0].major).toBe('软件工程'); |
|||
expect(resume.experience[0].id).toBe('exp-onboarding'); |
|||
expect(resume.skills).toEqual([{ name: 'React' }, { name: 'TypeScript' }]); |
|||
}); |
|||
|
|||
it('清空某个列表后不回落到引导资料(`??` 不是 `||`)', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
await saveResumeEducation([]); |
|||
|
|||
const resume = await fetchResume(); |
|||
expect(resume.education).toEqual([]); |
|||
// 未动过的板块不受影响
|
|||
expect(resume.experience).toHaveLength(1); |
|||
}); |
|||
|
|||
it('编辑引导派生出来的那一条不会把整个板块改丢', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
const before = await fetchResume(); |
|||
// 页面交回的就是它此刻看到的列表(含 id 为 edu-onboarding 的那条),只改学校
|
|||
await saveResumeEducation(before.education.map((it) => ({ ...it, school: '某某大学(改名后)' }))); |
|||
|
|||
const resume = await fetchResume(); |
|||
expect(resume.education).toHaveLength(1); |
|||
expect(resume.education[0].id).toBe('edu-onboarding'); |
|||
expect(resume.education[0].school).toBe('某某大学(改名后)'); |
|||
expect(resume.education[0].degree).toBe('硕士 非全日制'); |
|||
}); |
|||
|
|||
it('新增条目由接口层补 id,同一毫秒连续新增两条也不会撞 id', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
const items = [ |
|||
{ school: 'A 大学', major: '', degree: '本科', startYear: '2014', endYear: '2018' }, |
|||
{ school: 'B 大学', major: '', degree: '硕士', startYear: '2018', endYear: '2021' }, |
|||
]; |
|||
const saved = await saveResumeEducation(items); |
|||
expect(saved.every((it) => it.id !== '')).toBe(true); |
|||
expect(saved[0].id).not.toBe(saved[1].id); |
|||
|
|||
const resume = await fetchResume(); |
|||
expect(resume.education.map((it) => it.school)).toEqual(['A 大学', 'B 大学']); |
|||
}); |
|||
|
|||
it('重复 id 会被去重(页面拼接出错也不会产生重复列表 key)', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
const saved = await saveResumeEducation([ |
|||
{ id: 'edu-x', school: 'A 大学', major: '', degree: '', startYear: '', endYear: '' }, |
|||
{ id: 'edu-x', school: 'B 大学', major: '', degree: '', startYear: '', endYear: '' }, |
|||
]); |
|||
expect(saved).toHaveLength(1); |
|||
expect(saved[0].school).toBe('B 大学'); |
|||
}); |
|||
|
|||
it('工作经历与项目经历同样走覆盖层', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
await saveResumeExperience([ |
|||
{ |
|||
id: 'exp-onboarding', |
|||
company: '某某科技有限公司', |
|||
position: '前端负责人', |
|||
startDate: '2023年2月', |
|||
endDate: '至今', |
|||
description: '负责组件库建设', |
|||
aiOptimized: false, |
|||
}, |
|||
]); |
|||
await saveResumeProjects([{ name: '招聘端重构', role: '前端负责人', description: '首屏从 3.5s 降至 1.2s', aiOptimized: false }]); |
|||
|
|||
const resume = await fetchResume(); |
|||
expect(resume.experience[0].position).toBe('前端负责人'); |
|||
expect(resume.projects[0].name).toBe('招聘端重构'); |
|||
expect(resume.projects[0].id).not.toBe(''); |
|||
}); |
|||
|
|||
it('技能保存在接口层规整:trim / 去空 / 去重', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
const saved = await saveResumeSkills([{ name: ' React ' }, { name: 'React' }, { name: '' }, { name: 'TS' }]); |
|||
expect(saved).toEqual([{ name: 'React' }, { name: 'TS' }]); |
|||
await expect(fetchResume()).resolves.toMatchObject({ |
|||
skills: [{ name: 'React' }, { name: 'TS' }], |
|||
}); |
|||
}); |
|||
|
|||
it('自我评价保存与清空', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
await saveResumeSelfEvaluation(' 3 年前端经验 '); |
|||
await expect(fetchResume()).resolves.toMatchObject({ selfEvaluation: { content: '3 年前端经验' } }); |
|||
|
|||
await saveResumeSelfEvaluation(' '); |
|||
await expect(fetchResume()).resolves.toMatchObject({ selfEvaluation: { content: '' } }); |
|||
}); |
|||
|
|||
it('覆盖层存储损坏时按「未编辑过」处理,回落引导派生', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
await storageSet('resume:override', '{oops'); |
|||
const resume = await fetchResume(); |
|||
expect(resume.education[0].id).toBe('edu-onboarding'); |
|||
}); |
|||
|
|||
it('完全没有引导资料时仍回落演示简历(保护既有行为)', async () => { |
|||
await clearOnboardingProfile(); |
|||
const resume = await fetchResume(); |
|||
expect(resume.basicInfo.name).toBe('李明'); |
|||
}); |
|||
}); |
|||
|
|||
describe('简历「保存到本机」', () => { |
|||
beforeEach(async () => { |
|||
await clearOnboardingProfile(); |
|||
await clearResumeOverride(); |
|||
}); |
|||
|
|||
it('从未保存过时保存时间为空串', async () => { |
|||
await expect(fetchResumeSavedAt()).resolves.toBe(''); |
|||
}); |
|||
|
|||
it('保存后写入保存时间,且当前简历被固化(再改引导资料不再影响已保存的板块)', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
const resume = await fetchResume(); |
|||
const { savedAt } = await saveResumeSnapshot(resume); |
|||
|
|||
expect(savedAt).not.toBe(''); |
|||
await expect(fetchResumeSavedAt()).resolves.toBe(savedAt); |
|||
|
|||
// 固化后引导资料里的姓名变了,已保存的简历仍是保存那一刻的内容
|
|||
await saveOnboardingProfile(makeProfile({ ...FULL_PROFILE, resumeName: '改过的名字' })); |
|||
await expect(fetchResume()).resolves.toMatchObject({ basicInfo: { name: '张小满' } }); |
|||
}); |
|||
|
|||
it('退出登录会清掉覆盖层与保存时间(不让下一位用户看到上一位的编辑)', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
await saveResumeSnapshot(await fetchResume()); |
|||
await clearResumeOverride(); |
|||
|
|||
await expect(fetchResumeSavedAt()).resolves.toBe(''); |
|||
await expect(fetchResume()).resolves.toMatchObject({ basicInfo: { name: '张小满' } }); |
|||
// 引导资料还在(clearResumeOverride 只清简历编辑,不动引导资料)
|
|||
await expect(fetchResume()).resolves.toMatchObject({ education: [{ school: '某某大学' }] }); |
|||
}); |
|||
}); |
|||
|
|||
describe('AI 能力未接入时的诚实口径', () => { |
|||
beforeEach(async () => { |
|||
await clearOnboardingProfile(); |
|||
await clearResumeOverride(); |
|||
}); |
|||
|
|||
it('三个 AI 入口都返回 available:false,且调用前后简历完全不变', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
const before = await fetchResume(); |
|||
|
|||
const results = await Promise.all([ |
|||
optimizeResumeExperience('exp-onboarding'), |
|||
polishResumeSelfEvaluation('随便写点什么'), |
|||
optimizeResumeWithJD('岗位 JD'), |
|||
]); |
|||
for (const result of results) { |
|||
expect(result.available).toBe(false); |
|||
expect(result.message).not.toBe(''); |
|||
} |
|||
await expect(fetchResume()).resolves.toEqual(before); |
|||
}); |
|||
|
|||
it('一键优化保留用户编辑,且不给任何条目置「已优化」', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
await saveResumeEducation([{ id: 'edu-onboarding', school: '某某大学', major: '软件工程', degree: '硕士', startYear: '', endYear: '' }]); |
|||
|
|||
const { resume, score } = await optimizeResume(); |
|||
expect(score).toBe(92); |
|||
expect(resume.score.optimizePoints.every((p) => p.optimized)).toBe(true); |
|||
// 用户编辑被保留
|
|||
expect(resume.education[0].major).toBe('软件工程'); |
|||
// 正文没有被改写,就不该显示「已优化」徽标
|
|||
expect(resume.experience.every((it) => !it.aiOptimized)).toBe(true); |
|||
expect(resume.projects.every((it) => !it.aiOptimized)).toBe(true); |
|||
}); |
|||
}); |
|||
|
|||
describe('导出设置落 storage', () => { |
|||
const CUSTOM: ResumeExportSettings = { |
|||
format: 'pdf', |
|||
templateId: 'tech', |
|||
hideContact: true, |
|||
exportPassword: false, |
|||
paperSize: 'letter', |
|||
orientation: 'landscape', |
|||
fileName: '我的简历', |
|||
includeCover: true, |
|||
includeRecommendation: false, |
|||
}; |
|||
|
|||
beforeEach(async () => { |
|||
await storageRemove('resume:export-settings'); |
|||
}); |
|||
|
|||
it('保存后可回读(不再只改模块内常量)', async () => { |
|||
await saveResumeExportSettings(CUSTOM); |
|||
await expect(fetchResumeExportSettings()).resolves.toEqual(CUSTOM); |
|||
}); |
|||
|
|||
it('旧数据缺后加的字段时用默认值补齐', async () => { |
|||
await storageSet('resume:export-settings', JSON.stringify({ format: 'word' })); |
|||
await expect(fetchResumeExportSettings()).resolves.toMatchObject({ |
|||
format: 'word', |
|||
templateId: 'classic', |
|||
paperSize: 'A4', |
|||
orientation: 'portrait', |
|||
}); |
|||
}); |
|||
|
|||
it('存储损坏时回落默认值', async () => { |
|||
await storageSet('resume:export-settings', '{oops'); |
|||
await expect(fetchResumeExportSettings()).resolves.toMatchObject({ format: 'pdf', templateId: 'classic' }); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,245 @@ |
|||
import { |
|||
clearOnboardingProfile, |
|||
fetchResume, |
|||
optimizeResume, |
|||
saveJobPreference, |
|||
saveOnboardingProfile, |
|||
} from '../api/api'; |
|||
import { storageRemove } from '../lib/storage'; |
|||
import type { OnboardingProfile } from '../types'; |
|||
|
|||
// 内存版 storage mock(与 job-match.test.ts 同):键是 api 层传入的原始键
|
|||
jest.mock('../lib/storage', () => { |
|||
const store: Record<string, string> = {}; |
|||
return { |
|||
storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)), |
|||
storageSet: jest.fn(async (k: string, v: string) => { |
|||
store[k] = v; |
|||
}), |
|||
storageRemove: jest.fn(async (k: string) => { |
|||
delete store[k]; |
|||
}), |
|||
}; |
|||
}); |
|||
|
|||
/** 全部字段留空 / 为 null:等价于用户一路「跳过,直接进入」走到落地 */ |
|||
const EMPTY_PROFILE: OnboardingProfile = { |
|||
identity: 'candidate', |
|||
isStudent: null, |
|||
city: '', |
|||
expectedPositions: [], |
|||
expectedSalaryMin: '', |
|||
expectedSalaryMax: '', |
|||
resumeName: '', |
|||
gender: null, |
|||
birthYear: '', |
|||
birthMonth: '', |
|||
jobStatus: null, |
|||
disabledJobseeker: false, |
|||
firstWorkYear: '', |
|||
firstWorkMonth: '', |
|||
currentPosition: null, |
|||
company: '', |
|||
joinYear: '', |
|||
joinMonth: '', |
|||
skills: [], |
|||
workContent: '', |
|||
education: null, |
|||
eduType: null, |
|||
school: '', |
|||
avatar: null, |
|||
recruiter: null, |
|||
}; |
|||
|
|||
const makeProfile = (patch: Partial<OnboardingProfile>): OnboardingProfile => ({ |
|||
...EMPTY_PROFILE, |
|||
...patch, |
|||
}); |
|||
|
|||
/** 引导采集齐全的一份资料(在职,且填了学历/学校/公司/职位/入职时间/技能/工作内容) */ |
|||
const FULL_PROFILE = makeProfile({ |
|||
city: '杭州', |
|||
expectedPositions: ['鸿蒙开发工程师', '前端开发工程师'], |
|||
expectedSalaryMin: '20K', |
|||
expectedSalaryMax: '35K', |
|||
resumeName: '张小满', |
|||
jobStatus: '在职-月内到岗', |
|||
currentPosition: '高级前端工程师', |
|||
company: '某某科技有限公司', |
|||
joinYear: '2023年', |
|||
joinMonth: '2月', |
|||
skills: ['React', 'TypeScript', '鸿蒙'], |
|||
workContent: '负责招聘端前端架构与组件库建设', |
|||
education: '硕士', |
|||
eduType: '非全日制', |
|||
school: '某某大学', |
|||
}); |
|||
|
|||
describe('fetchResume 简历内容取自引导资料', () => { |
|||
beforeEach(async () => { |
|||
await clearOnboardingProfile(); |
|||
await storageRemove('user:job-preference'); |
|||
}); |
|||
|
|||
it('完全没有引导资料时才回落到演示简历', async () => { |
|||
const resume = await fetchResume(); |
|||
expect(resume.basicInfo.name).toBe('李明'); |
|||
expect(resume.education[0].school).toBe('北京大学'); |
|||
}); |
|||
|
|||
it('有资料时不再出现演示简历的姓名 / 学历 / 公司', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
const resume = await fetchResume(); |
|||
const serialized = JSON.stringify(resume); |
|||
expect(serialized).not.toContain('李明'); |
|||
expect(serialized).not.toContain('北京大学'); |
|||
expect(serialized).not.toContain('字节跳动'); |
|||
expect(serialized).not.toContain('138-8888-8888'); |
|||
}); |
|||
|
|||
it('一路跳过(资料全空)时各板块为空,不拿演示人设顶上', async () => { |
|||
await saveOnboardingProfile(EMPTY_PROFILE); |
|||
const resume = await fetchResume(); |
|||
expect(resume.basicInfo).toEqual({ |
|||
name: '', |
|||
avatar: '', |
|||
phone: '', |
|||
email: '', |
|||
location: '', |
|||
}); |
|||
expect(resume.education).toEqual([]); |
|||
expect(resume.experience).toEqual([]); |
|||
expect(resume.projects).toEqual([]); |
|||
expect(resume.skills).toEqual([]); |
|||
expect(resume.selfEvaluation).toEqual({ content: '' }); |
|||
expect(resume.jobIntention).toMatchObject({ position: '', salary: '', city: '', statusLabel: '' }); |
|||
}); |
|||
|
|||
it('采集齐全时逐项映射到简历', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
const resume = await fetchResume(); |
|||
|
|||
// 姓名 + 头像字(头像字取姓名首字,与页面展示的姓名一致)
|
|||
expect(resume.basicInfo.name).toBe('张小满'); |
|||
expect(resume.basicInfo.avatar).toBe('张'); |
|||
// 引导采集的是「期望城市」,不是「现居城市」,不拿它冒充所在城市
|
|||
expect(resume.basicInfo.location).toBe(''); |
|||
|
|||
// 求职意向:期望职位取第 1 个(与「我的」页偏好卡片同一个值),薪资拼成区间
|
|||
expect(resume.jobIntention.position).toBe('鸿蒙开发工程师'); |
|||
expect(resume.jobIntention.salary).toBe('20K-35K'); |
|||
expect(resume.jobIntention.city).toBe('杭州'); |
|||
|
|||
// 教育背景:学校 + 学历/学习形式;专业与年份引导未采集 → 留空
|
|||
expect(resume.education).toEqual([ |
|||
{ |
|||
id: 'edu-onboarding', |
|||
school: '某某大学', |
|||
major: '', |
|||
degree: '硕士 非全日制', |
|||
startYear: '', |
|||
endYear: '', |
|||
}, |
|||
]); |
|||
|
|||
// 工作经历:在职 → 结束时间写「至今」
|
|||
expect(resume.experience).toEqual([ |
|||
{ |
|||
id: 'exp-onboarding', |
|||
company: '某某科技有限公司', |
|||
position: '高级前端工程师', |
|||
startDate: '2023年2月', |
|||
endDate: '至今', |
|||
description: '负责招聘端前端架构与组件库建设', |
|||
aiOptimized: false, |
|||
}, |
|||
]); |
|||
|
|||
// 技能按填写顺序映射成标签
|
|||
expect(resume.skills).toEqual([{ name: 'React' }, { name: 'TypeScript' }, { name: '鸿蒙' }]); |
|||
}); |
|||
|
|||
it('离职时不写「至今」(结束时间引导未采集,留空而不是编一个)', async () => { |
|||
await saveOnboardingProfile( |
|||
makeProfile({ company: '某某科技有限公司', jobStatus: '离职-随时到岗' }), |
|||
); |
|||
const resume = await fetchResume(); |
|||
expect(resume.experience[0].endDate).toBe(''); |
|||
}); |
|||
|
|||
it('只填了公司时,职位与工作内容留空(页面据此隐藏对应行)', async () => { |
|||
await saveOnboardingProfile(makeProfile({ company: '某某科技有限公司' })); |
|||
const resume = await fetchResume(); |
|||
expect(resume.experience).toHaveLength(1); |
|||
expect(resume.experience[0].position).toBe(''); |
|||
expect(resume.experience[0].description).toBe(''); |
|||
}); |
|||
|
|||
it('没填公司时不产出工作经历条目(只有入职年月不构成一段经历)', async () => { |
|||
await saveOnboardingProfile(makeProfile({ joinYear: '2023年', joinMonth: '2月' })); |
|||
const resume = await fetchResume(); |
|||
expect(resume.experience).toEqual([]); |
|||
}); |
|||
|
|||
it('期望薪资只填了一头时与偏好卡片一致为空(不留「20K-」这种半截值)', async () => { |
|||
await saveOnboardingProfile(makeProfile({ expectedSalaryMin: '20K' })); |
|||
const resume = await fetchResume(); |
|||
expect(resume.jobIntention.salary).toBe(''); |
|||
}); |
|||
|
|||
it('求职状态「在职-暂不考虑」映射为不积极,其余为积极', async () => { |
|||
await saveOnboardingProfile(makeProfile({ jobStatus: '在职-暂不考虑' })); |
|||
await expect(fetchResume()).resolves.toMatchObject({ |
|||
jobIntention: { status: 'inactive', statusLabel: '在职-暂不考虑' }, |
|||
}); |
|||
|
|||
await saveOnboardingProfile(makeProfile({ jobStatus: '离职-随时到岗' })); |
|||
await expect(fetchResume()).resolves.toMatchObject({ |
|||
jobIntention: { status: 'active', statusLabel: '离职-随时到岗' }, |
|||
}); |
|||
}); |
|||
|
|||
it('求职意向读显式编辑过的偏好(与「我的」页偏好卡片同一个答案)', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
await saveJobPreference({ |
|||
position: '电池工程师', |
|||
city: '宁德', |
|||
salary: '30K-50K', |
|||
workType: '全职', |
|||
status: '在职-考虑机会', |
|||
}); |
|||
await expect(fetchResume()).resolves.toMatchObject({ |
|||
jobIntention: { |
|||
position: '电池工程师', |
|||
city: '宁德', |
|||
salary: '30K-50K', |
|||
status: 'active', |
|||
statusLabel: '在职-考虑机会', |
|||
}, |
|||
}); |
|||
}); |
|||
|
|||
it('AI 优化后仍是用户自己的简历(不换成演示人设)', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
const { resume, score } = await optimizeResume(); |
|||
expect(resume.basicInfo.name).toBe('张小满'); |
|||
expect(resume.experience[0].company).toBe('某某科技有限公司'); |
|||
expect(resume.education[0].school).toBe('某某大学'); |
|||
// 优化只更新评分块:分数变化、优化点全部标记完成
|
|||
expect(score).toBe(92); |
|||
expect(resume.score.optimizePoints.every((p) => p.optimized)).toBe(true); |
|||
// AI 改写的正文 mock 阶段生成不了,因此不替换用户真实填写的工作内容
|
|||
expect(resume.experience[0].description).toBe('负责招聘端前端架构与组件库建设'); |
|||
expect(JSON.stringify(resume)).not.toContain('李明'); |
|||
}); |
|||
|
|||
it('未采集的板块恒为空:项目经历、自我评价、手机号、邮箱', async () => { |
|||
await saveOnboardingProfile(FULL_PROFILE); |
|||
const resume = await fetchResume(); |
|||
expect(resume.projects).toEqual([]); |
|||
expect(resume.selfEvaluation.content).toBe(''); |
|||
// 登录手机号属瞬时字段,按 PIPL 不落库,因此简历里也拿不到
|
|||
expect(resume.basicInfo.phone).toBe(''); |
|||
expect(resume.basicInfo.email).toBe(''); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,74 @@ |
|||
import { applySalaryMin, salaryMaxOptions } from '../api/api'; |
|||
import { SALARY_MAX_OPTIONS, SALARY_MIN_OPTIONS } from '../constants'; |
|||
|
|||
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock(与 password.test.ts 同)
|
|||
jest.mock('../lib/storage', () => ({ |
|||
storageGet: jest.fn(async () => null), |
|||
storageSet: jest.fn(async () => undefined), |
|||
storageRemove: jest.fn(async () => undefined), |
|||
})); |
|||
|
|||
describe('salaryMaxOptions(期望月薪「最高」档位过滤)', () => { |
|||
it('未选最低值时给出完整阶梯', () => { |
|||
expect(salaryMaxOptions('')).toEqual(SALARY_MAX_OPTIONS); |
|||
}); |
|||
|
|||
it('最低值无法解析时同样给出完整阶梯(不凭空砍档位)', () => { |
|||
expect(salaryMaxOptions('面议')).toEqual(SALARY_MAX_OPTIONS); |
|||
}); |
|||
|
|||
it('过滤掉低于已选最低值的档位', () => { |
|||
// 最低 20K ⇒ 最高只能从 20K 起
|
|||
expect(salaryMaxOptions('20K')).toEqual(['20K', '25K', '30K', '40K', '60K']); |
|||
}); |
|||
|
|||
it('最低值取阶梯最小值时不过滤任何档位', () => { |
|||
expect(salaryMaxOptions('5K')).toEqual(SALARY_MAX_OPTIONS); |
|||
}); |
|||
|
|||
it('过滤结果始终是阶梯的子集且保持原有升序', () => { |
|||
SALARY_MIN_OPTIONS.forEach((min) => { |
|||
const options = salaryMaxOptions(min); |
|||
expect(options.length).toBeGreaterThan(0); |
|||
expect(SALARY_MAX_OPTIONS).toEqual(expect.arrayContaining(options)); |
|||
expect(options).toEqual([...options].sort((a, b) => parseInt(a, 10) - parseInt(b, 10))); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('applySalaryMin(改选最低期望月薪后校正最高值)', () => { |
|||
it('原最高值不低于新最低值时保留', () => { |
|||
expect(applySalaryMin('20K', '30K')).toEqual({ |
|||
expectedSalaryMin: '20K', |
|||
expectedSalaryMax: '30K', |
|||
}); |
|||
}); |
|||
|
|||
it('最低值与最高值相等时保留(闭区间,不误清)', () => { |
|||
expect(applySalaryMin('20K', '20K')).toEqual({ |
|||
expectedSalaryMin: '20K', |
|||
expectedSalaryMax: '20K', |
|||
}); |
|||
}); |
|||
|
|||
it('原最高值低于新最低值时清空最高值', () => { |
|||
expect(applySalaryMin('30K', '15K')).toEqual({ |
|||
expectedSalaryMin: '30K', |
|||
expectedSalaryMax: '', |
|||
}); |
|||
}); |
|||
|
|||
it('尚未选过最高值时保持为空', () => { |
|||
expect(applySalaryMin('30K', '')).toEqual({ |
|||
expectedSalaryMin: '30K', |
|||
expectedSalaryMax: '', |
|||
}); |
|||
}); |
|||
|
|||
it('最低值无法解析时不动最高值', () => { |
|||
expect(applySalaryMin('面议', '15K')).toEqual({ |
|||
expectedSalaryMin: '面议', |
|||
expectedSalaryMax: '15K', |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,97 @@ |
|||
import { clearOnboardingProfile, fetchUserProfile, saveOnboardingProfile } from '../api/api'; |
|||
import type { OnboardingProfile } from '../types'; |
|||
|
|||
// 内存版 storage mock(与 job-match.test.ts 同):键是 api 层传入的原始键
|
|||
jest.mock('../lib/storage', () => { |
|||
const store: Record<string, string> = {}; |
|||
return { |
|||
storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)), |
|||
storageSet: jest.fn(async (k: string, v: string) => { |
|||
store[k] = v; |
|||
}), |
|||
storageRemove: jest.fn(async (k: string) => { |
|||
delete store[k]; |
|||
}), |
|||
}; |
|||
}); |
|||
|
|||
/** 全部字段留空 / 为 null:等价于用户一路「跳过,直接进入」走到落地 */ |
|||
const EMPTY_PROFILE: OnboardingProfile = { |
|||
identity: 'candidate', |
|||
isStudent: null, |
|||
city: '', |
|||
expectedPositions: [], |
|||
expectedSalaryMin: '', |
|||
expectedSalaryMax: '', |
|||
resumeName: '', |
|||
gender: null, |
|||
birthYear: '', |
|||
birthMonth: '', |
|||
jobStatus: null, |
|||
disabledJobseeker: false, |
|||
firstWorkYear: '', |
|||
firstWorkMonth: '', |
|||
currentPosition: null, |
|||
company: '', |
|||
joinYear: '', |
|||
joinMonth: '', |
|||
skills: [], |
|||
workContent: '', |
|||
education: null, |
|||
eduType: null, |
|||
school: '', |
|||
avatar: null, |
|||
recruiter: null, |
|||
}; |
|||
|
|||
const makeProfile = (patch: Partial<OnboardingProfile>): OnboardingProfile => ({ |
|||
...EMPTY_PROFILE, |
|||
...patch, |
|||
}); |
|||
|
|||
describe('fetchUserProfile 我的页资料取自引导采集', () => { |
|||
beforeEach(async () => { |
|||
await clearOnboardingProfile(); |
|||
}); |
|||
|
|||
it('完全没有引导资料时才回落到演示资料', async () => { |
|||
const profile = await fetchUserProfile(); |
|||
expect(profile.name).toBe('李明'); |
|||
expect(profile.avatarKey).toBeNull(); |
|||
}); |
|||
|
|||
it('有资料时姓名取采集值、头像取姓名首字、简介只拼采集项', async () => { |
|||
await saveOnboardingProfile( |
|||
makeProfile({ resumeName: '张小满', education: '硕士', city: '杭州' }), |
|||
); |
|||
const profile = await fetchUserProfile(); |
|||
expect(profile.name).toBe('张小满'); |
|||
expect(profile.avatar).toBe('张'); |
|||
expect(profile.bio).toBe('硕士·杭州'); |
|||
}); |
|||
|
|||
it('一路跳过(资料全空)时不显示演示人设的姓名与简介', async () => { |
|||
await saveOnboardingProfile(EMPTY_PROFILE); |
|||
const profile = await fetchUserProfile(); |
|||
expect(profile.name).toBe(''); |
|||
expect(profile.avatar).toBe(''); |
|||
expect(profile.bio).toBe(''); |
|||
}); |
|||
|
|||
it('头像原始值原样透传:预设素材 key', async () => { |
|||
await saveOnboardingProfile(makeProfile({ avatar: 'avatarPreset3' })); |
|||
await expect(fetchUserProfile()).resolves.toMatchObject({ avatarKey: 'avatarPreset3' }); |
|||
}); |
|||
|
|||
it('头像原始值原样透传:拍照 / 相册得到的本地图片 URI', async () => { |
|||
await saveOnboardingProfile(makeProfile({ avatar: 'file:///data/picked.jpg' })); |
|||
await expect(fetchUserProfile()).resolves.toMatchObject({ |
|||
avatarKey: 'file:///data/picked.jpg', |
|||
}); |
|||
}); |
|||
|
|||
it('未采集头像时为 null(页面回落到姓名首字展示)', async () => { |
|||
await saveOnboardingProfile(makeProfile({ resumeName: '张小满' })); |
|||
await expect(fetchUserProfile()).resolves.toMatchObject({ avatarKey: null }); |
|||
}); |
|||
}); |
|||
File diff suppressed because it is too large
@ -0,0 +1,50 @@ |
|||
/** |
|||
* 应用缓存 hook(设置页「清除缓存」)。 |
|||
* 取体积走 useAsyncData(loading / error / refresh 一套),清理后自动重新取体积。 |
|||
*/ |
|||
import { useCallback, useState } from 'react'; |
|||
import { clearAppCache, fetchCacheSize } from '../api'; |
|||
import { formatBytes } from '../utils'; |
|||
import { useAsyncData } from './useAsyncData'; |
|||
|
|||
export interface UseAppCacheReturn { |
|||
/** 缓存体积展示文案(如 12.8MB);读取中为空串 */ |
|||
label: string; |
|||
/** 读取缓存体积中 */ |
|||
loading: boolean; |
|||
/** 读取失败 */ |
|||
error: Error | null; |
|||
/** 重新读取体积 */ |
|||
refresh: () => void; |
|||
/** 清理缓存,返回清掉的字节数;失败抛出由页面提示 */ |
|||
clear: () => Promise<number>; |
|||
/** 清理进行中(防重复点击) */ |
|||
clearing: boolean; |
|||
} |
|||
|
|||
export function useAppCache(): UseAppCacheReturn { |
|||
const fetcher = useCallback(() => fetchCacheSize(), []); |
|||
const { data, loading, error, refresh } = useAsyncData(fetcher); |
|||
const [clearing, setClearing] = useState(false); |
|||
|
|||
const clear = useCallback(async () => { |
|||
setClearing(true); |
|||
try { |
|||
const freed = await clearAppCache(); |
|||
// 清完重新读一次,避免页面还挂着清理前的数字
|
|||
refresh(); |
|||
return freed; |
|||
} finally { |
|||
setClearing(false); |
|||
} |
|||
}, [refresh]); |
|||
|
|||
return { |
|||
label: data === null ? '' : formatBytes(data), |
|||
loading, |
|||
error, |
|||
refresh, |
|||
clear, |
|||
clearing, |
|||
}; |
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
/** |
|||
* 取本机号码 hook(运营商一键登录第 1 步)。 |
|||
* |
|||
* 进入登录页即调用,取回本机号码与一次性鉴权令牌,供 P2Q 展示掩码号并换取登录态。 |
|||
* 与取数 hook 一样统一管理 loading / error / refresh,页面只消费结果。 |
|||
*/ |
|||
import { useCallback } from 'react'; |
|||
import { fetchCarrierPhone } from '../api'; |
|||
import type { CarrierName } from '../types'; |
|||
import { useAsyncData } from './useAsyncData'; |
|||
|
|||
export interface UseCarrierPhoneReturn { |
|||
/** 掩码本机号码(如 150****9367);未取到为 '' */ |
|||
maskedPhone: string; |
|||
/** 一次性鉴权令牌;为空表示没取到号,不能一键登录 */ |
|||
carrierToken: string; |
|||
/** 认证服务提供方(页脚「认证服务由 xx 提供」用) */ |
|||
carrier: CarrierName; |
|||
/** 号码是否已就绪 —— 页面据此决定「一键登录」可不可点 */ |
|||
ready: boolean; |
|||
/** 取号中 */ |
|||
loading: boolean; |
|||
/** 取号失败(页面据此展示失败分支 + 重新获取 + 改走验证码登录) */ |
|||
error: Error | null; |
|||
/** 重新取号 */ |
|||
refresh: () => void; |
|||
} |
|||
|
|||
export function useCarrierPhone(): UseCarrierPhoneReturn { |
|||
const fetcher = useCallback(() => fetchCarrierPhone(), []); |
|||
const { data, loading, error, refresh } = useAsyncData(fetcher); |
|||
|
|||
const carrierToken = data?.carrierToken ?? ''; |
|||
|
|||
return { |
|||
maskedPhone: data?.maskedPhone ?? '', |
|||
carrierToken, |
|||
carrier: data?.carrier ?? 'unknown', |
|||
ready: carrierToken !== '', |
|||
loading, |
|||
error, |
|||
refresh, |
|||
}; |
|||
} |
|||
@ -0,0 +1,42 @@ |
|||
/** |
|||
* 取图 hook:拍照 / 相册选图,管理「取图中」与失败提示。 |
|||
* |
|||
* 页面只调这里,不直接依赖 expo-image-picker(原生能力统一从 lib → hook 走, |
|||
* 与 storage 的处理方式一致)。`picking` 用于屏蔽重复点击 —— 相机界面弹出期间 |
|||
* 再点一次会重复申请权限并叠加两个系统弹窗。 |
|||
*/ |
|||
import { useCallback, useState } from 'react'; |
|||
import { IMAGE_PICK_DENIED_HINT } from '../constants'; |
|||
import type { ImagePickOptions, ImagePickResult, ImagePickSource } from '../lib/imagePicker'; |
|||
import { pickImage } from '../lib/imagePicker'; |
|||
|
|||
export interface UseImagePick { |
|||
/** 取图进行中(含系统相机/相册界面展示期间) */ |
|||
picking: boolean; |
|||
/** 上一次取图的失败提示;未授权时给出设置指引,取消或成功时为 null */ |
|||
error: string | null; |
|||
pick: (source: ImagePickSource, options?: ImagePickOptions) => Promise<ImagePickResult>; |
|||
} |
|||
|
|||
export function useImagePick(): UseImagePick { |
|||
const [picking, setPicking] = useState(false); |
|||
const [error, setError] = useState<string | null>(null); |
|||
|
|||
const pick = useCallback( |
|||
async (source: ImagePickSource, options?: ImagePickOptions): Promise<ImagePickResult> => { |
|||
setPicking(true); |
|||
setError(null); |
|||
try { |
|||
const result = await pickImage(source, options); |
|||
// 用户主动取消不是错误,不提示;未授权才需要指引他去系统设置
|
|||
setError(result.status === 'denied' ? IMAGE_PICK_DENIED_HINT : null); |
|||
return result; |
|||
} finally { |
|||
setPicking(false); |
|||
} |
|||
}, |
|||
[], |
|||
); |
|||
|
|||
return { picking, error, pick }; |
|||
} |
|||
@ -1,25 +1,152 @@ |
|||
/** |
|||
* 简历数据 hook:拉取简历 + AI 一键优化。 |
|||
* 简历数据 hook:取简历 + 按板块保存 + AI 动作。 |
|||
* |
|||
* 写操作一律「落库 → 重新取数」,不做本地乐观更新:`youzhi:resume:override` 是唯一真相源, |
|||
* 页面拿到的永远是落库后的结果(与 `useSalaryRecords` 同一套约定)。 |
|||
*/ |
|||
import { useCallback } from 'react'; |
|||
import { fetchResume, optimizeResume } from '../api'; |
|||
import type { Resume } from '../types'; |
|||
import { useCallback, useState } from 'react'; |
|||
import { |
|||
fetchResume, |
|||
fetchResumeSavedAt, |
|||
optimizeResume, |
|||
optimizeResumeExperience, |
|||
optimizeResumeWithJD, |
|||
polishResumeSelfEvaluation, |
|||
saveResumeBasicInfo, |
|||
saveResumeEducation, |
|||
saveResumeExperience, |
|||
saveResumeProjects, |
|||
saveResumeSelfEvaluation, |
|||
saveResumeSkills, |
|||
saveResumeSnapshot, |
|||
} from '../api'; |
|||
import type { |
|||
Resume, |
|||
ResumeAIActionResult, |
|||
ResumeBasicInfo, |
|||
ResumeEducation, |
|||
ResumeExperience, |
|||
ResumeItemInput, |
|||
ResumeProject, |
|||
ResumeSkill, |
|||
} from '../types'; |
|||
import { useAsyncData } from './useAsyncData'; |
|||
|
|||
/** |
|||
* 简历与保存时间一起取。 |
|||
* |
|||
* 分成两个 `useAsyncData` 实例会出现「简历已刷新、保存时间还是旧的」的错位, |
|||
* 且同一页面两处取数各自维护 loading/error,四态渲染要写两遍。 |
|||
*/ |
|||
interface ResumeBundle { |
|||
resume: Resume; |
|||
savedAt: string; |
|||
} |
|||
|
|||
export function useResume() { |
|||
const fetcher = useCallback(() => fetchResume(), []); |
|||
const { data, loading, error, refresh } = useAsyncData<Resume>(fetcher); |
|||
const fetcher = useCallback(async (): Promise<ResumeBundle> => { |
|||
const [resume, savedAt] = await Promise.all([fetchResume(), fetchResumeSavedAt()]); |
|||
return { resume, savedAt }; |
|||
}, []); |
|||
const { data, loading, error, refresh } = useAsyncData<ResumeBundle>(fetcher); |
|||
|
|||
/** 写操作进行中:页面据此禁用按钮,避免连点两次时后一次基于旧列表少删一条 */ |
|||
const [saving, setSaving] = useState(false); |
|||
|
|||
/** 写操作的统一收口:失败也要解除 saving(`finally`),否则按钮会一直灰着 */ |
|||
const runWrite = useCallback( |
|||
async <T,>(action: () => Promise<T>): Promise<T> => { |
|||
setSaving(true); |
|||
try { |
|||
const result = await action(); |
|||
refresh(); |
|||
return result; |
|||
} finally { |
|||
setSaving(false); |
|||
} |
|||
}, |
|||
[refresh], |
|||
); |
|||
|
|||
/** |
|||
* AI 动作的统一收口:`available` 为真才重新取数。 |
|||
* 能力未接入时简历一个字都没改,刷新只会白闪一次加载态。 |
|||
*/ |
|||
const runAIAction = useCallback( |
|||
async (action: () => Promise<ResumeAIActionResult>): Promise<ResumeAIActionResult> => { |
|||
const result = await action(); |
|||
if (result.available) refresh(); |
|||
return result; |
|||
}, |
|||
[refresh], |
|||
); |
|||
|
|||
const optimize = useCallback(async () => { |
|||
const result = await optimizeResume(); |
|||
return result.resume; |
|||
}, []); |
|||
|
|||
const saveBasicInfo = useCallback( |
|||
(input: Omit<ResumeBasicInfo, 'avatar'>) => runWrite(() => saveResumeBasicInfo(input)), |
|||
[runWrite], |
|||
); |
|||
const saveSelfEvaluation = useCallback( |
|||
(content: string) => runWrite(() => saveResumeSelfEvaluation(content)), |
|||
[runWrite], |
|||
); |
|||
const saveSkills = useCallback( |
|||
(skills: ResumeSkill[]) => runWrite(() => saveResumeSkills(skills)), |
|||
[runWrite], |
|||
); |
|||
const saveEducation = useCallback( |
|||
(items: ResumeItemInput<ResumeEducation>[]) => runWrite(() => saveResumeEducation(items)), |
|||
[runWrite], |
|||
); |
|||
const saveExperience = useCallback( |
|||
(items: ResumeItemInput<ResumeExperience>[]) => runWrite(() => saveResumeExperience(items)), |
|||
[runWrite], |
|||
); |
|||
const saveProjects = useCallback( |
|||
(items: ResumeItemInput<ResumeProject>[]) => runWrite(() => saveResumeProjects(items)), |
|||
[runWrite], |
|||
); |
|||
/** |
|||
* 头部「保存」:把页面此刻显示的简历整体固化到本机。 |
|||
* 收的是页面展示的那份(可能是「AI 一键优化」后的结果),而不是 hook 里缓存的原始值 |
|||
*/ |
|||
const save = useCallback((resume: Resume) => runWrite(() => saveResumeSnapshot(resume)), [runWrite]); |
|||
|
|||
const optimizeExperience = useCallback( |
|||
(id: string) => runAIAction(() => optimizeResumeExperience(id)), |
|||
[runAIAction], |
|||
); |
|||
const polishSelfEvaluation = useCallback( |
|||
(content: string) => runAIAction(() => polishResumeSelfEvaluation(content)), |
|||
[runAIAction], |
|||
); |
|||
const optimizeWithJD = useCallback( |
|||
(jd: string) => runAIAction(() => optimizeResumeWithJD(jd)), |
|||
[runAIAction], |
|||
); |
|||
|
|||
return { |
|||
resume: data ?? null, |
|||
resume: data?.resume ?? null, |
|||
/** 从未保存过时为空串,页面据此不渲染「上次保存」那行 */ |
|||
savedAt: data?.savedAt ?? '', |
|||
loading, |
|||
error, |
|||
refresh, |
|||
saving, |
|||
optimize, |
|||
save, |
|||
saveBasicInfo, |
|||
saveSelfEvaluation, |
|||
saveSkills, |
|||
saveEducation, |
|||
saveExperience, |
|||
saveProjects, |
|||
optimizeExperience, |
|||
polishSelfEvaluation, |
|||
optimizeWithJD, |
|||
}; |
|||
} |
|||
|
|||
@ -0,0 +1,32 @@ |
|||
/** |
|||
* 薪资计算页「更多工具」入口 hook。 |
|||
* 入口清单由接口层下发(含路由),页面只负责渲染与跳转。 |
|||
*/ |
|||
import { fetchSalaryToolLinks } from '../api'; |
|||
import type { SalaryToolLink } from '../types'; |
|||
import { useAsyncData } from './useAsyncData'; |
|||
|
|||
export interface UseSalaryToolLinksReturn { |
|||
/** 工具入口数组;未加载完成时为空数组,页面可安全 map */ |
|||
tools: SalaryToolLink[]; |
|||
/** 加载中 */ |
|||
loading: boolean; |
|||
/** 加载失败 */ |
|||
error: Error | null; |
|||
/** 重新获取 */ |
|||
refresh: () => void; |
|||
} |
|||
|
|||
// fetcher 定义在模块级,保证引用稳定(放进组件内会让 useAsyncData 的 effect 反复触发)
|
|||
const fetchTools = () => fetchSalaryToolLinks(); |
|||
|
|||
export function useSalaryToolLinks(): UseSalaryToolLinksReturn { |
|||
const { data, loading, error, refresh } = useAsyncData(fetchTools); |
|||
|
|||
return { |
|||
tools: data ?? [], |
|||
loading, |
|||
error, |
|||
refresh, |
|||
}; |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
/** |
|||
* 技能学习路径 hook:点「生成技能学习路径」时才请求(一次性生成,不是进页面就取数)。 |
|||
* 生成逻辑与推荐理由都在接口层,页面只负责弹窗展示。 |
|||
*/ |
|||
import { useCallback, useState } from 'react'; |
|||
import { fetchSkillLearningPath } from '../api'; |
|||
import type { SkillLearningPath } from '../types'; |
|||
|
|||
export interface UseSkillLearningPathReturn { |
|||
/** 生成结果;未生成时为 null */ |
|||
path: SkillLearningPath | null; |
|||
/** 生成中(防重复点击) */ |
|||
loading: boolean; |
|||
/** 生成失败 */ |
|||
error: Error | null; |
|||
/** 按行业生成学习路径 */ |
|||
generate: (industry: string) => Promise<SkillLearningPath | null>; |
|||
/** 关闭弹窗时清掉上次结果,下次打开重新生成 */ |
|||
reset: () => void; |
|||
} |
|||
|
|||
export function useSkillLearningPath(): UseSkillLearningPathReturn { |
|||
const [path, setPath] = useState<SkillLearningPath | null>(null); |
|||
const [loading, setLoading] = useState(false); |
|||
const [error, setError] = useState<Error | null>(null); |
|||
|
|||
const generate = useCallback(async (industry: string) => { |
|||
setLoading(true); |
|||
setError(null); |
|||
try { |
|||
const result = await fetchSkillLearningPath(industry); |
|||
setPath(result); |
|||
return result; |
|||
} catch (e) { |
|||
setError(e instanceof Error ? e : new Error('学习路径生成失败,请稍后重试')); |
|||
return null; |
|||
} finally { |
|||
setLoading(false); |
|||
} |
|||
}, []); |
|||
|
|||
const reset = useCallback(() => { |
|||
setPath(null); |
|||
setError(null); |
|||
}, []); |
|||
|
|||
return { path, loading, error, generate, reset }; |
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
import { Directory, Paths } from 'expo-file-system'; |
|||
|
|||
/** |
|||
* 应用缓存目录的读写。 |
|||
* |
|||
* 放在 lib 层而不是 api 层:它读的是设备本地文件系统,没有对应的后端接口, |
|||
* api 层只做「页面怎么拿」的封装(fetchCacheSize / clearAppCache)。 |
|||
* |
|||
* 清缓存**只删目录内容,保留目录本身** —— 缓存目录被系统与各原生模块共用, |
|||
* 把目录本身删掉会让后续写入失败。 |
|||
*/ |
|||
|
|||
/** |
|||
* 读取缓存目录占用(字节)。 |
|||
* 目录不存在或读不到时返回 0(清缓存本身不该因为读不到体积而失败)。 |
|||
*/ |
|||
export function readCacheBytes(): number { |
|||
try { |
|||
const dir = new Directory(Paths.cache); |
|||
return dir.exists ? (dir.size ?? 0) : 0; |
|||
} catch { |
|||
return 0; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 清空缓存目录内容。 |
|||
* 单个条目删除失败不阻断其余(可能被系统占用),返回清理前的占用体积。 |
|||
*/ |
|||
export function clearCacheDir(): number { |
|||
const before = readCacheBytes(); |
|||
try { |
|||
const dir = new Directory(Paths.cache); |
|||
if (!dir.exists) return 0; |
|||
for (const entry of dir.list()) { |
|||
try { |
|||
entry.delete(); |
|||
} catch { |
|||
// 被占用 / 无权限的条目跳过,继续清其余
|
|||
} |
|||
} |
|||
} catch { |
|||
return 0; |
|||
} |
|||
return before; |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue