From 00522d53f94eb6cae3474b50c3ea3d4c2eaced25 Mon Sep 17 00:00:00 2001 From: doublex001 <16157008+doublex001@user.noreply.gitee.com> Date: Thu, 17 Sep 2026 10:37:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=AD=BB=E6=8E=A7=E4=BB=B6=E6=B8=85?= =?UTF-8?q?=E9=9B=B6=E5=B9=B6=E4=BF=AE=E5=A4=8D=E4=B8=80=E9=94=AE=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=8F=96=E5=8F=B7=E3=80=81=E9=87=8D=E5=81=9A=E5=BC=95?= =?UTF-8?q?=E5=AF=BC=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一键登录(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。 --- .gitignore | 7 + AGENTS.md | 5 +- app.config.js | 12 + app/(enterprise)/enterprise-chat.tsx | 115 +- app/(enterprise)/talent-recommendation.tsx | 149 +- app/(tabs)/chat.tsx | 116 +- app/(tabs)/jobs.tsx | 11 +- app/(tabs)/profile.tsx | 39 +- app/appeal.tsx | 100 +- app/candidate-detail.tsx | 8 +- app/guide.tsx | 91 +- app/holland-result.tsx | 23 +- app/holland.tsx | 5 +- app/interview-score.tsx | 75 +- app/interview.tsx | 4 +- app/job-evaluations.tsx | 219 +- app/onboarding.tsx | 126 +- app/register.tsx | 4 +- app/resume.tsx | 680 +++++- app/resume/basic.tsx | 136 ++ app/resume/education.tsx | 272 +++ app/resume/experience.tsx | 323 +++ app/resume/export.tsx | 348 ++- app/resume/preview.tsx | 240 +++ app/resume/project.tsx | 225 ++ app/settings.tsx | 41 +- app/tools/salary-calculator.tsx | 48 +- app/tools/skill-cloud.tsx | 327 ++- components/onboarding/assets.ts | 70 +- components/onboarding/screens/B1Wechat.tsx | 4 +- components/onboarding/screens/B2Agreement.tsx | 4 +- components/onboarding/screens/B3Recruiter.tsx | 96 +- .../onboarding/screens/P10FirstWork.tsx | 9 +- .../onboarding/screens/P11CurrentPosition.tsx | 55 +- components/onboarding/screens/P12Company.tsx | 4 +- components/onboarding/screens/P13JoinDate.tsx | 16 +- components/onboarding/screens/P14Skills.tsx | 4 +- .../onboarding/screens/P16WorkContent.tsx | 12 +- .../onboarding/screens/P17Reference.tsx | 4 +- .../onboarding/screens/P18Education.tsx | 7 +- components/onboarding/screens/P1Login.tsx | 14 +- components/onboarding/screens/P20School.tsx | 4 +- components/onboarding/screens/P21Avatar.tsx | 74 +- components/onboarding/screens/P22Done.tsx | 10 +- components/onboarding/screens/P23Home.tsx | 181 -- components/onboarding/screens/P2PhoneCode.tsx | 14 +- .../onboarding/screens/P2QuickLogin.tsx | 108 +- .../onboarding/screens/P2WechatCode.tsx | 45 +- components/onboarding/screens/P3Identity.tsx | 29 +- components/onboarding/screens/P4Intent.tsx | 14 +- components/onboarding/screens/P5City.tsx | 13 +- .../onboarding/screens/P6ExpectedPosition.tsx | 72 +- components/onboarding/screens/P7Salary.tsx | 22 +- components/onboarding/screens/P8Resume1.tsx | 13 +- components/onboarding/screens/P9JobStatus.tsx | 8 +- components/onboarding/ui/Avatar.tsx | 20 +- components/onboarding/ui/Illustration.tsx | 12 +- components/onboarding/ui/ImageSlot.tsx | 64 + components/onboarding/ui/ScreenShell.tsx | 5 +- components/onboarding/ui/SupportFooter.tsx | 43 + components/onboarding/ui/WheelPicker.tsx | 74 +- components/resume/EditKit.tsx | 209 ++ package.json | 10 +- scripts/check-affordances.mjs | 86 + scripts/check-backend-stubs.mjs | 47 + src/__tests__/carrier-login.test.ts | 73 + src/__tests__/enterprise-filters.test.ts | 282 +++ src/__tests__/enterprise-reviews.test.ts | 62 + src/__tests__/interview-score-draft.test.ts | 96 + src/__tests__/job-match.test.ts | 255 +++ src/__tests__/onboarding-checklist.test.ts | 221 ++ src/__tests__/onboarding-profile.test.ts | 162 ++ src/__tests__/resume-export.test.ts | 240 +++ src/__tests__/resume-override.test.ts | 363 ++++ src/__tests__/resume-profile.test.ts | 245 +++ src/__tests__/salary-options.test.ts | 74 + src/__tests__/user-profile.test.ts | 97 + src/__tests__/utils.test.ts | 91 +- src/api/api.ts | 1903 +++++++++++++++-- src/api/index.ts | 35 + src/constants/index.ts | 195 +- src/hooks/index.ts | 5 + src/hooks/useAppCache.ts | 50 + src/hooks/useAppealForm.ts | 18 +- src/hooks/useCarrierPhone.ts | 44 + src/hooks/useEnterpriseCommunication.ts | 33 +- src/hooks/useImagePick.ts | 42 + src/hooks/useInterviewScore.ts | 67 +- src/hooks/useLogin.ts | 9 +- src/hooks/useMessageData.ts | 20 +- src/hooks/useOnboardingChecklist.ts | 32 +- src/hooks/useOnboardingFlow.tsx | 182 +- src/hooks/useRegisterFinish.ts | 9 +- src/hooks/useResume.ts | 141 +- src/hooks/useResumeExport.ts | 38 +- src/hooks/useSalaryToolLinks.ts | 32 + src/hooks/useSkillCloud.ts | 21 +- src/hooks/useSkillLearningPath.ts | 48 + src/hooks/useTalentRecommendation.ts | 33 +- src/lib/cache.ts | 46 + src/lib/imagePicker.ts | 65 + src/lib/resumeExport.ts | 54 + src/lib/shareFile.ts | 47 + src/theme/colors.ts | 13 + src/theme/index.ts | 9 +- src/types/carrier-auth.ts | 36 + src/types/enterprise.ts | 21 + src/types/index.ts | 16 + src/types/offer-compare.ts | 9 + src/types/onboarding.ts | 85 + src/types/resume.ts | 54 + src/types/tools.ts | 41 +- src/types/user.ts | 8 + src/utils/index.ts | 76 +- src/utils/resumeHtml.ts | 272 +++ src/utils/validators.ts | 12 + yarn.lock | 28 +- 运营商一键登录接入方案.md | 146 ++ 项目开发状态.md | 133 +- 119 files changed, 10568 insertions(+), 1046 deletions(-) create mode 100644 app/resume/basic.tsx create mode 100644 app/resume/education.tsx create mode 100644 app/resume/experience.tsx create mode 100644 app/resume/preview.tsx create mode 100644 app/resume/project.tsx delete mode 100644 components/onboarding/screens/P23Home.tsx create mode 100644 components/onboarding/ui/ImageSlot.tsx create mode 100644 components/onboarding/ui/SupportFooter.tsx create mode 100644 components/resume/EditKit.tsx create mode 100644 scripts/check-affordances.mjs create mode 100644 scripts/check-backend-stubs.mjs create mode 100644 src/__tests__/carrier-login.test.ts create mode 100644 src/__tests__/enterprise-filters.test.ts create mode 100644 src/__tests__/enterprise-reviews.test.ts create mode 100644 src/__tests__/interview-score-draft.test.ts create mode 100644 src/__tests__/job-match.test.ts create mode 100644 src/__tests__/onboarding-checklist.test.ts create mode 100644 src/__tests__/onboarding-profile.test.ts create mode 100644 src/__tests__/resume-export.test.ts create mode 100644 src/__tests__/resume-override.test.ts create mode 100644 src/__tests__/resume-profile.test.ts create mode 100644 src/__tests__/salary-options.test.ts create mode 100644 src/__tests__/user-profile.test.ts create mode 100644 src/hooks/useAppCache.ts create mode 100644 src/hooks/useCarrierPhone.ts create mode 100644 src/hooks/useImagePick.ts create mode 100644 src/hooks/useSalaryToolLinks.ts create mode 100644 src/hooks/useSkillLearningPath.ts create mode 100644 src/lib/cache.ts create mode 100644 src/lib/imagePicker.ts create mode 100644 src/lib/resumeExport.ts create mode 100644 src/lib/shareFile.ts create mode 100644 src/types/carrier-auth.ts create mode 100644 src/types/onboarding.ts create mode 100644 src/utils/resumeHtml.ts create mode 100644 运营商一键登录接入方案.md diff --git a/.gitignore b/.gitignore index 54e4152..6696077 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,10 @@ figma_tree.mjs # 商店截图素材:保留目录约定文档,排除二进制与生成物 screenshots/**/*.png screenshots/**/*.html + +# 包管理器锁文件:本项目统一用 yarn.lock,package-lock.json 由 npm 误生成,不入库 +package-lock.json + +# 调试截图(本地排查用,非资产) +shot-check.png +shot-*.png diff --git a/AGENTS.md b/AGENTS.md index 17ae494..32ae2bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,10 @@ Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before ======================================== 【核心原则】 +你是react native+expo开发工程师 做任务前,先检查问题有没有错误前提、逻辑跳跃和信息缺失。 不要迎合我,要独立判断,区分事实、预测和主观观点。 涉及数字、人物和结论时尽量核实来源。 不同意就直接指出并给出依据、风险和替代解释。 还要主动提醒我忽略的变量、成本和偏差。 +需要填充图片的地方,没有图片的话,请标明这里需要填充什么图片,便于后续填充。 + 仓库:https://gitea.baskbuddy.com/baskbuddy/mydreamjob 请在每周的周一-周五的9:20的时候拉取一次远端仓库、每天17:50提交一次git仓库,其他时间可以改动但不需要提交,说明今天新增与改动的内容,上班时间是9:00-18:00,有页面出口就一定要有入口。 业务逻辑分层管理,不是没有,而是被妥善组织: @@ -190,7 +193,7 @@ import { useJobList } from '../../src/hooks/useJobList'; ======================================== -五、真实后端就绪后 +五、真实后端就绪后(后端其他同事在做,用的Java+Springboot) ======================================== 仅替换 src/api/api.ts 中函数体为真实 fetch(签名不变),页面与 hooks 无需改动。 diff --git a/app.config.js b/app.config.js index 8b44de2..95e8dd9 100644 --- a/app.config.js +++ b/app.config.js @@ -49,6 +49,18 @@ export default { plugins: [ 'expo-router', 'expo-font', + // 拍照 / 相册取图(营业执照、头像)。原生权限文案只有出包时生效, + // Expo Go 里用的是 Expo Go 自己的 Info.plist,故此处改动需真机出包验证。 + [ + 'expo-image-picker', + { + // 本项目只选静态图、不录视频:不申请麦克风权限(最小必要原则), + // 插件默认会加 RECORD_AUDIO,这里显式关掉 + microphonePermission: false, + photosPermission: '有职需要访问相册,用于上传营业执照与设置头像', + cameraPermission: '有职需要使用相机,用于拍摄营业执照与设置头像', + }, + ], // 启动屏:冷启动期间展示品牌 logo,消除白屏。 // 资源为 assets/splash-icon.png(1024×1024,见 assets/)。 // 应用为纯亮色(userInterfaceStyle: 'light'),故不配置 dark 分支。 diff --git a/app/(enterprise)/enterprise-chat.tsx b/app/(enterprise)/enterprise-chat.tsx index 76c2104..2605a1f 100644 --- a/app/(enterprise)/enterprise-chat.tsx +++ b/app/(enterprise)/enterprise-chat.tsx @@ -1,6 +1,5 @@ import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { useState } from 'react'; import { ActivityIndicator, Alert, @@ -8,6 +7,7 @@ import { ScrollView, StyleSheet, Text, + TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -71,7 +71,13 @@ function TabBar({ /* ===================== 子组件:待处理任务 ===================== */ -function PendingTasks({ tasks }: { tasks: PendingTask[] }) { +function PendingTasks({ + tasks, + onPressTask, +}: { + tasks: PendingTask[]; + onPressTask: (task: PendingTask) => void; +}) { return ( 待处理 @@ -84,7 +90,11 @@ function PendingTasks({ tasks }: { tasks: PendingTask[] }) { ? colors.compareOrange : colors.primary; return ( - + onPressTask(task)} + > @@ -145,11 +155,31 @@ function ChatRow({ chat }: { chat: EnterpriseChatItem }) { /* ===================== 子组件:搜索栏 ===================== */ -function SearchBar() { +function SearchBar({ + value, + onChange, + onClear, +}: { + value: string; + onChange: (text: string) => void; + onClear: () => void; +}) { return ( - 搜索候选人... + + {value !== '' && ( + + + + )} ); } @@ -225,8 +255,18 @@ const QUICK_ACTIONS: QuickActionItem[] = [ export default function EnterpriseChatScreen() { const router = useRouter(); - const { communication, loading, error, refresh } = useEnterpriseCommunication(); - const [activeTab, setActiveTab] = useState('all'); + const { + communication, + activeTab, + setActiveTab, + keyword, + setKeyword, + chats, + isEmptySearch, + loading, + error, + refresh, + } = useEnterpriseCommunication(); // 快捷操作分发:有目标路由则跳转,无对应页面则提示「功能开发中」 const handleQuickAction = (action: QuickActionItem) => { @@ -237,6 +277,20 @@ export default function EnterpriseChatScreen() { } }; + // 待办卡片:优先跳页面,其次切 Tab(接口层给的目标,页面不写死映射) + const handlePressTask = (task: PendingTask) => { + if (task.route) { + router.push(task.route as never); + return; + } + if (task.tabKey) { + setActiveTab(task.tabKey); + setKeyword(''); + return; + } + Alert.alert('提示', `${task.label}功能开发中,敬请期待`); + }; + if (loading) { return ( @@ -270,18 +324,27 @@ export default function EnterpriseChatScreen() { {communication.hasData ? ( - - - - {communication.chats.map((chat, index) => { - const isLast = index === communication.chats.length - 1; - return ( - - - - ); - })} - + + setKeyword('')} /> + {chats.length === 0 ? ( + + + + {isEmptySearch ? `没有匹配「${keyword.trim()}」的会话` : '这个分类下暂时没有会话'} + + + ) : ( + + {chats.map((chat, index) => { + const isLast = index === chats.length - 1; + return ( + + + + ); + })} + + )} ) : ( @@ -448,11 +511,21 @@ const styles = StyleSheet.create({ paddingVertical: 10, marginBottom: 12, }, - searchPlaceholder: { + searchInput: { + flex: 1, marginLeft: 8, fontSize: 14, - color: colors.textHint, + color: colors.textPrimary, + padding: 0, + }, + emptyList: { + alignItems: 'center', + gap: 10, + paddingVertical: 32, + backgroundColor: colors.white, + borderRadius: 14, }, + emptyListText: { fontSize: 13, color: colors.textSecondary }, chatListCard: { backgroundColor: colors.white, borderRadius: 12, diff --git a/app/(enterprise)/talent-recommendation.tsx b/app/(enterprise)/talent-recommendation.tsx index 37e127e..0a61f69 100644 --- a/app/(enterprise)/talent-recommendation.tsx +++ b/app/(enterprise)/talent-recommendation.tsx @@ -8,6 +8,7 @@ import { ScrollView, StyleSheet, Text, + TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -17,7 +18,18 @@ import type { TalentCandidate, TalentRecommendationTab } from '../../src/types'; /* ===================== 子组件:顶部导航 ===================== */ -function Header({ companyName, onBack }: { companyName: string; onBack: () => void }) { +function Header({ + companyName, + onBack, + searching, + onToggleSearch, +}: { + companyName: string; + onBack: () => void; + /** 是否处于搜索态;加载中/加载失败时没有数据可搜,不传即不渲染搜索按钮 */ + searching?: boolean; + onToggleSearch?: () => void; +}) { return ( @@ -28,17 +40,54 @@ function Header({ companyName, onBack }: { companyName: string; onBack: () => vo 人才推荐 - - - - - - + {/* 原先这里是「搜索 + 漏斗」两个图标:漏斗没有对应页面,先摘掉; + 搜索改成真的关键词过滤(姓名 / 职位 / 技能 / 学校) */} + {onToggleSearch && ( + + + + )} ); } +/* ===================== 子组件:搜索栏 ===================== */ + +function SearchBar({ + value, + onChange, + onClear, +}: { + value: string; + onChange: (text: string) => void; + onClear: () => void; +}) { + return ( + + + + {value !== '' && ( + + + + )} + + ); +} + /* ===================== 子组件:Tab 栏 ===================== */ function TabBar({ @@ -233,8 +282,24 @@ const POOL_ACTIONS: PoolActionItem[] = [ export default function TalentRecommendationScreen() { const router = useRouter(); - const { recommendation, loading, error, refresh } = useTalentRecommendation(); - const [activeTab, setActiveTab] = useState('all'); + const { + recommendation, + activeTab, + setActiveTab, + keyword, + setKeyword, + candidates, + isEmptySearch, + loading, + error, + refresh, + } = useTalentRecommendation(); + const [searching, setSearching] = useState(false); + + const toggleSearch = () => { + if (searching) setKeyword(''); + setSearching((prev) => !prev); + }; // 人才池快捷操作分发:有目标路由则跳转,无对应页面则提示「功能开发中」, // 避免按钮点击后无任何反馈。与 enterprise-chat.tsx 的 handleQuickAction 同约定。 @@ -252,8 +317,10 @@ export default function TalentRecommendationScreen() { Alert.alert('提示', '人才市场报告功能开发中,敬请期待'); }; + /** 空状态里的「调整筛选条件」:把筛选真正复位(切回全部 + 清关键词) */ const handleFilter = () => { - Alert.alert('提示', '筛选条件功能开发中,敬请期待'); + setActiveTab('all'); + setKeyword(''); }; if (loading) { @@ -284,8 +351,16 @@ export default function TalentRecommendationScreen() { return ( -
router.back()} /> +
router.back()} + searching={searching} + onToggleSearch={toggleSearch} + /> + {searching && ( + setKeyword('')} /> + )} {recommendation.hasData ? ( @@ -293,9 +368,23 @@ export default function TalentRecommendationScreen() { count={recommendation.todayCount} onReport={handleReport} /> - {recommendation.candidates.map((candidate) => ( - - ))} + {candidates.length === 0 ? ( + + + + {isEmptySearch + ? `没有匹配「${keyword.trim()}」的候选人` + : '这个筛选条件下暂时没有候选人'} + + + 重置筛选 + + + ) : ( + candidates.map((candidate) => ( + + )) + )} { + if (!target) return; + void Linking.openURL(target.url).catch(() => { + Alert.alert( + isVideo ? '无法打开会议链接' : '无法打开地图', + isVideo ? '会议链接可能已失效,请联系 HR 重新获取。' : '请检查是否已安装地图应用后重试。', + ); + }); + }; + return ( @@ -95,13 +130,15 @@ function InterviewCard({ interview }: { interview: Interview }) { {isVideo ? '视频面试' : '线下面试'} - { - }} - > - {isVideo ? '加入会议' : '导航前往'} - + {target ? ( + + {target.label} + + ) : ( + + {isVideo ? '会议链接待 HR 提供' : '面试地址待 HR 提供'} + + )} ); @@ -113,8 +150,16 @@ function InterviewCard({ interview }: { interview: Interview }) { export default function ChatScreen() { const router = useRouter(); const [tab, setTab] = useState('message'); - const { conversations, notifications, interviews, matchResult, loading, error, refresh } = - useMessageData(); + const { + conversations, + notifications, + interviews, + matchResult, + pendingOfferCount, + loading, + error, + refresh, + } = useMessageData(); // 加载态 if (loading) { @@ -175,17 +220,20 @@ export default function ChatScreen() { {tab === 'message' && ( <> {/* 双向匹配成功横幅 */} + {/* + 双向匹配横幅:只陈述事实,不带「点击查看详情」—— + 匹配详情页尚未定义(企业列表之外还要展示什么,属产品决策), + 留着链接就是把用户带到一个不存在的地方。 + */} {matchResult && ( - { - }} - > + 恭喜!您与{matchResult.total}家企业双向匹配成功 - 点击查看详情 - + + {matchResult.companies.join('、')} + + )} {/* 会话列表 */} @@ -205,15 +253,16 @@ export default function ChatScreen() { {tab === 'notification' && ( <> - {/* Offer 待处理横幅 */} - { - }} - > - 您有2个Offer待处理 - 立即查看 > - + {/* Offer 待处理横幅:数量来自接口层,为 0 时整条不渲染 */} + {pendingOfferCount > 0 && ( + router.push('/jobs/offer-compare')} + > + 您有 {pendingOfferCount} 个 Offer 待处理 + 立即查看 > + + )} {/* 系统通知列表 */} {notifications.length === 0 ? ( @@ -228,15 +277,9 @@ export default function ChatScreen() { {tab === 'interview' && ( <> - {/* 面试日程标题 */} + {/* 面试日程标题:本 Tab 即全部日程,不再挂一个无处可去的「全部日程 >」 */} 今日面试 - { - }} - > - 全部日程 > - {/* 面试卡片 */} @@ -347,10 +390,10 @@ const styles = StyleSheet.create({ fontWeight: '700', color: colors.primaryDark, }, - matchBannerLink: { + matchBannerCompanies: { marginTop: 6, fontSize: 13, - color: colors.primary, + color: colors.primaryDark, }, // 会话卡片 convCard: { @@ -553,4 +596,9 @@ const styles = StyleSheet.create({ fontWeight: '600', color: colors.white, }, + /** 会议链接 / 面试地址缺失时的说明文案,替代一个点不动的按钮 */ + actionMissing: { + fontSize: 13, + color: colors.textHint, + }, }); diff --git a/app/(tabs)/jobs.tsx b/app/(tabs)/jobs.tsx index c4c45da..ba908fe 100644 --- a/app/(tabs)/jobs.tsx +++ b/app/(tabs)/jobs.tsx @@ -15,6 +15,7 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useJobList } from '../../src/hooks'; import { + ALL_CITY_LABEL, CITIES, EDUCATION_OPTIONS, EXPERIENCE_OPTIONS, @@ -144,8 +145,9 @@ function JobCard({ job }: { job: Job }) { export default function JobsScreen() { const router = useRouter(); const insets = useSafeAreaInsets(); - // 定位城市(点击可切换) - const [city, setCity] = useState('北京'); + // 城市筛选(点击可切换)。默认「全部城市」:此前写死「北京」, + // 等于把每个用户都钉在北京,引导里填的期望城市永远影响不到推荐 + const [city, setCity] = useState(ALL_CITY_LABEL); const [cityVisible, setCityVisible] = useState(false); const [keyword, setKeyword] = useState(''); const [sort, setSort] = useState('comprehensive'); @@ -311,6 +313,7 @@ export default function JobsScreen() { onRequestClose={() => setFilterVisible(false)} > setFilterVisible(false)}> + {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */} {}}> 更多筛选 @@ -376,6 +379,7 @@ export default function JobsScreen() { onRequestClose={() => setCityVisible(false)} > setCityVisible(false)}> + {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */} {}}> 选择城市 @@ -384,7 +388,8 @@ export default function JobsScreen() { - {CITIES.map((item) => ( + {/* 「全部城市」是默认值与复位入口:不给它出口,用户选了城市就再也回不到不限 */} + {[ALL_CITY_LABEL, ...CITIES].map((item) => ( - - {profile.avatar} - + {/* P21 选过头像就显示真实选择(预设素材 / 本地图片);没选过才回落到姓名首字 */} + {profile.avatarKey ? ( + + ) : ( + + {profile.avatar} + + )} - {profile.name} - {profile.bio} + {/* 姓名未采集(P6/P16 可跳过直接进入)时显示「未设置」,不显示别人的名字 */} + {profile.name || UNSET_LABEL} + {profile.bio !== '' && {profile.bio}} @@ -294,7 +307,10 @@ export default function ProfileScreen() { {PREF_ITEMS.map(({ key, label }, index) => { const value = preference[key]; const isLast = index === PREF_ITEMS.length - 1; - const isStatus = key === 'status'; + // 未采集到的项显示「未设置」,不拿空串冒充已填;也就不再显示状态圆点 + const isUnset = value.trim() === ''; + const display = isUnset ? UNSET_LABEL : value; + const isStatus = key === 'status' && !isUnset; return ( - {value} + {display} ) : ( - {value} + + {display} + )} ); @@ -588,6 +606,11 @@ const styles = StyleSheet.create({ fontWeight: '600', color: colors.textPrimary, }, + // 未采集态:弱化为占位符视觉,与真实填写的值区分开 + prefValueUnset: { + fontWeight: '400', + color: colors.textHint, + }, prefStatus: { flexDirection: 'row', alignItems: 'center', diff --git a/app/appeal.tsx b/app/appeal.tsx index 53944b6..1c92d0f 100644 --- a/app/appeal.tsx +++ b/app/appeal.tsx @@ -3,6 +3,7 @@ import { useRouter } from 'expo-router'; import { ActivityIndicator, Alert, + Image, Pressable, ScrollView, StyleSheet, @@ -11,7 +12,7 @@ import { View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useAppealForm } from '../src/hooks'; +import { useAppealForm, useImagePick } from '../src/hooks'; import { colors } from '../src/theme'; import type { AppealReason, AppealType } from '../src/types'; @@ -213,15 +214,61 @@ function DescriptionSection({ /* ===================== 子组件:上传凭证 ===================== */ -function EvidenceSection() { +function EvidenceSection({ + evidence, + picking, + error, + onPick, + onRemove, +}: { + evidence: string[]; + picking: boolean; + error: string | null; + onPick: (source: 'camera' | 'library') => void; + onRemove: (uri: string) => void; +}) { + const chooseSource = () => { + Alert.alert('上传凭证', '选择图片来源', [ + { text: '拍照', onPress: () => onPick('camera') }, + { text: '从相册选择', onPress: () => onPick('library') }, + { text: '取消', style: 'cancel' }, + ]); + }; + return ( - 上传凭证 - - - 点击上传图片或文件 - 支持 JPG / PNG / PDF,单个文件不超过 10MB + 上传凭证({evidence.length}) + + {evidence.length > 0 && ( + + {evidence.map((uri) => ( + + + onRemove(uri)} hitSlop={6}> + + + + ))} + + )} + + + {picking ? ( + + ) : ( + + )} + {picking ? '正在打开相册…' : '点击上传图片'} + + 当前支持 JPG / PNG 图片,单张不超过 10MB;文件(PDF)待上传接口就绪后开放 + + + {error ? {error} : null} ); } @@ -312,6 +359,9 @@ export default function AppealScreen() { setReason, reasonText, setReasonText, + evidence, + addEvidence, + removeEvidence, contactPhone, setContactPhone, contactEmail, @@ -319,6 +369,7 @@ export default function AppealScreen() { submit, submitting, } = useAppealForm(); + const { pick, picking, error: pickError } = useImagePick(); const handleSubmit = async () => { const result = await submit(); @@ -329,6 +380,12 @@ export default function AppealScreen() { } }; + /** 选一张证据图:取消与未授权由 useImagePick 区分(未授权给出设置指引) */ + const handlePickEvidence = async (source: 'camera' | 'library') => { + const result = await pick(source, { allowsEditing: false }); + if (result.status === 'picked') addEvidence(result.uri); + }; + if (loading) { return ( @@ -360,7 +417,13 @@ export default function AppealScreen() { /> - + void }) { 候选人详情 - - - + {/* 原先这里是「更多」图标:没有下拉菜单,先摘掉 */} + ); } @@ -332,7 +331,8 @@ export default function CandidateDetailScreen() { - + {/* 发起沟通:进该候选人的会话页(此前是空 Pressable,点了没反应) */} + router.push(`/chat/${candidate.id}`)}> 发起沟通 (null); const isLast = page === SLIDES.length - 1; + /** 已登录(含从设置页回顾)时只返回,不改引导标记、不进注册流程 */ + const reviewing = isTokenValid; const finish = () => { + if (reviewing) { + if (router.canGoBack()) router.back(); + else router.replace('/(tabs)/jobs'); + return; + } completeOnboarding(); router.replace('/register'); }; @@ -68,12 +88,21 @@ export default function GuideScreen() { scrollRef.current?.scrollTo({ x: next * width, animated: true }); }; + const slotWidth = Math.min(width - 96, 300); + const slotHeight = Math.round(slotWidth * 0.72); + return ( - + {reviewing ? ( + + + + ) : ( + + )} - 跳过 + {reviewing ? '关闭' : '跳过'} @@ -90,13 +119,7 @@ export default function GuideScreen() { > {SLIDES.map((slide) => ( - - - + {slide.title} {slide.desc} @@ -111,7 +134,9 @@ export default function GuideScreen() { - {isLast ? '立即体验' : '下一步'} + + {isLast ? (reviewing ? '完成' : '立即体验') : '下一步'} + @@ -131,7 +156,12 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 10, }, - topSpacer: { width: 40 }, + backBtn: { + width: 36, + height: 36, + alignItems: 'center', + justifyContent: 'center', + }, skipText: { fontSize: 14, color: colors.textSecondary, paddingHorizontal: 4 }, pager: { flex: 1 }, slide: { @@ -140,19 +170,12 @@ const styles = StyleSheet.create({ justifyContent: 'center', paddingHorizontal: 36, }, - illus: { - width: 132, - height: 132, - borderRadius: 36, - alignItems: 'center', - justifyContent: 'center', - marginBottom: 36, - }, slideTitle: { fontSize: 22, fontWeight: '800', color: colors.textPrimary, textAlign: 'center', + marginTop: 36, marginBottom: 14, }, slideDesc: { diff --git a/app/holland-result.tsx b/app/holland-result.tsx index bd61991..07fa0a0 100644 --- a/app/holland-result.tsx +++ b/app/holland-result.tsx @@ -14,7 +14,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import Svg, { Circle, Line, Polygon } from 'react-native-svg'; import { useHollandAssessment } from '../src/hooks'; import { colors, headerGradientShort } from '../src/theme'; -import type { HollandDimension, HollandTypeAnalysis } from '../src/types'; +import type { HollandAction, HollandDimension, HollandTypeAnalysis } from '../src/types'; const RADAR_SIZE = 160; const RADAR_CENTER = RADAR_SIZE / 2; @@ -186,15 +186,28 @@ function JobCard({ job }: { job: import('../src/types').HollandJobRecommendation /* ===================== 子组件:行动建议 ===================== */ -function ActionCard({ action }: { action: import('../src/types').HollandAction }) { - return ( - +function ActionCard({ action }: { action: HollandAction }) { + const router = useRouter(); + // 建议卡的目标路由由接口层给出(见 api.ts 的 actions);没有 route 的只做展示,不带箭头 + const route = action.route; + + const body = ( + <> {action.title} {action.desc} - + {route ? : null} + + ); + + if (!route) { + return {body}; + } + return ( + router.push(route as never)}> + {body} ); } diff --git a/app/holland.tsx b/app/holland.tsx index 3f30ee1..195437b 100644 --- a/app/holland.tsx +++ b/app/holland.tsx @@ -31,9 +31,8 @@ function Header({ onBack }: { onBack: () => void }) { 职业兴趣测评 - - 记录 - + {/* 原先这里是「记录」按钮:项目内没有测评记录页,留着等于点了没反应,先摘掉 */} + ); } diff --git a/app/interview-score.tsx b/app/interview-score.tsx index b6b5f69..094abd8 100644 --- a/app/interview-score.tsx +++ b/app/interview-score.tsx @@ -2,6 +2,7 @@ import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { ActivityIndicator, + Alert, Pressable, ScrollView, StyleSheet, @@ -13,6 +14,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import Svg, { Line, Polygon, Text as SvgText } from 'react-native-svg'; import { useInterviewScore } from '../src/hooks'; import { colors } from '../src/theme'; +import { formatDateTime } from '../src/utils'; import type { HireSuggestion, InterviewScoreData, InterviewScoreDimension, InterviewScoreTag } from '../src/types'; const COLOR_TOKENS: Record = { @@ -32,9 +34,8 @@ function Header({ onBack }: { onBack: () => void }) { 面试评分 - - - + {/* 原先这里是「历史评分」入口:没有历史评分页,先摘掉,加页面时再挂回来 */} + ); } @@ -271,8 +272,26 @@ export default function InterviewScoreScreen() { selectHireSuggestion, submit, submitting, + saveDraft, + savingDraft, + restoreDraft, + discardDraft, + hasPendingDraft, + draftSavedAt, } = useInterviewScore('interview-1'); + /** 保存草稿:成功后就地在按钮旁显示保存时间,失败给出提示 */ + const handleSaveDraft = async () => { + try { + const savedAt = await saveDraft(); + if (savedAt) { + Alert.alert('已保存草稿', `保存时间 ${formatDateTime(savedAt)},下次进入这场面试时可恢复。`); + } + } catch { + Alert.alert('保存失败', '草稿没能存到本机,请稍后重试。'); + } + }; + if (loading) { return ( @@ -295,6 +314,24 @@ export default function InterviewScoreScreen() {
router.back()} /> + {hasPendingDraft && ( + + + + + 发现未提交的草稿{draftSavedAt ? `(${formatDateTime(draftSavedAt)} 保存)` : ''} + + 恢复后会覆盖当前页面上的评分内容 + + + 恢复 + + void discardDraft()} hitSlop={6}> + 放弃 + + + )} + @@ -304,8 +341,12 @@ export default function InterviewScoreScreen() { - - 保存草稿 + + {savingDraft ? '保存中…' : '保存草稿'} + {/* 原先这里挂着「全部记录 >」:项目内没有面试练习记录页,先摘掉 */} 最近练习 - - 全部记录 > - {recent.map((item) => { const color = COLOR_TOKENS[item.resultColor] ?? colors.primary; diff --git a/app/job-evaluations.tsx b/app/job-evaluations.tsx index ecd1b17..4b31683 100644 --- a/app/job-evaluations.tsx +++ b/app/job-evaluations.tsx @@ -1,12 +1,15 @@ import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { ActivityIndicator, + Alert, + Modal, Pressable, ScrollView, StyleSheet, Text, + TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -30,9 +33,8 @@ function Header({ onBack }: { onBack: () => void }) { 岗位评价管理 - - - + {/* 原先这里是「筛选」图标:筛选面板尚未定义,先摘掉,做筛选时再挂回来 */} + ); } @@ -301,7 +303,13 @@ function EmptyState({ state }: { state: JobEvaluationEmptyState }) { /* ===================== 子组件:评价列表 ===================== */ -function ReviewList({ reviews }: { reviews: JobReview[] }) { +function ReviewList({ + reviews, + onReply, +}: { + reviews: JobReview[]; + onReply: (review: JobReview) => void; +}) { return ( 最新评价 @@ -325,11 +333,25 @@ function ReviewList({ reviews }: { reviews: JobReview[] }) { ))} + + {/* 已回复的评价把回复内容展示出来,否则「已回复」只是个说不清的标签 */} + {review.replied && review.replyContent ? ( + + 企业回复 + {review.replyContent} + + ) : null} + {review.date} - - {review.replied ? '已回复' : '回复'} - + {/* 已回复的评价不再提供重复回复入口 */} + {review.replied ? ( + 已回复 + ) : ( + onReply(review)}> + 回复 + + )} ))} @@ -337,16 +359,99 @@ function ReviewList({ reviews }: { reviews: JobReview[] }) { ); } +/* ===================== 子组件:回复输入弹层 ===================== */ + +/** 回复弹层:用 Modal + TextInput 而不是 Alert.prompt(后者只有 iOS 有) */ +function ReplyModal({ + review, + submitting, + onCancel, + onSubmit, +}: { + review: JobReview | null; + submitting: boolean; + onCancel: () => void; + onSubmit: (content: string) => void; +}) { + const [content, setContent] = useState(''); + + // 换一条评价就清空输入,避免把上一条的回复带过去 + useEffect(() => { + setContent(''); + }, [review?.id]); + + const trimmed = content.trim(); + + return ( + + + + 回复评价 + {review ? ( + + {review.userName}:{review.content} + + ) : null} + + + + 取消 + + trimmed !== '' && onSubmit(trimmed)} + disabled={trimmed === '' || submitting} + > + {submitting ? '提交中' : '提交回复'} + + + + + + ); +} + /* ===================== 页面主组件 ===================== */ export default function JobEvaluationsScreen() { const router = useRouter(); - const { management, loading: dataLoading, error: dataError } = useJobEvaluations(); + const { + management, + loading: dataLoading, + error: dataError, + refresh, + reply, + replyingId, + } = useJobEvaluations(); const { state: emptyState, loading: emptyLoading, error: emptyError } = useJobEvaluationEmptyState(); + /** 正在回复的评价;null 表示弹层关闭 */ + const [replying, setReplying] = useState(null); const loading = dataLoading || emptyLoading; const error = dataError || emptyError; + /** 提交回复:成功后重新拉数据(回复率与回复内容都要跟着变),失败给出可读提示 */ + const submitReply = (content: string) => { + if (!replying) return; + void reply(replying.id, content) + .then(() => { + setReplying(null); + refresh(); + }) + .catch((e: unknown) => { + Alert.alert('回复失败', e instanceof Error ? e.message : '请稍后重试'); + }); + }; + if (loading) { return ( @@ -386,10 +491,17 @@ export default function JobEvaluationsScreen() { - + ) : null} + + setReplying(null)} + onSubmit={submitReply} + /> ); } @@ -638,6 +750,93 @@ const styles = StyleSheet.create({ color: colors.primary, fontWeight: '500', }, + /** 已回复:不再是按钮,只是一个状态说明 */ + repliedText: { + fontSize: 12, + color: colors.successText, + fontWeight: '500', + }, + /* ---- 回复内容块 ---- */ + replyBlock: { + marginTop: 10, + padding: 10, + borderRadius: 8, + backgroundColor: colors.successLight, + }, + replyBlockLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.successText, + }, + replyBlockText: { + marginTop: 4, + fontSize: 13, + lineHeight: 20, + color: colors.textPrimary, + }, + /* ---- 回复弹层 ---- */ + modalMask: { + flex: 1, + backgroundColor: colors.scrim, + justifyContent: 'center', + paddingHorizontal: 28, + }, + modalCard: { + backgroundColor: colors.surface, + borderRadius: 14, + padding: 18, + }, + modalTitle: { + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary, + }, + modalQuote: { + marginTop: 8, + fontSize: 13, + lineHeight: 20, + color: colors.textSecondary, + }, + modalInput: { + marginTop: 12, + minHeight: 88, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 14, + color: colors.textPrimary, + textAlignVertical: 'top', + }, + modalActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: 12, + marginTop: 16, + }, + modalCancel: { + paddingHorizontal: 16, + paddingVertical: 10, + }, + modalCancelText: { + fontSize: 14, + color: colors.textSecondary, + }, + modalSubmit: { + paddingHorizontal: 18, + paddingVertical: 10, + borderRadius: 20, + backgroundColor: colors.primaryBtn, + }, + modalSubmitOff: { + backgroundColor: colors.textDisabled, + }, + modalSubmitText: { + fontSize: 14, + fontWeight: '600', + color: colors.white, + }, /* ---- 空状态 ---- */ emptyWrapper: { marginBottom: 16, diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 9b28bd6..7475be8 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -5,15 +5,19 @@ import { useEffect, useState } from 'react'; import { ActivityIndicator, Alert, + Modal, Pressable, ScrollView, StyleSheet, Switch, Text, + TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { buildChecklistText } from '../src/api'; import { useOnboardingChecklist } from '../src/hooks'; +import { shareText } from '../src/lib/shareFile'; import { colors, headerGradientShort } from '../src/theme'; import type { ChecklistGroup, OnboardingChecklist, OnboardingTip } from '../src/types'; @@ -103,18 +107,22 @@ function CountdownCard({ function ChecklistGroupCard({ group, onToggleItem, + onRemoveItem, togglingId, expanded, onToggleExpand, }: { group: ChecklistGroup; onToggleItem: (itemId: string, done: boolean) => void; + onRemoveItem: (itemId: string) => void; togglingId: string | null; expanded: boolean; onToggleExpand: () => void; }) { const theme = GROUP_ICON_THEME[group.id] ?? GROUP_ICON_THEME.material; const doneCount = group.items.filter((i) => i.done).length; + // 只有「我的自定义待办」这一组可以删条目:模板待办删了下次进页面还会回来 + const removable = group.id === 'custom'; return ( @@ -155,6 +163,11 @@ function ChecklistGroupCard({ 必填 )} + {removable && ( + onRemoveItem(item.id)} hitSlop={8}> + + + )} ))} @@ -238,12 +251,16 @@ function ErrorState({ onRetry }: { onRetry: () => void }) { export default function OnboardingScreen() { const router = useRouter(); - const { checklist, loading, error, refresh, toggle, togglingId } = useOnboardingChecklist(); + const { checklist, loading, error, refresh, toggle, togglingId, addItem, removeItem, mutating } = + useOnboardingChecklist(); /** 本地清单状态(勾选后即时更新,避免等待接口往返) */ const [localChecklist, setLocalChecklist] = useState(null); const [expandedIds, setExpandedIds] = useState([]); const [reminderEnabled, setReminderEnabled] = useState(false); + /** 「添加自定义待办」弹窗 */ + const [addVisible, setAddVisible] = useState(false); + const [draftTitle, setDraftTitle] = useState(''); useEffect(() => { if (checklist && !localChecklist) { @@ -268,15 +285,42 @@ export default function OnboardingScreen() { await toggle(itemId, done); }; - // 右上角「分享」暂无对应页面,按项目约定弹提示(此前绑定空函数,点击无任何反馈) - const handleShare = () => { - Alert.alert('提示', '分享入职清单功能开发中,敬请期待'); + /** 添加自定义待办:接口层负责落库并返回新清单,页面直接换成返回值 */ + const handleAddItem = async () => { + try { + const updated = await addItem(draftTitle); + setLocalChecklist(updated); + setDraftTitle(''); + setAddVisible(false); + } catch (e) { + Alert.alert('添加失败', e instanceof Error ? e.message : '请稍后重试'); + } + }; + + /** 删除自定义待办(模板条目接口层会拒绝) */ + const handleRemoveItem = async (itemId: string) => { + try { + const updated = await removeItem(itemId); + setLocalChecklist(updated); + } catch (e) { + Alert.alert('删除失败', e instanceof Error ? e.message : '请稍后重试'); + } + }; + + /** + * 导出 / 分享清单:把清单拼成文本交给系统分享面板。 + * `ok` 只代表面板已拉起,不能据此提示「分享成功」(见 lib/shareFile 的说明)。 + */ + const handleExport = async () => { + if (!display) return; + const outcome = await shareText(buildChecklistText(display), '入职准备清单'); + if (!outcome.ok) Alert.alert('无法导出', outcome.message); }; if (loading) { return ( -
router.back()} onShare={handleShare} /> +
router.back()} onShare={() => void handleExport()} /> ); @@ -285,7 +329,7 @@ export default function OnboardingScreen() { if (error || !display) { return ( -
router.back()} onShare={handleShare} /> +
router.back()} onShare={() => void handleExport()} /> ); @@ -293,7 +337,7 @@ export default function OnboardingScreen() { return ( -
router.back()} onShare={handleShare} /> +
router.back()} onShare={() => void handleExport()} /> @@ -331,14 +376,48 @@ export default function OnboardingScreen() { - + setAddVisible(true)} + disabled={mutating} + > 添加自定义待办 - + void handleExport()}> 导出清单 + + {/* 添加自定义待办:Alert.prompt 只有 iOS 有,这里用自绘弹窗(与岗位评价回复一致) */} + setAddVisible(false)}> + + + 添加自定义待办 + + + setAddVisible(false)}> + 取消 + + void handleAddItem()} + disabled={draftTitle.trim() === '' || mutating} + > + {mutating ? '添加中…' : '添加'} + + + + + ); } @@ -491,4 +570,33 @@ const styles = StyleSheet.create({ alignItems: 'center', }, exportBtnText: { fontSize: 15, fontWeight: '700', color: colors.white }, + footerBtnDisabled: { opacity: 0.5 }, + modalMask: { + flex: 1, + backgroundColor: colors.scrim, + justifyContent: 'center', + paddingHorizontal: 32, + }, + modalCard: { backgroundColor: colors.white, borderRadius: 16, padding: 20 }, + modalTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, + modalInput: { + marginTop: 14, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 14, + color: colors.textPrimary, + }, + modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 12, marginTop: 18 }, + modalCancel: { paddingHorizontal: 16, paddingVertical: 8 }, + modalCancelText: { fontSize: 14, color: colors.textSecondary }, + modalConfirm: { + backgroundColor: colors.primaryBtn, + borderRadius: 18, + paddingHorizontal: 20, + paddingVertical: 8, + }, + modalConfirmText: { fontSize: 14, fontWeight: '700', color: colors.white }, }); diff --git a/app/register.tsx b/app/register.tsx index 300f60a..2a2aee5 100644 --- a/app/register.tsx +++ b/app/register.tsx @@ -23,7 +23,6 @@ import { P18Education } from '../components/onboarding/screens/P18Education'; import { P20School } from '../components/onboarding/screens/P20School'; import { P21Avatar } from '../components/onboarding/screens/P21Avatar'; import { P22Done } from '../components/onboarding/screens/P22Done'; -import { P23Home } from '../components/onboarding/screens/P23Home'; import { B1Wechat } from '../components/onboarding/screens/B1Wechat'; import { B2Agreement } from '../components/onboarding/screens/B2Agreement'; import { B3Recruiter } from '../components/onboarding/screens/B3Recruiter'; @@ -56,7 +55,8 @@ function FlowRouter() { case 'P20': return ; case 'P21': return ; case 'P22': return ; - case 'P23': return ; + // 原 P23「假首页」已删除:它是写死上海 4 条职位的演示页,与真实主界面 (tabs)/jobs 重复。 + // 现在 P22(及 P6/P16 的快速通道)由 useRegisterFinish 直接 replace 进真主界面。 case 'B1': return ; case 'B2': return ; case 'B3': return ; diff --git a/app/resume.tsx b/app/resume.tsx index df99494..d5370e7 100644 --- a/app/resume.tsx +++ b/app/resume.tsx @@ -1,30 +1,41 @@ import { Ionicons } from '@expo/vector-icons'; -import { useRouter } from 'expo-router'; -import { useState } from 'react'; +import { useFocusEffect, useRouter } from 'expo-router'; +import { useCallback, useState } from 'react'; import { ActivityIndicator, + Alert, + Modal, Pressable, ScrollView, StyleSheet, Text, + TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useResume } from '../src/hooks'; +import { RESUME_EVAL_MAX_LENGTH, UNSET_LABEL } from '../src/constants'; import { colors } from '../src/theme'; import type { Resume as ResumeType, ResumeExperience, ResumeOptimizePoint, ResumeProject } from '../src/types'; +import { formatDateTime } from '../src/utils'; /* ===================== 子组件:顶部导航 ===================== */ +/** + * 顶部导航。`onSave` 可选:加载中 / 加载失败时简历尚未就绪,此时按钮置灰而不是给一个 + * 点了没反应的入口(同 `app/resume/export.tsx` 的处理)。 + */ function Header({ onBack, onSave, onExport, }: { onBack: () => void; - onSave: () => void; + /** 传 undefined = 简历未就绪,按钮置灰 */ + onSave?: () => void; onExport: () => void; }) { + const saveDisabled = onSave === undefined; return ( @@ -32,10 +43,25 @@ function Header({ AI简历优化 - - + + - + @@ -92,8 +118,12 @@ function ScoreCard({ function OptimizeCard({ points, + onOptimizeAll, + optimizing, }: { points: ResumeOptimizePoint[]; + onOptimizeAll: () => void; + optimizing: boolean; }) { return ( @@ -112,8 +142,12 @@ function OptimizeCard({ +{p.score}分 ))} - - 一键全部优化 + + {optimizing ? '优化中…' : '一键全部优化'} ); @@ -121,36 +155,39 @@ function OptimizeCard({ /* ===================== 子组件:基本信息 ===================== */ -function BasicInfoCard({ info }: { info: ResumeType['basicInfo'] }) { +function BasicInfoCard({ info, onEdit }: { info: ResumeType['basicInfo']; onEdit: () => void }) { + // 引导未强制填姓名(P6/P16 可跳过直接进入),头像字取展示姓名的首字,保持一致 + const name = info.name || UNSET_LABEL; + const rows = [ + { key: 'phone', icon: 'call-outline' as const, value: info.phone }, + { key: 'email', icon: 'mail-outline' as const, value: info.email }, + { key: 'location', icon: 'location-outline' as const, value: info.location }, + ].filter((row) => row.value !== ''); + return ( 基本信息 - + 编辑基本信息 > - {info.avatar} + {name.slice(0, 1)} - {info.name} + {name} - - - {info.phone} - - - - {info.email} - - - - {info.location} - + {/* 空值不渲染空行:手机号 / 邮箱引导未采集,画一行空白比不画更让人困惑 */} + {rows.map((row) => ( + + + {row.value} + + ))} @@ -160,11 +197,13 @@ function BasicInfoCard({ info }: { info: ResumeType['basicInfo'] }) { /* ===================== 子组件:求职意向 ===================== */ function JobIntentionCard({ intention }: { intention: ResumeType['jobIntention'] }) { + const router = useRouter(); return ( 求职意向 - + {/* 指向已存在的求职意向编辑页(此前是无 onPress 的死按钮) */} + router.push('/job-preference')} hitSlop={6}> 编辑 > @@ -175,7 +214,8 @@ function JobIntentionCard({ intention }: { intention: ResumeType['jobIntention'] @@ -191,15 +231,16 @@ function IntentionItem({ value: string; tag?: boolean; }) { + const text = value || UNSET_LABEL; return ( {label} {tag ? ( - {value} + {text} ) : ( - {value} + {text} )} ); @@ -207,29 +248,46 @@ function IntentionItem({ /* ===================== 子组件:教育背景 ===================== */ -function EducationCard({ education }: { education: ResumeType['education'] }) { +function EducationCard({ + education, + onAdd, + onEdit, + onItem, +}: { + education: ResumeType['education']; + onAdd: () => void; + onEdit: () => void; + onItem: (id: string) => void; +}) { return ( 教育背景 - - - 添加教育经历 - - - {education.map((edu) => ( - - - {edu.school} - - {edu.major} · {edu.degree} - - - - {edu.startYear}-{edu.endYear} - + + + 编辑 > + + + + 添加教育经历 + - ))} + + {education.length === 0 && 未填写教育背景} + {education.map((edu) => { + // 专业 / 年份引导未采集时为空串,拼出来会剩一个孤零零的「· 本科」或「-」 + const majorDegree = [edu.major, edu.degree].filter(Boolean).join(' · '); + const years = [edu.startYear, edu.endYear].filter(Boolean).join('-'); + return ( + onItem(edu.id)}> + + {edu.school} + {majorDegree !== '' && {majorDegree}} + + {years !== '' && {years}} + + ); + })} ); } @@ -238,38 +296,63 @@ function EducationCard({ education }: { education: ResumeType['education'] }) { function ExperienceCard({ experiences, + onAdd, + onEdit, + onItem, + onOptimize, + aiBusy, }: { experiences: ResumeExperience[]; + onAdd: () => void; + onEdit: () => void; + onItem: (id: string) => void; + onOptimize: (id: string) => void; + /** AI 请求进行中:按钮禁用,避免连点堆起多个请求 */ + aiBusy: boolean; }) { return ( 工作经历 - - - 添加工作经历 - + + + 编辑 > + + + + 添加工作经历 + + + {experiences.length === 0 && 未填写工作经历} {experiences.map((exp) => ( - + onItem(exp.id)}> {exp.company} - - {exp.startDate} - {exp.endDate} - + {/* 离职时结束时间未采集(留空),只展示已采集到的那一端,不补「-」 */} + {[exp.startDate, exp.endDate].filter(Boolean).length > 0 && ( + + {[exp.startDate, exp.endDate].filter(Boolean).join(' - ')} + + )} - {exp.position} - {exp.description} + {exp.position !== '' && {exp.position}} + {exp.description !== '' && {exp.description}} {!exp.aiOptimized && ( AI 建议优化 - + onOptimize(exp.id)} + disabled={aiBusy} + accessibilityLabel={`AI 优化 ${exp.company} 的工作内容`} + > AI 优化 )} - + ))} ); @@ -277,18 +360,34 @@ function ExperienceCard({ /* ===================== 子组件:项目经历 ===================== */ -function ProjectCard({ projects }: { projects: ResumeProject[] }) { +function ProjectCard({ + projects, + onAdd, + onEdit, + onItem, +}: { + projects: ResumeProject[]; + onAdd: () => void; + onEdit: () => void; + onItem: (id: string) => void; +}) { return ( 项目经历 - - - 添加项目经历 - + + + 编辑 > + + + + 添加项目经历 + + + {projects.length === 0 && 未填写项目经历} {projects.map((proj) => ( - + onItem(proj.id)}> {proj.name} {proj.role} @@ -300,7 +399,7 @@ function ProjectCard({ projects }: { projects: ResumeProject[] }) { 已量化 )} - + ))} ); @@ -308,17 +407,18 @@ function ProjectCard({ projects }: { projects: ResumeProject[] }) { /* ===================== 子组件:技能特长 ===================== */ -function SkillsCard({ skills }: { skills: ResumeType['skills'] }) { +function SkillsCard({ skills, onEdit }: { skills: ResumeType['skills']; onEdit: () => void }) { return ( 技能特长 - + 添加技能 + {skills.length === 0 && 未填写技能特长} {skills.map((skill) => ( {skill.name} @@ -329,18 +429,194 @@ function SkillsCard({ skills }: { skills: ResumeType['skills'] }) { ); } +/* ===================== 子组件:技能编辑弹窗 ===================== */ +/* + * 技能只有「名词」一个字段,为它单开一个路由不划算,就地弹层编辑: + * 已添加的标签点 × 即时删除,输入框 +「添加」追加。 + * 去重的权威规则在 API 层(`saveResumeSkills`),这里只做即时门槛让按钮状态诚实。 + */ +function SkillsModal({ + visible, + skills, + draft, + onDraftChange, + onClose, + onAdd, + onRemove, + saving, +}: { + visible: boolean; + skills: ResumeType['skills']; + /** 草稿由页面持有(与 `SaveSheet` 同款):关掉再打开时页面已经把它清掉了,弹层不必自己同步 */ + draft: string; + onDraftChange: (v: string) => void; + onClose: () => void; + onAdd: (name: string) => void; + onRemove: (name: string) => void; + saving: boolean; +}) { + const name = draft.trim(); + const duplicated = name !== '' && skills.some((s) => s.name === name); + const canAdd = name !== '' && !duplicated && !saving; + + return ( + + {/* 内层 Pressable 吞掉冒泡:点内容区不该关闭弹层 */} + + {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */} + {}}> + + 编辑技能 + + + + + + + 已添加(点 × 删除) + {skills.length === 0 ? ( + 还没有添加技能 + ) : ( + + {skills.map((skill) => ( + onRemove(skill.name)} + disabled={saving} + accessibilityLabel={`删除技能 ${skill.name}`} + > + {skill.name} + + + ))} + + )} + + + + canAdd && onAdd(name)} + returnKeyType="done" + /> + onAdd(name)} + disabled={!canAdd} + > + 添加 + + + {duplicated && 已添加过该技能} + + + + ); +} + +/* ===================== 子组件:自我评价编辑弹窗 ===================== */ + +function EvaluationModal({ + visible, + draft, + onDraftChange, + onClose, + onSave, + saving, +}: { + visible: boolean; + draft: string; + onDraftChange: (v: string) => void; + onClose: () => void; + onSave: () => void; + saving: boolean; +}) { + return ( + + + {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */} + {}}> + + 编辑自我评价 + + + + + + + + {draft.length}/{RESUME_EVAL_MAX_LENGTH} + + + + + 取消 + + + {saving ? '保存中…' : '保存'} + + + + + + ); +} + /* ===================== 子组件:自我评价 ===================== */ -function EvaluationCard({ content }: { content: string }) { +function EvaluationCard({ + content, + onEdit, + onPolish, + polishing, +}: { + content: string; + onEdit: () => void; + onPolish: () => void; + polishing: boolean; +}) { return ( 自我评价 - - AI 润色 - + + + {content === '' ? '去填写' : '编辑'} > + + + AI 润色 + + - {content} + {content === '' ? ( + 未填写自我评价 + ) : ( + {content} + )} ); } @@ -372,35 +648,127 @@ function ErrorState({ onRetry }: { onRetry: () => void }) { export default function ResumeScreen() { const router = useRouter(); - const { resume, loading, error, refresh, optimize } = useResume(); - const [currentResume, setCurrentResume] = useState(null); + const { + resume, + savedAt, + loading, + error, + refresh, + saving, + optimize, + save, + saveSelfEvaluation, + saveSkills, + optimizeExperience, + polishSelfEvaluation, + } = useResume(); + + /** + * 「AI 一键优化」只留下评分——它返回的正文与当前简历一致(mock 阶段不改写用户内容, + * 见 `optimizeResume`),把整份结果塞进 state 会让此后每一次编辑都显示旧数据。 + */ + const [optimizedScore, setOptimizedScore] = useState(null); const [optimizing, setOptimizing] = useState(false); + /** AI 单点动作进行中:按钮禁用,避免连点堆请求 */ + const [aiBusy, setAiBusy] = useState(false); - const displayResume = currentResume ?? resume; + const [skillsModal, setSkillsModal] = useState(false); + const [skillDraft, setSkillDraft] = useState(''); + const [evalModal, setEvalModal] = useState(false); + const [evalDraft, setEvalDraft] = useState(''); + + // 简历永远取自 storage(唯一真相源),会话内的优化只覆盖评分那一块 + const displayResume = resume === null ? null : { ...resume, score: optimizedScore ?? resume.score }; + + // 从编辑页返回时重新取数:各编辑页写入的是覆盖层,不刷新就显示旧值 + // (useAsyncData 只在挂载时取数) + useFocusEffect( + useCallback(() => { + refresh(); + }, [refresh]), + ); const handleOptimize = async () => { + if (optimizing) return; setOptimizing(true); try { const optimized = await optimize(); - setCurrentResume(optimized); + setOptimizedScore(optimized.score); } finally { setOptimizing(false); } }; - if (loading) { + const handleSave = async () => { + if (displayResume === null || saving) return; + try { + const { savedAt: at } = await save(displayResume); + Alert.alert('已保存到本机', `保存时间 ${formatDateTime(at)}`); + } catch { + Alert.alert('保存失败', '请稍后重试'); + } + }; + + /* ---------- AI 动作:能力未接入时只如实说明,不动简历 ---------- */ + + const runAIAction = async (action: () => Promise<{ message: string }>) => { + if (aiBusy) return; + setAiBusy(true); + try { + const result = await action(); + Alert.alert('暂未开放', result.message); + } finally { + setAiBusy(false); + } + }; + + /* ---------- 技能:删除即时落盘,添加是一次写入 ---------- */ + + const skills = displayResume?.skills ?? []; + + const handleAddSkill = async (name: string) => { + if (saving) return; + await saveSkills([...skills, { name }]); + setSkillDraft(''); + }; + + const handleRemoveSkill = (name: string) => { + if (saving) return; + void saveSkills(skills.filter((s) => s.name !== name)); + }; + + /* ---------- 自我评价 ---------- */ + + const openEvalModal = () => { + setEvalDraft(displayResume?.selfEvaluation.content ?? ''); + setEvalModal(true); + }; + + const handleSaveEvaluation = async () => { + if (saving) return; + await saveSelfEvaluation(evalDraft); + setEvalModal(false); + }; + + /* + * 只在「还没有任何数据」时显示加载态。 + * 本页每次获得焦点都会 refresh(编辑页可能写过覆盖层), + * 若按 `loading` 直接渲染骨架屏,从编辑页返回时会整页白闪一次 —— 刚改完的内容, + * 用户看到的却是一次全屏加载。 + */ + if (loading && displayResume === null) { return ( -
router.back()} onSave={() => {}} onExport={() => {}} /> +
router.back()} onExport={() => router.push('/resume/export')} /> ); } - if (error || !displayResume) { + if (error !== null || displayResume === null) { return ( -
router.back()} onSave={() => {}} onExport={() => {}} /> +
router.back()} onExport={() => router.push('/resume/export')} /> ); @@ -410,7 +778,7 @@ export default function ResumeScreen() {
router.back()} - onSave={() => {}} + onSave={handleSave} onExport={() => router.push('/resume/export')} /> @@ -420,18 +788,50 @@ export default function ResumeScreen() { onOptimize={handleOptimize} optimizing={optimizing} /> - - + {/* 「保存」不是装饰:这里要如实显示存到了什么时候,否则用户不知道头部那个图标干了什么 */} + {savedAt !== '' && 上次保存:{formatDateTime(savedAt)}} + + router.push('/resume/basic')} /> - - - - - + router.push('/resume/education?new=1')} + onEdit={() => router.push('/resume/education')} + onItem={(id) => router.push('/resume/education?id=' + id)} + /> + router.push('/resume/experience?new=1')} + onEdit={() => router.push('/resume/experience')} + onItem={(id) => router.push('/resume/experience?id=' + id)} + onOptimize={(id) => void runAIAction(() => optimizeExperience(id))} + aiBusy={aiBusy} + /> + router.push('/resume/project?new=1')} + onEdit={() => router.push('/resume/project')} + onItem={(id) => router.push('/resume/project?id=' + id)} + /> + setSkillsModal(true)} /> + void runAIAction(() => polishSelfEvaluation(displayResume.selfEvaluation.content))} + polishing={aiBusy} + /> - + router.push('/resume/preview')} + hitSlop={6} + > 预览 + + { + setSkillDraft(''); + setSkillsModal(false); + }} + onAdd={(name) => void handleAddSkill(name)} + onRemove={handleRemoveSkill} + saving={saving} + /> + setEvalModal(false)} + onSave={() => void handleSaveEvaluation()} + saving={saving} + /> ); } @@ -529,6 +951,7 @@ const styles = StyleSheet.create({ alignItems: 'center', }, optAllText: { fontSize: 14, fontWeight: '700', color: colors.primaryText }, + optAllBtnDisabled: { opacity: 0.7 }, sectionTitleRow: { flexDirection: 'row', alignItems: 'center', @@ -536,7 +959,10 @@ const styles = StyleSheet.create({ marginBottom: 14, }, sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, + sectionActions: { flexDirection: 'row', alignItems: 'center', gap: 14 }, editLink: { fontSize: 13, color: colors.primary }, + /** 「上次保存」那行:只在真的保存过之后出现 */ + savedAt: { fontSize: 12, color: colors.textHint, marginBottom: 12, marginTop: -4 }, basicRow: { flexDirection: 'row', alignItems: 'center' }, avatar: { width: 60, @@ -619,8 +1045,12 @@ const styles = StyleSheet.create({ paddingHorizontal: 12, paddingVertical: 6, }, + /** 弹层里的标签可点删除:补一个 × 图标,光靠「可点击」没有视觉线索 */ + skillTagEditable: { flexDirection: 'row', alignItems: 'center', gap: 6 }, skillText: { fontSize: 13, color: colors.textPrimary }, evalText: { fontSize: 13, color: colors.textSecondary, lineHeight: 22 }, + /** 引导未采集该板块时的空态文案(不伪装成已填写) */ + emptyText: { fontSize: 13, color: colors.textHint, marginTop: 4 }, footer: { position: 'absolute', left: 0, @@ -652,4 +1082,76 @@ const styles = StyleSheet.create({ alignItems: 'center', }, optimizeFooterBtnText: { fontSize: 15, fontWeight: '700', color: colors.white }, + /* ---------- 技能 / 自我评价弹层 ---------- */ + modalMask: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0, 0, 0, 0.4)' }, + modalSheet: { + backgroundColor: colors.white, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: 28, + maxHeight: '80%', + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 14, + }, + modalTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary }, + modalBody: { maxHeight: 180 }, + modalLabel: { fontSize: 13, color: colors.textHint, marginBottom: 10 }, + modalInputRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 16 }, + modalInput: { + flex: 1, + height: 46, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: 14, + fontSize: 15, + color: colors.textPrimary, + }, + modalAddBtn: { + backgroundColor: colors.primaryBtn, + borderRadius: 12, + paddingHorizontal: 18, + paddingVertical: 13, + }, + modalAddBtnDisabled: { backgroundColor: colors.border }, + modalAddText: { fontSize: 14, fontWeight: '700', color: colors.white }, + modalError: { fontSize: 12, color: colors.dangerText, marginTop: 8 }, + evalInput: { + height: 140, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.border, + paddingHorizontal: 14, + paddingTop: 12, + fontSize: 15, + lineHeight: 22, + color: colors.textPrimary, + textAlignVertical: 'top', + }, + evalCounter: { fontSize: 12, color: colors.textHint, textAlign: 'right', marginTop: 6 }, + modalFooter: { flexDirection: 'row', gap: 12, marginTop: 18 }, + modalCancelBtn: { + flex: 1, + borderRadius: 24, + paddingVertical: 14, + alignItems: 'center', + borderWidth: 1, + borderColor: colors.border, + }, + modalCancelText: { fontSize: 15, fontWeight: '700', color: colors.textSecondary }, + modalSaveBtn: { + flex: 2, + backgroundColor: colors.primaryBtn, + borderRadius: 24, + paddingVertical: 14, + alignItems: 'center', + }, + modalSaveBtnDisabled: { backgroundColor: colors.textDisabled }, + modalSaveText: { fontSize: 15, fontWeight: '700', color: colors.white }, }); diff --git a/app/resume/basic.tsx b/app/resume/basic.tsx new file mode 100644 index 0000000..d5bd568 --- /dev/null +++ b/app/resume/basic.tsx @@ -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(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 = router.back()} />; + + if (form === null) { + return ( + + {header} + {error !== null ? ( + + ) : ( + + )} + + ); + } + + 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 ( + + {header} + + update('name', v)} + placeholder="请输入姓名" + maxLength={20} + /> + update('phone', v)} + placeholder="请输入手机号" + keyboardType="phone-pad" + maxLength={11} + error={phoneError} + /> + update('email', v)} + placeholder="请输入邮箱" + keyboardType="email-address" + error={emailError} + /> + update('location', v)} + placeholder="如:北京 · 朝阳区" + maxLength={30} + /> + + {formError !== '' && {formError}} + + + + + + + + ); +} + +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 }, +}); diff --git a/app/resume/education.tsx b/app/resume/education.tsx new file mode 100644 index 0000000..c10ac6f --- /dev/null +++ b/app/resume/education.tsx @@ -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(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 = ( + (editing === null ? router.back() : setEditing(null))} /> + ); + + if (resume === null) { + return ( + + {header} + {error !== null ? ( + + ) : ( + + )} + + ); + } + + /* ===================== 表单态 ===================== */ + + 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 ( + + {header} + + update('school', v)} + placeholder="请输入学校名称" + maxLength={30} + autoFocus + /> + update('major', v)} + placeholder="如:计算机科学与技术" + maxLength={30} + /> + update('degree', v)} + placeholder="如:本科 / 硕士 非全日制" + maxLength={20} + hint="引导流程采集到的学历会带学习形式(如「硕士 非全日制」),这里可以照原样填" + /> + + + update('startYear', v.replace(/[^\d]/g, ''))} + placeholder="如:2018" + keyboardType="number-pad" + maxLength={4} + error={startError} + /> + + + update('endYear', v.replace(/[^\d]/g, ''))} + placeholder="如:2022" + keyboardType="number-pad" + maxLength={4} + error={endError} + /> + + + {orderError !== '' && {orderError}} + + + + + + + ); + } + + /* ===================== 列表态 ===================== */ + + 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 ( + + {header} + {list.length === 0 ? ( + + ) : ( + + {list.map((edu) => { + // 专业 / 学历 / 年份可能为空(引导只采集学校和学历),拼出来会剩「· 本科」这种半截值 + const majorDegree = [edu.major, edu.degree].filter(Boolean).join(' · '); + const years = [edu.startYear, edu.endYear].filter(Boolean).join('-'); + return ( + + + {edu.school} + {majorDegree !== '' && {majorDegree}} + {years !== '' && {years}} + + setEditing(toForm(edu))} + /> + handleDelete(edu)} + /> + + ); + })} + + )} + + setEditing(EMPTY_FORM)} /> + + + ); +} + +/* ===================== 表单与条目互转 ===================== */ + +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 }, +}); diff --git a/app/resume/experience.tsx b/app/resume/experience.tsx new file mode 100644 index 0000000..fc556b1 --- /dev/null +++ b/app/resume/experience.tsx @@ -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(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 = ( + (editing === null ? router.back() : setEditing(null))} + /> + ); + + if (resume === null) { + return ( + + {header} + {error !== null ? ( + + ) : ( + + )} + + ); + } + + /* ===================== 表单态 ===================== */ + + 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) => 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 ( + + {header} + + update({ company: v })} + placeholder="请输入公司名称" + maxLength={40} + autoFocus + /> + update({ position: v })} + placeholder="如:高级前端工程师" + maxLength={30} + /> + + + + 开始时间 * + + update({ startYear: v })} + onMonthChange={(v) => update({ startMonth: v })} + /> + {startError !== '' && {startError}} + + + + + 至今 + + // 打开「至今」时清掉已选的结束时间:留着会让存进去的 endDate 与开关不一致 + update(v ? { untilNow: true, endYear: '', endMonth: '' } : { untilNow: false }) + } + trackColor={{ false: colors.border, true: colors.primaryBorder }} + thumbColor={editing.untilNow ? colors.primaryBtn : colors.white} + /> + + {editing.untilNow ? ( + + ) : ( + <> + + 结束时间 * + + update({ endYear: v })} + onMonthChange={(v) => update({ endMonth: v })} + /> + {endError !== '' && {endError}} + + )} + + + update({ description: v })} + placeholder="负责了什么、做成了什么(AI 改写需后端接入)" + multiline + maxLength={500} + /> + {orderError !== '' && {orderError}} + + + + + + + ); + } + + /* ===================== 列表态 ===================== */ + + 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 ( + + {header} + {list.length === 0 ? ( + + ) : ( + + {list.map((exp) => { + const dates = [exp.startDate, exp.endDate].filter(Boolean).join(' - '); + return ( + + + {exp.company} + {exp.position !== '' && {exp.position}} + {dates !== '' && {dates}} + {exp.description !== '' && ( + + {exp.description} + + )} + + setEditing(toForm(exp))} + /> + handleDelete(exp)} + /> + + ); + })} + + )} + + setEditing(EMPTY_FORM)} /> + + + ); +} + +/* ===================== 表单与条目互转 ===================== */ + +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 }, +}); diff --git a/app/resume/export.tsx b/app/resume/export.tsx index ec1b0d7..a5e3060 100644 --- a/app/resume/export.tsx +++ b/app/resume/export.tsx @@ -3,6 +3,8 @@ import { useRouter } from 'expo-router'; import { useEffect, useState } from 'react'; import { ActivityIndicator, + Alert, + Modal, Pressable, ScrollView, StyleSheet, @@ -12,9 +14,17 @@ import { View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useResumeExport } from '../../src/hooks'; -import { colors } from '../../src/theme'; -import type { ResumeExportFormat, ResumeExportSettings, ResumeTemplate } from '../../src/types'; +import { + RESUME_EXPORT_FORMAT_NOTICE, + RESUME_EXPORT_PASSWORD_NOTICE, + RESUME_FILE_NAME_HINT, + RESUME_ORIENTATIONS, + RESUME_PAPER_SIZES, + RESUME_TEMPLATES, +} from '../../src/constants'; +import { useResume, useResumeExport } from '../../src/hooks'; +import { colors, templateAccentColors } from '../../src/theme'; +import type { ResumeExportFormat, ResumeExportSettings } from '../../src/types'; const FORMATS: { key: ResumeExportFormat; label: string; icon: keyof typeof Ionicons.glyphMap }[] = [ { key: 'pdf', label: 'PDF', icon: 'document-outline' }, @@ -22,18 +32,6 @@ const FORMATS: { key: ResumeExportFormat; label: string; icon: keyof typeof Ioni { key: 'image', label: '图片', icon: 'image-outline' }, ]; -const TEMPLATES: ResumeTemplate[] = [ - { id: 'classic', name: '简约经典', color: 'primary' }, - { id: 'creative', name: '创意设计', color: 'info' }, - { id: 'tech', name: '技术专业', color: 'success' }, -]; - -const TEMPLATE_COLORS: Record = { - primary: colors.primary, - info: colors.info, - success: colors.success, -}; - /* ===================== 子组件:顶部导航 ===================== */ /** @@ -59,16 +57,16 @@ function Header({ onBack, onSave }: { onBack: () => void; onSave?: () => void }) ); } -/* ===================== 子组件:预览区 ===================== */ +/* ===================== 子组件:预览入口 ===================== */ -function PreviewCard() { +function PreviewCard({ onOpen }: { onOpen: () => void }) { return ( 实时预览你的简历效果 - + 打开预览 @@ -109,6 +107,13 @@ function FormatSelect({ ); })} + {/* 非 PDF 选中的那一刻就说清能做到什么:等到导出后才说,用户已经白等一次 */} + {value !== 'pdf' && ( + + + {RESUME_EXPORT_FORMAT_NOTICE} + + )} ); } @@ -118,20 +123,22 @@ function FormatSelect({ function TemplateSelect({ value, onChange, + onMore, }: { value: string; onChange: (v: string) => void; + onMore: () => void; }) { return ( 选择简历模板 - + 全部模板 > - {TEMPLATES.map((t) => { + {RESUME_TEMPLATES.map((t) => { const selected = value === t.id; return ( onChange(t.id)} > + {/* TODO(素材):需 UI 出 3 张模板预览图(简约经典 / 创意设计 / 技术专业), + 当前用模板强调色色块 + 线条占位 */} @@ -167,11 +176,13 @@ function TemplateSelect({ function ToggleRow({ label, desc, + notice, value, onChange, }: { label: string; desc?: string; + notice?: string; value: boolean; onChange: (v: boolean) => void; }) { @@ -180,6 +191,7 @@ function ToggleRow({ {label} {desc && {desc}} + {notice && {notice}} void; placeholder?: string; + hint?: string; }) { return ( - {label} + + {label} + {hint && {hint}} + void; +}) { + return ( + + {label} + + {value} + + + + ); +} + +/* ===================== 子组件:选择弹层 ===================== */ + +interface SheetOption { + value: string; + label: string; + /** 模板缩略色块(纸型 / 方向不需要) */ + accent?: string; +} + +interface SheetState { + title: string; + hint?: string; + value: string; + options: SheetOption[]; + onSelect: (value: string) => void; +} + +/** 底部选择弹层:模板 / 纸型 / 方向共用(三者的交互完全一致,只差选项与标题) */ +function OptionSheet({ sheet, onClose }: { sheet: SheetState; onClose: () => void }) { + return ( + + + {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */} + {}}> + {sheet.title} + {sheet.options.map((option) => { + const selected = option.value === sheet.value; + return ( + sheet.onSelect(option.value)} + > + {option.accent !== undefined && ( + + )} + + {option.label} + + {selected && } + + ); + })} + {sheet.hint !== undefined && {sheet.hint}} + + 取消 + + + + + ); +} + /* ===================== 子组件:四态 ===================== */ function Loading() { @@ -241,12 +332,31 @@ function ErrorState({ onRetry }: { onRetry: () => void }) { ); } +/* ===================== 工具:storage 值 → 合法设置值 ===================== */ + +/** 弹层回传字符串,白名单校验后才写入设置(storage 里的值不保证是这两个之一) */ +function pickPaperSize(value: string): ResumeExportSettings['paperSize'] { + return value === 'letter' ? 'letter' : 'A4'; +} + +function pickOrientation(value: string): ResumeExportSettings['orientation'] { + return value === 'landscape' ? 'landscape' : 'portrait'; +} + /* ===================== 页面 ===================== */ export default function ResumeExportScreen() { const router = useRouter(); - const { settings, loading, error, refresh, save, exportFile, exporting } = useResumeExport(); + const { settings, loading, error, refresh, save, exportFile, share, exporting, sharing } = + useResumeExport(); + /** + * AI 定向优化走简历域的 hook(页面不直连 API)。 + * 这里只取 `optimizeWithJD`,本页不渲染简历数据。 + */ + const { optimizeWithJD } = useResume(); const [localSettings, setLocalSettings] = useState(null); + const [sheet, setSheet] = useState(null); + const [optimizing, setOptimizing] = useState(false); useEffect(() => { if (settings && !localSettings) { @@ -258,19 +368,68 @@ export default function ResumeExportScreen() { setLocalSettings((prev) => (prev ? { ...prev, [key]: value } : prev)); }; + /** 只落盘不带提示:导出 / 分享前先把设置保存好,避免导出用的是一份没保存的配置 */ + const persist = async () => { + if (localSettings) await save(localSettings); + }; + const handleSave = async () => { - if (localSettings) { - await save(localSettings); - } + await persist(); + Alert.alert('已保存', '导出设置已保存在本机,下次进入仍是这份设置。'); }; const handleExport = async () => { if (!localSettings) return; - await handleSave(); - await exportFile(localSettings); + await persist(); + const outcome = await exportFile(localSettings); + if (!outcome.ok) { + Alert.alert('导出失败', outcome.message); + return; + } + Alert.alert( + '导出成功', + [ + `已生成 ${outcome.fileName}(共 ${outcome.pages} 页)。`, + '文件在本机缓存中,点「分享」可保存到「文件」或发送给他人;缓存可能被系统清理,需要长期留存请先分享出去。', + localSettings.format === 'pdf' ? '' : RESUME_EXPORT_FORMAT_NOTICE, + ] + .filter((line) => line !== '') + .join('\n\n'), + [ + { text: '知道了', style: 'cancel' }, + { text: '分享', onPress: () => void handleShare() }, + ], + ); + }; + + const handleShare = async () => { + if (!localSettings) return; + await persist(); + const outcome = await share(localSettings); + // 成功不弹提示:系统分享面板本身就是反馈;失败必须说清原因 + if (!outcome.ok) Alert.alert('无法分享', outcome.message); + }; + + const jd = (localSettings?.targetJobDescription ?? '').trim(); + + const handleOptimize = async () => { + if (jd === '' || optimizing) return; + setOptimizing(true); + try { + const result = await optimizeWithJD(jd); + if (result.available) { + Alert.alert('已完成', result.message); + } else { + Alert.alert('暂未开放', result.message); + } + } finally { + setOptimizing(false); + } }; - if (loading) { + // 保存设置会 refresh,refresh 会把 loading 置真;此时 localSettings 已有值, + // 不能因为刷新再闪一次整页加载态(首次加载才显示 loading) + if (loading && localSettings === null) { return (
router.back()} /> @@ -292,11 +451,27 @@ export default function ResumeExportScreen() {
router.back()} onSave={handleSave} /> - + router.push('/resume/preview')} /> update('format', v)} /> - update('templateId', v)} /> + update('templateId', v)} + onMore={() => + setSheet({ + title: '选择简历模板', + hint: '更多模板将在后端模板库接入后提供', + value: localSettings.templateId, + options: RESUME_TEMPLATES.map((t) => ({ + value: t.id, + label: t.name, + accent: templateAccentColors[t.color] ?? colors.primary, + })), + onSelect: (v) => update('templateId', v), + }) + } + /> 导出设置 @@ -310,24 +485,55 @@ export default function ResumeExportScreen() { update('exportPassword', v)} /> - {}} /> - {}} /> + p.value === localSettings.paperSize)?.label ?? 'A4' + } + onPress={() => + setSheet({ + title: '选择纸型', + value: localSettings.paperSize, + options: RESUME_PAPER_SIZES.map((p) => ({ value: p.value, label: p.label })), + onSelect: (v) => update('paperSize', pickPaperSize(v)), + }) + } + /> + o.value === localSettings.orientation)?.label ?? + '纵向' + } + onPress={() => + setSheet({ + title: '选择方向', + value: localSettings.orientation, + options: RESUME_ORIENTATIONS.map((o) => ({ value: o.value, label: o.label })), + onSelect: (v) => update('orientation', pickOrientation(v)), + }) + } + /> update('fileName', v)} placeholder="请输入文件名" + hint={RESUME_FILE_NAME_HINT} /> update('includeCover', v)} /> update('includeRecommendation', v)} /> @@ -348,17 +554,37 @@ export default function ResumeExportScreen() { numberOfLines={4} textAlignVertical="top" /> - - - AI 智能优化 + + {optimizing ? ( + + ) : ( + <> + + AI 智能优化 + + )} + {/* 按钮置灰时把原因写在旁边:灰按钮不给理由,用户只会以为坏了 */} + {jd === '' && 请先粘贴岗位描述,再点优化} - - 分享简历 + void handleShare()} + disabled={sharing} + > + {sharing ? ( + + ) : ( + 分享简历 + )} + + {sheet !== null && ( + setSheet(null)} + /> + )} ); } @@ -439,6 +672,8 @@ const styles = StyleSheet.create({ formatLabelActive: { color: colors.primaryText }, formatHint: { fontSize: 10, color: colors.textHint, marginTop: 2 }, formatCheck: { position: 'absolute', top: 8, right: 8 }, + noticeRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 10 }, + noticeText: { fontSize: 12, color: colors.textHint, lineHeight: 17, marginTop: 4 }, templateRow: { flexDirection: 'row', gap: 10 }, templateCard: { flex: 1, @@ -489,13 +724,17 @@ const styles = StyleSheet.create({ borderTopWidth: 1, borderTopColor: colors.borderLight, }, + inputLabelBox: { flex: 1, marginRight: 12 }, inputLabel: { fontSize: 14, color: colors.textPrimary, width: 80 }, + inputHint: { fontSize: 11, color: colors.textHint, marginTop: 2 }, input: { flex: 1, fontSize: 14, color: colors.textPrimary, textAlign: 'right', }, + selectValue: { flexDirection: 'row', alignItems: 'center', gap: 4 }, + selectValueText: { fontSize: 14, color: colors.textSecondary }, targetLabel: { fontSize: 13, color: colors.textSecondary, marginBottom: 10 }, targetInput: { height: 100, @@ -514,7 +753,37 @@ const styles = StyleSheet.create({ paddingVertical: 12, marginTop: 12, }, + targetBtnDisabled: { backgroundColor: colors.textDisabled }, targetBtnText: { fontSize: 14, fontWeight: '700', color: colors.white, marginLeft: 6 }, + scrim: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'flex-end' }, + sheet: { + backgroundColor: colors.white, + borderTopLeftRadius: 18, + borderTopRightRadius: 18, + paddingHorizontal: 20, + paddingTop: 18, + paddingBottom: 28, + }, + sheetTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, marginBottom: 8 }, + sheetRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: colors.borderLight, + }, + sheetSwatch: { width: 22, height: 22, borderRadius: 6, marginRight: 12 }, + sheetLabel: { flex: 1, fontSize: 15, color: colors.textPrimary }, + sheetLabelActive: { color: colors.primaryText, fontWeight: '700' }, + sheetHint: { fontSize: 12, color: colors.textHint, marginTop: 12, lineHeight: 18 }, + sheetCancel: { + marginTop: 16, + paddingVertical: 12, + borderRadius: 24, + alignItems: 'center', + backgroundColor: colors.pageBg, + }, + sheetCancelText: { fontSize: 15, fontWeight: '600', color: colors.textSecondary }, footer: { position: 'absolute', left: 0, @@ -537,6 +806,7 @@ const styles = StyleSheet.create({ borderColor: colors.border, marginRight: 12, }, + shareBtnDisabled: { borderColor: colors.borderLight }, shareBtnText: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, exportBtn: { flex: 2, diff --git a/app/resume/preview.tsx b/app/resume/preview.tsx new file mode 100644 index 0000000..5afcb6b --- /dev/null +++ b/app/resume/preview.tsx @@ -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 = router.back()} />; + + if (resume === null) { + return ( + + {header} + {error !== null ? ( + + ) : ( + + )} + + ); + } + + const { basicInfo, jobIntention, education, experience, projects, skills, selfEvaluation } = resume; + + return ( + + {header} + + {/* 纸张:白底 + 描边,与导出效果同一套排版 */} + + + + {/* TODO(素材):接入后端后这里渲染用户上传的头像图片; + 当前以姓名首字兜底,无姓名时不占位 */} + {basicInfo.avatar !== '' ? basicInfo.avatar : '简'} + + + {basicInfo.name !== '' ? basicInfo.name : UNSET_LABEL} + {/* 有值才渲染:空白行比不渲染更让人困惑 */} + {[basicInfo.phone, basicInfo.email, basicInfo.location].filter((v) => v !== '').length > 0 && ( + + {[basicInfo.phone, basicInfo.email, basicInfo.location].filter((v) => v !== '').join(' · ')} + + )} + + + +
+ {hasIntention(jobIntention) ? ( + + {[ + jobIntention.position, + jobIntention.city, + jobIntention.salary, + jobIntention.statusLabel, + ] + .filter((v) => v !== '') + .join(' · ')} + + ) : ( + + )} +
+ +
+ {education.length === 0 ? ( + + ) : ( + education.map((edu) => ) + )} +
+ +
+ {experience.length === 0 ? ( + + ) : ( + experience.map((exp) => ) + )} +
+ +
+ {projects.length === 0 ? ( + + ) : ( + projects.map((proj) => ) + )} +
+ +
+ {skills.length === 0 ? ( + + ) : ( + {skills.map((s) => s.name).join(' · ')} + )} +
+ +
+ {selfEvaluation.content === '' ? ( + + ) : ( + {selfEvaluation.content} + )} +
+
+ + + 预览为简历正文效果,不含评分与优化建议。模板样式、纸张大小需后端导出服务支持,当前按默认版式呈现。 + +
+ + + router.push('/resume/export')} /> + +
+ ); +} + +/* ===================== 子组件 ===================== */ + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( + + {title} + {children} + + ); +} + +function Empty({ text }: { text: string }) { + return {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 ( + + + {edu.school} + {years !== '' && {years}} + + {majorDegree !== '' && {majorDegree}} + + ); +} + +function ExperienceItem({ exp }: { exp: ResumeExperience }) { + const dates = [exp.startDate, exp.endDate].filter(Boolean).join(' - '); + return ( + + + {exp.company} + {dates !== '' && {dates}} + + {exp.position !== '' && {exp.position}} + {exp.description !== '' && {exp.description}} + + ); +} + +function ProjectItem({ proj }: { proj: ResumeProject }) { + return ( + + + {proj.name} + {proj.role !== '' && {proj.role}} + + {proj.description !== '' && {proj.description}} + + ); +} + +/** 求职意向四项全空才算未填写(引导只采集到其中一两项时也算有内容) */ +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 }, +}); diff --git a/app/resume/project.tsx b/app/resume/project.tsx new file mode 100644 index 0000000..a09c954 --- /dev/null +++ b/app/resume/project.tsx @@ -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(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 = ( + (editing === null ? router.back() : setEditing(null))} + /> + ); + + if (resume === null) { + return ( + + {header} + {error !== null ? ( + + ) : ( + + )} + + ); + } + + /* ===================== 表单态 ===================== */ + + if (editing !== null) { + const name = editing.name.trim(); + const canSave = name !== ''; + + const update = (patch: Partial) => 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 ( + + {header} + + update({ name: v })} + placeholder="请输入项目名称" + maxLength={40} + autoFocus + /> + update({ role: v })} + placeholder="如:前端负责人" + maxLength={20} + /> + update({ description: v })} + placeholder="做了什么、结果如何(有数字更好,如:首屏从 3.5s 降至 1.2s)" + multiline + maxLength={500} + /> + + + + + + + ); + } + + /* ===================== 列表态 ===================== */ + + 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 ( + + {header} + {list.length === 0 ? ( + + ) : ( + + {list.map((proj) => ( + + + {proj.name} + {proj.role !== '' && {proj.role}} + {proj.description !== '' && ( + + {proj.description} + + )} + + setEditing(toForm(proj))} + /> + handleDelete(proj)} + /> + + ))} + + )} + + setEditing(EMPTY_FORM)} /> + + + ); +} + +/* ===================== 表单与条目互转 ===================== */ + +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 }, +}); diff --git a/app/settings.tsx b/app/settings.tsx index 367edbc..8617cb5 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -3,6 +3,8 @@ import { useRouter } from 'expo-router'; import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { colors } from '../src/theme'; +import { useAppCache } from '../src/hooks'; +import { formatBytes } from '../src/utils'; import { useAuth } from '../src/context/AuthContext'; import { useRole } from '../src/context/RoleContext'; @@ -62,7 +64,9 @@ const SETTINGS_GROUPS: SettingsGroup[] = [ { title: '通用', items: [ - { id: 'cache', title: '清除缓存', icon: 'trash-outline', value: '12.8MB' }, + { id: 'guide', title: '查看引导页', icon: 'compass-outline', route: '/guide' }, + // 缓存体积是运行时读出来的,不写死:实际值在渲染时从 useAppCache 取 + { id: 'cache', title: '清除缓存', icon: 'trash-outline' }, { id: 'about', title: '关于我们', @@ -93,6 +97,28 @@ export default function SettingsScreen() { const router = useRouter(); const { logout } = useAuth(); const { role, setRole } = useRole(); + const { label: cacheLabel, loading: cacheLoading, clear: clearCache, clearing } = useAppCache(); + + /** 清缓存:二次确认后再删,清完按实际清掉的体积给反馈(清不到就说清不到) */ + const handleClearCache = () => { + if (clearing) return; + Alert.alert('清除缓存', `将清空本机缓存文件(当前 ${cacheLabel || '读取中'})。账号、已填资料与简历不受影响。`, [ + { text: '取消', style: 'cancel' }, + { + text: '清除', + style: 'destructive', + onPress: () => { + void clearCache() + .then((freed) => { + Alert.alert('已清除缓存', freed > 0 ? `本次清理 ${formatBytes(freed)}` : '没有可清理的缓存文件'); + }) + .catch((e: unknown) => { + Alert.alert('清除失败', e instanceof Error ? e.message : '请稍后重试'); + }); + }, + }, + ]); + }; const handleLogout = () => { Alert.alert('确认退出登录', '退出后需要重新登录', [ @@ -123,6 +149,10 @@ export default function SettingsScreen() { handleIdentitySwitch(); return; } + if (item.id === 'cache') { + handleClearCache(); + return; + } if (item.route) { router.push(item.route); return; @@ -155,7 +185,14 @@ export default function SettingsScreen() { {item.title} - {item.value ? {item.value} : null} + {/* 缓存行显示运行时读到的体积,读不到就显示占位「--」而不是假数字 */} + {item.id === 'cache' ? ( + + {cacheLoading ? '计算中' : cacheLabel || '--'} + + ) : item.value ? ( + {item.value} + ) : null} ); diff --git a/app/tools/salary-calculator.tsx b/app/tools/salary-calculator.tsx index cef52e4..73920b8 100644 --- a/app/tools/salary-calculator.tsx +++ b/app/tools/salary-calculator.tsx @@ -13,7 +13,7 @@ import { View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { useSalaryCalculator, useSalaryRecords } from '../../src/hooks'; +import { useSalaryCalculator, useSalaryRecords, useSalaryToolLinks } from '../../src/hooks'; import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView'; import { colors, headerGradientShort } from '../../src/theme'; import { @@ -221,8 +221,12 @@ function StructureCard({ result }: { result: import('../../src/types').SalaryCal /* ===================== 子组件:更多工具 ===================== */ function ToolLink({ tool }: { tool: SalaryToolLink }) { - return ( - + const router = useRouter(); + // 目标路由由接口层给出;没有 route 的只做展示,不带箭头 + const route = tool.route; + + const body = ( + <> @@ -230,7 +234,16 @@ function ToolLink({ tool }: { tool: SalaryToolLink }) { {tool.title} {tool.desc} - + {route ? : null} + + ); + + if (!route) { + return {body}; + } + return ( + router.push(route as never)}> + {body} ); } @@ -241,6 +254,7 @@ export default function SalaryCalculatorScreen() { const router = useRouter(); const { inputs, setInputs, result } = useSalaryCalculator(); const records = useSalaryRecords(); + const { tools } = useSalaryToolLinks(); const [cityPickerVisible, setCityPickerVisible] = useState(false); const [historyVisible, setHistoryVisible] = useState(false); const [saveSheetVisible, setSaveSheetVisible] = useState(false); @@ -352,32 +366,10 @@ export default function SalaryCalculatorScreen() { {/* 薪资结构 */} - {/* 更多工具 */} + {/* 更多工具:清单来自接口层,均为项目内真实页面 */} 更多工具 - {[ - { - id: 'year-end', - title: '年终奖个税计算', - desc: '单独计税 / 并入综合所得对比', - icon: 'gift-outline', - route: '/tools/salary-calculator', - }, - { - id: 'severance', - title: '离职补偿金计算', - desc: 'N+1 补偿金与个税估算', - icon: 'cash-outline', - route: '/tools/salary-calculator', - }, - { - id: 'proof', - title: '公积金/社保证明', - desc: '一键查询缴纳记录', - icon: 'document-text-outline', - route: '/tools/salary-calculator', - }, - ].map((tool) => ( + {tools.map((tool) => ( ))} diff --git a/app/tools/skill-cloud.tsx b/app/tools/skill-cloud.tsx index 65fa168..069c590 100644 --- a/app/tools/skill-cloud.tsx +++ b/app/tools/skill-cloud.tsx @@ -3,17 +3,19 @@ import { useRouter } from 'expo-router'; import { useState } from 'react'; import { ActivityIndicator, + Modal, Pressable, ScrollView, StyleSheet, Text, + TextInput, View, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { IndustrySkillWordCloud } from '../../components/IndustrySkillWordCloud'; -import { useSkillCloud } from '../../src/hooks'; +import { useSkillCloud, useSkillLearningPath } from '../../src/hooks'; import { colors } from '../../src/theme'; -import type { SkillWord, TopSkill } from '../../src/types'; +import type { SkillLearningPath, SkillWord, TopSkill } from '../../src/types'; /** 技能分组 → 中文标签(用于选中详情) */ const GROUP_LABEL: Record = { @@ -25,20 +27,64 @@ const GROUP_LABEL: Record = { /* ===================== 子组件:顶部导航 ===================== */ -function Header({ onBack, onSearch }: { onBack: () => void; onSearch: () => void }) { +function Header({ + onBack, + onToggleSearch, + searching, +}: { + onBack: () => void; + onToggleSearch: () => void; + searching: boolean; +}) { return ( 行业技能词云 - - + + ); } +/* ===================== 子组件:搜索栏 ===================== */ + +function SearchBar({ + value, + onChange, + onClear, +}: { + value: string; + onChange: (text: string) => void; + onClear: () => void; +}) { + return ( + + + + {value !== '' && ( + + + + )} + + ); +} + /* ===================== 子组件:行业标签 ===================== */ function IndustryTabs({ @@ -159,7 +205,15 @@ function TopSkillList({ skills }: { skills: TopSkill[] }) { /* ===================== 子组件:技能匹配度 ===================== */ -function MatchCard({ percent }: { percent: number }) { +function MatchCard({ + percent, + onGenerate, + generating, +}: { + percent: number; + onGenerate: () => void; + generating: boolean; +}) { return ( @@ -169,14 +223,110 @@ function MatchCard({ percent }: { percent: number }) { - - - 生成技能学习路径 + + {generating ? ( + + ) : ( + + )} + + {generating ? '正在生成…' : '生成技能学习路径'} + ); } +/* ===================== 子组件:学习路径弹窗 ===================== */ + +function LearningPathModal({ + visible, + path, + loading, + error, + onRetry, + onClose, +}: { + visible: boolean; + path: SkillLearningPath | null; + loading: boolean; + error: Error | null; + onRetry: () => void; + onClose: () => void; +}) { + return ( + + + + + 技能学习路径 + + + + + + {loading ? ( + + + 正在按行业热度排优先级… + + ) : error ? ( + + {error.message} + + 点击重试 + + + ) : path ? ( + + + + {path.industry} · {path.position} + + + 当前匹配度 {path.matchPercent}% → 补齐后约 {path.targetPercent}% + + + 按行业热度权重从高到低排,先补权重最高的 {path.items.length} 项 + + + + {path.items.map((item) => ( + + + {item.priority} + + + + {item.name} + + {GROUP_LABEL[item.group]} + + + {item.reason} + + + + + {item.weight} + + + + ))} + + {path.generatedAt} + + ) : null} + + + + ); +} + /* ===================== 子组件:选中技能详情 ===================== */ function SelectedDetail({ word }: { word: SkillWord }) { @@ -235,15 +385,45 @@ function Empty() { export default function SkillCloudScreen() { const router = useRouter(); - const { industry, setIndustry, cloud, loading, error, refresh } = useSkillCloud(); + const { + industry, + setIndustry, + cloud, + keyword, + setKeyword, + words, + isEmpty, + loading, + error, + refresh, + } = useSkillCloud(); const [selectedId, setSelectedId] = useState(null); const [activeCategoryId, setActiveCategoryId] = useState('all'); + const [searching, setSearching] = useState(false); + const [pathVisible, setPathVisible] = useState(false); + const { path, loading: generating, error: pathError, generate, reset } = useSkillLearningPath(); - const selectedWord = cloud?.words.find((w) => w.id === selectedId) ?? null; + const selectedWord = words.find((w) => w.id === selectedId) ?? null; + + const handleGenerate = async () => { + // 先开弹窗再请求,弹窗自己有 loading / 失败重试三态,免得点完按钮没反应 + setPathVisible(true); + await generate(industry); + }; + + const closePathModal = () => { + setPathVisible(false); + reset(); + }; + + const toggleSearch = () => { + if (searching) setKeyword(''); + setSearching((prev) => !prev); + }; return ( -
router.back()} onSearch={() => {}} /> +
router.back()} onToggleSearch={toggleSearch} searching={searching} /> {loading ? ( @@ -253,6 +433,9 @@ export default function SkillCloudScreen() { ) : ( <> + {searching && ( + setKeyword('')} /> + )} - {cloud.industry} · 高频技能 - - setSelectedId(w.id)} - /> - + + {cloud.industry} · {keyword.trim() === '' ? '高频技能' : `含「${keyword.trim()}」的技能`} + + + {isEmpty ? ( + + + 没有匹配「{keyword.trim()}」的技能 + setKeyword('')} hitSlop={6}> + 清空关键词 + + + ) : ( + + setSelectedId(w.id)} + /> + + )} - + {cloud.updatedAt} )} + + void generate(industry)} + onClose={closePathModal} + /> ); } @@ -438,6 +647,82 @@ const styles = StyleSheet.create({ paddingVertical: 12, }, matchBtnText: { fontSize: 14, fontWeight: '700', color: colors.white, marginLeft: 6 }, + matchBtnDisabled: { backgroundColor: colors.textDisabled }, + searchBar: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + backgroundColor: colors.white, + paddingHorizontal: 16, + paddingBottom: 10, + }, + searchInput: { + flex: 1, + backgroundColor: colors.pageBg, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + fontSize: 14, + color: colors.textPrimary, + }, + searchEmpty: { + alignItems: 'center', + gap: 10, + paddingVertical: 32, + backgroundColor: colors.white, + borderRadius: 14, + borderWidth: 1, + borderColor: colors.borderLight, + }, + modalMask: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'flex-end' }, + modalSheet: { + backgroundColor: colors.white, + borderTopLeftRadius: 18, + borderTopRightRadius: 18, + maxHeight: '80%', + paddingBottom: 20, + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: colors.borderLight, + }, + modalTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, + modalCenter: { alignItems: 'center', justifyContent: 'center', gap: 12, paddingVertical: 48 }, + modalBody: { paddingHorizontal: 16, paddingBottom: 12 }, + pathSummary: { + backgroundColor: colors.primaryLight, + borderRadius: 12, + padding: 14, + marginTop: 14, + marginBottom: 4, + }, + pathSummaryText: { fontSize: 14, fontWeight: '700', color: colors.primaryText }, + pathSummaryGap: { marginTop: 6, fontSize: 13, color: colors.primaryText }, + pathSummaryHint: { marginTop: 6, fontSize: 12, color: colors.textSecondary }, + pathItem: { + flexDirection: 'row', + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: colors.borderLight, + }, + pathPriority: { + width: 24, + height: 24, + borderRadius: 12, + backgroundColor: colors.primary, + alignItems: 'center', + justifyContent: 'center', + }, + pathPriorityText: { fontSize: 12, fontWeight: '800', color: colors.white }, + pathItemBody: { flex: 1, marginLeft: 12 }, + pathItemHead: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + pathItemName: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, + pathItemReason: { marginTop: 6, fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, detailCard: { marginTop: 12, backgroundColor: colors.white, diff --git a/components/onboarding/assets.ts b/components/onboarding/assets.ts index 83781d8..89928d3 100644 --- a/components/onboarding/assets.ts +++ b/components/onboarding/assets.ts @@ -18,7 +18,19 @@ export type AssetName = | 'avatarDesigner' | 'wechat' | 'businessLicense' - | 'defaultAvatar'; + | 'defaultAvatar' + // 系统预设头像:5 个独立素材位,各自可换真实图(此前 5 个选项共用 defaultAvatar, + // 选哪个都一样,也没有区分「选了第几个」的依据) + | 'avatarPreset1' + | 'avatarPreset2' + | 'avatarPreset3' + | 'avatarPreset4' + | 'avatarPreset5' + // 引导页(/guide)四屏插图:每屏一张,补图只改这几行的 source + | 'guideEvaluate' + | 'guideWeightDim' + | 'guideGameMap' + | 'guideLegalAI'; type IconName = ComponentProps['name']; @@ -27,6 +39,7 @@ export interface AssetDef { source: ImageSourcePropType | null; tint: string; icon: IconName; + /** 素材说明,仅供换图时对照:**不渲染到界面上**(曾经把开发备注当正文画给用户看) */ hint: string; } @@ -41,8 +54,63 @@ export const ASSET_REGISTRY: Record = { wechat: { source: null, tint: colors.success, icon: 'logo-wechat', hint: '微信图标' }, businessLicense: { source: null, tint: colors.primaryLight, icon: 'document', hint: '营业执照示例图' }, defaultAvatar: { source: null, tint: colors.borderLight, icon: 'person', hint: '默认头像剪影' }, + // 换真实图只改这几行的 source;tint/icon 只是占位期用来互相区分的 + avatarPreset1: { source: null, tint: colors.primaryLight, icon: 'person', hint: '预设头像 1' }, + avatarPreset2: { source: null, tint: colors.infoLight, icon: 'happy', hint: '预设头像 2' }, + avatarPreset3: { source: null, tint: colors.successLight, icon: 'woman', hint: '预设头像 3' }, + avatarPreset4: { source: null, tint: colors.actionLight, icon: 'man', hint: '预设头像 4' }, + avatarPreset5: { source: null, tint: colors.dangerLight, icon: 'person-circle', hint: '预设头像 5' }, + /* + * 引导页四屏插图。hint 会**显示在页面上**(ImageSlot 的占位标注), + * 所以这里写的是「给谁看的、画什么」,补图后标注自动消失。 + * 建议尺寸 640×480(4:3),深色底以外的浅色背景,与页面白底衔接。 + */ + guideEvaluate: { + source: null, + tint: colors.primaryLight, + icon: 'star', + hint: '手机屏幕里展示公司评分与员工评价列表', + }, + guideWeightDim: { + source: null, + tint: colors.infoLight, + icon: 'options', + hint: '拖动滑块调节评价维度权重,实时算出综合得分', + }, + guideGameMap: { + source: null, + tint: colors.successLight, + icon: 'map', + hint: '闯关地图:7 个关卡节点,从「认识自己」走到「签约」', + }, + guideLegalAI: { + source: null, + tint: colors.actionLight, + icon: 'document-text', + hint: 'AI 法律助手与劳动合同文书', + }, }; +/** 头像设置页可选用的系统预设头像(顺序即展示顺序) */ +export const AVATAR_PRESETS: AssetName[] = [ + 'avatarPreset1', + 'avatarPreset2', + 'avatarPreset3', + 'avatarPreset4', + 'avatarPreset5', +]; + +/** + * 判断 P21 采集到的头像原始值是不是预设素材 key。 + * + * 该字段是联合值:预设头像存素材 key,拍照 / 相册存本地图片 URI(见 `OnboardingProfile.avatar`)。 + * 消费方(如「我的」页头像)据此决定按素材渲染还是按 URI 渲染 —— + * 这个判断依赖素材注册表,因此放在组件层,API 层只透传原始值。 + */ +export function isAvatarPreset(value: string): value is AssetName { + return (AVATAR_PRESETS as string[]).includes(value); +} + /** * 滑块拼图验证背景图池(每次刷新随机选一张) * RN 静态图片仅支持 require 引用,豁免 no-require-imports 规则 diff --git a/components/onboarding/screens/B1Wechat.tsx b/components/onboarding/screens/B1Wechat.tsx index 64a66d2..20f6af0 100644 --- a/components/onboarding/screens/B1Wechat.tsx +++ b/components/onboarding/screens/B1Wechat.tsx @@ -7,10 +7,10 @@ import { Avatar } from '../ui/Avatar'; import { PrimaryButton } from '../ui/PrimaryButton'; export function B1Wechat() { - const { navigate, back, meta } = useOnboarding(); + const { navigate, back } = useOnboarding(); return ( - + diff --git a/components/onboarding/screens/B2Agreement.tsx b/components/onboarding/screens/B2Agreement.tsx index c987d93..04b938f 100644 --- a/components/onboarding/screens/B2Agreement.tsx +++ b/components/onboarding/screens/B2Agreement.tsx @@ -14,10 +14,10 @@ const PARAGRAPHS = [ ]; export function B2Agreement() { - const { back, meta } = useOnboarding(); + const { back } = useOnboarding(); return ( - + 有职用户协议与隐私政策 {PARAGRAPHS.map((p, i) => ( diff --git a/components/onboarding/screens/B3Recruiter.tsx b/components/onboarding/screens/B3Recruiter.tsx index 8b2b24f..4c8e392 100644 --- a/components/onboarding/screens/B3Recruiter.tsx +++ b/components/onboarding/screens/B3Recruiter.tsx @@ -1,21 +1,64 @@ import { useState } from 'react'; -import { StyleSheet, Text, TextInput, View } from 'react-native'; +import { Image, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { colors } from '../../../src/theme'; -import { useOnboarding, useRegisterFinish } from '../../../src/hooks'; +import { + useEnterpriseCertification, + useImagePick, + useOnboarding, + useRegisterFinish, +} from '../../../src/hooks'; import { ScreenShell } from '../ui/ScreenShell'; import { Illustration } from '../ui/Illustration'; import { PrimaryButton } from '../ui/PrimaryButton'; export function B3Recruiter() { - const { data, setData, back, meta } = useOnboarding(); + const { data, setData, back } = useOnboarding(); const finish = useRegisterFinish(); - const [submitted, setSubmitted] = useState(false); + const { pick, picking, error: pickError } = useImagePick(); + const { submit, submitting } = useEnterpriseCertification(); + const [submitFailed, setSubmitFailed] = useState(false); - const canSubmit = data.recruiterCompany.trim().length > 0; + // 三项都是认证必需,缺一项不放行:没有营业执照的「企业认证」提交不出有意义的结果 + const canSubmit = + data.recruiterCompany.trim().length > 0 && + data.recruiterContact.trim().length > 0 && + data.recruiterLicenseUri !== null; + + const pickLicense = async () => { + // 营业执照是文档,方形裁剪会把内容切掉,用 4:3 + const result = await pick('library', { aspect: [4, 3] }); + if (result.status === 'picked') setData({ recruiterLicenseUri: result.uri }); + }; + + const handleSubmit = async () => { + if (!canSubmit || submitting) return; + setSubmitFailed(false); + try { + // 提交审核状态由接口返回驱动,不再用本地 useState 假装「已提交」 + const res = await submit({ + companyName: data.recruiterCompany.trim(), + // B3 不采集统一社会信用代码,由企业端「企业认证」页补全(该页已存在且有入口) + creditCode: '', + licenseUrl: data.recruiterLicenseUri ?? undefined, + contactName: data.recruiterContact.trim(), + // 联系人手机号用注册时登录的手机号,避免再问一次 + contactPhone: data.phone, + }); + if (!res.success) { + setSubmitFailed(true); + return; + } + // 落地前 useRegisterFinish 会 flushSave,把认证资料一起落库 + await finish('enterprise'); + } catch { + // 真实后端就绪后这里会接到网络异常 + setSubmitFailed(true); + } + }; return ( - - 请完成企业认证,开启招聘之旅(与求职者主线分开演示) + + 请完成企业认证,开启招聘之旅 公司名称 营业执照 - - - 点击上传营业执照 - + void pickLicense()} + > + {data.recruiterLicenseUri ? ( + + ) : ( + + )} + + {data.recruiterLicenseUri ? '已选择,点击可重新选择' : '点击上传营业执照'} + + + + {pickError ? {pickError} : null} 联系人 setData({ school: t })} + value={data.recruiterContact} + onChangeText={(t) => setData({ recruiterContact: t })} placeholder="请输入联系人姓名" placeholderTextColor={colors.textHint} /> + {submitFailed ? 认证提交失败,请稍后重试 : null} + { - setSubmitted(true); - finish('enterprise'); - }} + label="提交认证" + active={canSubmit && !submitting} + loading={submitting} + onPress={() => void handleSubmit()} /> @@ -79,6 +134,9 @@ const styles = StyleSheet.create({ borderColor: colors.borderStrong, backgroundColor: colors.white, }, + dim: { opacity: 0.5 }, + licensePreview: { width: 160, height: 120, borderRadius: 12 }, uploadText: { fontSize: 13, color: colors.textHint }, + error: { fontSize: 12, color: colors.dangerText, marginTop: 10 }, footer: { marginTop: 28 }, }); diff --git a/components/onboarding/screens/P10FirstWork.tsx b/components/onboarding/screens/P10FirstWork.tsx index ccda465..4e79faa 100644 --- a/components/onboarding/screens/P10FirstWork.tsx +++ b/components/onboarding/screens/P10FirstWork.tsx @@ -7,10 +7,13 @@ import { YearMonthWheel } from '../ui/WheelPicker'; import { PrimaryButton } from '../ui/PrimaryButton'; export function P10FirstWork() { - const { data, setData, navigate, back, meta } = useOnboarding(); + const { data, setData, navigate, back } = useOnboarding(); + + // 未滑动滚轮时 state 仍是空串,此时放行会把空年份写进资料 + const canNext = data.firstWorkYear !== '' && data.firstWorkMonth !== ''; return ( - + @@ -24,7 +27,7 @@ export function P10FirstWork() { /> - navigate('P11')} /> + navigate('P11')} /> ); diff --git a/components/onboarding/screens/P11CurrentPosition.tsx b/components/onboarding/screens/P11CurrentPosition.tsx index 3fb1c45..e023a40 100644 --- a/components/onboarding/screens/P11CurrentPosition.tsx +++ b/components/onboarding/screens/P11CurrentPosition.tsx @@ -16,9 +16,19 @@ const POS: Record = { }; const BLOCKS = ['客服', '内容运营', '电商运营', '用户运营']; +/** 全部分类的职位去重展开,供搜索跨分类匹配 */ +const ALL_POS: string[] = Array.from(new Set(Object.values(POS).flat())); + export function P11CurrentPosition() { - const { data, setData, navigate, back, meta } = useOnboarding(); + const { data, setData, navigate, back } = useOnboarding(); const [cat, setCat] = useState('互联网AI'); + const [query, setQuery] = useState(''); + + const keyword = query.trim(); + const searching = keyword !== ''; + // 搜索态跨分类匹配(与 P6 同一处理):只搜当前分类会让「搜不到」看起来像没有这个职位 + const visiblePos = searching ? ALL_POS.filter((p) => p.includes(keyword)) : POS[cat]; + const visibleBlocks = searching ? BLOCKS.filter((b) => b.includes(keyword)) : BLOCKS; const choose = (p: string) => { setData({ currentPosition: p }); @@ -26,13 +36,21 @@ export function P11CurrentPosition() { }; return ( - - {}} placeholder="搜索职位" /> + + setQuery('')} /> {CATS.map((c) => ( - setCat(c)}> + { + // 搜索态下切分类不改变右侧内容,清掉关键词保证动作有可见结果 + setQuery(''); + setCat(c); + }} + > {c} ))} @@ -40,20 +58,28 @@ export function P11CurrentPosition() { 猜你做过 - - {POS[cat].map((p) => ( - choose(p)} /> - ))} - + {visiblePos.length > 0 ? ( + + {visiblePos.map((p) => ( + choose(p)} /> + ))} + + ) : ( + 没有匹配「{keyword}」的职位 + )} 或者从这些板块选 - - {BLOCKS.map((b) => ( - choose(b)} /> - ))} - + {visibleBlocks.length > 0 ? ( + + {visibleBlocks.map((b) => ( + choose(b)} /> + ))} + + ) : ( + 没有匹配「{keyword}」的板块 + )} ); } @@ -67,5 +93,6 @@ const styles = StyleSheet.create({ catTextActive: { color: colors.primaryText, fontWeight: '700' }, col: { flex: 1 }, colTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 }, + empty: { fontSize: 13, color: colors.textHint, lineHeight: 20 }, blockTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginVertical: 16 }, }); diff --git a/components/onboarding/screens/P12Company.tsx b/components/onboarding/screens/P12Company.tsx index f9ad5f8..1187784 100644 --- a/components/onboarding/screens/P12Company.tsx +++ b/components/onboarding/screens/P12Company.tsx @@ -6,11 +6,11 @@ import { SearchInput } from '../ui/SearchInput'; import { PrimaryButton } from '../ui/PrimaryButton'; export function P12Company() { - const { data, setData, navigate, back, meta } = useOnboarding(); + const { data, setData, navigate, back } = useOnboarding(); const canNext = data.company.trim().length > 0; return ( - + setData({ company: t })} diff --git a/components/onboarding/screens/P13JoinDate.tsx b/components/onboarding/screens/P13JoinDate.tsx index e997e32..58a8ae5 100644 --- a/components/onboarding/screens/P13JoinDate.tsx +++ b/components/onboarding/screens/P13JoinDate.tsx @@ -7,24 +7,32 @@ import { YearMonthWheel } from '../ui/WheelPicker'; import { PrimaryButton } from '../ui/PrimaryButton'; export function P13JoinDate() { - const { data, setData, navigate, back, meta } = useOnboarding(); + const { data, setData, navigate, back } = useOnboarding(); + + // 未滑动滚轮时 state 仍是空串,此时放行会把空年月写进资料 + const canNext = data.joinYear !== '' && data.joinMonth !== ''; return ( - + - 在什么时候入职 {data.company} + {/* 公司名来自上一步,理论上不会为空;兜底避免拼出「在什么时候入职 」这样的半句话 */} + + {data.company ? `在什么时候入职 ${data.company}` : '请选择入职年月'} + setData({ joinYear: v })} onMonthChange={(v) => setData({ joinMonth: v })} + // 入职时间可能在明年,年份要往后留出空间(出生年月则不需要) + futureYears={5} /> - navigate('P14')} /> + navigate('P14')} /> ); diff --git a/components/onboarding/screens/P14Skills.tsx b/components/onboarding/screens/P14Skills.tsx index dfbddff..dd38265 100644 --- a/components/onboarding/screens/P14Skills.tsx +++ b/components/onboarding/screens/P14Skills.tsx @@ -18,7 +18,7 @@ const GROUPS = [ ]; export function P14Skills() { - const { data, setData, navigate, back, meta } = useOnboarding(); + const { data, setData, navigate, back } = useOnboarding(); const skills = data.skills; const full = skills.length >= MAX; @@ -31,7 +31,7 @@ export function P14Skills() { }; return ( - + 技能选择 0 && styles.counterActive]}> diff --git a/components/onboarding/screens/P16WorkContent.tsx b/components/onboarding/screens/P16WorkContent.tsx index 12e2129..5e95b36 100644 --- a/components/onboarding/screens/P16WorkContent.tsx +++ b/components/onboarding/screens/P16WorkContent.tsx @@ -2,23 +2,23 @@ import { Ionicons } from '@expo/vector-icons'; import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { colors } from '../../../src/theme'; import { useOnboarding, useRegisterFinish } from '../../../src/hooks'; -import type { AppRole } from '../../../src/types'; import { ScreenShell } from '../ui/ScreenShell'; import { PrimaryButton } from '../ui/PrimaryButton'; export function P16WorkContent() { - const { data, setData, navigate, back, meta } = useOnboarding(); + const { data, setData, navigate, back } = useOnboarding(); const finish = useRegisterFinish(); - const targetRole: AppRole = data.identity === 'recruiter' ? 'enterprise' : 'candidate'; const text = data.workContent; const canNext = text.trim().length >= 5; return ( - + 工作内容介绍 - finish(targetRole)}> - 保存 + {/* 快速通道:与 P6 同一语义(填完当前步即可落地),文案据实写「跳过」。 + 企业端只走 P3 → B3,到不了本页,故落地身份固定为求职者 */} + void finish('candidate')} hitSlop={6}> + 跳过,直接进入 diff --git a/components/onboarding/screens/P17Reference.tsx b/components/onboarding/screens/P17Reference.tsx index 57f8c68..ccfa27c 100644 --- a/components/onboarding/screens/P17Reference.tsx +++ b/components/onboarding/screens/P17Reference.tsx @@ -35,7 +35,7 @@ const SAMPLES = [ ]; export function P17Reference() { - const { setData, back, navigate, meta } = useOnboarding(); + const { setData, back, navigate } = useOnboarding(); const [idx, setIdx] = useState(0); const sample = SAMPLES[idx]; @@ -45,7 +45,7 @@ export function P17Reference() { }; return ( - + diff --git a/components/onboarding/screens/P18Education.tsx b/components/onboarding/screens/P18Education.tsx index 80e0e03..33775e5 100644 --- a/components/onboarding/screens/P18Education.tsx +++ b/components/onboarding/screens/P18Education.tsx @@ -10,11 +10,12 @@ import { PrimaryButton } from '../ui/PrimaryButton'; const LEVELS = ['初中及以下', '中专中技', '高中', '大专', '本科', '硕士', '博士']; export function P18Education() { - const { data, setData, navigate, back, meta } = useOnboarding(); - const canNext = data.education !== null; + const { data, setData, navigate, back } = useOnboarding(); + // 「类型」同为本页采集项,选了学历却没选全日制/非全日制同样算半截记录 + const canNext = data.education !== null && data.eduType !== null; return ( - + diff --git a/components/onboarding/screens/P1Login.tsx b/components/onboarding/screens/P1Login.tsx index fd7c61f..5a5074d 100644 --- a/components/onboarding/screens/P1Login.tsx +++ b/components/onboarding/screens/P1Login.tsx @@ -8,6 +8,7 @@ import { Illustration } from '../ui/Illustration'; import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementModal } from '../ui/AgreementModal'; import { PrimaryButton } from '../ui/PrimaryButton'; +import { SupportFooter } from '../ui/SupportFooter'; /** * P1 登录主页:淡渐变顶部 + 居中 Logo + 三种登录方式。 @@ -63,9 +64,7 @@ export function P1Login() { - 服务热线 - 举报监督电话 - 资质证照 + @@ -98,12 +97,5 @@ const styles = StyleSheet.create({ agree: { marginTop: 22 }, agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, link: { color: colors.primary, fontWeight: '600' }, - footer: { - flexDirection: 'row', - justifyContent: 'center', - gap: 18, - paddingTop: 24, - paddingBottom: 8, - }, - footItem: { fontSize: 12, color: colors.textHint }, + footer: { paddingTop: 24, paddingBottom: 8 }, }); diff --git a/components/onboarding/screens/P20School.tsx b/components/onboarding/screens/P20School.tsx index b54ffe0..a4b9d9d 100644 --- a/components/onboarding/screens/P20School.tsx +++ b/components/onboarding/screens/P20School.tsx @@ -5,11 +5,11 @@ import { SearchInput } from '../ui/SearchInput'; import { PrimaryButton } from '../ui/PrimaryButton'; export function P20School() { - const { data, setData, navigate, back, meta } = useOnboarding(); + const { data, setData, navigate, back } = useOnboarding(); const canNext = data.school.trim().length > 0; return ( - + setData({ school: t })} diff --git a/components/onboarding/screens/P21Avatar.tsx b/components/onboarding/screens/P21Avatar.tsx index 593c338..bc14912 100644 --- a/components/onboarding/screens/P21Avatar.tsx +++ b/components/onboarding/screens/P21Avatar.tsx @@ -1,66 +1,78 @@ -import { useState } from 'react'; import { Ionicons } from '@expo/vector-icons'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { colors } from '../../../src/theme'; -import { useOnboarding } from '../../../src/hooks'; +import { useImagePick, useOnboarding } from '../../../src/hooks'; import { ScreenShell } from '../ui/ScreenShell'; import { Avatar } from '../ui/Avatar'; import { PrimaryButton } from '../ui/PrimaryButton'; - -const PRESETS = [0, 1, 2, 3, 4]; +import { AVATAR_PRESETS } from '../assets'; export function P21Avatar() { - const { setData, navigate, back, meta } = useOnboarding(); - const [mode, setMode] = useState<'none' | 'camera' | 'album' | 'preset'>('none'); - const [picked, setPicked] = useState(-1); + const { data, setData, navigate, back } = useOnboarding(); + const { pick, picking, error } = useImagePick(); - const chooseCamera = () => { - setMode('camera'); - setData({ avatar: 'avatarDesigner' }); - }; - const chooseAlbum = () => { - setMode('album'); - setData({ avatar: 'avatarDesigner' }); - }; - const choosePreset = (i: number) => { - setMode('preset'); - setPicked(i); - setData({ avatar: 'defaultAvatar' }); - }; + // 头像只有一个真相 `data.avatar`:命中预设素材就是预设名,否则是拍照/相册的本地 URI + const presetName = AVATAR_PRESETS.find((name) => name === data.avatar) ?? null; + const avatarUri = data.avatar !== null && presetName === null ? data.avatar : null; - const previewName = mode === 'camera' || mode === 'album' ? 'avatarDesigner' : 'defaultAvatar'; + const pickFrom = async (source: 'camera' | 'library') => { + const result = await pick(source); + // 取消或未授权时保持原头像不动,不写入空值 + if (result.status === 'picked') setData({ avatar: result.uri }); + }; return ( - + - + - + {/* picking 期间禁用:否则系统权限弹窗还没出现就能连点出第二个 */} + void pickFrom('camera')} + > 拍照上传 - + void pickFrom('library')} + > 从相册选择 + {error ? {error} : null} + 系统默认头像 - {PRESETS.map((i) => ( + {AVATAR_PRESETS.map((name) => ( choosePreset(i)} + selected={data.avatar === name} + onPress={() => setData({ avatar: name })} /> ))} - navigate('P22')} /> + navigate('P22')} + /> ); @@ -70,7 +82,9 @@ const styles = StyleSheet.create({ previewWrap: { alignItems: 'center', marginVertical: 24 }, actions: { flexDirection: 'row', justifyContent: 'center', gap: 28, marginBottom: 28 }, actionBtn: { alignItems: 'center', gap: 6 }, + actionBtnDim: { opacity: 0.5 }, actionText: { fontSize: 12, color: colors.textSecondary }, + error: { fontSize: 12, color: colors.dangerText, textAlign: 'center', marginBottom: 18 }, subTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginBottom: 14 }, presets: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 }, footer: { marginTop: 32 }, diff --git a/components/onboarding/screens/P22Done.tsx b/components/onboarding/screens/P22Done.tsx index c6cfbea..e50d848 100644 --- a/components/onboarding/screens/P22Done.tsx +++ b/components/onboarding/screens/P22Done.tsx @@ -1,19 +1,16 @@ import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, Text, View } from 'react-native'; import { colors } from '../../../src/theme'; -import { useOnboarding, useRegisterFinish } from '../../../src/hooks'; -import type { AppRole } from '../../../src/types'; +import { useRegisterFinish } from '../../../src/hooks'; import { ScreenShell } from '../ui/ScreenShell'; import { Illustration } from '../ui/Illustration'; import { PrimaryButton } from '../ui/PrimaryButton'; export function P22Done() { - const { data, meta } = useOnboarding(); const finish = useRegisterFinish(); - const targetRole: AppRole = data.identity === 'recruiter' ? 'enterprise' : 'candidate'; return ( - + @@ -23,7 +20,8 @@ export function P22Done() { - finish(targetRole)} /> + {/* 企业端走 P3 → B3 落地,不会经过本页,故身份固定为求职者 */} + void finish('candidate')} /> ); diff --git a/components/onboarding/screens/P23Home.tsx b/components/onboarding/screens/P23Home.tsx deleted file mode 100644 index 059e752..0000000 --- a/components/onboarding/screens/P23Home.tsx +++ /dev/null @@ -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('guide'); - - useEffect(() => { - setStep('guide'); - }, []); - - return ( - - - - - 推荐职位 - - 上海 - - - - {JOBS.map((j) => ( - - - - {j.pos} - {j.company} - - - {j.salary} - - {j.city} - - - - ))} - - - - {TABS.map((t) => ( - setTab(t.key)}> - - {t.label} - - ))} - - - {step === 'guide' && ( - - - - 点击查看职位详情 - - setStep('location')}> - 知道了 - - - )} - - setStep('notify')} - footer={ - - setStep('notify')}> - 暂不 - - setStep('notify')}> - 允许 - - - } - > - 开启定位可为你推荐附近的职位与机会。 - - - setStep(null)} - footer={ - - setStep(null)}> - 暂不 - - setStep(null)}> - 允许 - - - } - > - 开启通知可及时接收面试邀请与 Boss 消息。 - - - ); -} - -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' }, -}); diff --git a/components/onboarding/screens/P2PhoneCode.tsx b/components/onboarding/screens/P2PhoneCode.tsx index 4317da7..93f6b32 100644 --- a/components/onboarding/screens/P2PhoneCode.tsx +++ b/components/onboarding/screens/P2PhoneCode.tsx @@ -1,7 +1,8 @@ import { useState } from 'react'; import { Ionicons } from '@expo/vector-icons'; -import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { colors } from '../../../src/theme'; +import { SMS_HELP_BODY, SMS_HELP_TITLE } from '../../../src/constants'; import { useAgreementGate, useLogin, useOnboarding, useSmsCode, useWechatBindPhone } from '../../../src/hooks'; import { isValidPhone, SMS_CODE_MAX_LENGTH, SMS_CODE_MIN_LENGTH } from '../../../src/utils'; import { useAuth } from '../../../src/context/AuthContext'; @@ -81,6 +82,9 @@ export function P2PhoneCode() { /** 掩码手机号:138****1234 */ const maskedPhone = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone; + /** 「接收不到短信」:原先是点了没反应的纯文本,改为给出可执行的下一步 */ + const showSmsHelp = () => Alert.alert(SMS_HELP_TITLE, SMS_HELP_BODY); + return ( setStage('phone') : back}> {stage === 'phone' ? ( @@ -110,7 +114,9 @@ export function P2PhoneCode() { - 接收不到短信 + + 接收不到短信? + gate(requestCode)} /> @@ -214,7 +220,9 @@ const styles = StyleSheet.create({ divider: { width: 1, height: 20, backgroundColor: colors.border, marginHorizontal: 10 }, phoneInput: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 }, underline: { height: 1, backgroundColor: colors.borderLight }, - smsHelp: { fontSize: 13, color: colors.textHint, marginTop: 16 }, + // 已是可点入口(原来是无反馈纯文本),故用可读对比度的 primaryText 而非 textHint + smsHelpRow: { alignSelf: 'flex-start', marginTop: 16 }, + smsHelp: { fontSize: 13, color: colors.primaryText }, btnArea: { marginTop: 28 }, agree: { marginTop: 18 }, formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger }, diff --git a/components/onboarding/screens/P2QuickLogin.tsx b/components/onboarding/screens/P2QuickLogin.tsx index 50bc112..92e2dbc 100644 --- a/components/onboarding/screens/P2QuickLogin.tsx +++ b/components/onboarding/screens/P2QuickLogin.tsx @@ -1,29 +1,48 @@ -import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { colors } from '../../../src/theme'; -import { useAgreementGate, useLogin, useOnboarding } from '../../../src/hooks'; +import { CARRIER_LABEL } from '../../../src/constants'; +import { useAgreementGate, useCarrierPhone, useLogin, useOnboarding } from '../../../src/hooks'; import { useAuth } from '../../../src/context/AuthContext'; import { ScreenShell } from '../ui/ScreenShell'; import { Illustration } from '../ui/Illustration'; import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { PrimaryButton } from '../ui/PrimaryButton'; +import { SupportFooter } from '../ui/SupportFooter'; import { AgreementModal } from '../ui/AgreementModal'; -/** P2Q 一键登录确认:本机号码认证 —— 掩码手机号 + 一键登录 + 运营商服务声明 */ +/** + * P2Q 一键登录确认:本机号码认证。 + * + * 取号(运营商 SDK)与换登录态是两步,本页负责串起来: + * 1. 进入页面即取号(useCarrierPhone)→ 展示掩码号码; + * 2. 点「一键登录」→ 用取号令牌换正式登录态 → 写登录态 → 把号码写回引导资料 → 进 P3。 + * + * 原先这里读的是 `data.phone`(只有短信登录才会写),运营商通道的号码既没取也没存, + * 所以永远显示占位「本机号码」;登录接口返回的 phone 也被直接丢掉。 + * 现在取号失败时不再假装号码还在:「一键登录」置灰,同时给出「重新获取」与「改用验证码登录」。 + */ export function P2QuickLogin() { const { data, setData, navigate, back } = useOnboarding(); const { gate, modalProps } = useAgreementGate(); - const { loginByCarrier, loading, error } = useLogin(); + const { carrierToken, maskedPhone, carrier, ready, loading: fetching, error: fetchError, refresh } = + useCarrierPhone(); + const { loginByCarrier, loading: logging, error: loginError } = useLogin(); /** 登录态写入:此前本页只跳 P3 不写 token */ const { login: writeAuth } = useAuth(); const agreed = data.agreed; - const phone = data.phone; + const carrierName = CARRIER_LABEL[carrier]; + /** 取号失败:号码与令牌都没有,本页只能提供重试或改走验证码登录 */ + const failed = !fetching && !ready; + const busy = fetching || logging; - /** 一键登录:运营商返回本机号码并换取令牌,成功后才进 P3 */ + /** 一键登录:用取号令牌换登录态,成功后才进 P3 */ const handleLogin = async () => { try { - const result = await loginByCarrier(); + const result = await loginByCarrier(carrierToken); writeAuth(result.token); + // 号码写回引导资料:后续步骤(P2P/P2R 预填、B3 联系人电话)都以这里为准 + setData({ phone: result.phone }); } catch { // 失败原因由 useLogin 记录,页面下方展示 return; @@ -31,8 +50,13 @@ export function P2QuickLogin() { navigate('P3'); }; - /** 掩码:150****9367;未输入时用演示号段占位 */ - const masked = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : '150****9367'; + /** 运营商认证服务条款由运营商在认证页内提供,本仓库没有该文档,不冒充能打开它 */ + const showCarrierTerms = () => { + Alert.alert( + `${carrierName}认证服务条款`, + `一键登录的认证服务由${carrierName}提供,服务条款以认证页展示的内容为准。`, + ); + }; return ( @@ -41,19 +65,45 @@ export function P2QuickLogin() { - {masked} - 首次验证通过即注册有职账号 + {ready ? ( + {maskedPhone} + ) : ( + + {failed ? '未能获取本机号码' : '正在获取本机号码…'} + + )} + + {failed ? '可重新获取,或改用验证码登录' : '首次验证通过即注册有职账号'} + gate(handleLogin)} /> + + {failed ? ( + + + 重新获取 + + gate(() => navigate('P2'))} + hitSlop={6} + > + 改用验证码登录 + + + ) : null} - {error ? {error.message} : null} + {loginError ? {loginError.message} : null} + {fetchError && !loginError ? ( + {fetchError.message} + ) : null} 已阅读并同意 - 《中国移动认证服务条款》 + + 《{carrierName}认证服务条款》 + 、 navigate('B2')}>《有职用户协议》 和 @@ -75,12 +127,8 @@ export function P2QuickLogin() { - - 服务热线 - 举报监督电话 - 资质证照 - - 认证服务由中国移动提供 + + 认证服务由{carrierName}提供 @@ -98,14 +146,28 @@ const styles = StyleSheet.create({ color: colors.textPrimary, marginTop: 40, }, + phonePending: { + textAlign: 'center', + fontSize: 22, + fontWeight: '700', + color: colors.textHint, + marginTop: 44, + }, + phoneFailed: { fontSize: 18, color: colors.dangerText }, subtitle: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 12 }, btnArea: { marginTop: 36 }, + fallbackRow: { + flexDirection: 'row', + justifyContent: 'center', + gap: 24, + marginTop: 18, + }, + fallbackBtn: { paddingVertical: 6, paddingHorizontal: 4 }, + fallbackText: { fontSize: 14, fontWeight: '600', color: colors.primaryText }, formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger }, agree: { marginTop: 20 }, agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, link: { color: colors.primary, fontWeight: '600' }, footer: { paddingTop: 16, paddingBottom: 8 }, - footRow: { flexDirection: 'row', justifyContent: 'center', gap: 18 }, - footItem: { fontSize: 12, color: colors.textHint }, carrierNote: { textAlign: 'center', fontSize: 12, color: colors.textHint, marginTop: 12 }, }); diff --git a/components/onboarding/screens/P2WechatCode.tsx b/components/onboarding/screens/P2WechatCode.tsx index a0b26a9..10bb1c1 100644 --- a/components/onboarding/screens/P2WechatCode.tsx +++ b/components/onboarding/screens/P2WechatCode.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; -import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { colors } from '../../../src/theme'; +import { SMS_HELP_BODY, SMS_HELP_TITLE } from '../../../src/constants'; import { useOnboarding } from '../../../src/hooks'; import { ScreenShell } from '../ui/ScreenShell'; import { PrimaryButton } from '../ui/PrimaryButton'; @@ -13,6 +14,8 @@ export function P2WechatCode() { const [code, setCode] = useState(''); const [left, setLeft] = useState(RESEND_SECONDS); const timer = useRef | null>(null); + /** 隐藏输入框的引用:点格子区域时把焦点交回它,键盘才会弹出来 */ + const inputRef = useRef(null); useEffect(() => { timer.current = setInterval(() => { @@ -30,10 +33,17 @@ export function P2WechatCode() { }, []); const phone = data.phone; - /** 尾号:有真实手机号用尾4位,否则演示 9367 */ - const tail = phone.length === 11 ? phone.slice(7) : '9367'; + /** + * 手机尾号(含两侧空格,无尾号时为空串,句子里不留空)。 + * 原先是 `phone.length === 11 ? phone.slice(7) : '9367'` —— 没有号码时拿编造的 + * 「9367」当用户的号码展示。 + */ + const tail = phone.length === 11 ? ` ${phone.slice(7)} ` : ''; const canNext = code.length >= 4; + /** 「接收不到短信」提示,与 P2 用同一份文案常量 */ + const showSmsHelp = () => Alert.alert(SMS_HELP_TITLE, SMS_HELP_BODY); + const resend = () => { if (left > 0) return; setLeft(RESEND_SECONDS); @@ -52,10 +62,10 @@ export function P2WechatCode() { return ( 输入短信验证码 - 已向您的手机 {tail} 发送验证码 + 已向您的手机{tail}发送验证码 - {/* 4 格分离输入:透明输入层覆盖,格子仅做展示 */} - + {/* 4 格分离输入:透明输入层覆盖,格子仅做展示;点格子把焦点交回输入框 */} + inputRef.current?.focus()}> {[0, 1, 2, 3].map((i) => ( @@ -65,6 +75,7 @@ export function P2WechatCode() { ))} setCode(t.replace(/[^\d]/g, '').slice(0, 4))} @@ -84,14 +95,11 @@ export function P2WechatCode() { navigate('B1')} /> - - - 语音接听验证码 - - - 接收不到短信 - - + {/* 「语音接听验证码」入口已移除:项目没有语音验证码通道,摆一个点了没反应的入口 + 等于宣称有这个能力。等接入语音通道再恢复(同 P5 移除「自动定位」的理由) */} + + 接收不到短信? + 短信验证码正在发送,请注意查收 @@ -116,12 +124,9 @@ const styles = StyleSheet.create({ resend: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 22 }, resendDisabled: { color: colors.textHint }, btnArea: { marginTop: 30 }, - helpRow: { - flexDirection: 'row', - justifyContent: 'space-between', - marginTop: 22, - }, - helpText: { fontSize: 13, color: colors.textHint }, + helpRow: { alignSelf: 'flex-start', marginTop: 22 }, + // 已是可点入口,用可读对比度的 primaryText(textHint 在 13pt 下不达 AA) + helpText: { fontSize: 13, color: colors.primaryText }, toastBar: { alignSelf: 'center', marginTop: 30, diff --git a/components/onboarding/screens/P3Identity.tsx b/components/onboarding/screens/P3Identity.tsx index 3a9c5ce..c95a904 100644 --- a/components/onboarding/screens/P3Identity.tsx +++ b/components/onboarding/screens/P3Identity.tsx @@ -1,11 +1,13 @@ -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; import { colors } from '../../../src/theme'; +import { useAuth } from '../../../src/context/AuthContext'; import { useOnboarding } from '../../../src/hooks'; import { ScreenShell } from '../ui/ScreenShell'; import { Illustration } from '../ui/Illustration'; export function P3Identity() { - const { setData, navigate, back, reset, meta } = useOnboarding(); + const { setData, navigate, back, reset } = useOnboarding(); + const { logout } = useAuth(); const chooseCandidate = () => { setData({ identity: 'candidate' }); @@ -16,8 +18,27 @@ export function P3Identity() { navigate('B3'); }; + /** + * 退出登录必须同时清掉令牌与角色。 + * 此前只调 `reset()`(仅清流程 state 与引导资料),登录态原封不动 —— + * 界面回到了登录页,但重进 App 又会被判定为「已登录」而直接放进主界面。 + */ + const handleLogout = () => { + Alert.alert('确认退出登录', '退出后需要重新登录', [ + { text: '取消', style: 'cancel' }, + { + text: '退出', + style: 'destructive', + onPress: () => { + logout(); + reset(); + }, + }, + ]); + }; + return ( - + 选择你的身份 @@ -40,7 +61,7 @@ export function P3Identity() { - + 退出登录 diff --git a/components/onboarding/screens/P4Intent.tsx b/components/onboarding/screens/P4Intent.tsx index 2cd9d39..7f3e5eb 100644 --- a/components/onboarding/screens/P4Intent.tsx +++ b/components/onboarding/screens/P4Intent.tsx @@ -9,7 +9,7 @@ import { PrimaryButton } from '../ui/PrimaryButton'; const HOT = ['Java', '项目经理', '运维', '测试', '前端', '数据分析', '电子', '通信']; export function P4Intent() { - const { data, setData, navigate, back, meta } = useOnboarding(); + const { data, setData, navigate, back } = useOnboarding(); const isStudent = data.isStudent; const city = data.city; const positions = data.expectedPositions; @@ -19,10 +19,12 @@ export function P4Intent() { expectedPositions: positions.includes(p) ? positions.filter((x) => x !== p) : [...positions, p], }); - const canNext = isStudent !== null && positions.length > 0; + // 工作地点是本页采集项之一(点标签进 P5 选),没选城市就不放行 —— + // 岗位推荐的偏好里城市是加分项,空城市会让推荐失去一半依据 + const canNext = isStudent !== null && positions.length > 0 && city !== ''; return ( - + 你是学生吗 setData({ isStudent: true })} /> @@ -32,7 +34,11 @@ export function P4Intent() { 工作地点 - navigate('P5')} /> + navigate('P5')} + /> 点击切换城市 diff --git a/components/onboarding/screens/P5City.tsx b/components/onboarding/screens/P5City.tsx index 96f0802..16ec7a6 100644 --- a/components/onboarding/screens/P5City.tsx +++ b/components/onboarding/screens/P5City.tsx @@ -11,7 +11,7 @@ const ALL = ['北京', '上海', '广州', '深圳', '杭州', '成都', '南京 const HOT = ['北京', '上海', '广州', '深圳', '杭州', '成都']; export function P5City() { - const { data, setData, back, meta } = useOnboarding(); + const { data, setData, back } = useOnboarding(); const [query, setQuery] = useState(''); const pick = (city: string) => { @@ -22,14 +22,11 @@ export function P5City() { const list = query ? ALL.filter((c) => c.includes(query)) : ALL; return ( - + setQuery('')} /> - pick('上海')}> - - 自动定位:上海 - - + {/* 原「自动定位:上海」按钮已移除:它不做定位,只是把「上海」写死成选中值。 + 不做定位就不宣称定位(已决定不引入 expo-location) */} 热门城市 {HOT.map((c) => ( @@ -51,8 +48,6 @@ export function P5City() { } const styles = StyleSheet.create({ - locate: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, marginTop: 12 }, - locateText: { fontSize: 14, fontWeight: '600', color: colors.primaryText }, section: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginTop: 22, marginBottom: 12 }, list: { gap: 2 }, cityItem: { diff --git a/components/onboarding/screens/P6ExpectedPosition.tsx b/components/onboarding/screens/P6ExpectedPosition.tsx index ee51713..2ecae4d 100644 --- a/components/onboarding/screens/P6ExpectedPosition.tsx +++ b/components/onboarding/screens/P6ExpectedPosition.tsx @@ -3,7 +3,7 @@ import { Ionicons } from '@expo/vector-icons'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { colors } from '../../../src/theme'; import { useOnboarding, useRegisterFinish } from '../../../src/hooks'; -import type { AppRole } from '../../../src/types'; +import { onboardingStepLabel } from '../../../src/utils'; import { ScreenShell } from '../ui/ScreenShell'; import { SearchInput } from '../ui/SearchInput'; import { Tag, TagGroup } from '../ui/Tag'; @@ -23,33 +23,45 @@ const POSITIONS: Record = { 医疗健康: ['临床医师', '药剂师', '健康管理'], }; +/** 全部分类的职位去重展开,供搜索跨分类匹配 */ +const ALL_POSITIONS: string[] = Array.from(new Set(Object.values(POSITIONS).flat())); + export function P6ExpectedPosition() { - const { data, setData, navigate, back, meta } = useOnboarding(); + const { data, setData, navigate, back } = useOnboarding(); const finish = useRegisterFinish(); - const targetRole: AppRole = data.identity === 'recruiter' ? 'enterprise' : 'candidate'; const [cat, setCat] = useState('互联网AI'); + const [query, setQuery] = useState(''); const positions = data.expectedPositions; + const step = onboardingStepLabel('P6'); + const keyword = query.trim(); + // 搜索态跨分类匹配:只搜当前分类时,在「互联网AI」下搜「UI设计师」会得到空结果, + // 看起来像平台没有这个职位,实际只是没切分类 + const visible = keyword ? ALL_POSITIONS.filter((p) => p.includes(keyword)) : POSITIONS[cat]; + const toggle = (p: string) => setData({ expectedPositions: positions.includes(p) ? positions.filter((x) => x !== p) : [...positions, p], }); return ( - + 期望职位 - - - 3/3 - - finish(targetRole)}> - 保存 - - + {/* 快速通道:填完意向即可落地,剩余资料后续再补。文案据实写「跳过」, + 不再叫「保存」——它并不保存,而是直接结束注册流程 */} + void finish('candidate')} hitSlop={6}> + 跳过,直接进入 + - {}} placeholder="搜索职位" /> + {step ? ( + + {step} + + ) : null} + + setQuery('')} /> @@ -57,7 +69,12 @@ export function P6ExpectedPosition() { setCat(c)} + onPress={() => { + // 搜索态下分类不参与过滤,只切分类看不出任何变化 —— 一并清掉关键词, + // 让「点分类」这个动作始终有可见结果 + setQuery(''); + setCat(c); + }} > {c} @@ -77,11 +94,15 @@ export function P6ExpectedPosition() { ))} )} - - {POSITIONS[cat].map((p) => ( - toggle(p)} /> - ))} - + {visible.length > 0 ? ( + + {visible.map((p) => ( + toggle(p)} /> + ))} + + ) : ( + 没有匹配「{keyword}」的职位,换个关键词试试 + )} @@ -93,12 +114,19 @@ export function P6ExpectedPosition() { } const styles = StyleSheet.create({ - header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }, title: { fontSize: 20, fontWeight: '800', color: colors.textPrimary }, - headerRight: { flexDirection: 'row', alignItems: 'center', gap: 14 }, - progress: { backgroundColor: colors.primaryLight, paddingHorizontal: 10, paddingVertical: 3, borderRadius: 10 }, + progress: { + alignSelf: 'flex-start', + backgroundColor: colors.primaryLight, + paddingHorizontal: 10, + paddingVertical: 3, + borderRadius: 10, + marginBottom: 12, + }, progressText: { fontSize: 12, fontWeight: '700', color: colors.primaryText }, save: { fontSize: 15, fontWeight: '700', color: colors.primary }, + empty: { fontSize: 13, color: colors.textHint, lineHeight: 20 }, body: { flexDirection: 'row', gap: 14, marginTop: 8 }, cats: { width: 92 }, catItem: { paddingVertical: 12, borderRadius: 10 }, diff --git a/components/onboarding/screens/P7Salary.tsx b/components/onboarding/screens/P7Salary.tsx index 6b83a30..e95d669 100644 --- a/components/onboarding/screens/P7Salary.tsx +++ b/components/onboarding/screens/P7Salary.tsx @@ -1,27 +1,31 @@ import { Pressable, StyleSheet, Text, View } from 'react-native'; import { colors } from '../../../src/theme'; import { useOnboarding } from '../../../src/hooks'; +import { applySalaryMin, salaryMaxOptions } from '../../../src/api'; +import { SALARY_MIN_OPTIONS } from '../../../src/constants'; import { ScreenShell } from '../ui/ScreenShell'; import { PrimaryButton } from '../ui/PrimaryButton'; -const MIN = ['9K', '10K', '11K']; -const MAX = ['11K', '12K', '13K']; - export function P7Salary() { - const { data, setData, navigate, back, meta } = useOnboarding(); + const { data, setData, navigate, back } = useOnboarding(); + + // 「最高」档位随「最低」收窄(规则在 API 层),改最低时同步校正最高, + // 避免存出「最低 30K / 最高 15K」这种自相矛盾的期望薪资 + const maxOptions = salaryMaxOptions(data.expectedSalaryMin); + const canNext = data.expectedSalaryMin !== '' && data.expectedSalaryMax !== ''; return ( - + 1K 代表 1000 元 最低 - {MIN.map((v) => ( + {SALARY_MIN_OPTIONS.map((v) => ( setData({ expectedSalaryMin: v })} + onPress={() => setData(applySalaryMin(v, data.expectedSalaryMax))} > {v} @@ -30,7 +34,7 @@ export function P7Salary() { 最高 - {MAX.map((v) => ( + {maxOptions.map((v) => ( - navigate('P8')} /> + navigate('P8')} /> ); diff --git a/components/onboarding/screens/P8Resume1.tsx b/components/onboarding/screens/P8Resume1.tsx index 19380c4..11282da 100644 --- a/components/onboarding/screens/P8Resume1.tsx +++ b/components/onboarding/screens/P8Resume1.tsx @@ -8,12 +8,18 @@ import { YearMonthWheel } from '../ui/WheelPicker'; import { PrimaryButton } from '../ui/PrimaryButton'; export function P8Resume1() { - const { data, setData, navigate, back, meta } = useOnboarding(); + const { data, setData, navigate, back } = useOnboarding(); - const canNext = data.resumeName.trim().length > 0; + // 姓名 / 性别 / 出生年月都是本页要采集的项,缺一项就不放行 —— 否则资料里会留下 + // 「有姓名但没有出生年月」的半截记录 + const canNext = + data.resumeName.trim().length > 0 && + data.gender !== null && + data.birthYear !== '' && + data.birthMonth !== ''; return ( - + @@ -39,6 +45,7 @@ export function P8Resume1() { month={data.birthMonth} onYearChange={(v) => setData({ birthYear: v })} onMonthChange={(v) => setData({ birthMonth: v })} + restingYear="2000年" /> diff --git a/components/onboarding/screens/P9JobStatus.tsx b/components/onboarding/screens/P9JobStatus.tsx index c35a189..49753b5 100644 --- a/components/onboarding/screens/P9JobStatus.tsx +++ b/components/onboarding/screens/P9JobStatus.tsx @@ -2,18 +2,20 @@ import { Ionicons } from '@expo/vector-icons'; import { StyleSheet, Text, View } from 'react-native'; import { colors } from '../../../src/theme'; import { useOnboarding } from '../../../src/hooks'; +import { JOB_STATUS_OPTIONS } from '../../../src/constants'; import { ScreenShell } from '../ui/ScreenShell'; import { RadioRow } from '../ui/RadioRow'; import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { PrimaryButton } from '../ui/PrimaryButton'; -const OPTIONS = ['离职-随时到岗', '在职-月内到岗', '在职-考虑机会', '在职-暂不考虑']; +// 选项放常量:简历「求职意向」的积极/不积极判定读的是同一份,不能各写一份 +const OPTIONS = JOB_STATUS_OPTIONS; export function P9JobStatus() { - const { data, setData, navigate, back, meta } = useOnboarding(); + const { data, setData, navigate, back } = useOnboarding(); return ( - + diff --git a/components/onboarding/ui/Avatar.tsx b/components/onboarding/ui/Avatar.tsx index f02bc9d..21989d4 100644 --- a/components/onboarding/ui/Avatar.tsx +++ b/components/onboarding/ui/Avatar.tsx @@ -1,17 +1,25 @@ -import { Pressable, StyleSheet, View } from 'react-native'; +import { Image, Pressable, StyleSheet, View } from 'react-native'; import { colors } from '../../../src/theme'; import { Illustration } from './Illustration'; import type { AssetName } from '../assets'; interface Props { name?: AssetName; + /** 拍照 / 相册得到的本地图片 URI;有值时优先于 name 渲染 */ + uri?: string | null; size?: number; selected?: boolean; onPress?: () => void; } -/** 圆形头像占位:默认头像剪影 / 真实图;选中态加主色描边 */ -export function Avatar({ name = 'defaultAvatar', size = 64, selected = false, onPress }: Props) { +/** 圆形头像:预设占位图 / 真实图片;选中态加主色描边 */ +export function Avatar({ + name = 'defaultAvatar', + uri = null, + size = 64, + selected = false, + onPress, +}: Props) { const content = ( - + {uri ? ( + + ) : ( + + )} ); if (onPress) return {content}; diff --git a/components/onboarding/ui/Illustration.tsx b/components/onboarding/ui/Illustration.tsx index 650ea85..65e3eea 100644 --- a/components/onboarding/ui/Illustration.tsx +++ b/components/onboarding/ui/Illustration.tsx @@ -1,4 +1,4 @@ -import { Image, ImageSourcePropType, StyleSheet, Text, View } from 'react-native'; +import { Image, ImageSourcePropType, StyleSheet, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import type { ComponentProps } from 'react'; import { colors } from '../../../src/theme'; @@ -13,7 +13,13 @@ interface Props { circle?: boolean; } -/** 统一插画/Logo/头像占位:有真实图渲染图,否则渲染色块 + 图标 + 提示文字 */ +/** + * 统一插画/Logo/头像占位:有真实图渲染图,否则渲染色块 + 图标。 + * + * 占位期**不渲染 `hint`**:那些文字是给设计/开发看的素材说明 + * (「手持 OFFER 职场男性插画」),直接画在用户界面上等于把开发备注当内容展示。 + * 说明仍留在 `assets.ts`,供换图时对照。 + */ export function Illustration({ name, size = 120, radius = 16, circle = false }: Props) { const def = ASSET_REGISTRY[name]; const source: ImageSourcePropType | null = def.source; @@ -26,12 +32,10 @@ export function Illustration({ name, size = 120, radius = 16, circle = false }: return ( - {!circle && {def.hint}} ); } const styles = StyleSheet.create({ block: { alignItems: 'center', justifyContent: 'center', gap: 6, overflow: 'hidden' }, - hint: { fontSize: 11, color: colors.textHint, paddingHorizontal: 8, textAlign: 'center' }, }); diff --git a/components/onboarding/ui/ImageSlot.tsx b/components/onboarding/ui/ImageSlot.tsx new file mode 100644 index 0000000..88963fb --- /dev/null +++ b/components/onboarding/ui/ImageSlot.tsx @@ -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['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 ( + + ); + } + + return ( + + + 配图待补 + {def.hint} + + ); +} + +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, + }, +}); diff --git a/components/onboarding/ui/ScreenShell.tsx b/components/onboarding/ui/ScreenShell.tsx index 6ee80f5..e54d5c8 100644 --- a/components/onboarding/ui/ScreenShell.tsx +++ b/components/onboarding/ui/ScreenShell.tsx @@ -14,13 +14,10 @@ interface Props { children: ReactNode; footer?: ReactNode; scroll?: boolean; - /** 兼容旧调用保留,已不再渲染(统一使用系统真实状态栏) */ - time?: string; - battery?: number; } /** 每屏统一外壳:系统状态栏 + 可选返回箭头 + 标题 + 滚动内容 + 底部固定区 */ -export function ScreenShell({ title, showBack, onBack, backIcon = 'chevron-back', children, footer, scroll = true, time: _time, battery: _battery }: Props) { +export function ScreenShell({ title, showBack, onBack, backIcon = 'chevron-back', children, footer, scroll = true }: Props) { return ( diff --git a/components/onboarding/ui/SupportFooter.tsx b/components/onboarding/ui/SupportFooter.tsx new file mode 100644 index 0000000..42f2791 --- /dev/null +++ b/components/onboarding/ui/SupportFooter.tsx @@ -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 ( + + {items.map((item) => ( + call(item.phone)} hitSlop={6}> + + {item.label} {item.phone} + + + ))} + + ); +} + +const styles = StyleSheet.create({ + // 两串「标签 + 11 位号码」在窄屏上会挤,允许换行 + row: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 18 }, + item: { fontSize: 12, color: colors.textHint }, +}); diff --git a/components/onboarding/ui/WheelPicker.tsx b/components/onboarding/ui/WheelPicker.tsx index 2780275..3f3feed 100644 --- a/components/onboarding/ui/WheelPicker.tsx +++ b/components/onboarding/ui/WheelPicker.tsx @@ -1,6 +1,8 @@ +import { useEffect, useRef } from 'react'; import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; import { ScrollView, StyleSheet, Text, View } from 'react-native'; import { colors } from '../../../src/theme'; +import { CURRENT_YEAR } from '../../../src/constants'; const ITEM_H = 44; @@ -9,13 +11,29 @@ function Column({ value, onValueChange, width, + restingIndex, }: { items: string[]; value: string; onValueChange: (v: string) => void; width: number; + /** 未选中时滚轮的停留位置(只影响视觉停留,不等于已选中) */ + restingIndex: number; }) { - const idx = Math.max(0, items.indexOf(value)); + const scrollRef = useRef(null); + // 未选中('')与用户手滑到第 0 项是两回事:这里不再把空值钳到索引 0, + // 而是保留「无选中项」状态,只在视觉上停在 restingIndex。 + const idx = items.indexOf(value); + const initialIndex = idx >= 0 ? idx : restingIndex; + + // contentOffset 只在 iOS 生效,Android 上不定位会让滚轮停在第一项、 + // 与高亮框里的文字对不上,故挂载时用 scrollTo 补一次(两端通用)。 + useEffect(() => { + scrollRef.current?.scrollTo({ y: initialIndex * ITEM_H, animated: false }); + // 仅挂载时定位:后续滚动由用户驱动,避免每次选值把滚轮拽回去 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const handleEnd = (e: NativeSyntheticEvent) => { const y = e.nativeEvent.contentOffset.y; const i = Math.min(items.length - 1, Math.max(0, Math.round(y / ITEM_H))); @@ -25,14 +43,15 @@ function Column({ return ( - + @@ -51,16 +70,52 @@ interface DualProps { month: string; onYearChange: (v: string) => void; onMonthChange: (v: string) => void; + /** + * 未选择年份时滚轮的停留项。默认停在当年(适合「入职时间」这类近期场景); + * 「出生年月」应传一个合理的起始年份,否则用户要空滑二十多行。 + */ + restingYear?: string; + /** + * 可选年份跨度:往过去几年、往未来几年。 + * 原实现固定 40 项(当年往前 39 年),只能覆盖 0–39 岁 —— + * 46 岁的求职者选不了自己的出生年;而「入职时间」也选不到明年。 + */ + pastYears?: number; + futureYears?: number; } /** 年月双滚轮:用于出生年月 / 首次参加工作 / 入职时间 */ -export function YearMonthWheel({ year, month, onYearChange, onMonthChange }: DualProps) { - const years = Array.from({ length: 40 }, (_, i) => `${2026 - i}年`); +export function YearMonthWheel({ + year, + month, + onYearChange, + onMonthChange, + restingYear = `${CURRENT_YEAR}年`, + pastYears = 60, + futureYears = 0, +}: DualProps) { + const years = Array.from( + { length: pastYears + futureYears + 1 }, + (_, i) => `${CURRENT_YEAR + futureYears - i}年`, + ); const months = Array.from({ length: 12 }, (_, i) => `${i + 1}月`); + const unselected = !year || !month; + const restingYearIndex = Math.max(0, years.indexOf(restingYear)); + return ( - - - + + + + + + {/* 未选中时滚轮无高亮项,需要文字提示「要滑动才有值」,否则用户会以为已经选好了 */} + {unselected ? 请滑动选择 : null} ); } @@ -80,8 +135,11 @@ const styles = StyleSheet.create({ backgroundColor: colors.primaryLight, opacity: 0.45, }, + // 未选中:高亮框退成中性描边,不再用主色底暗示「已经选好了」 + highlightIdle: { backgroundColor: colors.white, opacity: 0.6 }, item: { height: ITEM_H, alignItems: 'center', justifyContent: 'center' }, itemText: { fontSize: 18, color: colors.textHint }, itemTextActive: { fontSize: 20, fontWeight: '700', color: colors.textPrimary }, dual: { flexDirection: 'row', justifyContent: 'center', gap: 14 }, + hint: { fontSize: 13, color: colors.textHint, textAlign: 'center', marginTop: 10 }, }); diff --git a/components/resume/EditKit.tsx b/components/resume/EditKit.tsx new file mode 100644 index 0000000..f8bc75d --- /dev/null +++ b/components/resume/EditKit.tsx @@ -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 ( + + + + + {title} + + + ); +} + +/** + * 表单行。`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 ( + + + {label} + {required === true && *} + + + {hasError && {error}} + {!hasError && hint !== undefined && hint !== '' && {hint}} + + ); +} + +/** 只读说明行(放在卡片或页脚里,字号小于正文) */ +export function FormNote({ text }: { text: string }) { + return {text}; +} + +/** 白色圆角卡片容器(与简历首页的卡片同款) */ +export function SectionCard({ title, right, children }: { title: string; right?: ReactNode; children: ReactNode }) { + return ( + + + {title} + {right} + + {children} + + ); +} + +/** 页脚容器:上方一条浅描边,主按钮靠它压底 */ +export function PageFooter({ children }: { children: ReactNode }) { + return {children}; +} + +/** 居中状态块(加载中 / 加载失败 / 空列表都走这一个) */ +export function CenterState({ + icon, + text, + actionLabel, + onAction, +}: { + icon?: keyof typeof Ionicons.glyphMap; + text: string; + actionLabel?: string; + onAction?: () => void; +}) { + return ( + + {icon !== undefined && } + {text} + {actionLabel !== undefined && onAction !== undefined && ( + + {actionLabel} + + )} + + ); +} + +/** 列表行右侧的图标按钮(编辑 / 删除) */ +export function IconAction({ + icon, + onPress, + color, + label, +}: { + icon: keyof typeof Ionicons.glyphMap; + onPress: () => void; + color?: string; + /** 无障碍标签:图标按钮对读屏用户是没有名字的,必须补一个 */ + label: string; +}) { + return ( + + + + ); +} + +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' }, +}); diff --git a/package.json b/package.json index 8ca9678..1dbfc9d 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,14 @@ "expo": "~57.0.22", "expo-blur": "~57.0.3", "expo-constants": "~57.0.18", + "expo-file-system": "~57.0.7", "expo-font": "~57.0.4", + "expo-image-picker": "~57.0.17", "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.10", + "expo-print": "~57.0.2", "expo-router": "~57.0.21", + "expo-sharing": "~57.0.19", "expo-splash-screen": "~57.0.9", "expo-status-bar": "~57.0.1", "react": "19.2.3", @@ -26,7 +30,11 @@ "ios": "expo start --ios", "lint": "eslint .", "lint:fix": "eslint . --fix", - "test": "jest" + "test": "jest", + "typecheck": "tsc --noEmit", + "check:routes": "node scripts/check-routes.mjs", + "check:affordances": "node scripts/check-affordances.mjs", + "check:backend-stubs": "node scripts/check-backend-stubs.mjs" }, "private": true, "devDependencies": { diff --git a/scripts/check-affordances.mjs b/scripts/check-affordances.mjs new file mode 100644 index 0000000..9b2dc4c --- /dev/null +++ b/scripts/check-affordances.mjs @@ -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'; + +/** 从 ``(粗略忽略字符串内的 `>`,对本仓库足够) */ +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; +} diff --git a/scripts/check-backend-stubs.mjs b/scripts/check-backend-stubs.mjs new file mode 100644 index 0000000..a556dd4 --- /dev/null +++ b/scripts/check-backend-stubs.mjs @@ -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; +} diff --git a/src/__tests__/carrier-login.test.ts b/src/__tests__/carrier-login.test.ts new file mode 100644 index 0000000..9e0c3dc --- /dev/null +++ b/src/__tests__/carrier-login.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/enterprise-filters.test.ts b/src/__tests__/enterprise-filters.test.ts new file mode 100644 index 0000000..638b9d6 --- /dev/null +++ b/src/__tests__/enterprise-filters.test.ts @@ -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)); + }); + }); +}); diff --git a/src/__tests__/enterprise-reviews.test.ts b/src/__tests__/enterprise-reviews.test.ts new file mode 100644 index 0000000..9b9b2b6 --- /dev/null +++ b/src/__tests__/enterprise-reviews.test.ts @@ -0,0 +1,62 @@ +import { + fetchJobEvaluationManagement, + fetchPendingOfferCount, + replyJobReview, +} from '../api/api'; + +/** + * 内存版存储:回复要能跨两次请求读回来,才测得出「刷新后回复还在」。 + * 变量名必须以 mock 开头,jest.mock 的工厂函数只能引用这类外部变量。 + */ +const mockStore = new Map(); + +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); + }); +}); diff --git a/src/__tests__/interview-score-draft.test.ts b/src/__tests__/interview-score-draft.test.ts new file mode 100644 index 0000000..ebc8390 --- /dev/null +++ b/src/__tests__/interview-score-draft.test.ts @@ -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(); + 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 }).__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(); + }); +}); diff --git a/src/__tests__/job-match.test.ts b/src/__tests__/job-match.test.ts new file mode 100644 index 0000000..c110199 --- /dev/null +++ b/src/__tests__/job-match.test.ts @@ -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 = {}; + 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 => ({ + ...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: '杭州' }); + }); +}); diff --git a/src/__tests__/onboarding-checklist.test.ts b/src/__tests__/onboarding-checklist.test.ts new file mode 100644 index 0000000..d8cfe52 --- /dev/null +++ b/src/__tests__/onboarding-checklist.test.ts @@ -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(); + 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 }).__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 确认工位'); + }); +}); diff --git a/src/__tests__/onboarding-profile.test.ts b/src/__tests__/onboarding-profile.test.ts new file mode 100644 index 0000000..70e942e --- /dev/null +++ b/src/__tests__/onboarding-profile.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/resume-export.test.ts b/src/__tests__/resume-export.test.ts new file mode 100644 index 0000000..f09c150 --- /dev/null +++ b/src/__tests__/resume-export.test.ts @@ -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 { + 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: '', + avatar: '', + phone: '', + email: '', + location: '', + }, + }), + SETTINGS, + ); + expect(html).not.toContain(''); + 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: '">', + }); + expect(html).toContain('class="v-classic"'); + expect(html).not.toContain(''); + }); + + 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('
'); + expect(withCover).toContain('
'); + expect(withCover).toContain('
张小满
'); + }); + + 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('
'); + }); + + 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;'); + }); +}); diff --git a/src/__tests__/resume-override.test.ts b/src/__tests__/resume-override.test.ts new file mode 100644 index 0000000..4cc1057 --- /dev/null +++ b/src/__tests__/resume-override.test.ts @@ -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 = {}; + 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 => ({ + ...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' }); + }); +}); diff --git a/src/__tests__/resume-profile.test.ts b/src/__tests__/resume-profile.test.ts new file mode 100644 index 0000000..58a2ec1 --- /dev/null +++ b/src/__tests__/resume-profile.test.ts @@ -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 = {}; + 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 => ({ + ...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(''); + }); +}); diff --git a/src/__tests__/salary-options.test.ts b/src/__tests__/salary-options.test.ts new file mode 100644 index 0000000..9d83367 --- /dev/null +++ b/src/__tests__/salary-options.test.ts @@ -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', + }); + }); +}); diff --git a/src/__tests__/user-profile.test.ts b/src/__tests__/user-profile.test.ts new file mode 100644 index 0000000..416943c --- /dev/null +++ b/src/__tests__/user-profile.test.ts @@ -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 = {}; + 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 => ({ + ...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 }); + }); +}); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 8a4a72e..96e0313 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,4 +1,13 @@ -import { formatThousands, formatCurrency, formatViews } from '../utils'; +import { + formatThousands, + formatCurrency, + formatViews, + isValidEmail, + onboardingStepLabel, + splitYearMonth, + formatDateTime, +} from '../utils'; +import { ONBOARDING_STEP_ORDER } from '../constants'; describe('formatThousands', () => { it('千分位格式化 12846 → "12,846"', () => { @@ -29,3 +38,83 @@ describe('formatViews', () => { expect(formatViews(8500)).toBe('8500'); }); }); + +/* + * 进度文案此前是写在页面上的字面量(P6 写「3/3」),与实际步骤数无关。 + * 这里锁定「序号 = 数组下标 + 1」与「分母 = 主线长度」, + * 以后加删步骤不需要改测试,但写死数字会被抓出来。 + */ +describe('onboardingStepLabel', () => { + it('主线每一步的序号与位置一致,分母为步骤总数', () => { + ONBOARDING_STEP_ORDER.forEach((step, i) => { + expect(onboardingStepLabel(step)).toBe(`第 ${i + 1} / ${ONBOARDING_STEP_ORDER.length} 步`); + }); + }); + + it('非主线屏返回 null(不渲染进度)', () => { + // 登录/协议/企业端,以及 P5、P17 两个「进入后返回」的子页 + ['P1', 'P2', 'P2Q', 'P2P', 'P2R', 'P2W', 'P5', 'P17', 'B1', 'B2', 'B3', 'P23', ''].forEach( + (step) => { + expect(onboardingStepLabel(step)).toBeNull(); + }, + ); + }); + + it('主线不含重复项(重复会把同一步算两次)', () => { + expect(new Set(ONBOARDING_STEP_ORDER).size).toBe(ONBOARDING_STEP_ORDER.length); + }); +}); + +/* + * 工作经历编辑页回填滚轮用。解析不出时必须给空串(滚轮显示「请滑动选择」), + * 给它一个兜底索引会静默把用户的入职年月改成别的年份。 + */ +describe('splitYearMonth', () => { + it('「2023年2月」拆成「2023年」与「2月」', () => { + expect(splitYearMonth('2023年2月')).toEqual({ year: '2023年', month: '2月' }); + }); + + it('两位月份同样拆得开', () => { + expect(splitYearMonth('2023年12月')).toEqual({ year: '2023年', month: '12月' }); + }); + + it('前后空白不影响解析', () => { + expect(splitYearMonth(' 2023年2月 ')).toEqual({ year: '2023年', month: '2月' }); + }); + + it('空串与「至今」解析出两个空串(调用方据此走未选中态)', () => { + expect(splitYearMonth('')).toEqual({ year: '', month: '' }); + expect(splitYearMonth('至今')).toEqual({ year: '', month: '' }); + }); + + it('演示简历里的「2022.07」这种格式解析不出,也不猜', () => { + expect(splitYearMonth('2022.07')).toEqual({ year: '', month: '' }); + }); +}); + +describe('formatDateTime', () => { + it('输出「MM-DD HH:mm」', () => { + const local = new Date(2026, 8, 15, 14, 20); + expect(formatDateTime(local.toISOString())).toBe('09-15 14:20'); + }); + + it('时分补零', () => { + const local = new Date(2026, 0, 3, 9, 5); + expect(formatDateTime(local.toISOString())).toBe('01-03 09:05'); + }); + + it('空串与非法时间串返回空串(页面据此不渲染该行,而不是显示 Invalid Date)', () => { + expect(formatDateTime('')).toBe(''); + expect(formatDateTime('不是时间')).toBe(''); + }); +}); + +describe('isValidEmail', () => { + it.each(['a@b.co', 'zhang.xiao-man+1@example.com', ' a@b.cn '])('合法:%s', (email) => { + expect(isValidEmail(email)).toBe(true); + }); + + it.each(['', 'a', 'a@', '@b.com', 'a@b', 'a b@c.com', 'a@b.c'])('非法:%s', (email) => { + expect(isValidEmail(email)).toBe(false); + }); +}); diff --git a/src/api/api.ts b/src/api/api.ts index c7a58b1..993041a 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -5,6 +5,13 @@ * - 每个函数用 JSDoc 标注请求方式(@method)、URL(@url)、参数(@param)、返回值(@returns) * - 所有 mock 数据统一在本文件管理;真实后端就绪后,仅替换函数体为 fetch 即可 * - 业务规则计算(等级映射等)与排序/筛选/搜索/分页在本层完成 + * + * 后端接口预留(每个函数体内都写好了真实请求,只是注释着 —— 接后端时放开即可): + * - 纯 mock 返回的函数:`// TODO: 真实请求(后端就绪后替换)` + 注释的 fetch / res.json() + * - 以本地存储为唯一真相源的函数:`// TODO: 后端就绪后改为 await fetch(...);当前以本地存储为唯一真相源` + * (这类函数现在写的是 storage,放开注释时要把本地写换成请求) + * - AI 能力:参数名带 `_` 前缀表示尚未接入;去掉下划线 + 放开注释 = 该能力上线 + * 放开注释时记得同步删掉紧邻的 mock 返回,别让两套并存。 */ import Constants from 'expo-constants'; import type { @@ -36,9 +43,18 @@ import type { ChatMessage, RankOverview, Resume, - ResumeExportFormat, + ResumeAIActionResult, + ResumeBasicInfo, + ResumeEducation, + ResumeExperience, + ResumeExportOutcome, ResumeExportSettings, + ResumeItemInput, ResumeOptimizeResult, + ResumeOverride, + ResumeProject, + ResumeSelfEvaluation, + ResumeSkill, SalaryData, SalaryBracket, SalaryCalcInput, @@ -47,6 +63,7 @@ import type { SalaryInsuranceItem, SalaryStructureItem, SalaryTaxItem, + SalaryToolLink, UserProfile, UserStats, WeightDimension, @@ -54,11 +71,15 @@ import type { WeightTemplate, SkillCloudData, SkillCloudFilter, + SkillLearningItem, + SkillLearningPath, SkillWord, SkillCategory, TopSkill, ChecklistGroup, OnboardingChecklist, + OnboardingData, + OnboardingProfile, JobProgress, IdentityOption, AppRole, @@ -101,6 +122,7 @@ import type { EnterpriseQuickAction, EnterpriseStat, InterviewScoreData, + InterviewScoreDraft, InterviewScoreDimension, InterviewScoreTag, JobEvaluationDimension, @@ -111,8 +133,14 @@ import type { JobQualityEstimate, JobReview, JobManagement, + TalentCandidate, TalentRecommendation, + TalentRecommendationTab, + EnterpriseChatItem, + EnterpriseChatStatus, EnterpriseCommunication, + EnterpriseCommunicationTab, + PendingTask, EnterpriseCertificationBenefit, EnterpriseCertificationData, EnterpriseCertificationForm, @@ -127,20 +155,35 @@ import type { ChangePasswordPayload, PasswordResetPayload, PasswordRuleError, + CarrierName, + CarrierPhoneResult, } from '../types'; import { + ALL_CITY_LABEL, + CARRIER_TOKEN_TTL_MS, DEFAULT_SOCIAL_PROFILE, + JOB_MATCH_CITY_BONUS, + JOB_MATCH_POSITION_BONUS, + JOB_MATCH_SALARY_BONUS, + JOB_STATUS_EMPLOYED_PREFIX, + JOB_STATUS_INACTIVE, MOCK_DELAY, PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_RULE_LABEL, + POSITION_NOISE_WORDS, + PREFERENCE_DEFAULT_WORK_TYPE, + RESUME_AI_UNAVAILABLE_TIP, + SALARY_MAX_OPTIONS, SALARY_RANGES, SOCIAL_SECURITY_CITY_PROFILES, TOKEN_TTL_MS, } from '../constants'; -import { isValidPhone, isValidSmsCode } from '../utils'; +import { buildResumeExportHtml, isValidPhone, isValidSmsCode } from '../utils'; import { colors } from '../theme'; import { storageGet, storageSet, storageRemove } from '../lib/storage'; +import { exportResumePdf } from '../lib/resumeExport'; +import { clearCacheDir, readCacheBytes } from '../lib/cache'; /** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */ export const API_BASE_URL: string = @@ -626,6 +669,7 @@ const MOCK_USER_PROFILE: UserProfile = { name: '李明', avatar: '李', bio: '3年工作经验·本科·北京', + avatarKey: null, }; /** 用户统计数据(我的页四宫格) */ @@ -702,13 +746,17 @@ const MOCK_DIMENSION_SCORES: DimensionScore[] = [ { id: 'salary', name: '薪资匹配度', score: 82, level: 'excellent' }, ]; -/** 求职偏好 */ -const MOCK_JOB_PREFERENCE: JobPreference = { - position: '前端开发工程师', - city: '北京', - salary: '25-35K', - workType: '全职', - status: '积极求职中', +/** + * 求职偏好空值:引导未采集时返回它,由页面渲染「未设置」。 + * 此前这里返回写死的演示偏好(前端开发工程师 / 北京 / 25-35K), + * 导致「我的」页的偏好恒为假数据,与用户填的无关。 + */ +const EMPTY_JOB_PREFERENCE: JobPreference = { + position: '', + city: '', + salary: '', + workType: PREFERENCE_DEFAULT_WORK_TYPE, + status: '', }; /** 勋章列表(已获得 + 未获得;未获得带获取进度) */ @@ -865,6 +913,15 @@ export async function fetchJobList( // const res = await fetch(`${API_BASE_URL}/api/jobs?${query}`, { method: 'GET' }); // return res.json(); + // 仅在「默认推荐视图」叠加偏好加分:用户主动选了城市或输入关键词时, + // 一律尊重用户的选择,不额外加分(也把回归面限制在默认视图内) + const hasCityFilter = !!params.city && params.city !== ALL_CITY_LABEL; + const hasKeyword = !!params.keyword?.trim(); + const preference = + params.sortBy === 'comprehensive' && !hasCityFilter && !hasKeyword + ? await readEffectivePreference() + : null; + return new Promise((resolve) => { setTimeout(() => { // 1. 从 mock 数据源复制 @@ -886,7 +943,7 @@ export async function fetchJobList( } // 4. 筛选:城市 - if (params.city && params.city !== '全部城市') { + if (params.city && params.city !== ALL_CITY_LABEL) { const city = params.city; result = result.filter((job) => job.city.includes(city)); } @@ -924,7 +981,15 @@ export async function fetchJobList( break; case 'comprehensive': default: - result.sort((a, b) => b.score - a.score); + if (preference) { + // 已采集偏好:按「岗位分数 + 命中加分」重排,让符合偏好的岗位上浮 + result = result + .map((job) => ({ job, match: matchScore(job, preference) })) + .sort((a, b) => b.match - a.match) + .map((item) => item.job); + } else { + result.sort((a, b) => b.score - a.score); + } break; } @@ -1114,7 +1179,33 @@ export async function fetchArticles(): Promise { */ export async function fetchUserProfile(): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/user/me`, { method: 'GET' }); return res.json(); - return mockRequest(MOCK_USER_PROFILE); + const profile = await fetchOnboardingProfile(); + if (!profile) return mockRequest(MOCK_USER_PROFILE); + const { name, avatar } = displayNameAndAvatar(profile.resumeName); + return mockRequest({ + // 有引导资料就只用资料里的值:不拿 mock 的「李明 / 3年工作经验·本科·北京」补空, + // 空值由页面渲染「未设置」或隐藏该行 + ...MOCK_USER_PROFILE, + name, + avatar, + bio: buildProfileBio(profile), + // 头像原始值直接透传,由页面决定怎么渲染(素材 key / 本地 URI / 回落展示字符) + avatarKey: profile.avatar, + }); +} + +/** 姓名 → 展示用姓名与头像字(头像取姓氏,与 mock 的「李」同风格) */ +function displayNameAndAvatar(resumeName: string): { name: string; avatar: string } { + const name = resumeName.trim(); + return { name, avatar: name.slice(0, 1) }; +} + +/** + * 资料 → 「我的」页简介:只拼引导真正采集到的项,未采集的略过, + * 不用 mock 的工作年限/学历/城市冒充用户信息 + */ +function buildProfileBio(profile: OnboardingProfile): string { + return [profile.education, profile.city].filter(Boolean).join('·'); } /** @@ -1174,15 +1265,306 @@ export async function fetchDimensionScores(): Promise { return mockRequest(MOCK_DIMENSION_SCORES); } +/* ===================== 注册引导资料(mock 阶段持久化到 AsyncStorage) ===================== */ + +const ONBOARDING_PROFILE_KEY = 'onboarding:profile'; + +/** + * 保存注册引导采集到的求职者资料 + * @method POST + * @url /api/user/onboarding-profile + * @param profile 引导采集的资料(不含手机号 / 验证码 / 协议勾选等登录瞬时字段) + * @returns Promise + * @remark 由引导流程按步骤提交(见 useOnboardingFlow),不模拟网络延迟,避免拖慢每一步跳转 + */ +export async function saveOnboardingProfile(profile: OnboardingProfile): Promise { + // TODO: await fetch(`${API_BASE_URL}/api/user/onboarding-profile`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profile) }); + await storageSet(ONBOARDING_PROFILE_KEY, JSON.stringify(profile)); +} + +/** + * 获取注册引导采集到的求职者资料 + * @method GET + * @url /api/user/onboarding-profile + * @returns Promise 从未采集过时为 null(区分「没填过」与「填了空值」) + */ +export async function fetchOnboardingProfile(): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/user/onboarding-profile`, { method: 'GET' }); return res.json(); + const raw = await storageGet(ONBOARDING_PROFILE_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as OnboardingProfile; + } catch { + // 存储内容损坏时按「未采集」处理,不阻断引导流程 + return null; + } +} + +/** + * 清除注册引导资料(退出登录时调用,避免把上一位用户填的资料留给下一位) + * @method DELETE + * @url /api/user/onboarding-profile + * @returns Promise + */ +export async function clearOnboardingProfile(): Promise { + // TODO: await fetch(`${API_BASE_URL}/api/user/onboarding-profile`, { method: 'DELETE' }); + await storageRemove(ONBOARDING_PROFILE_KEY); +} + +/** + * 流程状态 → 持久化资料(数据转换属 API 层职责)。 + * @param data 引导流程当前状态 + * @returns 可落库的资料;登录瞬时字段(phone / code / agreed / wechatTicket)被剔除 + * @remark `OnboardingProfile` 由 `OnboardingData` 派生,漏掉字段会编译失败 + */ +export function toOnboardingProfile(data: OnboardingData): OnboardingProfile { + return { + identity: data.identity, + isStudent: data.isStudent, + city: data.city, + expectedPositions: data.expectedPositions, + expectedSalaryMin: data.expectedSalaryMin, + expectedSalaryMax: data.expectedSalaryMax, + resumeName: data.resumeName, + gender: data.gender, + birthYear: data.birthYear, + birthMonth: data.birthMonth, + jobStatus: data.jobStatus, + disabledJobseeker: data.disabledJobseeker, + firstWorkYear: data.firstWorkYear, + firstWorkMonth: data.firstWorkMonth, + currentPosition: data.currentPosition, + company: data.company, + joinYear: data.joinYear, + joinMonth: data.joinMonth, + skills: data.skills, + workContent: data.workContent, + education: data.education, + eduType: data.eduType, + school: data.school, + avatar: data.avatar, + // 企业端才保留认证信息 + recruiter: + data.identity === 'recruiter' + ? { + company: data.recruiterCompany, + contactName: data.recruiterContact, + licenseUri: data.recruiterLicenseUri, + } + : null, + }; +} + +/** + * 持久化资料 → 流程状态(数据转换属 API 层职责)。 + * @param profile 已落库的资料 + * @param base 兜底状态(通常为 INITIAL),未采集字段与登录瞬时字段沿用它 + * @returns 合并后的流程状态 + */ +export function fromOnboardingProfile( + profile: OnboardingProfile, + base: OnboardingData, +): OnboardingData { + return { + ...base, + identity: profile.identity, + isStudent: profile.isStudent, + city: profile.city, + expectedPositions: profile.expectedPositions, + expectedSalaryMin: profile.expectedSalaryMin, + expectedSalaryMax: profile.expectedSalaryMax, + resumeName: profile.resumeName, + gender: profile.gender, + birthYear: profile.birthYear, + birthMonth: profile.birthMonth, + jobStatus: profile.jobStatus, + disabledJobseeker: profile.disabledJobseeker, + firstWorkYear: profile.firstWorkYear, + firstWorkMonth: profile.firstWorkMonth, + currentPosition: profile.currentPosition, + company: profile.company, + joinYear: profile.joinYear, + joinMonth: profile.joinMonth, + skills: profile.skills, + workContent: profile.workContent, + education: profile.education, + eduType: profile.eduType, + school: profile.school, + avatar: profile.avatar, + recruiterCompany: profile.recruiter?.company ?? base.recruiterCompany, + recruiterContact: profile.recruiter?.contactName ?? base.recruiterContact, + recruiterLicenseUri: profile.recruiter?.licenseUri ?? base.recruiterLicenseUri, + }; +} + +/* ===================== 求职偏好(引导资料推导 + 用户显式编辑) ===================== */ + +const JOB_PREFERENCE_KEY = 'user:job-preference'; + +/** 读取用户在「求职意向」页显式保存过的偏好(未编辑过为 null) */ +async function readJobPreferenceOverride(): Promise { + const raw = await storageGet(JOB_PREFERENCE_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as JobPreference; + } catch { + // 存储内容损坏时按「未编辑过」处理,回退到引导资料推导 + return null; + } +} + +/** 引导资料 → 求职偏好。未采集的字段留空串,不做假数据填充 */ +function deriveJobPreference(profile: OnboardingProfile): JobPreference { + return { + position: profile.expectedPositions[0] ?? '', + city: profile.city, + salary: + profile.expectedSalaryMin && profile.expectedSalaryMax + ? `${profile.expectedSalaryMin}-${profile.expectedSalaryMax}` + : '', + workType: PREFERENCE_DEFAULT_WORK_TYPE, + status: profile.jobStatus ?? '', + }; +} + +/** + * 期望薪资字符串 → 数值区间 + * @param text 形如 `20K`(单值)或 `20-35K`(区间) + * @returns 数值区间;无法解析时为 null(不参与匹配,避免凭空加分) + */ +function parseSalaryRange(text: string): { min: number; max: number } | null { + const nums = text.match(/\d+/g); + if (!nums || nums.length === 0) return null; + const values = nums.map(Number); + return { min: Math.min(...values), max: Math.max(...values) }; +} + +/** + * 职位名归一化:剔除级别与通用后缀,让「前端开发工程师」能与「高级前端工程师」命中。 + * 全被剔除时退回原名(如职位名就是「工程师」),避免空串命中一切。 + */ +function normalizePosition(name: string): string { + const stripped = POSITION_NOISE_WORDS.reduce((acc, word) => acc.replace(word, ''), name).trim(); + return stripped === '' ? name : stripped; +} + +/** 期望职位与岗位职位是否命中(归一化后双向包含,粗粒度匹配) */ +function isPositionMatched(jobPositionName: string, expectedPositions: string[]): boolean { + const job = normalizePosition(jobPositionName); + return expectedPositions.some((position) => { + if (!position.trim()) return false; + const want = normalizePosition(position); + return job.includes(want) || want.includes(job); + }); +} + +/** + * 期望月薪「最高」可选阶梯:过滤掉低于已选最低值的档位。 + * 规则放这里而不是页面里 —— 「最高不得低于最低」是业务规则, + * 页面只负责渲染返回的档位、不写数值比较。 + * @param min 已选最低值(如 `20K`);未选或无法解析时返回完整阶梯 + * @returns 可选的最高值档位 + */ +export function salaryMaxOptions(min: string): string[] { + const floor = parseSalaryRange(min); + if (!floor) return [...SALARY_MAX_OPTIONS]; + return SALARY_MAX_OPTIONS.filter((option) => (parseSalaryRange(option)?.min ?? 0) >= floor.max); +} + +/** + * 改选最低期望月薪后,返回与之一致的 { 最低, 最高 }。 + * 原最高值低于新的最低值时必须清掉 —— 否则会存出「最低 30K / 最高 15K」这种 + * 自相矛盾的偏好,并顺着 `fetchJobPreference` 一路显示到「我的」页。 + * @param min 新的最低值 + * @param currentMax 当前最高值 + * @returns 校正后的最低/最高值(最高值被清空时为 `''`) + */ +export function applySalaryMin( + min: string, + currentMax: string, +): { expectedSalaryMin: string; expectedSalaryMax: string } { + const floor = parseSalaryRange(min); + const ceiling = parseSalaryRange(currentMax); + // 最低值本身无法解析时无从比较,保留原最高值(只有能证明「最高 < 最低」才清) + const keepMax = !floor || (!!ceiling && ceiling.max >= floor.max); + return { expectedSalaryMin: min, expectedSalaryMax: keepMax ? currentMax : '' }; +} + +/** 岗位推荐用的有效偏好 */ +interface EffectivePreference { + positions: string[]; + city: string; + salary: { min: number; max: number } | null; +} + +/** + * 岗位推荐用的有效偏好:显式保存过的意向优先,否则由引导资料推导。 + * 两处取值源(引导资料 / 求职意向编辑页)的优先级只在这里决定。 + */ +async function readEffectivePreference(): Promise { + const override = await readJobPreferenceOverride(); + if (override) { + return { + positions: override.position ? [override.position] : [], + city: override.city, + salary: parseSalaryRange(override.salary), + }; + } + const profile = await fetchOnboardingProfile(); + if (!profile) return null; + return { + positions: profile.expectedPositions, + city: profile.city, + salary: + profile.expectedSalaryMin && profile.expectedSalaryMax + ? parseSalaryRange(`${profile.expectedSalaryMin}-${profile.expectedSalaryMax}`) + : null, + }; +} + +/** + * 岗位匹配分 = 岗位自身分数 + 命中期望城市 / 职位 / 薪资的加分。 + * 权重见 src/constants,此处不写死魔法数字。 + */ +function matchScore(job: Omit, preference: EffectivePreference): number { + let score = job.score; + if (preference.city && job.city.includes(preference.city)) { + score += JOB_MATCH_CITY_BONUS; + } + if (isPositionMatched(job.positionName, preference.positions)) { + score += JOB_MATCH_POSITION_BONUS; + } + if ( + preference.salary && + job.salaryMin <= preference.salary.max && + job.salaryMax >= preference.salary.min + ) { + score += JOB_MATCH_SALARY_BONUS; + } + return score; +} + +/** + * 有效求职偏好:用户在「求职意向」页显式保存过的偏好 > 由引导资料推导 > 空值。 + * 「我的」页偏好卡片与简历「求职意向」都读它,保证两处展示同一个答案。 + */ +async function resolveJobPreference(): Promise { + const override = await readJobPreferenceOverride(); + if (override) return override; + const profile = await fetchOnboardingProfile(); + return profile ? deriveJobPreference(profile) : EMPTY_JOB_PREFERENCE; +} + /** * 获取求职偏好 * @method GET * @url /api/user/job-preference - * @returns Promise 求职偏好 + * @returns Promise 求职偏好;未采集到的字段为空串,由页面渲染「未设置」 + * @remark 取值优先级见 `resolveJobPreference` */ export async function fetchJobPreference(): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/user/job-preference`, { method: 'GET' }); return res.json(); - return mockRequest(MOCK_JOB_PREFERENCE); + return mockRequest(await resolveJobPreference()); } /** @@ -1348,6 +1730,7 @@ const MOCK_OFFER_COMPARE_DATA: OfferCompareData = { company: '字节跳动', position: '高级前端', salary: '25-40K', + status: 'pending', selected: true, color: colors.primary, }, @@ -1356,6 +1739,7 @@ const MOCK_OFFER_COMPARE_DATA: OfferCompareData = { company: '腾讯科技', position: '产品经理', salary: '20-35K', + status: 'pending', selected: true, color: colors.compareRed, }, @@ -1455,6 +1839,22 @@ export async function fetchOfferCompareData(offerIds?: string[]): Promise 待处理(status === 'pending')的 Offer 数量 + */ +export async function fetchPendingOfferCount(): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/offers/pending/count`, { method: 'GET' }); return res.json(); + const pending = MOCK_OFFER_COMPARE_DATA.offers.filter((offer) => offer.status === 'pending'); + return mockRequest(pending.length); +} + /* ===================== 评分权重设置 ===================== */ /** 默认权重维度(9 个,总和 100%) */ @@ -1728,6 +2128,74 @@ export async function fetchSkillCloud(industry: string): Promise }); } +/** + * 按关键词过滤技能词(页面搜索框用,纯函数)。 + * 空关键词返回全部,避免页面里再写一遍过滤逻辑。 + * @param words 技能词数组 + * @param keyword 关键词(大小写不敏感,首尾空格忽略) + * @returns SkillWord[] 命中的技能词 + */ +export function filterSkillWords(words: SkillWord[], keyword: string): SkillWord[] { + const kw = keyword.trim().toLowerCase(); + if (kw === '') return words; + return words.filter((w) => w.text.toLowerCase().includes(kw)); +} + +/** 技能分组 → 中文名(学习路径的推荐理由里要用) */ +const SKILL_GROUP_LABEL: Record = { + tech: '技术技能', + soft: '软技能', + domain: '领域能力', + tool: '工具', +}; + +/** + * 生成技能学习路径 + * 业务规则(接口层):以该行业技能词的权重为优先级,从高到低挑出待补技能; + * 待补数量按「匹配度缺口」换算,限制在 3-6 条之间,避免一次甩出一长串。 + * @method GET + * @url /api/tools/skill-learning-path + * @param industry { string } 行业名称 + * @returns Promise 学习路径 + */ +export async function fetchSkillLearningPath(industry: string): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/tools/skill-learning-path?industry=${encodeURIComponent(industry)}`, { method: 'GET' }); return res.json(); + const words = MOCK_SKILL_WORDS[industry] ?? MOCK_SKILL_WORDS[DEFAULT_SKILL_INDUSTRY]; + const resolvedIndustry = MOCK_SKILL_WORDS[industry] ? industry : DEFAULT_SKILL_INDUSTRY; + const matchPercent = 80; + + // 缺口越大补得越多,但先补权重最高的那几个(3 条起步、6 条封顶) + const gap = Math.max(0, 100 - matchPercent); + const wanted = Math.min(6, Math.max(3, Math.round(gap / 10))); + + const items: SkillLearningItem[] = [...words] + .sort((a, b) => b.weight - a.weight) + .slice(0, wanted) + .map((w, i) => ({ + id: w.id, + name: w.text, + group: w.group, + weight: w.weight, + priority: i + 1, + reason: `行业热度权重 ${w.weight},${SKILL_GROUP_LABEL[w.group]}类里排在前列的硬要求`, + })); + + // 补齐这些技能后的目标匹配度:按待补技能的权重占比折算,最多到 95 + const avgWeight = items.length + ? items.reduce((sum, it) => sum + it.weight, 0) / items.length + : 0; + const targetPercent = Math.min(95, Math.round(matchPercent + (gap * avgWeight) / 100)); + + return mockRequest({ + industry: resolvedIndustry, + position: '高级前端工程师', + matchPercent, + targetPercent, + items, + generatedAt: '刚刚生成', + }); +} + /* ===================== 薪资计算器 ===================== */ /** 各行业平均月薪参考(计算器对比用) */ @@ -1739,6 +2207,44 @@ const MOCK_SALARY_BRACKETS: SalaryBracket[] = [ { industry: '制造业', avgMonthly: 22, sampleCount: 1540 }, ]; +/** 薪资计算页「更多工具」(均为项目内真实页面,route 与 app/ 下路由一一对应) */ +const MOCK_SALARY_TOOL_LINKS: SalaryToolLink[] = [ + { + id: 'offer-compare', + title: 'Offer 对比', + desc: '把几个 offer 的薪资、福利放一起比', + icon: 'git-compare-outline', + route: '/jobs/offer-compare', + }, + { + id: 'salary-rank', + title: '竞争力排名', + desc: '看看同岗位里你的薪资处于什么位置', + icon: 'podium-outline', + route: '/jobs/rank', + }, + { + id: 'skill-cloud', + title: '行业技能词云', + desc: '目标行业的高频技能与要求权重', + icon: 'cloud-outline', + route: '/tools/skill-cloud', + }, +]; + +/** + * 薪资计算页「更多工具」入口。 + * 只放项目里真实存在的页面(原先页面内写死了「年终奖个税计算 / 离职补偿金计算 / + * 公积金社保证明」三条,路由全部指回薪资计算器自身,属于点不动的假入口,已摘除)。 + * @method GET + * @url /api/tools/salary-related + * @returns Promise 相关工具入口数组 + */ +export async function fetchSalaryToolLinks(): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/tools/salary-related`, { method: 'GET' }); return res.json(); + return mockRequest(MOCK_SALARY_TOOL_LINKS); +} + /** * 获取各行业平均月薪参考 * @method GET @@ -2119,7 +2625,11 @@ const MOCK_OPTIMIZED_RESUME: Resume = { }, }; -/** 默认导出设置 */ +/** + * 默认导出设置。 + * `fileName` 默认为空串:原默认值「李明_前端工程师简历」是演示人设, + * 会让每个用户的导出文件都顶着别人的名字,真实姓名由 `resolveExportFileName` 兜底。 + */ const DEFAULT_EXPORT_SETTINGS: ResumeExportSettings = { format: 'pdf', templateId: 'classic', @@ -2127,20 +2637,194 @@ const DEFAULT_EXPORT_SETTINGS: ResumeExportSettings = { exportPassword: false, paperSize: 'A4', orientation: 'portrait', - fileName: '李明_前端工程师简历', + fileName: '', includeCover: false, includeRecommendation: false, }; +/** + * 导出文件名:用户填了就用,没填按「姓名_期望职位简历」兜底,姓名职位都没有才用「我的简历」。 + * @param resume 简历(取姓名与期望职位) + * @param fileName 用户在导出设置里填的文件名 + * @returns 实际落盘的文件名(不含扩展名) + * @remark 放在 API 层:它是产品定的命名规则,导出与分享两条路必须用同一个名字, + * 页面各自拼一遍必然分叉(曾把默认值写成演示人设「李明_前端工程师简历」) + */ +export function resolveExportFileName( + resume: Pick, + fileName: string, +): string { + const custom = fileName.trim(); + if (custom !== '') return custom; + const name = resume.basicInfo.name.trim(); + const position = resume.jobIntention.position.trim(); + if (name !== '' && position !== '') return `${name}_${position}简历`; + if (name !== '') return `${name}的简历`; + if (position !== '') return `${position}简历`; + return '我的简历'; +} + +/* ===================== 简历:用户编辑覆盖层 ===================== + * 简历的默认内容由引导资料派生(`buildResumeFromProfile`),用户在编辑页里改过的板块存在 + * `resume:override`,优先级高于派生值 —— 与求职偏好 `user:job-preference` 同一套 + * 「显式编辑 > 引导推导」语义。真实后端就绪后把这组函数体换成 fetch 即可(签名不变)。 + */ + +/** 简历覆盖层存储键(与引导资料分键存放,避免互相覆盖) */ +const RESUME_OVERRIDE_KEY = 'resume:override'; + +/** 保存时间存储键:头部「保存」写入,页面显示「上次保存 …」 */ +const RESUME_SAVED_AT_KEY = 'resume:saved-at'; + +/** 导出设置存储键 */ +const RESUME_EXPORT_SETTINGS_KEY = 'resume:export-settings'; + +/** 读取覆盖层;未编辑过或存储内容损坏 → 空对象(损坏时按「未编辑过」处理,不抛错) */ +async function readResumeOverride(): Promise { + const raw = await storageGet(RESUME_OVERRIDE_KEY); + if (!raw) return {}; + try { + return JSON.parse(raw) as ResumeOverride; + } catch { + return {}; + } +} + +/** 合并写入:只覆盖 patch 里出现的板块,其它板块此前的编辑保留 */ +async function mergeResumeOverride(patch: ResumeOverride): Promise { + const current = await readResumeOverride(); + await storageSet(RESUME_OVERRIDE_KEY, JSON.stringify({ ...current, ...patch })); +} + +/** 未套覆盖层的简历:引导资料派生;完全没走过注册流程(无资料)时回落演示简历 */ +async function buildDerivedResume(): Promise { + const profile = await fetchOnboardingProfile(); + if (!profile) return MOCK_RESUME; + return buildResumeFromProfile(profile, await resolveJobPreference()); +} + +/** + * 把覆盖层叠到派生简历上:板块级覆盖 —— 键出现就以覆盖层为准。 + * + * 必须用 `??` 而不是 `||`:空数组、空串都是用户的合法编辑结果 + * (用户删光了教育经历,不该再冒出引导资料里的那一条)。 + */ +function applyResumeOverride(derived: Resume, override: ResumeOverride): Resume { + return { + ...derived, + basicInfo: override.basicInfo ?? derived.basicInfo, + education: override.education ?? derived.education, + experience: override.experience ?? derived.experience, + projects: override.projects ?? derived.projects, + skills: override.skills ?? derived.skills, + selfEvaluation: override.selfEvaluation ?? derived.selfEvaluation, + }; +} + +/** 简历条目 id:板块前缀 + 时间戳 + 随机后缀(同一毫秒内连续新增两条不会撞 id) */ +function createResumeItemId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** 给新增条目补 id,并按 id 去重(页面拼接出错时也不会产生重复的列表 key) */ +function withResumeItemIds( + items: ResumeItemInput[], + prefix: string, +): T[] { + const seen = new Map(); + for (const item of items) { + const id = item.id ?? createResumeItemId(prefix); + seen.set(id, { ...item, id } as T); + } + return [...seen.values()]; +} + +/** + * 引导资料 → 简历。 + * + * 只映射引导**真正采集到**的字段,未采集的一律留空,不再拿 `MOCK_RESUME` 的 + * 「李明 / 北京大学 / 字节跳动」顶上 —— 那会让用户在自己的简历里看到别人的 + * 学历与工作经历。页面按空值隐藏对应行(见 `app/resume.tsx`)。 + * + * @param preference 有效求职偏好(`resolveJobPreference`):「求职意向」板块与 + * 「我的」页偏好卡片读同一份,用户在求职意向编辑页改过也一致 + */ +function buildResumeFromProfile(profile: OnboardingProfile, preference: JobPreference): Resume { + const { name, avatar } = displayNameAndAvatar(profile.resumeName); + + // 采集项:毕业学校(P20)、学历 + 学习形式(P18)。 + // 专业、入学/毕业年份引导未采集 → 留空,不套用 mock 的「计算机科学与技术 2018-2022」 + const education: ResumeEducation[] = profile.school + ? [ + { + id: 'edu-onboarding', + school: profile.school, + major: '', + degree: [profile.education, profile.eduType].filter(Boolean).join(' '), + startYear: '', + endYear: '', + }, + ] + : []; + + // 采集项:当前公司(P12)、当前职位(P11)、入职年月(P13)、工作内容(P16)。 + // 在职才写「至今」;离职时结束时间引导未采集 → 留空,而不是编一个离职时间 + const experience: ResumeExperience[] = profile.company + ? [ + { + id: 'exp-onboarding', + company: profile.company, + position: profile.currentPosition ?? '', + startDate: `${profile.joinYear}${profile.joinMonth}`, + endDate: profile.jobStatus?.startsWith(JOB_STATUS_EMPLOYED_PREFIX) ? '至今' : '', + description: profile.workContent, + aiOptimized: false, + }, + ] + : []; + + return { + // 简历评分与优化点是产品的 AI 能力,mock 阶段恒返回同一份(真实后端就绪后由后端打分) + score: MOCK_RESUME.score, + basicInfo: { + name, + avatar, + // 手机号 / 邮箱引导未采集(登录手机号属瞬时字段,按 PIPL 不落库) + phone: '', + email: '', + // 引导采集的是 P4「期望城市」,不是「现居城市」,不能拿它冒充所在城市 + location: '', + }, + jobIntention: { + position: preference.position, + salary: preference.salary, + city: preference.city, + status: preference.status === JOB_STATUS_INACTIVE ? 'inactive' : 'active', + statusLabel: preference.status, + }, + education, + experience, + // 项目经历引导未采集 + projects: [], + skills: profile.skills.map((skillName) => ({ name: skillName })), + // 自我评价引导未采集 + selfEvaluation: { content: '' }, + }; +} + /** * 获取我的简历 * @method GET * @url /api/resume * @returns Promise 简历数据 + * @remark 内容 = 引导采集资料派生(`youzhi:onboarding:profile` + 有效求职偏好)叠加用户编辑 + * 覆盖层(`youzhi:resume:override`):某板块被编辑过就以覆盖层为准,未编辑过用派生值。 + * 只有完全没走过注册流程(无资料)时才回落到演示简历 */ export async function fetchResume(): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/resume`, { method: 'GET' }); return res.json(); - return mockRequest(MOCK_RESUME); + const derived = await buildDerivedResume(); + return mockRequest(applyResumeOverride(derived, await readResumeOverride())); } /** @@ -2148,10 +2832,206 @@ export async function fetchResume(): Promise { * @method POST * @url /api/resume/optimize * @returns Promise 优化后的简历 + * @remark 优化结果**在用户自己的简历上套用 mock 的评分块**: + * 返回 `MOCK_OPTIMIZED_RESUME` 会把整份简历换成演示人设「李明」—— + * 用户点一下「优化简历」,姓名、学历、公司全变成别人的。 + * AI 改写的正文(经历描述 / 自我评价)mock 阶段生成不了,因此**不替换**用户 + * 真实填写的内容,只更新评分与优化点状态;真实后端就绪后由后端返回改写结果。 */ export async function optimizeResume(): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/optimize`, { method: 'POST' }); return res.json(); - return mockRequest({ score: MOCK_OPTIMIZED_RESUME.score.score, resume: MOCK_OPTIMIZED_RESUME }); + const profile = await fetchOnboardingProfile(); + if (!profile) { + return mockRequest({ score: MOCK_OPTIMIZED_RESUME.score.score, resume: MOCK_OPTIMIZED_RESUME }); + } + const base = buildResumeFromProfile(profile, await resolveJobPreference()); + return mockRequest({ + score: MOCK_OPTIMIZED_RESUME.score.score, + // 用户自己编辑过的板块同样保留,优化不覆盖用户的输入 + resume: { ...applyResumeOverride(base, await readResumeOverride()), score: MOCK_OPTIMIZED_RESUME.score }, + }); +} + +/* ===================== 简历:按板块保存(用户编辑) ===================== */ + +/** + * 保存简历基本信息 + * @method PUT + * @url /api/resume/basic-info + * @param input 姓名 / 手机号 / 邮箱 / 所在城市(不含头像) + * @returns Promise 落库后的基本信息(头像字由本层按姓名派生) + * @remark 保存后该板块进入覆盖层:此后引导资料里的姓名不再影响简历 + * (与「求职意向」编辑页的覆盖语义一致) + */ +export async function saveResumeBasicInfo( + input: Omit, +): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/basic-info`, { method: 'PUT', body: JSON.stringify(input) }); return res.json(); + const info: ResumeBasicInfo = { ...input, avatar: displayNameAndAvatar(input.name).avatar }; + await mergeResumeOverride({ basicInfo: info }); + return mockRequest(info); +} + +/** + * 保存自我评价 + * @method PUT + * @url /api/resume/self-evaluation + * @param content 正文(trim 后为空表示清空该板块) + * @returns Promise 落库后的内容 + */ +export async function saveResumeSelfEvaluation(content: string): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/self-evaluation`, { method: 'PUT', body: JSON.stringify({ content }) }); return res.json(); + const section: ResumeSelfEvaluation = { content: content.trim() }; + await mergeResumeOverride({ selfEvaluation: section }); + return mockRequest(section); +} + +/** + * 保存技能特长(整表替换) + * @method PUT + * @url /api/resume/skills + * @param skills 标签列表 + * @returns Promise 规整后的列表 + * @remark 规整规则在本层:逐项 trim、丢弃空串、按名称去重(保留首次出现),页面不重复实现 + */ +export async function saveResumeSkills(skills: ResumeSkill[]): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/skills`, { method: 'PUT', body: JSON.stringify({ skills }) }); return res.json(); + const names: string[] = []; + for (const skill of skills) { + const name = skill.name.trim(); + if (name !== '' && !names.includes(name)) names.push(name); + } + const normalized: ResumeSkill[] = names.map((name) => ({ name })); + await mergeResumeOverride({ skills: normalized }); + return mockRequest(normalized); +} + +/** + * 保存教育背景(整表替换) + * @method PUT + * @url /api/resume/education + * @param items 用户此刻看到的完整列表;id 缺省表示新增项,由本层生成 + * @returns Promise 落库后的列表(新增项已带 id) + * @remark 传整表而不是逐条增删改:列表基底是「覆盖层 ?? 引导派生」的合并结果, + * 逐条写会在「编辑引导派生出来的那条」时把整个板块改丢(此时覆盖层里还没有该板块) + */ +export async function saveResumeEducation( + items: ResumeItemInput[], +): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/education`, { method: 'PUT', body: JSON.stringify({ items }) }); return res.json(); + const normalized = withResumeItemIds(items, 'edu'); + await mergeResumeOverride({ education: normalized }); + return mockRequest(normalized); +} + +/** 保存工作经历(整表替换)。@url /api/resume/experience @remark 同 saveResumeEducation */ +export async function saveResumeExperience( + items: ResumeItemInput[], +): Promise { + // TODO: 后端就绪后改为 await fetch(`${API_BASE_URL}/api/resume/experience`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(items) });当前以本地存储为唯一真相源 + const normalized = withResumeItemIds(items, 'exp'); + await mergeResumeOverride({ experience: normalized }); + return mockRequest(normalized); +} + +/** 保存项目经历(整表替换)。@url /api/resume/projects @remark 同 saveResumeEducation */ +export async function saveResumeProjects( + items: ResumeItemInput[], +): Promise { + // TODO: 后端就绪后改为 await fetch(`${API_BASE_URL}/api/resume/projects`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(items) });当前以本地存储为唯一真相源 + const normalized = withResumeItemIds(items, 'proj'); + await mergeResumeOverride({ projects: normalized }); + return mockRequest(normalized); +} + +/** + * 保存我的简历到本机 + * @method POST + * @url /api/resume/save + * @param resume 当前展示的简历 + * @returns Promise<{ savedAt: string }> 保存时间(ISO 串) + * @remark 把六个可编辑板块整体固化进覆盖层(此后再改引导资料不影响这份简历),并记录保存时间 + * 供页面显示「上次保存」。求职意向不入快照:它由 `resolveJobPreference` 单独供给, + * 同一份数据不能有两个来源。后端就绪后改为云端保存,页面与 hook 无需改动。 + */ +export async function saveResumeSnapshot(resume: Resume): Promise<{ savedAt: string }> { + // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/save`, { method: 'POST', body: JSON.stringify(resume) }); return res.json(); + await mergeResumeOverride({ + basicInfo: resume.basicInfo, + education: resume.education, + experience: resume.experience, + projects: resume.projects, + skills: resume.skills, + selfEvaluation: resume.selfEvaluation, + }); + const savedAt = new Date().toISOString(); + await storageSet(RESUME_SAVED_AT_KEY, savedAt); + return mockRequest({ savedAt }); +} + +/** + * 获取简历上次保存时间 + * @method GET + * @url /api/resume/saved-at + * @returns Promise ISO 时间串;从未保存过返回空串(页面据此不渲染该行) + */ +export async function fetchResumeSavedAt(): Promise { + // TODO: 后端就绪后改为 await fetch(`${API_BASE_URL}/api/resume/saved-at`, { method: 'GET' });当前以本地存储为唯一真相源 + return mockRequest((await storageGet(RESUME_SAVED_AT_KEY)) ?? ''); +} + +/** + * 清除简历编辑覆盖层与保存时间 + * @method DELETE + * @url /api/resume/override + * @returns Promise + * @remark 退出登录时调用(与 `clearOnboardingProfile` 成对):否则下一位用户在本机登录后, + * 会看到上一位用户编辑过的简历板块 + */ +export async function clearResumeOverride(): Promise { + // TODO: await fetch(`${API_BASE_URL}/api/resume/override`, { method: 'DELETE' }); + await storageRemove(RESUME_OVERRIDE_KEY); + await storageRemove(RESUME_SAVED_AT_KEY); +} + +/* ===================== 简历:AI 能力(后端未接入) ===================== + * 三个函数的 mock 分支一律返回 available:false 且**不碰 storage**:正文没有被改写的 + * 情况下把 `aiOptimized` 置 true 会让页面显示「已优化」徽标 —— 那是假状态。 + * 后端就绪后只改函数体(由后端改写并落库,或由本层写覆盖层),页面代码一行都不用动; + * 参数名去掉下划线即表示该能力已接入。 + */ + +/** AI 优化单条工作经历。@url /api/resume/experience/:id/optimize */ +export async function optimizeResumeExperience(_id: string): Promise { + // TODO: 真实请求(后端就绪后放开;参数名去掉下划线即表示该能力已接入) + // const res = await fetch(`${API_BASE_URL}/api/resume/experience/${_id}/optimize`, { + // method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: _id }), + // }); + // return res.json(); + + return mockRequest({ available: false, message: RESUME_AI_UNAVAILABLE_TIP }); +} + +/** AI 润色自我评价。@url /api/resume/self-evaluation/polish */ +export async function polishResumeSelfEvaluation(_content: string): Promise { + // TODO: 真实请求(后端就绪后放开;参数名去掉下划线即表示该能力已接入) + // const res = await fetch(`${API_BASE_URL}/api/resume/self-evaluation/polish`, { + // method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: _content }), + // }); + // return res.json(); + + return mockRequest({ available: false, message: RESUME_AI_UNAVAILABLE_TIP }); +} + +/** AI 按岗位 JD 定向优化简历。@url /api/resume/optimize/jd */ +export async function optimizeResumeWithJD(_jd: string): Promise { + // TODO: 真实请求(后端就绪后放开;参数名去掉下划线即表示该能力已接入) + // const res = await fetch(`${API_BASE_URL}/api/resume/optimize/jd`, { + // method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jd: _jd }), + // }); + // return res.json(); + + return mockRequest({ available: false, message: RESUME_AI_UNAVAILABLE_TIP }); } /** @@ -2159,10 +3039,19 @@ export async function optimizeResume(): Promise { * @method GET * @url /api/resume/export-settings * @returns Promise 导出设置 + * @remark 以 storage(`youzhi:resume:export-settings`)为唯一真相源;老数据缺后加的字段时 + * 用默认值补齐(不能整份替换,否则新字段会变成 undefined),存储损坏回落默认值 */ export async function fetchResumeExportSettings(): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/export-settings`, { method: 'GET' }); return res.json(); - return mockRequest({ ...DEFAULT_EXPORT_SETTINGS }); + const raw = await storageGet(RESUME_EXPORT_SETTINGS_KEY); + if (!raw) return mockRequest({ ...DEFAULT_EXPORT_SETTINGS }); + try { + const stored = JSON.parse(raw) as Partial; + return mockRequest({ ...DEFAULT_EXPORT_SETTINGS, ...stored }); + } catch { + return mockRequest({ ...DEFAULT_EXPORT_SETTINGS }); + } } /** @@ -2170,10 +3059,12 @@ export async function fetchResumeExportSettings(): Promise * @method PUT * @url /api/resume/export-settings * @param settings 导出设置 + * @remark 原实现是 `Object.assign(DEFAULT_EXPORT_SETTINGS, settings)`:只改模块内常量, + * 杀掉进程就丢(等于「保存」按钮保存不了),还会污染其它调用方读到的默认值 */ export async function saveResumeExportSettings(settings: ResumeExportSettings): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/export-settings`, { method: 'PUT', body: JSON.stringify(settings) }); return res.json(); - Object.assign(DEFAULT_EXPORT_SETTINGS, settings); + await storageSet(RESUME_EXPORT_SETTINGS_KEY, JSON.stringify(settings)); return mockRequest(undefined); } @@ -2182,18 +3073,19 @@ export async function saveResumeExportSettings(settings: ResumeExportSettings): * @method POST * @url /api/resume/export * @param settings 导出设置 - * @returns Promise<{ url: string }> 导出文件地址 - */ -export async function exportResume(settings: ResumeExportSettings): Promise<{ url: string }> { + * @returns Promise 本机文件地址与文件名;失败给出原因 + * @remark 原实现返回写死的 `https://cdn.example.com/resume/xxx.pdf` —— 不产出任何文件, + * 页面拿到假链接又没有提示,等于「点了没事发生」。本机阶段改为真出 PDF: + * 取当前简历(含用户编辑)→ 生成 HTML → 打印成本机文件。 + * 非 PDF 格式仍按 PDF 导出,由页面在格式选择处与导出成功后明确提示(需后端导出服务)。 + */ +export async function exportResume( + settings: ResumeExportSettings, +): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/export`, { method: 'POST', body: JSON.stringify(settings) }); return res.json(); - const formatMap: Record = { - pdf: 'pdf', - word: 'docx', - image: 'png', - }; - return mockRequest({ - url: `https://cdn.example.com/resume/${settings.fileName}.${formatMap[settings.format]}`, - }); + const resume = await fetchResume(); + const fileName = resolveExportFileName(resume, settings.fileName); + return exportResumePdf(buildResumeExportHtml(resume, settings), fileName, settings); } /* ===================== 入职准备清单 ===================== */ @@ -2287,6 +3179,66 @@ const MOCK_ONBOARDING_CHECKLIST: OnboardingChecklist = { ], }; +/** 勾选状态存储键:模板条目按 id 存 done,自定义条目另存一份 */ +const CHECKLIST_DONE_KEY = 'onboarding:checklist-done'; +const CHECKLIST_CUSTOM_KEY = 'onboarding:checklist-custom'; + +/** 自定义待办的存储结构 */ +interface CustomChecklistItem { + id: string; + title: string; + done: boolean; +} + +async function readDoneMap(): Promise> { + const raw = await storageGet(CHECKLIST_DONE_KEY); + if (raw === null) return {}; + try { + return JSON.parse(raw) as Record; + } catch { + return {}; + } +} + +async function readCustomItems(): Promise { + const raw = await storageGet(CHECKLIST_CUSTOM_KEY); + if (raw === null) return []; + try { + const parsed = JSON.parse(raw) as CustomChecklistItem[]; + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * 组装清单:模板分组叠加本机勾选状态,再拼上用户自定义的那组。 + * 勾选状态必须落库 —— 否则同一台设备上退出再进来,勾过的又变回未勾选。 + */ +async function buildOnboardingChecklist(): Promise { + const [doneMap, customItems] = await Promise.all([readDoneMap(), readCustomItems()]); + + const groups: ChecklistGroup[] = MOCK_CHECKLIST_GROUPS.map((g) => ({ + ...g, + items: g.items.map((item) => ({ ...item, done: doneMap[item.id] ?? item.done })), + })); + + if (customItems.length > 0) { + groups.push({ + id: 'custom', + title: '我的自定义待办', + icon: 'create-outline', + expanded: true, + items: customItems.map((c) => ({ id: c.id, title: c.title, done: c.done })), + }); + } + + const completed = groups.reduce((sum, g) => sum + g.items.filter((i) => i.done).length, 0); + const total = groups.reduce((sum, g) => sum + g.items.length, 0); + + return { ...MOCK_ONBOARDING_CHECKLIST, groups, completed, total }; +} + /** * 获取入职准备清单 * @method GET @@ -2295,7 +3247,7 @@ const MOCK_ONBOARDING_CHECKLIST: OnboardingChecklist = { */ export async function fetchOnboardingChecklist(): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/tools/onboarding-checklist`, { method: 'GET' }); return res.json(); - return mockRequest(MOCK_ONBOARDING_CHECKLIST); + return mockRequest(await buildOnboardingChecklist()); } /** @@ -2311,16 +3263,91 @@ export async function toggleChecklistItem( done: boolean, ): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/tools/onboarding-checklist/items/${itemId}`, { method: 'PATCH', body: JSON.stringify({ done }) }); return res.json(); - const groups = MOCK_CHECKLIST_GROUPS.map((g) => ({ - ...g, - items: g.items.map((item) => (item.id === itemId ? { ...item, done } : item)), - })); - const completed = groups.reduce( - (sum, g) => sum + g.items.filter((i) => i.done).length, - 0, + const doneMap = await readDoneMap(); + await storageSet(CHECKLIST_DONE_KEY, JSON.stringify({ ...doneMap, [itemId]: done })); + + // 自定义条目另有一份带标题的存档,勾选同样要落到那份里 + const customItems = await readCustomItems(); + if (customItems.some((c) => c.id === itemId)) { + await storageSet( + CHECKLIST_CUSTOM_KEY, + JSON.stringify(customItems.map((c) => (c.id === itemId ? { ...c, done } : c))), + ); + } + + return mockRequest(await buildOnboardingChecklist()); +} + +/** + * 添加一条自定义待办 + * @method POST + * @url /api/tools/onboarding-checklist/items + * @param title { string } 待办标题(去空格后不能为空) + * @returns Promise 更新后的清单;标题为空时 reject + */ +export async function addChecklistItem(title: string): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/tools/onboarding-checklist/items`, { method: 'POST', body: JSON.stringify({ title }) }); return res.json(); + const trimmed = title.trim(); + if (trimmed === '') return Promise.reject(new Error('待办内容不能为空')); + + const customItems = await readCustomItems(); + await storageSet( + CHECKLIST_CUSTOM_KEY, + JSON.stringify([ + ...customItems, + { id: `custom-${Date.now()}`, title: trimmed, done: false }, + ]), ); - const total = groups.reduce((sum, g) => sum + g.items.length, 0); - return mockRequest({ ...MOCK_ONBOARDING_CHECKLIST, groups, completed, total }); + + return mockRequest(await buildOnboardingChecklist()); +} + +/** + * 删除一条自定义待办(模板条目不允许删,删了下次进页面又会出现) + * @method DELETE + * @url /api/tools/onboarding-checklist/items/:id + * @param itemId { string } 自定义待办 id + * @returns Promise 更新后的清单;非自定义条目 reject + */ +export async function removeChecklistItem(itemId: string): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/tools/onboarding-checklist/items/${itemId}`, { method: 'DELETE' }); return res.json(); + const customItems = await readCustomItems(); + if (!customItems.some((c) => c.id === itemId)) { + return Promise.reject(new Error('模板待办不能删除')); + } + + await storageSet( + CHECKLIST_CUSTOM_KEY, + JSON.stringify(customItems.filter((c) => c.id !== itemId)), + ); + const doneMap = await readDoneMap(); + if (itemId in doneMap) { + const { [itemId]: _removed, ...rest } = doneMap; + await storageSet(CHECKLIST_DONE_KEY, JSON.stringify(rest)); + } + + return mockRequest(await buildOnboardingChecklist()); +} + +/** + * 把清单拼成可导出的纯文本(导出 / 分享用,纯函数)。 + * @param checklist 入职准备清单 + * @returns string 形如「入职准备清单(公司 · 岗位)」+ 分组 + 勾选标记 + */ +export function buildChecklistText(checklist: OnboardingChecklist): string { + const lines: string[] = [ + `入职准备清单(${checklist.company} · ${checklist.position})`, + `入职日期 ${checklist.onboardingDate} · 已完成 ${checklist.completed}/${checklist.total}`, + '', + ]; + checklist.groups.forEach((group) => { + lines.push(`【${group.title}】`); + group.items.forEach((item) => { + lines.push(`${item.done ? '[x]' : '[ ]'} ${item.title}`); + }); + lines.push(''); + }); + return lines.join('\n').trim(); } /* ===================== AI 模拟面试 ===================== */ @@ -2720,10 +3747,41 @@ const MOCK_JOB_EVALUATION_MANAGEMENT: JobEvaluationManagement = { * @method GET * @url /api/enterprise/evaluations * @returns Promise 岗位评价管理数据 - */ + * @remark backend-stub-ok:回复内容读 AsyncStorage(见 withReviewReplies) + */ +/** 企业回复的存储结构:评价 id → 回复内容 */ +const JOB_REVIEW_REPLIES_KEY = 'enterprise:review-replies'; + +/** 读取已提交的评价回复 */ +async function readReviewReplies(): Promise> { + const raw = await storageGet(JOB_REVIEW_REPLIES_KEY); + if (!raw) return {}; + try { + return JSON.parse(raw) as Record; + } catch { + // 存储损坏时按「没回复过」处理,不阻断评价列表展示 + return {}; + } +} + +/** 把已提交的回复合并进评价列表(真实后端下这两个字段由接口直接返回) */ +async function withReviewReplies(reviews: JobReview[]): Promise { + const replies = await readReviewReplies(); + return reviews.map((review) => + replies[review.id] === undefined + ? review + : { ...review, replied: true, replyContent: replies[review.id] }, + ); +} export async function fetchJobEvaluationManagement(): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/evaluations`, { method: 'GET' }); return res.json(); - return mockRequest(MOCK_JOB_EVALUATION_MANAGEMENT); + const reviews = await withReviewReplies(MOCK_JOB_REVIEWS); + /* + * 只合并回复内容,**不重算回复率**:回复率的分母是「全部评价」(reviewCount = 128), + * 而这里能列出的只有 3 条样例,拿样例算出来的是 67%,比原值 72% 更假。 + * 真实后端下这两个数字都由服务端给出。 + */ + return mockRequest({ ...MOCK_JOB_EVALUATION_MANAGEMENT, reviews }); } /** @@ -2733,15 +3791,23 @@ export async function fetchJobEvaluationManagement(): Promise 回复后的评价 + * @remark mock 阶段回复内容落 AsyncStorage:不落库的话刷新一次回复就没了, + * 界面上「已回复」会自己变回「回复」,等于骗用户 */ export async function replyJobReview( reviewId: string, reply: string, ): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/evaluations/${reviewId}/reply`, { method: 'POST', body: JSON.stringify({ reply }) }); return res.json(); + const content = reply.trim(); + if (content === '') { + return Promise.reject(new Error('回复内容不能为空')); + } + const replies = await readReviewReplies(); + replies[reviewId] = content; + await storageSet(JOB_REVIEW_REPLIES_KEY, JSON.stringify(replies)); const review = MOCK_JOB_REVIEWS.find((r) => r.id === reviewId) ?? MOCK_JOB_REVIEWS[0]; - void reply; - return mockRequest({ ...review, replied: true }); + return mockRequest({ ...review, replied: true, replyContent: content }); } /* ===================== 候选人详情 ===================== */ @@ -2947,6 +4013,61 @@ export async function submitInterviewScore( return mockRequest({ success: true }); } +/** 面试评分草稿的存储键(按面试 id 分开,避免串到另一场面试上) */ +function interviewScoreDraftKey(interviewId: string): string { + return `interview:score-draft:${interviewId}`; +} + +/** + * 保存面试评分草稿 + * @method PUT + * @url /api/enterprise/interviews/:id/score/draft + * @param interviewId { string } 面试 id + * @param score { InterviewScoreData } 当前评分内容 + * @returns Promise 含保存时间 + */ +export async function saveInterviewScoreDraft( + interviewId: string, + score: InterviewScoreData, +): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/interviews/${interviewId}/score/draft`, { method: 'PUT', body: JSON.stringify(score) }); return res.json(); + const draft: InterviewScoreDraft = { score, savedAt: new Date().toISOString() }; + await storageSet(interviewScoreDraftKey(interviewId), JSON.stringify(draft)); + return mockRequest(draft); +} + +/** + * 读取面试评分草稿 + * @method GET + * @url /api/enterprise/interviews/:id/score/draft + * @param interviewId { string } 面试 id + * @returns Promise 没存过或存储不可用时为 null + */ +export async function fetchInterviewScoreDraft( + interviewId: string, +): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/interviews/${interviewId}/score/draft`, { method: 'GET' }); return res.json(); + const raw = await storageGet(interviewScoreDraftKey(interviewId)); + if (raw === null) return null; + try { + return JSON.parse(raw) as InterviewScoreDraft; + } catch { + // 存的内容坏了就当作没有草稿,不要把异常抛到页面上 + return null; + } +} + +/** + * 删除面试评分草稿(提交评分成功、或用户放弃草稿时调用) + * @method DELETE + * @url /api/enterprise/interviews/:id/score/draft + * @param interviewId { string } 面试 id + */ +export async function clearInterviewScoreDraft(interviewId: string): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/interviews/${interviewId}/score/draft`, { method: 'DELETE' }); return res.json(); + await storageRemove(interviewScoreDraftKey(interviewId)); +} + /* ===================== 运营规则配置 ===================== */ /** 匹配模式选项 */ @@ -2995,6 +4116,10 @@ const MOCK_OPERATION_RULES: OperationRules = { * @returns Promise 运营规则配置 */ export async function fetchOperationRules(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/operation/rules`, { method: 'GET' }); + // return res.json(); + return mockRequest({ ...MOCK_OPERATION_RULES }); } @@ -3006,6 +4131,12 @@ export async function fetchOperationRules(): Promise { * @returns Promise 保存后的配置 */ export async function saveOperationRules(rules: OperationRules): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/operation/rules`, { + // method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(rules), + // }); + // return res.json(); + return mockRequest({ ...rules }); } @@ -3049,6 +4180,10 @@ const MOCK_AUDIT_REPORT: AuditReport = { * @returns Promise 数据审计报告 */ export async function fetchAuditReport(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/operation/audit-report`, { method: 'GET' }); + // return res.json(); + return mockRequest({ ...MOCK_AUDIT_REPORT }); } @@ -3083,6 +4218,10 @@ const MOCK_ENTERPRISE_CERTIFICATION: EnterpriseCertificationData = { * @returns Promise 企业认证数据 */ export async function fetchEnterpriseCertification(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/enterprise/certification`, { method: 'GET' }); + // return res.json(); + return mockRequest({ ...MOCK_ENTERPRISE_CERTIFICATION }); } @@ -3096,6 +4235,12 @@ export async function fetchEnterpriseCertification(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/enterprise/certification`, { + // method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form), + // }); + // return res.json(); + void form; return mockRequest({ success: true, status: 'pending' }); } @@ -3135,6 +4280,10 @@ const MOCK_HOLLAND_INTRO: HollandIntro = { * @returns Promise 测评介绍 */ export async function fetchHollandIntro(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/growth/holland-intro`, { method: 'GET' }); + // return res.json(); + return mockRequest(MOCK_HOLLAND_INTRO); } @@ -3227,69 +4376,229 @@ const MOCK_JOB_MANAGEMENT: JobManagement = { * @returns Promise 岗位管理数据 */ export async function fetchJobManagement(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/enterprise/job-management`, { method: 'GET' }); + // return res.json(); + return mockRequest(MOCK_JOB_MANAGEMENT); } /* ===================== 人才推荐(企业端) ===================== */ +/** 「匹配度高」的分数线 */ +const HIGH_MATCH_PERCENT = 90; + +/** 985/211 院校名单(用于「985/211」筛选) */ +const KEY_SCHOOLS = new Set([ + '北京大学', + '清华大学', + '上海交通大学', + '浙江大学', + '南京大学', + '哈尔滨工业大学', + '北京邮电大学', +]); + +/** 「5年经验」→ 5;解析不出时返回 0(按不满足处理) */ +function parseExperienceYears(experience: string): number { + const matched = /(\d+)\s*年/.exec(experience); + return matched ? Number(matched[1]) : 0; +} + +/** 候选人是否命中某个 Tab(与 TalentRecommendationTab 的 key 一一对应) */ +function matchTalentTab(candidate: TalentCandidate, tabKey: string): boolean { + switch (tabKey) { + case 'high': + return candidate.matchPercent >= HIGH_MATCH_PERCENT; + case '985211': + return KEY_SCHOOLS.has(candidate.school); + case '5years': + return parseExperienceYears(candidate.experience) >= 5; + case 'master': + return candidate.education !== '本科'; + case 'all': + default: + return true; + } +} + +/** + * 按 Tab 与关键词过滤候选人(纯函数)。 + * @param candidates 全部候选人 + * @param tabKey Tab key('all' 表示不过滤) + * @param keyword 搜索关键词,匹配姓名 / 职位 / 技能标签 / 学校;空串不过滤 + * @returns TalentCandidate[] 命中的候选人 + */ +export function filterTalentCandidates( + candidates: TalentCandidate[], + tabKey: string, + keyword: string, +): TalentCandidate[] { + const kw = keyword.trim().toLowerCase(); + return candidates.filter((c) => { + if (!matchTalentTab(c, tabKey)) return false; + if (kw === '') return true; + return ( + c.name.toLowerCase().includes(kw) || + c.title.toLowerCase().includes(kw) || + c.school.toLowerCase().includes(kw) || + c.tags.some((tag) => tag.toLowerCase().includes(kw)) + ); + }); +} + +/** Tab 计数由候选人列表推导,避免页面上显示的数字和列表里的人数对不上 */ +function buildTalentTabs(candidates: TalentCandidate[]): TalentRecommendationTab[] { + const countOf = (tabKey: string) => + candidates.filter((c) => matchTalentTab(c, tabKey)).length; + return [ + { key: 'all', label: '全部', count: candidates.length }, + { key: 'high', label: '匹配度高', count: countOf('high') }, + { key: '985211', label: '985/211', count: countOf('985211') }, + { key: '5years', label: '5年以上', count: countOf('5years') }, + { key: 'master', label: '硕士及以上', count: countOf('master') }, + ]; +} + + +/** 推荐候选人列表(上面那份 mock 的原始数据,Tab 计数与 todayCount 都从它推导) */ +const MOCK_TALENT_CANDIDATES: TalentCandidate[] = [ + { + id: 'cand-1', + name: '张明远', + avatar: '张', + title: '高级前端工程师 · 5年经验', + experience: '5年经验', + age: 28, + school: '北京大学', + city: '北京', + expectedSalary: '期望薪资 30-35K', + matchPercent: 96, + overallScore: 88, + percentile: 99, + tags: ['React', 'TypeScript', 'Node.js'], + education: '硕士', + }, + { + id: 'cand-2', + name: '李雨桐', + avatar: '李', + title: '产品经理 · 3年经验', + experience: '3年经验', + age: 26, + school: '上海交通大学', + city: '北京', + expectedSalary: '期望薪资 25-30K', + matchPercent: 82, + overallScore: 75, + percentile: 75, + tags: ['大厂背景', '项目经验', '沟通能力强'], + education: '本科', + }, + { + id: 'cand-3', + name: '王建国', + avatar: '王', + title: 'Java后端开发 · 7年经验', + experience: '7年经验', + age: 33, + school: '浙江大学', + city: '北京', + expectedSalary: '期望薪资 35-45K', + matchPercent: 91, + overallScore: 92, + percentile: 92, + tags: ['技术扎实', '经验丰富', '高绩效'], + education: '本科', + }, + { + id: 'cand-4', + name: '陈思远', + avatar: '陈', + title: '资深前端工程师 · 6年经验', + experience: '6年经验', + age: 30, + school: '清华大学', + city: '北京', + expectedSalary: '期望薪资 40-50K', + matchPercent: 94, + overallScore: 90, + percentile: 96, + tags: ['架构经验', '跨端开发', '带团队'], + education: '硕士', + }, + { + id: 'cand-5', + name: '赵敏', + avatar: '赵', + title: '数据分析师 · 4年经验', + experience: '4年经验', + age: 27, + school: '南京大学', + city: '上海', + expectedSalary: '期望薪资 25-32K', + matchPercent: 88, + overallScore: 84, + percentile: 88, + tags: ['SQL', 'Python', '业务理解'], + education: '硕士', + }, + { + id: 'cand-6', + name: '孙浩', + avatar: '孙', + title: '测试开发工程师 · 5年经验', + experience: '5年经验', + age: 29, + school: '北京邮电大学', + city: '北京', + expectedSalary: '期望薪资 22-28K', + matchPercent: 79, + overallScore: 78, + percentile: 72, + tags: ['自动化测试', '性能测试'], + education: '本科', + }, + { + id: 'cand-7', + name: '周雨', + avatar: '周', + title: 'UI 设计师 · 8年经验', + experience: '8年经验', + age: 34, + school: '中国美术学院', + city: '杭州', + expectedSalary: '期望薪资 28-35K', + matchPercent: 76, + overallScore: 80, + percentile: 70, + tags: ['设计系统', 'B 端设计'], + education: '本科', + }, + { + id: 'cand-8', + name: '吴桐', + avatar: '吴', + title: '算法工程师 · 3年经验', + experience: '3年经验', + age: 26, + school: '哈尔滨工业大学', + city: '深圳', + expectedSalary: '期望薪资 35-45K', + matchPercent: 85, + overallScore: 86, + percentile: 90, + tags: ['推荐算法', '论文产出'], + education: '博士', + }, +]; + const MOCK_TALENT_RECOMMENDATION: TalentRecommendation = { companyName: '智联科技', hasData: true, - todayCount: 15, - tabs: [ - { key: 'all', label: '全部', count: 12 }, - { key: 'high', label: '匹配度高', count: 8 }, - { key: '985211', label: '985/211', count: 5 }, - { key: '5years', label: '5年以上', count: 5 }, - { key: 'master', label: '硕士及以上', count: 3 }, - ], - candidates: [ - { - id: 'cand-1', - name: '张明远', - avatar: '张', - title: '高级前端工程师 · 5年经验', - experience: '5年经验', - age: 28, - school: '北京大学', - city: '北京', - expectedSalary: '期望薪资 30-35K', - matchPercent: 96, - overallScore: 88, - percentile: 99, - tags: ['React', 'TypeScript', 'Node.js'], - }, - { - id: 'cand-2', - name: '李雨桐', - avatar: '李', - title: '产品经理 · 3年经验', - experience: '3年经验', - age: 26, - school: '上海交通大学', - city: '北京', - expectedSalary: '期望薪资 25-30K', - matchPercent: 82, - overallScore: 75, - percentile: 75, - tags: ['大厂背景', '项目经验', '沟通能力强'], - }, - { - id: 'cand-3', - name: '王建国', - avatar: '王', - title: 'Java后端开发 · 7年经验', - experience: '7年经验', - age: 33, - school: '浙江大学', - city: '北京', - expectedSalary: '期望薪资 35-45K', - matchPercent: 91, - overallScore: 92, - percentile: 92, - tags: ['技术扎实', '经验丰富', '高绩效'], - }, - ], + todayCount: MOCK_TALENT_CANDIDATES.length, + tabs: buildTalentTabs(MOCK_TALENT_CANDIDATES), + candidates: MOCK_TALENT_CANDIDATES, poolStats: [ { id: 'active', value: '12,580', label: '活跃候选人' }, { id: 'new', value: '326', label: '今日新增' }, @@ -3315,60 +4624,171 @@ const MOCK_TALENT_RECOMMENDATION: TalentRecommendation = { * @returns Promise 人才推荐数据 */ export async function fetchTalentRecommendation(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/enterprise/talent-recommendation`, { method: 'GET' }); + // return res.json(); + return mockRequest(MOCK_TALENT_RECOMMENDATION); } /* ===================== 企业沟通 ===================== */ -const MOCK_ENTERPRISE_COMMUNICATION: EnterpriseCommunication = { - companyName: '智联科技', - hasData: true, - pendingTasks: [ - { id: 'reply', label: '待回复', count: 3, hint: '24小时内未回复', icon: 'chatbubble-ellipses-outline', color: 'action' }, - { id: 'score', label: '待评分', count: 2, hint: '面试结束待及时评分', icon: 'star-outline', color: 'compareOrange' }, - ], - tabs: [ - { key: 'all', label: '全部', count: 12 }, - { key: 'pending', label: '未回复', count: 3 }, - { key: 'interview', label: '面试邀请', count: 2 }, - { key: 'offer', label: 'Offer沟通', count: 1 }, - { key: 'closed', label: '已归档', count: 4 }, - ], - chats: [ - { - id: 'chat-1', - userId: 'u-1', - name: '张明远', - avatar: '张', - title: '高级前端工程师 · 最近活跃', - lastMessage: '你的简历很符合我们的要求,方便下周面试吗?', - time: '10分钟前', - unreadCount: 1, - labels: [], - }, +/** 企业沟通会话列表(status 决定落在哪个 Tab 下) */ +const MOCK_ENTERPRISE_CHATS: EnterpriseChatItem[] = [ + { + id: 'chat-1', + userId: 'u-1', + name: '张明远', + avatar: '张', + title: '高级前端工程师 · 最近活跃', + lastMessage: '你的简历很符合我们的要求,方便下周面试吗?', + time: '10分钟前', + unreadCount: 1, + labels: [], + status: 'pending', + }, + { + id: 'chat-2', + userId: 'u-2', + name: '李雨桐', + avatar: '李', + title: '产品经理', + lastMessage: '你好,我看贵司的产品经理岗位很匹配,想进一步沟通。', + time: '30分钟前', + unreadCount: 1, + labels: [], + status: 'pending', + }, + { + id: 'chat-3', + userId: 'u-3', + name: '王建国', + avatar: '王', + title: 'Java后端开发', + lastMessage: '已收到面试邀请,确认参加。', + time: '2小时前', + unreadCount: 0, + labels: ['面试待评分'], + status: 'interview', + }, + { + id: 'chat-4', + userId: 'u-4', + name: '陈思远', + avatar: '陈', + title: '资深前端工程师', + lastMessage: '面试时间我这边可以,麻烦发个会议链接。', + time: '3小时前', + unreadCount: 1, + labels: ['面试待评分'], + status: 'interview', + }, + { + id: 'chat-5', + userId: 'u-5', + name: '赵敏', + avatar: '赵', + title: '数据分析师', + lastMessage: '薪资和期权方案我这边基本认可,什么时候可以发正式 offer?', + time: '昨天', + unreadCount: 0, + labels: ['Offer沟通'], + status: 'offer', + }, + { + id: 'chat-6', + userId: 'u-6', + name: '孙浩', + avatar: '孙', + title: '测试开发工程师', + lastMessage: '考虑后决定接受另一家的机会,感谢贵司的认可。', + time: '3天前', + unreadCount: 0, + labels: ['已归档'], + status: 'closed', + }, + { + id: 'chat-7', + userId: 'u-7', + name: '周雨', + avatar: '周', + title: 'UI 设计师', + lastMessage: '作品集已更新,麻烦再看看新的三个案例。', + time: '4天前', + unreadCount: 0, + labels: ['已归档'], + status: 'closed', + }, + { + id: 'chat-8', + userId: 'u-8', + name: '吴桐', + avatar: '吴', + title: '运维工程师', + lastMessage: '这边流程走完了,简历先留档,后续有合适岗位再联系。', + time: '5天前', + unreadCount: 0, + labels: ['已归档'], + status: 'closed', + }, + { + id: 'chat-9', + userId: 'u-9', + name: '郑楠', + avatar: '郑', + title: '算法工程师', + lastMessage: '面试未通过,简历已进人才库。', + time: '上周', + unreadCount: 0, + labels: ['已归档'], + status: 'closed', + }, +]; + +/** 三个待办任务的计数由会话列表推导(业务规则放接口层,避免页面上写死徽标数字) */ +function buildPendingTasks(chats: EnterpriseChatItem[]): PendingTask[] { + return [ { - id: 'chat-2', - userId: 'u-2', - name: '李雨桐', - avatar: '李', - title: '产品经理', - lastMessage: '你好,我看贵司的产品经理岗位很匹配,想进一步沟通。', - time: '30分钟前', - unreadCount: 0, - labels: [], + id: 'reply', + label: '待回复', + count: chats.filter((c) => c.unreadCount > 0).length, + hint: '24小时内未回复', + icon: 'chatbubble-ellipses-outline', + color: 'action', + // 点「待回复」切到「未回复」Tab,别让带箭头的卡片点下去什么也不发生 + tabKey: 'pending', }, { - id: 'chat-3', - userId: 'u-3', - name: '王建国', - avatar: '王', - title: 'Java后端开发', - lastMessage: '已收到面试邀请,确认参加。', - time: '2小时前', - unreadCount: 0, - labels: ['面试待评分'], + id: 'score', + label: '待评分', + count: chats.filter((c) => c.labels.includes('面试待评分')).length, + hint: '面试结束待及时评分', + icon: 'star-outline', + color: 'compareOrange', + route: '/interview-score', }, - ], + ]; +} + +/** Tab 计数同样由会话列表推导 */ +function buildCommunicationTabs(chats: EnterpriseChatItem[]): EnterpriseCommunicationTab[] { + const countOf = (status: EnterpriseChatStatus) => + chats.filter((c) => c.status === status).length; + return [ + { key: 'all', label: '全部', count: chats.length }, + { key: 'pending', label: '未回复', count: countOf('pending') }, + { key: 'interview', label: '面试邀请', count: countOf('interview') }, + { key: 'offer', label: 'Offer沟通', count: countOf('offer') }, + { key: 'closed', label: '已归档', count: countOf('closed') }, + ]; +} + +const MOCK_ENTERPRISE_COMMUNICATION: EnterpriseCommunication = { + companyName: '智联科技', + hasData: true, + pendingTasks: buildPendingTasks(MOCK_ENTERPRISE_CHATS), + tabs: buildCommunicationTabs(MOCK_ENTERPRISE_CHATS), + chats: MOCK_ENTERPRISE_CHATS, dataReference: [ { label: '主动联系简历数比被动接收高', value: '60%' }, { label: '24小时内回复的面试转化率提升', value: '2.3倍' }, @@ -3383,9 +4803,37 @@ const MOCK_ENTERPRISE_COMMUNICATION: EnterpriseCommunication = { * @returns Promise 沟通数据 */ export async function fetchEnterpriseCommunication(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/enterprise/communication`, { method: 'GET' }); + // return res.json(); + return mockRequest(MOCK_ENTERPRISE_COMMUNICATION); } +/** + * 按 Tab 与关键词过滤沟通会话(纯函数,页面只渲染结果)。 + * @param chats 全部会话 + * @param tabKey Tab key('all' 表示不过滤) + * @param keyword 搜索关键词,匹配姓名 / 职位 / 最后一条消息;空串不过滤 + * @returns EnterpriseChatItem[] 命中的会话 + */ +export function filterEnterpriseChats( + chats: EnterpriseChatItem[], + tabKey: string, + keyword: string, +): EnterpriseChatItem[] { + const kw = keyword.trim().toLowerCase(); + return chats.filter((chat) => { + if (tabKey !== 'all' && chat.status !== tabKey) return false; + if (kw === '') return true; + return ( + chat.name.toLowerCase().includes(kw) || + chat.title.toLowerCase().includes(kw) || + chat.lastMessage.toLowerCase().includes(kw) + ); + }); +} + /* ===================== 岗位评价管理空状态 ===================== */ const MOCK_JOB_EVALUATION_EMPTY: JobEvaluationEmptyState = { @@ -3414,6 +4862,10 @@ const MOCK_JOB_EVALUATION_EMPTY: JobEvaluationEmptyState = { * @returns Promise 空状态数据 */ export async function fetchJobEvaluationEmptyState(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/enterprise/job-evaluation-empty`, { method: 'GET' }); + // return res.json(); + return mockRequest(MOCK_JOB_EVALUATION_EMPTY); } @@ -3905,6 +5357,10 @@ export async function submitGameLevel( * @returns Promise 重置后的进度 */ export async function resetGameProgress(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/game/reset`, { method: 'POST' }); + // return res.json(); + MOCK_GAME_PROGRESS = { ...DEFAULT_GAME_PROGRESS }; return mockRequest(MOCK_GAME_PROGRESS); } @@ -4261,7 +5717,11 @@ export async function createLegalOrder( * @remark 真实接入时,应由后端支付回调确认支付结果后再激活会员态,前端不自行判定成功 */ export async function payLegalOrder(orderId: string): Promise { - // TODO: 调用真实支付 SDK → 后端回调确认 → 返回订单 + // TODO: 支付不能只发一个请求 —— 需走支付 SDK,且以后端回调确认为准(本机返回的 paid 不可信)。 + // 后端就绪后放开下面两行,并把本机的「直接置 paid」换成「等回调后再读订单」: + // const res = await fetch(`${API_BASE_URL}/api/legal/orders/${orderId}/pay`, { method: 'POST' }); + // return res.json(); + const orders = await readLegalOrders(); const idx = orders.findIndex((o) => o.id === orderId); if (idx === -1) { @@ -4602,6 +6062,10 @@ const MOCK_PRIVACY_CENTER: PrivacyCenter = { * @returns Promise 隐私中心数据 */ export async function fetchPrivacyCenter(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/settings/privacy-center`, { method: 'GET' }); + // return res.json(); + return mockRequest(MOCK_PRIVACY_CENTER); } @@ -4615,6 +6079,12 @@ export async function fetchPrivacyCenter(): Promise { export async function updatePrivacyCenterSetting( payload: UpdateTogglePayload, ): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/settings/privacy-center/${payload.id}`, { + // method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), + // }); + // return res.json(); + for (const section of MOCK_PRIVACY_CENTER.sections) { const target = section.items.find((item) => item.id === payload.id); if (target && target.type === 'switch') { @@ -4767,6 +6237,10 @@ const MOCK_DND_SETTING: DndSetting = { * @returns Promise */ export async function fetchDndSetting(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/settings/privacy/dnd`, { method: 'GET' }); + // return res.json(); + return mockRequest({ ...MOCK_DND_SETTING }); } @@ -4778,6 +6252,12 @@ export async function fetchDndSetting(): Promise { * @returns Promise */ export async function updateDndSetting(payload: Partial): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/settings/privacy/dnd`, { + // method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), + // }); + // return res.json(); + Object.assign(MOCK_DND_SETTING, payload); return mockRequest({ ...MOCK_DND_SETTING }); } @@ -4797,6 +6277,10 @@ const MOCK_KEYWORD_BLOCK: KeywordBlockSetting = { * @returns Promise */ export async function fetchKeywordBlock(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/settings/privacy/keywords`, { method: 'GET' }); + // return res.json(); + return mockRequest({ ...MOCK_KEYWORD_BLOCK, keywords: [...MOCK_KEYWORD_BLOCK.keywords] }); } @@ -4808,6 +6292,12 @@ export async function fetchKeywordBlock(): Promise { * @returns Promise */ export async function addBlockKeyword(keyword: string): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/settings/privacy/keywords`, { + // method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keyword }), + // }); + // return res.json(); + const value = keyword.trim(); if (value && !MOCK_KEYWORD_BLOCK.keywords.includes(value)) { MOCK_KEYWORD_BLOCK.keywords.push(value); @@ -4823,6 +6313,10 @@ export async function addBlockKeyword(keyword: string): Promise */ export async function removeBlockKeyword(keyword: string): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/settings/privacy/keywords/${encodeURIComponent(keyword)}`, { method: 'DELETE' }); + // return res.json(); + MOCK_KEYWORD_BLOCK.keywords = MOCK_KEYWORD_BLOCK.keywords.filter((item) => item !== keyword); return mockRequest({ ...MOCK_KEYWORD_BLOCK, keywords: [...MOCK_KEYWORD_BLOCK.keywords] }); } @@ -4854,6 +6348,10 @@ const MOCK_ACCOUNT_CANCEL: AccountCancelData = { * @returns Promise */ export async function fetchAccountCancel(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/settings/account-cancel`, { method: 'GET' }); + // return res.json(); + return mockRequest({ ...MOCK_ACCOUNT_CANCEL, canCancel: MOCK_ACCOUNT_CANCEL.checklist.every((item) => item.resolved), @@ -4902,6 +6400,10 @@ const MOCK_DELETE_ALL_DATA: DeleteAllData = { * @returns Promise */ export async function fetchDeleteAllData(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/settings/delete-data`, { method: 'GET' }); + // return res.json(); + return mockRequest({ ...MOCK_DELETE_ALL_DATA }); } @@ -4929,7 +6431,7 @@ export async function submitDeleteData( /** * 隐私政策正文 - * 【待确认】文中公司全称、联系邮箱、客服电话、数据存储地等需法务确认后替换占位内容 + * 【待确认】文中公司全称、客服邮箱、数据存储地等需法务确认后替换占位内容(客服电话已于 2026-09-15 填入 15154608969) */ const MOCK_PRIVACY_POLICY: LegalDocument = { id: 'privacy-policy', @@ -5005,7 +6507,7 @@ const MOCK_PRIVACY_POLICY: LegalDocument = { { heading: '九、联系我们', paragraphs: [ - '如您对本政策或您的个人信息处理有任何疑问、意见或建议,可通过以下方式与我们联系:【待确认:客服邮箱 / 客服电话 / 个人信息保护负责人邮箱】。', + '如您对本政策或您的个人信息处理有任何疑问、意见或建议,可通过以下方式与我们联系:客服电话 15154608969;客服邮箱与个人信息保护负责人邮箱:【待确认:客服邮箱 / 个人信息保护负责人邮箱】。', '公司名称:【待确认:公司全称】。注册地址:【待确认:注册地址】。我们将在收到您的请求后 15 个工作日内答复。', ], }, @@ -5230,6 +6732,10 @@ const MOCK_PERSONAL_DATA_EXPORT: PersonalDataExport = { * @returns Promise 个人数据导出数据 */ export async function fetchPersonalDataExport(): Promise { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/settings/personal-data-export`, { method: 'GET' }); + // return res.json(); + return mockRequest(MOCK_PERSONAL_DATA_EXPORT); } @@ -5240,6 +6746,10 @@ export async function fetchPersonalDataExport(): Promise { * @returns Promise<{ success: boolean; message: string }> 申请结果 */ export async function requestPersonalDataExport(): Promise<{ success: boolean; message: string }> { + // TODO: 真实请求(后端就绪后替换) + // const res = await fetch(`${API_BASE_URL}/api/settings/personal-data-export`, { method: 'POST' }); + // return res.json(); + return mockRequest({ success: true, message: '导出申请已提交,预计 1-2 分钟后可在最近导出中下载。' }); } @@ -5534,17 +7044,66 @@ export function loginByPhone(phone: string, code: string): Promise } /** - * 运营商一键登录(本机号码认证)。 + * 手机号脱敏(业务规则在本层,页面不自己切字符串)。 + * @param phone 手机号 + * @returns 11 位时返回 150****9367;非 11 位原样返回(宁可露出也不截断成错的) + */ +export function maskPhone(phone: string): string { + return phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone; +} + +/** + * 运营商 SDK 在 mock 阶段取回的本机号码。 + * 真实接入后由端上 SDK 取号,这个常量连同 mock 分支一并删掉。 + * + * 注意它是**接口层的模拟数据**(与 mockJobData 同性质),不是页面里写死的展示值: + * 页面只能拿 `fetchCarrierPhone()` 的返回,取不到号就必须走失败分支。 + */ +const MOCK_CARRIER_PHONE = '15000009367'; +const MOCK_CARRIER_NAME: CarrierName = 'cmcc'; + +/** + * 取本机号码(运营商一键登录第 1 步:预取号)。 + * + * 真实实现在**端上**完成,不经过我们的服务器:需接入运营商号码认证 SDK + * (中国移动/联通/电信,或阿里云号码认证服务等聚合方案)。它是原生模块, + * Expo Go 跑不了,必须 dev build / 出包 —— 详见 运营商一键登录接入方案.md。 + * + * @method POST + * @url /api/auth/carrier/number + * @returns Promise 本机号码 + 掩码 + 一次性鉴权令牌;取号失败时号码与令牌为空串 + */ +export function fetchCarrierPhone(): Promise { + // TODO: 端上取号(原生 SDK),去掉下面的 mock 返回: + // const r = await NumberAuthSDK.getMobileNumber(); // 5 分钟内有效,用后即废 + // if (!r?.token) return { phone: '', maskedPhone: '', carrierToken: '', carrier: 'unknown', expiresAt: 0 }; + // return { phone: r.phone, maskedPhone: maskPhone(r.phone), carrierToken: r.token, carrier: carrierOf(r.operator), expiresAt: Date.now() + CARRIER_TOKEN_TTL_MS }; + return mockRequest({ + phone: MOCK_CARRIER_PHONE, + maskedPhone: maskPhone(MOCK_CARRIER_PHONE), + carrierToken: `mock_carrier_token_${Date.now()}`, + carrier: MOCK_CARRIER_NAME, + expiresAt: Date.now() + CARRIER_TOKEN_TTL_MS, + }); +} + +/** + * 运营商一键登录(本机号码认证第 2 步:用取号令牌换登录态)。 * - * 真实场景由运营商 SDK 返回本机号码与鉴权 token,前端直接换取登录态, - * 无需用户输入。此处 mock 返回与 P2Q 展示掩码(150****9367)一致的号码。 + * 取号拿到的号码只在端上做展示,**可信号码以后端向运营商换取的结果为准**, + * 所以返回的 phone 由接口层给出,页面拿它写回资料,不自己拼预取到的号码。 * * @method POST * @url /api/auth/login-by-carrier - * @returns 登录结果(含 token) + * @param carrierToken 取号接口下发的一次性鉴权令牌(空串表示没取到号,直接判失败) + * @returns Promise 登录结果(含 token 与已核验的手机号) */ -export function loginByCarrier(): Promise { - return mockRequest({ token: 'mock_token_carrier', phone: '15000009367' }); +export function loginByCarrier(carrierToken: string): Promise { + // TODO: const res = await fetch(`${API_BASE_URL}/api/auth/login-by-carrier`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ carrierToken }) }); return res.json(); + if (!carrierToken) { + return Promise.reject(new Error('本机号码未取到,请重新获取或改用验证码登录')); + } + return mockRequest({ token: `mock_token_carrier_${carrierToken}`, phone: MOCK_CARRIER_PHONE }); } /* ===================== 账号:密码规则(登录/重置/改密三处共用) ===================== */ @@ -5786,7 +7345,41 @@ export function bindWechatAndRegister( * @url /api/user/job-preference * @param preference 求职意向(期望职位/城市/薪资/工作类型/求职状态) * @returns 保存后的求职意向 + * @remark 存入独立的 `user:job-preference` 键:这是用户显式编辑的偏好, + * 与引导采集的资料(`onboarding:profile`)分开存放,避免互相覆盖 */ -export function saveJobPreference(preference: JobPreference): Promise { +export async function saveJobPreference(preference: JobPreference): Promise { + // TODO: await fetch(`${API_BASE_URL}/api/user/job-preference`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(preference) }); + await storageSet(JOB_PREFERENCE_KEY, JSON.stringify(preference)); return mockRequest(preference); } + +/* ===================== 本地缓存(设置页「清除缓存」) ===================== */ + +/** + * 读取应用缓存占用(字节)。 + * + * 没有对应的后端接口:读的是**设备上的缓存目录**,因此不走 fetch。 + * 读不到(目录不存在 / jest 环境无原生模块)返回 0,页面据此显示 0B。 + * + * @returns Promise 缓存占用字节数 + * @remark backend-stub-ok:本地文件系统读取,非网络资源 + */ +export async function fetchCacheSize(): Promise { + // backend-stub-ok + return readCacheBytes(); +} + +/** + * 清空应用缓存。 + * + * 没有对应的后端接口:清的是设备上的缓存目录内容(保留目录本身)。 + * 用户已登录状态、已填资料、简历等都在 AsyncStorage / 文档目录,不受影响。 + * + * @returns Promise 本次清理掉的字节数(用于「已清除 xx」提示) + * @remark backend-stub-ok:本地文件系统写入,非网络资源 + */ +export async function clearAppCache(): Promise { + // backend-stub-ok + return clearCacheDir(); +} diff --git a/src/api/index.ts b/src/api/index.ts index 485af63..1a5344c 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -26,6 +26,7 @@ export { sendChatMessage, fetchEnterpriseCertification, fetchEnterpriseCommunication, + filterEnterpriseChats, fetchEnterpriseOverview, fetchGameLevels, fetchGameProgress, @@ -36,6 +37,7 @@ export { fetchJobManagement, fetchJobPublishForm, fetchTalentRecommendation, + filterTalentCandidates, fetchLegalBrand, fetchLegalConversation, fetchLegalFAQs, @@ -55,6 +57,7 @@ export { fetchFavorites, fetchHistory, fetchOfferCompareData, + fetchPendingOfferCount, fetchJobOverallScore, fetchJobPreference, saveJobPreference, @@ -64,6 +67,16 @@ export { fetchNotifications, fetchNotificationSettings, fetchOnboardingChecklist, + addChecklistItem, + removeChecklistItem, + buildChecklistText, + fetchOnboardingProfile, + saveOnboardingProfile, + clearOnboardingProfile, + toOnboardingProfile, + fromOnboardingProfile, + salaryMaxOptions, + applySalaryMin, fetchOperationRules, fetchOverallScore, fetchPrivacySettings, @@ -90,6 +103,10 @@ export { loginByPhone, loginByPassword, loginByCarrier, + fetchCarrierPhone, + maskPhone, + fetchCacheSize, + clearAppCache, sendSmsCode, resetPassword, changePassword, @@ -104,26 +121,44 @@ export { fetchRankData, fetchResume, fetchResumeExportSettings, + fetchResumeSavedAt, + clearResumeOverride, fetchSalaryBrackets, fetchSalaryData, fetchSalaryRecords, + fetchSalaryToolLinks, saveSalaryRecord, deleteSalaryRecord, fetchSkillCloud, + fetchSkillLearningPath, + filterSkillWords, fetchUserProfile, fetchUserStats, fetchWeightSettings, inviteCandidate, estimateJobQuality, optimizeResume, + optimizeResumeExperience, + optimizeResumeWithJD, + polishResumeSelfEvaluation, publishJob, replyJobReview, saveOperationRules, + saveResumeBasicInfo, + saveResumeEducation, + saveResumeExperience, saveResumeExportSettings, + saveResumeProjects, + saveResumeSelfEvaluation, + saveResumeSkills, + saveResumeSnapshot, exportResume, saveWeightSettings, submitEnterpriseCertification, submitInterviewScore, + saveInterviewScoreDraft, + fetchInterviewScoreDraft, + clearInterviewScoreDraft, computeSalaryResult, resetGameProgress, sendLegalMessage, diff --git a/src/constants/index.ts b/src/constants/index.ts index de016cc..5b05e18 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,7 +1,13 @@ /** * 常量统一导出(枚举、配置项等) */ -import type { CitySocialProfile, JobLevel, PasswordRuleError } from '../types'; +import type { + CarrierName, + CitySocialProfile, + JobLevel, + PasswordRuleError, + ResumeTemplate, +} from '../types'; /** 请求失败自动重试次数 */ export const MAX_RETRY_COUNT = 3; @@ -62,9 +68,38 @@ export const DEFAULT_SOCIAL_PROFILE: CitySocialProfile = { /** 一天的毫秒数,用于令牌有效期等时间计算 */ export const ONE_DAY_MS = 24 * 60 * 60 * 1000; +/** 拍照 / 相册未授权时的提示:权限是用户的选择,需要告诉他去哪里改,而不是静默失败 */ +export const IMAGE_PICK_DENIED_HINT = '未获得权限,请在系统设置中开启相机 / 相册权限后重试'; + +/** + * 当前年份。滚轮可选年份等以「今年」为起点生成, + * 写死字面量会在跨年时悄悄过期,故取系统时间。 + */ +export const CURRENT_YEAR = new Date().getFullYear(); + /** 登录令牌有效期(毫秒),与后端 JWT 过期时间保持一致 */ export const TOKEN_TTL_MS = 30 * ONE_DAY_MS; +/* ===================== 运营商一键登录(本机号码认证) ===================== */ + +/** + * 运营商取号令牌有效期(毫秒)。 + * 运营商下发的鉴权令牌是一次性的、短时效(业界普遍 5 分钟), + * 超时后必须重新取号 —— 页面在超时时把「一键登录」置灰并给出重新取号入口。 + */ +export const CARRIER_TOKEN_TTL_MS = 5 * 60 * 1000; + +/** + * 认证服务提供方 → 展示名。 + * 取号接口返回哪家就用哪家,页面不写死「中国移动」。 + */ +export const CARRIER_LABEL: Record = { + cmcc: '中国移动', + cucc: '中国联通', + ctcc: '中国电信', + unknown: '运营商', +}; + /* ===================== 登录密码规则 ===================== */ /** 登录密码最小长度(含) */ @@ -85,3 +120,161 @@ export const PASSWORD_RULE_LABEL: Record = { mismatch: '两次输入的密码不一致', 'same-as-old': '新密码不能与原密码相同', }; + +/* ===================== 岗位推荐匹配(引导采集的偏好 → 岗位排序加分) ===================== */ + +/** 「全部城市」哨兵值:作为筛选条件时等同「未筛选」,岗位推荐才叠加偏好加分 */ +export const ALL_CITY_LABEL = '全部城市'; + +/** 岗位所在地命中期望城市的加分 */ +export const JOB_MATCH_CITY_BONUS = 15; + +/** 岗位职位命中期望职位的加分 */ +export const JOB_MATCH_POSITION_BONUS = 20; + +/** 岗位薪资区间与期望薪资区间有重叠的加分 */ +export const JOB_MATCH_SALARY_BONUS = 10; + +/** + * 职位名归一化时剔除的噪音词(级别与通用后缀)。 + * 目的是让「前端开发工程师」能与「高级前端工程师」匹配上 —— 去掉后都剩「前端」。 + * 这是 mock 阶段的粗粒度匹配策略,真实后端就绪后由后端返回匹配度取代。 + */ +export const POSITION_NOISE_WORDS = ['高级', '资深', '初级', '中级', '实习', '工程师', '开发', '经理']; + +/* ===================== 求职偏好展示 ===================== */ + +/** + * 字段未采集时的占位文案(求职偏好、简历、我的页头部共用同一个)。 + * 引导采集不到就不做假数据填充,也不拿 mock 人设顶上。 + */ +export const UNSET_LABEL = '未设置'; + +/** 工作类型默认值:引导流程未采集该项,映射时补默认「全职」 */ +export const PREFERENCE_DEFAULT_WORK_TYPE = '全职'; + +/* ===================== 期望月薪(P7)可选阶梯 ===================== */ + +/** + * 期望月薪「最低」可选阶梯(K)。 + * 原阶梯只有 9K/10K/11K 三档,窄到不合常理(等于替用户回答了「你要 10K 左右」), + * 这里按真实招聘市场的月薪分布铺开。 + */ +export const SALARY_MIN_OPTIONS: string[] = [ + '5K', '8K', '10K', '12K', '15K', '20K', '25K', '30K', '40K', +]; + +/** 期望月薪「最高」可选阶梯(K);实际展示时按已选最低值过滤(见 api 层 salaryMaxOptions) */ +export const SALARY_MAX_OPTIONS: string[] = [ + '8K', '10K', '12K', '15K', '20K', '25K', '30K', '40K', '60K', +]; + +/* ===================== 引导流程步骤(进度文案的唯一依据) ===================== */ + +/** + * 求职者主线的步骤顺序(登录之后开始,到落地前一步结束)。 + * + * 只含「一路下一步」的线性页面:P5(城市)是从 P4 进入后 `back()` 返回的子页, + * P17(案例参考)是从 P16 进入后返回的子页,二者不计入步骤数。 + * 企业端 P3 → B3 只有一步,不适用本序列。 + */ +export const ONBOARDING_STEP_ORDER: string[] = [ + 'P3', // 选择身份 + 'P4', // 求职意向 + 'P6', // 期望职位 + 'P7', // 期望月薪 + 'P8', // 姓名/性别/出生年月 + 'P9', // 求职状态 + 'P10', // 首次工作时间 + 'P11', // 当前职位 + 'P12', // 当前公司 + 'P13', // 入职时间 + 'P14', // 技能 + 'P16', // 工作内容 + 'P18', // 学历 + 'P20', // 毕业学校 + 'P21', // 头像 + 'P22', // 完成 +]; + +/* ===================== 求职状态(P9,简历「求职意向」映射的唯一依据) ===================== */ + +/** 求职状态选项:P9 页与简历映射共用同一份,避免两处文案分叉 */ +export const JOB_STATUS_OPTIONS: string[] = [ + '离职-随时到岗', + '在职-月内到岗', + '在职-考虑机会', + '在职-暂不考虑', +]; + +/** 唯一「暂不考虑」状态:映射简历求职状态时视为不积极(其余选项均视为积极求职中) */ +export const JOB_STATUS_INACTIVE = '在职-暂不考虑'; + +/** 在职状态前缀:简历工作经历据此判断是否仍在职(在职才写「至今」,离职不编结束时间) */ +export const JOB_STATUS_EMPLOYED_PREFIX = '在职'; + +/* ===================== 合规页脚 ===================== */ + +/** 客服热线(2026-09-15 由产品提供) */ +export const SUPPORT_HOTLINE = '15154608969'; + +/** 举报监督电话(同客服热线) */ +export const SUPERVISION_PHONE = '15154608969'; + +/* ===================== 短信验证码求助 ===================== */ + +/** 「接收不到短信」提示标题(P2 与 P2W 共用,避免两页文案分叉) */ +export const SMS_HELP_TITLE = '接收不到短信?'; + +/** 「接收不到短信」提示正文:给出可执行的下一步,而不是一句无反馈的纯文本 */ +export const SMS_HELP_BODY = [ + '1. 确认手机号填写正确、信号正常;', + '2. 检查是否被短信拦截(垃圾短信 / 拦截记录);', + '3. 等待倒计时结束后点「重新发送」。', +].join('\n'); + +/* ===================== 简历编辑与导出(后端未接阶段的落地口径) ===================== */ + +/** 简历模板(导出用):页面与选择弹层读同一份;后端就绪后由模板库接口下发 */ +export const RESUME_TEMPLATES: ResumeTemplate[] = [ + { id: 'classic', name: '简约经典', color: 'primary' }, + { id: 'creative', name: '创意设计', color: 'info' }, + { id: 'tech', name: '技术专业', color: 'success' }, +]; + +/** 自我评价最大字数:输入框的 maxLength 与字数提示读同一个值,避免两处不一致 */ +export const RESUME_EVAL_MAX_LENGTH = 300; + +/** 导出纸型(`ResumeExportSettings.paperSize` 的可选值 + 展示文案) */ +export const RESUME_PAPER_SIZES: ReadonlyArray<{ value: 'A4' | 'letter'; label: string }> = [ + { value: 'A4', label: 'A4' }, + { value: 'letter', label: 'Letter' }, +]; + +/** 导出方向(`ResumeExportSettings.orientation` 的可选值 + 展示文案) */ +export const RESUME_ORIENTATIONS: ReadonlyArray<{ + value: 'portrait' | 'landscape'; + label: string; +}> = [ + { value: 'portrait', label: '纵向' }, + { value: 'landscape', label: '横向' }, +]; + +/** 文件名未填时的导出文件名兜底规则说明(导出页的输入框提示与提示语读同一份) */ +export const RESUME_FILE_NAME_HINT = '不填则用「姓名_期望职位简历」'; + +/** + * AI 简历能力(逐条优化 / 自我评价润色 / 按 JD 定向优化)未接入时的统一提示。 + * 后端就绪后连同页面的提示分支一并删除。 + */ +export const RESUME_AI_UNAVAILABLE_TIP = + 'AI 改写需要后端模型支持,当前版本暂未开放;你的简历内容不会被改动。'; + +/** 非 PDF 格式的导出说明:本机只能生成 PDF,Word / 图片需后端导出服务 */ +export const RESUME_EXPORT_FORMAT_NOTICE = 'Word / 图片导出需后端导出服务,当前按 PDF 导出'; + +/** 导出密码说明:PDF 加密需后端导出服务 */ +export const RESUME_EXPORT_PASSWORD_NOTICE = '密码保护需后端导出服务,当前导出的文件不含密码'; + +/** 导出失败提示:本机生成不可用时给出明确归因,而不是返回假链接 */ +export const RESUME_EXPORT_FAIL_HINT = '本机生成 PDF 失败,导出功能需后端导出服务支持'; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c0dbc1f..9a8e77d 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -6,6 +6,7 @@ export { useAboutInfo } from './useAboutInfo'; export { useAccountCancel } from './useAccountCancel'; export { useAccountSecurity } from './useAccountSecurity'; export { useAgreementGate, type AgreementGate } from './useAgreementGate'; +export { useImagePick, type UseImagePick } from './useImagePick'; export { useAIInterview } from './useAIInterview'; export { useAppealForm } from './useAppealForm'; export { useAsyncData } from './useAsyncData'; @@ -45,6 +46,8 @@ export { useMessageData } from './useMessageData'; export { useNotificationSettings } from './useNotificationSettings'; export { useOfferCompareData } from './useOfferCompareData'; export { useOnboardingChecklist } from './useOnboardingChecklist'; +export { useCarrierPhone } from './useCarrierPhone'; +export { useAppCache } from './useAppCache'; export { useOperationRules } from './useOperationRules'; export { usePasswordLogin } from './usePasswordLogin'; export { usePasswordReset } from './usePasswordReset'; @@ -60,7 +63,9 @@ export { useResumeExport } from './useResumeExport'; export { useSalaryBrackets } from './useSalaryBrackets'; export { useSalaryCalculator } from './useSalaryCalculator'; export { useSalaryRecords } from './useSalaryRecords'; +export { useSalaryToolLinks } from './useSalaryToolLinks'; export { useSkillCloud } from './useSkillCloud'; +export { useSkillLearningPath } from './useSkillLearningPath'; export { useTalentRecommendation } from './useTalentRecommendation'; export { useWeightSettings } from './useWeightSettings'; export { useLogin } from './useLogin'; diff --git a/src/hooks/useAppCache.ts b/src/hooks/useAppCache.ts new file mode 100644 index 0000000..ea26180 --- /dev/null +++ b/src/hooks/useAppCache.ts @@ -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; + /** 清理进行中(防重复点击) */ + 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, + }; +} diff --git a/src/hooks/useAppealForm.ts b/src/hooks/useAppealForm.ts index 88b0ef2..adb5bf2 100644 --- a/src/hooks/useAppealForm.ts +++ b/src/hooks/useAppealForm.ts @@ -15,6 +15,8 @@ export function useAppealForm() { const [dimensions, setDimensions] = useState([]); const [reason, setReason] = useState(null); const [reasonText, setReasonText] = useState(''); + /** 证据图片:本机 URI 列表(后端接入上传接口后改为返回的图片 URL) */ + const [evidence, setEvidence] = useState([]); const [contactPhone, setContactPhone] = useState(''); const [contactEmail, setContactEmail] = useState(''); const [agree, setAgree] = useState(false); @@ -31,6 +33,16 @@ export function useAppealForm() { setDimensions((prev) => (prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id])); }, []); + /** 追加一张证据图(同一张重复选不重复入列) */ + const addEvidence = useCallback((uri: string) => { + setEvidence((prev) => (prev.includes(uri) ? prev : [...prev, uri])); + }, []); + + /** 移除一张证据图 */ + const removeEvidence = useCallback((uri: string) => { + setEvidence((prev) => prev.filter((e) => e !== uri)); + }, []); + const submit = useCallback(async () => { if (!data) return null; setSubmitting(true); @@ -44,6 +56,7 @@ export function useAppealForm() { })), reason: reason ?? data.reason, reasonText, + evidence, contactPhone, contactEmail, agree, @@ -52,7 +65,7 @@ export function useAppealForm() { } finally { setSubmitting(false); } - }, [data, type, dimensions, reason, reasonText, contactPhone, contactEmail, agree]); + }, [data, type, dimensions, reason, reasonText, evidence, contactPhone, contactEmail, agree]); return { form: data ?? null, @@ -67,6 +80,9 @@ export function useAppealForm() { setReason, reasonText, setReasonText, + evidence, + addEvidence, + removeEvidence, contactPhone, setContactPhone, contactEmail, diff --git a/src/hooks/useCarrierPhone.ts b/src/hooks/useCarrierPhone.ts new file mode 100644 index 0000000..c29a115 --- /dev/null +++ b/src/hooks/useCarrierPhone.ts @@ -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, + }; +} diff --git a/src/hooks/useEnterpriseCommunication.ts b/src/hooks/useEnterpriseCommunication.ts index 8eeaa66..b52fb7e 100644 --- a/src/hooks/useEnterpriseCommunication.ts +++ b/src/hooks/useEnterpriseCommunication.ts @@ -1,17 +1,40 @@ /** * 企业端沟通数据 hook。 + * Tab 与搜索词的状态放在这里:过滤规则由接口层(filterEnterpriseChats)给出, + * 页面只负责把用户点击/输入转成 setTab / setKeyword,再渲染 chats。 */ -import { useCallback } from 'react'; -import { fetchEnterpriseCommunication } from '../api'; -import type { EnterpriseCommunication } from '../types'; +import { useCallback, useState } from 'react'; +import { fetchEnterpriseCommunication, filterEnterpriseChats } from '../api'; +import type { EnterpriseChatItem, EnterpriseCommunication } from '../types'; import { useAsyncData } from './useAsyncData'; export function useEnterpriseCommunication() { - const fetcher = useCallback(async (): Promise => fetchEnterpriseCommunication(), []); + const fetcher = useCallback( + async (): Promise => fetchEnterpriseCommunication(), + [], + ); const { data, loading, error, refresh } = useAsyncData(fetcher); + const [activeTab, setActiveTab] = useState('all'); + const [keyword, setKeyword] = useState(''); + + const communication = data ?? null; + /** 按当前 Tab + 关键词过滤后的会话列表 */ + const chats: EnterpriseChatItem[] = communication + ? filterEnterpriseChats(communication.chats, activeTab, keyword) + : []; + + /** 有关键词但一条没命中(用于区分「空 Tab」与「搜不到」) */ + const isEmptySearch = communication !== null && keyword.trim() !== '' && chats.length === 0; + return { - communication: data ?? null, + communication, + activeTab, + setActiveTab, + keyword, + setKeyword, + chats, + isEmptySearch, loading, error, refresh, diff --git a/src/hooks/useImagePick.ts b/src/hooks/useImagePick.ts new file mode 100644 index 0000000..546de42 --- /dev/null +++ b/src/hooks/useImagePick.ts @@ -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; +} + +export function useImagePick(): UseImagePick { + const [picking, setPicking] = useState(false); + const [error, setError] = useState(null); + + const pick = useCallback( + async (source: ImagePickSource, options?: ImagePickOptions): Promise => { + 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 }; +} diff --git a/src/hooks/useInterviewScore.ts b/src/hooks/useInterviewScore.ts index 9d2ca4a..03468af 100644 --- a/src/hooks/useInterviewScore.ts +++ b/src/hooks/useInterviewScore.ts @@ -2,7 +2,13 @@ * 面试评分 hook:拉取评分数据 + 本地编辑 + 提交评分。 */ import { useCallback, useEffect, useState } from 'react'; -import { fetchInterviewScore, submitInterviewScore } from '../api'; +import { + clearInterviewScoreDraft, + fetchInterviewScore, + fetchInterviewScoreDraft, + saveInterviewScoreDraft, + submitInterviewScore, +} from '../api'; import type { HireSuggestion, InterviewScoreData } from '../types'; import { useAsyncData } from './useAsyncData'; @@ -12,6 +18,11 @@ export function useInterviewScore(interviewId: string) { const [score, setScore] = useState(null); const [submitting, setSubmitting] = useState(false); + /** 上次保存草稿的时间(ISO);null 表示还没保存过 */ + const [draftSavedAt, setDraftSavedAt] = useState(null); + const [savingDraft, setSavingDraft] = useState(false); + /** 本次进入页面时发现的历史草稿(提示用户可恢复);恢复或放弃后清空 */ + const [pendingDraft, setPendingDraft] = useState(null); useEffect(() => { if (data && !score) { @@ -19,6 +30,20 @@ export function useInterviewScore(interviewId: string) { } }, [data, score]); + // 进页面查一次是否有未提交的草稿。有的话不直接覆盖,先让用户选择恢复还是放弃 + useEffect(() => { + let alive = true; + void (async () => { + const draft = await fetchInterviewScoreDraft(interviewId); + if (!alive || !draft) return; + setPendingDraft(draft.score); + setDraftSavedAt(draft.savedAt); + })(); + return () => { + alive = false; + }; + }, [interviewId]); + const updateDimensionScore = useCallback((id: string, value: number) => { setScore((prev) => { if (!prev) return prev; @@ -52,12 +77,44 @@ export function useInterviewScore(interviewId: string) { if (!score) return null; setSubmitting(true); try { - return await submitInterviewScore(interviewId, score); + const result = await submitInterviewScore(interviewId, score); + // 提交成功后再清草稿:先清后提交的话,提交失败就把用户的草稿弄丢了 + await clearInterviewScoreDraft(interviewId); + setDraftSavedAt(null); + setPendingDraft(null); + return result; } finally { setSubmitting(false); } }, [interviewId, score]); + /** 保存草稿,返回保存时间(ISO);失败时抛出由页面提示 */ + const saveDraft = useCallback(async () => { + if (!score) return null; + setSavingDraft(true); + try { + const draft = await saveInterviewScoreDraft(interviewId, score); + setDraftSavedAt(draft.savedAt); + return draft.savedAt; + } finally { + setSavingDraft(false); + } + }, [interviewId, score]); + + /** 用草稿覆盖当前编辑内容 */ + const restoreDraft = useCallback(() => { + if (!pendingDraft) return; + setScore(pendingDraft); + setPendingDraft(null); + }, [pendingDraft]); + + /** 放弃草稿:删掉本地暂存,保留当前页面上的内容 */ + const discardDraft = useCallback(async () => { + await clearInterviewScoreDraft(interviewId); + setPendingDraft(null); + setDraftSavedAt(null); + }, [interviewId]); + return { score, loading, @@ -69,5 +126,11 @@ export function useInterviewScore(interviewId: string) { selectHireSuggestion, submit, submitting, + saveDraft, + savingDraft, + restoreDraft, + discardDraft, + hasPendingDraft: pendingDraft !== null, + draftSavedAt, }; } diff --git a/src/hooks/useLogin.ts b/src/hooks/useLogin.ts index a207530..f645293 100644 --- a/src/hooks/useLogin.ts +++ b/src/hooks/useLogin.ts @@ -4,8 +4,11 @@ import { loginByCarrier, loginByPhone, type LoginResult } from '../api'; export interface UseLoginReturn { /** 手机号 + 验证码登录,成功返回 LoginResult */ login: (phone: string, code: string) => Promise; - /** 运营商一键登录(本机号码认证),成功返回 LoginResult */ - loginByCarrier: () => Promise; + /** + * 运营商一键登录(本机号码认证),成功返回 LoginResult。 + * @param carrierToken 取号 hook(useCarrierPhone)拿到的鉴权令牌;传空串必然失败 + */ + loginByCarrier: (carrierToken: string) => Promise; /** 登录进行中 */ loading: boolean; /** 登录失败的错误(用于页面提示) */ @@ -42,7 +45,7 @@ export function useLogin(): UseLoginReturn { ); const carrierLogin = useCallback( - () => run(() => loginByCarrier(), '一键登录失败,请稍后重试'), + (carrierToken: string) => run(() => loginByCarrier(carrierToken), '一键登录失败,请稍后重试'), [run], ); diff --git a/src/hooks/useMessageData.ts b/src/hooks/useMessageData.ts index 2747138..d696cc9 100644 --- a/src/hooks/useMessageData.ts +++ b/src/hooks/useMessageData.ts @@ -6,18 +6,21 @@ import { fetchInterviews, fetchMatchResult, fetchNotifications, + fetchPendingOfferCount, } from '../api'; import { useAsyncData } from './useAsyncData'; /** 聚合拉取消息中心全部数据(模块级函数,引用稳定,避免 useAsyncData 反复触发) */ async function fetchMessageData() { - const [conversations, notifications, interviews, matchResult] = await Promise.all([ - fetchConversations(), - fetchNotifications(), - fetchInterviews(), - fetchMatchResult(), - ]); - return { conversations, notifications, interviews, matchResult }; + const [conversations, notifications, interviews, matchResult, pendingOfferCount] = + await Promise.all([ + fetchConversations(), + fetchNotifications(), + fetchInterviews(), + fetchMatchResult(), + fetchPendingOfferCount(), + ]); + return { conversations, notifications, interviews, matchResult, pendingOfferCount }; } export function useMessageData() { @@ -27,6 +30,8 @@ export function useMessageData() { const notifications = data?.notifications ?? []; const interviews = data?.interviews ?? []; const matchResult = data?.matchResult ?? null; + /** 待处理 Offer 数:为 0 时聊天页不渲染横幅,不再显示写死的「2 个」 */ + const pendingOfferCount = data?.pendingOfferCount ?? 0; // 空状态:加载完成且无错误、但三类列表均为空 const isEmpty = !loading && @@ -40,6 +45,7 @@ export function useMessageData() { notifications, interviews, matchResult, + pendingOfferCount, loading, error, refresh, diff --git a/src/hooks/useOnboardingChecklist.ts b/src/hooks/useOnboardingChecklist.ts index 5861ab6..74978c0 100644 --- a/src/hooks/useOnboardingChecklist.ts +++ b/src/hooks/useOnboardingChecklist.ts @@ -2,7 +2,12 @@ * 入职准备清单 hook:拉取清单 + 切换待办完成状态。 */ import { useCallback, useState } from 'react'; -import { fetchOnboardingChecklist, toggleChecklistItem } from '../api'; +import { + addChecklistItem, + fetchOnboardingChecklist, + removeChecklistItem, + toggleChecklistItem, +} from '../api'; import type { OnboardingChecklist } from '../types'; import { useAsyncData } from './useAsyncData'; @@ -10,6 +15,7 @@ export function useOnboardingChecklist() { const fetcher = useCallback(() => fetchOnboardingChecklist(), []); const { data, loading, error, refresh } = useAsyncData(fetcher); const [togglingId, setTogglingId] = useState(null); + const [mutating, setMutating] = useState(false); const toggle = useCallback( async (itemId: string, done: boolean) => { @@ -23,6 +29,26 @@ export function useOnboardingChecklist() { [], ); + /** 添加自定义待办,返回更新后的清单;标题为空或写入失败时 reject */ + const addItem = useCallback(async (title: string) => { + setMutating(true); + try { + return await addChecklistItem(title); + } finally { + setMutating(false); + } + }, []); + + /** 删除自定义待办,返回更新后的清单 */ + const removeItem = useCallback(async (itemId: string) => { + setMutating(true); + try { + return await removeChecklistItem(itemId); + } finally { + setMutating(false); + } + }, []); + return { checklist: data ?? null, loading, @@ -30,5 +56,9 @@ export function useOnboardingChecklist() { refresh, toggle, togglingId, + addItem, + removeItem, + /** 增删进行中(防重复点击) */ + mutating, }; } diff --git a/src/hooks/useOnboardingFlow.tsx b/src/hooks/useOnboardingFlow.tsx index 1b8c65e..3830301 100644 --- a/src/hooks/useOnboardingFlow.tsx +++ b/src/hooks/useOnboardingFlow.tsx @@ -1,76 +1,59 @@ import type { ReactNode } from 'react'; -import { createContext, useContext, useMemo, useState } from 'react'; -import type { AssetName } from '../../components/onboarding/assets'; - -/** 注册全流程收集的数据模型 */ -export interface OnboardingData { - phone: string; - code: string; - agreed: boolean; - /** - * 微信登录带入的临时凭证。 - * 仅「微信第一次登录」链路有值:微信授权后 openid 未绑定账号, - * 需走手机号 + 验证码完成注册,后端据此把 openid 与新账号关联。 - * 手机号登录等其他链路为 null,此时 P2 页按普通登录处理。 - */ - wechatTicket: { token: string; openid: string } | null; - identity: 'candidate' | 'recruiter' | null; - isStudent: boolean | null; - city: string; - expectedPositions: string[]; - expectedSalaryMin: string; - expectedSalaryMax: string; - resumeName: string; - gender: '女' | '男' | null; - birthYear: string; - birthMonth: string; - jobStatus: string | null; - disabledJobseeker: boolean; - firstWorkYear: string; - firstWorkMonth: string; - currentPosition: string | null; - company: string; - joinYear: string; - joinMonth: string; - skills: string[]; - workContent: string; - education: string | null; - eduType: '全日制' | '非全日制' | null; - school: string; - avatar: AssetName | null; - recruiterCompany: string; -} +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { + clearOnboardingProfile, + clearResumeOverride, + fetchOnboardingProfile, + fromOnboardingProfile, + saveOnboardingProfile, + toOnboardingProfile, +} from '../api'; +import type { OnboardingData } from '../types'; +/** + * 引导流程起始状态:全部为「未采集」态。 + * + * 这里曾预填一份演示人设(杨幸幸 / 上海 / 前端开发工程师 / 本科…),后果是 + * **用户全程不操作也能一路点到落地**,而采集到的「资料」没有一项是他选的。 + * 清空后每页进入即未选态,未填的步骤由各页的 `active` 门槛拦住。 + * + * 注意 `''` / `null` 与「用户选了空值」不可区分,所以各页的空值兜底必须同时到位, + * 尤其 P8 / P10 / P13 的 YearMonthWheel:它此前把 `''` 钳到索引 0, + * 滚轮显示「2026年」而 state 仍是空串 —— 现在改为「未选中态 + 请滑动选择」。 + */ const INITIAL: OnboardingData = { phone: '', code: '', - agreed: true, + // 协议必须由用户主动勾选:预勾选不构成《个人信息保护法》要求的「明示同意」 + agreed: false, wechatTicket: null, identity: null, isStudent: null, - city: '上海', - expectedPositions: ['前端开发工程师', '鸿蒙开发工程师', '全栈工程师'], - expectedSalaryMin: '10K', - expectedSalaryMax: '12K', - resumeName: '杨幸幸', - gender: '女', - birthYear: '2004年', - birthMonth: '12月', - jobStatus: '在职-月内到岗', + city: '', + expectedPositions: [], + expectedSalaryMin: '', + expectedSalaryMax: '', + resumeName: '', + gender: null, + birthYear: '', + birthMonth: '', + jobStatus: null, disabledJobseeker: false, - firstWorkYear: '2026年', - firstWorkMonth: '4月', + firstWorkYear: '', + firstWorkMonth: '', currentPosition: null, - company: '巴适伙伴(上海)科技有限公司', - joinYear: '2026年', - joinMonth: '7月', + company: '', + joinYear: '', + joinMonth: '', skills: [], workContent: '', - education: '本科', - eduType: '全日制', + education: null, + eduType: null, school: '', avatar: null, recruiterCompany: '', + recruiterContact: '', + recruiterLicenseUri: null, }; interface Ctx { @@ -79,11 +62,9 @@ interface Ctx { current: string; navigate: (screen: string) => void; back: () => void; - canGoBack: boolean; - /** 两个保存出口:收集当前信息后直接进 P23,剩余资料后续补充 */ - saveAndEnter: () => void; + /** 立即把已采集资料落库(落地前调用,避免最后一步的输入随页面卸载丢失) */ + flushSave: () => Promise; reset: () => void; - meta: { time: string; battery: number }; } const OnboardingContext = createContext(null); @@ -93,29 +74,73 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { const [stack, setStack] = useState([]); const [current, setCurrent] = useState('P1'); + /** + * state 的同步镜像。 + * + * 落库发生在导航时,而导航与 setData 可能处在同一个事件里(如 P5City 的 + * `pick()`:`setData({ city })` 紧跟 `back()`)。此时 state 尚未提交, + * 闭包里的 data 还是旧值,只有从镜像读才拿得到刚写入的内容。 + */ + const dataRef = useRef(INITIAL); + + /** 唯一的状态写入口:同时更新镜像,保证两者永不脱节 */ + const commitData = useCallback((next: OnboardingData) => { + dataRef.current = next; + setDataState(next); + }, []); + + /** 落库:整份资料覆盖写入,不做逐键写入 */ + const flushSave = useCallback( + () => saveOnboardingProfile(toOnboardingProfile(dataRef.current)), + [], + ); + + /* + * 挂载时水合:把上次采集到的资料合并回 INITIAL 之上。 + * 流程仍从 P1 开始(不做断点续填),但各页呈现的是「已填过」的状态。 + */ + useEffect(() => { + let cancelled = false; + void (async () => { + const profile = await fetchOnboardingProfile(); + if (cancelled || !profile) return; + commitData(fromOnboardingProfile(profile, INITIAL)); + })(); + return () => { + cancelled = true; + }; + }, [commitData]); + const value = useMemo(() => { const setData = (partial: Partial) => - setDataState((prev) => ({ ...prev, ...partial })); + commitData({ ...dataRef.current, ...partial }); const navigate = (screen: string) => { - setStack((s) => [...s, current]); - setCurrent(screen); - }; - const back = () => { + void flushSave(); setStack((s) => { - if (s.length === 0) return s; - const prev = s[s.length - 1]; - setCurrent(prev); - return s.slice(0, -1); + const at = s.indexOf(screen); + // 目标已在历史里(如 P17「采用并填写」回到 P16):截断回它,不再压栈。 + // 否则同一屏在栈里出现两次,返回时先退到「未来」的那一屏 —— P16/P17 互跳后 + // 在 P16 按返回会退到 P17,看起来像返回键失灵 + if (at >= 0) return s.slice(0, at); + return [...s, current]; }); + setCurrent(screen); }; - const saveAndEnter = () => { - setStack([]); - setCurrent('P23'); + const back = () => { + void flushSave(); + if (stack.length === 0) return; + // 不在 setStack 的更新函数里做副作用(StrictMode 下更新函数会被调用两次) + setCurrent(stack[stack.length - 1]); + setStack((s) => s.slice(0, -1)); }; const reset = () => { - setDataState(INITIAL); + commitData(INITIAL); setStack([]); setCurrent('P1'); + // 退出登录:连持久化资料一起清掉,否则下次挂载会把上一位用户的资料水合回来 + void clearOnboardingProfile(); + // 简历的用户编辑同样按用户隔离:不清掉会让下一位用户看到上一位改过的简历 + void clearResumeOverride(); }; return { data, @@ -123,13 +148,10 @@ export function OnboardingProvider({ children }: { children: ReactNode }) { current, navigate, back, - canGoBack: stack.length > 0, - saveAndEnter, + flushSave, reset, - // 已停用:原假状态栏时间线演示数据,现统一使用系统真实状态栏,保留字段仅为兼容旧调用 - meta: { time: '', battery: 0 }, }; - }, [data, stack, current]); + }, [data, stack, current, commitData, flushSave]); return {children}; } diff --git a/src/hooks/useRegisterFinish.ts b/src/hooks/useRegisterFinish.ts index 3f4cd37..f0bd877 100644 --- a/src/hooks/useRegisterFinish.ts +++ b/src/hooks/useRegisterFinish.ts @@ -2,9 +2,10 @@ import { useRouter } from 'expo-router'; import { useAuth } from '../context/AuthContext'; import { useRole } from '../context/RoleContext'; import type { AppRole } from '../types'; +import { useOnboarding } from './useOnboardingFlow'; /** - * 注册完成后的真实落地:写入登录态与身份,跳转对应端主界面。 + * 注册完成后的真实落地:先把采集到的资料落库,再写入登录态与身份,跳转对应端主界面。 * 真实后端就绪后,login 的 token 替换为接口返回; * setRole 用于让 index.tsx 漏斗放行,避免注册完成又被弹回注册。 */ @@ -12,8 +13,12 @@ export function useRegisterFinish() { const router = useRouter(); const { login } = useAuth(); const { setRole } = useRole(); + const { flushSave } = useOnboarding(); - return (role: AppRole) => { + return async (role: AppRole) => { + // 落地前落库:最后一步(如 B3 的公司名/联系人)不会再有下一次跳转, + // 不在这里提交就会随页面卸载一起丢掉 + await flushSave(); setRole(role); login(`mock-token-${Date.now()}`); router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); diff --git a/src/hooks/useResume.ts b/src/hooks/useResume.ts index 624d1a3..83b3073 100644 --- a/src/hooks/useResume.ts +++ b/src/hooks/useResume.ts @@ -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(fetcher); + const fetcher = useCallback(async (): Promise => { + const [resume, savedAt] = await Promise.all([fetchResume(), fetchResumeSavedAt()]); + return { resume, savedAt }; + }, []); + const { data, loading, error, refresh } = useAsyncData(fetcher); + + /** 写操作进行中:页面据此禁用按钮,避免连点两次时后一次基于旧列表少删一条 */ + const [saving, setSaving] = useState(false); + + /** 写操作的统一收口:失败也要解除 saving(`finally`),否则按钮会一直灰着 */ + const runWrite = useCallback( + async (action: () => Promise): Promise => { + setSaving(true); + try { + const result = await action(); + refresh(); + return result; + } finally { + setSaving(false); + } + }, + [refresh], + ); + + /** + * AI 动作的统一收口:`available` 为真才重新取数。 + * 能力未接入时简历一个字都没改,刷新只会白闪一次加载态。 + */ + const runAIAction = useCallback( + async (action: () => Promise): Promise => { + 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) => 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[]) => runWrite(() => saveResumeEducation(items)), + [runWrite], + ); + const saveExperience = useCallback( + (items: ResumeItemInput[]) => runWrite(() => saveResumeExperience(items)), + [runWrite], + ); + const saveProjects = useCallback( + (items: ResumeItemInput[]) => 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, }; } diff --git a/src/hooks/useResumeExport.ts b/src/hooks/useResumeExport.ts index a31c46f..ce5ead3 100644 --- a/src/hooks/useResumeExport.ts +++ b/src/hooks/useResumeExport.ts @@ -1,5 +1,9 @@ /** - * 简历导出 hook:拉取/保存导出设置,执行导出。 + * 简历导出 hook:拉取/保存导出设置,生成导出文件,拉起系统分享面板。 + * + * 导出与分享都要先有文件,两条路都走 `exportResume`(API 层),因此: + * - 分享不需要页面先点「立即导出」—— 用户直接点分享也拿得到文件; + * - 分享面板标题里的文件名与导出落盘的文件名**必然一致**(同一个函数算出来的)。 */ import { useCallback, useState } from 'react'; import { @@ -7,13 +11,18 @@ import { fetchResumeExportSettings, saveResumeExportSettings, } from '../api'; -import type { ResumeExportSettings } from '../types'; +import { shareFile } from '../lib/shareFile'; +import type { ResumeExportOutcome, ResumeExportSettings } from '../types'; import { useAsyncData } from './useAsyncData'; +/** 分享结果:`ok` 只表示分享面板已拉起(用户是否真的发出去了,应用无从得知) */ +export type ResumeShareOutcome = { ok: true } | { ok: false; message: string }; + export function useResumeExport() { const fetcher = useCallback(() => fetchResumeExportSettings(), []); const { data, loading, error, refresh } = useAsyncData(fetcher); const [exporting, setExporting] = useState(false); + const [sharing, setSharing] = useState(false); const save = useCallback( async (settings: ResumeExportSettings) => { @@ -24,11 +33,10 @@ export function useResumeExport() { ); const exportFile = useCallback( - async (settings: ResumeExportSettings) => { + async (settings: ResumeExportSettings): Promise => { setExporting(true); try { - const result = await exportResume(settings); - return result.url; + return await exportResume(settings); } finally { setExporting(false); } @@ -36,6 +44,24 @@ export function useResumeExport() { [], ); + const share = useCallback( + async (settings: ResumeExportSettings): Promise => { + setSharing(true); + try { + const outcome = await exportResume(settings); + if (!outcome.ok) return { ok: false, message: outcome.message }; + return shareFile(outcome.url, { + mimeType: 'application/pdf', + UTI: 'com.adobe.pdf', + dialogTitle: `分享 ${outcome.fileName}`, + }); + } finally { + setSharing(false); + } + }, + [], + ); + return { settings: data ?? null, loading, @@ -43,6 +69,8 @@ export function useResumeExport() { refresh, save, exportFile, + share, exporting, + sharing, }; } diff --git a/src/hooks/useSalaryToolLinks.ts b/src/hooks/useSalaryToolLinks.ts new file mode 100644 index 0000000..cf1b80d --- /dev/null +++ b/src/hooks/useSalaryToolLinks.ts @@ -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, + }; +} diff --git a/src/hooks/useSkillCloud.ts b/src/hooks/useSkillCloud.ts index 33b9264..8b5ef2d 100644 --- a/src/hooks/useSkillCloud.ts +++ b/src/hooks/useSkillCloud.ts @@ -1,22 +1,35 @@ /** * 行业技能词云 hook:按行业拉取技能词数据,统一管理 loading / error / refresh。 - * 切换行业时自动重新取数(fetcher 依赖 industry)。 + * 切换行业时自动重新取数(fetcher 依赖 industry); + * 搜索关键词的过滤规则放在接口层(filterSkillWords),这里只编排状态。 */ import { useCallback, useState } from 'react'; -import { fetchSkillCloud } from '../api'; -import type { SkillCloudData } from '../types'; +import { fetchSkillCloud, filterSkillWords } from '../api'; +import type { SkillCloudData, SkillWord } from '../types'; import { useAsyncData } from './useAsyncData'; export function useSkillCloud(initialIndustry = '互联网/IT') { const [industry, setIndustry] = useState(initialIndustry); + const [keyword, setKeyword] = useState(''); const fetcher = useCallback(() => fetchSkillCloud(industry), [industry]); const { data, loading, error, refresh } = useAsyncData(fetcher); + const cloud = (data as SkillCloudData | null) ?? null; + + /** 按关键词过滤后的技能词;未输入关键词时即全量 */ + const words: SkillWord[] = cloud ? filterSkillWords(cloud.words, keyword) : []; return { industry, setIndustry, - cloud: (data as SkillCloudData | null) ?? null, + cloud, + /** 搜索关键词 */ + keyword, + setKeyword, + /** 过滤后的技能词(渲染词云用) */ + words, + /** 有关键词但一条没命中 */ + isEmpty: cloud !== null && words.length === 0, loading, error, refresh, diff --git a/src/hooks/useSkillLearningPath.ts b/src/hooks/useSkillLearningPath.ts new file mode 100644 index 0000000..5e551ee --- /dev/null +++ b/src/hooks/useSkillLearningPath.ts @@ -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; + /** 关闭弹窗时清掉上次结果,下次打开重新生成 */ + reset: () => void; +} + +export function useSkillLearningPath(): UseSkillLearningPathReturn { + const [path, setPath] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 }; +} diff --git a/src/hooks/useTalentRecommendation.ts b/src/hooks/useTalentRecommendation.ts index c7711c8..5e6b02b 100644 --- a/src/hooks/useTalentRecommendation.ts +++ b/src/hooks/useTalentRecommendation.ts @@ -1,17 +1,40 @@ /** * 企业端人才推荐数据 hook。 + * Tab 与搜索词状态放在这里,过滤规则由接口层(filterTalentCandidates)给出, + * 页面只把点击/输入转成 setTab / setKeyword 再渲染 candidates。 */ -import { useCallback } from 'react'; -import { fetchTalentRecommendation } from '../api'; -import type { TalentRecommendation } from '../types'; +import { useCallback, useState } from 'react'; +import { fetchTalentRecommendation, filterTalentCandidates } from '../api'; +import type { TalentCandidate, TalentRecommendation } from '../types'; import { useAsyncData } from './useAsyncData'; export function useTalentRecommendation() { - const fetcher = useCallback(async (): Promise => fetchTalentRecommendation(), []); + const fetcher = useCallback( + async (): Promise => fetchTalentRecommendation(), + [], + ); const { data, loading, error, refresh } = useAsyncData(fetcher); + const [activeTab, setActiveTab] = useState('all'); + const [keyword, setKeyword] = useState(''); + + const recommendation = data ?? null; + /** 按当前 Tab + 关键词过滤后的候选人 */ + const candidates: TalentCandidate[] = recommendation + ? filterTalentCandidates(recommendation.candidates, activeTab, keyword) + : []; + + /** 有关键词但一条没命中 */ + const isEmptySearch = recommendation !== null && keyword.trim() !== '' && candidates.length === 0; + return { - recommendation: data ?? null, + recommendation, + activeTab, + setActiveTab, + keyword, + setKeyword, + candidates, + isEmptySearch, loading, error, refresh, diff --git a/src/lib/cache.ts b/src/lib/cache.ts new file mode 100644 index 0000000..3f40e00 --- /dev/null +++ b/src/lib/cache.ts @@ -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; +} diff --git a/src/lib/imagePicker.ts b/src/lib/imagePicker.ts new file mode 100644 index 0000000..7df7d4f --- /dev/null +++ b/src/lib/imagePicker.ts @@ -0,0 +1,65 @@ +/** + * 图片选择封装(拍照 / 相册)。 + * + * 与 `storage.ts` 同属「包装原生能力」的 lib 模块:页面组件不直接 import + * expo-image-picker,由 Hook 调到这里,这样换实现(或加裁剪、压缩策略)只改一处。 + * + * 返回结果用联合类型建模,「用户取消」与「没有权限」都必须能与成功区分开 —— + * 否则页面只能把两种情况都当成失败,或干脆假装成功(本模块存在的直接原因)。 + */ +import * as ImagePicker from 'expo-image-picker'; + +/** 取图来源:相机 / 相册 */ +export type ImagePickSource = 'camera' | 'library'; + +/** 取图结果:成功带本地 URI;取消与未授权分开表示,便于页面给出不同提示 */ +export type ImagePickResult = + | { status: 'picked'; uri: string } + | { status: 'cancelled' } + | { status: 'denied' }; + +export interface ImagePickOptions { + /** 裁剪框宽高比,默认 1:1(头像);营业执照等文档型图片传 4:3 */ + aspect?: [number, number]; + /** 是否允许裁剪,默认允许 */ + allowsEditing?: boolean; +} + +/** + * 拍照或从相册选一张图片。 + * @param source 取图来源 + * @param options 裁剪比例等(见 ImagePickOptions) + * @returns 成功 / 取消 / 未授权三态结果 + * @remark 权限被拒时返回 `denied` 而不抛错:权限是用户的选择,不是程序异常 + */ +export async function pickImage( + source: ImagePickSource, + options: ImagePickOptions = {}, +): Promise { + const { aspect = [1, 1], allowsEditing = true } = options; + + const permission = + source === 'camera' + ? await ImagePicker.requestCameraPermissionsAsync() + : await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) return { status: 'denied' }; + + // mediaTypes 用 'images'(SDK 57 起 MediaTypeOptions 已废弃): + // 本项目只取静态图,且原生侧已通过 config plugin 关掉麦克风权限(见 app.config.js) + const pickerOptions: ImagePicker.ImagePickerOptions = { + mediaTypes: ['images'], + allowsEditing, + aspect, + quality: 0.8, + }; + + const result = + source === 'camera' + ? await ImagePicker.launchCameraAsync(pickerOptions) + : await ImagePicker.launchImageLibraryAsync(pickerOptions); + + if (result.canceled) return { status: 'cancelled' }; + // canceled 为 false 时 assets 必为非空数组,这里再兜一层类型安全 + const asset = result.assets[0]; + return asset ? { status: 'picked', uri: asset.uri } : { status: 'cancelled' }; +} diff --git a/src/lib/resumeExport.ts b/src/lib/resumeExport.ts new file mode 100644 index 0000000..01a4e47 --- /dev/null +++ b/src/lib/resumeExport.ts @@ -0,0 +1,54 @@ +/** + * 简历 PDF 落盘(原生能力封装,与 `imagePicker.ts` 同一层)。 + * + * 只做「HTML → 本机 PDF 文件」这一件事,命名规则与 HTML 内容由调用方给(API 层)。 + * 失败一律返回 `{ ok: false, message }`,不抛异常也不返回假地址 —— 调用方据此给出 + * 能说清原因的提示(Expo Go 缺模块、存储不可写等)。 + */ +import { File } from 'expo-file-system'; +import * as Print from 'expo-print'; +import { RESUME_EXPORT_FAIL_HINT } from '../constants'; +import type { ResumeExportOutcome, ResumeExportSettings } from '../types'; +import { exportPageSize, sanitizeFileName } from '../utils'; + +/** + * 把简历 HTML 渲染成本机 PDF 文件。 + * @param html `buildResumeExportHtml` 产出的完整 HTML 文档 + * @param fileName 期望的文件名(不含扩展名),非法字符会被清洗 + * @param settings 导出设置(取纸型与方向决定页宽页高) + * @returns 成功给出 `file://` 地址与页数;失败给出一段用户能看懂的原因 + */ +export async function exportResumePdf( + html: string, + fileName: string, + settings: ResumeExportSettings, +): Promise { + const { width, height } = exportPageSize(settings.paperSize, settings.orientation); + try { + const printed = await Print.printToFileAsync({ html, width, height }); + const safeName = sanitizeFileName(fileName); + return { + ok: true, + url: renameQuietly(new File(printed.uri), `${safeName}.pdf`), + fileName: `${safeName}.pdf`, + pages: printed.numberOfPages, + }; + } catch { + return { ok: false, message: RESUME_EXPORT_FAIL_HINT }; + } +} + +/** + * 打印引擎落盘的是随机临时名(如 `Print-xxxx.pdf`),分享面板与「文件」App 里显示的就是它, + * 与用户设置的文件名对不上。改名前先重命名成用户设的名字。 + * + * 重命名失败(同名文件占用等)不值得让整次导出作废,退回原地址继续。 + */ +function renameQuietly(file: File, newName: string): string { + try { + file.rename(newName); + } catch { + // 忽略:文件名不好看,但文件是好的 + } + return file.uri; +} diff --git a/src/lib/shareFile.ts b/src/lib/shareFile.ts new file mode 100644 index 0000000..54f655a --- /dev/null +++ b/src/lib/shareFile.ts @@ -0,0 +1,47 @@ +/** + * 系统分享面板(原生能力封装)。 + * + * `Sharing.shareAsync` 在**面板关闭**后 resolve,用户到底发给谁、有没有发出去, + * 应用无从得知 —— 所以 `ok: true` 只表示「面板已拉起」,页面不得据此提示「分享成功」。 + */ +import * as Sharing from 'expo-sharing'; +import { Share } from 'react-native'; + +export type ShareOutcome = { ok: true } | { ok: false; message: string }; + +/** + * 拉起系统分享面板分享一段文本(没有文件可分享时用,如导出清单)。 + * 与 shareFile 同样只表示「面板已拉起」:用户取消或没发出去,应用都无从得知。 + * @param message 要分享的文本 + * @param title 分享面板标题(Android) + */ +export async function shareText(message: string, title?: string): Promise { + try { + await Share.share(title ? { message, title } : { message }); + return { ok: true }; + } catch { + return { ok: false, message: '分享面板未能拉起,请稍后重试' }; + } +} + +/** + * 拉起系统分享面板分享一个本机文件。 + * @param uri 本机文件地址(`file://`) + * @param mimeType Android 用的 MIME 类型,如 `application/pdf` + * @param UTI iOS 用的统一类型标识,如 `com.adobe.pdf` + * @param dialogTitle Android/web 的分享面板标题 + */ +export async function shareFile( + uri: string, + options: { mimeType: string; UTI: string; dialogTitle?: string }, +): Promise { + try { + if (!(await Sharing.isAvailableAsync())) { + return { ok: false, message: '当前设备不支持系统分享面板' }; + } + await Sharing.shareAsync(uri, options); + return { ok: true }; + } catch { + return { ok: false, message: '分享面板未能拉起,请稍后重试' }; + } +} diff --git a/src/theme/colors.ts b/src/theme/colors.ts index bd1bce3..c37d81b 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -116,6 +116,19 @@ export const colors = { scrim: 'rgba(0, 0, 0, 0.35)', } as const; +/** + * 简历模板的强调色(模板选择卡片与导出 PDF 的页眉/标题线共用同一份)。 + * + * 模板色是导出物里的**印刷色**,不随亮/暗模式变化,因此固定取亮色家族。 + * 放在 theme 而不是页面里:模板卡片与 PDF 两处都要用,各写一份必然分叉, + * 而色值是本文件独占的职责。 + */ +export const templateAccentColors: Record = { + primary: colors.primary, + info: colors.info, + success: colors.success, +}; + /* ===================== 暗色模式(亮 → 暗映射) ===================== */ export const darkColors = { diff --git a/src/theme/index.ts b/src/theme/index.ts index 196c845..12d3fb0 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -1,4 +1,11 @@ /** * 主题统一导出 */ -export { colors, darkColors, headerGradient, headerGradientShort, headerGradientSolid } from './colors'; +export { + colors, + darkColors, + headerGradient, + headerGradientShort, + headerGradientSolid, + templateAccentColors, +} from './colors'; diff --git a/src/types/carrier-auth.ts b/src/types/carrier-auth.ts new file mode 100644 index 0000000..eba2a6b --- /dev/null +++ b/src/types/carrier-auth.ts @@ -0,0 +1,36 @@ +/** + * 运营商一键登录(本机号码认证)领域模型。 + * + * 真实链路分两步,缺一不可: + * 1. 取号 —— 进入登录页即由运营商 SDK 在端上取回本机号码(页面按掩码展示)+ 一次性鉴权令牌; + * 2. 换登录态 —— 用户点「一键登录」后,把令牌交给后端,由后端向运营商换取正式 token。 + * + * 号码与令牌都来自运营商 SDK,**全程不需要用户输入**,这是它区别于短信登录的地方; + * 反过来,取号失败或令牌过期时就没有任何可用号码,此时页面必须允许用户改走验证码登录, + * 不能停在原地假装号码还在。 + */ + +/** + * 认证服务提供方。 + * 页面上的「认证服务由 xx 提供」文案按此取值,不在页面写死某一家运营商。 + */ +export type CarrierName = 'cmcc' | 'cucc' | 'ctcc' | 'unknown'; + +/** + * 取号结果(运营商 SDK 的取号回调,对应 `GET/POST /api/auth/carrier`)。 + * + * 取号失败时 `phone` / `carrierToken` 为空串,页面据 `carrierToken` 是否为空决定 + * 「一键登录」是否可点 —— 空令牌调登录接口必然失败,不如直接不让点。 + */ +export interface CarrierPhoneResult { + /** 本机号码(11 位);取号失败为 '' */ + phone: string; + /** 掩码号码(如 150****9367),由接口层脱敏,页面直接展示 */ + maskedPhone: string; + /** 运营商下发的鉴权令牌(一次性),换取登录态时回传后端;失败为 '' */ + carrierToken: string; + /** 认证服务提供方 */ + carrier: CarrierName; + /** 令牌过期时间戳(毫秒);过期后需重新取号 */ + expiresAt: number; +} diff --git a/src/types/enterprise.ts b/src/types/enterprise.ts index cff5406..76dc735 100644 --- a/src/types/enterprise.ts +++ b/src/types/enterprise.ts @@ -195,6 +195,8 @@ export interface JobReview { tags: string[]; /** 是否已回复 */ replied: boolean; + /** 企业回复内容;未回复时为空 */ + replyContent?: string; } /** 岗位评价管理聚合数据 */ @@ -369,6 +371,14 @@ export interface InterviewScoreData { salarySuggestion: SalarySuggestion; } +/** 面试评分草稿(本地暂存,未提交的评分) */ +export interface InterviewScoreDraft { + /** 暂存的评分内容 */ + score: InterviewScoreData; + /** 保存时间(ISO 字符串) */ + savedAt: string; +} + /* ===================== 岗位管理 ===================== */ /** 岗位管理 Tab 项 */ @@ -493,6 +503,8 @@ export interface TalentCandidate { age: number; /** 学校 */ school: string; + /** 学历(「硕士及以上」筛选用) */ + education: '本科' | '硕士' | '博士'; /** 城市 */ city: string; /** 期望薪资 */ @@ -548,8 +560,15 @@ export interface PendingTask { icon: string; /** 颜色 token */ color: string; + /** 对应的沟通 Tab key,点击后切到该 Tab(与 route 二选一) */ + tabKey?: string; + /** 对应的页面路由,点击后跳转(与 tabKey 二选一) */ + route?: string; } +/** 会话在沟通列表里的归属状态,与 Tab 的 key 一一对应('all' 不是状态) */ +export type EnterpriseChatStatus = 'pending' | 'interview' | 'offer' | 'closed'; + /** 企业沟通会话项 */ export interface EnterpriseChatItem { /** 会话 id */ @@ -570,6 +589,8 @@ export interface EnterpriseChatItem { unreadCount: number; /** 标签 */ labels: string[]; + /** 归属状态(决定落在哪个 Tab 下) */ + status: EnterpriseChatStatus; } /** 企业沟通聚合数据 */ diff --git a/src/types/index.ts b/src/types/index.ts index eab6d6a..7edc3af 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -45,11 +45,13 @@ export type { EnterpriseCommunication, EnterpriseCommunicationTab, EnterpriseChatItem, + EnterpriseChatStatus, EnterpriseOverview, EnterpriseQuickAction, EnterpriseStat, HireSuggestion, InterviewScoreData, + InterviewScoreDraft, InterviewScoreDimension, InterviewScoreTag, JobEducationRequirement, @@ -101,6 +103,7 @@ export type { CompareData, CompareDimensionScore, CompareJob } from './compare'; export type { OfferCompareData, OfferItem, + OfferStatus, OfferScore, OfferBenefitGroup, OfferBenefitRow, @@ -130,14 +133,18 @@ export type { export type { WeightDimension, WeightSettings, WeightTemplate } from './weight'; export type { Resume, + ResumeAIActionResult, ResumeBasicInfo, ResumeEducation, ResumeExperience, ResumeExportFormat, + ResumeExportOutcome, ResumeExportSettings, + ResumeItemInput, ResumeJobIntention, ResumeOptimizePoint, ResumeOptimizeResult, + ResumeOverride, ResumeProject, ResumeScore, ResumeSelfEvaluation, @@ -171,6 +178,8 @@ export type { SkillCategory, SkillCloudData, SkillCloudFilter, + SkillLearningItem, + SkillLearningPath, SkillWord, TopSkill, } from './tools'; @@ -263,6 +272,13 @@ export type { WechatSession, WechatUserProfile, } from './wechat-auth'; +export type { CarrierName, CarrierPhoneResult } from './carrier-auth'; +export type { + OnboardingData, + OnboardingProfile, + OnboardingSessionField, + RecruiterProfile, +} from './onboarding'; export type { ChangePasswordPayload, PasswordResetPayload, diff --git a/src/types/offer-compare.ts b/src/types/offer-compare.ts index 667edd7..0c2c397 100644 --- a/src/types/offer-compare.ts +++ b/src/types/offer-compare.ts @@ -2,6 +2,13 @@ * Offer 对比相关类型定义 */ +/** + * Offer 的处理状态。 + * 「待处理」才需要在聊天页提醒(见 useMessageData 的 pendingOfferCount), + * 已接受/已拒绝的只留在对比页里查。 + */ +export type OfferStatus = 'pending' | 'accepted' | 'declined'; + /** 单个 Offer */ export interface OfferItem { id: string; @@ -11,6 +18,8 @@ export interface OfferItem { position: string; /** 薪资范围 */ salary: string; + /** 处理状态 */ + status: OfferStatus; /** 是否已选中参与对比 */ selected: boolean; /** 主题色(用于图表/高亮) */ diff --git a/src/types/onboarding.ts b/src/types/onboarding.ts new file mode 100644 index 0000000..f4b9276 --- /dev/null +++ b/src/types/onboarding.ts @@ -0,0 +1,85 @@ +/** + * 注册引导(P1 登录 → 落地主界面)的数据模型。 + * + * 这里有两个刻意分开的模型,不要合并: + * - `OnboardingData`:**流程内可变状态**,包含 `phone` / `code` / `agreed` / + * `wechatTicket` 这些登录瞬时字段,不该落库; + * - `OnboardingProfile`:**持久化模型**,只保留用户真正填过的资料。 + * + * `OnboardingProfile` 由 `OnboardingData` 派生(Omit 掉瞬时字段),因此 + * 往 `OnboardingData` 新增字段时,`toOnboardingProfile` 会因缺少该字段而 + * **编译失败** —— 字段漂移在 tsc 阶段就被拦住,不会静默漏存。 + */ + +/** 登录瞬时字段:只在流程内有效,不落库 */ +export type OnboardingSessionField = 'phone' | 'code' | 'agreed' | 'wechatTicket'; + +/** 注册全流程收集的数据模型(流程内可变状态) */ +export interface OnboardingData { + phone: string; + code: string; + agreed: boolean; + /** + * 微信登录带入的临时凭证。 + * 仅「微信第一次登录」链路有值:微信授权后 openid 未绑定账号, + * 需走手机号 + 验证码完成注册,后端据此把 openid 与新账号关联。 + * 手机号登录等其他链路为 null,此时 P2 页按普通登录处理。 + */ + wechatTicket: { token: string; openid: string } | null; + identity: 'candidate' | 'recruiter' | null; + isStudent: boolean | null; + city: string; + /** 期望职位,最多 3 个,第 1 个作为「期望职位」主展示值 */ + expectedPositions: string[]; + expectedSalaryMin: string; + expectedSalaryMax: string; + resumeName: string; + gender: '女' | '男' | null; + birthYear: string; + birthMonth: string; + jobStatus: string | null; + disabledJobseeker: boolean; + firstWorkYear: string; + firstWorkMonth: string; + currentPosition: string | null; + company: string; + joinYear: string; + joinMonth: string; + skills: string[]; + workContent: string; + education: string | null; + eduType: '全日制' | '非全日制' | null; + school: string; + /** + * 头像:预设头像的 asset key,或拍照 / 相册返回的本地 URI。 + * 用 `string` 而非组件层的 `AssetName`,避免 `src/types` 反向依赖 `components`。 + */ + avatar: string | null; + /** 企业端(B3)公司全称 */ + recruiterCompany: string; + /** 企业端(B3)联系人姓名(此前误写入 `school`,与毕业学校撞字段) */ + recruiterContact: string; + /** 企业端(B3)营业执照本地图片 URI,未选择时为 null */ + recruiterLicenseUri: string | null; +} + +/** 企业端(B3)招聘者认证信息 */ +export interface RecruiterProfile { + company: string; + contactName: string; + /** 营业执照本地图片 URI,未选择时为 null */ + licenseUri: string | null; +} + +/** 注册引导采集到的资料(持久化模型) */ +export interface OnboardingProfile + extends Omit< + OnboardingData, + | OnboardingSessionField + | 'recruiterCompany' + | 'recruiterContact' + | 'recruiterLicenseUri' + > { + /** 企业端认证信息,与求职者字段分开存放 */ + recruiter: RecruiterProfile | null; +} diff --git a/src/types/resume.ts b/src/types/resume.ts index d7b854e..10edd4a 100644 --- a/src/types/resume.ts +++ b/src/types/resume.ts @@ -169,3 +169,57 @@ export interface ResumeOptimizeResult { /** 优化后的简历数据 */ resume: Resume; } + +/** + * 简历覆盖层:用户显式编辑过的板块。 + * + * - 键出现 = 该板块用户编辑过,一律以覆盖层为准(`[]`、`''` 也算「已编辑」——删光了就不该再冒出来) + * - 键不存在 = 未编辑过,用引导资料派生(`buildResumeFromProfile`) + * + * 序列化后 `undefined` 键会被 JSON 丢弃,因此「未编辑过」与「清空成空值」天然可区分。 + * + * 不含 `jobIntention`:求职意向由 `resolveJobPreference()` 供给(同一份数据不能有两个来源); + * 不含 `score`:评分与优化点是产品的 AI 能力,后端就绪后由后端下发。 + */ +export interface ResumeOverride { + basicInfo?: ResumeBasicInfo; + education?: ResumeEducation[]; + experience?: ResumeExperience[]; + projects?: ResumeProject[]; + skills?: ResumeSkill[]; + selfEvaluation?: ResumeSelfEvaluation; +} + +/** + * 列表条目的保存入参:`id` 缺省表示新增项,由 API 层补 id(页面不掌握 id 生成规则)。 + * 页面提交的是它此刻看到的整份列表(已经是「覆盖层 ?? 引导派生」的合并结果)。 + */ +export type ResumeItemInput = Omit & { id?: string }; + +/** + * AI 简历能力的调用结果。 + * - `available: false`:后端模型未接入(mock 阶段恒如此),页面只提示,**不得改动简历** + * - `available: true`:能力已完成(改写与落库都在 API 层内做完),页面刷新即可 + */ +export type ResumeAIActionResult = + | { available: false; message: string } + | { available: true; message: string }; + +/** + * 简历导出结果。 + * + * 用联合类型而不是「返回假链接」或「抛异常」:导不出文件是**可预期的正常分支** + * (Expo Go 里原生模块可能缺失、设备存储不可写),必须让页面拿到一段能说的原因, + * 而不是一个 `https://cdn.example.com/...` 这种点了打不开的地址。 + */ +export type ResumeExportOutcome = + | { + ok: true; + /** 本机文件地址(`file://`) */ + url: string; + /** 落盘文件名(含扩展名),页面提示与分享面板显示的都是它 */ + fileName: string; + /** 页数,由打印引擎返回 */ + pages: number; + } + | { ok: false; message: string }; diff --git a/src/types/tools.ts b/src/types/tools.ts index d053238..a3167c0 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -4,7 +4,14 @@ */ /** 入职准备清单分类 */ -export type ChecklistCategory = 'material' | 'procedure' | 'study' | 'life' | 'daily'; +export type ChecklistCategory = + | 'material' + | 'procedure' + | 'study' + | 'life' + | 'daily' + /** 用户自己添加的待办,只有这一组可以删条目 */ + | 'custom'; /** 入职准备清单单项 */ export interface ChecklistItem { @@ -135,6 +142,38 @@ export interface SkillCloudData { updatedAt: string; } +/** 技能学习路径中的单个待补技能 */ +export interface SkillLearningItem { + /** 唯一 id */ + id: string; + /** 技能名称 */ + name: string; + /** 所属分组(页面用于配色与标签) */ + group: SkillWord['group']; + /** 行业热度权重 0-100,决定补齐优先级 */ + weight: number; + /** 优先补齐顺序,从 1 开始 */ + priority: number; + /** 推荐理由(接口层按权重与分组生成) */ + reason: string; +} + +/** 技能学习路径(由行业词云 + 当前匹配度推导) */ +export interface SkillLearningPath { + /** 行业名称 */ + industry: string; + /** 岗位名称(展示用) */ + position: string; + /** 当前技能匹配度 0-100 */ + matchPercent: number; + /** 补齐后可达成的匹配度 0-100 */ + targetPercent: number; + /** 待补技能清单(按优先级排序) */ + items: SkillLearningItem[]; + /** 生成时间(展示用) */ + generatedAt: string; +} + /** 薪资计算器输入参数 */ export interface SalaryCalcInput { /** 税前月薪(元) */ diff --git a/src/types/user.ts b/src/types/user.ts index 19d7edd..0bee4c5 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -11,6 +11,14 @@ export interface UserProfile { avatar: string; /** 个人简介,如 "3年工作经验·本科·北京" */ bio: string; + /** + * 引导 P21 采集的头像原始值:预设头像的素材 key,或拍照 / 相册得到的本地图片 URI; + * 未采集时为 null,页面回落到 `avatar` 展示字符。 + * + * 存原始值而不是解析后的图片:素材 key → 图片的解析在组件层 + * (`components/onboarding/assets.ts`),`src/types` 不可依赖 `components/`。 + */ + avatarKey: string | null; } /** 用户统计数据(我的页四宫格) */ diff --git a/src/utils/index.ts b/src/utils/index.ts index 7450f2d..b8bb27e 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,17 +1,23 @@ /** * 纯工具函数(字符串 / 数字格式化等)。 */ +import { ONBOARDING_STEP_ORDER } from '../constants'; -// 表单校验规则(手机号 / 验证码):页面与 API 层共用,避免规则被复制后分叉 +// 表单校验规则(手机号 / 验证码 / 邮箱):页面与 API 层共用,避免规则被复制后分叉 export { + EMAIL_RE, PHONE_RE, SMS_CODE_MAX_LENGTH, SMS_CODE_MIN_LENGTH, SMS_CODE_RE, + isValidEmail, isValidPhone, isValidSmsCode, } from './validators'; +// 导出物排版(简历 HTML、纸张尺寸、文件名清洗):纯函数,与原生能力分开便于单测 +export { buildResumeExportHtml, exportPageSize, sanitizeFileName } from './resumeHtml'; + /** * 千分位格式化:12846 → "12,846" */ @@ -19,6 +25,20 @@ export function formatThousands(n: number): string { return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); } +/** + * 字节 → 可读体积(清缓存用):0 → "0B"、2048 → "2KB"、1.25MB → "1.3MB"。 + * 保留一位小数且不显示 ".0"(与 formatViews 同口径)。 + */ +export function formatBytes(bytes: number): string { + if (bytes <= 0) return '0B'; + if (bytes < 1024) return `${Math.round(bytes)}B`; + const kb = bytes / 1024; + if (kb < 1024) return `${Math.round(kb)}KB`; + const mb = kb / 1024; + if (mb < 1024) return `${mb.toFixed(1).replace(/\.0$/, '')}MB`; + return `${(mb / 1024).toFixed(2).replace(/\.00$/, '')}GB`; +} + /** * 金额格式化:30000 → "30,000";decimals 控制小数位,默认 0。 */ @@ -39,3 +59,57 @@ export function formatViews(views: number): string { } return String(views); } + +/** + * 引导流程进度文案:「第 3 / 16 步」。 + * + * 页面上原先把进度写成字面量「3/3」,唯一依据是写的人的印象 —— 实际这条链有 16 步, + * 且加减步骤后字面量必然失真。这里改为按 `ONBOARDING_STEP_ORDER` 计算。 + * 不在主线上的屏(P5 / P17 等子页、登录页、企业端)返回 null,由页面决定不渲染。 + */ +export function onboardingStepLabel(step: string): string | null { + const at = ONBOARDING_STEP_ORDER.indexOf(step); + if (at < 0) return null; + return `第 ${at + 1} / ${ONBOARDING_STEP_ORDER.length} 步`; +} + +/** + * 时间点展示:「2026-09-15T06:20:00.000Z」→「09-15 14:20」(本地时区)。 + * 空串或无法解析时返回空串,调用方据此不渲染该行(而不是显示 Invalid Date)。 + */ +export function formatDateTime(iso: string): string { + if (iso === '') return ''; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +/** 工作经历的起止时间形如「2023年2月」(与引导 P13 的存储格式一致) */ +const YEAR_MONTH_RE = /^(\d{4}年)(\d{1,2}月)$/; + +/** + * 「2023年2月」→ `{ year: '2023年', month: '2月' }`;解析不出(空串 /「至今」)→ 两个空串。 + * + * 回填滚轮时必须用空串表示「未选择」:`YearMonthWheel` 收到空串会显示「请滑动选择」, + * 而给它一个兜底索引会静默写入错误年份(引导链上踩过这个坑)。 + */ +export function splitYearMonth(text: string): { year: string; month: string } { + const matched = YEAR_MONTH_RE.exec(text.trim()); + if (!matched) return { year: '', month: '' }; + return { year: matched[1], month: matched[2] }; +} + +/** + * 「2023年」+「2月」→ 可比较的数值(202302),供起止时间先后判断。 + * + * 未选全(任一项为空串或解析不出数字)返回 `null` —— 调用方据此区分 + * 「没选」与「选了 1 月」:`Number('')` 是 0,不先拦掉会把空值当成 0 年。 + */ +export function yearMonthValue(year: string, month: string): number | null { + if (year.trim() === '' || month.trim() === '') return null; + const y = Number(year.replace('年', '').trim()); + const m = Number(month.replace('月', '').trim()); + if (!Number.isFinite(y) || !Number.isFinite(m)) return null; + return y * 100 + m; +} diff --git a/src/utils/resumeHtml.ts b/src/utils/resumeHtml.ts new file mode 100644 index 0000000..d3e19cc --- /dev/null +++ b/src/utils/resumeHtml.ts @@ -0,0 +1,272 @@ +/** + * 简历导出 HTML 生成(纯函数,不碰文件系统、不碰原生模块)。 + * + * 导出物是**给招聘方看的简历正文**:不出现评分、优化建议、AI 徽标这些 App 内的辅助信息。 + * 与预览页的差别只有一处 —— 预览保留空板块标题(让用户看见缺什么),导出物整段略去 + * (纸上写「未填写教育背景」是噪音)。 + * + * 用户填的每一个字符串都经 `esc()` 转义:简历字段是自由文本,`<` 与 `&` 不转义会让 + * 生成的 HTML 结构错乱(更别说注入)。 + */ +import { RESUME_TEMPLATES, UNSET_LABEL } from '../constants'; +import { colors, templateAccentColors } from '../theme'; +import type { Resume, ResumeExportSettings } from '../types'; + +/** 模板 id → 版式变量名。白名单化:id 来自 storage,直接拼进 HTML 就等于把标签闭合权交给存储内容 */ +const VARIANT_BY_TEMPLATE: Record = { + classic: 'classic', + creative: 'creative', + tech: 'tech', +}; + +/** 纸张尺寸(pt,72dpi):A4 = 595×842,Letter = 612×792 */ +const PAPER_POINTS: Record = { + A4: { width: 595, height: 842 }, + letter: { width: 612, height: 792 }, +}; + +/** 文件名里不能出现的字符:路径分隔符与各系统保留字符,另外换行会撑坏分享面板的标题 */ +const FILE_NAME_ILLEGAL_RE = /[\\/:*?"<>|\n\r\t]/g; + +/** + * 纸型 + 方向 → 打印引擎的页宽页高(pt)。 + * + * 必须显式传给打印引擎:不传时 iOS 走 Letter、Android 走库内默认值,「纸型」这个设置项 + * 会变成点了没反应的假开关。 + */ +export function exportPageSize( + paperSize: ResumeExportSettings['paperSize'], + orientation: ResumeExportSettings['orientation'], +): { width: number; height: number } { + const base = PAPER_POINTS[paperSize] ?? PAPER_POINTS.A4; + return orientation === 'landscape' ? { width: base.height, height: base.width } : base; +} + +/** + * 清洗文件名(不含扩展名):剔除非法字符;清完为空时回落「我的简历」。 + * 导出与分享两条路都要落盘,命名规则只留这一处。 + */ +export function sanitizeFileName(name: string): string { + const cleaned = name.replace(FILE_NAME_ILLEGAL_RE, '').trim(); + return cleaned === '' ? '我的简历' : cleaned; +} + +/** HTML 转义:简历字段是自由文本,未转义的 `<`/`&` 会破坏结构 */ +function esc(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** 非空片段用分隔符连接;全空返回空串(调用方据此略去整段,不留半截值) */ +function joinParts(parts: string[], separator: string): string { + return parts.filter((p) => p.trim() !== '').join(separator); +} + +/** 一个板块:标题 + 内容;内容为空则整段返回空串 */ +function section(className: string, title: string, body: string): string { + if (body.trim() === '') return ''; + return `

${esc(title)}

${body}
`; +} + +function paragraph(text: string): string { + return `

${esc(text)}

`; +} + +export function buildResumeExportHtml(resume: Resume, settings: ResumeExportSettings): string { + const page = exportPageSize(settings.paperSize, settings.orientation); + /** + * 横向时版面很宽(A4 横向正文宽约 766pt),一行字太长不便阅读 —— 正文列限宽为纸张短边 + * 并居中;纵向时纸张本身就是窄的,不需要限宽。 + */ + const columnStyle = + settings.orientation === 'landscape' + ? `max-width: ${page.height}pt; margin: 0 auto;` + : ''; + const variant = VARIANT_BY_TEMPLATE[settings.templateId] ?? 'classic'; + const template = RESUME_TEMPLATES.find((t) => t.id === settings.templateId) ?? RESUME_TEMPLATES[0]; + const accent = templateAccentColors[template.color] ?? colors.primary; + + const { basicInfo, jobIntention, education, experience, projects, skills, selfEvaluation } = + resume; + + const name = basicInfo.name.trim() === '' ? UNSET_LABEL : basicInfo.name.trim(); + /** 隐藏联系方式只藏手机号/邮箱,所在城市不属于联系方式(藏了会让招聘方不知道人在哪) */ + const contactParts = settings.hideContact + ? [basicInfo.location] + : [basicInfo.phone, basicInfo.email, basicInfo.location]; + const contact = joinParts(contactParts, ' · '); + + const intention = joinParts( + [jobIntention.position, jobIntention.city, jobIntention.salary, jobIntention.statusLabel], + ' · ', + ); + + const educationHtml = education + .map((edu) => { + const meta = joinParts([edu.major, edu.degree], ' · '); + const years = joinParts([edu.startYear, edu.endYear], '-'); + return [ + '
', + `
${esc(edu.school)}`, + years === '' ? '' : `${esc(years)}`, + '
', + meta === '' ? '' : `
${esc(meta)}
`, + '
', + ].join(''); + }) + .join(''); + + const experienceHtml = experience + .map((exp) => { + const dates = joinParts([exp.startDate, exp.endDate], ' - '); + return [ + '
', + `
${esc(exp.company)}`, + dates === '' ? '' : `${esc(dates)}`, + '
', + exp.position.trim() === '' ? '' : `
${esc(exp.position)}
`, + exp.description.trim() === '' ? '' : paragraph(exp.description), + '
', + ].join(''); + }) + .join(''); + + const projectsHtml = projects + .map((proj) => + [ + '
', + `
${esc(proj.name)}`, + proj.role.trim() === '' ? '' : `${esc(proj.role)}`, + '
', + proj.description.trim() === '' ? '' : paragraph(proj.description), + '
', + ].join(''), + ) + .join(''); + + const skillsHtml = + skills.length === 0 ? '' : paragraph(skills.map((s) => s.name).join(' · ')); + + const sections = [ + section('sec-intention', '求职意向', intention === '' ? '' : paragraph(intention)), + section('sec-education', '教育背景', educationHtml), + section('sec-experience', '工作经历', experienceHtml), + section('sec-projects', '项目经历', projectsHtml), + section('sec-skills', '技能特长', skillsHtml), + section('sec-evaluation', '自我评价', selfEvaluation.content.trim() === '' ? '' : paragraph(selfEvaluation.content)), + ].join(''); + + const cover = settings.includeCover + ? [ + '
', + '
', + `
${esc(name)}
`, + jobIntention.position.trim() === '' + ? '' + : `
${esc(jobIntention.position)}
`, + contact === '' ? '' : `
${esc(contact)}
`, + '
', + ].join('') + : ''; + + /** + * 自荐信:以用户的自我评价为正文。 + * 没有自我评价时**不生成**这一页 —— 没有素材就编一封自荐信属于伪造内容, + * 页面在开关旁已注明「自我评价为空时不生成」。 + */ + const letter = + settings.includeRecommendation && selfEvaluation.content.trim() !== '' + ? [ + '
', + '

自荐信

', + paragraph(selfEvaluation.content), + '
', + ].join('') + : ''; + + return ` + + + +${esc(name)}的简历 + + + +${cover}
+
+
${esc(name)}
+${contact === '' ? '' : `
${esc(contact)}
`} +
+${sections} +
${letter} + +`; +} diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 7ae40ae..5546807 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -17,6 +17,9 @@ export const PHONE_RE = /^1\d{10}$/; /** 短信验证码:4–6 位数字 */ export const SMS_CODE_RE = /^\d{4,6}$/; +/** 邮箱:本地部分@域名,域名至少含一个点且后缀为 2 位以上字母(明显笔误拦在本地,真实性由后端验证) */ +export const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[A-Za-z]{2,}$/; + /** * 校验手机号是否为合法的大陆号码。 * @param phone 手机号 @@ -35,3 +38,12 @@ export function isValidPhone(phone: string): boolean { export function isValidSmsCode(code: string): boolean { return SMS_CODE_RE.test(code.trim()); } + +/** + * 校验邮箱格式。只拦明显笔误,不做 DNS / 可达性验证(由后端判定)。 + * @param email 邮箱 + * @returns 合法返回 true + */ +export function isValidEmail(email: string): boolean { + return EMAIL_RE.test(email.trim()); +} diff --git a/yarn.lock b/yarn.lock index d14c8f8..88035d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -798,7 +798,7 @@ dependencies: node-forge "^1.3.3" -"@expo/config-plugins@~57.0.9": +"@expo/config-plugins@^57.0.9", "@expo/config-plugins@~57.0.9": version "57.0.9" resolved "https://registry.npmmirror.com/@expo/config-plugins/-/config-plugins-57.0.9.tgz" integrity sha512-hHgfL1avkCdEvDSw7IwlKwRYYNgcxzbNNMIk6W6lTkJpY0MajinAfeJUS0J+wPCsjUfGbVqOJM+XhPaO5ulUxg== @@ -3517,6 +3517,18 @@ expo-glass-effect@^57.0.3: resolved "https://registry.npmmirror.com/expo-glass-effect/-/expo-glass-effect-57.0.3.tgz#acf0ba84733f5762b8f8b13819e6542faf627dbe" integrity sha512-aTh2J5eCxf+rSRMBJTnP8Ib5/k8DYFVDuEX1xqesN9F9aqTXocwMv/aQRVBNUs/iogDWhHIJz2pN97IsrrTcoA== +expo-image-loader@~57.0.1: + version "57.0.1" + resolved "https://registry.npmmirror.com/expo-image-loader/-/expo-image-loader-57.0.1.tgz#b031dc3e62ac09004f3e436d7081487236791f53" + integrity sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug== + +expo-image-picker@~57.0.17: + version "57.0.17" + resolved "https://registry.npmmirror.com/expo-image-picker/-/expo-image-picker-57.0.17.tgz#69d6c84760159dad5c7162fc83ece962c211db09" + integrity sha512-qPcCkGSoqDLpmdXCLiLe09Je01gzBrwEgLd4SBI03Od/5t531vDTkzUmGemjm7vcsCTG2iqZMh2VZXBscAPO3g== + dependencies: + expo-image-loader "~57.0.1" + expo-keep-awake@~57.0.2: version "57.0.2" resolved "https://registry.npmmirror.com/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz#ce266252ad478523bb3431d40f67bd643f86bd9c" @@ -3559,6 +3571,11 @@ expo-modules-jsi@~57.1.0: resolved "https://registry.npmmirror.com/expo-modules-jsi/-/expo-modules-jsi-57.1.0.tgz" integrity sha512-a5ckeHfnbYfonhcHGkM0EU/a0Keh+/OufXy/HyFS+spr5Ib0N4Oh1ZsSYFQhp5xdOUFkVofKRhfH+VmcccIIpA== +expo-print@~57.0.2: + version "57.0.2" + resolved "https://registry.npmmirror.com/expo-print/-/expo-print-57.0.2.tgz#95c4453249bed1bb751803226abe508c939ec08d" + integrity sha512-jEbUqtH59xT42dCrmVVrg9Ct1ofD5GAHdQmUiu0EkHdzvqop7G6dt7rCXpq0iUYbu8ow6HH8yLRPbd3VMdTUug== + expo-router@~57.0.21: version "57.0.21" resolved "https://registry.npmmirror.com/expo-router/-/expo-router-57.0.21.tgz#2a7cde386f47725d6310b427b7fce84ccf41b140" @@ -3597,6 +3614,15 @@ expo-server@^57.0.3: resolved "https://registry.npmmirror.com/expo-server/-/expo-server-57.0.3.tgz" integrity sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ== +expo-sharing@~57.0.19: + version "57.0.19" + resolved "https://registry.npmmirror.com/expo-sharing/-/expo-sharing-57.0.19.tgz#453e0ecd810f956a434564983a9110a66e7e7cc9" + integrity sha512-lZ9chd/kxalZkMaJnPxW6dEpj7hnlrikesiEV9KLS99VKgkfLgH+/pFvIfk0YwGJ1b1/eLGcZ7OfJ2jjSgFpNA== + dependencies: + "@expo/config-plugins" "^57.0.9" + "@expo/config-types" "^57.0.2" + "@expo/plist" "^0.8.1" + expo-splash-screen@~57.0.9: version "57.0.9" resolved "https://registry.npmmirror.com/expo-splash-screen/-/expo-splash-screen-57.0.9.tgz#916e23cdfcb1538ba88e091692dedcb36b13b08d" diff --git a/运营商一键登录接入方案.md b/运营商一键登录接入方案.md new file mode 100644 index 0000000..723588d --- /dev/null +++ b/运营商一键登录接入方案.md @@ -0,0 +1,146 @@ +# 运营商一键登录接入方案(P2Q) + +> 状态:方案文档 · 不涉及代码改写 · 真实取号 SDK 就绪后按路线图替换 mock 即可 +> 适用范围:有职 App 运营商一键登录(本机号码认证) +> 关联文件:`src/types/carrier-auth.ts`、`src/hooks/useCarrierPhone.ts`、`src/hooks/useLogin.ts`、`src/api/api.ts`、`components/onboarding/screens/P2QuickLogin.tsx`、`src/__tests__/carrier-login.test.ts` +> 本文回答的问题:「一键登录时应当获取到手机号,为什么获取不到」—— 见第 1 节。 + +--- + +## 0. 一句话结论 + +**在本机(Expo Go / 模拟器)跑,取号必然是假的**:本机号码只能由运营商的号码认证 SDK 在端上取回,它是原生模块,Expo Go 内不存在,模拟器也没有 SIM 卡。当前 `fetchCarrierPhone()` 返回的是写死的号码 `15000009367`(掩码 `150****9367`),用于把**页面链路**跑通。 +**接入真实取号 = 端上换 1 处 SDK 调用(`fetchCarrierPhone` 函数体) + 服务器换 1 个接口(`/api/auth/login-by-carrier`) + 用 EAS Build 生成自定义客户端。** 页面、hook、类型**零改动**。 + +--- + +## 1. 原先「取不到手机号」的两个真实原因(已修) + +排查结论:**不是取号 SDK 的问题,是页面根本没读取号结果**。修复前 `P2QuickLogin.tsx` 有两个独立缺陷,叠加起来表现为「号码永远显示占位」: + +| # | 缺陷 | 表现 | 现状态 | +|---|---|---|---| +| 1 | 页面读的是 `data.phone` —— 该字段**只有短信登录通道(P2)才会写入** | 走一键登录进来时 `data.phone` 恒为空 → 号码位永远渲染占位文案「本机号码」,用户以为没取到号 | ✅ 已改为读 `useCarrierPhone()` 的 `maskedPhone` | +| 2 | `loginByCarrier()` 返回的 `phone` **被直接丢弃** | 即使后端换了真号码,页面与后续步骤(P2P/P2R 预填、B3 联系人电话)也拿不到 | ✅ 成功登录后 `setData({ phone: result.phone })` 写回引导资料 | + +顺带补上的**失败分支**(此前不存在,取号失败也照样显示号码、按钮照样可点): + +- `carrierToken` 为空 = 没取到号 → 「一键登录」置灰(空令牌调登录接口必然失败,不如不让点); +- 号码位改为「正在获取本机号码…」/「未能获取本机号码」,并给出**重新获取**与**改用验证码登录**两个出口; +- 取号是**进页面即预取**(`useCarrierPhone` → `useAsyncData`,带自动重试与请求去重),不是等用户点按钮才去取。 + +> 没修的(有意保留):`P2QuickLogin` 的《xx认证服务条款》**不冒充能打开**——该文档由运营商在认证页内提供,本仓库没有,故只弹一段如实说明;页脚服务热线为空时整块不渲染(`SUPPORT_HOTLINE` 为空)。 + +--- + +## 2. 现状盘点(已实现 vs 待接入) + +| 层 | 文件 | 现状 | 接入动作 | +|---|---|---|---| +| 类型 | `src/types/carrier-auth.ts` | ✅ `CarrierPhoneResult` / `CarrierName` 已定义 | 无需改动 | +| 取号 hook | `src/hooks/useCarrierPhone.ts` | ✅ loading / error / refresh 已就绪 | 无需改动 | +| 页面流转 | `P2QuickLogin.tsx` | ✅ 取号 → 换登录态 → 写登录态 → 写回号码 → `P3` | 无需改动 | +| 登录态 | `src/context/AuthContext.tsx` | ✅ `login(token, expiresAt)` 已就绪 | 无需改动 | +| **端上取号** | `src/api/api.ts` · `fetchCarrierPhone()` | ⚠️ **mock**:写死 `15000009367` | **换运营商 SDK 调用(端上)** | +| **换登录态** | `src/api/api.ts` · `loginByCarrier()` | ⚠️ **mock**:直接下发 `mock_token_carrier_*` | **换真实接口** | +| 运营商/聚合服务 | —— | ❌ 未开通 | 开通号码认证服务 | +| 构建产物 | EAS | ❌ 无自定义客户端 | `eas build --profile development` | + +**已验证的关键事实**(直接取自源码,非推测): + +- `loginByCarrier('')` **直接 reject**(`本机号码未取到,请重新获取或改用验证码登录`),页面因此不能靠空令牌换到登录态。 +- 登录成功后 `writeAuth(result.token)` **确实写入登录态**(此前该页只 `navigate('P3')` 不写 token,是本项目曾存在的严重缺口,已于 2026-09-15 一并修复)。 +- 号码的**唯一真相源是后端换取结果**,不是端上预取的号码:`loginByCarrier` 返回的 `phone` 由接口层给出,页面不自己拼预取到的号码写资料。 +- 取号令牌**一次性且 5 分钟内有效**(`CARRIER_TOKEN_TTL_MS`),过期需重新取号。 + +--- + +## 3. 两个硬约束(设计已遵守,接后端不可破坏) + +1. **端上取号 ≠ 可信号码**:运营商 SDK 在端上取回的号码可以被改机工具伪造,因此**可信号码必须由后端拿令牌向运营商换取**(`/api/auth/login-by-carrier` 的返回为准)。前端只把取到的号码拿去展示。 +2. **令牌一次性 + 短 TTL**:`carrierToken` 用后即废、且 5 分钟过期。所以「取号」与「换登录态」必须紧挨着(页面级),不能缓存起来留到下个页面用;过期后唯一的正确动作是**重新取号**。 + +--- + +## 4. 接入路线图(按执行顺序) + +### Step 1 · 开通运营商号码认证服务(前置,阻塞项) +- 任选其一:中国移动 / 联通 / 电信官方号码认证能力,或**聚合方案**(阿里云号码认证服务、极光、闪验等,一次接多家运营商)。 +- 需要准备:企业资质、`android.package`(`com.youzhi.app`,见 `app.config.js`)与**签名 SHA256**、iOS `bundleIdentifier`。 +- 拿到的 **AppKey / AppSecret** 需同时配置在**端上(SDK 初始化)**与**服务器(换取号码)**两侧。 + +### Step 2 · 安装取号 SDK(Expo 插件优先) +```bash +npx expo install <所选方案的 RN 插件> +``` +- 原生模块需在 `ios/` `android/` 原生壳中注册;有 config plugin 的方案由预构建自动注入,无需手改原生代码。 +- **Expo Go 一定跑不了取号**(`requireNativeModule` 在 import 期执行,缺模块会直接崩),所以本地开发期保留 mock 分支,用 `__DEV__` 或环境变量区分。 + +### Step 3 · 生成 EAS 自定义开发客户端 +```bash +eas build --profile development --platform android +eas build --profile development --platform ios +``` +- 与微信登录同一前提(见 `微信登录接入方案.md` 的 Step 3):**后续所有真机验证都在此客户端中进行**。 +- iOS 需要在 `app.config.js` 补运营商要求的隐私描述文案(`expo-build-properties` / `infoPlist`),Android 需确认运营商要求的权限未被裁剪。 + +### Step 4 · 替换端上取号(唯一改动点 · 前端) +文件:`src/api/api.ts` 的 `fetchCarrierPhone()`,把 mock 返回换成 SDK 调用(注释里已写好变形写法): + +```ts +const r = await NumberAuthSDK.getMobileNumber(); // 5 分钟内有效,用后即废 +if (!r?.token) return { phone: '', maskedPhone: '', carrierToken: '', carrier: 'unknown', expiresAt: 0 }; +return { + phone: r.phone, + maskedPhone: maskPhone(r.phone), + carrierToken: r.token, + carrier: carrierOf(r.operator), + expiresAt: Date.now() + CARRIER_TOKEN_TTL_MS, +}; +``` + +- **失败一律返回空串而不是假号码** —— 页面正是靠 `carrierToken === ''` 判定失败并置灰按钮,返回假号码会让失败分支永远进不去。 +- `maskPhone` / `carrierOf` 已在接口层就位,页面不做脱敏。 +- **此函数签名不变,页面与 hook 不动。** + +### Step 5 · 实现后端换号接口 +| 接口 | 方法 | 职责 | 关键返回 | +|---|---|---|---| +| `/api/auth/carrier/number` | POST | 端上取号的**服务端旁路**(可选:部分方案把 token 的校验放在这里) | `CarrierPhoneResult` | +| `/api/auth/login-by-carrier` | POST | 用一次性 `carrierToken` 向运营商换取真实号码 → 查库 → 建号/登录 | `LoginResult`:`token` + **已核验的 `phone`** + `expiresAt` | + +- 后端须保证:`carrierToken` 一次性、来源可校验、过期拒绝;**号码以后端换取结果为准**(前端传来的号码一律不信任)。 +- 该接口同时也是「登录即注册」入口:号码在库中不存在时应自动建档(与验证码通道一致)。 + +### Step 6 · 清除 mock 残留 +- 删除 `MOCK_CARRIER_PHONE` / `MOCK_CARRIER_NAME` 与 `fetchCarrierPhone` 的 mock 分支。 +- `src/__tests__/carrier-login.test.ts` 中断言「mock 号码」的用例改为断言**失败分支**(空令牌 reject、空号码不置 `ready`),这部分在真实取号后依然是有效契约。 + +--- + +## 5. 手动验收路径(可复现) + +1. 在自定义客户端(非 Expo Go)上安装,**插入有效 SIM 卡并开启数据流量**(取号需要蜂窝网络鉴权,纯 Wi-Fi 下多数方案会失败)。 +2. 进入 P2Q → 期望**不输入任何号码**即展示掩码本机号码(与 SIM 卡一致),页脚显示「认证服务由 xx 提供」。 +3. 点「一键登录」→ 期望直接进 P3,且后续注册流程(P2P/P2R 预填手机号、B3 联系人电话)**都用这个真号码**。 +4. 关流量 / 开飞行模式 / 拔卡 → 期望号码位显示「未能获取本机号码」、按钮置灰、两个出口(重新获取 / 改用验证码登录)可用。 +5. 令牌过期后(>5 分钟再点)→ 期望提示重新取号,不出现「点了没反应」。 +6. 回归 `npx tsc --noEmit` 与 `npx eslint .` 均 0 错误。 + +--- + +## 6. 风险与待确认项 + +| 项 | 风险/阻塞 | 处置 | +|---|---|---| +| 运营商服务开通 | 需企业资质与签名指纹,非代码可解 | Step 1 前置,未开通前无法真机验证 | +| 蜂窝网络依赖 | 无 SIM / 无流量 / 双卡选错卡都会取号失败 | 失败分支已就位;验收时明确用流量 | +| 端上号码不可信 | 改机工具可伪造取号结果 | 号码一律以后端换取结果为准(Step 5) | +| Expo Go 无法验证 | 原生模块不存在,import 期即崩 | 开发期保留 mock 分支 + dev build 出包 | +| SDK 选型 | 各家插件维护状态不一 | 接入时以官方 docs 为准(AGENTS.md 要求先读 `docs.expo.dev/versions/v57.0.0/`) | + +--- + +## 7. 一句话给执行者 + +**别动页面、hook、类型。** 只动两处:`fetchCarrierPhone()`(换端上 SDK 调用,失败返回空串)和 `loginByCarrier()`(换真接口,号码以后端为准),配齐运营商号码认证服务 + EAS 自定义客户端,一键登录即可真机走通。 diff --git a/项目开发状态.md b/项目开发状态.md index 966e60c..00f5830 100644 --- a/项目开发状态.md +++ b/项目开发状态.md @@ -1,9 +1,10 @@ # 有职 App 开发进度与规划文档 > 生成日期:2026-09-10 -> 最后更新:2026-09-15(登录方式改为「验证码 / 密码 / 运营商一键登录」三通道 · 密码闭环打通 · 登录态未写入的严重缺口已修复) +> 最后更新:2026-09-17(**全仓死控件清零:9 处 → 0 处**,企业沟通 / 人才推荐的 Tab 与搜索真正生效、申诉举证接真实选图、面试评分草稿落库、入职清单可增删与导出,见 4.7) +> 上一版:2026-09-15(登录方式改为「验证码 / 密码 / 运营商一键登录」三通道 · 密码闭环打通 · 登录态未写入的严重缺口已修复 · **注册引导链闭环:采集资料真正落库并被主界面读取,删除假首页 P23**,见 4.3 · **简历域死控件清零:新增可写通道与 4 个编辑/预览页,导出真出 PDF、分享真拉面板**,见 4.4 · **127 个接口函数全部预留注释版后端请求,新增审计脚本**,见 4.6) > 范围:项目健康审查 + 已开发 / 待开发清单 -> 结论先行:**当前项目可运行**(`tsc` 0 错误、`eslint` 0 error、路由 0 失效跳转),无需修正阻塞错误。 +> 结论先行:**当前项目可运行**(`tsc` 0 错误、`eslint` 0 error、路由 0 失效跳转、死控件 0 处、219 例测试全绿),无需修正阻塞错误。 --- @@ -12,8 +13,11 @@ | 检查项 | 命令 / 方式 | 结果 | |---|---|---| | 类型检查 | `npx tsc --noEmit` | ✅ 0 错误(Exit 0) | -| 代码规范 | `npx eslint .` | ✅ 0 error / ⚠️ 8 warning(均为 `any` 软警告) | -| 路由闭合 | `node scripts/check-routes.mjs` | ✅ 67 路由全部存在,0 失效跳转(2026-09-15 复跑) | +| 代码规范 | `npx eslint .` | ✅ 0 error / 0 warning(2026-09-17 复跑) | +| 路由闭合 | `node scripts/check-routes.mjs` | ✅ 72 路由全部存在,0 失效跳转(12 个「孤立页」经核对为动态路由 / Tab 页误报,见 4.8) | +| 死控件审计 | `node scripts/check-affordances.mjs` | ✅ **0 处**(2026-09-17 清零,9 → 0,见 4.8) | +| 后端接口预留审计 | `node scripts/check-backend-stubs.mjs` | ✅ 137 个接口函数全部留有注释版真实请求(见 4.6) | +| 单元测试 | `npm test` | ✅ 18 个文件 **219 例全绿**(2026-09-17) | | 入口接线 | 读 `app/_layout.tsx` / `app/index.tsx` | ✅ 根栈 → 入口围栏 → 双端 Tab 完整 | | 应用配置 | `app.config.js` | ✅ name「有职」、scheme `youzhi`、plugins 正确 | @@ -27,7 +31,7 @@ - **双端同构**: - 求职端 `(tabs)`:岗位 / 沟通 / 成长 / 我的(落地 `jobs`) - 企业端 `(enterprise)`:岗位 / 人才 / 沟通 / 我的(落地 `job-management`) -- **分层(src/)**:`types`(领域模型) · `api`(5088 行 mock 数据层) · `hooks`(语义化取数) · `components`(42 组件) · `context`(Auth/Role) · `theme`(colors token) · `constants` · `utils` · `lib`(storage / wechat-sdk) +- **分层(src/)**:`types`(领域模型) · `api`(7385 行 mock 数据层) · `hooks`(语义化取数) · `components`(42 组件) · `context`(Auth/Role) · `theme`(colors token) · `constants` · `utils` · `lib`(storage / wechat-sdk / imagePicker / resumeExport / shareFile / cache) - **数据链路**:页面 → Hook(`useXxx`) → API(`fetchXxx`) → 后端。当前全 mock,签名已固化,接真实后端只改 `src/api/api.ts` 函数体。 - **配色治理**:统一走 `src/theme/colors.ts`,禁止硬编码十六进制;透明度用 token + `15`/`20` 后缀。 @@ -86,7 +90,41 @@ - **岗位评价发布(双向透明评价生态核心)**:✅ 2026-09-10 接通。此前 `jobs/[id].tsx` 底部发布按钮为「功能开发中」占位——产品核心定位即评价体系却无法发布。现已新增 `createJobReview`(AsyncStorage 键 `job:reviews`,按 jobId 归属),`fetchEmployeeReviews` 合并「本人发布(置顶)+ 内置评价」,`useJobDetail` 暴露 `publishReview`,页面接线含空输入拦截、发布中 loading、成功后清空输入框并自动刷新列表。 - **隐私政策 / 用户协议内容**:`src/api/api.ts` 中 `MOCK_PRIVACY_POLICY` / `MOCK_USER_AGREEMENT` 已含完整合规草案(各 9–10 章),**18 岁门槛已落地**(隐私政策第六章 + 用户协议前言)。仅余【待确认】占位待法务/产品替换:公司全称、注册地址、客服邮箱/电话、个人信息保护负责人邮箱、管辖法院。属**上架硬性审查项**,占位填妥即可提交审核。 -### 4.3 工程与上线 +### 4.3 注册引导链闭环(2026-09-15 完成) + +**问题**:整条引导链在空转 —— `components/onboarding/screens/` 下 20+ 个引导屏**没有一处调用 API**,用户从 P2 登录到 P22 完成填的姓名 / 城市 / 期望职位 / 期望薪资 / 技能 / 学历 / 公司**全部只写进 `useOnboardingFlow` 的内存 state**(该 Provider 只包在 `/register` 路由内),进程一结束全部蒸发。主界面因此读不到用户填的任何东西:「我的」页偏好**恒为** mock 的「前端开发工程师 / 北京 / 25-35K」。 + +- **落库闭环**:新增持久化模型 `src/types/onboarding.ts`(`OnboardingProfile` 与含登录瞬时字段的 `OnboardingData` 拆开,`phone` / `code` / `agreed` / `wechatTicket` 不落库)+ API `saveOnboardingProfile` / `fetchOnboardingProfile` / `clearOnboardingProfile`(键 `youzhi:onboarding:profile`)。写入为**步骤粒度**(`navigate` / `back` / `finish` 各提交一次,不逐键写,避免每次按键都打存储);Provider 挂载时水合,流程仍从 P1 开始(按决定不做断点续填)。 +- **主界面读取(让填的生效)**:`fetchJobPreference` 改为读持久化资料并映射(工作类型默认「全职」;未采集项显示 `UNSET_LABEL`「未设置」,不再返回 `MOCK_JOB_PREFERENCE` 常量);`fetchJobList` 在**默认推荐视图**(`sortBy==='comprehensive'` 且未显式筛选城市 / 关键词)叠加偏好加分(城市 +15 / 职位 +20 / 薪资区间重叠 +10,权重在 `src/constants`),**显式筛选一律不叠加偏好加分**;`fetchUserProfile` 的姓名 / 头像字 / 简介改用采集值(有资料就只用资料,**不再用 mock 的「李明 / 3年工作经验·本科·北京」补空**,空值由页面渲染「未设置」或隐藏该行)。`app/(tabs)/jobs.tsx` 默认城市由写死的「北京」改为「全部城市」—— 不改这一处,偏好加分永远不生效。 +- **简历页接采集资料**:`fetchResume` 原先只覆盖姓名与头像,其余(学历「北京大学」、工作经历「字节跳动」、技能 Vue/React)仍是演示人设「李明」的 —— 用户会在自己的简历里看到别人的学历和公司。现改为 `buildResumeFromProfile` 逐项映射采集值:教育背景(P20 学校 + P18 学历/学习形式)、工作经历(P12 公司 + P11 职位 + P13 入职年月 + P16 工作内容,在职才写「至今」,离职时结束时间留空而不编)、技能(P14)、求职意向;**未采集项一律留空**(专业、入学毕业年份、项目经历、自我评价、手机号、邮箱),页面按空值隐藏对应行,不再拼出「· 本科」「2022.07 - 」这类半截内容,空板块渲染「未填写」。P9 的求职状态选项提为常量 `JOB_STATUS_OPTIONS`(`JOB_STATUS_INACTIVE` / `JOB_STATUS_EMPLOYED_PREFIX` 同处),简历「积极 / 不积极」与「是否至今」的判定与页面读同一份,避免两处文案分叉。 +- **求职意向只认一个答案**:新增 `resolveJobPreference()`(显式编辑 > 引导资料推导 > 空值),「我的」页偏好卡片与简历「求职意向」都读它 —— 原先简历的求职意向只读引导资料,用户在求职意向编辑页改过之后,两个页面会给出不同答案。顺带把「基本信息的所在城市」留空:引导采集的是 P4「**期望**城市」,不能冒充「现居城市」(期望城市仍正确显示在求职意向里)。 +- **「优化简历」不再把简历换成别人**:`optimizeResume` 原样返回 `MOCK_OPTIMIZED_RESUME`,即演示人设「李明 / 北京大学 / 字节跳动」的整份简历 —— 用户点一下按钮,自己的简历就变成了别人的。现改为在**用户自己的简历**上套用 mock 评分块;AI 改写的正文(经历描述 / 自我评价)mock 阶段生成不了,因此不替换用户真实填写的内容,真实后端就绪后由后端返回改写结果。 +- **简历页「编辑 >」接上真实出口**:求职意向卡的「编辑 >」原先无 `onPress`,现指向已存在的 `/job-preference` 编辑页,并在简历页加 `useFocusEffect(refresh)` —— 该页保存的是显式偏好,返回时不重新取数就会显示旧值(`useAsyncData` 只在挂载时取数)。 +- **清演示人设**:`INITIAL` 里预填的「杨幸幸 / 女 / 上海 / 前端开发工程师 / 本科」等全部清空。此前**用户全程不操作也能一路点到落地**,而采集出的「资料」没有一项是他选的。清空后逐页补空值兜底(P4 / P8 / P10 / P13 / P18 等页的 `active` 门槛;`YearMonthWheel` 改为「未选中 + 请滑动选择」,此前把空值钳到索引 0,滚轮显示「2026年」而 state 仍是空串,会静默写入错误年份);`agreed` 由 `true` 改 `false` —— 预勾选不构成《个人信息保护法》要求的明示同意,且它让协议弹窗(`useAgreementGate`)此前永不触发。 +- **缺陷修复**:`navigate()` 去重压栈(修 P16 ↔ P17 往返后返回键错乱);P3「退出登录」补 `useAuth().logout()`(原先只清流程 state,token 未清,退出后重进仍被判为已登录);B3 企业认证重做(营业执照可点可传、联系人不再写进 `data.school`、提交走 `submitEnterpriseCertification`、去掉本地 `useState` 翻出的假「等待审核」态与开发口径文案);P21 头像接入 `expo-image-picker`(**新增依赖**)后拍照 / 相册 / 5 个预设头像各自生效。 +- **死控件(点零反馈)逐项处理**:P6 / P11 搜索框接真实过滤(跨分类匹配 + 空结果提示);P6 写死的进度角标「3/3」改为按 `ONBOARDING_STEP_ORDER` 计算的「第 3 / 16 步」;P6 / P16 的「保存」文案改为「跳过,直接进入」(它实际调 `finish()` 结束注册,并不「保存」);P2 与 P2W 的「接收不到短信」改为可点并给出可执行指引;P1 / P2Q 页脚与 P2Q 条款链接按下条处理。 +- **不编造数据(本轮的处理原则)**:页脚「服务热线 / 举报监督电话」改为常量 `SUPPORT_HOTLINE` / `SUPERVISION_PHONE`(**现为空,为空则整块不渲染**,填入号码即自动出现并接 `tel:` 拨号;与隐私政策的【待确认】客服电话同源,属同一批待补信息);「资质证照」入口一并去掉 —— 项目内**没有**该页面,`app/legal.tsx` 是「阿布阿诺法律服务」付费咨询页,指过去是误导(**需新建资质证照页 + 提供证照信息**);P2Q 原先以「本机号码」名义显示写死的 `150****9367`,改为无号码时只显示槽位标签「本机号码」;P2W 的手机尾号回落值 `9367` 同理去掉;P5「自动定位:上海」(不做定位却把「上海」写死成选中值)与 P2W「语音接听验证码」(无语音通道)两个入口**移除**。 +- **删 P23 假首页**:`P23Home` 是写死上海 4 条职位的演示页,与真 `(tabs)/jobs` 重复,且含两颗绑同一回调的假权限弹窗;删除后 P22 与 P6 / P16 的快速通道由 `useRegisterFinish` 直接 `replace` 进真主界面。同步清理悬空接口 `saveAndEnter` / `canGoBack` / `meta`(`meta` 恒为 `{time:'',battery:0}`,21 个屏仍在传参、`ScreenShell` 收到即丢弃)与插画占位上的素材说明文字(`Illustration` 在 `source` 为 null 时不再渲染 `hint` —— 它会把「手持 OFFER 职场男性插画」这类开发备注直接画在用户界面上)。 +- **测试**:新增 `job-match.test.ts`(14 例,锁定匹配规则与「显式筛选不叠加偏好」边界)、`salary-options.test.ts`(10 例)、`onboarding-profile.test.ts`(映射往返 + 字段漂移检测)、`resume-profile.test.ts`(12 例:采集齐全逐项映射、一路跳过时各板块为空、离职不写「至今」、求职意向读显式编辑过的偏好、AI 优化后仍是本人简历、「不再出现演示人设」字符串扫描)、`user-profile.test.ts`(6 例:姓名/头像字/简介映射、一路跳过时不显示演示人设、头像原始值按预设 key / 本地 URI 两类透传)、`onboardingStepLabel`(3 例)。`npm test` **97 例全绿**;`tsc` / `eslint` / `check-routes`(67 路由 / 0 失效跳转 / 0 孤立页)均 0 问题。 +- **待验证 / 待补**:`expo-image-picker` 的原生权限文案(`app.config.js` 的 `photosPermission` / `cameraPermission`,并显式关掉插件默认申请的麦克风权限)**只在出包时生效**,Expo Go 用的是它自己的 `Info.plist`,需真机出包验证;手工路径(真机走完 P1→P22 →「我的」页偏好与所填一致 → `/jobs` 偏好岗位上浮;引导途中杀 App 重进数据仍在)**尚未在设备上执行**。 +- **落地页仍为 mock / 仍是死控件(本轮未动,待产品拍板)**:① `/resume` 的「简历评分 72 分」与 5 条 AI 优化点、`/profile` 的投递 28 / 收藏 12 / 沟通 8 / Offer 3、八大维度评分与勋章,全是与用户无关的写死数据(AI 评分与统计属产品能力,需真实算法或后端)。**「评分保留展示、不加示例标注」已于 2026-09-15 拍板**(见 4.6 末条),但「写死 mock」这一事实不变;② ~~`/resume` 仍有 10 处 `Pressable` **无 `onPress`**~~ **已修(2026-09-15,见 4.4)**:简历域 26 处死控件全部接线,`check-affordances.mjs` 简历域命中 0;③ ~~P21 采集的头像落地后无消费方~~ **已修**:「我的」页头部改为显示用户真正选的头像 —— `UserProfile` 新增 `avatarKey`(**存原始值**:预设素材 key 或本地图片 URI,API 层只透传,解析在组件层 `isAvatarPreset()`,因为 `src/types` 不可 import `components/`),选过就渲染真实头像,没选过才回落到姓名首字。**注意**:拍照 / 相册得到的是 `expo-image-picker` 的缓存目录 URI,系统清缓存后该图片可能失效(渲染空白圆圈,不崩);接真实后端时应改为上传取回永久 URL。 + +### 4.4 简历域功能补齐(2026-09-15 完成) + +**问题**:引导链闭环之后,简历成了新的瓶颈 —— `app/resume.tsx` 与 `app/resume/export.tsx` 共 **26 处** `Pressable` 点下去没有任何反应(顶部「保存」「导出」、「编辑基本信息」、四个「添加*」、「AI 优化 / AI 润色 / 一键全部优化」、模板 / 纸型 / 方向 / 预览 / AI 智能优化 / 分享简历),且**简历根本没有可写通道** —— `fetchResume()` 每次从引导资料现算派生,storage 里没有任何简历键。根因不是「忘了绑回调」,而是无处可写,所以先建数据层。处理口径:**实现功能,不是删按钮**。 + +- **覆盖层数据层**(新增类型 `ResumeOverride` / `ResumeItemInput` / `ResumeAIActionResult` / `ResumeExportOutcome`):简历按「**用户显式编辑过的板块 > 引导资料派生**」取值,键 `youzhi:resume:override`;**键出现(含 `[]`、`''`)即归用户,键不存在才用引导派生** —— 删光教育经历不会再冒出引导那条(`undefined` 被 `JSON.stringify` 丢弃,正好用 `??` 而非 `||` 区分「未编辑」与「清空」)。整表保存而非逐条写:逐条写会以空数组为基底,把引导派生的整块改丢。登出时一并清除该键(`clearResumeOverride`),避免换账号后看到上一个人的简历。 +- **7 个可写接口 + 4 个新页面**:`saveResumeBasicInfo / saveResumeSelfEvaluation / saveResumeSkills / saveResumeEducation / saveResumeExperience / saveResumeProjects / saveResumeSnapshot`;页面 `/resume/basic`、`/resume/education`、`/resume/experience`、`/resume/project`(编辑)、`/resume/preview`(只读预览)。**多字段板块走独立编辑页,技能与自我评价走页面内弹窗**(按字段量分开处理:弹窗装不下教育/工作/项目的字段量,编辑页对单字段又太重)。新增条目**不带 `id` 字段**(不是给空串 —— 空串不是 nullish,会被当成「已有 id」写成空),id 由 API 层补,同毫秒连加两条也不会撞号。 +- **头部「保存」补充功能**:把当前简历 6 个可编辑板块整体写入覆盖层 + 保存时间戳(`youzhi:resume:saved-at`),页面显示「上次保存:MM-DD HH:mm」(从未保存则不显示该行)。 +- **AI 类按钮:保留入口 + 坦诚提示**(`optimizeResumeExperience` / `polishResumeSelfEvaluation` / `optimizeResumeWithJD`):mock 恒返回 `available:false` + 一句「AI 改写需要后端模型支持,当前版本暂未开放;你的简历内容不会被改动」,页面只提示、不改简历。**绝不在正文未改写时把 `aiOptimized` 置 true** —— 那会让列表显示「已优化」徽标,用户等于被自己的界面骗了。参数加 `_` 前缀,去掉下划线即表示能力已接入。 +- **导出设置真正保存**:`saveResumeExportSettings` 原先是 `Object.assign(DEFAULT_EXPORT_SETTINGS, …)` —— 只改模块内常量,杀进程即丢,「保存」按钮实则保存不了,还会污染其它调用方读到的默认值。现改落 `youzhi:resume:export-settings`(读时用默认值补齐后加字段,存储损坏回落默认)。默认文件名原为演示人设「李明_前端工程师简历」,现为空串,由 `resolveExportFileName` 兜底成「姓名_期望职位简历」。模板 / 纸型 / 方向的选择弹层**就地在页内改设置、随「保存」落盘**(跳新页会出现「模板已存、格式没存」)。 +- **导出与分享真做**(新增依赖 `expo-print` / `expo-sharing` / `expo-file-system`):「立即导出」原先返回写死的 `https://cdn.example.com/resume/xxx.pdf` —— 不产出文件、页面还丢弃返回值、不给任何提示,是纯假成功。现改为**本机真出 PDF**:`buildResumeExportHtml`(纯函数,空板块整段略去、用户文本全转义、模板 id 白名单校验)→ `printToFileAsync` 按纸型 / 方向的真实页宽页高打印 → 重命名成用户设的文件名(打印引擎生成的是随机临时名,不改的话分享面板里是一串乱码);「分享简历」拉起系统面板分享这份文件。**封面页、自荐信、隐藏联系方式、纸型 / 方向四个开关由此第一次真正生效**(此前全是装饰):自荐信以自我评价为正文,**自我评价为空时不生成**(没有素材就编一封自荐信属于伪造内容)。Word / 图片格式与导出密码需后端导出服务,在**选中的那一刻**与导出成功后都写明。 +- **未做的验证(诚实口径)**:真机 smoke test(生成 PDF 并拉起分享面板)**尚未执行**;Expo Go 是否内置 `expo-print` / `expo-sharing` 本轮无法联网核实(Expo 文档被网络策略拦截)。已完成的替代验证:`expo-doctor` 20/21 通过(依赖版本与 SDK 57 要求一致)、`expo export --platform android` 打包通过(新模块 import 可解析)、两个原生封装失败时一律返回 `{ ok: false, message }` 并弹出原因,**不返回假链接**。若 Expo Go 确缺模块,症状是**启动即崩**(`requireNativeModule` 在 import 期执行,非静默失败),届时改用 development build 出包即可,代码无需改。 +- **审计工具常驻**:新增 `scripts/check-affordances.mjs`(扫「无处理函数 / 空处理函数」的 `Pressable`,支持 `affordance-ok` 豁免标记),与 `check-routes.mjs` 配对,加 `npm run check:affordances` / `check:routes` / `typecheck` 三个 script(暂不进 CI 硬门槛:其余 22 处未修完,`--error` 会一直红)。全仓死控件由 **37 → 22**,**简历域 0 处**(**22 → 0 于 2026-09-17 完成,见 4.8**)。 +- **测试**:新增 `resume-override.test.ts`(26 例:覆盖层语义、清空不回落到引导、编辑引导派生条目不改丢、同毫秒 id 不撞号、技能 trim 去重、存储损坏回落、AI 三接口调用前后简历深相等)、`resume-export.test.ts`(20 例:空板块不产出、隐藏联系方式、HTML 转义、模板 id 白名单、自荐信无素材不生成、纸型页宽、文件名清洗)。`npm test` **155 例全绿**;`tsc` / `eslint` / `check-routes`(72 路由 / 0 失效跳转)均 0 问题。 +- **本轮未动(待续做,已探明落点;截至 2026-09-17 均已清完,见 4.8)**:`app/job-evaluations.tsx` 筛选与回复(`replyJobReview` 与 hook 的 `reply` **已存在**,接线即可)、`app/holland-result.tsx` 行动卡(`HollandAction.route` 已定义)、`app/(enterprise)/enterprise-chat.tsx` 待处理任务项、`app/interview-score.tsx` 历史与草稿、`app/(tabs)/chat.tsx` 4 处(加入会议 / 导航需 `Linking` 封装,仓库 0 处)、`app/(tabs)/jobs.tsx` 搜索框 state 未传给 `useJobList`(**真 bug**)与 `FILTERS` 三项未实现、`app/tools/*` 若干。**注意**:`jobs.tsx` 与 `resume.tsx` 里成对的 `filterPanel` / `modalSheet` 空 `onPress` 是**吞冒泡守卫,需保留**(已加豁免标记,不属死控件)。 + +### 4.5 工程与上线 - **应用图标**:✅ 已完成(提交 `878710c`)。`assets/icon.png` 已替换为「有职」logo,实测 1024×1024,符合商店要求。 - **启动屏**:✅ 已接线。安装 `expo-splash-screen@57.0.9`,在 `app.config.js` 以 config plugin 形式配置 `assets/splash-icon.png`(1024×1024、`imageWidth: 200`、`resizeMode: 'contain'`、白底)。此前该资源为死资源且冷启动白屏。因应用为纯亮色(`userInterfaceStyle: 'light'`),未配置 `dark` 分支。**注意**:启动屏属原生配置,Expo Go 内不生效,需 `eas build` 出包后可见。 @@ -96,16 +134,81 @@ 两者均可由 `eas login` 后执行 `eas init` 自动注入,**不要手工填**。`ios.bundleIdentifier` / `android.package` 当前为 `com.youzhi.app`,可按开发者账号调整。 - **`updates`(OTA)块当前为注释状态,属有意为之**:见提交 `aa7b171`。占位的 `REPLACE_WITH_PROJECT_ID` 会让 Expo Go(开发期)启动时去拉取不存在的远程更新,报 "Failed to download remote update" 并蓝屏。待 `eas init` 注入真实 projectId 后再取消注释启用 OTA。 -- **真实后端**:全量 mock(`src/api/api.ts` 5088 行),未切换真实 `fetch`。 +- **真实后端**:全量 mock(`src/api/api.ts` 7385 行),未切换真实 `fetch`。 - **技术债**:8 处 `any` 软警告已全部清理(图标断言改为 `keyof typeof Ionicons.glyphMap`、路由断言改为 `as string`),`eslint .` 现 0 error 0 warning,`tsc --noEmit` 保持 0 错误。 +### 4.6 后端接口预留约定(2026-09-15) + +**口径**(用户 2026-09-15 拍板):**需要展示数据的地方照常展示;每个数据函数都要把真实后端请求写好、注释着;接后端时放开注释即可。** + +- **落地**:`src/api/api.ts` 127 个 `export async function` **全部**在函数体内留了真实请求,分两式(约定写在该文件顶部): + - **纯 mock 返回**的函数 → `// TODO: 真实请求(后端就绪后替换)` + 注释的 `const res = await fetch(...)` / `return res.json();` + - **以本地存储为唯一真相源**的函数(引导资料、简历编辑覆盖层、导出设置、薪资记录等)→ 单行 `// TODO: 后端就绪后改为 await fetch(...);当前以本地存储为唯一真相源` —— 放开时要**把本地写换成请求**,不是加一行。 + - **AI 能力**(简历三处):参数名带 `_` 前缀 = 尚未接入;**去掉下划线 + 放开注释** = 该能力上线。 +- **本轮补齐**:原先 29 个函数(简历域 6 + 运营/企业端/游戏/支付/隐私设置 23)正文里连注释版请求都没有,现已全部补上;放宽的判定是「函数体里出现过 `fetch(`」,**注释着的也算**,因此不依赖注释写法。 +- **常驻审计**:新增 `scripts/check-backend-stubs.mjs`(`npm run check:backend-stubs`,与 `check-routes` / `check-affordances` 配对)。确实不该接后端的函数,在函数体内标 `backend-stub-ok` 豁免。与 `check-affordances` 一样**暂不进 CI 硬门槛**。 +- **不做的**:不为了「看起来有接口」而编造未定义的 URL —— 上表 29 个函数的 `@url` 在各自 JSDoc 里本来就已定义,本轮只是把**已定义的接口**落成注释代码。 +- **沿用上轮口径**:`/resume` 的「简历评分 72 分」**保留展示、不加「示例」标注**(评分是产品的 AI 能力,接口已在 `fetchResume` 内预留注释版,后端就绪后由后端下发);但它目前仍是写死的 mock,与用户数据无关,这一点不变。 + +### 4.7 引导页重做 / 运营商一键登录修复(2026-09-16 批次) + +> 该批次与 4.8 同属长期未提交的工作区改动,一并入库(见 4.8 末条)。 + +**① 一键登录取不到手机号 —— 根因不在取号,在页面根本没读结果。** `P2QuickLogin`(P2Q)有两个独立缺陷叠加,表现为「号码永远显示占位」: + +| # | 缺陷 | 现状态 | +|---|---|---| +| 1 | 页面读的是 `data.phone` —— 该字段**只有短信登录通道(P2)才写入**,走一键登录进来时恒为空 | ✅ 改读 `useCarrierPhone()` 的 `maskedPhone` | +| 2 | `loginByCarrier()` 返回的 `phone` **被直接丢弃**,后端换了真号码也传不下去 | ✅ 登录成功后 `setData({ phone: result.phone })` 写回引导资料(P2P/P2R 预填、B3 联系人电话同源) | + +- **新增取号链路**:`fetchCarrierPhone()`(接口层取号 + `maskPhone` 脱敏)+ `useCarrierPhone`(进页面即预取,loading / error / refresh 齐备)+ `loginByCarrier(carrierToken)` 换登录态。**空令牌直接 reject**,页面据此置灰「一键登录」(空令牌调登录接口必然失败,不如不让点)。 +- **补上失败分支**(此前不存在:取号失败也照样显示号码、按钮照样可点):号码位分「正在获取 / 未能获取」,并给出**重新获取**与**改用验证码登录**两个出口;`carrierToken` 空的判定取代了原先写死的「本机号码」占位。 +- **不冒充的能力**:《xx认证服务条款》由运营商在认证页内提供,本仓库没有该文档,故只弹如实说明,不做成能打开的链接;页脚服务热线为空时整块不渲染。 +- **真实取号是端上原生 SDK(Expo Go 跑不了)**,当前 `fetchCarrierPhone` 返回写死号码 `15000009367` 用于跑通页面链路。接入路线图、两个硬约束(端上号码不可信 / 令牌一次性 5 分钟)、6 步执行清单与手动验收路径见新增文档 **`运营商一键登录接入方案.md`**(`src/api/api.ts` 的 JSDoc 原先就指向该文件名,此前文档缺失,本轮补齐)。 + +**② 引导页(`/guide`)重做 + 补「重新查看引导」入口。** + +- **有出口就必须有入口**:引导页此前只能由首次启动时 `/` 的重定向进入,看过一次就再也进不去。`app/settings.tsx` 菜单已加 `{ id: 'guide', title: '查看引导页', route: '/guide' }`。 +- **两种进入方式、出口不同**(写错会把已登录用户甩回注册流程):首页跳来 → 看完 `replace('/register')`;设置页回顾(`isTokenValid`)→ 只 `back()`,不改引导标记、不进注册流程。 +- **内容与配图重做**:四屏文案全部重写(聚焦差异化能力),配图改走新增组件 `ImageSlot`。 +- **配图位带文字标注**(按 2026-09-16 要求「要配图的地方页面留有文字标注放什么图」):素材未到位时页面上画虚线占位框 + 「配图待补」+ **该放什么图**的说明(取自 `assets.ts` 的 `hint`),补图后标注自动消失。新增 4 个引导页素材位 `guideEvaluate` / `guideWeightDim` / `guideGameMap` / `guideLegalAI`,**换真图只改 `assets.ts` 里对应一行的 `source`**。与 `Illustration` 的分工:那个是小图标位(头像 / Logo),占位只画色块;`ImageSlot` 是内容大图位,占位必须自解释。 + - **待补素材**:`assets.ts` 注册 17 个素材位,其中 **16 个 `source` 仍为 `null`**(含引导页 4 张,另一张 `logo` 已是真图)。 +- **「首次启动没出现」的核查结论**:`app/index.tsx`(本轮未改动)在 hydration 完成后按 `onboardingSeen` 放行 —— 没看过即 `Redirect /guide`,默认值 `false`(`AuthContext` 的 `memoryAuth`),存储里没有该键时不会误判为已看过。因此**跳过引导只可能来自「本机已看过一次」**(看完即 `completeOnboarding()` 写入 `youzhi:auth`),属设计行为;要复现真·首次启动需清应用数据或卸载重装。**该结论为代码走查所得,本机未复现(无设备可测)。** +- **顺带**:设置页「清除缓存」的体积改为运行时读取(`useAppCache` + `src/lib/cache.ts`),不再写死数字;页脚服务热线抽成 `SupportFooter`(号码为空则整块不渲染)。 + +### 4.8 全仓死控件清零(2026-09-17) + +**结果**:`node scripts/check-affordances.mjs` **9 处 → 0 处**,全仓再无「点了没反应」的 `Pressable`。上一轮遗留的 9 处逐项落地(口径仍是**实现功能,不是删按钮**;确实做不到的就删入口或写明原因): + +1. **企业沟通 `app/(enterprise)/enterprise-chat.tsx`**:待处理任务卡(待回复 / 待评分)此前无 `onPress` → 现「待回复」切到未回复 Tab、「待评分」跳 `/interview-score`,去向由接口层 `PendingTask.tabKey` / `route` 下发;搜索框接真实过滤;Tab 计数与列表**同源** —— `buildCommunicationTabs` / `buildPendingTasks` 均由会话列表推导(此前页面写死的 12/3/2/1/4 与 3 条示例数据对不上,会出现「Tab 上写 3、列表 0 条」)。 +2. **人才推荐 `app/(enterprise)/talent-recommendation.tsx`**:顶部「搜索 + 漏斗」两枚图标 —— 漏斗无对应页面 → **摘掉**;搜索改成真关键词过滤(姓名 / 职位 / 技能标签 / 学校)。「调整筛选条件」原为弹「功能开发中」→ 改为真复位(切回全部 + 清关键词)。Tab 计数同样由候选人列表推导(`matchTalentTab`:匹配度 ≥90 / 985-211 院校名单 / 经验 ≥5 年 / 硕士及以上)。「查看人才市场报告」仍无对应页面 → 保留入口 + 明确提示,不假装跳转。 +3. **申诉举证 `app/appeal.tsx`**:原为空 `onPress` → 接真实选图(新增 `src/lib/imagePicker.ts` + `useImagePick`),可拍照 / 相册 / 移除缩略图,证据随 `submitAppeal` 一并提交。**PDF 类材料后端上传接口不存在,在页面上写明**,而不是给个点不动的按钮。 +4. **候选人详情 `app/candidate-detail.tsx`**:「发起沟通」→ `/chat/[id]`(此前点不动)。 +5. **面试评分 `app/interview-score.tsx`**:「保存草稿」接真实本地暂存(键 `youzhi:interview:score-draft:`,**按面试 id 分开不串台**),再次进入显示「检测到未提交草稿」+ 恢复 / 放弃;提交成功后才清草稿。 +6. **入职准备清单 `app/onboarding.tsx`**:勾选状态**落库**(此前退出再进入勾选全丢,`completed` 恒为 3/18);新增自定义待办分组(**只有这一组可删**,模板条目删了下次进页面还会出现,接口层直接 reject);「导出 / 分享」由 `buildChecklistText` 生成纯文本经系统分享面板发出(`shareText`)。 +7. **微信验证码页 `components/onboarding/screens/P2WechatCode.tsx`**:点验证码输入区未聚焦 → 加 `inputRef` 聚焦。 + +**顺带修掉的两类「假入口」**(同属「不留假交互」口径): + +- **薪资计算器「更多工具」**原先三项都指回计算器自己(点了等于没点)→ 改由接口层 `fetchSalaryToolLinks` 下发真实路由(`/jobs/offer-compare`、`/jobs/rank`、`/tools/skill-cloud`);无路由的项渲染成纯文本、不显示箭头。 +- **技能词云**:「生成学习路径」原无实现 → 接口层 `fetchSkillLearningPath` 按行业技能词权重取待补技能(条数按匹配度缺口换算、限定 3–6 条),页面弹层展示;词云搜索接真实过滤(`filterSkillWords`)。 + +**测试**:新增 3 个文件 **51 例** —— `onboarding-checklist.test.ts`(清单落库 / 自定义条目增删 / 导出文本 / 存储损坏回落)、`enterprise-filters.test.ts`(两页 Tab + 关键词过滤 / **Tab 计数与过滤结果逐项一致** / 学习路径规则)、`interview-score-draft.test.ts`(草稿往返 / 不串台 / 覆盖保存 / 幂等清除)。**存储 mock 由「恒返回 null」改为带状态的 Map** —— 否则「存了要能读回来」这类行为根本没被验证。`npm test` **219 例全绿**;`tsc --noEmit` / `eslint .` 0 问题。 + +**三个审计脚本全绿**:`check-routes`(72 路由 / 0 失效跳转)、`check-affordances`(**0 处**)、`check-backend-stubs`(**137** 个接口函数均留注释版真实请求)。 +> 注:`check-routes` 另报 12 个「无入口指向的孤立页」(`/jobs/[id]`、`/chat/[id]`、`/legal/checkout`、`/settings/privacy/[id]`、`/growth`、`/profile` 等),已逐个核对**均为误报** —— 它们是动态路由或 Tab 页,入口由模板字符串(`router.push(\`/jobs/${id}\`)`)或 Tab 配置给出,静态扫描看不到。 + +**提交**:本批次(4.7 + 4.8)与前一日积压的工作区改动一并入库;`package-lock.json`(本项目统一用 `yarn.lock`)与调试截图 `shot-check.png` 加入 `.gitignore`,不入库。 + --- ## 五、后续增量开发建议(优先级) -- **P0(上架前必做)**:~~图标替换~~ **应用图标【已完成,`878710c`,1024×1024「有职」logo】** · ~~隐私 / 协议内容填充~~ **隐私/协议草案【已存在,含 18 岁门槛,待填【待确认】占位】** · ~~EAS 初始化~~ **EAS 配置骨架【已完成,`owner` / `projectId` 两处占位符阻塞 `eas build`,需 `eas init` 注入,详见 4.3】** · ~~18 岁门槛核对~~ **18 岁门槛【已在隐私政策第六章 + 用户协议前言落地】** · ~~微信登录接入评估~~ **微信登录接入评估【已完成 → 见 `微信登录接入方案.md`,SDK 接入阻塞于微信开放平台 AppID】** · ~~启动屏接线~~ **启动屏【已完成,`expo-splash-screen` + `splash-icon.png`,需出包验证】**。 +- **P0(上架前必做)**:~~图标替换~~ **应用图标【已完成,`878710c`,1024×1024「有职」logo】** · ~~隐私 / 协议内容填充~~ **隐私/协议草案【已存在,含 18 岁门槛,待填【待确认】占位】** · ~~EAS 初始化~~ **EAS 配置骨架【已完成,`owner` / `projectId` 两处占位符阻塞 `eas build`,需 `eas init` 注入,详见 4.5】** · ~~18 岁门槛核对~~ **18 岁门槛【已在隐私政策第六章 + 用户协议前言落地】** · ~~微信登录接入评估~~ **微信登录接入评估【已完成 → 见 `微信登录接入方案.md`,SDK 接入阻塞于微信开放平台 AppID】** · ~~启动屏接线~~ **启动屏【已完成,`expo-splash-screen` + `splash-icon.png`,需出包验证】**。 +- **P0(补充 · 2026-09-15 新发现)**:① ~~页脚服务热线 / 举报监督电话待填~~ **已填 `15154608969`**(`src/constants/index.ts` 的 `SUPPORT_HOTLINE` / `SUPERVISION_PHONE`,为空时页脚整块不渲染、填入即自动出现并接 `tel:` 拨号);**隐私政策里【待确认】的客服电话应与之对齐**(同一个号,两处别填成两个)。② **缺「资质证照」页** —— 项目内没有该页面(`app/legal.tsx` 是「阿布阿诺法律服务」付费咨询页,不可复用),上架前需新建并填入营业执照 / ICP / 人力资源服务许可证等信息。③ **注册引导链 + 简历导出的真机手工验证均未执行**(见 4.3 / 4.4 末条)。 - **P1(核心差异化)**:~~微信登录接入方案~~ **微信登录接入方案【已落文档,真实 SDK 接入阻塞于微信开放平台移动应用申请】** · ~~企业端快捷入口话术统一~~ **企业端快捷入口话术统一【已完成,6 处 label 对齐规范词;含沟通页快捷操作 onPress 接线,tsc/eslint 0 问题】** · ~~付费法律服务支付~~ **付费法律服务支付【已完成:mock 支付闭环,选套餐→结算→模拟支付→会员激活持久化,tsc/eslint 0 问题】** · ~~通关小游戏 7 关内容~~ **通关小游戏 7 关内容【已完成:结构原已建成,本次增厚至 7 关 22 题,tsc/eslint 0 问题】**。 -- **P2(打磨)**:~~技术债清理~~ **技术债已清理(8 处 `any` → 明确类型,`eslint` 0 warning)** · 真实后端对接 · OTA 通道验证。 +- **P2(打磨)**:~~技术债清理~~ **技术债已清理(8 处 `any` → 明确类型,`eslint` 0 warning)** · ~~死控件清零~~ **死控件已清零(22 → 0,2026-09-17,见 4.8)** · 真实后端对接 · OTA 通道验证。 +- **P1(补充 · 2026-09-17)**:① **运营商一键登录取号**需运营商号码认证服务 + EAS 自定义客户端,接入路线图见 `运营商一键登录接入方案.md`(端上换 1 处 SDK 调用 + 后端换 1 个接口,页面/hook/类型零改动);② **引导页 4 张配图**待提供素材(`assets.ts` 的 `guideEvaluate` / `guideWeightDim` / `guideGameMap` / `guideLegalAI`,换图只改 `source` 一行);③ **`assets.ts` 内 16 个素材位 `source` 仍为 `null`**(营业执照示例图、5 张预设头像、3 张场景插画等)—— 未补时按约定降级:引导页那 4 张显示「配图待补 + 该放什么图」标注,头像 / 插画类降级为色块 + 图标(可用的占位态,非空白)。 --- @@ -121,9 +224,21 @@ npx eslint . # 路由闭合审计(改动路由后必跑) node scripts/check-routes.mjs +# 死控件审计(改动交互后必跑;豁免标记 affordance-ok) +node scripts/check-affordances.mjs + +# 后端接口预留审计(新增/改接口后必跑;豁免标记 backend-stub-ok) +node scripts/check-backend-stubs.mjs + # 单元测试(src/__tests__ 下纯逻辑) npm test +# 依赖与 SDK 一致性(动过依赖后跑,1 项预存问题:yarn.lock 与 package-lock.json 并存) +npx expo-doctor + +# 打包自检(能过说明新 import 可解析,不替代真机验证) +npx expo export --platform android --output-dir <临时目录> + # 启动 npm start # 即 expo start ```