Compare commits

...

12 Commits

Author SHA1 Message Date
doublex001 00522d53f9 feat: 死控件清零并修复一键登录取号、重做引导页 1 month ago
doublex001 552a9a1f34 docs: 同步开发状态与微信登录方案,记录登录方式改造与数字校正 1 month ago
doublex001 666ac4f6ac fix(ui): 注册流程主按钮底色改用 primaryBtn,满足 WCAG AA 1 month ago
doublex001 5fbb7222f0 feat(auth): 补齐密码登录通道并修复登录态从未写入的缺口 1 month ago
doublex001 82771bc103 fix(ui): 修复多处按钮绑定空函数导致点击无反馈 1 month ago
doublex001 d1b12f3f1f docs: 同步开发状态文档,补充 EAS 占位符上架阻塞说明 1 month ago
doublex001 ee264b644c fix(privacy): 修复隐私设置页开关项无入口,7 个页面无法跳转 1 month ago
doublex001 0e3f1333c1 feat(ui): 接入品牌启动屏,消除冷启动白屏 1 month ago
doublex001 16e72749eb chore(deps): 移除游离 npm 锁文件,依赖对齐 SDK 57 期望版本 1 month ago
doublex001 aa7b171774 fix(config): 移除占位 updates 配置,修复 Expo Go 启动蓝屏 2 months ago
doublex001 878710c75a chore(assets): 替换应用图标为「有职」品牌 logo 2 months ago
doublex001 2afe503b32 feat(salary): 薪资计算器保存记录与计算历史 2 months ago
  1. 7
      .gitignore
  2. 5
      AGENTS.md
  3. 41
      app.config.js
  4. 101
      app/(enterprise)/enterprise-chat.tsx
  5. 188
      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. 7
      app/chat/[id].tsx
  12. 91
      app/guide.tsx
  13. 35
      app/holland-result.tsx
  14. 5
      app/holland.tsx
  15. 4
      app/hr-evaluation.tsx
  16. 75
      app/interview-score.tsx
  17. 16
      app/interview.tsx
  18. 217
      app/job-evaluations.tsx
  19. 126
      app/onboarding.tsx
  20. 14
      app/progress.tsx
  21. 9
      app/register.tsx
  22. 644
      app/resume.tsx
  23. 136
      app/resume/basic.tsx
  24. 272
      app/resume/education.tsx
  25. 323
      app/resume/experience.tsx
  26. 355
      app/resume/export.tsx
  27. 240
      app/resume/preview.tsx
  28. 225
      app/resume/project.tsx
  29. 41
      app/settings.tsx
  30. 211
      app/settings/password.tsx
  31. 29
      app/settings/privacy.tsx
  32. 274
      app/tools/salary-calculator.tsx
  33. 313
      app/tools/skill-cloud.tsx
  34. BIN
      assets/android-icon-background.png
  35. BIN
      assets/android-icon-foreground.png
  36. BIN
      assets/android-icon-monochrome.png
  37. BIN
      assets/icon.png
  38. 70
      components/onboarding/assets.ts
  39. 4
      components/onboarding/screens/B1Wechat.tsx
  40. 4
      components/onboarding/screens/B2Agreement.tsx
  41. 94
      components/onboarding/screens/B3Recruiter.tsx
  42. 9
      components/onboarding/screens/P10FirstWork.tsx
  43. 39
      components/onboarding/screens/P11CurrentPosition.tsx
  44. 4
      components/onboarding/screens/P12Company.tsx
  45. 16
      components/onboarding/screens/P13JoinDate.tsx
  46. 4
      components/onboarding/screens/P14Skills.tsx
  47. 12
      components/onboarding/screens/P16WorkContent.tsx
  48. 4
      components/onboarding/screens/P17Reference.tsx
  49. 7
      components/onboarding/screens/P18Education.tsx
  50. 155
      components/onboarding/screens/P1Login.tsx
  51. 4
      components/onboarding/screens/P20School.tsx
  52. 74
      components/onboarding/screens/P21Avatar.tsx
  53. 10
      components/onboarding/screens/P22Done.tsx
  54. 181
      components/onboarding/screens/P23Home.tsx
  55. 139
      components/onboarding/screens/P2PasswordLogin.tsx
  56. 182
      components/onboarding/screens/P2PasswordReset.tsx
  57. 156
      components/onboarding/screens/P2PhoneCode.tsx
  58. 142
      components/onboarding/screens/P2QuickLogin.tsx
  59. 43
      components/onboarding/screens/P2WechatCode.tsx
  60. 29
      components/onboarding/screens/P3Identity.tsx
  61. 14
      components/onboarding/screens/P4Intent.tsx
  62. 13
      components/onboarding/screens/P5City.tsx
  63. 60
      components/onboarding/screens/P6ExpectedPosition.tsx
  64. 22
      components/onboarding/screens/P7Salary.tsx
  65. 13
      components/onboarding/screens/P8Resume1.tsx
  66. 8
      components/onboarding/screens/P9JobStatus.tsx
  67. 18
      components/onboarding/ui/Avatar.tsx
  68. 12
      components/onboarding/ui/Illustration.tsx
  69. 64
      components/onboarding/ui/ImageSlot.tsx
  70. 4
      components/onboarding/ui/PrimaryButton.tsx
  71. 5
      components/onboarding/ui/ScreenShell.tsx
  72. 43
      components/onboarding/ui/SupportFooter.tsx
  73. 72
      components/onboarding/ui/WheelPicker.tsx
  74. 209
      components/resume/EditKit.tsx
  75. 27
      package.json
  76. 86
      scripts/check-affordances.mjs
  77. 47
      scripts/check-backend-stubs.mjs
  78. 73
      src/__tests__/carrier-login.test.ts
  79. 282
      src/__tests__/enterprise-filters.test.ts
  80. 62
      src/__tests__/enterprise-reviews.test.ts
  81. 96
      src/__tests__/interview-score-draft.test.ts
  82. 255
      src/__tests__/job-match.test.ts
  83. 221
      src/__tests__/onboarding-checklist.test.ts
  84. 162
      src/__tests__/onboarding-profile.test.ts
  85. 177
      src/__tests__/password.test.ts
  86. 240
      src/__tests__/resume-export.test.ts
  87. 363
      src/__tests__/resume-override.test.ts
  88. 245
      src/__tests__/resume-profile.test.ts
  89. 74
      src/__tests__/salary-options.test.ts
  90. 74
      src/__tests__/salary.test.ts
  91. 97
      src/__tests__/user-profile.test.ts
  92. 91
      src/__tests__/utils.test.ts
  93. 1995
      src/api/api.ts
  94. 51
      src/api/index.ts
  95. 72
      src/components/PasswordField.tsx
  96. 216
      src/constants/index.ts
  97. 11
      src/hooks/index.ts
  98. 73
      src/hooks/useAgreementGate.ts
  99. 50
      src/hooks/useAppCache.ts
  100. 18
      src/hooks/useAppealForm.ts

7
.gitignore

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

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
请在每周的周一-周五的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 无需改动。

41
app.config.js

@ -24,11 +24,13 @@ export default {
policy: 'appVersion',
},
// EAS Update(OTA 热更新)通道与端点。
// url 中的 PROJECT_ID 在 `eas init` 后由 EAS 注入(见 extra.eas.projectId)。
updates: {
url: 'https://u.expo.dev/REPLACE_WITH_PROJECT_ID',
channel: 'production',
},
// 注意:占位的 REPLACE_WITH_PROJECT_ID 会让 Expo Go(开发期)启动时去拉取
// 不存在的远程更新,报 “Failed to download remote update” 并蓝屏。
// 运行 `eas init` 后由 EAS 注入真实地址;开发期(Expo Go)请勿保留此块。
// updates: {
// url: 'https://u.expo.dev/<PROJECT_ID>',
// channel: 'production',
// },
ios: {
supportsTablet: true,
// iOS Bundle Identifier:上架 App Store 的唯一标识,按你的开发者账号命名。
@ -44,7 +46,34 @@ export default {
monochromeImage: './assets/android-icon-monochrome.png',
},
},
plugins: ['expo-router', 'expo-font'],
plugins: [
'expo-router',
'expo-font',
// 拍照 / 相册取图(营业执照、头像)。原生权限文案只有出包时生效,
// Expo Go 里用的是 Expo Go 自己的 Info.plist,故此处改动需真机出包验证。
[
'expo-image-picker',
{
// 本项目只选静态图、不录视频:不申请麦克风权限(最小必要原则),
// 插件默认会加 RECORD_AUDIO,这里显式关掉
microphonePermission: false,
photosPermission: '有职需要访问相册,用于上传营业执照与设置头像',
cameraPermission: '有职需要使用相机,用于拍摄营业执照与设置头像',
},
],
// 启动屏:冷启动期间展示品牌 logo,消除白屏。
// 资源为 assets/splash-icon.png(1024×1024,见 assets/)。
// 应用为纯亮色(userInterfaceStyle: 'light'),故不配置 dark 分支。
[
'expo-splash-screen',
{
image: './assets/splash-icon.png',
imageWidth: 200,
resizeMode: 'contain',
backgroundColor: '#FFFFFF',
},
],
],
extra: {
apiBaseUrl: process.env.API_BASE_URL || 'https://dev.api.com',
// EAS 项目 ID:`eas init` 后会自动注入此处(与 updates.url 中的 PROJECT_ID 对应)。

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

@ -1,6 +1,5 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
@ -8,6 +7,7 @@ import {
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@ -71,7 +71,13 @@ function TabBar({
/* ===================== 子组件:待处理任务 ===================== */
function PendingTasks({ tasks }: { tasks: PendingTask[] }) {
function PendingTasks({
tasks,
onPressTask,
}: {
tasks: PendingTask[];
onPressTask: (task: PendingTask) => void;
}) {
return (
<View style={styles.pendingCard}>
<Text style={styles.pendingTitle}>待处理</Text>
@ -84,7 +90,11 @@ function PendingTasks({ tasks }: { tasks: PendingTask[] }) {
? colors.compareOrange
: colors.primary;
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' }]}>
<Ionicons name={task.icon as keyof typeof Ionicons.glyphMap} size={22} color={token} />
</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 (
<View style={styles.searchBar}>
<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>
);
}
@ -193,7 +223,7 @@ function EmptyState({
<Ionicons name="chatbubbles-outline" size={48} color={colors.primary} />
</View>
<Text style={styles.emptyTitle}>还没有沟通记录</Text>
<Text style={styles.emptySubtitle}>从人才库中发现合适的候选人,主动发起沟通吧</Text>
<Text style={styles.emptySubtitle}>从人才推荐中发现合适的候选人,主动发起沟通吧</Text>
<Pressable style={styles.emptyBtn} onPress={onDiscover} hitSlop={6}>
<Text style={styles.emptyBtnText}>去发现人才</Text>
</Pressable>
@ -225,8 +255,18 @@ const QUICK_ACTIONS: QuickActionItem[] = [
export default function EnterpriseChatScreen() {
const router = useRouter();
const { communication, loading, error, refresh } = useEnterpriseCommunication();
const [activeTab, setActiveTab] = useState('all');
const {
communication,
activeTab,
setActiveTab,
keyword,
setKeyword,
chats,
isEmptySearch,
loading,
error,
refresh,
} = useEnterpriseCommunication();
// 快捷操作分发:有目标路由则跳转,无对应页面则提示「功能开发中」
const handleQuickAction = (action: QuickActionItem) => {
@ -237,6 +277,20 @@ export default function EnterpriseChatScreen() {
}
};
// 待办卡片:优先跳页面,其次切 Tab(接口层给的目标,页面不写死映射)
const handlePressTask = (task: PendingTask) => {
if (task.route) {
router.push(task.route as never);
return;
}
if (task.tabKey) {
setActiveTab(task.tabKey);
setKeyword('');
return;
}
Alert.alert('提示', `${task.label}功能开发中,敬请期待`);
};
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
@ -270,11 +324,19 @@ export default function EnterpriseChatScreen() {
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{communication.hasData ? (
<View>
<PendingTasks tasks={communication.pendingTasks} />
<SearchBar />
<PendingTasks tasks={communication.pendingTasks} onPressTask={handlePressTask} />
<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}>
{communication.chats.map((chat, index) => {
const isLast = index === communication.chats.length - 1;
{chats.map((chat, index) => {
const isLast = index === chats.length - 1;
return (
<View key={chat.id} style={isLast ? undefined : styles.chatBorder}>
<ChatRow chat={chat} />
@ -282,6 +344,7 @@ export default function EnterpriseChatScreen() {
);
})}
</View>
)}
<QuickActions actions={QUICK_ACTIONS} onPressAction={handleQuickAction} />
</View>
) : (
@ -448,11 +511,21 @@ const styles = StyleSheet.create({
paddingVertical: 10,
marginBottom: 12,
},
searchPlaceholder: {
searchInput: {
flex: 1,
marginLeft: 8,
fontSize: 14,
color: colors.textHint,
color: colors.textPrimary,
padding: 0,
},
emptyList: {
alignItems: 'center',
gap: 10,
paddingVertical: 32,
backgroundColor: colors.white,
borderRadius: 14,
},
emptyListText: { fontSize: 13, color: colors.textSecondary },
chatListCard: {
backgroundColor: colors.white,
borderRadius: 12,

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

@ -3,10 +3,12 @@ import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@ -16,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 (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
@ -27,17 +40,54 @@ function Header({ companyName, onBack }: { companyName: string; onBack: () => vo
<Text style={styles.headerTitle}>人才推荐</Text>
</View>
<View style={styles.headerActions}>
<Pressable style={styles.headerActionBtn} hitSlop={6}>
<Ionicons name="search-outline" size={22} color={colors.textPrimary} />
</Pressable>
<Pressable style={styles.headerActionBtn} hitSlop={6}>
<Ionicons name="options-outline" size={22} color={colors.textPrimary} />
{/* 原先这里是「搜索 + 漏斗」两个图标:漏斗没有对应页面,先摘掉;
搜索改成真的关键词过滤(姓名 / 职位 / 技能 / 学校) */}
{onToggleSearch && (
<Pressable style={styles.headerActionBtn} onPress={onToggleSearch} hitSlop={6}>
<Ionicons
name={searching ? 'close' : 'search-outline'}
size={22}
color={colors.textPrimary}
/>
</Pressable>
)}
</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 栏 ===================== */
function TabBar({
@ -140,7 +190,7 @@ function CandidateCard({ candidate }: { candidate: TalentCandidate }) {
/* ===================== 子组件:人才池数据 ===================== */
function TalentPool({ stats, actions }: { stats: { id: string; value: string; label: string }[]; actions: { icon: string; label: string }[] }) {
function TalentPool({ stats, actions, onPressAction }: { stats: { id: string; value: string; label: string }[]; actions: PoolActionItem[]; onPressAction: (action: PoolActionItem) => void }) {
return (
<View style={styles.poolCard}>
<Text style={styles.poolTitle}>人才池数据</Text>
@ -154,7 +204,7 @@ function TalentPool({ stats, actions }: { stats: { id: string; value: string; la
</View>
<View style={styles.poolActions}>
{actions.map((action) => (
<Pressable key={action.label} style={styles.poolAction}>
<Pressable key={action.label} style={styles.poolAction} onPress={() => onPressAction(action)}>
<Ionicons name={action.icon as keyof typeof Ionicons.glyphMap} size={20} color={colors.textSecondary} />
<Text style={styles.poolActionText}>{action.label}</Text>
</Pressable>
@ -217,17 +267,61 @@ function EmptyState({
/* ===================== 页面 ===================== */
const POOL_ACTIONS = [
/**
* 人才池快捷操作项:带 route 的跳转目标页,无对应页面的弹「功能开发中」提示。
* 与 enterprise-chat.tsx 的 QuickActionItem 保持同一约定。
*/
type PoolActionItem = { icon: string; label: string; route?: string };
const POOL_ACTIONS: PoolActionItem[] = [
{ icon: 'mail-outline', label: '批量发邀请' },
{ icon: 'document-text-outline', label: '批量发Offer' },
{ icon: 'calendar-outline', label: '面试安排' },
{ icon: 'calendar-outline', label: '面试评分', route: '/interview-score' },
{ icon: 'notifications-outline', label: '群发通知' },
];
export default function TalentRecommendationScreen() {
const router = useRouter();
const { recommendation, loading, error, refresh } = useTalentRecommendation();
const [activeTab, setActiveTab] = useState('all');
const {
recommendation,
activeTab,
setActiveTab,
keyword,
setKeyword,
candidates,
isEmptySearch,
loading,
error,
refresh,
} = useTalentRecommendation();
const [searching, setSearching] = useState(false);
const toggleSearch = () => {
if (searching) setKeyword('');
setSearching((prev) => !prev);
};
// 人才池快捷操作分发:有目标路由则跳转,无对应页面则提示「功能开发中」,
// 避免按钮点击后无任何反馈。与 enterprise-chat.tsx 的 handleQuickAction 同约定。
const handlePoolAction = (action: PoolActionItem) => {
if (action.route) {
router.push(action.route);
} else {
Alert.alert('提示', `${action.label}功能开发中,敬请期待`);
}
};
// 以下两处原先绑的是空函数(() => {}),按钮点击后无任何反馈。
// 暂无对应页面,按项目约定弹「功能开发中」提示。
const handleReport = () => {
Alert.alert('提示', '人才市场报告功能开发中,敬请期待');
};
/** 空状态里的「调整筛选条件」:把筛选真正复位(切回全部 + 清关键词) */
const handleFilter = () => {
setActiveTab('all');
setKeyword('');
};
if (loading) {
return (
@ -257,25 +351,51 @@ export default function TalentRecommendationScreen() {
return (
<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} />
{searching && (
<SearchBar value={keyword} onChange={setKeyword} onClear={() => setKeyword('')} />
)}
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{recommendation.hasData ? (
<View>
<TodayHeader
count={recommendation.todayCount}
onReport={() => {}}
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} />
))}
<TalentPool stats={recommendation.poolStats} actions={POOL_ACTIONS} />
))
)}
<TalentPool
stats={recommendation.poolStats}
actions={POOL_ACTIONS}
onPressAction={handlePoolAction}
/>
</View>
) : (
<EmptyState
actions={recommendation.recommendedActions}
dataReference={recommendation.dataReference}
onFilter={() => {}}
onFilter={handleFilter}
/>
)}
</ScrollView>
@ -305,6 +425,38 @@ const styles = StyleSheet.create({
width: 36,
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: {
flexDirection: 'row',
backgroundColor: colors.white,

104
app/(tabs)/chat.tsx

@ -4,6 +4,8 @@ import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Alert,
Linking,
Pressable,
ScrollView,
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 }) {
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 (
<View style={styles.interviewCard}>
<View style={styles.interviewHeader}>
@ -95,13 +130,15 @@ function InterviewCard({ interview }: { interview: Interview }) {
{isVideo ? '视频面试' : '线下面试'}
</Text>
</View>
<Pressable
style={styles.actionBtn}
onPress={() => {
}}
>
<Text style={styles.actionText}>{isVideo ? '加入会议' : '导航前往'}</Text>
{target ? (
<Pressable style={styles.actionBtn} onPress={open}>
<Text style={styles.actionText}>{target.label}</Text>
</Pressable>
) : (
<Text style={styles.actionMissing}>
{isVideo ? '会议链接待 HR 提供' : '面试地址待 HR 提供'}
</Text>
)}
</View>
</View>
);
@ -113,8 +150,16 @@ function InterviewCard({ interview }: { interview: Interview }) {
export default function ChatScreen() {
const router = useRouter();
const [tab, setTab] = useState<TabKey>('message');
const { conversations, notifications, interviews, matchResult, loading, error, refresh } =
useMessageData();
const {
conversations,
notifications,
interviews,
matchResult,
pendingOfferCount,
loading,
error,
refresh,
} = useMessageData();
// 加载态
if (loading) {
@ -175,17 +220,20 @@ export default function ChatScreen() {
{tab === 'message' && (
<>
{/* 双向匹配成功横幅 */}
{/*
双向匹配横幅:只陈述事实,不带「点击查看详情」——
匹配详情页尚未定义(企业列表之外还要展示什么,属产品决策),
留着链接就是把用户带到一个不存在的地方。
*/}
{matchResult && (
<Pressable
style={styles.matchBanner}
onPress={() => {
}}
>
<View style={styles.matchBanner}>
<Text style={styles.matchBannerTitle}>
恭喜!您与{matchResult.total}家企业双向匹配成功
</Text>
<Text style={styles.matchBannerLink}>点击查看详情</Text>
</Pressable>
<Text style={styles.matchBannerCompanies} numberOfLines={2}>
{matchResult.companies.join('、')}
</Text>
</View>
)}
{/* 会话列表 */}
@ -205,15 +253,16 @@ export default function ChatScreen() {
{tab === 'notification' && (
<>
{/* Offer 待处理横幅 */}
{/* Offer 待处理横幅:数量来自接口层,为 0 时整条不渲染 */}
{pendingOfferCount > 0 && (
<Pressable
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>
</Pressable>
)}
{/* 系统通知列表 */}
{notifications.length === 0 ? (
@ -228,15 +277,9 @@ export default function ChatScreen() {
{tab === 'interview' && (
<>
{/* 面试日程标题 */}
{/* 面试日程标题:本 Tab 即全部日程,不再挂一个无处可去的「全部日程 >」 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>今日面试</Text>
<Pressable
onPress={() => {
}}
>
<Text style={styles.sectionLink}>全部日程 &gt;</Text>
</Pressable>
</View>
{/* 面试卡片 */}
@ -347,10 +390,10 @@ const styles = StyleSheet.create({
fontWeight: '700',
color: colors.primaryDark,
},
matchBannerLink: {
matchBannerCompanies: {
marginTop: 6,
fontSize: 13,
color: colors.primary,
color: colors.primaryDark,
},
// 会话卡片
convCard: {
@ -553,4 +596,9 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: colors.white,
},
/** 会议链接 / 面试地址缺失时的说明文案,替代一个点不动的按钮 */
actionMissing: {
fontSize: 13,
color: colors.textHint,
},
});

11
app/(tabs)/jobs.tsx

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

33
app/(tabs)/profile.tsx

@ -12,6 +12,9 @@ import {
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
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 { colors, headerGradientSolid } from '../../src/theme';
import type { Badge, DimensionScore, EnterpriseEntry, JobPreference, MenuItem, UserStats } from '../../src/types';
@ -238,12 +241,22 @@ export default function ProfileScreen() {
</Pressable>
</View>
<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}>
<Text style={styles.avatarText}>{profile.avatar}</Text>
</View>
)}
<View style={styles.profileInfo}>
<Text style={styles.userName}>{profile.name}</Text>
<Text style={styles.bio}>{profile.bio}</Text>
{/* 姓名未采集(P6/P16 可跳过直接进入)时显示「未设置」,不显示别人的名字 */}
<Text style={styles.userName}>{profile.name || UNSET_LABEL}</Text>
{profile.bio !== '' && <Text style={styles.bio}>{profile.bio}</Text>}
</View>
</View>
</LinearGradient>
@ -294,7 +307,10 @@ export default function ProfileScreen() {
{PREF_ITEMS.map(({ key, label }, index) => {
const value = preference[key];
const isLast = index === PREF_ITEMS.length - 1;
const isStatus = key === 'status';
// 未采集到的项显示「未设置」,不拿空串冒充已填;也就不再显示状态圆点
const isUnset = value.trim() === '';
const display = isUnset ? UNSET_LABEL : value;
const isStatus = key === 'status' && !isUnset;
return (
<Pressable
key={key}
@ -305,10 +321,12 @@ export default function ProfileScreen() {
{isStatus ? (
<View style={styles.prefStatus}>
<View style={styles.prefStatusDot} />
<Text style={styles.prefStatusText}>{value}</Text>
<Text style={styles.prefStatusText}>{display}</Text>
</View>
) : (
<Text style={styles.prefValue}>{value}</Text>
<Text style={[styles.prefValue, isUnset && styles.prefValueUnset]}>
{display}
</Text>
)}
</Pressable>
);
@ -588,6 +606,11 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: colors.textPrimary,
},
// 未采集态:弱化为占位符视觉,与真实填写的值区分开
prefValueUnset: {
fontWeight: '400',
color: colors.textHint,
},
prefStatus: {
flexDirection: 'row',
alignItems: 'center',

98
app/appeal.tsx

@ -3,6 +3,7 @@ import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Alert,
Image,
Pressable,
ScrollView,
StyleSheet,
@ -11,7 +12,7 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAppealForm } from '../src/hooks';
import { useAppealForm, useImagePick } from '../src/hooks';
import { colors } from '../src/theme';
import type { AppealReason, AppealType } from '../src/types';
@ -213,15 +214,61 @@ function DescriptionSection({
/* ===================== 子组件:上传凭证 ===================== */
function EvidenceSection() {
function EvidenceSection({
evidence,
picking,
error,
onPick,
onRemove,
}: {
evidence: string[];
picking: boolean;
error: string | null;
onPick: (source: 'camera' | 'library') => void;
onRemove: (uri: string) => void;
}) {
const chooseSource = () => {
Alert.alert('上传凭证', '选择图片来源', [
{ text: '拍照', onPress: () => onPick('camera') },
{ text: '从相册选择', onPress: () => onPick('library') },
{ text: '取消', style: 'cancel' },
]);
};
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>上传凭证</Text>
<Pressable style={styles.uploadBox}>
<Text style={styles.sectionTitle}>上传凭证({evidence.length})</Text>
{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} />
<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>
{error ? <Text style={styles.uploadError}>{error}</Text> : null}
</View>
);
}
@ -312,6 +359,9 @@ export default function AppealScreen() {
setReason,
reasonText,
setReasonText,
evidence,
addEvidence,
removeEvidence,
contactPhone,
setContactPhone,
contactEmail,
@ -319,6 +369,7 @@ export default function AppealScreen() {
submit,
submitting,
} = useAppealForm();
const { pick, picking, error: pickError } = useImagePick();
const handleSubmit = async () => {
const result = await submit();
@ -329,6 +380,12 @@ export default function AppealScreen() {
}
};
/** 选一张证据图:取消与未授权由 useImagePick 区分(未授权给出设置指引) */
const handlePickEvidence = async (source: 'camera' | 'library') => {
const result = await pick(source, { allowsEditing: false });
if (result.status === 'picked') addEvidence(result.uri);
};
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
@ -360,7 +417,13 @@ export default function AppealScreen() {
/>
<ReasonSection active={reason} onSelect={setReason} />
<DescriptionSection value={reasonText} onChange={setReasonText} />
<EvidenceSection />
<EvidenceSection
evidence={evidence}
picking={picking}
error={pickError}
onPick={handlePickEvidence}
onRemove={removeEvidence}
/>
<ContactSection
phone={contactPhone}
email={contactEmail}
@ -494,6 +557,27 @@ const styles = StyleSheet.create({
},
uploadText: { fontSize: 14, color: colors.textPrimary, marginTop: 8 },
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: {
backgroundColor: colors.white,
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} />
</Pressable>
<Text style={styles.headerTitle}>候选人详情</Text>
<Pressable style={styles.headerBtn} hitSlop={8}>
<Ionicons name="ellipsis-horizontal" size={22} color={colors.textPrimary} />
</Pressable>
{/* 原先这里是「更多」图标:没有下拉菜单,先摘掉 */}
<View style={styles.headerBtn} />
</View>
);
}
@ -332,7 +331,8 @@ export default function CandidateDetailScreen() {
<HireSuggestionCard suggestions={candidate.hireSuggestions} salary={candidate.salarySuggestion} />
</ScrollView>
<View style={styles.footer}>
<Pressable style={styles.chatBtn}>
{/* 发起沟通:进该候选人的会话页(此前是空 Pressable,点了没反应) */}
<Pressable style={styles.chatBtn} onPress={() => router.push(`/chat/${candidate.id}`)}>
<Text style={styles.chatText}>发起沟通</Text>
</Pressable>
<Pressable

7
app/chat/[id].tsx

@ -325,6 +325,11 @@ export default function ChatDetailScreen() {
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100);
}, [input, sending, sendMessage]);
// 右上角「更多」暂无对应菜单页,按本文件既有约定弹提示(此前为空函数,点了无反应)
const handleMore = () => {
Alert.alert('提示', '功能开发中,敬请期待');
};
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
@ -357,7 +362,7 @@ export default function ChatDetailScreen() {
avatar={detail.avatar}
online={detail.online}
onBack={() => router.back()}
onMore={() => {}}
onMore={handleMore}
/>
{detail.matchInfo && (

91
app/guide.tsx

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

35
app/holland-result.tsx

@ -3,6 +3,7 @@ import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
@ -13,7 +14,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Line, Polygon } from 'react-native-svg';
import { useHollandAssessment } from '../src/hooks';
import { colors, headerGradientShort } from '../src/theme';
import type { HollandDimension, HollandTypeAnalysis } from '../src/types';
import type { HollandAction, HollandDimension, HollandTypeAnalysis } from '../src/types';
const RADAR_SIZE = 160;
const RADAR_CENTER = RADAR_SIZE / 2;
@ -185,15 +186,28 @@ function JobCard({ job }: { job: import('../src/types').HollandJobRecommendation
/* ===================== 子组件:行动建议 ===================== */
function ActionCard({ action }: { action: import('../src/types').HollandAction }) {
return (
<Pressable style={styles.actionCard}>
function ActionCard({ action }: { action: HollandAction }) {
const router = useRouter();
// 建议卡的目标路由由接口层给出(见 api.ts 的 actions);没有 route 的只做展示,不带箭头
const route = action.route;
const body = (
<>
<Ionicons name={action.icon as keyof typeof Ionicons.glyphMap} size={22} color={colors.primary} />
<View style={styles.actionBody}>
<Text style={styles.actionTitle}>{action.title}</Text>
<Text style={styles.actionDesc}>{action.desc}</Text>
</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>
);
}
@ -228,10 +242,15 @@ export default function HollandResultScreen() {
const { assessment, loading, error, refresh } = useHollandAssessment();
const result = assessment?.result;
// 右上角「分享」暂无对应页面,按项目约定弹提示(此前绑定空函数,点击无任何反馈)
const handleShare = () => {
Alert.alert('提示', '分享测评报告功能开发中,敬请期待');
};
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={() => {}} />
<Header onBack={() => router.back()} onShare={handleShare} />
<Loading />
</SafeAreaView>
);
@ -240,7 +259,7 @@ export default function HollandResultScreen() {
if (error || !result) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={() => {}} />
<Header onBack={() => router.back()} onShare={handleShare} />
<ErrorState onRetry={refresh} />
</SafeAreaView>
);
@ -248,7 +267,7 @@ export default function HollandResultScreen() {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={() => {}} />
<Header onBack={() => router.back()} onShare={handleShare} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<ResultHero code={result.code} name={result.name} dimensions={result.dimensions} />

5
app/holland.tsx

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

4
app/hr-evaluation.tsx

@ -256,7 +256,7 @@ export default function HREvaluationScreen() {
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onAppeal={() => {}} />
<Header onBack={() => router.back()} onAppeal={() => router.push('/appeal')} />
<Loading />
</SafeAreaView>
);
@ -265,7 +265,7 @@ export default function HREvaluationScreen() {
if (error || !evaluation) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onAppeal={() => {}} />
<Header onBack={() => router.back()} onAppeal={() => router.push('/appeal')} />
<ErrorState onRetry={refresh} />
</SafeAreaView>
);

75
app/interview-score.tsx

@ -2,6 +2,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
@ -13,6 +14,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Line, Polygon, Text as SvgText } from 'react-native-svg';
import { useInterviewScore } from '../src/hooks';
import { colors } from '../src/theme';
import { formatDateTime } from '../src/utils';
import type { HireSuggestion, InterviewScoreData, InterviewScoreDimension, InterviewScoreTag } from '../src/types';
const COLOR_TOKENS: Record<string, string> = {
@ -32,9 +34,8 @@ function Header({ onBack }: { onBack: () => void }) {
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>面试评分</Text>
<Pressable style={styles.headerBtn} hitSlop={8}>
<Ionicons name="time-outline" size={22} color={colors.textPrimary} />
</Pressable>
{/* 原先这里是「历史评分」入口:没有历史评分页,先摘掉,加页面时再挂回来 */}
<View style={styles.headerBtn} />
</View>
);
}
@ -271,8 +272,26 @@ export default function InterviewScoreScreen() {
selectHireSuggestion,
submit,
submitting,
saveDraft,
savingDraft,
restoreDraft,
discardDraft,
hasPendingDraft,
draftSavedAt,
} = useInterviewScore('interview-1');
/** 保存草稿:成功后就地在按钮旁显示保存时间,失败给出提示 */
const handleSaveDraft = async () => {
try {
const savedAt = await saveDraft();
if (savedAt) {
Alert.alert('已保存草稿', `保存时间 ${formatDateTime(savedAt)},下次进入这场面试时可恢复。`);
}
} catch {
Alert.alert('保存失败', '草稿没能存到本机,请稍后重试。');
}
};
if (loading) {
return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}>
@ -295,6 +314,24 @@ export default function InterviewScoreScreen() {
<Header onBack={() => router.back()} />
</SafeAreaView>
<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} />
<OverallScoreCard score={score.overallScore} />
<RadarChart dimensions={score.dimensions} />
@ -304,8 +341,12 @@ export default function InterviewScoreScreen() {
<SalarySuggestionCard salary={score.salarySuggestion} />
</ScrollView>
<View style={styles.footer}>
<Pressable style={styles.draftBtn}>
<Text style={styles.draftText}>保存草稿</Text>
<Pressable
style={[styles.draftBtn, savingDraft && styles.draftBtnDisabled]}
onPress={handleSaveDraft}
disabled={savingDraft}
>
<Text style={styles.draftText}>{savingDraft ? '保存中…' : '保存草稿'}</Text>
</Pressable>
<Pressable
style={[styles.submitBtn, submitting && styles.submitBtnDisabled]}
@ -582,6 +623,30 @@ const styles = StyleSheet.create({
fontWeight: '600',
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: {
flex: 2,
height: 46,

16
app/interview.tsx

@ -2,6 +2,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
@ -221,11 +222,9 @@ function ImprovementCard({
function RecentCard({ recent }: { recent: InterviewRecent[] }) {
return (
<View style={styles.card}>
{/* 原先这里挂着「全部记录 >」:项目内没有面试练习记录页,先摘掉 */}
<View style={styles.cardHeaderRow}>
<Text style={styles.cardTitle}>最近练习</Text>
<Pressable>
<Text style={styles.moreLink}>全部记录 &gt;</Text>
</Pressable>
</View>
{recent.map((item) => {
const color = COLOR_TOKENS[item.resultColor] ?? colors.primary;
@ -289,10 +288,15 @@ export default function InterviewScreen() {
await start();
};
// 右上角「分享」暂无对应页面,按项目约定弹提示(此前绑定空函数,点击无任何反馈)
const handleShare = () => {
Alert.alert('提示', '分享面试配置功能开发中,敬请期待');
};
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={() => {}} />
<Header onBack={() => router.back()} onShare={handleShare} />
<Loading />
</SafeAreaView>
);
@ -301,7 +305,7 @@ export default function InterviewScreen() {
if (error || !data) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={() => {}} />
<Header onBack={() => router.back()} onShare={handleShare} />
<ErrorState onRetry={refresh} />
</SafeAreaView>
);
@ -309,7 +313,7 @@ export default function InterviewScreen() {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={() => {}} />
<Header onBack={() => router.back()} onShare={handleShare} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<ModeCards modes={data.modes} active={mode} onSelect={setMode} />

217
app/job-evaluations.tsx

@ -1,12 +1,15 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@ -30,9 +33,8 @@ function Header({ onBack }: { onBack: () => void }) {
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>岗位评价管理</Text>
<Pressable style={styles.headerBtn} hitSlop={8}>
<Ionicons name="filter-outline" size={22} color={colors.textPrimary} />
</Pressable>
{/* 原先这里是「筛选」图标:筛选面板尚未定义,先摘掉,做筛选时再挂回来 */}
<View style={styles.headerBtn} />
</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 (
<View style={styles.card}>
<Text style={styles.cardTitle}>最新评价</Text>
@ -325,11 +333,25 @@ function ReviewList({ reviews }: { reviews: JobReview[] }) {
</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}>
<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>
)}
</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() {
const router = useRouter();
const { management, loading: dataLoading, error: dataError } = useJobEvaluations();
const {
management,
loading: dataLoading,
error: dataError,
refresh,
reply,
replyingId,
} = useJobEvaluations();
const { state: emptyState, loading: emptyLoading, error: emptyError } = useJobEvaluationEmptyState();
/** 正在回复的评价;null 表示弹层关闭 */
const [replying, setReplying] = useState<JobReview | null>(null);
const loading = dataLoading || emptyLoading;
const error = dataError || emptyError;
/** 提交回复:成功后重新拉数据(回复率与回复内容都要跟着变),失败给出可读提示 */
const submitReply = (content: string) => {
if (!replying) return;
void reply(replying.id, content)
.then(() => {
setReplying(null);
refresh();
})
.catch((e: unknown) => {
Alert.alert('回复失败', e instanceof Error ? e.message : '请稍后重试');
});
};
if (loading) {
return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}>
@ -386,10 +491,17 @@ export default function JobEvaluationsScreen() {
<TrendChart trend={management.trend} />
<RadarChart dimensions={management.dimensions} />
<JobScoreList jobs={management.jobScores} />
<ReviewList reviews={management.reviews} />
<ReviewList reviews={management.reviews} onReply={setReplying} />
</>
) : null}
</ScrollView>
<ReplyModal
review={replying}
submitting={replying !== null && replyingId === replying.id}
onCancel={() => setReplying(null)}
onSubmit={submitReply}
/>
</View>
);
}
@ -638,6 +750,93 @@ const styles = StyleSheet.create({
color: colors.primary,
fontWeight: '500',
},
/** 已回复:不再是按钮,只是一个状态说明 */
repliedText: {
fontSize: 12,
color: colors.successText,
fontWeight: '500',
},
/* ---- 回复内容块 ---- */
replyBlock: {
marginTop: 10,
padding: 10,
borderRadius: 8,
backgroundColor: colors.successLight,
},
replyBlockLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.successText,
},
replyBlockText: {
marginTop: 4,
fontSize: 13,
lineHeight: 20,
color: colors.textPrimary,
},
/* ---- 回复弹层 ---- */
modalMask: {
flex: 1,
backgroundColor: colors.scrim,
justifyContent: 'center',
paddingHorizontal: 28,
},
modalCard: {
backgroundColor: colors.surface,
borderRadius: 14,
padding: 18,
},
modalTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
modalQuote: {
marginTop: 8,
fontSize: 13,
lineHeight: 20,
color: colors.textSecondary,
},
modalInput: {
marginTop: 12,
minHeight: 88,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
color: colors.textPrimary,
textAlignVertical: 'top',
},
modalActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: 12,
marginTop: 16,
},
modalCancel: {
paddingHorizontal: 16,
paddingVertical: 10,
},
modalCancelText: {
fontSize: 14,
color: colors.textSecondary,
},
modalSubmit: {
paddingHorizontal: 18,
paddingVertical: 10,
borderRadius: 20,
backgroundColor: colors.primaryBtn,
},
modalSubmitOff: {
backgroundColor: colors.textDisabled,
},
modalSubmitText: {
fontSize: 14,
fontWeight: '600',
color: colors.white,
},
/* ---- 空状态 ---- */
emptyWrapper: {
marginBottom: 16,

126
app/onboarding.tsx

@ -4,15 +4,20 @@ import { useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { buildChecklistText } from '../src/api';
import { useOnboardingChecklist } from '../src/hooks';
import { shareText } from '../src/lib/shareFile';
import { colors, headerGradientShort } from '../src/theme';
import type { ChecklistGroup, OnboardingChecklist, OnboardingTip } from '../src/types';
@ -102,18 +107,22 @@ function CountdownCard({
function ChecklistGroupCard({
group,
onToggleItem,
onRemoveItem,
togglingId,
expanded,
onToggleExpand,
}: {
group: ChecklistGroup;
onToggleItem: (itemId: string, done: boolean) => void;
onRemoveItem: (itemId: string) => void;
togglingId: string | null;
expanded: boolean;
onToggleExpand: () => void;
}) {
const theme = GROUP_ICON_THEME[group.id] ?? GROUP_ICON_THEME.material;
const doneCount = group.items.filter((i) => i.done).length;
// 只有「我的自定义待办」这一组可以删条目:模板待办删了下次进页面还会回来
const removable = group.id === 'custom';
return (
<View style={styles.groupCard}>
<Pressable style={styles.groupHead} onPress={onToggleExpand}>
@ -154,6 +163,11 @@ function ChecklistGroupCard({
<Text style={styles.requiredText}>必填</Text>
</View>
)}
{removable && (
<Pressable onPress={() => onRemoveItem(item.id)} hitSlop={8}>
<Ionicons name="close-circle-outline" size={18} color={colors.textHint} />
</Pressable>
)}
</Pressable>
))}
</View>
@ -237,12 +251,16 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
export default function OnboardingScreen() {
const router = useRouter();
const { checklist, loading, error, refresh, toggle, togglingId } = useOnboardingChecklist();
const { checklist, loading, error, refresh, toggle, togglingId, addItem, removeItem, mutating } =
useOnboardingChecklist();
/** 本地清单状态(勾选后即时更新,避免等待接口往返) */
const [localChecklist, setLocalChecklist] = useState<OnboardingChecklist | null>(null);
const [expandedIds, setExpandedIds] = useState<string[]>([]);
const [reminderEnabled, setReminderEnabled] = useState(false);
/** 「添加自定义待办」弹窗 */
const [addVisible, setAddVisible] = useState(false);
const [draftTitle, setDraftTitle] = useState('');
useEffect(() => {
if (checklist && !localChecklist) {
@ -267,10 +285,42 @@ export default function OnboardingScreen() {
await toggle(itemId, done);
};
/** 添加自定义待办:接口层负责落库并返回新清单,页面直接换成返回值 */
const handleAddItem = async () => {
try {
const updated = await addItem(draftTitle);
setLocalChecklist(updated);
setDraftTitle('');
setAddVisible(false);
} catch (e) {
Alert.alert('添加失败', e instanceof Error ? e.message : '请稍后重试');
}
};
/** 删除自定义待办(模板条目接口层会拒绝) */
const handleRemoveItem = async (itemId: string) => {
try {
const updated = await removeItem(itemId);
setLocalChecklist(updated);
} catch (e) {
Alert.alert('删除失败', e instanceof Error ? e.message : '请稍后重试');
}
};
/**
* 导出 / 分享清单:把清单拼成文本交给系统分享面板。
* `ok` 只代表面板已拉起,不能据此提示「分享成功」(见 lib/shareFile 的说明)。
*/
const handleExport = async () => {
if (!display) return;
const outcome = await shareText(buildChecklistText(display), '入职准备清单');
if (!outcome.ok) Alert.alert('无法导出', outcome.message);
};
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={() => {}} />
<Header onBack={() => router.back()} onShare={() => void handleExport()} />
<Loading />
</SafeAreaView>
);
@ -279,7 +329,7 @@ export default function OnboardingScreen() {
if (error || !display) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={() => {}} />
<Header onBack={() => router.back()} onShare={() => void handleExport()} />
<ErrorState onRetry={refresh} />
</SafeAreaView>
);
@ -287,7 +337,7 @@ export default function OnboardingScreen() {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={() => {}} />
<Header onBack={() => router.back()} onShare={() => void handleExport()} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<CountdownCard
daysLeft={display.daysLeft}
@ -303,6 +353,7 @@ export default function OnboardingScreen() {
key={group.id}
group={group}
onToggleItem={handleToggleItem}
onRemoveItem={handleRemoveItem}
togglingId={togglingId}
expanded={expandedIds.includes(group.id)}
onToggleExpand={() =>
@ -325,14 +376,48 @@ export default function OnboardingScreen() {
</ScrollView>
<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} />
<Text style={styles.addBtnText}>添加自定义待办</Text>
</Pressable>
<Pressable style={styles.exportBtn}>
<Pressable style={styles.exportBtn} onPress={() => void handleExport()}>
<Text style={styles.exportBtnText}>导出清单</Text>
</Pressable>
</View>
{/* 添加自定义待办:Alert.prompt 只有 iOS 有,这里用自绘弹窗(与岗位评价回复一致) */}
<Modal visible={addVisible} transparent animationType="fade" onRequestClose={() => setAddVisible(false)}>
<View style={styles.modalMask}>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>添加自定义待办</Text>
<TextInput
style={styles.modalInput}
value={draftTitle}
onChangeText={setDraftTitle}
placeholder="例如:办理落户材料"
placeholderTextColor={colors.textHint}
maxLength={30}
autoFocus
/>
<View style={styles.modalActions}>
<Pressable style={styles.modalCancel} onPress={() => setAddVisible(false)}>
<Text style={styles.modalCancelText}>取消</Text>
</Pressable>
<Pressable
style={[styles.modalConfirm, (draftTitle.trim() === '' || mutating) && styles.footerBtnDisabled]}
onPress={() => void handleAddItem()}
disabled={draftTitle.trim() === '' || mutating}
>
<Text style={styles.modalConfirmText}>{mutating ? '添加中…' : '添加'}</Text>
</Pressable>
</View>
</View>
</View>
</Modal>
</SafeAreaView>
);
}
@ -485,4 +570,33 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
exportBtnText: { fontSize: 15, fontWeight: '700', color: colors.white },
footerBtnDisabled: { opacity: 0.5 },
modalMask: {
flex: 1,
backgroundColor: colors.scrim,
justifyContent: 'center',
paddingHorizontal: 32,
},
modalCard: { backgroundColor: colors.white, borderRadius: 16, padding: 20 },
modalTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
modalInput: {
marginTop: 14,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
color: colors.textPrimary,
},
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 12, marginTop: 18 },
modalCancel: { paddingHorizontal: 16, paddingVertical: 8 },
modalCancelText: { fontSize: 14, color: colors.textSecondary },
modalConfirm: {
backgroundColor: colors.primaryBtn,
borderRadius: 18,
paddingHorizontal: 20,
paddingVertical: 8,
},
modalConfirmText: { fontSize: 14, fontWeight: '700', color: colors.white },
});

14
app/progress.tsx

@ -33,16 +33,16 @@ const STAGE_THEME: Record<JobProgressStage, { bg: string; text: string }> = {
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack, onFilter }: { onBack: () => void; onFilter: () => void }) {
function Header({ onBack }: { 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}>求职进度</Text>
<Pressable style={styles.headerBtn} onPress={onFilter} hitSlop={8}>
<Ionicons name="funnel-outline" size={22} color={colors.textPrimary} />
</Pressable>
{/* 原右侧漏斗筛选按钮与页内 FilterTabs 功能重复且点击无反应,已移除。
此处保留同尺寸占位以维持标题居中。 */}
<View style={styles.headerBtn} />
</View>
);
}
@ -242,7 +242,7 @@ export default function ProgressScreen() {
if (loading) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onFilter={() => {}} />
<Header onBack={() => router.back()} />
<Loading />
</SafeAreaView>
);
@ -251,7 +251,7 @@ export default function ProgressScreen() {
if (error || !progress) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onFilter={() => {}} />
<Header onBack={() => router.back()} />
<ErrorState onRetry={refresh} />
</SafeAreaView>
);
@ -259,7 +259,7 @@ export default function ProgressScreen() {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onFilter={() => {}} />
<Header onBack={() => router.back()} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<StatsCard stats={progress.stats} />
<OfferAlert count={progress.offers.length} />

9
app/register.tsx

@ -2,6 +2,8 @@ import { OnboardingProvider, useOnboarding } from '../src/hooks';
import { P1Login } from '../components/onboarding/screens/P1Login';
import { P2PhoneCode } from '../components/onboarding/screens/P2PhoneCode';
import { P2QuickLogin } from '../components/onboarding/screens/P2QuickLogin';
import { P2PasswordLogin } from '../components/onboarding/screens/P2PasswordLogin';
import { P2PasswordReset } from '../components/onboarding/screens/P2PasswordReset';
import { P2WechatCode } from '../components/onboarding/screens/P2WechatCode';
import { P3Identity } from '../components/onboarding/screens/P3Identity';
import { P4Intent } from '../components/onboarding/screens/P4Intent';
@ -21,7 +23,6 @@ import { P18Education } from '../components/onboarding/screens/P18Education';
import { P20School } from '../components/onboarding/screens/P20School';
import { P21Avatar } from '../components/onboarding/screens/P21Avatar';
import { P22Done } from '../components/onboarding/screens/P22Done';
import { P23Home } from '../components/onboarding/screens/P23Home';
import { B1Wechat } from '../components/onboarding/screens/B1Wechat';
import { B2Agreement } from '../components/onboarding/screens/B2Agreement';
import { B3Recruiter } from '../components/onboarding/screens/B3Recruiter';
@ -32,6 +33,9 @@ function FlowRouter() {
case 'P1': return <P1Login />;
case 'P2': return <P2PhoneCode />;
case 'P2Q': return <P2QuickLogin />;
case 'P2P': return <P2PasswordLogin />;
case 'P2R': return <P2PasswordReset />;
// P2W / B1 为微信登录页:2026-09-15 按产品决定摘除入口,代码保留待启用(见 微信登录接入方案.md)
case 'P2W': return <P2WechatCode />;
case 'P3': return <P3Identity />;
case 'P4': return <P4Intent />;
@ -51,7 +55,8 @@ function FlowRouter() {
case 'P20': return <P20School />;
case 'P21': return <P21Avatar />;
case 'P22': return <P22Done />;
case 'P23': return <P23Home />;
// 原 P23「假首页」已删除:它是写死上海 4 条职位的演示页,与真实主界面 (tabs)/jobs 重复。
// 现在 P22(及 P6/P16 的快速通道)由 useRegisterFinish 直接 replace 进真主界面。
case 'B1': return <B1Wechat />;
case 'B2': return <B2Agreement />;
case 'B3': return <B3Recruiter />;

644
app/resume.tsx

@ -1,30 +1,41 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useFocusEffect, useRouter } from 'expo-router';
import { useCallback, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useResume } from '../src/hooks';
import { RESUME_EVAL_MAX_LENGTH, UNSET_LABEL } from '../src/constants';
import { colors } from '../src/theme';
import type { Resume as ResumeType, ResumeExperience, ResumeOptimizePoint, ResumeProject } from '../src/types';
import { formatDateTime } from '../src/utils';
/* ===================== 子组件:顶部导航 ===================== */
/**
* 顶部导航。`onSave` 可选:加载中 / 加载失败时简历尚未就绪,此时按钮置灰而不是给一个
* 点了没反应的入口(同 `app/resume/export.tsx` 的处理)。
*/
function Header({
onBack,
onSave,
onExport,
}: {
onBack: () => void;
onSave: () => void;
/** 传 undefined = 简历未就绪,按钮置灰 */
onSave?: () => void;
onExport: () => void;
}) {
const saveDisabled = onSave === undefined;
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
@ -32,10 +43,25 @@ function Header({
</Pressable>
<Text style={styles.headerTitle}>AI简历优化</Text>
<View style={styles.headerActions}>
<Pressable style={styles.headerActionBtn} onPress={onSave} hitSlop={8}>
<Ionicons name="save-outline" size={20} color={colors.textPrimary} />
<Pressable
style={styles.headerActionBtn}
onPress={onSave}
disabled={saveDisabled}
hitSlop={8}
accessibilityLabel="保存简历到本机"
>
<Ionicons
name="save-outline"
size={20}
color={saveDisabled ? colors.textDisabled : colors.textPrimary}
/>
</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} />
</Pressable>
</View>
@ -92,8 +118,12 @@ function ScoreCard({
function OptimizeCard({
points,
onOptimizeAll,
optimizing,
}: {
points: ResumeOptimizePoint[];
onOptimizeAll: () => void;
optimizing: boolean;
}) {
return (
<View style={styles.card}>
@ -112,8 +142,12 @@ function OptimizeCard({
<Text style={[styles.optScore, p.optimized && styles.optScoreDone]}>+{p.score}分</Text>
</View>
))}
<Pressable style={styles.optAllBtn}>
<Text style={styles.optAllText}>一键全部优化</Text>
<Pressable
style={[styles.optAllBtn, optimizing && styles.optAllBtnDisabled]}
onPress={onOptimizeAll}
disabled={optimizing}
>
<Text style={styles.optAllText}>{optimizing ? '优化中…' : '一键全部优化'}</Text>
</Pressable>
</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 (
<View style={styles.card}>
<View style={styles.sectionTitleRow}>
<Text style={styles.sectionTitle}>基本信息</Text>
<Pressable>
<Pressable onPress={onEdit} hitSlop={6}>
<Text style={styles.editLink}>编辑基本信息 &gt;</Text>
</Pressable>
</View>
<View style={styles.basicRow}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{info.avatar}</Text>
<Text style={styles.avatarText}>{name.slice(0, 1)}</Text>
</View>
<View style={styles.basicBody}>
<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} />
</View>
<View style={styles.contactRow}>
<Ionicons name="call-outline" size={14} color={colors.textHint} />
<Text style={styles.contactText}>{info.phone}</Text>
</View>
<View style={styles.contactRow}>
<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>
{/* 空值不渲染空行:手机号 / 邮箱引导未采集,画一行空白比不画更让人困惑 */}
{rows.map((row) => (
<View key={row.key} style={styles.contactRow}>
<Ionicons name={row.icon} size={14} color={colors.textHint} />
<Text style={styles.contactText}>{row.value}</Text>
</View>
))}
</View>
</View>
</View>
@ -160,11 +197,13 @@ function BasicInfoCard({ info }: { info: ResumeType['basicInfo'] }) {
/* ===================== 子组件:求职意向 ===================== */
function JobIntentionCard({ intention }: { intention: ResumeType['jobIntention'] }) {
const router = useRouter();
return (
<View style={styles.card}>
<View style={styles.sectionTitleRow}>
<Text style={styles.sectionTitle}>求职意向</Text>
<Pressable>
{/* 指向已存在的求职意向编辑页(此前是无 onPress 的死按钮) */}
<Pressable onPress={() => router.push('/job-preference')} hitSlop={6}>
<Text style={styles.editLink}>编辑 &gt;</Text>
</Pressable>
</View>
@ -175,7 +214,8 @@ function JobIntentionCard({ intention }: { intention: ResumeType['jobIntention']
<IntentionItem
label="求职状态"
value={intention.statusLabel}
tag={intention.status === 'active'}
// 无状态文案时不打「积极求职中」的彩色标签:颜色本身也是一句话
tag={intention.status === 'active' && intention.statusLabel !== ''}
/>
</View>
</View>
@ -191,15 +231,16 @@ function IntentionItem({
value: string;
tag?: boolean;
}) {
const text = value || UNSET_LABEL;
return (
<View style={styles.intentionItem}>
<Text style={styles.intentionLabel}>{label}</Text>
{tag ? (
<View style={styles.intentionTag}>
<Text style={styles.intentionTagText}>{value}</Text>
<Text style={styles.intentionTagText}>{text}</Text>
</View>
) : (
<Text style={styles.intentionValue}>{value}</Text>
<Text style={styles.intentionValue}>{text}</Text>
)}
</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 (
<View style={styles.card}>
<View style={styles.sectionTitleRow}>
<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} />
<Text style={styles.addText}>添加教育经历</Text>
</Pressable>
</View>
{education.map((edu) => (
<View key={edu.id} style={styles.eduRow}>
</View>
{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>
<Text style={styles.eduSchool}>{edu.school}</Text>
<Text style={styles.eduMajor}>
{edu.major} · {edu.degree}
</Text>
</View>
<Text style={styles.eduYear}>
{edu.startYear}-{edu.endYear}
</Text>
{majorDegree !== '' && <Text style={styles.eduMajor}>{majorDegree}</Text>}
</View>
))}
{years !== '' && <Text style={styles.eduYear}>{years}</Text>}
</Pressable>
);
})}
</View>
);
}
@ -238,38 +296,63 @@ function EducationCard({ education }: { education: ResumeType['education'] }) {
function ExperienceCard({
experiences,
onAdd,
onEdit,
onItem,
onOptimize,
aiBusy,
}: {
experiences: ResumeExperience[];
onAdd: () => void;
onEdit: () => void;
onItem: (id: string) => void;
onOptimize: (id: string) => void;
/** AI 请求进行中:按钮禁用,避免连点堆起多个请求 */
aiBusy: boolean;
}) {
return (
<View style={styles.card}>
<View style={styles.sectionTitleRow}>
<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} />
<Text style={styles.addText}>添加工作经历</Text>
</Pressable>
</View>
</View>
{experiences.length === 0 && <Text style={styles.emptyText}>未填写工作经历</Text>}
{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}>
<Text style={styles.expCompany}>{exp.company}</Text>
{/* 离职时结束时间未采集(留空),只展示已采集到的那一端,不补「-」 */}
{[exp.startDate, exp.endDate].filter(Boolean).length > 0 && (
<Text style={styles.expDate}>
{exp.startDate} - {exp.endDate}
{[exp.startDate, exp.endDate].filter(Boolean).join(' - ')}
</Text>
)}
</View>
<Text style={styles.expPosition}>{exp.position}</Text>
<Text style={styles.expDesc}>{exp.description}</Text>
{exp.position !== '' && <Text style={styles.expPosition}>{exp.position}</Text>}
{exp.description !== '' && <Text style={styles.expDesc}>{exp.description}</Text>}
{!exp.aiOptimized && (
<View style={styles.aiTip}>
<Ionicons name="sparkles" size={14} color={colors.info} />
<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>
</Pressable>
</View>
)}
</View>
</Pressable>
))}
</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 (
<View style={styles.card}>
<View style={styles.sectionTitleRow}>
<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} />
<Text style={styles.addText}>添加项目经历</Text>
</Pressable>
</View>
</View>
{projects.length === 0 && <Text style={styles.emptyText}>未填写项目经历</Text>}
{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}>
<Text style={styles.expCompany}>{proj.name}</Text>
<Text style={styles.expPosition}>{proj.role}</Text>
@ -300,7 +399,7 @@ function ProjectCard({ projects }: { projects: ResumeProject[] }) {
<Text style={styles.optimizedText}>已量化</Text>
</View>
)}
</View>
</Pressable>
))}
</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 (
<View style={styles.card}>
<View style={styles.sectionTitleRow}>
<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} />
<Text style={styles.addText}>添加技能</Text>
</Pressable>
</View>
<View style={styles.skillsWrap}>
{skills.length === 0 && <Text style={styles.emptyText}>未填写技能特长</Text>}
{skills.map((skill) => (
<View key={skill.name} style={styles.skillTag}>
<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 (
<View style={styles.card}>
<View style={styles.sectionTitleRow}>
<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>
</Pressable>
</View>
</View>
{content === '' ? (
<Text style={styles.emptyText}>未填写自我评价</Text>
) : (
<Text style={styles.evalText}>{content}</Text>
)}
</View>
);
}
@ -372,35 +648,127 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
export default function ResumeScreen() {
const router = useRouter();
const { resume, loading, error, refresh, optimize } = useResume();
const [currentResume, setCurrentResume] = useState<ResumeType | null>(null);
const {
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);
/** 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 () => {
if (optimizing) return;
setOptimizing(true);
try {
const optimized = await optimize();
setCurrentResume(optimized);
setOptimizedScore(optimized.score);
} finally {
setOptimizing(false);
}
};
if (loading) {
const handleSave = async () => {
if (displayResume === null || saving) return;
try {
const { savedAt: at } = await save(displayResume);
Alert.alert('已保存到本机', `保存时间 ${formatDateTime(at)}`);
} catch {
Alert.alert('保存失败', '请稍后重试');
}
};
/* ---------- AI 动作:能力未接入时只如实说明,不动简历 ---------- */
const runAIAction = async (action: () => Promise<{ message: string }>) => {
if (aiBusy) return;
setAiBusy(true);
try {
const result = await action();
Alert.alert('暂未开放', result.message);
} finally {
setAiBusy(false);
}
};
/* ---------- 技能:删除即时落盘,添加是一次写入 ---------- */
const skills = displayResume?.skills ?? [];
const handleAddSkill = async (name: string) => {
if (saving) return;
await saveSkills([...skills, { name }]);
setSkillDraft('');
};
const handleRemoveSkill = (name: string) => {
if (saving) return;
void saveSkills(skills.filter((s) => s.name !== name));
};
/* ---------- 自我评价 ---------- */
const openEvalModal = () => {
setEvalDraft(displayResume?.selfEvaluation.content ?? '');
setEvalModal(true);
};
const handleSaveEvaluation = async () => {
if (saving) return;
await saveSelfEvaluation(evalDraft);
setEvalModal(false);
};
/*
* 只在「还没有任何数据」时显示加载态。
* 本页每次获得焦点都会 refresh(编辑页可能写过覆盖层),
* 若按 `loading` 直接渲染骨架屏,从编辑页返回时会整页白闪一次 —— 刚改完的内容,
* 用户看到的却是一次全屏加载。
*/
if (loading && displayResume === null) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onSave={() => {}} onExport={() => {}} />
<Header onBack={() => router.back()} onExport={() => router.push('/resume/export')} />
<Loading />
</SafeAreaView>
);
}
if (error || !displayResume) {
if (error !== null || displayResume === null) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onSave={() => {}} onExport={() => {}} />
<Header onBack={() => router.back()} onExport={() => router.push('/resume/export')} />
<ErrorState onRetry={refresh} />
</SafeAreaView>
);
@ -410,7 +778,7 @@ export default function ResumeScreen() {
<SafeAreaView style={styles.container} edges={['top']}>
<Header
onBack={() => router.back()}
onSave={() => {}}
onSave={handleSave}
onExport={() => router.push('/resume/export')}
/>
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
@ -420,18 +788,50 @@ export default function ResumeScreen() {
onOptimize={handleOptimize}
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} />
<EducationCard education={displayResume.education} />
<ExperienceCard experiences={displayResume.experience} />
<ProjectCard projects={displayResume.projects} />
<SkillsCard skills={displayResume.skills} />
<EvaluationCard content={displayResume.selfEvaluation.content} />
<EducationCard
education={displayResume.education}
onAdd={() => router.push('/resume/education?new=1')}
onEdit={() => router.push('/resume/education')}
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>
<View style={styles.footer}>
<Pressable style={styles.previewBtn}>
<Pressable
style={styles.previewBtn}
onPress={() => router.push('/resume/preview')}
hitSlop={6}
>
<Text style={styles.previewBtnText}>预览</Text>
</Pressable>
<Pressable
@ -446,6 +846,28 @@ export default function ResumeScreen() {
)}
</Pressable>
</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>
);
}
@ -529,6 +951,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
optAllText: { fontSize: 14, fontWeight: '700', color: colors.primaryText },
optAllBtnDisabled: { opacity: 0.7 },
sectionTitleRow: {
flexDirection: 'row',
alignItems: 'center',
@ -536,7 +959,10 @@ const styles = StyleSheet.create({
marginBottom: 14,
},
sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
sectionActions: { flexDirection: 'row', alignItems: 'center', gap: 14 },
editLink: { fontSize: 13, color: colors.primary },
/** 「上次保存」那行:只在真的保存过之后出现 */
savedAt: { fontSize: 12, color: colors.textHint, marginBottom: 12, marginTop: -4 },
basicRow: { flexDirection: 'row', alignItems: 'center' },
avatar: {
width: 60,
@ -619,8 +1045,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 6,
},
/** 弹层里的标签可点删除:补一个 × 图标,光靠「可点击」没有视觉线索 */
skillTagEditable: { flexDirection: 'row', alignItems: 'center', gap: 6 },
skillText: { fontSize: 13, color: colors.textPrimary },
evalText: { fontSize: 13, color: colors.textSecondary, lineHeight: 22 },
/** 引导未采集该板块时的空态文案(不伪装成已填写) */
emptyText: { fontSize: 13, color: colors.textHint, marginTop: 4 },
footer: {
position: 'absolute',
left: 0,
@ -652,4 +1082,76 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
optimizeFooterBtnText: { fontSize: 15, fontWeight: '700', color: colors.white },
/* ---------- 技能 / 自我评价弹层 ---------- */
modalMask: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0, 0, 0, 0.4)' },
modalSheet: {
backgroundColor: colors.white,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 28,
maxHeight: '80%',
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 14,
},
modalTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
modalBody: { maxHeight: 180 },
modalLabel: { fontSize: 13, color: colors.textHint, marginBottom: 10 },
modalInputRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 16 },
modalInput: {
flex: 1,
height: 46,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 14,
fontSize: 15,
color: colors.textPrimary,
},
modalAddBtn: {
backgroundColor: colors.primaryBtn,
borderRadius: 12,
paddingHorizontal: 18,
paddingVertical: 13,
},
modalAddBtnDisabled: { backgroundColor: colors.border },
modalAddText: { fontSize: 14, fontWeight: '700', color: colors.white },
modalError: { fontSize: 12, color: colors.dangerText, marginTop: 8 },
evalInput: {
height: 140,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 14,
paddingTop: 12,
fontSize: 15,
lineHeight: 22,
color: colors.textPrimary,
textAlignVertical: 'top',
},
evalCounter: { fontSize: 12, color: colors.textHint, textAlign: 'right', marginTop: 6 },
modalFooter: { flexDirection: 'row', gap: 12, marginTop: 18 },
modalCancelBtn: {
flex: 1,
borderRadius: 24,
paddingVertical: 14,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
},
modalCancelText: { fontSize: 15, fontWeight: '700', color: colors.textSecondary },
modalSaveBtn: {
flex: 2,
backgroundColor: colors.primaryBtn,
borderRadius: 24,
paddingVertical: 14,
alignItems: 'center',
},
modalSaveBtnDisabled: { backgroundColor: colors.textDisabled },
modalSaveText: { fontSize: 15, fontWeight: '700', color: colors.white },
});

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

355
app/resume/export.tsx

@ -3,6 +3,8 @@ import { useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
Modal,
Pressable,
ScrollView,
StyleSheet,
@ -12,9 +14,17 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useResumeExport } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { ResumeExportFormat, ResumeExportSettings, ResumeTemplate } from '../../src/types';
import {
RESUME_EXPORT_FORMAT_NOTICE,
RESUME_EXPORT_PASSWORD_NOTICE,
RESUME_FILE_NAME_HINT,
RESUME_ORIENTATIONS,
RESUME_PAPER_SIZES,
RESUME_TEMPLATES,
} from '../../src/constants';
import { useResume, useResumeExport } from '../../src/hooks';
import { colors, templateAccentColors } from '../../src/theme';
import type { ResumeExportFormat, ResumeExportSettings } from '../../src/types';
const FORMATS: { key: ResumeExportFormat; label: string; icon: keyof typeof Ionicons.glyphMap }[] = [
{ key: 'pdf', label: 'PDF', icon: 'document-outline' },
@ -22,44 +32,41 @@ const FORMATS: { key: ResumeExportFormat; label: string; icon: keyof typeof Ioni
{ key: 'image', label: '图片', icon: 'image-outline' },
];
const TEMPLATES: ResumeTemplate[] = [
{ id: 'classic', name: '简约经典', color: 'primary' },
{ id: 'creative', name: '创意设计', color: 'info' },
{ id: 'tech', name: '技术专业', color: 'success' },
];
const TEMPLATE_COLORS: Record<string, string> = {
primary: colors.primary,
info: colors.info,
success: colors.success,
};
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack, onSave }: { onBack: () => void; onSave: () => void }) {
/**
* 顶部导航。onSave 可选:加载中 / 加载失败时设置尚未就绪(localSettings 为 null),
* 此时不渲染「保存」按钮,避免出现点了无反应的入口(比照 progress.tsx 移除无效按钮的处理)。
* 该态下仍渲染同尺寸占位,维持标题居中。
*/
function Header({ onBack, onSave }: { onBack: () => void; onSave?: () => void }) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>导出简历</Text>
{onSave ? (
<Pressable style={styles.headerBtn} onPress={onSave} hitSlop={8}>
<Text style={styles.headerSave}>保存</Text>
</Pressable>
) : (
<View style={styles.headerBtn} />
)}
</View>
);
}
/* ===================== 子组件:预览区 ===================== */
/* ===================== 子组件:预览入口 ===================== */
function PreviewCard() {
function PreviewCard({ onOpen }: { onOpen: () => void }) {
return (
<View style={styles.previewCard}>
<View style={styles.previewThumb}>
<Ionicons name="document-text-outline" size={48} color={colors.primary} />
</View>
<Text style={styles.previewText}>实时预览你的简历效果</Text>
<Pressable style={styles.previewLink}>
<Pressable style={styles.previewLink} onPress={onOpen} hitSlop={8}>
<Text style={styles.previewLinkText}>打开预览</Text>
<Ionicons name="chevron-forward" size={14} color={colors.primary} />
</Pressable>
@ -100,6 +107,13 @@ function FormatSelect({
);
})}
</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>
);
}
@ -109,20 +123,22 @@ function FormatSelect({
function TemplateSelect({
value,
onChange,
onMore,
}: {
value: string;
onChange: (v: string) => void;
onMore: () => void;
}) {
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>选择简历模板</Text>
<Pressable>
<Pressable onPress={onMore} hitSlop={8}>
<Text style={styles.moreLink}>全部模板 &gt;</Text>
</Pressable>
</View>
<View style={styles.templateRow}>
{TEMPLATES.map((t) => {
{RESUME_TEMPLATES.map((t) => {
const selected = value === t.id;
return (
<Pressable
@ -130,10 +146,12 @@ function TemplateSelect({
style={[styles.templateCard, selected && styles.templateCardActive]}
onPress={() => onChange(t.id)}
>
{/* TODO(素材):需 UI 出 3 张模板预览图(简约经典 / 创意设计 / 技术专业),
当前用模板强调色色块 + 线条占位 */}
<View
style={[
styles.templateThumb,
{ backgroundColor: TEMPLATE_COLORS[t.color] ?? colors.primary },
{ backgroundColor: templateAccentColors[t.color] ?? colors.primary },
]}
>
<View style={styles.templateLines}>
@ -158,11 +176,13 @@ function TemplateSelect({
function ToggleRow({
label,
desc,
notice,
value,
onChange,
}: {
label: string;
desc?: string;
notice?: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
@ -171,6 +191,7 @@ function ToggleRow({
<View style={styles.toggleText}>
<Text style={styles.toggleLabel}>{label}</Text>
{desc && <Text style={styles.toggleDesc}>{desc}</Text>}
{notice && <Text style={styles.noticeText}>{notice}</Text>}
</View>
<Switch
value={value}
@ -182,22 +203,27 @@ function ToggleRow({
);
}
/* ===================== 子组件:输入行 ===================== */
/* ===================== 子组件:输入行 / 选择行 ===================== */
function InputRow({
label,
value,
onChange,
placeholder,
hint,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
hint?: string;
}) {
return (
<View style={styles.inputRow}>
<View style={styles.inputLabelBox}>
<Text style={styles.inputLabel}>{label}</Text>
{hint && <Text style={styles.inputHint}>{hint}</Text>}
</View>
<TextInput
style={styles.input}
value={value}
@ -209,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() {
@ -232,12 +332,31 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
);
}
/* ===================== 工具:storage 值 → 合法设置值 ===================== */
/** 弹层回传字符串,白名单校验后才写入设置(storage 里的值不保证是这两个之一) */
function pickPaperSize(value: string): ResumeExportSettings['paperSize'] {
return value === 'letter' ? 'letter' : 'A4';
}
function pickOrientation(value: string): ResumeExportSettings['orientation'] {
return value === 'landscape' ? 'landscape' : 'portrait';
}
/* ===================== 页面 ===================== */
export default function ResumeExportScreen() {
const router = useRouter();
const { settings, loading, error, refresh, save, exportFile, exporting } = useResumeExport();
const { settings, loading, error, refresh, save, exportFile, share, exporting, sharing } =
useResumeExport();
/**
* AI 定向优化走简历域的 hook(页面不直连 API)。
* 这里只取 `optimizeWithJD`,本页不渲染简历数据。
*/
const { optimizeWithJD } = useResume();
const [localSettings, setLocalSettings] = useState<ResumeExportSettings | null>(null);
const [sheet, setSheet] = useState<SheetState | null>(null);
const [optimizing, setOptimizing] = useState(false);
useEffect(() => {
if (settings && !localSettings) {
@ -249,22 +368,71 @@ export default function ResumeExportScreen() {
setLocalSettings((prev) => (prev ? { ...prev, [key]: value } : prev));
};
/** 只落盘不带提示:导出 / 分享前先把设置保存好,避免导出用的是一份没保存的配置 */
const persist = async () => {
if (localSettings) await save(localSettings);
};
const handleSave = async () => {
if (localSettings) {
await save(localSettings);
}
await persist();
Alert.alert('已保存', '导出设置已保存在本机,下次进入仍是这份设置。');
};
const handleExport = async () => {
if (!localSettings) return;
await handleSave();
await exportFile(localSettings);
await persist();
const outcome = await exportFile(localSettings);
if (!outcome.ok) {
Alert.alert('导出失败', outcome.message);
return;
}
Alert.alert(
'导出成功',
[
`已生成 ${outcome.fileName}(共 ${outcome.pages} 页)。`,
'文件在本机缓存中,点「分享」可保存到「文件」或发送给他人;缓存可能被系统清理,需要长期留存请先分享出去。',
localSettings.format === 'pdf' ? '' : RESUME_EXPORT_FORMAT_NOTICE,
]
.filter((line) => line !== '')
.join('\n\n'),
[
{ text: '知道了', style: 'cancel' },
{ text: '分享', onPress: () => void handleShare() },
],
);
};
const handleShare = async () => {
if (!localSettings) return;
await persist();
const outcome = await share(localSettings);
// 成功不弹提示:系统分享面板本身就是反馈;失败必须说清原因
if (!outcome.ok) Alert.alert('无法分享', outcome.message);
};
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 (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onSave={() => {}} />
<Header onBack={() => router.back()} />
<Loading />
</SafeAreaView>
);
@ -273,7 +441,7 @@ export default function ResumeExportScreen() {
if (error || !localSettings) {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onSave={() => {}} />
<Header onBack={() => router.back()} />
<ErrorState onRetry={refresh} />
</SafeAreaView>
);
@ -283,11 +451,27 @@ export default function ResumeExportScreen() {
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onSave={handleSave} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
<PreviewCard />
<PreviewCard onOpen={() => router.push('/resume/preview')} />
<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}>
<Text style={styles.sectionTitle}>导出设置</Text>
@ -301,24 +485,55 @@ export default function ResumeExportScreen() {
<ToggleRow
label="导出密码"
desc="为导出的简历设置查看密码"
notice={localSettings.exportPassword ? RESUME_EXPORT_PASSWORD_NOTICE : undefined}
value={localSettings.exportPassword}
onChange={(v) => update('exportPassword', v)}
/>
<InputRow label="纸型" value={localSettings.paperSize} onChange={() => {}} />
<InputRow label="方向" value={localSettings.orientation === 'portrait' ? '纵向' : '横向'} onChange={() => {}} />
<SelectRow
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
label="文件名"
value={localSettings.fileName}
onChange={(v) => update('fileName', v)}
placeholder="请输入文件名"
hint={RESUME_FILE_NAME_HINT}
/>
<ToggleRow
label="包含封面页"
desc="封面含姓名、期望职位与联系方式"
value={localSettings.includeCover}
onChange={(v) => update('includeCover', v)}
/>
<ToggleRow
label="包含自荐信"
desc="以自我评价内容生成;自我评价为空时不生成"
value={localSettings.includeRecommendation}
onChange={(v) => update('includeRecommendation', v)}
/>
@ -339,17 +554,37 @@ export default function ResumeExportScreen() {
numberOfLines={4}
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} />
<Text style={styles.targetBtnText}>AI 智能优化</Text>
</>
)}
</Pressable>
{/* 按钮置灰时把原因写在旁边:灰按钮不给理由,用户只会以为坏了 */}
{jd === '' && <Text style={styles.noticeText}>请先粘贴岗位描述,再点优化</Text>}
</View>
</View>
</ScrollView>
<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>
)}
</Pressable>
<Pressable
style={[styles.exportBtn, exporting && styles.exportBtnDisabled]}
@ -363,6 +598,13 @@ export default function ResumeExportScreen() {
)}
</Pressable>
</View>
{sheet !== null && (
<OptionSheet
sheet={sheet}
onClose={() => setSheet(null)}
/>
)}
</SafeAreaView>
);
}
@ -430,6 +672,8 @@ const styles = StyleSheet.create({
formatLabelActive: { color: colors.primaryText },
formatHint: { fontSize: 10, color: colors.textHint, marginTop: 2 },
formatCheck: { position: 'absolute', top: 8, right: 8 },
noticeRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 10 },
noticeText: { fontSize: 12, color: colors.textHint, lineHeight: 17, marginTop: 4 },
templateRow: { flexDirection: 'row', gap: 10 },
templateCard: {
flex: 1,
@ -480,13 +724,17 @@ const styles = StyleSheet.create({
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
inputLabelBox: { flex: 1, marginRight: 12 },
inputLabel: { fontSize: 14, color: colors.textPrimary, width: 80 },
inputHint: { fontSize: 11, color: colors.textHint, marginTop: 2 },
input: {
flex: 1,
fontSize: 14,
color: colors.textPrimary,
textAlign: 'right',
},
selectValue: { flexDirection: 'row', alignItems: 'center', gap: 4 },
selectValueText: { fontSize: 14, color: colors.textSecondary },
targetLabel: { fontSize: 13, color: colors.textSecondary, marginBottom: 10 },
targetInput: {
height: 100,
@ -505,7 +753,37 @@ const styles = StyleSheet.create({
paddingVertical: 12,
marginTop: 12,
},
targetBtnDisabled: { backgroundColor: colors.textDisabled },
targetBtnText: { fontSize: 14, fontWeight: '700', color: colors.white, marginLeft: 6 },
scrim: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'flex-end' },
sheet: {
backgroundColor: colors.white,
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
paddingHorizontal: 20,
paddingTop: 18,
paddingBottom: 28,
},
sheetTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, marginBottom: 8 },
sheetRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
sheetSwatch: { width: 22, height: 22, borderRadius: 6, marginRight: 12 },
sheetLabel: { flex: 1, fontSize: 15, color: colors.textPrimary },
sheetLabelActive: { color: colors.primaryText, fontWeight: '700' },
sheetHint: { fontSize: 12, color: colors.textHint, marginTop: 12, lineHeight: 18 },
sheetCancel: {
marginTop: 16,
paddingVertical: 12,
borderRadius: 24,
alignItems: 'center',
backgroundColor: colors.pageBg,
},
sheetCancelText: { fontSize: 15, fontWeight: '600', color: colors.textSecondary },
footer: {
position: 'absolute',
left: 0,
@ -528,6 +806,7 @@ const styles = StyleSheet.create({
borderColor: colors.border,
marginRight: 12,
},
shareBtnDisabled: { borderColor: colors.borderLight },
shareBtnText: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
exportBtn: {
flex: 2,

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 { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../src/theme';
import { useAppCache } from '../src/hooks';
import { formatBytes } from '../src/utils';
import { useAuth } from '../src/context/AuthContext';
import { useRole } from '../src/context/RoleContext';
@ -62,7 +64,9 @@ const SETTINGS_GROUPS: SettingsGroup[] = [
{
title: '通用',
items: [
{ id: 'cache', title: '清除缓存', icon: 'trash-outline', value: '12.8MB' },
{ id: 'guide', title: '查看引导页', icon: 'compass-outline', route: '/guide' },
// 缓存体积是运行时读出来的,不写死:实际值在渲染时从 useAppCache 取
{ id: 'cache', title: '清除缓存', icon: 'trash-outline' },
{
id: 'about',
title: '关于我们',
@ -93,6 +97,28 @@ export default function SettingsScreen() {
const router = useRouter();
const { logout } = useAuth();
const { role, setRole } = useRole();
const { label: cacheLabel, loading: cacheLoading, clear: clearCache, clearing } = useAppCache();
/** 清缓存:二次确认后再删,清完按实际清掉的体积给反馈(清不到就说清不到) */
const handleClearCache = () => {
if (clearing) return;
Alert.alert('清除缓存', `将清空本机缓存文件(当前 ${cacheLabel || '读取中'})。账号、已填资料与简历不受影响。`, [
{ text: '取消', style: 'cancel' },
{
text: '清除',
style: 'destructive',
onPress: () => {
void clearCache()
.then((freed) => {
Alert.alert('已清除缓存', freed > 0 ? `本次清理 ${formatBytes(freed)}` : '没有可清理的缓存文件');
})
.catch((e: unknown) => {
Alert.alert('清除失败', e instanceof Error ? e.message : '请稍后重试');
});
},
},
]);
};
const handleLogout = () => {
Alert.alert('确认退出登录', '退出后需要重新登录', [
@ -123,6 +149,10 @@ export default function SettingsScreen() {
handleIdentitySwitch();
return;
}
if (item.id === 'cache') {
handleClearCache();
return;
}
if (item.route) {
router.push(item.route);
return;
@ -155,7 +185,14 @@ export default function SettingsScreen() {
<Ionicons name={item.icon} size={18} color={colors.primaryDark} />
</View>
<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} />
</Pressable>
);

211
app/settings/password.tsx

@ -0,0 +1,211 @@
import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useChangePassword } from '../../src/hooks';
import { useAuth } from '../../src/context/AuthContext';
import { PasswordField } from '../../src/components/PasswordField';
import { colors } from '../../src/theme';
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack }: { 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}>修改登录密码</Text>
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 页面 ===================== */
/**
* 账号与安全 → 修改登录密码。
*
* 入口由 MOCK_ACCOUNT_SECURITY 的 route 字段驱动(见 src/api/api.ts),
* 此前该菜单项没有 route,点击无任何反应。
* 改密成功后强制重新登录,避免旧会话继续持有已失效的密码凭据。
*/
export default function ChangePasswordScreen() {
const router = useRouter();
const { logout } = useAuth();
const { change, loading, error, checkNewPassword } = useChangePassword();
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
/** 规则与一致性校验在 API 层计算,这里只渲染文案 */
const hint =
newPassword.length > 0 ? checkNewPassword(oldPassword, newPassword, confirmPassword) : null;
const canSubmit =
oldPassword.length > 0 &&
newPassword.length > 0 &&
confirmPassword.length > 0 &&
hint === null &&
!loading;
const handleSubmit = async () => {
if (!canSubmit) return;
try {
await change({ oldPassword, newPassword, confirmPassword });
} catch {
// 失败原因由 useChangePassword 记录,页面下方展示
return;
}
Alert.alert('密码已修改', '请使用新密码重新登录', [
{
text: '重新登录',
onPress: () => {
logout();
router.replace('/register');
},
},
]);
};
return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} />
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<Text style={styles.tip}>
为保障账号安全,修改密码后需要使用新密码重新登录。
</Text>
<View style={styles.card}>
<PasswordField
value={oldPassword}
onChangeText={setOldPassword}
placeholder="请输入原密码"
/>
<View style={styles.gap} />
<PasswordField
value={newPassword}
onChangeText={setNewPassword}
placeholder="请输入新密码"
/>
<View style={styles.gap} />
<PasswordField
value={confirmPassword}
onChangeText={setConfirmPassword}
placeholder="请再次输入新密码"
/>
</View>
{hint ? (
<Text style={styles.hintDanger}>{hint}</Text>
) : (
<Text style={styles.hint}>8–20 位,需同时包含字母和数字</Text>
)}
{error ? <Text style={styles.formError}>{error.message}</Text> : null}
<Pressable
style={[styles.submitBtn, !canSubmit && styles.submitDisabled]}
disabled={!canSubmit}
onPress={handleSubmit}
>
<Text style={styles.submitText}>{loading ? '提交中…' : '确认修改'}</Text>
</Pressable>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
flex: 1,
textAlign: 'center',
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: 12,
paddingBottom: 24,
},
tip: {
fontSize: 12,
lineHeight: 17,
color: colors.textSecondary,
marginBottom: 10,
paddingHorizontal: 4,
},
card: {
backgroundColor: colors.white,
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 6,
},
gap: {
height: 6,
},
hint: {
marginTop: 12,
paddingHorizontal: 4,
fontSize: 12,
color: colors.textHint,
},
hintDanger: {
marginTop: 12,
paddingHorizontal: 4,
fontSize: 12,
color: colors.danger,
},
formError: {
marginTop: 10,
paddingHorizontal: 4,
fontSize: 12,
color: colors.danger,
},
submitBtn: {
marginTop: 24,
height: 50,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primaryBtn,
},
submitDisabled: {
backgroundColor: colors.textDisabled,
},
submitText: {
fontSize: 16,
fontWeight: '700',
color: colors.white,
},
});

29
app/settings/privacy.tsx

@ -65,19 +65,29 @@ function SettingRow({
const titleColor = isDanger ? colors.dangerText : colors.textPrimary;
const handlePress = () => {
if (item.route) {
router.push(item.route);
return;
}
if (item.route) router.push(item.route);
};
if (item.type === 'switch') {
return (
<View style={[styles.row, !isLast && styles.rowBorder]}>
const rowBody = (
<View style={styles.rowBody}>
<Text style={styles.rowTitle}>{item.title}</Text>
{item.description ? <Text style={styles.rowDesc}>{item.description}</Text> : null}
</View>
);
// 开关型条目:带 route 的(如「勿扰模式」「真实姓名可见性」)点击左侧文字区进详情页,
// 右侧 Switch 保持独立可切换,两者互不干扰。
if (item.type === 'switch') {
return (
<View style={[styles.row, !isLast && styles.rowBorder]}>
{item.route ? (
<Pressable style={styles.rowTapArea} onPress={handlePress} hitSlop={4}>
{rowBody}
<Ionicons name="chevron-forward" size={18} color={colors.textHint} />
</Pressable>
) : (
rowBody
)}
<Switch
value={item.enabled}
onValueChange={(next) => onToggle?.(item.id, next)}
@ -270,6 +280,11 @@ const styles = StyleSheet.create({
flex: 1,
marginRight: 12,
},
rowTapArea: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
rowTitle: {
fontSize: 15,
fontWeight: '600',

274
app/tools/salary-calculator.tsx

@ -13,16 +13,24 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSalaryCalculator } from '../../src/hooks';
import { useSalaryCalculator, useSalaryRecords, useSalaryToolLinks } from '../../src/hooks';
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
import { colors, headerGradientShort } from '../../src/theme';
import {
CITIES,
DEFAULT_SOCIAL_PROFILE,
SOCIAL_SECURITY_CITY_PROFILES,
} from '../../src/constants';
import type { SalaryCalcInput, SalaryToolLink } from '../../src/types';
import type { SalaryCalcInput, SalaryCalcRecord, SalaryToolLink } from '../../src/types';
import { formatCurrency } from '../../src/utils';
/** 记录时间戳 → 简短可读文本(月-日 时:分) */
function formatRecordTime(ts: number): string {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/* ===================== 子组件:顶部导航 ===================== */
@ -213,8 +221,12 @@ function StructureCard({ result }: { result: import('../../src/types').SalaryCal
/* ===================== 子组件:更多工具 ===================== */
function ToolLink({ tool }: { tool: SalaryToolLink }) {
return (
<Pressable style={styles.toolRow}>
const router = useRouter();
// 目标路由由接口层给出;没有 route 的只做展示,不带箭头
const route = tool.route;
const body = (
<>
<View style={[styles.toolIcon, { backgroundColor: colors.primaryLight }]}>
<Ionicons name={tool.icon as keyof typeof Ionicons.glyphMap} size={20} color={colors.primary} />
</View>
@ -222,7 +234,16 @@ function ToolLink({ tool }: { tool: SalaryToolLink }) {
<Text style={styles.toolTitle}>{tool.title}</Text>
<Text style={styles.toolDesc}>{tool.desc}</Text>
</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>
);
}
@ -232,7 +253,35 @@ function ToolLink({ tool }: { tool: SalaryToolLink }) {
export default function SalaryCalculatorScreen() {
const router = useRouter();
const { inputs, setInputs, result } = useSalaryCalculator();
const records = useSalaryRecords();
const { tools } = useSalaryToolLinks();
const [cityPickerVisible, setCityPickerVisible] = useState(false);
const [historyVisible, setHistoryVisible] = useState(false);
const [saveSheetVisible, setSaveSheetVisible] = useState(false);
const [saveNote, setSaveNote] = useState('');
const [saving, setSaving] = useState(false);
/** 保存当前计算结果到本地记录 */
const handleSave = async () => {
if (saving) return;
setSaving(true);
try {
await records.save(inputs, result, saveNote);
setSaveSheetVisible(false);
setSaveNote('');
Alert.alert('已保存', '当前薪资计算已保存到本地记录');
} catch {
Alert.alert('保存失败', '请稍后重试');
} finally {
setSaving(false);
}
};
/** 从历史记录载入一笔参数到计算器 */
const handleLoadRecord = (record: SalaryCalcRecord) => {
setInputs(record.input);
setHistoryVisible(false);
};
const cityProfile =
SOCIAL_SECURITY_CITY_PROFILES[inputs.city] ?? DEFAULT_SOCIAL_PROFILE;
@ -260,7 +309,7 @@ export default function SalaryCalculatorScreen() {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onHistory={() => Alert.alert('历史记录', '计算历史功能开发中,敬请期待')} />
<Header onBack={() => router.back()} onHistory={() => setHistoryVisible(true)} />
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{/* 输入卡片 */}
<View style={styles.card}>
@ -317,37 +366,15 @@ export default function SalaryCalculatorScreen() {
{/* 薪资结构 */}
<StructureCard result={result} />
{/* 更多工具 */}
{/* 更多工具:清单来自接口层,均为项目内真实页面 */}
<View style={styles.card}>
<Text style={styles.cardTitle}>更多工具</Text>
{[
{
id: 'year-end',
title: '年终奖个税计算',
desc: '单独计税 / 并入综合所得对比',
icon: 'gift-outline',
route: '/tools/salary-calculator',
},
{
id: 'severance',
title: '离职补偿金计算',
desc: 'N+1 补偿金与个税估算',
icon: 'cash-outline',
route: '/tools/salary-calculator',
},
{
id: 'proof',
title: '公积金/社保证明',
desc: '一键查询缴纳记录',
icon: 'document-text-outline',
route: '/tools/salary-calculator',
},
].map((tool) => (
{tools.map((tool) => (
<ToolLink key={tool.id} tool={tool} />
))}
</View>
<Pressable style={styles.saveBtn} onPress={() => Alert.alert('保存计算记录', '保存功能开发中,敬请期待')}>
<Pressable style={styles.saveBtn} onPress={() => setSaveSheetVisible(true)}>
<Text style={styles.saveBtnText}>保存计算记录</Text>
</Pressable>
</ScrollView>
@ -358,6 +385,26 @@ export default function SalaryCalculatorScreen() {
onClose={() => setCityPickerVisible(false)}
onSelect={handleSelectCity}
/>
<SaveSheet
visible={saveSheetVisible}
note={saveNote}
onNoteChange={setSaveNote}
onSave={handleSave}
onClose={() => setSaveSheetVisible(false)}
saving={saving}
/>
<HistoryModal
visible={historyVisible}
records={records.records}
loading={records.loading}
error={records.error}
onRetry={records.refresh}
onSelect={handleLoadRecord}
onDelete={records.remove}
onClose={() => setHistoryVisible(false)}
/>
</SafeAreaView>
);
}
@ -404,6 +451,129 @@ function CityPicker({
);
}
/* ===================== 子组件:保存记录底部弹层 ===================== */
function SaveSheet({
visible,
note,
onNoteChange,
onSave,
onClose,
saving,
}: {
visible: boolean;
note: string;
onNoteChange: (v: string) => void;
onSave: () => void;
onClose: () => void;
saving: boolean;
}) {
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.sheetBackdrop} onPress={onClose}>
<View style={styles.sheet}>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>保存计算记录</Text>
<Text style={styles.saveHint}>备注(选填,便于区分不同方案)</Text>
<TextInput
style={styles.saveNoteInput}
value={note}
onChangeText={onNoteChange}
placeholder="如:深圳 offer 对比"
placeholderTextColor={colors.textHint}
maxLength={20}
/>
<View style={styles.saveActions}>
<Pressable style={styles.saveCancel} onPress={onClose}>
<Text style={styles.saveCancelText}>取消</Text>
</Pressable>
<Pressable
style={[styles.saveConfirm, saving && styles.saveConfirmDisabled]}
onPress={onSave}
disabled={saving}
>
<Text style={styles.saveConfirmText}>{saving ? '保存中…' : '保存'}</Text>
</Pressable>
</View>
</View>
</Pressable>
</Modal>
);
}
/* ===================== 子组件:历史记录底部弹层 ===================== */
function HistoryModal({
visible,
records,
loading,
error,
onRetry,
onSelect,
onDelete,
onClose,
}: {
visible: boolean;
records: SalaryCalcRecord[];
loading: boolean;
error: Error | null;
onRetry: () => void;
onSelect: (record: SalaryCalcRecord) => void;
onDelete: (id: string) => void;
onClose: () => void;
}) {
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.sheetBackdrop} onPress={onClose}>
<View style={styles.sheet}>
<View style={styles.sheetHandle} />
<Text style={styles.sheetTitle}>计算历史</Text>
{loading ? (
<View style={styles.historyBody}>
<LoadingState text="加载历史记录…" />
</View>
) : error ? (
<View style={styles.historyBody}>
<ErrorState message="历史记录加载失败" onRetry={onRetry} />
</View>
) : records.length === 0 ? (
<View style={styles.historyBody}>
<EmptyState icon="time-outline" title="暂无保存的计算记录" subtitle="在下方「保存计算记录」即可留档" />
</View>
) : (
<ScrollView style={styles.historyList} showsVerticalScrollIndicator={false}>
{records.map((record) => (
<View key={record.id} style={styles.historyRow}>
<Pressable style={styles.historyRowMain} onPress={() => onSelect(record)}>
<Text style={styles.historyTitle} numberOfLines={1}>
{record.note || `${record.input.city} · 税前${formatCurrency(record.input.monthlyGross, 0)}`}
</Text>
<Text style={styles.historySub}>
到手 {formatCurrency(record.result.monthlyNet)}/月 · {formatRecordTime(record.createdAt)}
</Text>
</Pressable>
<Pressable
style={styles.historyDelete}
onPress={() =>
Alert.alert('删除记录', '确定删除这条计算记录?', [
{ text: '取消', style: 'cancel' },
{ text: '删除', style: 'destructive', onPress: () => onDelete(record.id) },
])
}
hitSlop={8}
>
<Ionicons name="trash-outline" size={20} color={colors.danger} />
</Pressable>
</View>
))}
</ScrollView>
)}
</View>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.pageBg },
header: {
@ -589,4 +759,46 @@ const styles = StyleSheet.create({
sheetItemActive: { backgroundColor: colors.primaryLight },
sheetItemText: { fontSize: 15, color: colors.textPrimary },
sheetItemTextActive: { color: colors.primary, fontWeight: '700' },
saveHint: { fontSize: 12, color: colors.textHint, marginBottom: 8 },
saveNoteInput: {
backgroundColor: colors.pageBg,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
color: colors.textPrimary,
marginBottom: 16,
},
saveActions: { flexDirection: 'row', gap: 12 },
saveCancel: {
flex: 1,
backgroundColor: colors.pageBg,
borderRadius: 24,
paddingVertical: 13,
alignItems: 'center',
},
saveCancelText: { fontSize: 16, fontWeight: '700', color: colors.textSecondary },
saveConfirm: {
flex: 1,
backgroundColor: colors.primaryBtn,
borderRadius: 24,
paddingVertical: 13,
alignItems: 'center',
},
saveConfirmDisabled: { opacity: 0.6 },
saveConfirmText: { fontSize: 16, fontWeight: '700', color: colors.white },
historyBody: { height: 300, justifyContent: 'center' },
historyList: { maxHeight: 420 },
historyRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 14,
paddingHorizontal: 4,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
historyRowMain: { flex: 1, marginRight: 8 },
historyTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
historySub: { fontSize: 12, color: colors.textSecondary, marginTop: 4 },
historyDelete: { padding: 6 },
});

313
app/tools/skill-cloud.tsx

@ -3,17 +3,19 @@ import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { IndustrySkillWordCloud } from '../../components/IndustrySkillWordCloud';
import { useSkillCloud } from '../../src/hooks';
import { useSkillCloud, useSkillLearningPath } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { SkillWord, TopSkill } from '../../src/types';
import type { SkillLearningPath, SkillWord, TopSkill } from '../../src/types';
/** 技能分组 → 中文标签(用于选中详情) */
const GROUP_LABEL: Record<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 (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>行业技能词云</Text>
<Pressable style={styles.headerBtn} onPress={onSearch} hitSlop={8}>
<Ionicons name="search-outline" size={22} color={colors.textPrimary} />
<Pressable style={styles.headerBtn} onPress={onToggleSearch} hitSlop={8}>
<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>
)}
</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 (
<View style={styles.matchCard}>
<View style={styles.matchHeader}>
@ -169,14 +223,110 @@ function MatchCard({ percent }: { percent: number }) {
<View style={styles.matchTrack}>
<View style={[styles.matchFill, { width: `${percent}%` }]} />
</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} />
<Text style={styles.matchBtnText}>生成技能学习路径</Text>
)}
<Text style={styles.matchBtnText}>
{generating ? '正在生成…' : '生成技能学习路径'}
</Text>
</Pressable>
</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 }) {
@ -235,15 +385,45 @@ function Empty() {
export default function SkillCloudScreen() {
const router = useRouter();
const { industry, setIndustry, cloud, loading, error, refresh } = useSkillCloud();
const {
industry,
setIndustry,
cloud,
keyword,
setKeyword,
words,
isEmpty,
loading,
error,
refresh,
} = useSkillCloud();
const [selectedId, setSelectedId] = useState<string | null>(null);
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 (
<SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onSearch={() => {}} />
<Header onBack={() => router.back()} onToggleSearch={toggleSearch} searching={searching} />
{loading ? (
<Loading />
@ -253,6 +433,9 @@ export default function SkillCloudScreen() {
<Empty />
) : (
<>
{searching && (
<SearchBar value={keyword} onChange={setKeyword} onClear={() => setKeyword('')} />
)}
<IndustryTabs
industries={cloud.filters.industries}
active={industry}
@ -269,14 +452,27 @@ export default function SkillCloudScreen() {
]}
/>
<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}>
<IndustrySkillWordCloud
words={cloud.words}
words={words}
selectedId={selectedId}
onSelect={(w) => setSelectedId(w.id)}
/>
</View>
)}
<CategoryTabs
categories={cloud.categories}
@ -292,12 +488,25 @@ export default function SkillCloudScreen() {
<TopSkillList skills={cloud.topSkills} />
<MatchCard percent={cloud.matchPercent} />
<MatchCard
percent={cloud.matchPercent}
onGenerate={handleGenerate}
generating={generating}
/>
<Text style={styles.updated}>{cloud.updatedAt}</Text>
</ScrollView>
</>
)}
<LearningPathModal
visible={pathVisible}
path={path}
loading={generating}
error={pathError}
onRetry={() => void generate(industry)}
onClose={closePathModal}
/>
</SafeAreaView>
);
}
@ -438,6 +647,82 @@ const styles = StyleSheet.create({
paddingVertical: 12,
},
matchBtnText: { fontSize: 14, fontWeight: '700', color: colors.white, marginLeft: 6 },
matchBtnDisabled: { backgroundColor: colors.textDisabled },
searchBar: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: colors.white,
paddingHorizontal: 16,
paddingBottom: 10,
},
searchInput: {
flex: 1,
backgroundColor: colors.pageBg,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 8,
fontSize: 14,
color: colors.textPrimary,
},
searchEmpty: {
alignItems: 'center',
gap: 10,
paddingVertical: 32,
backgroundColor: colors.white,
borderRadius: 14,
borderWidth: 1,
borderColor: colors.borderLight,
},
modalMask: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'flex-end' },
modalSheet: {
backgroundColor: colors.white,
borderTopLeftRadius: 18,
borderTopRightRadius: 18,
maxHeight: '80%',
paddingBottom: 20,
},
modalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
modalTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
modalCenter: { alignItems: 'center', justifyContent: 'center', gap: 12, paddingVertical: 48 },
modalBody: { paddingHorizontal: 16, paddingBottom: 12 },
pathSummary: {
backgroundColor: colors.primaryLight,
borderRadius: 12,
padding: 14,
marginTop: 14,
marginBottom: 4,
},
pathSummaryText: { fontSize: 14, fontWeight: '700', color: colors.primaryText },
pathSummaryGap: { marginTop: 6, fontSize: 13, color: colors.primaryText },
pathSummaryHint: { marginTop: 6, fontSize: 12, color: colors.textSecondary },
pathItem: {
flexDirection: 'row',
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
pathPriority: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
pathPriorityText: { fontSize: 12, fontWeight: '800', color: colors.white },
pathItemBody: { flex: 1, marginLeft: 12 },
pathItemHead: { flexDirection: 'row', alignItems: 'center', gap: 8 },
pathItemName: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
pathItemReason: { marginTop: 6, fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
detailCard: {
marginTop: 12,
backgroundColor: colors.white,

BIN
assets/android-icon-background.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
assets/android-icon-foreground.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 28 KiB

BIN
assets/android-icon-monochrome.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/icon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 383 KiB

70
components/onboarding/assets.ts

@ -18,7 +18,19 @@ export type AssetName =
| 'avatarDesigner'
| 'wechat'
| 'businessLicense'
| 'defaultAvatar';
| 'defaultAvatar'
// 系统预设头像:5 个独立素材位,各自可换真实图(此前 5 个选项共用 defaultAvatar,
// 选哪个都一样,也没有区分「选了第几个」的依据)
| 'avatarPreset1'
| 'avatarPreset2'
| 'avatarPreset3'
| 'avatarPreset4'
| 'avatarPreset5'
// 引导页(/guide)四屏插图:每屏一张,补图只改这几行的 source
| 'guideEvaluate'
| 'guideWeightDim'
| 'guideGameMap'
| 'guideLegalAI';
type IconName = ComponentProps<typeof Ionicons>['name'];
@ -27,6 +39,7 @@ export interface AssetDef {
source: ImageSourcePropType | null;
tint: string;
icon: IconName;
/** 素材说明,仅供换图时对照:**不渲染到界面上**(曾经把开发备注当正文画给用户看) */
hint: string;
}
@ -41,8 +54,63 @@ export const ASSET_REGISTRY: Record<AssetName, AssetDef> = {
wechat: { source: null, tint: colors.success, icon: 'logo-wechat', hint: '微信图标' },
businessLicense: { source: null, tint: colors.primaryLight, icon: 'document', hint: '营业执照示例图' },
defaultAvatar: { source: null, tint: colors.borderLight, icon: 'person', hint: '默认头像剪影' },
// 换真实图只改这几行的 source;tint/icon 只是占位期用来互相区分的
avatarPreset1: { source: null, tint: colors.primaryLight, icon: 'person', hint: '预设头像 1' },
avatarPreset2: { source: null, tint: colors.infoLight, icon: 'happy', hint: '预设头像 2' },
avatarPreset3: { source: null, tint: colors.successLight, icon: 'woman', hint: '预设头像 3' },
avatarPreset4: { source: null, tint: colors.actionLight, icon: 'man', hint: '预设头像 4' },
avatarPreset5: { source: null, tint: colors.dangerLight, icon: 'person-circle', hint: '预设头像 5' },
/*
* 引导页四屏插图。hint 会**显示在页面上**(ImageSlot 的占位标注),
* 所以这里写的是「给谁看的、画什么」,补图后标注自动消失。
* 建议尺寸 640×480(4:3),深色底以外的浅色背景,与页面白底衔接。
*/
guideEvaluate: {
source: null,
tint: colors.primaryLight,
icon: 'star',
hint: '手机屏幕里展示公司评分与员工评价列表',
},
guideWeightDim: {
source: null,
tint: colors.infoLight,
icon: 'options',
hint: '拖动滑块调节评价维度权重,实时算出综合得分',
},
guideGameMap: {
source: null,
tint: colors.successLight,
icon: 'map',
hint: '闯关地图:7 个关卡节点,从「认识自己」走到「签约」',
},
guideLegalAI: {
source: null,
tint: colors.actionLight,
icon: 'document-text',
hint: 'AI 法律助手与劳动合同文书',
},
};
/** 头像设置页可选用的系统预设头像(顺序即展示顺序) */
export const AVATAR_PRESETS: AssetName[] = [
'avatarPreset1',
'avatarPreset2',
'avatarPreset3',
'avatarPreset4',
'avatarPreset5',
];
/**
* 判断 P21 采集到的头像原始值是不是预设素材 key。
*
* 该字段是联合值:预设头像存素材 key,拍照 / 相册存本地图片 URI(见 `OnboardingProfile.avatar`)。
* 消费方(如「我的」页头像)据此决定按素材渲染还是按 URI 渲染 ——
* 这个判断依赖素材注册表,因此放在组件层,API 层只透传原始值。
*/
export function isAvatarPreset(value: string): value is AssetName {
return (AVATAR_PRESETS as string[]).includes(value);
}
/**
* 滑块拼图验证背景图池(每次刷新随机选一张)
* RN 静态图片仅支持 require 引用,豁免 no-require-imports 规则

4
components/onboarding/screens/B1Wechat.tsx

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

4
components/onboarding/screens/B2Agreement.tsx

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

94
components/onboarding/screens/B3Recruiter.tsx

@ -1,21 +1,64 @@
import { useState } from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { Image, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme';
import { useOnboarding, useRegisterFinish } from '../../../src/hooks';
import {
useEnterpriseCertification,
useImagePick,
useOnboarding,
useRegisterFinish,
} from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration';
import { PrimaryButton } from '../ui/PrimaryButton';
export function B3Recruiter() {
const { data, setData, back, meta } = useOnboarding();
const { data, setData, back } = useOnboarding();
const finish = useRegisterFinish();
const [submitted, setSubmitted] = useState(false);
const { pick, picking, error: pickError } = useImagePick();
const { submit, submitting } = useEnterpriseCertification();
const [submitFailed, setSubmitFailed] = useState(false);
const canSubmit = data.recruiterCompany.trim().length > 0;
// 三项都是认证必需,缺一项不放行:没有营业执照的「企业认证」提交不出有意义的结果
const canSubmit =
data.recruiterCompany.trim().length > 0 &&
data.recruiterContact.trim().length > 0 &&
data.recruiterLicenseUri !== null;
const pickLicense = async () => {
// 营业执照是文档,方形裁剪会把内容切掉,用 4:3
const result = await pick('library', { aspect: [4, 3] });
if (result.status === 'picked') setData({ recruiterLicenseUri: result.uri });
};
const handleSubmit = async () => {
if (!canSubmit || submitting) return;
setSubmitFailed(false);
try {
// 提交审核状态由接口返回驱动,不再用本地 useState 假装「已提交」
const res = await submit({
companyName: data.recruiterCompany.trim(),
// B3 不采集统一社会信用代码,由企业端「企业认证」页补全(该页已存在且有入口)
creditCode: '',
licenseUrl: data.recruiterLicenseUri ?? undefined,
contactName: data.recruiterContact.trim(),
// 联系人手机号用注册时登录的手机号,避免再问一次
contactPhone: data.phone,
});
if (!res.success) {
setSubmitFailed(true);
return;
}
// 落地前 useRegisterFinish 会 flushSave,把认证资料一起落库
await finish('enterprise');
} catch {
// 真实后端就绪后这里会接到网络异常
setSubmitFailed(true);
}
};
return (
<ScreenShell time={meta.time} battery={meta.battery} title="企业认证" showBack onBack={back}>
<Text style={styles.tip}>请完成企业认证,开启招聘之旅(与求职者主线分开演示)</Text>
<ScreenShell title="企业认证" showBack onBack={back}>
<Text style={styles.tip}>请完成企业认证,开启招聘之旅</Text>
<Text style={styles.label}>公司名称</Text>
<TextInput
@ -27,28 +70,40 @@ export function B3Recruiter() {
/>
<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} />
<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>
<TextInput
style={styles.input}
value={data.school}
onChangeText={(t) => setData({ school: t })}
value={data.recruiterContact}
onChangeText={(t) => setData({ recruiterContact: t })}
placeholder="请输入联系人姓名"
placeholderTextColor={colors.textHint}
/>
{submitFailed ? <Text style={styles.error}>认证提交失败,请稍后重试</Text> : null}
<View style={styles.footer}>
<PrimaryButton
label={submitted ? '已提交,等待审核' : '提交认证'}
active={canSubmit && !submitted}
onPress={() => {
setSubmitted(true);
finish('enterprise');
}}
label="提交认证"
active={canSubmit && !submitting}
loading={submitting}
onPress={() => void handleSubmit()}
/>
</View>
</ScreenShell>
@ -79,6 +134,9 @@ const styles = StyleSheet.create({
borderColor: colors.borderStrong,
backgroundColor: colors.white,
},
dim: { opacity: 0.5 },
licensePreview: { width: 160, height: 120, borderRadius: 12 },
uploadText: { fontSize: 13, color: colors.textHint },
error: { fontSize: 12, color: colors.dangerText, marginTop: 10 },
footer: { marginTop: 28 },
});

9
components/onboarding/screens/P10FirstWork.tsx

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

39
components/onboarding/screens/P11CurrentPosition.tsx

@ -16,9 +16,19 @@ const POS: Record<string, string[]> = {
};
const BLOCKS = ['客服', '内容运营', '电商运营', '用户运营'];
/** 全部分类的职位去重展开,供搜索跨分类匹配 */
const ALL_POS: string[] = Array.from(new Set(Object.values(POS).flat()));
export function P11CurrentPosition() {
const { data, setData, navigate, back, meta } = useOnboarding();
const { data, setData, navigate, back } = useOnboarding();
const [cat, setCat] = useState('互联网AI');
const [query, setQuery] = useState('');
const keyword = query.trim();
const searching = keyword !== '';
// 搜索态跨分类匹配(与 P6 同一处理):只搜当前分类会让「搜不到」看起来像没有这个职位
const visiblePos = searching ? ALL_POS.filter((p) => p.includes(keyword)) : POS[cat];
const visibleBlocks = searching ? BLOCKS.filter((b) => b.includes(keyword)) : BLOCKS;
const choose = (p: string) => {
setData({ currentPosition: p });
@ -26,13 +36,21 @@ export function P11CurrentPosition() {
};
return (
<ScreenShell time={meta.time} battery={meta.battery} title="当前工作职位" showBack onBack={back}>
<SearchInput value="" onChangeText={() => {}} placeholder="搜索职位" />
<ScreenShell title="当前工作职位" showBack onBack={back}>
<SearchInput value={query} onChangeText={setQuery} placeholder="搜索职位" onClear={() => setQuery('')} />
<View style={styles.body}>
<View style={styles.cats}>
{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>
</Pressable>
))}
@ -40,20 +58,28 @@ export function P11CurrentPosition() {
<View style={styles.col}>
<Text style={styles.colTitle}>猜你做过</Text>
{visiblePos.length > 0 ? (
<TagGroup>
{POS[cat].map((p) => (
{visiblePos.map((p) => (
<Tag key={p} label={p} selected={data.currentPosition === p} onPress={() => choose(p)} />
))}
</TagGroup>
) : (
<Text style={styles.empty}>没有匹配「{keyword}」的职位</Text>
)}
</View>
</View>
<Text style={styles.blockTitle}>或者从这些板块选</Text>
{visibleBlocks.length > 0 ? (
<TagGroup>
{BLOCKS.map((b) => (
{visibleBlocks.map((b) => (
<Tag key={b} label={b} selected={data.currentPosition === b} onPress={() => choose(b)} />
))}
</TagGroup>
) : (
<Text style={styles.empty}>没有匹配「{keyword}」的板块</Text>
)}
</ScreenShell>
);
}
@ -67,5 +93,6 @@ const styles = StyleSheet.create({
catTextActive: { color: colors.primaryText, fontWeight: '700' },
col: { flex: 1 },
colTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 },
empty: { fontSize: 13, color: colors.textHint, lineHeight: 20 },
blockTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginVertical: 16 },
});

4
components/onboarding/screens/P12Company.tsx

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

4
components/onboarding/screens/P14Skills.tsx

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

4
components/onboarding/screens/P17Reference.tsx

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

7
components/onboarding/screens/P18Education.tsx

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

155
components/onboarding/screens/P1Login.tsx

@ -1,92 +1,25 @@
import { useEffect, useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { useRouter } from 'expo-router';
import { LinearGradient } from 'expo-linear-gradient';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../../../src/theme';
import { useOnboarding, useWechatLogin } from '../../../src/hooks';
import { useRole } from '../../../src/context/RoleContext';
import { useAgreementGate, useOnboarding } from '../../../src/hooks';
import { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { AgreementModal } from '../ui/AgreementModal';
import { SliderCaptcha } from '../ui/SliderCaptcha';
import { DeletedAccountModal } from '../ui/DeletedAccountModal';
/** P1 登录主页:对齐参考样式 —— 淡渐变顶部 + 居中 Logo + 微信绿实心/灰底一键登录 + 协议弹窗拦截 */
import { PrimaryButton } from '../ui/PrimaryButton';
import { SupportFooter } from '../ui/SupportFooter';
/**
* P1 登录主页:淡渐变顶部 + 居中 Logo + 三种登录方式。
*
* 2026-09-15 按产品决定摘除微信登录入口(代码与页面保留,见 微信登录接入方案.md),
* 现为:验证码登录(主)/ 一键登录(次)/ 密码登录(弱)。
*/
export function P1Login() {
const { data, setData, navigate } = useOnboarding();
const router = useRouter();
const { setRole } = useRole();
const { gate, modalProps } = useAgreementGate();
const agreed = data.agreed;
/** 未勾选协议时被拦截的动作,弹窗「同意」后继续执行 */
const [pending, setPending] = useState<(() => void) | null>(null);
const [captchaVisible, setCaptchaVisible] = useState(false);
/** 微信登录状态机:负责三分支判定与流转 */
const {
state: wechatState,
start: startWechatLogin,
cancelLogin,
reset: resetWechat,
loading: wechatLoading,
} = useWechatLogin();
/** 协议门卫:已勾选直接执行;未勾选弹协议弹窗 */
const gate = (action: () => void) => {
if (agreed) {
action();
} else {
setPending(() => action);
}
};
const accept = () => {
setData({ agreed: true });
const act = pending;
setPending(null);
act?.();
};
/** 微信登录:先滑块拼图验证,通过后进入微信授权与三分支判定 */
const wechatLogin = () => setCaptchaVisible(true);
/*
* 状态机的副作用集中在这里,页面只负责触发与渲染:
* - needBindPhone:第一次登录,把临时凭证存入流程数据后跳 P2(手机号 + 验证码)
* - success:第二次及以后登录,凭后端返回的身份直接落地,无身份则回注册流程选择
* - alreadyLoggedIn:本地 token 未过期,免授权直接落地
* 跳转后立即 reset,避免返回本页时重复触发。
*/
useEffect(() => {
if (wechatState.kind === 'needBindPhone') {
setData({
wechatTicket: {
token: wechatState.ticket.token,
openid: wechatState.ticket.openid,
},
});
navigate('P2');
resetWechat();
return;
}
if (wechatState.kind === 'success') {
if (wechatState.role) {
setRole(wechatState.role);
router.replace(
wechatState.role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs',
);
} else {
router.replace('/register');
}
resetWechat();
return;
}
if (wechatState.kind === 'alreadyLoggedIn') {
router.replace('/(tabs)/jobs');
resetWechat();
}
}, [wechatState, resetWechat, router, setRole, setData, navigate]);
return (
<SafeAreaView style={styles.screen} edges={['top', 'bottom']}>
@ -100,28 +33,15 @@ export function P1Login() {
<View style={styles.flexSpacer} />
{/* 登录方式 */}
<Pressable
style={styles.wechatBtn}
onPress={() => gate(wechatLogin)}
disabled={wechatLoading}
>
<Ionicons name="logo-wechat" size={20} color={colors.textInverse} />
<Text style={styles.wechatText}>
{wechatLoading ? '登录中…' : '微信登录'}
</Text>
</Pressable>
{wechatState.kind === 'error' ? (
<Text style={styles.loginError}>{wechatState.message}</Text>
) : null}
{/* 登录方式:主通道为验证码登录,其余两种降级展示 */}
<PrimaryButton label="验证码登录" onPress={() => gate(() => navigate('P2'))} />
<Pressable style={styles.quickBtn} onPress={() => gate(() => navigate('P2Q'))}>
<Text style={styles.quickText}>一键登录</Text>
</Pressable>
<Pressable onPress={() => gate(() => navigate('P2'))} hitSlop={8}>
<Text style={styles.otherLink}>其他方式登录</Text>
<Pressable onPress={() => gate(() => navigate('P2P'))} hitSlop={8}>
<Text style={styles.otherLink}>密码登录</Text>
</Pressable>
{/* 协议勾选 */}
@ -144,32 +64,11 @@ export function P1Login() {
<View style={styles.flexSpacer} />
<View style={styles.footer}>
<Text style={styles.footItem}>服务热线</Text>
<Text style={styles.footItem}>举报监督电话</Text>
<Text style={styles.footItem}>资质证照</Text>
<SupportFooter />
</View>
</ScrollView>
<SliderCaptcha
visible={captchaVisible}
onSuccess={() => {
setCaptchaVisible(false);
void startWechatLogin();
}}
onClose={() => setCaptchaVisible(false)}
/>
<DeletedAccountModal visible={wechatState.kind === 'deleted'} onClose={cancelLogin} />
<AgreementModal
visible={pending !== null}
onAccept={accept}
onDecline={() => setPending(null)}
onOpenAgreement={() => {
setPending(null);
navigate('B2');
}}
/>
<AgreementModal {...modalProps} />
</SafeAreaView>
);
}
@ -180,16 +79,6 @@ const styles = StyleSheet.create({
content: { flexGrow: 1, paddingHorizontal: 24, paddingBottom: 12 },
brandArea: { alignItems: 'center', marginTop: '22%' },
flexSpacer: { flex: 1, minHeight: 28 },
wechatBtn: {
height: 50,
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
backgroundColor: colors.success,
},
wechatText: { fontSize: 16, fontWeight: '700', color: colors.textInverse },
quickBtn: {
height: 50,
borderRadius: 12,
@ -205,16 +94,8 @@ const styles = StyleSheet.create({
fontSize: 14,
color: colors.textSecondary,
},
loginError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agree: { marginTop: 22 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' },
footer: {
flexDirection: 'row',
justifyContent: 'center',
gap: 18,
paddingTop: 24,
paddingBottom: 8,
},
footItem: { fontSize: 12, color: colors.textHint },
footer: { paddingTop: 24, paddingBottom: 8 },
});

4
components/onboarding/screens/P20School.tsx

@ -5,11 +5,11 @@ import { SearchInput } from '../ui/SearchInput';
import { PrimaryButton } from '../ui/PrimaryButton';
export function P20School() {
const { data, setData, navigate, back, meta } = useOnboarding();
const { data, setData, navigate, back } = useOnboarding();
const canNext = data.school.trim().length > 0;
return (
<ScreenShell time={meta.time} battery={meta.battery} title="毕业学校" showBack onBack={back}>
<ScreenShell title="毕业学校" showBack onBack={back}>
<SearchInput
value={data.school}
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 { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks';
import { useImagePick, useOnboarding } from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell';
import { Avatar } from '../ui/Avatar';
import { PrimaryButton } from '../ui/PrimaryButton';
const PRESETS = [0, 1, 2, 3, 4];
import { AVATAR_PRESETS } from '../assets';
export function P21Avatar() {
const { setData, navigate, back, meta } = useOnboarding();
const [mode, setMode] = useState<'none' | 'camera' | 'album' | 'preset'>('none');
const [picked, setPicked] = useState(-1);
const { data, setData, navigate, back } = useOnboarding();
const { pick, picking, error } = useImagePick();
const chooseCamera = () => {
setMode('camera');
setData({ avatar: 'avatarDesigner' });
};
const chooseAlbum = () => {
setMode('album');
setData({ avatar: 'avatarDesigner' });
};
const choosePreset = (i: number) => {
setMode('preset');
setPicked(i);
setData({ avatar: 'defaultAvatar' });
};
// 头像只有一个真相 `data.avatar`:命中预设素材就是预设名,否则是拍照/相册的本地 URI
const presetName = AVATAR_PRESETS.find((name) => name === data.avatar) ?? null;
const avatarUri = data.avatar !== null && presetName === null ? data.avatar : null;
const previewName = mode === 'camera' || mode === 'album' ? 'avatarDesigner' : 'defaultAvatar';
const pickFrom = async (source: 'camera' | 'library') => {
const result = await pick(source);
// 取消或未授权时保持原头像不动,不写入空值
if (result.status === 'picked') setData({ avatar: result.uri });
};
return (
<ScreenShell time={meta.time} battery={meta.battery} title="头像设置" showBack onBack={back}>
<ScreenShell title="头像设置" showBack onBack={back}>
<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 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} />
<Text style={styles.actionText}>拍照上传</Text>
</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} />
<Text style={styles.actionText}>从相册选择</Text>
</Pressable>
</View>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Text style={styles.subTitle}>系统默认头像</Text>
<View style={styles.presets}>
{PRESETS.map((i) => (
{AVATAR_PRESETS.map((name) => (
<Avatar
key={i}
key={name}
name={name}
size={56}
selected={mode === 'preset' && picked === i}
onPress={() => choosePreset(i)}
selected={data.avatar === name}
onPress={() => setData({ avatar: name })}
/>
))}
</View>
<View style={styles.footer}>
<PrimaryButton label="下一步" active={mode !== 'none'} onPress={() => navigate('P22')} />
<PrimaryButton
label="下一步"
active={data.avatar !== null && !picking}
onPress={() => navigate('P22')}
/>
</View>
</ScreenShell>
);
@ -70,7 +82,9 @@ const styles = StyleSheet.create({
previewWrap: { alignItems: 'center', marginVertical: 24 },
actions: { flexDirection: 'row', justifyContent: 'center', gap: 28, marginBottom: 28 },
actionBtn: { alignItems: 'center', gap: 6 },
actionBtnDim: { opacity: 0.5 },
actionText: { fontSize: 12, color: colors.textSecondary },
error: { fontSize: 12, color: colors.dangerText, textAlign: 'center', marginBottom: 18 },
subTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginBottom: 14 },
presets: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 },
footer: { marginTop: 32 },

10
components/onboarding/screens/P22Done.tsx

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

139
components/onboarding/screens/P2PasswordLogin.tsx

@ -0,0 +1,139 @@
import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme';
import { useAgreementGate, useOnboarding, usePasswordLogin } from '../../../src/hooks';
import { isValidPhone } from '../../../src/utils';
import { useAuth } from '../../../src/context/AuthContext';
import { useRole } from '../../../src/context/RoleContext';
import { PasswordField } from '../../../src/components/PasswordField';
import { ScreenShell } from '../ui/ScreenShell';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { PrimaryButton } from '../ui/PrimaryButton';
import { AgreementModal } from '../ui/AgreementModal';
/**
* P2P 手机号 + 密码登录。
*
* 与 P2(验证码)、P2R(设置密码)互相可达:本页底部可切回验证码登录,忘记密码进 P2R。
* 登录成功后按是否已选过身份决定落地页,与 P1 微信分支的原逻辑一致。
*/
export function P2PasswordLogin() {
const { data, setData, navigate, back } = useOnboarding();
const { gate, modalProps } = useAgreementGate();
const { login, loading, error } = usePasswordLogin();
/** 登录态写入:换取令牌后必须落库,否则冷启动会被打回登录页 */
const { login: writeAuth } = useAuth();
const { role } = useRole();
const router = useRouter();
const [password, setPassword] = useState('');
const agreed = data.agreed;
const phone = data.phone;
const phoneValid = isValidPhone(phone);
const canSubmit = phoneValid && password.length > 0;
const handleLogin = async () => {
if (!canSubmit || loading) return;
try {
const result = await login(phone, password);
writeAuth(result.token);
} catch {
// 失败原因由 usePasswordLogin 记录,页面下方展示
return;
}
// 已有身份的老用户直接进对应主界面,新用户才去选身份
if (role) {
router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs');
return;
}
navigate('P3');
};
return (
<ScreenShell showBack onBack={back}>
<Text style={styles.h1}>密码登录</Text>
<Text style={styles.subtitle}>使用手机号与登录密码进入有职</Text>
{/* 手机号 */}
<View style={styles.fieldRow}>
<Text style={styles.prefix}>+86</Text>
<Ionicons name="caret-down" size={14} color={colors.textSecondary} />
<View style={styles.divider} />
<TextInput
style={styles.input}
value={phone}
onChangeText={(t) => setData({ phone: t.replace(/[^\d]/g, '').slice(0, 11) })}
placeholder="请输入手机号"
placeholderTextColor={colors.textHint}
keyboardType="phone-pad"
maxLength={11}
/>
{phone.length > 0 ? (
<Pressable onPress={() => setData({ phone: '' })} hitSlop={8}>
<Ionicons name="close-circle" size={18} color={colors.textDisabled} />
</Pressable>
) : null}
</View>
<View style={styles.underline} />
{/* 密码 */}
<View style={styles.passwordArea}>
<PasswordField value={password} onChangeText={setPassword} placeholder="请输入登录密码" />
</View>
<Pressable onPress={() => gate(() => navigate('P2R'))} hitSlop={8}>
<Text style={styles.forgotLink}>忘记密码?</Text>
</Pressable>
<View style={styles.btnArea}>
<PrimaryButton label="登录" active={canSubmit} loading={loading} onPress={() => gate(handleLogin)} />
</View>
{error ? <Text style={styles.formError}>{error.message}</Text> : null}
<View style={styles.agree}>
<AgreementCheckbox
checked={agreed}
onToggle={() => setData({ agreed: !agreed })}
text={
<Text style={styles.agreeText}>
已阅读并同意
<Text style={styles.link} onPress={() => navigate('B2')}>《有职用户协议》</Text>
和
<Text style={styles.link} onPress={() => navigate('B2')}>《隐私政策》</Text>
,允许有职统一管理本人账号信息
</Text>
}
/>
</View>
<Pressable onPress={() => gate(() => navigate('P2'))} hitSlop={8}>
<Text style={styles.switchLink}>验证码登录</Text>
</Pressable>
<AgreementModal {...modalProps} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
h1: { fontSize: 26, fontWeight: '800', color: colors.textPrimary, marginTop: 12 },
subtitle: { fontSize: 14, color: colors.textSecondary, marginTop: 10 },
fieldRow: { flexDirection: 'row', alignItems: 'center', height: 56, marginTop: 28, gap: 6 },
prefix: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
divider: { width: 1, height: 20, backgroundColor: colors.border, marginHorizontal: 10 },
input: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 },
underline: { height: 1, backgroundColor: colors.borderLight },
passwordArea: { marginTop: 22 },
forgotLink: { marginTop: 14, alignSelf: 'flex-end', fontSize: 13, color: colors.primaryText, fontWeight: '600' },
btnArea: { marginTop: 26 },
formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agree: { marginTop: 18 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' },
switchLink: { marginTop: 26, textAlign: 'center', fontSize: 14, color: colors.textSecondary },
});

182
components/onboarding/screens/P2PasswordReset.tsx

@ -0,0 +1,182 @@
import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme';
import { useAgreementGate, useOnboarding, usePasswordReset, useSmsCode } from '../../../src/hooks';
import { isValidPhone, isValidSmsCode, SMS_CODE_MAX_LENGTH } from '../../../src/utils';
import { useAuth } from '../../../src/context/AuthContext';
import { useRole } from '../../../src/context/RoleContext';
import { PasswordField } from '../../../src/components/PasswordField';
import { ScreenShell } from '../ui/ScreenShell';
import { CountdownButton } from '../ui/CountdownButton';
import { PrimaryButton } from '../ui/PrimaryButton';
import { AgreementModal } from '../ui/AgreementModal';
/** 重置阶段:验证手机号 → 设置新密码 */
type Stage = 'verify' | 'password';
/**
* P2R 设置 / 重置登录密码(同页两阶段)。
*
* 一个页面同时承担两件事:
* 1. 忘记密码 —— 老用户忘记原密码,验证手机号后重设;
* 2. 首次设置密码 —— 「登录即注册」的用户天生没有密码,走同一入口首次获得密码。
* 因此文案对两者都成立,不区分来源,避免出现「有密码登录入口但没人拿得到密码」的死结。
*/
export function P2PasswordReset() {
const { data, setData, navigate, back } = useOnboarding();
const { gate, modalProps } = useAgreementGate();
const { reset, loading, error, checkPassword } = usePasswordReset();
const { send: sendCode, error: smsError } = useSmsCode();
/** 重置成功即写入登录态,用户不必再用新密码登录一次 */
const { login: writeAuth } = useAuth();
const { role } = useRole();
const router = useRouter();
const [stage, setStage] = useState<Stage>('verify');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const phone = data.phone;
const phoneValid = isValidPhone(phone);
const codeValid = isValidSmsCode(code);
/** 密码规则提示(规则在 API 层计算,这里只渲染文案) */
const hint = password.length > 0 ? checkPassword(password, confirm) : null;
const canSubmit = codeValid && password.length > 0 && confirm.length > 0 && hint === null && !loading;
/**
* 发送验证码(只发送,不切阶段)。
* 验证码是否正确由最终提交时的后端校验统一判定,本步不做本地校验,
* 避免「前端说对了、后端说错了」的不一致。
*/
const sendCodeNow = async () => {
if (!phoneValid) return;
try {
await sendCode(phone);
} catch {
// 失败原因由 useSmsCode 记录,页面下方展示
}
};
const handleSubmit = async () => {
if (!canSubmit) return;
let result;
try {
result = await reset({ phone, code, password });
writeAuth(result.token);
} catch {
// 失败原因由 usePasswordReset 记录,页面下方展示
return;
}
// 已有身份的老用户直接进对应主界面,新用户才去选身份
if (role) {
router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs');
return;
}
navigate('P3');
};
return (
<ScreenShell showBack onBack={stage === 'password' ? () => setStage('verify') : back}>
{stage === 'verify' ? (
/* ---------- 阶段1:验证手机号 ---------- */
<View>
<Text style={styles.h1}>验证手机号</Text>
<Text style={styles.subtitle}>验证通过后即可设置新的登录密码</Text>
<View style={styles.fieldRow}>
<Text style={styles.prefix}>+86</Text>
<Ionicons name="caret-down" size={14} color={colors.textSecondary} />
<View style={styles.divider} />
<TextInput
style={styles.input}
value={phone}
onChangeText={(t) => setData({ phone: t.replace(/[^\d]/g, '').slice(0, 11) })}
placeholder="请输入手机号"
placeholderTextColor={colors.textHint}
keyboardType="phone-pad"
maxLength={11}
/>
{phone.length > 0 ? (
<Pressable onPress={() => setData({ phone: '' })} hitSlop={8}>
<Ionicons name="close-circle" size={18} color={colors.textDisabled} />
</Pressable>
) : null}
</View>
<View style={styles.underline} />
<View style={styles.fieldRow}>
<TextInput
style={styles.input}
value={code}
onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, SMS_CODE_MAX_LENGTH))}
placeholder="请输入验证码"
placeholderTextColor={colors.textHint}
keyboardType="number-pad"
maxLength={SMS_CODE_MAX_LENGTH}
/>
<CountdownButton disabled={!phoneValid} onSend={() => void sendCodeNow()} />
</View>
<View style={styles.underline} />
<View style={styles.btnArea}>
<PrimaryButton
label="下一步"
active={phoneValid && codeValid}
onPress={() => gate(() => setStage('password'))}
/>
</View>
{smsError ? <Text style={styles.formError}>{smsError.message}</Text> : null}
</View>
) : (
/* ---------- 阶段2:设置新密码 ---------- */
<View>
<Text style={styles.h1}>设置登录密码</Text>
<Text style={styles.subtitle}>设置后可用手机号 + 密码登录</Text>
<View style={styles.passwordArea}>
<PasswordField value={password} onChangeText={setPassword} placeholder="请输入新密码" />
</View>
<View style={styles.passwordArea}>
<PasswordField value={confirm} onChangeText={setConfirm} placeholder="请再次输入新密码" />
</View>
{hint ? (
<Text style={styles.hintDanger}>{hint}</Text>
) : (
<Text style={styles.hint}>8–20 位,需同时包含字母和数字</Text>
)}
<View style={styles.btnArea}>
<PrimaryButton label="完成" active={canSubmit} loading={loading} onPress={() => gate(handleSubmit)} />
</View>
{error ? <Text style={styles.formError}>{error.message}</Text> : null}
</View>
)}
<AgreementModal {...modalProps} />
</ScreenShell>
);
}
const styles = StyleSheet.create({
h1: { fontSize: 26, fontWeight: '800', color: colors.textPrimary, marginTop: 12 },
subtitle: { fontSize: 14, color: colors.textSecondary, marginTop: 10 },
fieldRow: { flexDirection: 'row', alignItems: 'center', height: 56, marginTop: 26, gap: 6 },
prefix: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
divider: { width: 1, height: 20, backgroundColor: colors.border, marginHorizontal: 10 },
input: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 },
underline: { height: 1, backgroundColor: colors.borderLight },
passwordArea: { marginTop: 22 },
hint: { marginTop: 14, fontSize: 12, color: colors.textHint },
hintDanger: { marginTop: 14, fontSize: 12, color: colors.danger },
btnArea: { marginTop: 26 },
formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
});

156
components/onboarding/screens/P2PhoneCode.tsx

@ -1,73 +1,77 @@
import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme';
import { useOnboarding, useWechatBindPhone } from '../../../src/hooks';
import { SMS_HELP_BODY, SMS_HELP_TITLE } from '../../../src/constants';
import { useAgreementGate, useLogin, useOnboarding, useSmsCode, useWechatBindPhone } from '../../../src/hooks';
import { isValidPhone, SMS_CODE_MAX_LENGTH, SMS_CODE_MIN_LENGTH } from '../../../src/utils';
import { useAuth } from '../../../src/context/AuthContext';
import { ScreenShell } from '../ui/ScreenShell';
import { CountdownButton } from '../ui/CountdownButton';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { PrimaryButton } from '../ui/PrimaryButton';
import { AgreementModal } from '../ui/AgreementModal';
import { SliderCaptcha } from '../ui/SliderCaptcha';
const PHONE_RE = /^1\d{10}$/;
/** 登录阶段:手机号 → 验证码 */
type Stage = 'phone' | 'code';
/** P2 登录/注册:阶段1 手机号(对齐参考样式)→ 阶段2 验证码 + 60s 倒计时 */
export function P2PhoneCode() {
const { data, setData, navigate, back } = useOnboarding();
const [code, setCode] = useState('');
const [codeSent, setCodeSent] = useState(false);
/** 未勾选协议时被拦截的动作 */
const [pending, setPending] = useState<(() => void) | null>(null);
const [captchaVisible, setCaptchaVisible] = useState(false);
/** 微信第一次登录的绑定注册能力 */
const { gate, modalProps } = useAgreementGate();
const { login, loading: logging, error: loginError } = useLogin();
const { send: sendCode, error: smsError } = useSmsCode();
/** 登录态写入:此前本页只跳 P3 不写 token,导致登录态始终为空 */
const { login: writeAuth } = useAuth();
const { bind, loading: binding, error: bindError } = useWechatBindPhone();
const [code, setCode] = useState('');
const [stage, setStage] = useState<Stage>('phone');
const agreed = data.agreed;
const phone = data.phone;
const phoneValid = PHONE_RE.test(phone);
const phoneValid = isValidPhone(phone);
/** 有值说明本页是从「微信第一次登录」进来的,提交时需完成微信绑定 */
const wechatTicket = data.wechatTicket;
const gate = (action: () => void) => {
if (agreed) {
action();
} else {
setPending(() => action);
/** 阶段1 → 阶段2:发送验证码;发送失败则停在阶段1,不误导用户去填验证码 */
const requestCode = async () => {
if (!phoneValid) return;
try {
await sendCode(phone);
setStage('code');
} catch {
// 失败原因由 useSmsCode 记录,页面下方统一展示
}
};
const accept = () => {
setData({ agreed: true });
const act = pending;
setPending(null);
act?.();
};
/** 阶段1 → 阶段2:发送验证码 */
const sendCode = () => {
if (!phoneValid) return;
setCodeSent(true);
/** 阶段2 重新获取(不改变阶段) */
const resendCode = () => {
void sendCode(phone).catch(() => {
// 同上,错误已记录
});
};
/*
* 提交验证码:
* - 微信第一次登录(data.wechatTicket 有值):调绑定注册接口把 openid 与手机号关联,
* 成功即写入登录态,再进 P3 选择身份;失败则留在当前页并提示。
* - 其他链路:沿用原逻辑,仅记录手机号后进 P3。
* hook 内部已写入登录态,再进 P3 选择身份;失败则留在当前页并提示。
* - 手机号登录:换取令牌并写入登录态(本次修复的缺口),成功后才进 P3。
*/
const handleLogin = async () => {
if (code.length < 4) return;
if (code.length < SMS_CODE_MIN_LENGTH) return;
setData({ phone, code });
if (wechatTicket) {
try {
await bind({
token: wechatTicket.token,
openid: wechatTicket.openid,
phone,
code,
});
await bind({ token: wechatTicket.token, openid: wechatTicket.openid, phone, code });
} catch {
return;
}
} else {
try {
const result = await login(phone, code);
writeAuth(result.token);
} catch {
return;
}
@ -78,9 +82,12 @@ export function P2PhoneCode() {
/** 掩码手机号:138****1234 */
const maskedPhone = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone;
/** 「接收不到短信」:原先是点了没反应的纯文本,改为给出可执行的下一步 */
const showSmsHelp = () => Alert.alert(SMS_HELP_TITLE, SMS_HELP_BODY);
return (
<ScreenShell showBack onBack={back}>
{!codeSent ? (
<ScreenShell showBack onBack={stage === 'code' ? () => setStage('phone') : back}>
{stage === 'phone' ? (
/* ---------- 阶段1:手机号 ---------- */
<View>
<Text style={styles.h1}>登录/注册</Text>
@ -107,12 +114,16 @@ export function P2PhoneCode() {
</View>
<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}>
<PrimaryButton label="下一步" active={phoneValid} onPress={() => gate(sendCode)} />
<PrimaryButton label="下一步" active={phoneValid} onPress={() => gate(requestCode)} />
</View>
{smsError ? <Text style={styles.formError}>{smsError.message}</Text> : null}
<View style={styles.agree}>
<AgreementCheckbox
checked={agreed}
@ -129,25 +140,17 @@ export function P2PhoneCode() {
/>
</View>
{/* 分隔线 + 其他登录方式 */}
{/* 其他登录方式:密码登录(与 P2P 互跳,保证两条通道都有入口) */}
<View style={styles.orRow}>
<View style={styles.orLine} />
<Text style={styles.orText}>或通过以下方式登录</Text>
<Text style={styles.orText}>或</Text>
<View style={styles.orLine} />
</View>
<View style={styles.socialRow}>
<Pressable style={[styles.socialCircle, { backgroundColor: colors.success }]} onPress={() => gate(() => setCaptchaVisible(true))}>
<Ionicons name="logo-wechat" size={26} color={colors.textInverse} />
</Pressable>
<Pressable style={[styles.socialCircle, { backgroundColor: colors.primary }]} onPress={() => gate(sendCode)}>
<Ionicons name="chatbubble-ellipses" size={24} color={colors.textInverse} />
</Pressable>
<Pressable style={[styles.socialCircle, { backgroundColor: colors.dangerBtn }]} onPress={() => gate(sendCode)}>
<Text style={styles.socialIdText}>{'数字\n身份'}</Text>
<Pressable onPress={() => gate(() => navigate('P2P'))} hitSlop={8}>
<Text style={styles.switchLink}>密码登录</Text>
</Pressable>
</View>
</View>
) : (
/* ---------- 阶段2:验证码 ---------- */
<View>
@ -158,25 +161,27 @@ export function P2PhoneCode() {
<TextInput
style={styles.phoneInput}
value={code}
onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, 6))}
onChangeText={(t) => setCode(t.replace(/[^\d]/g, '').slice(0, SMS_CODE_MAX_LENGTH))}
placeholder="请输入验证码"
placeholderTextColor={colors.textHint}
keyboardType="number-pad"
maxLength={6}
maxLength={SMS_CODE_MAX_LENGTH}
/>
<CountdownButton disabled={!phoneValid} onSend={() => setCodeSent(true)} />
<CountdownButton disabled={!phoneValid} onSend={resendCode} />
</View>
<View style={styles.underline} />
<View style={styles.btnArea}>
<PrimaryButton
label={binding ? '绑定中…' : '登录'}
active={code.length >= 4 && !binding}
active={code.length >= SMS_CODE_MIN_LENGTH && !binding && !logging}
onPress={() => gate(handleLogin)}
/>
</View>
{bindError ? <Text style={styles.bindError}>{bindError.message}</Text> : null}
{bindError ? <Text style={styles.formError}>{bindError.message}</Text> : null}
{loginError ? <Text style={styles.formError}>{loginError.message}</Text> : null}
{smsError ? <Text style={styles.formError}>{smsError.message}</Text> : null}
<View style={styles.agree}>
<AgreementCheckbox
@ -196,24 +201,7 @@ export function P2PhoneCode() {
</View>
)}
<SliderCaptcha
visible={captchaVisible}
onSuccess={() => {
setCaptchaVisible(false);
navigate('P2W');
}}
onClose={() => setCaptchaVisible(false)}
/>
<AgreementModal
visible={pending !== null}
onAccept={accept}
onDecline={() => setPending(null)}
onOpenAgreement={() => {
setPending(null);
navigate('B2');
}}
/>
<AgreementModal {...modalProps} />
</ScreenShell>
);
}
@ -232,22 +220,16 @@ const styles = StyleSheet.create({
divider: { width: 1, height: 20, backgroundColor: colors.border, marginHorizontal: 10 },
phoneInput: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 },
underline: { height: 1, backgroundColor: colors.borderLight },
smsHelp: { fontSize: 13, color: colors.textHint, marginTop: 16 },
// 已是可点入口(原来是无反馈纯文本),故用可读对比度的 primaryText 而非 textHint
smsHelpRow: { alignSelf: 'flex-start', marginTop: 16 },
smsHelp: { fontSize: 13, color: colors.primaryText },
btnArea: { marginTop: 28 },
agree: { marginTop: 18 },
bindError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' },
orRow: { flexDirection: 'row', alignItems: 'center', marginTop: 40, gap: 12 },
orRow: { flexDirection: 'row', alignItems: 'center', marginTop: 36, gap: 12 },
orLine: { flex: 1, height: 1, backgroundColor: colors.borderLight },
orText: { fontSize: 12, color: colors.textHint },
socialRow: { flexDirection: 'row', justifyContent: 'center', gap: 28, marginTop: 26 },
socialCircle: {
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
},
socialIdText: { fontSize: 12, fontWeight: '700', color: colors.textInverse, textAlign: 'center', lineHeight: 15 },
switchLink: { marginTop: 20, textAlign: 'center', fontSize: 14, color: colors.primaryText, fontWeight: '600' },
});

142
components/onboarding/screens/P2QuickLogin.tsx

@ -1,39 +1,63 @@
import { useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks';
import { CARRIER_LABEL } from '../../../src/constants';
import { useAgreementGate, useCarrierPhone, useLogin, useOnboarding } from '../../../src/hooks';
import { useAuth } from '../../../src/context/AuthContext';
import { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { PrimaryButton } from '../ui/PrimaryButton';
import { SupportFooter } from '../ui/SupportFooter';
import { AgreementModal } from '../ui/AgreementModal';
/** P2Q 一键登录确认:本机号码认证 —— 掩码手机号 + 一键登录 + 运营商服务声明 */
/**
* P2Q 一键登录确认:本机号码认证。
*
* 取号(运营商 SDK)与换登录态是两步,本页负责串起来:
* 1. 进入页面即取号(useCarrierPhone)→ 展示掩码号码;
* 2. 点「一键登录」→ 用取号令牌换正式登录态 → 写登录态 → 把号码写回引导资料 → 进 P3。
*
* 原先这里读的是 `data.phone`(只有短信登录才会写),运营商通道的号码既没取也没存,
* 所以永远显示占位「本机号码」;登录接口返回的 phone 也被直接丢掉。
* 现在取号失败时不再假装号码还在:「一键登录」置灰,同时给出「重新获取」与「改用验证码登录」。
*/
export function P2QuickLogin() {
const { data, setData, navigate, back } = useOnboarding();
const [pending, setPending] = useState<(() => void) | null>(null);
const { gate, modalProps } = useAgreementGate();
const { carrierToken, maskedPhone, carrier, ready, loading: fetching, error: fetchError, refresh } =
useCarrierPhone();
const { loginByCarrier, loading: logging, error: loginError } = useLogin();
/** 登录态写入:此前本页只跳 P3 不写 token */
const { login: writeAuth } = useAuth();
const agreed = data.agreed;
const phone = data.phone;
const carrierName = CARRIER_LABEL[carrier];
/** 取号失败:号码与令牌都没有,本页只能提供重试或改走验证码登录 */
const failed = !fetching && !ready;
const busy = fetching || logging;
const gate = (action: () => void) => {
if (agreed) {
action();
} else {
setPending(() => action);
/** 一键登录:用取号令牌换登录态,成功后才进 P3 */
const handleLogin = async () => {
try {
const result = await loginByCarrier(carrierToken);
writeAuth(result.token);
// 号码写回引导资料:后续步骤(P2P/P2R 预填、B3 联系人电话)都以这里为准
setData({ phone: result.phone });
} catch {
// 失败原因由 useLogin 记录,页面下方展示
return;
}
navigate('P3');
};
const accept = () => {
setData({ agreed: true });
const act = pending;
setPending(null);
act?.();
/** 运营商认证服务条款由运营商在认证页内提供,本仓库没有该文档,不冒充能打开它 */
const showCarrierTerms = () => {
Alert.alert(
`${carrierName}认证服务条款`,
`一键登录的认证服务由${carrierName}提供,服务条款以认证页展示的内容为准。`,
);
};
/** 掩码:150****9367;未输入时用演示号段占位 */
const masked = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : '150****9367';
return (
<ScreenShell showBack onBack={back}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
@ -41,13 +65,46 @@ export function P2QuickLogin() {
<Illustration name="logo" size={88} radius={22} />
</View>
<Text style={styles.phone}>{masked}</Text>
<Text style={styles.subtitle}>首次验证通过即注册有职账号</Text>
{ready ? (
<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}>
<PrimaryButton label="一键登录" active onPress={() => gate(() => navigate('P3'))} />
<PrimaryButton
label="一键登录"
active={ready && !busy}
loading={logging}
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>
{loginError ? <Text style={styles.formError}>{loginError.message}</Text> : null}
{fetchError && !loginError ? (
<Text style={styles.formError}>{fetchError.message}</Text>
) : null}
<View style={styles.agree}>
<AgreementCheckbox
checked={agreed}
@ -55,7 +112,9 @@ export function P2QuickLogin() {
text={
<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>
和
@ -68,23 +127,11 @@ export function P2QuickLogin() {
</ScrollView>
<View style={styles.footer}>
<View style={styles.footRow}>
<Text style={styles.footItem}>服务热线</Text>
<Text style={styles.footItem}>举报监督电话</Text>
<Text style={styles.footItem}>资质证照</Text>
</View>
<Text style={styles.carrierNote}>认证服务由中国移动提供</Text>
<SupportFooter />
<Text style={styles.carrierNote}>认证服务由{carrierName}提供</Text>
</View>
<AgreementModal
visible={pending !== null}
onAccept={accept}
onDecline={() => setPending(null)}
onOpenAgreement={() => {
setPending(null);
navigate('B2');
}}
/>
<AgreementModal {...modalProps} />
</ScreenShell>
);
}
@ -99,13 +146,28 @@ const styles = StyleSheet.create({
color: colors.textPrimary,
marginTop: 40,
},
phonePending: {
textAlign: 'center',
fontSize: 22,
fontWeight: '700',
color: colors.textHint,
marginTop: 44,
},
phoneFailed: { fontSize: 18, color: colors.dangerText },
subtitle: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 12 },
btnArea: { marginTop: 36 },
fallbackRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 24,
marginTop: 18,
},
fallbackBtn: { paddingVertical: 6, paddingHorizontal: 4 },
fallbackText: { fontSize: 14, fontWeight: '600', color: colors.primaryText },
formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agree: { marginTop: 20 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' },
footer: { paddingTop: 16, paddingBottom: 8 },
footRow: { flexDirection: 'row', justifyContent: 'center', gap: 18 },
footItem: { fontSize: 12, color: colors.textHint },
carrierNote: { textAlign: 'center', fontSize: 12, color: colors.textHint, marginTop: 12 },
});

43
components/onboarding/screens/P2WechatCode.tsx

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

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

14
components/onboarding/screens/P4Intent.tsx

@ -9,7 +9,7 @@ import { PrimaryButton } from '../ui/PrimaryButton';
const HOT = ['Java', '项目经理', '运维', '测试', '前端', '数据分析', '电子', '通信'];
export function P4Intent() {
const { data, setData, navigate, back, meta } = useOnboarding();
const { data, setData, navigate, back } = useOnboarding();
const isStudent = data.isStudent;
const city = data.city;
const positions = data.expectedPositions;
@ -19,10 +19,12 @@ export function P4Intent() {
expectedPositions: positions.includes(p) ? positions.filter((x) => x !== p) : [...positions, p],
});
const canNext = isStudent !== null && positions.length > 0;
// 工作地点是本页采集项之一(点标签进 P5 选),没选城市就不放行 ——
// 岗位推荐的偏好里城市是加分项,空城市会让推荐失去一半依据
const canNext = isStudent !== null && positions.length > 0 && city !== '';
return (
<ScreenShell time={meta.time} battery={meta.battery} title="求职意向" showBack onBack={back}>
<ScreenShell title="求职意向" showBack onBack={back}>
<Text style={styles.section}>你是学生吗</Text>
<TagGroup>
<Tag label="是" selected={isStudent === true} onPress={() => setData({ isStudent: true })} />
@ -32,7 +34,11 @@ export function P4Intent() {
<Text style={styles.section}>工作地点</Text>
<View style={styles.cityRow}>
<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>
</View>

13
components/onboarding/screens/P5City.tsx

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

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

22
components/onboarding/screens/P7Salary.tsx

@ -1,27 +1,31 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks';
import { applySalaryMin, salaryMaxOptions } from '../../../src/api';
import { SALARY_MIN_OPTIONS } from '../../../src/constants';
import { ScreenShell } from '../ui/ScreenShell';
import { PrimaryButton } from '../ui/PrimaryButton';
const MIN = ['9K', '10K', '11K'];
const MAX = ['11K', '12K', '13K'];
export function P7Salary() {
const { data, setData, navigate, back, meta } = useOnboarding();
const { data, setData, navigate, back } = useOnboarding();
// 「最高」档位随「最低」收窄(规则在 API 层),改最低时同步校正最高,
// 避免存出「最低 30K / 最高 15K」这种自相矛盾的期望薪资
const maxOptions = salaryMaxOptions(data.expectedSalaryMin);
const canNext = data.expectedSalaryMin !== '' && data.expectedSalaryMax !== '';
return (
<ScreenShell time={meta.time} battery={meta.battery} title="期望月薪" showBack onBack={back}>
<ScreenShell title="期望月薪" showBack onBack={back}>
<Text style={styles.tip}>1K 代表 1000 元</Text>
<View style={styles.cols}>
<View style={styles.col}>
<Text style={styles.colTitle}>最低</Text>
{MIN.map((v) => (
{SALARY_MIN_OPTIONS.map((v) => (
<Pressable
key={v}
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>
</Pressable>
@ -30,7 +34,7 @@ export function P7Salary() {
<View style={styles.col}>
<Text style={styles.colTitle}>最高</Text>
{MAX.map((v) => (
{maxOptions.map((v) => (
<Pressable
key={v}
style={[styles.opt, data.expectedSalaryMax === v && styles.optActive]}
@ -43,7 +47,7 @@ export function P7Salary() {
</View>
<View style={styles.footer}>
<PrimaryButton label="下一步" active onPress={() => navigate('P8')} />
<PrimaryButton label="下一步" active={canNext} onPress={() => navigate('P8')} />
</View>
</ScreenShell>
);

13
components/onboarding/screens/P8Resume1.tsx

@ -8,12 +8,18 @@ import { YearMonthWheel } from '../ui/WheelPicker';
import { PrimaryButton } from '../ui/PrimaryButton';
export function P8Resume1() {
const { data, setData, navigate, back, meta } = useOnboarding();
const { data, setData, navigate, back } = useOnboarding();
const canNext = data.resumeName.trim().length > 0;
// 姓名 / 性别 / 出生年月都是本页要采集的项,缺一项就不放行 —— 否则资料里会留下
// 「有姓名但没有出生年月」的半截记录
const canNext =
data.resumeName.trim().length > 0 &&
data.gender !== null &&
data.birthYear !== '' &&
data.birthMonth !== '';
return (
<ScreenShell time={meta.time} battery={meta.battery} title="创建在线简历" showBack onBack={back}>
<ScreenShell title="创建在线简历" showBack onBack={back}>
<View style={styles.iconWrap}>
<Ionicons name="document-text" size={34} color={colors.primary} />
</View>
@ -39,6 +45,7 @@ export function P8Resume1() {
month={data.birthMonth}
onYearChange={(v) => setData({ birthYear: v })}
onMonthChange={(v) => setData({ birthMonth: v })}
restingYear="2000年"
/>
<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 { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks';
import { JOB_STATUS_OPTIONS } from '../../../src/constants';
import { ScreenShell } from '../ui/ScreenShell';
import { RadioRow } from '../ui/RadioRow';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { PrimaryButton } from '../ui/PrimaryButton';
const OPTIONS = ['离职-随时到岗', '在职-月内到岗', '在职-考虑机会', '在职-暂不考虑'];
// 选项放常量:简历「求职意向」的积极/不积极判定读的是同一份,不能各写一份
const OPTIONS = JOB_STATUS_OPTIONS;
export function P9JobStatus() {
const { data, setData, navigate, back, meta } = useOnboarding();
const { data, setData, navigate, back } = useOnboarding();
return (
<ScreenShell time={meta.time} battery={meta.battery} title="求职状态" showBack onBack={back}>
<ScreenShell title="求职状态" showBack onBack={back}>
<View style={styles.iconWrap}>
<Ionicons name="person" size={34} color={colors.primary} />
</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 { Illustration } from './Illustration';
import type { AssetName } from '../assets';
interface Props {
name?: AssetName;
/** 拍照 / 相册得到的本地图片 URI;有值时优先于 name 渲染 */
uri?: string | null;
size?: number;
selected?: boolean;
onPress?: () => void;
}
/** 圆形头像占位:默认头像剪影 / 真实图;选中态加主色描边 */
export function Avatar({ name = 'defaultAvatar', size = 64, selected = false, onPress }: Props) {
/** 圆形头像:预设占位图 / 真实图片;选中态加主色描边 */
export function Avatar({
name = 'defaultAvatar',
uri = null,
size = 64,
selected = false,
onPress,
}: Props) {
const content = (
<View
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 },
]}
>
{uri ? (
<Image source={{ uri }} style={{ width: size, height: size }} resizeMode="cover" />
) : (
<Illustration name={name} size={size} circle />
)}
</View>
);
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 type { ComponentProps } from 'react';
import { colors } from '../../../src/theme';
@ -13,7 +13,13 @@ interface Props {
circle?: boolean;
}
/** 统一插画/Logo/头像占位:有真实图渲染图,否则渲染色块 + 图标 + 提示文字 */
/**
* 统一插画/Logo/头像占位:有真实图渲染图,否则渲染色块 + 图标。
*
* 占位期**不渲染 `hint`**:那些文字是给设计/开发看的素材说明
* (「手持 OFFER 职场男性插画」),直接画在用户界面上等于把开发备注当内容展示。
* 说明仍留在 `assets.ts`,供换图时对照。
*/
export function Illustration({ name, size = 120, radius = 16, circle = false }: Props) {
const def = ASSET_REGISTRY[name];
const source: ImageSourcePropType | null = def.source;
@ -26,12 +32,10 @@ export function Illustration({ name, size = 120, radius = 16, circle = false }:
return (
<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} />
{!circle && <Text style={styles.hint}>{def.hint}</Text>}
</View>
);
}
const styles = StyleSheet.create({
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,
},
});

4
components/onboarding/ui/PrimaryButton.tsx

@ -23,7 +23,9 @@ export function PrimaryButton({ label, active = true, onPress, loading = false }
const styles = StyleSheet.create({
btn: { height: 50, borderRadius: 12, alignItems: 'center', justifyContent: 'center' },
btnActive: { backgroundColor: colors.primary },
// 按钮底色用 primaryBtn 而非 primary:白字对比 4.8:1 过 WCAG AA,
// primary 作白字底只有 3.64:1,不满足 AA(见 AGENTS.md 配色规范)
btnActive: { backgroundColor: colors.primaryBtn },
btnDisabled: { backgroundColor: colors.textDisabled },
btnText: { fontSize: 17, fontWeight: '700', color: colors.textInverse },
});

5
components/onboarding/ui/ScreenShell.tsx

@ -14,13 +14,10 @@ interface Props {
children: ReactNode;
footer?: ReactNode;
scroll?: boolean;
/** 兼容旧调用保留,已不再渲染(统一使用系统真实状态栏) */
time?: string;
battery?: number;
}
/** 每屏统一外壳:系统状态栏 + 可选返回箭头 + 标题 + 滚动内容 + 底部固定区 */
export function ScreenShell({ title, showBack, onBack, backIcon = 'chevron-back', children, footer, scroll = true, time: _time, battery: _battery }: Props) {
export function ScreenShell({ title, showBack, onBack, backIcon = 'chevron-back', children, footer, scroll = true }: Props) {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<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 { ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme';
import { CURRENT_YEAR } from '../../../src/constants';
const ITEM_H = 44;
@ -9,13 +11,29 @@ function Column({
value,
onValueChange,
width,
restingIndex,
}: {
items: string[];
value: string;
onValueChange: (v: string) => void;
width: number;
/** 未选中时滚轮的停留位置(只影响视觉停留,不等于已选中) */
restingIndex: number;
}) {
const idx = Math.max(0, items.indexOf(value));
const scrollRef = useRef<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 y = e.nativeEvent.contentOffset.y;
const i = Math.min(items.length - 1, Math.max(0, Math.round(y / ITEM_H)));
@ -25,14 +43,15 @@ function Column({
return (
<View style={[styles.col, { width }]}>
<View style={styles.highlight} pointerEvents="none" />
<View style={[styles.highlight, idx < 0 && styles.highlightIdle]} pointerEvents="none" />
<ScrollView
ref={scrollRef}
style={styles.scroll}
showsVerticalScrollIndicator={false}
snapToInterval={ITEM_H}
snapToAlignment="center"
decelerationRate="fast"
contentOffset={{ x: 0, y: idx * ITEM_H }}
contentOffset={{ x: 0, y: initialIndex * ITEM_H }}
contentContainerStyle={{ paddingVertical: ITEM_H }}
onMomentumScrollEnd={handleEnd}
>
@ -51,16 +70,52 @@ interface DualProps {
month: string;
onYearChange: (v: string) => void;
onMonthChange: (v: string) => void;
/**
* 未选择年份时滚轮的停留项。默认停在当年(适合「入职时间」这类近期场景);
* 「出生年月」应传一个合理的起始年份,否则用户要空滑二十多行。
*/
restingYear?: string;
/**
* 可选年份跨度:往过去几年、往未来几年。
* 原实现固定 40 项(当年往前 39 年),只能覆盖 0–39 岁 ——
* 46 岁的求职者选不了自己的出生年;而「入职时间」也选不到明年。
*/
pastYears?: number;
futureYears?: number;
}
/** 年月双滚轮:用于出生年月 / 首次参加工作 / 入职时间 */
export function YearMonthWheel({ year, month, onYearChange, onMonthChange }: DualProps) {
const years = Array.from({ length: 40 }, (_, i) => `${2026 - i}年`);
export function YearMonthWheel({
year,
month,
onYearChange,
onMonthChange,
restingYear = `${CURRENT_YEAR}年`,
pastYears = 60,
futureYears = 0,
}: DualProps) {
const years = Array.from(
{ length: pastYears + futureYears + 1 },
(_, i) => `${CURRENT_YEAR + futureYears - i}年`,
);
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}月`);
const unselected = !year || !month;
const restingYearIndex = Math.max(0, years.indexOf(restingYear));
return (
<View>
<View style={styles.dual}>
<Column items={years} value={year} onValueChange={onYearChange} width={130} />
<Column items={months} value={month} onValueChange={onMonthChange} width={110} />
<Column
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>
);
}
@ -80,8 +135,11 @@ const styles = StyleSheet.create({
backgroundColor: colors.primaryLight,
opacity: 0.45,
},
// 未选中:高亮框退成中性描边,不再用主色底暗示「已经选好了」
highlightIdle: { backgroundColor: colors.white, opacity: 0.6 },
item: { height: ITEM_H, alignItems: 'center', justifyContent: 'center' },
itemText: { fontSize: 18, color: colors.textHint },
itemTextActive: { fontSize: 20, fontWeight: '700', color: colors.textPrimary },
dual: { flexDirection: 'row', justifyContent: 'center', gap: 14 },
hint: { fontSize: 13, color: colors.textHint, textAlign: 'center', marginTop: 10 },
});

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

27
package.json

@ -5,16 +5,21 @@
"dependencies": {
"@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0",
"expo": "57.0.21",
"expo-blur": "~57.0.2",
"expo-constants": "~57.0.12",
"expo-font": "~57.0.1",
"expo-linear-gradient": "~57.0.1",
"expo-linking": "~57.0.6",
"expo-router": "~57.0.14",
"expo": "~57.0.22",
"expo-blur": "~57.0.3",
"expo-constants": "~57.0.18",
"expo-file-system": "~57.0.7",
"expo-font": "~57.0.4",
"expo-image-picker": "~57.0.17",
"expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.10",
"expo-print": "~57.0.2",
"expo-router": "~57.0.21",
"expo-sharing": "~57.0.19",
"expo-splash-screen": "~57.0.9",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
"react-native": "0.86.2",
"react-native": "0.86.3",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4"
@ -25,7 +30,11 @@
"ios": "expo start --ios",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "jest"
"test": "jest",
"typecheck": "tsc --noEmit",
"check:routes": "node scripts/check-routes.mjs",
"check:affordances": "node scripts/check-affordances.mjs",
"check:backend-stubs": "node scripts/check-backend-stubs.mjs"
},
"private": true,
"devDependencies": {

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

177
src/__tests__/password.test.ts

@ -0,0 +1,177 @@
import {
changePassword,
getPasswordRuleMessage,
loginByPassword,
resetPassword,
sendSmsCode,
validatePassword,
validatePasswordConfirm,
} from '../api/api';
import { PASSWORD_RULE_LABEL } from '../constants';
import type { PasswordRuleError } from '../types';
import { isValidPhone, isValidSmsCode } from '../utils';
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock(与 salary.test.ts 同)
jest.mock('../lib/storage', () => ({
storageGet: jest.fn(async () => null),
storageSet: jest.fn(async () => undefined),
storageRemove: jest.fn(async () => undefined),
}));
/** 联合类型的全部成员:新增成员而漏配文案时,下面的完整性用例会失败 */
const ALL_RULE_ERRORS: PasswordRuleError[] = [
'empty',
'too-short',
'too-long',
'need-letter-digit',
'mismatch',
'same-as-old',
];
describe('validatePassword(密码强度业务规则)', () => {
it('空密码返回 empty', () => {
expect(validatePassword('')).toBe('empty');
});
it('少于 8 位返回 too-short', () => {
expect(validatePassword('ab12')).toBe('too-short');
expect(validatePassword('abc1234')).toBe('too-short');
});
it('超过 20 位返回 too-long', () => {
expect(validatePassword('abc12345678901234567890')).toBe('too-long');
});
it('纯数字返回 need-letter-digit', () => {
expect(validatePassword('12345678')).toBe('need-letter-digit');
});
it('纯字母返回 need-letter-digit', () => {
expect(validatePassword('abcdefgh')).toBe('need-letter-digit');
});
it('8–20 位且含字母和数字时通过', () => {
expect(validatePassword('abc12345')).toBeNull();
expect(validatePassword('a1b2c3d4e5f6g7h8i9j0')).toBeNull();
});
});
describe('validatePasswordConfirm(两次输入一致性)', () => {
it('一致时返回 null', () => {
expect(validatePasswordConfirm('abc12345', 'abc12345')).toBeNull();
});
it('不一致时返回 mismatch', () => {
expect(validatePasswordConfirm('abc12345', 'abc12346')).toBe('mismatch');
});
});
describe('PASSWORD_RULE_LABEL(错误码 → 文案映射完整性)', () => {
it('每个错误码都有非空中文文案', () => {
ALL_RULE_ERRORS.forEach((code) => {
const label = PASSWORD_RULE_LABEL[code];
expect(typeof label).toBe('string');
expect(label.length).toBeGreaterThan(0);
});
});
it('getPasswordRuleMessage 与映射表一致', () => {
ALL_RULE_ERRORS.forEach((code) => {
expect(getPasswordRuleMessage(code)).toBe(PASSWORD_RULE_LABEL[code]);
});
});
});
describe('validators(手机号 / 验证码格式)', () => {
it('手机号必须是 1 开头共 11 位', () => {
expect(isValidPhone('13800138000')).toBe(true);
expect(isValidPhone('1380013800')).toBe(false);
expect(isValidPhone('23800138000')).toBe(false);
expect(isValidPhone('138001380001')).toBe(false);
expect(isValidPhone('')).toBe(false);
});
it('验证码为 4–6 位数字', () => {
expect(isValidSmsCode('1234')).toBe(true);
expect(isValidSmsCode('123456')).toBe(true);
expect(isValidSmsCode('123')).toBe(false);
expect(isValidSmsCode('1234567')).toBe(false);
expect(isValidSmsCode('12a4')).toBe(false);
});
});
describe('loginByPassword(手机号 + 密码登录)', () => {
it('手机号非法时拒绝', async () => {
await expect(loginByPassword('138', 'abc12345')).rejects.toThrow('手机号格式不正确');
});
it('密码不合规时统一提示账号或密码错误(不泄露账号是否存在)', async () => {
await expect(loginByPassword('13800138000', 'abc')).rejects.toThrow('手机号或密码错误');
});
it('参数合法时返回令牌', async () => {
const result = await loginByPassword('13800138000', 'abc12345');
expect(result.phone).toBe('13800138000');
expect(result.token).toContain('13800138000');
});
});
describe('sendSmsCode(发送验证码)', () => {
it('手机号非法时拒绝', async () => {
await expect(sendSmsCode('138')).rejects.toThrow('手机号格式不正确');
});
it('手机号合法时成功', async () => {
await expect(sendSmsCode('13800138000')).resolves.toBeUndefined();
});
});
describe('resetPassword(重置 / 首次设置密码)', () => {
it('验证码不合法时拒绝', async () => {
await expect(
resetPassword({ phone: '13800138000', code: '12', password: 'abc12345' }),
).rejects.toThrow('验证码不正确');
});
it('新密码不合规时返回对应文案', async () => {
await expect(
resetPassword({ phone: '13800138000', code: '1234', password: '12345678' }),
).rejects.toThrow(PASSWORD_RULE_LABEL['need-letter-digit']);
});
it('参数合法时直接下发登录态,用户无需再登录一次', async () => {
const result = await resetPassword({
phone: '13800138000',
code: '1234',
password: 'abc12345',
});
expect(result.phone).toBe('13800138000');
expect(result.token).toContain('13800138000');
});
});
describe('changePassword(已登录态改密)', () => {
it('未填原密码时拒绝', async () => {
await expect(
changePassword({ oldPassword: '', newPassword: 'abc12345', confirmPassword: 'abc12345' }),
).rejects.toThrow('请输入原密码');
});
it('两次新密码不一致时拒绝', async () => {
await expect(
changePassword({ oldPassword: 'old12345', newPassword: 'abc12345', confirmPassword: 'abc12346' }),
).rejects.toThrow(PASSWORD_RULE_LABEL.mismatch);
});
it('新密码与原密码相同时拒绝', async () => {
await expect(
changePassword({ oldPassword: 'abc12345', newPassword: 'abc12345', confirmPassword: 'abc12345' }),
).rejects.toThrow(PASSWORD_RULE_LABEL['same-as-old']);
});
it('参数合法时成功', async () => {
await expect(
changePassword({ oldPassword: 'old12345', newPassword: 'abc12345', confirmPassword: 'abc12345' }),
).resolves.toBeUndefined();
});
});

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

74
src/__tests__/salary.test.ts

@ -1,23 +1,40 @@
import { computeSalaryResult } from '../api/api';
import { computeSalaryResult, fetchSalaryRecords, saveSalaryRecord, deleteSalaryRecord } from '../api/api';
import type { SalaryCalcInput } from '../types';
// 内存版 storage mock,使记录类接口可在单测中验证(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];
}),
};
});
describe('computeSalaryResult(薪资计算器业务规则)', () => {
it('低薪且五险一金+起征点覆盖时个税为 0', () => {
const input = {
monthlyGross: 5000,
city: '北京',
bonusMonths: 0,
socialBase: 5000,
socialBase: 10000,
providentFundRate: 0.12,
specialDeduction: 0,
};
const r = computeSalaryResult(input as any);
// monthlySocial = 5000*(0.08+0.02+0.005+0.12) = 1125
expect(r.monthlySocial).toBe(1125);
// taxable = max(0, 5000-1125-5000) = 0 → 个税 0
// 社保基数 10000 在北京市上下限(6821-33891)内,不触发 clamp
// monthlySocial = 10000*(0.08+0.02+0.005+0.12) = 2250
expect(r.monthlySocial).toBe(2250);
// taxable = max(0, 5000-2250-5000) = 0 → 个税 0
expect(r.monthlyTax).toBe(0);
// monthlyNet = 5000 - 1125 = 3875
expect(r.monthlyNet).toBe(3875);
// monthlyNet = 5000 - 2250 = 2750
expect(r.monthlyNet).toBe(2750);
expect(r.annualGross).toBe(60000);
expect(r.annualNet).toBe(46500);
expect(r.annualNet).toBe(33000);
});
it('含年终奖时月度税前合计摊入', () => {
@ -33,3 +50,44 @@ describe('computeSalaryResult(薪资计算器业务规则)', () => {
expect(r.monthlyTotal).toBe(11667);
});
});
describe('薪资计算记录(本地持久化)', () => {
const baseInput: SalaryCalcInput = {
monthlyGross: 30000,
city: '北京',
socialBase: 25000,
providentFundRate: 0.12,
specialDeduction: 2000,
bonusMonths: 0,
};
it('保存后可在记录列表中读取,并按时间倒序', async () => {
const r1 = computeSalaryResult(baseInput);
const rec1 = await saveSalaryRecord(baseInput, r1, '深圳 offer');
expect(rec1.id).toBeTruthy();
expect(rec1.note).toBe('深圳 offer');
const later = await new Promise<ReturnType<typeof saveSalaryRecord>>((resolve) => {
setTimeout(async () => {
const r2 = computeSalaryResult({ ...baseInput, monthlyGross: 40000 });
resolve(await saveSalaryRecord({ ...baseInput, monthlyGross: 40000 }, r2));
}, 5);
});
const list = await fetchSalaryRecords();
expect(list).toHaveLength(2);
// 后保存的排在最前
expect(list[0].id).toBe(later.id);
expect(list[1].id).toBe(rec1.id);
});
it('删除记录后不再出现在列表中', async () => {
const listBefore = await fetchSalaryRecords();
const target = listBefore[0];
await deleteSalaryRecord(target.id);
const listAfter = await fetchSalaryRecords();
expect(listAfter.find((r) => r.id === target.id)).toBeUndefined();
expect(listAfter).toHaveLength(listBefore.length - 1);
});
});

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', () => {
it('千分位格式化 12846 → "12,846"', () => {
@ -29,3 +38,83 @@ describe('formatViews', () => {
expect(formatViews(8500)).toBe('8500');
});
});
/*
* 进度文案此前是写在页面上的字面量(P6 写「3/3」),与实际步骤数无关。
* 这里锁定「序号 = 数组下标 + 1」与「分母 = 主线长度」,
* 以后加删步骤不需要改测试,但写死数字会被抓出来。
*/
describe('onboardingStepLabel', () => {
it('主线每一步的序号与位置一致,分母为步骤总数', () => {
ONBOARDING_STEP_ORDER.forEach((step, i) => {
expect(onboardingStepLabel(step)).toBe(`第 ${i + 1} / ${ONBOARDING_STEP_ORDER.length} 步`);
});
});
it('非主线屏返回 null(不渲染进度)', () => {
// 登录/协议/企业端,以及 P5、P17 两个「进入后返回」的子页
['P1', 'P2', 'P2Q', 'P2P', 'P2R', 'P2W', 'P5', 'P17', 'B1', 'B2', 'B3', 'P23', ''].forEach(
(step) => {
expect(onboardingStepLabel(step)).toBeNull();
},
);
});
it('主线不含重复项(重复会把同一步算两次)', () => {
expect(new Set(ONBOARDING_STEP_ORDER).size).toBe(ONBOARDING_STEP_ORDER.length);
});
});
/*
* 工作经历编辑页回填滚轮用。解析不出时必须给空串(滚轮显示「请滑动选择」),
* 给它一个兜底索引会静默把用户的入职年月改成别的年份。
*/
describe('splitYearMonth', () => {
it('「2023年2月」拆成「2023年」与「2月」', () => {
expect(splitYearMonth('2023年2月')).toEqual({ year: '2023年', month: '2月' });
});
it('两位月份同样拆得开', () => {
expect(splitYearMonth('2023年12月')).toEqual({ year: '2023年', month: '12月' });
});
it('前后空白不影响解析', () => {
expect(splitYearMonth(' 2023年2月 ')).toEqual({ year: '2023年', month: '2月' });
});
it('空串与「至今」解析出两个空串(调用方据此走未选中态)', () => {
expect(splitYearMonth('')).toEqual({ year: '', month: '' });
expect(splitYearMonth('至今')).toEqual({ year: '', month: '' });
});
it('演示简历里的「2022.07」这种格式解析不出,也不猜', () => {
expect(splitYearMonth('2022.07')).toEqual({ year: '', month: '' });
});
});
describe('formatDateTime', () => {
it('输出「MM-DD HH:mm」', () => {
const local = new Date(2026, 8, 15, 14, 20);
expect(formatDateTime(local.toISOString())).toBe('09-15 14:20');
});
it('时分补零', () => {
const local = new Date(2026, 0, 3, 9, 5);
expect(formatDateTime(local.toISOString())).toBe('01-03 09:05');
});
it('空串与非法时间串返回空串(页面据此不渲染该行,而不是显示 Invalid Date)', () => {
expect(formatDateTime('')).toBe('');
expect(formatDateTime('不是时间')).toBe('');
});
});
describe('isValidEmail', () => {
it.each(['a@b.co', 'zhang.xiao-man+1@example.com', ' a@b.cn '])('合法:%s', (email) => {
expect(isValidEmail(email)).toBe(true);
});
it.each(['', 'a', 'a@', '@b.com', 'a@b', 'a b@c.com', 'a@b.c'])('非法:%s', (email) => {
expect(isValidEmail(email)).toBe(false);
});
});

1995
src/api/api.ts

File diff suppressed because it is too large

51
src/api/index.ts

@ -3,6 +3,11 @@
*/
export type { LoginResult } from './api';
export type { WechatLoginResult, WechatSession, WechatBindPhonePayload } from '../types';
export type {
ChangePasswordPayload,
PasswordResetPayload,
PasswordRuleError,
} from '../types';
export {
fetchAboutInfo,
fetchAbilityAssessment,
@ -21,6 +26,7 @@ export {
sendChatMessage,
fetchEnterpriseCertification,
fetchEnterpriseCommunication,
filterEnterpriseChats,
fetchEnterpriseOverview,
fetchGameLevels,
fetchGameProgress,
@ -31,6 +37,7 @@ export {
fetchJobManagement,
fetchJobPublishForm,
fetchTalentRecommendation,
filterTalentCandidates,
fetchLegalBrand,
fetchLegalConversation,
fetchLegalFAQs,
@ -50,6 +57,7 @@ export {
fetchFavorites,
fetchHistory,
fetchOfferCompareData,
fetchPendingOfferCount,
fetchJobOverallScore,
fetchJobPreference,
saveJobPreference,
@ -59,6 +67,16 @@ export {
fetchNotifications,
fetchNotificationSettings,
fetchOnboardingChecklist,
addChecklistItem,
removeChecklistItem,
buildChecklistText,
fetchOnboardingProfile,
saveOnboardingProfile,
clearOnboardingProfile,
toOnboardingProfile,
fromOnboardingProfile,
salaryMaxOptions,
applySalaryMin,
fetchOperationRules,
fetchOverallScore,
fetchPrivacySettings,
@ -83,6 +101,18 @@ export {
fetchLegalServiceRequests,
createLegalServiceRequest,
loginByPhone,
loginByPassword,
loginByCarrier,
fetchCarrierPhone,
maskPhone,
fetchCacheSize,
clearAppCache,
sendSmsCode,
resetPassword,
changePassword,
validatePassword,
validatePasswordConfirm,
getPasswordRuleMessage,
fetchWechatLoginResult,
bindWechatAndRegister,
setMockWechatAccountStatus,
@ -91,23 +121,44 @@ export {
fetchRankData,
fetchResume,
fetchResumeExportSettings,
fetchResumeSavedAt,
clearResumeOverride,
fetchSalaryBrackets,
fetchSalaryData,
fetchSalaryRecords,
fetchSalaryToolLinks,
saveSalaryRecord,
deleteSalaryRecord,
fetchSkillCloud,
fetchSkillLearningPath,
filterSkillWords,
fetchUserProfile,
fetchUserStats,
fetchWeightSettings,
inviteCandidate,
estimateJobQuality,
optimizeResume,
optimizeResumeExperience,
optimizeResumeWithJD,
polishResumeSelfEvaluation,
publishJob,
replyJobReview,
saveOperationRules,
saveResumeBasicInfo,
saveResumeEducation,
saveResumeExperience,
saveResumeExportSettings,
saveResumeProjects,
saveResumeSelfEvaluation,
saveResumeSkills,
saveResumeSnapshot,
exportResume,
saveWeightSettings,
submitEnterpriseCertification,
submitInterviewScore,
saveInterviewScoreDraft,
fetchInterviewScoreDraft,
clearInterviewScoreDraft,
computeSalaryResult,
resetGameProgress,
sendLegalMessage,

72
src/components/PasswordField.tsx

@ -0,0 +1,72 @@
import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, TextInput, View } from 'react-native';
import { PASSWORD_MAX_LENGTH } from '../constants';
import { colors } from '../theme';
interface Props {
value: string;
onChangeText: (t: string) => void;
placeholder?: string;
/** 最大长度,默认取密码规则上限 */
maxLength?: number;
/** 失焦回调,供页面触发规则校验提示 */
onBlur?: () => void;
}
/**
* 密码输入行:下划线样式 + 明文切换(眼睛图标)+ 一键清空。
* 登录页、重置密码页、账号安全改密页共用,避免三处各写一份输入样式。
*/
export function PasswordField({
value,
onChangeText,
placeholder = '请输入密码',
maxLength = PASSWORD_MAX_LENGTH,
onBlur,
}: Props) {
const [visible, setVisible] = useState(false);
return (
<>
<View style={styles.row}>
<TextInput
style={styles.input}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={colors.textHint}
secureTextEntry={!visible}
autoCapitalize="none"
autoCorrect={false}
maxLength={maxLength}
onBlur={onBlur}
/>
{value.length > 0 ? (
<Pressable onPress={() => onChangeText('')} hitSlop={8}>
<Ionicons name="close-circle" size={18} color={colors.textDisabled} />
</Pressable>
) : null}
<Pressable onPress={() => setVisible((v) => !v)} hitSlop={8}>
<Ionicons
name={visible ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.textHint}
/>
</Pressable>
</View>
<View style={styles.underline} />
</>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
height: 56,
gap: 10,
},
input: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 },
underline: { height: 1, backgroundColor: colors.borderLight },
});

216
src/constants/index.ts

@ -1,7 +1,13 @@
/**
* 常量统一导出(枚举、配置项等)
*/
import type { CitySocialProfile, JobLevel } from '../types';
import type {
CarrierName,
CitySocialProfile,
JobLevel,
PasswordRuleError,
ResumeTemplate,
} from '../types';
/** 请求失败自动重试次数 */
export const MAX_RETRY_COUNT = 3;
@ -62,5 +68,213 @@ export const DEFAULT_SOCIAL_PROFILE: CitySocialProfile = {
/** 一天的毫秒数,用于令牌有效期等时间计算 */
export const ONE_DAY_MS = 24 * 60 * 60 * 1000;
/** 拍照 / 相册未授权时的提示:权限是用户的选择,需要告诉他去哪里改,而不是静默失败 */
export const IMAGE_PICK_DENIED_HINT = '未获得权限,请在系统设置中开启相机 / 相册权限后重试';
/**
* 当前年份。滚轮可选年份等以「今年」为起点生成,
* 写死字面量会在跨年时悄悄过期,故取系统时间。
*/
export const CURRENT_YEAR = new Date().getFullYear();
/** 登录令牌有效期(毫秒),与后端 JWT 过期时间保持一致 */
export const TOKEN_TTL_MS = 30 * ONE_DAY_MS;
/* ===================== 运营商一键登录(本机号码认证) ===================== */
/**
* 运营商取号令牌有效期(毫秒)。
* 运营商下发的鉴权令牌是一次性的、短时效(业界普遍 5 分钟),
* 超时后必须重新取号 —— 页面在超时时把「一键登录」置灰并给出重新取号入口。
*/
export const CARRIER_TOKEN_TTL_MS = 5 * 60 * 1000;
/**
* 认证服务提供方 → 展示名。
* 取号接口返回哪家就用哪家,页面不写死「中国移动」。
*/
export const CARRIER_LABEL: Record<CarrierName, string> = {
cmcc: '中国移动',
cucc: '中国联通',
ctcc: '中国电信',
unknown: '运营商',
};
/* ===================== 登录密码规则 ===================== */
/** 登录密码最小长度(含) */
export const PASSWORD_MIN_LENGTH = 8;
/** 登录密码最大长度(含) */
export const PASSWORD_MAX_LENGTH = 20;
/**
* 密码规则错误码 → 中文提示(展示用枚举映射)。
* 页面只读该映射渲染提示,不在组件内写 if 判断规则。
*/
export const PASSWORD_RULE_LABEL: Record<PasswordRuleError, string> = {
empty: '请输入密码',
'too-short': `密码至少 ${PASSWORD_MIN_LENGTH} 位`,
'too-long': `密码最多 ${PASSWORD_MAX_LENGTH} 位`,
'need-letter-digit': '密码需同时包含字母和数字',
mismatch: '两次输入的密码不一致',
'same-as-old': '新密码不能与原密码相同',
};
/* ===================== 岗位推荐匹配(引导采集的偏好 → 岗位排序加分) ===================== */
/** 「全部城市」哨兵值:作为筛选条件时等同「未筛选」,岗位推荐才叠加偏好加分 */
export const ALL_CITY_LABEL = '全部城市';
/** 岗位所在地命中期望城市的加分 */
export const JOB_MATCH_CITY_BONUS = 15;
/** 岗位职位命中期望职位的加分 */
export const JOB_MATCH_POSITION_BONUS = 20;
/** 岗位薪资区间与期望薪资区间有重叠的加分 */
export const JOB_MATCH_SALARY_BONUS = 10;
/**
* 职位名归一化时剔除的噪音词(级别与通用后缀)。
* 目的是让「前端开发工程师」能与「高级前端工程师」匹配上 —— 去掉后都剩「前端」。
* 这是 mock 阶段的粗粒度匹配策略,真实后端就绪后由后端返回匹配度取代。
*/
export const POSITION_NOISE_WORDS = ['高级', '资深', '初级', '中级', '实习', '工程师', '开发', '经理'];
/* ===================== 求职偏好展示 ===================== */
/**
* 字段未采集时的占位文案(求职偏好、简历、我的页头部共用同一个)。
* 引导采集不到就不做假数据填充,也不拿 mock 人设顶上。
*/
export const UNSET_LABEL = '未设置';
/** 工作类型默认值:引导流程未采集该项,映射时补默认「全职」 */
export const PREFERENCE_DEFAULT_WORK_TYPE = '全职';
/* ===================== 期望月薪(P7)可选阶梯 ===================== */
/**
* 期望月薪「最低」可选阶梯(K)。
* 原阶梯只有 9K/10K/11K 三档,窄到不合常理(等于替用户回答了「你要 10K 左右」),
* 这里按真实招聘市场的月薪分布铺开。
*/
export const SALARY_MIN_OPTIONS: string[] = [
'5K', '8K', '10K', '12K', '15K', '20K', '25K', '30K', '40K',
];
/** 期望月薪「最高」可选阶梯(K);实际展示时按已选最低值过滤(见 api 层 salaryMaxOptions) */
export const SALARY_MAX_OPTIONS: string[] = [
'8K', '10K', '12K', '15K', '20K', '25K', '30K', '40K', '60K',
];
/* ===================== 引导流程步骤(进度文案的唯一依据) ===================== */
/**
* 求职者主线的步骤顺序(登录之后开始,到落地前一步结束)。
*
* 只含「一路下一步」的线性页面:P5(城市)是从 P4 进入后 `back()` 返回的子页,
* P17(案例参考)是从 P16 进入后返回的子页,二者不计入步骤数。
* 企业端 P3 → B3 只有一步,不适用本序列。
*/
export const ONBOARDING_STEP_ORDER: string[] = [
'P3', // 选择身份
'P4', // 求职意向
'P6', // 期望职位
'P7', // 期望月薪
'P8', // 姓名/性别/出生年月
'P9', // 求职状态
'P10', // 首次工作时间
'P11', // 当前职位
'P12', // 当前公司
'P13', // 入职时间
'P14', // 技能
'P16', // 工作内容
'P18', // 学历
'P20', // 毕业学校
'P21', // 头像
'P22', // 完成
];
/* ===================== 求职状态(P9,简历「求职意向」映射的唯一依据) ===================== */
/** 求职状态选项:P9 页与简历映射共用同一份,避免两处文案分叉 */
export const JOB_STATUS_OPTIONS: string[] = [
'离职-随时到岗',
'在职-月内到岗',
'在职-考虑机会',
'在职-暂不考虑',
];
/** 唯一「暂不考虑」状态:映射简历求职状态时视为不积极(其余选项均视为积极求职中) */
export const JOB_STATUS_INACTIVE = '在职-暂不考虑';
/** 在职状态前缀:简历工作经历据此判断是否仍在职(在职才写「至今」,离职不编结束时间) */
export const JOB_STATUS_EMPLOYED_PREFIX = '在职';
/* ===================== 合规页脚 ===================== */
/** 客服热线(2026-09-15 由产品提供) */
export const SUPPORT_HOTLINE = '15154608969';
/** 举报监督电话(同客服热线) */
export const SUPERVISION_PHONE = '15154608969';
/* ===================== 短信验证码求助 ===================== */
/** 「接收不到短信」提示标题(P2 与 P2W 共用,避免两页文案分叉) */
export const SMS_HELP_TITLE = '接收不到短信?';
/** 「接收不到短信」提示正文:给出可执行的下一步,而不是一句无反馈的纯文本 */
export const SMS_HELP_BODY = [
'1. 确认手机号填写正确、信号正常;',
'2. 检查是否被短信拦截(垃圾短信 / 拦截记录);',
'3. 等待倒计时结束后点「重新发送」。',
].join('\n');
/* ===================== 简历编辑与导出(后端未接阶段的落地口径) ===================== */
/** 简历模板(导出用):页面与选择弹层读同一份;后端就绪后由模板库接口下发 */
export const RESUME_TEMPLATES: ResumeTemplate[] = [
{ id: 'classic', name: '简约经典', color: 'primary' },
{ id: 'creative', name: '创意设计', color: 'info' },
{ id: 'tech', name: '技术专业', color: 'success' },
];
/** 自我评价最大字数:输入框的 maxLength 与字数提示读同一个值,避免两处不一致 */
export const RESUME_EVAL_MAX_LENGTH = 300;
/** 导出纸型(`ResumeExportSettings.paperSize` 的可选值 + 展示文案) */
export const RESUME_PAPER_SIZES: ReadonlyArray<{ value: 'A4' | 'letter'; label: string }> = [
{ value: 'A4', label: 'A4' },
{ value: 'letter', label: 'Letter' },
];
/** 导出方向(`ResumeExportSettings.orientation` 的可选值 + 展示文案) */
export const RESUME_ORIENTATIONS: ReadonlyArray<{
value: 'portrait' | 'landscape';
label: string;
}> = [
{ value: 'portrait', label: '纵向' },
{ value: 'landscape', label: '横向' },
];
/** 文件名未填时的导出文件名兜底规则说明(导出页的输入框提示与提示语读同一份) */
export const RESUME_FILE_NAME_HINT = '不填则用「姓名_期望职位简历」';
/**
* AI 简历能力(逐条优化 / 自我评价润色 / 按 JD 定向优化)未接入时的统一提示。
* 后端就绪后连同页面的提示分支一并删除。
*/
export const RESUME_AI_UNAVAILABLE_TIP =
'AI 改写需要后端模型支持,当前版本暂未开放;你的简历内容不会被改动。';
/** 非 PDF 格式的导出说明:本机只能生成 PDF,Word / 图片需后端导出服务 */
export const RESUME_EXPORT_FORMAT_NOTICE = 'Word / 图片导出需后端导出服务,当前按 PDF 导出';
/** 导出密码说明:PDF 加密需后端导出服务 */
export const RESUME_EXPORT_PASSWORD_NOTICE = '密码保护需后端导出服务,当前导出的文件不含密码';
/** 导出失败提示:本机生成不可用时给出明确归因,而不是返回假链接 */
export const RESUME_EXPORT_FAIL_HINT = '本机生成 PDF 失败,导出功能需后端导出服务支持';

11
src/hooks/index.ts

@ -5,12 +5,15 @@ export { useAbilityAssessment } from './useAbilityAssessment';
export { useAboutInfo } from './useAboutInfo';
export { useAccountCancel } from './useAccountCancel';
export { useAccountSecurity } from './useAccountSecurity';
export { useAgreementGate, type AgreementGate } from './useAgreementGate';
export { useImagePick, type UseImagePick } from './useImagePick';
export { useAIInterview } from './useAIInterview';
export { useAppealForm } from './useAppealForm';
export { useAsyncData } from './useAsyncData';
export { useAuditReport } from './useAuditReport';
export { useCandidateDetail } from './useCandidateDetail';
export { useCompareData } from './useCompareData';
export { useChangePassword } from './useChangePassword';
export { useConversation } from './useConversation';
export { useDeleteAllData } from './useDeleteAllData';
export { useDndSetting } from './useDndSetting';
@ -43,7 +46,12 @@ export { useMessageData } from './useMessageData';
export { useNotificationSettings } from './useNotificationSettings';
export { useOfferCompareData } from './useOfferCompareData';
export { useOnboardingChecklist } from './useOnboardingChecklist';
export { useCarrierPhone } from './useCarrierPhone';
export { useAppCache } from './useAppCache';
export { useOperationRules } from './useOperationRules';
export { usePasswordLogin } from './usePasswordLogin';
export { usePasswordReset } from './usePasswordReset';
export { useSmsCode } from './useSmsCode';
export { usePrivacyCenter } from './usePrivacyCenter';
export { usePrivacyDetailSetting } from './usePrivacyDetailSetting';
export { usePrivacySettings } from './usePrivacySettings';
@ -54,7 +62,10 @@ export { useResume } from './useResume';
export { useResumeExport } from './useResumeExport';
export { useSalaryBrackets } from './useSalaryBrackets';
export { useSalaryCalculator } from './useSalaryCalculator';
export { useSalaryRecords } from './useSalaryRecords';
export { useSalaryToolLinks } from './useSalaryToolLinks';
export { useSkillCloud } from './useSkillCloud';
export { useSkillLearningPath } from './useSkillLearningPath';
export { useTalentRecommendation } from './useTalentRecommendation';
export { useWeightSettings } from './useWeightSettings';
export { useLogin } from './useLogin';

73
src/hooks/useAgreementGate.ts

@ -0,0 +1,73 @@
import { useCallback, useState } from 'react';
import { useOnboarding } from './useOnboardingFlow';
/**
* 协议门卫(登录类页面共用)。
*
* 此前 gate / pending / accept 三段逻辑被逐字复制在 P1Login、P2PhoneCode、P2QuickLogin
* 三个页面里,新增密码登录页会变成第 4、5 份,故抽到此处统一维护。
*
* 用法:
* ```tsx
* const { gate, modalProps } = useAgreementGate();
* <Pressable onPress={() => gate(() => navigate('P3'))} />
* <AgreementModal {...modalProps} />
* ```
*/
export interface AgreementGate {
/** 门卫:已勾选协议直接执行动作;未勾选则弹协议弹窗,点「同意」后继续执行 */
gate: (action: () => void) => void;
/** 直接展开给 <AgreementModal {...modalProps} /> */
modalProps: {
visible: boolean;
onAccept: () => void;
onDecline: () => void;
onOpenAgreement: () => void;
};
}
export function useAgreementGate(): AgreementGate {
const { data, setData, navigate } = useOnboarding();
/** 未勾选协议时被拦截的动作,弹窗「同意」后继续执行 */
const [pending, setPending] = useState<(() => void) | null>(null);
const gate = useCallback(
(action: () => void) => {
if (data.agreed) {
action();
return;
}
// 动作是函数,必须包一层,否则 setState 会把它当成更新函数执行
setPending(() => action);
},
[data.agreed],
);
const onAccept = useCallback(() => {
setData({ agreed: true });
// 先取出待执行动作再清空:避免在 setState 更新函数里做副作用(StrictMode 下会重复执行)
const act = pending;
setPending(null);
act?.();
}, [pending, setData]);
const onDecline = useCallback(() => {
setPending(null);
}, []);
const onOpenAgreement = useCallback(() => {
setPending(null);
navigate('B2');
}, [navigate]);
return {
gate,
modalProps: {
visible: pending !== null,
onAccept,
onDecline,
onOpenAgreement,
},
};
}

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

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

Loading…
Cancel
Save