Browse Source

feat: 死控件清零并修复一键登录取号、重做引导页

一键登录(P2Q)取不到手机号的根因不在取号:页面读的是 data.phone,而该字段
只有短信通道才写入,且 loginByCarrier 返回的 phone 被直接丢弃,于是号码位恒为
占位「本机号码」。现改为进页面即预取(useCarrierPhone + fetchCarrierPhone),
失败时置灰按钮并给出「重新获取 / 改用验证码登录」,成功后把号码写回引导资料
(P2P/P2R 预填与 B3 联系人电话同源)。真实取号是运营商原生 SDK,Expo Go 跑不了,
接入路线图见新增文档《运营商一键登录接入方案.md》。

引导页重做四屏内容与配图,配图位走新增组件 ImageSlot:素材未到位时在页面上标注
「配图待补」+ 该放什么图,补图只改 assets.ts 的 source 一行;设置页补「查看引导页」
入口(此前只有首次启动能进,看过一次就再也进不去),并区分「首次启动」与
「已登录回顾」两种出口,避免把已登录用户甩回注册流程。

企业沟通 / 人才推荐两页的 Tab 与搜索真正生效:Tab 计数与待办数量改由列表推导
(原先写死的数字与示例数据对不上,会出现「Tab 写 3、列表 0 条」),无对应页面的
漏斗图标摘除,筛选复位不再只是弹提示;申诉举证接真实选图,候选人详情「发起沟通」
跳会话页,面试评分支持本地草稿与恢复,入职清单勾选落库并可增删自定义待办、导出文本,
薪资计算器「更多工具」改指真实路由(原先三项都指回自己),技能词云接搜索与学习路径生成。

测试新增 3 个文件 51 例(共 219 例全绿),存储 mock 由「恒返回 null」改为带状态 Map,
使「存了要能读回来」这类行为真正被验证;tsc / eslint / 三个审计脚本(0 失效跳转、
0 死控件、137 个接口预留)均 0 问题。

另:package-lock.json(本项目统一用 yarn.lock)与本地调试截图加入 .gitignore。
master
doublex001 1 month ago
parent
commit
00522d53f9
  1. 7
      .gitignore
  2. 5
      AGENTS.md
  3. 12
      app.config.js
  4. 99
      app/(enterprise)/enterprise-chat.tsx
  5. 145
      app/(enterprise)/talent-recommendation.tsx
  6. 104
      app/(tabs)/chat.tsx
  7. 11
      app/(tabs)/jobs.tsx
  8. 33
      app/(tabs)/profile.tsx
  9. 98
      app/appeal.tsx
  10. 8
      app/candidate-detail.tsx
  11. 91
      app/guide.tsx
  12. 23
      app/holland-result.tsx
  13. 5
      app/holland.tsx
  14. 75
      app/interview-score.tsx
  15. 4
      app/interview.tsx
  16. 217
      app/job-evaluations.tsx
  17. 126
      app/onboarding.tsx
  18. 4
      app/register.tsx
  19. 644
      app/resume.tsx
  20. 136
      app/resume/basic.tsx
  21. 272
      app/resume/education.tsx
  22. 323
      app/resume/experience.tsx
  23. 340
      app/resume/export.tsx
  24. 240
      app/resume/preview.tsx
  25. 225
      app/resume/project.tsx
  26. 41
      app/settings.tsx
  27. 48
      app/tools/salary-calculator.tsx
  28. 313
      app/tools/skill-cloud.tsx
  29. 70
      components/onboarding/assets.ts
  30. 4
      components/onboarding/screens/B1Wechat.tsx
  31. 4
      components/onboarding/screens/B2Agreement.tsx
  32. 94
      components/onboarding/screens/B3Recruiter.tsx
  33. 9
      components/onboarding/screens/P10FirstWork.tsx
  34. 39
      components/onboarding/screens/P11CurrentPosition.tsx
  35. 4
      components/onboarding/screens/P12Company.tsx
  36. 16
      components/onboarding/screens/P13JoinDate.tsx
  37. 4
      components/onboarding/screens/P14Skills.tsx
  38. 12
      components/onboarding/screens/P16WorkContent.tsx
  39. 4
      components/onboarding/screens/P17Reference.tsx
  40. 7
      components/onboarding/screens/P18Education.tsx
  41. 14
      components/onboarding/screens/P1Login.tsx
  42. 4
      components/onboarding/screens/P20School.tsx
  43. 74
      components/onboarding/screens/P21Avatar.tsx
  44. 10
      components/onboarding/screens/P22Done.tsx
  45. 181
      components/onboarding/screens/P23Home.tsx
  46. 14
      components/onboarding/screens/P2PhoneCode.tsx
  47. 108
      components/onboarding/screens/P2QuickLogin.tsx
  48. 41
      components/onboarding/screens/P2WechatCode.tsx
  49. 29
      components/onboarding/screens/P3Identity.tsx
  50. 14
      components/onboarding/screens/P4Intent.tsx
  51. 13
      components/onboarding/screens/P5City.tsx
  52. 60
      components/onboarding/screens/P6ExpectedPosition.tsx
  53. 22
      components/onboarding/screens/P7Salary.tsx
  54. 13
      components/onboarding/screens/P8Resume1.tsx
  55. 8
      components/onboarding/screens/P9JobStatus.tsx
  56. 18
      components/onboarding/ui/Avatar.tsx
  57. 12
      components/onboarding/ui/Illustration.tsx
  58. 64
      components/onboarding/ui/ImageSlot.tsx
  59. 5
      components/onboarding/ui/ScreenShell.tsx
  60. 43
      components/onboarding/ui/SupportFooter.tsx
  61. 72
      components/onboarding/ui/WheelPicker.tsx
  62. 209
      components/resume/EditKit.tsx
  63. 10
      package.json
  64. 86
      scripts/check-affordances.mjs
  65. 47
      scripts/check-backend-stubs.mjs
  66. 73
      src/__tests__/carrier-login.test.ts
  67. 282
      src/__tests__/enterprise-filters.test.ts
  68. 62
      src/__tests__/enterprise-reviews.test.ts
  69. 96
      src/__tests__/interview-score-draft.test.ts
  70. 255
      src/__tests__/job-match.test.ts
  71. 221
      src/__tests__/onboarding-checklist.test.ts
  72. 162
      src/__tests__/onboarding-profile.test.ts
  73. 240
      src/__tests__/resume-export.test.ts
  74. 363
      src/__tests__/resume-override.test.ts
  75. 245
      src/__tests__/resume-profile.test.ts
  76. 74
      src/__tests__/salary-options.test.ts
  77. 97
      src/__tests__/user-profile.test.ts
  78. 91
      src/__tests__/utils.test.ts
  79. 1781
      src/api/api.ts
  80. 35
      src/api/index.ts
  81. 195
      src/constants/index.ts
  82. 5
      src/hooks/index.ts
  83. 50
      src/hooks/useAppCache.ts
  84. 18
      src/hooks/useAppealForm.ts
  85. 44
      src/hooks/useCarrierPhone.ts
  86. 33
      src/hooks/useEnterpriseCommunication.ts
  87. 42
      src/hooks/useImagePick.ts
  88. 67
      src/hooks/useInterviewScore.ts
  89. 9
      src/hooks/useLogin.ts
  90. 10
      src/hooks/useMessageData.ts
  91. 32
      src/hooks/useOnboardingChecklist.ts
  92. 178
      src/hooks/useOnboardingFlow.tsx
  93. 9
      src/hooks/useRegisterFinish.ts
  94. 141
      src/hooks/useResume.ts
  95. 38
      src/hooks/useResumeExport.ts
  96. 32
      src/hooks/useSalaryToolLinks.ts
  97. 21
      src/hooks/useSkillCloud.ts
  98. 48
      src/hooks/useSkillLearningPath.ts
  99. 33
      src/hooks/useTalentRecommendation.ts
  100. 46
      src/lib/cache.ts

7
.gitignore

@ -60,3 +60,10 @@ figma_tree.mjs
# 商店截图素材:保留目录约定文档,排除二进制与生成物 # 商店截图素材:保留目录约定文档,排除二进制与生成物
screenshots/**/*.png screenshots/**/*.png
screenshots/**/*.html screenshots/**/*.html
# 包管理器锁文件:本项目统一用 yarn.lock,package-lock.json 由 npm 误生成,不入库
package-lock.json
# 调试截图(本地排查用,非资产)
shot-check.png
shot-*.png

5
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 仓库:https://gitea.baskbuddy.com/baskbuddy/mydreamjob
请在每周的周一-周五的9:20的时候拉取一次远端仓库、每天17:50提交一次git仓库,其他时间可以改动但不需要提交,说明今天新增与改动的内容,上班时间是9:00-18:00,有页面出口就一定要有入口。 请在每周的周一-周五的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 无需改动。 仅替换 src/api/api.ts 中函数体为真实 fetch(签名不变),页面与 hooks 无需改动。

12
app.config.js

@ -49,6 +49,18 @@ export default {
plugins: [ plugins: [
'expo-router', 'expo-router',
'expo-font', 'expo-font',
// 拍照 / 相册取图(营业执照、头像)。原生权限文案只有出包时生效,
// Expo Go 里用的是 Expo Go 自己的 Info.plist,故此处改动需真机出包验证。
[
'expo-image-picker',
{
// 本项目只选静态图、不录视频:不申请麦克风权限(最小必要原则),
// 插件默认会加 RECORD_AUDIO,这里显式关掉
microphonePermission: false,
photosPermission: '有职需要访问相册,用于上传营业执照与设置头像',
cameraPermission: '有职需要使用相机,用于拍摄营业执照与设置头像',
},
],
// 启动屏:冷启动期间展示品牌 logo,消除白屏。 // 启动屏:冷启动期间展示品牌 logo,消除白屏。
// 资源为 assets/splash-icon.png(1024×1024,见 assets/)。 // 资源为 assets/splash-icon.png(1024×1024,见 assets/)。
// 应用为纯亮色(userInterfaceStyle: 'light'),故不配置 dark 分支。 // 应用为纯亮色(userInterfaceStyle: 'light'),故不配置 dark 分支。

99
app/(enterprise)/enterprise-chat.tsx

@ -1,6 +1,5 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
@ -8,6 +7,7 @@ import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; 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 ( return (
<View style={styles.pendingCard}> <View style={styles.pendingCard}>
<Text style={styles.pendingTitle}>待处理</Text> <Text style={styles.pendingTitle}>待处理</Text>
@ -84,7 +90,11 @@ function PendingTasks({ tasks }: { tasks: PendingTask[] }) {
? colors.compareOrange ? colors.compareOrange
: colors.primary; : colors.primary;
return ( return (
<Pressable key={task.id} style={styles.pendingItem}> <Pressable
key={task.id}
style={styles.pendingItem}
onPress={() => onPressTask(task)}
>
<View style={[styles.pendingIconWrap, { backgroundColor: token + '15' }]}> <View style={[styles.pendingIconWrap, { backgroundColor: token + '15' }]}>
<Ionicons name={task.icon as keyof typeof Ionicons.glyphMap} size={22} color={token} /> <Ionicons name={task.icon as keyof typeof Ionicons.glyphMap} size={22} color={token} />
</View> </View>
@ -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 ( return (
<View style={styles.searchBar}> <View style={styles.searchBar}>
<Ionicons name="search-outline" size={18} color={colors.textHint} /> <Ionicons name="search-outline" size={18} color={colors.textHint} />
<Text style={styles.searchPlaceholder}>搜索候选人...</Text> <TextInput
style={styles.searchInput}
value={value}
onChangeText={onChange}
placeholder="搜索候选人姓名 / 职位 / 消息"
placeholderTextColor={colors.textHint}
returnKeyType="search"
/>
{value !== '' && (
<Pressable onPress={onClear} hitSlop={8}>
<Ionicons name="close-circle" size={16} color={colors.textHint} />
</Pressable>
)}
</View> </View>
); );
} }
@ -225,8 +255,18 @@ const QUICK_ACTIONS: QuickActionItem[] = [
export default function EnterpriseChatScreen() { export default function EnterpriseChatScreen() {
const router = useRouter(); const router = useRouter();
const { communication, loading, error, refresh } = useEnterpriseCommunication(); const {
const [activeTab, setActiveTab] = useState('all'); communication,
activeTab,
setActiveTab,
keyword,
setKeyword,
chats,
isEmptySearch,
loading,
error,
refresh,
} = useEnterpriseCommunication();
// 快捷操作分发:有目标路由则跳转,无对应页面则提示「功能开发中」 // 快捷操作分发:有目标路由则跳转,无对应页面则提示「功能开发中」
const handleQuickAction = (action: QuickActionItem) => { 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) { if (loading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
@ -270,11 +324,19 @@ export default function EnterpriseChatScreen() {
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{communication.hasData ? ( {communication.hasData ? (
<View> <View>
<PendingTasks tasks={communication.pendingTasks} /> <PendingTasks tasks={communication.pendingTasks} onPressTask={handlePressTask} />
<SearchBar /> <SearchBar value={keyword} onChange={setKeyword} onClear={() => setKeyword('')} />
{chats.length === 0 ? (
<View style={styles.emptyList}>
<Ionicons name="chatbubbles-outline" size={32} color={colors.textHint} />
<Text style={styles.emptyListText}>
{isEmptySearch ? `没有匹配「${keyword.trim()}」的会话` : '这个分类下暂时没有会话'}
</Text>
</View>
) : (
<View style={styles.chatListCard}> <View style={styles.chatListCard}>
{communication.chats.map((chat, index) => { {chats.map((chat, index) => {
const isLast = index === communication.chats.length - 1; const isLast = index === chats.length - 1;
return ( return (
<View key={chat.id} style={isLast ? undefined : styles.chatBorder}> <View key={chat.id} style={isLast ? undefined : styles.chatBorder}>
<ChatRow chat={chat} /> <ChatRow chat={chat} />
@ -282,6 +344,7 @@ export default function EnterpriseChatScreen() {
); );
})} })}
</View> </View>
)}
<QuickActions actions={QUICK_ACTIONS} onPressAction={handleQuickAction} /> <QuickActions actions={QUICK_ACTIONS} onPressAction={handleQuickAction} />
</View> </View>
) : ( ) : (
@ -448,11 +511,21 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
marginBottom: 12, marginBottom: 12,
}, },
searchPlaceholder: { searchInput: {
flex: 1,
marginLeft: 8, marginLeft: 8,
fontSize: 14, 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: { chatListCard: {
backgroundColor: colors.white, backgroundColor: colors.white,
borderRadius: 12, borderRadius: 12,

145
app/(enterprise)/talent-recommendation.tsx

@ -8,6 +8,7 @@ import {
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; 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 ( return (
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
@ -28,17 +40,54 @@ function Header({ companyName, onBack }: { companyName: string; onBack: () => vo
<Text style={styles.headerTitle}>人才推荐</Text> <Text style={styles.headerTitle}>人才推荐</Text>
</View> </View>
<View style={styles.headerActions}> <View style={styles.headerActions}>
<Pressable style={styles.headerActionBtn} hitSlop={6}> {/* 原先这里是「搜索 + 漏斗」两个图标:漏斗没有对应页面,先摘掉;
<Ionicons name="search-outline" size={22} color={colors.textPrimary} /> 搜索改成真的关键词过滤(姓名 / 职位 / 技能 / 学校) */}
</Pressable> {onToggleSearch && (
<Pressable style={styles.headerActionBtn} hitSlop={6}> <Pressable style={styles.headerActionBtn} onPress={onToggleSearch} hitSlop={6}>
<Ionicons name="options-outline" size={22} color={colors.textPrimary} /> <Ionicons
name={searching ? 'close' : 'search-outline'}
size={22}
color={colors.textPrimary}
/>
</Pressable> </Pressable>
)}
</View> </View>
</View> </View>
); );
} }
/* ===================== 子组件:搜索栏 ===================== */
function SearchBar({
value,
onChange,
onClear,
}: {
value: string;
onChange: (text: string) => void;
onClear: () => void;
}) {
return (
<View style={styles.searchBar}>
<Ionicons name="search-outline" size={18} color={colors.textHint} />
<TextInput
style={styles.searchInput}
value={value}
onChangeText={onChange}
placeholder="搜索姓名 / 职位 / 技能"
placeholderTextColor={colors.textHint}
autoFocus
returnKeyType="search"
/>
{value !== '' && (
<Pressable onPress={onClear} hitSlop={8}>
<Ionicons name="close-circle" size={16} color={colors.textHint} />
</Pressable>
)}
</View>
);
}
/* ===================== 子组件:Tab 栏 ===================== */ /* ===================== 子组件:Tab 栏 ===================== */
function TabBar({ function TabBar({
@ -233,8 +282,24 @@ const POOL_ACTIONS: PoolActionItem[] = [
export default function TalentRecommendationScreen() { export default function TalentRecommendationScreen() {
const router = useRouter(); const router = useRouter();
const { recommendation, loading, error, refresh } = useTalentRecommendation(); const {
const [activeTab, setActiveTab] = useState('all'); 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 同约定。 // 避免按钮点击后无任何反馈。与 enterprise-chat.tsx 的 handleQuickAction 同约定。
@ -252,8 +317,10 @@ export default function TalentRecommendationScreen() {
Alert.alert('提示', '人才市场报告功能开发中,敬请期待'); Alert.alert('提示', '人才市场报告功能开发中,敬请期待');
}; };
/** 空状态里的「调整筛选条件」:把筛选真正复位(切回全部 + 清关键词) */
const handleFilter = () => { const handleFilter = () => {
Alert.alert('提示', '筛选条件功能开发中,敬请期待'); setActiveTab('all');
setKeyword('');
}; };
if (loading) { if (loading) {
@ -284,8 +351,16 @@ export default function TalentRecommendationScreen() {
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<Header companyName={recommendation.companyName} onBack={() => router.back()} /> <Header
companyName={recommendation.companyName}
onBack={() => router.back()}
searching={searching}
onToggleSearch={toggleSearch}
/>
<TabBar tabs={recommendation.tabs} active={activeTab} onChange={setActiveTab} /> <TabBar tabs={recommendation.tabs} active={activeTab} onChange={setActiveTab} />
{searching && (
<SearchBar value={keyword} onChange={setKeyword} onClear={() => setKeyword('')} />
)}
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{recommendation.hasData ? ( {recommendation.hasData ? (
<View> <View>
@ -293,9 +368,23 @@ export default function TalentRecommendationScreen() {
count={recommendation.todayCount} count={recommendation.todayCount}
onReport={handleReport} onReport={handleReport}
/> />
{recommendation.candidates.map((candidate) => ( {candidates.length === 0 ? (
<View style={styles.emptyList}>
<Ionicons name="search-outline" size={32} color={colors.textHint} />
<Text style={styles.emptyListText}>
{isEmptySearch
? `没有匹配「${keyword.trim()}」的候选人`
: '这个筛选条件下暂时没有候选人'}
</Text>
<Pressable style={styles.emptyListBtn} onPress={handleFilter} hitSlop={6}>
<Text style={styles.emptyListBtnText}>重置筛选</Text>
</Pressable>
</View>
) : (
candidates.map((candidate) => (
<CandidateCard key={candidate.id} candidate={candidate} /> <CandidateCard key={candidate.id} candidate={candidate} />
))} ))
)}
<TalentPool <TalentPool
stats={recommendation.poolStats} stats={recommendation.poolStats}
actions={POOL_ACTIONS} actions={POOL_ACTIONS}
@ -336,6 +425,38 @@ const styles = StyleSheet.create({
width: 36, width: 36,
alignItems: 'center', alignItems: 'center',
}, },
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,
},
emptyList: {
alignItems: 'center',
gap: 10,
paddingVertical: 32,
backgroundColor: colors.white,
borderRadius: 14,
},
emptyListText: { fontSize: 13, color: colors.textSecondary },
emptyListBtn: {
paddingHorizontal: 18,
paddingVertical: 8,
borderRadius: 16,
backgroundColor: colors.primaryLight,
},
emptyListBtnText: { fontSize: 13, fontWeight: '600', color: colors.primaryText },
tabBar: { tabBar: {
flexDirection: 'row', flexDirection: 'row',
backgroundColor: colors.white, backgroundColor: colors.white,

104
app/(tabs)/chat.tsx

@ -4,6 +4,8 @@ import { useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Linking,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -77,9 +79,42 @@ function NotificationCard({ notification }: { notification: Notification }) {
); );
} }
/**
* 面试卡片的跳转目标:视频面试取会议链接,线下面试取地址导航。
* 由接口层给出的字段决定,字段缺失就返回 null —— 页面据此不渲染按钮,
* 而不是渲染一个点不动的「加入会议」。
*/
function interviewActionTarget(interview: Interview): { url: string; label: string } | null {
if (interview.type === 'video') {
return interview.meetingUrl
? { url: interview.meetingUrl, label: '加入会议' }
: null;
}
return interview.location
? // 高德 URI API:callnative=1 优先唤起高德 App,未安装则落网页版
{
url: `https://uri.amap.com/search?keyword=${encodeURIComponent(interview.location)}&src=youzhi&callnative=1`,
label: '导航前往',
}
: null;
}
/** 面试卡片 */ /** 面试卡片 */
function InterviewCard({ interview }: { interview: Interview }) { function InterviewCard({ interview }: { interview: Interview }) {
const isVideo = interview.type === 'video'; const isVideo = interview.type === 'video';
const target = interviewActionTarget(interview);
/** 打不开就明说,不静默失败(未装地图 / 链接失效都会走到这里) */
const open = () => {
if (!target) return;
void Linking.openURL(target.url).catch(() => {
Alert.alert(
isVideo ? '无法打开会议链接' : '无法打开地图',
isVideo ? '会议链接可能已失效,请联系 HR 重新获取。' : '请检查是否已安装地图应用后重试。',
);
});
};
return ( return (
<View style={styles.interviewCard}> <View style={styles.interviewCard}>
<View style={styles.interviewHeader}> <View style={styles.interviewHeader}>
@ -95,13 +130,15 @@ function InterviewCard({ interview }: { interview: Interview }) {
{isVideo ? '视频面试' : '线下面试'} {isVideo ? '视频面试' : '线下面试'}
</Text> </Text>
</View> </View>
<Pressable {target ? (
style={styles.actionBtn} <Pressable style={styles.actionBtn} onPress={open}>
onPress={() => { <Text style={styles.actionText}>{target.label}</Text>
}}
>
<Text style={styles.actionText}>{isVideo ? '加入会议' : '导航前往'}</Text>
</Pressable> </Pressable>
) : (
<Text style={styles.actionMissing}>
{isVideo ? '会议链接待 HR 提供' : '面试地址待 HR 提供'}
</Text>
)}
</View> </View>
</View> </View>
); );
@ -113,8 +150,16 @@ function InterviewCard({ interview }: { interview: Interview }) {
export default function ChatScreen() { export default function ChatScreen() {
const router = useRouter(); const router = useRouter();
const [tab, setTab] = useState<TabKey>('message'); const [tab, setTab] = useState<TabKey>('message');
const { conversations, notifications, interviews, matchResult, loading, error, refresh } = const {
useMessageData(); conversations,
notifications,
interviews,
matchResult,
pendingOfferCount,
loading,
error,
refresh,
} = useMessageData();
// 加载态 // 加载态
if (loading) { if (loading) {
@ -175,17 +220,20 @@ export default function ChatScreen() {
{tab === 'message' && ( {tab === 'message' && (
<> <>
{/* 双向匹配成功横幅 */} {/* 双向匹配成功横幅 */}
{/*
双向匹配横幅:只陈述事实,不带「点击查看详情」——
匹配详情页尚未定义(企业列表之外还要展示什么,属产品决策),
留着链接就是把用户带到一个不存在的地方。
*/}
{matchResult && ( {matchResult && (
<Pressable <View style={styles.matchBanner}>
style={styles.matchBanner}
onPress={() => {
}}
>
<Text style={styles.matchBannerTitle}> <Text style={styles.matchBannerTitle}>
恭喜!您与{matchResult.total}家企业双向匹配成功 恭喜!您与{matchResult.total}家企业双向匹配成功
</Text> </Text>
<Text style={styles.matchBannerLink}>点击查看详情</Text> <Text style={styles.matchBannerCompanies} numberOfLines={2}>
</Pressable> {matchResult.companies.join('、')}
</Text>
</View>
)} )}
{/* 会话列表 */} {/* 会话列表 */}
@ -205,15 +253,16 @@ export default function ChatScreen() {
{tab === 'notification' && ( {tab === 'notification' && (
<> <>
{/* Offer 待处理横幅 */} {/* Offer 待处理横幅:数量来自接口层,为 0 时整条不渲染 */}
{pendingOfferCount > 0 && (
<Pressable <Pressable
style={styles.offerBanner} style={styles.offerBanner}
onPress={() => { onPress={() => router.push('/jobs/offer-compare')}
}}
> >
<Text style={styles.offerTitle}>您有2个Offer待处理</Text> <Text style={styles.offerTitle}>您有 {pendingOfferCount} 个 Offer 待处理</Text>
<Text style={styles.offerLink}>立即查看 &gt;</Text> <Text style={styles.offerLink}>立即查看 &gt;</Text>
</Pressable> </Pressable>
)}
{/* 系统通知列表 */} {/* 系统通知列表 */}
{notifications.length === 0 ? ( {notifications.length === 0 ? (
@ -228,15 +277,9 @@ export default function ChatScreen() {
{tab === 'interview' && ( {tab === 'interview' && (
<> <>
{/* 面试日程标题 */} {/* 面试日程标题:本 Tab 即全部日程,不再挂一个无处可去的「全部日程 >」 */}
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>今日面试</Text> <Text style={styles.sectionTitle}>今日面试</Text>
<Pressable
onPress={() => {
}}
>
<Text style={styles.sectionLink}>全部日程 &gt;</Text>
</Pressable>
</View> </View>
{/* 面试卡片 */} {/* 面试卡片 */}
@ -347,10 +390,10 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
color: colors.primaryDark, color: colors.primaryDark,
}, },
matchBannerLink: { matchBannerCompanies: {
marginTop: 6, marginTop: 6,
fontSize: 13, fontSize: 13,
color: colors.primary, color: colors.primaryDark,
}, },
// 会话卡片 // 会话卡片
convCard: { convCard: {
@ -553,4 +596,9 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
color: colors.white, color: colors.white,
}, },
/** 会议链接 / 面试地址缺失时的说明文案,替代一个点不动的按钮 */
actionMissing: {
fontSize: 13,
color: colors.textHint,
},
}); });

11
app/(tabs)/jobs.tsx

@ -15,6 +15,7 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useJobList } from '../../src/hooks'; import { useJobList } from '../../src/hooks';
import { import {
ALL_CITY_LABEL,
CITIES, CITIES,
EDUCATION_OPTIONS, EDUCATION_OPTIONS,
EXPERIENCE_OPTIONS, EXPERIENCE_OPTIONS,
@ -144,8 +145,9 @@ function JobCard({ job }: { job: Job }) {
export default function JobsScreen() { export default function JobsScreen() {
const router = useRouter(); const router = useRouter();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
// 定位城市(点击可切换) // 城市筛选(点击可切换)。默认「全部城市」:此前写死「北京」,
const [city, setCity] = useState('北京'); // 等于把每个用户都钉在北京,引导里填的期望城市永远影响不到推荐
const [city, setCity] = useState(ALL_CITY_LABEL);
const [cityVisible, setCityVisible] = useState(false); const [cityVisible, setCityVisible] = useState(false);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [sort, setSort] = useState<SortType>('comprehensive'); const [sort, setSort] = useState<SortType>('comprehensive');
@ -311,6 +313,7 @@ export default function JobsScreen() {
onRequestClose={() => setFilterVisible(false)} onRequestClose={() => setFilterVisible(false)}
> >
<Pressable style={styles.modalOverlay} onPress={() => setFilterVisible(false)}> <Pressable style={styles.modalOverlay} onPress={() => setFilterVisible(false)}>
{/* affordance-ok:吞冒泡,点弹层内容区不关闭 */}
<Pressable style={styles.filterPanel} onPress={() => {}}> <Pressable style={styles.filterPanel} onPress={() => {}}>
<View style={styles.panelHeader}> <View style={styles.panelHeader}>
<Text style={styles.panelTitle}>更多筛选</Text> <Text style={styles.panelTitle}>更多筛选</Text>
@ -376,6 +379,7 @@ export default function JobsScreen() {
onRequestClose={() => setCityVisible(false)} onRequestClose={() => setCityVisible(false)}
> >
<Pressable style={styles.modalOverlay} onPress={() => setCityVisible(false)}> <Pressable style={styles.modalOverlay} onPress={() => setCityVisible(false)}>
{/* affordance-ok:吞冒泡,点弹层内容区不关闭 */}
<Pressable style={styles.filterPanel} onPress={() => {}}> <Pressable style={styles.filterPanel} onPress={() => {}}>
<View style={styles.panelHeader}> <View style={styles.panelHeader}>
<Text style={styles.panelTitle}>选择城市</Text> <Text style={styles.panelTitle}>选择城市</Text>
@ -384,7 +388,8 @@ export default function JobsScreen() {
</Pressable> </Pressable>
</View> </View>
<View style={styles.cityGrid}> <View style={styles.cityGrid}>
{CITIES.map((item) => ( {/* 「全部城市」是默认值与复位入口:不给它出口,用户选了城市就再也回不到不限 */}
{[ALL_CITY_LABEL, ...CITIES].map((item) => (
<Pressable <Pressable
key={item} key={item}
style={[styles.optionChip, city === item && styles.optionChipActive]} style={[styles.optionChip, city === item && styles.optionChipActive]}

33
app/(tabs)/profile.tsx

@ -12,6 +12,9 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useProfileData } from '../../src/hooks'; import { useProfileData } from '../../src/hooks';
import { UNSET_LABEL } from '../../src/constants';
import { Avatar } from '../../components/onboarding/ui/Avatar';
import { isAvatarPreset } from '../../components/onboarding/assets';
import { useRole } from '../../src/context/RoleContext'; import { useRole } from '../../src/context/RoleContext';
import { colors, headerGradientSolid } from '../../src/theme'; import { colors, headerGradientSolid } from '../../src/theme';
import type { Badge, DimensionScore, EnterpriseEntry, JobPreference, MenuItem, UserStats } from '../../src/types'; import type { Badge, DimensionScore, EnterpriseEntry, JobPreference, MenuItem, UserStats } from '../../src/types';
@ -238,12 +241,22 @@ export default function ProfileScreen() {
</Pressable> </Pressable>
</View> </View>
<View style={styles.profileRow}> <View style={styles.profileRow}>
{/* P21 选过头像就显示真实选择(预设素材 / 本地图片);没选过才回落到姓名首字 */}
{profile.avatarKey ? (
<Avatar
name={isAvatarPreset(profile.avatarKey) ? profile.avatarKey : 'defaultAvatar'}
uri={isAvatarPreset(profile.avatarKey) ? null : profile.avatarKey}
size={60}
/>
) : (
<View style={styles.avatar}> <View style={styles.avatar}>
<Text style={styles.avatarText}>{profile.avatar}</Text> <Text style={styles.avatarText}>{profile.avatar}</Text>
</View> </View>
)}
<View style={styles.profileInfo}> <View style={styles.profileInfo}>
<Text style={styles.userName}>{profile.name}</Text> {/* 姓名未采集(P6/P16 可跳过直接进入)时显示「未设置」,不显示别人的名字 */}
<Text style={styles.bio}>{profile.bio}</Text> <Text style={styles.userName}>{profile.name || UNSET_LABEL}</Text>
{profile.bio !== '' && <Text style={styles.bio}>{profile.bio}</Text>}
</View> </View>
</View> </View>
</LinearGradient> </LinearGradient>
@ -294,7 +307,10 @@ export default function ProfileScreen() {
{PREF_ITEMS.map(({ key, label }, index) => { {PREF_ITEMS.map(({ key, label }, index) => {
const value = preference[key]; const value = preference[key];
const isLast = index === PREF_ITEMS.length - 1; 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 ( return (
<Pressable <Pressable
key={key} key={key}
@ -305,10 +321,12 @@ export default function ProfileScreen() {
{isStatus ? ( {isStatus ? (
<View style={styles.prefStatus}> <View style={styles.prefStatus}>
<View style={styles.prefStatusDot} /> <View style={styles.prefStatusDot} />
<Text style={styles.prefStatusText}>{value}</Text> <Text style={styles.prefStatusText}>{display}</Text>
</View> </View>
) : ( ) : (
<Text style={styles.prefValue}>{value}</Text> <Text style={[styles.prefValue, isUnset && styles.prefValueUnset]}>
{display}
</Text>
)} )}
</Pressable> </Pressable>
); );
@ -588,6 +606,11 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
color: colors.textPrimary, color: colors.textPrimary,
}, },
// 未采集态:弱化为占位符视觉,与真实填写的值区分开
prefValueUnset: {
fontWeight: '400',
color: colors.textHint,
},
prefStatus: { prefStatus: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

98
app/appeal.tsx

@ -3,6 +3,7 @@ import { useRouter } from 'expo-router';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Image,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -11,7 +12,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; 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 { colors } from '../src/theme';
import type { AppealReason, AppealType } from '../src/types'; 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 ( return (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>上传凭证</Text> <Text style={styles.sectionTitle}>上传凭证({evidence.length})</Text>
<Pressable style={styles.uploadBox}>
{evidence.length > 0 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.evidenceRow}>
{evidence.map((uri) => (
<View key={uri} style={styles.evidenceItem}>
<Image source={{ uri }} style={styles.evidenceImage} />
<Pressable style={styles.evidenceRemove} onPress={() => onRemove(uri)} hitSlop={6}>
<Ionicons name="close" size={14} color={colors.white} />
</Pressable>
</View>
))}
</ScrollView>
)}
<Pressable
style={[styles.uploadBox, picking && styles.uploadBoxDisabled]}
onPress={chooseSource}
disabled={picking}
>
{picking ? (
<ActivityIndicator size="small" color={colors.primary} />
) : (
<Ionicons name="cloud-upload-outline" size={28} color={colors.textHint} /> <Ionicons name="cloud-upload-outline" size={28} color={colors.textHint} />
<Text style={styles.uploadText}>点击上传图片或文件</Text> )}
<Text style={styles.uploadHint}>支持 JPG / PNG / PDF,单个文件不超过 10MB</Text> <Text style={styles.uploadText}>{picking ? '正在打开相册…' : '点击上传图片'}</Text>
<Text style={styles.uploadHint}>
当前支持 JPG / PNG 图片,单张不超过 10MB;文件(PDF)待上传接口就绪后开放
</Text>
</Pressable> </Pressable>
{error ? <Text style={styles.uploadError}>{error}</Text> : null}
</View> </View>
); );
} }
@ -312,6 +359,9 @@ export default function AppealScreen() {
setReason, setReason,
reasonText, reasonText,
setReasonText, setReasonText,
evidence,
addEvidence,
removeEvidence,
contactPhone, contactPhone,
setContactPhone, setContactPhone,
contactEmail, contactEmail,
@ -319,6 +369,7 @@ export default function AppealScreen() {
submit, submit,
submitting, submitting,
} = useAppealForm(); } = useAppealForm();
const { pick, picking, error: pickError } = useImagePick();
const handleSubmit = async () => { const handleSubmit = async () => {
const result = await submit(); 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) { if (loading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
@ -360,7 +417,13 @@ export default function AppealScreen() {
/> />
<ReasonSection active={reason} onSelect={setReason} /> <ReasonSection active={reason} onSelect={setReason} />
<DescriptionSection value={reasonText} onChange={setReasonText} /> <DescriptionSection value={reasonText} onChange={setReasonText} />
<EvidenceSection /> <EvidenceSection
evidence={evidence}
picking={picking}
error={pickError}
onPick={handlePickEvidence}
onRemove={removeEvidence}
/>
<ContactSection <ContactSection
phone={contactPhone} phone={contactPhone}
email={contactEmail} email={contactEmail}
@ -494,6 +557,27 @@ const styles = StyleSheet.create({
}, },
uploadText: { fontSize: 14, color: colors.textPrimary, marginTop: 8 }, uploadText: { fontSize: 14, color: colors.textPrimary, marginTop: 8 },
uploadHint: { fontSize: 11, color: colors.textHint, marginTop: 4 }, uploadHint: { fontSize: 11, color: colors.textHint, marginTop: 4 },
uploadBoxDisabled: { opacity: 0.6 },
uploadError: { fontSize: 12, color: colors.dangerText, marginTop: 8 },
evidenceRow: { marginBottom: 10 },
evidenceItem: { marginRight: 10 },
evidenceImage: {
width: 72,
height: 72,
borderRadius: 8,
backgroundColor: colors.pageBg,
},
evidenceRemove: {
position: 'absolute',
top: -6,
right: -6,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: colors.textPrimary,
alignItems: 'center',
justifyContent: 'center',
},
card: { card: {
backgroundColor: colors.white, backgroundColor: colors.white,
borderRadius: 10, borderRadius: 10,

8
app/candidate-detail.tsx

@ -23,9 +23,8 @@ function Header({ onBack }: { onBack: () => void }) {
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>候选人详情</Text> <Text style={styles.headerTitle}>候选人详情</Text>
<Pressable style={styles.headerBtn} hitSlop={8}> {/* 原先这里是「更多」图标:没有下拉菜单,先摘掉 */}
<Ionicons name="ellipsis-horizontal" size={22} color={colors.textPrimary} /> <View style={styles.headerBtn} />
</Pressable>
</View> </View>
); );
} }
@ -332,7 +331,8 @@ export default function CandidateDetailScreen() {
<HireSuggestionCard suggestions={candidate.hireSuggestions} salary={candidate.salarySuggestion} /> <HireSuggestionCard suggestions={candidate.hireSuggestions} salary={candidate.salarySuggestion} />
</ScrollView> </ScrollView>
<View style={styles.footer}> <View style={styles.footer}>
<Pressable style={styles.chatBtn}> {/* 发起沟通:进该候选人的会话页(此前是空 Pressable,点了没反应) */}
<Pressable style={styles.chatBtn} onPress={() => router.push(`/chat/${candidate.id}`)}>
<Text style={styles.chatText}>发起沟通</Text> <Text style={styles.chatText}>发起沟通</Text>
</Pressable> </Pressable>
<Pressable <Pressable

91
app/guide.tsx

@ -13,47 +13,67 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { colors, headerGradientShort } from '../src/theme'; import { colors, headerGradientShort } from '../src/theme';
import { useAuth } from '../src/context/AuthContext'; import { useAuth } from '../src/context/AuthContext';
import type { AssetName } from '../components/onboarding/assets';
import { ImageSlot } from '../components/onboarding/ui/ImageSlot';
interface GuideSlide { interface GuideSlide {
icon: string; /** 屏内插图素材位(见 components/onboarding/assets.ts) */
art: AssetName;
title: string; title: string;
desc: string; desc: string;
} }
/** 引导页内容:聚焦有职四大差异化能力 */ /**
* 引导页内容:聚焦有职四大差异化能力。
* 每屏的配图走 ImageSlot —— 素材未到位时页面上会显示「配图待补 + 该放什么图」的标注。
*/
const SLIDES: GuideSlide[] = [ const SLIDES: GuideSlide[] = [
{ {
icon: 'compass-outline', art: 'guideEvaluate',
title: '双向透明求职生态', title: '先看评价,再投简历',
desc: '看真实公司评价与行业声誉,让信息差不再成为求职的障碍。', desc: '公司评分、在职员工评价、行业声誉都摆在明面上,信息差不该由求职者买单。',
}, },
{ {
icon: 'options-outline', art: 'guideWeightDim',
title: '自定义评价维度', title: '按你的标准打分',
desc: '按你的关注点设置权重,实时算出综合得分,找到最匹配的公司。', desc: '关注什么就调什么权重,实时算出综合得分,找到真正合适你的那家公司。',
}, },
{ {
icon: 'game-controller-outline', art: 'guideGameMap',
title: '通关小游戏', title: '用 7 关走完求职全流程',
desc: '7 关闯关引导你走完「认识自己 → 签约」的完整求职链路。', desc: '认识自己 → 明确方向 → 准备简历 → 面试 → 签约,边闯关边把该准备的都准备好。',
}, },
{ {
icon: 'shield-checkmark-outline', art: 'guideLegalAI',
title: '阿布阿诺 AI 法律服务', title: '签字之前,先看清权益',
desc: '免费咨询与模板,付费律师与合同审核,求职权益有保障。', desc: '免费的 AI 咨询与合同模板,付费的律师与合同审核,入职前后都有人帮你把关。',
}, },
]; ];
/**
* 引导页(/guide)。
*
* 两种进入方式,出口不同 —— 出口错了会把已登录用户甩回注册流程:
* - 首次启动:`/` 判定没看过引导时跳来,看完落 /register(跳过等同看完);
* - 已登录后回顾:设置页「查看引导页」推入,出口是返回上一页,不能再去 /register。
*/
export default function GuideScreen() { export default function GuideScreen() {
const router = useRouter(); const router = useRouter();
const { completeOnboarding } = useAuth(); const { completeOnboarding, isTokenValid } = useAuth();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const scrollRef = useRef<ScrollView>(null); const scrollRef = useRef<ScrollView>(null);
const isLast = page === SLIDES.length - 1; const isLast = page === SLIDES.length - 1;
/** 已登录(含从设置页回顾)时只返回,不改引导标记、不进注册流程 */
const reviewing = isTokenValid;
const finish = () => { const finish = () => {
if (reviewing) {
if (router.canGoBack()) router.back();
else router.replace('/(tabs)/jobs');
return;
}
completeOnboarding(); completeOnboarding();
router.replace('/register'); router.replace('/register');
}; };
@ -68,12 +88,21 @@ export default function GuideScreen() {
scrollRef.current?.scrollTo({ x: next * width, animated: true }); scrollRef.current?.scrollTo({ x: next * width, animated: true });
}; };
const slotWidth = Math.min(width - 96, 300);
const slotHeight = Math.round(slotWidth * 0.72);
return ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.topBar}> <View style={styles.topBar}>
<View style={styles.topSpacer} /> {reviewing ? (
<Pressable onPress={finish} hitSlop={8} style={styles.backBtn}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
) : (
<View style={styles.backBtn} />
)}
<Pressable onPress={finish} hitSlop={8}> <Pressable onPress={finish} hitSlop={8}>
<Text style={styles.skipText}>跳过</Text> <Text style={styles.skipText}>{reviewing ? '关闭' : '跳过'}</Text>
</Pressable> </Pressable>
</View> </View>
@ -90,13 +119,7 @@ export default function GuideScreen() {
> >
{SLIDES.map((slide) => ( {SLIDES.map((slide) => (
<View key={slide.title} style={[styles.slide, { width }]}> <View key={slide.title} style={[styles.slide, { width }]}>
<LinearGradient colors={[...headerGradientShort]} style={styles.illus}> <ImageSlot name={slide.art} width={slotWidth} height={slotHeight} radius={24} />
<Ionicons
name={slide.icon as keyof typeof Ionicons.glyphMap}
size={56}
color={colors.textInverse}
/>
</LinearGradient>
<Text style={styles.slideTitle}>{slide.title}</Text> <Text style={styles.slideTitle}>{slide.title}</Text>
<Text style={styles.slideDesc}>{slide.desc}</Text> <Text style={styles.slideDesc}>{slide.desc}</Text>
</View> </View>
@ -111,7 +134,9 @@ export default function GuideScreen() {
</View> </View>
<Pressable style={styles.nextBtn} onPress={goNext} hitSlop={6}> <Pressable style={styles.nextBtn} onPress={goNext} hitSlop={6}>
<LinearGradient colors={[...headerGradientShort]} style={styles.nextGradient}> <LinearGradient colors={[...headerGradientShort]} style={styles.nextGradient}>
<Text style={styles.nextText}>{isLast ? '立即体验' : '下一步'}</Text> <Text style={styles.nextText}>
{isLast ? (reviewing ? '完成' : '立即体验') : '下一步'}
</Text>
</LinearGradient> </LinearGradient>
</Pressable> </Pressable>
<Text style={styles.pageHint}> <Text style={styles.pageHint}>
@ -131,7 +156,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 10, paddingVertical: 10,
}, },
topSpacer: { width: 40 }, backBtn: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
},
skipText: { fontSize: 14, color: colors.textSecondary, paddingHorizontal: 4 }, skipText: { fontSize: 14, color: colors.textSecondary, paddingHorizontal: 4 },
pager: { flex: 1 }, pager: { flex: 1 },
slide: { slide: {
@ -140,19 +170,12 @@ const styles = StyleSheet.create({
justifyContent: 'center', justifyContent: 'center',
paddingHorizontal: 36, paddingHorizontal: 36,
}, },
illus: {
width: 132,
height: 132,
borderRadius: 36,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 36,
},
slideTitle: { slideTitle: {
fontSize: 22, fontSize: 22,
fontWeight: '800', fontWeight: '800',
color: colors.textPrimary, color: colors.textPrimary,
textAlign: 'center', textAlign: 'center',
marginTop: 36,
marginBottom: 14, marginBottom: 14,
}, },
slideDesc: { slideDesc: {

23
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 Svg, { Circle, Line, Polygon } from 'react-native-svg';
import { useHollandAssessment } from '../src/hooks'; import { useHollandAssessment } from '../src/hooks';
import { colors, headerGradientShort } from '../src/theme'; 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_SIZE = 160;
const RADAR_CENTER = RADAR_SIZE / 2; 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 }) { function ActionCard({ action }: { action: HollandAction }) {
return ( const router = useRouter();
<Pressable style={styles.actionCard}> // 建议卡的目标路由由接口层给出(见 api.ts 的 actions);没有 route 的只做展示,不带箭头
const route = action.route;
const body = (
<>
<Ionicons name={action.icon as keyof typeof Ionicons.glyphMap} size={22} color={colors.primary} /> <Ionicons name={action.icon as keyof typeof Ionicons.glyphMap} size={22} color={colors.primary} />
<View style={styles.actionBody}> <View style={styles.actionBody}>
<Text style={styles.actionTitle}>{action.title}</Text> <Text style={styles.actionTitle}>{action.title}</Text>
<Text style={styles.actionDesc}>{action.desc}</Text> <Text style={styles.actionDesc}>{action.desc}</Text>
</View> </View>
<Ionicons name="chevron-forward" size={18} color={colors.textHint} /> {route ? <Ionicons name="chevron-forward" size={18} color={colors.textHint} /> : null}
</>
);
if (!route) {
return <View style={styles.actionCard}>{body}</View>;
}
return (
<Pressable style={styles.actionCard} onPress={() => router.push(route as never)}>
{body}
</Pressable> </Pressable>
); );
} }

5
app/holland.tsx

@ -31,9 +31,8 @@ function Header({ onBack }: { onBack: () => void }) {
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>职业兴趣测评</Text> <Text style={styles.headerTitle}>职业兴趣测评</Text>
<Pressable style={styles.headerBtn} hitSlop={8}> {/* 原先这里是「记录」按钮:项目内没有测评记录页,留着等于点了没反应,先摘掉 */}
<Text style={styles.headerRecord}>记录</Text> <View style={styles.headerBtn} />
</Pressable>
</View> </View>
); );
} }

75
app/interview-score.tsx

@ -2,6 +2,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, 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 Svg, { Line, Polygon, Text as SvgText } from 'react-native-svg';
import { useInterviewScore } from '../src/hooks'; import { useInterviewScore } from '../src/hooks';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import { formatDateTime } from '../src/utils';
import type { HireSuggestion, InterviewScoreData, InterviewScoreDimension, InterviewScoreTag } from '../src/types'; import type { HireSuggestion, InterviewScoreData, InterviewScoreDimension, InterviewScoreTag } from '../src/types';
const COLOR_TOKENS: Record<string, string> = { const COLOR_TOKENS: Record<string, string> = {
@ -32,9 +34,8 @@ function Header({ onBack }: { onBack: () => void }) {
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>面试评分</Text> <Text style={styles.headerTitle}>面试评分</Text>
<Pressable style={styles.headerBtn} hitSlop={8}> {/* 原先这里是「历史评分」入口:没有历史评分页,先摘掉,加页面时再挂回来 */}
<Ionicons name="time-outline" size={22} color={colors.textPrimary} /> <View style={styles.headerBtn} />
</Pressable>
</View> </View>
); );
} }
@ -271,8 +272,26 @@ export default function InterviewScoreScreen() {
selectHireSuggestion, selectHireSuggestion,
submit, submit,
submitting, submitting,
saveDraft,
savingDraft,
restoreDraft,
discardDraft,
hasPendingDraft,
draftSavedAt,
} = useInterviewScore('interview-1'); } = useInterviewScore('interview-1');
/** 保存草稿:成功后就地在按钮旁显示保存时间,失败给出提示 */
const handleSaveDraft = async () => {
try {
const savedAt = await saveDraft();
if (savedAt) {
Alert.alert('已保存草稿', `保存时间 ${formatDateTime(savedAt)},下次进入这场面试时可恢复。`);
}
} catch {
Alert.alert('保存失败', '草稿没能存到本机,请稍后重试。');
}
};
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}> <SafeAreaView style={styles.loadingContainer} edges={['top']}>
@ -295,6 +314,24 @@ export default function InterviewScoreScreen() {
<Header onBack={() => router.back()} /> <Header onBack={() => router.back()} />
</SafeAreaView> </SafeAreaView>
<ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}> <ScrollView style={styles.scroll} contentContainerStyle={styles.scrollContent}>
{hasPendingDraft && (
<View style={styles.draftBanner}>
<Ionicons name="time-outline" size={18} color={colors.infoText} />
<View style={styles.draftBannerBody}>
<Text style={styles.draftBannerTitle}>
发现未提交的草稿{draftSavedAt ? `(${formatDateTime(draftSavedAt)} 保存)` : ''}
</Text>
<Text style={styles.draftBannerDesc}>恢复后会覆盖当前页面上的评分内容</Text>
</View>
<Pressable style={styles.draftBannerBtn} onPress={restoreDraft} hitSlop={6}>
<Text style={styles.draftBannerBtnText}>恢复</Text>
</Pressable>
<Pressable onPress={() => void discardDraft()} hitSlop={6}>
<Text style={styles.draftBannerDiscard}>放弃</Text>
</Pressable>
</View>
)}
<InterviewInfoCard data={score} /> <InterviewInfoCard data={score} />
<OverallScoreCard score={score.overallScore} /> <OverallScoreCard score={score.overallScore} />
<RadarChart dimensions={score.dimensions} /> <RadarChart dimensions={score.dimensions} />
@ -304,8 +341,12 @@ export default function InterviewScoreScreen() {
<SalarySuggestionCard salary={score.salarySuggestion} /> <SalarySuggestionCard salary={score.salarySuggestion} />
</ScrollView> </ScrollView>
<View style={styles.footer}> <View style={styles.footer}>
<Pressable style={styles.draftBtn}> <Pressable
<Text style={styles.draftText}>保存草稿</Text> style={[styles.draftBtn, savingDraft && styles.draftBtnDisabled]}
onPress={handleSaveDraft}
disabled={savingDraft}
>
<Text style={styles.draftText}>{savingDraft ? '保存中…' : '保存草稿'}</Text>
</Pressable> </Pressable>
<Pressable <Pressable
style={[styles.submitBtn, submitting && styles.submitBtnDisabled]} style={[styles.submitBtn, submitting && styles.submitBtnDisabled]}
@ -582,6 +623,30 @@ const styles = StyleSheet.create({
fontWeight: '600', fontWeight: '600',
color: colors.textPrimary, color: colors.textPrimary,
}, },
draftBtnDisabled: { opacity: 0.6 },
draftBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: colors.infoLight,
borderWidth: 1,
borderColor: colors.infoBorder,
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 10,
marginBottom: 12,
},
draftBannerBody: { flex: 1 },
draftBannerTitle: { fontSize: 13, fontWeight: '700', color: colors.infoText },
draftBannerDesc: { fontSize: 11, color: colors.textSecondary, marginTop: 2 },
draftBannerBtn: {
backgroundColor: colors.infoBtn,
borderRadius: 14,
paddingHorizontal: 12,
paddingVertical: 6,
},
draftBannerBtnText: { fontSize: 12, fontWeight: '700', color: colors.white },
draftBannerDiscard: { fontSize: 12, color: colors.textSecondary },
submitBtn: { submitBtn: {
flex: 2, flex: 2,
height: 46, height: 46,

4
app/interview.tsx

@ -222,11 +222,9 @@ function ImprovementCard({
function RecentCard({ recent }: { recent: InterviewRecent[] }) { function RecentCard({ recent }: { recent: InterviewRecent[] }) {
return ( return (
<View style={styles.card}> <View style={styles.card}>
{/* 原先这里挂着「全部记录 >」:项目内没有面试练习记录页,先摘掉 */}
<View style={styles.cardHeaderRow}> <View style={styles.cardHeaderRow}>
<Text style={styles.cardTitle}>最近练习</Text> <Text style={styles.cardTitle}>最近练习</Text>
<Pressable>
<Text style={styles.moreLink}>全部记录 &gt;</Text>
</Pressable>
</View> </View>
{recent.map((item) => { {recent.map((item) => {
const color = COLOR_TOKENS[item.resultColor] ?? colors.primary; const color = COLOR_TOKENS[item.resultColor] ?? colors.primary;

217
app/job-evaluations.tsx

@ -1,12 +1,15 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Modal,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
@ -30,9 +33,8 @@ function Header({ onBack }: { onBack: () => void }) {
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>岗位评价管理</Text> <Text style={styles.headerTitle}>岗位评价管理</Text>
<Pressable style={styles.headerBtn} hitSlop={8}> {/* 原先这里是「筛选」图标:筛选面板尚未定义,先摘掉,做筛选时再挂回来 */}
<Ionicons name="filter-outline" size={22} color={colors.textPrimary} /> <View style={styles.headerBtn} />
</Pressable>
</View> </View>
); );
} }
@ -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 ( return (
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.cardTitle}>最新评价</Text> <Text style={styles.cardTitle}>最新评价</Text>
@ -325,11 +333,25 @@ function ReviewList({ reviews }: { reviews: JobReview[] }) {
</View> </View>
))} ))}
</View> </View>
{/* 已回复的评价把回复内容展示出来,否则「已回复」只是个说不清的标签 */}
{review.replied && review.replyContent ? (
<View style={styles.replyBlock}>
<Text style={styles.replyBlockLabel}>企业回复</Text>
<Text style={styles.replyBlockText}>{review.replyContent}</Text>
</View>
) : null}
<View style={styles.reviewFooter}> <View style={styles.reviewFooter}>
<Text style={styles.reviewDate}>{review.date}</Text> <Text style={styles.reviewDate}>{review.date}</Text>
<Pressable style={styles.replyBtn}> {/* 已回复的评价不再提供重复回复入口 */}
<Text style={styles.replyText}>{review.replied ? '已回复' : '回复'}</Text> {review.replied ? (
<Text style={styles.repliedText}>已回复</Text>
) : (
<Pressable style={styles.replyBtn} onPress={() => onReply(review)}>
<Text style={styles.replyText}>回复</Text>
</Pressable> </Pressable>
)}
</View> </View>
</View> </View>
))} ))}
@ -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 (
<Modal visible={review !== null} transparent animationType="fade" onRequestClose={onCancel}>
<View style={styles.modalMask}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>回复评价</Text>
{review ? (
<Text style={styles.modalQuote} numberOfLines={3}>
{review.userName}:{review.content}
</Text>
) : null}
<TextInput
style={styles.modalInput}
value={content}
onChangeText={setContent}
placeholder="写下你的回复,会展示在该评价下方"
placeholderTextColor={colors.textHint}
multiline
maxLength={200}
editable={!submitting}
/>
<View style={styles.modalActions}>
<Pressable style={styles.modalCancel} onPress={onCancel} disabled={submitting}>
<Text style={styles.modalCancelText}>取消</Text>
</Pressable>
<Pressable
style={[styles.modalSubmit, (trimmed === '' || submitting) && styles.modalSubmitOff]}
onPress={() => trimmed !== '' && onSubmit(trimmed)}
disabled={trimmed === '' || submitting}
>
<Text style={styles.modalSubmitText}>{submitting ? '提交中' : '提交回复'}</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
);
}
/* ===================== 页面主组件 ===================== */ /* ===================== 页面主组件 ===================== */
export default function JobEvaluationsScreen() { export default function JobEvaluationsScreen() {
const router = useRouter(); 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(); const { state: emptyState, loading: emptyLoading, error: emptyError } = useJobEvaluationEmptyState();
/** 正在回复的评价;null 表示弹层关闭 */
const [replying, setReplying] = useState<JobReview | null>(null);
const loading = dataLoading || emptyLoading; const loading = dataLoading || emptyLoading;
const error = dataError || emptyError; 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) { if (loading) {
return ( return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}> <SafeAreaView style={styles.loadingContainer} edges={['top']}>
@ -386,10 +491,17 @@ export default function JobEvaluationsScreen() {
<TrendChart trend={management.trend} /> <TrendChart trend={management.trend} />
<RadarChart dimensions={management.dimensions} /> <RadarChart dimensions={management.dimensions} />
<JobScoreList jobs={management.jobScores} /> <JobScoreList jobs={management.jobScores} />
<ReviewList reviews={management.reviews} /> <ReviewList reviews={management.reviews} onReply={setReplying} />
</> </>
) : null} ) : null}
</ScrollView> </ScrollView>
<ReplyModal
review={replying}
submitting={replying !== null && replyingId === replying.id}
onCancel={() => setReplying(null)}
onSubmit={submitReply}
/>
</View> </View>
); );
} }
@ -638,6 +750,93 @@ const styles = StyleSheet.create({
color: colors.primary, color: colors.primary,
fontWeight: '500', 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: { emptyWrapper: {
marginBottom: 16, marginBottom: 16,

126
app/onboarding.tsx

@ -5,15 +5,19 @@ import { useEffect, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert, Alert,
Modal,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Switch, Switch,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { buildChecklistText } from '../src/api';
import { useOnboardingChecklist } from '../src/hooks'; import { useOnboardingChecklist } from '../src/hooks';
import { shareText } from '../src/lib/shareFile';
import { colors, headerGradientShort } from '../src/theme'; import { colors, headerGradientShort } from '../src/theme';
import type { ChecklistGroup, OnboardingChecklist, OnboardingTip } from '../src/types'; import type { ChecklistGroup, OnboardingChecklist, OnboardingTip } from '../src/types';
@ -103,18 +107,22 @@ function CountdownCard({
function ChecklistGroupCard({ function ChecklistGroupCard({
group, group,
onToggleItem, onToggleItem,
onRemoveItem,
togglingId, togglingId,
expanded, expanded,
onToggleExpand, onToggleExpand,
}: { }: {
group: ChecklistGroup; group: ChecklistGroup;
onToggleItem: (itemId: string, done: boolean) => void; onToggleItem: (itemId: string, done: boolean) => void;
onRemoveItem: (itemId: string) => void;
togglingId: string | null; togglingId: string | null;
expanded: boolean; expanded: boolean;
onToggleExpand: () => void; onToggleExpand: () => void;
}) { }) {
const theme = GROUP_ICON_THEME[group.id] ?? GROUP_ICON_THEME.material; const theme = GROUP_ICON_THEME[group.id] ?? GROUP_ICON_THEME.material;
const doneCount = group.items.filter((i) => i.done).length; const doneCount = group.items.filter((i) => i.done).length;
// 只有「我的自定义待办」这一组可以删条目:模板待办删了下次进页面还会回来
const removable = group.id === 'custom';
return ( return (
<View style={styles.groupCard}> <View style={styles.groupCard}>
<Pressable style={styles.groupHead} onPress={onToggleExpand}> <Pressable style={styles.groupHead} onPress={onToggleExpand}>
@ -155,6 +163,11 @@ function ChecklistGroupCard({
<Text style={styles.requiredText}>必填</Text> <Text style={styles.requiredText}>必填</Text>
</View> </View>
)} )}
{removable && (
<Pressable onPress={() => onRemoveItem(item.id)} hitSlop={8}>
<Ionicons name="close-circle-outline" size={18} color={colors.textHint} />
</Pressable>
)}
</Pressable> </Pressable>
))} ))}
</View> </View>
@ -238,12 +251,16 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
export default function OnboardingScreen() { export default function OnboardingScreen() {
const router = useRouter(); 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<OnboardingChecklist | null>(null); const [localChecklist, setLocalChecklist] = useState<OnboardingChecklist | null>(null);
const [expandedIds, setExpandedIds] = useState<string[]>([]); const [expandedIds, setExpandedIds] = useState<string[]>([]);
const [reminderEnabled, setReminderEnabled] = useState(false); const [reminderEnabled, setReminderEnabled] = useState(false);
/** 「添加自定义待办」弹窗 */
const [addVisible, setAddVisible] = useState(false);
const [draftTitle, setDraftTitle] = useState('');
useEffect(() => { useEffect(() => {
if (checklist && !localChecklist) { if (checklist && !localChecklist) {
@ -268,15 +285,42 @@ export default function OnboardingScreen() {
await toggle(itemId, done); await toggle(itemId, done);
}; };
// 右上角「分享」暂无对应页面,按项目约定弹提示(此前绑定空函数,点击无任何反馈) /** 添加自定义待办:接口层负责落库并返回新清单,页面直接换成返回值 */
const handleShare = () => { const handleAddItem = async () => {
Alert.alert('提示', '分享入职清单功能开发中,敬请期待'); 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) { if (loading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={handleShare} /> <Header onBack={() => router.back()} onShare={() => void handleExport()} />
<Loading /> <Loading />
</SafeAreaView> </SafeAreaView>
); );
@ -285,7 +329,7 @@ export default function OnboardingScreen() {
if (error || !display) { if (error || !display) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={handleShare} /> <Header onBack={() => router.back()} onShare={() => void handleExport()} />
<ErrorState onRetry={refresh} /> <ErrorState onRetry={refresh} />
</SafeAreaView> </SafeAreaView>
); );
@ -293,7 +337,7 @@ export default function OnboardingScreen() {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={handleShare} /> <Header onBack={() => router.back()} onShare={() => void handleExport()} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<CountdownCard <CountdownCard
daysLeft={display.daysLeft} daysLeft={display.daysLeft}
@ -309,6 +353,7 @@ export default function OnboardingScreen() {
key={group.id} key={group.id}
group={group} group={group}
onToggleItem={handleToggleItem} onToggleItem={handleToggleItem}
onRemoveItem={handleRemoveItem}
togglingId={togglingId} togglingId={togglingId}
expanded={expandedIds.includes(group.id)} expanded={expandedIds.includes(group.id)}
onToggleExpand={() => onToggleExpand={() =>
@ -331,14 +376,48 @@ export default function OnboardingScreen() {
</ScrollView> </ScrollView>
<View style={styles.footer}> <View style={styles.footer}>
<Pressable style={styles.addBtn}> <Pressable
style={[styles.addBtn, mutating && styles.footerBtnDisabled]}
onPress={() => setAddVisible(true)}
disabled={mutating}
>
<Ionicons name="add" size={20} color={colors.primary} /> <Ionicons name="add" size={20} color={colors.primary} />
<Text style={styles.addBtnText}>添加自定义待办</Text> <Text style={styles.addBtnText}>添加自定义待办</Text>
</Pressable> </Pressable>
<Pressable style={styles.exportBtn}> <Pressable style={styles.exportBtn} onPress={() => void handleExport()}>
<Text style={styles.exportBtnText}>导出清单</Text> <Text style={styles.exportBtnText}>导出清单</Text>
</Pressable> </Pressable>
</View> </View>
{/* 添加自定义待办:Alert.prompt 只有 iOS 有,这里用自绘弹窗(与岗位评价回复一致) */}
<Modal visible={addVisible} transparent animationType="fade" onRequestClose={() => setAddVisible(false)}>
<View style={styles.modalMask}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>添加自定义待办</Text>
<TextInput
style={styles.modalInput}
value={draftTitle}
onChangeText={setDraftTitle}
placeholder="例如:办理落户材料"
placeholderTextColor={colors.textHint}
maxLength={30}
autoFocus
/>
<View style={styles.modalActions}>
<Pressable style={styles.modalCancel} onPress={() => setAddVisible(false)}>
<Text style={styles.modalCancelText}>取消</Text>
</Pressable>
<Pressable
style={[styles.modalConfirm, (draftTitle.trim() === '' || mutating) && styles.footerBtnDisabled]}
onPress={() => void handleAddItem()}
disabled={draftTitle.trim() === '' || mutating}
>
<Text style={styles.modalConfirmText}>{mutating ? '添加中…' : '添加'}</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -491,4 +570,33 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
exportBtnText: { fontSize: 15, fontWeight: '700', color: colors.white }, 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 },
}); });

4
app/register.tsx

@ -23,7 +23,6 @@ import { P18Education } from '../components/onboarding/screens/P18Education';
import { P20School } from '../components/onboarding/screens/P20School'; import { P20School } from '../components/onboarding/screens/P20School';
import { P21Avatar } from '../components/onboarding/screens/P21Avatar'; import { P21Avatar } from '../components/onboarding/screens/P21Avatar';
import { P22Done } from '../components/onboarding/screens/P22Done'; import { P22Done } from '../components/onboarding/screens/P22Done';
import { P23Home } from '../components/onboarding/screens/P23Home';
import { B1Wechat } from '../components/onboarding/screens/B1Wechat'; import { B1Wechat } from '../components/onboarding/screens/B1Wechat';
import { B2Agreement } from '../components/onboarding/screens/B2Agreement'; import { B2Agreement } from '../components/onboarding/screens/B2Agreement';
import { B3Recruiter } from '../components/onboarding/screens/B3Recruiter'; import { B3Recruiter } from '../components/onboarding/screens/B3Recruiter';
@ -56,7 +55,8 @@ function FlowRouter() {
case 'P20': return <P20School />; case 'P20': return <P20School />;
case 'P21': return <P21Avatar />; case 'P21': return <P21Avatar />;
case 'P22': return <P22Done />; case 'P22': return <P22Done />;
case 'P23': return <P23Home />; // 原 P23「假首页」已删除:它是写死上海 4 条职位的演示页,与真实主界面 (tabs)/jobs 重复。
// 现在 P22(及 P6/P16 的快速通道)由 useRegisterFinish 直接 replace 进真主界面。
case 'B1': return <B1Wechat />; case 'B1': return <B1Wechat />;
case 'B2': return <B2Agreement />; case 'B2': return <B2Agreement />;
case 'B3': return <B3Recruiter />; case 'B3': return <B3Recruiter />;

644
app/resume.tsx

@ -1,30 +1,41 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useFocusEffect, useRouter } from 'expo-router';
import { useState } from 'react'; import { useCallback, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Modal,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useResume } from '../src/hooks'; import { useResume } from '../src/hooks';
import { RESUME_EVAL_MAX_LENGTH, UNSET_LABEL } from '../src/constants';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import type { Resume as ResumeType, ResumeExperience, ResumeOptimizePoint, ResumeProject } from '../src/types'; import type { Resume as ResumeType, ResumeExperience, ResumeOptimizePoint, ResumeProject } from '../src/types';
import { formatDateTime } from '../src/utils';
/* ===================== 子组件:顶部导航 ===================== */ /* ===================== 子组件:顶部导航 ===================== */
/**
* 顶部导航。`onSave` 可选:加载中 / 加载失败时简历尚未就绪,此时按钮置灰而不是给一个
* 点了没反应的入口(同 `app/resume/export.tsx` 的处理)。
*/
function Header({ function Header({
onBack, onBack,
onSave, onSave,
onExport, onExport,
}: { }: {
onBack: () => void; onBack: () => void;
onSave: () => void; /** 传 undefined = 简历未就绪,按钮置灰 */
onSave?: () => void;
onExport: () => void; onExport: () => void;
}) { }) {
const saveDisabled = onSave === undefined;
return ( return (
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
@ -32,10 +43,25 @@ function Header({
</Pressable> </Pressable>
<Text style={styles.headerTitle}>AI简历优化</Text> <Text style={styles.headerTitle}>AI简历优化</Text>
<View style={styles.headerActions}> <View style={styles.headerActions}>
<Pressable style={styles.headerActionBtn} onPress={onSave} hitSlop={8}> <Pressable
<Ionicons name="save-outline" size={20} color={colors.textPrimary} /> style={styles.headerActionBtn}
onPress={onSave}
disabled={saveDisabled}
hitSlop={8}
accessibilityLabel="保存简历到本机"
>
<Ionicons
name="save-outline"
size={20}
color={saveDisabled ? colors.textDisabled : colors.textPrimary}
/>
</Pressable> </Pressable>
<Pressable style={styles.headerActionBtn} onPress={onExport} hitSlop={8}> <Pressable
style={styles.headerActionBtn}
onPress={onExport}
hitSlop={8}
accessibilityLabel="导出简历"
>
<Ionicons name="download-outline" size={20} color={colors.textPrimary} /> <Ionicons name="download-outline" size={20} color={colors.textPrimary} />
</Pressable> </Pressable>
</View> </View>
@ -92,8 +118,12 @@ function ScoreCard({
function OptimizeCard({ function OptimizeCard({
points, points,
onOptimizeAll,
optimizing,
}: { }: {
points: ResumeOptimizePoint[]; points: ResumeOptimizePoint[];
onOptimizeAll: () => void;
optimizing: boolean;
}) { }) {
return ( return (
<View style={styles.card}> <View style={styles.card}>
@ -112,8 +142,12 @@ function OptimizeCard({
<Text style={[styles.optScore, p.optimized && styles.optScoreDone]}>+{p.score}分</Text> <Text style={[styles.optScore, p.optimized && styles.optScoreDone]}>+{p.score}分</Text>
</View> </View>
))} ))}
<Pressable style={styles.optAllBtn}> <Pressable
<Text style={styles.optAllText}>一键全部优化</Text> style={[styles.optAllBtn, optimizing && styles.optAllBtnDisabled]}
onPress={onOptimizeAll}
disabled={optimizing}
>
<Text style={styles.optAllText}>{optimizing ? '优化中…' : '一键全部优化'}</Text>
</Pressable> </Pressable>
</View> </View>
); );
@ -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 ( return (
<View style={styles.card}> <View style={styles.card}>
<View style={styles.sectionTitleRow}> <View style={styles.sectionTitleRow}>
<Text style={styles.sectionTitle}>基本信息</Text> <Text style={styles.sectionTitle}>基本信息</Text>
<Pressable> <Pressable onPress={onEdit} hitSlop={6}>
<Text style={styles.editLink}>编辑基本信息 &gt;</Text> <Text style={styles.editLink}>编辑基本信息 &gt;</Text>
</Pressable> </Pressable>
</View> </View>
<View style={styles.basicRow}> <View style={styles.basicRow}>
<View style={styles.avatar}> <View style={styles.avatar}>
<Text style={styles.avatarText}>{info.avatar}</Text> <Text style={styles.avatarText}>{name.slice(0, 1)}</Text>
</View> </View>
<View style={styles.basicBody}> <View style={styles.basicBody}>
<View style={styles.nameRow}> <View style={styles.nameRow}>
<Text style={styles.name}>{info.name}</Text> <Text style={styles.name}>{name}</Text>
<Ionicons name="pencil" size={14} color={colors.primary} /> <Ionicons name="pencil" size={14} color={colors.primary} />
</View> </View>
<View style={styles.contactRow}> {/* 空值不渲染空行:手机号 / 邮箱引导未采集,画一行空白比不画更让人困惑 */}
<Ionicons name="call-outline" size={14} color={colors.textHint} /> {rows.map((row) => (
<Text style={styles.contactText}>{info.phone}</Text> <View key={row.key} style={styles.contactRow}>
</View> <Ionicons name={row.icon} size={14} color={colors.textHint} />
<View style={styles.contactRow}> <Text style={styles.contactText}>{row.value}</Text>
<Ionicons name="mail-outline" size={14} color={colors.textHint} />
<Text style={styles.contactText}>{info.email}</Text>
</View>
<View style={styles.contactRow}>
<Ionicons name="location-outline" size={14} color={colors.textHint} />
<Text style={styles.contactText}>{info.location}</Text>
</View> </View>
))}
</View> </View>
</View> </View>
</View> </View>
@ -160,11 +197,13 @@ function BasicInfoCard({ info }: { info: ResumeType['basicInfo'] }) {
/* ===================== 子组件:求职意向 ===================== */ /* ===================== 子组件:求职意向 ===================== */
function JobIntentionCard({ intention }: { intention: ResumeType['jobIntention'] }) { function JobIntentionCard({ intention }: { intention: ResumeType['jobIntention'] }) {
const router = useRouter();
return ( return (
<View style={styles.card}> <View style={styles.card}>
<View style={styles.sectionTitleRow}> <View style={styles.sectionTitleRow}>
<Text style={styles.sectionTitle}>求职意向</Text> <Text style={styles.sectionTitle}>求职意向</Text>
<Pressable> {/* 指向已存在的求职意向编辑页(此前是无 onPress 的死按钮) */}
<Pressable onPress={() => router.push('/job-preference')} hitSlop={6}>
<Text style={styles.editLink}>编辑 &gt;</Text> <Text style={styles.editLink}>编辑 &gt;</Text>
</Pressable> </Pressable>
</View> </View>
@ -175,7 +214,8 @@ function JobIntentionCard({ intention }: { intention: ResumeType['jobIntention']
<IntentionItem <IntentionItem
label="求职状态" label="求职状态"
value={intention.statusLabel} value={intention.statusLabel}
tag={intention.status === 'active'} // 无状态文案时不打「积极求职中」的彩色标签:颜色本身也是一句话
tag={intention.status === 'active' && intention.statusLabel !== ''}
/> />
</View> </View>
</View> </View>
@ -191,15 +231,16 @@ function IntentionItem({
value: string; value: string;
tag?: boolean; tag?: boolean;
}) { }) {
const text = value || UNSET_LABEL;
return ( return (
<View style={styles.intentionItem}> <View style={styles.intentionItem}>
<Text style={styles.intentionLabel}>{label}</Text> <Text style={styles.intentionLabel}>{label}</Text>
{tag ? ( {tag ? (
<View style={styles.intentionTag}> <View style={styles.intentionTag}>
<Text style={styles.intentionTagText}>{value}</Text> <Text style={styles.intentionTagText}>{text}</Text>
</View> </View>
) : ( ) : (
<Text style={styles.intentionValue}>{value}</Text> <Text style={styles.intentionValue}>{text}</Text>
)} )}
</View> </View>
); );
@ -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 ( return (
<View style={styles.card}> <View style={styles.card}>
<View style={styles.sectionTitleRow}> <View style={styles.sectionTitleRow}>
<Text style={styles.sectionTitle}>教育背景</Text> <Text style={styles.sectionTitle}>教育背景</Text>
<Pressable style={styles.addBtn}> <View style={styles.sectionActions}>
<Pressable onPress={onEdit} hitSlop={6}>
<Text style={styles.editLink}>编辑 &gt;</Text>
</Pressable>
<Pressable style={styles.addBtn} onPress={onAdd} hitSlop={6}>
<Ionicons name="add" size={18} color={colors.primary} /> <Ionicons name="add" size={18} color={colors.primary} />
<Text style={styles.addText}>添加教育经历</Text> <Text style={styles.addText}>添加教育经历</Text>
</Pressable> </Pressable>
</View> </View>
{education.map((edu) => ( </View>
<View key={edu.id} style={styles.eduRow}> {education.length === 0 && <Text style={styles.emptyText}>未填写教育背景</Text>}
{education.map((edu) => {
// 专业 / 年份引导未采集时为空串,拼出来会剩一个孤零零的「· 本科」或「-」
const majorDegree = [edu.major, edu.degree].filter(Boolean).join(' · ');
const years = [edu.startYear, edu.endYear].filter(Boolean).join('-');
return (
<Pressable key={edu.id} style={styles.eduRow} onPress={() => onItem(edu.id)}>
<View> <View>
<Text style={styles.eduSchool}>{edu.school}</Text> <Text style={styles.eduSchool}>{edu.school}</Text>
<Text style={styles.eduMajor}> {majorDegree !== '' && <Text style={styles.eduMajor}>{majorDegree}</Text>}
{edu.major} · {edu.degree}
</Text>
</View>
<Text style={styles.eduYear}>
{edu.startYear}-{edu.endYear}
</Text>
</View> </View>
))} {years !== '' && <Text style={styles.eduYear}>{years}</Text>}
</Pressable>
);
})}
</View> </View>
); );
} }
@ -238,38 +296,63 @@ function EducationCard({ education }: { education: ResumeType['education'] }) {
function ExperienceCard({ function ExperienceCard({
experiences, experiences,
onAdd,
onEdit,
onItem,
onOptimize,
aiBusy,
}: { }: {
experiences: ResumeExperience[]; experiences: ResumeExperience[];
onAdd: () => void;
onEdit: () => void;
onItem: (id: string) => void;
onOptimize: (id: string) => void;
/** AI 请求进行中:按钮禁用,避免连点堆起多个请求 */
aiBusy: boolean;
}) { }) {
return ( return (
<View style={styles.card}> <View style={styles.card}>
<View style={styles.sectionTitleRow}> <View style={styles.sectionTitleRow}>
<Text style={styles.sectionTitle}>工作经历</Text> <Text style={styles.sectionTitle}>工作经历</Text>
<Pressable style={styles.addBtn}> <View style={styles.sectionActions}>
<Pressable onPress={onEdit} hitSlop={6}>
<Text style={styles.editLink}>编辑 &gt;</Text>
</Pressable>
<Pressable style={styles.addBtn} onPress={onAdd} hitSlop={6}>
<Ionicons name="add" size={18} color={colors.primary} /> <Ionicons name="add" size={18} color={colors.primary} />
<Text style={styles.addText}>添加工作经历</Text> <Text style={styles.addText}>添加工作经历</Text>
</Pressable> </Pressable>
</View> </View>
</View>
{experiences.length === 0 && <Text style={styles.emptyText}>未填写工作经历</Text>}
{experiences.map((exp) => ( {experiences.map((exp) => (
<View key={exp.id} style={styles.expRow}> <Pressable key={exp.id} style={styles.expRow} onPress={() => onItem(exp.id)}>
<View style={styles.expHeader}> <View style={styles.expHeader}>
<Text style={styles.expCompany}>{exp.company}</Text> <Text style={styles.expCompany}>{exp.company}</Text>
{/* 离职时结束时间未采集(留空),只展示已采集到的那一端,不补「-」 */}
{[exp.startDate, exp.endDate].filter(Boolean).length > 0 && (
<Text style={styles.expDate}> <Text style={styles.expDate}>
{exp.startDate} - {exp.endDate} {[exp.startDate, exp.endDate].filter(Boolean).join(' - ')}
</Text> </Text>
)}
</View> </View>
<Text style={styles.expPosition}>{exp.position}</Text> {exp.position !== '' && <Text style={styles.expPosition}>{exp.position}</Text>}
<Text style={styles.expDesc}>{exp.description}</Text> {exp.description !== '' && <Text style={styles.expDesc}>{exp.description}</Text>}
{!exp.aiOptimized && ( {!exp.aiOptimized && (
<View style={styles.aiTip}> <View style={styles.aiTip}>
<Ionicons name="sparkles" size={14} color={colors.info} /> <Ionicons name="sparkles" size={14} color={colors.info} />
<Text style={styles.aiTipText}>AI 建议优化</Text> <Text style={styles.aiTipText}>AI 建议优化</Text>
<Pressable style={styles.aiOptimizeMiniBtn}> <Pressable
style={styles.aiOptimizeMiniBtn}
onPress={() => onOptimize(exp.id)}
disabled={aiBusy}
accessibilityLabel={`AI 优化 ${exp.company} 的工作内容`}
>
<Text style={styles.aiOptimizeMiniText}>AI 优化</Text> <Text style={styles.aiOptimizeMiniText}>AI 优化</Text>
</Pressable> </Pressable>
</View> </View>
)} )}
</View> </Pressable>
))} ))}
</View> </View>
); );
@ -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 ( return (
<View style={styles.card}> <View style={styles.card}>
<View style={styles.sectionTitleRow}> <View style={styles.sectionTitleRow}>
<Text style={styles.sectionTitle}>项目经历</Text> <Text style={styles.sectionTitle}>项目经历</Text>
<Pressable style={styles.addBtn}> <View style={styles.sectionActions}>
<Pressable onPress={onEdit} hitSlop={6}>
<Text style={styles.editLink}>编辑 &gt;</Text>
</Pressable>
<Pressable style={styles.addBtn} onPress={onAdd} hitSlop={6}>
<Ionicons name="add" size={18} color={colors.primary} /> <Ionicons name="add" size={18} color={colors.primary} />
<Text style={styles.addText}>添加项目经历</Text> <Text style={styles.addText}>添加项目经历</Text>
</Pressable> </Pressable>
</View> </View>
</View>
{projects.length === 0 && <Text style={styles.emptyText}>未填写项目经历</Text>}
{projects.map((proj) => ( {projects.map((proj) => (
<View key={proj.id} style={styles.projRow}> <Pressable key={proj.id} style={styles.projRow} onPress={() => onItem(proj.id)}>
<View style={styles.expHeader}> <View style={styles.expHeader}>
<Text style={styles.expCompany}>{proj.name}</Text> <Text style={styles.expCompany}>{proj.name}</Text>
<Text style={styles.expPosition}>{proj.role}</Text> <Text style={styles.expPosition}>{proj.role}</Text>
@ -300,7 +399,7 @@ function ProjectCard({ projects }: { projects: ResumeProject[] }) {
<Text style={styles.optimizedText}>已量化</Text> <Text style={styles.optimizedText}>已量化</Text>
</View> </View>
)} )}
</View> </Pressable>
))} ))}
</View> </View>
); );
@ -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 ( return (
<View style={styles.card}> <View style={styles.card}>
<View style={styles.sectionTitleRow}> <View style={styles.sectionTitleRow}>
<Text style={styles.sectionTitle}>技能特长</Text> <Text style={styles.sectionTitle}>技能特长</Text>
<Pressable style={styles.addBtn}> <Pressable style={styles.addBtn} onPress={onEdit} hitSlop={6}>
<Ionicons name="add" size={18} color={colors.primary} /> <Ionicons name="add" size={18} color={colors.primary} />
<Text style={styles.addText}>添加技能</Text> <Text style={styles.addText}>添加技能</Text>
</Pressable> </Pressable>
</View> </View>
<View style={styles.skillsWrap}> <View style={styles.skillsWrap}>
{skills.length === 0 && <Text style={styles.emptyText}>未填写技能特长</Text>}
{skills.map((skill) => ( {skills.map((skill) => (
<View key={skill.name} style={styles.skillTag}> <View key={skill.name} style={styles.skillTag}>
<Text style={styles.skillText}>{skill.name}</Text> <Text style={styles.skillText}>{skill.name}</Text>
@ -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 (
<Modal transparent visible={visible} animationType="slide" onRequestClose={onClose}>
{/* 内层 Pressable 吞掉冒泡:点内容区不该关闭弹层 */}
<Pressable style={styles.modalMask} onPress={onClose}>
{/* affordance-ok:吞冒泡,点弹层内容区不关闭 */}
<Pressable style={styles.modalSheet} onPress={() => {}}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>编辑技能</Text>
<Pressable onPress={onClose} hitSlop={8} accessibilityLabel="关闭">
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
<ScrollView style={styles.modalBody} showsVerticalScrollIndicator={false}>
<Text style={styles.modalLabel}>已添加(点 × 删除)</Text>
{skills.length === 0 ? (
<Text style={styles.emptyText}>还没有添加技能</Text>
) : (
<View style={styles.skillsWrap}>
{skills.map((skill) => (
<Pressable
key={skill.name}
style={[styles.skillTag, styles.skillTagEditable]}
onPress={() => onRemove(skill.name)}
disabled={saving}
accessibilityLabel={`删除技能 ${skill.name}`}
>
<Text style={styles.skillText}>{skill.name}</Text>
<Ionicons name="close" size={14} color={colors.textSecondary} />
</Pressable>
))}
</View>
)}
</ScrollView>
<View style={styles.modalInputRow}>
<TextInput
style={styles.modalInput}
value={draft}
onChangeText={onDraftChange}
placeholder="如:TypeScript"
placeholderTextColor={colors.textHint}
maxLength={20}
onSubmitEditing={() => canAdd && onAdd(name)}
returnKeyType="done"
/>
<Pressable
style={[styles.modalAddBtn, !canAdd && styles.modalAddBtnDisabled]}
onPress={() => onAdd(name)}
disabled={!canAdd}
>
<Text style={styles.modalAddText}>添加</Text>
</Pressable>
</View>
{duplicated && <Text style={styles.modalError}>已添加过该技能</Text>}
</Pressable>
</Pressable>
</Modal>
);
}
/* ===================== 子组件:自我评价编辑弹窗 ===================== */
function EvaluationModal({
visible,
draft,
onDraftChange,
onClose,
onSave,
saving,
}: {
visible: boolean;
draft: string;
onDraftChange: (v: string) => void;
onClose: () => void;
onSave: () => void;
saving: boolean;
}) {
return (
<Modal transparent visible={visible} animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.modalMask} onPress={onClose}>
{/* affordance-ok:吞冒泡,点弹层内容区不关闭 */}
<Pressable style={styles.modalSheet} onPress={() => {}}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>编辑自我评价</Text>
<Pressable onPress={onClose} hitSlop={8} accessibilityLabel="关闭">
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
<TextInput
style={styles.evalInput}
value={draft}
onChangeText={onDraftChange}
placeholder="用几句话说明你的经验、优势与求职期望"
placeholderTextColor={colors.textHint}
multiline
maxLength={RESUME_EVAL_MAX_LENGTH}
autoFocus
/>
<Text style={styles.evalCounter}>
{draft.length}/{RESUME_EVAL_MAX_LENGTH}
</Text>
<View style={styles.modalFooter}>
<Pressable style={styles.modalCancelBtn} onPress={onClose} hitSlop={6}>
<Text style={styles.modalCancelText}>取消</Text>
</Pressable>
<Pressable
style={[styles.modalSaveBtn, saving && styles.modalSaveBtnDisabled]}
onPress={onSave}
disabled={saving}
>
<Text style={styles.modalSaveText}>{saving ? '保存中…' : '保存'}</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
);
}
/* ===================== 子组件:自我评价 ===================== */ /* ===================== 子组件:自我评价 ===================== */
function EvaluationCard({ content }: { content: string }) { function EvaluationCard({
content,
onEdit,
onPolish,
polishing,
}: {
content: string;
onEdit: () => void;
onPolish: () => void;
polishing: boolean;
}) {
return ( return (
<View style={styles.card}> <View style={styles.card}>
<View style={styles.sectionTitleRow}> <View style={styles.sectionTitleRow}>
<Text style={styles.sectionTitle}>自我评价</Text> <Text style={styles.sectionTitle}>自我评价</Text>
<Pressable style={styles.aiOptimizeMiniBtn}> <View style={styles.sectionActions}>
<Pressable onPress={onEdit} hitSlop={6}>
<Text style={styles.editLink}>{content === '' ? '去填写' : '编辑'} &gt;</Text>
</Pressable>
<Pressable
style={styles.aiOptimizeMiniBtn}
onPress={onPolish}
disabled={polishing}
accessibilityLabel="AI 润色自我评价"
>
<Text style={styles.aiOptimizeMiniText}>AI 润色</Text> <Text style={styles.aiOptimizeMiniText}>AI 润色</Text>
</Pressable> </Pressable>
</View> </View>
</View>
{content === '' ? (
<Text style={styles.emptyText}>未填写自我评价</Text>
) : (
<Text style={styles.evalText}>{content}</Text> <Text style={styles.evalText}>{content}</Text>
)}
</View> </View>
); );
} }
@ -372,35 +648,127 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
export default function ResumeScreen() { export default function ResumeScreen() {
const router = useRouter(); const router = useRouter();
const { resume, loading, error, refresh, optimize } = useResume(); const {
const [currentResume, setCurrentResume] = useState<ResumeType | null>(null); resume,
savedAt,
loading,
error,
refresh,
saving,
optimize,
save,
saveSelfEvaluation,
saveSkills,
optimizeExperience,
polishSelfEvaluation,
} = useResume();
/**
* 「AI 一键优化」只留下评分——它返回的正文与当前简历一致(mock 阶段不改写用户内容,
* 见 `optimizeResume`),把整份结果塞进 state 会让此后每一次编辑都显示旧数据。
*/
const [optimizedScore, setOptimizedScore] = useState<ResumeType['score'] | null>(null);
const [optimizing, setOptimizing] = useState(false); const [optimizing, setOptimizing] = useState(false);
/** AI 单点动作进行中:按钮禁用,避免连点堆请求 */
const [aiBusy, setAiBusy] = useState(false);
const [skillsModal, setSkillsModal] = useState(false);
const [skillDraft, setSkillDraft] = useState('');
const [evalModal, setEvalModal] = useState(false);
const [evalDraft, setEvalDraft] = useState('');
const displayResume = currentResume ?? resume; // 简历永远取自 storage(唯一真相源),会话内的优化只覆盖评分那一块
const displayResume = resume === null ? null : { ...resume, score: optimizedScore ?? resume.score };
// 从编辑页返回时重新取数:各编辑页写入的是覆盖层,不刷新就显示旧值
// (useAsyncData 只在挂载时取数)
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh]),
);
const handleOptimize = async () => { const handleOptimize = async () => {
if (optimizing) return;
setOptimizing(true); setOptimizing(true);
try { try {
const optimized = await optimize(); const optimized = await optimize();
setCurrentResume(optimized); setOptimizedScore(optimized.score);
} finally { } finally {
setOptimizing(false); 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 ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onSave={() => {}} onExport={() => {}} /> <Header onBack={() => router.back()} onExport={() => router.push('/resume/export')} />
<Loading /> <Loading />
</SafeAreaView> </SafeAreaView>
); );
} }
if (error || !displayResume) { if (error !== null || displayResume === null) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onSave={() => {}} onExport={() => {}} /> <Header onBack={() => router.back()} onExport={() => router.push('/resume/export')} />
<ErrorState onRetry={refresh} /> <ErrorState onRetry={refresh} />
</SafeAreaView> </SafeAreaView>
); );
@ -410,7 +778,7 @@ export default function ResumeScreen() {
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header <Header
onBack={() => router.back()} onBack={() => router.back()}
onSave={() => {}} onSave={handleSave}
onExport={() => router.push('/resume/export')} onExport={() => router.push('/resume/export')}
/> />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
@ -420,18 +788,50 @@ export default function ResumeScreen() {
onOptimize={handleOptimize} onOptimize={handleOptimize}
optimizing={optimizing} optimizing={optimizing}
/> />
<OptimizeCard points={displayResume.score.optimizePoints} /> {/* 「保存」不是装饰:这里要如实显示存到了什么时候,否则用户不知道头部那个图标干了什么 */}
<BasicInfoCard info={displayResume.basicInfo} /> {savedAt !== '' && <Text style={styles.savedAt}>上次保存:{formatDateTime(savedAt)}</Text>}
<OptimizeCard
points={displayResume.score.optimizePoints}
onOptimizeAll={handleOptimize}
optimizing={optimizing}
/>
<BasicInfoCard info={displayResume.basicInfo} onEdit={() => router.push('/resume/basic')} />
<JobIntentionCard intention={displayResume.jobIntention} /> <JobIntentionCard intention={displayResume.jobIntention} />
<EducationCard education={displayResume.education} /> <EducationCard
<ExperienceCard experiences={displayResume.experience} /> education={displayResume.education}
<ProjectCard projects={displayResume.projects} /> onAdd={() => router.push('/resume/education?new=1')}
<SkillsCard skills={displayResume.skills} /> onEdit={() => router.push('/resume/education')}
<EvaluationCard content={displayResume.selfEvaluation.content} /> onItem={(id) => router.push('/resume/education?id=' + id)}
/>
<ExperienceCard
experiences={displayResume.experience}
onAdd={() => 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}
/>
<ProjectCard
projects={displayResume.projects}
onAdd={() => router.push('/resume/project?new=1')}
onEdit={() => router.push('/resume/project')}
onItem={(id) => router.push('/resume/project?id=' + id)}
/>
<SkillsCard skills={displayResume.skills} onEdit={() => setSkillsModal(true)} />
<EvaluationCard
content={displayResume.selfEvaluation.content}
onEdit={openEvalModal}
onPolish={() => void runAIAction(() => polishSelfEvaluation(displayResume.selfEvaluation.content))}
polishing={aiBusy}
/>
</ScrollView> </ScrollView>
<View style={styles.footer}> <View style={styles.footer}>
<Pressable style={styles.previewBtn}> <Pressable
style={styles.previewBtn}
onPress={() => router.push('/resume/preview')}
hitSlop={6}
>
<Text style={styles.previewBtnText}>预览</Text> <Text style={styles.previewBtnText}>预览</Text>
</Pressable> </Pressable>
<Pressable <Pressable
@ -446,6 +846,28 @@ export default function ResumeScreen() {
)} )}
</Pressable> </Pressable>
</View> </View>
<SkillsModal
visible={skillsModal}
skills={displayResume.skills}
draft={skillDraft}
onDraftChange={setSkillDraft}
onClose={() => {
setSkillDraft('');
setSkillsModal(false);
}}
onAdd={(name) => void handleAddSkill(name)}
onRemove={handleRemoveSkill}
saving={saving}
/>
<EvaluationModal
visible={evalModal}
draft={evalDraft}
onDraftChange={setEvalDraft}
onClose={() => setEvalModal(false)}
onSave={() => void handleSaveEvaluation()}
saving={saving}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -529,6 +951,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
optAllText: { fontSize: 14, fontWeight: '700', color: colors.primaryText }, optAllText: { fontSize: 14, fontWeight: '700', color: colors.primaryText },
optAllBtnDisabled: { opacity: 0.7 },
sectionTitleRow: { sectionTitleRow: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@ -536,7 +959,10 @@ const styles = StyleSheet.create({
marginBottom: 14, marginBottom: 14,
}, },
sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
sectionActions: { flexDirection: 'row', alignItems: 'center', gap: 14 },
editLink: { fontSize: 13, color: colors.primary }, editLink: { fontSize: 13, color: colors.primary },
/** 「上次保存」那行:只在真的保存过之后出现 */
savedAt: { fontSize: 12, color: colors.textHint, marginBottom: 12, marginTop: -4 },
basicRow: { flexDirection: 'row', alignItems: 'center' }, basicRow: { flexDirection: 'row', alignItems: 'center' },
avatar: { avatar: {
width: 60, width: 60,
@ -619,8 +1045,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 12, paddingHorizontal: 12,
paddingVertical: 6, paddingVertical: 6,
}, },
/** 弹层里的标签可点删除:补一个 × 图标,光靠「可点击」没有视觉线索 */
skillTagEditable: { flexDirection: 'row', alignItems: 'center', gap: 6 },
skillText: { fontSize: 13, color: colors.textPrimary }, skillText: { fontSize: 13, color: colors.textPrimary },
evalText: { fontSize: 13, color: colors.textSecondary, lineHeight: 22 }, evalText: { fontSize: 13, color: colors.textSecondary, lineHeight: 22 },
/** 引导未采集该板块时的空态文案(不伪装成已填写) */
emptyText: { fontSize: 13, color: colors.textHint, marginTop: 4 },
footer: { footer: {
position: 'absolute', position: 'absolute',
left: 0, left: 0,
@ -652,4 +1082,76 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
optimizeFooterBtnText: { fontSize: 15, fontWeight: '700', color: colors.white }, 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 },
}); });

136
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<BasicForm | null>(null);
const [formError, setFormError] = useState('');
useEffect(() => {
if (resume && !form) {
setForm({
name: resume.basicInfo.name,
phone: resume.basicInfo.phone,
email: resume.basicInfo.email,
location: resume.basicInfo.location,
});
}
}, [resume, form]);
const header = <EditHeader title="编辑基本信息" onBack={() => router.back()} />;
if (form === null) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
{error !== null ? (
<CenterState icon="alert-circle-outline" text="加载失败" actionLabel="点击重试" onAction={refresh} />
) : (
<CenterState text={loading ? '正在加载…' : '简历数据为空'} />
)}
</SafeAreaView>
);
}
const phone = form.phone.trim();
const email = form.email.trim();
const phoneError = phone !== '' && !isValidPhone(phone) ? '请输入 11 位手机号' : '';
const emailError = email !== '' && !isValidEmail(email) ? '请输入正确的邮箱地址' : '';
const canSave = phoneError === '' && emailError === '';
const update = (field: keyof BasicForm, text: string) =>
setForm((prev) => ({ ...(prev ?? form), [field]: text }));
const handleSave = async () => {
if (!canSave || saving) return;
setFormError('');
try {
await saveBasicInfo({ name: form.name, phone, email, location: form.location });
router.back();
} catch {
// 落库失败就留在本页如实说,不装作已经保存(后端接入后换成真实错误文案)
setFormError('保存失败,请稍后重试');
}
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
<ScrollView
contentContainerStyle={styles.body}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<Field
label="姓名"
value={form.name}
onChangeText={(v) => update('name', v)}
placeholder="请输入姓名"
maxLength={20}
/>
<Field
label="手机号"
value={form.phone}
onChangeText={(v) => update('phone', v)}
placeholder="请输入手机号"
keyboardType="phone-pad"
maxLength={11}
error={phoneError}
/>
<Field
label="邮箱"
value={form.email}
onChangeText={(v) => update('email', v)}
placeholder="请输入邮箱"
keyboardType="email-address"
error={emailError}
/>
<Field
label="所在城市"
value={form.location}
onChangeText={(v) => update('location', v)}
placeholder="如:北京 · 朝阳区"
maxLength={30}
/>
{formError !== '' && <Text style={styles.formError}>{formError}</Text>}
<FormNote text="手机号与邮箱引导流程未采集,需要在这里补全;简历里没填的项不显示空行。" />
</ScrollView>
<PageFooter>
<PrimaryButton label={saving ? '保存中…' : '保存'} active={canSave} onPress={handleSave} loading={saving} />
</PageFooter>
</SafeAreaView>
);
}
interface BasicForm {
name: string;
phone: string;
email: string;
location: string;
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.pageBg },
body: { padding: 20, paddingBottom: 24 },
formError: { fontSize: 13, color: colors.dangerText, marginBottom: 8 },
});

272
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<EduForm | null>(null);
/** 入口参数只解析一次:解析后再取数(或保存后的刷新)不该把用户拉回表单/列表 */
const [entryApplied, setEntryApplied] = useState(false);
useEffect(() => {
if (!resume || entryApplied) return;
setEntryApplied(true);
if (params.new === '1') {
setEditing(EMPTY_FORM);
return;
}
if (params.id !== undefined && params.id !== '') {
const found = resume.education.find((it) => it.id === params.id);
// 找不到就留在列表态:编一条空表单让用户改,等于凭空多出一条教育经历
if (found) setEditing(toForm(found));
}
}, [resume, entryApplied, params.new, params.id]);
const list = resume?.education ?? [];
const header = (
<EditHeader title={editing === null ? '教育背景' : editing.id === undefined ? '添加教育经历' : '编辑教育经历'} onBack={() => (editing === null ? router.back() : setEditing(null))} />
);
if (resume === null) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
{error !== null ? (
<CenterState icon="alert-circle-outline" text="加载失败" actionLabel="点击重试" onAction={refresh} />
) : (
<CenterState text={loading ? '正在加载…' : '简历数据为空'} />
)}
</SafeAreaView>
);
}
/* ===================== 表单态 ===================== */
if (editing !== null) {
const school = editing.school.trim();
const startError = editing.startYear !== '' && !YEAR_RE.test(editing.startYear) ? '请输入 4 位年份' : '';
const endError = editing.endYear !== '' && !YEAR_RE.test(editing.endYear) ? '请输入 4 位年份' : '';
const orderError =
startError === '' && endError === '' && editing.startYear !== '' && editing.endYear !== '' && editing.startYear > editing.endYear
? '毕业年份不能早于入学年份'
: '';
const canSave = school !== '' && startError === '' && endError === '' && orderError === '';
const update = (field: keyof EduForm, text: string) =>
setEditing((prev) => ({ ...(prev ?? editing), [field]: text }));
const handleSubmit = async () => {
if (!canSave || saving) return;
const fields = {
school,
major: editing.major.trim(),
degree: editing.degree.trim(),
startYear: editing.startYear,
endYear: editing.endYear,
};
// 交回整表。新增项**不带 id 字段**(而不是给空串):API 层用 `?? ` 判缺省,
// 空串不是 nullish,会把 id 写成空的
const next =
editing.id === undefined
? [...list, fields]
: list.map((it) => (it.id === editing.id ? { ...fields, id: editing.id } : it));
await saveEducation(next);
// 保存后回到列表态,让用户看见结果(不 router.back(),否则看不到自己刚加的那条)
setEditing(null);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
<ScrollView
contentContainerStyle={styles.body}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<Field
label="学校"
required
value={editing.school}
onChangeText={(v) => update('school', v)}
placeholder="请输入学校名称"
maxLength={30}
autoFocus
/>
<Field
label="专业"
value={editing.major}
onChangeText={(v) => update('major', v)}
placeholder="如:计算机科学与技术"
maxLength={30}
/>
<Field
label="学历"
value={editing.degree}
onChangeText={(v) => update('degree', v)}
placeholder="如:本科 / 硕士 非全日制"
maxLength={20}
hint="引导流程采集到的学历会带学习形式(如「硕士 非全日制」),这里可以照原样填"
/>
<View style={styles.yearRow}>
<View style={styles.yearItem}>
<Field
label="入学年份"
value={editing.startYear}
onChangeText={(v) => update('startYear', v.replace(/[^\d]/g, ''))}
placeholder="如:2018"
keyboardType="number-pad"
maxLength={4}
error={startError}
/>
</View>
<View style={styles.yearItem}>
<Field
label="毕业年份"
value={editing.endYear}
onChangeText={(v) => update('endYear', v.replace(/[^\d]/g, ''))}
placeholder="如:2022"
keyboardType="number-pad"
maxLength={4}
error={endError}
/>
</View>
</View>
{orderError !== '' && <Text style={styles.formError}>{orderError}</Text>}
<FormNote text="学校为必填;专业与年份没填就不在简历里显示,不会留下孤零零的分隔符。" />
</ScrollView>
<PageFooter>
<PrimaryButton label={saving ? '保存中…' : '保存'} active={canSave} onPress={handleSubmit} loading={saving} />
</PageFooter>
</SafeAreaView>
);
}
/* ===================== 列表态 ===================== */
const handleDelete = (item: ResumeEducation) => {
Alert.alert('删除这条教育经历?', item.school, [
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
// 即时落盘:删掉就走也不用担心没保存
onPress: () => {
void saveEducation(list.filter((it) => it.id !== item.id));
},
},
]);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
{list.length === 0 ? (
<CenterState icon="school-outline" text="还没有教育经历" />
) : (
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{list.map((edu) => {
// 专业 / 学历 / 年份可能为空(引导只采集学校和学历),拼出来会剩「· 本科」这种半截值
const majorDegree = [edu.major, edu.degree].filter(Boolean).join(' · ');
const years = [edu.startYear, edu.endYear].filter(Boolean).join('-');
return (
<View key={edu.id} style={styles.row}>
<View style={styles.rowBody}>
<Text style={styles.rowTitle}>{edu.school}</Text>
{majorDegree !== '' && <Text style={styles.rowSub}>{majorDegree}</Text>}
{years !== '' && <Text style={styles.rowSub}>{years}</Text>}
</View>
<IconAction
icon="create-outline"
label={`编辑 ${edu.school}`}
color={colors.primary}
onPress={() => setEditing(toForm(edu))}
/>
<IconAction
icon="trash-outline"
label={`删除 ${edu.school}`}
color={colors.danger}
onPress={() => handleDelete(edu)}
/>
</View>
);
})}
</ScrollView>
)}
<PageFooter>
<PrimaryButton label="添加教育经历" onPress={() => setEditing(EMPTY_FORM)} />
</PageFooter>
</SafeAreaView>
);
}
/* ===================== 表单与条目互转 ===================== */
interface EduForm {
/** undefined = 新增项(由 API 层补 id) */
id?: string;
school: string;
major: string;
degree: string;
startYear: string;
endYear: string;
}
const EMPTY_FORM: EduForm = { school: '', major: '', degree: '', startYear: '', endYear: '' };
const YEAR_RE = /^\d{4}$/;
const toForm = (edu: ResumeEducation): EduForm => ({
id: edu.id,
school: edu.school,
major: edu.major,
degree: edu.degree,
startYear: edu.startYear,
endYear: edu.endYear,
});
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.pageBg },
body: { padding: 20, paddingBottom: 24 },
row: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: 14,
paddingVertical: 14,
paddingLeft: 16,
paddingRight: 8,
marginBottom: 10,
borderWidth: 1,
borderColor: colors.borderLight,
},
rowBody: { flex: 1, gap: 4 },
rowTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
rowSub: { fontSize: 13, color: colors.textSecondary },
yearRow: { flexDirection: 'row', gap: 12 },
yearItem: { flex: 1 },
formError: { fontSize: 13, color: colors.dangerText, marginBottom: 8 },
});

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

340
app/resume/export.tsx

@ -3,6 +3,8 @@ import { useRouter } from 'expo-router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Modal,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -12,9 +14,17 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useResumeExport } from '../../src/hooks'; import {
import { colors } from '../../src/theme'; RESUME_EXPORT_FORMAT_NOTICE,
import type { ResumeExportFormat, ResumeExportSettings, ResumeTemplate } from '../../src/types'; 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 }[] = [ const FORMATS: { key: ResumeExportFormat; label: string; icon: keyof typeof Ionicons.glyphMap }[] = [
{ key: 'pdf', label: 'PDF', icon: 'document-outline' }, { 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' }, { 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<string, string> = {
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 ( return (
<View style={styles.previewCard}> <View style={styles.previewCard}>
<View style={styles.previewThumb}> <View style={styles.previewThumb}>
<Ionicons name="document-text-outline" size={48} color={colors.primary} /> <Ionicons name="document-text-outline" size={48} color={colors.primary} />
</View> </View>
<Text style={styles.previewText}>实时预览你的简历效果</Text> <Text style={styles.previewText}>实时预览你的简历效果</Text>
<Pressable style={styles.previewLink}> <Pressable style={styles.previewLink} onPress={onOpen} hitSlop={8}>
<Text style={styles.previewLinkText}>打开预览</Text> <Text style={styles.previewLinkText}>打开预览</Text>
<Ionicons name="chevron-forward" size={14} color={colors.primary} /> <Ionicons name="chevron-forward" size={14} color={colors.primary} />
</Pressable> </Pressable>
@ -109,6 +107,13 @@ function FormatSelect({
); );
})} })}
</View> </View>
{/* 非 PDF 选中的那一刻就说清能做到什么:等到导出后才说,用户已经白等一次 */}
{value !== 'pdf' && (
<View style={styles.noticeRow}>
<Ionicons name="information-circle-outline" size={14} color={colors.textHint} />
<Text style={styles.noticeText}>{RESUME_EXPORT_FORMAT_NOTICE}</Text>
</View>
)}
</View> </View>
); );
} }
@ -118,20 +123,22 @@ function FormatSelect({
function TemplateSelect({ function TemplateSelect({
value, value,
onChange, onChange,
onMore,
}: { }: {
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
onMore: () => void;
}) { }) {
return ( return (
<View style={styles.section}> <View style={styles.section}>
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>选择简历模板</Text> <Text style={styles.sectionTitle}>选择简历模板</Text>
<Pressable> <Pressable onPress={onMore} hitSlop={8}>
<Text style={styles.moreLink}>全部模板 &gt;</Text> <Text style={styles.moreLink}>全部模板 &gt;</Text>
</Pressable> </Pressable>
</View> </View>
<View style={styles.templateRow}> <View style={styles.templateRow}>
{TEMPLATES.map((t) => { {RESUME_TEMPLATES.map((t) => {
const selected = value === t.id; const selected = value === t.id;
return ( return (
<Pressable <Pressable
@ -139,10 +146,12 @@ function TemplateSelect({
style={[styles.templateCard, selected && styles.templateCardActive]} style={[styles.templateCard, selected && styles.templateCardActive]}
onPress={() => onChange(t.id)} onPress={() => onChange(t.id)}
> >
{/* TODO(素材):需 UI 出 3 张模板预览图(简约经典 / 创意设计 / 技术专业),
当前用模板强调色色块 + 线条占位 */}
<View <View
style={[ style={[
styles.templateThumb, styles.templateThumb,
{ backgroundColor: TEMPLATE_COLORS[t.color] ?? colors.primary }, { backgroundColor: templateAccentColors[t.color] ?? colors.primary },
]} ]}
> >
<View style={styles.templateLines}> <View style={styles.templateLines}>
@ -167,11 +176,13 @@ function TemplateSelect({
function ToggleRow({ function ToggleRow({
label, label,
desc, desc,
notice,
value, value,
onChange, onChange,
}: { }: {
label: string; label: string;
desc?: string; desc?: string;
notice?: string;
value: boolean; value: boolean;
onChange: (v: boolean) => void; onChange: (v: boolean) => void;
}) { }) {
@ -180,6 +191,7 @@ function ToggleRow({
<View style={styles.toggleText}> <View style={styles.toggleText}>
<Text style={styles.toggleLabel}>{label}</Text> <Text style={styles.toggleLabel}>{label}</Text>
{desc && <Text style={styles.toggleDesc}>{desc}</Text>} {desc && <Text style={styles.toggleDesc}>{desc}</Text>}
{notice && <Text style={styles.noticeText}>{notice}</Text>}
</View> </View>
<Switch <Switch
value={value} value={value}
@ -191,22 +203,27 @@ function ToggleRow({
); );
} }
/* ===================== 子组件:输入行 ===================== */ /* ===================== 子组件:输入行 / 选择行 ===================== */
function InputRow({ function InputRow({
label, label,
value, value,
onChange, onChange,
placeholder, placeholder,
hint,
}: { }: {
label: string; label: string;
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
placeholder?: string; placeholder?: string;
hint?: string;
}) { }) {
return ( return (
<View style={styles.inputRow}> <View style={styles.inputRow}>
<View style={styles.inputLabelBox}>
<Text style={styles.inputLabel}>{label}</Text> <Text style={styles.inputLabel}>{label}</Text>
{hint && <Text style={styles.inputHint}>{hint}</Text>}
</View>
<TextInput <TextInput
style={styles.input} style={styles.input}
value={value} value={value}
@ -218,6 +235,80 @@ function InputRow({
); );
} }
/** 点开弹层选择的行:纸型 / 方向用(原先两行的 onChange 是空函数,点下去没反应) */
function SelectRow({
label,
value,
onPress,
}: {
label: string;
value: string;
onPress: () => void;
}) {
return (
<Pressable style={styles.inputRow} onPress={onPress}>
<Text style={styles.inputLabel}>{label}</Text>
<View style={styles.selectValue}>
<Text style={styles.selectValueText}>{value}</Text>
<Ionicons name="chevron-forward" size={14} color={colors.textHint} />
</View>
</Pressable>
);
}
/* ===================== 子组件:选择弹层 ===================== */
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 (
<Modal visible transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.scrim} onPress={onClose}>
{/* affordance-ok:吞冒泡,点弹层内容区不关闭 */}
<Pressable style={styles.sheet} onPress={() => {}}>
<Text style={styles.sheetTitle}>{sheet.title}</Text>
{sheet.options.map((option) => {
const selected = option.value === sheet.value;
return (
<Pressable
key={option.value}
style={styles.sheetRow}
onPress={() => sheet.onSelect(option.value)}
>
{option.accent !== undefined && (
<View style={[styles.sheetSwatch, { backgroundColor: option.accent }]} />
)}
<Text style={[styles.sheetLabel, selected && styles.sheetLabelActive]}>
{option.label}
</Text>
{selected && <Ionicons name="checkmark" size={18} color={colors.primary} />}
</Pressable>
);
})}
{sheet.hint !== undefined && <Text style={styles.sheetHint}>{sheet.hint}</Text>}
<Pressable style={styles.sheetCancel} onPress={onClose}>
<Text style={styles.sheetCancelText}>取消</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
);
}
/* ===================== 子组件:四态 ===================== */ /* ===================== 子组件:四态 ===================== */
function Loading() { 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() { export default function ResumeExportScreen() {
const router = useRouter(); 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<ResumeExportSettings | null>(null); const [localSettings, setLocalSettings] = useState<ResumeExportSettings | null>(null);
const [sheet, setSheet] = useState<SheetState | null>(null);
const [optimizing, setOptimizing] = useState(false);
useEffect(() => { useEffect(() => {
if (settings && !localSettings) { if (settings && !localSettings) {
@ -258,19 +368,68 @@ export default function ResumeExportScreen() {
setLocalSettings((prev) => (prev ? { ...prev, [key]: value } : prev)); setLocalSettings((prev) => (prev ? { ...prev, [key]: value } : prev));
}; };
/** 只落盘不带提示:导出 / 分享前先把设置保存好,避免导出用的是一份没保存的配置 */
const persist = async () => {
if (localSettings) await save(localSettings);
};
const handleSave = async () => { const handleSave = async () => {
if (localSettings) { await persist();
await save(localSettings); Alert.alert('已保存', '导出设置已保存在本机,下次进入仍是这份设置。');
}
}; };
const handleExport = async () => { const handleExport = async () => {
if (!localSettings) return; if (!localSettings) return;
await handleSave(); await persist();
await exportFile(localSettings); 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);
}; };
if (loading) { 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);
}
};
// 保存设置会 refresh,refresh 会把 loading 置真;此时 localSettings 已有值,
// 不能因为刷新再闪一次整页加载态(首次加载才显示 loading)
if (loading && localSettings === null) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} /> <Header onBack={() => router.back()} />
@ -292,11 +451,27 @@ export default function ResumeExportScreen() {
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onSave={handleSave} /> <Header onBack={() => router.back()} onSave={handleSave} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<PreviewCard /> <PreviewCard onOpen={() => router.push('/resume/preview')} />
<FormatSelect value={localSettings.format} onChange={(v) => update('format', v)} /> <FormatSelect value={localSettings.format} onChange={(v) => update('format', v)} />
<TemplateSelect value={localSettings.templateId} onChange={(v) => update('templateId', v)} /> <TemplateSelect
value={localSettings.templateId}
onChange={(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),
})
}
/>
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>导出设置</Text> <Text style={styles.sectionTitle}>导出设置</Text>
@ -310,24 +485,55 @@ export default function ResumeExportScreen() {
<ToggleRow <ToggleRow
label="导出密码" label="导出密码"
desc="为导出的简历设置查看密码" desc="为导出的简历设置查看密码"
notice={localSettings.exportPassword ? RESUME_EXPORT_PASSWORD_NOTICE : undefined}
value={localSettings.exportPassword} value={localSettings.exportPassword}
onChange={(v) => update('exportPassword', v)} onChange={(v) => update('exportPassword', v)}
/> />
<InputRow label="纸型" value={localSettings.paperSize} onChange={() => {}} /> <SelectRow
<InputRow label="方向" value={localSettings.orientation === 'portrait' ? '纵向' : '横向'} onChange={() => {}} /> label="纸型"
value={
RESUME_PAPER_SIZES.find((p) => 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)),
})
}
/>
<SelectRow
label="方向"
value={
RESUME_ORIENTATIONS.find((o) => 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)),
})
}
/>
<InputRow <InputRow
label="文件名" label="文件名"
value={localSettings.fileName} value={localSettings.fileName}
onChange={(v) => update('fileName', v)} onChange={(v) => update('fileName', v)}
placeholder="请输入文件名" placeholder="请输入文件名"
hint={RESUME_FILE_NAME_HINT}
/> />
<ToggleRow <ToggleRow
label="包含封面页" label="包含封面页"
desc="封面含姓名、期望职位与联系方式"
value={localSettings.includeCover} value={localSettings.includeCover}
onChange={(v) => update('includeCover', v)} onChange={(v) => update('includeCover', v)}
/> />
<ToggleRow <ToggleRow
label="包含自荐信" label="包含自荐信"
desc="以自我评价内容生成;自我评价为空时不生成"
value={localSettings.includeRecommendation} value={localSettings.includeRecommendation}
onChange={(v) => update('includeRecommendation', v)} onChange={(v) => update('includeRecommendation', v)}
/> />
@ -348,17 +554,37 @@ export default function ResumeExportScreen() {
numberOfLines={4} numberOfLines={4}
textAlignVertical="top" textAlignVertical="top"
/> />
<Pressable style={styles.targetBtn}> <Pressable
style={[styles.targetBtn, (jd === '' || optimizing) && styles.targetBtnDisabled]}
onPress={handleOptimize}
disabled={jd === '' || optimizing}
>
{optimizing ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<>
<Ionicons name="sparkles" size={16} color={colors.white} /> <Ionicons name="sparkles" size={16} color={colors.white} />
<Text style={styles.targetBtnText}>AI 智能优化</Text> <Text style={styles.targetBtnText}>AI 智能优化</Text>
</>
)}
</Pressable> </Pressable>
{/* 按钮置灰时把原因写在旁边:灰按钮不给理由,用户只会以为坏了 */}
{jd === '' && <Text style={styles.noticeText}>请先粘贴岗位描述,再点优化</Text>}
</View> </View>
</View> </View>
</ScrollView> </ScrollView>
<View style={styles.footer}> <View style={styles.footer}>
<Pressable style={styles.shareBtn}> <Pressable
style={[styles.shareBtn, sharing && styles.shareBtnDisabled]}
onPress={() => void handleShare()}
disabled={sharing}
>
{sharing ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Text style={styles.shareBtnText}>分享简历</Text> <Text style={styles.shareBtnText}>分享简历</Text>
)}
</Pressable> </Pressable>
<Pressable <Pressable
style={[styles.exportBtn, exporting && styles.exportBtnDisabled]} style={[styles.exportBtn, exporting && styles.exportBtnDisabled]}
@ -372,6 +598,13 @@ export default function ResumeExportScreen() {
)} )}
</Pressable> </Pressable>
</View> </View>
{sheet !== null && (
<OptionSheet
sheet={sheet}
onClose={() => setSheet(null)}
/>
)}
</SafeAreaView> </SafeAreaView>
); );
} }
@ -439,6 +672,8 @@ const styles = StyleSheet.create({
formatLabelActive: { color: colors.primaryText }, formatLabelActive: { color: colors.primaryText },
formatHint: { fontSize: 10, color: colors.textHint, marginTop: 2 }, formatHint: { fontSize: 10, color: colors.textHint, marginTop: 2 },
formatCheck: { position: 'absolute', top: 8, right: 8 }, 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 }, templateRow: { flexDirection: 'row', gap: 10 },
templateCard: { templateCard: {
flex: 1, flex: 1,
@ -489,13 +724,17 @@ const styles = StyleSheet.create({
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: colors.borderLight, borderTopColor: colors.borderLight,
}, },
inputLabelBox: { flex: 1, marginRight: 12 },
inputLabel: { fontSize: 14, color: colors.textPrimary, width: 80 }, inputLabel: { fontSize: 14, color: colors.textPrimary, width: 80 },
inputHint: { fontSize: 11, color: colors.textHint, marginTop: 2 },
input: { input: {
flex: 1, flex: 1,
fontSize: 14, fontSize: 14,
color: colors.textPrimary, color: colors.textPrimary,
textAlign: 'right', textAlign: 'right',
}, },
selectValue: { flexDirection: 'row', alignItems: 'center', gap: 4 },
selectValueText: { fontSize: 14, color: colors.textSecondary },
targetLabel: { fontSize: 13, color: colors.textSecondary, marginBottom: 10 }, targetLabel: { fontSize: 13, color: colors.textSecondary, marginBottom: 10 },
targetInput: { targetInput: {
height: 100, height: 100,
@ -514,7 +753,37 @@ const styles = StyleSheet.create({
paddingVertical: 12, paddingVertical: 12,
marginTop: 12, marginTop: 12,
}, },
targetBtnDisabled: { backgroundColor: colors.textDisabled },
targetBtnText: { fontSize: 14, fontWeight: '700', color: colors.white, marginLeft: 6 }, 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: { footer: {
position: 'absolute', position: 'absolute',
left: 0, left: 0,
@ -537,6 +806,7 @@ const styles = StyleSheet.create({
borderColor: colors.border, borderColor: colors.border,
marginRight: 12, marginRight: 12,
}, },
shareBtnDisabled: { borderColor: colors.borderLight },
shareBtnText: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, shareBtnText: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
exportBtn: { exportBtn: {
flex: 2, flex: 2,

240
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 = <EditHeader title="简历预览" onBack={() => router.back()} />;
if (resume === null) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
{error !== null ? (
<CenterState icon="alert-circle-outline" text="加载失败" actionLabel="点击重试" onAction={refresh} />
) : (
<CenterState text={loading ? '正在加载…' : '简历数据为空'} />
)}
</SafeAreaView>
);
}
const { basicInfo, jobIntention, education, experience, projects, skills, selfEvaluation } = resume;
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{/* 纸张:白底 + 描边,与导出效果同一套排版 */}
<View style={styles.paper}>
<View style={styles.paperHead}>
<View style={styles.avatar}>
{/* TODO(素材):接入后端后这里渲染用户上传的头像图片;
当前以姓名首字兜底,无姓名时不占位 */}
<Text style={styles.avatarText}>{basicInfo.avatar !== '' ? basicInfo.avatar : '简'}</Text>
</View>
<View style={styles.headBody}>
<Text style={styles.name}>{basicInfo.name !== '' ? basicInfo.name : UNSET_LABEL}</Text>
{/* 有值才渲染:空白行比不渲染更让人困惑 */}
{[basicInfo.phone, basicInfo.email, basicInfo.location].filter((v) => v !== '').length > 0 && (
<Text style={styles.contact}>
{[basicInfo.phone, basicInfo.email, basicInfo.location].filter((v) => v !== '').join(' · ')}
</Text>
)}
</View>
</View>
<Section title="求职意向">
{hasIntention(jobIntention) ? (
<Text style={styles.paragraph}>
{[
jobIntention.position,
jobIntention.city,
jobIntention.salary,
jobIntention.statusLabel,
]
.filter((v) => v !== '')
.join(' · ')}
</Text>
) : (
<Empty text="未填写求职意向" />
)}
</Section>
<Section title="教育背景">
{education.length === 0 ? (
<Empty text="未填写教育背景" />
) : (
education.map((edu) => <EducationItem key={edu.id} edu={edu} />)
)}
</Section>
<Section title="工作经历">
{experience.length === 0 ? (
<Empty text="未填写工作经历" />
) : (
experience.map((exp) => <ExperienceItem key={exp.id} exp={exp} />)
)}
</Section>
<Section title="项目经历">
{projects.length === 0 ? (
<Empty text="未填写项目经历" />
) : (
projects.map((proj) => <ProjectItem key={proj.id} proj={proj} />)
)}
</Section>
<Section title="技能特长">
{skills.length === 0 ? (
<Empty text="未填写技能特长" />
) : (
<Text style={styles.paragraph}>{skills.map((s) => s.name).join(' · ')}</Text>
)}
</Section>
<Section title="自我评价">
{selfEvaluation.content === '' ? (
<Empty text="未填写自我评价" />
) : (
<Text style={styles.paragraph}>{selfEvaluation.content}</Text>
)}
</Section>
</View>
<Text style={styles.tip}>
预览为简历正文效果,不含评分与优化建议。模板样式、纸张大小需后端导出服务支持,当前按默认版式呈现。
</Text>
</ScrollView>
<PageFooter>
<PrimaryButton label="去导出设置" onPress={() => router.push('/resume/export')} />
</PageFooter>
</SafeAreaView>
);
}
/* ===================== 子组件 ===================== */
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{title}</Text>
{children}
</View>
);
}
function Empty({ text }: { text: string }) {
return <Text style={styles.emptyText}>{text}</Text>;
}
function EducationItem({ edu }: { edu: ResumeEducation }) {
// 专业 / 学历 / 年份可能为空,拼出来会剩半截值
const majorDegree = [edu.major, edu.degree].filter(Boolean).join(' · ');
const years = [edu.startYear, edu.endYear].filter(Boolean).join('-');
return (
<View style={styles.item}>
<View style={styles.itemHead}>
<Text style={styles.itemTitle}>{edu.school}</Text>
{years !== '' && <Text style={styles.itemMeta}>{years}</Text>}
</View>
{majorDegree !== '' && <Text style={styles.itemSub}>{majorDegree}</Text>}
</View>
);
}
function ExperienceItem({ exp }: { exp: ResumeExperience }) {
const dates = [exp.startDate, exp.endDate].filter(Boolean).join(' - ');
return (
<View style={styles.item}>
<View style={styles.itemHead}>
<Text style={styles.itemTitle}>{exp.company}</Text>
{dates !== '' && <Text style={styles.itemMeta}>{dates}</Text>}
</View>
{exp.position !== '' && <Text style={styles.itemSub}>{exp.position}</Text>}
{exp.description !== '' && <Text style={styles.paragraph}>{exp.description}</Text>}
</View>
);
}
function ProjectItem({ proj }: { proj: ResumeProject }) {
return (
<View style={styles.item}>
<View style={styles.itemHead}>
<Text style={styles.itemTitle}>{proj.name}</Text>
{proj.role !== '' && <Text style={styles.itemMeta}>{proj.role}</Text>}
</View>
{proj.description !== '' && <Text style={styles.paragraph}>{proj.description}</Text>}
</View>
);
}
/** 求职意向四项全空才算未填写(引导只采集到其中一两项时也算有内容) */
function hasIntention(intention: Resume['jobIntention']): boolean {
return (
[intention.position, intention.city, intention.salary, intention.statusLabel].filter(
(v) => v !== '',
).length > 0
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.pageBg },
body: { padding: 16, paddingBottom: 24 },
paper: {
backgroundColor: colors.white,
borderRadius: 8,
padding: 20,
borderWidth: 1,
borderColor: colors.borderLight,
},
paperHead: { flexDirection: 'row', alignItems: 'center', marginBottom: 20 },
avatar: {
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: { fontSize: 22, fontWeight: '700', color: colors.white },
headBody: { marginLeft: 14, flex: 1 },
name: { fontSize: 20, fontWeight: '800', color: colors.textPrimary },
contact: { fontSize: 13, color: colors.textSecondary, marginTop: 6 },
section: { marginBottom: 18 },
sectionTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
borderLeftWidth: 3,
borderLeftColor: colors.primary,
paddingLeft: 8,
marginBottom: 10,
},
item: { marginBottom: 12 },
itemHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
itemTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, flex: 1 },
itemMeta: { fontSize: 12, color: colors.textHint },
itemSub: { fontSize: 13, color: colors.textSecondary, marginTop: 4 },
paragraph: { fontSize: 13, color: colors.textSecondary, lineHeight: 21, marginTop: 6 },
emptyText: { fontSize: 13, color: colors.textHint },
tip: { fontSize: 12, color: colors.textHint, lineHeight: 18, marginTop: 4 },
});

225
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<ProjForm | null>(null);
const [entryApplied, setEntryApplied] = useState(false);
useEffect(() => {
if (!resume || entryApplied) return;
setEntryApplied(true);
if (params.new === '1') {
setEditing(EMPTY_FORM);
return;
}
if (params.id !== undefined && params.id !== '') {
const found = resume.projects.find((it) => it.id === params.id);
if (found) setEditing(toForm(found));
}
}, [resume, entryApplied, params.new, params.id]);
const list = resume?.projects ?? [];
const header = (
<EditHeader
title={editing === null ? '项目经历' : editing.id === undefined ? '添加项目经历' : '编辑项目经历'}
onBack={() => (editing === null ? router.back() : setEditing(null))}
/>
);
if (resume === null) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
{error !== null ? (
<CenterState icon="alert-circle-outline" text="加载失败" actionLabel="点击重试" onAction={refresh} />
) : (
<CenterState text={loading ? '正在加载…' : '简历数据为空'} />
)}
</SafeAreaView>
);
}
/* ===================== 表单态 ===================== */
if (editing !== null) {
const name = editing.name.trim();
const canSave = name !== '';
const update = (patch: Partial<ProjForm>) => setEditing((prev) => ({ ...(prev ?? editing), ...patch }));
const handleSubmit = async () => {
if (!canSave || saving) return;
const fields = {
name,
role: editing.role.trim(),
description: editing.description.trim(),
aiOptimized: false,
};
const next =
editing.id === undefined
? [...list, fields]
: list.map((it) => (it.id === editing.id ? { ...fields, id: editing.id } : it));
await saveProjects(next);
setEditing(null);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
<ScrollView
contentContainerStyle={styles.body}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<Field
label="项目名称"
required
value={editing.name}
onChangeText={(v) => update({ name: v })}
placeholder="请输入项目名称"
maxLength={40}
autoFocus
/>
<Field
label="担任角色"
value={editing.role}
onChangeText={(v) => update({ role: v })}
placeholder="如:前端负责人"
maxLength={20}
/>
<Field
label="项目描述"
value={editing.description}
onChangeText={(v) => update({ description: v })}
placeholder="做了什么、结果如何(有数字更好,如:首屏从 3.5s 降至 1.2s)"
multiline
maxLength={500}
/>
<FormNote text="项目名称为必填;角色与描述没填就不在简历里显示空行。" />
</ScrollView>
<PageFooter>
<PrimaryButton label={saving ? '保存中…' : '保存'} active={canSave} onPress={handleSubmit} loading={saving} />
</PageFooter>
</SafeAreaView>
);
}
/* ===================== 列表态 ===================== */
const handleDelete = (item: ResumeProject) => {
Alert.alert('删除这条项目经历?', item.name, [
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: () => {
void saveProjects(list.filter((it) => it.id !== item.id));
},
},
]);
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{header}
{list.length === 0 ? (
<CenterState icon="albums-outline" text="还没有项目经历" />
) : (
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{list.map((proj) => (
<View key={proj.id} style={styles.row}>
<View style={styles.rowBody}>
<Text style={styles.rowTitle}>{proj.name}</Text>
{proj.role !== '' && <Text style={styles.rowSub}>{proj.role}</Text>}
{proj.description !== '' && (
<Text style={styles.rowDesc} numberOfLines={2}>
{proj.description}
</Text>
)}
</View>
<IconAction
icon="create-outline"
label={`编辑 ${proj.name}`}
color={colors.primary}
onPress={() => setEditing(toForm(proj))}
/>
<IconAction
icon="trash-outline"
label={`删除 ${proj.name}`}
color={colors.danger}
onPress={() => handleDelete(proj)}
/>
</View>
))}
</ScrollView>
)}
<PageFooter>
<PrimaryButton label="添加项目经历" onPress={() => setEditing(EMPTY_FORM)} />
</PageFooter>
</SafeAreaView>
);
}
/* ===================== 表单与条目互转 ===================== */
interface ProjForm {
/** undefined = 新增项(由 API 层补 id) */
id?: string;
name: string;
role: string;
description: string;
}
const EMPTY_FORM: ProjForm = { name: '', role: '', description: '' };
const toForm = (proj: ResumeProject): ProjForm => ({
id: proj.id,
name: proj.name,
role: proj.role,
description: proj.description,
});
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.pageBg },
body: { padding: 20, paddingBottom: 24 },
row: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: 14,
paddingVertical: 14,
paddingLeft: 16,
paddingRight: 8,
marginBottom: 10,
borderWidth: 1,
borderColor: colors.borderLight,
},
rowBody: { flex: 1, gap: 4 },
rowTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
rowSub: { fontSize: 13, color: colors.textSecondary },
rowDesc: { fontSize: 13, color: colors.textHint, lineHeight: 18 },
});

41
app/settings.tsx

@ -3,6 +3,8 @@ import { useRouter } from 'expo-router';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import { useAppCache } from '../src/hooks';
import { formatBytes } from '../src/utils';
import { useAuth } from '../src/context/AuthContext'; import { useAuth } from '../src/context/AuthContext';
import { useRole } from '../src/context/RoleContext'; import { useRole } from '../src/context/RoleContext';
@ -62,7 +64,9 @@ const SETTINGS_GROUPS: SettingsGroup[] = [
{ {
title: '通用', title: '通用',
items: [ 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', id: 'about',
title: '关于我们', title: '关于我们',
@ -93,6 +97,28 @@ export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const { logout } = useAuth(); const { logout } = useAuth();
const { role, setRole } = useRole(); 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 = () => { const handleLogout = () => {
Alert.alert('确认退出登录', '退出后需要重新登录', [ Alert.alert('确认退出登录', '退出后需要重新登录', [
@ -123,6 +149,10 @@ export default function SettingsScreen() {
handleIdentitySwitch(); handleIdentitySwitch();
return; return;
} }
if (item.id === 'cache') {
handleClearCache();
return;
}
if (item.route) { if (item.route) {
router.push(item.route); router.push(item.route);
return; return;
@ -155,7 +185,14 @@ export default function SettingsScreen() {
<Ionicons name={item.icon} size={18} color={colors.primaryDark} /> <Ionicons name={item.icon} size={18} color={colors.primaryDark} />
</View> </View>
<Text style={styles.rowTitle}>{item.title}</Text> <Text style={styles.rowTitle}>{item.title}</Text>
{item.value ? <Text style={styles.rowValue}>{item.value}</Text> : null} {/* 缓存行显示运行时读到的体积,读不到就显示占位「--」而不是假数字 */}
{item.id === 'cache' ? (
<Text style={styles.rowValue}>
{cacheLoading ? '计算中' : cacheLabel || '--'}
</Text>
) : item.value ? (
<Text style={styles.rowValue}>{item.value}</Text>
) : null}
<Ionicons name="chevron-forward" size={16} color={colors.textHint} /> <Ionicons name="chevron-forward" size={16} color={colors.textHint} />
</Pressable> </Pressable>
); );

48
app/tools/salary-calculator.tsx

@ -13,7 +13,7 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; 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 { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
import { colors, headerGradientShort } from '../../src/theme'; import { colors, headerGradientShort } from '../../src/theme';
import { import {
@ -221,8 +221,12 @@ function StructureCard({ result }: { result: import('../../src/types').SalaryCal
/* ===================== 子组件:更多工具 ===================== */ /* ===================== 子组件:更多工具 ===================== */
function ToolLink({ tool }: { tool: SalaryToolLink }) { function ToolLink({ tool }: { tool: SalaryToolLink }) {
return ( const router = useRouter();
<Pressable style={styles.toolRow}> // 目标路由由接口层给出;没有 route 的只做展示,不带箭头
const route = tool.route;
const body = (
<>
<View style={[styles.toolIcon, { backgroundColor: colors.primaryLight }]}> <View style={[styles.toolIcon, { backgroundColor: colors.primaryLight }]}>
<Ionicons name={tool.icon as keyof typeof Ionicons.glyphMap} size={20} color={colors.primary} /> <Ionicons name={tool.icon as keyof typeof Ionicons.glyphMap} size={20} color={colors.primary} />
</View> </View>
@ -230,7 +234,16 @@ function ToolLink({ tool }: { tool: SalaryToolLink }) {
<Text style={styles.toolTitle}>{tool.title}</Text> <Text style={styles.toolTitle}>{tool.title}</Text>
<Text style={styles.toolDesc}>{tool.desc}</Text> <Text style={styles.toolDesc}>{tool.desc}</Text>
</View> </View>
<Ionicons name="chevron-forward" size={18} color={colors.textHint} /> {route ? <Ionicons name="chevron-forward" size={18} color={colors.textHint} /> : null}
</>
);
if (!route) {
return <View style={styles.toolRow}>{body}</View>;
}
return (
<Pressable style={styles.toolRow} onPress={() => router.push(route as never)}>
{body}
</Pressable> </Pressable>
); );
} }
@ -241,6 +254,7 @@ export default function SalaryCalculatorScreen() {
const router = useRouter(); const router = useRouter();
const { inputs, setInputs, result } = useSalaryCalculator(); const { inputs, setInputs, result } = useSalaryCalculator();
const records = useSalaryRecords(); const records = useSalaryRecords();
const { tools } = useSalaryToolLinks();
const [cityPickerVisible, setCityPickerVisible] = useState(false); const [cityPickerVisible, setCityPickerVisible] = useState(false);
const [historyVisible, setHistoryVisible] = useState(false); const [historyVisible, setHistoryVisible] = useState(false);
const [saveSheetVisible, setSaveSheetVisible] = useState(false); const [saveSheetVisible, setSaveSheetVisible] = useState(false);
@ -352,32 +366,10 @@ export default function SalaryCalculatorScreen() {
{/* 薪资结构 */} {/* 薪资结构 */}
<StructureCard result={result} /> <StructureCard result={result} />
{/* 更多工具 */} {/* 更多工具:清单来自接口层,均为项目内真实页面 */}
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.cardTitle}>更多工具</Text> <Text style={styles.cardTitle}>更多工具</Text>
{[ {tools.map((tool) => (
{
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) => (
<ToolLink key={tool.id} tool={tool} /> <ToolLink key={tool.id} tool={tool} />
))} ))}
</View> </View>

313
app/tools/skill-cloud.tsx

@ -3,17 +3,19 @@ import { useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Modal,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
TextInput,
View, View,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { IndustrySkillWordCloud } from '../../components/IndustrySkillWordCloud'; import { IndustrySkillWordCloud } from '../../components/IndustrySkillWordCloud';
import { useSkillCloud } from '../../src/hooks'; import { useSkillCloud, useSkillLearningPath } from '../../src/hooks';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import type { SkillWord, TopSkill } from '../../src/types'; import type { SkillLearningPath, SkillWord, TopSkill } from '../../src/types';
/** 技能分组 → 中文标签(用于选中详情) */ /** 技能分组 → 中文标签(用于选中详情) */
const GROUP_LABEL: Record<SkillWord['group'], string> = { const GROUP_LABEL: Record<SkillWord['group'], string> = {
@ -25,16 +27,60 @@ const GROUP_LABEL: Record<SkillWord['group'], string> = {
/* ===================== 子组件:顶部导航 ===================== */ /* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack, onSearch }: { onBack: () => void; onSearch: () => void }) { function Header({
onBack,
onToggleSearch,
searching,
}: {
onBack: () => void;
onToggleSearch: () => void;
searching: boolean;
}) {
return ( return (
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>行业技能词云</Text> <Text style={styles.headerTitle}>行业技能词云</Text>
<Pressable style={styles.headerBtn} onPress={onSearch} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={onToggleSearch} hitSlop={8}>
<Ionicons name="search-outline" size={22} color={colors.textPrimary} /> <Ionicons
name={searching ? 'close' : 'search-outline'}
size={22}
color={colors.textPrimary}
/>
</Pressable>
</View>
);
}
/* ===================== 子组件:搜索栏 ===================== */
function SearchBar({
value,
onChange,
onClear,
}: {
value: string;
onChange: (text: string) => void;
onClear: () => void;
}) {
return (
<View style={styles.searchBar}>
<Ionicons name="search-outline" size={16} color={colors.textHint} />
<TextInput
style={styles.searchInput}
value={value}
onChangeText={onChange}
placeholder="搜索技能,如 React"
placeholderTextColor={colors.textHint}
autoFocus
returnKeyType="search"
/>
{value !== '' && (
<Pressable onPress={onClear} hitSlop={8}>
<Ionicons name="close-circle" size={16} color={colors.textHint} />
</Pressable> </Pressable>
)}
</View> </View>
); );
} }
@ -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 ( return (
<View style={styles.matchCard}> <View style={styles.matchCard}>
<View style={styles.matchHeader}> <View style={styles.matchHeader}>
@ -169,14 +223,110 @@ function MatchCard({ percent }: { percent: number }) {
<View style={styles.matchTrack}> <View style={styles.matchTrack}>
<View style={[styles.matchFill, { width: `${percent}%` }]} /> <View style={[styles.matchFill, { width: `${percent}%` }]} />
</View> </View>
<Pressable style={styles.matchBtn}> <Pressable
style={[styles.matchBtn, generating && styles.matchBtnDisabled]}
onPress={onGenerate}
disabled={generating}
>
{generating ? (
<ActivityIndicator size="small" color={colors.white} />
) : (
<Ionicons name="sparkles" size={16} color={colors.white} /> <Ionicons name="sparkles" size={16} color={colors.white} />
<Text style={styles.matchBtnText}>生成技能学习路径</Text> )}
<Text style={styles.matchBtnText}>
{generating ? '正在生成…' : '生成技能学习路径'}
</Text>
</Pressable> </Pressable>
</View> </View>
); );
} }
/* ===================== 子组件:学习路径弹窗 ===================== */
function LearningPathModal({
visible,
path,
loading,
error,
onRetry,
onClose,
}: {
visible: boolean;
path: SkillLearningPath | null;
loading: boolean;
error: Error | null;
onRetry: () => void;
onClose: () => void;
}) {
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<View style={styles.modalMask}>
<View style={styles.modalSheet}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>技能学习路径</Text>
<Pressable onPress={onClose} hitSlop={8}>
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
{loading ? (
<View style={styles.modalCenter}>
<ActivityIndicator size="large" color={colors.primary} />
<Text style={styles.tip}>正在按行业热度排优先级…</Text>
</View>
) : error ? (
<View style={styles.modalCenter}>
<Text style={styles.tip}>{error.message}</Text>
<Pressable style={styles.retryBtn} onPress={onRetry} hitSlop={6}>
<Text style={styles.retryText}>点击重试</Text>
</Pressable>
</View>
) : path ? (
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.modalBody}>
<View style={styles.pathSummary}>
<Text style={styles.pathSummaryText}>
{path.industry} · {path.position}
</Text>
<Text style={styles.pathSummaryGap}>
当前匹配度 {path.matchPercent}% → 补齐后约 {path.targetPercent}%
</Text>
<Text style={styles.pathSummaryHint}>
按行业热度权重从高到低排,先补权重最高的 {path.items.length} 项
</Text>
</View>
{path.items.map((item) => (
<View key={item.id} style={styles.pathItem}>
<View style={styles.pathPriority}>
<Text style={styles.pathPriorityText}>{item.priority}</Text>
</View>
<View style={styles.pathItemBody}>
<View style={styles.pathItemHead}>
<Text style={styles.pathItemName}>{item.name}</Text>
<View style={styles.groupBadge}>
<Text style={styles.groupText}>{GROUP_LABEL[item.group]}</Text>
</View>
</View>
<Text style={styles.pathItemReason}>{item.reason}</Text>
<View style={styles.weightRow}>
<View style={styles.weightTrack}>
<View style={[styles.weightFill, { width: `${item.weight}%` }]} />
</View>
<Text style={styles.weightNum}>{item.weight}</Text>
</View>
</View>
</View>
))}
<Text style={styles.updated}>{path.generatedAt}</Text>
</ScrollView>
) : null}
</View>
</View>
</Modal>
);
}
/* ===================== 子组件:选中技能详情 ===================== */ /* ===================== 子组件:选中技能详情 ===================== */
function SelectedDetail({ word }: { word: SkillWord }) { function SelectedDetail({ word }: { word: SkillWord }) {
@ -235,15 +385,45 @@ function Empty() {
export default function SkillCloudScreen() { export default function SkillCloudScreen() {
const router = useRouter(); 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<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [activeCategoryId, setActiveCategoryId] = useState<string>('all'); const [activeCategoryId, setActiveCategoryId] = useState<string>('all');
const [searching, setSearching] = useState(false);
const [pathVisible, setPathVisible] = useState(false);
const { path, loading: generating, error: pathError, generate, reset } = useSkillLearningPath();
const selectedWord = words.find((w) => w.id === selectedId) ?? null;
const handleGenerate = async () => {
// 先开弹窗再请求,弹窗自己有 loading / 失败重试三态,免得点完按钮没反应
setPathVisible(true);
await generate(industry);
};
const selectedWord = cloud?.words.find((w) => w.id === selectedId) ?? null; const closePathModal = () => {
setPathVisible(false);
reset();
};
const toggleSearch = () => {
if (searching) setKeyword('');
setSearching((prev) => !prev);
};
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onSearch={() => {}} /> <Header onBack={() => router.back()} onToggleSearch={toggleSearch} searching={searching} />
{loading ? ( {loading ? (
<Loading /> <Loading />
@ -253,6 +433,9 @@ export default function SkillCloudScreen() {
<Empty /> <Empty />
) : ( ) : (
<> <>
{searching && (
<SearchBar value={keyword} onChange={setKeyword} onClear={() => setKeyword('')} />
)}
<IndustryTabs <IndustryTabs
industries={cloud.filters.industries} industries={cloud.filters.industries}
active={industry} active={industry}
@ -269,14 +452,27 @@ export default function SkillCloudScreen() {
]} ]}
/> />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<Text style={styles.sectionTitle}>{cloud.industry} · 高频技能</Text> <Text style={styles.sectionTitle}>
{cloud.industry} · {keyword.trim() === '' ? '高频技能' : `含「${keyword.trim()}」的技能`}
</Text>
{isEmpty ? (
<View style={styles.searchEmpty}>
<Ionicons name="search-outline" size={32} color={colors.textHint} />
<Text style={styles.tip}>没有匹配「{keyword.trim()}」的技能</Text>
<Pressable style={styles.retryBtn} onPress={() => setKeyword('')} hitSlop={6}>
<Text style={styles.retryText}>清空关键词</Text>
</Pressable>
</View>
) : (
<View style={styles.cloudCard}> <View style={styles.cloudCard}>
<IndustrySkillWordCloud <IndustrySkillWordCloud
words={cloud.words} words={words}
selectedId={selectedId} selectedId={selectedId}
onSelect={(w) => setSelectedId(w.id)} onSelect={(w) => setSelectedId(w.id)}
/> />
</View> </View>
)}
<CategoryTabs <CategoryTabs
categories={cloud.categories} categories={cloud.categories}
@ -292,12 +488,25 @@ export default function SkillCloudScreen() {
<TopSkillList skills={cloud.topSkills} /> <TopSkillList skills={cloud.topSkills} />
<MatchCard percent={cloud.matchPercent} /> <MatchCard
percent={cloud.matchPercent}
onGenerate={handleGenerate}
generating={generating}
/>
<Text style={styles.updated}>{cloud.updatedAt}</Text> <Text style={styles.updated}>{cloud.updatedAt}</Text>
</ScrollView> </ScrollView>
</> </>
)} )}
<LearningPathModal
visible={pathVisible}
path={path}
loading={generating}
error={pathError}
onRetry={() => void generate(industry)}
onClose={closePathModal}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -438,6 +647,82 @@ const styles = StyleSheet.create({
paddingVertical: 12, paddingVertical: 12,
}, },
matchBtnText: { fontSize: 14, fontWeight: '700', color: colors.white, marginLeft: 6 }, 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: { detailCard: {
marginTop: 12, marginTop: 12,
backgroundColor: colors.white, backgroundColor: colors.white,

70
components/onboarding/assets.ts

@ -18,7 +18,19 @@ export type AssetName =
| 'avatarDesigner' | 'avatarDesigner'
| 'wechat' | 'wechat'
| 'businessLicense' | 'businessLicense'
| 'defaultAvatar'; | 'defaultAvatar'
// 系统预设头像:5 个独立素材位,各自可换真实图(此前 5 个选项共用 defaultAvatar,
// 选哪个都一样,也没有区分「选了第几个」的依据)
| 'avatarPreset1'
| 'avatarPreset2'
| 'avatarPreset3'
| 'avatarPreset4'
| 'avatarPreset5'
// 引导页(/guide)四屏插图:每屏一张,补图只改这几行的 source
| 'guideEvaluate'
| 'guideWeightDim'
| 'guideGameMap'
| 'guideLegalAI';
type IconName = ComponentProps<typeof Ionicons>['name']; type IconName = ComponentProps<typeof Ionicons>['name'];
@ -27,6 +39,7 @@ export interface AssetDef {
source: ImageSourcePropType | null; source: ImageSourcePropType | null;
tint: string; tint: string;
icon: IconName; icon: IconName;
/** 素材说明,仅供换图时对照:**不渲染到界面上**(曾经把开发备注当正文画给用户看) */
hint: string; hint: string;
} }
@ -41,8 +54,63 @@ export const ASSET_REGISTRY: Record<AssetName, AssetDef> = {
wechat: { source: null, tint: colors.success, icon: 'logo-wechat', hint: '微信图标' }, wechat: { source: null, tint: colors.success, icon: 'logo-wechat', hint: '微信图标' },
businessLicense: { source: null, tint: colors.primaryLight, icon: 'document', hint: '营业执照示例图' }, businessLicense: { source: null, tint: colors.primaryLight, icon: 'document', hint: '营业执照示例图' },
defaultAvatar: { source: null, tint: colors.borderLight, icon: 'person', 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 规则 * RN 静态图片仅支持 require 引用,豁免 no-require-imports 规则

4
components/onboarding/screens/B1Wechat.tsx

@ -7,10 +7,10 @@ import { Avatar } from '../ui/Avatar';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
export function B1Wechat() { export function B1Wechat() {
const { navigate, back, meta } = useOnboarding(); const { navigate, back } = useOnboarding();
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="微信授权" showBack onBack={back}> <ScreenShell title="微信授权" showBack onBack={back}>
<View style={styles.center}> <View style={styles.center}>
<Illustration name="wechat" size={72} radius={18} /> <Illustration name="wechat" size={72} radius={18} />
<View style={styles.preview}> <View style={styles.preview}>

4
components/onboarding/screens/B2Agreement.tsx

@ -14,10 +14,10 @@ const PARAGRAPHS = [
]; ];
export function B2Agreement() { export function B2Agreement() {
const { back, meta } = useOnboarding(); const { back } = useOnboarding();
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="用户协议 / 隐私政策" showBack onBack={back}> <ScreenShell title="用户协议 / 隐私政策" showBack onBack={back}>
<ScrollView style={styles.body} showsVerticalScrollIndicator={false}> <ScrollView style={styles.body} showsVerticalScrollIndicator={false}>
<Text style={styles.h}>有职用户协议与隐私政策</Text> <Text style={styles.h}>有职用户协议与隐私政策</Text>
{PARAGRAPHS.map((p, i) => ( {PARAGRAPHS.map((p, i) => (

94
components/onboarding/screens/B3Recruiter.tsx

@ -1,21 +1,64 @@
import { useState } from 'react'; 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 { colors } from '../../../src/theme';
import { useOnboarding, useRegisterFinish } from '../../../src/hooks'; import {
useEnterpriseCertification,
useImagePick,
useOnboarding,
useRegisterFinish,
} from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration'; import { Illustration } from '../ui/Illustration';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
export function B3Recruiter() { export function B3Recruiter() {
const { data, setData, back, meta } = useOnboarding(); const { data, setData, back } = useOnboarding();
const finish = useRegisterFinish(); 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 ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="企业认证" showBack onBack={back}> <ScreenShell title="企业认证" showBack onBack={back}>
<Text style={styles.tip}>请完成企业认证,开启招聘之旅(与求职者主线分开演示)</Text> <Text style={styles.tip}>请完成企业认证,开启招聘之旅</Text>
<Text style={styles.label}>公司名称</Text> <Text style={styles.label}>公司名称</Text>
<TextInput <TextInput
@ -27,28 +70,40 @@ export function B3Recruiter() {
/> />
<Text style={styles.label}>营业执照</Text> <Text style={styles.label}>营业执照</Text>
<View style={styles.upload}> <Pressable
style={[styles.upload, picking && styles.dim]}
disabled={picking}
onPress={() => void pickLicense()}
>
{data.recruiterLicenseUri ? (
<Image source={{ uri: data.recruiterLicenseUri }} style={styles.licensePreview} resizeMode="cover" />
) : (
<Illustration name="businessLicense" size={96} radius={12} /> <Illustration name="businessLicense" size={96} radius={12} />
<Text style={styles.uploadText}>点击上传营业执照</Text> )}
</View> <Text style={styles.uploadText}>
{data.recruiterLicenseUri ? '已选择,点击可重新选择' : '点击上传营业执照'}
</Text>
</Pressable>
{pickError ? <Text style={styles.error}>{pickError}</Text> : null}
<Text style={styles.label}>联系人</Text> <Text style={styles.label}>联系人</Text>
<TextInput <TextInput
style={styles.input} style={styles.input}
value={data.school} value={data.recruiterContact}
onChangeText={(t) => setData({ school: t })} onChangeText={(t) => setData({ recruiterContact: t })}
placeholder="请输入联系人姓名" placeholder="请输入联系人姓名"
placeholderTextColor={colors.textHint} placeholderTextColor={colors.textHint}
/> />
{submitFailed ? <Text style={styles.error}>认证提交失败,请稍后重试</Text> : null}
<View style={styles.footer}> <View style={styles.footer}>
<PrimaryButton <PrimaryButton
label={submitted ? '已提交,等待审核' : '提交认证'} label="提交认证"
active={canSubmit && !submitted} active={canSubmit && !submitting}
onPress={() => { loading={submitting}
setSubmitted(true); onPress={() => void handleSubmit()}
finish('enterprise');
}}
/> />
</View> </View>
</ScreenShell> </ScreenShell>
@ -79,6 +134,9 @@ const styles = StyleSheet.create({
borderColor: colors.borderStrong, borderColor: colors.borderStrong,
backgroundColor: colors.white, backgroundColor: colors.white,
}, },
dim: { opacity: 0.5 },
licensePreview: { width: 160, height: 120, borderRadius: 12 },
uploadText: { fontSize: 13, color: colors.textHint }, uploadText: { fontSize: 13, color: colors.textHint },
error: { fontSize: 12, color: colors.dangerText, marginTop: 10 },
footer: { marginTop: 28 }, footer: { marginTop: 28 },
}); });

9
components/onboarding/screens/P10FirstWork.tsx

@ -7,10 +7,13 @@ import { YearMonthWheel } from '../ui/WheelPicker';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
export function P10FirstWork() { export function P10FirstWork() {
const { data, setData, navigate, back, meta } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
// 未滑动滚轮时 state 仍是空串,此时放行会把空年份写进资料
const canNext = data.firstWorkYear !== '' && data.firstWorkMonth !== '';
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="首次参加工作" showBack onBack={back}> <ScreenShell title="首次参加工作" showBack onBack={back}>
<View style={styles.iconWrap}> <View style={styles.iconWrap}>
<Ionicons name="calendar" size={34} color={colors.primary} /> <Ionicons name="calendar" size={34} color={colors.primary} />
</View> </View>
@ -24,7 +27,7 @@ export function P10FirstWork() {
/> />
<View style={styles.footer}> <View style={styles.footer}>
<PrimaryButton label="下一步" active onPress={() => navigate('P11')} /> <PrimaryButton label="下一步" active={canNext} onPress={() => navigate('P11')} />
</View> </View>
</ScreenShell> </ScreenShell>
); );

39
components/onboarding/screens/P11CurrentPosition.tsx

@ -16,9 +16,19 @@ const POS: Record<string, string[]> = {
}; };
const BLOCKS = ['客服', '内容运营', '电商运营', '用户运营']; const BLOCKS = ['客服', '内容运营', '电商运营', '用户运营'];
/** 全部分类的职位去重展开,供搜索跨分类匹配 */
const ALL_POS: string[] = Array.from(new Set(Object.values(POS).flat()));
export function P11CurrentPosition() { export function P11CurrentPosition() {
const { data, setData, navigate, back, meta } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const [cat, setCat] = useState('互联网AI'); 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) => { const choose = (p: string) => {
setData({ currentPosition: p }); setData({ currentPosition: p });
@ -26,13 +36,21 @@ export function P11CurrentPosition() {
}; };
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="当前工作职位" showBack onBack={back}> <ScreenShell title="当前工作职位" showBack onBack={back}>
<SearchInput value="" onChangeText={() => {}} placeholder="搜索职位" /> <SearchInput value={query} onChangeText={setQuery} placeholder="搜索职位" onClear={() => setQuery('')} />
<View style={styles.body}> <View style={styles.body}>
<View style={styles.cats}> <View style={styles.cats}>
{CATS.map((c) => ( {CATS.map((c) => (
<Pressable key={c} style={[styles.catItem, c === cat && styles.catItemActive]} onPress={() => setCat(c)}> <Pressable
key={c}
style={[styles.catItem, c === cat && styles.catItemActive]}
onPress={() => {
// 搜索态下切分类不改变右侧内容,清掉关键词保证动作有可见结果
setQuery('');
setCat(c);
}}
>
<Text style={[styles.catText, c === cat && styles.catTextActive]}>{c}</Text> <Text style={[styles.catText, c === cat && styles.catTextActive]}>{c}</Text>
</Pressable> </Pressable>
))} ))}
@ -40,20 +58,28 @@ export function P11CurrentPosition() {
<View style={styles.col}> <View style={styles.col}>
<Text style={styles.colTitle}>猜你做过</Text> <Text style={styles.colTitle}>猜你做过</Text>
{visiblePos.length > 0 ? (
<TagGroup> <TagGroup>
{POS[cat].map((p) => ( {visiblePos.map((p) => (
<Tag key={p} label={p} selected={data.currentPosition === p} onPress={() => choose(p)} /> <Tag key={p} label={p} selected={data.currentPosition === p} onPress={() => choose(p)} />
))} ))}
</TagGroup> </TagGroup>
) : (
<Text style={styles.empty}>没有匹配「{keyword}」的职位</Text>
)}
</View> </View>
</View> </View>
<Text style={styles.blockTitle}>或者从这些板块选</Text> <Text style={styles.blockTitle}>或者从这些板块选</Text>
{visibleBlocks.length > 0 ? (
<TagGroup> <TagGroup>
{BLOCKS.map((b) => ( {visibleBlocks.map((b) => (
<Tag key={b} label={b} selected={data.currentPosition === b} onPress={() => choose(b)} /> <Tag key={b} label={b} selected={data.currentPosition === b} onPress={() => choose(b)} />
))} ))}
</TagGroup> </TagGroup>
) : (
<Text style={styles.empty}>没有匹配「{keyword}」的板块</Text>
)}
</ScreenShell> </ScreenShell>
); );
} }
@ -67,5 +93,6 @@ const styles = StyleSheet.create({
catTextActive: { color: colors.primaryText, fontWeight: '700' }, catTextActive: { color: colors.primaryText, fontWeight: '700' },
col: { flex: 1 }, col: { flex: 1 },
colTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 }, 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 }, blockTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginVertical: 16 },
}); });

4
components/onboarding/screens/P12Company.tsx

@ -6,11 +6,11 @@ import { SearchInput } from '../ui/SearchInput';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
export function P12Company() { export function P12Company() {
const { data, setData, navigate, back, meta } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const canNext = data.company.trim().length > 0; const canNext = data.company.trim().length > 0;
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="就职公司" showBack onBack={back}> <ScreenShell title="就职公司" showBack onBack={back}>
<SearchInput <SearchInput
value={data.company} value={data.company}
onChangeText={(t) => setData({ company: t })} onChangeText={(t) => setData({ company: t })}

16
components/onboarding/screens/P13JoinDate.tsx

@ -7,24 +7,32 @@ import { YearMonthWheel } from '../ui/WheelPicker';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
export function P13JoinDate() { export function P13JoinDate() {
const { data, setData, navigate, back, meta } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
// 未滑动滚轮时 state 仍是空串,此时放行会把空年月写进资料
const canNext = data.joinYear !== '' && data.joinMonth !== '';
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="入职时间" showBack onBack={back}> <ScreenShell title="入职时间" showBack onBack={back}>
<View style={styles.iconWrap}> <View style={styles.iconWrap}>
<Ionicons name="calendar" size={34} color={colors.primary} /> <Ionicons name="calendar" size={34} color={colors.primary} />
</View> </View>
<Text style={styles.tip}>在什么时候入职 {data.company}</Text> {/* 公司名来自上一步,理论上不会为空;兜底避免拼出「在什么时候入职 」这样的半句话 */}
<Text style={styles.tip}>
{data.company ? `在什么时候入职 ${data.company}` : '请选择入职年月'}
</Text>
<YearMonthWheel <YearMonthWheel
year={data.joinYear} year={data.joinYear}
month={data.joinMonth} month={data.joinMonth}
onYearChange={(v) => setData({ joinYear: v })} onYearChange={(v) => setData({ joinYear: v })}
onMonthChange={(v) => setData({ joinMonth: v })} onMonthChange={(v) => setData({ joinMonth: v })}
// 入职时间可能在明年,年份要往后留出空间(出生年月则不需要)
futureYears={5}
/> />
<View style={styles.footer}> <View style={styles.footer}>
<PrimaryButton label="下一步" active onPress={() => navigate('P14')} /> <PrimaryButton label="下一步" active={canNext} onPress={() => navigate('P14')} />
</View> </View>
</ScreenShell> </ScreenShell>
); );

4
components/onboarding/screens/P14Skills.tsx

@ -18,7 +18,7 @@ const GROUPS = [
]; ];
export function P14Skills() { export function P14Skills() {
const { data, setData, navigate, back, meta } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const skills = data.skills; const skills = data.skills;
const full = skills.length >= MAX; const full = skills.length >= MAX;
@ -31,7 +31,7 @@ export function P14Skills() {
}; };
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} showBack onBack={back}> <ScreenShell showBack onBack={back}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>技能选择</Text> <Text style={styles.title}>技能选择</Text>
<View style={[styles.counter, skills.length > 0 && styles.counterActive]}> <View style={[styles.counter, skills.length > 0 && styles.counterActive]}>

12
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 { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme'; import { colors } from '../../../src/theme';
import { useOnboarding, useRegisterFinish } from '../../../src/hooks'; import { useOnboarding, useRegisterFinish } from '../../../src/hooks';
import type { AppRole } from '../../../src/types';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
export function P16WorkContent() { export function P16WorkContent() {
const { data, setData, navigate, back, meta } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const finish = useRegisterFinish(); const finish = useRegisterFinish();
const targetRole: AppRole = data.identity === 'recruiter' ? 'enterprise' : 'candidate';
const text = data.workContent; const text = data.workContent;
const canNext = text.trim().length >= 5; const canNext = text.trim().length >= 5;
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} showBack onBack={back}> <ScreenShell showBack onBack={back}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>工作内容介绍</Text> <Text style={styles.title}>工作内容介绍</Text>
<Pressable onPress={() => finish(targetRole)}> {/* 快速通道:与 P6 同一语义(填完当前步即可落地),文案据实写「跳过」。
<Text style={styles.save}>保存</Text> 企业端只走 P3 → B3,到不了本页,故落地身份固定为求职者 */}
<Pressable onPress={() => void finish('candidate')} hitSlop={6}>
<Text style={styles.save}>跳过,直接进入</Text>
</Pressable> </Pressable>
</View> </View>

4
components/onboarding/screens/P17Reference.tsx

@ -35,7 +35,7 @@ const SAMPLES = [
]; ];
export function P17Reference() { export function P17Reference() {
const { setData, back, navigate, meta } = useOnboarding(); const { setData, back, navigate } = useOnboarding();
const [idx, setIdx] = useState(0); const [idx, setIdx] = useState(0);
const sample = SAMPLES[idx]; const sample = SAMPLES[idx];
@ -45,7 +45,7 @@ export function P17Reference() {
}; };
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="参考案例" showBack onBack={back}> <ScreenShell title="参考案例" showBack onBack={back}>
<View style={styles.head}> <View style={styles.head}>
<Avatar name="avatarDesigner" size={56} /> <Avatar name="avatarDesigner" size={56} />
<View style={styles.headText}> <View style={styles.headText}>

7
components/onboarding/screens/P18Education.tsx

@ -10,11 +10,12 @@ import { PrimaryButton } from '../ui/PrimaryButton';
const LEVELS = ['初中及以下', '中专中技', '高中', '大专', '本科', '硕士', '博士']; const LEVELS = ['初中及以下', '中专中技', '高中', '大专', '本科', '硕士', '博士'];
export function P18Education() { export function P18Education() {
const { data, setData, navigate, back, meta } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const canNext = data.education !== null; // 「类型」同为本页采集项,选了学历却没选全日制/非全日制同样算半截记录
const canNext = data.education !== null && data.eduType !== null;
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="最高学历" showBack onBack={back}> <ScreenShell title="最高学历" showBack onBack={back}>
<View style={styles.iconWrap}> <View style={styles.iconWrap}>
<Ionicons name="document" size={34} color={colors.primary} /> <Ionicons name="document" size={34} color={colors.primary} />
</View> </View>

14
components/onboarding/screens/P1Login.tsx

@ -8,6 +8,7 @@ import { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { AgreementModal } from '../ui/AgreementModal'; import { AgreementModal } from '../ui/AgreementModal';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
import { SupportFooter } from '../ui/SupportFooter';
/** /**
* P1 登录主页:淡渐变顶部 + 居中 Logo + 三种登录方式。 * P1 登录主页:淡渐变顶部 + 居中 Logo + 三种登录方式。
@ -63,9 +64,7 @@ export function P1Login() {
<View style={styles.flexSpacer} /> <View style={styles.flexSpacer} />
<View style={styles.footer}> <View style={styles.footer}>
<Text style={styles.footItem}>服务热线</Text> <SupportFooter />
<Text style={styles.footItem}>举报监督电话</Text>
<Text style={styles.footItem}>资质证照</Text>
</View> </View>
</ScrollView> </ScrollView>
@ -98,12 +97,5 @@ const styles = StyleSheet.create({
agree: { marginTop: 22 }, agree: { marginTop: 22 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' }, link: { color: colors.primary, fontWeight: '600' },
footer: { footer: { paddingTop: 24, paddingBottom: 8 },
flexDirection: 'row',
justifyContent: 'center',
gap: 18,
paddingTop: 24,
paddingBottom: 8,
},
footItem: { fontSize: 12, color: colors.textHint },
}); });

4
components/onboarding/screens/P20School.tsx

@ -5,11 +5,11 @@ import { SearchInput } from '../ui/SearchInput';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
export function P20School() { export function P20School() {
const { data, setData, navigate, back, meta } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const canNext = data.school.trim().length > 0; const canNext = data.school.trim().length > 0;
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="毕业学校" showBack onBack={back}> <ScreenShell title="毕业学校" showBack onBack={back}>
<SearchInput <SearchInput
value={data.school} value={data.school}
onChangeText={(t) => setData({ school: t })} onChangeText={(t) => setData({ school: t })}

74
components/onboarding/screens/P21Avatar.tsx

@ -1,66 +1,78 @@
import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme'; import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks'; import { useImagePick, useOnboarding } from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
import { AVATAR_PRESETS } from '../assets';
const PRESETS = [0, 1, 2, 3, 4];
export function P21Avatar() { export function P21Avatar() {
const { setData, navigate, back, meta } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const [mode, setMode] = useState<'none' | 'camera' | 'album' | 'preset'>('none'); const { pick, picking, error } = useImagePick();
const [picked, setPicked] = useState(-1);
const chooseCamera = () => { // 头像只有一个真相 `data.avatar`:命中预设素材就是预设名,否则是拍照/相册的本地 URI
setMode('camera'); const presetName = AVATAR_PRESETS.find((name) => name === data.avatar) ?? null;
setData({ avatar: 'avatarDesigner' }); const avatarUri = data.avatar !== null && presetName === null ? data.avatar : null;
};
const chooseAlbum = () => {
setMode('album');
setData({ avatar: 'avatarDesigner' });
};
const choosePreset = (i: number) => {
setMode('preset');
setPicked(i);
setData({ avatar: 'defaultAvatar' });
};
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 ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="头像设置" showBack onBack={back}> <ScreenShell title="头像设置" showBack onBack={back}>
<View style={styles.previewWrap}> <View style={styles.previewWrap}>
<Avatar name={previewName} size={110} selected={mode !== 'none'} /> <Avatar
name={presetName ?? 'defaultAvatar'}
uri={avatarUri}
size={110}
selected={data.avatar !== null}
/>
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
<Pressable style={styles.actionBtn} onPress={chooseCamera}> {/* picking 期间禁用:否则系统权限弹窗还没出现就能连点出第二个 */}
<Pressable
style={[styles.actionBtn, picking && styles.actionBtnDim]}
disabled={picking}
onPress={() => void pickFrom('camera')}
>
<Ionicons name="camera" size={22} color={colors.primary} /> <Ionicons name="camera" size={22} color={colors.primary} />
<Text style={styles.actionText}>拍照上传</Text> <Text style={styles.actionText}>拍照上传</Text>
</Pressable> </Pressable>
<Pressable style={styles.actionBtn} onPress={chooseAlbum}> <Pressable
style={[styles.actionBtn, picking && styles.actionBtnDim]}
disabled={picking}
onPress={() => void pickFrom('library')}
>
<Ionicons name="images" size={22} color={colors.primary} /> <Ionicons name="images" size={22} color={colors.primary} />
<Text style={styles.actionText}>从相册选择</Text> <Text style={styles.actionText}>从相册选择</Text>
</Pressable> </Pressable>
</View> </View>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Text style={styles.subTitle}>系统默认头像</Text> <Text style={styles.subTitle}>系统默认头像</Text>
<View style={styles.presets}> <View style={styles.presets}>
{PRESETS.map((i) => ( {AVATAR_PRESETS.map((name) => (
<Avatar <Avatar
key={i} key={name}
name={name}
size={56} size={56}
selected={mode === 'preset' && picked === i} selected={data.avatar === name}
onPress={() => choosePreset(i)} onPress={() => setData({ avatar: name })}
/> />
))} ))}
</View> </View>
<View style={styles.footer}> <View style={styles.footer}>
<PrimaryButton label="下一步" active={mode !== 'none'} onPress={() => navigate('P22')} /> <PrimaryButton
label="下一步"
active={data.avatar !== null && !picking}
onPress={() => navigate('P22')}
/>
</View> </View>
</ScreenShell> </ScreenShell>
); );
@ -70,7 +82,9 @@ const styles = StyleSheet.create({
previewWrap: { alignItems: 'center', marginVertical: 24 }, previewWrap: { alignItems: 'center', marginVertical: 24 },
actions: { flexDirection: 'row', justifyContent: 'center', gap: 28, marginBottom: 28 }, actions: { flexDirection: 'row', justifyContent: 'center', gap: 28, marginBottom: 28 },
actionBtn: { alignItems: 'center', gap: 6 }, actionBtn: { alignItems: 'center', gap: 6 },
actionBtnDim: { opacity: 0.5 },
actionText: { fontSize: 12, color: colors.textSecondary }, 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 }, subTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginBottom: 14 },
presets: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 }, presets: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 },
footer: { marginTop: 32 }, footer: { marginTop: 32 },

10
components/onboarding/screens/P22Done.tsx

@ -1,19 +1,16 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme'; import { colors } from '../../../src/theme';
import { useOnboarding, useRegisterFinish } from '../../../src/hooks'; import { useRegisterFinish } from '../../../src/hooks';
import type { AppRole } from '../../../src/types';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration'; import { Illustration } from '../ui/Illustration';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
export function P22Done() { export function P22Done() {
const { data, meta } = useOnboarding();
const finish = useRegisterFinish(); const finish = useRegisterFinish();
const targetRole: AppRole = data.identity === 'recruiter' ? 'enterprise' : 'candidate';
return ( return (
<ScreenShell time={meta.time} battery={meta.battery}> <ScreenShell>
<View style={styles.center}> <View style={styles.center}>
<View style={styles.glow}> <View style={styles.glow}>
<Ionicons name="checkmark-circle" size={72} color={colors.success} /> <Ionicons name="checkmark-circle" size={72} color={colors.success} />
@ -23,7 +20,8 @@ export function P22Done() {
</View> </View>
<View style={styles.footer}> <View style={styles.footer}>
<PrimaryButton label="进入 APP" active onPress={() => finish(targetRole)} /> {/* 企业端走 P3 → B3 落地,不会经过本页,故身份固定为求职者 */}
<PrimaryButton label="进入 APP" active onPress={() => void finish('candidate')} />
</View> </View>
</ScreenShell> </ScreenShell>
); );

181
components/onboarding/screens/P23Home.tsx

@ -1,181 +0,0 @@
import { useEffect, useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { StatusBar } from 'expo-status-bar';
import { colors } from '../../../src/theme';
import { Illustration } from '../ui/Illustration';
import { Modal } from '../ui/Modal';
type Step = 'guide' | 'location' | 'notify' | null;
const JOBS = [
{ company: '巴适伙伴', pos: '前端开发工程师', salary: '12-18K', city: '上海' },
{ company: '星河科技', pos: '鸿蒙开发工程师', salary: '15-22K', city: '上海' },
{ company: '云图网络', pos: '全栈工程师', salary: '13-20K', city: '杭州' },
{ company: '启明软件', pos: '数据分析师', salary: '10-16K', city: '深圳' },
];
const TABS = [
{ key: 'home', icon: 'home', label: '首页' },
{ key: 'msg', icon: 'chatbubble', label: '消息' },
{ key: 'me', icon: 'person', label: '我的' },
] as const;
export function P23Home() {
const [tab, setTab] = useState<(typeof TABS)[number]['key']>('home');
const [step, setStep] = useState<Step>('guide');
useEffect(() => {
setStep('guide');
}, []);
return (
<SafeAreaView style={styles.screen} edges={['top']}>
<StatusBar style="dark" />
<View style={styles.topBar}>
<Text style={styles.topTitle}>推荐职位</Text>
<Ionicons name="location" size={16} color={colors.primary} />
<Text style={styles.topCity}>上海</Text>
</View>
<ScrollView style={styles.list} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false}>
{JOBS.map((j) => (
<View key={j.company} style={styles.card}>
<Illustration name="logo" size={44} radius={10} />
<View style={styles.cardBody}>
<Text style={styles.pos}>{j.pos}</Text>
<Text style={styles.company}>{j.company}</Text>
</View>
<View style={styles.cardRight}>
<Text style={styles.salary}>{j.salary}</Text>
<View style={styles.cityTag}>
<Text style={styles.cityText}>{j.city}</Text>
</View>
</View>
</View>
))}
</ScrollView>
<View style={styles.tabBar}>
{TABS.map((t) => (
<Pressable key={t.key} style={styles.tab} onPress={() => setTab(t.key)}>
<Ionicons name={t.icon} size={22} color={tab === t.key ? colors.primary : colors.textHint} />
<Text style={[styles.tabLabel, tab === t.key && styles.tabLabelActive]}>{t.label}</Text>
</Pressable>
))}
</View>
{step === 'guide' && (
<View style={styles.guide}>
<View style={styles.guideCard}>
<Ionicons name="arrow-down" size={20} color={colors.primary} />
<Text style={styles.guideText}>点击查看职位详情</Text>
</View>
<Pressable style={styles.guideBtn} onPress={() => setStep('location')}>
<Text style={styles.guideBtnText}>知道了</Text>
</Pressable>
</View>
)}
<Modal
visible={step === 'location'}
title="允许获取位置"
onClose={() => setStep('notify')}
footer={
<View style={styles.modalBtns}>
<Pressable style={[styles.modalBtn, styles.modalBtnGhost]} onPress={() => setStep('notify')}>
<Text style={styles.modalBtnGhostText}>暂不</Text>
</Pressable>
<Pressable style={[styles.modalBtn, styles.modalBtnPrimary]} onPress={() => setStep('notify')}>
<Text style={styles.modalBtnPrimaryText}>允许</Text>
</Pressable>
</View>
}
>
<Text style={styles.modalBody}>开启定位可为你推荐附近的职位与机会。</Text>
</Modal>
<Modal
visible={step === 'notify'}
title="开启通知"
onClose={() => setStep(null)}
footer={
<View style={styles.modalBtns}>
<Pressable style={[styles.modalBtn, styles.modalBtnGhost]} onPress={() => setStep(null)}>
<Text style={styles.modalBtnGhostText}>暂不</Text>
</Pressable>
<Pressable style={[styles.modalBtn, styles.modalBtnPrimary]} onPress={() => setStep(null)}>
<Text style={styles.modalBtnPrimaryText}>允许</Text>
</Pressable>
</View>
}
>
<Text style={styles.modalBody}>开启通知可及时接收面试邀请与 Boss 消息。</Text>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.pageBg },
topBar: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 20, paddingVertical: 12, backgroundColor: colors.white },
topTitle: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, marginRight: 'auto' },
topCity: { fontSize: 13, color: colors.primary, fontWeight: '600' },
list: { flex: 1 },
listContent: { padding: 16, gap: 12 },
card: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
backgroundColor: colors.white,
borderRadius: 14,
padding: 14,
borderWidth: 1,
borderColor: colors.border,
},
cardBody: { flex: 1, gap: 4 },
pos: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
company: { fontSize: 12, color: colors.textSecondary },
cardRight: { alignItems: 'flex-end', gap: 6 },
salary: { fontSize: 15, fontWeight: '700', color: colors.info },
cityTag: { backgroundColor: colors.primaryLight, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 8 },
cityText: { fontSize: 11, color: colors.primaryText },
tabBar: {
flexDirection: 'row',
borderTopWidth: 1,
borderColor: colors.borderLight,
backgroundColor: colors.white,
paddingVertical: 8,
},
tab: { flex: 1, alignItems: 'center', gap: 2 },
tabLabel: { fontSize: 11, color: colors.textHint },
tabLabelActive: { color: colors.primary, fontWeight: '700' },
guide: {
...StyleSheet.absoluteFill,
backgroundColor: colors.scrim,
alignItems: 'center',
justifyContent: 'flex-start',
paddingTop: 150,
},
guideCard: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: colors.white,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 12,
},
guideText: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
guideBtn: { marginTop: 16, backgroundColor: colors.primary, paddingHorizontal: 28, paddingVertical: 10, borderRadius: 20 },
guideBtnText: { color: colors.textInverse, fontWeight: '700' },
modalBtns: { flexDirection: 'row', gap: 12 },
modalBtn: { flex: 1, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center' },
modalBtnGhost: { backgroundColor: colors.borderLight },
modalBtnGhostText: { color: colors.textSecondary, fontWeight: '600' },
modalBtnPrimary: { backgroundColor: colors.primary },
modalBtnPrimaryText: { color: colors.textInverse, fontWeight: '700' },
modalBody: { fontSize: 14, color: colors.textSecondary, lineHeight: 22, textAlign: 'center' },
});

14
components/onboarding/screens/P2PhoneCode.tsx

@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons'; 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 { colors } from '../../../src/theme';
import { SMS_HELP_BODY, SMS_HELP_TITLE } from '../../../src/constants';
import { useAgreementGate, useLogin, useOnboarding, useSmsCode, useWechatBindPhone } from '../../../src/hooks'; import { useAgreementGate, useLogin, useOnboarding, useSmsCode, useWechatBindPhone } from '../../../src/hooks';
import { isValidPhone, SMS_CODE_MAX_LENGTH, SMS_CODE_MIN_LENGTH } from '../../../src/utils'; import { isValidPhone, SMS_CODE_MAX_LENGTH, SMS_CODE_MIN_LENGTH } from '../../../src/utils';
import { useAuth } from '../../../src/context/AuthContext'; import { useAuth } from '../../../src/context/AuthContext';
@ -81,6 +82,9 @@ export function P2PhoneCode() {
/** 掩码手机号:138****1234 */ /** 掩码手机号:138****1234 */
const maskedPhone = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone; const maskedPhone = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone;
/** 「接收不到短信」:原先是点了没反应的纯文本,改为给出可执行的下一步 */
const showSmsHelp = () => Alert.alert(SMS_HELP_TITLE, SMS_HELP_BODY);
return ( return (
<ScreenShell showBack onBack={stage === 'code' ? () => setStage('phone') : back}> <ScreenShell showBack onBack={stage === 'code' ? () => setStage('phone') : back}>
{stage === 'phone' ? ( {stage === 'phone' ? (
@ -110,7 +114,9 @@ export function P2PhoneCode() {
</View> </View>
<View style={styles.underline} /> <View style={styles.underline} />
<Text style={styles.smsHelp}>接收不到短信</Text> <Pressable onPress={showSmsHelp} hitSlop={6} style={styles.smsHelpRow}>
<Text style={styles.smsHelp}>接收不到短信?</Text>
</Pressable>
<View style={styles.btnArea}> <View style={styles.btnArea}>
<PrimaryButton label="下一步" active={phoneValid} onPress={() => gate(requestCode)} /> <PrimaryButton label="下一步" active={phoneValid} onPress={() => gate(requestCode)} />
@ -214,7 +220,9 @@ const styles = StyleSheet.create({
divider: { width: 1, height: 20, backgroundColor: colors.border, marginHorizontal: 10 }, divider: { width: 1, height: 20, backgroundColor: colors.border, marginHorizontal: 10 },
phoneInput: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 }, phoneInput: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 },
underline: { height: 1, backgroundColor: colors.borderLight }, 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 }, btnArea: { marginTop: 28 },
agree: { marginTop: 18 }, agree: { marginTop: 18 },
formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger }, formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },

108
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 { 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 { useAuth } from '../../../src/context/AuthContext';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration'; import { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
import { SupportFooter } from '../ui/SupportFooter';
import { AgreementModal } from '../ui/AgreementModal'; import { AgreementModal } from '../ui/AgreementModal';
/** P2Q 一键登录确认:本机号码认证 —— 掩码手机号 + 一键登录 + 运营商服务声明 */ /**
* P2Q 一键登录确认:本机号码认证。
*
* 取号(运营商 SDK)与换登录态是两步,本页负责串起来:
* 1. 进入页面即取号(useCarrierPhone)→ 展示掩码号码;
* 2. 点「一键登录」→ 用取号令牌换正式登录态 → 写登录态 → 把号码写回引导资料 → 进 P3。
*
* 原先这里读的是 `data.phone`(只有短信登录才会写),运营商通道的号码既没取也没存,
* 所以永远显示占位「本机号码」;登录接口返回的 phone 也被直接丢掉。
* 现在取号失败时不再假装号码还在:「一键登录」置灰,同时给出「重新获取」与「改用验证码登录」。
*/
export function P2QuickLogin() { export function P2QuickLogin() {
const { data, setData, navigate, back } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const { gate, modalProps } = useAgreementGate(); 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 */ /** 登录态写入:此前本页只跳 P3 不写 token */
const { login: writeAuth } = useAuth(); const { login: writeAuth } = useAuth();
const agreed = data.agreed; 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 () => { const handleLogin = async () => {
try { try {
const result = await loginByCarrier(); const result = await loginByCarrier(carrierToken);
writeAuth(result.token); writeAuth(result.token);
// 号码写回引导资料:后续步骤(P2P/P2R 预填、B3 联系人电话)都以这里为准
setData({ phone: result.phone });
} catch { } catch {
// 失败原因由 useLogin 记录,页面下方展示 // 失败原因由 useLogin 记录,页面下方展示
return; return;
@ -31,8 +50,13 @@ export function P2QuickLogin() {
navigate('P3'); 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 ( return (
<ScreenShell showBack onBack={back}> <ScreenShell showBack onBack={back}>
@ -41,19 +65,45 @@ export function P2QuickLogin() {
<Illustration name="logo" size={88} radius={22} /> <Illustration name="logo" size={88} radius={22} />
</View> </View>
<Text style={styles.phone}>{masked}</Text> {ready ? (
<Text style={styles.subtitle}>首次验证通过即注册有职账号</Text> <Text style={styles.phone}>{maskedPhone}</Text>
) : (
<Text style={[styles.phonePending, failed && styles.phoneFailed]}>
{failed ? '未能获取本机号码' : '正在获取本机号码…'}
</Text>
)}
<Text style={styles.subtitle}>
{failed ? '可重新获取,或改用验证码登录' : '首次验证通过即注册有职账号'}
</Text>
<View style={styles.btnArea}> <View style={styles.btnArea}>
<PrimaryButton <PrimaryButton
label="一键登录" label="一键登录"
active={!loading} active={ready && !busy}
loading={loading} loading={logging}
onPress={() => gate(handleLogin)} onPress={() => gate(handleLogin)}
/> />
{failed ? (
<View style={styles.fallbackRow}>
<Pressable style={styles.fallbackBtn} onPress={refresh} hitSlop={6}>
<Text style={styles.fallbackText}>重新获取</Text>
</Pressable>
<Pressable
style={styles.fallbackBtn}
onPress={() => gate(() => navigate('P2'))}
hitSlop={6}
>
<Text style={styles.fallbackText}>改用验证码登录</Text>
</Pressable>
</View>
) : null}
</View> </View>
{error ? <Text style={styles.formError}>{error.message}</Text> : null} {loginError ? <Text style={styles.formError}>{loginError.message}</Text> : null}
{fetchError && !loginError ? (
<Text style={styles.formError}>{fetchError.message}</Text>
) : null}
<View style={styles.agree}> <View style={styles.agree}>
<AgreementCheckbox <AgreementCheckbox
@ -62,7 +112,9 @@ export function P2QuickLogin() {
text={ text={
<Text style={styles.agreeText}> <Text style={styles.agreeText}>
已阅读并同意 已阅读并同意
<Text style={styles.link}>《中国移动认证服务条款》</Text> <Text style={styles.link} onPress={showCarrierTerms}>
《{carrierName}认证服务条款》
</Text>
、 、
<Text style={styles.link} onPress={() => navigate('B2')}>《有职用户协议》</Text> <Text style={styles.link} onPress={() => navigate('B2')}>《有职用户协议》</Text>
和 和
@ -75,12 +127,8 @@ export function P2QuickLogin() {
</ScrollView> </ScrollView>
<View style={styles.footer}> <View style={styles.footer}>
<View style={styles.footRow}> <SupportFooter />
<Text style={styles.footItem}>服务热线</Text> <Text style={styles.carrierNote}>认证服务由{carrierName}提供</Text>
<Text style={styles.footItem}>举报监督电话</Text>
<Text style={styles.footItem}>资质证照</Text>
</View>
<Text style={styles.carrierNote}>认证服务由中国移动提供</Text>
</View> </View>
<AgreementModal {...modalProps} /> <AgreementModal {...modalProps} />
@ -98,14 +146,28 @@ const styles = StyleSheet.create({
color: colors.textPrimary, color: colors.textPrimary,
marginTop: 40, 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 }, subtitle: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 12 },
btnArea: { marginTop: 36 }, 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 }, formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agree: { marginTop: 20 }, agree: { marginTop: 20 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 }, agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' }, link: { color: colors.primary, fontWeight: '600' },
footer: { paddingTop: 16, paddingBottom: 8 }, 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 }, carrierNote: { textAlign: 'center', fontSize: 12, color: colors.textHint, marginTop: 12 },
}); });

41
components/onboarding/screens/P2WechatCode.tsx

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; 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 { colors } from '../../../src/theme';
import { SMS_HELP_BODY, SMS_HELP_TITLE } from '../../../src/constants';
import { useOnboarding } from '../../../src/hooks'; import { useOnboarding } from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
@ -13,6 +14,8 @@ export function P2WechatCode() {
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [left, setLeft] = useState(RESEND_SECONDS); const [left, setLeft] = useState(RESEND_SECONDS);
const timer = useRef<ReturnType<typeof setInterval> | null>(null); const timer = useRef<ReturnType<typeof setInterval> | null>(null);
/** 隐藏输入框的引用:点格子区域时把焦点交回它,键盘才会弹出来 */
const inputRef = useRef<TextInput>(null);
useEffect(() => { useEffect(() => {
timer.current = setInterval(() => { timer.current = setInterval(() => {
@ -30,10 +33,17 @@ export function P2WechatCode() {
}, []); }, []);
const phone = data.phone; 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; const canNext = code.length >= 4;
/** 「接收不到短信」提示,与 P2 用同一份文案常量 */
const showSmsHelp = () => Alert.alert(SMS_HELP_TITLE, SMS_HELP_BODY);
const resend = () => { const resend = () => {
if (left > 0) return; if (left > 0) return;
setLeft(RESEND_SECONDS); setLeft(RESEND_SECONDS);
@ -54,8 +64,8 @@ export function P2WechatCode() {
<Text style={styles.h1}>输入短信验证码</Text> <Text style={styles.h1}>输入短信验证码</Text>
<Text style={styles.subtitle}>已向您的手机{tail}发送验证码</Text> <Text style={styles.subtitle}>已向您的手机{tail}发送验证码</Text>
{/* 4 格分离输入:透明输入层覆盖,格子仅做展示 */} {/* 4 格分离输入:透明输入层覆盖,格子仅做展示;点格子把焦点交回输入框 */}
<Pressable style={styles.cellsWrap}> <Pressable style={styles.cellsWrap} onPress={() => inputRef.current?.focus()}>
<View style={styles.cellsRow}> <View style={styles.cellsRow}>
{[0, 1, 2, 3].map((i) => ( {[0, 1, 2, 3].map((i) => (
<View key={i} style={styles.cell}> <View key={i} style={styles.cell}>
@ -65,6 +75,7 @@ export function P2WechatCode() {
))} ))}
</View> </View>
<TextInput <TextInput
ref={inputRef}
style={styles.hiddenInput} style={styles.hiddenInput}
value={code} value={code}
onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, 4))} onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, 4))}
@ -84,14 +95,11 @@ export function P2WechatCode() {
<PrimaryButton label="下一步" active={canNext} onPress={() => navigate('B1')} /> <PrimaryButton label="下一步" active={canNext} onPress={() => navigate('B1')} />
</View> </View>
<View style={styles.helpRow}> {/* 「语音接听验证码」入口已移除:项目没有语音验证码通道,摆一个点了没反应的入口
<Pressable hitSlop={6}> 等于宣称有这个能力。等接入语音通道再恢复(同 P5 移除「自动定位」的理由) */}
<Text style={styles.helpText}>语音接听验证码</Text> <Pressable onPress={showSmsHelp} hitSlop={6} style={styles.helpRow}>
</Pressable> <Text style={styles.helpText}>接收不到短信?</Text>
<Pressable hitSlop={6}>
<Text style={styles.helpText}>接收不到短信</Text>
</Pressable> </Pressable>
</View>
<View style={styles.toastBar}> <View style={styles.toastBar}>
<Text style={styles.toastText}>短信验证码正在发送,请注意查收</Text> <Text style={styles.toastText}>短信验证码正在发送,请注意查收</Text>
@ -116,12 +124,9 @@ const styles = StyleSheet.create({
resend: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 22 }, resend: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 22 },
resendDisabled: { color: colors.textHint }, resendDisabled: { color: colors.textHint },
btnArea: { marginTop: 30 }, btnArea: { marginTop: 30 },
helpRow: { helpRow: { alignSelf: 'flex-start', marginTop: 22 },
flexDirection: 'row', // 已是可点入口,用可读对比度的 primaryText(textHint 在 13pt 下不达 AA)
justifyContent: 'space-between', helpText: { fontSize: 13, color: colors.primaryText },
marginTop: 22,
},
helpText: { fontSize: 13, color: colors.textHint },
toastBar: { toastBar: {
alignSelf: 'center', alignSelf: 'center',
marginTop: 30, marginTop: 30,

29
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 { colors } from '../../../src/theme';
import { useAuth } from '../../../src/context/AuthContext';
import { useOnboarding } from '../../../src/hooks'; import { useOnboarding } from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration'; import { Illustration } from '../ui/Illustration';
export function P3Identity() { export function P3Identity() {
const { setData, navigate, back, reset, meta } = useOnboarding(); const { setData, navigate, back, reset } = useOnboarding();
const { logout } = useAuth();
const chooseCandidate = () => { const chooseCandidate = () => {
setData({ identity: 'candidate' }); setData({ identity: 'candidate' });
@ -16,8 +18,27 @@ export function P3Identity() {
navigate('B3'); navigate('B3');
}; };
/**
* 退出登录必须同时清掉令牌与角色。
* 此前只调 `reset()`(仅清流程 state 与引导资料),登录态原封不动 ——
* 界面回到了登录页,但重进 App 又会被判定为「已登录」而直接放进主界面。
*/
const handleLogout = () => {
Alert.alert('确认退出登录', '退出后需要重新登录', [
{ text: '取消', style: 'cancel' },
{
text: '退出',
style: 'destructive',
onPress: () => {
logout();
reset();
},
},
]);
};
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} showBack onBack={back}> <ScreenShell showBack onBack={back}>
<Text style={styles.heading}>选择你的身份</Text> <Text style={styles.heading}>选择你的身份</Text>
<Pressable style={styles.card} onPress={chooseCandidate}> <Pressable style={styles.card} onPress={chooseCandidate}>
@ -40,7 +61,7 @@ export function P3Identity() {
</View> </View>
</Pressable> </Pressable>
<Pressable style={styles.logout} onPress={reset}> <Pressable style={styles.logout} onPress={handleLogout}>
<Text style={styles.logoutText}>退出登录</Text> <Text style={styles.logoutText}>退出登录</Text>
</Pressable> </Pressable>
</ScreenShell> </ScreenShell>

14
components/onboarding/screens/P4Intent.tsx

@ -9,7 +9,7 @@ import { PrimaryButton } from '../ui/PrimaryButton';
const HOT = ['Java', '项目经理', '运维', '测试', '前端', '数据分析', '电子', '通信']; const HOT = ['Java', '项目经理', '运维', '测试', '前端', '数据分析', '电子', '通信'];
export function P4Intent() { export function P4Intent() {
const { data, setData, navigate, back, meta } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const isStudent = data.isStudent; const isStudent = data.isStudent;
const city = data.city; const city = data.city;
const positions = data.expectedPositions; const positions = data.expectedPositions;
@ -19,10 +19,12 @@ export function P4Intent() {
expectedPositions: positions.includes(p) ? positions.filter((x) => x !== p) : [...positions, p], 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 ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="求职意向" showBack onBack={back}> <ScreenShell title="求职意向" showBack onBack={back}>
<Text style={styles.section}>你是学生吗</Text> <Text style={styles.section}>你是学生吗</Text>
<TagGroup> <TagGroup>
<Tag label="是" selected={isStudent === true} onPress={() => setData({ isStudent: true })} /> <Tag label="是" selected={isStudent === true} onPress={() => setData({ isStudent: true })} />
@ -32,7 +34,11 @@ export function P4Intent() {
<Text style={styles.section}>工作地点</Text> <Text style={styles.section}>工作地点</Text>
<View style={styles.cityRow}> <View style={styles.cityRow}>
<Ionicons name="location" size={18} color={colors.primary} /> <Ionicons name="location" size={18} color={colors.primary} />
<Tag label={city} selected onPress={() => navigate('P5')} /> <Tag
label={city || '请选择'}
selected={city !== ''}
onPress={() => navigate('P5')}
/>
<Text style={styles.cityHint}>点击切换城市</Text> <Text style={styles.cityHint}>点击切换城市</Text>
</View> </View>

13
components/onboarding/screens/P5City.tsx

@ -11,7 +11,7 @@ const ALL = ['北京', '上海', '广州', '深圳', '杭州', '成都', '南京
const HOT = ['北京', '上海', '广州', '深圳', '杭州', '成都']; const HOT = ['北京', '上海', '广州', '深圳', '杭州', '成都'];
export function P5City() { export function P5City() {
const { data, setData, back, meta } = useOnboarding(); const { data, setData, back } = useOnboarding();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const pick = (city: string) => { const pick = (city: string) => {
@ -22,14 +22,11 @@ export function P5City() {
const list = query ? ALL.filter((c) => c.includes(query)) : ALL; const list = query ? ALL.filter((c) => c.includes(query)) : ALL;
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="选择城市" showBack onBack={back}> <ScreenShell title="选择城市" showBack onBack={back}>
<SearchInput value={query} onChangeText={setQuery} placeholder="搜索城市" onClear={() => setQuery('')} /> <SearchInput value={query} onChangeText={setQuery} placeholder="搜索城市" onClear={() => setQuery('')} />
<Pressable style={styles.locate} onPress={() => pick('上海')}> {/* 原「自动定位:上海」按钮已移除:它不做定位,只是把「上海」写死成选中值。
<Ionicons name="locate" size={18} color={colors.primary} /> 不做定位就不宣称定位(已决定不引入 expo-location) */}
<Text style={styles.locateText}>自动定位:上海</Text>
</Pressable>
<Text style={styles.section}>热门城市</Text> <Text style={styles.section}>热门城市</Text>
<TagGroup> <TagGroup>
{HOT.map((c) => ( {HOT.map((c) => (
@ -51,8 +48,6 @@ export function P5City() {
} }
const styles = StyleSheet.create({ 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 }, section: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginTop: 22, marginBottom: 12 },
list: { gap: 2 }, list: { gap: 2 },
cityItem: { cityItem: {

60
components/onboarding/screens/P6ExpectedPosition.tsx

@ -3,7 +3,7 @@ import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme'; import { colors } from '../../../src/theme';
import { useOnboarding, useRegisterFinish } from '../../../src/hooks'; import { useOnboarding, useRegisterFinish } from '../../../src/hooks';
import type { AppRole } from '../../../src/types'; import { onboardingStepLabel } from '../../../src/utils';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { SearchInput } from '../ui/SearchInput'; import { SearchInput } from '../ui/SearchInput';
import { Tag, TagGroup } from '../ui/Tag'; import { Tag, TagGroup } from '../ui/Tag';
@ -23,33 +23,45 @@ const POSITIONS: Record<string, string[]> = {
医疗健康: ['临床医师', '药剂师', '健康管理'], 医疗健康: ['临床医师', '药剂师', '健康管理'],
}; };
/** 全部分类的职位去重展开,供搜索跨分类匹配 */
const ALL_POSITIONS: string[] = Array.from(new Set(Object.values(POSITIONS).flat()));
export function P6ExpectedPosition() { export function P6ExpectedPosition() {
const { data, setData, navigate, back, meta } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
const finish = useRegisterFinish(); const finish = useRegisterFinish();
const targetRole: AppRole = data.identity === 'recruiter' ? 'enterprise' : 'candidate';
const [cat, setCat] = useState('互联网AI'); const [cat, setCat] = useState('互联网AI');
const [query, setQuery] = useState('');
const positions = data.expectedPositions; 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) => const toggle = (p: string) =>
setData({ setData({
expectedPositions: positions.includes(p) ? positions.filter((x) => x !== p) : [...positions, p], expectedPositions: positions.includes(p) ? positions.filter((x) => x !== p) : [...positions, p],
}); });
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} showBack onBack={back}> <ScreenShell showBack onBack={back}>
<View style={styles.header}> <View style={styles.header}>
<Text style={styles.title}>期望职位</Text> <Text style={styles.title}>期望职位</Text>
<View style={styles.headerRight}> {/* 快速通道:填完意向即可落地,剩余资料后续再补。文案据实写「跳过」,
<View style={styles.progress}> 不再叫「保存」——它并不保存,而是直接结束注册流程 */}
<Text style={styles.progressText}>3/3</Text> <Pressable onPress={() => void finish('candidate')} hitSlop={6}>
</View> <Text style={styles.save}>跳过,直接进入</Text>
<Pressable onPress={() => finish(targetRole)}>
<Text style={styles.save}>保存</Text>
</Pressable> </Pressable>
</View> </View>
{step ? (
<View style={styles.progress}>
<Text style={styles.progressText}>{step}</Text>
</View> </View>
) : null}
<SearchInput value="" onChangeText={() => {}} placeholder="搜索职位" /> <SearchInput value={query} onChangeText={setQuery} placeholder="搜索职位" onClear={() => setQuery('')} />
<View style={styles.body}> <View style={styles.body}>
<View style={styles.cats}> <View style={styles.cats}>
@ -57,7 +69,12 @@ export function P6ExpectedPosition() {
<Pressable <Pressable
key={c} key={c}
style={[styles.catItem, c === cat && styles.catItemActive]} style={[styles.catItem, c === cat && styles.catItemActive]}
onPress={() => setCat(c)} onPress={() => {
// 搜索态下分类不参与过滤,只切分类看不出任何变化 —— 一并清掉关键词,
// 让「点分类」这个动作始终有可见结果
setQuery('');
setCat(c);
}}
> >
<Text style={[styles.catText, c === cat && styles.catTextActive]}>{c}</Text> <Text style={[styles.catText, c === cat && styles.catTextActive]}>{c}</Text>
</Pressable> </Pressable>
@ -77,11 +94,15 @@ export function P6ExpectedPosition() {
))} ))}
</View> </View>
)} )}
{visible.length > 0 ? (
<TagGroup> <TagGroup>
{POSITIONS[cat].map((p) => ( {visible.map((p) => (
<Tag key={p} label={p} selected={positions.includes(p)} onPress={() => toggle(p)} /> <Tag key={p} label={p} selected={positions.includes(p)} onPress={() => toggle(p)} />
))} ))}
</TagGroup> </TagGroup>
) : (
<Text style={styles.empty}>没有匹配「{keyword}」的职位,换个关键词试试</Text>
)}
</View> </View>
</View> </View>
@ -93,12 +114,19 @@ export function P6ExpectedPosition() {
} }
const styles = StyleSheet.create({ 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 }, title: { fontSize: 20, fontWeight: '800', color: colors.textPrimary },
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 14 }, progress: {
progress: { backgroundColor: colors.primaryLight, paddingHorizontal: 10, paddingVertical: 3, borderRadius: 10 }, alignSelf: 'flex-start',
backgroundColor: colors.primaryLight,
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 10,
marginBottom: 12,
},
progressText: { fontSize: 12, fontWeight: '700', color: colors.primaryText }, progressText: { fontSize: 12, fontWeight: '700', color: colors.primaryText },
save: { fontSize: 15, fontWeight: '700', color: colors.primary }, save: { fontSize: 15, fontWeight: '700', color: colors.primary },
empty: { fontSize: 13, color: colors.textHint, lineHeight: 20 },
body: { flexDirection: 'row', gap: 14, marginTop: 8 }, body: { flexDirection: 'row', gap: 14, marginTop: 8 },
cats: { width: 92 }, cats: { width: 92 },
catItem: { paddingVertical: 12, borderRadius: 10 }, catItem: { paddingVertical: 12, borderRadius: 10 },

22
components/onboarding/screens/P7Salary.tsx

@ -1,27 +1,31 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme'; import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks'; import { useOnboarding } from '../../../src/hooks';
import { applySalaryMin, salaryMaxOptions } from '../../../src/api';
import { SALARY_MIN_OPTIONS } from '../../../src/constants';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
const MIN = ['9K', '10K', '11K'];
const MAX = ['11K', '12K', '13K'];
export function P7Salary() { 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 ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="期望月薪" showBack onBack={back}> <ScreenShell title="期望月薪" showBack onBack={back}>
<Text style={styles.tip}>1K 代表 1000 元</Text> <Text style={styles.tip}>1K 代表 1000 元</Text>
<View style={styles.cols}> <View style={styles.cols}>
<View style={styles.col}> <View style={styles.col}>
<Text style={styles.colTitle}>最低</Text> <Text style={styles.colTitle}>最低</Text>
{MIN.map((v) => ( {SALARY_MIN_OPTIONS.map((v) => (
<Pressable <Pressable
key={v} key={v}
style={[styles.opt, data.expectedSalaryMin === v && styles.optActive]} style={[styles.opt, data.expectedSalaryMin === v && styles.optActive]}
onPress={() => setData({ expectedSalaryMin: v })} onPress={() => setData(applySalaryMin(v, data.expectedSalaryMax))}
> >
<Text style={[styles.optText, data.expectedSalaryMin === v && styles.optTextActive]}>{v}</Text> <Text style={[styles.optText, data.expectedSalaryMin === v && styles.optTextActive]}>{v}</Text>
</Pressable> </Pressable>
@ -30,7 +34,7 @@ export function P7Salary() {
<View style={styles.col}> <View style={styles.col}>
<Text style={styles.colTitle}>最高</Text> <Text style={styles.colTitle}>最高</Text>
{MAX.map((v) => ( {maxOptions.map((v) => (
<Pressable <Pressable
key={v} key={v}
style={[styles.opt, data.expectedSalaryMax === v && styles.optActive]} style={[styles.opt, data.expectedSalaryMax === v && styles.optActive]}
@ -43,7 +47,7 @@ export function P7Salary() {
</View> </View>
<View style={styles.footer}> <View style={styles.footer}>
<PrimaryButton label="下一步" active onPress={() => navigate('P8')} /> <PrimaryButton label="下一步" active={canNext} onPress={() => navigate('P8')} />
</View> </View>
</ScreenShell> </ScreenShell>
); );

13
components/onboarding/screens/P8Resume1.tsx

@ -8,12 +8,18 @@ import { YearMonthWheel } from '../ui/WheelPicker';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
export function P8Resume1() { 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 ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="创建在线简历" showBack onBack={back}> <ScreenShell title="创建在线简历" showBack onBack={back}>
<View style={styles.iconWrap}> <View style={styles.iconWrap}>
<Ionicons name="document-text" size={34} color={colors.primary} /> <Ionicons name="document-text" size={34} color={colors.primary} />
</View> </View>
@ -39,6 +45,7 @@ export function P8Resume1() {
month={data.birthMonth} month={data.birthMonth}
onYearChange={(v) => setData({ birthYear: v })} onYearChange={(v) => setData({ birthYear: v })}
onMonthChange={(v) => setData({ birthMonth: v })} onMonthChange={(v) => setData({ birthMonth: v })}
restingYear="2000年"
/> />
<View style={styles.footer}> <View style={styles.footer}>

8
components/onboarding/screens/P9JobStatus.tsx

@ -2,18 +2,20 @@ import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme'; import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks'; import { useOnboarding } from '../../../src/hooks';
import { JOB_STATUS_OPTIONS } from '../../../src/constants';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { RadioRow } from '../ui/RadioRow'; import { RadioRow } from '../ui/RadioRow';
import { AgreementCheckbox } from '../ui/AgreementCheckbox'; import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { PrimaryButton } from '../ui/PrimaryButton'; import { PrimaryButton } from '../ui/PrimaryButton';
const OPTIONS = ['离职-随时到岗', '在职-月内到岗', '在职-考虑机会', '在职-暂不考虑']; // 选项放常量:简历「求职意向」的积极/不积极判定读的是同一份,不能各写一份
const OPTIONS = JOB_STATUS_OPTIONS;
export function P9JobStatus() { export function P9JobStatus() {
const { data, setData, navigate, back, meta } = useOnboarding(); const { data, setData, navigate, back } = useOnboarding();
return ( return (
<ScreenShell time={meta.time} battery={meta.battery} title="求职状态" showBack onBack={back}> <ScreenShell title="求职状态" showBack onBack={back}>
<View style={styles.iconWrap}> <View style={styles.iconWrap}>
<Ionicons name="person" size={34} color={colors.primary} /> <Ionicons name="person" size={34} color={colors.primary} />
</View> </View>

18
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 { colors } from '../../../src/theme';
import { Illustration } from './Illustration'; import { Illustration } from './Illustration';
import type { AssetName } from '../assets'; import type { AssetName } from '../assets';
interface Props { interface Props {
name?: AssetName; name?: AssetName;
/** 拍照 / 相册得到的本地图片 URI;有值时优先于 name 渲染 */
uri?: string | null;
size?: number; size?: number;
selected?: boolean; selected?: boolean;
onPress?: () => void; 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 = ( const content = (
<View <View
style={[ style={[
@ -19,7 +27,11 @@ export function Avatar({ name = 'defaultAvatar', size = 64, selected = false, on
{ width: size, height: size, borderRadius: size / 2, borderColor: selected ? colors.primary : colors.border }, { width: size, height: size, borderRadius: size / 2, borderColor: selected ? colors.primary : colors.border },
]} ]}
> >
{uri ? (
<Image source={{ uri }} style={{ width: size, height: size }} resizeMode="cover" />
) : (
<Illustration name={name} size={size} circle /> <Illustration name={name} size={size} circle />
)}
</View> </View>
); );
if (onPress) return <Pressable onPress={onPress} hitSlop={6}>{content}</Pressable>; if (onPress) return <Pressable onPress={onPress} hitSlop={6}>{content}</Pressable>;

12
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 { Ionicons } from '@expo/vector-icons';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { colors } from '../../../src/theme'; import { colors } from '../../../src/theme';
@ -13,7 +13,13 @@ interface Props {
circle?: boolean; circle?: boolean;
} }
/** 统一插画/Logo/头像占位:有真实图渲染图,否则渲染色块 + 图标 + 提示文字 */ /**
* 统一插画/Logo/头像占位:有真实图渲染图,否则渲染色块 + 图标。
*
* 占位期**不渲染 `hint`**:那些文字是给设计/开发看的素材说明
* (「手持 OFFER 职场男性插画」),直接画在用户界面上等于把开发备注当内容展示。
* 说明仍留在 `assets.ts`,供换图时对照。
*/
export function Illustration({ name, size = 120, radius = 16, circle = false }: Props) { export function Illustration({ name, size = 120, radius = 16, circle = false }: Props) {
const def = ASSET_REGISTRY[name]; const def = ASSET_REGISTRY[name];
const source: ImageSourcePropType | null = def.source; const source: ImageSourcePropType | null = def.source;
@ -26,12 +32,10 @@ export function Illustration({ name, size = 120, radius = 16, circle = false }:
return ( return (
<View style={[styles.block, { width: size, height: size, borderRadius: r, backgroundColor: def.tint }]}> <View style={[styles.block, { width: size, height: size, borderRadius: r, backgroundColor: def.tint }]}>
<Ionicons name={def.icon as IconName} size={size * 0.32} color={colors.primary} /> <Ionicons name={def.icon as IconName} size={size * 0.32} color={colors.primary} />
{!circle && <Text style={styles.hint}>{def.hint}</Text>}
</View> </View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
block: { alignItems: 'center', justifyContent: 'center', gap: 6, overflow: 'hidden' }, block: { alignItems: 'center', justifyContent: 'center', gap: 6, overflow: 'hidden' },
hint: { fontSize: 11, color: colors.textHint, paddingHorizontal: 8, textAlign: 'center' },
}); });

64
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<typeof Ionicons>['name'];
interface Props {
/** 素材位名(见 assets.ts 的 ASSET_REGISTRY) */
name: AssetName;
width: number;
height: number;
radius?: number;
}
/**
* 内容配图槽位:素材已提供则渲染真实图,否则画一个**带文字标注**的占位框。
*
* 与 `Illustration` 的分工:那个是「小尺寸图标位」(头像 / Logo),占位只画色块;
* 这个是「内容大图位」,占位必须让看图的人知道这里要放什么图,
* 所以把 `assets.ts` 里的 hint 直接标在页面上,补图后标注自动消失。
*/
export function ImageSlot({ name, width, height, radius = 16 }: Props) {
const def = ASSET_REGISTRY[name];
if (def.source) {
return (
<Image source={def.source} style={{ width, height, borderRadius: radius }} resizeMode="cover" />
);
}
return (
<View style={[styles.slot, { width, height, borderRadius: radius, backgroundColor: def.tint }]}>
<Ionicons name={def.icon as IconName} size={26} color={colors.primary} />
<Text style={styles.tag}>配图待补</Text>
<Text style={styles.hint}>{def.hint}</Text>
</View>
);
}
const styles = StyleSheet.create({
slot: {
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: colors.primaryBorder,
},
tag: {
marginTop: 8,
fontSize: 12,
fontWeight: '700',
color: colors.primaryText,
},
hint: {
marginTop: 4,
fontSize: 12,
lineHeight: 18,
textAlign: 'center',
color: colors.textSecondary,
},
});

5
components/onboarding/ui/ScreenShell.tsx

@ -14,13 +14,10 @@ interface Props {
children: ReactNode; children: ReactNode;
footer?: ReactNode; footer?: ReactNode;
scroll?: boolean; 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 ( return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar style="dark" /> <StatusBar style="dark" />

43
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 (
<View style={styles.row}>
{items.map((item) => (
<Pressable key={item.key} onPress={() => call(item.phone)} hitSlop={6}>
<Text style={styles.item}>
{item.label} {item.phone}
</Text>
</Pressable>
))}
</View>
);
}
const styles = StyleSheet.create({
// 两串「标签 + 11 位号码」在窄屏上会挤,允许换行
row: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 18 },
item: { fontSize: 12, color: colors.textHint },
});

72
components/onboarding/ui/WheelPicker.tsx

@ -1,6 +1,8 @@
import { useEffect, useRef } from 'react';
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
import { ScrollView, StyleSheet, Text, View } from 'react-native'; import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme'; import { colors } from '../../../src/theme';
import { CURRENT_YEAR } from '../../../src/constants';
const ITEM_H = 44; const ITEM_H = 44;
@ -9,13 +11,29 @@ function Column({
value, value,
onValueChange, onValueChange,
width, width,
restingIndex,
}: { }: {
items: string[]; items: string[];
value: string; value: string;
onValueChange: (v: string) => void; onValueChange: (v: string) => void;
width: number; width: number;
/** 未选中时滚轮的停留位置(只影响视觉停留,不等于已选中) */
restingIndex: number;
}) { }) {
const idx = Math.max(0, items.indexOf(value)); const scrollRef = useRef<ScrollView>(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<NativeScrollEvent>) => { const handleEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
const y = e.nativeEvent.contentOffset.y; const y = e.nativeEvent.contentOffset.y;
const i = Math.min(items.length - 1, Math.max(0, Math.round(y / ITEM_H))); const i = Math.min(items.length - 1, Math.max(0, Math.round(y / ITEM_H)));
@ -25,14 +43,15 @@ function Column({
return ( return (
<View style={[styles.col, { width }]}> <View style={[styles.col, { width }]}>
<View style={styles.highlight} pointerEvents="none" /> <View style={[styles.highlight, idx < 0 && styles.highlightIdle]} pointerEvents="none" />
<ScrollView <ScrollView
ref={scrollRef}
style={styles.scroll} style={styles.scroll}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
snapToInterval={ITEM_H} snapToInterval={ITEM_H}
snapToAlignment="center" snapToAlignment="center"
decelerationRate="fast" decelerationRate="fast"
contentOffset={{ x: 0, y: idx * ITEM_H }} contentOffset={{ x: 0, y: initialIndex * ITEM_H }}
contentContainerStyle={{ paddingVertical: ITEM_H }} contentContainerStyle={{ paddingVertical: ITEM_H }}
onMomentumScrollEnd={handleEnd} onMomentumScrollEnd={handleEnd}
> >
@ -51,16 +70,52 @@ interface DualProps {
month: string; month: string;
onYearChange: (v: string) => void; onYearChange: (v: string) => void;
onMonthChange: (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) { export function YearMonthWheel({
const years = Array.from({ length: 40 }, (_, i) => `${2026 - i}年`); 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 months = Array.from({ length: 12 }, (_, i) => `${i + 1}月`);
const unselected = !year || !month;
const restingYearIndex = Math.max(0, years.indexOf(restingYear));
return ( return (
<View>
<View style={styles.dual}> <View style={styles.dual}>
<Column items={years} value={year} onValueChange={onYearChange} width={130} /> <Column
<Column items={months} value={month} onValueChange={onMonthChange} width={110} /> items={years}
value={year}
onValueChange={onYearChange}
width={130}
restingIndex={restingYearIndex}
/>
<Column items={months} value={month} onValueChange={onMonthChange} width={110} restingIndex={0} />
</View>
{/* 未选中时滚轮无高亮项,需要文字提示「要滑动才有值」,否则用户会以为已经选好了 */}
{unselected ? <Text style={styles.hint}>请滑动选择</Text> : null}
</View> </View>
); );
} }
@ -80,8 +135,11 @@ const styles = StyleSheet.create({
backgroundColor: colors.primaryLight, backgroundColor: colors.primaryLight,
opacity: 0.45, opacity: 0.45,
}, },
// 未选中:高亮框退成中性描边,不再用主色底暗示「已经选好了」
highlightIdle: { backgroundColor: colors.white, opacity: 0.6 },
item: { height: ITEM_H, alignItems: 'center', justifyContent: 'center' }, item: { height: ITEM_H, alignItems: 'center', justifyContent: 'center' },
itemText: { fontSize: 18, color: colors.textHint }, itemText: { fontSize: 18, color: colors.textHint },
itemTextActive: { fontSize: 20, fontWeight: '700', color: colors.textPrimary }, itemTextActive: { fontSize: 20, fontWeight: '700', color: colors.textPrimary },
dual: { flexDirection: 'row', justifyContent: 'center', gap: 14 }, dual: { flexDirection: 'row', justifyContent: 'center', gap: 14 },
hint: { fontSize: 13, color: colors.textHint, textAlign: 'center', marginTop: 10 },
}); });

209
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 (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>{title}</Text>
<View style={styles.headerBtn} />
</View>
);
}
/**
* 表单行。`error` 只在**填了但填错**时传入 —— 空值是「未采集」不是「填错了」,
* 一进页面就飘红字会让人以为自己做错了什么。
*/
export function Field({
label,
value,
onChangeText,
placeholder,
keyboardType,
maxLength,
error,
hint,
multiline,
autoFocus,
required,
}: {
label: string;
value: string;
onChangeText: (v: string) => void;
placeholder: string;
keyboardType?: 'default' | 'phone-pad' | 'email-address' | 'number-pad';
maxLength?: number;
error?: string;
hint?: string;
multiline?: boolean;
autoFocus?: boolean;
/** 必填项在 label 后打一个红色星号(只做视觉标注,拦截规则在页面里) */
required?: boolean;
}) {
const hasError = error !== undefined && error !== '';
return (
<View style={styles.section}>
<Text style={styles.label}>
{label}
{required === true && <Text style={styles.requiredMark}> *</Text>}
</Text>
<TextInput
style={[styles.input, multiline === true && styles.inputMultiline, hasError && styles.inputError]}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.textHint}
keyboardType={keyboardType ?? 'default'}
maxLength={maxLength}
multiline={multiline}
autoFocus={autoFocus}
autoCapitalize="none"
/>
{hasError && <Text style={styles.fieldError}>{error}</Text>}
{!hasError && hint !== undefined && hint !== '' && <Text style={styles.hint}>{hint}</Text>}
</View>
);
}
/** 只读说明行(放在卡片或页脚里,字号小于正文) */
export function FormNote({ text }: { text: string }) {
return <Text style={styles.note}>{text}</Text>;
}
/** 白色圆角卡片容器(与简历首页的卡片同款) */
export function SectionCard({ title, right, children }: { title: string; right?: ReactNode; children: ReactNode }) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<Text style={styles.cardTitle}>{title}</Text>
{right}
</View>
{children}
</View>
);
}
/** 页脚容器:上方一条浅描边,主按钮靠它压底 */
export function PageFooter({ children }: { children: ReactNode }) {
return <View style={styles.footer}>{children}</View>;
}
/** 居中状态块(加载中 / 加载失败 / 空列表都走这一个) */
export function CenterState({
icon,
text,
actionLabel,
onAction,
}: {
icon?: keyof typeof Ionicons.glyphMap;
text: string;
actionLabel?: string;
onAction?: () => void;
}) {
return (
<View style={styles.center}>
{icon !== undefined && <Ionicons name={icon} size={40} color={colors.textHint} />}
<Text style={styles.centerText}>{text}</Text>
{actionLabel !== undefined && onAction !== undefined && (
<Pressable style={styles.centerBtn} onPress={onAction} hitSlop={6}>
<Text style={styles.centerBtnText}>{actionLabel}</Text>
</Pressable>
)}
</View>
);
}
/** 列表行右侧的图标按钮(编辑 / 删除) */
export function IconAction({
icon,
onPress,
color,
label,
}: {
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
color?: string;
/** 无障碍标签:图标按钮对读屏用户是没有名字的,必须补一个 */
label: string;
}) {
return (
<Pressable onPress={onPress} hitSlop={8} style={styles.iconBtn} accessibilityLabel={label}>
<Ionicons name={icon} size={18} color={color ?? colors.textHint} />
</Pressable>
);
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingVertical: 12,
backgroundColor: colors.white,
},
headerBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
section: { marginBottom: 18 },
label: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginBottom: 10 },
requiredMark: { color: colors.danger },
input: {
height: 50,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.white,
paddingHorizontal: 14,
fontSize: 16,
color: colors.textPrimary,
},
inputMultiline: { height: 120, paddingTop: 12, textAlignVertical: 'top' },
inputError: { borderColor: colors.dangerBorder },
fieldError: { fontSize: 12, color: colors.dangerText, marginTop: 6 },
hint: { fontSize: 12, color: colors.textHint, marginTop: 6 },
note: { fontSize: 12, color: colors.textHint, lineHeight: 18 },
card: {
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
marginBottom: 12,
borderWidth: 1,
borderColor: colors.borderLight,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 10,
},
cardTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
footer: {
paddingHorizontal: 20,
paddingVertical: 14,
backgroundColor: colors.white,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12, paddingVertical: 40 },
centerText: { fontSize: 14, color: colors.textSecondary },
centerBtn: {
paddingHorizontal: 18,
paddingVertical: 8,
borderRadius: 16,
backgroundColor: colors.primaryLight,
},
centerBtnText: { fontSize: 13, fontWeight: '600', color: colors.primaryText },
iconBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
});

10
package.json

@ -8,10 +8,14 @@
"expo": "~57.0.22", "expo": "~57.0.22",
"expo-blur": "~57.0.3", "expo-blur": "~57.0.3",
"expo-constants": "~57.0.18", "expo-constants": "~57.0.18",
"expo-file-system": "~57.0.7",
"expo-font": "~57.0.4", "expo-font": "~57.0.4",
"expo-image-picker": "~57.0.17",
"expo-linear-gradient": "~57.0.2", "expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.10", "expo-linking": "~57.0.10",
"expo-print": "~57.0.2",
"expo-router": "~57.0.21", "expo-router": "~57.0.21",
"expo-sharing": "~57.0.19",
"expo-splash-screen": "~57.0.9", "expo-splash-screen": "~57.0.9",
"expo-status-bar": "~57.0.1", "expo-status-bar": "~57.0.1",
"react": "19.2.3", "react": "19.2.3",
@ -26,7 +30,11 @@
"ios": "expo start --ios", "ios": "expo start --ios",
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "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, "private": true,
"devDependencies": { "devDependencies": {

86
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';
/** 从 `<Tag` 的起始下标起,找到该开标签的结束 `>`(粗略忽略字符串内的 `>`,对本仓库足够) */
function findTagEnd(text, from) {
let depth = 0;
for (let i = from; i < text.length; i += 1) {
const ch = text[i];
if (ch === '{') depth += 1;
else if (ch === '}') depth -= 1;
else if (ch === '>' && depth === 0) return i;
}
return -1;
}
function walk(dir, out = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full, out);
else if (/\.tsx$/.test(entry)) out.push(full);
}
return out;
}
const TAG_RE = /<(Pressable|TouchableOpacity|TouchableHighlight|TouchableWithoutFeedback)\b/g;
/** 空处理函数:绑了 onPress 但什么都不做,点下去同样零反馈 */
const EMPTY_HANDLER_RE = /onPress=\{\s*\(\s*\)\s*=>\s*(\{\s*\}|undefined|null)\s*\}/;
const findings = [];
for (const root of ROOTS) {
for (const file of walk(root)) {
const text = readFileSync(file, 'utf8');
const lines = text.split('\n');
for (const match of text.matchAll(TAG_RE)) {
const end = findTagEnd(text, match.index);
if (end < 0) continue;
const tag = text.slice(match.index, end + 1);
const empty = EMPTY_HANDLER_RE.test(tag);
if (!empty && HANDLER_PROPS.some((prop) => tag.includes(prop))) continue;
const line = text.slice(0, match.index).split('\n').length;
// 豁免标记写在标签行或其上一行(弹层外的说明注释通常写在上一行)
if (lines[line - 1].includes(EXEMPT_MARKER) || (lines[line - 2] ?? '').includes(EXEMPT_MARKER)) {
continue;
}
findings.push({
file,
line,
reason: empty ? '空处理函数' : '无处理函数',
tag: lines[line - 1].trim(),
});
}
}
}
if (findings.length === 0) {
console.log('✅ 未发现无处理函数的可点击控件');
} else {
console.log(`=== 点下去没有反应的可点击控件 (${findings.length}) ===`);
for (const f of findings) {
console.log(`${f.file}:${f.line} [${f.reason}] ${f.tag}`);
}
if (process.argv.includes('--error')) process.exitCode = 1;
}

47
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;
}

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

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

62
src/__tests__/enterprise-reviews.test.ts

@ -0,0 +1,62 @@
import {
fetchJobEvaluationManagement,
fetchPendingOfferCount,
replyJobReview,
} from '../api/api';
/**
* 内存版存储:回复要能跨两次请求读回来,才测得出「刷新后回复还在」。
* 变量名必须以 mock 开头,jest.mock 的工厂函数只能引用这类外部变量。
*/
const mockStore = new Map<string, string>();
jest.mock('../lib/storage', () => ({
storageGet: jest.fn(async (key: string) => mockStore.get(key) ?? null),
storageSet: jest.fn(async (key: string, value: string) => {
mockStore.set(key, value);
}),
storageRemove: jest.fn(async (key: string) => {
mockStore.delete(key);
}),
}));
beforeEach(() => {
mockStore.clear();
});
describe('回复岗位评价', () => {
it('回复成功后返回 replied 与回复内容', async () => {
const review = await replyJobReview('jr2', '感谢反馈,我们已调整排班');
expect(review.replied).toBe(true);
expect(review.replyContent).toBe('感谢反馈,我们已调整排班');
});
it('回复内容落库,重新拉列表后仍然带着回复(不会刷新一次就变回「回复」)', async () => {
await replyJobReview('jr2', '感谢反馈');
const management = await fetchJobEvaluationManagement();
const target = management.reviews.find((r) => r.id === 'jr2');
expect(target?.replied).toBe(true);
expect(target?.replyContent).toBe('感谢反馈');
});
it('空回复直接失败,不写入存储', async () => {
await expect(replyJobReview('jr2', ' ')).rejects.toThrow('回复内容不能为空');
const management = await fetchJobEvaluationManagement();
const target = management.reviews.find((r) => r.id === 'jr2');
expect(target?.replied).toBe(false);
});
it('回复率不由这 3 条样例算:分母是全部评价,样例算出来比原值更假', async () => {
const management = await fetchJobEvaluationManagement();
// 样例里 3 条有 2 条已回复 = 67%,而看板上的回复率来自全部 128 条
expect(management.replyRate).toBe(72);
});
});
describe('待处理 Offer 数量', () => {
it('按状态统计,只数 pending', async () => {
expect(await fetchPendingOfferCount()).toBe(2);
});
});

96
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<string, string>();
return {
__store: store,
storageGet: jest.fn(async (key: string) => store.get(key) ?? null),
storageSet: jest.fn(async (key: string, value: string) => {
store.set(key, value);
}),
storageRemove: jest.fn(async (key: string) => {
store.delete(key);
}),
};
});
const store = (jest.requireMock('../lib/storage') as { __store: Map<string, string> }).__store;
const INTERVIEW_A = 'interview-1';
const INTERVIEW_B = 'interview-2';
beforeEach(() => {
store.clear();
});
describe('面试评分草稿(本地暂存 + 恢复)', () => {
it('保存后能原样读回,且带回保存时间', async () => {
const score = await fetchInterviewScore(INTERVIEW_A);
const draft = await saveInterviewScoreDraft(INTERVIEW_A, { ...score, comment: '沟通清晰' });
expect(draft.score.comment).toBe('沟通清晰');
expect(Number.isNaN(Date.parse(draft.savedAt))).toBe(false);
const restored = await fetchInterviewScoreDraft(INTERVIEW_A);
expect(restored?.score.comment).toBe('沟通清晰');
expect(restored?.savedAt).toBe(draft.savedAt);
});
it('未存过草稿时返回 null(页面据此不显示恢复提示)', async () => {
expect(await fetchInterviewScoreDraft('从没评过的面试')).toBeNull();
});
it('不同面试的草稿互不串台', async () => {
const score = await fetchInterviewScore(INTERVIEW_A);
await saveInterviewScoreDraft(INTERVIEW_A, { ...score, comment: 'A 场的评价' });
await saveInterviewScoreDraft(INTERVIEW_B, { ...score, comment: 'B 场的评价' });
expect((await fetchInterviewScoreDraft(INTERVIEW_A))?.score.comment).toBe('A 场的评价');
expect((await fetchInterviewScoreDraft(INTERVIEW_B))?.score.comment).toBe('B 场的评价');
});
it('同一场面试重复保存时覆盖前一份,不是并列存两份', async () => {
const score = await fetchInterviewScore(INTERVIEW_A);
await saveInterviewScoreDraft(INTERVIEW_A, { ...score, comment: '第一版' });
await saveInterviewScoreDraft(INTERVIEW_A, { ...score, comment: '第二版' });
expect((await fetchInterviewScoreDraft(INTERVIEW_A))?.score.comment).toBe('第二版');
});
it('存储内容损坏时当作没有草稿,不抛异常到页面', async () => {
await saveInterviewScoreDraft(INTERVIEW_A, await fetchInterviewScore(INTERVIEW_A));
store.set(`interview:score-draft:${INTERVIEW_A}`, '{{坏掉的内容');
expect(await fetchInterviewScoreDraft(INTERVIEW_A)).toBeNull();
});
it('清除后读不到草稿(提交成功或用户放弃后调用)', async () => {
const score = await fetchInterviewScore(INTERVIEW_A);
await saveInterviewScoreDraft(INTERVIEW_A, score);
await clearInterviewScoreDraft(INTERVIEW_A);
expect(await fetchInterviewScoreDraft(INTERVIEW_A)).toBeNull();
});
it('清除只影响该场面试,其他场次的草稿保留', async () => {
const score = await fetchInterviewScore(INTERVIEW_A);
await saveInterviewScoreDraft(INTERVIEW_A, score);
await saveInterviewScoreDraft(INTERVIEW_B, score);
await clearInterviewScoreDraft(INTERVIEW_A);
expect(await fetchInterviewScoreDraft(INTERVIEW_B)).not.toBeNull();
});
it('清除没存过草稿的面试时不报错(幂等)', async () => {
await expect(clearInterviewScoreDraft('从没评过的面试')).resolves.toBeUndefined();
});
});

255
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<string, string> = {};
return {
storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)),
storageSet: jest.fn(async (k: string, v: string) => {
store[k] = v;
}),
storageRemove: jest.fn(async (k: string) => {
delete store[k];
}),
};
});
/** 全部字段留空 / 为 null 的兜底资料,只覆盖测试关心的字段 */
const BASE_PROFILE: OnboardingProfile = {
identity: 'candidate',
isStudent: null,
city: '',
expectedPositions: [],
expectedSalaryMin: '',
expectedSalaryMax: '',
resumeName: '',
gender: null,
birthYear: '',
birthMonth: '',
jobStatus: null,
disabledJobseeker: false,
firstWorkYear: '',
firstWorkMonth: '',
currentPosition: null,
company: '',
joinYear: '',
joinMonth: '',
skills: [],
workContent: '',
education: null,
eduType: null,
school: '',
avatar: null,
recruiter: null,
};
const makeProfile = (patch: Partial<OnboardingProfile>): OnboardingProfile => ({
...BASE_PROFILE,
...patch,
});
/**
* mock 岗位(`src/api/api.ts` 的 MOCK_JOBS)固定为 9 条:
* | id | 职位 | 城市 | 薪资 | 分数 |
* | 1 | 高级前端工程师 | 北京 | 25-40 | 92 |
* | 2 | 产品经理 | 深圳 | 20-35 | 78 |
* | 3 | UI设计师 | 杭州 | 15-25 | 85 |
* | 4 | Java开发工程师 | 北京 | 18-30 | 65 |
* | 5 | 前端实习生 | 上海 | 6-9 | 60 |
* | 6 | 金融分析师 | 深圳 | 30-50 | 88 |
* | 7 | 课程顾问 | 北京 | 12-20 | 72 |
* | 8 | 医学代表 | 广州 | 15-25 | 75 |
* | 9 | 电池工程师 | 宁德 | 28-45 | 90 |
* 下面所有「决定性输入 → 决定性顺序」的断言都基于这张表。
*/
const DEFAULT_PARAMS = { sortBy: 'comprehensive' as const };
const idsOf = (jobs: Job[]): string[] => jobs.map((job) => job.id);
describe('fetchJobList 岗位推荐匹配(默认推荐视图叠加偏好加分)', () => {
beforeEach(async () => {
await clearOnboardingProfile();
await storageRemove('user:job-preference');
});
it('无任何采集数据时,按岗位分数降序(不凭空加分)', async () => {
const { jobs, total } = await fetchJobList(DEFAULT_PARAMS);
expect(total).toBe(9);
expect(idsOf(jobs)).toEqual(['1', '9', '6', '3', '2', '8', '7', '4', '5']);
});
it('命中期望城市 +15:杭州岗位从第 4 位升到第 1 位', async () => {
await saveOnboardingProfile(makeProfile({ city: '杭州' }));
const { jobs } = await fetchJobList(DEFAULT_PARAMS);
// UI设计师(杭州,85)→ 85+15=100,越过原来的第 1 名(92)
expect(idsOf(jobs)).toEqual(['3', '1', '9', '6', '2', '8', '7', '4', '5']);
});
it('命中期望职位 +20:职位名归一化后再比对', async () => {
await saveOnboardingProfile(makeProfile({ expectedPositions: ['前端开发工程师'] }));
const { jobs } = await fetchJobList(DEFAULT_PARAMS);
// 「前端开发工程师」⇒ 去掉「开发」「工程师」⇒「前端」
// 岗位 1「高级前端工程师」⇒「前端」命中(+20 → 112),原本就在第 1 位
// 岗位 5「前端实习生」⇒「前端」同样命中(+20 → 80),从末位升到第 5 位
expect(idsOf(jobs)).toEqual(['1', '9', '6', '3', '5', '2', '8', '7', '4']);
});
it('期望薪资与岗位薪资区间有重叠 +10(含边界值)', async () => {
await saveOnboardingProfile(
makeProfile({ expectedSalaryMin: '35K', expectedSalaryMax: '45K' }),
);
const { jobs } = await fetchJobList(DEFAULT_PARAMS);
// 命中(+10):1(25-40) 2(20-35 边界 35>=35) 6(30-50) 9(28-45)
// 未命中:3(15-25) 4(18-30) 5(6-9) 7(12-20) 8(15-25)
// 结果:产品经理 78+10=88 越过 UI设计师 85
expect(idsOf(jobs)).toEqual(['1', '9', '6', '2', '3', '8', '7', '4', '5']);
});
it('三项加分可叠加:命中越多排越前', async () => {
await saveOnboardingProfile(
makeProfile({
city: '杭州',
expectedPositions: ['UI设计师'],
expectedSalaryMin: '20K',
expectedSalaryMax: '35K',
}),
);
const { jobs } = await fetchJobList(DEFAULT_PARAMS);
// UI设计师:85 + 城市15 + 职位20 + 薪资10(15-25 与 20-35 有交集)= 130
expect(idsOf(jobs)[0]).toBe('3');
expect(idsOf(jobs)[1]).toBe('1'); // 92 + 薪资10(25-40 与 20-35 有交集)
});
it('无法解析的期望薪资不加分(不凭空命中)', async () => {
await saveOnboardingProfile(
makeProfile({ expectedSalaryMin: '面议', expectedSalaryMax: '面议' }),
);
const { jobs } = await fetchJobList(DEFAULT_PARAMS);
expect(idsOf(jobs)).toEqual(['1', '9', '6', '3', '2', '8', '7', '4', '5']);
});
});
describe('fetchJobList 显式筛选优先(用户主动筛选时不叠加偏好加分)', () => {
/**
* 该偏好若被叠加,会把「课程顾问」(72+20+10=102)顶到北京岗位的第 1 位
* —— 未叠加时北京岗位按原始分数序为 [1(92), 7(72), 4(65)]。
* 城市留空:城市加分对同城岗位一视同仁,加与不加都改不了相对顺序,留着反而干扰断言。
*/
const PREFERENCE: JobPreference = {
position: '课程顾问',
city: '',
salary: '12K-20K',
workType: '全职',
status: '在职-月内到岗',
};
beforeEach(async () => {
await clearOnboardingProfile();
await storageRemove('user:job-preference');
await saveJobPreference(PREFERENCE);
});
it('显式选了城市:只做筛选,不叠加加分', async () => {
// 北京岗位原始顺序:1(92) / 7(72) / 4(65)
const filtered = await fetchJobList({ ...DEFAULT_PARAMS, city: '北京' });
expect(idsOf(filtered.jobs)).toEqual(['1', '7', '4']);
// 同一份偏好在默认视图下确实生效(否则上面的断言可能是「偏好没读到」而假通过)
const unfiltered = await fetchJobList(DEFAULT_PARAMS);
expect(idsOf(unfiltered.jobs)[0]).toBe('7'); // 72 + 职位20 + 薪资10 = 102
});
it('显式输入了关键词:只做搜索,不叠加加分', async () => {
const matched = await fetchJobList({ ...DEFAULT_PARAMS, keyword: '工程师' });
expect(idsOf(matched.jobs)).toEqual(['1', '9', '4']);
// 上面这份偏好只对「课程顾问」加权,换成「电池工程师」再验一次默认视图
await saveJobPreference({ ...PREFERENCE, position: '电池工程师', salary: '' });
const filtered = await fetchJobList({ ...DEFAULT_PARAMS, keyword: '工程师' });
expect(idsOf(filtered.jobs)).toEqual(['1', '9', '4']);
const unfiltered = await fetchJobList(DEFAULT_PARAMS);
expect(idsOf(unfiltered.jobs)[0]).toBe('9'); // 90 + 职位20 = 110
});
it('城市传「全部城市」哨兵值等同未筛选,偏好生效', async () => {
const { jobs } = await fetchJobList({ ...DEFAULT_PARAMS, city: '全部城市' });
expect(idsOf(jobs)[0]).toBe('7');
});
});
describe('fetchJobPreference 取值优先级(显式编辑 > 引导资料推导 > 空值)', () => {
beforeEach(async () => {
await clearOnboardingProfile();
await storageRemove('user:job-preference');
});
it('无任何数据时返回空值,工作类型补默认「全职」', async () => {
await expect(fetchJobPreference()).resolves.toEqual({
position: '',
city: '',
salary: '',
workType: '全职',
status: '',
});
});
it('由引导资料推导:期望职位取第 1 个,薪资拼成区间', async () => {
await saveOnboardingProfile(
makeProfile({
city: '杭州',
expectedPositions: ['鸿蒙开发工程师', '前端开发工程师'],
expectedSalaryMin: '20K',
expectedSalaryMax: '35K',
jobStatus: '离职-随时到岗',
}),
);
await expect(fetchJobPreference()).resolves.toEqual({
position: '鸿蒙开发工程师',
city: '杭州',
salary: '20K-35K',
workType: '全职',
status: '离职-随时到岗',
});
});
it('期望薪资只填了一头时不拼区间(不留「20K-」这种半截值)', async () => {
await saveOnboardingProfile(
makeProfile({ expectedSalaryMin: '20K', expectedSalaryMax: '' }),
);
await expect(fetchJobPreference()).resolves.toMatchObject({ salary: '' });
});
it('用户在求职意向页显式保存过时,以显式值为准', async () => {
await saveOnboardingProfile(makeProfile({ city: '杭州', expectedPositions: ['前端开发工程师'] }));
await saveJobPreference({
position: '电池工程师',
city: '宁德',
salary: '',
workType: '全职',
status: '',
});
await expect(fetchJobPreference()).resolves.toMatchObject({
position: '电池工程师',
city: '宁德',
});
// 岗位排序同样按显式偏好:宁德岗位 90+15=105 越过杭州岗位 85+15=100
const { jobs } = await fetchJobList(DEFAULT_PARAMS);
expect(idsOf(jobs)[0]).toBe('9');
});
it('显式偏好存储损坏时回退到引导资料推导', async () => {
await saveOnboardingProfile(makeProfile({ city: '杭州' }));
await storageSet('user:job-preference', '{ 这不是合法 JSON');
await expect(fetchJobPreference()).resolves.toMatchObject({ city: '杭州' });
});
});

221
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<string, string>();
return {
__store: store,
storageGet: jest.fn(async (key: string) => store.get(key) ?? null),
storageSet: jest.fn(async (key: string, value: string) => {
store.set(key, value);
}),
storageRemove: jest.fn(async (key: string) => {
store.delete(key);
}),
};
});
const store = (jest.requireMock('../lib/storage') as { __store: Map<string, string> }).__store;
/** 存储键(取自 api.ts,用于构造「存储内容损坏」的降级用例) */
const DONE_KEY = 'onboarding:checklist-done';
const CUSTOM_KEY = 'onboarding:checklist-custom';
/** 模板清单的基线:4 组共 18 条,初始已完成 3 条 */
const TEMPLATE_TOTAL = 18;
const TEMPLATE_DONE = 3;
function findItem(checklist: OnboardingChecklist, itemId: string) {
const found = checklist.groups.flatMap((group) => group.items).find((item) => item.id === itemId);
if (!found) throw new Error(`清单里找不到条目 ${itemId}`);
return found;
}
function findGroup(checklist: OnboardingChecklist, groupId: string) {
return checklist.groups.find((group) => group.id === groupId);
}
beforeEach(() => {
store.clear();
});
describe('fetchOnboardingChecklist(入职准备清单)', () => {
it('首次拉取:模板四组共 18 条,已完成 3 条,没有自定义分组', async () => {
const checklist = await fetchOnboardingChecklist();
expect(checklist.total).toBe(TEMPLATE_TOTAL);
expect(checklist.completed).toBe(TEMPLATE_DONE);
expect(checklist.groups.map((g) => g.id)).toEqual(['material', 'procedure', 'study', 'life']);
});
it('存储内容损坏时退回模板值,不把异常抛到页面', async () => {
store.set(DONE_KEY, '{不是合法 JSON');
store.set(CUSTOM_KEY, '["也不是数组里的条目"]"');
const checklist = await fetchOnboardingChecklist();
expect(checklist.total).toBe(TEMPLATE_TOTAL);
expect(checklist.completed).toBe(TEMPLATE_DONE);
});
it('自定义存储里放的不是数组时按没有自定义条目处理', async () => {
store.set(CUSTOM_KEY, '{"id":"custom-1"}');
const checklist = await fetchOnboardingChecklist();
expect(findGroup(checklist, 'custom')).toBeUndefined();
expect(checklist.total).toBe(TEMPLATE_TOTAL);
});
});
describe('toggleChecklistItem(勾选落库)', () => {
it('勾选后计数 +1,且重新拉取仍然是勾上的', async () => {
await toggleChecklistItem('m4', true);
// 换一次全新的读取,模拟退出页面再进来
const checklist = await fetchOnboardingChecklist();
expect(findItem(checklist, 'm4').done).toBe(true);
expect(checklist.completed).toBe(TEMPLATE_DONE + 1);
expect(checklist.total).toBe(TEMPLATE_TOTAL);
});
it('取消勾选后计数回落(不会只增不减)', async () => {
await toggleChecklistItem('m1', false);
const checklist = await fetchOnboardingChecklist();
expect(findItem(checklist, 'm1').done).toBe(false);
expect(checklist.completed).toBe(TEMPLATE_DONE - 1);
});
it('只影响被点的那一条,同组其他条目不受牵连', async () => {
await toggleChecklistItem('m4', true);
const checklist = await fetchOnboardingChecklist();
expect(findItem(checklist, 'm1').done).toBe(true);
expect(findItem(checklist, 'm2').done).toBe(true);
expect(findItem(checklist, 'm5').done).toBe(false);
});
});
describe('addChecklistItem(新增自定义待办)', () => {
it('新增后出现「我的自定义待办」分组,总数 +1,且默认未完成', async () => {
const checklist = await addChecklistItem('找 HR 确认工位');
const customGroup = findGroup(checklist, 'custom');
expect(customGroup).toBeDefined();
expect(customGroup?.items).toHaveLength(1);
expect(customGroup?.items[0].title).toBe('找 HR 确认工位');
expect(customGroup?.items[0].done).toBe(false);
expect(checklist.total).toBe(TEMPLATE_TOTAL + 1);
expect(checklist.completed).toBe(TEMPLATE_DONE);
});
it('首尾空格被裁掉', async () => {
const checklist = await addChecklistItem(' 准备入职材料 ');
expect(findGroup(checklist, 'custom')?.items[0].title).toBe('准备入职材料');
});
it('标题只有空格时 reject,且不写入任何条目', async () => {
await expect(addChecklistItem(' ')).rejects.toThrow('待办内容不能为空');
const checklist = await fetchOnboardingChecklist();
expect(checklist.total).toBe(TEMPLATE_TOTAL);
});
it('自定义条目勾选后也落库(勾选状态存在另一份带标题的存档里)', async () => {
const added = await addChecklistItem('找 HR 确认工位');
const customId = findGroup(added, 'custom')?.items[0].id;
expect(customId).toBeDefined();
await toggleChecklistItem(customId as string, true);
const checklist = await fetchOnboardingChecklist();
expect(findItem(checklist, customId as string).done).toBe(true);
expect(checklist.completed).toBe(TEMPLATE_DONE + 1);
});
});
describe('removeChecklistItem(删除自定义待办)', () => {
it('删掉唯一一条自定义待办后,自定义分组整个消失且计数回落', async () => {
const added = await addChecklistItem('找 HR 确认工位');
const customId = findGroup(added, 'custom')?.items[0].id as string;
const checklist = await removeChecklistItem(customId);
expect(findGroup(checklist, 'custom')).toBeUndefined();
expect(checklist.total).toBe(TEMPLATE_TOTAL);
expect(checklist.groups.flatMap((g) => g.items).some((i) => i.id === customId)).toBe(false);
});
it('删掉已勾选的自定义待办后,勾选记录一并清掉(completed 不虚高)', async () => {
const added = await addChecklistItem('找 HR 确认工位');
const customId = findGroup(added, 'custom')?.items[0].id as string;
const checked = await toggleChecklistItem(customId, true);
expect(checked.completed).toBe(TEMPLATE_DONE + 1);
const checklist = await removeChecklistItem(customId);
expect(checklist.completed).toBe(TEMPLATE_DONE);
expect(checklist.total).toBe(TEMPLATE_TOTAL);
});
it('模板条目不允许删除(删了下次进页面又会出现)', async () => {
await expect(removeChecklistItem('m1')).rejects.toThrow('模板待办不能删除');
const checklist = await fetchOnboardingChecklist();
expect(checklist.total).toBe(TEMPLATE_TOTAL);
});
it('删除后不影响其余自定义条目', async () => {
const first = await addChecklistItem('找 HR 确认工位');
const firstId = findGroup(first, 'custom')?.items[0].id as string;
const second = await addChecklistItem('预约体检');
const kept = findGroup(second, 'custom')?.items.find((i) => i.id !== firstId);
expect(kept).toBeDefined();
const checklist = await removeChecklistItem(firstId);
const customGroup = findGroup(checklist, 'custom');
expect(customGroup?.items).toHaveLength(1);
expect(customGroup?.items[0].title).toBe('预约体检');
});
});
describe('buildChecklistText(导出文本,纯函数)', () => {
it('含标题行、进度行、分组名与勾选标记', async () => {
const checklist = await fetchOnboardingChecklist();
const text = buildChecklistText(checklist);
expect(text).toContain(`入职准备清单(${checklist.company} · ${checklist.position})`);
expect(text).toContain(`已完成 ${checklist.completed}/${checklist.total}`);
checklist.groups.forEach((group) => {
expect(text).toContain(`【${group.title}】`);
});
expect(text).toContain('[x] 身份证原件及复印件');
expect(text).toContain('[ ] 入职体检报告');
});
it('勾选标记与条目 done 一一对应,条数不缺不多', async () => {
const checklist = await fetchOnboardingChecklist();
const text = buildChecklistText(checklist);
const checked = (text.match(/^\[x\] /gm) ?? []).length;
const unchecked = (text.match(/^\[ \] /gm) ?? []).length;
expect(checked).toBe(checklist.completed);
expect(checked + unchecked).toBe(checklist.total);
});
it('自定义条目也会进导出文本', async () => {
const checklist = await addChecklistItem('找 HR 确认工位');
const text = buildChecklistText(checklist);
expect(text).toContain('【我的自定义待办】');
expect(text).toContain('[ ] 找 HR 确认工位');
});
});

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

240
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> = {}): Resume {
return {
score: { score: 80, maxScore: 100, optimizePoints: [] },
basicInfo: {
name: '张小满',
avatar: '张',
phone: '13800001111',
email: 'zhang@example.com',
location: '杭州',
},
jobIntention: {
position: '鸿蒙开发工程师',
salary: '20K-35K',
city: '杭州',
status: 'active',
statusLabel: '在职-月内到岗',
},
education: [],
experience: [],
projects: [],
skills: [],
selfEvaluation: { content: '' },
...patch,
};
}
describe('exportPageSize', () => {
it('A4 纵向 = 595×842 pt', () => {
expect(exportPageSize('A4', 'portrait')).toEqual({ width: 595, height: 842 });
});
it('A4 横向把长边给宽度', () => {
expect(exportPageSize('A4', 'landscape')).toEqual({ width: 842, height: 595 });
});
it('Letter 纵向 = 612×792 pt', () => {
expect(exportPageSize('letter', 'portrait')).toEqual({ width: 612, height: 792 });
});
});
describe('sanitizeFileName', () => {
it('剔除路径分隔符与系统保留字符', () => {
expect(sanitizeFileName('张小满/我的:简历*?.pdf')).toBe('张小满我的简历.pdf');
});
it('首尾空格被清掉', () => {
expect(sanitizeFileName(' 我的简历 ')).toBe('我的简历');
});
it('清完为空回落「我的简历」', () => {
expect(sanitizeFileName('///')).toBe('我的简历');
expect(sanitizeFileName(' ')).toBe('我的简历');
});
});
describe('buildResumeExportHtml', () => {
it('空板块整段略去(导出物里不写「未填写 X」)', () => {
const html = buildResumeExportHtml(makeResume(), SETTINGS);
expect(html).not.toContain('教育背景');
expect(html).not.toContain('工作经历');
expect(html).not.toContain('项目经历');
expect(html).not.toContain('技能特长');
expect(html).not.toContain('自我评价');
// 有内容的板块仍在
expect(html).toContain('求职意向');
expect(html).toContain('鸿蒙开发工程师');
});
it('隐藏联系方式时手机号与邮箱都不出现,但所在城市保留', () => {
const html = buildResumeExportHtml(makeResume(), { ...SETTINGS, hideContact: true });
expect(html).not.toContain('13800001111');
expect(html).not.toContain('zhang@example.com');
expect(html).toContain('杭州');
});
it('未隐藏联系方式时三者在同一行', () => {
const html = buildResumeExportHtml(makeResume(), SETTINGS);
expect(html).toContain('13800001111 · zhang@example.com · 杭州');
});
it('用户填的 HTML 被转义,不会破坏结构', () => {
const html = buildResumeExportHtml(
makeResume({
basicInfo: {
name: '<script>alert(1)</script>',
avatar: '',
phone: '',
email: '',
location: '',
},
}),
SETTINGS,
);
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
});
it('姓名与职位都为空时用「未设置」占位,不编造人名', () => {
const html = buildResumeExportHtml(
makeResume({
basicInfo: { name: '', avatar: '', phone: '', email: '', location: '' },
jobIntention: {
position: '',
salary: '',
city: '',
status: 'active',
statusLabel: '',
},
}),
SETTINGS,
);
expect(html).toContain('未设置');
expect(html).not.toContain('李明');
});
it('模板 id 非法时回落经典版式(不把 storage 里的值拼进 class)', () => {
const html = buildResumeExportHtml(makeResume(), {
...SETTINGS,
templateId: '"><script>x</script>',
});
expect(html).toContain('class="v-classic"');
expect(html).not.toContain('<script>x</script>');
});
it('模板强调色进入 HTML:创意设计用 info 色', () => {
const html = buildResumeExportHtml(makeResume(), { ...SETTINGS, templateId: 'creative' });
expect(html).toContain('class="v-creative"');
expect(html).toContain('border-bottom: 2px solid #7C5CFC');
});
it('封面页按开关生成', () => {
const without = buildResumeExportHtml(makeResume(), SETTINGS);
const withCover = buildResumeExportHtml(makeResume(), { ...SETTINGS, includeCover: true });
expect(without).not.toContain('<section class="cover">');
expect(withCover).toContain('<section class="cover">');
expect(withCover).toContain('<div class="cover-name">张小满</div>');
});
it('自荐信以自我评价为正文', () => {
const html = buildResumeExportHtml(
makeResume({ selfEvaluation: { content: '三年鸿蒙开发经验。' } }),
{ ...SETTINGS, includeRecommendation: true },
);
expect(html).toContain('自荐信');
expect(html).toContain('三年鸿蒙开发经验。');
});
it('自我评价为空时不生成自荐信页(不编造内容)', () => {
const html = buildResumeExportHtml(makeResume(), {
...SETTINGS,
includeRecommendation: true,
});
// 只能断言正文里没有自荐信区块:CSS 里三个版式的规则是恒定写死的
expect(html).not.toContain('自荐信');
expect(html).not.toContain('<section class="page letter">');
});
it('AI 徽标不出现在导出物里(那是 App 内的辅助信息)', () => {
const html = buildResumeExportHtml(
makeResume({
experience: [
{
id: 'exp-1',
company: '某某科技',
position: '前端工程师',
startDate: '2023年2月',
endDate: '至今',
description: '负责鸿蒙应用开发',
aiOptimized: true,
},
],
}),
SETTINGS,
);
expect(html).toContain('某某科技');
expect(html).toContain('2023年2月 - 至今');
expect(html).not.toContain('AI');
});
it('工作内容的多行换行不被压掉(white-space: pre-line)', () => {
const html = buildResumeExportHtml(
makeResume({
experience: [
{
id: 'exp-1',
company: '某某科技',
position: '',
startDate: '',
endDate: '',
description: '第一行\n第二行',
aiOptimized: false,
},
],
}),
SETTINGS,
);
expect(html).toContain('第一行\n第二行');
expect(html).toContain('white-space: pre-line');
});
it('教育条目的专业学历为空时不拼出半截分隔符', () => {
const html = buildResumeExportHtml(
makeResume({
education: [
{ id: 'edu-1', school: '某某大学', major: '', degree: '硕士', startYear: '2018', endYear: '' },
],
}),
SETTINGS,
);
expect(html).toContain('硕士');
expect(html).not.toContain('· 硕士');
expect(html).toContain('2018');
});
it('纸型与方向写进 @page / 页宽页高', () => {
const html = buildResumeExportHtml(makeResume(), {
...SETTINGS,
paperSize: 'letter',
orientation: 'landscape',
});
expect(html).toContain('max-width: 612pt; margin: 0 auto;');
});
});

363
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<string, string> = {};
return {
storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)),
storageSet: jest.fn(async (k: string, v: string) => {
store[k] = v;
}),
storageRemove: jest.fn(async (k: string) => {
delete store[k];
}),
};
});
const EMPTY_PROFILE: OnboardingProfile = {
identity: 'candidate',
isStudent: null,
city: '',
expectedPositions: [],
expectedSalaryMin: '',
expectedSalaryMax: '',
resumeName: '',
gender: null,
birthYear: '',
birthMonth: '',
jobStatus: null,
disabledJobseeker: false,
firstWorkYear: '',
firstWorkMonth: '',
currentPosition: null,
company: '',
joinYear: '',
joinMonth: '',
skills: [],
workContent: '',
education: null,
eduType: null,
school: '',
avatar: null,
recruiter: null,
};
const makeProfile = (patch: Partial<OnboardingProfile>): OnboardingProfile => ({
...EMPTY_PROFILE,
...patch,
});
/** 引导采集齐全的一份资料(与 resume-profile.test.ts 的 FULL_PROFILE 同源) */
const FULL_PROFILE = makeProfile({
city: '杭州',
expectedPositions: ['鸿蒙开发工程师'],
expectedSalaryMin: '20K',
expectedSalaryMax: '35K',
resumeName: '张小满',
jobStatus: '在职-月内到岗',
currentPosition: '高级前端工程师',
company: '某某科技有限公司',
joinYear: '2023年',
joinMonth: '2月',
skills: ['React', 'TypeScript'],
workContent: '负责招聘端前端架构与组件库建设',
education: '硕士',
eduType: '非全日制',
school: '某某大学',
});
describe('简历覆盖层:用户编辑优先于引导派生', () => {
beforeEach(async () => {
await clearOnboardingProfile();
await storageRemove('user:job-preference');
await clearResumeOverride();
await storageRemove('resume:export-settings');
});
it('未编辑任何板块时与引导派生完全一致(合并的默认分支是恒等变换)', async () => {
await saveOnboardingProfile(FULL_PROFILE);
const resume = await fetchResume();
expect(resume.basicInfo).toEqual({
name: '张小满',
avatar: '张',
phone: '',
email: '',
location: '',
});
expect(resume.education).toEqual([
{ id: 'edu-onboarding', school: '某某大学', major: '', degree: '硕士 非全日制', startYear: '', endYear: '' },
]);
expect(resume.experience).toEqual([
{
id: 'exp-onboarding',
company: '某某科技有限公司',
position: '高级前端工程师',
startDate: '2023年2月',
endDate: '至今',
description: '负责招聘端前端架构与组件库建设',
aiOptimized: false,
},
]);
expect(resume.skills).toEqual([{ name: 'React' }, { name: 'TypeScript' }]);
});
it('保存基本信息后以覆盖层为准,头像字仍由接口层按姓名派生', async () => {
await saveOnboardingProfile(FULL_PROFILE);
await saveResumeBasicInfo({
name: '王小二',
phone: '13800138000',
email: 'wang@example.com',
location: '上海',
});
const resume = await fetchResume();
expect(resume.basicInfo).toEqual({
name: '王小二',
avatar: '王',
phone: '13800138000',
email: 'wang@example.com',
location: '上海',
});
expect(JSON.stringify(resume)).not.toContain('张小满');
});
it('板块级独立:只编辑教育时,工作经历仍来自引导派生', async () => {
await saveOnboardingProfile(FULL_PROFILE);
await saveResumeEducation([{ id: 'edu-onboarding', school: '某某大学', major: '软件工程', degree: '硕士', startYear: '2018', endYear: '2021' }]);
const resume = await fetchResume();
expect(resume.education[0].major).toBe('软件工程');
expect(resume.experience[0].id).toBe('exp-onboarding');
expect(resume.skills).toEqual([{ name: 'React' }, { name: 'TypeScript' }]);
});
it('清空某个列表后不回落到引导资料(`??` 不是 `||`)', async () => {
await saveOnboardingProfile(FULL_PROFILE);
await saveResumeEducation([]);
const resume = await fetchResume();
expect(resume.education).toEqual([]);
// 未动过的板块不受影响
expect(resume.experience).toHaveLength(1);
});
it('编辑引导派生出来的那一条不会把整个板块改丢', async () => {
await saveOnboardingProfile(FULL_PROFILE);
const before = await fetchResume();
// 页面交回的就是它此刻看到的列表(含 id 为 edu-onboarding 的那条),只改学校
await saveResumeEducation(before.education.map((it) => ({ ...it, school: '某某大学(改名后)' })));
const resume = await fetchResume();
expect(resume.education).toHaveLength(1);
expect(resume.education[0].id).toBe('edu-onboarding');
expect(resume.education[0].school).toBe('某某大学(改名后)');
expect(resume.education[0].degree).toBe('硕士 非全日制');
});
it('新增条目由接口层补 id,同一毫秒连续新增两条也不会撞 id', async () => {
await saveOnboardingProfile(FULL_PROFILE);
const items = [
{ school: 'A 大学', major: '', degree: '本科', startYear: '2014', endYear: '2018' },
{ school: 'B 大学', major: '', degree: '硕士', startYear: '2018', endYear: '2021' },
];
const saved = await saveResumeEducation(items);
expect(saved.every((it) => it.id !== '')).toBe(true);
expect(saved[0].id).not.toBe(saved[1].id);
const resume = await fetchResume();
expect(resume.education.map((it) => it.school)).toEqual(['A 大学', 'B 大学']);
});
it('重复 id 会被去重(页面拼接出错也不会产生重复列表 key)', async () => {
await saveOnboardingProfile(FULL_PROFILE);
const saved = await saveResumeEducation([
{ id: 'edu-x', school: 'A 大学', major: '', degree: '', startYear: '', endYear: '' },
{ id: 'edu-x', school: 'B 大学', major: '', degree: '', startYear: '', endYear: '' },
]);
expect(saved).toHaveLength(1);
expect(saved[0].school).toBe('B 大学');
});
it('工作经历与项目经历同样走覆盖层', async () => {
await saveOnboardingProfile(FULL_PROFILE);
await saveResumeExperience([
{
id: 'exp-onboarding',
company: '某某科技有限公司',
position: '前端负责人',
startDate: '2023年2月',
endDate: '至今',
description: '负责组件库建设',
aiOptimized: false,
},
]);
await saveResumeProjects([{ name: '招聘端重构', role: '前端负责人', description: '首屏从 3.5s 降至 1.2s', aiOptimized: false }]);
const resume = await fetchResume();
expect(resume.experience[0].position).toBe('前端负责人');
expect(resume.projects[0].name).toBe('招聘端重构');
expect(resume.projects[0].id).not.toBe('');
});
it('技能保存在接口层规整:trim / 去空 / 去重', async () => {
await saveOnboardingProfile(FULL_PROFILE);
const saved = await saveResumeSkills([{ name: ' React ' }, { name: 'React' }, { name: '' }, { name: 'TS' }]);
expect(saved).toEqual([{ name: 'React' }, { name: 'TS' }]);
await expect(fetchResume()).resolves.toMatchObject({
skills: [{ name: 'React' }, { name: 'TS' }],
});
});
it('自我评价保存与清空', async () => {
await saveOnboardingProfile(FULL_PROFILE);
await saveResumeSelfEvaluation(' 3 年前端经验 ');
await expect(fetchResume()).resolves.toMatchObject({ selfEvaluation: { content: '3 年前端经验' } });
await saveResumeSelfEvaluation(' ');
await expect(fetchResume()).resolves.toMatchObject({ selfEvaluation: { content: '' } });
});
it('覆盖层存储损坏时按「未编辑过」处理,回落引导派生', async () => {
await saveOnboardingProfile(FULL_PROFILE);
await storageSet('resume:override', '{oops');
const resume = await fetchResume();
expect(resume.education[0].id).toBe('edu-onboarding');
});
it('完全没有引导资料时仍回落演示简历(保护既有行为)', async () => {
await clearOnboardingProfile();
const resume = await fetchResume();
expect(resume.basicInfo.name).toBe('李明');
});
});
describe('简历「保存到本机」', () => {
beforeEach(async () => {
await clearOnboardingProfile();
await clearResumeOverride();
});
it('从未保存过时保存时间为空串', async () => {
await expect(fetchResumeSavedAt()).resolves.toBe('');
});
it('保存后写入保存时间,且当前简历被固化(再改引导资料不再影响已保存的板块)', async () => {
await saveOnboardingProfile(FULL_PROFILE);
const resume = await fetchResume();
const { savedAt } = await saveResumeSnapshot(resume);
expect(savedAt).not.toBe('');
await expect(fetchResumeSavedAt()).resolves.toBe(savedAt);
// 固化后引导资料里的姓名变了,已保存的简历仍是保存那一刻的内容
await saveOnboardingProfile(makeProfile({ ...FULL_PROFILE, resumeName: '改过的名字' }));
await expect(fetchResume()).resolves.toMatchObject({ basicInfo: { name: '张小满' } });
});
it('退出登录会清掉覆盖层与保存时间(不让下一位用户看到上一位的编辑)', async () => {
await saveOnboardingProfile(FULL_PROFILE);
await saveResumeSnapshot(await fetchResume());
await clearResumeOverride();
await expect(fetchResumeSavedAt()).resolves.toBe('');
await expect(fetchResume()).resolves.toMatchObject({ basicInfo: { name: '张小满' } });
// 引导资料还在(clearResumeOverride 只清简历编辑,不动引导资料)
await expect(fetchResume()).resolves.toMatchObject({ education: [{ school: '某某大学' }] });
});
});
describe('AI 能力未接入时的诚实口径', () => {
beforeEach(async () => {
await clearOnboardingProfile();
await clearResumeOverride();
});
it('三个 AI 入口都返回 available:false,且调用前后简历完全不变', async () => {
await saveOnboardingProfile(FULL_PROFILE);
const before = await fetchResume();
const results = await Promise.all([
optimizeResumeExperience('exp-onboarding'),
polishResumeSelfEvaluation('随便写点什么'),
optimizeResumeWithJD('岗位 JD'),
]);
for (const result of results) {
expect(result.available).toBe(false);
expect(result.message).not.toBe('');
}
await expect(fetchResume()).resolves.toEqual(before);
});
it('一键优化保留用户编辑,且不给任何条目置「已优化」', async () => {
await saveOnboardingProfile(FULL_PROFILE);
await saveResumeEducation([{ id: 'edu-onboarding', school: '某某大学', major: '软件工程', degree: '硕士', startYear: '', endYear: '' }]);
const { resume, score } = await optimizeResume();
expect(score).toBe(92);
expect(resume.score.optimizePoints.every((p) => p.optimized)).toBe(true);
// 用户编辑被保留
expect(resume.education[0].major).toBe('软件工程');
// 正文没有被改写,就不该显示「已优化」徽标
expect(resume.experience.every((it) => !it.aiOptimized)).toBe(true);
expect(resume.projects.every((it) => !it.aiOptimized)).toBe(true);
});
});
describe('导出设置落 storage', () => {
const CUSTOM: ResumeExportSettings = {
format: 'pdf',
templateId: 'tech',
hideContact: true,
exportPassword: false,
paperSize: 'letter',
orientation: 'landscape',
fileName: '我的简历',
includeCover: true,
includeRecommendation: false,
};
beforeEach(async () => {
await storageRemove('resume:export-settings');
});
it('保存后可回读(不再只改模块内常量)', async () => {
await saveResumeExportSettings(CUSTOM);
await expect(fetchResumeExportSettings()).resolves.toEqual(CUSTOM);
});
it('旧数据缺后加的字段时用默认值补齐', async () => {
await storageSet('resume:export-settings', JSON.stringify({ format: 'word' }));
await expect(fetchResumeExportSettings()).resolves.toMatchObject({
format: 'word',
templateId: 'classic',
paperSize: 'A4',
orientation: 'portrait',
});
});
it('存储损坏时回落默认值', async () => {
await storageSet('resume:export-settings', '{oops');
await expect(fetchResumeExportSettings()).resolves.toMatchObject({ format: 'pdf', templateId: 'classic' });
});
});

245
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<string, string> = {};
return {
storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)),
storageSet: jest.fn(async (k: string, v: string) => {
store[k] = v;
}),
storageRemove: jest.fn(async (k: string) => {
delete store[k];
}),
};
});
/** 全部字段留空 / 为 null:等价于用户一路「跳过,直接进入」走到落地 */
const EMPTY_PROFILE: OnboardingProfile = {
identity: 'candidate',
isStudent: null,
city: '',
expectedPositions: [],
expectedSalaryMin: '',
expectedSalaryMax: '',
resumeName: '',
gender: null,
birthYear: '',
birthMonth: '',
jobStatus: null,
disabledJobseeker: false,
firstWorkYear: '',
firstWorkMonth: '',
currentPosition: null,
company: '',
joinYear: '',
joinMonth: '',
skills: [],
workContent: '',
education: null,
eduType: null,
school: '',
avatar: null,
recruiter: null,
};
const makeProfile = (patch: Partial<OnboardingProfile>): OnboardingProfile => ({
...EMPTY_PROFILE,
...patch,
});
/** 引导采集齐全的一份资料(在职,且填了学历/学校/公司/职位/入职时间/技能/工作内容) */
const FULL_PROFILE = makeProfile({
city: '杭州',
expectedPositions: ['鸿蒙开发工程师', '前端开发工程师'],
expectedSalaryMin: '20K',
expectedSalaryMax: '35K',
resumeName: '张小满',
jobStatus: '在职-月内到岗',
currentPosition: '高级前端工程师',
company: '某某科技有限公司',
joinYear: '2023年',
joinMonth: '2月',
skills: ['React', 'TypeScript', '鸿蒙'],
workContent: '负责招聘端前端架构与组件库建设',
education: '硕士',
eduType: '非全日制',
school: '某某大学',
});
describe('fetchResume 简历内容取自引导资料', () => {
beforeEach(async () => {
await clearOnboardingProfile();
await storageRemove('user:job-preference');
});
it('完全没有引导资料时才回落到演示简历', async () => {
const resume = await fetchResume();
expect(resume.basicInfo.name).toBe('李明');
expect(resume.education[0].school).toBe('北京大学');
});
it('有资料时不再出现演示简历的姓名 / 学历 / 公司', async () => {
await saveOnboardingProfile(FULL_PROFILE);
const resume = await fetchResume();
const serialized = JSON.stringify(resume);
expect(serialized).not.toContain('李明');
expect(serialized).not.toContain('北京大学');
expect(serialized).not.toContain('字节跳动');
expect(serialized).not.toContain('138-8888-8888');
});
it('一路跳过(资料全空)时各板块为空,不拿演示人设顶上', async () => {
await saveOnboardingProfile(EMPTY_PROFILE);
const resume = await fetchResume();
expect(resume.basicInfo).toEqual({
name: '',
avatar: '',
phone: '',
email: '',
location: '',
});
expect(resume.education).toEqual([]);
expect(resume.experience).toEqual([]);
expect(resume.projects).toEqual([]);
expect(resume.skills).toEqual([]);
expect(resume.selfEvaluation).toEqual({ content: '' });
expect(resume.jobIntention).toMatchObject({ position: '', salary: '', city: '', statusLabel: '' });
});
it('采集齐全时逐项映射到简历', async () => {
await saveOnboardingProfile(FULL_PROFILE);
const resume = await fetchResume();
// 姓名 + 头像字(头像字取姓名首字,与页面展示的姓名一致)
expect(resume.basicInfo.name).toBe('张小满');
expect(resume.basicInfo.avatar).toBe('张');
// 引导采集的是「期望城市」,不是「现居城市」,不拿它冒充所在城市
expect(resume.basicInfo.location).toBe('');
// 求职意向:期望职位取第 1 个(与「我的」页偏好卡片同一个值),薪资拼成区间
expect(resume.jobIntention.position).toBe('鸿蒙开发工程师');
expect(resume.jobIntention.salary).toBe('20K-35K');
expect(resume.jobIntention.city).toBe('杭州');
// 教育背景:学校 + 学历/学习形式;专业与年份引导未采集 → 留空
expect(resume.education).toEqual([
{
id: 'edu-onboarding',
school: '某某大学',
major: '',
degree: '硕士 非全日制',
startYear: '',
endYear: '',
},
]);
// 工作经历:在职 → 结束时间写「至今」
expect(resume.experience).toEqual([
{
id: 'exp-onboarding',
company: '某某科技有限公司',
position: '高级前端工程师',
startDate: '2023年2月',
endDate: '至今',
description: '负责招聘端前端架构与组件库建设',
aiOptimized: false,
},
]);
// 技能按填写顺序映射成标签
expect(resume.skills).toEqual([{ name: 'React' }, { name: 'TypeScript' }, { name: '鸿蒙' }]);
});
it('离职时不写「至今」(结束时间引导未采集,留空而不是编一个)', async () => {
await saveOnboardingProfile(
makeProfile({ company: '某某科技有限公司', jobStatus: '离职-随时到岗' }),
);
const resume = await fetchResume();
expect(resume.experience[0].endDate).toBe('');
});
it('只填了公司时,职位与工作内容留空(页面据此隐藏对应行)', async () => {
await saveOnboardingProfile(makeProfile({ company: '某某科技有限公司' }));
const resume = await fetchResume();
expect(resume.experience).toHaveLength(1);
expect(resume.experience[0].position).toBe('');
expect(resume.experience[0].description).toBe('');
});
it('没填公司时不产出工作经历条目(只有入职年月不构成一段经历)', async () => {
await saveOnboardingProfile(makeProfile({ joinYear: '2023年', joinMonth: '2月' }));
const resume = await fetchResume();
expect(resume.experience).toEqual([]);
});
it('期望薪资只填了一头时与偏好卡片一致为空(不留「20K-」这种半截值)', async () => {
await saveOnboardingProfile(makeProfile({ expectedSalaryMin: '20K' }));
const resume = await fetchResume();
expect(resume.jobIntention.salary).toBe('');
});
it('求职状态「在职-暂不考虑」映射为不积极,其余为积极', async () => {
await saveOnboardingProfile(makeProfile({ jobStatus: '在职-暂不考虑' }));
await expect(fetchResume()).resolves.toMatchObject({
jobIntention: { status: 'inactive', statusLabel: '在职-暂不考虑' },
});
await saveOnboardingProfile(makeProfile({ jobStatus: '离职-随时到岗' }));
await expect(fetchResume()).resolves.toMatchObject({
jobIntention: { status: 'active', statusLabel: '离职-随时到岗' },
});
});
it('求职意向读显式编辑过的偏好(与「我的」页偏好卡片同一个答案)', async () => {
await saveOnboardingProfile(FULL_PROFILE);
await saveJobPreference({
position: '电池工程师',
city: '宁德',
salary: '30K-50K',
workType: '全职',
status: '在职-考虑机会',
});
await expect(fetchResume()).resolves.toMatchObject({
jobIntention: {
position: '电池工程师',
city: '宁德',
salary: '30K-50K',
status: 'active',
statusLabel: '在职-考虑机会',
},
});
});
it('AI 优化后仍是用户自己的简历(不换成演示人设)', async () => {
await saveOnboardingProfile(FULL_PROFILE);
const { resume, score } = await optimizeResume();
expect(resume.basicInfo.name).toBe('张小满');
expect(resume.experience[0].company).toBe('某某科技有限公司');
expect(resume.education[0].school).toBe('某某大学');
// 优化只更新评分块:分数变化、优化点全部标记完成
expect(score).toBe(92);
expect(resume.score.optimizePoints.every((p) => p.optimized)).toBe(true);
// AI 改写的正文 mock 阶段生成不了,因此不替换用户真实填写的工作内容
expect(resume.experience[0].description).toBe('负责招聘端前端架构与组件库建设');
expect(JSON.stringify(resume)).not.toContain('李明');
});
it('未采集的板块恒为空:项目经历、自我评价、手机号、邮箱', async () => {
await saveOnboardingProfile(FULL_PROFILE);
const resume = await fetchResume();
expect(resume.projects).toEqual([]);
expect(resume.selfEvaluation.content).toBe('');
// 登录手机号属瞬时字段,按 PIPL 不落库,因此简历里也拿不到
expect(resume.basicInfo.phone).toBe('');
expect(resume.basicInfo.email).toBe('');
});
});

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

97
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<string, string> = {};
return {
storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)),
storageSet: jest.fn(async (k: string, v: string) => {
store[k] = v;
}),
storageRemove: jest.fn(async (k: string) => {
delete store[k];
}),
};
});
/** 全部字段留空 / 为 null:等价于用户一路「跳过,直接进入」走到落地 */
const EMPTY_PROFILE: OnboardingProfile = {
identity: 'candidate',
isStudent: null,
city: '',
expectedPositions: [],
expectedSalaryMin: '',
expectedSalaryMax: '',
resumeName: '',
gender: null,
birthYear: '',
birthMonth: '',
jobStatus: null,
disabledJobseeker: false,
firstWorkYear: '',
firstWorkMonth: '',
currentPosition: null,
company: '',
joinYear: '',
joinMonth: '',
skills: [],
workContent: '',
education: null,
eduType: null,
school: '',
avatar: null,
recruiter: null,
};
const makeProfile = (patch: Partial<OnboardingProfile>): OnboardingProfile => ({
...EMPTY_PROFILE,
...patch,
});
describe('fetchUserProfile 我的页资料取自引导采集', () => {
beforeEach(async () => {
await clearOnboardingProfile();
});
it('完全没有引导资料时才回落到演示资料', async () => {
const profile = await fetchUserProfile();
expect(profile.name).toBe('李明');
expect(profile.avatarKey).toBeNull();
});
it('有资料时姓名取采集值、头像取姓名首字、简介只拼采集项', async () => {
await saveOnboardingProfile(
makeProfile({ resumeName: '张小满', education: '硕士', city: '杭州' }),
);
const profile = await fetchUserProfile();
expect(profile.name).toBe('张小满');
expect(profile.avatar).toBe('张');
expect(profile.bio).toBe('硕士·杭州');
});
it('一路跳过(资料全空)时不显示演示人设的姓名与简介', async () => {
await saveOnboardingProfile(EMPTY_PROFILE);
const profile = await fetchUserProfile();
expect(profile.name).toBe('');
expect(profile.avatar).toBe('');
expect(profile.bio).toBe('');
});
it('头像原始值原样透传:预设素材 key', async () => {
await saveOnboardingProfile(makeProfile({ avatar: 'avatarPreset3' }));
await expect(fetchUserProfile()).resolves.toMatchObject({ avatarKey: 'avatarPreset3' });
});
it('头像原始值原样透传:拍照 / 相册得到的本地图片 URI', async () => {
await saveOnboardingProfile(makeProfile({ avatar: 'file:///data/picked.jpg' }));
await expect(fetchUserProfile()).resolves.toMatchObject({
avatarKey: 'file:///data/picked.jpg',
});
});
it('未采集头像时为 null(页面回落到姓名首字展示)', async () => {
await saveOnboardingProfile(makeProfile({ resumeName: '张小满' }));
await expect(fetchUserProfile()).resolves.toMatchObject({ avatarKey: null });
});
});

91
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', () => { describe('formatThousands', () => {
it('千分位格式化 12846 → "12,846"', () => { it('千分位格式化 12846 → "12,846"', () => {
@ -29,3 +38,83 @@ describe('formatViews', () => {
expect(formatViews(8500)).toBe('8500'); 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);
});
});

1781
src/api/api.ts

File diff suppressed because it is too large

35
src/api/index.ts

@ -26,6 +26,7 @@ export {
sendChatMessage, sendChatMessage,
fetchEnterpriseCertification, fetchEnterpriseCertification,
fetchEnterpriseCommunication, fetchEnterpriseCommunication,
filterEnterpriseChats,
fetchEnterpriseOverview, fetchEnterpriseOverview,
fetchGameLevels, fetchGameLevels,
fetchGameProgress, fetchGameProgress,
@ -36,6 +37,7 @@ export {
fetchJobManagement, fetchJobManagement,
fetchJobPublishForm, fetchJobPublishForm,
fetchTalentRecommendation, fetchTalentRecommendation,
filterTalentCandidates,
fetchLegalBrand, fetchLegalBrand,
fetchLegalConversation, fetchLegalConversation,
fetchLegalFAQs, fetchLegalFAQs,
@ -55,6 +57,7 @@ export {
fetchFavorites, fetchFavorites,
fetchHistory, fetchHistory,
fetchOfferCompareData, fetchOfferCompareData,
fetchPendingOfferCount,
fetchJobOverallScore, fetchJobOverallScore,
fetchJobPreference, fetchJobPreference,
saveJobPreference, saveJobPreference,
@ -64,6 +67,16 @@ export {
fetchNotifications, fetchNotifications,
fetchNotificationSettings, fetchNotificationSettings,
fetchOnboardingChecklist, fetchOnboardingChecklist,
addChecklistItem,
removeChecklistItem,
buildChecklistText,
fetchOnboardingProfile,
saveOnboardingProfile,
clearOnboardingProfile,
toOnboardingProfile,
fromOnboardingProfile,
salaryMaxOptions,
applySalaryMin,
fetchOperationRules, fetchOperationRules,
fetchOverallScore, fetchOverallScore,
fetchPrivacySettings, fetchPrivacySettings,
@ -90,6 +103,10 @@ export {
loginByPhone, loginByPhone,
loginByPassword, loginByPassword,
loginByCarrier, loginByCarrier,
fetchCarrierPhone,
maskPhone,
fetchCacheSize,
clearAppCache,
sendSmsCode, sendSmsCode,
resetPassword, resetPassword,
changePassword, changePassword,
@ -104,26 +121,44 @@ export {
fetchRankData, fetchRankData,
fetchResume, fetchResume,
fetchResumeExportSettings, fetchResumeExportSettings,
fetchResumeSavedAt,
clearResumeOverride,
fetchSalaryBrackets, fetchSalaryBrackets,
fetchSalaryData, fetchSalaryData,
fetchSalaryRecords, fetchSalaryRecords,
fetchSalaryToolLinks,
saveSalaryRecord, saveSalaryRecord,
deleteSalaryRecord, deleteSalaryRecord,
fetchSkillCloud, fetchSkillCloud,
fetchSkillLearningPath,
filterSkillWords,
fetchUserProfile, fetchUserProfile,
fetchUserStats, fetchUserStats,
fetchWeightSettings, fetchWeightSettings,
inviteCandidate, inviteCandidate,
estimateJobQuality, estimateJobQuality,
optimizeResume, optimizeResume,
optimizeResumeExperience,
optimizeResumeWithJD,
polishResumeSelfEvaluation,
publishJob, publishJob,
replyJobReview, replyJobReview,
saveOperationRules, saveOperationRules,
saveResumeBasicInfo,
saveResumeEducation,
saveResumeExperience,
saveResumeExportSettings, saveResumeExportSettings,
saveResumeProjects,
saveResumeSelfEvaluation,
saveResumeSkills,
saveResumeSnapshot,
exportResume, exportResume,
saveWeightSettings, saveWeightSettings,
submitEnterpriseCertification, submitEnterpriseCertification,
submitInterviewScore, submitInterviewScore,
saveInterviewScoreDraft,
fetchInterviewScoreDraft,
clearInterviewScoreDraft,
computeSalaryResult, computeSalaryResult,
resetGameProgress, resetGameProgress,
sendLegalMessage, sendLegalMessage,

195
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; 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 ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** 拍照 / 相册未授权时的提示:权限是用户的选择,需要告诉他去哪里改,而不是静默失败 */
export const IMAGE_PICK_DENIED_HINT = '未获得权限,请在系统设置中开启相机 / 相册权限后重试';
/**
* 当前年份。滚轮可选年份等以「今年」为起点生成,
* 写死字面量会在跨年时悄悄过期,故取系统时间。
*/
export const CURRENT_YEAR = new Date().getFullYear();
/** 登录令牌有效期(毫秒),与后端 JWT 过期时间保持一致 */ /** 登录令牌有效期(毫秒),与后端 JWT 过期时间保持一致 */
export const TOKEN_TTL_MS = 30 * ONE_DAY_MS; export const TOKEN_TTL_MS = 30 * ONE_DAY_MS;
/* ===================== 运营商一键登录(本机号码认证) ===================== */
/**
* 运营商取号令牌有效期(毫秒)。
* 运营商下发的鉴权令牌是一次性的、短时效(业界普遍 5 分钟),
* 超时后必须重新取号 —— 页面在超时时把「一键登录」置灰并给出重新取号入口。
*/
export const CARRIER_TOKEN_TTL_MS = 5 * 60 * 1000;
/**
* 认证服务提供方 → 展示名。
* 取号接口返回哪家就用哪家,页面不写死「中国移动」。
*/
export const CARRIER_LABEL: Record<CarrierName, string> = {
cmcc: '中国移动',
cucc: '中国联通',
ctcc: '中国电信',
unknown: '运营商',
};
/* ===================== 登录密码规则 ===================== */ /* ===================== 登录密码规则 ===================== */
/** 登录密码最小长度(含) */ /** 登录密码最小长度(含) */
@ -85,3 +120,161 @@ export const PASSWORD_RULE_LABEL: Record<PasswordRuleError, string> = {
mismatch: '两次输入的密码不一致', mismatch: '两次输入的密码不一致',
'same-as-old': '新密码不能与原密码相同', '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 失败,导出功能需后端导出服务支持';

5
src/hooks/index.ts

@ -6,6 +6,7 @@ export { useAboutInfo } from './useAboutInfo';
export { useAccountCancel } from './useAccountCancel'; export { useAccountCancel } from './useAccountCancel';
export { useAccountSecurity } from './useAccountSecurity'; export { useAccountSecurity } from './useAccountSecurity';
export { useAgreementGate, type AgreementGate } from './useAgreementGate'; export { useAgreementGate, type AgreementGate } from './useAgreementGate';
export { useImagePick, type UseImagePick } from './useImagePick';
export { useAIInterview } from './useAIInterview'; export { useAIInterview } from './useAIInterview';
export { useAppealForm } from './useAppealForm'; export { useAppealForm } from './useAppealForm';
export { useAsyncData } from './useAsyncData'; export { useAsyncData } from './useAsyncData';
@ -45,6 +46,8 @@ export { useMessageData } from './useMessageData';
export { useNotificationSettings } from './useNotificationSettings'; export { useNotificationSettings } from './useNotificationSettings';
export { useOfferCompareData } from './useOfferCompareData'; export { useOfferCompareData } from './useOfferCompareData';
export { useOnboardingChecklist } from './useOnboardingChecklist'; export { useOnboardingChecklist } from './useOnboardingChecklist';
export { useCarrierPhone } from './useCarrierPhone';
export { useAppCache } from './useAppCache';
export { useOperationRules } from './useOperationRules'; export { useOperationRules } from './useOperationRules';
export { usePasswordLogin } from './usePasswordLogin'; export { usePasswordLogin } from './usePasswordLogin';
export { usePasswordReset } from './usePasswordReset'; export { usePasswordReset } from './usePasswordReset';
@ -60,7 +63,9 @@ export { useResumeExport } from './useResumeExport';
export { useSalaryBrackets } from './useSalaryBrackets'; export { useSalaryBrackets } from './useSalaryBrackets';
export { useSalaryCalculator } from './useSalaryCalculator'; export { useSalaryCalculator } from './useSalaryCalculator';
export { useSalaryRecords } from './useSalaryRecords'; export { useSalaryRecords } from './useSalaryRecords';
export { useSalaryToolLinks } from './useSalaryToolLinks';
export { useSkillCloud } from './useSkillCloud'; export { useSkillCloud } from './useSkillCloud';
export { useSkillLearningPath } from './useSkillLearningPath';
export { useTalentRecommendation } from './useTalentRecommendation'; export { useTalentRecommendation } from './useTalentRecommendation';
export { useWeightSettings } from './useWeightSettings'; export { useWeightSettings } from './useWeightSettings';
export { useLogin } from './useLogin'; export { useLogin } from './useLogin';

50
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<number>;
/** 清理进行中(防重复点击) */
clearing: boolean;
}
export function useAppCache(): UseAppCacheReturn {
const fetcher = useCallback(() => fetchCacheSize(), []);
const { data, loading, error, refresh } = useAsyncData(fetcher);
const [clearing, setClearing] = useState(false);
const clear = useCallback(async () => {
setClearing(true);
try {
const freed = await clearAppCache();
// 清完重新读一次,避免页面还挂着清理前的数字
refresh();
return freed;
} finally {
setClearing(false);
}
}, [refresh]);
return {
label: data === null ? '' : formatBytes(data),
loading,
error,
refresh,
clear,
clearing,
};
}

18
src/hooks/useAppealForm.ts

@ -15,6 +15,8 @@ export function useAppealForm() {
const [dimensions, setDimensions] = useState<string[]>([]); const [dimensions, setDimensions] = useState<string[]>([]);
const [reason, setReason] = useState<AppealReason | null>(null); const [reason, setReason] = useState<AppealReason | null>(null);
const [reasonText, setReasonText] = useState(''); const [reasonText, setReasonText] = useState('');
/** 证据图片:本机 URI 列表(后端接入上传接口后改为返回的图片 URL) */
const [evidence, setEvidence] = useState<string[]>([]);
const [contactPhone, setContactPhone] = useState(''); const [contactPhone, setContactPhone] = useState('');
const [contactEmail, setContactEmail] = useState(''); const [contactEmail, setContactEmail] = useState('');
const [agree, setAgree] = useState(false); const [agree, setAgree] = useState(false);
@ -31,6 +33,16 @@ export function useAppealForm() {
setDimensions((prev) => (prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id])); 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 () => { const submit = useCallback(async () => {
if (!data) return null; if (!data) return null;
setSubmitting(true); setSubmitting(true);
@ -44,6 +56,7 @@ export function useAppealForm() {
})), })),
reason: reason ?? data.reason, reason: reason ?? data.reason,
reasonText, reasonText,
evidence,
contactPhone, contactPhone,
contactEmail, contactEmail,
agree, agree,
@ -52,7 +65,7 @@ export function useAppealForm() {
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}, [data, type, dimensions, reason, reasonText, contactPhone, contactEmail, agree]); }, [data, type, dimensions, reason, reasonText, evidence, contactPhone, contactEmail, agree]);
return { return {
form: data ?? null, form: data ?? null,
@ -67,6 +80,9 @@ export function useAppealForm() {
setReason, setReason,
reasonText, reasonText,
setReasonText, setReasonText,
evidence,
addEvidence,
removeEvidence,
contactPhone, contactPhone,
setContactPhone, setContactPhone,
contactEmail, contactEmail,

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

33
src/hooks/useEnterpriseCommunication.ts

@ -1,17 +1,40 @@
/** /**
* 企业端沟通数据 hook。 * 企业端沟通数据 hook。
* Tab 与搜索词的状态放在这里:过滤规则由接口层(filterEnterpriseChats)给出,
* 页面只负责把用户点击/输入转成 setTab / setKeyword,再渲染 chats。
*/ */
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { fetchEnterpriseCommunication } from '../api'; import { fetchEnterpriseCommunication, filterEnterpriseChats } from '../api';
import type { EnterpriseCommunication } from '../types'; import type { EnterpriseChatItem, EnterpriseCommunication } from '../types';
import { useAsyncData } from './useAsyncData'; import { useAsyncData } from './useAsyncData';
export function useEnterpriseCommunication() { export function useEnterpriseCommunication() {
const fetcher = useCallback(async (): Promise<EnterpriseCommunication> => fetchEnterpriseCommunication(), []); const fetcher = useCallback(
async (): Promise<EnterpriseCommunication> => fetchEnterpriseCommunication(),
[],
);
const { data, loading, error, refresh } = useAsyncData(fetcher); 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 { return {
communication: data ?? null, communication,
activeTab,
setActiveTab,
keyword,
setKeyword,
chats,
isEmptySearch,
loading, loading,
error, error,
refresh, refresh,

42
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<ImagePickResult>;
}
export function useImagePick(): UseImagePick {
const [picking, setPicking] = useState(false);
const [error, setError] = useState<string | null>(null);
const pick = useCallback(
async (source: ImagePickSource, options?: ImagePickOptions): Promise<ImagePickResult> => {
setPicking(true);
setError(null);
try {
const result = await pickImage(source, options);
// 用户主动取消不是错误,不提示;未授权才需要指引他去系统设置
setError(result.status === 'denied' ? IMAGE_PICK_DENIED_HINT : null);
return result;
} finally {
setPicking(false);
}
},
[],
);
return { picking, error, pick };
}

67
src/hooks/useInterviewScore.ts

@ -2,7 +2,13 @@
* 面试评分 hook:拉取评分数据 + 本地编辑 + 提交评分。 * 面试评分 hook:拉取评分数据 + 本地编辑 + 提交评分。
*/ */
import { useCallback, useEffect, useState } from 'react'; 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 type { HireSuggestion, InterviewScoreData } from '../types';
import { useAsyncData } from './useAsyncData'; import { useAsyncData } from './useAsyncData';
@ -12,6 +18,11 @@ export function useInterviewScore(interviewId: string) {
const [score, setScore] = useState<InterviewScoreData | null>(null); const [score, setScore] = useState<InterviewScoreData | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
/** 上次保存草稿的时间(ISO);null 表示还没保存过 */
const [draftSavedAt, setDraftSavedAt] = useState<string | null>(null);
const [savingDraft, setSavingDraft] = useState(false);
/** 本次进入页面时发现的历史草稿(提示用户可恢复);恢复或放弃后清空 */
const [pendingDraft, setPendingDraft] = useState<InterviewScoreData | null>(null);
useEffect(() => { useEffect(() => {
if (data && !score) { if (data && !score) {
@ -19,6 +30,20 @@ export function useInterviewScore(interviewId: string) {
} }
}, [data, score]); }, [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) => { const updateDimensionScore = useCallback((id: string, value: number) => {
setScore((prev) => { setScore((prev) => {
if (!prev) return prev; if (!prev) return prev;
@ -52,12 +77,44 @@ export function useInterviewScore(interviewId: string) {
if (!score) return null; if (!score) return null;
setSubmitting(true); setSubmitting(true);
try { try {
return await submitInterviewScore(interviewId, score); const result = await submitInterviewScore(interviewId, score);
// 提交成功后再清草稿:先清后提交的话,提交失败就把用户的草稿弄丢了
await clearInterviewScoreDraft(interviewId);
setDraftSavedAt(null);
setPendingDraft(null);
return result;
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}, [interviewId, score]); }, [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 { return {
score, score,
loading, loading,
@ -69,5 +126,11 @@ export function useInterviewScore(interviewId: string) {
selectHireSuggestion, selectHireSuggestion,
submit, submit,
submitting, submitting,
saveDraft,
savingDraft,
restoreDraft,
discardDraft,
hasPendingDraft: pendingDraft !== null,
draftSavedAt,
}; };
} }

9
src/hooks/useLogin.ts

@ -4,8 +4,11 @@ import { loginByCarrier, loginByPhone, type LoginResult } from '../api';
export interface UseLoginReturn { export interface UseLoginReturn {
/** 手机号 + 验证码登录,成功返回 LoginResult */ /** 手机号 + 验证码登录,成功返回 LoginResult */
login: (phone: string, code: string) => Promise<LoginResult>; login: (phone: string, code: string) => Promise<LoginResult>;
/** 运营商一键登录(本机号码认证),成功返回 LoginResult */ /**
loginByCarrier: () => Promise<LoginResult>; * 运营商一键登录(本机号码认证),成功返回 LoginResult。
* @param carrierToken 取号 hook(useCarrierPhone)拿到的鉴权令牌;传空串必然失败
*/
loginByCarrier: (carrierToken: string) => Promise<LoginResult>;
/** 登录进行中 */ /** 登录进行中 */
loading: boolean; loading: boolean;
/** 登录失败的错误(用于页面提示) */ /** 登录失败的错误(用于页面提示) */
@ -42,7 +45,7 @@ export function useLogin(): UseLoginReturn {
); );
const carrierLogin = useCallback( const carrierLogin = useCallback(
() => run(() => loginByCarrier(), '一键登录失败,请稍后重试'), (carrierToken: string) => run(() => loginByCarrier(carrierToken), '一键登录失败,请稍后重试'),
[run], [run],
); );

10
src/hooks/useMessageData.ts

@ -6,18 +6,21 @@ import {
fetchInterviews, fetchInterviews,
fetchMatchResult, fetchMatchResult,
fetchNotifications, fetchNotifications,
fetchPendingOfferCount,
} from '../api'; } from '../api';
import { useAsyncData } from './useAsyncData'; import { useAsyncData } from './useAsyncData';
/** 聚合拉取消息中心全部数据(模块级函数,引用稳定,避免 useAsyncData 反复触发) */ /** 聚合拉取消息中心全部数据(模块级函数,引用稳定,避免 useAsyncData 反复触发) */
async function fetchMessageData() { async function fetchMessageData() {
const [conversations, notifications, interviews, matchResult] = await Promise.all([ const [conversations, notifications, interviews, matchResult, pendingOfferCount] =
await Promise.all([
fetchConversations(), fetchConversations(),
fetchNotifications(), fetchNotifications(),
fetchInterviews(), fetchInterviews(),
fetchMatchResult(), fetchMatchResult(),
fetchPendingOfferCount(),
]); ]);
return { conversations, notifications, interviews, matchResult }; return { conversations, notifications, interviews, matchResult, pendingOfferCount };
} }
export function useMessageData() { export function useMessageData() {
@ -27,6 +30,8 @@ export function useMessageData() {
const notifications = data?.notifications ?? []; const notifications = data?.notifications ?? [];
const interviews = data?.interviews ?? []; const interviews = data?.interviews ?? [];
const matchResult = data?.matchResult ?? null; const matchResult = data?.matchResult ?? null;
/** 待处理 Offer 数:为 0 时聊天页不渲染横幅,不再显示写死的「2 个」 */
const pendingOfferCount = data?.pendingOfferCount ?? 0;
// 空状态:加载完成且无错误、但三类列表均为空 // 空状态:加载完成且无错误、但三类列表均为空
const isEmpty = const isEmpty =
!loading && !loading &&
@ -40,6 +45,7 @@ export function useMessageData() {
notifications, notifications,
interviews, interviews,
matchResult, matchResult,
pendingOfferCount,
loading, loading,
error, error,
refresh, refresh,

32
src/hooks/useOnboardingChecklist.ts

@ -2,7 +2,12 @@
* 入职准备清单 hook:拉取清单 + 切换待办完成状态。 * 入职准备清单 hook:拉取清单 + 切换待办完成状态。
*/ */
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { fetchOnboardingChecklist, toggleChecklistItem } from '../api'; import {
addChecklistItem,
fetchOnboardingChecklist,
removeChecklistItem,
toggleChecklistItem,
} from '../api';
import type { OnboardingChecklist } from '../types'; import type { OnboardingChecklist } from '../types';
import { useAsyncData } from './useAsyncData'; import { useAsyncData } from './useAsyncData';
@ -10,6 +15,7 @@ export function useOnboardingChecklist() {
const fetcher = useCallback(() => fetchOnboardingChecklist(), []); const fetcher = useCallback(() => fetchOnboardingChecklist(), []);
const { data, loading, error, refresh } = useAsyncData<OnboardingChecklist>(fetcher); const { data, loading, error, refresh } = useAsyncData<OnboardingChecklist>(fetcher);
const [togglingId, setTogglingId] = useState<string | null>(null); const [togglingId, setTogglingId] = useState<string | null>(null);
const [mutating, setMutating] = useState(false);
const toggle = useCallback( const toggle = useCallback(
async (itemId: string, done: boolean) => { 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 { return {
checklist: data ?? null, checklist: data ?? null,
loading, loading,
@ -30,5 +56,9 @@ export function useOnboardingChecklist() {
refresh, refresh,
toggle, toggle,
togglingId, togglingId,
addItem,
removeItem,
/** 增删进行中(防重复点击) */
mutating,
}; };
} }

178
src/hooks/useOnboardingFlow.tsx

@ -1,76 +1,59 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { createContext, useContext, useMemo, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { AssetName } from '../../components/onboarding/assets'; import {
clearOnboardingProfile,
clearResumeOverride,
fetchOnboardingProfile,
fromOnboardingProfile,
saveOnboardingProfile,
toOnboardingProfile,
} from '../api';
import type { OnboardingData } from '../types';
/** 注册全流程收集的数据模型 */
export interface OnboardingData {
phone: string;
code: string;
agreed: boolean;
/** /**
* 微信登录带入的临时凭证。 * 引导流程起始状态:全部为「未采集」态。
* 仅「微信第一次登录」链路有值:微信授权后 openid 未绑定账号, *
* 需走手机号 + 验证码完成注册,后端据此把 openid 与新账号关联。 * 这里曾预填一份演示人设(杨幸幸 / 上海 / 前端开发工程师 / 本科…),后果是
* 手机号登录等其他链路为 null,此时 P2 页按普通登录处理。 * **用户全程不操作也能一路点到落地**,而采集到的「资料」没有一项是他选的。
* 清空后每页进入即未选态,未填的步骤由各页的 `active` 门槛拦住。
*
* 注意 `''` / `null` 与「用户选了空值」不可区分,所以各页的空值兜底必须同时到位,
* 尤其 P8 / P10 / P13 的 YearMonthWheel:它此前把 `''` 钳到索引 0,
* 滚轮显示「2026年」而 state 仍是空串 —— 现在改为「未选中态 + 请滑动选择」。
*/ */
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;
}
const INITIAL: OnboardingData = { const INITIAL: OnboardingData = {
phone: '', phone: '',
code: '', code: '',
agreed: true, // 协议必须由用户主动勾选:预勾选不构成《个人信息保护法》要求的「明示同意」
agreed: false,
wechatTicket: null, wechatTicket: null,
identity: null, identity: null,
isStudent: null, isStudent: null,
city: '上海', city: '',
expectedPositions: ['前端开发工程师', '鸿蒙开发工程师', '全栈工程师'], expectedPositions: [],
expectedSalaryMin: '10K', expectedSalaryMin: '',
expectedSalaryMax: '12K', expectedSalaryMax: '',
resumeName: '杨幸幸', resumeName: '',
gender: '女', gender: null,
birthYear: '2004年', birthYear: '',
birthMonth: '12月', birthMonth: '',
jobStatus: '在职-月内到岗', jobStatus: null,
disabledJobseeker: false, disabledJobseeker: false,
firstWorkYear: '2026年', firstWorkYear: '',
firstWorkMonth: '4月', firstWorkMonth: '',
currentPosition: null, currentPosition: null,
company: '巴适伙伴(上海)科技有限公司', company: '',
joinYear: '2026年', joinYear: '',
joinMonth: '7月', joinMonth: '',
skills: [], skills: [],
workContent: '', workContent: '',
education: '本科', education: null,
eduType: '全日制', eduType: null,
school: '', school: '',
avatar: null, avatar: null,
recruiterCompany: '', recruiterCompany: '',
recruiterContact: '',
recruiterLicenseUri: null,
}; };
interface Ctx { interface Ctx {
@ -79,11 +62,9 @@ interface Ctx {
current: string; current: string;
navigate: (screen: string) => void; navigate: (screen: string) => void;
back: () => void; back: () => void;
canGoBack: boolean; /** 立即把已采集资料落库(落地前调用,避免最后一步的输入随页面卸载丢失) */
/** 两个保存出口:收集当前信息后直接进 P23,剩余资料后续补充 */ flushSave: () => Promise<void>;
saveAndEnter: () => void;
reset: () => void; reset: () => void;
meta: { time: string; battery: number };
} }
const OnboardingContext = createContext<Ctx | null>(null); const OnboardingContext = createContext<Ctx | null>(null);
@ -93,29 +74,73 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
const [stack, setStack] = useState<string[]>([]); const [stack, setStack] = useState<string[]>([]);
const [current, setCurrent] = useState<string>('P1'); const [current, setCurrent] = useState<string>('P1');
/**
* state 的同步镜像。
*
* 落库发生在导航时,而导航与 setData 可能处在同一个事件里(如 P5City 的
* `pick()`:`setData({ city })` 紧跟 `back()`)。此时 state 尚未提交,
* 闭包里的 data 还是旧值,只有从镜像读才拿得到刚写入的内容。
*/
const dataRef = useRef<OnboardingData>(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<Ctx>(() => { const value = useMemo<Ctx>(() => {
const setData = (partial: Partial<OnboardingData>) => const setData = (partial: Partial<OnboardingData>) =>
setDataState((prev) => ({ ...prev, ...partial })); commitData({ ...dataRef.current, ...partial });
const navigate = (screen: string) => { const navigate = (screen: string) => {
setStack((s) => [...s, current]); void flushSave();
setCurrent(screen);
};
const back = () => {
setStack((s) => { setStack((s) => {
if (s.length === 0) return s; const at = s.indexOf(screen);
const prev = s[s.length - 1]; // 目标已在历史里(如 P17「采用并填写」回到 P16):截断回它,不再压栈。
setCurrent(prev); // 否则同一屏在栈里出现两次,返回时先退到「未来」的那一屏 —— P16/P17 互跳后
return s.slice(0, -1); // 在 P16 按返回会退到 P17,看起来像返回键失灵
if (at >= 0) return s.slice(0, at);
return [...s, current];
}); });
setCurrent(screen);
}; };
const saveAndEnter = () => { const back = () => {
setStack([]); void flushSave();
setCurrent('P23'); if (stack.length === 0) return;
// 不在 setStack 的更新函数里做副作用(StrictMode 下更新函数会被调用两次)
setCurrent(stack[stack.length - 1]);
setStack((s) => s.slice(0, -1));
}; };
const reset = () => { const reset = () => {
setDataState(INITIAL); commitData(INITIAL);
setStack([]); setStack([]);
setCurrent('P1'); setCurrent('P1');
// 退出登录:连持久化资料一起清掉,否则下次挂载会把上一位用户的资料水合回来
void clearOnboardingProfile();
// 简历的用户编辑同样按用户隔离:不清掉会让下一位用户看到上一位改过的简历
void clearResumeOverride();
}; };
return { return {
data, data,
@ -123,13 +148,10 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
current, current,
navigate, navigate,
back, back,
canGoBack: stack.length > 0, flushSave,
saveAndEnter,
reset, reset,
// 已停用:原假状态栏时间线演示数据,现统一使用系统真实状态栏,保留字段仅为兼容旧调用
meta: { time: '', battery: 0 },
}; };
}, [data, stack, current]); }, [data, stack, current, commitData, flushSave]);
return <OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>; return <OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>;
} }

9
src/hooks/useRegisterFinish.ts

@ -2,9 +2,10 @@ import { useRouter } from 'expo-router';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useRole } from '../context/RoleContext'; import { useRole } from '../context/RoleContext';
import type { AppRole } from '../types'; import type { AppRole } from '../types';
import { useOnboarding } from './useOnboardingFlow';
/** /**
* 注册完成后的真实落地:写入登录态与身份,跳转对应端主界面。 * 注册完成后的真实落地:先把采集到的资料落库,再写入登录态与身份,跳转对应端主界面。
* 真实后端就绪后,login 的 token 替换为接口返回; * 真实后端就绪后,login 的 token 替换为接口返回;
* setRole 用于让 index.tsx 漏斗放行,避免注册完成又被弹回注册。 * setRole 用于让 index.tsx 漏斗放行,避免注册完成又被弹回注册。
*/ */
@ -12,8 +13,12 @@ export function useRegisterFinish() {
const router = useRouter(); const router = useRouter();
const { login } = useAuth(); const { login } = useAuth();
const { setRole } = useRole(); const { setRole } = useRole();
const { flushSave } = useOnboarding();
return (role: AppRole) => { return async (role: AppRole) => {
// 落地前落库:最后一步(如 B3 的公司名/联系人)不会再有下一次跳转,
// 不在这里提交就会随页面卸载一起丢掉
await flushSave();
setRole(role); setRole(role);
login(`mock-token-${Date.now()}`); login(`mock-token-${Date.now()}`);
router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs');

141
src/hooks/useResume.ts

@ -1,25 +1,152 @@
/** /**
* 简历数据 hook:拉取简历 + AI 一键优化。 * 简历数据 hook:取简历 + 按板块保存 + AI 动作。
*
* 写操作一律「落库 → 重新取数」,不做本地乐观更新:`youzhi:resume:override` 是唯一真相源,
* 页面拿到的永远是落库后的结果(与 `useSalaryRecords` 同一套约定)。
*/ */
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { fetchResume, optimizeResume } from '../api'; import {
import type { Resume } from '../types'; 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'; import { useAsyncData } from './useAsyncData';
/**
* 简历与保存时间一起取。
*
* 分成两个 `useAsyncData` 实例会出现「简历已刷新、保存时间还是旧的」的错位,
* 且同一页面两处取数各自维护 loading/error,四态渲染要写两遍。
*/
interface ResumeBundle {
resume: Resume;
savedAt: string;
}
export function useResume() { export function useResume() {
const fetcher = useCallback(() => fetchResume(), []); const fetcher = useCallback(async (): Promise<ResumeBundle> => {
const { data, loading, error, refresh } = useAsyncData<Resume>(fetcher); const [resume, savedAt] = await Promise.all([fetchResume(), fetchResumeSavedAt()]);
return { resume, savedAt };
}, []);
const { data, loading, error, refresh } = useAsyncData<ResumeBundle>(fetcher);
/** 写操作进行中:页面据此禁用按钮,避免连点两次时后一次基于旧列表少删一条 */
const [saving, setSaving] = useState(false);
/** 写操作的统一收口:失败也要解除 saving(`finally`),否则按钮会一直灰着 */
const runWrite = useCallback(
async <T,>(action: () => Promise<T>): Promise<T> => {
setSaving(true);
try {
const result = await action();
refresh();
return result;
} finally {
setSaving(false);
}
},
[refresh],
);
/**
* AI 动作的统一收口:`available` 为真才重新取数。
* 能力未接入时简历一个字都没改,刷新只会白闪一次加载态。
*/
const runAIAction = useCallback(
async (action: () => Promise<ResumeAIActionResult>): Promise<ResumeAIActionResult> => {
const result = await action();
if (result.available) refresh();
return result;
},
[refresh],
);
const optimize = useCallback(async () => { const optimize = useCallback(async () => {
const result = await optimizeResume(); const result = await optimizeResume();
return result.resume; return result.resume;
}, []); }, []);
const saveBasicInfo = useCallback(
(input: Omit<ResumeBasicInfo, 'avatar'>) => runWrite(() => saveResumeBasicInfo(input)),
[runWrite],
);
const saveSelfEvaluation = useCallback(
(content: string) => runWrite(() => saveResumeSelfEvaluation(content)),
[runWrite],
);
const saveSkills = useCallback(
(skills: ResumeSkill[]) => runWrite(() => saveResumeSkills(skills)),
[runWrite],
);
const saveEducation = useCallback(
(items: ResumeItemInput<ResumeEducation>[]) => runWrite(() => saveResumeEducation(items)),
[runWrite],
);
const saveExperience = useCallback(
(items: ResumeItemInput<ResumeExperience>[]) => runWrite(() => saveResumeExperience(items)),
[runWrite],
);
const saveProjects = useCallback(
(items: ResumeItemInput<ResumeProject>[]) => runWrite(() => saveResumeProjects(items)),
[runWrite],
);
/**
* 头部「保存」:把页面此刻显示的简历整体固化到本机。
* 收的是页面展示的那份(可能是「AI 一键优化」后的结果),而不是 hook 里缓存的原始值
*/
const save = useCallback((resume: Resume) => runWrite(() => saveResumeSnapshot(resume)), [runWrite]);
const optimizeExperience = useCallback(
(id: string) => runAIAction(() => optimizeResumeExperience(id)),
[runAIAction],
);
const polishSelfEvaluation = useCallback(
(content: string) => runAIAction(() => polishResumeSelfEvaluation(content)),
[runAIAction],
);
const optimizeWithJD = useCallback(
(jd: string) => runAIAction(() => optimizeResumeWithJD(jd)),
[runAIAction],
);
return { return {
resume: data ?? null, resume: data?.resume ?? null,
/** 从未保存过时为空串,页面据此不渲染「上次保存」那行 */
savedAt: data?.savedAt ?? '',
loading, loading,
error, error,
refresh, refresh,
saving,
optimize, optimize,
save,
saveBasicInfo,
saveSelfEvaluation,
saveSkills,
saveEducation,
saveExperience,
saveProjects,
optimizeExperience,
polishSelfEvaluation,
optimizeWithJD,
}; };
} }

38
src/hooks/useResumeExport.ts

@ -1,5 +1,9 @@
/** /**
* 简历导出 hook:拉取/保存导出设置,执行导出。 * 简历导出 hook:拉取/保存导出设置,生成导出文件,拉起系统分享面板。
*
* 导出与分享都要先有文件,两条路都走 `exportResume`(API 层),因此:
* - 分享不需要页面先点「立即导出」—— 用户直接点分享也拿得到文件;
* - 分享面板标题里的文件名与导出落盘的文件名**必然一致**(同一个函数算出来的)。
*/ */
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { import {
@ -7,13 +11,18 @@ import {
fetchResumeExportSettings, fetchResumeExportSettings,
saveResumeExportSettings, saveResumeExportSettings,
} from '../api'; } from '../api';
import type { ResumeExportSettings } from '../types'; import { shareFile } from '../lib/shareFile';
import type { ResumeExportOutcome, ResumeExportSettings } from '../types';
import { useAsyncData } from './useAsyncData'; import { useAsyncData } from './useAsyncData';
/** 分享结果:`ok` 只表示分享面板已拉起(用户是否真的发出去了,应用无从得知) */
export type ResumeShareOutcome = { ok: true } | { ok: false; message: string };
export function useResumeExport() { export function useResumeExport() {
const fetcher = useCallback(() => fetchResumeExportSettings(), []); const fetcher = useCallback(() => fetchResumeExportSettings(), []);
const { data, loading, error, refresh } = useAsyncData<ResumeExportSettings>(fetcher); const { data, loading, error, refresh } = useAsyncData<ResumeExportSettings>(fetcher);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [sharing, setSharing] = useState(false);
const save = useCallback( const save = useCallback(
async (settings: ResumeExportSettings) => { async (settings: ResumeExportSettings) => {
@ -24,11 +33,10 @@ export function useResumeExport() {
); );
const exportFile = useCallback( const exportFile = useCallback(
async (settings: ResumeExportSettings) => { async (settings: ResumeExportSettings): Promise<ResumeExportOutcome> => {
setExporting(true); setExporting(true);
try { try {
const result = await exportResume(settings); return await exportResume(settings);
return result.url;
} finally { } finally {
setExporting(false); setExporting(false);
} }
@ -36,6 +44,24 @@ export function useResumeExport() {
[], [],
); );
const share = useCallback(
async (settings: ResumeExportSettings): Promise<ResumeShareOutcome> => {
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 { return {
settings: data ?? null, settings: data ?? null,
loading, loading,
@ -43,6 +69,8 @@ export function useResumeExport() {
refresh, refresh,
save, save,
exportFile, exportFile,
share,
exporting, exporting,
sharing,
}; };
} }

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

21
src/hooks/useSkillCloud.ts

@ -1,22 +1,35 @@
/** /**
* 行业技能词云 hook:按行业拉取技能词数据,统一管理 loading / error / refresh。 * 行业技能词云 hook:按行业拉取技能词数据,统一管理 loading / error / refresh。
* 切换行业时自动重新取数(fetcher 依赖 industry)。 * 切换行业时自动重新取数(fetcher 依赖 industry);
* 搜索关键词的过滤规则放在接口层(filterSkillWords),这里只编排状态。
*/ */
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { fetchSkillCloud } from '../api'; import { fetchSkillCloud, filterSkillWords } from '../api';
import type { SkillCloudData } from '../types'; import type { SkillCloudData, SkillWord } from '../types';
import { useAsyncData } from './useAsyncData'; import { useAsyncData } from './useAsyncData';
export function useSkillCloud(initialIndustry = '互联网/IT') { export function useSkillCloud(initialIndustry = '互联网/IT') {
const [industry, setIndustry] = useState(initialIndustry); const [industry, setIndustry] = useState(initialIndustry);
const [keyword, setKeyword] = useState('');
const fetcher = useCallback(() => fetchSkillCloud(industry), [industry]); const fetcher = useCallback(() => fetchSkillCloud(industry), [industry]);
const { data, loading, error, refresh } = useAsyncData(fetcher); const { data, loading, error, refresh } = useAsyncData(fetcher);
const cloud = (data as SkillCloudData | null) ?? null;
/** 按关键词过滤后的技能词;未输入关键词时即全量 */
const words: SkillWord[] = cloud ? filterSkillWords(cloud.words, keyword) : [];
return { return {
industry, industry,
setIndustry, setIndustry,
cloud: (data as SkillCloudData | null) ?? null, cloud,
/** 搜索关键词 */
keyword,
setKeyword,
/** 过滤后的技能词(渲染词云用) */
words,
/** 有关键词但一条没命中 */
isEmpty: cloud !== null && words.length === 0,
loading, loading,
error, error,
refresh, refresh,

48
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<SkillLearningPath | null>;
/** 关闭弹窗时清掉上次结果,下次打开重新生成 */
reset: () => void;
}
export function useSkillLearningPath(): UseSkillLearningPathReturn {
const [path, setPath] = useState<SkillLearningPath | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const generate = useCallback(async (industry: string) => {
setLoading(true);
setError(null);
try {
const result = await fetchSkillLearningPath(industry);
setPath(result);
return result;
} catch (e) {
setError(e instanceof Error ? e : new Error('学习路径生成失败,请稍后重试'));
return null;
} finally {
setLoading(false);
}
}, []);
const reset = useCallback(() => {
setPath(null);
setError(null);
}, []);
return { path, loading, error, generate, reset };
}

33
src/hooks/useTalentRecommendation.ts

@ -1,17 +1,40 @@
/** /**
* 企业端人才推荐数据 hook。 * 企业端人才推荐数据 hook。
* Tab 与搜索词状态放在这里,过滤规则由接口层(filterTalentCandidates)给出,
* 页面只把点击/输入转成 setTab / setKeyword 再渲染 candidates。
*/ */
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { fetchTalentRecommendation } from '../api'; import { fetchTalentRecommendation, filterTalentCandidates } from '../api';
import type { TalentRecommendation } from '../types'; import type { TalentCandidate, TalentRecommendation } from '../types';
import { useAsyncData } from './useAsyncData'; import { useAsyncData } from './useAsyncData';
export function useTalentRecommendation() { export function useTalentRecommendation() {
const fetcher = useCallback(async (): Promise<TalentRecommendation> => fetchTalentRecommendation(), []); const fetcher = useCallback(
async (): Promise<TalentRecommendation> => fetchTalentRecommendation(),
[],
);
const { data, loading, error, refresh } = useAsyncData(fetcher); 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 { return {
recommendation: data ?? null, recommendation,
activeTab,
setActiveTab,
keyword,
setKeyword,
candidates,
isEmptySearch,
loading, loading,
error, error,
refresh, refresh,

46
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;
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save