diff --git a/.gitignore b/.gitignore
index 54e4152..6696077 100644
--- a/.gitignore
+++ b/.gitignore
@@ -60,3 +60,10 @@ figma_tree.mjs
# 商店截图素材:保留目录约定文档,排除二进制与生成物
screenshots/**/*.png
screenshots/**/*.html
+
+# 包管理器锁文件:本项目统一用 yarn.lock,package-lock.json 由 npm 误生成,不入库
+package-lock.json
+
+# 调试截图(本地排查用,非资产)
+shot-check.png
+shot-*.png
diff --git a/AGENTS.md b/AGENTS.md
index 17ae494..32ae2bf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,7 +9,10 @@ Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before
========================================
【核心原则】
+你是react native+expo开发工程师
做任务前,先检查问题有没有错误前提、逻辑跳跃和信息缺失。 不要迎合我,要独立判断,区分事实、预测和主观观点。 涉及数字、人物和结论时尽量核实来源。 不同意就直接指出并给出依据、风险和替代解释。 还要主动提醒我忽略的变量、成本和偏差。
+需要填充图片的地方,没有图片的话,请标明这里需要填充什么图片,便于后续填充。
+
仓库:https://gitea.baskbuddy.com/baskbuddy/mydreamjob
请在每周的周一-周五的9:20的时候拉取一次远端仓库、每天17:50提交一次git仓库,其他时间可以改动但不需要提交,说明今天新增与改动的内容,上班时间是9:00-18:00,有页面出口就一定要有入口。
业务逻辑分层管理,不是没有,而是被妥善组织:
@@ -190,7 +193,7 @@ import { useJobList } from '../../src/hooks/useJobList';
========================================
-五、真实后端就绪后
+五、真实后端就绪后(后端其他同事在做,用的Java+Springboot)
========================================
仅替换 src/api/api.ts 中函数体为真实 fetch(签名不变),页面与 hooks 无需改动。
diff --git a/app.config.js b/app.config.js
index 8b44de2..95e8dd9 100644
--- a/app.config.js
+++ b/app.config.js
@@ -49,6 +49,18 @@ export default {
plugins: [
'expo-router',
'expo-font',
+ // 拍照 / 相册取图(营业执照、头像)。原生权限文案只有出包时生效,
+ // Expo Go 里用的是 Expo Go 自己的 Info.plist,故此处改动需真机出包验证。
+ [
+ 'expo-image-picker',
+ {
+ // 本项目只选静态图、不录视频:不申请麦克风权限(最小必要原则),
+ // 插件默认会加 RECORD_AUDIO,这里显式关掉
+ microphonePermission: false,
+ photosPermission: '有职需要访问相册,用于上传营业执照与设置头像',
+ cameraPermission: '有职需要使用相机,用于拍摄营业执照与设置头像',
+ },
+ ],
// 启动屏:冷启动期间展示品牌 logo,消除白屏。
// 资源为 assets/splash-icon.png(1024×1024,见 assets/)。
// 应用为纯亮色(userInterfaceStyle: 'light'),故不配置 dark 分支。
diff --git a/app/(enterprise)/enterprise-chat.tsx b/app/(enterprise)/enterprise-chat.tsx
index 76c2104..2605a1f 100644
--- a/app/(enterprise)/enterprise-chat.tsx
+++ b/app/(enterprise)/enterprise-chat.tsx
@@ -1,6 +1,5 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
-import { useState } from 'react';
import {
ActivityIndicator,
Alert,
@@ -8,6 +7,7 @@ import {
ScrollView,
StyleSheet,
Text,
+ TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -71,7 +71,13 @@ function TabBar({
/* ===================== 子组件:待处理任务 ===================== */
-function PendingTasks({ tasks }: { tasks: PendingTask[] }) {
+function PendingTasks({
+ tasks,
+ onPressTask,
+}: {
+ tasks: PendingTask[];
+ onPressTask: (task: PendingTask) => void;
+}) {
return (
待处理
@@ -84,7 +90,11 @@ function PendingTasks({ tasks }: { tasks: PendingTask[] }) {
? colors.compareOrange
: colors.primary;
return (
-
+ onPressTask(task)}
+ >
@@ -145,11 +155,31 @@ function ChatRow({ chat }: { chat: EnterpriseChatItem }) {
/* ===================== 子组件:搜索栏 ===================== */
-function SearchBar() {
+function SearchBar({
+ value,
+ onChange,
+ onClear,
+}: {
+ value: string;
+ onChange: (text: string) => void;
+ onClear: () => void;
+}) {
return (
- 搜索候选人...
+
+ {value !== '' && (
+
+
+
+ )}
);
}
@@ -225,8 +255,18 @@ const QUICK_ACTIONS: QuickActionItem[] = [
export default function EnterpriseChatScreen() {
const router = useRouter();
- const { communication, loading, error, refresh } = useEnterpriseCommunication();
- const [activeTab, setActiveTab] = useState('all');
+ const {
+ communication,
+ activeTab,
+ setActiveTab,
+ keyword,
+ setKeyword,
+ chats,
+ isEmptySearch,
+ loading,
+ error,
+ refresh,
+ } = useEnterpriseCommunication();
// 快捷操作分发:有目标路由则跳转,无对应页面则提示「功能开发中」
const handleQuickAction = (action: QuickActionItem) => {
@@ -237,6 +277,20 @@ export default function EnterpriseChatScreen() {
}
};
+ // 待办卡片:优先跳页面,其次切 Tab(接口层给的目标,页面不写死映射)
+ const handlePressTask = (task: PendingTask) => {
+ if (task.route) {
+ router.push(task.route as never);
+ return;
+ }
+ if (task.tabKey) {
+ setActiveTab(task.tabKey);
+ setKeyword('');
+ return;
+ }
+ Alert.alert('提示', `${task.label}功能开发中,敬请期待`);
+ };
+
if (loading) {
return (
@@ -270,18 +324,27 @@ export default function EnterpriseChatScreen() {
{communication.hasData ? (
-
-
-
- {communication.chats.map((chat, index) => {
- const isLast = index === communication.chats.length - 1;
- return (
-
-
-
- );
- })}
-
+
+ setKeyword('')} />
+ {chats.length === 0 ? (
+
+
+
+ {isEmptySearch ? `没有匹配「${keyword.trim()}」的会话` : '这个分类下暂时没有会话'}
+
+
+ ) : (
+
+ {chats.map((chat, index) => {
+ const isLast = index === chats.length - 1;
+ return (
+
+
+
+ );
+ })}
+
+ )}
) : (
@@ -448,11 +511,21 @@ const styles = StyleSheet.create({
paddingVertical: 10,
marginBottom: 12,
},
- searchPlaceholder: {
+ searchInput: {
+ flex: 1,
marginLeft: 8,
fontSize: 14,
- color: colors.textHint,
+ color: colors.textPrimary,
+ padding: 0,
+ },
+ emptyList: {
+ alignItems: 'center',
+ gap: 10,
+ paddingVertical: 32,
+ backgroundColor: colors.white,
+ borderRadius: 14,
},
+ emptyListText: { fontSize: 13, color: colors.textSecondary },
chatListCard: {
backgroundColor: colors.white,
borderRadius: 12,
diff --git a/app/(enterprise)/talent-recommendation.tsx b/app/(enterprise)/talent-recommendation.tsx
index 37e127e..0a61f69 100644
--- a/app/(enterprise)/talent-recommendation.tsx
+++ b/app/(enterprise)/talent-recommendation.tsx
@@ -8,6 +8,7 @@ import {
ScrollView,
StyleSheet,
Text,
+ TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -17,7 +18,18 @@ import type { TalentCandidate, TalentRecommendationTab } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */
-function Header({ companyName, onBack }: { companyName: string; onBack: () => void }) {
+function Header({
+ companyName,
+ onBack,
+ searching,
+ onToggleSearch,
+}: {
+ companyName: string;
+ onBack: () => void;
+ /** 是否处于搜索态;加载中/加载失败时没有数据可搜,不传即不渲染搜索按钮 */
+ searching?: boolean;
+ onToggleSearch?: () => void;
+}) {
return (
@@ -28,17 +40,54 @@ function Header({ companyName, onBack }: { companyName: string; onBack: () => vo
人才推荐
-
-
-
-
-
-
+ {/* 原先这里是「搜索 + 漏斗」两个图标:漏斗没有对应页面,先摘掉;
+ 搜索改成真的关键词过滤(姓名 / 职位 / 技能 / 学校) */}
+ {onToggleSearch && (
+
+
+
+ )}
);
}
+/* ===================== 子组件:搜索栏 ===================== */
+
+function SearchBar({
+ value,
+ onChange,
+ onClear,
+}: {
+ value: string;
+ onChange: (text: string) => void;
+ onClear: () => void;
+}) {
+ return (
+
+
+
+ {value !== '' && (
+
+
+
+ )}
+
+ );
+}
+
/* ===================== 子组件:Tab 栏 ===================== */
function TabBar({
@@ -233,8 +282,24 @@ const POOL_ACTIONS: PoolActionItem[] = [
export default function TalentRecommendationScreen() {
const router = useRouter();
- const { recommendation, loading, error, refresh } = useTalentRecommendation();
- const [activeTab, setActiveTab] = useState('all');
+ const {
+ recommendation,
+ activeTab,
+ setActiveTab,
+ keyword,
+ setKeyword,
+ candidates,
+ isEmptySearch,
+ loading,
+ error,
+ refresh,
+ } = useTalentRecommendation();
+ const [searching, setSearching] = useState(false);
+
+ const toggleSearch = () => {
+ if (searching) setKeyword('');
+ setSearching((prev) => !prev);
+ };
// 人才池快捷操作分发:有目标路由则跳转,无对应页面则提示「功能开发中」,
// 避免按钮点击后无任何反馈。与 enterprise-chat.tsx 的 handleQuickAction 同约定。
@@ -252,8 +317,10 @@ export default function TalentRecommendationScreen() {
Alert.alert('提示', '人才市场报告功能开发中,敬请期待');
};
+ /** 空状态里的「调整筛选条件」:把筛选真正复位(切回全部 + 清关键词) */
const handleFilter = () => {
- Alert.alert('提示', '筛选条件功能开发中,敬请期待');
+ setActiveTab('all');
+ setKeyword('');
};
if (loading) {
@@ -284,8 +351,16 @@ export default function TalentRecommendationScreen() {
return (
- router.back()} />
+ router.back()}
+ searching={searching}
+ onToggleSearch={toggleSearch}
+ />
+ {searching && (
+ setKeyword('')} />
+ )}
{recommendation.hasData ? (
@@ -293,9 +368,23 @@ export default function TalentRecommendationScreen() {
count={recommendation.todayCount}
onReport={handleReport}
/>
- {recommendation.candidates.map((candidate) => (
-
- ))}
+ {candidates.length === 0 ? (
+
+
+
+ {isEmptySearch
+ ? `没有匹配「${keyword.trim()}」的候选人`
+ : '这个筛选条件下暂时没有候选人'}
+
+
+ 重置筛选
+
+
+ ) : (
+ candidates.map((candidate) => (
+
+ ))
+ )}
{
+ if (!target) return;
+ void Linking.openURL(target.url).catch(() => {
+ Alert.alert(
+ isVideo ? '无法打开会议链接' : '无法打开地图',
+ isVideo ? '会议链接可能已失效,请联系 HR 重新获取。' : '请检查是否已安装地图应用后重试。',
+ );
+ });
+ };
+
return (
@@ -95,13 +130,15 @@ function InterviewCard({ interview }: { interview: Interview }) {
{isVideo ? '视频面试' : '线下面试'}
- {
- }}
- >
- {isVideo ? '加入会议' : '导航前往'}
-
+ {target ? (
+
+ {target.label}
+
+ ) : (
+
+ {isVideo ? '会议链接待 HR 提供' : '面试地址待 HR 提供'}
+
+ )}
);
@@ -113,8 +150,16 @@ function InterviewCard({ interview }: { interview: Interview }) {
export default function ChatScreen() {
const router = useRouter();
const [tab, setTab] = useState('message');
- const { conversations, notifications, interviews, matchResult, loading, error, refresh } =
- useMessageData();
+ const {
+ conversations,
+ notifications,
+ interviews,
+ matchResult,
+ pendingOfferCount,
+ loading,
+ error,
+ refresh,
+ } = useMessageData();
// 加载态
if (loading) {
@@ -175,17 +220,20 @@ export default function ChatScreen() {
{tab === 'message' && (
<>
{/* 双向匹配成功横幅 */}
+ {/*
+ 双向匹配横幅:只陈述事实,不带「点击查看详情」——
+ 匹配详情页尚未定义(企业列表之外还要展示什么,属产品决策),
+ 留着链接就是把用户带到一个不存在的地方。
+ */}
{matchResult && (
- {
- }}
- >
+
恭喜!您与{matchResult.total}家企业双向匹配成功
- 点击查看详情
-
+
+ {matchResult.companies.join('、')}
+
+
)}
{/* 会话列表 */}
@@ -205,15 +253,16 @@ export default function ChatScreen() {
{tab === 'notification' && (
<>
- {/* Offer 待处理横幅 */}
- {
- }}
- >
- 您有2个Offer待处理
- 立即查看 >
-
+ {/* Offer 待处理横幅:数量来自接口层,为 0 时整条不渲染 */}
+ {pendingOfferCount > 0 && (
+ router.push('/jobs/offer-compare')}
+ >
+ 您有 {pendingOfferCount} 个 Offer 待处理
+ 立即查看 >
+
+ )}
{/* 系统通知列表 */}
{notifications.length === 0 ? (
@@ -228,15 +277,9 @@ export default function ChatScreen() {
{tab === 'interview' && (
<>
- {/* 面试日程标题 */}
+ {/* 面试日程标题:本 Tab 即全部日程,不再挂一个无处可去的「全部日程 >」 */}
今日面试
- {
- }}
- >
- 全部日程 >
-
{/* 面试卡片 */}
@@ -347,10 +390,10 @@ const styles = StyleSheet.create({
fontWeight: '700',
color: colors.primaryDark,
},
- matchBannerLink: {
+ matchBannerCompanies: {
marginTop: 6,
fontSize: 13,
- color: colors.primary,
+ color: colors.primaryDark,
},
// 会话卡片
convCard: {
@@ -553,4 +596,9 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: colors.white,
},
+ /** 会议链接 / 面试地址缺失时的说明文案,替代一个点不动的按钮 */
+ actionMissing: {
+ fontSize: 13,
+ color: colors.textHint,
+ },
});
diff --git a/app/(tabs)/jobs.tsx b/app/(tabs)/jobs.tsx
index c4c45da..ba908fe 100644
--- a/app/(tabs)/jobs.tsx
+++ b/app/(tabs)/jobs.tsx
@@ -15,6 +15,7 @@ import {
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useJobList } from '../../src/hooks';
import {
+ ALL_CITY_LABEL,
CITIES,
EDUCATION_OPTIONS,
EXPERIENCE_OPTIONS,
@@ -144,8 +145,9 @@ function JobCard({ job }: { job: Job }) {
export default function JobsScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
- // 定位城市(点击可切换)
- const [city, setCity] = useState('北京');
+ // 城市筛选(点击可切换)。默认「全部城市」:此前写死「北京」,
+ // 等于把每个用户都钉在北京,引导里填的期望城市永远影响不到推荐
+ const [city, setCity] = useState(ALL_CITY_LABEL);
const [cityVisible, setCityVisible] = useState(false);
const [keyword, setKeyword] = useState('');
const [sort, setSort] = useState('comprehensive');
@@ -311,6 +313,7 @@ export default function JobsScreen() {
onRequestClose={() => setFilterVisible(false)}
>
setFilterVisible(false)}>
+ {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */}
{}}>
更多筛选
@@ -376,6 +379,7 @@ export default function JobsScreen() {
onRequestClose={() => setCityVisible(false)}
>
setCityVisible(false)}>
+ {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */}
{}}>
选择城市
@@ -384,7 +388,8 @@ export default function JobsScreen() {
- {CITIES.map((item) => (
+ {/* 「全部城市」是默认值与复位入口:不给它出口,用户选了城市就再也回不到不限 */}
+ {[ALL_CITY_LABEL, ...CITIES].map((item) => (
-
- {profile.avatar}
-
+ {/* P21 选过头像就显示真实选择(预设素材 / 本地图片);没选过才回落到姓名首字 */}
+ {profile.avatarKey ? (
+
+ ) : (
+
+ {profile.avatar}
+
+ )}
- {profile.name}
- {profile.bio}
+ {/* 姓名未采集(P6/P16 可跳过直接进入)时显示「未设置」,不显示别人的名字 */}
+ {profile.name || UNSET_LABEL}
+ {profile.bio !== '' && {profile.bio}}
@@ -294,7 +307,10 @@ export default function ProfileScreen() {
{PREF_ITEMS.map(({ key, label }, index) => {
const value = preference[key];
const isLast = index === PREF_ITEMS.length - 1;
- const isStatus = key === 'status';
+ // 未采集到的项显示「未设置」,不拿空串冒充已填;也就不再显示状态圆点
+ const isUnset = value.trim() === '';
+ const display = isUnset ? UNSET_LABEL : value;
+ const isStatus = key === 'status' && !isUnset;
return (
- {value}
+ {display}
) : (
- {value}
+
+ {display}
+
)}
);
@@ -588,6 +606,11 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: colors.textPrimary,
},
+ // 未采集态:弱化为占位符视觉,与真实填写的值区分开
+ prefValueUnset: {
+ fontWeight: '400',
+ color: colors.textHint,
+ },
prefStatus: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/app/appeal.tsx b/app/appeal.tsx
index 53944b6..1c92d0f 100644
--- a/app/appeal.tsx
+++ b/app/appeal.tsx
@@ -3,6 +3,7 @@ import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Alert,
+ Image,
Pressable,
ScrollView,
StyleSheet,
@@ -11,7 +12,7 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { useAppealForm } from '../src/hooks';
+import { useAppealForm, useImagePick } from '../src/hooks';
import { colors } from '../src/theme';
import type { AppealReason, AppealType } from '../src/types';
@@ -213,15 +214,61 @@ function DescriptionSection({
/* ===================== 子组件:上传凭证 ===================== */
-function EvidenceSection() {
+function EvidenceSection({
+ evidence,
+ picking,
+ error,
+ onPick,
+ onRemove,
+}: {
+ evidence: string[];
+ picking: boolean;
+ error: string | null;
+ onPick: (source: 'camera' | 'library') => void;
+ onRemove: (uri: string) => void;
+}) {
+ const chooseSource = () => {
+ Alert.alert('上传凭证', '选择图片来源', [
+ { text: '拍照', onPress: () => onPick('camera') },
+ { text: '从相册选择', onPress: () => onPick('library') },
+ { text: '取消', style: 'cancel' },
+ ]);
+ };
+
return (
- 上传凭证
-
-
- 点击上传图片或文件
- 支持 JPG / PNG / PDF,单个文件不超过 10MB
+ 上传凭证({evidence.length})
+
+ {evidence.length > 0 && (
+
+ {evidence.map((uri) => (
+
+
+ onRemove(uri)} hitSlop={6}>
+
+
+
+ ))}
+
+ )}
+
+
+ {picking ? (
+
+ ) : (
+
+ )}
+ {picking ? '正在打开相册…' : '点击上传图片'}
+
+ 当前支持 JPG / PNG 图片,单张不超过 10MB;文件(PDF)待上传接口就绪后开放
+
+
+ {error ? {error} : null}
);
}
@@ -312,6 +359,9 @@ export default function AppealScreen() {
setReason,
reasonText,
setReasonText,
+ evidence,
+ addEvidence,
+ removeEvidence,
contactPhone,
setContactPhone,
contactEmail,
@@ -319,6 +369,7 @@ export default function AppealScreen() {
submit,
submitting,
} = useAppealForm();
+ const { pick, picking, error: pickError } = useImagePick();
const handleSubmit = async () => {
const result = await submit();
@@ -329,6 +380,12 @@ export default function AppealScreen() {
}
};
+ /** 选一张证据图:取消与未授权由 useImagePick 区分(未授权给出设置指引) */
+ const handlePickEvidence = async (source: 'camera' | 'library') => {
+ const result = await pick(source, { allowsEditing: false });
+ if (result.status === 'picked') addEvidence(result.uri);
+ };
+
if (loading) {
return (
@@ -360,7 +417,13 @@ export default function AppealScreen() {
/>
-
+
void }) {
候选人详情
-
-
-
+ {/* 原先这里是「更多」图标:没有下拉菜单,先摘掉 */}
+
);
}
@@ -332,7 +331,8 @@ export default function CandidateDetailScreen() {
-
+ {/* 发起沟通:进该候选人的会话页(此前是空 Pressable,点了没反应) */}
+ router.push(`/chat/${candidate.id}`)}>
发起沟通
(null);
const isLast = page === SLIDES.length - 1;
+ /** 已登录(含从设置页回顾)时只返回,不改引导标记、不进注册流程 */
+ const reviewing = isTokenValid;
const finish = () => {
+ if (reviewing) {
+ if (router.canGoBack()) router.back();
+ else router.replace('/(tabs)/jobs');
+ return;
+ }
completeOnboarding();
router.replace('/register');
};
@@ -68,12 +88,21 @@ export default function GuideScreen() {
scrollRef.current?.scrollTo({ x: next * width, animated: true });
};
+ const slotWidth = Math.min(width - 96, 300);
+ const slotHeight = Math.round(slotWidth * 0.72);
+
return (
-
+ {reviewing ? (
+
+
+
+ ) : (
+
+ )}
- 跳过
+ {reviewing ? '关闭' : '跳过'}
@@ -90,13 +119,7 @@ export default function GuideScreen() {
>
{SLIDES.map((slide) => (
-
-
-
+
{slide.title}
{slide.desc}
@@ -111,7 +134,9 @@ export default function GuideScreen() {
- {isLast ? '立即体验' : '下一步'}
+
+ {isLast ? (reviewing ? '完成' : '立即体验') : '下一步'}
+
@@ -131,7 +156,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 10,
},
- topSpacer: { width: 40 },
+ backBtn: {
+ width: 36,
+ height: 36,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
skipText: { fontSize: 14, color: colors.textSecondary, paddingHorizontal: 4 },
pager: { flex: 1 },
slide: {
@@ -140,19 +170,12 @@ const styles = StyleSheet.create({
justifyContent: 'center',
paddingHorizontal: 36,
},
- illus: {
- width: 132,
- height: 132,
- borderRadius: 36,
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: 36,
- },
slideTitle: {
fontSize: 22,
fontWeight: '800',
color: colors.textPrimary,
textAlign: 'center',
+ marginTop: 36,
marginBottom: 14,
},
slideDesc: {
diff --git a/app/holland-result.tsx b/app/holland-result.tsx
index bd61991..07fa0a0 100644
--- a/app/holland-result.tsx
+++ b/app/holland-result.tsx
@@ -14,7 +14,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Line, Polygon } from 'react-native-svg';
import { useHollandAssessment } from '../src/hooks';
import { colors, headerGradientShort } from '../src/theme';
-import type { HollandDimension, HollandTypeAnalysis } from '../src/types';
+import type { HollandAction, HollandDimension, HollandTypeAnalysis } from '../src/types';
const RADAR_SIZE = 160;
const RADAR_CENTER = RADAR_SIZE / 2;
@@ -186,15 +186,28 @@ function JobCard({ job }: { job: import('../src/types').HollandJobRecommendation
/* ===================== 子组件:行动建议 ===================== */
-function ActionCard({ action }: { action: import('../src/types').HollandAction }) {
- return (
-
+function ActionCard({ action }: { action: HollandAction }) {
+ const router = useRouter();
+ // 建议卡的目标路由由接口层给出(见 api.ts 的 actions);没有 route 的只做展示,不带箭头
+ const route = action.route;
+
+ const body = (
+ <>
{action.title}
{action.desc}
-
+ {route ? : null}
+ >
+ );
+
+ if (!route) {
+ return {body};
+ }
+ return (
+ router.push(route as never)}>
+ {body}
);
}
diff --git a/app/holland.tsx b/app/holland.tsx
index 3f30ee1..195437b 100644
--- a/app/holland.tsx
+++ b/app/holland.tsx
@@ -31,9 +31,8 @@ function Header({ onBack }: { onBack: () => void }) {
职业兴趣测评
-
- 记录
-
+ {/* 原先这里是「记录」按钮:项目内没有测评记录页,留着等于点了没反应,先摘掉 */}
+
);
}
diff --git a/app/interview-score.tsx b/app/interview-score.tsx
index b6b5f69..094abd8 100644
--- a/app/interview-score.tsx
+++ b/app/interview-score.tsx
@@ -2,6 +2,7 @@ import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
+ Alert,
Pressable,
ScrollView,
StyleSheet,
@@ -13,6 +14,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Line, Polygon, Text as SvgText } from 'react-native-svg';
import { useInterviewScore } from '../src/hooks';
import { colors } from '../src/theme';
+import { formatDateTime } from '../src/utils';
import type { HireSuggestion, InterviewScoreData, InterviewScoreDimension, InterviewScoreTag } from '../src/types';
const COLOR_TOKENS: Record = {
@@ -32,9 +34,8 @@ function Header({ onBack }: { onBack: () => void }) {
面试评分
-
-
-
+ {/* 原先这里是「历史评分」入口:没有历史评分页,先摘掉,加页面时再挂回来 */}
+
);
}
@@ -271,8 +272,26 @@ export default function InterviewScoreScreen() {
selectHireSuggestion,
submit,
submitting,
+ saveDraft,
+ savingDraft,
+ restoreDraft,
+ discardDraft,
+ hasPendingDraft,
+ draftSavedAt,
} = useInterviewScore('interview-1');
+ /** 保存草稿:成功后就地在按钮旁显示保存时间,失败给出提示 */
+ const handleSaveDraft = async () => {
+ try {
+ const savedAt = await saveDraft();
+ if (savedAt) {
+ Alert.alert('已保存草稿', `保存时间 ${formatDateTime(savedAt)},下次进入这场面试时可恢复。`);
+ }
+ } catch {
+ Alert.alert('保存失败', '草稿没能存到本机,请稍后重试。');
+ }
+ };
+
if (loading) {
return (
@@ -295,6 +314,24 @@ export default function InterviewScoreScreen() {
+ {hasPendingDraft && (
+
+
+
+
+ 发现未提交的草稿{draftSavedAt ? `(${formatDateTime(draftSavedAt)} 保存)` : ''}
+
+ 恢复后会覆盖当前页面上的评分内容
+
+
+ 恢复
+
+ void discardDraft()} hitSlop={6}>
+ 放弃
+
+
+ )}
+
@@ -304,8 +341,12 @@ export default function InterviewScoreScreen() {
-
- 保存草稿
+
+ {savingDraft ? '保存中…' : '保存草稿'}
+ {/* 原先这里挂着「全部记录 >」:项目内没有面试练习记录页,先摘掉 */}
最近练习
-
- 全部记录 >
-
{recent.map((item) => {
const color = COLOR_TOKENS[item.resultColor] ?? colors.primary;
diff --git a/app/job-evaluations.tsx b/app/job-evaluations.tsx
index ecd1b17..4b31683 100644
--- a/app/job-evaluations.tsx
+++ b/app/job-evaluations.tsx
@@ -1,12 +1,15 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import {
ActivityIndicator,
+ Alert,
+ Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
+ TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -30,9 +33,8 @@ function Header({ onBack }: { onBack: () => void }) {
岗位评价管理
-
-
-
+ {/* 原先这里是「筛选」图标:筛选面板尚未定义,先摘掉,做筛选时再挂回来 */}
+
);
}
@@ -301,7 +303,13 @@ function EmptyState({ state }: { state: JobEvaluationEmptyState }) {
/* ===================== 子组件:评价列表 ===================== */
-function ReviewList({ reviews }: { reviews: JobReview[] }) {
+function ReviewList({
+ reviews,
+ onReply,
+}: {
+ reviews: JobReview[];
+ onReply: (review: JobReview) => void;
+}) {
return (
最新评价
@@ -325,11 +333,25 @@ function ReviewList({ reviews }: { reviews: JobReview[] }) {
))}
+
+ {/* 已回复的评价把回复内容展示出来,否则「已回复」只是个说不清的标签 */}
+ {review.replied && review.replyContent ? (
+
+ 企业回复
+ {review.replyContent}
+
+ ) : null}
+
{review.date}
-
- {review.replied ? '已回复' : '回复'}
-
+ {/* 已回复的评价不再提供重复回复入口 */}
+ {review.replied ? (
+ 已回复
+ ) : (
+ onReply(review)}>
+ 回复
+
+ )}
))}
@@ -337,16 +359,99 @@ function ReviewList({ reviews }: { reviews: JobReview[] }) {
);
}
+/* ===================== 子组件:回复输入弹层 ===================== */
+
+/** 回复弹层:用 Modal + TextInput 而不是 Alert.prompt(后者只有 iOS 有) */
+function ReplyModal({
+ review,
+ submitting,
+ onCancel,
+ onSubmit,
+}: {
+ review: JobReview | null;
+ submitting: boolean;
+ onCancel: () => void;
+ onSubmit: (content: string) => void;
+}) {
+ const [content, setContent] = useState('');
+
+ // 换一条评价就清空输入,避免把上一条的回复带过去
+ useEffect(() => {
+ setContent('');
+ }, [review?.id]);
+
+ const trimmed = content.trim();
+
+ return (
+
+
+
+ 回复评价
+ {review ? (
+
+ {review.userName}:{review.content}
+
+ ) : null}
+
+
+
+ 取消
+
+ trimmed !== '' && onSubmit(trimmed)}
+ disabled={trimmed === '' || submitting}
+ >
+ {submitting ? '提交中' : '提交回复'}
+
+
+
+
+
+ );
+}
+
/* ===================== 页面主组件 ===================== */
export default function JobEvaluationsScreen() {
const router = useRouter();
- const { management, loading: dataLoading, error: dataError } = useJobEvaluations();
+ const {
+ management,
+ loading: dataLoading,
+ error: dataError,
+ refresh,
+ reply,
+ replyingId,
+ } = useJobEvaluations();
const { state: emptyState, loading: emptyLoading, error: emptyError } = useJobEvaluationEmptyState();
+ /** 正在回复的评价;null 表示弹层关闭 */
+ const [replying, setReplying] = useState(null);
const loading = dataLoading || emptyLoading;
const error = dataError || emptyError;
+ /** 提交回复:成功后重新拉数据(回复率与回复内容都要跟着变),失败给出可读提示 */
+ const submitReply = (content: string) => {
+ if (!replying) return;
+ void reply(replying.id, content)
+ .then(() => {
+ setReplying(null);
+ refresh();
+ })
+ .catch((e: unknown) => {
+ Alert.alert('回复失败', e instanceof Error ? e.message : '请稍后重试');
+ });
+ };
+
if (loading) {
return (
@@ -386,10 +491,17 @@ export default function JobEvaluationsScreen() {
-
+
>
) : null}
+
+ setReplying(null)}
+ onSubmit={submitReply}
+ />
);
}
@@ -638,6 +750,93 @@ const styles = StyleSheet.create({
color: colors.primary,
fontWeight: '500',
},
+ /** 已回复:不再是按钮,只是一个状态说明 */
+ repliedText: {
+ fontSize: 12,
+ color: colors.successText,
+ fontWeight: '500',
+ },
+ /* ---- 回复内容块 ---- */
+ replyBlock: {
+ marginTop: 10,
+ padding: 10,
+ borderRadius: 8,
+ backgroundColor: colors.successLight,
+ },
+ replyBlockLabel: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: colors.successText,
+ },
+ replyBlockText: {
+ marginTop: 4,
+ fontSize: 13,
+ lineHeight: 20,
+ color: colors.textPrimary,
+ },
+ /* ---- 回复弹层 ---- */
+ modalMask: {
+ flex: 1,
+ backgroundColor: colors.scrim,
+ justifyContent: 'center',
+ paddingHorizontal: 28,
+ },
+ modalCard: {
+ backgroundColor: colors.surface,
+ borderRadius: 14,
+ padding: 18,
+ },
+ modalTitle: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ },
+ modalQuote: {
+ marginTop: 8,
+ fontSize: 13,
+ lineHeight: 20,
+ color: colors.textSecondary,
+ },
+ modalInput: {
+ marginTop: 12,
+ minHeight: 88,
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: colors.border,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ fontSize: 14,
+ color: colors.textPrimary,
+ textAlignVertical: 'top',
+ },
+ modalActions: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ gap: 12,
+ marginTop: 16,
+ },
+ modalCancel: {
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ },
+ modalCancelText: {
+ fontSize: 14,
+ color: colors.textSecondary,
+ },
+ modalSubmit: {
+ paddingHorizontal: 18,
+ paddingVertical: 10,
+ borderRadius: 20,
+ backgroundColor: colors.primaryBtn,
+ },
+ modalSubmitOff: {
+ backgroundColor: colors.textDisabled,
+ },
+ modalSubmitText: {
+ fontSize: 14,
+ fontWeight: '600',
+ color: colors.white,
+ },
/* ---- 空状态 ---- */
emptyWrapper: {
marginBottom: 16,
diff --git a/app/onboarding.tsx b/app/onboarding.tsx
index 9b28bd6..7475be8 100644
--- a/app/onboarding.tsx
+++ b/app/onboarding.tsx
@@ -5,15 +5,19 @@ import { useEffect, useState } from 'react';
import {
ActivityIndicator,
Alert,
+ Modal,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
+ TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
+import { buildChecklistText } from '../src/api';
import { useOnboardingChecklist } from '../src/hooks';
+import { shareText } from '../src/lib/shareFile';
import { colors, headerGradientShort } from '../src/theme';
import type { ChecklistGroup, OnboardingChecklist, OnboardingTip } from '../src/types';
@@ -103,18 +107,22 @@ function CountdownCard({
function ChecklistGroupCard({
group,
onToggleItem,
+ onRemoveItem,
togglingId,
expanded,
onToggleExpand,
}: {
group: ChecklistGroup;
onToggleItem: (itemId: string, done: boolean) => void;
+ onRemoveItem: (itemId: string) => void;
togglingId: string | null;
expanded: boolean;
onToggleExpand: () => void;
}) {
const theme = GROUP_ICON_THEME[group.id] ?? GROUP_ICON_THEME.material;
const doneCount = group.items.filter((i) => i.done).length;
+ // 只有「我的自定义待办」这一组可以删条目:模板待办删了下次进页面还会回来
+ const removable = group.id === 'custom';
return (
@@ -155,6 +163,11 @@ function ChecklistGroupCard({
必填
)}
+ {removable && (
+ onRemoveItem(item.id)} hitSlop={8}>
+
+
+ )}
))}
@@ -238,12 +251,16 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
export default function OnboardingScreen() {
const router = useRouter();
- const { checklist, loading, error, refresh, toggle, togglingId } = useOnboardingChecklist();
+ const { checklist, loading, error, refresh, toggle, togglingId, addItem, removeItem, mutating } =
+ useOnboardingChecklist();
/** 本地清单状态(勾选后即时更新,避免等待接口往返) */
const [localChecklist, setLocalChecklist] = useState(null);
const [expandedIds, setExpandedIds] = useState([]);
const [reminderEnabled, setReminderEnabled] = useState(false);
+ /** 「添加自定义待办」弹窗 */
+ const [addVisible, setAddVisible] = useState(false);
+ const [draftTitle, setDraftTitle] = useState('');
useEffect(() => {
if (checklist && !localChecklist) {
@@ -268,15 +285,42 @@ export default function OnboardingScreen() {
await toggle(itemId, done);
};
- // 右上角「分享」暂无对应页面,按项目约定弹提示(此前绑定空函数,点击无任何反馈)
- const handleShare = () => {
- Alert.alert('提示', '分享入职清单功能开发中,敬请期待');
+ /** 添加自定义待办:接口层负责落库并返回新清单,页面直接换成返回值 */
+ const handleAddItem = async () => {
+ try {
+ const updated = await addItem(draftTitle);
+ setLocalChecklist(updated);
+ setDraftTitle('');
+ setAddVisible(false);
+ } catch (e) {
+ Alert.alert('添加失败', e instanceof Error ? e.message : '请稍后重试');
+ }
+ };
+
+ /** 删除自定义待办(模板条目接口层会拒绝) */
+ const handleRemoveItem = async (itemId: string) => {
+ try {
+ const updated = await removeItem(itemId);
+ setLocalChecklist(updated);
+ } catch (e) {
+ Alert.alert('删除失败', e instanceof Error ? e.message : '请稍后重试');
+ }
+ };
+
+ /**
+ * 导出 / 分享清单:把清单拼成文本交给系统分享面板。
+ * `ok` 只代表面板已拉起,不能据此提示「分享成功」(见 lib/shareFile 的说明)。
+ */
+ const handleExport = async () => {
+ if (!display) return;
+ const outcome = await shareText(buildChecklistText(display), '入职准备清单');
+ if (!outcome.ok) Alert.alert('无法导出', outcome.message);
};
if (loading) {
return (
- router.back()} onShare={handleShare} />
+ router.back()} onShare={() => void handleExport()} />
);
@@ -285,7 +329,7 @@ export default function OnboardingScreen() {
if (error || !display) {
return (
- router.back()} onShare={handleShare} />
+ router.back()} onShare={() => void handleExport()} />
);
@@ -293,7 +337,7 @@ export default function OnboardingScreen() {
return (
- router.back()} onShare={handleShare} />
+ router.back()} onShare={() => void handleExport()} />
@@ -331,14 +376,48 @@ export default function OnboardingScreen() {
-
+ setAddVisible(true)}
+ disabled={mutating}
+ >
添加自定义待办
-
+ void handleExport()}>
导出清单
+
+ {/* 添加自定义待办:Alert.prompt 只有 iOS 有,这里用自绘弹窗(与岗位评价回复一致) */}
+ setAddVisible(false)}>
+
+
+ 添加自定义待办
+
+
+ setAddVisible(false)}>
+ 取消
+
+ void handleAddItem()}
+ disabled={draftTitle.trim() === '' || mutating}
+ >
+ {mutating ? '添加中…' : '添加'}
+
+
+
+
+
);
}
@@ -491,4 +570,33 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
exportBtnText: { fontSize: 15, fontWeight: '700', color: colors.white },
+ footerBtnDisabled: { opacity: 0.5 },
+ modalMask: {
+ flex: 1,
+ backgroundColor: colors.scrim,
+ justifyContent: 'center',
+ paddingHorizontal: 32,
+ },
+ modalCard: { backgroundColor: colors.white, borderRadius: 16, padding: 20 },
+ modalTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
+ modalInput: {
+ marginTop: 14,
+ borderWidth: 1,
+ borderColor: colors.border,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ fontSize: 14,
+ color: colors.textPrimary,
+ },
+ modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 12, marginTop: 18 },
+ modalCancel: { paddingHorizontal: 16, paddingVertical: 8 },
+ modalCancelText: { fontSize: 14, color: colors.textSecondary },
+ modalConfirm: {
+ backgroundColor: colors.primaryBtn,
+ borderRadius: 18,
+ paddingHorizontal: 20,
+ paddingVertical: 8,
+ },
+ modalConfirmText: { fontSize: 14, fontWeight: '700', color: colors.white },
});
diff --git a/app/register.tsx b/app/register.tsx
index 300f60a..2a2aee5 100644
--- a/app/register.tsx
+++ b/app/register.tsx
@@ -23,7 +23,6 @@ import { P18Education } from '../components/onboarding/screens/P18Education';
import { P20School } from '../components/onboarding/screens/P20School';
import { P21Avatar } from '../components/onboarding/screens/P21Avatar';
import { P22Done } from '../components/onboarding/screens/P22Done';
-import { P23Home } from '../components/onboarding/screens/P23Home';
import { B1Wechat } from '../components/onboarding/screens/B1Wechat';
import { B2Agreement } from '../components/onboarding/screens/B2Agreement';
import { B3Recruiter } from '../components/onboarding/screens/B3Recruiter';
@@ -56,7 +55,8 @@ function FlowRouter() {
case 'P20': return ;
case 'P21': return ;
case 'P22': return ;
- case 'P23': return ;
+ // 原 P23「假首页」已删除:它是写死上海 4 条职位的演示页,与真实主界面 (tabs)/jobs 重复。
+ // 现在 P22(及 P6/P16 的快速通道)由 useRegisterFinish 直接 replace 进真主界面。
case 'B1': return ;
case 'B2': return ;
case 'B3': return ;
diff --git a/app/resume.tsx b/app/resume.tsx
index df99494..d5370e7 100644
--- a/app/resume.tsx
+++ b/app/resume.tsx
@@ -1,30 +1,41 @@
import { Ionicons } from '@expo/vector-icons';
-import { useRouter } from 'expo-router';
-import { useState } from 'react';
+import { useFocusEffect, useRouter } from 'expo-router';
+import { useCallback, useState } from 'react';
import {
ActivityIndicator,
+ Alert,
+ Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
+ TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useResume } from '../src/hooks';
+import { RESUME_EVAL_MAX_LENGTH, UNSET_LABEL } from '../src/constants';
import { colors } from '../src/theme';
import type { Resume as ResumeType, ResumeExperience, ResumeOptimizePoint, ResumeProject } from '../src/types';
+import { formatDateTime } from '../src/utils';
/* ===================== 子组件:顶部导航 ===================== */
+/**
+ * 顶部导航。`onSave` 可选:加载中 / 加载失败时简历尚未就绪,此时按钮置灰而不是给一个
+ * 点了没反应的入口(同 `app/resume/export.tsx` 的处理)。
+ */
function Header({
onBack,
onSave,
onExport,
}: {
onBack: () => void;
- onSave: () => void;
+ /** 传 undefined = 简历未就绪,按钮置灰 */
+ onSave?: () => void;
onExport: () => void;
}) {
+ const saveDisabled = onSave === undefined;
return (
@@ -32,10 +43,25 @@ function Header({
AI简历优化
-
-
+
+
-
+
@@ -92,8 +118,12 @@ function ScoreCard({
function OptimizeCard({
points,
+ onOptimizeAll,
+ optimizing,
}: {
points: ResumeOptimizePoint[];
+ onOptimizeAll: () => void;
+ optimizing: boolean;
}) {
return (
@@ -112,8 +142,12 @@ function OptimizeCard({
+{p.score}分
))}
-
- 一键全部优化
+
+ {optimizing ? '优化中…' : '一键全部优化'}
);
@@ -121,36 +155,39 @@ function OptimizeCard({
/* ===================== 子组件:基本信息 ===================== */
-function BasicInfoCard({ info }: { info: ResumeType['basicInfo'] }) {
+function BasicInfoCard({ info, onEdit }: { info: ResumeType['basicInfo']; onEdit: () => void }) {
+ // 引导未强制填姓名(P6/P16 可跳过直接进入),头像字取展示姓名的首字,保持一致
+ const name = info.name || UNSET_LABEL;
+ const rows = [
+ { key: 'phone', icon: 'call-outline' as const, value: info.phone },
+ { key: 'email', icon: 'mail-outline' as const, value: info.email },
+ { key: 'location', icon: 'location-outline' as const, value: info.location },
+ ].filter((row) => row.value !== '');
+
return (
基本信息
-
+
编辑基本信息 >
- {info.avatar}
+ {name.slice(0, 1)}
- {info.name}
+ {name}
-
-
- {info.phone}
-
-
-
- {info.email}
-
-
-
- {info.location}
-
+ {/* 空值不渲染空行:手机号 / 邮箱引导未采集,画一行空白比不画更让人困惑 */}
+ {rows.map((row) => (
+
+
+ {row.value}
+
+ ))}
@@ -160,11 +197,13 @@ function BasicInfoCard({ info }: { info: ResumeType['basicInfo'] }) {
/* ===================== 子组件:求职意向 ===================== */
function JobIntentionCard({ intention }: { intention: ResumeType['jobIntention'] }) {
+ const router = useRouter();
return (
求职意向
-
+ {/* 指向已存在的求职意向编辑页(此前是无 onPress 的死按钮) */}
+ router.push('/job-preference')} hitSlop={6}>
编辑 >
@@ -175,7 +214,8 @@ function JobIntentionCard({ intention }: { intention: ResumeType['jobIntention']
@@ -191,15 +231,16 @@ function IntentionItem({
value: string;
tag?: boolean;
}) {
+ const text = value || UNSET_LABEL;
return (
{label}
{tag ? (
- {value}
+ {text}
) : (
- {value}
+ {text}
)}
);
@@ -207,29 +248,46 @@ function IntentionItem({
/* ===================== 子组件:教育背景 ===================== */
-function EducationCard({ education }: { education: ResumeType['education'] }) {
+function EducationCard({
+ education,
+ onAdd,
+ onEdit,
+ onItem,
+}: {
+ education: ResumeType['education'];
+ onAdd: () => void;
+ onEdit: () => void;
+ onItem: (id: string) => void;
+}) {
return (
教育背景
-
-
- 添加教育经历
-
-
- {education.map((edu) => (
-
-
- {edu.school}
-
- {edu.major} · {edu.degree}
-
-
-
- {edu.startYear}-{edu.endYear}
-
+
+
+ 编辑 >
+
+
+
+ 添加教育经历
+
- ))}
+
+ {education.length === 0 && 未填写教育背景}
+ {education.map((edu) => {
+ // 专业 / 年份引导未采集时为空串,拼出来会剩一个孤零零的「· 本科」或「-」
+ const majorDegree = [edu.major, edu.degree].filter(Boolean).join(' · ');
+ const years = [edu.startYear, edu.endYear].filter(Boolean).join('-');
+ return (
+ onItem(edu.id)}>
+
+ {edu.school}
+ {majorDegree !== '' && {majorDegree}}
+
+ {years !== '' && {years}}
+
+ );
+ })}
);
}
@@ -238,38 +296,63 @@ function EducationCard({ education }: { education: ResumeType['education'] }) {
function ExperienceCard({
experiences,
+ onAdd,
+ onEdit,
+ onItem,
+ onOptimize,
+ aiBusy,
}: {
experiences: ResumeExperience[];
+ onAdd: () => void;
+ onEdit: () => void;
+ onItem: (id: string) => void;
+ onOptimize: (id: string) => void;
+ /** AI 请求进行中:按钮禁用,避免连点堆起多个请求 */
+ aiBusy: boolean;
}) {
return (
工作经历
-
-
- 添加工作经历
-
+
+
+ 编辑 >
+
+
+
+ 添加工作经历
+
+
+ {experiences.length === 0 && 未填写工作经历}
{experiences.map((exp) => (
-
+ onItem(exp.id)}>
{exp.company}
-
- {exp.startDate} - {exp.endDate}
-
+ {/* 离职时结束时间未采集(留空),只展示已采集到的那一端,不补「-」 */}
+ {[exp.startDate, exp.endDate].filter(Boolean).length > 0 && (
+
+ {[exp.startDate, exp.endDate].filter(Boolean).join(' - ')}
+
+ )}
- {exp.position}
- {exp.description}
+ {exp.position !== '' && {exp.position}}
+ {exp.description !== '' && {exp.description}}
{!exp.aiOptimized && (
AI 建议优化
-
+ onOptimize(exp.id)}
+ disabled={aiBusy}
+ accessibilityLabel={`AI 优化 ${exp.company} 的工作内容`}
+ >
AI 优化
)}
-
+
))}
);
@@ -277,18 +360,34 @@ function ExperienceCard({
/* ===================== 子组件:项目经历 ===================== */
-function ProjectCard({ projects }: { projects: ResumeProject[] }) {
+function ProjectCard({
+ projects,
+ onAdd,
+ onEdit,
+ onItem,
+}: {
+ projects: ResumeProject[];
+ onAdd: () => void;
+ onEdit: () => void;
+ onItem: (id: string) => void;
+}) {
return (
项目经历
-
-
- 添加项目经历
-
+
+
+ 编辑 >
+
+
+
+ 添加项目经历
+
+
+ {projects.length === 0 && 未填写项目经历}
{projects.map((proj) => (
-
+ onItem(proj.id)}>
{proj.name}
{proj.role}
@@ -300,7 +399,7 @@ function ProjectCard({ projects }: { projects: ResumeProject[] }) {
已量化
)}
-
+
))}
);
@@ -308,17 +407,18 @@ function ProjectCard({ projects }: { projects: ResumeProject[] }) {
/* ===================== 子组件:技能特长 ===================== */
-function SkillsCard({ skills }: { skills: ResumeType['skills'] }) {
+function SkillsCard({ skills, onEdit }: { skills: ResumeType['skills']; onEdit: () => void }) {
return (
技能特长
-
+
添加技能
+ {skills.length === 0 && 未填写技能特长}
{skills.map((skill) => (
{skill.name}
@@ -329,18 +429,194 @@ function SkillsCard({ skills }: { skills: ResumeType['skills'] }) {
);
}
+/* ===================== 子组件:技能编辑弹窗 ===================== */
+/*
+ * 技能只有「名词」一个字段,为它单开一个路由不划算,就地弹层编辑:
+ * 已添加的标签点 × 即时删除,输入框 +「添加」追加。
+ * 去重的权威规则在 API 层(`saveResumeSkills`),这里只做即时门槛让按钮状态诚实。
+ */
+function SkillsModal({
+ visible,
+ skills,
+ draft,
+ onDraftChange,
+ onClose,
+ onAdd,
+ onRemove,
+ saving,
+}: {
+ visible: boolean;
+ skills: ResumeType['skills'];
+ /** 草稿由页面持有(与 `SaveSheet` 同款):关掉再打开时页面已经把它清掉了,弹层不必自己同步 */
+ draft: string;
+ onDraftChange: (v: string) => void;
+ onClose: () => void;
+ onAdd: (name: string) => void;
+ onRemove: (name: string) => void;
+ saving: boolean;
+}) {
+ const name = draft.trim();
+ const duplicated = name !== '' && skills.some((s) => s.name === name);
+ const canAdd = name !== '' && !duplicated && !saving;
+
+ return (
+
+ {/* 内层 Pressable 吞掉冒泡:点内容区不该关闭弹层 */}
+
+ {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */}
+ {}}>
+
+ 编辑技能
+
+
+
+
+
+
+ 已添加(点 × 删除)
+ {skills.length === 0 ? (
+ 还没有添加技能
+ ) : (
+
+ {skills.map((skill) => (
+ onRemove(skill.name)}
+ disabled={saving}
+ accessibilityLabel={`删除技能 ${skill.name}`}
+ >
+ {skill.name}
+
+
+ ))}
+
+ )}
+
+
+
+ canAdd && onAdd(name)}
+ returnKeyType="done"
+ />
+ onAdd(name)}
+ disabled={!canAdd}
+ >
+ 添加
+
+
+ {duplicated && 已添加过该技能}
+
+
+
+ );
+}
+
+/* ===================== 子组件:自我评价编辑弹窗 ===================== */
+
+function EvaluationModal({
+ visible,
+ draft,
+ onDraftChange,
+ onClose,
+ onSave,
+ saving,
+}: {
+ visible: boolean;
+ draft: string;
+ onDraftChange: (v: string) => void;
+ onClose: () => void;
+ onSave: () => void;
+ saving: boolean;
+}) {
+ return (
+
+
+ {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */}
+ {}}>
+
+ 编辑自我评价
+
+
+
+
+
+
+
+ {draft.length}/{RESUME_EVAL_MAX_LENGTH}
+
+
+
+
+ 取消
+
+
+ {saving ? '保存中…' : '保存'}
+
+
+
+
+
+ );
+}
+
/* ===================== 子组件:自我评价 ===================== */
-function EvaluationCard({ content }: { content: string }) {
+function EvaluationCard({
+ content,
+ onEdit,
+ onPolish,
+ polishing,
+}: {
+ content: string;
+ onEdit: () => void;
+ onPolish: () => void;
+ polishing: boolean;
+}) {
return (
自我评价
-
- AI 润色
-
+
+
+ {content === '' ? '去填写' : '编辑'} >
+
+
+ AI 润色
+
+
- {content}
+ {content === '' ? (
+ 未填写自我评价
+ ) : (
+ {content}
+ )}
);
}
@@ -372,35 +648,127 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
export default function ResumeScreen() {
const router = useRouter();
- const { resume, loading, error, refresh, optimize } = useResume();
- const [currentResume, setCurrentResume] = useState(null);
+ const {
+ resume,
+ savedAt,
+ loading,
+ error,
+ refresh,
+ saving,
+ optimize,
+ save,
+ saveSelfEvaluation,
+ saveSkills,
+ optimizeExperience,
+ polishSelfEvaluation,
+ } = useResume();
+
+ /**
+ * 「AI 一键优化」只留下评分——它返回的正文与当前简历一致(mock 阶段不改写用户内容,
+ * 见 `optimizeResume`),把整份结果塞进 state 会让此后每一次编辑都显示旧数据。
+ */
+ const [optimizedScore, setOptimizedScore] = useState(null);
const [optimizing, setOptimizing] = useState(false);
+ /** AI 单点动作进行中:按钮禁用,避免连点堆请求 */
+ const [aiBusy, setAiBusy] = useState(false);
- const displayResume = currentResume ?? resume;
+ const [skillsModal, setSkillsModal] = useState(false);
+ const [skillDraft, setSkillDraft] = useState('');
+ const [evalModal, setEvalModal] = useState(false);
+ const [evalDraft, setEvalDraft] = useState('');
+
+ // 简历永远取自 storage(唯一真相源),会话内的优化只覆盖评分那一块
+ const displayResume = resume === null ? null : { ...resume, score: optimizedScore ?? resume.score };
+
+ // 从编辑页返回时重新取数:各编辑页写入的是覆盖层,不刷新就显示旧值
+ // (useAsyncData 只在挂载时取数)
+ useFocusEffect(
+ useCallback(() => {
+ refresh();
+ }, [refresh]),
+ );
const handleOptimize = async () => {
+ if (optimizing) return;
setOptimizing(true);
try {
const optimized = await optimize();
- setCurrentResume(optimized);
+ setOptimizedScore(optimized.score);
} finally {
setOptimizing(false);
}
};
- if (loading) {
+ const handleSave = async () => {
+ if (displayResume === null || saving) return;
+ try {
+ const { savedAt: at } = await save(displayResume);
+ Alert.alert('已保存到本机', `保存时间 ${formatDateTime(at)}`);
+ } catch {
+ Alert.alert('保存失败', '请稍后重试');
+ }
+ };
+
+ /* ---------- AI 动作:能力未接入时只如实说明,不动简历 ---------- */
+
+ const runAIAction = async (action: () => Promise<{ message: string }>) => {
+ if (aiBusy) return;
+ setAiBusy(true);
+ try {
+ const result = await action();
+ Alert.alert('暂未开放', result.message);
+ } finally {
+ setAiBusy(false);
+ }
+ };
+
+ /* ---------- 技能:删除即时落盘,添加是一次写入 ---------- */
+
+ const skills = displayResume?.skills ?? [];
+
+ const handleAddSkill = async (name: string) => {
+ if (saving) return;
+ await saveSkills([...skills, { name }]);
+ setSkillDraft('');
+ };
+
+ const handleRemoveSkill = (name: string) => {
+ if (saving) return;
+ void saveSkills(skills.filter((s) => s.name !== name));
+ };
+
+ /* ---------- 自我评价 ---------- */
+
+ const openEvalModal = () => {
+ setEvalDraft(displayResume?.selfEvaluation.content ?? '');
+ setEvalModal(true);
+ };
+
+ const handleSaveEvaluation = async () => {
+ if (saving) return;
+ await saveSelfEvaluation(evalDraft);
+ setEvalModal(false);
+ };
+
+ /*
+ * 只在「还没有任何数据」时显示加载态。
+ * 本页每次获得焦点都会 refresh(编辑页可能写过覆盖层),
+ * 若按 `loading` 直接渲染骨架屏,从编辑页返回时会整页白闪一次 —— 刚改完的内容,
+ * 用户看到的却是一次全屏加载。
+ */
+ if (loading && displayResume === null) {
return (
- router.back()} onSave={() => {}} onExport={() => {}} />
+ router.back()} onExport={() => router.push('/resume/export')} />
);
}
- if (error || !displayResume) {
+ if (error !== null || displayResume === null) {
return (
- router.back()} onSave={() => {}} onExport={() => {}} />
+ router.back()} onExport={() => router.push('/resume/export')} />
);
@@ -410,7 +778,7 @@ export default function ResumeScreen() {
router.back()}
- onSave={() => {}}
+ onSave={handleSave}
onExport={() => router.push('/resume/export')}
/>
@@ -420,18 +788,50 @@ export default function ResumeScreen() {
onOptimize={handleOptimize}
optimizing={optimizing}
/>
-
-
+ {/* 「保存」不是装饰:这里要如实显示存到了什么时候,否则用户不知道头部那个图标干了什么 */}
+ {savedAt !== '' && 上次保存:{formatDateTime(savedAt)}}
+
+ router.push('/resume/basic')} />
-
-
-
-
-
+ router.push('/resume/education?new=1')}
+ onEdit={() => router.push('/resume/education')}
+ onItem={(id) => router.push('/resume/education?id=' + id)}
+ />
+ router.push('/resume/experience?new=1')}
+ onEdit={() => router.push('/resume/experience')}
+ onItem={(id) => router.push('/resume/experience?id=' + id)}
+ onOptimize={(id) => void runAIAction(() => optimizeExperience(id))}
+ aiBusy={aiBusy}
+ />
+ router.push('/resume/project?new=1')}
+ onEdit={() => router.push('/resume/project')}
+ onItem={(id) => router.push('/resume/project?id=' + id)}
+ />
+ setSkillsModal(true)} />
+ void runAIAction(() => polishSelfEvaluation(displayResume.selfEvaluation.content))}
+ polishing={aiBusy}
+ />
-
+ router.push('/resume/preview')}
+ hitSlop={6}
+ >
预览
+
+ {
+ setSkillDraft('');
+ setSkillsModal(false);
+ }}
+ onAdd={(name) => void handleAddSkill(name)}
+ onRemove={handleRemoveSkill}
+ saving={saving}
+ />
+ setEvalModal(false)}
+ onSave={() => void handleSaveEvaluation()}
+ saving={saving}
+ />
);
}
@@ -529,6 +951,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
optAllText: { fontSize: 14, fontWeight: '700', color: colors.primaryText },
+ optAllBtnDisabled: { opacity: 0.7 },
sectionTitleRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -536,7 +959,10 @@ const styles = StyleSheet.create({
marginBottom: 14,
},
sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
+ sectionActions: { flexDirection: 'row', alignItems: 'center', gap: 14 },
editLink: { fontSize: 13, color: colors.primary },
+ /** 「上次保存」那行:只在真的保存过之后出现 */
+ savedAt: { fontSize: 12, color: colors.textHint, marginBottom: 12, marginTop: -4 },
basicRow: { flexDirection: 'row', alignItems: 'center' },
avatar: {
width: 60,
@@ -619,8 +1045,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 6,
},
+ /** 弹层里的标签可点删除:补一个 × 图标,光靠「可点击」没有视觉线索 */
+ skillTagEditable: { flexDirection: 'row', alignItems: 'center', gap: 6 },
skillText: { fontSize: 13, color: colors.textPrimary },
evalText: { fontSize: 13, color: colors.textSecondary, lineHeight: 22 },
+ /** 引导未采集该板块时的空态文案(不伪装成已填写) */
+ emptyText: { fontSize: 13, color: colors.textHint, marginTop: 4 },
footer: {
position: 'absolute',
left: 0,
@@ -652,4 +1082,76 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
optimizeFooterBtnText: { fontSize: 15, fontWeight: '700', color: colors.white },
+ /* ---------- 技能 / 自我评价弹层 ---------- */
+ modalMask: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0, 0, 0, 0.4)' },
+ modalSheet: {
+ backgroundColor: colors.white,
+ borderTopLeftRadius: 20,
+ borderTopRightRadius: 20,
+ paddingHorizontal: 20,
+ paddingTop: 16,
+ paddingBottom: 28,
+ maxHeight: '80%',
+ },
+ modalHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: 14,
+ },
+ modalTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
+ modalBody: { maxHeight: 180 },
+ modalLabel: { fontSize: 13, color: colors.textHint, marginBottom: 10 },
+ modalInputRow: { flexDirection: 'row', alignItems: 'center', gap: 10, marginTop: 16 },
+ modalInput: {
+ flex: 1,
+ height: 46,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: colors.border,
+ paddingHorizontal: 14,
+ fontSize: 15,
+ color: colors.textPrimary,
+ },
+ modalAddBtn: {
+ backgroundColor: colors.primaryBtn,
+ borderRadius: 12,
+ paddingHorizontal: 18,
+ paddingVertical: 13,
+ },
+ modalAddBtnDisabled: { backgroundColor: colors.border },
+ modalAddText: { fontSize: 14, fontWeight: '700', color: colors.white },
+ modalError: { fontSize: 12, color: colors.dangerText, marginTop: 8 },
+ evalInput: {
+ height: 140,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: colors.border,
+ paddingHorizontal: 14,
+ paddingTop: 12,
+ fontSize: 15,
+ lineHeight: 22,
+ color: colors.textPrimary,
+ textAlignVertical: 'top',
+ },
+ evalCounter: { fontSize: 12, color: colors.textHint, textAlign: 'right', marginTop: 6 },
+ modalFooter: { flexDirection: 'row', gap: 12, marginTop: 18 },
+ modalCancelBtn: {
+ flex: 1,
+ borderRadius: 24,
+ paddingVertical: 14,
+ alignItems: 'center',
+ borderWidth: 1,
+ borderColor: colors.border,
+ },
+ modalCancelText: { fontSize: 15, fontWeight: '700', color: colors.textSecondary },
+ modalSaveBtn: {
+ flex: 2,
+ backgroundColor: colors.primaryBtn,
+ borderRadius: 24,
+ paddingVertical: 14,
+ alignItems: 'center',
+ },
+ modalSaveBtnDisabled: { backgroundColor: colors.textDisabled },
+ modalSaveText: { fontSize: 15, fontWeight: '700', color: colors.white },
});
diff --git a/app/resume/basic.tsx b/app/resume/basic.tsx
new file mode 100644
index 0000000..d5bd568
--- /dev/null
+++ b/app/resume/basic.tsx
@@ -0,0 +1,136 @@
+import { useRouter } from 'expo-router';
+import { useEffect, useState } from 'react';
+import { ScrollView, StyleSheet, Text } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { PrimaryButton } from '../../components/onboarding/ui/PrimaryButton';
+import { CenterState, EditHeader, Field, FormNote, PageFooter } from '../../components/resume/EditKit';
+import { useResume } from '../../src/hooks';
+import { colors } from '../../src/theme';
+import { isValidEmail, isValidPhone } from '../../src/utils';
+
+/**
+ * 基本信息编辑页。
+ *
+ * 只收简历正文里那四项:姓名 / 手机号 / 邮箱 / 所在城市。
+ * - 期望城市不在这里(属求职意向,归 `/job-preference`,同一份数据不能有两个入口)
+ * - 头像不在这里(由 API 层按姓名派生首字,页面从不传头像)
+ * - 手机号与邮箱是**填了才校验**:引导流程未采集这两项,空值是「未采集」不是「填错了」
+ */
+export default function ResumeBasicScreen() {
+ const router = useRouter();
+ const { resume, loading, error, refresh, saving, saveBasicInfo } = useResume();
+
+ /** 表单只在首次取到数据时初始化一次,此后由用户输入驱动(否则每次 refresh 都会把手输的值冲掉) */
+ const [form, setForm] = useState(null);
+ const [formError, setFormError] = useState('');
+
+ useEffect(() => {
+ if (resume && !form) {
+ setForm({
+ name: resume.basicInfo.name,
+ phone: resume.basicInfo.phone,
+ email: resume.basicInfo.email,
+ location: resume.basicInfo.location,
+ });
+ }
+ }, [resume, form]);
+
+ const header = router.back()} />;
+
+ if (form === null) {
+ return (
+
+ {header}
+ {error !== null ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+
+ const phone = form.phone.trim();
+ const email = form.email.trim();
+ const phoneError = phone !== '' && !isValidPhone(phone) ? '请输入 11 位手机号' : '';
+ const emailError = email !== '' && !isValidEmail(email) ? '请输入正确的邮箱地址' : '';
+ const canSave = phoneError === '' && emailError === '';
+
+ const update = (field: keyof BasicForm, text: string) =>
+ setForm((prev) => ({ ...(prev ?? form), [field]: text }));
+
+ const handleSave = async () => {
+ if (!canSave || saving) return;
+ setFormError('');
+ try {
+ await saveBasicInfo({ name: form.name, phone, email, location: form.location });
+ router.back();
+ } catch {
+ // 落库失败就留在本页如实说,不装作已经保存(后端接入后换成真实错误文案)
+ setFormError('保存失败,请稍后重试');
+ }
+ };
+
+ return (
+
+ {header}
+
+ update('name', v)}
+ placeholder="请输入姓名"
+ maxLength={20}
+ />
+ update('phone', v)}
+ placeholder="请输入手机号"
+ keyboardType="phone-pad"
+ maxLength={11}
+ error={phoneError}
+ />
+ update('email', v)}
+ placeholder="请输入邮箱"
+ keyboardType="email-address"
+ error={emailError}
+ />
+ update('location', v)}
+ placeholder="如:北京 · 朝阳区"
+ maxLength={30}
+ />
+
+ {formError !== '' && {formError}}
+
+
+
+
+
+
+
+ );
+}
+
+interface BasicForm {
+ name: string;
+ phone: string;
+ email: string;
+ location: string;
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: colors.pageBg },
+ body: { padding: 20, paddingBottom: 24 },
+ formError: { fontSize: 13, color: colors.dangerText, marginBottom: 8 },
+});
diff --git a/app/resume/education.tsx b/app/resume/education.tsx
new file mode 100644
index 0000000..c10ac6f
--- /dev/null
+++ b/app/resume/education.tsx
@@ -0,0 +1,272 @@
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import { useEffect, useState } from 'react';
+import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { PrimaryButton } from '../../components/onboarding/ui/PrimaryButton';
+import {
+ CenterState,
+ EditHeader,
+ Field,
+ FormNote,
+ IconAction,
+ PageFooter,
+} from '../../components/resume/EditKit';
+import { useResume } from '../../src/hooks';
+import { colors } from '../../src/theme';
+import type { ResumeEducation } from '../../src/types';
+
+/**
+ * 教育背景编辑页:一页两态(列表态 / 表单态)。
+ *
+ * 入口三种:首页「添加教育经历」带 `?new=1` 直接进表单、条目「编辑」带 `?id=` 进表单、
+ * 首页「编辑 >」不带参数进列表。删除与编辑都在本页完成并**即时落盘**,
+ * 不存在「改了没保存就走」的困惑。
+ */
+export default function ResumeEducationScreen() {
+ const router = useRouter();
+ const params = useLocalSearchParams<{ new?: string; id?: string }>();
+ const { resume, loading, error, refresh, saving, saveEducation } = useResume();
+
+ /** 非空 = 表单态;null = 列表态 */
+ const [editing, setEditing] = useState(null);
+ /** 入口参数只解析一次:解析后再取数(或保存后的刷新)不该把用户拉回表单/列表 */
+ const [entryApplied, setEntryApplied] = useState(false);
+
+ useEffect(() => {
+ if (!resume || entryApplied) return;
+ setEntryApplied(true);
+ if (params.new === '1') {
+ setEditing(EMPTY_FORM);
+ return;
+ }
+ if (params.id !== undefined && params.id !== '') {
+ const found = resume.education.find((it) => it.id === params.id);
+ // 找不到就留在列表态:编一条空表单让用户改,等于凭空多出一条教育经历
+ if (found) setEditing(toForm(found));
+ }
+ }, [resume, entryApplied, params.new, params.id]);
+
+ const list = resume?.education ?? [];
+ const header = (
+ (editing === null ? router.back() : setEditing(null))} />
+ );
+
+ if (resume === null) {
+ return (
+
+ {header}
+ {error !== null ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+
+ /* ===================== 表单态 ===================== */
+
+ if (editing !== null) {
+ const school = editing.school.trim();
+ const startError = editing.startYear !== '' && !YEAR_RE.test(editing.startYear) ? '请输入 4 位年份' : '';
+ const endError = editing.endYear !== '' && !YEAR_RE.test(editing.endYear) ? '请输入 4 位年份' : '';
+ const orderError =
+ startError === '' && endError === '' && editing.startYear !== '' && editing.endYear !== '' && editing.startYear > editing.endYear
+ ? '毕业年份不能早于入学年份'
+ : '';
+ const canSave = school !== '' && startError === '' && endError === '' && orderError === '';
+
+ const update = (field: keyof EduForm, text: string) =>
+ setEditing((prev) => ({ ...(prev ?? editing), [field]: text }));
+
+ const handleSubmit = async () => {
+ if (!canSave || saving) return;
+ const fields = {
+ school,
+ major: editing.major.trim(),
+ degree: editing.degree.trim(),
+ startYear: editing.startYear,
+ endYear: editing.endYear,
+ };
+ // 交回整表。新增项**不带 id 字段**(而不是给空串):API 层用 `?? ` 判缺省,
+ // 空串不是 nullish,会把 id 写成空的
+ const next =
+ editing.id === undefined
+ ? [...list, fields]
+ : list.map((it) => (it.id === editing.id ? { ...fields, id: editing.id } : it));
+ await saveEducation(next);
+ // 保存后回到列表态,让用户看见结果(不 router.back(),否则看不到自己刚加的那条)
+ setEditing(null);
+ };
+
+ return (
+
+ {header}
+
+ update('school', v)}
+ placeholder="请输入学校名称"
+ maxLength={30}
+ autoFocus
+ />
+ update('major', v)}
+ placeholder="如:计算机科学与技术"
+ maxLength={30}
+ />
+ update('degree', v)}
+ placeholder="如:本科 / 硕士 非全日制"
+ maxLength={20}
+ hint="引导流程采集到的学历会带学习形式(如「硕士 非全日制」),这里可以照原样填"
+ />
+
+
+ update('startYear', v.replace(/[^\d]/g, ''))}
+ placeholder="如:2018"
+ keyboardType="number-pad"
+ maxLength={4}
+ error={startError}
+ />
+
+
+ update('endYear', v.replace(/[^\d]/g, ''))}
+ placeholder="如:2022"
+ keyboardType="number-pad"
+ maxLength={4}
+ error={endError}
+ />
+
+
+ {orderError !== '' && {orderError}}
+
+
+
+
+
+
+ );
+ }
+
+ /* ===================== 列表态 ===================== */
+
+ const handleDelete = (item: ResumeEducation) => {
+ Alert.alert('删除这条教育经历?', item.school, [
+ { text: '取消', style: 'cancel' },
+ {
+ text: '删除',
+ style: 'destructive',
+ // 即时落盘:删掉就走也不用担心没保存
+ onPress: () => {
+ void saveEducation(list.filter((it) => it.id !== item.id));
+ },
+ },
+ ]);
+ };
+
+ return (
+
+ {header}
+ {list.length === 0 ? (
+
+ ) : (
+
+ {list.map((edu) => {
+ // 专业 / 学历 / 年份可能为空(引导只采集学校和学历),拼出来会剩「· 本科」这种半截值
+ const majorDegree = [edu.major, edu.degree].filter(Boolean).join(' · ');
+ const years = [edu.startYear, edu.endYear].filter(Boolean).join('-');
+ return (
+
+
+ {edu.school}
+ {majorDegree !== '' && {majorDegree}}
+ {years !== '' && {years}}
+
+ setEditing(toForm(edu))}
+ />
+ handleDelete(edu)}
+ />
+
+ );
+ })}
+
+ )}
+
+ setEditing(EMPTY_FORM)} />
+
+
+ );
+}
+
+/* ===================== 表单与条目互转 ===================== */
+
+interface EduForm {
+ /** undefined = 新增项(由 API 层补 id) */
+ id?: string;
+ school: string;
+ major: string;
+ degree: string;
+ startYear: string;
+ endYear: string;
+}
+
+const EMPTY_FORM: EduForm = { school: '', major: '', degree: '', startYear: '', endYear: '' };
+
+const YEAR_RE = /^\d{4}$/;
+
+const toForm = (edu: ResumeEducation): EduForm => ({
+ id: edu.id,
+ school: edu.school,
+ major: edu.major,
+ degree: edu.degree,
+ startYear: edu.startYear,
+ endYear: edu.endYear,
+});
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: colors.pageBg },
+ body: { padding: 20, paddingBottom: 24 },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: colors.white,
+ borderRadius: 14,
+ paddingVertical: 14,
+ paddingLeft: 16,
+ paddingRight: 8,
+ marginBottom: 10,
+ borderWidth: 1,
+ borderColor: colors.borderLight,
+ },
+ rowBody: { flex: 1, gap: 4 },
+ rowTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
+ rowSub: { fontSize: 13, color: colors.textSecondary },
+ yearRow: { flexDirection: 'row', gap: 12 },
+ yearItem: { flex: 1 },
+ formError: { fontSize: 13, color: colors.dangerText, marginBottom: 8 },
+});
diff --git a/app/resume/experience.tsx b/app/resume/experience.tsx
new file mode 100644
index 0000000..fc556b1
--- /dev/null
+++ b/app/resume/experience.tsx
@@ -0,0 +1,323 @@
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import { useEffect, useState } from 'react';
+import { Alert, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { PrimaryButton } from '../../components/onboarding/ui/PrimaryButton';
+import { YearMonthWheel } from '../../components/onboarding/ui/WheelPicker';
+import {
+ CenterState,
+ EditHeader,
+ Field,
+ FormNote,
+ IconAction,
+ PageFooter,
+} from '../../components/resume/EditKit';
+import { useResume } from '../../src/hooks';
+import { colors } from '../../src/theme';
+import type { ResumeExperience } from '../../src/types';
+import { splitYearMonth, yearMonthValue } from '../../src/utils';
+
+/**
+ * 工作经历编辑页:一页两态(列表态 / 表单态)。
+ *
+ * 时间用 `YearMonthWheel`,存储格式与引导采集一致(`'2023年2月'`),
+ * 回填靠 `splitYearMonth`;解析不出(空串 /「至今」)时两个滚轮都停在未选中态,
+ * **不给兜底索引** —— 给索引会静默把用户的入职年月改成别的年份。
+ */
+export default function ResumeExperienceScreen() {
+ const router = useRouter();
+ const params = useLocalSearchParams<{ new?: string; id?: string }>();
+ const { resume, loading, error, refresh, saving, saveExperience } = useResume();
+
+ const [editing, setEditing] = useState(null);
+ const [entryApplied, setEntryApplied] = useState(false);
+
+ useEffect(() => {
+ if (!resume || entryApplied) return;
+ setEntryApplied(true);
+ if (params.new === '1') {
+ setEditing(EMPTY_FORM);
+ return;
+ }
+ if (params.id !== undefined && params.id !== '') {
+ const found = resume.experience.find((it) => it.id === params.id);
+ if (found) setEditing(toForm(found));
+ }
+ }, [resume, entryApplied, params.new, params.id]);
+
+ const list = resume?.experience ?? [];
+ const header = (
+ (editing === null ? router.back() : setEditing(null))}
+ />
+ );
+
+ if (resume === null) {
+ return (
+
+ {header}
+ {error !== null ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+
+ /* ===================== 表单态 ===================== */
+
+ if (editing !== null) {
+ const company = editing.company.trim();
+ const startValue = yearMonthValue(editing.startYear, editing.startMonth);
+ const endValue = yearMonthValue(editing.endYear, editing.endMonth);
+ const startError = startValue === null ? '请选择开始时间' : '';
+ const endError = editing.untilNow ? '' : endValue === null ? '请选择结束时间' : '';
+ const orderError =
+ startValue !== null && endValue !== null && endValue < startValue ? '结束时间不能早于开始时间' : '';
+ const canSave = company !== '' && startError === '' && endError === '' && orderError === '';
+
+ const update = (patch: Partial) => setEditing((prev) => ({ ...(prev ?? editing), ...patch }));
+
+ const handleSubmit = async () => {
+ if (!canSave || saving) return;
+ const fields = {
+ company,
+ position: editing.position.trim(),
+ startDate: `${editing.startYear}${editing.startMonth}`,
+ // 「至今」才写「至今」;否则存选中的年月。没选完的情况已被上面的校验拦住
+ endDate: editing.untilNow ? UNTIL_NOW : `${editing.endYear}${editing.endMonth}`,
+ description: editing.description.trim(),
+ // AI 改写未接入,用户手改的文字不该被标成「已优化」
+ aiOptimized: false,
+ };
+ const next =
+ editing.id === undefined
+ ? [...list, fields]
+ : list.map((it) => (it.id === editing.id ? { ...fields, id: editing.id } : it));
+ await saveExperience(next);
+ setEditing(null);
+ };
+
+ return (
+
+ {header}
+
+ update({ company: v })}
+ placeholder="请输入公司名称"
+ maxLength={40}
+ autoFocus
+ />
+ update({ position: v })}
+ placeholder="如:高级前端工程师"
+ maxLength={30}
+ />
+
+
+
+ 开始时间 *
+
+ update({ startYear: v })}
+ onMonthChange={(v) => update({ startMonth: v })}
+ />
+ {startError !== '' && {startError}}
+
+
+
+
+ 至今
+
+ // 打开「至今」时清掉已选的结束时间:留着会让存进去的 endDate 与开关不一致
+ update(v ? { untilNow: true, endYear: '', endMonth: '' } : { untilNow: false })
+ }
+ trackColor={{ false: colors.border, true: colors.primaryBorder }}
+ thumbColor={editing.untilNow ? colors.primaryBtn : colors.white}
+ />
+
+ {editing.untilNow ? (
+
+ ) : (
+ <>
+
+ 结束时间 *
+
+ update({ endYear: v })}
+ onMonthChange={(v) => update({ endMonth: v })}
+ />
+ {endError !== '' && {endError}}
+ >
+ )}
+
+
+ update({ description: v })}
+ placeholder="负责了什么、做成了什么(AI 改写需后端接入)"
+ multiline
+ maxLength={500}
+ />
+ {orderError !== '' && {orderError}}
+
+
+
+
+
+
+ );
+ }
+
+ /* ===================== 列表态 ===================== */
+
+ const handleDelete = (item: ResumeExperience) => {
+ Alert.alert('删除这条工作经历?', item.company, [
+ { text: '取消', style: 'cancel' },
+ {
+ text: '删除',
+ style: 'destructive',
+ onPress: () => {
+ void saveExperience(list.filter((it) => it.id !== item.id));
+ },
+ },
+ ]);
+ };
+
+ return (
+
+ {header}
+ {list.length === 0 ? (
+
+ ) : (
+
+ {list.map((exp) => {
+ const dates = [exp.startDate, exp.endDate].filter(Boolean).join(' - ');
+ return (
+
+
+ {exp.company}
+ {exp.position !== '' && {exp.position}}
+ {dates !== '' && {dates}}
+ {exp.description !== '' && (
+
+ {exp.description}
+
+ )}
+
+ setEditing(toForm(exp))}
+ />
+ handleDelete(exp)}
+ />
+
+ );
+ })}
+
+ )}
+
+ setEditing(EMPTY_FORM)} />
+
+
+ );
+}
+
+/* ===================== 表单与条目互转 ===================== */
+
+interface ExpForm {
+ /** undefined = 新增项(由 API 层补 id) */
+ id?: string;
+ company: string;
+ position: string;
+ startYear: string;
+ startMonth: string;
+ untilNow: boolean;
+ endYear: string;
+ endMonth: string;
+ description: string;
+}
+
+/** 结束时间的「至今」哨兵值,与引导派生出来的格式一致 */
+const UNTIL_NOW = '至今';
+
+const EMPTY_FORM: ExpForm = {
+ company: '',
+ position: '',
+ startYear: '',
+ startMonth: '',
+ // 默认按「在职」预置不成立:新的一条究竟是哪一段经历只有用户知道,默认 false 由他显式选
+ untilNow: false,
+ endYear: '',
+ endMonth: '',
+ description: '',
+};
+
+const toForm = (exp: ResumeExperience): ExpForm => {
+ const start = splitYearMonth(exp.startDate);
+ const untilNow = exp.endDate === UNTIL_NOW;
+ const end = splitYearMonth(untilNow ? '' : exp.endDate);
+ return {
+ id: exp.id,
+ company: exp.company,
+ position: exp.position,
+ startYear: start.year,
+ startMonth: start.month,
+ untilNow,
+ endYear: end.year,
+ endMonth: end.month,
+ description: exp.description,
+ };
+};
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: colors.pageBg },
+ body: { padding: 20, paddingBottom: 24 },
+ section: { marginBottom: 18 },
+ label: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
+ labelTop: { marginTop: 14, marginBottom: 10 },
+ requiredMark: { color: colors.danger },
+ fieldError: { fontSize: 12, color: colors.dangerText, marginTop: 6 },
+ formError: { fontSize: 13, color: colors.dangerText, marginBottom: 8 },
+ switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: colors.white,
+ borderRadius: 14,
+ paddingVertical: 14,
+ paddingLeft: 16,
+ paddingRight: 8,
+ marginBottom: 10,
+ borderWidth: 1,
+ borderColor: colors.borderLight,
+ },
+ rowBody: { flex: 1, gap: 4 },
+ rowTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
+ rowSub: { fontSize: 13, color: colors.textSecondary },
+ rowDesc: { fontSize: 13, color: colors.textHint, lineHeight: 18 },
+});
diff --git a/app/resume/export.tsx b/app/resume/export.tsx
index ec1b0d7..a5e3060 100644
--- a/app/resume/export.tsx
+++ b/app/resume/export.tsx
@@ -3,6 +3,8 @@ import { useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import {
ActivityIndicator,
+ Alert,
+ Modal,
Pressable,
ScrollView,
StyleSheet,
@@ -12,9 +14,17 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { useResumeExport } from '../../src/hooks';
-import { colors } from '../../src/theme';
-import type { ResumeExportFormat, ResumeExportSettings, ResumeTemplate } from '../../src/types';
+import {
+ RESUME_EXPORT_FORMAT_NOTICE,
+ RESUME_EXPORT_PASSWORD_NOTICE,
+ RESUME_FILE_NAME_HINT,
+ RESUME_ORIENTATIONS,
+ RESUME_PAPER_SIZES,
+ RESUME_TEMPLATES,
+} from '../../src/constants';
+import { useResume, useResumeExport } from '../../src/hooks';
+import { colors, templateAccentColors } from '../../src/theme';
+import type { ResumeExportFormat, ResumeExportSettings } from '../../src/types';
const FORMATS: { key: ResumeExportFormat; label: string; icon: keyof typeof Ionicons.glyphMap }[] = [
{ key: 'pdf', label: 'PDF', icon: 'document-outline' },
@@ -22,18 +32,6 @@ const FORMATS: { key: ResumeExportFormat; label: string; icon: keyof typeof Ioni
{ key: 'image', label: '图片', icon: 'image-outline' },
];
-const TEMPLATES: ResumeTemplate[] = [
- { id: 'classic', name: '简约经典', color: 'primary' },
- { id: 'creative', name: '创意设计', color: 'info' },
- { id: 'tech', name: '技术专业', color: 'success' },
-];
-
-const TEMPLATE_COLORS: Record = {
- primary: colors.primary,
- info: colors.info,
- success: colors.success,
-};
-
/* ===================== 子组件:顶部导航 ===================== */
/**
@@ -59,16 +57,16 @@ function Header({ onBack, onSave }: { onBack: () => void; onSave?: () => void })
);
}
-/* ===================== 子组件:预览区 ===================== */
+/* ===================== 子组件:预览入口 ===================== */
-function PreviewCard() {
+function PreviewCard({ onOpen }: { onOpen: () => void }) {
return (
实时预览你的简历效果
-
+
打开预览
@@ -109,6 +107,13 @@ function FormatSelect({
);
})}
+ {/* 非 PDF 选中的那一刻就说清能做到什么:等到导出后才说,用户已经白等一次 */}
+ {value !== 'pdf' && (
+
+
+ {RESUME_EXPORT_FORMAT_NOTICE}
+
+ )}
);
}
@@ -118,20 +123,22 @@ function FormatSelect({
function TemplateSelect({
value,
onChange,
+ onMore,
}: {
value: string;
onChange: (v: string) => void;
+ onMore: () => void;
}) {
return (
选择简历模板
-
+
全部模板 >
- {TEMPLATES.map((t) => {
+ {RESUME_TEMPLATES.map((t) => {
const selected = value === t.id;
return (
onChange(t.id)}
>
+ {/* TODO(素材):需 UI 出 3 张模板预览图(简约经典 / 创意设计 / 技术专业),
+ 当前用模板强调色色块 + 线条占位 */}
@@ -167,11 +176,13 @@ function TemplateSelect({
function ToggleRow({
label,
desc,
+ notice,
value,
onChange,
}: {
label: string;
desc?: string;
+ notice?: string;
value: boolean;
onChange: (v: boolean) => void;
}) {
@@ -180,6 +191,7 @@ function ToggleRow({
{label}
{desc && {desc}}
+ {notice && {notice}}
void;
placeholder?: string;
+ hint?: string;
}) {
return (
- {label}
+
+ {label}
+ {hint && {hint}}
+
void;
+}) {
+ return (
+
+ {label}
+
+ {value}
+
+
+
+ );
+}
+
+/* ===================== 子组件:选择弹层 ===================== */
+
+interface SheetOption {
+ value: string;
+ label: string;
+ /** 模板缩略色块(纸型 / 方向不需要) */
+ accent?: string;
+}
+
+interface SheetState {
+ title: string;
+ hint?: string;
+ value: string;
+ options: SheetOption[];
+ onSelect: (value: string) => void;
+}
+
+/** 底部选择弹层:模板 / 纸型 / 方向共用(三者的交互完全一致,只差选项与标题) */
+function OptionSheet({ sheet, onClose }: { sheet: SheetState; onClose: () => void }) {
+ return (
+
+
+ {/* affordance-ok:吞冒泡,点弹层内容区不关闭 */}
+ {}}>
+ {sheet.title}
+ {sheet.options.map((option) => {
+ const selected = option.value === sheet.value;
+ return (
+ sheet.onSelect(option.value)}
+ >
+ {option.accent !== undefined && (
+
+ )}
+
+ {option.label}
+
+ {selected && }
+
+ );
+ })}
+ {sheet.hint !== undefined && {sheet.hint}}
+
+ 取消
+
+
+
+
+ );
+}
+
/* ===================== 子组件:四态 ===================== */
function Loading() {
@@ -241,12 +332,31 @@ function ErrorState({ onRetry }: { onRetry: () => void }) {
);
}
+/* ===================== 工具:storage 值 → 合法设置值 ===================== */
+
+/** 弹层回传字符串,白名单校验后才写入设置(storage 里的值不保证是这两个之一) */
+function pickPaperSize(value: string): ResumeExportSettings['paperSize'] {
+ return value === 'letter' ? 'letter' : 'A4';
+}
+
+function pickOrientation(value: string): ResumeExportSettings['orientation'] {
+ return value === 'landscape' ? 'landscape' : 'portrait';
+}
+
/* ===================== 页面 ===================== */
export default function ResumeExportScreen() {
const router = useRouter();
- const { settings, loading, error, refresh, save, exportFile, exporting } = useResumeExport();
+ const { settings, loading, error, refresh, save, exportFile, share, exporting, sharing } =
+ useResumeExport();
+ /**
+ * AI 定向优化走简历域的 hook(页面不直连 API)。
+ * 这里只取 `optimizeWithJD`,本页不渲染简历数据。
+ */
+ const { optimizeWithJD } = useResume();
const [localSettings, setLocalSettings] = useState(null);
+ const [sheet, setSheet] = useState(null);
+ const [optimizing, setOptimizing] = useState(false);
useEffect(() => {
if (settings && !localSettings) {
@@ -258,19 +368,68 @@ export default function ResumeExportScreen() {
setLocalSettings((prev) => (prev ? { ...prev, [key]: value } : prev));
};
+ /** 只落盘不带提示:导出 / 分享前先把设置保存好,避免导出用的是一份没保存的配置 */
+ const persist = async () => {
+ if (localSettings) await save(localSettings);
+ };
+
const handleSave = async () => {
- if (localSettings) {
- await save(localSettings);
- }
+ await persist();
+ Alert.alert('已保存', '导出设置已保存在本机,下次进入仍是这份设置。');
};
const handleExport = async () => {
if (!localSettings) return;
- await handleSave();
- await exportFile(localSettings);
+ await persist();
+ const outcome = await exportFile(localSettings);
+ if (!outcome.ok) {
+ Alert.alert('导出失败', outcome.message);
+ return;
+ }
+ Alert.alert(
+ '导出成功',
+ [
+ `已生成 ${outcome.fileName}(共 ${outcome.pages} 页)。`,
+ '文件在本机缓存中,点「分享」可保存到「文件」或发送给他人;缓存可能被系统清理,需要长期留存请先分享出去。',
+ localSettings.format === 'pdf' ? '' : RESUME_EXPORT_FORMAT_NOTICE,
+ ]
+ .filter((line) => line !== '')
+ .join('\n\n'),
+ [
+ { text: '知道了', style: 'cancel' },
+ { text: '分享', onPress: () => void handleShare() },
+ ],
+ );
+ };
+
+ const handleShare = async () => {
+ if (!localSettings) return;
+ await persist();
+ const outcome = await share(localSettings);
+ // 成功不弹提示:系统分享面板本身就是反馈;失败必须说清原因
+ if (!outcome.ok) Alert.alert('无法分享', outcome.message);
+ };
+
+ const jd = (localSettings?.targetJobDescription ?? '').trim();
+
+ const handleOptimize = async () => {
+ if (jd === '' || optimizing) return;
+ setOptimizing(true);
+ try {
+ const result = await optimizeWithJD(jd);
+ if (result.available) {
+ Alert.alert('已完成', result.message);
+ } else {
+ Alert.alert('暂未开放', result.message);
+ }
+ } finally {
+ setOptimizing(false);
+ }
};
- if (loading) {
+ // 保存设置会 refresh,refresh 会把 loading 置真;此时 localSettings 已有值,
+ // 不能因为刷新再闪一次整页加载态(首次加载才显示 loading)
+ if (loading && localSettings === null) {
return (
router.back()} />
@@ -292,11 +451,27 @@ export default function ResumeExportScreen() {
router.back()} onSave={handleSave} />
-
+ router.push('/resume/preview')} />
update('format', v)} />
- update('templateId', v)} />
+ update('templateId', v)}
+ onMore={() =>
+ setSheet({
+ title: '选择简历模板',
+ hint: '更多模板将在后端模板库接入后提供',
+ value: localSettings.templateId,
+ options: RESUME_TEMPLATES.map((t) => ({
+ value: t.id,
+ label: t.name,
+ accent: templateAccentColors[t.color] ?? colors.primary,
+ })),
+ onSelect: (v) => update('templateId', v),
+ })
+ }
+ />
导出设置
@@ -310,24 +485,55 @@ export default function ResumeExportScreen() {
update('exportPassword', v)}
/>
- {}} />
- {}} />
+ p.value === localSettings.paperSize)?.label ?? 'A4'
+ }
+ onPress={() =>
+ setSheet({
+ title: '选择纸型',
+ value: localSettings.paperSize,
+ options: RESUME_PAPER_SIZES.map((p) => ({ value: p.value, label: p.label })),
+ onSelect: (v) => update('paperSize', pickPaperSize(v)),
+ })
+ }
+ />
+ o.value === localSettings.orientation)?.label ??
+ '纵向'
+ }
+ onPress={() =>
+ setSheet({
+ title: '选择方向',
+ value: localSettings.orientation,
+ options: RESUME_ORIENTATIONS.map((o) => ({ value: o.value, label: o.label })),
+ onSelect: (v) => update('orientation', pickOrientation(v)),
+ })
+ }
+ />
update('fileName', v)}
placeholder="请输入文件名"
+ hint={RESUME_FILE_NAME_HINT}
/>
update('includeCover', v)}
/>
update('includeRecommendation', v)}
/>
@@ -348,17 +554,37 @@ export default function ResumeExportScreen() {
numberOfLines={4}
textAlignVertical="top"
/>
-
-
- AI 智能优化
+
+ {optimizing ? (
+
+ ) : (
+ <>
+
+ AI 智能优化
+ >
+ )}
+ {/* 按钮置灰时把原因写在旁边:灰按钮不给理由,用户只会以为坏了 */}
+ {jd === '' && 请先粘贴岗位描述,再点优化}
-
- 分享简历
+ void handleShare()}
+ disabled={sharing}
+ >
+ {sharing ? (
+
+ ) : (
+ 分享简历
+ )}
+
+ {sheet !== null && (
+ setSheet(null)}
+ />
+ )}
);
}
@@ -439,6 +672,8 @@ const styles = StyleSheet.create({
formatLabelActive: { color: colors.primaryText },
formatHint: { fontSize: 10, color: colors.textHint, marginTop: 2 },
formatCheck: { position: 'absolute', top: 8, right: 8 },
+ noticeRow: { flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 10 },
+ noticeText: { fontSize: 12, color: colors.textHint, lineHeight: 17, marginTop: 4 },
templateRow: { flexDirection: 'row', gap: 10 },
templateCard: {
flex: 1,
@@ -489,13 +724,17 @@ const styles = StyleSheet.create({
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
+ inputLabelBox: { flex: 1, marginRight: 12 },
inputLabel: { fontSize: 14, color: colors.textPrimary, width: 80 },
+ inputHint: { fontSize: 11, color: colors.textHint, marginTop: 2 },
input: {
flex: 1,
fontSize: 14,
color: colors.textPrimary,
textAlign: 'right',
},
+ selectValue: { flexDirection: 'row', alignItems: 'center', gap: 4 },
+ selectValueText: { fontSize: 14, color: colors.textSecondary },
targetLabel: { fontSize: 13, color: colors.textSecondary, marginBottom: 10 },
targetInput: {
height: 100,
@@ -514,7 +753,37 @@ const styles = StyleSheet.create({
paddingVertical: 12,
marginTop: 12,
},
+ targetBtnDisabled: { backgroundColor: colors.textDisabled },
targetBtnText: { fontSize: 14, fontWeight: '700', color: colors.white, marginLeft: 6 },
+ scrim: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'flex-end' },
+ sheet: {
+ backgroundColor: colors.white,
+ borderTopLeftRadius: 18,
+ borderTopRightRadius: 18,
+ paddingHorizontal: 20,
+ paddingTop: 18,
+ paddingBottom: 28,
+ },
+ sheetTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary, marginBottom: 8 },
+ sheetRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 14,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderLight,
+ },
+ sheetSwatch: { width: 22, height: 22, borderRadius: 6, marginRight: 12 },
+ sheetLabel: { flex: 1, fontSize: 15, color: colors.textPrimary },
+ sheetLabelActive: { color: colors.primaryText, fontWeight: '700' },
+ sheetHint: { fontSize: 12, color: colors.textHint, marginTop: 12, lineHeight: 18 },
+ sheetCancel: {
+ marginTop: 16,
+ paddingVertical: 12,
+ borderRadius: 24,
+ alignItems: 'center',
+ backgroundColor: colors.pageBg,
+ },
+ sheetCancelText: { fontSize: 15, fontWeight: '600', color: colors.textSecondary },
footer: {
position: 'absolute',
left: 0,
@@ -537,6 +806,7 @@ const styles = StyleSheet.create({
borderColor: colors.border,
marginRight: 12,
},
+ shareBtnDisabled: { borderColor: colors.borderLight },
shareBtnText: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
exportBtn: {
flex: 2,
diff --git a/app/resume/preview.tsx b/app/resume/preview.tsx
new file mode 100644
index 0000000..5afcb6b
--- /dev/null
+++ b/app/resume/preview.tsx
@@ -0,0 +1,240 @@
+import { useRouter } from 'expo-router';
+import { ScrollView, StyleSheet, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { PrimaryButton } from '../../components/onboarding/ui/PrimaryButton';
+import { CenterState, EditHeader, PageFooter } from '../../components/resume/EditKit';
+import { useResume } from '../../src/hooks';
+import { UNSET_LABEL } from '../../src/constants';
+import { colors } from '../../src/theme';
+import type { Resume, ResumeEducation, ResumeExperience, ResumeProject } from '../../src/types';
+
+/**
+ * 简历预览页(只读)。
+ *
+ * 只渲染简历**正文**:不含评分卡与优化建议 —— 那些是 App 内的辅助信息,不会出现在
+ * 导出的文件里,混在一起会让人以为导出物里也有。
+ *
+ * 空板块**保留标题 + 「未填写 X」**:预览的价值正是让用户看见自己缺什么,
+ * 整段隐去反而看不出漏了哪一块。
+ */
+export default function ResumePreviewScreen() {
+ const router = useRouter();
+ const { resume, loading, error, refresh } = useResume();
+
+ const header = router.back()} />;
+
+ if (resume === null) {
+ return (
+
+ {header}
+ {error !== null ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+
+ const { basicInfo, jobIntention, education, experience, projects, skills, selfEvaluation } = resume;
+
+ return (
+
+ {header}
+
+ {/* 纸张:白底 + 描边,与导出效果同一套排版 */}
+
+
+
+ {/* TODO(素材):接入后端后这里渲染用户上传的头像图片;
+ 当前以姓名首字兜底,无姓名时不占位 */}
+ {basicInfo.avatar !== '' ? basicInfo.avatar : '简'}
+
+
+ {basicInfo.name !== '' ? basicInfo.name : UNSET_LABEL}
+ {/* 有值才渲染:空白行比不渲染更让人困惑 */}
+ {[basicInfo.phone, basicInfo.email, basicInfo.location].filter((v) => v !== '').length > 0 && (
+
+ {[basicInfo.phone, basicInfo.email, basicInfo.location].filter((v) => v !== '').join(' · ')}
+
+ )}
+
+
+
+
+ {hasIntention(jobIntention) ? (
+
+ {[
+ jobIntention.position,
+ jobIntention.city,
+ jobIntention.salary,
+ jobIntention.statusLabel,
+ ]
+ .filter((v) => v !== '')
+ .join(' · ')}
+
+ ) : (
+
+ )}
+
+
+
+ {education.length === 0 ? (
+
+ ) : (
+ education.map((edu) => )
+ )}
+
+
+
+ {experience.length === 0 ? (
+
+ ) : (
+ experience.map((exp) => )
+ )}
+
+
+
+ {projects.length === 0 ? (
+
+ ) : (
+ projects.map((proj) => )
+ )}
+
+
+
+ {skills.length === 0 ? (
+
+ ) : (
+ {skills.map((s) => s.name).join(' · ')}
+ )}
+
+
+
+ {selfEvaluation.content === '' ? (
+
+ ) : (
+ {selfEvaluation.content}
+ )}
+
+
+
+
+ 预览为简历正文效果,不含评分与优化建议。模板样式、纸张大小需后端导出服务支持,当前按默认版式呈现。
+
+
+
+
+ router.push('/resume/export')} />
+
+
+ );
+}
+
+/* ===================== 子组件 ===================== */
+
+function Section({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ {title}
+ {children}
+
+ );
+}
+
+function Empty({ text }: { text: string }) {
+ return {text};
+}
+
+function EducationItem({ edu }: { edu: ResumeEducation }) {
+ // 专业 / 学历 / 年份可能为空,拼出来会剩半截值
+ const majorDegree = [edu.major, edu.degree].filter(Boolean).join(' · ');
+ const years = [edu.startYear, edu.endYear].filter(Boolean).join('-');
+ return (
+
+
+ {edu.school}
+ {years !== '' && {years}}
+
+ {majorDegree !== '' && {majorDegree}}
+
+ );
+}
+
+function ExperienceItem({ exp }: { exp: ResumeExperience }) {
+ const dates = [exp.startDate, exp.endDate].filter(Boolean).join(' - ');
+ return (
+
+
+ {exp.company}
+ {dates !== '' && {dates}}
+
+ {exp.position !== '' && {exp.position}}
+ {exp.description !== '' && {exp.description}}
+
+ );
+}
+
+function ProjectItem({ proj }: { proj: ResumeProject }) {
+ return (
+
+
+ {proj.name}
+ {proj.role !== '' && {proj.role}}
+
+ {proj.description !== '' && {proj.description}}
+
+ );
+}
+
+/** 求职意向四项全空才算未填写(引导只采集到其中一两项时也算有内容) */
+function hasIntention(intention: Resume['jobIntention']): boolean {
+ return (
+ [intention.position, intention.city, intention.salary, intention.statusLabel].filter(
+ (v) => v !== '',
+ ).length > 0
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: colors.pageBg },
+ body: { padding: 16, paddingBottom: 24 },
+ paper: {
+ backgroundColor: colors.white,
+ borderRadius: 8,
+ padding: 20,
+ borderWidth: 1,
+ borderColor: colors.borderLight,
+ },
+ paperHead: { flexDirection: 'row', alignItems: 'center', marginBottom: 20 },
+ avatar: {
+ width: 52,
+ height: 52,
+ borderRadius: 26,
+ backgroundColor: colors.primary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ avatarText: { fontSize: 22, fontWeight: '700', color: colors.white },
+ headBody: { marginLeft: 14, flex: 1 },
+ name: { fontSize: 20, fontWeight: '800', color: colors.textPrimary },
+ contact: { fontSize: 13, color: colors.textSecondary, marginTop: 6 },
+ section: { marginBottom: 18 },
+ sectionTitle: {
+ fontSize: 14,
+ fontWeight: '700',
+ color: colors.textPrimary,
+ borderLeftWidth: 3,
+ borderLeftColor: colors.primary,
+ paddingLeft: 8,
+ marginBottom: 10,
+ },
+ item: { marginBottom: 12 },
+ itemHead: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
+ itemTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, flex: 1 },
+ itemMeta: { fontSize: 12, color: colors.textHint },
+ itemSub: { fontSize: 13, color: colors.textSecondary, marginTop: 4 },
+ paragraph: { fontSize: 13, color: colors.textSecondary, lineHeight: 21, marginTop: 6 },
+ emptyText: { fontSize: 13, color: colors.textHint },
+ tip: { fontSize: 12, color: colors.textHint, lineHeight: 18, marginTop: 4 },
+});
diff --git a/app/resume/project.tsx b/app/resume/project.tsx
new file mode 100644
index 0000000..a09c954
--- /dev/null
+++ b/app/resume/project.tsx
@@ -0,0 +1,225 @@
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import { useEffect, useState } from 'react';
+import { Alert, ScrollView, StyleSheet, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { PrimaryButton } from '../../components/onboarding/ui/PrimaryButton';
+import {
+ CenterState,
+ EditHeader,
+ Field,
+ FormNote,
+ IconAction,
+ PageFooter,
+} from '../../components/resume/EditKit';
+import { useResume } from '../../src/hooks';
+import { colors } from '../../src/theme';
+import type { ResumeProject } from '../../src/types';
+
+/**
+ * 项目经历编辑页:一页两态(列表态 / 表单态)。
+ *
+ * 本页不提供「AI 优化」按钮:项目经历在首页只有「已量化」徽标,
+ * 徽标的置位要等后端真能改写正文,不能让用户点出一个立刻显示「已优化」的假状态。
+ */
+export default function ResumeProjectScreen() {
+ const router = useRouter();
+ const params = useLocalSearchParams<{ new?: string; id?: string }>();
+ const { resume, loading, error, refresh, saving, saveProjects } = useResume();
+
+ const [editing, setEditing] = useState(null);
+ const [entryApplied, setEntryApplied] = useState(false);
+
+ useEffect(() => {
+ if (!resume || entryApplied) return;
+ setEntryApplied(true);
+ if (params.new === '1') {
+ setEditing(EMPTY_FORM);
+ return;
+ }
+ if (params.id !== undefined && params.id !== '') {
+ const found = resume.projects.find((it) => it.id === params.id);
+ if (found) setEditing(toForm(found));
+ }
+ }, [resume, entryApplied, params.new, params.id]);
+
+ const list = resume?.projects ?? [];
+ const header = (
+ (editing === null ? router.back() : setEditing(null))}
+ />
+ );
+
+ if (resume === null) {
+ return (
+
+ {header}
+ {error !== null ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
+
+ /* ===================== 表单态 ===================== */
+
+ if (editing !== null) {
+ const name = editing.name.trim();
+ const canSave = name !== '';
+
+ const update = (patch: Partial) => setEditing((prev) => ({ ...(prev ?? editing), ...patch }));
+
+ const handleSubmit = async () => {
+ if (!canSave || saving) return;
+ const fields = {
+ name,
+ role: editing.role.trim(),
+ description: editing.description.trim(),
+ aiOptimized: false,
+ };
+ const next =
+ editing.id === undefined
+ ? [...list, fields]
+ : list.map((it) => (it.id === editing.id ? { ...fields, id: editing.id } : it));
+ await saveProjects(next);
+ setEditing(null);
+ };
+
+ return (
+
+ {header}
+
+ update({ name: v })}
+ placeholder="请输入项目名称"
+ maxLength={40}
+ autoFocus
+ />
+ update({ role: v })}
+ placeholder="如:前端负责人"
+ maxLength={20}
+ />
+ update({ description: v })}
+ placeholder="做了什么、结果如何(有数字更好,如:首屏从 3.5s 降至 1.2s)"
+ multiline
+ maxLength={500}
+ />
+
+
+
+
+
+
+ );
+ }
+
+ /* ===================== 列表态 ===================== */
+
+ const handleDelete = (item: ResumeProject) => {
+ Alert.alert('删除这条项目经历?', item.name, [
+ { text: '取消', style: 'cancel' },
+ {
+ text: '删除',
+ style: 'destructive',
+ onPress: () => {
+ void saveProjects(list.filter((it) => it.id !== item.id));
+ },
+ },
+ ]);
+ };
+
+ return (
+
+ {header}
+ {list.length === 0 ? (
+
+ ) : (
+
+ {list.map((proj) => (
+
+
+ {proj.name}
+ {proj.role !== '' && {proj.role}}
+ {proj.description !== '' && (
+
+ {proj.description}
+
+ )}
+
+ setEditing(toForm(proj))}
+ />
+ handleDelete(proj)}
+ />
+
+ ))}
+
+ )}
+
+ setEditing(EMPTY_FORM)} />
+
+
+ );
+}
+
+/* ===================== 表单与条目互转 ===================== */
+
+interface ProjForm {
+ /** undefined = 新增项(由 API 层补 id) */
+ id?: string;
+ name: string;
+ role: string;
+ description: string;
+}
+
+const EMPTY_FORM: ProjForm = { name: '', role: '', description: '' };
+
+const toForm = (proj: ResumeProject): ProjForm => ({
+ id: proj.id,
+ name: proj.name,
+ role: proj.role,
+ description: proj.description,
+});
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: colors.pageBg },
+ body: { padding: 20, paddingBottom: 24 },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: colors.white,
+ borderRadius: 14,
+ paddingVertical: 14,
+ paddingLeft: 16,
+ paddingRight: 8,
+ marginBottom: 10,
+ borderWidth: 1,
+ borderColor: colors.borderLight,
+ },
+ rowBody: { flex: 1, gap: 4 },
+ rowTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
+ rowSub: { fontSize: 13, color: colors.textSecondary },
+ rowDesc: { fontSize: 13, color: colors.textHint, lineHeight: 18 },
+});
diff --git a/app/settings.tsx b/app/settings.tsx
index 367edbc..8617cb5 100644
--- a/app/settings.tsx
+++ b/app/settings.tsx
@@ -3,6 +3,8 @@ import { useRouter } from 'expo-router';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../src/theme';
+import { useAppCache } from '../src/hooks';
+import { formatBytes } from '../src/utils';
import { useAuth } from '../src/context/AuthContext';
import { useRole } from '../src/context/RoleContext';
@@ -62,7 +64,9 @@ const SETTINGS_GROUPS: SettingsGroup[] = [
{
title: '通用',
items: [
- { id: 'cache', title: '清除缓存', icon: 'trash-outline', value: '12.8MB' },
+ { id: 'guide', title: '查看引导页', icon: 'compass-outline', route: '/guide' },
+ // 缓存体积是运行时读出来的,不写死:实际值在渲染时从 useAppCache 取
+ { id: 'cache', title: '清除缓存', icon: 'trash-outline' },
{
id: 'about',
title: '关于我们',
@@ -93,6 +97,28 @@ export default function SettingsScreen() {
const router = useRouter();
const { logout } = useAuth();
const { role, setRole } = useRole();
+ const { label: cacheLabel, loading: cacheLoading, clear: clearCache, clearing } = useAppCache();
+
+ /** 清缓存:二次确认后再删,清完按实际清掉的体积给反馈(清不到就说清不到) */
+ const handleClearCache = () => {
+ if (clearing) return;
+ Alert.alert('清除缓存', `将清空本机缓存文件(当前 ${cacheLabel || '读取中'})。账号、已填资料与简历不受影响。`, [
+ { text: '取消', style: 'cancel' },
+ {
+ text: '清除',
+ style: 'destructive',
+ onPress: () => {
+ void clearCache()
+ .then((freed) => {
+ Alert.alert('已清除缓存', freed > 0 ? `本次清理 ${formatBytes(freed)}` : '没有可清理的缓存文件');
+ })
+ .catch((e: unknown) => {
+ Alert.alert('清除失败', e instanceof Error ? e.message : '请稍后重试');
+ });
+ },
+ },
+ ]);
+ };
const handleLogout = () => {
Alert.alert('确认退出登录', '退出后需要重新登录', [
@@ -123,6 +149,10 @@ export default function SettingsScreen() {
handleIdentitySwitch();
return;
}
+ if (item.id === 'cache') {
+ handleClearCache();
+ return;
+ }
if (item.route) {
router.push(item.route);
return;
@@ -155,7 +185,14 @@ export default function SettingsScreen() {
{item.title}
- {item.value ? {item.value} : null}
+ {/* 缓存行显示运行时读到的体积,读不到就显示占位「--」而不是假数字 */}
+ {item.id === 'cache' ? (
+
+ {cacheLoading ? '计算中' : cacheLabel || '--'}
+
+ ) : item.value ? (
+ {item.value}
+ ) : null}
);
diff --git a/app/tools/salary-calculator.tsx b/app/tools/salary-calculator.tsx
index cef52e4..73920b8 100644
--- a/app/tools/salary-calculator.tsx
+++ b/app/tools/salary-calculator.tsx
@@ -13,7 +13,7 @@ import {
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { useSalaryCalculator, useSalaryRecords } from '../../src/hooks';
+import { useSalaryCalculator, useSalaryRecords, useSalaryToolLinks } from '../../src/hooks';
import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
import { colors, headerGradientShort } from '../../src/theme';
import {
@@ -221,8 +221,12 @@ function StructureCard({ result }: { result: import('../../src/types').SalaryCal
/* ===================== 子组件:更多工具 ===================== */
function ToolLink({ tool }: { tool: SalaryToolLink }) {
- return (
-
+ const router = useRouter();
+ // 目标路由由接口层给出;没有 route 的只做展示,不带箭头
+ const route = tool.route;
+
+ const body = (
+ <>
@@ -230,7 +234,16 @@ function ToolLink({ tool }: { tool: SalaryToolLink }) {
{tool.title}
{tool.desc}
-
+ {route ? : null}
+ >
+ );
+
+ if (!route) {
+ return {body};
+ }
+ return (
+ router.push(route as never)}>
+ {body}
);
}
@@ -241,6 +254,7 @@ export default function SalaryCalculatorScreen() {
const router = useRouter();
const { inputs, setInputs, result } = useSalaryCalculator();
const records = useSalaryRecords();
+ const { tools } = useSalaryToolLinks();
const [cityPickerVisible, setCityPickerVisible] = useState(false);
const [historyVisible, setHistoryVisible] = useState(false);
const [saveSheetVisible, setSaveSheetVisible] = useState(false);
@@ -352,32 +366,10 @@ export default function SalaryCalculatorScreen() {
{/* 薪资结构 */}
- {/* 更多工具 */}
+ {/* 更多工具:清单来自接口层,均为项目内真实页面 */}
更多工具
- {[
- {
- id: 'year-end',
- title: '年终奖个税计算',
- desc: '单独计税 / 并入综合所得对比',
- icon: 'gift-outline',
- route: '/tools/salary-calculator',
- },
- {
- id: 'severance',
- title: '离职补偿金计算',
- desc: 'N+1 补偿金与个税估算',
- icon: 'cash-outline',
- route: '/tools/salary-calculator',
- },
- {
- id: 'proof',
- title: '公积金/社保证明',
- desc: '一键查询缴纳记录',
- icon: 'document-text-outline',
- route: '/tools/salary-calculator',
- },
- ].map((tool) => (
+ {tools.map((tool) => (
))}
diff --git a/app/tools/skill-cloud.tsx b/app/tools/skill-cloud.tsx
index 65fa168..069c590 100644
--- a/app/tools/skill-cloud.tsx
+++ b/app/tools/skill-cloud.tsx
@@ -3,17 +3,19 @@ import { useRouter } from 'expo-router';
import { useState } from 'react';
import {
ActivityIndicator,
+ Modal,
Pressable,
ScrollView,
StyleSheet,
Text,
+ TextInput,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { IndustrySkillWordCloud } from '../../components/IndustrySkillWordCloud';
-import { useSkillCloud } from '../../src/hooks';
+import { useSkillCloud, useSkillLearningPath } from '../../src/hooks';
import { colors } from '../../src/theme';
-import type { SkillWord, TopSkill } from '../../src/types';
+import type { SkillLearningPath, SkillWord, TopSkill } from '../../src/types';
/** 技能分组 → 中文标签(用于选中详情) */
const GROUP_LABEL: Record = {
@@ -25,20 +27,64 @@ const GROUP_LABEL: Record = {
/* ===================== 子组件:顶部导航 ===================== */
-function Header({ onBack, onSearch }: { onBack: () => void; onSearch: () => void }) {
+function Header({
+ onBack,
+ onToggleSearch,
+ searching,
+}: {
+ onBack: () => void;
+ onToggleSearch: () => void;
+ searching: boolean;
+}) {
return (
行业技能词云
-
-
+
+
);
}
+/* ===================== 子组件:搜索栏 ===================== */
+
+function SearchBar({
+ value,
+ onChange,
+ onClear,
+}: {
+ value: string;
+ onChange: (text: string) => void;
+ onClear: () => void;
+}) {
+ return (
+
+
+
+ {value !== '' && (
+
+
+
+ )}
+
+ );
+}
+
/* ===================== 子组件:行业标签 ===================== */
function IndustryTabs({
@@ -159,7 +205,15 @@ function TopSkillList({ skills }: { skills: TopSkill[] }) {
/* ===================== 子组件:技能匹配度 ===================== */
-function MatchCard({ percent }: { percent: number }) {
+function MatchCard({
+ percent,
+ onGenerate,
+ generating,
+}: {
+ percent: number;
+ onGenerate: () => void;
+ generating: boolean;
+}) {
return (
@@ -169,14 +223,110 @@ function MatchCard({ percent }: { percent: number }) {
-
-
- 生成技能学习路径
+
+ {generating ? (
+
+ ) : (
+
+ )}
+
+ {generating ? '正在生成…' : '生成技能学习路径'}
+
);
}
+/* ===================== 子组件:学习路径弹窗 ===================== */
+
+function LearningPathModal({
+ visible,
+ path,
+ loading,
+ error,
+ onRetry,
+ onClose,
+}: {
+ visible: boolean;
+ path: SkillLearningPath | null;
+ loading: boolean;
+ error: Error | null;
+ onRetry: () => void;
+ onClose: () => void;
+}) {
+ return (
+
+
+
+
+ 技能学习路径
+
+
+
+
+
+ {loading ? (
+
+
+ 正在按行业热度排优先级…
+
+ ) : error ? (
+
+ {error.message}
+
+ 点击重试
+
+
+ ) : path ? (
+
+
+
+ {path.industry} · {path.position}
+
+
+ 当前匹配度 {path.matchPercent}% → 补齐后约 {path.targetPercent}%
+
+
+ 按行业热度权重从高到低排,先补权重最高的 {path.items.length} 项
+
+
+
+ {path.items.map((item) => (
+
+
+ {item.priority}
+
+
+
+ {item.name}
+
+ {GROUP_LABEL[item.group]}
+
+
+ {item.reason}
+
+
+
+
+ {item.weight}
+
+
+
+ ))}
+
+ {path.generatedAt}
+
+ ) : null}
+
+
+
+ );
+}
+
/* ===================== 子组件:选中技能详情 ===================== */
function SelectedDetail({ word }: { word: SkillWord }) {
@@ -235,15 +385,45 @@ function Empty() {
export default function SkillCloudScreen() {
const router = useRouter();
- const { industry, setIndustry, cloud, loading, error, refresh } = useSkillCloud();
+ const {
+ industry,
+ setIndustry,
+ cloud,
+ keyword,
+ setKeyword,
+ words,
+ isEmpty,
+ loading,
+ error,
+ refresh,
+ } = useSkillCloud();
const [selectedId, setSelectedId] = useState(null);
const [activeCategoryId, setActiveCategoryId] = useState('all');
+ const [searching, setSearching] = useState(false);
+ const [pathVisible, setPathVisible] = useState(false);
+ const { path, loading: generating, error: pathError, generate, reset } = useSkillLearningPath();
- const selectedWord = cloud?.words.find((w) => w.id === selectedId) ?? null;
+ const selectedWord = words.find((w) => w.id === selectedId) ?? null;
+
+ const handleGenerate = async () => {
+ // 先开弹窗再请求,弹窗自己有 loading / 失败重试三态,免得点完按钮没反应
+ setPathVisible(true);
+ await generate(industry);
+ };
+
+ const closePathModal = () => {
+ setPathVisible(false);
+ reset();
+ };
+
+ const toggleSearch = () => {
+ if (searching) setKeyword('');
+ setSearching((prev) => !prev);
+ };
return (
- router.back()} onSearch={() => {}} />
+ router.back()} onToggleSearch={toggleSearch} searching={searching} />
{loading ? (
@@ -253,6 +433,9 @@ export default function SkillCloudScreen() {
) : (
<>
+ {searching && (
+ setKeyword('')} />
+ )}
- {cloud.industry} · 高频技能
-
- setSelectedId(w.id)}
- />
-
+
+ {cloud.industry} · {keyword.trim() === '' ? '高频技能' : `含「${keyword.trim()}」的技能`}
+
+
+ {isEmpty ? (
+
+
+ 没有匹配「{keyword.trim()}」的技能
+ setKeyword('')} hitSlop={6}>
+ 清空关键词
+
+
+ ) : (
+
+ setSelectedId(w.id)}
+ />
+
+ )}
-
+
{cloud.updatedAt}
>
)}
+
+ void generate(industry)}
+ onClose={closePathModal}
+ />
);
}
@@ -438,6 +647,82 @@ const styles = StyleSheet.create({
paddingVertical: 12,
},
matchBtnText: { fontSize: 14, fontWeight: '700', color: colors.white, marginLeft: 6 },
+ matchBtnDisabled: { backgroundColor: colors.textDisabled },
+ searchBar: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ backgroundColor: colors.white,
+ paddingHorizontal: 16,
+ paddingBottom: 10,
+ },
+ searchInput: {
+ flex: 1,
+ backgroundColor: colors.pageBg,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: 8,
+ fontSize: 14,
+ color: colors.textPrimary,
+ },
+ searchEmpty: {
+ alignItems: 'center',
+ gap: 10,
+ paddingVertical: 32,
+ backgroundColor: colors.white,
+ borderRadius: 14,
+ borderWidth: 1,
+ borderColor: colors.borderLight,
+ },
+ modalMask: { flex: 1, backgroundColor: colors.scrim, justifyContent: 'flex-end' },
+ modalSheet: {
+ backgroundColor: colors.white,
+ borderTopLeftRadius: 18,
+ borderTopRightRadius: 18,
+ maxHeight: '80%',
+ paddingBottom: 20,
+ },
+ modalHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderLight,
+ },
+ modalTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
+ modalCenter: { alignItems: 'center', justifyContent: 'center', gap: 12, paddingVertical: 48 },
+ modalBody: { paddingHorizontal: 16, paddingBottom: 12 },
+ pathSummary: {
+ backgroundColor: colors.primaryLight,
+ borderRadius: 12,
+ padding: 14,
+ marginTop: 14,
+ marginBottom: 4,
+ },
+ pathSummaryText: { fontSize: 14, fontWeight: '700', color: colors.primaryText },
+ pathSummaryGap: { marginTop: 6, fontSize: 13, color: colors.primaryText },
+ pathSummaryHint: { marginTop: 6, fontSize: 12, color: colors.textSecondary },
+ pathItem: {
+ flexDirection: 'row',
+ paddingVertical: 14,
+ borderBottomWidth: 1,
+ borderBottomColor: colors.borderLight,
+ },
+ pathPriority: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: colors.primary,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ pathPriorityText: { fontSize: 12, fontWeight: '800', color: colors.white },
+ pathItemBody: { flex: 1, marginLeft: 12 },
+ pathItemHead: { flexDirection: 'row', alignItems: 'center', gap: 8 },
+ pathItemName: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
+ pathItemReason: { marginTop: 6, fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
detailCard: {
marginTop: 12,
backgroundColor: colors.white,
diff --git a/components/onboarding/assets.ts b/components/onboarding/assets.ts
index 83781d8..89928d3 100644
--- a/components/onboarding/assets.ts
+++ b/components/onboarding/assets.ts
@@ -18,7 +18,19 @@ export type AssetName =
| 'avatarDesigner'
| 'wechat'
| 'businessLicense'
- | 'defaultAvatar';
+ | 'defaultAvatar'
+ // 系统预设头像:5 个独立素材位,各自可换真实图(此前 5 个选项共用 defaultAvatar,
+ // 选哪个都一样,也没有区分「选了第几个」的依据)
+ | 'avatarPreset1'
+ | 'avatarPreset2'
+ | 'avatarPreset3'
+ | 'avatarPreset4'
+ | 'avatarPreset5'
+ // 引导页(/guide)四屏插图:每屏一张,补图只改这几行的 source
+ | 'guideEvaluate'
+ | 'guideWeightDim'
+ | 'guideGameMap'
+ | 'guideLegalAI';
type IconName = ComponentProps['name'];
@@ -27,6 +39,7 @@ export interface AssetDef {
source: ImageSourcePropType | null;
tint: string;
icon: IconName;
+ /** 素材说明,仅供换图时对照:**不渲染到界面上**(曾经把开发备注当正文画给用户看) */
hint: string;
}
@@ -41,8 +54,63 @@ export const ASSET_REGISTRY: Record = {
wechat: { source: null, tint: colors.success, icon: 'logo-wechat', hint: '微信图标' },
businessLicense: { source: null, tint: colors.primaryLight, icon: 'document', hint: '营业执照示例图' },
defaultAvatar: { source: null, tint: colors.borderLight, icon: 'person', hint: '默认头像剪影' },
+ // 换真实图只改这几行的 source;tint/icon 只是占位期用来互相区分的
+ avatarPreset1: { source: null, tint: colors.primaryLight, icon: 'person', hint: '预设头像 1' },
+ avatarPreset2: { source: null, tint: colors.infoLight, icon: 'happy', hint: '预设头像 2' },
+ avatarPreset3: { source: null, tint: colors.successLight, icon: 'woman', hint: '预设头像 3' },
+ avatarPreset4: { source: null, tint: colors.actionLight, icon: 'man', hint: '预设头像 4' },
+ avatarPreset5: { source: null, tint: colors.dangerLight, icon: 'person-circle', hint: '预设头像 5' },
+ /*
+ * 引导页四屏插图。hint 会**显示在页面上**(ImageSlot 的占位标注),
+ * 所以这里写的是「给谁看的、画什么」,补图后标注自动消失。
+ * 建议尺寸 640×480(4:3),深色底以外的浅色背景,与页面白底衔接。
+ */
+ guideEvaluate: {
+ source: null,
+ tint: colors.primaryLight,
+ icon: 'star',
+ hint: '手机屏幕里展示公司评分与员工评价列表',
+ },
+ guideWeightDim: {
+ source: null,
+ tint: colors.infoLight,
+ icon: 'options',
+ hint: '拖动滑块调节评价维度权重,实时算出综合得分',
+ },
+ guideGameMap: {
+ source: null,
+ tint: colors.successLight,
+ icon: 'map',
+ hint: '闯关地图:7 个关卡节点,从「认识自己」走到「签约」',
+ },
+ guideLegalAI: {
+ source: null,
+ tint: colors.actionLight,
+ icon: 'document-text',
+ hint: 'AI 法律助手与劳动合同文书',
+ },
};
+/** 头像设置页可选用的系统预设头像(顺序即展示顺序) */
+export const AVATAR_PRESETS: AssetName[] = [
+ 'avatarPreset1',
+ 'avatarPreset2',
+ 'avatarPreset3',
+ 'avatarPreset4',
+ 'avatarPreset5',
+];
+
+/**
+ * 判断 P21 采集到的头像原始值是不是预设素材 key。
+ *
+ * 该字段是联合值:预设头像存素材 key,拍照 / 相册存本地图片 URI(见 `OnboardingProfile.avatar`)。
+ * 消费方(如「我的」页头像)据此决定按素材渲染还是按 URI 渲染 ——
+ * 这个判断依赖素材注册表,因此放在组件层,API 层只透传原始值。
+ */
+export function isAvatarPreset(value: string): value is AssetName {
+ return (AVATAR_PRESETS as string[]).includes(value);
+}
+
/**
* 滑块拼图验证背景图池(每次刷新随机选一张)
* RN 静态图片仅支持 require 引用,豁免 no-require-imports 规则
diff --git a/components/onboarding/screens/B1Wechat.tsx b/components/onboarding/screens/B1Wechat.tsx
index 64a66d2..20f6af0 100644
--- a/components/onboarding/screens/B1Wechat.tsx
+++ b/components/onboarding/screens/B1Wechat.tsx
@@ -7,10 +7,10 @@ import { Avatar } from '../ui/Avatar';
import { PrimaryButton } from '../ui/PrimaryButton';
export function B1Wechat() {
- const { navigate, back, meta } = useOnboarding();
+ const { navigate, back } = useOnboarding();
return (
-
+
diff --git a/components/onboarding/screens/B2Agreement.tsx b/components/onboarding/screens/B2Agreement.tsx
index c987d93..04b938f 100644
--- a/components/onboarding/screens/B2Agreement.tsx
+++ b/components/onboarding/screens/B2Agreement.tsx
@@ -14,10 +14,10 @@ const PARAGRAPHS = [
];
export function B2Agreement() {
- const { back, meta } = useOnboarding();
+ const { back } = useOnboarding();
return (
-
+
有职用户协议与隐私政策
{PARAGRAPHS.map((p, i) => (
diff --git a/components/onboarding/screens/B3Recruiter.tsx b/components/onboarding/screens/B3Recruiter.tsx
index 8b2b24f..4c8e392 100644
--- a/components/onboarding/screens/B3Recruiter.tsx
+++ b/components/onboarding/screens/B3Recruiter.tsx
@@ -1,21 +1,64 @@
import { useState } from 'react';
-import { StyleSheet, Text, TextInput, View } from 'react-native';
+import { Image, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme';
-import { useOnboarding, useRegisterFinish } from '../../../src/hooks';
+import {
+ useEnterpriseCertification,
+ useImagePick,
+ useOnboarding,
+ useRegisterFinish,
+} from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration';
import { PrimaryButton } from '../ui/PrimaryButton';
export function B3Recruiter() {
- const { data, setData, back, meta } = useOnboarding();
+ const { data, setData, back } = useOnboarding();
const finish = useRegisterFinish();
- const [submitted, setSubmitted] = useState(false);
+ const { pick, picking, error: pickError } = useImagePick();
+ const { submit, submitting } = useEnterpriseCertification();
+ const [submitFailed, setSubmitFailed] = useState(false);
- const canSubmit = data.recruiterCompany.trim().length > 0;
+ // 三项都是认证必需,缺一项不放行:没有营业执照的「企业认证」提交不出有意义的结果
+ const canSubmit =
+ data.recruiterCompany.trim().length > 0 &&
+ data.recruiterContact.trim().length > 0 &&
+ data.recruiterLicenseUri !== null;
+
+ const pickLicense = async () => {
+ // 营业执照是文档,方形裁剪会把内容切掉,用 4:3
+ const result = await pick('library', { aspect: [4, 3] });
+ if (result.status === 'picked') setData({ recruiterLicenseUri: result.uri });
+ };
+
+ const handleSubmit = async () => {
+ if (!canSubmit || submitting) return;
+ setSubmitFailed(false);
+ try {
+ // 提交审核状态由接口返回驱动,不再用本地 useState 假装「已提交」
+ const res = await submit({
+ companyName: data.recruiterCompany.trim(),
+ // B3 不采集统一社会信用代码,由企业端「企业认证」页补全(该页已存在且有入口)
+ creditCode: '',
+ licenseUrl: data.recruiterLicenseUri ?? undefined,
+ contactName: data.recruiterContact.trim(),
+ // 联系人手机号用注册时登录的手机号,避免再问一次
+ contactPhone: data.phone,
+ });
+ if (!res.success) {
+ setSubmitFailed(true);
+ return;
+ }
+ // 落地前 useRegisterFinish 会 flushSave,把认证资料一起落库
+ await finish('enterprise');
+ } catch {
+ // 真实后端就绪后这里会接到网络异常
+ setSubmitFailed(true);
+ }
+ };
return (
-
- 请完成企业认证,开启招聘之旅(与求职者主线分开演示)
+
+ 请完成企业认证,开启招聘之旅
公司名称
营业执照
-
-
- 点击上传营业执照
-
+ void pickLicense()}
+ >
+ {data.recruiterLicenseUri ? (
+
+ ) : (
+
+ )}
+
+ {data.recruiterLicenseUri ? '已选择,点击可重新选择' : '点击上传营业执照'}
+
+
+
+ {pickError ? {pickError} : null}
联系人
setData({ school: t })}
+ value={data.recruiterContact}
+ onChangeText={(t) => setData({ recruiterContact: t })}
placeholder="请输入联系人姓名"
placeholderTextColor={colors.textHint}
/>
+ {submitFailed ? 认证提交失败,请稍后重试 : null}
+
{
- setSubmitted(true);
- finish('enterprise');
- }}
+ label="提交认证"
+ active={canSubmit && !submitting}
+ loading={submitting}
+ onPress={() => void handleSubmit()}
/>
@@ -79,6 +134,9 @@ const styles = StyleSheet.create({
borderColor: colors.borderStrong,
backgroundColor: colors.white,
},
+ dim: { opacity: 0.5 },
+ licensePreview: { width: 160, height: 120, borderRadius: 12 },
uploadText: { fontSize: 13, color: colors.textHint },
+ error: { fontSize: 12, color: colors.dangerText, marginTop: 10 },
footer: { marginTop: 28 },
});
diff --git a/components/onboarding/screens/P10FirstWork.tsx b/components/onboarding/screens/P10FirstWork.tsx
index ccda465..4e79faa 100644
--- a/components/onboarding/screens/P10FirstWork.tsx
+++ b/components/onboarding/screens/P10FirstWork.tsx
@@ -7,10 +7,13 @@ import { YearMonthWheel } from '../ui/WheelPicker';
import { PrimaryButton } from '../ui/PrimaryButton';
export function P10FirstWork() {
- const { data, setData, navigate, back, meta } = useOnboarding();
+ const { data, setData, navigate, back } = useOnboarding();
+
+ // 未滑动滚轮时 state 仍是空串,此时放行会把空年份写进资料
+ const canNext = data.firstWorkYear !== '' && data.firstWorkMonth !== '';
return (
-
+
@@ -24,7 +27,7 @@ export function P10FirstWork() {
/>
- navigate('P11')} />
+ navigate('P11')} />
);
diff --git a/components/onboarding/screens/P11CurrentPosition.tsx b/components/onboarding/screens/P11CurrentPosition.tsx
index 3fb1c45..e023a40 100644
--- a/components/onboarding/screens/P11CurrentPosition.tsx
+++ b/components/onboarding/screens/P11CurrentPosition.tsx
@@ -16,9 +16,19 @@ const POS: Record = {
};
const BLOCKS = ['客服', '内容运营', '电商运营', '用户运营'];
+/** 全部分类的职位去重展开,供搜索跨分类匹配 */
+const ALL_POS: string[] = Array.from(new Set(Object.values(POS).flat()));
+
export function P11CurrentPosition() {
- const { data, setData, navigate, back, meta } = useOnboarding();
+ const { data, setData, navigate, back } = useOnboarding();
const [cat, setCat] = useState('互联网AI');
+ const [query, setQuery] = useState('');
+
+ const keyword = query.trim();
+ const searching = keyword !== '';
+ // 搜索态跨分类匹配(与 P6 同一处理):只搜当前分类会让「搜不到」看起来像没有这个职位
+ const visiblePos = searching ? ALL_POS.filter((p) => p.includes(keyword)) : POS[cat];
+ const visibleBlocks = searching ? BLOCKS.filter((b) => b.includes(keyword)) : BLOCKS;
const choose = (p: string) => {
setData({ currentPosition: p });
@@ -26,13 +36,21 @@ export function P11CurrentPosition() {
};
return (
-
- {}} placeholder="搜索职位" />
+
+ setQuery('')} />
{CATS.map((c) => (
- setCat(c)}>
+ {
+ // 搜索态下切分类不改变右侧内容,清掉关键词保证动作有可见结果
+ setQuery('');
+ setCat(c);
+ }}
+ >
{c}
))}
@@ -40,20 +58,28 @@ export function P11CurrentPosition() {
猜你做过
-
- {POS[cat].map((p) => (
- choose(p)} />
- ))}
-
+ {visiblePos.length > 0 ? (
+
+ {visiblePos.map((p) => (
+ choose(p)} />
+ ))}
+
+ ) : (
+ 没有匹配「{keyword}」的职位
+ )}
或者从这些板块选
-
- {BLOCKS.map((b) => (
- choose(b)} />
- ))}
-
+ {visibleBlocks.length > 0 ? (
+
+ {visibleBlocks.map((b) => (
+ choose(b)} />
+ ))}
+
+ ) : (
+ 没有匹配「{keyword}」的板块
+ )}
);
}
@@ -67,5 +93,6 @@ const styles = StyleSheet.create({
catTextActive: { color: colors.primaryText, fontWeight: '700' },
col: { flex: 1 },
colTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 },
+ empty: { fontSize: 13, color: colors.textHint, lineHeight: 20 },
blockTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginVertical: 16 },
});
diff --git a/components/onboarding/screens/P12Company.tsx b/components/onboarding/screens/P12Company.tsx
index f9ad5f8..1187784 100644
--- a/components/onboarding/screens/P12Company.tsx
+++ b/components/onboarding/screens/P12Company.tsx
@@ -6,11 +6,11 @@ import { SearchInput } from '../ui/SearchInput';
import { PrimaryButton } from '../ui/PrimaryButton';
export function P12Company() {
- const { data, setData, navigate, back, meta } = useOnboarding();
+ const { data, setData, navigate, back } = useOnboarding();
const canNext = data.company.trim().length > 0;
return (
-
+
setData({ company: t })}
diff --git a/components/onboarding/screens/P13JoinDate.tsx b/components/onboarding/screens/P13JoinDate.tsx
index e997e32..58a8ae5 100644
--- a/components/onboarding/screens/P13JoinDate.tsx
+++ b/components/onboarding/screens/P13JoinDate.tsx
@@ -7,24 +7,32 @@ import { YearMonthWheel } from '../ui/WheelPicker';
import { PrimaryButton } from '../ui/PrimaryButton';
export function P13JoinDate() {
- const { data, setData, navigate, back, meta } = useOnboarding();
+ const { data, setData, navigate, back } = useOnboarding();
+
+ // 未滑动滚轮时 state 仍是空串,此时放行会把空年月写进资料
+ const canNext = data.joinYear !== '' && data.joinMonth !== '';
return (
-
+
- 在什么时候入职 {data.company}
+ {/* 公司名来自上一步,理论上不会为空;兜底避免拼出「在什么时候入职 」这样的半句话 */}
+
+ {data.company ? `在什么时候入职 ${data.company}` : '请选择入职年月'}
+
setData({ joinYear: v })}
onMonthChange={(v) => setData({ joinMonth: v })}
+ // 入职时间可能在明年,年份要往后留出空间(出生年月则不需要)
+ futureYears={5}
/>
- navigate('P14')} />
+ navigate('P14')} />
);
diff --git a/components/onboarding/screens/P14Skills.tsx b/components/onboarding/screens/P14Skills.tsx
index dfbddff..dd38265 100644
--- a/components/onboarding/screens/P14Skills.tsx
+++ b/components/onboarding/screens/P14Skills.tsx
@@ -18,7 +18,7 @@ const GROUPS = [
];
export function P14Skills() {
- const { data, setData, navigate, back, meta } = useOnboarding();
+ const { data, setData, navigate, back } = useOnboarding();
const skills = data.skills;
const full = skills.length >= MAX;
@@ -31,7 +31,7 @@ export function P14Skills() {
};
return (
-
+
技能选择
0 && styles.counterActive]}>
diff --git a/components/onboarding/screens/P16WorkContent.tsx b/components/onboarding/screens/P16WorkContent.tsx
index 12e2129..5e95b36 100644
--- a/components/onboarding/screens/P16WorkContent.tsx
+++ b/components/onboarding/screens/P16WorkContent.tsx
@@ -2,23 +2,23 @@ import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme';
import { useOnboarding, useRegisterFinish } from '../../../src/hooks';
-import type { AppRole } from '../../../src/types';
import { ScreenShell } from '../ui/ScreenShell';
import { PrimaryButton } from '../ui/PrimaryButton';
export function P16WorkContent() {
- const { data, setData, navigate, back, meta } = useOnboarding();
+ const { data, setData, navigate, back } = useOnboarding();
const finish = useRegisterFinish();
- const targetRole: AppRole = data.identity === 'recruiter' ? 'enterprise' : 'candidate';
const text = data.workContent;
const canNext = text.trim().length >= 5;
return (
-
+
工作内容介绍
- finish(targetRole)}>
- 保存
+ {/* 快速通道:与 P6 同一语义(填完当前步即可落地),文案据实写「跳过」。
+ 企业端只走 P3 → B3,到不了本页,故落地身份固定为求职者 */}
+ void finish('candidate')} hitSlop={6}>
+ 跳过,直接进入
diff --git a/components/onboarding/screens/P17Reference.tsx b/components/onboarding/screens/P17Reference.tsx
index 57f8c68..ccfa27c 100644
--- a/components/onboarding/screens/P17Reference.tsx
+++ b/components/onboarding/screens/P17Reference.tsx
@@ -35,7 +35,7 @@ const SAMPLES = [
];
export function P17Reference() {
- const { setData, back, navigate, meta } = useOnboarding();
+ const { setData, back, navigate } = useOnboarding();
const [idx, setIdx] = useState(0);
const sample = SAMPLES[idx];
@@ -45,7 +45,7 @@ export function P17Reference() {
};
return (
-
+
diff --git a/components/onboarding/screens/P18Education.tsx b/components/onboarding/screens/P18Education.tsx
index 80e0e03..33775e5 100644
--- a/components/onboarding/screens/P18Education.tsx
+++ b/components/onboarding/screens/P18Education.tsx
@@ -10,11 +10,12 @@ import { PrimaryButton } from '../ui/PrimaryButton';
const LEVELS = ['初中及以下', '中专中技', '高中', '大专', '本科', '硕士', '博士'];
export function P18Education() {
- const { data, setData, navigate, back, meta } = useOnboarding();
- const canNext = data.education !== null;
+ const { data, setData, navigate, back } = useOnboarding();
+ // 「类型」同为本页采集项,选了学历却没选全日制/非全日制同样算半截记录
+ const canNext = data.education !== null && data.eduType !== null;
return (
-
+
diff --git a/components/onboarding/screens/P1Login.tsx b/components/onboarding/screens/P1Login.tsx
index fd7c61f..5a5074d 100644
--- a/components/onboarding/screens/P1Login.tsx
+++ b/components/onboarding/screens/P1Login.tsx
@@ -8,6 +8,7 @@ import { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { AgreementModal } from '../ui/AgreementModal';
import { PrimaryButton } from '../ui/PrimaryButton';
+import { SupportFooter } from '../ui/SupportFooter';
/**
* P1 登录主页:淡渐变顶部 + 居中 Logo + 三种登录方式。
@@ -63,9 +64,7 @@ export function P1Login() {
- 服务热线
- 举报监督电话
- 资质证照
+
@@ -98,12 +97,5 @@ const styles = StyleSheet.create({
agree: { marginTop: 22 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' },
- footer: {
- flexDirection: 'row',
- justifyContent: 'center',
- gap: 18,
- paddingTop: 24,
- paddingBottom: 8,
- },
- footItem: { fontSize: 12, color: colors.textHint },
+ footer: { paddingTop: 24, paddingBottom: 8 },
});
diff --git a/components/onboarding/screens/P20School.tsx b/components/onboarding/screens/P20School.tsx
index b54ffe0..a4b9d9d 100644
--- a/components/onboarding/screens/P20School.tsx
+++ b/components/onboarding/screens/P20School.tsx
@@ -5,11 +5,11 @@ import { SearchInput } from '../ui/SearchInput';
import { PrimaryButton } from '../ui/PrimaryButton';
export function P20School() {
- const { data, setData, navigate, back, meta } = useOnboarding();
+ const { data, setData, navigate, back } = useOnboarding();
const canNext = data.school.trim().length > 0;
return (
-
+
setData({ school: t })}
diff --git a/components/onboarding/screens/P21Avatar.tsx b/components/onboarding/screens/P21Avatar.tsx
index 593c338..bc14912 100644
--- a/components/onboarding/screens/P21Avatar.tsx
+++ b/components/onboarding/screens/P21Avatar.tsx
@@ -1,66 +1,78 @@
-import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme';
-import { useOnboarding } from '../../../src/hooks';
+import { useImagePick, useOnboarding } from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell';
import { Avatar } from '../ui/Avatar';
import { PrimaryButton } from '../ui/PrimaryButton';
-
-const PRESETS = [0, 1, 2, 3, 4];
+import { AVATAR_PRESETS } from '../assets';
export function P21Avatar() {
- const { setData, navigate, back, meta } = useOnboarding();
- const [mode, setMode] = useState<'none' | 'camera' | 'album' | 'preset'>('none');
- const [picked, setPicked] = useState(-1);
+ const { data, setData, navigate, back } = useOnboarding();
+ const { pick, picking, error } = useImagePick();
- const chooseCamera = () => {
- setMode('camera');
- setData({ avatar: 'avatarDesigner' });
- };
- const chooseAlbum = () => {
- setMode('album');
- setData({ avatar: 'avatarDesigner' });
- };
- const choosePreset = (i: number) => {
- setMode('preset');
- setPicked(i);
- setData({ avatar: 'defaultAvatar' });
- };
+ // 头像只有一个真相 `data.avatar`:命中预设素材就是预设名,否则是拍照/相册的本地 URI
+ const presetName = AVATAR_PRESETS.find((name) => name === data.avatar) ?? null;
+ const avatarUri = data.avatar !== null && presetName === null ? data.avatar : null;
- const previewName = mode === 'camera' || mode === 'album' ? 'avatarDesigner' : 'defaultAvatar';
+ const pickFrom = async (source: 'camera' | 'library') => {
+ const result = await pick(source);
+ // 取消或未授权时保持原头像不动,不写入空值
+ if (result.status === 'picked') setData({ avatar: result.uri });
+ };
return (
-
+
-
+
-
+ {/* picking 期间禁用:否则系统权限弹窗还没出现就能连点出第二个 */}
+ void pickFrom('camera')}
+ >
拍照上传
-
+ void pickFrom('library')}
+ >
从相册选择
+ {error ? {error} : null}
+
系统默认头像
- {PRESETS.map((i) => (
+ {AVATAR_PRESETS.map((name) => (
choosePreset(i)}
+ selected={data.avatar === name}
+ onPress={() => setData({ avatar: name })}
/>
))}
- navigate('P22')} />
+ navigate('P22')}
+ />
);
@@ -70,7 +82,9 @@ const styles = StyleSheet.create({
previewWrap: { alignItems: 'center', marginVertical: 24 },
actions: { flexDirection: 'row', justifyContent: 'center', gap: 28, marginBottom: 28 },
actionBtn: { alignItems: 'center', gap: 6 },
+ actionBtnDim: { opacity: 0.5 },
actionText: { fontSize: 12, color: colors.textSecondary },
+ error: { fontSize: 12, color: colors.dangerText, textAlign: 'center', marginBottom: 18 },
subTitle: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginBottom: 14 },
presets: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 },
footer: { marginTop: 32 },
diff --git a/components/onboarding/screens/P22Done.tsx b/components/onboarding/screens/P22Done.tsx
index c6cfbea..e50d848 100644
--- a/components/onboarding/screens/P22Done.tsx
+++ b/components/onboarding/screens/P22Done.tsx
@@ -1,19 +1,16 @@
import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme';
-import { useOnboarding, useRegisterFinish } from '../../../src/hooks';
-import type { AppRole } from '../../../src/types';
+import { useRegisterFinish } from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration';
import { PrimaryButton } from '../ui/PrimaryButton';
export function P22Done() {
- const { data, meta } = useOnboarding();
const finish = useRegisterFinish();
- const targetRole: AppRole = data.identity === 'recruiter' ? 'enterprise' : 'candidate';
return (
-
+
@@ -23,7 +20,8 @@ export function P22Done() {
- finish(targetRole)} />
+ {/* 企业端走 P3 → B3 落地,不会经过本页,故身份固定为求职者 */}
+ void finish('candidate')} />
);
diff --git a/components/onboarding/screens/P23Home.tsx b/components/onboarding/screens/P23Home.tsx
deleted file mode 100644
index 059e752..0000000
--- a/components/onboarding/screens/P23Home.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import { useEffect, useState } from 'react';
-import { Ionicons } from '@expo/vector-icons';
-import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
-import { SafeAreaView } from 'react-native-safe-area-context';
-import { StatusBar } from 'expo-status-bar';
-import { colors } from '../../../src/theme';
-import { Illustration } from '../ui/Illustration';
-import { Modal } from '../ui/Modal';
-
-type Step = 'guide' | 'location' | 'notify' | null;
-
-const JOBS = [
- { company: '巴适伙伴', pos: '前端开发工程师', salary: '12-18K', city: '上海' },
- { company: '星河科技', pos: '鸿蒙开发工程师', salary: '15-22K', city: '上海' },
- { company: '云图网络', pos: '全栈工程师', salary: '13-20K', city: '杭州' },
- { company: '启明软件', pos: '数据分析师', salary: '10-16K', city: '深圳' },
-];
-
-const TABS = [
- { key: 'home', icon: 'home', label: '首页' },
- { key: 'msg', icon: 'chatbubble', label: '消息' },
- { key: 'me', icon: 'person', label: '我的' },
-] as const;
-
-export function P23Home() {
- const [tab, setTab] = useState<(typeof TABS)[number]['key']>('home');
- const [step, setStep] = useState('guide');
-
- useEffect(() => {
- setStep('guide');
- }, []);
-
- return (
-
-
-
-
- 推荐职位
-
- 上海
-
-
-
- {JOBS.map((j) => (
-
-
-
- {j.pos}
- {j.company}
-
-
- {j.salary}
-
- {j.city}
-
-
-
- ))}
-
-
-
- {TABS.map((t) => (
- setTab(t.key)}>
-
- {t.label}
-
- ))}
-
-
- {step === 'guide' && (
-
-
-
- 点击查看职位详情
-
- setStep('location')}>
- 知道了
-
-
- )}
-
- setStep('notify')}
- footer={
-
- setStep('notify')}>
- 暂不
-
- setStep('notify')}>
- 允许
-
-
- }
- >
- 开启定位可为你推荐附近的职位与机会。
-
-
- setStep(null)}
- footer={
-
- setStep(null)}>
- 暂不
-
- setStep(null)}>
- 允许
-
-
- }
- >
- 开启通知可及时接收面试邀请与 Boss 消息。
-
-
- );
-}
-
-const styles = StyleSheet.create({
- screen: { flex: 1, backgroundColor: colors.pageBg },
- topBar: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingHorizontal: 20, paddingVertical: 12, backgroundColor: colors.white },
- topTitle: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, marginRight: 'auto' },
- topCity: { fontSize: 13, color: colors.primary, fontWeight: '600' },
- list: { flex: 1 },
- listContent: { padding: 16, gap: 12 },
- card: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 12,
- backgroundColor: colors.white,
- borderRadius: 14,
- padding: 14,
- borderWidth: 1,
- borderColor: colors.border,
- },
- cardBody: { flex: 1, gap: 4 },
- pos: { fontSize: 15, fontWeight: '700', color: colors.textPrimary },
- company: { fontSize: 12, color: colors.textSecondary },
- cardRight: { alignItems: 'flex-end', gap: 6 },
- salary: { fontSize: 15, fontWeight: '700', color: colors.info },
- cityTag: { backgroundColor: colors.primaryLight, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 8 },
- cityText: { fontSize: 11, color: colors.primaryText },
- tabBar: {
- flexDirection: 'row',
- borderTopWidth: 1,
- borderColor: colors.borderLight,
- backgroundColor: colors.white,
- paddingVertical: 8,
- },
- tab: { flex: 1, alignItems: 'center', gap: 2 },
- tabLabel: { fontSize: 11, color: colors.textHint },
- tabLabelActive: { color: colors.primary, fontWeight: '700' },
- guide: {
- ...StyleSheet.absoluteFill,
- backgroundColor: colors.scrim,
- alignItems: 'center',
- justifyContent: 'flex-start',
- paddingTop: 150,
- },
- guideCard: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: 6,
- backgroundColor: colors.white,
- paddingHorizontal: 16,
- paddingVertical: 10,
- borderRadius: 12,
- },
- guideText: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
- guideBtn: { marginTop: 16, backgroundColor: colors.primary, paddingHorizontal: 28, paddingVertical: 10, borderRadius: 20 },
- guideBtnText: { color: colors.textInverse, fontWeight: '700' },
- modalBtns: { flexDirection: 'row', gap: 12 },
- modalBtn: { flex: 1, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center' },
- modalBtnGhost: { backgroundColor: colors.borderLight },
- modalBtnGhostText: { color: colors.textSecondary, fontWeight: '600' },
- modalBtnPrimary: { backgroundColor: colors.primary },
- modalBtnPrimaryText: { color: colors.textInverse, fontWeight: '700' },
- modalBody: { fontSize: 14, color: colors.textSecondary, lineHeight: 22, textAlign: 'center' },
-});
diff --git a/components/onboarding/screens/P2PhoneCode.tsx b/components/onboarding/screens/P2PhoneCode.tsx
index 4317da7..93f6b32 100644
--- a/components/onboarding/screens/P2PhoneCode.tsx
+++ b/components/onboarding/screens/P2PhoneCode.tsx
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
-import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
+import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme';
+import { SMS_HELP_BODY, SMS_HELP_TITLE } from '../../../src/constants';
import { useAgreementGate, useLogin, useOnboarding, useSmsCode, useWechatBindPhone } from '../../../src/hooks';
import { isValidPhone, SMS_CODE_MAX_LENGTH, SMS_CODE_MIN_LENGTH } from '../../../src/utils';
import { useAuth } from '../../../src/context/AuthContext';
@@ -81,6 +82,9 @@ export function P2PhoneCode() {
/** 掩码手机号:138****1234 */
const maskedPhone = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone;
+ /** 「接收不到短信」:原先是点了没反应的纯文本,改为给出可执行的下一步 */
+ const showSmsHelp = () => Alert.alert(SMS_HELP_TITLE, SMS_HELP_BODY);
+
return (
setStage('phone') : back}>
{stage === 'phone' ? (
@@ -110,7 +114,9 @@ export function P2PhoneCode() {
- 接收不到短信
+
+ 接收不到短信?
+
gate(requestCode)} />
@@ -214,7 +220,9 @@ const styles = StyleSheet.create({
divider: { width: 1, height: 20, backgroundColor: colors.border, marginHorizontal: 10 },
phoneInput: { flex: 1, fontSize: 17, color: colors.textPrimary, padding: 0 },
underline: { height: 1, backgroundColor: colors.borderLight },
- smsHelp: { fontSize: 13, color: colors.textHint, marginTop: 16 },
+ // 已是可点入口(原来是无反馈纯文本),故用可读对比度的 primaryText 而非 textHint
+ smsHelpRow: { alignSelf: 'flex-start', marginTop: 16 },
+ smsHelp: { fontSize: 13, color: colors.primaryText },
btnArea: { marginTop: 28 },
agree: { marginTop: 18 },
formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
diff --git a/components/onboarding/screens/P2QuickLogin.tsx b/components/onboarding/screens/P2QuickLogin.tsx
index 50bc112..92e2dbc 100644
--- a/components/onboarding/screens/P2QuickLogin.tsx
+++ b/components/onboarding/screens/P2QuickLogin.tsx
@@ -1,29 +1,48 @@
-import { ScrollView, StyleSheet, Text, View } from 'react-native';
+import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme';
-import { useAgreementGate, useLogin, useOnboarding } from '../../../src/hooks';
+import { CARRIER_LABEL } from '../../../src/constants';
+import { useAgreementGate, useCarrierPhone, useLogin, useOnboarding } from '../../../src/hooks';
import { useAuth } from '../../../src/context/AuthContext';
import { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { PrimaryButton } from '../ui/PrimaryButton';
+import { SupportFooter } from '../ui/SupportFooter';
import { AgreementModal } from '../ui/AgreementModal';
-/** P2Q 一键登录确认:本机号码认证 —— 掩码手机号 + 一键登录 + 运营商服务声明 */
+/**
+ * P2Q 一键登录确认:本机号码认证。
+ *
+ * 取号(运营商 SDK)与换登录态是两步,本页负责串起来:
+ * 1. 进入页面即取号(useCarrierPhone)→ 展示掩码号码;
+ * 2. 点「一键登录」→ 用取号令牌换正式登录态 → 写登录态 → 把号码写回引导资料 → 进 P3。
+ *
+ * 原先这里读的是 `data.phone`(只有短信登录才会写),运营商通道的号码既没取也没存,
+ * 所以永远显示占位「本机号码」;登录接口返回的 phone 也被直接丢掉。
+ * 现在取号失败时不再假装号码还在:「一键登录」置灰,同时给出「重新获取」与「改用验证码登录」。
+ */
export function P2QuickLogin() {
const { data, setData, navigate, back } = useOnboarding();
const { gate, modalProps } = useAgreementGate();
- const { loginByCarrier, loading, error } = useLogin();
+ const { carrierToken, maskedPhone, carrier, ready, loading: fetching, error: fetchError, refresh } =
+ useCarrierPhone();
+ const { loginByCarrier, loading: logging, error: loginError } = useLogin();
/** 登录态写入:此前本页只跳 P3 不写 token */
const { login: writeAuth } = useAuth();
const agreed = data.agreed;
- const phone = data.phone;
+ const carrierName = CARRIER_LABEL[carrier];
+ /** 取号失败:号码与令牌都没有,本页只能提供重试或改走验证码登录 */
+ const failed = !fetching && !ready;
+ const busy = fetching || logging;
- /** 一键登录:运营商返回本机号码并换取令牌,成功后才进 P3 */
+ /** 一键登录:用取号令牌换登录态,成功后才进 P3 */
const handleLogin = async () => {
try {
- const result = await loginByCarrier();
+ const result = await loginByCarrier(carrierToken);
writeAuth(result.token);
+ // 号码写回引导资料:后续步骤(P2P/P2R 预填、B3 联系人电话)都以这里为准
+ setData({ phone: result.phone });
} catch {
// 失败原因由 useLogin 记录,页面下方展示
return;
@@ -31,8 +50,13 @@ export function P2QuickLogin() {
navigate('P3');
};
- /** 掩码:150****9367;未输入时用演示号段占位 */
- const masked = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : '150****9367';
+ /** 运营商认证服务条款由运营商在认证页内提供,本仓库没有该文档,不冒充能打开它 */
+ const showCarrierTerms = () => {
+ Alert.alert(
+ `${carrierName}认证服务条款`,
+ `一键登录的认证服务由${carrierName}提供,服务条款以认证页展示的内容为准。`,
+ );
+ };
return (
@@ -41,19 +65,45 @@ export function P2QuickLogin() {
- {masked}
- 首次验证通过即注册有职账号
+ {ready ? (
+ {maskedPhone}
+ ) : (
+
+ {failed ? '未能获取本机号码' : '正在获取本机号码…'}
+
+ )}
+
+ {failed ? '可重新获取,或改用验证码登录' : '首次验证通过即注册有职账号'}
+
gate(handleLogin)}
/>
+
+ {failed ? (
+
+
+ 重新获取
+
+ gate(() => navigate('P2'))}
+ hitSlop={6}
+ >
+ 改用验证码登录
+
+
+ ) : null}
- {error ? {error.message} : null}
+ {loginError ? {loginError.message} : null}
+ {fetchError && !loginError ? (
+ {fetchError.message}
+ ) : null}
已阅读并同意
- 《中国移动认证服务条款》
+
+ 《{carrierName}认证服务条款》
+
、
navigate('B2')}>《有职用户协议》
和
@@ -75,12 +127,8 @@ export function P2QuickLogin() {
-
- 服务热线
- 举报监督电话
- 资质证照
-
- 认证服务由中国移动提供
+
+ 认证服务由{carrierName}提供
@@ -98,14 +146,28 @@ const styles = StyleSheet.create({
color: colors.textPrimary,
marginTop: 40,
},
+ phonePending: {
+ textAlign: 'center',
+ fontSize: 22,
+ fontWeight: '700',
+ color: colors.textHint,
+ marginTop: 44,
+ },
+ phoneFailed: { fontSize: 18, color: colors.dangerText },
subtitle: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 12 },
btnArea: { marginTop: 36 },
+ fallbackRow: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ gap: 24,
+ marginTop: 18,
+ },
+ fallbackBtn: { paddingVertical: 6, paddingHorizontal: 4 },
+ fallbackText: { fontSize: 14, fontWeight: '600', color: colors.primaryText },
formError: { marginTop: 10, textAlign: 'center', fontSize: 12, color: colors.danger },
agree: { marginTop: 20 },
agreeText: { fontSize: 12, color: colors.textSecondary, lineHeight: 18 },
link: { color: colors.primary, fontWeight: '600' },
footer: { paddingTop: 16, paddingBottom: 8 },
- footRow: { flexDirection: 'row', justifyContent: 'center', gap: 18 },
- footItem: { fontSize: 12, color: colors.textHint },
carrierNote: { textAlign: 'center', fontSize: 12, color: colors.textHint, marginTop: 12 },
});
diff --git a/components/onboarding/screens/P2WechatCode.tsx b/components/onboarding/screens/P2WechatCode.tsx
index a0b26a9..10bb1c1 100644
--- a/components/onboarding/screens/P2WechatCode.tsx
+++ b/components/onboarding/screens/P2WechatCode.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
-import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
+import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme';
+import { SMS_HELP_BODY, SMS_HELP_TITLE } from '../../../src/constants';
import { useOnboarding } from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell';
import { PrimaryButton } from '../ui/PrimaryButton';
@@ -13,6 +14,8 @@ export function P2WechatCode() {
const [code, setCode] = useState('');
const [left, setLeft] = useState(RESEND_SECONDS);
const timer = useRef | null>(null);
+ /** 隐藏输入框的引用:点格子区域时把焦点交回它,键盘才会弹出来 */
+ const inputRef = useRef(null);
useEffect(() => {
timer.current = setInterval(() => {
@@ -30,10 +33,17 @@ export function P2WechatCode() {
}, []);
const phone = data.phone;
- /** 尾号:有真实手机号用尾4位,否则演示 9367 */
- const tail = phone.length === 11 ? phone.slice(7) : '9367';
+ /**
+ * 手机尾号(含两侧空格,无尾号时为空串,句子里不留空)。
+ * 原先是 `phone.length === 11 ? phone.slice(7) : '9367'` —— 没有号码时拿编造的
+ * 「9367」当用户的号码展示。
+ */
+ const tail = phone.length === 11 ? ` ${phone.slice(7)} ` : '';
const canNext = code.length >= 4;
+ /** 「接收不到短信」提示,与 P2 用同一份文案常量 */
+ const showSmsHelp = () => Alert.alert(SMS_HELP_TITLE, SMS_HELP_BODY);
+
const resend = () => {
if (left > 0) return;
setLeft(RESEND_SECONDS);
@@ -52,10 +62,10 @@ export function P2WechatCode() {
return (
输入短信验证码
- 已向您的手机 {tail} 发送验证码
+ 已向您的手机{tail}发送验证码
- {/* 4 格分离输入:透明输入层覆盖,格子仅做展示 */}
-
+ {/* 4 格分离输入:透明输入层覆盖,格子仅做展示;点格子把焦点交回输入框 */}
+ inputRef.current?.focus()}>
{[0, 1, 2, 3].map((i) => (
@@ -65,6 +75,7 @@ export function P2WechatCode() {
))}
setCode(t.replace(/[^\d]/g, '').slice(0, 4))}
@@ -84,14 +95,11 @@ export function P2WechatCode() {
navigate('B1')} />
-
-
- 语音接听验证码
-
-
- 接收不到短信
-
-
+ {/* 「语音接听验证码」入口已移除:项目没有语音验证码通道,摆一个点了没反应的入口
+ 等于宣称有这个能力。等接入语音通道再恢复(同 P5 移除「自动定位」的理由) */}
+
+ 接收不到短信?
+
短信验证码正在发送,请注意查收
@@ -116,12 +124,9 @@ const styles = StyleSheet.create({
resend: { textAlign: 'center', fontSize: 14, color: colors.textSecondary, marginTop: 22 },
resendDisabled: { color: colors.textHint },
btnArea: { marginTop: 30 },
- helpRow: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginTop: 22,
- },
- helpText: { fontSize: 13, color: colors.textHint },
+ helpRow: { alignSelf: 'flex-start', marginTop: 22 },
+ // 已是可点入口,用可读对比度的 primaryText(textHint 在 13pt 下不达 AA)
+ helpText: { fontSize: 13, color: colors.primaryText },
toastBar: {
alignSelf: 'center',
marginTop: 30,
diff --git a/components/onboarding/screens/P3Identity.tsx b/components/onboarding/screens/P3Identity.tsx
index 3a9c5ce..c95a904 100644
--- a/components/onboarding/screens/P3Identity.tsx
+++ b/components/onboarding/screens/P3Identity.tsx
@@ -1,11 +1,13 @@
-import { Pressable, StyleSheet, Text, View } from 'react-native';
+import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme';
+import { useAuth } from '../../../src/context/AuthContext';
import { useOnboarding } from '../../../src/hooks';
import { ScreenShell } from '../ui/ScreenShell';
import { Illustration } from '../ui/Illustration';
export function P3Identity() {
- const { setData, navigate, back, reset, meta } = useOnboarding();
+ const { setData, navigate, back, reset } = useOnboarding();
+ const { logout } = useAuth();
const chooseCandidate = () => {
setData({ identity: 'candidate' });
@@ -16,8 +18,27 @@ export function P3Identity() {
navigate('B3');
};
+ /**
+ * 退出登录必须同时清掉令牌与角色。
+ * 此前只调 `reset()`(仅清流程 state 与引导资料),登录态原封不动 ——
+ * 界面回到了登录页,但重进 App 又会被判定为「已登录」而直接放进主界面。
+ */
+ const handleLogout = () => {
+ Alert.alert('确认退出登录', '退出后需要重新登录', [
+ { text: '取消', style: 'cancel' },
+ {
+ text: '退出',
+ style: 'destructive',
+ onPress: () => {
+ logout();
+ reset();
+ },
+ },
+ ]);
+ };
+
return (
-
+
选择你的身份
@@ -40,7 +61,7 @@ export function P3Identity() {
-
+
退出登录
diff --git a/components/onboarding/screens/P4Intent.tsx b/components/onboarding/screens/P4Intent.tsx
index 2cd9d39..7f3e5eb 100644
--- a/components/onboarding/screens/P4Intent.tsx
+++ b/components/onboarding/screens/P4Intent.tsx
@@ -9,7 +9,7 @@ import { PrimaryButton } from '../ui/PrimaryButton';
const HOT = ['Java', '项目经理', '运维', '测试', '前端', '数据分析', '电子', '通信'];
export function P4Intent() {
- const { data, setData, navigate, back, meta } = useOnboarding();
+ const { data, setData, navigate, back } = useOnboarding();
const isStudent = data.isStudent;
const city = data.city;
const positions = data.expectedPositions;
@@ -19,10 +19,12 @@ export function P4Intent() {
expectedPositions: positions.includes(p) ? positions.filter((x) => x !== p) : [...positions, p],
});
- const canNext = isStudent !== null && positions.length > 0;
+ // 工作地点是本页采集项之一(点标签进 P5 选),没选城市就不放行 ——
+ // 岗位推荐的偏好里城市是加分项,空城市会让推荐失去一半依据
+ const canNext = isStudent !== null && positions.length > 0 && city !== '';
return (
-
+
你是学生吗
setData({ isStudent: true })} />
@@ -32,7 +34,11 @@ export function P4Intent() {
工作地点
- navigate('P5')} />
+ navigate('P5')}
+ />
点击切换城市
diff --git a/components/onboarding/screens/P5City.tsx b/components/onboarding/screens/P5City.tsx
index 96f0802..16ec7a6 100644
--- a/components/onboarding/screens/P5City.tsx
+++ b/components/onboarding/screens/P5City.tsx
@@ -11,7 +11,7 @@ const ALL = ['北京', '上海', '广州', '深圳', '杭州', '成都', '南京
const HOT = ['北京', '上海', '广州', '深圳', '杭州', '成都'];
export function P5City() {
- const { data, setData, back, meta } = useOnboarding();
+ const { data, setData, back } = useOnboarding();
const [query, setQuery] = useState('');
const pick = (city: string) => {
@@ -22,14 +22,11 @@ export function P5City() {
const list = query ? ALL.filter((c) => c.includes(query)) : ALL;
return (
-
+
setQuery('')} />
- pick('上海')}>
-
- 自动定位:上海
-
-
+ {/* 原「自动定位:上海」按钮已移除:它不做定位,只是把「上海」写死成选中值。
+ 不做定位就不宣称定位(已决定不引入 expo-location) */}
热门城市
{HOT.map((c) => (
@@ -51,8 +48,6 @@ export function P5City() {
}
const styles = StyleSheet.create({
- locate: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 14, marginTop: 12 },
- locateText: { fontSize: 14, fontWeight: '600', color: colors.primaryText },
section: { fontSize: 14, fontWeight: '700', color: colors.textPrimary, marginTop: 22, marginBottom: 12 },
list: { gap: 2 },
cityItem: {
diff --git a/components/onboarding/screens/P6ExpectedPosition.tsx b/components/onboarding/screens/P6ExpectedPosition.tsx
index ee51713..2ecae4d 100644
--- a/components/onboarding/screens/P6ExpectedPosition.tsx
+++ b/components/onboarding/screens/P6ExpectedPosition.tsx
@@ -3,7 +3,7 @@ import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme';
import { useOnboarding, useRegisterFinish } from '../../../src/hooks';
-import type { AppRole } from '../../../src/types';
+import { onboardingStepLabel } from '../../../src/utils';
import { ScreenShell } from '../ui/ScreenShell';
import { SearchInput } from '../ui/SearchInput';
import { Tag, TagGroup } from '../ui/Tag';
@@ -23,33 +23,45 @@ const POSITIONS: Record = {
医疗健康: ['临床医师', '药剂师', '健康管理'],
};
+/** 全部分类的职位去重展开,供搜索跨分类匹配 */
+const ALL_POSITIONS: string[] = Array.from(new Set(Object.values(POSITIONS).flat()));
+
export function P6ExpectedPosition() {
- const { data, setData, navigate, back, meta } = useOnboarding();
+ const { data, setData, navigate, back } = useOnboarding();
const finish = useRegisterFinish();
- const targetRole: AppRole = data.identity === 'recruiter' ? 'enterprise' : 'candidate';
const [cat, setCat] = useState('互联网AI');
+ const [query, setQuery] = useState('');
const positions = data.expectedPositions;
+ const step = onboardingStepLabel('P6');
+ const keyword = query.trim();
+ // 搜索态跨分类匹配:只搜当前分类时,在「互联网AI」下搜「UI设计师」会得到空结果,
+ // 看起来像平台没有这个职位,实际只是没切分类
+ const visible = keyword ? ALL_POSITIONS.filter((p) => p.includes(keyword)) : POSITIONS[cat];
+
const toggle = (p: string) =>
setData({
expectedPositions: positions.includes(p) ? positions.filter((x) => x !== p) : [...positions, p],
});
return (
-
+
期望职位
-
-
- 3/3
-
- finish(targetRole)}>
- 保存
-
-
+ {/* 快速通道:填完意向即可落地,剩余资料后续再补。文案据实写「跳过」,
+ 不再叫「保存」——它并不保存,而是直接结束注册流程 */}
+ void finish('candidate')} hitSlop={6}>
+ 跳过,直接进入
+
- {}} placeholder="搜索职位" />
+ {step ? (
+
+ {step}
+
+ ) : null}
+
+ setQuery('')} />
@@ -57,7 +69,12 @@ export function P6ExpectedPosition() {
setCat(c)}
+ onPress={() => {
+ // 搜索态下分类不参与过滤,只切分类看不出任何变化 —— 一并清掉关键词,
+ // 让「点分类」这个动作始终有可见结果
+ setQuery('');
+ setCat(c);
+ }}
>
{c}
@@ -77,11 +94,15 @@ export function P6ExpectedPosition() {
))}
)}
-
- {POSITIONS[cat].map((p) => (
- toggle(p)} />
- ))}
-
+ {visible.length > 0 ? (
+
+ {visible.map((p) => (
+ toggle(p)} />
+ ))}
+
+ ) : (
+ 没有匹配「{keyword}」的职位,换个关键词试试
+ )}
@@ -93,12 +114,19 @@ export function P6ExpectedPosition() {
}
const styles = StyleSheet.create({
- header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 },
+ header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 },
title: { fontSize: 20, fontWeight: '800', color: colors.textPrimary },
- headerRight: { flexDirection: 'row', alignItems: 'center', gap: 14 },
- progress: { backgroundColor: colors.primaryLight, paddingHorizontal: 10, paddingVertical: 3, borderRadius: 10 },
+ progress: {
+ alignSelf: 'flex-start',
+ backgroundColor: colors.primaryLight,
+ paddingHorizontal: 10,
+ paddingVertical: 3,
+ borderRadius: 10,
+ marginBottom: 12,
+ },
progressText: { fontSize: 12, fontWeight: '700', color: colors.primaryText },
save: { fontSize: 15, fontWeight: '700', color: colors.primary },
+ empty: { fontSize: 13, color: colors.textHint, lineHeight: 20 },
body: { flexDirection: 'row', gap: 14, marginTop: 8 },
cats: { width: 92 },
catItem: { paddingVertical: 12, borderRadius: 10 },
diff --git a/components/onboarding/screens/P7Salary.tsx b/components/onboarding/screens/P7Salary.tsx
index 6b83a30..e95d669 100644
--- a/components/onboarding/screens/P7Salary.tsx
+++ b/components/onboarding/screens/P7Salary.tsx
@@ -1,27 +1,31 @@
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks';
+import { applySalaryMin, salaryMaxOptions } from '../../../src/api';
+import { SALARY_MIN_OPTIONS } from '../../../src/constants';
import { ScreenShell } from '../ui/ScreenShell';
import { PrimaryButton } from '../ui/PrimaryButton';
-const MIN = ['9K', '10K', '11K'];
-const MAX = ['11K', '12K', '13K'];
-
export function P7Salary() {
- const { data, setData, navigate, back, meta } = useOnboarding();
+ const { data, setData, navigate, back } = useOnboarding();
+
+ // 「最高」档位随「最低」收窄(规则在 API 层),改最低时同步校正最高,
+ // 避免存出「最低 30K / 最高 15K」这种自相矛盾的期望薪资
+ const maxOptions = salaryMaxOptions(data.expectedSalaryMin);
+ const canNext = data.expectedSalaryMin !== '' && data.expectedSalaryMax !== '';
return (
-
+
1K 代表 1000 元
最低
- {MIN.map((v) => (
+ {SALARY_MIN_OPTIONS.map((v) => (
setData({ expectedSalaryMin: v })}
+ onPress={() => setData(applySalaryMin(v, data.expectedSalaryMax))}
>
{v}
@@ -30,7 +34,7 @@ export function P7Salary() {
最高
- {MAX.map((v) => (
+ {maxOptions.map((v) => (
- navigate('P8')} />
+ navigate('P8')} />
);
diff --git a/components/onboarding/screens/P8Resume1.tsx b/components/onboarding/screens/P8Resume1.tsx
index 19380c4..11282da 100644
--- a/components/onboarding/screens/P8Resume1.tsx
+++ b/components/onboarding/screens/P8Resume1.tsx
@@ -8,12 +8,18 @@ import { YearMonthWheel } from '../ui/WheelPicker';
import { PrimaryButton } from '../ui/PrimaryButton';
export function P8Resume1() {
- const { data, setData, navigate, back, meta } = useOnboarding();
+ const { data, setData, navigate, back } = useOnboarding();
- const canNext = data.resumeName.trim().length > 0;
+ // 姓名 / 性别 / 出生年月都是本页要采集的项,缺一项就不放行 —— 否则资料里会留下
+ // 「有姓名但没有出生年月」的半截记录
+ const canNext =
+ data.resumeName.trim().length > 0 &&
+ data.gender !== null &&
+ data.birthYear !== '' &&
+ data.birthMonth !== '';
return (
-
+
@@ -39,6 +45,7 @@ export function P8Resume1() {
month={data.birthMonth}
onYearChange={(v) => setData({ birthYear: v })}
onMonthChange={(v) => setData({ birthMonth: v })}
+ restingYear="2000年"
/>
diff --git a/components/onboarding/screens/P9JobStatus.tsx b/components/onboarding/screens/P9JobStatus.tsx
index c35a189..49753b5 100644
--- a/components/onboarding/screens/P9JobStatus.tsx
+++ b/components/onboarding/screens/P9JobStatus.tsx
@@ -2,18 +2,20 @@ import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks';
+import { JOB_STATUS_OPTIONS } from '../../../src/constants';
import { ScreenShell } from '../ui/ScreenShell';
import { RadioRow } from '../ui/RadioRow';
import { AgreementCheckbox } from '../ui/AgreementCheckbox';
import { PrimaryButton } from '../ui/PrimaryButton';
-const OPTIONS = ['离职-随时到岗', '在职-月内到岗', '在职-考虑机会', '在职-暂不考虑'];
+// 选项放常量:简历「求职意向」的积极/不积极判定读的是同一份,不能各写一份
+const OPTIONS = JOB_STATUS_OPTIONS;
export function P9JobStatus() {
- const { data, setData, navigate, back, meta } = useOnboarding();
+ const { data, setData, navigate, back } = useOnboarding();
return (
-
+
diff --git a/components/onboarding/ui/Avatar.tsx b/components/onboarding/ui/Avatar.tsx
index f02bc9d..21989d4 100644
--- a/components/onboarding/ui/Avatar.tsx
+++ b/components/onboarding/ui/Avatar.tsx
@@ -1,17 +1,25 @@
-import { Pressable, StyleSheet, View } from 'react-native';
+import { Image, Pressable, StyleSheet, View } from 'react-native';
import { colors } from '../../../src/theme';
import { Illustration } from './Illustration';
import type { AssetName } from '../assets';
interface Props {
name?: AssetName;
+ /** 拍照 / 相册得到的本地图片 URI;有值时优先于 name 渲染 */
+ uri?: string | null;
size?: number;
selected?: boolean;
onPress?: () => void;
}
-/** 圆形头像占位:默认头像剪影 / 真实图;选中态加主色描边 */
-export function Avatar({ name = 'defaultAvatar', size = 64, selected = false, onPress }: Props) {
+/** 圆形头像:预设占位图 / 真实图片;选中态加主色描边 */
+export function Avatar({
+ name = 'defaultAvatar',
+ uri = null,
+ size = 64,
+ selected = false,
+ onPress,
+}: Props) {
const content = (
-
+ {uri ? (
+
+ ) : (
+
+ )}
);
if (onPress) return {content};
diff --git a/components/onboarding/ui/Illustration.tsx b/components/onboarding/ui/Illustration.tsx
index 650ea85..65e3eea 100644
--- a/components/onboarding/ui/Illustration.tsx
+++ b/components/onboarding/ui/Illustration.tsx
@@ -1,4 +1,4 @@
-import { Image, ImageSourcePropType, StyleSheet, Text, View } from 'react-native';
+import { Image, ImageSourcePropType, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import type { ComponentProps } from 'react';
import { colors } from '../../../src/theme';
@@ -13,7 +13,13 @@ interface Props {
circle?: boolean;
}
-/** 统一插画/Logo/头像占位:有真实图渲染图,否则渲染色块 + 图标 + 提示文字 */
+/**
+ * 统一插画/Logo/头像占位:有真实图渲染图,否则渲染色块 + 图标。
+ *
+ * 占位期**不渲染 `hint`**:那些文字是给设计/开发看的素材说明
+ * (「手持 OFFER 职场男性插画」),直接画在用户界面上等于把开发备注当内容展示。
+ * 说明仍留在 `assets.ts`,供换图时对照。
+ */
export function Illustration({ name, size = 120, radius = 16, circle = false }: Props) {
const def = ASSET_REGISTRY[name];
const source: ImageSourcePropType | null = def.source;
@@ -26,12 +32,10 @@ export function Illustration({ name, size = 120, radius = 16, circle = false }:
return (
- {!circle && {def.hint}}
);
}
const styles = StyleSheet.create({
block: { alignItems: 'center', justifyContent: 'center', gap: 6, overflow: 'hidden' },
- hint: { fontSize: 11, color: colors.textHint, paddingHorizontal: 8, textAlign: 'center' },
});
diff --git a/components/onboarding/ui/ImageSlot.tsx b/components/onboarding/ui/ImageSlot.tsx
new file mode 100644
index 0000000..88963fb
--- /dev/null
+++ b/components/onboarding/ui/ImageSlot.tsx
@@ -0,0 +1,64 @@
+import { Image, StyleSheet, Text, View } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import type { ComponentProps } from 'react';
+import { colors } from '../../../src/theme';
+import { ASSET_REGISTRY, type AssetName } from '../assets';
+
+type IconName = ComponentProps['name'];
+
+interface Props {
+ /** 素材位名(见 assets.ts 的 ASSET_REGISTRY) */
+ name: AssetName;
+ width: number;
+ height: number;
+ radius?: number;
+}
+
+/**
+ * 内容配图槽位:素材已提供则渲染真实图,否则画一个**带文字标注**的占位框。
+ *
+ * 与 `Illustration` 的分工:那个是「小尺寸图标位」(头像 / Logo),占位只画色块;
+ * 这个是「内容大图位」,占位必须让看图的人知道这里要放什么图,
+ * 所以把 `assets.ts` 里的 hint 直接标在页面上,补图后标注自动消失。
+ */
+export function ImageSlot({ name, width, height, radius = 16 }: Props) {
+ const def = ASSET_REGISTRY[name];
+
+ if (def.source) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ 配图待补
+ {def.hint}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ slot: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ paddingHorizontal: 20,
+ borderWidth: 1,
+ borderStyle: 'dashed',
+ borderColor: colors.primaryBorder,
+ },
+ tag: {
+ marginTop: 8,
+ fontSize: 12,
+ fontWeight: '700',
+ color: colors.primaryText,
+ },
+ hint: {
+ marginTop: 4,
+ fontSize: 12,
+ lineHeight: 18,
+ textAlign: 'center',
+ color: colors.textSecondary,
+ },
+});
diff --git a/components/onboarding/ui/ScreenShell.tsx b/components/onboarding/ui/ScreenShell.tsx
index 6ee80f5..e54d5c8 100644
--- a/components/onboarding/ui/ScreenShell.tsx
+++ b/components/onboarding/ui/ScreenShell.tsx
@@ -14,13 +14,10 @@ interface Props {
children: ReactNode;
footer?: ReactNode;
scroll?: boolean;
- /** 兼容旧调用保留,已不再渲染(统一使用系统真实状态栏) */
- time?: string;
- battery?: number;
}
/** 每屏统一外壳:系统状态栏 + 可选返回箭头 + 标题 + 滚动内容 + 底部固定区 */
-export function ScreenShell({ title, showBack, onBack, backIcon = 'chevron-back', children, footer, scroll = true, time: _time, battery: _battery }: Props) {
+export function ScreenShell({ title, showBack, onBack, backIcon = 'chevron-back', children, footer, scroll = true }: Props) {
return (
diff --git a/components/onboarding/ui/SupportFooter.tsx b/components/onboarding/ui/SupportFooter.tsx
new file mode 100644
index 0000000..42f2791
--- /dev/null
+++ b/components/onboarding/ui/SupportFooter.tsx
@@ -0,0 +1,43 @@
+import { Linking, Pressable, StyleSheet, Text, View } from 'react-native';
+import { colors } from '../../../src/theme';
+import { SUPERVISION_PHONE, SUPPORT_HOTLINE } from '../../../src/constants';
+
+/**
+ * 合规页脚:服务热线 / 举报监督电话。
+ *
+ * 原先是三行纯文本,没号码也没点击反馈(P1 与 P2Q 各复制一份)。
+ * 这里改为读常量:**号码未配置时整块不渲染** —— 号码属于真实主体信息,
+ * 编一个摆上去就是假数据。填入 `SUPPORT_HOTLINE` / `SUPERVISION_PHONE` 后
+ * 页脚自动出现并支持点击拨号。
+ */
+export function SupportFooter() {
+ const items = [
+ { key: 'hotline', label: '服务热线', phone: SUPPORT_HOTLINE },
+ { key: 'supervision', label: '举报监督电话', phone: SUPERVISION_PHONE },
+ ].filter((item) => item.phone !== '');
+
+ if (items.length === 0) return null;
+
+ /** 设备不支持 tel:(如平板 / 模拟器)时 openURL 会 reject,不弹未捕获异常 */
+ const call = (phone: string) => {
+ void Linking.openURL(`tel:${phone}`).catch(() => undefined);
+ };
+
+ return (
+
+ {items.map((item) => (
+ call(item.phone)} hitSlop={6}>
+
+ {item.label} {item.phone}
+
+
+ ))}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ // 两串「标签 + 11 位号码」在窄屏上会挤,允许换行
+ row: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 18 },
+ item: { fontSize: 12, color: colors.textHint },
+});
diff --git a/components/onboarding/ui/WheelPicker.tsx b/components/onboarding/ui/WheelPicker.tsx
index 2780275..3f3feed 100644
--- a/components/onboarding/ui/WheelPicker.tsx
+++ b/components/onboarding/ui/WheelPicker.tsx
@@ -1,6 +1,8 @@
+import { useEffect, useRef } from 'react';
import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme';
+import { CURRENT_YEAR } from '../../../src/constants';
const ITEM_H = 44;
@@ -9,13 +11,29 @@ function Column({
value,
onValueChange,
width,
+ restingIndex,
}: {
items: string[];
value: string;
onValueChange: (v: string) => void;
width: number;
+ /** 未选中时滚轮的停留位置(只影响视觉停留,不等于已选中) */
+ restingIndex: number;
}) {
- const idx = Math.max(0, items.indexOf(value));
+ const scrollRef = useRef(null);
+ // 未选中('')与用户手滑到第 0 项是两回事:这里不再把空值钳到索引 0,
+ // 而是保留「无选中项」状态,只在视觉上停在 restingIndex。
+ const idx = items.indexOf(value);
+ const initialIndex = idx >= 0 ? idx : restingIndex;
+
+ // contentOffset 只在 iOS 生效,Android 上不定位会让滚轮停在第一项、
+ // 与高亮框里的文字对不上,故挂载时用 scrollTo 补一次(两端通用)。
+ useEffect(() => {
+ scrollRef.current?.scrollTo({ y: initialIndex * ITEM_H, animated: false });
+ // 仅挂载时定位:后续滚动由用户驱动,避免每次选值把滚轮拽回去
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
const handleEnd = (e: NativeSyntheticEvent) => {
const y = e.nativeEvent.contentOffset.y;
const i = Math.min(items.length - 1, Math.max(0, Math.round(y / ITEM_H)));
@@ -25,14 +43,15 @@ function Column({
return (
-
+
@@ -51,16 +70,52 @@ interface DualProps {
month: string;
onYearChange: (v: string) => void;
onMonthChange: (v: string) => void;
+ /**
+ * 未选择年份时滚轮的停留项。默认停在当年(适合「入职时间」这类近期场景);
+ * 「出生年月」应传一个合理的起始年份,否则用户要空滑二十多行。
+ */
+ restingYear?: string;
+ /**
+ * 可选年份跨度:往过去几年、往未来几年。
+ * 原实现固定 40 项(当年往前 39 年),只能覆盖 0–39 岁 ——
+ * 46 岁的求职者选不了自己的出生年;而「入职时间」也选不到明年。
+ */
+ pastYears?: number;
+ futureYears?: number;
}
/** 年月双滚轮:用于出生年月 / 首次参加工作 / 入职时间 */
-export function YearMonthWheel({ year, month, onYearChange, onMonthChange }: DualProps) {
- const years = Array.from({ length: 40 }, (_, i) => `${2026 - i}年`);
+export function YearMonthWheel({
+ year,
+ month,
+ onYearChange,
+ onMonthChange,
+ restingYear = `${CURRENT_YEAR}年`,
+ pastYears = 60,
+ futureYears = 0,
+}: DualProps) {
+ const years = Array.from(
+ { length: pastYears + futureYears + 1 },
+ (_, i) => `${CURRENT_YEAR + futureYears - i}年`,
+ );
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}月`);
+ const unselected = !year || !month;
+ const restingYearIndex = Math.max(0, years.indexOf(restingYear));
+
return (
-
-
-
+
+
+
+
+
+ {/* 未选中时滚轮无高亮项,需要文字提示「要滑动才有值」,否则用户会以为已经选好了 */}
+ {unselected ? 请滑动选择 : null}
);
}
@@ -80,8 +135,11 @@ const styles = StyleSheet.create({
backgroundColor: colors.primaryLight,
opacity: 0.45,
},
+ // 未选中:高亮框退成中性描边,不再用主色底暗示「已经选好了」
+ highlightIdle: { backgroundColor: colors.white, opacity: 0.6 },
item: { height: ITEM_H, alignItems: 'center', justifyContent: 'center' },
itemText: { fontSize: 18, color: colors.textHint },
itemTextActive: { fontSize: 20, fontWeight: '700', color: colors.textPrimary },
dual: { flexDirection: 'row', justifyContent: 'center', gap: 14 },
+ hint: { fontSize: 13, color: colors.textHint, textAlign: 'center', marginTop: 10 },
});
diff --git a/components/resume/EditKit.tsx b/components/resume/EditKit.tsx
new file mode 100644
index 0000000..f8bc75d
--- /dev/null
+++ b/components/resume/EditKit.tsx
@@ -0,0 +1,209 @@
+/**
+ * 简历编辑页共用件(基本信息 / 教育背景 / 工作经历 / 项目经历四页同一套骨架)。
+ *
+ * 抽出来的理由:四页的头部、表单行、四态、页脚完全一致,逐页复制会让
+ * 「错误提示样式」「禁用态按钮」这类细节在四处各自漂移。
+ */
+import { Ionicons } from '@expo/vector-icons';
+import type { ReactNode } from 'react';
+import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
+import { colors } from '../../src/theme';
+
+/** 顶部导航:返回 + 居中标题(右侧留同宽占位,保证标题居中) */
+export function EditHeader({ title, onBack }: { title: string; onBack: () => void }) {
+ return (
+
+
+
+
+ {title}
+
+
+ );
+}
+
+/**
+ * 表单行。`error` 只在**填了但填错**时传入 —— 空值是「未采集」不是「填错了」,
+ * 一进页面就飘红字会让人以为自己做错了什么。
+ */
+export function Field({
+ label,
+ value,
+ onChangeText,
+ placeholder,
+ keyboardType,
+ maxLength,
+ error,
+ hint,
+ multiline,
+ autoFocus,
+ required,
+}: {
+ label: string;
+ value: string;
+ onChangeText: (v: string) => void;
+ placeholder: string;
+ keyboardType?: 'default' | 'phone-pad' | 'email-address' | 'number-pad';
+ maxLength?: number;
+ error?: string;
+ hint?: string;
+ multiline?: boolean;
+ autoFocus?: boolean;
+ /** 必填项在 label 后打一个红色星号(只做视觉标注,拦截规则在页面里) */
+ required?: boolean;
+}) {
+ const hasError = error !== undefined && error !== '';
+ return (
+
+
+ {label}
+ {required === true && *}
+
+
+ {hasError && {error}}
+ {!hasError && hint !== undefined && hint !== '' && {hint}}
+
+ );
+}
+
+/** 只读说明行(放在卡片或页脚里,字号小于正文) */
+export function FormNote({ text }: { text: string }) {
+ return {text};
+}
+
+/** 白色圆角卡片容器(与简历首页的卡片同款) */
+export function SectionCard({ title, right, children }: { title: string; right?: ReactNode; children: ReactNode }) {
+ return (
+
+
+ {title}
+ {right}
+
+ {children}
+
+ );
+}
+
+/** 页脚容器:上方一条浅描边,主按钮靠它压底 */
+export function PageFooter({ children }: { children: ReactNode }) {
+ return {children};
+}
+
+/** 居中状态块(加载中 / 加载失败 / 空列表都走这一个) */
+export function CenterState({
+ icon,
+ text,
+ actionLabel,
+ onAction,
+}: {
+ icon?: keyof typeof Ionicons.glyphMap;
+ text: string;
+ actionLabel?: string;
+ onAction?: () => void;
+}) {
+ return (
+
+ {icon !== undefined && }
+ {text}
+ {actionLabel !== undefined && onAction !== undefined && (
+
+ {actionLabel}
+
+ )}
+
+ );
+}
+
+/** 列表行右侧的图标按钮(编辑 / 删除) */
+export function IconAction({
+ icon,
+ onPress,
+ color,
+ label,
+}: {
+ icon: keyof typeof Ionicons.glyphMap;
+ onPress: () => void;
+ color?: string;
+ /** 无障碍标签:图标按钮对读屏用户是没有名字的,必须补一个 */
+ label: string;
+}) {
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: 12,
+ paddingVertical: 12,
+ backgroundColor: colors.white,
+ },
+ headerBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
+ headerTitle: { fontSize: 17, fontWeight: '700', color: colors.textPrimary },
+ section: { marginBottom: 18 },
+ label: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginBottom: 10 },
+ requiredMark: { color: colors.danger },
+ input: {
+ height: 50,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: colors.border,
+ backgroundColor: colors.white,
+ paddingHorizontal: 14,
+ fontSize: 16,
+ color: colors.textPrimary,
+ },
+ inputMultiline: { height: 120, paddingTop: 12, textAlignVertical: 'top' },
+ inputError: { borderColor: colors.dangerBorder },
+ fieldError: { fontSize: 12, color: colors.dangerText, marginTop: 6 },
+ hint: { fontSize: 12, color: colors.textHint, marginTop: 6 },
+ note: { fontSize: 12, color: colors.textHint, lineHeight: 18 },
+ card: {
+ backgroundColor: colors.white,
+ borderRadius: 16,
+ padding: 16,
+ marginBottom: 12,
+ borderWidth: 1,
+ borderColor: colors.borderLight,
+ },
+ cardHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: 10,
+ },
+ cardTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
+ footer: {
+ paddingHorizontal: 20,
+ paddingVertical: 14,
+ backgroundColor: colors.white,
+ borderTopWidth: 1,
+ borderTopColor: colors.borderLight,
+ },
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12, paddingVertical: 40 },
+ centerText: { fontSize: 14, color: colors.textSecondary },
+ centerBtn: {
+ paddingHorizontal: 18,
+ paddingVertical: 8,
+ borderRadius: 16,
+ backgroundColor: colors.primaryLight,
+ },
+ centerBtnText: { fontSize: 13, fontWeight: '600', color: colors.primaryText },
+ iconBtn: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
+});
diff --git a/package.json b/package.json
index 8ca9678..1dbfc9d 100644
--- a/package.json
+++ b/package.json
@@ -8,10 +8,14 @@
"expo": "~57.0.22",
"expo-blur": "~57.0.3",
"expo-constants": "~57.0.18",
+ "expo-file-system": "~57.0.7",
"expo-font": "~57.0.4",
+ "expo-image-picker": "~57.0.17",
"expo-linear-gradient": "~57.0.2",
"expo-linking": "~57.0.10",
+ "expo-print": "~57.0.2",
"expo-router": "~57.0.21",
+ "expo-sharing": "~57.0.19",
"expo-splash-screen": "~57.0.9",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
@@ -26,7 +30,11 @@
"ios": "expo start --ios",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
- "test": "jest"
+ "test": "jest",
+ "typecheck": "tsc --noEmit",
+ "check:routes": "node scripts/check-routes.mjs",
+ "check:affordances": "node scripts/check-affordances.mjs",
+ "check:backend-stubs": "node scripts/check-backend-stubs.mjs"
},
"private": true,
"devDependencies": {
diff --git a/scripts/check-affordances.mjs b/scripts/check-affordances.mjs
new file mode 100644
index 0000000..9b2dc4c
--- /dev/null
+++ b/scripts/check-affordances.mjs
@@ -0,0 +1,86 @@
+/**
+ * 可点击控件审计:找出「看起来能点、其实没有处理函数」的 Pressable / Touchable*。
+ *
+ * 背景:本项目把「不留假入口」当红线(点下去零反馈的控件会把开发中的状态摆给用户看)。
+ * 与 `check-routes.mjs` 的关系:那个查「页面能不能到达」,这个查「按钮点下去有没有反应」。
+ *
+ * 判定:控件标签内既没有 onPress / onPressIn / onLongPress / onPressOut,
+ * 也没有 `disabled`(禁用态是有意的——视觉上应当置灰)与属性展开({...props})。
+ *
+ * 豁免:标签所在行或上一行写了 `affordance-ok` 注释的跳过 —— 底部弹层里
+ * `onPress={() => {}}` 是**有意**用来吞掉冒泡的(不吞就会点内容区也关掉弹层),
+ * 与「忘了接线」是两回事,靠注释把两者区分开。
+ *
+ * 用法:node scripts/check-affordances.mjs # 列出全部
+ * node scripts/check-affordances.mjs --error # 有命中则 exit 1(可用于 CI)
+ */
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+
+const ROOTS = ['app', 'components'];
+const HANDLER_PROPS = ['onPress', 'onPressIn', 'onPressOut', 'onLongPress', 'disabled', '{...'];
+
+/** 有意为之的控件(吞冒泡)在行内或上一行标这个,避免与「忘了接线」混在一起 */
+const EXEMPT_MARKER = 'affordance-ok';
+
+/** 从 ``(粗略忽略字符串内的 `>`,对本仓库足够) */
+function findTagEnd(text, from) {
+ let depth = 0;
+ for (let i = from; i < text.length; i += 1) {
+ const ch = text[i];
+ if (ch === '{') depth += 1;
+ else if (ch === '}') depth -= 1;
+ else if (ch === '>' && depth === 0) return i;
+ }
+ return -1;
+}
+
+function walk(dir, out = []) {
+ for (const entry of readdirSync(dir)) {
+ const full = join(dir, entry);
+ if (statSync(full).isDirectory()) walk(full, out);
+ else if (/\.tsx$/.test(entry)) out.push(full);
+ }
+ return out;
+}
+
+const TAG_RE = /<(Pressable|TouchableOpacity|TouchableHighlight|TouchableWithoutFeedback)\b/g;
+
+/** 空处理函数:绑了 onPress 但什么都不做,点下去同样零反馈 */
+const EMPTY_HANDLER_RE = /onPress=\{\s*\(\s*\)\s*=>\s*(\{\s*\}|undefined|null)\s*\}/;
+
+const findings = [];
+for (const root of ROOTS) {
+ for (const file of walk(root)) {
+ const text = readFileSync(file, 'utf8');
+ const lines = text.split('\n');
+ for (const match of text.matchAll(TAG_RE)) {
+ const end = findTagEnd(text, match.index);
+ if (end < 0) continue;
+ const tag = text.slice(match.index, end + 1);
+ const empty = EMPTY_HANDLER_RE.test(tag);
+ if (!empty && HANDLER_PROPS.some((prop) => tag.includes(prop))) continue;
+ const line = text.slice(0, match.index).split('\n').length;
+ // 豁免标记写在标签行或其上一行(弹层外的说明注释通常写在上一行)
+ if (lines[line - 1].includes(EXEMPT_MARKER) || (lines[line - 2] ?? '').includes(EXEMPT_MARKER)) {
+ continue;
+ }
+ findings.push({
+ file,
+ line,
+ reason: empty ? '空处理函数' : '无处理函数',
+ tag: lines[line - 1].trim(),
+ });
+ }
+ }
+}
+
+if (findings.length === 0) {
+ console.log('✅ 未发现无处理函数的可点击控件');
+} else {
+ console.log(`=== 点下去没有反应的可点击控件 (${findings.length}) ===`);
+ for (const f of findings) {
+ console.log(`${f.file}:${f.line} [${f.reason}] ${f.tag}`);
+ }
+ if (process.argv.includes('--error')) process.exitCode = 1;
+}
diff --git a/scripts/check-backend-stubs.mjs b/scripts/check-backend-stubs.mjs
new file mode 100644
index 0000000..a556dd4
--- /dev/null
+++ b/scripts/check-backend-stubs.mjs
@@ -0,0 +1,47 @@
+/**
+ * 后端接口预留审计:找出「函数体里没有留下任何后端调用痕迹」的接口函数。
+ *
+ * 背景:本项目当前是 mock 阶段,数据要能展示,但每个函数都要把真实请求写好、注释起来,
+ * 后端就绪时**放开注释**即可接入(见 `src/api/api.ts` 顶部的「后端接口预留」约定)。
+ * 判断标准是「函数体里出现过 fetch(」—— 注释着的也算,因此不依赖注释的写法。
+ *
+ * 豁免:确实没有后端接口、本就以本地存储为唯一真相源的函数,在函数体内任意位置写
+ * `backend-stub-ok` 注释即可跳过(约定见 api.ts 顶部)。
+ *
+ * 用法:node scripts/check-backend-stubs.mjs # 列出全部
+ * node scripts/check-backend-stubs.mjs --error # 有命中则 exit 1(可用于 CI)
+ */
+import { readFileSync } from 'node:fs';
+
+const FILE = 'src/api/api.ts';
+/** 有意不接后端的函数在函数体内标这个,避免与「忘了留接口」混在一起 */
+const EXEMPT_MARKER = 'backend-stub-ok';
+
+const lines = readFileSync(FILE, 'utf8').split('\n');
+
+/** 顶层 `export async function` 的起始行;同步函数不涉及请求,不参与审计 */
+const starts = [];
+lines.forEach((line, index) => {
+ const match = line.match(/^export async function (\w+)/);
+ if (match) starts.push({ name: match[1], index });
+});
+starts.push({ name: null, index: lines.length });
+
+const findings = [];
+for (let i = 0; i < starts.length - 1; i += 1) {
+ const { name, index } = starts[i];
+ const body = lines.slice(index, starts[i + 1].index).join('\n');
+ if (body.includes('fetch(') || body.includes(EXEMPT_MARKER)) continue;
+ findings.push({ name, line: index + 1 });
+}
+
+if (findings.length === 0) {
+ console.log(`✅ ${starts.length - 1} 个接口函数均已预留后端调用(注释版)`);
+} else {
+ console.log(`=== 未预留后端调用的接口函数 (${findings.length}) ===`);
+ for (const f of findings) {
+ console.log(`${FILE}:${f.line} ${f.name}`);
+ }
+ console.log('请补上注释版真实请求,或在不接后端的函数里标注 backend-stub-ok');
+ if (process.argv.includes('--error')) process.exitCode = 1;
+}
diff --git a/src/__tests__/carrier-login.test.ts b/src/__tests__/carrier-login.test.ts
new file mode 100644
index 0000000..9e0c3dc
--- /dev/null
+++ b/src/__tests__/carrier-login.test.ts
@@ -0,0 +1,73 @@
+import { fetchCarrierPhone, loginByCarrier, maskPhone } from '../api/api';
+import { CARRIER_LABEL, CARRIER_TOKEN_TTL_MS } from '../constants';
+import type { CarrierName, CarrierPhoneResult } from '../types';
+
+// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock(与 password.test.ts 同)
+jest.mock('../lib/storage', () => ({
+ storageGet: jest.fn(async () => null),
+ storageSet: jest.fn(async () => undefined),
+ storageRemove: jest.fn(async () => undefined),
+}));
+
+const ALL_CARRIERS: CarrierName[] = ['cmcc', 'cucc', 'ctcc', 'unknown'];
+
+describe('maskPhone(脱敏在接口层,页面不自己切字符串)', () => {
+ it('11 位手机号保留前 3 后 4', () => {
+ expect(maskPhone('15000009367')).toBe('150****9367');
+ });
+
+ it('非 11 位原样返回,不截断成错的', () => {
+ expect(maskPhone('')).toBe('');
+ expect(maskPhone('1500000')).toBe('1500000');
+ expect(maskPhone('150000093671')).toBe('150000093671');
+ });
+});
+
+describe('fetchCarrierPhone(取号)', () => {
+ it('返回号码、掩码、一次性令牌与过期时间', async () => {
+ const before = Date.now();
+ const result: CarrierPhoneResult = await fetchCarrierPhone();
+
+ expect(result.phone).toHaveLength(11);
+ expect(result.maskedPhone).toBe(maskPhone(result.phone));
+ expect(result.carrierToken).not.toBe('');
+ expect(ALL_CARRIERS).toContain(result.carrier);
+ expect(result.expiresAt).toBeGreaterThanOrEqual(before + CARRIER_TOKEN_TTL_MS);
+ });
+
+ it('掩码号不含完整号码,展示用不会泄露', async () => {
+ const result = await fetchCarrierPhone();
+ expect(result.maskedPhone).not.toBe(result.phone);
+ expect(result.maskedPhone).toContain('****');
+ });
+});
+
+describe('loginByCarrier(用取号令牌换登录态)', () => {
+ it('拿取号令牌换到登录态与后端核验过的号码', async () => {
+ const { phone, carrierToken } = await fetchCarrierPhone();
+ const result = await loginByCarrier(carrierToken);
+
+ expect(result.token).not.toBe('');
+ // 号码以后端向运营商换取的结果为准,应与取号展示的是同一个
+ expect(result.phone).toBe(phone);
+ });
+
+ it('令牌为空时直接失败,不发请求', async () => {
+ await expect(loginByCarrier('')).rejects.toThrow('本机号码未取到');
+ });
+
+ it('每次取号的令牌都不同(一次性令牌不可复用)', async () => {
+ const first = await fetchCarrierPhone();
+ const second = await fetchCarrierPhone();
+ expect(first.carrierToken).not.toBe(second.carrierToken);
+ });
+});
+
+describe('CARRIER_LABEL', () => {
+ it('联合类型每个成员都有展示名(新增运营商漏配时失败)', () => {
+ ALL_CARRIERS.forEach((carrier) => {
+ expect(CARRIER_LABEL[carrier]).toBeTruthy();
+ });
+ expect(Object.keys(CARRIER_LABEL)).toHaveLength(ALL_CARRIERS.length);
+ });
+});
diff --git a/src/__tests__/enterprise-filters.test.ts b/src/__tests__/enterprise-filters.test.ts
new file mode 100644
index 0000000..638b9d6
--- /dev/null
+++ b/src/__tests__/enterprise-filters.test.ts
@@ -0,0 +1,282 @@
+import {
+ fetchEnterpriseCommunication,
+ fetchSkillLearningPath,
+ fetchTalentRecommendation,
+ filterEnterpriseChats,
+ filterSkillWords,
+ filterTalentCandidates,
+} from '../api/api';
+
+// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock
+jest.mock('../lib/storage', () => ({
+ storageGet: jest.fn(async () => null),
+ storageSet: jest.fn(async () => undefined),
+ storageRemove: jest.fn(async () => undefined),
+}));
+
+describe('filterEnterpriseChats(企业沟通 Tab + 关键词过滤)', () => {
+ const chats = [
+ {
+ id: 'c1',
+ userId: 'u1',
+ name: '张明远',
+ avatar: '张',
+ title: '高级前端工程师',
+ lastMessage: '方便下周面试吗?',
+ time: '10分钟前',
+ unreadCount: 1,
+ labels: [],
+ status: 'pending' as const,
+ },
+ {
+ id: 'c2',
+ userId: 'u2',
+ name: '李雨桐',
+ avatar: '李',
+ title: '产品经理',
+ lastMessage: '想进一步沟通',
+ time: '30分钟前',
+ unreadCount: 0,
+ labels: ['面试待评分'],
+ status: 'interview' as const,
+ },
+ ];
+
+ it('全部 Tab 且无关键词时原样返回', () => {
+ expect(filterEnterpriseChats(chats, 'all', '')).toHaveLength(2);
+ });
+
+ it('按状态过滤:只留该 Tab 下的会话', () => {
+ expect(filterEnterpriseChats(chats, 'pending', '').map((c) => c.id)).toEqual(['c1']);
+ expect(filterEnterpriseChats(chats, 'interview', '').map((c) => c.id)).toEqual(['c2']);
+ expect(filterEnterpriseChats(chats, 'offer', '')).toHaveLength(0);
+ });
+
+ it('关键词命中姓名 / 职位 / 最后一条消息', () => {
+ expect(filterEnterpriseChats(chats, 'all', '张明远').map((c) => c.id)).toEqual(['c1']);
+ expect(filterEnterpriseChats(chats, 'all', '产品经理').map((c) => c.id)).toEqual(['c2']);
+ expect(filterEnterpriseChats(chats, 'all', '面试').map((c) => c.id)).toEqual(['c1']);
+ });
+
+ it('关键词忽略大小写与首尾空格', () => {
+ expect(filterEnterpriseChats(chats, 'all', ' 产品经理 ')).toHaveLength(1);
+ expect(filterEnterpriseChats(chats, 'all', '产品经理'.toUpperCase())).toHaveLength(1);
+ });
+
+ it('Tab 与关键词同时生效,命中不到时返回空数组', () => {
+ // 「张明远」不在面试邀请 Tab 下
+ expect(filterEnterpriseChats(chats, 'interview', '张明远')).toHaveLength(0);
+ });
+
+ it('关键词为空串时不误伤(不过滤)', () => {
+ expect(filterEnterpriseChats(chats, 'all', ' ')).toHaveLength(2);
+ });
+});
+
+describe('filterTalentCandidates(人才推荐 Tab + 关键词过滤)', () => {
+ const candidates = [
+ {
+ id: 'c1',
+ name: '张明远',
+ avatar: '张',
+ title: '高级前端工程师 · 5年经验',
+ experience: '5年经验',
+ age: 28,
+ school: '北京大学',
+ city: '北京',
+ expectedSalary: '期望薪资 30-35K',
+ matchPercent: 96,
+ overallScore: 88,
+ percentile: 99,
+ tags: ['React', 'TypeScript'],
+ education: '硕士' as const,
+ },
+ {
+ id: 'c2',
+ name: '李雨桐',
+ avatar: '李',
+ title: '产品经理 · 3年经验',
+ experience: '3年经验',
+ age: 26,
+ school: '上海交通大学',
+ city: '北京',
+ expectedSalary: '期望薪资 25-30K',
+ matchPercent: 82,
+ overallScore: 75,
+ percentile: 75,
+ tags: ['大厂背景'],
+ education: '本科' as const,
+ },
+ {
+ id: 'c3',
+ name: '周雨',
+ avatar: '周',
+ title: 'UI 设计师 · 8年经验',
+ experience: '8年经验',
+ age: 34,
+ school: '中国美术学院',
+ city: '杭州',
+ expectedSalary: '期望薪资 28-35K',
+ matchPercent: 76,
+ overallScore: 80,
+ percentile: 70,
+ tags: ['设计系统'],
+ education: '本科' as const,
+ },
+ ];
+
+ it('全部 Tab 且无关键词时原样返回', () => {
+ expect(filterTalentCandidates(candidates, 'all', '')).toHaveLength(3);
+ });
+
+ it('匹配度高:只留匹配度 ≥ 90 的候选人', () => {
+ expect(filterTalentCandidates(candidates, 'high', '').map((c) => c.id)).toEqual(['c1']);
+ });
+
+ it('985/211:按院校名单判定,非名单院校被排除', () => {
+ expect(filterTalentCandidates(candidates, '985211', '').map((c) => c.id)).toEqual(['c1', 'c2']);
+ });
+
+ it('5年以上:按经验年限数值判定(5 年本身算命中)', () => {
+ expect(filterTalentCandidates(candidates, '5years', '').map((c) => c.id)).toEqual(['c1', 'c3']);
+ });
+
+ it('硕士及以上:本科被排除,硕士/博士命中', () => {
+ expect(filterTalentCandidates(candidates, 'master', '').map((c) => c.id)).toEqual(['c1']);
+ });
+
+ it('关键词命中姓名 / 职位 / 学校 / 技能标签', () => {
+ expect(filterTalentCandidates(candidates, 'all', '李雨桐').map((c) => c.id)).toEqual(['c2']);
+ expect(filterTalentCandidates(candidates, 'all', '设计师').map((c) => c.id)).toEqual(['c3']);
+ expect(filterTalentCandidates(candidates, 'all', '北京大学').map((c) => c.id)).toEqual(['c1']);
+ expect(filterTalentCandidates(candidates, 'all', 'react').map((c) => c.id)).toEqual(['c1']);
+ });
+
+ it('Tab 与关键词同时生效,命中不到时返回空数组', () => {
+ expect(filterTalentCandidates(candidates, 'high', '周雨')).toHaveLength(0);
+ });
+});
+
+describe('企业端 Tab 计数由列表推导(页面数字与列表人数必须一致)', () => {
+ it('沟通 Tab 计数与逐条状态统计一致,且各状态之和等于全部', async () => {
+ const { chats, tabs } = await fetchEnterpriseCommunication();
+
+ const countOf = (status: string) => chats.filter((chat) => chat.status === status).length;
+ const tabOf = (key: string) => tabs.find((tab) => tab.key === key)?.count;
+
+ expect(tabOf('all')).toBe(chats.length);
+ expect(tabOf('pending')).toBe(countOf('pending'));
+ expect(tabOf('interview')).toBe(countOf('interview'));
+ expect(tabOf('offer')).toBe(countOf('offer'));
+ expect(tabOf('closed')).toBe(countOf('closed'));
+ expect(
+ (tabOf('pending') ?? 0) +
+ (tabOf('interview') ?? 0) +
+ (tabOf('offer') ?? 0) +
+ (tabOf('closed') ?? 0),
+ ).toBe(chats.length);
+ });
+
+ it('每个 Tab 点到列表都有对应数据,不会出现「数字 3、列表 0 条」', async () => {
+ const { chats, tabs } = await fetchEnterpriseCommunication();
+
+ tabs.forEach((tab) => {
+ expect(filterEnterpriseChats(chats, tab.key, '')).toHaveLength(tab.count);
+ });
+ });
+
+ it('人才推荐 Tab 计数与过滤结果一致', async () => {
+ const { candidates, tabs, todayCount } = await fetchTalentRecommendation();
+
+ expect(todayCount).toBe(candidates.length);
+ tabs.forEach((tab) => {
+ expect(filterTalentCandidates(candidates, tab.key, '')).toHaveLength(tab.count);
+ });
+ });
+
+ it('待办任务计数由会话列表推导,且都带可跳转的去向', async () => {
+ const { chats, pendingTasks } = await fetchEnterpriseCommunication();
+
+ const reply = pendingTasks.find((task) => task.id === 'reply');
+ const score = pendingTasks.find((task) => task.id === 'score');
+
+ expect(reply?.count).toBe(chats.filter((chat) => chat.unreadCount > 0).length);
+ expect(reply?.tabKey).toBe('pending');
+ expect(score?.count).toBe(chats.filter((chat) => chat.labels.includes('面试待评分')).length);
+ expect(score?.route).toBe('/interview-score');
+
+ // 带箭头/可点的待办卡片必须指向真实去向,否则就是假入口
+ pendingTasks.forEach((task) => {
+ expect(task.tabKey ?? task.route).toBeTruthy();
+ });
+ });
+});
+
+describe('filterSkillWords(技能词云搜索)', () => {
+ const words = [
+ { id: 's1', text: 'React', weight: 96, group: 'tech' as const },
+ { id: 's2', text: 'TypeScript', weight: 92, group: 'tech' as const },
+ { id: 's3', text: '前端工程化', weight: 82, group: 'tech' as const },
+ ];
+
+ it('空关键词返回全部(不当成「搜不到」)', () => {
+ expect(filterSkillWords(words, '')).toEqual(words);
+ expect(filterSkillWords(words, ' ')).toEqual(words);
+ });
+
+ it('关键词忽略大小写与首尾空格', () => {
+ expect(filterSkillWords(words, 'react').map((w) => w.id)).toEqual(['s1']);
+ expect(filterSkillWords(words, ' REACT ').map((w) => w.id)).toEqual(['s1']);
+ });
+
+ it('中文关键词按子串匹配', () => {
+ expect(filterSkillWords(words, '前端').map((w) => w.id)).toEqual(['s3']);
+ });
+
+ it('搜不到时返回空数组(页面据此显示空状态)', () => {
+ expect(filterSkillWords(words, '不存在的技能')).toEqual([]);
+ });
+});
+
+describe('fetchSkillLearningPath(技能学习路径)', () => {
+ it('按权重从高到低挑待补技能,优先级从 1 递增', async () => {
+ const path = await fetchSkillLearningPath('互联网/IT');
+
+ expect(path.items.length).toBeGreaterThanOrEqual(3);
+ expect(path.items.length).toBeLessThanOrEqual(6);
+ expect(path.items.map((item) => item.priority)).toEqual(
+ path.items.map((_, index) => index + 1),
+ );
+ // 权重降序:先补最硬的要求
+ const weights = path.items.map((item) => item.weight);
+ expect(weights).toEqual([...weights].sort((a, b) => b - a));
+ });
+
+ it('补齐后的目标匹配度高于当前匹配度,且不超过 95', async () => {
+ const path = await fetchSkillLearningPath('互联网/IT');
+
+ expect(path.targetPercent).toBeGreaterThan(path.matchPercent);
+ expect(path.targetPercent).toBeLessThanOrEqual(95);
+ });
+
+ it('技能词条数不足 6 条时不会凭空造出条目', async () => {
+ const path = await fetchSkillLearningPath('教育');
+ expect(path.items).toHaveLength(3);
+ });
+
+ it('未知行业兜底到默认行业,并把实际使用的行业回传(不谎报成入参行业)', async () => {
+ const path = await fetchSkillLearningPath('外星行业');
+
+ expect(path.industry).toBe('互联网/IT');
+ expect(path.items.length).toBeGreaterThan(0);
+ });
+
+ it('每条待补技能都有可读的推荐理由', async () => {
+ const path = await fetchSkillLearningPath('金融');
+
+ path.items.forEach((item) => {
+ expect(item.name).not.toBe('');
+ expect(item.reason).toContain(String(item.weight));
+ });
+ });
+});
diff --git a/src/__tests__/enterprise-reviews.test.ts b/src/__tests__/enterprise-reviews.test.ts
new file mode 100644
index 0000000..9b9b2b6
--- /dev/null
+++ b/src/__tests__/enterprise-reviews.test.ts
@@ -0,0 +1,62 @@
+import {
+ fetchJobEvaluationManagement,
+ fetchPendingOfferCount,
+ replyJobReview,
+} from '../api/api';
+
+/**
+ * 内存版存储:回复要能跨两次请求读回来,才测得出「刷新后回复还在」。
+ * 变量名必须以 mock 开头,jest.mock 的工厂函数只能引用这类外部变量。
+ */
+const mockStore = new Map();
+
+jest.mock('../lib/storage', () => ({
+ storageGet: jest.fn(async (key: string) => mockStore.get(key) ?? null),
+ storageSet: jest.fn(async (key: string, value: string) => {
+ mockStore.set(key, value);
+ }),
+ storageRemove: jest.fn(async (key: string) => {
+ mockStore.delete(key);
+ }),
+}));
+
+beforeEach(() => {
+ mockStore.clear();
+});
+
+describe('回复岗位评价', () => {
+ it('回复成功后返回 replied 与回复内容', async () => {
+ const review = await replyJobReview('jr2', '感谢反馈,我们已调整排班');
+ expect(review.replied).toBe(true);
+ expect(review.replyContent).toBe('感谢反馈,我们已调整排班');
+ });
+
+ it('回复内容落库,重新拉列表后仍然带着回复(不会刷新一次就变回「回复」)', async () => {
+ await replyJobReview('jr2', '感谢反馈');
+
+ const management = await fetchJobEvaluationManagement();
+ const target = management.reviews.find((r) => r.id === 'jr2');
+ expect(target?.replied).toBe(true);
+ expect(target?.replyContent).toBe('感谢反馈');
+ });
+
+ it('空回复直接失败,不写入存储', async () => {
+ await expect(replyJobReview('jr2', ' ')).rejects.toThrow('回复内容不能为空');
+
+ const management = await fetchJobEvaluationManagement();
+ const target = management.reviews.find((r) => r.id === 'jr2');
+ expect(target?.replied).toBe(false);
+ });
+
+ it('回复率不由这 3 条样例算:分母是全部评价,样例算出来比原值更假', async () => {
+ const management = await fetchJobEvaluationManagement();
+ // 样例里 3 条有 2 条已回复 = 67%,而看板上的回复率来自全部 128 条
+ expect(management.replyRate).toBe(72);
+ });
+});
+
+describe('待处理 Offer 数量', () => {
+ it('按状态统计,只数 pending', async () => {
+ expect(await fetchPendingOfferCount()).toBe(2);
+ });
+});
diff --git a/src/__tests__/interview-score-draft.test.ts b/src/__tests__/interview-score-draft.test.ts
new file mode 100644
index 0000000..ebc8390
--- /dev/null
+++ b/src/__tests__/interview-score-draft.test.ts
@@ -0,0 +1,96 @@
+import {
+ clearInterviewScoreDraft,
+ fetchInterviewScore,
+ fetchInterviewScoreDraft,
+ saveInterviewScoreDraft,
+} from '../api/api';
+
+// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage。
+// 草稿功能本身就是「存了要能读回来」,所以这里必须是带状态的 mock。
+jest.mock('../lib/storage', () => {
+ const store = new Map();
+ return {
+ __store: store,
+ storageGet: jest.fn(async (key: string) => store.get(key) ?? null),
+ storageSet: jest.fn(async (key: string, value: string) => {
+ store.set(key, value);
+ }),
+ storageRemove: jest.fn(async (key: string) => {
+ store.delete(key);
+ }),
+ };
+});
+
+const store = (jest.requireMock('../lib/storage') as { __store: Map }).__store;
+
+const INTERVIEW_A = 'interview-1';
+const INTERVIEW_B = 'interview-2';
+
+beforeEach(() => {
+ store.clear();
+});
+
+describe('面试评分草稿(本地暂存 + 恢复)', () => {
+ it('保存后能原样读回,且带回保存时间', async () => {
+ const score = await fetchInterviewScore(INTERVIEW_A);
+ const draft = await saveInterviewScoreDraft(INTERVIEW_A, { ...score, comment: '沟通清晰' });
+
+ expect(draft.score.comment).toBe('沟通清晰');
+ expect(Number.isNaN(Date.parse(draft.savedAt))).toBe(false);
+
+ const restored = await fetchInterviewScoreDraft(INTERVIEW_A);
+ expect(restored?.score.comment).toBe('沟通清晰');
+ expect(restored?.savedAt).toBe(draft.savedAt);
+ });
+
+ it('未存过草稿时返回 null(页面据此不显示恢复提示)', async () => {
+ expect(await fetchInterviewScoreDraft('从没评过的面试')).toBeNull();
+ });
+
+ it('不同面试的草稿互不串台', async () => {
+ const score = await fetchInterviewScore(INTERVIEW_A);
+ await saveInterviewScoreDraft(INTERVIEW_A, { ...score, comment: 'A 场的评价' });
+ await saveInterviewScoreDraft(INTERVIEW_B, { ...score, comment: 'B 场的评价' });
+
+ expect((await fetchInterviewScoreDraft(INTERVIEW_A))?.score.comment).toBe('A 场的评价');
+ expect((await fetchInterviewScoreDraft(INTERVIEW_B))?.score.comment).toBe('B 场的评价');
+ });
+
+ it('同一场面试重复保存时覆盖前一份,不是并列存两份', async () => {
+ const score = await fetchInterviewScore(INTERVIEW_A);
+ await saveInterviewScoreDraft(INTERVIEW_A, { ...score, comment: '第一版' });
+ await saveInterviewScoreDraft(INTERVIEW_A, { ...score, comment: '第二版' });
+
+ expect((await fetchInterviewScoreDraft(INTERVIEW_A))?.score.comment).toBe('第二版');
+ });
+
+ it('存储内容损坏时当作没有草稿,不抛异常到页面', async () => {
+ await saveInterviewScoreDraft(INTERVIEW_A, await fetchInterviewScore(INTERVIEW_A));
+ store.set(`interview:score-draft:${INTERVIEW_A}`, '{{坏掉的内容');
+
+ expect(await fetchInterviewScoreDraft(INTERVIEW_A)).toBeNull();
+ });
+
+ it('清除后读不到草稿(提交成功或用户放弃后调用)', async () => {
+ const score = await fetchInterviewScore(INTERVIEW_A);
+ await saveInterviewScoreDraft(INTERVIEW_A, score);
+
+ await clearInterviewScoreDraft(INTERVIEW_A);
+
+ expect(await fetchInterviewScoreDraft(INTERVIEW_A)).toBeNull();
+ });
+
+ it('清除只影响该场面试,其他场次的草稿保留', async () => {
+ const score = await fetchInterviewScore(INTERVIEW_A);
+ await saveInterviewScoreDraft(INTERVIEW_A, score);
+ await saveInterviewScoreDraft(INTERVIEW_B, score);
+
+ await clearInterviewScoreDraft(INTERVIEW_A);
+
+ expect(await fetchInterviewScoreDraft(INTERVIEW_B)).not.toBeNull();
+ });
+
+ it('清除没存过草稿的面试时不报错(幂等)', async () => {
+ await expect(clearInterviewScoreDraft('从没评过的面试')).resolves.toBeUndefined();
+ });
+});
diff --git a/src/__tests__/job-match.test.ts b/src/__tests__/job-match.test.ts
new file mode 100644
index 0000000..c110199
--- /dev/null
+++ b/src/__tests__/job-match.test.ts
@@ -0,0 +1,255 @@
+import {
+ clearOnboardingProfile,
+ fetchJobList,
+ fetchJobPreference,
+ saveJobPreference,
+ saveOnboardingProfile,
+} from '../api/api';
+import { storageRemove, storageSet } from '../lib/storage';
+import type { Job, JobPreference, OnboardingProfile } from '../types';
+
+// 内存版 storage mock(与 salary.test.ts / onboarding-profile.test.ts 同)。
+// 键是 api 层传入的原始键(无 youzhi: 前缀,前缀由真实 storage 封装补上)。
+jest.mock('../lib/storage', () => {
+ const store: Record = {};
+ return {
+ storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)),
+ storageSet: jest.fn(async (k: string, v: string) => {
+ store[k] = v;
+ }),
+ storageRemove: jest.fn(async (k: string) => {
+ delete store[k];
+ }),
+ };
+});
+
+/** 全部字段留空 / 为 null 的兜底资料,只覆盖测试关心的字段 */
+const BASE_PROFILE: OnboardingProfile = {
+ identity: 'candidate',
+ isStudent: null,
+ city: '',
+ expectedPositions: [],
+ expectedSalaryMin: '',
+ expectedSalaryMax: '',
+ resumeName: '',
+ gender: null,
+ birthYear: '',
+ birthMonth: '',
+ jobStatus: null,
+ disabledJobseeker: false,
+ firstWorkYear: '',
+ firstWorkMonth: '',
+ currentPosition: null,
+ company: '',
+ joinYear: '',
+ joinMonth: '',
+ skills: [],
+ workContent: '',
+ education: null,
+ eduType: null,
+ school: '',
+ avatar: null,
+ recruiter: null,
+};
+
+const makeProfile = (patch: Partial): OnboardingProfile => ({
+ ...BASE_PROFILE,
+ ...patch,
+});
+
+/**
+ * mock 岗位(`src/api/api.ts` 的 MOCK_JOBS)固定为 9 条:
+ * | id | 职位 | 城市 | 薪资 | 分数 |
+ * | 1 | 高级前端工程师 | 北京 | 25-40 | 92 |
+ * | 2 | 产品经理 | 深圳 | 20-35 | 78 |
+ * | 3 | UI设计师 | 杭州 | 15-25 | 85 |
+ * | 4 | Java开发工程师 | 北京 | 18-30 | 65 |
+ * | 5 | 前端实习生 | 上海 | 6-9 | 60 |
+ * | 6 | 金融分析师 | 深圳 | 30-50 | 88 |
+ * | 7 | 课程顾问 | 北京 | 12-20 | 72 |
+ * | 8 | 医学代表 | 广州 | 15-25 | 75 |
+ * | 9 | 电池工程师 | 宁德 | 28-45 | 90 |
+ * 下面所有「决定性输入 → 决定性顺序」的断言都基于这张表。
+ */
+const DEFAULT_PARAMS = { sortBy: 'comprehensive' as const };
+
+const idsOf = (jobs: Job[]): string[] => jobs.map((job) => job.id);
+
+describe('fetchJobList 岗位推荐匹配(默认推荐视图叠加偏好加分)', () => {
+ beforeEach(async () => {
+ await clearOnboardingProfile();
+ await storageRemove('user:job-preference');
+ });
+
+ it('无任何采集数据时,按岗位分数降序(不凭空加分)', async () => {
+ const { jobs, total } = await fetchJobList(DEFAULT_PARAMS);
+ expect(total).toBe(9);
+ expect(idsOf(jobs)).toEqual(['1', '9', '6', '3', '2', '8', '7', '4', '5']);
+ });
+
+ it('命中期望城市 +15:杭州岗位从第 4 位升到第 1 位', async () => {
+ await saveOnboardingProfile(makeProfile({ city: '杭州' }));
+ const { jobs } = await fetchJobList(DEFAULT_PARAMS);
+ // UI设计师(杭州,85)→ 85+15=100,越过原来的第 1 名(92)
+ expect(idsOf(jobs)).toEqual(['3', '1', '9', '6', '2', '8', '7', '4', '5']);
+ });
+
+ it('命中期望职位 +20:职位名归一化后再比对', async () => {
+ await saveOnboardingProfile(makeProfile({ expectedPositions: ['前端开发工程师'] }));
+ const { jobs } = await fetchJobList(DEFAULT_PARAMS);
+ // 「前端开发工程师」⇒ 去掉「开发」「工程师」⇒「前端」
+ // 岗位 1「高级前端工程师」⇒「前端」命中(+20 → 112),原本就在第 1 位
+ // 岗位 5「前端实习生」⇒「前端」同样命中(+20 → 80),从末位升到第 5 位
+ expect(idsOf(jobs)).toEqual(['1', '9', '6', '3', '5', '2', '8', '7', '4']);
+ });
+
+ it('期望薪资与岗位薪资区间有重叠 +10(含边界值)', async () => {
+ await saveOnboardingProfile(
+ makeProfile({ expectedSalaryMin: '35K', expectedSalaryMax: '45K' }),
+ );
+ const { jobs } = await fetchJobList(DEFAULT_PARAMS);
+ // 命中(+10):1(25-40) 2(20-35 边界 35>=35) 6(30-50) 9(28-45)
+ // 未命中:3(15-25) 4(18-30) 5(6-9) 7(12-20) 8(15-25)
+ // 结果:产品经理 78+10=88 越过 UI设计师 85
+ expect(idsOf(jobs)).toEqual(['1', '9', '6', '2', '3', '8', '7', '4', '5']);
+ });
+
+ it('三项加分可叠加:命中越多排越前', async () => {
+ await saveOnboardingProfile(
+ makeProfile({
+ city: '杭州',
+ expectedPositions: ['UI设计师'],
+ expectedSalaryMin: '20K',
+ expectedSalaryMax: '35K',
+ }),
+ );
+ const { jobs } = await fetchJobList(DEFAULT_PARAMS);
+ // UI设计师:85 + 城市15 + 职位20 + 薪资10(15-25 与 20-35 有交集)= 130
+ expect(idsOf(jobs)[0]).toBe('3');
+ expect(idsOf(jobs)[1]).toBe('1'); // 92 + 薪资10(25-40 与 20-35 有交集)
+ });
+
+ it('无法解析的期望薪资不加分(不凭空命中)', async () => {
+ await saveOnboardingProfile(
+ makeProfile({ expectedSalaryMin: '面议', expectedSalaryMax: '面议' }),
+ );
+ const { jobs } = await fetchJobList(DEFAULT_PARAMS);
+ expect(idsOf(jobs)).toEqual(['1', '9', '6', '3', '2', '8', '7', '4', '5']);
+ });
+});
+
+describe('fetchJobList 显式筛选优先(用户主动筛选时不叠加偏好加分)', () => {
+ /**
+ * 该偏好若被叠加,会把「课程顾问」(72+20+10=102)顶到北京岗位的第 1 位
+ * —— 未叠加时北京岗位按原始分数序为 [1(92), 7(72), 4(65)]。
+ * 城市留空:城市加分对同城岗位一视同仁,加与不加都改不了相对顺序,留着反而干扰断言。
+ */
+ const PREFERENCE: JobPreference = {
+ position: '课程顾问',
+ city: '',
+ salary: '12K-20K',
+ workType: '全职',
+ status: '在职-月内到岗',
+ };
+
+ beforeEach(async () => {
+ await clearOnboardingProfile();
+ await storageRemove('user:job-preference');
+ await saveJobPreference(PREFERENCE);
+ });
+
+ it('显式选了城市:只做筛选,不叠加加分', async () => {
+ // 北京岗位原始顺序:1(92) / 7(72) / 4(65)
+ const filtered = await fetchJobList({ ...DEFAULT_PARAMS, city: '北京' });
+ expect(idsOf(filtered.jobs)).toEqual(['1', '7', '4']);
+
+ // 同一份偏好在默认视图下确实生效(否则上面的断言可能是「偏好没读到」而假通过)
+ const unfiltered = await fetchJobList(DEFAULT_PARAMS);
+ expect(idsOf(unfiltered.jobs)[0]).toBe('7'); // 72 + 职位20 + 薪资10 = 102
+ });
+
+ it('显式输入了关键词:只做搜索,不叠加加分', async () => {
+ const matched = await fetchJobList({ ...DEFAULT_PARAMS, keyword: '工程师' });
+ expect(idsOf(matched.jobs)).toEqual(['1', '9', '4']);
+
+ // 上面这份偏好只对「课程顾问」加权,换成「电池工程师」再验一次默认视图
+ await saveJobPreference({ ...PREFERENCE, position: '电池工程师', salary: '' });
+ const filtered = await fetchJobList({ ...DEFAULT_PARAMS, keyword: '工程师' });
+ expect(idsOf(filtered.jobs)).toEqual(['1', '9', '4']);
+ const unfiltered = await fetchJobList(DEFAULT_PARAMS);
+ expect(idsOf(unfiltered.jobs)[0]).toBe('9'); // 90 + 职位20 = 110
+ });
+
+ it('城市传「全部城市」哨兵值等同未筛选,偏好生效', async () => {
+ const { jobs } = await fetchJobList({ ...DEFAULT_PARAMS, city: '全部城市' });
+ expect(idsOf(jobs)[0]).toBe('7');
+ });
+});
+
+describe('fetchJobPreference 取值优先级(显式编辑 > 引导资料推导 > 空值)', () => {
+ beforeEach(async () => {
+ await clearOnboardingProfile();
+ await storageRemove('user:job-preference');
+ });
+
+ it('无任何数据时返回空值,工作类型补默认「全职」', async () => {
+ await expect(fetchJobPreference()).resolves.toEqual({
+ position: '',
+ city: '',
+ salary: '',
+ workType: '全职',
+ status: '',
+ });
+ });
+
+ it('由引导资料推导:期望职位取第 1 个,薪资拼成区间', async () => {
+ await saveOnboardingProfile(
+ makeProfile({
+ city: '杭州',
+ expectedPositions: ['鸿蒙开发工程师', '前端开发工程师'],
+ expectedSalaryMin: '20K',
+ expectedSalaryMax: '35K',
+ jobStatus: '离职-随时到岗',
+ }),
+ );
+ await expect(fetchJobPreference()).resolves.toEqual({
+ position: '鸿蒙开发工程师',
+ city: '杭州',
+ salary: '20K-35K',
+ workType: '全职',
+ status: '离职-随时到岗',
+ });
+ });
+
+ it('期望薪资只填了一头时不拼区间(不留「20K-」这种半截值)', async () => {
+ await saveOnboardingProfile(
+ makeProfile({ expectedSalaryMin: '20K', expectedSalaryMax: '' }),
+ );
+ await expect(fetchJobPreference()).resolves.toMatchObject({ salary: '' });
+ });
+
+ it('用户在求职意向页显式保存过时,以显式值为准', async () => {
+ await saveOnboardingProfile(makeProfile({ city: '杭州', expectedPositions: ['前端开发工程师'] }));
+ await saveJobPreference({
+ position: '电池工程师',
+ city: '宁德',
+ salary: '',
+ workType: '全职',
+ status: '',
+ });
+
+ await expect(fetchJobPreference()).resolves.toMatchObject({
+ position: '电池工程师',
+ city: '宁德',
+ });
+ // 岗位排序同样按显式偏好:宁德岗位 90+15=105 越过杭州岗位 85+15=100
+ const { jobs } = await fetchJobList(DEFAULT_PARAMS);
+ expect(idsOf(jobs)[0]).toBe('9');
+ });
+
+ it('显式偏好存储损坏时回退到引导资料推导', async () => {
+ await saveOnboardingProfile(makeProfile({ city: '杭州' }));
+ await storageSet('user:job-preference', '{ 这不是合法 JSON');
+ await expect(fetchJobPreference()).resolves.toMatchObject({ city: '杭州' });
+ });
+});
diff --git a/src/__tests__/onboarding-checklist.test.ts b/src/__tests__/onboarding-checklist.test.ts
new file mode 100644
index 0000000..d8cfe52
--- /dev/null
+++ b/src/__tests__/onboarding-checklist.test.ts
@@ -0,0 +1,221 @@
+import {
+ addChecklistItem,
+ buildChecklistText,
+ fetchOnboardingChecklist,
+ removeChecklistItem,
+ toggleChecklistItem,
+} from '../api/api';
+import type { OnboardingChecklist } from '../types';
+
+// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage。
+// 这里用带状态的 Map 版 mock(不是恒返回 null 的空实现),
+// 否则「勾选后重新进页面还在」这类落库行为根本没被验证到。
+jest.mock('../lib/storage', () => {
+ const store = new Map();
+ return {
+ __store: store,
+ storageGet: jest.fn(async (key: string) => store.get(key) ?? null),
+ storageSet: jest.fn(async (key: string, value: string) => {
+ store.set(key, value);
+ }),
+ storageRemove: jest.fn(async (key: string) => {
+ store.delete(key);
+ }),
+ };
+});
+
+const store = (jest.requireMock('../lib/storage') as { __store: Map }).__store;
+
+/** 存储键(取自 api.ts,用于构造「存储内容损坏」的降级用例) */
+const DONE_KEY = 'onboarding:checklist-done';
+const CUSTOM_KEY = 'onboarding:checklist-custom';
+
+/** 模板清单的基线:4 组共 18 条,初始已完成 3 条 */
+const TEMPLATE_TOTAL = 18;
+const TEMPLATE_DONE = 3;
+
+function findItem(checklist: OnboardingChecklist, itemId: string) {
+ const found = checklist.groups.flatMap((group) => group.items).find((item) => item.id === itemId);
+ if (!found) throw new Error(`清单里找不到条目 ${itemId}`);
+ return found;
+}
+
+function findGroup(checklist: OnboardingChecklist, groupId: string) {
+ return checklist.groups.find((group) => group.id === groupId);
+}
+
+beforeEach(() => {
+ store.clear();
+});
+
+describe('fetchOnboardingChecklist(入职准备清单)', () => {
+ it('首次拉取:模板四组共 18 条,已完成 3 条,没有自定义分组', async () => {
+ const checklist = await fetchOnboardingChecklist();
+ expect(checklist.total).toBe(TEMPLATE_TOTAL);
+ expect(checklist.completed).toBe(TEMPLATE_DONE);
+ expect(checklist.groups.map((g) => g.id)).toEqual(['material', 'procedure', 'study', 'life']);
+ });
+
+ it('存储内容损坏时退回模板值,不把异常抛到页面', async () => {
+ store.set(DONE_KEY, '{不是合法 JSON');
+ store.set(CUSTOM_KEY, '["也不是数组里的条目"]"');
+
+ const checklist = await fetchOnboardingChecklist();
+ expect(checklist.total).toBe(TEMPLATE_TOTAL);
+ expect(checklist.completed).toBe(TEMPLATE_DONE);
+ });
+
+ it('自定义存储里放的不是数组时按没有自定义条目处理', async () => {
+ store.set(CUSTOM_KEY, '{"id":"custom-1"}');
+
+ const checklist = await fetchOnboardingChecklist();
+ expect(findGroup(checklist, 'custom')).toBeUndefined();
+ expect(checklist.total).toBe(TEMPLATE_TOTAL);
+ });
+});
+
+describe('toggleChecklistItem(勾选落库)', () => {
+ it('勾选后计数 +1,且重新拉取仍然是勾上的', async () => {
+ await toggleChecklistItem('m4', true);
+
+ // 换一次全新的读取,模拟退出页面再进来
+ const checklist = await fetchOnboardingChecklist();
+ expect(findItem(checklist, 'm4').done).toBe(true);
+ expect(checklist.completed).toBe(TEMPLATE_DONE + 1);
+ expect(checklist.total).toBe(TEMPLATE_TOTAL);
+ });
+
+ it('取消勾选后计数回落(不会只增不减)', async () => {
+ await toggleChecklistItem('m1', false);
+
+ const checklist = await fetchOnboardingChecklist();
+ expect(findItem(checklist, 'm1').done).toBe(false);
+ expect(checklist.completed).toBe(TEMPLATE_DONE - 1);
+ });
+
+ it('只影响被点的那一条,同组其他条目不受牵连', async () => {
+ await toggleChecklistItem('m4', true);
+
+ const checklist = await fetchOnboardingChecklist();
+ expect(findItem(checklist, 'm1').done).toBe(true);
+ expect(findItem(checklist, 'm2').done).toBe(true);
+ expect(findItem(checklist, 'm5').done).toBe(false);
+ });
+});
+
+describe('addChecklistItem(新增自定义待办)', () => {
+ it('新增后出现「我的自定义待办」分组,总数 +1,且默认未完成', async () => {
+ const checklist = await addChecklistItem('找 HR 确认工位');
+
+ const customGroup = findGroup(checklist, 'custom');
+ expect(customGroup).toBeDefined();
+ expect(customGroup?.items).toHaveLength(1);
+ expect(customGroup?.items[0].title).toBe('找 HR 确认工位');
+ expect(customGroup?.items[0].done).toBe(false);
+ expect(checklist.total).toBe(TEMPLATE_TOTAL + 1);
+ expect(checklist.completed).toBe(TEMPLATE_DONE);
+ });
+
+ it('首尾空格被裁掉', async () => {
+ const checklist = await addChecklistItem(' 准备入职材料 ');
+ expect(findGroup(checklist, 'custom')?.items[0].title).toBe('准备入职材料');
+ });
+
+ it('标题只有空格时 reject,且不写入任何条目', async () => {
+ await expect(addChecklistItem(' ')).rejects.toThrow('待办内容不能为空');
+
+ const checklist = await fetchOnboardingChecklist();
+ expect(checklist.total).toBe(TEMPLATE_TOTAL);
+ });
+
+ it('自定义条目勾选后也落库(勾选状态存在另一份带标题的存档里)', async () => {
+ const added = await addChecklistItem('找 HR 确认工位');
+ const customId = findGroup(added, 'custom')?.items[0].id;
+ expect(customId).toBeDefined();
+
+ await toggleChecklistItem(customId as string, true);
+
+ const checklist = await fetchOnboardingChecklist();
+ expect(findItem(checklist, customId as string).done).toBe(true);
+ expect(checklist.completed).toBe(TEMPLATE_DONE + 1);
+ });
+});
+
+describe('removeChecklistItem(删除自定义待办)', () => {
+ it('删掉唯一一条自定义待办后,自定义分组整个消失且计数回落', async () => {
+ const added = await addChecklistItem('找 HR 确认工位');
+ const customId = findGroup(added, 'custom')?.items[0].id as string;
+
+ const checklist = await removeChecklistItem(customId);
+
+ expect(findGroup(checklist, 'custom')).toBeUndefined();
+ expect(checklist.total).toBe(TEMPLATE_TOTAL);
+ expect(checklist.groups.flatMap((g) => g.items).some((i) => i.id === customId)).toBe(false);
+ });
+
+ it('删掉已勾选的自定义待办后,勾选记录一并清掉(completed 不虚高)', async () => {
+ const added = await addChecklistItem('找 HR 确认工位');
+ const customId = findGroup(added, 'custom')?.items[0].id as string;
+ const checked = await toggleChecklistItem(customId, true);
+ expect(checked.completed).toBe(TEMPLATE_DONE + 1);
+
+ const checklist = await removeChecklistItem(customId);
+
+ expect(checklist.completed).toBe(TEMPLATE_DONE);
+ expect(checklist.total).toBe(TEMPLATE_TOTAL);
+ });
+
+ it('模板条目不允许删除(删了下次进页面又会出现)', async () => {
+ await expect(removeChecklistItem('m1')).rejects.toThrow('模板待办不能删除');
+
+ const checklist = await fetchOnboardingChecklist();
+ expect(checklist.total).toBe(TEMPLATE_TOTAL);
+ });
+
+ it('删除后不影响其余自定义条目', async () => {
+ const first = await addChecklistItem('找 HR 确认工位');
+ const firstId = findGroup(first, 'custom')?.items[0].id as string;
+ const second = await addChecklistItem('预约体检');
+ const kept = findGroup(second, 'custom')?.items.find((i) => i.id !== firstId);
+ expect(kept).toBeDefined();
+
+ const checklist = await removeChecklistItem(firstId);
+
+ const customGroup = findGroup(checklist, 'custom');
+ expect(customGroup?.items).toHaveLength(1);
+ expect(customGroup?.items[0].title).toBe('预约体检');
+ });
+});
+
+describe('buildChecklistText(导出文本,纯函数)', () => {
+ it('含标题行、进度行、分组名与勾选标记', async () => {
+ const checklist = await fetchOnboardingChecklist();
+ const text = buildChecklistText(checklist);
+
+ expect(text).toContain(`入职准备清单(${checklist.company} · ${checklist.position})`);
+ expect(text).toContain(`已完成 ${checklist.completed}/${checklist.total}`);
+ checklist.groups.forEach((group) => {
+ expect(text).toContain(`【${group.title}】`);
+ });
+ expect(text).toContain('[x] 身份证原件及复印件');
+ expect(text).toContain('[ ] 入职体检报告');
+ });
+
+ it('勾选标记与条目 done 一一对应,条数不缺不多', async () => {
+ const checklist = await fetchOnboardingChecklist();
+ const text = buildChecklistText(checklist);
+
+ const checked = (text.match(/^\[x\] /gm) ?? []).length;
+ const unchecked = (text.match(/^\[ \] /gm) ?? []).length;
+ expect(checked).toBe(checklist.completed);
+ expect(checked + unchecked).toBe(checklist.total);
+ });
+
+ it('自定义条目也会进导出文本', async () => {
+ const checklist = await addChecklistItem('找 HR 确认工位');
+ const text = buildChecklistText(checklist);
+
+ expect(text).toContain('【我的自定义待办】');
+ expect(text).toContain('[ ] 找 HR 确认工位');
+ });
+});
diff --git a/src/__tests__/onboarding-profile.test.ts b/src/__tests__/onboarding-profile.test.ts
new file mode 100644
index 0000000..70e942e
--- /dev/null
+++ b/src/__tests__/onboarding-profile.test.ts
@@ -0,0 +1,162 @@
+import { fromOnboardingProfile, toOnboardingProfile } from '../api/api';
+import type { OnboardingData } from '../types';
+
+// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock(与 password.test.ts 同)
+jest.mock('../lib/storage', () => ({
+ storageGet: jest.fn(async () => null),
+ storageSet: jest.fn(async () => undefined),
+ storageRemove: jest.fn(async () => undefined),
+}));
+
+/**
+ * 每个字段都给一个「只属于它」的值,且与 BASE 不同。
+ * 这样任何字段没被 fromOnboardingProfile 映射回去,都会回落到 BASE 的值而被断言抓到。
+ */
+const DATA: OnboardingData = {
+ phone: '13800138000',
+ code: '1234',
+ agreed: true,
+ wechatTicket: { token: 'wx-token', openid: 'wx-openid' },
+ identity: 'candidate',
+ isStudent: true,
+ city: '杭州',
+ expectedPositions: ['鸿蒙开发工程师', '前端开发工程师'],
+ expectedSalaryMin: '20K',
+ expectedSalaryMax: '35K',
+ resumeName: '张小满',
+ gender: '男',
+ birthYear: '1998年',
+ birthMonth: '3月',
+ jobStatus: '离职-随时到岗',
+ disabledJobseeker: true,
+ firstWorkYear: '2021年',
+ firstWorkMonth: '6月',
+ currentPosition: '高级前端工程师',
+ company: '某某科技有限公司',
+ joinYear: '2023年',
+ joinMonth: '2月',
+ skills: ['React', 'TypeScript'],
+ workContent: '负责招聘端前端架构',
+ education: '硕士',
+ eduType: '非全日制',
+ school: '某某大学',
+ avatar: 'avatarDesigner',
+ recruiterCompany: '某某科技有限公司',
+ recruiterContact: '王招聘',
+ recruiterLicenseUri: 'file:///mock/license.jpg',
+};
+
+/** 兜底状态:每个字段都与 DATA 不同,用于识别「没被覆盖」的字段 */
+const BASE: OnboardingData = {
+ phone: '',
+ code: '',
+ agreed: false,
+ wechatTicket: null,
+ identity: null,
+ isStudent: null,
+ city: '',
+ expectedPositions: [],
+ expectedSalaryMin: '',
+ expectedSalaryMax: '',
+ resumeName: '',
+ gender: null,
+ birthYear: '',
+ birthMonth: '',
+ jobStatus: null,
+ disabledJobseeker: false,
+ firstWorkYear: '',
+ firstWorkMonth: '',
+ currentPosition: null,
+ company: '',
+ joinYear: '',
+ joinMonth: '',
+ skills: [],
+ workContent: '',
+ education: null,
+ eduType: null,
+ school: '',
+ avatar: null,
+ recruiterCompany: '',
+ recruiterContact: '',
+ recruiterLicenseUri: null,
+};
+
+describe('toOnboardingProfile(流程状态 → 持久化资料)', () => {
+ it('剔除登录瞬时字段,不落库', () => {
+ const profile = toOnboardingProfile(DATA);
+ const keys = Object.keys(profile);
+ expect(keys).not.toContain('phone');
+ expect(keys).not.toContain('code');
+ expect(keys).not.toContain('agreed');
+ expect(keys).not.toContain('wechatTicket');
+ });
+
+ it('求职者身份下不产生企业端认证信息', () => {
+ expect(toOnboardingProfile(DATA).recruiter).toBeNull();
+ });
+
+ it('招聘者身份下公司名与联系人收进 recruiter(不再污染毕业学校)', () => {
+ const profile = toOnboardingProfile({ ...DATA, identity: 'recruiter' });
+ expect(profile.recruiter).toEqual({
+ company: DATA.recruiterCompany,
+ contactName: DATA.recruiterContact,
+ licenseUri: DATA.recruiterLicenseUri,
+ });
+ expect(profile.school).toBe(DATA.school);
+ });
+});
+
+describe('fromOnboardingProfile(持久化资料 → 流程状态)', () => {
+ it('全部资料字段原样还原', () => {
+ const restored = fromOnboardingProfile(toOnboardingProfile(DATA), BASE);
+ const {
+ phone,
+ code,
+ agreed,
+ wechatTicket,
+ recruiterCompany,
+ recruiterContact,
+ recruiterLicenseUri,
+ ...profileFields
+ } = DATA;
+ // 资料字段逐项一致;未映射的字段会回落到 BASE 从而失败
+ Object.entries(profileFields).forEach(([key, expected]) => {
+ expect(restored[key as keyof OnboardingData]).toEqual(expected);
+ });
+ // 顺带确认这个测试本身是有效的:BASE 与 DATA 确实不同
+ expect(restored.city).not.toBe(BASE.city);
+ expect(phone).toBe('13800138000');
+ expect(code).toBe('1234');
+ expect(agreed).toBe(true);
+ expect(wechatTicket).not.toBeNull();
+ // 企业端三字段在求职者身份下刻意不落库,由下面的用例专门断言
+ expect(recruiterCompany).toBe('某某科技有限公司');
+ expect(recruiterContact).toBe('王招聘');
+ expect(recruiterLicenseUri).toBe('file:///mock/license.jpg');
+ });
+
+ it('求职者身份下不保留企业端字段', () => {
+ const restored = fromOnboardingProfile(toOnboardingProfile(DATA), BASE);
+ expect(restored.recruiterCompany).toBe(BASE.recruiterCompany);
+ expect(restored.recruiterContact).toBe(BASE.recruiterContact);
+ expect(restored.recruiterLicenseUri).toBe(BASE.recruiterLicenseUri);
+ });
+
+ it('登录瞬时字段沿用兜底状态,不被资料覆盖', () => {
+ const restored = fromOnboardingProfile(toOnboardingProfile(DATA), BASE);
+ expect(restored.phone).toBe(BASE.phone);
+ expect(restored.code).toBe(BASE.code);
+ expect(restored.agreed).toBe(BASE.agreed);
+ expect(restored.wechatTicket).toBe(BASE.wechatTicket);
+ });
+
+ it('企业端资料还原到 recruiterCompany / recruiterContact / recruiterLicenseUri', () => {
+ const restored = fromOnboardingProfile(
+ toOnboardingProfile({ ...DATA, identity: 'recruiter' }),
+ BASE,
+ );
+ expect(restored.recruiterCompany).toBe(DATA.recruiterCompany);
+ expect(restored.recruiterContact).toBe(DATA.recruiterContact);
+ expect(restored.recruiterLicenseUri).toBe(DATA.recruiterLicenseUri);
+ });
+});
diff --git a/src/__tests__/resume-export.test.ts b/src/__tests__/resume-export.test.ts
new file mode 100644
index 0000000..f09c150
--- /dev/null
+++ b/src/__tests__/resume-export.test.ts
@@ -0,0 +1,240 @@
+/**
+ * 简历导出排版(纯函数)测试。
+ *
+ * 只测 `src/utils/resumeHtml.ts`:HTML 生成、纸张尺寸、文件名清洗。
+ * 原生落盘(expo-print)不在此覆盖 —— 它由 P7 的真机 smoke test 验证。
+ */
+import { buildResumeExportHtml, exportPageSize, sanitizeFileName } from '../utils';
+import type { Resume, ResumeExportSettings } from '../types';
+
+const SETTINGS: ResumeExportSettings = {
+ format: 'pdf',
+ templateId: 'classic',
+ hideContact: false,
+ exportPassword: false,
+ paperSize: 'A4',
+ orientation: 'portrait',
+ fileName: '',
+ includeCover: false,
+ includeRecommendation: false,
+};
+
+function makeResume(patch: Partial = {}): Resume {
+ return {
+ score: { score: 80, maxScore: 100, optimizePoints: [] },
+ basicInfo: {
+ name: '张小满',
+ avatar: '张',
+ phone: '13800001111',
+ email: 'zhang@example.com',
+ location: '杭州',
+ },
+ jobIntention: {
+ position: '鸿蒙开发工程师',
+ salary: '20K-35K',
+ city: '杭州',
+ status: 'active',
+ statusLabel: '在职-月内到岗',
+ },
+ education: [],
+ experience: [],
+ projects: [],
+ skills: [],
+ selfEvaluation: { content: '' },
+ ...patch,
+ };
+}
+
+describe('exportPageSize', () => {
+ it('A4 纵向 = 595×842 pt', () => {
+ expect(exportPageSize('A4', 'portrait')).toEqual({ width: 595, height: 842 });
+ });
+ it('A4 横向把长边给宽度', () => {
+ expect(exportPageSize('A4', 'landscape')).toEqual({ width: 842, height: 595 });
+ });
+ it('Letter 纵向 = 612×792 pt', () => {
+ expect(exportPageSize('letter', 'portrait')).toEqual({ width: 612, height: 792 });
+ });
+});
+
+describe('sanitizeFileName', () => {
+ it('剔除路径分隔符与系统保留字符', () => {
+ expect(sanitizeFileName('张小满/我的:简历*?.pdf')).toBe('张小满我的简历.pdf');
+ });
+ it('首尾空格被清掉', () => {
+ expect(sanitizeFileName(' 我的简历 ')).toBe('我的简历');
+ });
+ it('清完为空回落「我的简历」', () => {
+ expect(sanitizeFileName('///')).toBe('我的简历');
+ expect(sanitizeFileName(' ')).toBe('我的简历');
+ });
+});
+
+describe('buildResumeExportHtml', () => {
+ it('空板块整段略去(导出物里不写「未填写 X」)', () => {
+ const html = buildResumeExportHtml(makeResume(), SETTINGS);
+ expect(html).not.toContain('教育背景');
+ expect(html).not.toContain('工作经历');
+ expect(html).not.toContain('项目经历');
+ expect(html).not.toContain('技能特长');
+ expect(html).not.toContain('自我评价');
+ // 有内容的板块仍在
+ expect(html).toContain('求职意向');
+ expect(html).toContain('鸿蒙开发工程师');
+ });
+
+ it('隐藏联系方式时手机号与邮箱都不出现,但所在城市保留', () => {
+ const html = buildResumeExportHtml(makeResume(), { ...SETTINGS, hideContact: true });
+ expect(html).not.toContain('13800001111');
+ expect(html).not.toContain('zhang@example.com');
+ expect(html).toContain('杭州');
+ });
+
+ it('未隐藏联系方式时三者在同一行', () => {
+ const html = buildResumeExportHtml(makeResume(), SETTINGS);
+ expect(html).toContain('13800001111 · zhang@example.com · 杭州');
+ });
+
+ it('用户填的 HTML 被转义,不会破坏结构', () => {
+ const html = buildResumeExportHtml(
+ makeResume({
+ basicInfo: {
+ name: '',
+ avatar: '',
+ phone: '',
+ email: '',
+ location: '',
+ },
+ }),
+ SETTINGS,
+ );
+ expect(html).not.toContain('');
+ expect(html).toContain('<script>alert(1)</script>');
+ });
+
+ it('姓名与职位都为空时用「未设置」占位,不编造人名', () => {
+ const html = buildResumeExportHtml(
+ makeResume({
+ basicInfo: { name: '', avatar: '', phone: '', email: '', location: '' },
+ jobIntention: {
+ position: '',
+ salary: '',
+ city: '',
+ status: 'active',
+ statusLabel: '',
+ },
+ }),
+ SETTINGS,
+ );
+ expect(html).toContain('未设置');
+ expect(html).not.toContain('李明');
+ });
+
+ it('模板 id 非法时回落经典版式(不把 storage 里的值拼进 class)', () => {
+ const html = buildResumeExportHtml(makeResume(), {
+ ...SETTINGS,
+ templateId: '">',
+ });
+ expect(html).toContain('class="v-classic"');
+ expect(html).not.toContain('');
+ });
+
+ it('模板强调色进入 HTML:创意设计用 info 色', () => {
+ const html = buildResumeExportHtml(makeResume(), { ...SETTINGS, templateId: 'creative' });
+ expect(html).toContain('class="v-creative"');
+ expect(html).toContain('border-bottom: 2px solid #7C5CFC');
+ });
+
+ it('封面页按开关生成', () => {
+ const without = buildResumeExportHtml(makeResume(), SETTINGS);
+ const withCover = buildResumeExportHtml(makeResume(), { ...SETTINGS, includeCover: true });
+ expect(without).not.toContain('');
+ expect(withCover).toContain('');
+ expect(withCover).toContain('张小满
');
+ });
+
+ it('自荐信以自我评价为正文', () => {
+ const html = buildResumeExportHtml(
+ makeResume({ selfEvaluation: { content: '三年鸿蒙开发经验。' } }),
+ { ...SETTINGS, includeRecommendation: true },
+ );
+ expect(html).toContain('自荐信');
+ expect(html).toContain('三年鸿蒙开发经验。');
+ });
+
+ it('自我评价为空时不生成自荐信页(不编造内容)', () => {
+ const html = buildResumeExportHtml(makeResume(), {
+ ...SETTINGS,
+ includeRecommendation: true,
+ });
+ // 只能断言正文里没有自荐信区块:CSS 里三个版式的规则是恒定写死的
+ expect(html).not.toContain('自荐信');
+ expect(html).not.toContain('');
+ });
+
+ it('AI 徽标不出现在导出物里(那是 App 内的辅助信息)', () => {
+ const html = buildResumeExportHtml(
+ makeResume({
+ experience: [
+ {
+ id: 'exp-1',
+ company: '某某科技',
+ position: '前端工程师',
+ startDate: '2023年2月',
+ endDate: '至今',
+ description: '负责鸿蒙应用开发',
+ aiOptimized: true,
+ },
+ ],
+ }),
+ SETTINGS,
+ );
+ expect(html).toContain('某某科技');
+ expect(html).toContain('2023年2月 - 至今');
+ expect(html).not.toContain('AI');
+ });
+
+ it('工作内容的多行换行不被压掉(white-space: pre-line)', () => {
+ const html = buildResumeExportHtml(
+ makeResume({
+ experience: [
+ {
+ id: 'exp-1',
+ company: '某某科技',
+ position: '',
+ startDate: '',
+ endDate: '',
+ description: '第一行\n第二行',
+ aiOptimized: false,
+ },
+ ],
+ }),
+ SETTINGS,
+ );
+ expect(html).toContain('第一行\n第二行');
+ expect(html).toContain('white-space: pre-line');
+ });
+
+ it('教育条目的专业学历为空时不拼出半截分隔符', () => {
+ const html = buildResumeExportHtml(
+ makeResume({
+ education: [
+ { id: 'edu-1', school: '某某大学', major: '', degree: '硕士', startYear: '2018', endYear: '' },
+ ],
+ }),
+ SETTINGS,
+ );
+ expect(html).toContain('硕士');
+ expect(html).not.toContain('· 硕士');
+ expect(html).toContain('2018');
+ });
+
+ it('纸型与方向写进 @page / 页宽页高', () => {
+ const html = buildResumeExportHtml(makeResume(), {
+ ...SETTINGS,
+ paperSize: 'letter',
+ orientation: 'landscape',
+ });
+ expect(html).toContain('max-width: 612pt; margin: 0 auto;');
+ });
+});
diff --git a/src/__tests__/resume-override.test.ts b/src/__tests__/resume-override.test.ts
new file mode 100644
index 0000000..4cc1057
--- /dev/null
+++ b/src/__tests__/resume-override.test.ts
@@ -0,0 +1,363 @@
+import {
+ clearOnboardingProfile,
+ clearResumeOverride,
+ fetchResume,
+ fetchResumeExportSettings,
+ fetchResumeSavedAt,
+ optimizeResume,
+ optimizeResumeExperience,
+ optimizeResumeWithJD,
+ polishResumeSelfEvaluation,
+ saveOnboardingProfile,
+ saveResumeBasicInfo,
+ saveResumeEducation,
+ saveResumeExperience,
+ saveResumeExportSettings,
+ saveResumeProjects,
+ saveResumeSelfEvaluation,
+ saveResumeSkills,
+ saveResumeSnapshot,
+} from '../api/api';
+import { storageRemove, storageSet } from '../lib/storage';
+import type { OnboardingProfile, ResumeExportSettings } from '../types';
+
+// 内存版 storage mock(与 resume-profile.test.ts 同):键是 api 层传入的原始键
+jest.mock('../lib/storage', () => {
+ const store: Record = {};
+ return {
+ storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)),
+ storageSet: jest.fn(async (k: string, v: string) => {
+ store[k] = v;
+ }),
+ storageRemove: jest.fn(async (k: string) => {
+ delete store[k];
+ }),
+ };
+});
+
+const EMPTY_PROFILE: OnboardingProfile = {
+ identity: 'candidate',
+ isStudent: null,
+ city: '',
+ expectedPositions: [],
+ expectedSalaryMin: '',
+ expectedSalaryMax: '',
+ resumeName: '',
+ gender: null,
+ birthYear: '',
+ birthMonth: '',
+ jobStatus: null,
+ disabledJobseeker: false,
+ firstWorkYear: '',
+ firstWorkMonth: '',
+ currentPosition: null,
+ company: '',
+ joinYear: '',
+ joinMonth: '',
+ skills: [],
+ workContent: '',
+ education: null,
+ eduType: null,
+ school: '',
+ avatar: null,
+ recruiter: null,
+};
+
+const makeProfile = (patch: Partial): OnboardingProfile => ({
+ ...EMPTY_PROFILE,
+ ...patch,
+});
+
+/** 引导采集齐全的一份资料(与 resume-profile.test.ts 的 FULL_PROFILE 同源) */
+const FULL_PROFILE = makeProfile({
+ city: '杭州',
+ expectedPositions: ['鸿蒙开发工程师'],
+ expectedSalaryMin: '20K',
+ expectedSalaryMax: '35K',
+ resumeName: '张小满',
+ jobStatus: '在职-月内到岗',
+ currentPosition: '高级前端工程师',
+ company: '某某科技有限公司',
+ joinYear: '2023年',
+ joinMonth: '2月',
+ skills: ['React', 'TypeScript'],
+ workContent: '负责招聘端前端架构与组件库建设',
+ education: '硕士',
+ eduType: '非全日制',
+ school: '某某大学',
+});
+
+describe('简历覆盖层:用户编辑优先于引导派生', () => {
+ beforeEach(async () => {
+ await clearOnboardingProfile();
+ await storageRemove('user:job-preference');
+ await clearResumeOverride();
+ await storageRemove('resume:export-settings');
+ });
+
+ it('未编辑任何板块时与引导派生完全一致(合并的默认分支是恒等变换)', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ const resume = await fetchResume();
+ expect(resume.basicInfo).toEqual({
+ name: '张小满',
+ avatar: '张',
+ phone: '',
+ email: '',
+ location: '',
+ });
+ expect(resume.education).toEqual([
+ { id: 'edu-onboarding', school: '某某大学', major: '', degree: '硕士 非全日制', startYear: '', endYear: '' },
+ ]);
+ expect(resume.experience).toEqual([
+ {
+ id: 'exp-onboarding',
+ company: '某某科技有限公司',
+ position: '高级前端工程师',
+ startDate: '2023年2月',
+ endDate: '至今',
+ description: '负责招聘端前端架构与组件库建设',
+ aiOptimized: false,
+ },
+ ]);
+ expect(resume.skills).toEqual([{ name: 'React' }, { name: 'TypeScript' }]);
+ });
+
+ it('保存基本信息后以覆盖层为准,头像字仍由接口层按姓名派生', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ await saveResumeBasicInfo({
+ name: '王小二',
+ phone: '13800138000',
+ email: 'wang@example.com',
+ location: '上海',
+ });
+
+ const resume = await fetchResume();
+ expect(resume.basicInfo).toEqual({
+ name: '王小二',
+ avatar: '王',
+ phone: '13800138000',
+ email: 'wang@example.com',
+ location: '上海',
+ });
+ expect(JSON.stringify(resume)).not.toContain('张小满');
+ });
+
+ it('板块级独立:只编辑教育时,工作经历仍来自引导派生', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ await saveResumeEducation([{ id: 'edu-onboarding', school: '某某大学', major: '软件工程', degree: '硕士', startYear: '2018', endYear: '2021' }]);
+
+ const resume = await fetchResume();
+ expect(resume.education[0].major).toBe('软件工程');
+ expect(resume.experience[0].id).toBe('exp-onboarding');
+ expect(resume.skills).toEqual([{ name: 'React' }, { name: 'TypeScript' }]);
+ });
+
+ it('清空某个列表后不回落到引导资料(`??` 不是 `||`)', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ await saveResumeEducation([]);
+
+ const resume = await fetchResume();
+ expect(resume.education).toEqual([]);
+ // 未动过的板块不受影响
+ expect(resume.experience).toHaveLength(1);
+ });
+
+ it('编辑引导派生出来的那一条不会把整个板块改丢', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ const before = await fetchResume();
+ // 页面交回的就是它此刻看到的列表(含 id 为 edu-onboarding 的那条),只改学校
+ await saveResumeEducation(before.education.map((it) => ({ ...it, school: '某某大学(改名后)' })));
+
+ const resume = await fetchResume();
+ expect(resume.education).toHaveLength(1);
+ expect(resume.education[0].id).toBe('edu-onboarding');
+ expect(resume.education[0].school).toBe('某某大学(改名后)');
+ expect(resume.education[0].degree).toBe('硕士 非全日制');
+ });
+
+ it('新增条目由接口层补 id,同一毫秒连续新增两条也不会撞 id', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ const items = [
+ { school: 'A 大学', major: '', degree: '本科', startYear: '2014', endYear: '2018' },
+ { school: 'B 大学', major: '', degree: '硕士', startYear: '2018', endYear: '2021' },
+ ];
+ const saved = await saveResumeEducation(items);
+ expect(saved.every((it) => it.id !== '')).toBe(true);
+ expect(saved[0].id).not.toBe(saved[1].id);
+
+ const resume = await fetchResume();
+ expect(resume.education.map((it) => it.school)).toEqual(['A 大学', 'B 大学']);
+ });
+
+ it('重复 id 会被去重(页面拼接出错也不会产生重复列表 key)', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ const saved = await saveResumeEducation([
+ { id: 'edu-x', school: 'A 大学', major: '', degree: '', startYear: '', endYear: '' },
+ { id: 'edu-x', school: 'B 大学', major: '', degree: '', startYear: '', endYear: '' },
+ ]);
+ expect(saved).toHaveLength(1);
+ expect(saved[0].school).toBe('B 大学');
+ });
+
+ it('工作经历与项目经历同样走覆盖层', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ await saveResumeExperience([
+ {
+ id: 'exp-onboarding',
+ company: '某某科技有限公司',
+ position: '前端负责人',
+ startDate: '2023年2月',
+ endDate: '至今',
+ description: '负责组件库建设',
+ aiOptimized: false,
+ },
+ ]);
+ await saveResumeProjects([{ name: '招聘端重构', role: '前端负责人', description: '首屏从 3.5s 降至 1.2s', aiOptimized: false }]);
+
+ const resume = await fetchResume();
+ expect(resume.experience[0].position).toBe('前端负责人');
+ expect(resume.projects[0].name).toBe('招聘端重构');
+ expect(resume.projects[0].id).not.toBe('');
+ });
+
+ it('技能保存在接口层规整:trim / 去空 / 去重', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ const saved = await saveResumeSkills([{ name: ' React ' }, { name: 'React' }, { name: '' }, { name: 'TS' }]);
+ expect(saved).toEqual([{ name: 'React' }, { name: 'TS' }]);
+ await expect(fetchResume()).resolves.toMatchObject({
+ skills: [{ name: 'React' }, { name: 'TS' }],
+ });
+ });
+
+ it('自我评价保存与清空', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ await saveResumeSelfEvaluation(' 3 年前端经验 ');
+ await expect(fetchResume()).resolves.toMatchObject({ selfEvaluation: { content: '3 年前端经验' } });
+
+ await saveResumeSelfEvaluation(' ');
+ await expect(fetchResume()).resolves.toMatchObject({ selfEvaluation: { content: '' } });
+ });
+
+ it('覆盖层存储损坏时按「未编辑过」处理,回落引导派生', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ await storageSet('resume:override', '{oops');
+ const resume = await fetchResume();
+ expect(resume.education[0].id).toBe('edu-onboarding');
+ });
+
+ it('完全没有引导资料时仍回落演示简历(保护既有行为)', async () => {
+ await clearOnboardingProfile();
+ const resume = await fetchResume();
+ expect(resume.basicInfo.name).toBe('李明');
+ });
+});
+
+describe('简历「保存到本机」', () => {
+ beforeEach(async () => {
+ await clearOnboardingProfile();
+ await clearResumeOverride();
+ });
+
+ it('从未保存过时保存时间为空串', async () => {
+ await expect(fetchResumeSavedAt()).resolves.toBe('');
+ });
+
+ it('保存后写入保存时间,且当前简历被固化(再改引导资料不再影响已保存的板块)', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ const resume = await fetchResume();
+ const { savedAt } = await saveResumeSnapshot(resume);
+
+ expect(savedAt).not.toBe('');
+ await expect(fetchResumeSavedAt()).resolves.toBe(savedAt);
+
+ // 固化后引导资料里的姓名变了,已保存的简历仍是保存那一刻的内容
+ await saveOnboardingProfile(makeProfile({ ...FULL_PROFILE, resumeName: '改过的名字' }));
+ await expect(fetchResume()).resolves.toMatchObject({ basicInfo: { name: '张小满' } });
+ });
+
+ it('退出登录会清掉覆盖层与保存时间(不让下一位用户看到上一位的编辑)', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ await saveResumeSnapshot(await fetchResume());
+ await clearResumeOverride();
+
+ await expect(fetchResumeSavedAt()).resolves.toBe('');
+ await expect(fetchResume()).resolves.toMatchObject({ basicInfo: { name: '张小满' } });
+ // 引导资料还在(clearResumeOverride 只清简历编辑,不动引导资料)
+ await expect(fetchResume()).resolves.toMatchObject({ education: [{ school: '某某大学' }] });
+ });
+});
+
+describe('AI 能力未接入时的诚实口径', () => {
+ beforeEach(async () => {
+ await clearOnboardingProfile();
+ await clearResumeOverride();
+ });
+
+ it('三个 AI 入口都返回 available:false,且调用前后简历完全不变', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ const before = await fetchResume();
+
+ const results = await Promise.all([
+ optimizeResumeExperience('exp-onboarding'),
+ polishResumeSelfEvaluation('随便写点什么'),
+ optimizeResumeWithJD('岗位 JD'),
+ ]);
+ for (const result of results) {
+ expect(result.available).toBe(false);
+ expect(result.message).not.toBe('');
+ }
+ await expect(fetchResume()).resolves.toEqual(before);
+ });
+
+ it('一键优化保留用户编辑,且不给任何条目置「已优化」', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ await saveResumeEducation([{ id: 'edu-onboarding', school: '某某大学', major: '软件工程', degree: '硕士', startYear: '', endYear: '' }]);
+
+ const { resume, score } = await optimizeResume();
+ expect(score).toBe(92);
+ expect(resume.score.optimizePoints.every((p) => p.optimized)).toBe(true);
+ // 用户编辑被保留
+ expect(resume.education[0].major).toBe('软件工程');
+ // 正文没有被改写,就不该显示「已优化」徽标
+ expect(resume.experience.every((it) => !it.aiOptimized)).toBe(true);
+ expect(resume.projects.every((it) => !it.aiOptimized)).toBe(true);
+ });
+});
+
+describe('导出设置落 storage', () => {
+ const CUSTOM: ResumeExportSettings = {
+ format: 'pdf',
+ templateId: 'tech',
+ hideContact: true,
+ exportPassword: false,
+ paperSize: 'letter',
+ orientation: 'landscape',
+ fileName: '我的简历',
+ includeCover: true,
+ includeRecommendation: false,
+ };
+
+ beforeEach(async () => {
+ await storageRemove('resume:export-settings');
+ });
+
+ it('保存后可回读(不再只改模块内常量)', async () => {
+ await saveResumeExportSettings(CUSTOM);
+ await expect(fetchResumeExportSettings()).resolves.toEqual(CUSTOM);
+ });
+
+ it('旧数据缺后加的字段时用默认值补齐', async () => {
+ await storageSet('resume:export-settings', JSON.stringify({ format: 'word' }));
+ await expect(fetchResumeExportSettings()).resolves.toMatchObject({
+ format: 'word',
+ templateId: 'classic',
+ paperSize: 'A4',
+ orientation: 'portrait',
+ });
+ });
+
+ it('存储损坏时回落默认值', async () => {
+ await storageSet('resume:export-settings', '{oops');
+ await expect(fetchResumeExportSettings()).resolves.toMatchObject({ format: 'pdf', templateId: 'classic' });
+ });
+});
diff --git a/src/__tests__/resume-profile.test.ts b/src/__tests__/resume-profile.test.ts
new file mode 100644
index 0000000..58a2ec1
--- /dev/null
+++ b/src/__tests__/resume-profile.test.ts
@@ -0,0 +1,245 @@
+import {
+ clearOnboardingProfile,
+ fetchResume,
+ optimizeResume,
+ saveJobPreference,
+ saveOnboardingProfile,
+} from '../api/api';
+import { storageRemove } from '../lib/storage';
+import type { OnboardingProfile } from '../types';
+
+// 内存版 storage mock(与 job-match.test.ts 同):键是 api 层传入的原始键
+jest.mock('../lib/storage', () => {
+ const store: Record = {};
+ return {
+ storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)),
+ storageSet: jest.fn(async (k: string, v: string) => {
+ store[k] = v;
+ }),
+ storageRemove: jest.fn(async (k: string) => {
+ delete store[k];
+ }),
+ };
+});
+
+/** 全部字段留空 / 为 null:等价于用户一路「跳过,直接进入」走到落地 */
+const EMPTY_PROFILE: OnboardingProfile = {
+ identity: 'candidate',
+ isStudent: null,
+ city: '',
+ expectedPositions: [],
+ expectedSalaryMin: '',
+ expectedSalaryMax: '',
+ resumeName: '',
+ gender: null,
+ birthYear: '',
+ birthMonth: '',
+ jobStatus: null,
+ disabledJobseeker: false,
+ firstWorkYear: '',
+ firstWorkMonth: '',
+ currentPosition: null,
+ company: '',
+ joinYear: '',
+ joinMonth: '',
+ skills: [],
+ workContent: '',
+ education: null,
+ eduType: null,
+ school: '',
+ avatar: null,
+ recruiter: null,
+};
+
+const makeProfile = (patch: Partial): OnboardingProfile => ({
+ ...EMPTY_PROFILE,
+ ...patch,
+});
+
+/** 引导采集齐全的一份资料(在职,且填了学历/学校/公司/职位/入职时间/技能/工作内容) */
+const FULL_PROFILE = makeProfile({
+ city: '杭州',
+ expectedPositions: ['鸿蒙开发工程师', '前端开发工程师'],
+ expectedSalaryMin: '20K',
+ expectedSalaryMax: '35K',
+ resumeName: '张小满',
+ jobStatus: '在职-月内到岗',
+ currentPosition: '高级前端工程师',
+ company: '某某科技有限公司',
+ joinYear: '2023年',
+ joinMonth: '2月',
+ skills: ['React', 'TypeScript', '鸿蒙'],
+ workContent: '负责招聘端前端架构与组件库建设',
+ education: '硕士',
+ eduType: '非全日制',
+ school: '某某大学',
+});
+
+describe('fetchResume 简历内容取自引导资料', () => {
+ beforeEach(async () => {
+ await clearOnboardingProfile();
+ await storageRemove('user:job-preference');
+ });
+
+ it('完全没有引导资料时才回落到演示简历', async () => {
+ const resume = await fetchResume();
+ expect(resume.basicInfo.name).toBe('李明');
+ expect(resume.education[0].school).toBe('北京大学');
+ });
+
+ it('有资料时不再出现演示简历的姓名 / 学历 / 公司', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ const resume = await fetchResume();
+ const serialized = JSON.stringify(resume);
+ expect(serialized).not.toContain('李明');
+ expect(serialized).not.toContain('北京大学');
+ expect(serialized).not.toContain('字节跳动');
+ expect(serialized).not.toContain('138-8888-8888');
+ });
+
+ it('一路跳过(资料全空)时各板块为空,不拿演示人设顶上', async () => {
+ await saveOnboardingProfile(EMPTY_PROFILE);
+ const resume = await fetchResume();
+ expect(resume.basicInfo).toEqual({
+ name: '',
+ avatar: '',
+ phone: '',
+ email: '',
+ location: '',
+ });
+ expect(resume.education).toEqual([]);
+ expect(resume.experience).toEqual([]);
+ expect(resume.projects).toEqual([]);
+ expect(resume.skills).toEqual([]);
+ expect(resume.selfEvaluation).toEqual({ content: '' });
+ expect(resume.jobIntention).toMatchObject({ position: '', salary: '', city: '', statusLabel: '' });
+ });
+
+ it('采集齐全时逐项映射到简历', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ const resume = await fetchResume();
+
+ // 姓名 + 头像字(头像字取姓名首字,与页面展示的姓名一致)
+ expect(resume.basicInfo.name).toBe('张小满');
+ expect(resume.basicInfo.avatar).toBe('张');
+ // 引导采集的是「期望城市」,不是「现居城市」,不拿它冒充所在城市
+ expect(resume.basicInfo.location).toBe('');
+
+ // 求职意向:期望职位取第 1 个(与「我的」页偏好卡片同一个值),薪资拼成区间
+ expect(resume.jobIntention.position).toBe('鸿蒙开发工程师');
+ expect(resume.jobIntention.salary).toBe('20K-35K');
+ expect(resume.jobIntention.city).toBe('杭州');
+
+ // 教育背景:学校 + 学历/学习形式;专业与年份引导未采集 → 留空
+ expect(resume.education).toEqual([
+ {
+ id: 'edu-onboarding',
+ school: '某某大学',
+ major: '',
+ degree: '硕士 非全日制',
+ startYear: '',
+ endYear: '',
+ },
+ ]);
+
+ // 工作经历:在职 → 结束时间写「至今」
+ expect(resume.experience).toEqual([
+ {
+ id: 'exp-onboarding',
+ company: '某某科技有限公司',
+ position: '高级前端工程师',
+ startDate: '2023年2月',
+ endDate: '至今',
+ description: '负责招聘端前端架构与组件库建设',
+ aiOptimized: false,
+ },
+ ]);
+
+ // 技能按填写顺序映射成标签
+ expect(resume.skills).toEqual([{ name: 'React' }, { name: 'TypeScript' }, { name: '鸿蒙' }]);
+ });
+
+ it('离职时不写「至今」(结束时间引导未采集,留空而不是编一个)', async () => {
+ await saveOnboardingProfile(
+ makeProfile({ company: '某某科技有限公司', jobStatus: '离职-随时到岗' }),
+ );
+ const resume = await fetchResume();
+ expect(resume.experience[0].endDate).toBe('');
+ });
+
+ it('只填了公司时,职位与工作内容留空(页面据此隐藏对应行)', async () => {
+ await saveOnboardingProfile(makeProfile({ company: '某某科技有限公司' }));
+ const resume = await fetchResume();
+ expect(resume.experience).toHaveLength(1);
+ expect(resume.experience[0].position).toBe('');
+ expect(resume.experience[0].description).toBe('');
+ });
+
+ it('没填公司时不产出工作经历条目(只有入职年月不构成一段经历)', async () => {
+ await saveOnboardingProfile(makeProfile({ joinYear: '2023年', joinMonth: '2月' }));
+ const resume = await fetchResume();
+ expect(resume.experience).toEqual([]);
+ });
+
+ it('期望薪资只填了一头时与偏好卡片一致为空(不留「20K-」这种半截值)', async () => {
+ await saveOnboardingProfile(makeProfile({ expectedSalaryMin: '20K' }));
+ const resume = await fetchResume();
+ expect(resume.jobIntention.salary).toBe('');
+ });
+
+ it('求职状态「在职-暂不考虑」映射为不积极,其余为积极', async () => {
+ await saveOnboardingProfile(makeProfile({ jobStatus: '在职-暂不考虑' }));
+ await expect(fetchResume()).resolves.toMatchObject({
+ jobIntention: { status: 'inactive', statusLabel: '在职-暂不考虑' },
+ });
+
+ await saveOnboardingProfile(makeProfile({ jobStatus: '离职-随时到岗' }));
+ await expect(fetchResume()).resolves.toMatchObject({
+ jobIntention: { status: 'active', statusLabel: '离职-随时到岗' },
+ });
+ });
+
+ it('求职意向读显式编辑过的偏好(与「我的」页偏好卡片同一个答案)', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ await saveJobPreference({
+ position: '电池工程师',
+ city: '宁德',
+ salary: '30K-50K',
+ workType: '全职',
+ status: '在职-考虑机会',
+ });
+ await expect(fetchResume()).resolves.toMatchObject({
+ jobIntention: {
+ position: '电池工程师',
+ city: '宁德',
+ salary: '30K-50K',
+ status: 'active',
+ statusLabel: '在职-考虑机会',
+ },
+ });
+ });
+
+ it('AI 优化后仍是用户自己的简历(不换成演示人设)', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ const { resume, score } = await optimizeResume();
+ expect(resume.basicInfo.name).toBe('张小满');
+ expect(resume.experience[0].company).toBe('某某科技有限公司');
+ expect(resume.education[0].school).toBe('某某大学');
+ // 优化只更新评分块:分数变化、优化点全部标记完成
+ expect(score).toBe(92);
+ expect(resume.score.optimizePoints.every((p) => p.optimized)).toBe(true);
+ // AI 改写的正文 mock 阶段生成不了,因此不替换用户真实填写的工作内容
+ expect(resume.experience[0].description).toBe('负责招聘端前端架构与组件库建设');
+ expect(JSON.stringify(resume)).not.toContain('李明');
+ });
+
+ it('未采集的板块恒为空:项目经历、自我评价、手机号、邮箱', async () => {
+ await saveOnboardingProfile(FULL_PROFILE);
+ const resume = await fetchResume();
+ expect(resume.projects).toEqual([]);
+ expect(resume.selfEvaluation.content).toBe('');
+ // 登录手机号属瞬时字段,按 PIPL 不落库,因此简历里也拿不到
+ expect(resume.basicInfo.phone).toBe('');
+ expect(resume.basicInfo.email).toBe('');
+ });
+});
diff --git a/src/__tests__/salary-options.test.ts b/src/__tests__/salary-options.test.ts
new file mode 100644
index 0000000..9d83367
--- /dev/null
+++ b/src/__tests__/salary-options.test.ts
@@ -0,0 +1,74 @@
+import { applySalaryMin, salaryMaxOptions } from '../api/api';
+import { SALARY_MAX_OPTIONS, SALARY_MIN_OPTIONS } from '../constants';
+
+// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock(与 password.test.ts 同)
+jest.mock('../lib/storage', () => ({
+ storageGet: jest.fn(async () => null),
+ storageSet: jest.fn(async () => undefined),
+ storageRemove: jest.fn(async () => undefined),
+}));
+
+describe('salaryMaxOptions(期望月薪「最高」档位过滤)', () => {
+ it('未选最低值时给出完整阶梯', () => {
+ expect(salaryMaxOptions('')).toEqual(SALARY_MAX_OPTIONS);
+ });
+
+ it('最低值无法解析时同样给出完整阶梯(不凭空砍档位)', () => {
+ expect(salaryMaxOptions('面议')).toEqual(SALARY_MAX_OPTIONS);
+ });
+
+ it('过滤掉低于已选最低值的档位', () => {
+ // 最低 20K ⇒ 最高只能从 20K 起
+ expect(salaryMaxOptions('20K')).toEqual(['20K', '25K', '30K', '40K', '60K']);
+ });
+
+ it('最低值取阶梯最小值时不过滤任何档位', () => {
+ expect(salaryMaxOptions('5K')).toEqual(SALARY_MAX_OPTIONS);
+ });
+
+ it('过滤结果始终是阶梯的子集且保持原有升序', () => {
+ SALARY_MIN_OPTIONS.forEach((min) => {
+ const options = salaryMaxOptions(min);
+ expect(options.length).toBeGreaterThan(0);
+ expect(SALARY_MAX_OPTIONS).toEqual(expect.arrayContaining(options));
+ expect(options).toEqual([...options].sort((a, b) => parseInt(a, 10) - parseInt(b, 10)));
+ });
+ });
+});
+
+describe('applySalaryMin(改选最低期望月薪后校正最高值)', () => {
+ it('原最高值不低于新最低值时保留', () => {
+ expect(applySalaryMin('20K', '30K')).toEqual({
+ expectedSalaryMin: '20K',
+ expectedSalaryMax: '30K',
+ });
+ });
+
+ it('最低值与最高值相等时保留(闭区间,不误清)', () => {
+ expect(applySalaryMin('20K', '20K')).toEqual({
+ expectedSalaryMin: '20K',
+ expectedSalaryMax: '20K',
+ });
+ });
+
+ it('原最高值低于新最低值时清空最高值', () => {
+ expect(applySalaryMin('30K', '15K')).toEqual({
+ expectedSalaryMin: '30K',
+ expectedSalaryMax: '',
+ });
+ });
+
+ it('尚未选过最高值时保持为空', () => {
+ expect(applySalaryMin('30K', '')).toEqual({
+ expectedSalaryMin: '30K',
+ expectedSalaryMax: '',
+ });
+ });
+
+ it('最低值无法解析时不动最高值', () => {
+ expect(applySalaryMin('面议', '15K')).toEqual({
+ expectedSalaryMin: '面议',
+ expectedSalaryMax: '15K',
+ });
+ });
+});
diff --git a/src/__tests__/user-profile.test.ts b/src/__tests__/user-profile.test.ts
new file mode 100644
index 0000000..416943c
--- /dev/null
+++ b/src/__tests__/user-profile.test.ts
@@ -0,0 +1,97 @@
+import { clearOnboardingProfile, fetchUserProfile, saveOnboardingProfile } from '../api/api';
+import type { OnboardingProfile } from '../types';
+
+// 内存版 storage mock(与 job-match.test.ts 同):键是 api 层传入的原始键
+jest.mock('../lib/storage', () => {
+ const store: Record = {};
+ return {
+ storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)),
+ storageSet: jest.fn(async (k: string, v: string) => {
+ store[k] = v;
+ }),
+ storageRemove: jest.fn(async (k: string) => {
+ delete store[k];
+ }),
+ };
+});
+
+/** 全部字段留空 / 为 null:等价于用户一路「跳过,直接进入」走到落地 */
+const EMPTY_PROFILE: OnboardingProfile = {
+ identity: 'candidate',
+ isStudent: null,
+ city: '',
+ expectedPositions: [],
+ expectedSalaryMin: '',
+ expectedSalaryMax: '',
+ resumeName: '',
+ gender: null,
+ birthYear: '',
+ birthMonth: '',
+ jobStatus: null,
+ disabledJobseeker: false,
+ firstWorkYear: '',
+ firstWorkMonth: '',
+ currentPosition: null,
+ company: '',
+ joinYear: '',
+ joinMonth: '',
+ skills: [],
+ workContent: '',
+ education: null,
+ eduType: null,
+ school: '',
+ avatar: null,
+ recruiter: null,
+};
+
+const makeProfile = (patch: Partial): OnboardingProfile => ({
+ ...EMPTY_PROFILE,
+ ...patch,
+});
+
+describe('fetchUserProfile 我的页资料取自引导采集', () => {
+ beforeEach(async () => {
+ await clearOnboardingProfile();
+ });
+
+ it('完全没有引导资料时才回落到演示资料', async () => {
+ const profile = await fetchUserProfile();
+ expect(profile.name).toBe('李明');
+ expect(profile.avatarKey).toBeNull();
+ });
+
+ it('有资料时姓名取采集值、头像取姓名首字、简介只拼采集项', async () => {
+ await saveOnboardingProfile(
+ makeProfile({ resumeName: '张小满', education: '硕士', city: '杭州' }),
+ );
+ const profile = await fetchUserProfile();
+ expect(profile.name).toBe('张小满');
+ expect(profile.avatar).toBe('张');
+ expect(profile.bio).toBe('硕士·杭州');
+ });
+
+ it('一路跳过(资料全空)时不显示演示人设的姓名与简介', async () => {
+ await saveOnboardingProfile(EMPTY_PROFILE);
+ const profile = await fetchUserProfile();
+ expect(profile.name).toBe('');
+ expect(profile.avatar).toBe('');
+ expect(profile.bio).toBe('');
+ });
+
+ it('头像原始值原样透传:预设素材 key', async () => {
+ await saveOnboardingProfile(makeProfile({ avatar: 'avatarPreset3' }));
+ await expect(fetchUserProfile()).resolves.toMatchObject({ avatarKey: 'avatarPreset3' });
+ });
+
+ it('头像原始值原样透传:拍照 / 相册得到的本地图片 URI', async () => {
+ await saveOnboardingProfile(makeProfile({ avatar: 'file:///data/picked.jpg' }));
+ await expect(fetchUserProfile()).resolves.toMatchObject({
+ avatarKey: 'file:///data/picked.jpg',
+ });
+ });
+
+ it('未采集头像时为 null(页面回落到姓名首字展示)', async () => {
+ await saveOnboardingProfile(makeProfile({ resumeName: '张小满' }));
+ await expect(fetchUserProfile()).resolves.toMatchObject({ avatarKey: null });
+ });
+});
diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts
index 8a4a72e..96e0313 100644
--- a/src/__tests__/utils.test.ts
+++ b/src/__tests__/utils.test.ts
@@ -1,4 +1,13 @@
-import { formatThousands, formatCurrency, formatViews } from '../utils';
+import {
+ formatThousands,
+ formatCurrency,
+ formatViews,
+ isValidEmail,
+ onboardingStepLabel,
+ splitYearMonth,
+ formatDateTime,
+} from '../utils';
+import { ONBOARDING_STEP_ORDER } from '../constants';
describe('formatThousands', () => {
it('千分位格式化 12846 → "12,846"', () => {
@@ -29,3 +38,83 @@ describe('formatViews', () => {
expect(formatViews(8500)).toBe('8500');
});
});
+
+/*
+ * 进度文案此前是写在页面上的字面量(P6 写「3/3」),与实际步骤数无关。
+ * 这里锁定「序号 = 数组下标 + 1」与「分母 = 主线长度」,
+ * 以后加删步骤不需要改测试,但写死数字会被抓出来。
+ */
+describe('onboardingStepLabel', () => {
+ it('主线每一步的序号与位置一致,分母为步骤总数', () => {
+ ONBOARDING_STEP_ORDER.forEach((step, i) => {
+ expect(onboardingStepLabel(step)).toBe(`第 ${i + 1} / ${ONBOARDING_STEP_ORDER.length} 步`);
+ });
+ });
+
+ it('非主线屏返回 null(不渲染进度)', () => {
+ // 登录/协议/企业端,以及 P5、P17 两个「进入后返回」的子页
+ ['P1', 'P2', 'P2Q', 'P2P', 'P2R', 'P2W', 'P5', 'P17', 'B1', 'B2', 'B3', 'P23', ''].forEach(
+ (step) => {
+ expect(onboardingStepLabel(step)).toBeNull();
+ },
+ );
+ });
+
+ it('主线不含重复项(重复会把同一步算两次)', () => {
+ expect(new Set(ONBOARDING_STEP_ORDER).size).toBe(ONBOARDING_STEP_ORDER.length);
+ });
+});
+
+/*
+ * 工作经历编辑页回填滚轮用。解析不出时必须给空串(滚轮显示「请滑动选择」),
+ * 给它一个兜底索引会静默把用户的入职年月改成别的年份。
+ */
+describe('splitYearMonth', () => {
+ it('「2023年2月」拆成「2023年」与「2月」', () => {
+ expect(splitYearMonth('2023年2月')).toEqual({ year: '2023年', month: '2月' });
+ });
+
+ it('两位月份同样拆得开', () => {
+ expect(splitYearMonth('2023年12月')).toEqual({ year: '2023年', month: '12月' });
+ });
+
+ it('前后空白不影响解析', () => {
+ expect(splitYearMonth(' 2023年2月 ')).toEqual({ year: '2023年', month: '2月' });
+ });
+
+ it('空串与「至今」解析出两个空串(调用方据此走未选中态)', () => {
+ expect(splitYearMonth('')).toEqual({ year: '', month: '' });
+ expect(splitYearMonth('至今')).toEqual({ year: '', month: '' });
+ });
+
+ it('演示简历里的「2022.07」这种格式解析不出,也不猜', () => {
+ expect(splitYearMonth('2022.07')).toEqual({ year: '', month: '' });
+ });
+});
+
+describe('formatDateTime', () => {
+ it('输出「MM-DD HH:mm」', () => {
+ const local = new Date(2026, 8, 15, 14, 20);
+ expect(formatDateTime(local.toISOString())).toBe('09-15 14:20');
+ });
+
+ it('时分补零', () => {
+ const local = new Date(2026, 0, 3, 9, 5);
+ expect(formatDateTime(local.toISOString())).toBe('01-03 09:05');
+ });
+
+ it('空串与非法时间串返回空串(页面据此不渲染该行,而不是显示 Invalid Date)', () => {
+ expect(formatDateTime('')).toBe('');
+ expect(formatDateTime('不是时间')).toBe('');
+ });
+});
+
+describe('isValidEmail', () => {
+ it.each(['a@b.co', 'zhang.xiao-man+1@example.com', ' a@b.cn '])('合法:%s', (email) => {
+ expect(isValidEmail(email)).toBe(true);
+ });
+
+ it.each(['', 'a', 'a@', '@b.com', 'a@b', 'a b@c.com', 'a@b.c'])('非法:%s', (email) => {
+ expect(isValidEmail(email)).toBe(false);
+ });
+});
diff --git a/src/api/api.ts b/src/api/api.ts
index c7a58b1..993041a 100644
--- a/src/api/api.ts
+++ b/src/api/api.ts
@@ -5,6 +5,13 @@
* - 每个函数用 JSDoc 标注请求方式(@method)、URL(@url)、参数(@param)、返回值(@returns)
* - 所有 mock 数据统一在本文件管理;真实后端就绪后,仅替换函数体为 fetch 即可
* - 业务规则计算(等级映射等)与排序/筛选/搜索/分页在本层完成
+ *
+ * 后端接口预留(每个函数体内都写好了真实请求,只是注释着 —— 接后端时放开即可):
+ * - 纯 mock 返回的函数:`// TODO: 真实请求(后端就绪后替换)` + 注释的 fetch / res.json()
+ * - 以本地存储为唯一真相源的函数:`// TODO: 后端就绪后改为 await fetch(...);当前以本地存储为唯一真相源`
+ * (这类函数现在写的是 storage,放开注释时要把本地写换成请求)
+ * - AI 能力:参数名带 `_` 前缀表示尚未接入;去掉下划线 + 放开注释 = 该能力上线
+ * 放开注释时记得同步删掉紧邻的 mock 返回,别让两套并存。
*/
import Constants from 'expo-constants';
import type {
@@ -36,9 +43,18 @@ import type {
ChatMessage,
RankOverview,
Resume,
- ResumeExportFormat,
+ ResumeAIActionResult,
+ ResumeBasicInfo,
+ ResumeEducation,
+ ResumeExperience,
+ ResumeExportOutcome,
ResumeExportSettings,
+ ResumeItemInput,
ResumeOptimizeResult,
+ ResumeOverride,
+ ResumeProject,
+ ResumeSelfEvaluation,
+ ResumeSkill,
SalaryData,
SalaryBracket,
SalaryCalcInput,
@@ -47,6 +63,7 @@ import type {
SalaryInsuranceItem,
SalaryStructureItem,
SalaryTaxItem,
+ SalaryToolLink,
UserProfile,
UserStats,
WeightDimension,
@@ -54,11 +71,15 @@ import type {
WeightTemplate,
SkillCloudData,
SkillCloudFilter,
+ SkillLearningItem,
+ SkillLearningPath,
SkillWord,
SkillCategory,
TopSkill,
ChecklistGroup,
OnboardingChecklist,
+ OnboardingData,
+ OnboardingProfile,
JobProgress,
IdentityOption,
AppRole,
@@ -101,6 +122,7 @@ import type {
EnterpriseQuickAction,
EnterpriseStat,
InterviewScoreData,
+ InterviewScoreDraft,
InterviewScoreDimension,
InterviewScoreTag,
JobEvaluationDimension,
@@ -111,8 +133,14 @@ import type {
JobQualityEstimate,
JobReview,
JobManagement,
+ TalentCandidate,
TalentRecommendation,
+ TalentRecommendationTab,
+ EnterpriseChatItem,
+ EnterpriseChatStatus,
EnterpriseCommunication,
+ EnterpriseCommunicationTab,
+ PendingTask,
EnterpriseCertificationBenefit,
EnterpriseCertificationData,
EnterpriseCertificationForm,
@@ -127,20 +155,35 @@ import type {
ChangePasswordPayload,
PasswordResetPayload,
PasswordRuleError,
+ CarrierName,
+ CarrierPhoneResult,
} from '../types';
import {
+ ALL_CITY_LABEL,
+ CARRIER_TOKEN_TTL_MS,
DEFAULT_SOCIAL_PROFILE,
+ JOB_MATCH_CITY_BONUS,
+ JOB_MATCH_POSITION_BONUS,
+ JOB_MATCH_SALARY_BONUS,
+ JOB_STATUS_EMPLOYED_PREFIX,
+ JOB_STATUS_INACTIVE,
MOCK_DELAY,
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
PASSWORD_RULE_LABEL,
+ POSITION_NOISE_WORDS,
+ PREFERENCE_DEFAULT_WORK_TYPE,
+ RESUME_AI_UNAVAILABLE_TIP,
+ SALARY_MAX_OPTIONS,
SALARY_RANGES,
SOCIAL_SECURITY_CITY_PROFILES,
TOKEN_TTL_MS,
} from '../constants';
-import { isValidPhone, isValidSmsCode } from '../utils';
+import { buildResumeExportHtml, isValidPhone, isValidSmsCode } from '../utils';
import { colors } from '../theme';
import { storageGet, storageSet, storageRemove } from '../lib/storage';
+import { exportResumePdf } from '../lib/resumeExport';
+import { clearCacheDir, readCacheBytes } from '../lib/cache';
/** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */
export const API_BASE_URL: string =
@@ -626,6 +669,7 @@ const MOCK_USER_PROFILE: UserProfile = {
name: '李明',
avatar: '李',
bio: '3年工作经验·本科·北京',
+ avatarKey: null,
};
/** 用户统计数据(我的页四宫格) */
@@ -702,13 +746,17 @@ const MOCK_DIMENSION_SCORES: DimensionScore[] = [
{ id: 'salary', name: '薪资匹配度', score: 82, level: 'excellent' },
];
-/** 求职偏好 */
-const MOCK_JOB_PREFERENCE: JobPreference = {
- position: '前端开发工程师',
- city: '北京',
- salary: '25-35K',
- workType: '全职',
- status: '积极求职中',
+/**
+ * 求职偏好空值:引导未采集时返回它,由页面渲染「未设置」。
+ * 此前这里返回写死的演示偏好(前端开发工程师 / 北京 / 25-35K),
+ * 导致「我的」页的偏好恒为假数据,与用户填的无关。
+ */
+const EMPTY_JOB_PREFERENCE: JobPreference = {
+ position: '',
+ city: '',
+ salary: '',
+ workType: PREFERENCE_DEFAULT_WORK_TYPE,
+ status: '',
};
/** 勋章列表(已获得 + 未获得;未获得带获取进度) */
@@ -865,6 +913,15 @@ export async function fetchJobList(
// const res = await fetch(`${API_BASE_URL}/api/jobs?${query}`, { method: 'GET' });
// return res.json();
+ // 仅在「默认推荐视图」叠加偏好加分:用户主动选了城市或输入关键词时,
+ // 一律尊重用户的选择,不额外加分(也把回归面限制在默认视图内)
+ const hasCityFilter = !!params.city && params.city !== ALL_CITY_LABEL;
+ const hasKeyword = !!params.keyword?.trim();
+ const preference =
+ params.sortBy === 'comprehensive' && !hasCityFilter && !hasKeyword
+ ? await readEffectivePreference()
+ : null;
+
return new Promise((resolve) => {
setTimeout(() => {
// 1. 从 mock 数据源复制
@@ -886,7 +943,7 @@ export async function fetchJobList(
}
// 4. 筛选:城市
- if (params.city && params.city !== '全部城市') {
+ if (params.city && params.city !== ALL_CITY_LABEL) {
const city = params.city;
result = result.filter((job) => job.city.includes(city));
}
@@ -924,7 +981,15 @@ export async function fetchJobList(
break;
case 'comprehensive':
default:
- result.sort((a, b) => b.score - a.score);
+ if (preference) {
+ // 已采集偏好:按「岗位分数 + 命中加分」重排,让符合偏好的岗位上浮
+ result = result
+ .map((job) => ({ job, match: matchScore(job, preference) }))
+ .sort((a, b) => b.match - a.match)
+ .map((item) => item.job);
+ } else {
+ result.sort((a, b) => b.score - a.score);
+ }
break;
}
@@ -1114,7 +1179,33 @@ export async function fetchArticles(): Promise {
*/
export async function fetchUserProfile(): Promise {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/me`, { method: 'GET' }); return res.json();
- return mockRequest(MOCK_USER_PROFILE);
+ const profile = await fetchOnboardingProfile();
+ if (!profile) return mockRequest(MOCK_USER_PROFILE);
+ const { name, avatar } = displayNameAndAvatar(profile.resumeName);
+ return mockRequest({
+ // 有引导资料就只用资料里的值:不拿 mock 的「李明 / 3年工作经验·本科·北京」补空,
+ // 空值由页面渲染「未设置」或隐藏该行
+ ...MOCK_USER_PROFILE,
+ name,
+ avatar,
+ bio: buildProfileBio(profile),
+ // 头像原始值直接透传,由页面决定怎么渲染(素材 key / 本地 URI / 回落展示字符)
+ avatarKey: profile.avatar,
+ });
+}
+
+/** 姓名 → 展示用姓名与头像字(头像取姓氏,与 mock 的「李」同风格) */
+function displayNameAndAvatar(resumeName: string): { name: string; avatar: string } {
+ const name = resumeName.trim();
+ return { name, avatar: name.slice(0, 1) };
+}
+
+/**
+ * 资料 → 「我的」页简介:只拼引导真正采集到的项,未采集的略过,
+ * 不用 mock 的工作年限/学历/城市冒充用户信息
+ */
+function buildProfileBio(profile: OnboardingProfile): string {
+ return [profile.education, profile.city].filter(Boolean).join('·');
}
/**
@@ -1174,15 +1265,306 @@ export async function fetchDimensionScores(): Promise {
return mockRequest(MOCK_DIMENSION_SCORES);
}
+/* ===================== 注册引导资料(mock 阶段持久化到 AsyncStorage) ===================== */
+
+const ONBOARDING_PROFILE_KEY = 'onboarding:profile';
+
+/**
+ * 保存注册引导采集到的求职者资料
+ * @method POST
+ * @url /api/user/onboarding-profile
+ * @param profile 引导采集的资料(不含手机号 / 验证码 / 协议勾选等登录瞬时字段)
+ * @returns Promise
+ * @remark 由引导流程按步骤提交(见 useOnboardingFlow),不模拟网络延迟,避免拖慢每一步跳转
+ */
+export async function saveOnboardingProfile(profile: OnboardingProfile): Promise {
+ // TODO: await fetch(`${API_BASE_URL}/api/user/onboarding-profile`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profile) });
+ await storageSet(ONBOARDING_PROFILE_KEY, JSON.stringify(profile));
+}
+
+/**
+ * 获取注册引导采集到的求职者资料
+ * @method GET
+ * @url /api/user/onboarding-profile
+ * @returns Promise 从未采集过时为 null(区分「没填过」与「填了空值」)
+ */
+export async function fetchOnboardingProfile(): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/user/onboarding-profile`, { method: 'GET' }); return res.json();
+ const raw = await storageGet(ONBOARDING_PROFILE_KEY);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as OnboardingProfile;
+ } catch {
+ // 存储内容损坏时按「未采集」处理,不阻断引导流程
+ return null;
+ }
+}
+
+/**
+ * 清除注册引导资料(退出登录时调用,避免把上一位用户填的资料留给下一位)
+ * @method DELETE
+ * @url /api/user/onboarding-profile
+ * @returns Promise
+ */
+export async function clearOnboardingProfile(): Promise {
+ // TODO: await fetch(`${API_BASE_URL}/api/user/onboarding-profile`, { method: 'DELETE' });
+ await storageRemove(ONBOARDING_PROFILE_KEY);
+}
+
+/**
+ * 流程状态 → 持久化资料(数据转换属 API 层职责)。
+ * @param data 引导流程当前状态
+ * @returns 可落库的资料;登录瞬时字段(phone / code / agreed / wechatTicket)被剔除
+ * @remark `OnboardingProfile` 由 `OnboardingData` 派生,漏掉字段会编译失败
+ */
+export function toOnboardingProfile(data: OnboardingData): OnboardingProfile {
+ return {
+ identity: data.identity,
+ isStudent: data.isStudent,
+ city: data.city,
+ expectedPositions: data.expectedPositions,
+ expectedSalaryMin: data.expectedSalaryMin,
+ expectedSalaryMax: data.expectedSalaryMax,
+ resumeName: data.resumeName,
+ gender: data.gender,
+ birthYear: data.birthYear,
+ birthMonth: data.birthMonth,
+ jobStatus: data.jobStatus,
+ disabledJobseeker: data.disabledJobseeker,
+ firstWorkYear: data.firstWorkYear,
+ firstWorkMonth: data.firstWorkMonth,
+ currentPosition: data.currentPosition,
+ company: data.company,
+ joinYear: data.joinYear,
+ joinMonth: data.joinMonth,
+ skills: data.skills,
+ workContent: data.workContent,
+ education: data.education,
+ eduType: data.eduType,
+ school: data.school,
+ avatar: data.avatar,
+ // 企业端才保留认证信息
+ recruiter:
+ data.identity === 'recruiter'
+ ? {
+ company: data.recruiterCompany,
+ contactName: data.recruiterContact,
+ licenseUri: data.recruiterLicenseUri,
+ }
+ : null,
+ };
+}
+
+/**
+ * 持久化资料 → 流程状态(数据转换属 API 层职责)。
+ * @param profile 已落库的资料
+ * @param base 兜底状态(通常为 INITIAL),未采集字段与登录瞬时字段沿用它
+ * @returns 合并后的流程状态
+ */
+export function fromOnboardingProfile(
+ profile: OnboardingProfile,
+ base: OnboardingData,
+): OnboardingData {
+ return {
+ ...base,
+ identity: profile.identity,
+ isStudent: profile.isStudent,
+ city: profile.city,
+ expectedPositions: profile.expectedPositions,
+ expectedSalaryMin: profile.expectedSalaryMin,
+ expectedSalaryMax: profile.expectedSalaryMax,
+ resumeName: profile.resumeName,
+ gender: profile.gender,
+ birthYear: profile.birthYear,
+ birthMonth: profile.birthMonth,
+ jobStatus: profile.jobStatus,
+ disabledJobseeker: profile.disabledJobseeker,
+ firstWorkYear: profile.firstWorkYear,
+ firstWorkMonth: profile.firstWorkMonth,
+ currentPosition: profile.currentPosition,
+ company: profile.company,
+ joinYear: profile.joinYear,
+ joinMonth: profile.joinMonth,
+ skills: profile.skills,
+ workContent: profile.workContent,
+ education: profile.education,
+ eduType: profile.eduType,
+ school: profile.school,
+ avatar: profile.avatar,
+ recruiterCompany: profile.recruiter?.company ?? base.recruiterCompany,
+ recruiterContact: profile.recruiter?.contactName ?? base.recruiterContact,
+ recruiterLicenseUri: profile.recruiter?.licenseUri ?? base.recruiterLicenseUri,
+ };
+}
+
+/* ===================== 求职偏好(引导资料推导 + 用户显式编辑) ===================== */
+
+const JOB_PREFERENCE_KEY = 'user:job-preference';
+
+/** 读取用户在「求职意向」页显式保存过的偏好(未编辑过为 null) */
+async function readJobPreferenceOverride(): Promise {
+ const raw = await storageGet(JOB_PREFERENCE_KEY);
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as JobPreference;
+ } catch {
+ // 存储内容损坏时按「未编辑过」处理,回退到引导资料推导
+ return null;
+ }
+}
+
+/** 引导资料 → 求职偏好。未采集的字段留空串,不做假数据填充 */
+function deriveJobPreference(profile: OnboardingProfile): JobPreference {
+ return {
+ position: profile.expectedPositions[0] ?? '',
+ city: profile.city,
+ salary:
+ profile.expectedSalaryMin && profile.expectedSalaryMax
+ ? `${profile.expectedSalaryMin}-${profile.expectedSalaryMax}`
+ : '',
+ workType: PREFERENCE_DEFAULT_WORK_TYPE,
+ status: profile.jobStatus ?? '',
+ };
+}
+
+/**
+ * 期望薪资字符串 → 数值区间
+ * @param text 形如 `20K`(单值)或 `20-35K`(区间)
+ * @returns 数值区间;无法解析时为 null(不参与匹配,避免凭空加分)
+ */
+function parseSalaryRange(text: string): { min: number; max: number } | null {
+ const nums = text.match(/\d+/g);
+ if (!nums || nums.length === 0) return null;
+ const values = nums.map(Number);
+ return { min: Math.min(...values), max: Math.max(...values) };
+}
+
+/**
+ * 职位名归一化:剔除级别与通用后缀,让「前端开发工程师」能与「高级前端工程师」命中。
+ * 全被剔除时退回原名(如职位名就是「工程师」),避免空串命中一切。
+ */
+function normalizePosition(name: string): string {
+ const stripped = POSITION_NOISE_WORDS.reduce((acc, word) => acc.replace(word, ''), name).trim();
+ return stripped === '' ? name : stripped;
+}
+
+/** 期望职位与岗位职位是否命中(归一化后双向包含,粗粒度匹配) */
+function isPositionMatched(jobPositionName: string, expectedPositions: string[]): boolean {
+ const job = normalizePosition(jobPositionName);
+ return expectedPositions.some((position) => {
+ if (!position.trim()) return false;
+ const want = normalizePosition(position);
+ return job.includes(want) || want.includes(job);
+ });
+}
+
+/**
+ * 期望月薪「最高」可选阶梯:过滤掉低于已选最低值的档位。
+ * 规则放这里而不是页面里 —— 「最高不得低于最低」是业务规则,
+ * 页面只负责渲染返回的档位、不写数值比较。
+ * @param min 已选最低值(如 `20K`);未选或无法解析时返回完整阶梯
+ * @returns 可选的最高值档位
+ */
+export function salaryMaxOptions(min: string): string[] {
+ const floor = parseSalaryRange(min);
+ if (!floor) return [...SALARY_MAX_OPTIONS];
+ return SALARY_MAX_OPTIONS.filter((option) => (parseSalaryRange(option)?.min ?? 0) >= floor.max);
+}
+
+/**
+ * 改选最低期望月薪后,返回与之一致的 { 最低, 最高 }。
+ * 原最高值低于新的最低值时必须清掉 —— 否则会存出「最低 30K / 最高 15K」这种
+ * 自相矛盾的偏好,并顺着 `fetchJobPreference` 一路显示到「我的」页。
+ * @param min 新的最低值
+ * @param currentMax 当前最高值
+ * @returns 校正后的最低/最高值(最高值被清空时为 `''`)
+ */
+export function applySalaryMin(
+ min: string,
+ currentMax: string,
+): { expectedSalaryMin: string; expectedSalaryMax: string } {
+ const floor = parseSalaryRange(min);
+ const ceiling = parseSalaryRange(currentMax);
+ // 最低值本身无法解析时无从比较,保留原最高值(只有能证明「最高 < 最低」才清)
+ const keepMax = !floor || (!!ceiling && ceiling.max >= floor.max);
+ return { expectedSalaryMin: min, expectedSalaryMax: keepMax ? currentMax : '' };
+}
+
+/** 岗位推荐用的有效偏好 */
+interface EffectivePreference {
+ positions: string[];
+ city: string;
+ salary: { min: number; max: number } | null;
+}
+
+/**
+ * 岗位推荐用的有效偏好:显式保存过的意向优先,否则由引导资料推导。
+ * 两处取值源(引导资料 / 求职意向编辑页)的优先级只在这里决定。
+ */
+async function readEffectivePreference(): Promise {
+ const override = await readJobPreferenceOverride();
+ if (override) {
+ return {
+ positions: override.position ? [override.position] : [],
+ city: override.city,
+ salary: parseSalaryRange(override.salary),
+ };
+ }
+ const profile = await fetchOnboardingProfile();
+ if (!profile) return null;
+ return {
+ positions: profile.expectedPositions,
+ city: profile.city,
+ salary:
+ profile.expectedSalaryMin && profile.expectedSalaryMax
+ ? parseSalaryRange(`${profile.expectedSalaryMin}-${profile.expectedSalaryMax}`)
+ : null,
+ };
+}
+
+/**
+ * 岗位匹配分 = 岗位自身分数 + 命中期望城市 / 职位 / 薪资的加分。
+ * 权重见 src/constants,此处不写死魔法数字。
+ */
+function matchScore(job: Omit, preference: EffectivePreference): number {
+ let score = job.score;
+ if (preference.city && job.city.includes(preference.city)) {
+ score += JOB_MATCH_CITY_BONUS;
+ }
+ if (isPositionMatched(job.positionName, preference.positions)) {
+ score += JOB_MATCH_POSITION_BONUS;
+ }
+ if (
+ preference.salary &&
+ job.salaryMin <= preference.salary.max &&
+ job.salaryMax >= preference.salary.min
+ ) {
+ score += JOB_MATCH_SALARY_BONUS;
+ }
+ return score;
+}
+
+/**
+ * 有效求职偏好:用户在「求职意向」页显式保存过的偏好 > 由引导资料推导 > 空值。
+ * 「我的」页偏好卡片与简历「求职意向」都读它,保证两处展示同一个答案。
+ */
+async function resolveJobPreference(): Promise {
+ const override = await readJobPreferenceOverride();
+ if (override) return override;
+ const profile = await fetchOnboardingProfile();
+ return profile ? deriveJobPreference(profile) : EMPTY_JOB_PREFERENCE;
+}
+
/**
* 获取求职偏好
* @method GET
* @url /api/user/job-preference
- * @returns Promise 求职偏好
+ * @returns Promise 求职偏好;未采集到的字段为空串,由页面渲染「未设置」
+ * @remark 取值优先级见 `resolveJobPreference`
*/
export async function fetchJobPreference(): Promise {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/job-preference`, { method: 'GET' }); return res.json();
- return mockRequest(MOCK_JOB_PREFERENCE);
+ return mockRequest(await resolveJobPreference());
}
/**
@@ -1348,6 +1730,7 @@ const MOCK_OFFER_COMPARE_DATA: OfferCompareData = {
company: '字节跳动',
position: '高级前端',
salary: '25-40K',
+ status: 'pending',
selected: true,
color: colors.primary,
},
@@ -1356,6 +1739,7 @@ const MOCK_OFFER_COMPARE_DATA: OfferCompareData = {
company: '腾讯科技',
position: '产品经理',
salary: '20-35K',
+ status: 'pending',
selected: true,
color: colors.compareRed,
},
@@ -1455,6 +1839,22 @@ export async function fetchOfferCompareData(offerIds?: string[]): Promise 待处理(status === 'pending')的 Offer 数量
+ */
+export async function fetchPendingOfferCount(): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/offers/pending/count`, { method: 'GET' }); return res.json();
+ const pending = MOCK_OFFER_COMPARE_DATA.offers.filter((offer) => offer.status === 'pending');
+ return mockRequest(pending.length);
+}
+
/* ===================== 评分权重设置 ===================== */
/** 默认权重维度(9 个,总和 100%) */
@@ -1728,6 +2128,74 @@ export async function fetchSkillCloud(industry: string): Promise
});
}
+/**
+ * 按关键词过滤技能词(页面搜索框用,纯函数)。
+ * 空关键词返回全部,避免页面里再写一遍过滤逻辑。
+ * @param words 技能词数组
+ * @param keyword 关键词(大小写不敏感,首尾空格忽略)
+ * @returns SkillWord[] 命中的技能词
+ */
+export function filterSkillWords(words: SkillWord[], keyword: string): SkillWord[] {
+ const kw = keyword.trim().toLowerCase();
+ if (kw === '') return words;
+ return words.filter((w) => w.text.toLowerCase().includes(kw));
+}
+
+/** 技能分组 → 中文名(学习路径的推荐理由里要用) */
+const SKILL_GROUP_LABEL: Record = {
+ tech: '技术技能',
+ soft: '软技能',
+ domain: '领域能力',
+ tool: '工具',
+};
+
+/**
+ * 生成技能学习路径
+ * 业务规则(接口层):以该行业技能词的权重为优先级,从高到低挑出待补技能;
+ * 待补数量按「匹配度缺口」换算,限制在 3-6 条之间,避免一次甩出一长串。
+ * @method GET
+ * @url /api/tools/skill-learning-path
+ * @param industry { string } 行业名称
+ * @returns Promise 学习路径
+ */
+export async function fetchSkillLearningPath(industry: string): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/tools/skill-learning-path?industry=${encodeURIComponent(industry)}`, { method: 'GET' }); return res.json();
+ const words = MOCK_SKILL_WORDS[industry] ?? MOCK_SKILL_WORDS[DEFAULT_SKILL_INDUSTRY];
+ const resolvedIndustry = MOCK_SKILL_WORDS[industry] ? industry : DEFAULT_SKILL_INDUSTRY;
+ const matchPercent = 80;
+
+ // 缺口越大补得越多,但先补权重最高的那几个(3 条起步、6 条封顶)
+ const gap = Math.max(0, 100 - matchPercent);
+ const wanted = Math.min(6, Math.max(3, Math.round(gap / 10)));
+
+ const items: SkillLearningItem[] = [...words]
+ .sort((a, b) => b.weight - a.weight)
+ .slice(0, wanted)
+ .map((w, i) => ({
+ id: w.id,
+ name: w.text,
+ group: w.group,
+ weight: w.weight,
+ priority: i + 1,
+ reason: `行业热度权重 ${w.weight},${SKILL_GROUP_LABEL[w.group]}类里排在前列的硬要求`,
+ }));
+
+ // 补齐这些技能后的目标匹配度:按待补技能的权重占比折算,最多到 95
+ const avgWeight = items.length
+ ? items.reduce((sum, it) => sum + it.weight, 0) / items.length
+ : 0;
+ const targetPercent = Math.min(95, Math.round(matchPercent + (gap * avgWeight) / 100));
+
+ return mockRequest({
+ industry: resolvedIndustry,
+ position: '高级前端工程师',
+ matchPercent,
+ targetPercent,
+ items,
+ generatedAt: '刚刚生成',
+ });
+}
+
/* ===================== 薪资计算器 ===================== */
/** 各行业平均月薪参考(计算器对比用) */
@@ -1739,6 +2207,44 @@ const MOCK_SALARY_BRACKETS: SalaryBracket[] = [
{ industry: '制造业', avgMonthly: 22, sampleCount: 1540 },
];
+/** 薪资计算页「更多工具」(均为项目内真实页面,route 与 app/ 下路由一一对应) */
+const MOCK_SALARY_TOOL_LINKS: SalaryToolLink[] = [
+ {
+ id: 'offer-compare',
+ title: 'Offer 对比',
+ desc: '把几个 offer 的薪资、福利放一起比',
+ icon: 'git-compare-outline',
+ route: '/jobs/offer-compare',
+ },
+ {
+ id: 'salary-rank',
+ title: '竞争力排名',
+ desc: '看看同岗位里你的薪资处于什么位置',
+ icon: 'podium-outline',
+ route: '/jobs/rank',
+ },
+ {
+ id: 'skill-cloud',
+ title: '行业技能词云',
+ desc: '目标行业的高频技能与要求权重',
+ icon: 'cloud-outline',
+ route: '/tools/skill-cloud',
+ },
+];
+
+/**
+ * 薪资计算页「更多工具」入口。
+ * 只放项目里真实存在的页面(原先页面内写死了「年终奖个税计算 / 离职补偿金计算 /
+ * 公积金社保证明」三条,路由全部指回薪资计算器自身,属于点不动的假入口,已摘除)。
+ * @method GET
+ * @url /api/tools/salary-related
+ * @returns Promise 相关工具入口数组
+ */
+export async function fetchSalaryToolLinks(): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/tools/salary-related`, { method: 'GET' }); return res.json();
+ return mockRequest(MOCK_SALARY_TOOL_LINKS);
+}
+
/**
* 获取各行业平均月薪参考
* @method GET
@@ -2119,7 +2625,11 @@ const MOCK_OPTIMIZED_RESUME: Resume = {
},
};
-/** 默认导出设置 */
+/**
+ * 默认导出设置。
+ * `fileName` 默认为空串:原默认值「李明_前端工程师简历」是演示人设,
+ * 会让每个用户的导出文件都顶着别人的名字,真实姓名由 `resolveExportFileName` 兜底。
+ */
const DEFAULT_EXPORT_SETTINGS: ResumeExportSettings = {
format: 'pdf',
templateId: 'classic',
@@ -2127,20 +2637,194 @@ const DEFAULT_EXPORT_SETTINGS: ResumeExportSettings = {
exportPassword: false,
paperSize: 'A4',
orientation: 'portrait',
- fileName: '李明_前端工程师简历',
+ fileName: '',
includeCover: false,
includeRecommendation: false,
};
+/**
+ * 导出文件名:用户填了就用,没填按「姓名_期望职位简历」兜底,姓名职位都没有才用「我的简历」。
+ * @param resume 简历(取姓名与期望职位)
+ * @param fileName 用户在导出设置里填的文件名
+ * @returns 实际落盘的文件名(不含扩展名)
+ * @remark 放在 API 层:它是产品定的命名规则,导出与分享两条路必须用同一个名字,
+ * 页面各自拼一遍必然分叉(曾把默认值写成演示人设「李明_前端工程师简历」)
+ */
+export function resolveExportFileName(
+ resume: Pick,
+ fileName: string,
+): string {
+ const custom = fileName.trim();
+ if (custom !== '') return custom;
+ const name = resume.basicInfo.name.trim();
+ const position = resume.jobIntention.position.trim();
+ if (name !== '' && position !== '') return `${name}_${position}简历`;
+ if (name !== '') return `${name}的简历`;
+ if (position !== '') return `${position}简历`;
+ return '我的简历';
+}
+
+/* ===================== 简历:用户编辑覆盖层 =====================
+ * 简历的默认内容由引导资料派生(`buildResumeFromProfile`),用户在编辑页里改过的板块存在
+ * `resume:override`,优先级高于派生值 —— 与求职偏好 `user:job-preference` 同一套
+ * 「显式编辑 > 引导推导」语义。真实后端就绪后把这组函数体换成 fetch 即可(签名不变)。
+ */
+
+/** 简历覆盖层存储键(与引导资料分键存放,避免互相覆盖) */
+const RESUME_OVERRIDE_KEY = 'resume:override';
+
+/** 保存时间存储键:头部「保存」写入,页面显示「上次保存 …」 */
+const RESUME_SAVED_AT_KEY = 'resume:saved-at';
+
+/** 导出设置存储键 */
+const RESUME_EXPORT_SETTINGS_KEY = 'resume:export-settings';
+
+/** 读取覆盖层;未编辑过或存储内容损坏 → 空对象(损坏时按「未编辑过」处理,不抛错) */
+async function readResumeOverride(): Promise {
+ const raw = await storageGet(RESUME_OVERRIDE_KEY);
+ if (!raw) return {};
+ try {
+ return JSON.parse(raw) as ResumeOverride;
+ } catch {
+ return {};
+ }
+}
+
+/** 合并写入:只覆盖 patch 里出现的板块,其它板块此前的编辑保留 */
+async function mergeResumeOverride(patch: ResumeOverride): Promise {
+ const current = await readResumeOverride();
+ await storageSet(RESUME_OVERRIDE_KEY, JSON.stringify({ ...current, ...patch }));
+}
+
+/** 未套覆盖层的简历:引导资料派生;完全没走过注册流程(无资料)时回落演示简历 */
+async function buildDerivedResume(): Promise {
+ const profile = await fetchOnboardingProfile();
+ if (!profile) return MOCK_RESUME;
+ return buildResumeFromProfile(profile, await resolveJobPreference());
+}
+
+/**
+ * 把覆盖层叠到派生简历上:板块级覆盖 —— 键出现就以覆盖层为准。
+ *
+ * 必须用 `??` 而不是 `||`:空数组、空串都是用户的合法编辑结果
+ * (用户删光了教育经历,不该再冒出引导资料里的那一条)。
+ */
+function applyResumeOverride(derived: Resume, override: ResumeOverride): Resume {
+ return {
+ ...derived,
+ basicInfo: override.basicInfo ?? derived.basicInfo,
+ education: override.education ?? derived.education,
+ experience: override.experience ?? derived.experience,
+ projects: override.projects ?? derived.projects,
+ skills: override.skills ?? derived.skills,
+ selfEvaluation: override.selfEvaluation ?? derived.selfEvaluation,
+ };
+}
+
+/** 简历条目 id:板块前缀 + 时间戳 + 随机后缀(同一毫秒内连续新增两条不会撞 id) */
+function createResumeItemId(prefix: string): string {
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+/** 给新增条目补 id,并按 id 去重(页面拼接出错时也不会产生重复的列表 key) */
+function withResumeItemIds(
+ items: ResumeItemInput[],
+ prefix: string,
+): T[] {
+ const seen = new Map();
+ for (const item of items) {
+ const id = item.id ?? createResumeItemId(prefix);
+ seen.set(id, { ...item, id } as T);
+ }
+ return [...seen.values()];
+}
+
+/**
+ * 引导资料 → 简历。
+ *
+ * 只映射引导**真正采集到**的字段,未采集的一律留空,不再拿 `MOCK_RESUME` 的
+ * 「李明 / 北京大学 / 字节跳动」顶上 —— 那会让用户在自己的简历里看到别人的
+ * 学历与工作经历。页面按空值隐藏对应行(见 `app/resume.tsx`)。
+ *
+ * @param preference 有效求职偏好(`resolveJobPreference`):「求职意向」板块与
+ * 「我的」页偏好卡片读同一份,用户在求职意向编辑页改过也一致
+ */
+function buildResumeFromProfile(profile: OnboardingProfile, preference: JobPreference): Resume {
+ const { name, avatar } = displayNameAndAvatar(profile.resumeName);
+
+ // 采集项:毕业学校(P20)、学历 + 学习形式(P18)。
+ // 专业、入学/毕业年份引导未采集 → 留空,不套用 mock 的「计算机科学与技术 2018-2022」
+ const education: ResumeEducation[] = profile.school
+ ? [
+ {
+ id: 'edu-onboarding',
+ school: profile.school,
+ major: '',
+ degree: [profile.education, profile.eduType].filter(Boolean).join(' '),
+ startYear: '',
+ endYear: '',
+ },
+ ]
+ : [];
+
+ // 采集项:当前公司(P12)、当前职位(P11)、入职年月(P13)、工作内容(P16)。
+ // 在职才写「至今」;离职时结束时间引导未采集 → 留空,而不是编一个离职时间
+ const experience: ResumeExperience[] = profile.company
+ ? [
+ {
+ id: 'exp-onboarding',
+ company: profile.company,
+ position: profile.currentPosition ?? '',
+ startDate: `${profile.joinYear}${profile.joinMonth}`,
+ endDate: profile.jobStatus?.startsWith(JOB_STATUS_EMPLOYED_PREFIX) ? '至今' : '',
+ description: profile.workContent,
+ aiOptimized: false,
+ },
+ ]
+ : [];
+
+ return {
+ // 简历评分与优化点是产品的 AI 能力,mock 阶段恒返回同一份(真实后端就绪后由后端打分)
+ score: MOCK_RESUME.score,
+ basicInfo: {
+ name,
+ avatar,
+ // 手机号 / 邮箱引导未采集(登录手机号属瞬时字段,按 PIPL 不落库)
+ phone: '',
+ email: '',
+ // 引导采集的是 P4「期望城市」,不是「现居城市」,不能拿它冒充所在城市
+ location: '',
+ },
+ jobIntention: {
+ position: preference.position,
+ salary: preference.salary,
+ city: preference.city,
+ status: preference.status === JOB_STATUS_INACTIVE ? 'inactive' : 'active',
+ statusLabel: preference.status,
+ },
+ education,
+ experience,
+ // 项目经历引导未采集
+ projects: [],
+ skills: profile.skills.map((skillName) => ({ name: skillName })),
+ // 自我评价引导未采集
+ selfEvaluation: { content: '' },
+ };
+}
+
/**
* 获取我的简历
* @method GET
* @url /api/resume
* @returns Promise 简历数据
+ * @remark 内容 = 引导采集资料派生(`youzhi:onboarding:profile` + 有效求职偏好)叠加用户编辑
+ * 覆盖层(`youzhi:resume:override`):某板块被编辑过就以覆盖层为准,未编辑过用派生值。
+ * 只有完全没走过注册流程(无资料)时才回落到演示简历
*/
export async function fetchResume(): Promise {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume`, { method: 'GET' }); return res.json();
- return mockRequest(MOCK_RESUME);
+ const derived = await buildDerivedResume();
+ return mockRequest(applyResumeOverride(derived, await readResumeOverride()));
}
/**
@@ -2148,10 +2832,206 @@ export async function fetchResume(): Promise {
* @method POST
* @url /api/resume/optimize
* @returns Promise 优化后的简历
+ * @remark 优化结果**在用户自己的简历上套用 mock 的评分块**:
+ * 返回 `MOCK_OPTIMIZED_RESUME` 会把整份简历换成演示人设「李明」——
+ * 用户点一下「优化简历」,姓名、学历、公司全变成别人的。
+ * AI 改写的正文(经历描述 / 自我评价)mock 阶段生成不了,因此**不替换**用户
+ * 真实填写的内容,只更新评分与优化点状态;真实后端就绪后由后端返回改写结果。
*/
export async function optimizeResume(): Promise {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/optimize`, { method: 'POST' }); return res.json();
- return mockRequest({ score: MOCK_OPTIMIZED_RESUME.score.score, resume: MOCK_OPTIMIZED_RESUME });
+ const profile = await fetchOnboardingProfile();
+ if (!profile) {
+ return mockRequest({ score: MOCK_OPTIMIZED_RESUME.score.score, resume: MOCK_OPTIMIZED_RESUME });
+ }
+ const base = buildResumeFromProfile(profile, await resolveJobPreference());
+ return mockRequest({
+ score: MOCK_OPTIMIZED_RESUME.score.score,
+ // 用户自己编辑过的板块同样保留,优化不覆盖用户的输入
+ resume: { ...applyResumeOverride(base, await readResumeOverride()), score: MOCK_OPTIMIZED_RESUME.score },
+ });
+}
+
+/* ===================== 简历:按板块保存(用户编辑) ===================== */
+
+/**
+ * 保存简历基本信息
+ * @method PUT
+ * @url /api/resume/basic-info
+ * @param input 姓名 / 手机号 / 邮箱 / 所在城市(不含头像)
+ * @returns Promise 落库后的基本信息(头像字由本层按姓名派生)
+ * @remark 保存后该板块进入覆盖层:此后引导资料里的姓名不再影响简历
+ * (与「求职意向」编辑页的覆盖语义一致)
+ */
+export async function saveResumeBasicInfo(
+ input: Omit,
+): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/basic-info`, { method: 'PUT', body: JSON.stringify(input) }); return res.json();
+ const info: ResumeBasicInfo = { ...input, avatar: displayNameAndAvatar(input.name).avatar };
+ await mergeResumeOverride({ basicInfo: info });
+ return mockRequest(info);
+}
+
+/**
+ * 保存自我评价
+ * @method PUT
+ * @url /api/resume/self-evaluation
+ * @param content 正文(trim 后为空表示清空该板块)
+ * @returns Promise 落库后的内容
+ */
+export async function saveResumeSelfEvaluation(content: string): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/self-evaluation`, { method: 'PUT', body: JSON.stringify({ content }) }); return res.json();
+ const section: ResumeSelfEvaluation = { content: content.trim() };
+ await mergeResumeOverride({ selfEvaluation: section });
+ return mockRequest(section);
+}
+
+/**
+ * 保存技能特长(整表替换)
+ * @method PUT
+ * @url /api/resume/skills
+ * @param skills 标签列表
+ * @returns Promise 规整后的列表
+ * @remark 规整规则在本层:逐项 trim、丢弃空串、按名称去重(保留首次出现),页面不重复实现
+ */
+export async function saveResumeSkills(skills: ResumeSkill[]): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/skills`, { method: 'PUT', body: JSON.stringify({ skills }) }); return res.json();
+ const names: string[] = [];
+ for (const skill of skills) {
+ const name = skill.name.trim();
+ if (name !== '' && !names.includes(name)) names.push(name);
+ }
+ const normalized: ResumeSkill[] = names.map((name) => ({ name }));
+ await mergeResumeOverride({ skills: normalized });
+ return mockRequest(normalized);
+}
+
+/**
+ * 保存教育背景(整表替换)
+ * @method PUT
+ * @url /api/resume/education
+ * @param items 用户此刻看到的完整列表;id 缺省表示新增项,由本层生成
+ * @returns Promise 落库后的列表(新增项已带 id)
+ * @remark 传整表而不是逐条增删改:列表基底是「覆盖层 ?? 引导派生」的合并结果,
+ * 逐条写会在「编辑引导派生出来的那条」时把整个板块改丢(此时覆盖层里还没有该板块)
+ */
+export async function saveResumeEducation(
+ items: ResumeItemInput[],
+): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/education`, { method: 'PUT', body: JSON.stringify({ items }) }); return res.json();
+ const normalized = withResumeItemIds(items, 'edu');
+ await mergeResumeOverride({ education: normalized });
+ return mockRequest(normalized);
+}
+
+/** 保存工作经历(整表替换)。@url /api/resume/experience @remark 同 saveResumeEducation */
+export async function saveResumeExperience(
+ items: ResumeItemInput[],
+): Promise {
+ // TODO: 后端就绪后改为 await fetch(`${API_BASE_URL}/api/resume/experience`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(items) });当前以本地存储为唯一真相源
+ const normalized = withResumeItemIds(items, 'exp');
+ await mergeResumeOverride({ experience: normalized });
+ return mockRequest(normalized);
+}
+
+/** 保存项目经历(整表替换)。@url /api/resume/projects @remark 同 saveResumeEducation */
+export async function saveResumeProjects(
+ items: ResumeItemInput[],
+): Promise {
+ // TODO: 后端就绪后改为 await fetch(`${API_BASE_URL}/api/resume/projects`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(items) });当前以本地存储为唯一真相源
+ const normalized = withResumeItemIds(items, 'proj');
+ await mergeResumeOverride({ projects: normalized });
+ return mockRequest(normalized);
+}
+
+/**
+ * 保存我的简历到本机
+ * @method POST
+ * @url /api/resume/save
+ * @param resume 当前展示的简历
+ * @returns Promise<{ savedAt: string }> 保存时间(ISO 串)
+ * @remark 把六个可编辑板块整体固化进覆盖层(此后再改引导资料不影响这份简历),并记录保存时间
+ * 供页面显示「上次保存」。求职意向不入快照:它由 `resolveJobPreference` 单独供给,
+ * 同一份数据不能有两个来源。后端就绪后改为云端保存,页面与 hook 无需改动。
+ */
+export async function saveResumeSnapshot(resume: Resume): Promise<{ savedAt: string }> {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/resume/save`, { method: 'POST', body: JSON.stringify(resume) }); return res.json();
+ await mergeResumeOverride({
+ basicInfo: resume.basicInfo,
+ education: resume.education,
+ experience: resume.experience,
+ projects: resume.projects,
+ skills: resume.skills,
+ selfEvaluation: resume.selfEvaluation,
+ });
+ const savedAt = new Date().toISOString();
+ await storageSet(RESUME_SAVED_AT_KEY, savedAt);
+ return mockRequest({ savedAt });
+}
+
+/**
+ * 获取简历上次保存时间
+ * @method GET
+ * @url /api/resume/saved-at
+ * @returns Promise ISO 时间串;从未保存过返回空串(页面据此不渲染该行)
+ */
+export async function fetchResumeSavedAt(): Promise {
+ // TODO: 后端就绪后改为 await fetch(`${API_BASE_URL}/api/resume/saved-at`, { method: 'GET' });当前以本地存储为唯一真相源
+ return mockRequest((await storageGet(RESUME_SAVED_AT_KEY)) ?? '');
+}
+
+/**
+ * 清除简历编辑覆盖层与保存时间
+ * @method DELETE
+ * @url /api/resume/override
+ * @returns Promise
+ * @remark 退出登录时调用(与 `clearOnboardingProfile` 成对):否则下一位用户在本机登录后,
+ * 会看到上一位用户编辑过的简历板块
+ */
+export async function clearResumeOverride(): Promise {
+ // TODO: await fetch(`${API_BASE_URL}/api/resume/override`, { method: 'DELETE' });
+ await storageRemove(RESUME_OVERRIDE_KEY);
+ await storageRemove(RESUME_SAVED_AT_KEY);
+}
+
+/* ===================== 简历:AI 能力(后端未接入) =====================
+ * 三个函数的 mock 分支一律返回 available:false 且**不碰 storage**:正文没有被改写的
+ * 情况下把 `aiOptimized` 置 true 会让页面显示「已优化」徽标 —— 那是假状态。
+ * 后端就绪后只改函数体(由后端改写并落库,或由本层写覆盖层),页面代码一行都不用动;
+ * 参数名去掉下划线即表示该能力已接入。
+ */
+
+/** AI 优化单条工作经历。@url /api/resume/experience/:id/optimize */
+export async function optimizeResumeExperience(_id: string): Promise {
+ // TODO: 真实请求(后端就绪后放开;参数名去掉下划线即表示该能力已接入)
+ // const res = await fetch(`${API_BASE_URL}/api/resume/experience/${_id}/optimize`, {
+ // method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: _id }),
+ // });
+ // return res.json();
+
+ return mockRequest({ available: false, message: RESUME_AI_UNAVAILABLE_TIP });
+}
+
+/** AI 润色自我评价。@url /api/resume/self-evaluation/polish */
+export async function polishResumeSelfEvaluation(_content: string): Promise {
+ // TODO: 真实请求(后端就绪后放开;参数名去掉下划线即表示该能力已接入)
+ // const res = await fetch(`${API_BASE_URL}/api/resume/self-evaluation/polish`, {
+ // method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: _content }),
+ // });
+ // return res.json();
+
+ return mockRequest({ available: false, message: RESUME_AI_UNAVAILABLE_TIP });
+}
+
+/** AI 按岗位 JD 定向优化简历。@url /api/resume/optimize/jd */
+export async function optimizeResumeWithJD(_jd: string): Promise {
+ // TODO: 真实请求(后端就绪后放开;参数名去掉下划线即表示该能力已接入)
+ // const res = await fetch(`${API_BASE_URL}/api/resume/optimize/jd`, {
+ // method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jd: _jd }),
+ // });
+ // return res.json();
+
+ return mockRequest({ available: false, message: RESUME_AI_UNAVAILABLE_TIP });
}
/**
@@ -2159,10 +3039,19 @@ export async function optimizeResume(): Promise {
* @method GET
* @url /api/resume/export-settings
* @returns Promise 导出设置
+ * @remark 以 storage(`youzhi:resume:export-settings`)为唯一真相源;老数据缺后加的字段时
+ * 用默认值补齐(不能整份替换,否则新字段会变成 undefined),存储损坏回落默认值
*/
export async function fetchResumeExportSettings(): Promise {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/export-settings`, { method: 'GET' }); return res.json();
- return mockRequest({ ...DEFAULT_EXPORT_SETTINGS });
+ const raw = await storageGet(RESUME_EXPORT_SETTINGS_KEY);
+ if (!raw) return mockRequest({ ...DEFAULT_EXPORT_SETTINGS });
+ try {
+ const stored = JSON.parse(raw) as Partial;
+ return mockRequest({ ...DEFAULT_EXPORT_SETTINGS, ...stored });
+ } catch {
+ return mockRequest({ ...DEFAULT_EXPORT_SETTINGS });
+ }
}
/**
@@ -2170,10 +3059,12 @@ export async function fetchResumeExportSettings(): Promise
* @method PUT
* @url /api/resume/export-settings
* @param settings 导出设置
+ * @remark 原实现是 `Object.assign(DEFAULT_EXPORT_SETTINGS, settings)`:只改模块内常量,
+ * 杀掉进程就丢(等于「保存」按钮保存不了),还会污染其它调用方读到的默认值
*/
export async function saveResumeExportSettings(settings: ResumeExportSettings): Promise {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/export-settings`, { method: 'PUT', body: JSON.stringify(settings) }); return res.json();
- Object.assign(DEFAULT_EXPORT_SETTINGS, settings);
+ await storageSet(RESUME_EXPORT_SETTINGS_KEY, JSON.stringify(settings));
return mockRequest(undefined);
}
@@ -2182,18 +3073,19 @@ export async function saveResumeExportSettings(settings: ResumeExportSettings):
* @method POST
* @url /api/resume/export
* @param settings 导出设置
- * @returns Promise<{ url: string }> 导出文件地址
- */
-export async function exportResume(settings: ResumeExportSettings): Promise<{ url: string }> {
+ * @returns Promise 本机文件地址与文件名;失败给出原因
+ * @remark 原实现返回写死的 `https://cdn.example.com/resume/xxx.pdf` —— 不产出任何文件,
+ * 页面拿到假链接又没有提示,等于「点了没事发生」。本机阶段改为真出 PDF:
+ * 取当前简历(含用户编辑)→ 生成 HTML → 打印成本机文件。
+ * 非 PDF 格式仍按 PDF 导出,由页面在格式选择处与导出成功后明确提示(需后端导出服务)。
+ */
+export async function exportResume(
+ settings: ResumeExportSettings,
+): Promise {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/export`, { method: 'POST', body: JSON.stringify(settings) }); return res.json();
- const formatMap: Record = {
- pdf: 'pdf',
- word: 'docx',
- image: 'png',
- };
- return mockRequest({
- url: `https://cdn.example.com/resume/${settings.fileName}.${formatMap[settings.format]}`,
- });
+ const resume = await fetchResume();
+ const fileName = resolveExportFileName(resume, settings.fileName);
+ return exportResumePdf(buildResumeExportHtml(resume, settings), fileName, settings);
}
/* ===================== 入职准备清单 ===================== */
@@ -2287,6 +3179,66 @@ const MOCK_ONBOARDING_CHECKLIST: OnboardingChecklist = {
],
};
+/** 勾选状态存储键:模板条目按 id 存 done,自定义条目另存一份 */
+const CHECKLIST_DONE_KEY = 'onboarding:checklist-done';
+const CHECKLIST_CUSTOM_KEY = 'onboarding:checklist-custom';
+
+/** 自定义待办的存储结构 */
+interface CustomChecklistItem {
+ id: string;
+ title: string;
+ done: boolean;
+}
+
+async function readDoneMap(): Promise> {
+ const raw = await storageGet(CHECKLIST_DONE_KEY);
+ if (raw === null) return {};
+ try {
+ return JSON.parse(raw) as Record;
+ } catch {
+ return {};
+ }
+}
+
+async function readCustomItems(): Promise {
+ const raw = await storageGet(CHECKLIST_CUSTOM_KEY);
+ if (raw === null) return [];
+ try {
+ const parsed = JSON.parse(raw) as CustomChecklistItem[];
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * 组装清单:模板分组叠加本机勾选状态,再拼上用户自定义的那组。
+ * 勾选状态必须落库 —— 否则同一台设备上退出再进来,勾过的又变回未勾选。
+ */
+async function buildOnboardingChecklist(): Promise {
+ const [doneMap, customItems] = await Promise.all([readDoneMap(), readCustomItems()]);
+
+ const groups: ChecklistGroup[] = MOCK_CHECKLIST_GROUPS.map((g) => ({
+ ...g,
+ items: g.items.map((item) => ({ ...item, done: doneMap[item.id] ?? item.done })),
+ }));
+
+ if (customItems.length > 0) {
+ groups.push({
+ id: 'custom',
+ title: '我的自定义待办',
+ icon: 'create-outline',
+ expanded: true,
+ items: customItems.map((c) => ({ id: c.id, title: c.title, done: c.done })),
+ });
+ }
+
+ const completed = groups.reduce((sum, g) => sum + g.items.filter((i) => i.done).length, 0);
+ const total = groups.reduce((sum, g) => sum + g.items.length, 0);
+
+ return { ...MOCK_ONBOARDING_CHECKLIST, groups, completed, total };
+}
+
/**
* 获取入职准备清单
* @method GET
@@ -2295,7 +3247,7 @@ const MOCK_ONBOARDING_CHECKLIST: OnboardingChecklist = {
*/
export async function fetchOnboardingChecklist(): Promise {
// TODO: const res = await fetch(`${API_BASE_URL}/api/tools/onboarding-checklist`, { method: 'GET' }); return res.json();
- return mockRequest(MOCK_ONBOARDING_CHECKLIST);
+ return mockRequest(await buildOnboardingChecklist());
}
/**
@@ -2311,16 +3263,91 @@ export async function toggleChecklistItem(
done: boolean,
): Promise {
// TODO: const res = await fetch(`${API_BASE_URL}/api/tools/onboarding-checklist/items/${itemId}`, { method: 'PATCH', body: JSON.stringify({ done }) }); return res.json();
- const groups = MOCK_CHECKLIST_GROUPS.map((g) => ({
- ...g,
- items: g.items.map((item) => (item.id === itemId ? { ...item, done } : item)),
- }));
- const completed = groups.reduce(
- (sum, g) => sum + g.items.filter((i) => i.done).length,
- 0,
+ const doneMap = await readDoneMap();
+ await storageSet(CHECKLIST_DONE_KEY, JSON.stringify({ ...doneMap, [itemId]: done }));
+
+ // 自定义条目另有一份带标题的存档,勾选同样要落到那份里
+ const customItems = await readCustomItems();
+ if (customItems.some((c) => c.id === itemId)) {
+ await storageSet(
+ CHECKLIST_CUSTOM_KEY,
+ JSON.stringify(customItems.map((c) => (c.id === itemId ? { ...c, done } : c))),
+ );
+ }
+
+ return mockRequest(await buildOnboardingChecklist());
+}
+
+/**
+ * 添加一条自定义待办
+ * @method POST
+ * @url /api/tools/onboarding-checklist/items
+ * @param title { string } 待办标题(去空格后不能为空)
+ * @returns Promise 更新后的清单;标题为空时 reject
+ */
+export async function addChecklistItem(title: string): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/tools/onboarding-checklist/items`, { method: 'POST', body: JSON.stringify({ title }) }); return res.json();
+ const trimmed = title.trim();
+ if (trimmed === '') return Promise.reject(new Error('待办内容不能为空'));
+
+ const customItems = await readCustomItems();
+ await storageSet(
+ CHECKLIST_CUSTOM_KEY,
+ JSON.stringify([
+ ...customItems,
+ { id: `custom-${Date.now()}`, title: trimmed, done: false },
+ ]),
);
- const total = groups.reduce((sum, g) => sum + g.items.length, 0);
- return mockRequest({ ...MOCK_ONBOARDING_CHECKLIST, groups, completed, total });
+
+ return mockRequest(await buildOnboardingChecklist());
+}
+
+/**
+ * 删除一条自定义待办(模板条目不允许删,删了下次进页面又会出现)
+ * @method DELETE
+ * @url /api/tools/onboarding-checklist/items/:id
+ * @param itemId { string } 自定义待办 id
+ * @returns Promise 更新后的清单;非自定义条目 reject
+ */
+export async function removeChecklistItem(itemId: string): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/tools/onboarding-checklist/items/${itemId}`, { method: 'DELETE' }); return res.json();
+ const customItems = await readCustomItems();
+ if (!customItems.some((c) => c.id === itemId)) {
+ return Promise.reject(new Error('模板待办不能删除'));
+ }
+
+ await storageSet(
+ CHECKLIST_CUSTOM_KEY,
+ JSON.stringify(customItems.filter((c) => c.id !== itemId)),
+ );
+ const doneMap = await readDoneMap();
+ if (itemId in doneMap) {
+ const { [itemId]: _removed, ...rest } = doneMap;
+ await storageSet(CHECKLIST_DONE_KEY, JSON.stringify(rest));
+ }
+
+ return mockRequest(await buildOnboardingChecklist());
+}
+
+/**
+ * 把清单拼成可导出的纯文本(导出 / 分享用,纯函数)。
+ * @param checklist 入职准备清单
+ * @returns string 形如「入职准备清单(公司 · 岗位)」+ 分组 + 勾选标记
+ */
+export function buildChecklistText(checklist: OnboardingChecklist): string {
+ const lines: string[] = [
+ `入职准备清单(${checklist.company} · ${checklist.position})`,
+ `入职日期 ${checklist.onboardingDate} · 已完成 ${checklist.completed}/${checklist.total}`,
+ '',
+ ];
+ checklist.groups.forEach((group) => {
+ lines.push(`【${group.title}】`);
+ group.items.forEach((item) => {
+ lines.push(`${item.done ? '[x]' : '[ ]'} ${item.title}`);
+ });
+ lines.push('');
+ });
+ return lines.join('\n').trim();
}
/* ===================== AI 模拟面试 ===================== */
@@ -2720,10 +3747,41 @@ const MOCK_JOB_EVALUATION_MANAGEMENT: JobEvaluationManagement = {
* @method GET
* @url /api/enterprise/evaluations
* @returns Promise 岗位评价管理数据
- */
+ * @remark backend-stub-ok:回复内容读 AsyncStorage(见 withReviewReplies)
+ */
+/** 企业回复的存储结构:评价 id → 回复内容 */
+const JOB_REVIEW_REPLIES_KEY = 'enterprise:review-replies';
+
+/** 读取已提交的评价回复 */
+async function readReviewReplies(): Promise> {
+ const raw = await storageGet(JOB_REVIEW_REPLIES_KEY);
+ if (!raw) return {};
+ try {
+ return JSON.parse(raw) as Record;
+ } catch {
+ // 存储损坏时按「没回复过」处理,不阻断评价列表展示
+ return {};
+ }
+}
+
+/** 把已提交的回复合并进评价列表(真实后端下这两个字段由接口直接返回) */
+async function withReviewReplies(reviews: JobReview[]): Promise {
+ const replies = await readReviewReplies();
+ return reviews.map((review) =>
+ replies[review.id] === undefined
+ ? review
+ : { ...review, replied: true, replyContent: replies[review.id] },
+ );
+}
export async function fetchJobEvaluationManagement(): Promise {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/evaluations`, { method: 'GET' }); return res.json();
- return mockRequest(MOCK_JOB_EVALUATION_MANAGEMENT);
+ const reviews = await withReviewReplies(MOCK_JOB_REVIEWS);
+ /*
+ * 只合并回复内容,**不重算回复率**:回复率的分母是「全部评价」(reviewCount = 128),
+ * 而这里能列出的只有 3 条样例,拿样例算出来的是 67%,比原值 72% 更假。
+ * 真实后端下这两个数字都由服务端给出。
+ */
+ return mockRequest({ ...MOCK_JOB_EVALUATION_MANAGEMENT, reviews });
}
/**
@@ -2733,15 +3791,23 @@ export async function fetchJobEvaluationManagement(): Promise 回复后的评价
+ * @remark mock 阶段回复内容落 AsyncStorage:不落库的话刷新一次回复就没了,
+ * 界面上「已回复」会自己变回「回复」,等于骗用户
*/
export async function replyJobReview(
reviewId: string,
reply: string,
): Promise {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/evaluations/${reviewId}/reply`, { method: 'POST', body: JSON.stringify({ reply }) }); return res.json();
+ const content = reply.trim();
+ if (content === '') {
+ return Promise.reject(new Error('回复内容不能为空'));
+ }
+ const replies = await readReviewReplies();
+ replies[reviewId] = content;
+ await storageSet(JOB_REVIEW_REPLIES_KEY, JSON.stringify(replies));
const review = MOCK_JOB_REVIEWS.find((r) => r.id === reviewId) ?? MOCK_JOB_REVIEWS[0];
- void reply;
- return mockRequest({ ...review, replied: true });
+ return mockRequest({ ...review, replied: true, replyContent: content });
}
/* ===================== 候选人详情 ===================== */
@@ -2947,6 +4013,61 @@ export async function submitInterviewScore(
return mockRequest({ success: true });
}
+/** 面试评分草稿的存储键(按面试 id 分开,避免串到另一场面试上) */
+function interviewScoreDraftKey(interviewId: string): string {
+ return `interview:score-draft:${interviewId}`;
+}
+
+/**
+ * 保存面试评分草稿
+ * @method PUT
+ * @url /api/enterprise/interviews/:id/score/draft
+ * @param interviewId { string } 面试 id
+ * @param score { InterviewScoreData } 当前评分内容
+ * @returns Promise 含保存时间
+ */
+export async function saveInterviewScoreDraft(
+ interviewId: string,
+ score: InterviewScoreData,
+): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/interviews/${interviewId}/score/draft`, { method: 'PUT', body: JSON.stringify(score) }); return res.json();
+ const draft: InterviewScoreDraft = { score, savedAt: new Date().toISOString() };
+ await storageSet(interviewScoreDraftKey(interviewId), JSON.stringify(draft));
+ return mockRequest(draft);
+}
+
+/**
+ * 读取面试评分草稿
+ * @method GET
+ * @url /api/enterprise/interviews/:id/score/draft
+ * @param interviewId { string } 面试 id
+ * @returns Promise 没存过或存储不可用时为 null
+ */
+export async function fetchInterviewScoreDraft(
+ interviewId: string,
+): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/interviews/${interviewId}/score/draft`, { method: 'GET' }); return res.json();
+ const raw = await storageGet(interviewScoreDraftKey(interviewId));
+ if (raw === null) return null;
+ try {
+ return JSON.parse(raw) as InterviewScoreDraft;
+ } catch {
+ // 存的内容坏了就当作没有草稿,不要把异常抛到页面上
+ return null;
+ }
+}
+
+/**
+ * 删除面试评分草稿(提交评分成功、或用户放弃草稿时调用)
+ * @method DELETE
+ * @url /api/enterprise/interviews/:id/score/draft
+ * @param interviewId { string } 面试 id
+ */
+export async function clearInterviewScoreDraft(interviewId: string): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/interviews/${interviewId}/score/draft`, { method: 'DELETE' }); return res.json();
+ await storageRemove(interviewScoreDraftKey(interviewId));
+}
+
/* ===================== 运营规则配置 ===================== */
/** 匹配模式选项 */
@@ -2995,6 +4116,10 @@ const MOCK_OPERATION_RULES: OperationRules = {
* @returns Promise 运营规则配置
*/
export async function fetchOperationRules(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/operation/rules`, { method: 'GET' });
+ // return res.json();
+
return mockRequest({ ...MOCK_OPERATION_RULES });
}
@@ -3006,6 +4131,12 @@ export async function fetchOperationRules(): Promise {
* @returns Promise 保存后的配置
*/
export async function saveOperationRules(rules: OperationRules): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/operation/rules`, {
+ // method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(rules),
+ // });
+ // return res.json();
+
return mockRequest({ ...rules });
}
@@ -3049,6 +4180,10 @@ const MOCK_AUDIT_REPORT: AuditReport = {
* @returns Promise 数据审计报告
*/
export async function fetchAuditReport(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/operation/audit-report`, { method: 'GET' });
+ // return res.json();
+
return mockRequest({ ...MOCK_AUDIT_REPORT });
}
@@ -3083,6 +4218,10 @@ const MOCK_ENTERPRISE_CERTIFICATION: EnterpriseCertificationData = {
* @returns Promise 企业认证数据
*/
export async function fetchEnterpriseCertification(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/enterprise/certification`, { method: 'GET' });
+ // return res.json();
+
return mockRequest({ ...MOCK_ENTERPRISE_CERTIFICATION });
}
@@ -3096,6 +4235,12 @@ export async function fetchEnterpriseCertification(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/enterprise/certification`, {
+ // method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form),
+ // });
+ // return res.json();
+
void form;
return mockRequest({ success: true, status: 'pending' });
}
@@ -3135,6 +4280,10 @@ const MOCK_HOLLAND_INTRO: HollandIntro = {
* @returns Promise 测评介绍
*/
export async function fetchHollandIntro(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/growth/holland-intro`, { method: 'GET' });
+ // return res.json();
+
return mockRequest(MOCK_HOLLAND_INTRO);
}
@@ -3227,69 +4376,229 @@ const MOCK_JOB_MANAGEMENT: JobManagement = {
* @returns Promise 岗位管理数据
*/
export async function fetchJobManagement(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/enterprise/job-management`, { method: 'GET' });
+ // return res.json();
+
return mockRequest(MOCK_JOB_MANAGEMENT);
}
/* ===================== 人才推荐(企业端) ===================== */
+/** 「匹配度高」的分数线 */
+const HIGH_MATCH_PERCENT = 90;
+
+/** 985/211 院校名单(用于「985/211」筛选) */
+const KEY_SCHOOLS = new Set([
+ '北京大学',
+ '清华大学',
+ '上海交通大学',
+ '浙江大学',
+ '南京大学',
+ '哈尔滨工业大学',
+ '北京邮电大学',
+]);
+
+/** 「5年经验」→ 5;解析不出时返回 0(按不满足处理) */
+function parseExperienceYears(experience: string): number {
+ const matched = /(\d+)\s*年/.exec(experience);
+ return matched ? Number(matched[1]) : 0;
+}
+
+/** 候选人是否命中某个 Tab(与 TalentRecommendationTab 的 key 一一对应) */
+function matchTalentTab(candidate: TalentCandidate, tabKey: string): boolean {
+ switch (tabKey) {
+ case 'high':
+ return candidate.matchPercent >= HIGH_MATCH_PERCENT;
+ case '985211':
+ return KEY_SCHOOLS.has(candidate.school);
+ case '5years':
+ return parseExperienceYears(candidate.experience) >= 5;
+ case 'master':
+ return candidate.education !== '本科';
+ case 'all':
+ default:
+ return true;
+ }
+}
+
+/**
+ * 按 Tab 与关键词过滤候选人(纯函数)。
+ * @param candidates 全部候选人
+ * @param tabKey Tab key('all' 表示不过滤)
+ * @param keyword 搜索关键词,匹配姓名 / 职位 / 技能标签 / 学校;空串不过滤
+ * @returns TalentCandidate[] 命中的候选人
+ */
+export function filterTalentCandidates(
+ candidates: TalentCandidate[],
+ tabKey: string,
+ keyword: string,
+): TalentCandidate[] {
+ const kw = keyword.trim().toLowerCase();
+ return candidates.filter((c) => {
+ if (!matchTalentTab(c, tabKey)) return false;
+ if (kw === '') return true;
+ return (
+ c.name.toLowerCase().includes(kw) ||
+ c.title.toLowerCase().includes(kw) ||
+ c.school.toLowerCase().includes(kw) ||
+ c.tags.some((tag) => tag.toLowerCase().includes(kw))
+ );
+ });
+}
+
+/** Tab 计数由候选人列表推导,避免页面上显示的数字和列表里的人数对不上 */
+function buildTalentTabs(candidates: TalentCandidate[]): TalentRecommendationTab[] {
+ const countOf = (tabKey: string) =>
+ candidates.filter((c) => matchTalentTab(c, tabKey)).length;
+ return [
+ { key: 'all', label: '全部', count: candidates.length },
+ { key: 'high', label: '匹配度高', count: countOf('high') },
+ { key: '985211', label: '985/211', count: countOf('985211') },
+ { key: '5years', label: '5年以上', count: countOf('5years') },
+ { key: 'master', label: '硕士及以上', count: countOf('master') },
+ ];
+}
+
+
+/** 推荐候选人列表(上面那份 mock 的原始数据,Tab 计数与 todayCount 都从它推导) */
+const MOCK_TALENT_CANDIDATES: TalentCandidate[] = [
+ {
+ id: 'cand-1',
+ name: '张明远',
+ avatar: '张',
+ title: '高级前端工程师 · 5年经验',
+ experience: '5年经验',
+ age: 28,
+ school: '北京大学',
+ city: '北京',
+ expectedSalary: '期望薪资 30-35K',
+ matchPercent: 96,
+ overallScore: 88,
+ percentile: 99,
+ tags: ['React', 'TypeScript', 'Node.js'],
+ education: '硕士',
+ },
+ {
+ id: 'cand-2',
+ name: '李雨桐',
+ avatar: '李',
+ title: '产品经理 · 3年经验',
+ experience: '3年经验',
+ age: 26,
+ school: '上海交通大学',
+ city: '北京',
+ expectedSalary: '期望薪资 25-30K',
+ matchPercent: 82,
+ overallScore: 75,
+ percentile: 75,
+ tags: ['大厂背景', '项目经验', '沟通能力强'],
+ education: '本科',
+ },
+ {
+ id: 'cand-3',
+ name: '王建国',
+ avatar: '王',
+ title: 'Java后端开发 · 7年经验',
+ experience: '7年经验',
+ age: 33,
+ school: '浙江大学',
+ city: '北京',
+ expectedSalary: '期望薪资 35-45K',
+ matchPercent: 91,
+ overallScore: 92,
+ percentile: 92,
+ tags: ['技术扎实', '经验丰富', '高绩效'],
+ education: '本科',
+ },
+ {
+ id: 'cand-4',
+ name: '陈思远',
+ avatar: '陈',
+ title: '资深前端工程师 · 6年经验',
+ experience: '6年经验',
+ age: 30,
+ school: '清华大学',
+ city: '北京',
+ expectedSalary: '期望薪资 40-50K',
+ matchPercent: 94,
+ overallScore: 90,
+ percentile: 96,
+ tags: ['架构经验', '跨端开发', '带团队'],
+ education: '硕士',
+ },
+ {
+ id: 'cand-5',
+ name: '赵敏',
+ avatar: '赵',
+ title: '数据分析师 · 4年经验',
+ experience: '4年经验',
+ age: 27,
+ school: '南京大学',
+ city: '上海',
+ expectedSalary: '期望薪资 25-32K',
+ matchPercent: 88,
+ overallScore: 84,
+ percentile: 88,
+ tags: ['SQL', 'Python', '业务理解'],
+ education: '硕士',
+ },
+ {
+ id: 'cand-6',
+ name: '孙浩',
+ avatar: '孙',
+ title: '测试开发工程师 · 5年经验',
+ experience: '5年经验',
+ age: 29,
+ school: '北京邮电大学',
+ city: '北京',
+ expectedSalary: '期望薪资 22-28K',
+ matchPercent: 79,
+ overallScore: 78,
+ percentile: 72,
+ tags: ['自动化测试', '性能测试'],
+ education: '本科',
+ },
+ {
+ id: 'cand-7',
+ name: '周雨',
+ avatar: '周',
+ title: 'UI 设计师 · 8年经验',
+ experience: '8年经验',
+ age: 34,
+ school: '中国美术学院',
+ city: '杭州',
+ expectedSalary: '期望薪资 28-35K',
+ matchPercent: 76,
+ overallScore: 80,
+ percentile: 70,
+ tags: ['设计系统', 'B 端设计'],
+ education: '本科',
+ },
+ {
+ id: 'cand-8',
+ name: '吴桐',
+ avatar: '吴',
+ title: '算法工程师 · 3年经验',
+ experience: '3年经验',
+ age: 26,
+ school: '哈尔滨工业大学',
+ city: '深圳',
+ expectedSalary: '期望薪资 35-45K',
+ matchPercent: 85,
+ overallScore: 86,
+ percentile: 90,
+ tags: ['推荐算法', '论文产出'],
+ education: '博士',
+ },
+];
+
const MOCK_TALENT_RECOMMENDATION: TalentRecommendation = {
companyName: '智联科技',
hasData: true,
- todayCount: 15,
- tabs: [
- { key: 'all', label: '全部', count: 12 },
- { key: 'high', label: '匹配度高', count: 8 },
- { key: '985211', label: '985/211', count: 5 },
- { key: '5years', label: '5年以上', count: 5 },
- { key: 'master', label: '硕士及以上', count: 3 },
- ],
- candidates: [
- {
- id: 'cand-1',
- name: '张明远',
- avatar: '张',
- title: '高级前端工程师 · 5年经验',
- experience: '5年经验',
- age: 28,
- school: '北京大学',
- city: '北京',
- expectedSalary: '期望薪资 30-35K',
- matchPercent: 96,
- overallScore: 88,
- percentile: 99,
- tags: ['React', 'TypeScript', 'Node.js'],
- },
- {
- id: 'cand-2',
- name: '李雨桐',
- avatar: '李',
- title: '产品经理 · 3年经验',
- experience: '3年经验',
- age: 26,
- school: '上海交通大学',
- city: '北京',
- expectedSalary: '期望薪资 25-30K',
- matchPercent: 82,
- overallScore: 75,
- percentile: 75,
- tags: ['大厂背景', '项目经验', '沟通能力强'],
- },
- {
- id: 'cand-3',
- name: '王建国',
- avatar: '王',
- title: 'Java后端开发 · 7年经验',
- experience: '7年经验',
- age: 33,
- school: '浙江大学',
- city: '北京',
- expectedSalary: '期望薪资 35-45K',
- matchPercent: 91,
- overallScore: 92,
- percentile: 92,
- tags: ['技术扎实', '经验丰富', '高绩效'],
- },
- ],
+ todayCount: MOCK_TALENT_CANDIDATES.length,
+ tabs: buildTalentTabs(MOCK_TALENT_CANDIDATES),
+ candidates: MOCK_TALENT_CANDIDATES,
poolStats: [
{ id: 'active', value: '12,580', label: '活跃候选人' },
{ id: 'new', value: '326', label: '今日新增' },
@@ -3315,60 +4624,171 @@ const MOCK_TALENT_RECOMMENDATION: TalentRecommendation = {
* @returns Promise 人才推荐数据
*/
export async function fetchTalentRecommendation(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/enterprise/talent-recommendation`, { method: 'GET' });
+ // return res.json();
+
return mockRequest(MOCK_TALENT_RECOMMENDATION);
}
/* ===================== 企业沟通 ===================== */
-const MOCK_ENTERPRISE_COMMUNICATION: EnterpriseCommunication = {
- companyName: '智联科技',
- hasData: true,
- pendingTasks: [
- { id: 'reply', label: '待回复', count: 3, hint: '24小时内未回复', icon: 'chatbubble-ellipses-outline', color: 'action' },
- { id: 'score', label: '待评分', count: 2, hint: '面试结束待及时评分', icon: 'star-outline', color: 'compareOrange' },
- ],
- tabs: [
- { key: 'all', label: '全部', count: 12 },
- { key: 'pending', label: '未回复', count: 3 },
- { key: 'interview', label: '面试邀请', count: 2 },
- { key: 'offer', label: 'Offer沟通', count: 1 },
- { key: 'closed', label: '已归档', count: 4 },
- ],
- chats: [
- {
- id: 'chat-1',
- userId: 'u-1',
- name: '张明远',
- avatar: '张',
- title: '高级前端工程师 · 最近活跃',
- lastMessage: '你的简历很符合我们的要求,方便下周面试吗?',
- time: '10分钟前',
- unreadCount: 1,
- labels: [],
- },
+/** 企业沟通会话列表(status 决定落在哪个 Tab 下) */
+const MOCK_ENTERPRISE_CHATS: EnterpriseChatItem[] = [
+ {
+ id: 'chat-1',
+ userId: 'u-1',
+ name: '张明远',
+ avatar: '张',
+ title: '高级前端工程师 · 最近活跃',
+ lastMessage: '你的简历很符合我们的要求,方便下周面试吗?',
+ time: '10分钟前',
+ unreadCount: 1,
+ labels: [],
+ status: 'pending',
+ },
+ {
+ id: 'chat-2',
+ userId: 'u-2',
+ name: '李雨桐',
+ avatar: '李',
+ title: '产品经理',
+ lastMessage: '你好,我看贵司的产品经理岗位很匹配,想进一步沟通。',
+ time: '30分钟前',
+ unreadCount: 1,
+ labels: [],
+ status: 'pending',
+ },
+ {
+ id: 'chat-3',
+ userId: 'u-3',
+ name: '王建国',
+ avatar: '王',
+ title: 'Java后端开发',
+ lastMessage: '已收到面试邀请,确认参加。',
+ time: '2小时前',
+ unreadCount: 0,
+ labels: ['面试待评分'],
+ status: 'interview',
+ },
+ {
+ id: 'chat-4',
+ userId: 'u-4',
+ name: '陈思远',
+ avatar: '陈',
+ title: '资深前端工程师',
+ lastMessage: '面试时间我这边可以,麻烦发个会议链接。',
+ time: '3小时前',
+ unreadCount: 1,
+ labels: ['面试待评分'],
+ status: 'interview',
+ },
+ {
+ id: 'chat-5',
+ userId: 'u-5',
+ name: '赵敏',
+ avatar: '赵',
+ title: '数据分析师',
+ lastMessage: '薪资和期权方案我这边基本认可,什么时候可以发正式 offer?',
+ time: '昨天',
+ unreadCount: 0,
+ labels: ['Offer沟通'],
+ status: 'offer',
+ },
+ {
+ id: 'chat-6',
+ userId: 'u-6',
+ name: '孙浩',
+ avatar: '孙',
+ title: '测试开发工程师',
+ lastMessage: '考虑后决定接受另一家的机会,感谢贵司的认可。',
+ time: '3天前',
+ unreadCount: 0,
+ labels: ['已归档'],
+ status: 'closed',
+ },
+ {
+ id: 'chat-7',
+ userId: 'u-7',
+ name: '周雨',
+ avatar: '周',
+ title: 'UI 设计师',
+ lastMessage: '作品集已更新,麻烦再看看新的三个案例。',
+ time: '4天前',
+ unreadCount: 0,
+ labels: ['已归档'],
+ status: 'closed',
+ },
+ {
+ id: 'chat-8',
+ userId: 'u-8',
+ name: '吴桐',
+ avatar: '吴',
+ title: '运维工程师',
+ lastMessage: '这边流程走完了,简历先留档,后续有合适岗位再联系。',
+ time: '5天前',
+ unreadCount: 0,
+ labels: ['已归档'],
+ status: 'closed',
+ },
+ {
+ id: 'chat-9',
+ userId: 'u-9',
+ name: '郑楠',
+ avatar: '郑',
+ title: '算法工程师',
+ lastMessage: '面试未通过,简历已进人才库。',
+ time: '上周',
+ unreadCount: 0,
+ labels: ['已归档'],
+ status: 'closed',
+ },
+];
+
+/** 三个待办任务的计数由会话列表推导(业务规则放接口层,避免页面上写死徽标数字) */
+function buildPendingTasks(chats: EnterpriseChatItem[]): PendingTask[] {
+ return [
{
- id: 'chat-2',
- userId: 'u-2',
- name: '李雨桐',
- avatar: '李',
- title: '产品经理',
- lastMessage: '你好,我看贵司的产品经理岗位很匹配,想进一步沟通。',
- time: '30分钟前',
- unreadCount: 0,
- labels: [],
+ id: 'reply',
+ label: '待回复',
+ count: chats.filter((c) => c.unreadCount > 0).length,
+ hint: '24小时内未回复',
+ icon: 'chatbubble-ellipses-outline',
+ color: 'action',
+ // 点「待回复」切到「未回复」Tab,别让带箭头的卡片点下去什么也不发生
+ tabKey: 'pending',
},
{
- id: 'chat-3',
- userId: 'u-3',
- name: '王建国',
- avatar: '王',
- title: 'Java后端开发',
- lastMessage: '已收到面试邀请,确认参加。',
- time: '2小时前',
- unreadCount: 0,
- labels: ['面试待评分'],
+ id: 'score',
+ label: '待评分',
+ count: chats.filter((c) => c.labels.includes('面试待评分')).length,
+ hint: '面试结束待及时评分',
+ icon: 'star-outline',
+ color: 'compareOrange',
+ route: '/interview-score',
},
- ],
+ ];
+}
+
+/** Tab 计数同样由会话列表推导 */
+function buildCommunicationTabs(chats: EnterpriseChatItem[]): EnterpriseCommunicationTab[] {
+ const countOf = (status: EnterpriseChatStatus) =>
+ chats.filter((c) => c.status === status).length;
+ return [
+ { key: 'all', label: '全部', count: chats.length },
+ { key: 'pending', label: '未回复', count: countOf('pending') },
+ { key: 'interview', label: '面试邀请', count: countOf('interview') },
+ { key: 'offer', label: 'Offer沟通', count: countOf('offer') },
+ { key: 'closed', label: '已归档', count: countOf('closed') },
+ ];
+}
+
+const MOCK_ENTERPRISE_COMMUNICATION: EnterpriseCommunication = {
+ companyName: '智联科技',
+ hasData: true,
+ pendingTasks: buildPendingTasks(MOCK_ENTERPRISE_CHATS),
+ tabs: buildCommunicationTabs(MOCK_ENTERPRISE_CHATS),
+ chats: MOCK_ENTERPRISE_CHATS,
dataReference: [
{ label: '主动联系简历数比被动接收高', value: '60%' },
{ label: '24小时内回复的面试转化率提升', value: '2.3倍' },
@@ -3383,9 +4803,37 @@ const MOCK_ENTERPRISE_COMMUNICATION: EnterpriseCommunication = {
* @returns Promise 沟通数据
*/
export async function fetchEnterpriseCommunication(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/enterprise/communication`, { method: 'GET' });
+ // return res.json();
+
return mockRequest(MOCK_ENTERPRISE_COMMUNICATION);
}
+/**
+ * 按 Tab 与关键词过滤沟通会话(纯函数,页面只渲染结果)。
+ * @param chats 全部会话
+ * @param tabKey Tab key('all' 表示不过滤)
+ * @param keyword 搜索关键词,匹配姓名 / 职位 / 最后一条消息;空串不过滤
+ * @returns EnterpriseChatItem[] 命中的会话
+ */
+export function filterEnterpriseChats(
+ chats: EnterpriseChatItem[],
+ tabKey: string,
+ keyword: string,
+): EnterpriseChatItem[] {
+ const kw = keyword.trim().toLowerCase();
+ return chats.filter((chat) => {
+ if (tabKey !== 'all' && chat.status !== tabKey) return false;
+ if (kw === '') return true;
+ return (
+ chat.name.toLowerCase().includes(kw) ||
+ chat.title.toLowerCase().includes(kw) ||
+ chat.lastMessage.toLowerCase().includes(kw)
+ );
+ });
+}
+
/* ===================== 岗位评价管理空状态 ===================== */
const MOCK_JOB_EVALUATION_EMPTY: JobEvaluationEmptyState = {
@@ -3414,6 +4862,10 @@ const MOCK_JOB_EVALUATION_EMPTY: JobEvaluationEmptyState = {
* @returns Promise 空状态数据
*/
export async function fetchJobEvaluationEmptyState(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/enterprise/job-evaluation-empty`, { method: 'GET' });
+ // return res.json();
+
return mockRequest(MOCK_JOB_EVALUATION_EMPTY);
}
@@ -3905,6 +5357,10 @@ export async function submitGameLevel(
* @returns Promise 重置后的进度
*/
export async function resetGameProgress(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/game/reset`, { method: 'POST' });
+ // return res.json();
+
MOCK_GAME_PROGRESS = { ...DEFAULT_GAME_PROGRESS };
return mockRequest(MOCK_GAME_PROGRESS);
}
@@ -4261,7 +5717,11 @@ export async function createLegalOrder(
* @remark 真实接入时,应由后端支付回调确认支付结果后再激活会员态,前端不自行判定成功
*/
export async function payLegalOrder(orderId: string): Promise {
- // TODO: 调用真实支付 SDK → 后端回调确认 → 返回订单
+ // TODO: 支付不能只发一个请求 —— 需走支付 SDK,且以后端回调确认为准(本机返回的 paid 不可信)。
+ // 后端就绪后放开下面两行,并把本机的「直接置 paid」换成「等回调后再读订单」:
+ // const res = await fetch(`${API_BASE_URL}/api/legal/orders/${orderId}/pay`, { method: 'POST' });
+ // return res.json();
+
const orders = await readLegalOrders();
const idx = orders.findIndex((o) => o.id === orderId);
if (idx === -1) {
@@ -4602,6 +6062,10 @@ const MOCK_PRIVACY_CENTER: PrivacyCenter = {
* @returns Promise 隐私中心数据
*/
export async function fetchPrivacyCenter(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/settings/privacy-center`, { method: 'GET' });
+ // return res.json();
+
return mockRequest(MOCK_PRIVACY_CENTER);
}
@@ -4615,6 +6079,12 @@ export async function fetchPrivacyCenter(): Promise {
export async function updatePrivacyCenterSetting(
payload: UpdateTogglePayload,
): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/settings/privacy-center/${payload.id}`, {
+ // method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
+ // });
+ // return res.json();
+
for (const section of MOCK_PRIVACY_CENTER.sections) {
const target = section.items.find((item) => item.id === payload.id);
if (target && target.type === 'switch') {
@@ -4767,6 +6237,10 @@ const MOCK_DND_SETTING: DndSetting = {
* @returns Promise
*/
export async function fetchDndSetting(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/settings/privacy/dnd`, { method: 'GET' });
+ // return res.json();
+
return mockRequest({ ...MOCK_DND_SETTING });
}
@@ -4778,6 +6252,12 @@ export async function fetchDndSetting(): Promise {
* @returns Promise
*/
export async function updateDndSetting(payload: Partial): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/settings/privacy/dnd`, {
+ // method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
+ // });
+ // return res.json();
+
Object.assign(MOCK_DND_SETTING, payload);
return mockRequest({ ...MOCK_DND_SETTING });
}
@@ -4797,6 +6277,10 @@ const MOCK_KEYWORD_BLOCK: KeywordBlockSetting = {
* @returns Promise
*/
export async function fetchKeywordBlock(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/settings/privacy/keywords`, { method: 'GET' });
+ // return res.json();
+
return mockRequest({ ...MOCK_KEYWORD_BLOCK, keywords: [...MOCK_KEYWORD_BLOCK.keywords] });
}
@@ -4808,6 +6292,12 @@ export async function fetchKeywordBlock(): Promise {
* @returns Promise
*/
export async function addBlockKeyword(keyword: string): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/settings/privacy/keywords`, {
+ // method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keyword }),
+ // });
+ // return res.json();
+
const value = keyword.trim();
if (value && !MOCK_KEYWORD_BLOCK.keywords.includes(value)) {
MOCK_KEYWORD_BLOCK.keywords.push(value);
@@ -4823,6 +6313,10 @@ export async function addBlockKeyword(keyword: string): Promise
*/
export async function removeBlockKeyword(keyword: string): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/settings/privacy/keywords/${encodeURIComponent(keyword)}`, { method: 'DELETE' });
+ // return res.json();
+
MOCK_KEYWORD_BLOCK.keywords = MOCK_KEYWORD_BLOCK.keywords.filter((item) => item !== keyword);
return mockRequest({ ...MOCK_KEYWORD_BLOCK, keywords: [...MOCK_KEYWORD_BLOCK.keywords] });
}
@@ -4854,6 +6348,10 @@ const MOCK_ACCOUNT_CANCEL: AccountCancelData = {
* @returns Promise
*/
export async function fetchAccountCancel(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/settings/account-cancel`, { method: 'GET' });
+ // return res.json();
+
return mockRequest({
...MOCK_ACCOUNT_CANCEL,
canCancel: MOCK_ACCOUNT_CANCEL.checklist.every((item) => item.resolved),
@@ -4902,6 +6400,10 @@ const MOCK_DELETE_ALL_DATA: DeleteAllData = {
* @returns Promise
*/
export async function fetchDeleteAllData(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/settings/delete-data`, { method: 'GET' });
+ // return res.json();
+
return mockRequest({ ...MOCK_DELETE_ALL_DATA });
}
@@ -4929,7 +6431,7 @@ export async function submitDeleteData(
/**
* 隐私政策正文
- * 【待确认】文中公司全称、联系邮箱、客服电话、数据存储地等需法务确认后替换占位内容
+ * 【待确认】文中公司全称、客服邮箱、数据存储地等需法务确认后替换占位内容(客服电话已于 2026-09-15 填入 15154608969)
*/
const MOCK_PRIVACY_POLICY: LegalDocument = {
id: 'privacy-policy',
@@ -5005,7 +6507,7 @@ const MOCK_PRIVACY_POLICY: LegalDocument = {
{
heading: '九、联系我们',
paragraphs: [
- '如您对本政策或您的个人信息处理有任何疑问、意见或建议,可通过以下方式与我们联系:【待确认:客服邮箱 / 客服电话 / 个人信息保护负责人邮箱】。',
+ '如您对本政策或您的个人信息处理有任何疑问、意见或建议,可通过以下方式与我们联系:客服电话 15154608969;客服邮箱与个人信息保护负责人邮箱:【待确认:客服邮箱 / 个人信息保护负责人邮箱】。',
'公司名称:【待确认:公司全称】。注册地址:【待确认:注册地址】。我们将在收到您的请求后 15 个工作日内答复。',
],
},
@@ -5230,6 +6732,10 @@ const MOCK_PERSONAL_DATA_EXPORT: PersonalDataExport = {
* @returns Promise 个人数据导出数据
*/
export async function fetchPersonalDataExport(): Promise {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/settings/personal-data-export`, { method: 'GET' });
+ // return res.json();
+
return mockRequest(MOCK_PERSONAL_DATA_EXPORT);
}
@@ -5240,6 +6746,10 @@ export async function fetchPersonalDataExport(): Promise {
* @returns Promise<{ success: boolean; message: string }> 申请结果
*/
export async function requestPersonalDataExport(): Promise<{ success: boolean; message: string }> {
+ // TODO: 真实请求(后端就绪后替换)
+ // const res = await fetch(`${API_BASE_URL}/api/settings/personal-data-export`, { method: 'POST' });
+ // return res.json();
+
return mockRequest({ success: true, message: '导出申请已提交,预计 1-2 分钟后可在最近导出中下载。' });
}
@@ -5534,17 +7044,66 @@ export function loginByPhone(phone: string, code: string): Promise
}
/**
- * 运营商一键登录(本机号码认证)。
+ * 手机号脱敏(业务规则在本层,页面不自己切字符串)。
+ * @param phone 手机号
+ * @returns 11 位时返回 150****9367;非 11 位原样返回(宁可露出也不截断成错的)
+ */
+export function maskPhone(phone: string): string {
+ return phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone;
+}
+
+/**
+ * 运营商 SDK 在 mock 阶段取回的本机号码。
+ * 真实接入后由端上 SDK 取号,这个常量连同 mock 分支一并删掉。
+ *
+ * 注意它是**接口层的模拟数据**(与 mockJobData 同性质),不是页面里写死的展示值:
+ * 页面只能拿 `fetchCarrierPhone()` 的返回,取不到号就必须走失败分支。
+ */
+const MOCK_CARRIER_PHONE = '15000009367';
+const MOCK_CARRIER_NAME: CarrierName = 'cmcc';
+
+/**
+ * 取本机号码(运营商一键登录第 1 步:预取号)。
+ *
+ * 真实实现在**端上**完成,不经过我们的服务器:需接入运营商号码认证 SDK
+ * (中国移动/联通/电信,或阿里云号码认证服务等聚合方案)。它是原生模块,
+ * Expo Go 跑不了,必须 dev build / 出包 —— 详见 运营商一键登录接入方案.md。
+ *
+ * @method POST
+ * @url /api/auth/carrier/number
+ * @returns Promise 本机号码 + 掩码 + 一次性鉴权令牌;取号失败时号码与令牌为空串
+ */
+export function fetchCarrierPhone(): Promise {
+ // TODO: 端上取号(原生 SDK),去掉下面的 mock 返回:
+ // const r = await NumberAuthSDK.getMobileNumber(); // 5 分钟内有效,用后即废
+ // if (!r?.token) return { phone: '', maskedPhone: '', carrierToken: '', carrier: 'unknown', expiresAt: 0 };
+ // return { phone: r.phone, maskedPhone: maskPhone(r.phone), carrierToken: r.token, carrier: carrierOf(r.operator), expiresAt: Date.now() + CARRIER_TOKEN_TTL_MS };
+ return mockRequest({
+ phone: MOCK_CARRIER_PHONE,
+ maskedPhone: maskPhone(MOCK_CARRIER_PHONE),
+ carrierToken: `mock_carrier_token_${Date.now()}`,
+ carrier: MOCK_CARRIER_NAME,
+ expiresAt: Date.now() + CARRIER_TOKEN_TTL_MS,
+ });
+}
+
+/**
+ * 运营商一键登录(本机号码认证第 2 步:用取号令牌换登录态)。
*
- * 真实场景由运营商 SDK 返回本机号码与鉴权 token,前端直接换取登录态,
- * 无需用户输入。此处 mock 返回与 P2Q 展示掩码(150****9367)一致的号码。
+ * 取号拿到的号码只在端上做展示,**可信号码以后端向运营商换取的结果为准**,
+ * 所以返回的 phone 由接口层给出,页面拿它写回资料,不自己拼预取到的号码。
*
* @method POST
* @url /api/auth/login-by-carrier
- * @returns 登录结果(含 token)
+ * @param carrierToken 取号接口下发的一次性鉴权令牌(空串表示没取到号,直接判失败)
+ * @returns Promise 登录结果(含 token 与已核验的手机号)
*/
-export function loginByCarrier(): Promise {
- return mockRequest({ token: 'mock_token_carrier', phone: '15000009367' });
+export function loginByCarrier(carrierToken: string): Promise {
+ // TODO: const res = await fetch(`${API_BASE_URL}/api/auth/login-by-carrier`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ carrierToken }) }); return res.json();
+ if (!carrierToken) {
+ return Promise.reject(new Error('本机号码未取到,请重新获取或改用验证码登录'));
+ }
+ return mockRequest({ token: `mock_token_carrier_${carrierToken}`, phone: MOCK_CARRIER_PHONE });
}
/* ===================== 账号:密码规则(登录/重置/改密三处共用) ===================== */
@@ -5786,7 +7345,41 @@ export function bindWechatAndRegister(
* @url /api/user/job-preference
* @param preference 求职意向(期望职位/城市/薪资/工作类型/求职状态)
* @returns 保存后的求职意向
+ * @remark 存入独立的 `user:job-preference` 键:这是用户显式编辑的偏好,
+ * 与引导采集的资料(`onboarding:profile`)分开存放,避免互相覆盖
*/
-export function saveJobPreference(preference: JobPreference): Promise {
+export async function saveJobPreference(preference: JobPreference): Promise {
+ // TODO: await fetch(`${API_BASE_URL}/api/user/job-preference`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(preference) });
+ await storageSet(JOB_PREFERENCE_KEY, JSON.stringify(preference));
return mockRequest(preference);
}
+
+/* ===================== 本地缓存(设置页「清除缓存」) ===================== */
+
+/**
+ * 读取应用缓存占用(字节)。
+ *
+ * 没有对应的后端接口:读的是**设备上的缓存目录**,因此不走 fetch。
+ * 读不到(目录不存在 / jest 环境无原生模块)返回 0,页面据此显示 0B。
+ *
+ * @returns Promise 缓存占用字节数
+ * @remark backend-stub-ok:本地文件系统读取,非网络资源
+ */
+export async function fetchCacheSize(): Promise {
+ // backend-stub-ok
+ return readCacheBytes();
+}
+
+/**
+ * 清空应用缓存。
+ *
+ * 没有对应的后端接口:清的是设备上的缓存目录内容(保留目录本身)。
+ * 用户已登录状态、已填资料、简历等都在 AsyncStorage / 文档目录,不受影响。
+ *
+ * @returns Promise 本次清理掉的字节数(用于「已清除 xx」提示)
+ * @remark backend-stub-ok:本地文件系统写入,非网络资源
+ */
+export async function clearAppCache(): Promise {
+ // backend-stub-ok
+ return clearCacheDir();
+}
diff --git a/src/api/index.ts b/src/api/index.ts
index 485af63..1a5344c 100644
--- a/src/api/index.ts
+++ b/src/api/index.ts
@@ -26,6 +26,7 @@ export {
sendChatMessage,
fetchEnterpriseCertification,
fetchEnterpriseCommunication,
+ filterEnterpriseChats,
fetchEnterpriseOverview,
fetchGameLevels,
fetchGameProgress,
@@ -36,6 +37,7 @@ export {
fetchJobManagement,
fetchJobPublishForm,
fetchTalentRecommendation,
+ filterTalentCandidates,
fetchLegalBrand,
fetchLegalConversation,
fetchLegalFAQs,
@@ -55,6 +57,7 @@ export {
fetchFavorites,
fetchHistory,
fetchOfferCompareData,
+ fetchPendingOfferCount,
fetchJobOverallScore,
fetchJobPreference,
saveJobPreference,
@@ -64,6 +67,16 @@ export {
fetchNotifications,
fetchNotificationSettings,
fetchOnboardingChecklist,
+ addChecklistItem,
+ removeChecklistItem,
+ buildChecklistText,
+ fetchOnboardingProfile,
+ saveOnboardingProfile,
+ clearOnboardingProfile,
+ toOnboardingProfile,
+ fromOnboardingProfile,
+ salaryMaxOptions,
+ applySalaryMin,
fetchOperationRules,
fetchOverallScore,
fetchPrivacySettings,
@@ -90,6 +103,10 @@ export {
loginByPhone,
loginByPassword,
loginByCarrier,
+ fetchCarrierPhone,
+ maskPhone,
+ fetchCacheSize,
+ clearAppCache,
sendSmsCode,
resetPassword,
changePassword,
@@ -104,26 +121,44 @@ export {
fetchRankData,
fetchResume,
fetchResumeExportSettings,
+ fetchResumeSavedAt,
+ clearResumeOverride,
fetchSalaryBrackets,
fetchSalaryData,
fetchSalaryRecords,
+ fetchSalaryToolLinks,
saveSalaryRecord,
deleteSalaryRecord,
fetchSkillCloud,
+ fetchSkillLearningPath,
+ filterSkillWords,
fetchUserProfile,
fetchUserStats,
fetchWeightSettings,
inviteCandidate,
estimateJobQuality,
optimizeResume,
+ optimizeResumeExperience,
+ optimizeResumeWithJD,
+ polishResumeSelfEvaluation,
publishJob,
replyJobReview,
saveOperationRules,
+ saveResumeBasicInfo,
+ saveResumeEducation,
+ saveResumeExperience,
saveResumeExportSettings,
+ saveResumeProjects,
+ saveResumeSelfEvaluation,
+ saveResumeSkills,
+ saveResumeSnapshot,
exportResume,
saveWeightSettings,
submitEnterpriseCertification,
submitInterviewScore,
+ saveInterviewScoreDraft,
+ fetchInterviewScoreDraft,
+ clearInterviewScoreDraft,
computeSalaryResult,
resetGameProgress,
sendLegalMessage,
diff --git a/src/constants/index.ts b/src/constants/index.ts
index de016cc..5b05e18 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -1,7 +1,13 @@
/**
* 常量统一导出(枚举、配置项等)
*/
-import type { CitySocialProfile, JobLevel, PasswordRuleError } from '../types';
+import type {
+ CarrierName,
+ CitySocialProfile,
+ JobLevel,
+ PasswordRuleError,
+ ResumeTemplate,
+} from '../types';
/** 请求失败自动重试次数 */
export const MAX_RETRY_COUNT = 3;
@@ -62,9 +68,38 @@ export const DEFAULT_SOCIAL_PROFILE: CitySocialProfile = {
/** 一天的毫秒数,用于令牌有效期等时间计算 */
export const ONE_DAY_MS = 24 * 60 * 60 * 1000;
+/** 拍照 / 相册未授权时的提示:权限是用户的选择,需要告诉他去哪里改,而不是静默失败 */
+export const IMAGE_PICK_DENIED_HINT = '未获得权限,请在系统设置中开启相机 / 相册权限后重试';
+
+/**
+ * 当前年份。滚轮可选年份等以「今年」为起点生成,
+ * 写死字面量会在跨年时悄悄过期,故取系统时间。
+ */
+export const CURRENT_YEAR = new Date().getFullYear();
+
/** 登录令牌有效期(毫秒),与后端 JWT 过期时间保持一致 */
export const TOKEN_TTL_MS = 30 * ONE_DAY_MS;
+/* ===================== 运营商一键登录(本机号码认证) ===================== */
+
+/**
+ * 运营商取号令牌有效期(毫秒)。
+ * 运营商下发的鉴权令牌是一次性的、短时效(业界普遍 5 分钟),
+ * 超时后必须重新取号 —— 页面在超时时把「一键登录」置灰并给出重新取号入口。
+ */
+export const CARRIER_TOKEN_TTL_MS = 5 * 60 * 1000;
+
+/**
+ * 认证服务提供方 → 展示名。
+ * 取号接口返回哪家就用哪家,页面不写死「中国移动」。
+ */
+export const CARRIER_LABEL: Record = {
+ cmcc: '中国移动',
+ cucc: '中国联通',
+ ctcc: '中国电信',
+ unknown: '运营商',
+};
+
/* ===================== 登录密码规则 ===================== */
/** 登录密码最小长度(含) */
@@ -85,3 +120,161 @@ export const PASSWORD_RULE_LABEL: Record = {
mismatch: '两次输入的密码不一致',
'same-as-old': '新密码不能与原密码相同',
};
+
+/* ===================== 岗位推荐匹配(引导采集的偏好 → 岗位排序加分) ===================== */
+
+/** 「全部城市」哨兵值:作为筛选条件时等同「未筛选」,岗位推荐才叠加偏好加分 */
+export const ALL_CITY_LABEL = '全部城市';
+
+/** 岗位所在地命中期望城市的加分 */
+export const JOB_MATCH_CITY_BONUS = 15;
+
+/** 岗位职位命中期望职位的加分 */
+export const JOB_MATCH_POSITION_BONUS = 20;
+
+/** 岗位薪资区间与期望薪资区间有重叠的加分 */
+export const JOB_MATCH_SALARY_BONUS = 10;
+
+/**
+ * 职位名归一化时剔除的噪音词(级别与通用后缀)。
+ * 目的是让「前端开发工程师」能与「高级前端工程师」匹配上 —— 去掉后都剩「前端」。
+ * 这是 mock 阶段的粗粒度匹配策略,真实后端就绪后由后端返回匹配度取代。
+ */
+export const POSITION_NOISE_WORDS = ['高级', '资深', '初级', '中级', '实习', '工程师', '开发', '经理'];
+
+/* ===================== 求职偏好展示 ===================== */
+
+/**
+ * 字段未采集时的占位文案(求职偏好、简历、我的页头部共用同一个)。
+ * 引导采集不到就不做假数据填充,也不拿 mock 人设顶上。
+ */
+export const UNSET_LABEL = '未设置';
+
+/** 工作类型默认值:引导流程未采集该项,映射时补默认「全职」 */
+export const PREFERENCE_DEFAULT_WORK_TYPE = '全职';
+
+/* ===================== 期望月薪(P7)可选阶梯 ===================== */
+
+/**
+ * 期望月薪「最低」可选阶梯(K)。
+ * 原阶梯只有 9K/10K/11K 三档,窄到不合常理(等于替用户回答了「你要 10K 左右」),
+ * 这里按真实招聘市场的月薪分布铺开。
+ */
+export const SALARY_MIN_OPTIONS: string[] = [
+ '5K', '8K', '10K', '12K', '15K', '20K', '25K', '30K', '40K',
+];
+
+/** 期望月薪「最高」可选阶梯(K);实际展示时按已选最低值过滤(见 api 层 salaryMaxOptions) */
+export const SALARY_MAX_OPTIONS: string[] = [
+ '8K', '10K', '12K', '15K', '20K', '25K', '30K', '40K', '60K',
+];
+
+/* ===================== 引导流程步骤(进度文案的唯一依据) ===================== */
+
+/**
+ * 求职者主线的步骤顺序(登录之后开始,到落地前一步结束)。
+ *
+ * 只含「一路下一步」的线性页面:P5(城市)是从 P4 进入后 `back()` 返回的子页,
+ * P17(案例参考)是从 P16 进入后返回的子页,二者不计入步骤数。
+ * 企业端 P3 → B3 只有一步,不适用本序列。
+ */
+export const ONBOARDING_STEP_ORDER: string[] = [
+ 'P3', // 选择身份
+ 'P4', // 求职意向
+ 'P6', // 期望职位
+ 'P7', // 期望月薪
+ 'P8', // 姓名/性别/出生年月
+ 'P9', // 求职状态
+ 'P10', // 首次工作时间
+ 'P11', // 当前职位
+ 'P12', // 当前公司
+ 'P13', // 入职时间
+ 'P14', // 技能
+ 'P16', // 工作内容
+ 'P18', // 学历
+ 'P20', // 毕业学校
+ 'P21', // 头像
+ 'P22', // 完成
+];
+
+/* ===================== 求职状态(P9,简历「求职意向」映射的唯一依据) ===================== */
+
+/** 求职状态选项:P9 页与简历映射共用同一份,避免两处文案分叉 */
+export const JOB_STATUS_OPTIONS: string[] = [
+ '离职-随时到岗',
+ '在职-月内到岗',
+ '在职-考虑机会',
+ '在职-暂不考虑',
+];
+
+/** 唯一「暂不考虑」状态:映射简历求职状态时视为不积极(其余选项均视为积极求职中) */
+export const JOB_STATUS_INACTIVE = '在职-暂不考虑';
+
+/** 在职状态前缀:简历工作经历据此判断是否仍在职(在职才写「至今」,离职不编结束时间) */
+export const JOB_STATUS_EMPLOYED_PREFIX = '在职';
+
+/* ===================== 合规页脚 ===================== */
+
+/** 客服热线(2026-09-15 由产品提供) */
+export const SUPPORT_HOTLINE = '15154608969';
+
+/** 举报监督电话(同客服热线) */
+export const SUPERVISION_PHONE = '15154608969';
+
+/* ===================== 短信验证码求助 ===================== */
+
+/** 「接收不到短信」提示标题(P2 与 P2W 共用,避免两页文案分叉) */
+export const SMS_HELP_TITLE = '接收不到短信?';
+
+/** 「接收不到短信」提示正文:给出可执行的下一步,而不是一句无反馈的纯文本 */
+export const SMS_HELP_BODY = [
+ '1. 确认手机号填写正确、信号正常;',
+ '2. 检查是否被短信拦截(垃圾短信 / 拦截记录);',
+ '3. 等待倒计时结束后点「重新发送」。',
+].join('\n');
+
+/* ===================== 简历编辑与导出(后端未接阶段的落地口径) ===================== */
+
+/** 简历模板(导出用):页面与选择弹层读同一份;后端就绪后由模板库接口下发 */
+export const RESUME_TEMPLATES: ResumeTemplate[] = [
+ { id: 'classic', name: '简约经典', color: 'primary' },
+ { id: 'creative', name: '创意设计', color: 'info' },
+ { id: 'tech', name: '技术专业', color: 'success' },
+];
+
+/** 自我评价最大字数:输入框的 maxLength 与字数提示读同一个值,避免两处不一致 */
+export const RESUME_EVAL_MAX_LENGTH = 300;
+
+/** 导出纸型(`ResumeExportSettings.paperSize` 的可选值 + 展示文案) */
+export const RESUME_PAPER_SIZES: ReadonlyArray<{ value: 'A4' | 'letter'; label: string }> = [
+ { value: 'A4', label: 'A4' },
+ { value: 'letter', label: 'Letter' },
+];
+
+/** 导出方向(`ResumeExportSettings.orientation` 的可选值 + 展示文案) */
+export const RESUME_ORIENTATIONS: ReadonlyArray<{
+ value: 'portrait' | 'landscape';
+ label: string;
+}> = [
+ { value: 'portrait', label: '纵向' },
+ { value: 'landscape', label: '横向' },
+];
+
+/** 文件名未填时的导出文件名兜底规则说明(导出页的输入框提示与提示语读同一份) */
+export const RESUME_FILE_NAME_HINT = '不填则用「姓名_期望职位简历」';
+
+/**
+ * AI 简历能力(逐条优化 / 自我评价润色 / 按 JD 定向优化)未接入时的统一提示。
+ * 后端就绪后连同页面的提示分支一并删除。
+ */
+export const RESUME_AI_UNAVAILABLE_TIP =
+ 'AI 改写需要后端模型支持,当前版本暂未开放;你的简历内容不会被改动。';
+
+/** 非 PDF 格式的导出说明:本机只能生成 PDF,Word / 图片需后端导出服务 */
+export const RESUME_EXPORT_FORMAT_NOTICE = 'Word / 图片导出需后端导出服务,当前按 PDF 导出';
+
+/** 导出密码说明:PDF 加密需后端导出服务 */
+export const RESUME_EXPORT_PASSWORD_NOTICE = '密码保护需后端导出服务,当前导出的文件不含密码';
+
+/** 导出失败提示:本机生成不可用时给出明确归因,而不是返回假链接 */
+export const RESUME_EXPORT_FAIL_HINT = '本机生成 PDF 失败,导出功能需后端导出服务支持';
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index c0dbc1f..9a8e77d 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -6,6 +6,7 @@ export { useAboutInfo } from './useAboutInfo';
export { useAccountCancel } from './useAccountCancel';
export { useAccountSecurity } from './useAccountSecurity';
export { useAgreementGate, type AgreementGate } from './useAgreementGate';
+export { useImagePick, type UseImagePick } from './useImagePick';
export { useAIInterview } from './useAIInterview';
export { useAppealForm } from './useAppealForm';
export { useAsyncData } from './useAsyncData';
@@ -45,6 +46,8 @@ export { useMessageData } from './useMessageData';
export { useNotificationSettings } from './useNotificationSettings';
export { useOfferCompareData } from './useOfferCompareData';
export { useOnboardingChecklist } from './useOnboardingChecklist';
+export { useCarrierPhone } from './useCarrierPhone';
+export { useAppCache } from './useAppCache';
export { useOperationRules } from './useOperationRules';
export { usePasswordLogin } from './usePasswordLogin';
export { usePasswordReset } from './usePasswordReset';
@@ -60,7 +63,9 @@ export { useResumeExport } from './useResumeExport';
export { useSalaryBrackets } from './useSalaryBrackets';
export { useSalaryCalculator } from './useSalaryCalculator';
export { useSalaryRecords } from './useSalaryRecords';
+export { useSalaryToolLinks } from './useSalaryToolLinks';
export { useSkillCloud } from './useSkillCloud';
+export { useSkillLearningPath } from './useSkillLearningPath';
export { useTalentRecommendation } from './useTalentRecommendation';
export { useWeightSettings } from './useWeightSettings';
export { useLogin } from './useLogin';
diff --git a/src/hooks/useAppCache.ts b/src/hooks/useAppCache.ts
new file mode 100644
index 0000000..ea26180
--- /dev/null
+++ b/src/hooks/useAppCache.ts
@@ -0,0 +1,50 @@
+/**
+ * 应用缓存 hook(设置页「清除缓存」)。
+ * 取体积走 useAsyncData(loading / error / refresh 一套),清理后自动重新取体积。
+ */
+import { useCallback, useState } from 'react';
+import { clearAppCache, fetchCacheSize } from '../api';
+import { formatBytes } from '../utils';
+import { useAsyncData } from './useAsyncData';
+
+export interface UseAppCacheReturn {
+ /** 缓存体积展示文案(如 12.8MB);读取中为空串 */
+ label: string;
+ /** 读取缓存体积中 */
+ loading: boolean;
+ /** 读取失败 */
+ error: Error | null;
+ /** 重新读取体积 */
+ refresh: () => void;
+ /** 清理缓存,返回清掉的字节数;失败抛出由页面提示 */
+ clear: () => Promise;
+ /** 清理进行中(防重复点击) */
+ clearing: boolean;
+}
+
+export function useAppCache(): UseAppCacheReturn {
+ const fetcher = useCallback(() => fetchCacheSize(), []);
+ const { data, loading, error, refresh } = useAsyncData(fetcher);
+ const [clearing, setClearing] = useState(false);
+
+ const clear = useCallback(async () => {
+ setClearing(true);
+ try {
+ const freed = await clearAppCache();
+ // 清完重新读一次,避免页面还挂着清理前的数字
+ refresh();
+ return freed;
+ } finally {
+ setClearing(false);
+ }
+ }, [refresh]);
+
+ return {
+ label: data === null ? '' : formatBytes(data),
+ loading,
+ error,
+ refresh,
+ clear,
+ clearing,
+ };
+}
diff --git a/src/hooks/useAppealForm.ts b/src/hooks/useAppealForm.ts
index 88b0ef2..adb5bf2 100644
--- a/src/hooks/useAppealForm.ts
+++ b/src/hooks/useAppealForm.ts
@@ -15,6 +15,8 @@ export function useAppealForm() {
const [dimensions, setDimensions] = useState([]);
const [reason, setReason] = useState(null);
const [reasonText, setReasonText] = useState('');
+ /** 证据图片:本机 URI 列表(后端接入上传接口后改为返回的图片 URL) */
+ const [evidence, setEvidence] = useState([]);
const [contactPhone, setContactPhone] = useState('');
const [contactEmail, setContactEmail] = useState('');
const [agree, setAgree] = useState(false);
@@ -31,6 +33,16 @@ export function useAppealForm() {
setDimensions((prev) => (prev.includes(id) ? prev.filter((d) => d !== id) : [...prev, id]));
}, []);
+ /** 追加一张证据图(同一张重复选不重复入列) */
+ const addEvidence = useCallback((uri: string) => {
+ setEvidence((prev) => (prev.includes(uri) ? prev : [...prev, uri]));
+ }, []);
+
+ /** 移除一张证据图 */
+ const removeEvidence = useCallback((uri: string) => {
+ setEvidence((prev) => prev.filter((e) => e !== uri));
+ }, []);
+
const submit = useCallback(async () => {
if (!data) return null;
setSubmitting(true);
@@ -44,6 +56,7 @@ export function useAppealForm() {
})),
reason: reason ?? data.reason,
reasonText,
+ evidence,
contactPhone,
contactEmail,
agree,
@@ -52,7 +65,7 @@ export function useAppealForm() {
} finally {
setSubmitting(false);
}
- }, [data, type, dimensions, reason, reasonText, contactPhone, contactEmail, agree]);
+ }, [data, type, dimensions, reason, reasonText, evidence, contactPhone, contactEmail, agree]);
return {
form: data ?? null,
@@ -67,6 +80,9 @@ export function useAppealForm() {
setReason,
reasonText,
setReasonText,
+ evidence,
+ addEvidence,
+ removeEvidence,
contactPhone,
setContactPhone,
contactEmail,
diff --git a/src/hooks/useCarrierPhone.ts b/src/hooks/useCarrierPhone.ts
new file mode 100644
index 0000000..c29a115
--- /dev/null
+++ b/src/hooks/useCarrierPhone.ts
@@ -0,0 +1,44 @@
+/**
+ * 取本机号码 hook(运营商一键登录第 1 步)。
+ *
+ * 进入登录页即调用,取回本机号码与一次性鉴权令牌,供 P2Q 展示掩码号并换取登录态。
+ * 与取数 hook 一样统一管理 loading / error / refresh,页面只消费结果。
+ */
+import { useCallback } from 'react';
+import { fetchCarrierPhone } from '../api';
+import type { CarrierName } from '../types';
+import { useAsyncData } from './useAsyncData';
+
+export interface UseCarrierPhoneReturn {
+ /** 掩码本机号码(如 150****9367);未取到为 '' */
+ maskedPhone: string;
+ /** 一次性鉴权令牌;为空表示没取到号,不能一键登录 */
+ carrierToken: string;
+ /** 认证服务提供方(页脚「认证服务由 xx 提供」用) */
+ carrier: CarrierName;
+ /** 号码是否已就绪 —— 页面据此决定「一键登录」可不可点 */
+ ready: boolean;
+ /** 取号中 */
+ loading: boolean;
+ /** 取号失败(页面据此展示失败分支 + 重新获取 + 改走验证码登录) */
+ error: Error | null;
+ /** 重新取号 */
+ refresh: () => void;
+}
+
+export function useCarrierPhone(): UseCarrierPhoneReturn {
+ const fetcher = useCallback(() => fetchCarrierPhone(), []);
+ const { data, loading, error, refresh } = useAsyncData(fetcher);
+
+ const carrierToken = data?.carrierToken ?? '';
+
+ return {
+ maskedPhone: data?.maskedPhone ?? '',
+ carrierToken,
+ carrier: data?.carrier ?? 'unknown',
+ ready: carrierToken !== '',
+ loading,
+ error,
+ refresh,
+ };
+}
diff --git a/src/hooks/useEnterpriseCommunication.ts b/src/hooks/useEnterpriseCommunication.ts
index 8eeaa66..b52fb7e 100644
--- a/src/hooks/useEnterpriseCommunication.ts
+++ b/src/hooks/useEnterpriseCommunication.ts
@@ -1,17 +1,40 @@
/**
* 企业端沟通数据 hook。
+ * Tab 与搜索词的状态放在这里:过滤规则由接口层(filterEnterpriseChats)给出,
+ * 页面只负责把用户点击/输入转成 setTab / setKeyword,再渲染 chats。
*/
-import { useCallback } from 'react';
-import { fetchEnterpriseCommunication } from '../api';
-import type { EnterpriseCommunication } from '../types';
+import { useCallback, useState } from 'react';
+import { fetchEnterpriseCommunication, filterEnterpriseChats } from '../api';
+import type { EnterpriseChatItem, EnterpriseCommunication } from '../types';
import { useAsyncData } from './useAsyncData';
export function useEnterpriseCommunication() {
- const fetcher = useCallback(async (): Promise => fetchEnterpriseCommunication(), []);
+ const fetcher = useCallback(
+ async (): Promise => fetchEnterpriseCommunication(),
+ [],
+ );
const { data, loading, error, refresh } = useAsyncData(fetcher);
+ const [activeTab, setActiveTab] = useState('all');
+ const [keyword, setKeyword] = useState('');
+
+ const communication = data ?? null;
+ /** 按当前 Tab + 关键词过滤后的会话列表 */
+ const chats: EnterpriseChatItem[] = communication
+ ? filterEnterpriseChats(communication.chats, activeTab, keyword)
+ : [];
+
+ /** 有关键词但一条没命中(用于区分「空 Tab」与「搜不到」) */
+ const isEmptySearch = communication !== null && keyword.trim() !== '' && chats.length === 0;
+
return {
- communication: data ?? null,
+ communication,
+ activeTab,
+ setActiveTab,
+ keyword,
+ setKeyword,
+ chats,
+ isEmptySearch,
loading,
error,
refresh,
diff --git a/src/hooks/useImagePick.ts b/src/hooks/useImagePick.ts
new file mode 100644
index 0000000..546de42
--- /dev/null
+++ b/src/hooks/useImagePick.ts
@@ -0,0 +1,42 @@
+/**
+ * 取图 hook:拍照 / 相册选图,管理「取图中」与失败提示。
+ *
+ * 页面只调这里,不直接依赖 expo-image-picker(原生能力统一从 lib → hook 走,
+ * 与 storage 的处理方式一致)。`picking` 用于屏蔽重复点击 —— 相机界面弹出期间
+ * 再点一次会重复申请权限并叠加两个系统弹窗。
+ */
+import { useCallback, useState } from 'react';
+import { IMAGE_PICK_DENIED_HINT } from '../constants';
+import type { ImagePickOptions, ImagePickResult, ImagePickSource } from '../lib/imagePicker';
+import { pickImage } from '../lib/imagePicker';
+
+export interface UseImagePick {
+ /** 取图进行中(含系统相机/相册界面展示期间) */
+ picking: boolean;
+ /** 上一次取图的失败提示;未授权时给出设置指引,取消或成功时为 null */
+ error: string | null;
+ pick: (source: ImagePickSource, options?: ImagePickOptions) => Promise;
+}
+
+export function useImagePick(): UseImagePick {
+ const [picking, setPicking] = useState(false);
+ const [error, setError] = useState(null);
+
+ const pick = useCallback(
+ async (source: ImagePickSource, options?: ImagePickOptions): Promise => {
+ setPicking(true);
+ setError(null);
+ try {
+ const result = await pickImage(source, options);
+ // 用户主动取消不是错误,不提示;未授权才需要指引他去系统设置
+ setError(result.status === 'denied' ? IMAGE_PICK_DENIED_HINT : null);
+ return result;
+ } finally {
+ setPicking(false);
+ }
+ },
+ [],
+ );
+
+ return { picking, error, pick };
+}
diff --git a/src/hooks/useInterviewScore.ts b/src/hooks/useInterviewScore.ts
index 9d2ca4a..03468af 100644
--- a/src/hooks/useInterviewScore.ts
+++ b/src/hooks/useInterviewScore.ts
@@ -2,7 +2,13 @@
* 面试评分 hook:拉取评分数据 + 本地编辑 + 提交评分。
*/
import { useCallback, useEffect, useState } from 'react';
-import { fetchInterviewScore, submitInterviewScore } from '../api';
+import {
+ clearInterviewScoreDraft,
+ fetchInterviewScore,
+ fetchInterviewScoreDraft,
+ saveInterviewScoreDraft,
+ submitInterviewScore,
+} from '../api';
import type { HireSuggestion, InterviewScoreData } from '../types';
import { useAsyncData } from './useAsyncData';
@@ -12,6 +18,11 @@ export function useInterviewScore(interviewId: string) {
const [score, setScore] = useState(null);
const [submitting, setSubmitting] = useState(false);
+ /** 上次保存草稿的时间(ISO);null 表示还没保存过 */
+ const [draftSavedAt, setDraftSavedAt] = useState(null);
+ const [savingDraft, setSavingDraft] = useState(false);
+ /** 本次进入页面时发现的历史草稿(提示用户可恢复);恢复或放弃后清空 */
+ const [pendingDraft, setPendingDraft] = useState(null);
useEffect(() => {
if (data && !score) {
@@ -19,6 +30,20 @@ export function useInterviewScore(interviewId: string) {
}
}, [data, score]);
+ // 进页面查一次是否有未提交的草稿。有的话不直接覆盖,先让用户选择恢复还是放弃
+ useEffect(() => {
+ let alive = true;
+ void (async () => {
+ const draft = await fetchInterviewScoreDraft(interviewId);
+ if (!alive || !draft) return;
+ setPendingDraft(draft.score);
+ setDraftSavedAt(draft.savedAt);
+ })();
+ return () => {
+ alive = false;
+ };
+ }, [interviewId]);
+
const updateDimensionScore = useCallback((id: string, value: number) => {
setScore((prev) => {
if (!prev) return prev;
@@ -52,12 +77,44 @@ export function useInterviewScore(interviewId: string) {
if (!score) return null;
setSubmitting(true);
try {
- return await submitInterviewScore(interviewId, score);
+ const result = await submitInterviewScore(interviewId, score);
+ // 提交成功后再清草稿:先清后提交的话,提交失败就把用户的草稿弄丢了
+ await clearInterviewScoreDraft(interviewId);
+ setDraftSavedAt(null);
+ setPendingDraft(null);
+ return result;
} finally {
setSubmitting(false);
}
}, [interviewId, score]);
+ /** 保存草稿,返回保存时间(ISO);失败时抛出由页面提示 */
+ const saveDraft = useCallback(async () => {
+ if (!score) return null;
+ setSavingDraft(true);
+ try {
+ const draft = await saveInterviewScoreDraft(interviewId, score);
+ setDraftSavedAt(draft.savedAt);
+ return draft.savedAt;
+ } finally {
+ setSavingDraft(false);
+ }
+ }, [interviewId, score]);
+
+ /** 用草稿覆盖当前编辑内容 */
+ const restoreDraft = useCallback(() => {
+ if (!pendingDraft) return;
+ setScore(pendingDraft);
+ setPendingDraft(null);
+ }, [pendingDraft]);
+
+ /** 放弃草稿:删掉本地暂存,保留当前页面上的内容 */
+ const discardDraft = useCallback(async () => {
+ await clearInterviewScoreDraft(interviewId);
+ setPendingDraft(null);
+ setDraftSavedAt(null);
+ }, [interviewId]);
+
return {
score,
loading,
@@ -69,5 +126,11 @@ export function useInterviewScore(interviewId: string) {
selectHireSuggestion,
submit,
submitting,
+ saveDraft,
+ savingDraft,
+ restoreDraft,
+ discardDraft,
+ hasPendingDraft: pendingDraft !== null,
+ draftSavedAt,
};
}
diff --git a/src/hooks/useLogin.ts b/src/hooks/useLogin.ts
index a207530..f645293 100644
--- a/src/hooks/useLogin.ts
+++ b/src/hooks/useLogin.ts
@@ -4,8 +4,11 @@ import { loginByCarrier, loginByPhone, type LoginResult } from '../api';
export interface UseLoginReturn {
/** 手机号 + 验证码登录,成功返回 LoginResult */
login: (phone: string, code: string) => Promise;
- /** 运营商一键登录(本机号码认证),成功返回 LoginResult */
- loginByCarrier: () => Promise;
+ /**
+ * 运营商一键登录(本机号码认证),成功返回 LoginResult。
+ * @param carrierToken 取号 hook(useCarrierPhone)拿到的鉴权令牌;传空串必然失败
+ */
+ loginByCarrier: (carrierToken: string) => Promise;
/** 登录进行中 */
loading: boolean;
/** 登录失败的错误(用于页面提示) */
@@ -42,7 +45,7 @@ export function useLogin(): UseLoginReturn {
);
const carrierLogin = useCallback(
- () => run(() => loginByCarrier(), '一键登录失败,请稍后重试'),
+ (carrierToken: string) => run(() => loginByCarrier(carrierToken), '一键登录失败,请稍后重试'),
[run],
);
diff --git a/src/hooks/useMessageData.ts b/src/hooks/useMessageData.ts
index 2747138..d696cc9 100644
--- a/src/hooks/useMessageData.ts
+++ b/src/hooks/useMessageData.ts
@@ -6,18 +6,21 @@ import {
fetchInterviews,
fetchMatchResult,
fetchNotifications,
+ fetchPendingOfferCount,
} from '../api';
import { useAsyncData } from './useAsyncData';
/** 聚合拉取消息中心全部数据(模块级函数,引用稳定,避免 useAsyncData 反复触发) */
async function fetchMessageData() {
- const [conversations, notifications, interviews, matchResult] = await Promise.all([
- fetchConversations(),
- fetchNotifications(),
- fetchInterviews(),
- fetchMatchResult(),
- ]);
- return { conversations, notifications, interviews, matchResult };
+ const [conversations, notifications, interviews, matchResult, pendingOfferCount] =
+ await Promise.all([
+ fetchConversations(),
+ fetchNotifications(),
+ fetchInterviews(),
+ fetchMatchResult(),
+ fetchPendingOfferCount(),
+ ]);
+ return { conversations, notifications, interviews, matchResult, pendingOfferCount };
}
export function useMessageData() {
@@ -27,6 +30,8 @@ export function useMessageData() {
const notifications = data?.notifications ?? [];
const interviews = data?.interviews ?? [];
const matchResult = data?.matchResult ?? null;
+ /** 待处理 Offer 数:为 0 时聊天页不渲染横幅,不再显示写死的「2 个」 */
+ const pendingOfferCount = data?.pendingOfferCount ?? 0;
// 空状态:加载完成且无错误、但三类列表均为空
const isEmpty =
!loading &&
@@ -40,6 +45,7 @@ export function useMessageData() {
notifications,
interviews,
matchResult,
+ pendingOfferCount,
loading,
error,
refresh,
diff --git a/src/hooks/useOnboardingChecklist.ts b/src/hooks/useOnboardingChecklist.ts
index 5861ab6..74978c0 100644
--- a/src/hooks/useOnboardingChecklist.ts
+++ b/src/hooks/useOnboardingChecklist.ts
@@ -2,7 +2,12 @@
* 入职准备清单 hook:拉取清单 + 切换待办完成状态。
*/
import { useCallback, useState } from 'react';
-import { fetchOnboardingChecklist, toggleChecklistItem } from '../api';
+import {
+ addChecklistItem,
+ fetchOnboardingChecklist,
+ removeChecklistItem,
+ toggleChecklistItem,
+} from '../api';
import type { OnboardingChecklist } from '../types';
import { useAsyncData } from './useAsyncData';
@@ -10,6 +15,7 @@ export function useOnboardingChecklist() {
const fetcher = useCallback(() => fetchOnboardingChecklist(), []);
const { data, loading, error, refresh } = useAsyncData(fetcher);
const [togglingId, setTogglingId] = useState(null);
+ const [mutating, setMutating] = useState(false);
const toggle = useCallback(
async (itemId: string, done: boolean) => {
@@ -23,6 +29,26 @@ export function useOnboardingChecklist() {
[],
);
+ /** 添加自定义待办,返回更新后的清单;标题为空或写入失败时 reject */
+ const addItem = useCallback(async (title: string) => {
+ setMutating(true);
+ try {
+ return await addChecklistItem(title);
+ } finally {
+ setMutating(false);
+ }
+ }, []);
+
+ /** 删除自定义待办,返回更新后的清单 */
+ const removeItem = useCallback(async (itemId: string) => {
+ setMutating(true);
+ try {
+ return await removeChecklistItem(itemId);
+ } finally {
+ setMutating(false);
+ }
+ }, []);
+
return {
checklist: data ?? null,
loading,
@@ -30,5 +56,9 @@ export function useOnboardingChecklist() {
refresh,
toggle,
togglingId,
+ addItem,
+ removeItem,
+ /** 增删进行中(防重复点击) */
+ mutating,
};
}
diff --git a/src/hooks/useOnboardingFlow.tsx b/src/hooks/useOnboardingFlow.tsx
index 1b8c65e..3830301 100644
--- a/src/hooks/useOnboardingFlow.tsx
+++ b/src/hooks/useOnboardingFlow.tsx
@@ -1,76 +1,59 @@
import type { ReactNode } from 'react';
-import { createContext, useContext, useMemo, useState } from 'react';
-import type { AssetName } from '../../components/onboarding/assets';
-
-/** 注册全流程收集的数据模型 */
-export interface OnboardingData {
- phone: string;
- code: string;
- agreed: boolean;
- /**
- * 微信登录带入的临时凭证。
- * 仅「微信第一次登录」链路有值:微信授权后 openid 未绑定账号,
- * 需走手机号 + 验证码完成注册,后端据此把 openid 与新账号关联。
- * 手机号登录等其他链路为 null,此时 P2 页按普通登录处理。
- */
- wechatTicket: { token: string; openid: string } | null;
- identity: 'candidate' | 'recruiter' | null;
- isStudent: boolean | null;
- city: string;
- expectedPositions: string[];
- expectedSalaryMin: string;
- expectedSalaryMax: string;
- resumeName: string;
- gender: '女' | '男' | null;
- birthYear: string;
- birthMonth: string;
- jobStatus: string | null;
- disabledJobseeker: boolean;
- firstWorkYear: string;
- firstWorkMonth: string;
- currentPosition: string | null;
- company: string;
- joinYear: string;
- joinMonth: string;
- skills: string[];
- workContent: string;
- education: string | null;
- eduType: '全日制' | '非全日制' | null;
- school: string;
- avatar: AssetName | null;
- recruiterCompany: string;
-}
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ clearOnboardingProfile,
+ clearResumeOverride,
+ fetchOnboardingProfile,
+ fromOnboardingProfile,
+ saveOnboardingProfile,
+ toOnboardingProfile,
+} from '../api';
+import type { OnboardingData } from '../types';
+/**
+ * 引导流程起始状态:全部为「未采集」态。
+ *
+ * 这里曾预填一份演示人设(杨幸幸 / 上海 / 前端开发工程师 / 本科…),后果是
+ * **用户全程不操作也能一路点到落地**,而采集到的「资料」没有一项是他选的。
+ * 清空后每页进入即未选态,未填的步骤由各页的 `active` 门槛拦住。
+ *
+ * 注意 `''` / `null` 与「用户选了空值」不可区分,所以各页的空值兜底必须同时到位,
+ * 尤其 P8 / P10 / P13 的 YearMonthWheel:它此前把 `''` 钳到索引 0,
+ * 滚轮显示「2026年」而 state 仍是空串 —— 现在改为「未选中态 + 请滑动选择」。
+ */
const INITIAL: OnboardingData = {
phone: '',
code: '',
- agreed: true,
+ // 协议必须由用户主动勾选:预勾选不构成《个人信息保护法》要求的「明示同意」
+ agreed: false,
wechatTicket: null,
identity: null,
isStudent: null,
- city: '上海',
- expectedPositions: ['前端开发工程师', '鸿蒙开发工程师', '全栈工程师'],
- expectedSalaryMin: '10K',
- expectedSalaryMax: '12K',
- resumeName: '杨幸幸',
- gender: '女',
- birthYear: '2004年',
- birthMonth: '12月',
- jobStatus: '在职-月内到岗',
+ city: '',
+ expectedPositions: [],
+ expectedSalaryMin: '',
+ expectedSalaryMax: '',
+ resumeName: '',
+ gender: null,
+ birthYear: '',
+ birthMonth: '',
+ jobStatus: null,
disabledJobseeker: false,
- firstWorkYear: '2026年',
- firstWorkMonth: '4月',
+ firstWorkYear: '',
+ firstWorkMonth: '',
currentPosition: null,
- company: '巴适伙伴(上海)科技有限公司',
- joinYear: '2026年',
- joinMonth: '7月',
+ company: '',
+ joinYear: '',
+ joinMonth: '',
skills: [],
workContent: '',
- education: '本科',
- eduType: '全日制',
+ education: null,
+ eduType: null,
school: '',
avatar: null,
recruiterCompany: '',
+ recruiterContact: '',
+ recruiterLicenseUri: null,
};
interface Ctx {
@@ -79,11 +62,9 @@ interface Ctx {
current: string;
navigate: (screen: string) => void;
back: () => void;
- canGoBack: boolean;
- /** 两个保存出口:收集当前信息后直接进 P23,剩余资料后续补充 */
- saveAndEnter: () => void;
+ /** 立即把已采集资料落库(落地前调用,避免最后一步的输入随页面卸载丢失) */
+ flushSave: () => Promise;
reset: () => void;
- meta: { time: string; battery: number };
}
const OnboardingContext = createContext(null);
@@ -93,29 +74,73 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
const [stack, setStack] = useState([]);
const [current, setCurrent] = useState('P1');
+ /**
+ * state 的同步镜像。
+ *
+ * 落库发生在导航时,而导航与 setData 可能处在同一个事件里(如 P5City 的
+ * `pick()`:`setData({ city })` 紧跟 `back()`)。此时 state 尚未提交,
+ * 闭包里的 data 还是旧值,只有从镜像读才拿得到刚写入的内容。
+ */
+ const dataRef = useRef(INITIAL);
+
+ /** 唯一的状态写入口:同时更新镜像,保证两者永不脱节 */
+ const commitData = useCallback((next: OnboardingData) => {
+ dataRef.current = next;
+ setDataState(next);
+ }, []);
+
+ /** 落库:整份资料覆盖写入,不做逐键写入 */
+ const flushSave = useCallback(
+ () => saveOnboardingProfile(toOnboardingProfile(dataRef.current)),
+ [],
+ );
+
+ /*
+ * 挂载时水合:把上次采集到的资料合并回 INITIAL 之上。
+ * 流程仍从 P1 开始(不做断点续填),但各页呈现的是「已填过」的状态。
+ */
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ const profile = await fetchOnboardingProfile();
+ if (cancelled || !profile) return;
+ commitData(fromOnboardingProfile(profile, INITIAL));
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [commitData]);
+
const value = useMemo(() => {
const setData = (partial: Partial) =>
- setDataState((prev) => ({ ...prev, ...partial }));
+ commitData({ ...dataRef.current, ...partial });
const navigate = (screen: string) => {
- setStack((s) => [...s, current]);
- setCurrent(screen);
- };
- const back = () => {
+ void flushSave();
setStack((s) => {
- if (s.length === 0) return s;
- const prev = s[s.length - 1];
- setCurrent(prev);
- return s.slice(0, -1);
+ const at = s.indexOf(screen);
+ // 目标已在历史里(如 P17「采用并填写」回到 P16):截断回它,不再压栈。
+ // 否则同一屏在栈里出现两次,返回时先退到「未来」的那一屏 —— P16/P17 互跳后
+ // 在 P16 按返回会退到 P17,看起来像返回键失灵
+ if (at >= 0) return s.slice(0, at);
+ return [...s, current];
});
+ setCurrent(screen);
};
- const saveAndEnter = () => {
- setStack([]);
- setCurrent('P23');
+ const back = () => {
+ void flushSave();
+ if (stack.length === 0) return;
+ // 不在 setStack 的更新函数里做副作用(StrictMode 下更新函数会被调用两次)
+ setCurrent(stack[stack.length - 1]);
+ setStack((s) => s.slice(0, -1));
};
const reset = () => {
- setDataState(INITIAL);
+ commitData(INITIAL);
setStack([]);
setCurrent('P1');
+ // 退出登录:连持久化资料一起清掉,否则下次挂载会把上一位用户的资料水合回来
+ void clearOnboardingProfile();
+ // 简历的用户编辑同样按用户隔离:不清掉会让下一位用户看到上一位改过的简历
+ void clearResumeOverride();
};
return {
data,
@@ -123,13 +148,10 @@ export function OnboardingProvider({ children }: { children: ReactNode }) {
current,
navigate,
back,
- canGoBack: stack.length > 0,
- saveAndEnter,
+ flushSave,
reset,
- // 已停用:原假状态栏时间线演示数据,现统一使用系统真实状态栏,保留字段仅为兼容旧调用
- meta: { time: '', battery: 0 },
};
- }, [data, stack, current]);
+ }, [data, stack, current, commitData, flushSave]);
return {children};
}
diff --git a/src/hooks/useRegisterFinish.ts b/src/hooks/useRegisterFinish.ts
index 3f4cd37..f0bd877 100644
--- a/src/hooks/useRegisterFinish.ts
+++ b/src/hooks/useRegisterFinish.ts
@@ -2,9 +2,10 @@ import { useRouter } from 'expo-router';
import { useAuth } from '../context/AuthContext';
import { useRole } from '../context/RoleContext';
import type { AppRole } from '../types';
+import { useOnboarding } from './useOnboardingFlow';
/**
- * 注册完成后的真实落地:写入登录态与身份,跳转对应端主界面。
+ * 注册完成后的真实落地:先把采集到的资料落库,再写入登录态与身份,跳转对应端主界面。
* 真实后端就绪后,login 的 token 替换为接口返回;
* setRole 用于让 index.tsx 漏斗放行,避免注册完成又被弹回注册。
*/
@@ -12,8 +13,12 @@ export function useRegisterFinish() {
const router = useRouter();
const { login } = useAuth();
const { setRole } = useRole();
+ const { flushSave } = useOnboarding();
- return (role: AppRole) => {
+ return async (role: AppRole) => {
+ // 落地前落库:最后一步(如 B3 的公司名/联系人)不会再有下一次跳转,
+ // 不在这里提交就会随页面卸载一起丢掉
+ await flushSave();
setRole(role);
login(`mock-token-${Date.now()}`);
router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs');
diff --git a/src/hooks/useResume.ts b/src/hooks/useResume.ts
index 624d1a3..83b3073 100644
--- a/src/hooks/useResume.ts
+++ b/src/hooks/useResume.ts
@@ -1,25 +1,152 @@
/**
- * 简历数据 hook:拉取简历 + AI 一键优化。
+ * 简历数据 hook:取简历 + 按板块保存 + AI 动作。
+ *
+ * 写操作一律「落库 → 重新取数」,不做本地乐观更新:`youzhi:resume:override` 是唯一真相源,
+ * 页面拿到的永远是落库后的结果(与 `useSalaryRecords` 同一套约定)。
*/
-import { useCallback } from 'react';
-import { fetchResume, optimizeResume } from '../api';
-import type { Resume } from '../types';
+import { useCallback, useState } from 'react';
+import {
+ fetchResume,
+ fetchResumeSavedAt,
+ optimizeResume,
+ optimizeResumeExperience,
+ optimizeResumeWithJD,
+ polishResumeSelfEvaluation,
+ saveResumeBasicInfo,
+ saveResumeEducation,
+ saveResumeExperience,
+ saveResumeProjects,
+ saveResumeSelfEvaluation,
+ saveResumeSkills,
+ saveResumeSnapshot,
+} from '../api';
+import type {
+ Resume,
+ ResumeAIActionResult,
+ ResumeBasicInfo,
+ ResumeEducation,
+ ResumeExperience,
+ ResumeItemInput,
+ ResumeProject,
+ ResumeSkill,
+} from '../types';
import { useAsyncData } from './useAsyncData';
+/**
+ * 简历与保存时间一起取。
+ *
+ * 分成两个 `useAsyncData` 实例会出现「简历已刷新、保存时间还是旧的」的错位,
+ * 且同一页面两处取数各自维护 loading/error,四态渲染要写两遍。
+ */
+interface ResumeBundle {
+ resume: Resume;
+ savedAt: string;
+}
+
export function useResume() {
- const fetcher = useCallback(() => fetchResume(), []);
- const { data, loading, error, refresh } = useAsyncData(fetcher);
+ const fetcher = useCallback(async (): Promise => {
+ const [resume, savedAt] = await Promise.all([fetchResume(), fetchResumeSavedAt()]);
+ return { resume, savedAt };
+ }, []);
+ const { data, loading, error, refresh } = useAsyncData(fetcher);
+
+ /** 写操作进行中:页面据此禁用按钮,避免连点两次时后一次基于旧列表少删一条 */
+ const [saving, setSaving] = useState(false);
+
+ /** 写操作的统一收口:失败也要解除 saving(`finally`),否则按钮会一直灰着 */
+ const runWrite = useCallback(
+ async (action: () => Promise): Promise => {
+ setSaving(true);
+ try {
+ const result = await action();
+ refresh();
+ return result;
+ } finally {
+ setSaving(false);
+ }
+ },
+ [refresh],
+ );
+
+ /**
+ * AI 动作的统一收口:`available` 为真才重新取数。
+ * 能力未接入时简历一个字都没改,刷新只会白闪一次加载态。
+ */
+ const runAIAction = useCallback(
+ async (action: () => Promise): Promise => {
+ const result = await action();
+ if (result.available) refresh();
+ return result;
+ },
+ [refresh],
+ );
const optimize = useCallback(async () => {
const result = await optimizeResume();
return result.resume;
}, []);
+ const saveBasicInfo = useCallback(
+ (input: Omit) => runWrite(() => saveResumeBasicInfo(input)),
+ [runWrite],
+ );
+ const saveSelfEvaluation = useCallback(
+ (content: string) => runWrite(() => saveResumeSelfEvaluation(content)),
+ [runWrite],
+ );
+ const saveSkills = useCallback(
+ (skills: ResumeSkill[]) => runWrite(() => saveResumeSkills(skills)),
+ [runWrite],
+ );
+ const saveEducation = useCallback(
+ (items: ResumeItemInput[]) => runWrite(() => saveResumeEducation(items)),
+ [runWrite],
+ );
+ const saveExperience = useCallback(
+ (items: ResumeItemInput[]) => runWrite(() => saveResumeExperience(items)),
+ [runWrite],
+ );
+ const saveProjects = useCallback(
+ (items: ResumeItemInput[]) => runWrite(() => saveResumeProjects(items)),
+ [runWrite],
+ );
+ /**
+ * 头部「保存」:把页面此刻显示的简历整体固化到本机。
+ * 收的是页面展示的那份(可能是「AI 一键优化」后的结果),而不是 hook 里缓存的原始值
+ */
+ const save = useCallback((resume: Resume) => runWrite(() => saveResumeSnapshot(resume)), [runWrite]);
+
+ const optimizeExperience = useCallback(
+ (id: string) => runAIAction(() => optimizeResumeExperience(id)),
+ [runAIAction],
+ );
+ const polishSelfEvaluation = useCallback(
+ (content: string) => runAIAction(() => polishResumeSelfEvaluation(content)),
+ [runAIAction],
+ );
+ const optimizeWithJD = useCallback(
+ (jd: string) => runAIAction(() => optimizeResumeWithJD(jd)),
+ [runAIAction],
+ );
+
return {
- resume: data ?? null,
+ resume: data?.resume ?? null,
+ /** 从未保存过时为空串,页面据此不渲染「上次保存」那行 */
+ savedAt: data?.savedAt ?? '',
loading,
error,
refresh,
+ saving,
optimize,
+ save,
+ saveBasicInfo,
+ saveSelfEvaluation,
+ saveSkills,
+ saveEducation,
+ saveExperience,
+ saveProjects,
+ optimizeExperience,
+ polishSelfEvaluation,
+ optimizeWithJD,
};
}
diff --git a/src/hooks/useResumeExport.ts b/src/hooks/useResumeExport.ts
index a31c46f..ce5ead3 100644
--- a/src/hooks/useResumeExport.ts
+++ b/src/hooks/useResumeExport.ts
@@ -1,5 +1,9 @@
/**
- * 简历导出 hook:拉取/保存导出设置,执行导出。
+ * 简历导出 hook:拉取/保存导出设置,生成导出文件,拉起系统分享面板。
+ *
+ * 导出与分享都要先有文件,两条路都走 `exportResume`(API 层),因此:
+ * - 分享不需要页面先点「立即导出」—— 用户直接点分享也拿得到文件;
+ * - 分享面板标题里的文件名与导出落盘的文件名**必然一致**(同一个函数算出来的)。
*/
import { useCallback, useState } from 'react';
import {
@@ -7,13 +11,18 @@ import {
fetchResumeExportSettings,
saveResumeExportSettings,
} from '../api';
-import type { ResumeExportSettings } from '../types';
+import { shareFile } from '../lib/shareFile';
+import type { ResumeExportOutcome, ResumeExportSettings } from '../types';
import { useAsyncData } from './useAsyncData';
+/** 分享结果:`ok` 只表示分享面板已拉起(用户是否真的发出去了,应用无从得知) */
+export type ResumeShareOutcome = { ok: true } | { ok: false; message: string };
+
export function useResumeExport() {
const fetcher = useCallback(() => fetchResumeExportSettings(), []);
const { data, loading, error, refresh } = useAsyncData(fetcher);
const [exporting, setExporting] = useState(false);
+ const [sharing, setSharing] = useState(false);
const save = useCallback(
async (settings: ResumeExportSettings) => {
@@ -24,11 +33,10 @@ export function useResumeExport() {
);
const exportFile = useCallback(
- async (settings: ResumeExportSettings) => {
+ async (settings: ResumeExportSettings): Promise => {
setExporting(true);
try {
- const result = await exportResume(settings);
- return result.url;
+ return await exportResume(settings);
} finally {
setExporting(false);
}
@@ -36,6 +44,24 @@ export function useResumeExport() {
[],
);
+ const share = useCallback(
+ async (settings: ResumeExportSettings): Promise => {
+ setSharing(true);
+ try {
+ const outcome = await exportResume(settings);
+ if (!outcome.ok) return { ok: false, message: outcome.message };
+ return shareFile(outcome.url, {
+ mimeType: 'application/pdf',
+ UTI: 'com.adobe.pdf',
+ dialogTitle: `分享 ${outcome.fileName}`,
+ });
+ } finally {
+ setSharing(false);
+ }
+ },
+ [],
+ );
+
return {
settings: data ?? null,
loading,
@@ -43,6 +69,8 @@ export function useResumeExport() {
refresh,
save,
exportFile,
+ share,
exporting,
+ sharing,
};
}
diff --git a/src/hooks/useSalaryToolLinks.ts b/src/hooks/useSalaryToolLinks.ts
new file mode 100644
index 0000000..cf1b80d
--- /dev/null
+++ b/src/hooks/useSalaryToolLinks.ts
@@ -0,0 +1,32 @@
+/**
+ * 薪资计算页「更多工具」入口 hook。
+ * 入口清单由接口层下发(含路由),页面只负责渲染与跳转。
+ */
+import { fetchSalaryToolLinks } from '../api';
+import type { SalaryToolLink } from '../types';
+import { useAsyncData } from './useAsyncData';
+
+export interface UseSalaryToolLinksReturn {
+ /** 工具入口数组;未加载完成时为空数组,页面可安全 map */
+ tools: SalaryToolLink[];
+ /** 加载中 */
+ loading: boolean;
+ /** 加载失败 */
+ error: Error | null;
+ /** 重新获取 */
+ refresh: () => void;
+}
+
+// fetcher 定义在模块级,保证引用稳定(放进组件内会让 useAsyncData 的 effect 反复触发)
+const fetchTools = () => fetchSalaryToolLinks();
+
+export function useSalaryToolLinks(): UseSalaryToolLinksReturn {
+ const { data, loading, error, refresh } = useAsyncData(fetchTools);
+
+ return {
+ tools: data ?? [],
+ loading,
+ error,
+ refresh,
+ };
+}
diff --git a/src/hooks/useSkillCloud.ts b/src/hooks/useSkillCloud.ts
index 33b9264..8b5ef2d 100644
--- a/src/hooks/useSkillCloud.ts
+++ b/src/hooks/useSkillCloud.ts
@@ -1,22 +1,35 @@
/**
* 行业技能词云 hook:按行业拉取技能词数据,统一管理 loading / error / refresh。
- * 切换行业时自动重新取数(fetcher 依赖 industry)。
+ * 切换行业时自动重新取数(fetcher 依赖 industry);
+ * 搜索关键词的过滤规则放在接口层(filterSkillWords),这里只编排状态。
*/
import { useCallback, useState } from 'react';
-import { fetchSkillCloud } from '../api';
-import type { SkillCloudData } from '../types';
+import { fetchSkillCloud, filterSkillWords } from '../api';
+import type { SkillCloudData, SkillWord } from '../types';
import { useAsyncData } from './useAsyncData';
export function useSkillCloud(initialIndustry = '互联网/IT') {
const [industry, setIndustry] = useState(initialIndustry);
+ const [keyword, setKeyword] = useState('');
const fetcher = useCallback(() => fetchSkillCloud(industry), [industry]);
const { data, loading, error, refresh } = useAsyncData(fetcher);
+ const cloud = (data as SkillCloudData | null) ?? null;
+
+ /** 按关键词过滤后的技能词;未输入关键词时即全量 */
+ const words: SkillWord[] = cloud ? filterSkillWords(cloud.words, keyword) : [];
return {
industry,
setIndustry,
- cloud: (data as SkillCloudData | null) ?? null,
+ cloud,
+ /** 搜索关键词 */
+ keyword,
+ setKeyword,
+ /** 过滤后的技能词(渲染词云用) */
+ words,
+ /** 有关键词但一条没命中 */
+ isEmpty: cloud !== null && words.length === 0,
loading,
error,
refresh,
diff --git a/src/hooks/useSkillLearningPath.ts b/src/hooks/useSkillLearningPath.ts
new file mode 100644
index 0000000..5e551ee
--- /dev/null
+++ b/src/hooks/useSkillLearningPath.ts
@@ -0,0 +1,48 @@
+/**
+ * 技能学习路径 hook:点「生成技能学习路径」时才请求(一次性生成,不是进页面就取数)。
+ * 生成逻辑与推荐理由都在接口层,页面只负责弹窗展示。
+ */
+import { useCallback, useState } from 'react';
+import { fetchSkillLearningPath } from '../api';
+import type { SkillLearningPath } from '../types';
+
+export interface UseSkillLearningPathReturn {
+ /** 生成结果;未生成时为 null */
+ path: SkillLearningPath | null;
+ /** 生成中(防重复点击) */
+ loading: boolean;
+ /** 生成失败 */
+ error: Error | null;
+ /** 按行业生成学习路径 */
+ generate: (industry: string) => Promise;
+ /** 关闭弹窗时清掉上次结果,下次打开重新生成 */
+ reset: () => void;
+}
+
+export function useSkillLearningPath(): UseSkillLearningPathReturn {
+ const [path, setPath] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const generate = useCallback(async (industry: string) => {
+ setLoading(true);
+ setError(null);
+ try {
+ const result = await fetchSkillLearningPath(industry);
+ setPath(result);
+ return result;
+ } catch (e) {
+ setError(e instanceof Error ? e : new Error('学习路径生成失败,请稍后重试'));
+ return null;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const reset = useCallback(() => {
+ setPath(null);
+ setError(null);
+ }, []);
+
+ return { path, loading, error, generate, reset };
+}
diff --git a/src/hooks/useTalentRecommendation.ts b/src/hooks/useTalentRecommendation.ts
index c7711c8..5e6b02b 100644
--- a/src/hooks/useTalentRecommendation.ts
+++ b/src/hooks/useTalentRecommendation.ts
@@ -1,17 +1,40 @@
/**
* 企业端人才推荐数据 hook。
+ * Tab 与搜索词状态放在这里,过滤规则由接口层(filterTalentCandidates)给出,
+ * 页面只把点击/输入转成 setTab / setKeyword 再渲染 candidates。
*/
-import { useCallback } from 'react';
-import { fetchTalentRecommendation } from '../api';
-import type { TalentRecommendation } from '../types';
+import { useCallback, useState } from 'react';
+import { fetchTalentRecommendation, filterTalentCandidates } from '../api';
+import type { TalentCandidate, TalentRecommendation } from '../types';
import { useAsyncData } from './useAsyncData';
export function useTalentRecommendation() {
- const fetcher = useCallback(async (): Promise => fetchTalentRecommendation(), []);
+ const fetcher = useCallback(
+ async (): Promise => fetchTalentRecommendation(),
+ [],
+ );
const { data, loading, error, refresh } = useAsyncData(fetcher);
+ const [activeTab, setActiveTab] = useState('all');
+ const [keyword, setKeyword] = useState('');
+
+ const recommendation = data ?? null;
+ /** 按当前 Tab + 关键词过滤后的候选人 */
+ const candidates: TalentCandidate[] = recommendation
+ ? filterTalentCandidates(recommendation.candidates, activeTab, keyword)
+ : [];
+
+ /** 有关键词但一条没命中 */
+ const isEmptySearch = recommendation !== null && keyword.trim() !== '' && candidates.length === 0;
+
return {
- recommendation: data ?? null,
+ recommendation,
+ activeTab,
+ setActiveTab,
+ keyword,
+ setKeyword,
+ candidates,
+ isEmptySearch,
loading,
error,
refresh,
diff --git a/src/lib/cache.ts b/src/lib/cache.ts
new file mode 100644
index 0000000..3f40e00
--- /dev/null
+++ b/src/lib/cache.ts
@@ -0,0 +1,46 @@
+import { Directory, Paths } from 'expo-file-system';
+
+/**
+ * 应用缓存目录的读写。
+ *
+ * 放在 lib 层而不是 api 层:它读的是设备本地文件系统,没有对应的后端接口,
+ * api 层只做「页面怎么拿」的封装(fetchCacheSize / clearAppCache)。
+ *
+ * 清缓存**只删目录内容,保留目录本身** —— 缓存目录被系统与各原生模块共用,
+ * 把目录本身删掉会让后续写入失败。
+ */
+
+/**
+ * 读取缓存目录占用(字节)。
+ * 目录不存在或读不到时返回 0(清缓存本身不该因为读不到体积而失败)。
+ */
+export function readCacheBytes(): number {
+ try {
+ const dir = new Directory(Paths.cache);
+ return dir.exists ? (dir.size ?? 0) : 0;
+ } catch {
+ return 0;
+ }
+}
+
+/**
+ * 清空缓存目录内容。
+ * 单个条目删除失败不阻断其余(可能被系统占用),返回清理前的占用体积。
+ */
+export function clearCacheDir(): number {
+ const before = readCacheBytes();
+ try {
+ const dir = new Directory(Paths.cache);
+ if (!dir.exists) return 0;
+ for (const entry of dir.list()) {
+ try {
+ entry.delete();
+ } catch {
+ // 被占用 / 无权限的条目跳过,继续清其余
+ }
+ }
+ } catch {
+ return 0;
+ }
+ return before;
+}
diff --git a/src/lib/imagePicker.ts b/src/lib/imagePicker.ts
new file mode 100644
index 0000000..7df7d4f
--- /dev/null
+++ b/src/lib/imagePicker.ts
@@ -0,0 +1,65 @@
+/**
+ * 图片选择封装(拍照 / 相册)。
+ *
+ * 与 `storage.ts` 同属「包装原生能力」的 lib 模块:页面组件不直接 import
+ * expo-image-picker,由 Hook 调到这里,这样换实现(或加裁剪、压缩策略)只改一处。
+ *
+ * 返回结果用联合类型建模,「用户取消」与「没有权限」都必须能与成功区分开 ——
+ * 否则页面只能把两种情况都当成失败,或干脆假装成功(本模块存在的直接原因)。
+ */
+import * as ImagePicker from 'expo-image-picker';
+
+/** 取图来源:相机 / 相册 */
+export type ImagePickSource = 'camera' | 'library';
+
+/** 取图结果:成功带本地 URI;取消与未授权分开表示,便于页面给出不同提示 */
+export type ImagePickResult =
+ | { status: 'picked'; uri: string }
+ | { status: 'cancelled' }
+ | { status: 'denied' };
+
+export interface ImagePickOptions {
+ /** 裁剪框宽高比,默认 1:1(头像);营业执照等文档型图片传 4:3 */
+ aspect?: [number, number];
+ /** 是否允许裁剪,默认允许 */
+ allowsEditing?: boolean;
+}
+
+/**
+ * 拍照或从相册选一张图片。
+ * @param source 取图来源
+ * @param options 裁剪比例等(见 ImagePickOptions)
+ * @returns 成功 / 取消 / 未授权三态结果
+ * @remark 权限被拒时返回 `denied` 而不抛错:权限是用户的选择,不是程序异常
+ */
+export async function pickImage(
+ source: ImagePickSource,
+ options: ImagePickOptions = {},
+): Promise {
+ const { aspect = [1, 1], allowsEditing = true } = options;
+
+ const permission =
+ source === 'camera'
+ ? await ImagePicker.requestCameraPermissionsAsync()
+ : await ImagePicker.requestMediaLibraryPermissionsAsync();
+ if (!permission.granted) return { status: 'denied' };
+
+ // mediaTypes 用 'images'(SDK 57 起 MediaTypeOptions 已废弃):
+ // 本项目只取静态图,且原生侧已通过 config plugin 关掉麦克风权限(见 app.config.js)
+ const pickerOptions: ImagePicker.ImagePickerOptions = {
+ mediaTypes: ['images'],
+ allowsEditing,
+ aspect,
+ quality: 0.8,
+ };
+
+ const result =
+ source === 'camera'
+ ? await ImagePicker.launchCameraAsync(pickerOptions)
+ : await ImagePicker.launchImageLibraryAsync(pickerOptions);
+
+ if (result.canceled) return { status: 'cancelled' };
+ // canceled 为 false 时 assets 必为非空数组,这里再兜一层类型安全
+ const asset = result.assets[0];
+ return asset ? { status: 'picked', uri: asset.uri } : { status: 'cancelled' };
+}
diff --git a/src/lib/resumeExport.ts b/src/lib/resumeExport.ts
new file mode 100644
index 0000000..01a4e47
--- /dev/null
+++ b/src/lib/resumeExport.ts
@@ -0,0 +1,54 @@
+/**
+ * 简历 PDF 落盘(原生能力封装,与 `imagePicker.ts` 同一层)。
+ *
+ * 只做「HTML → 本机 PDF 文件」这一件事,命名规则与 HTML 内容由调用方给(API 层)。
+ * 失败一律返回 `{ ok: false, message }`,不抛异常也不返回假地址 —— 调用方据此给出
+ * 能说清原因的提示(Expo Go 缺模块、存储不可写等)。
+ */
+import { File } from 'expo-file-system';
+import * as Print from 'expo-print';
+import { RESUME_EXPORT_FAIL_HINT } from '../constants';
+import type { ResumeExportOutcome, ResumeExportSettings } from '../types';
+import { exportPageSize, sanitizeFileName } from '../utils';
+
+/**
+ * 把简历 HTML 渲染成本机 PDF 文件。
+ * @param html `buildResumeExportHtml` 产出的完整 HTML 文档
+ * @param fileName 期望的文件名(不含扩展名),非法字符会被清洗
+ * @param settings 导出设置(取纸型与方向决定页宽页高)
+ * @returns 成功给出 `file://` 地址与页数;失败给出一段用户能看懂的原因
+ */
+export async function exportResumePdf(
+ html: string,
+ fileName: string,
+ settings: ResumeExportSettings,
+): Promise {
+ const { width, height } = exportPageSize(settings.paperSize, settings.orientation);
+ try {
+ const printed = await Print.printToFileAsync({ html, width, height });
+ const safeName = sanitizeFileName(fileName);
+ return {
+ ok: true,
+ url: renameQuietly(new File(printed.uri), `${safeName}.pdf`),
+ fileName: `${safeName}.pdf`,
+ pages: printed.numberOfPages,
+ };
+ } catch {
+ return { ok: false, message: RESUME_EXPORT_FAIL_HINT };
+ }
+}
+
+/**
+ * 打印引擎落盘的是随机临时名(如 `Print-xxxx.pdf`),分享面板与「文件」App 里显示的就是它,
+ * 与用户设置的文件名对不上。改名前先重命名成用户设的名字。
+ *
+ * 重命名失败(同名文件占用等)不值得让整次导出作废,退回原地址继续。
+ */
+function renameQuietly(file: File, newName: string): string {
+ try {
+ file.rename(newName);
+ } catch {
+ // 忽略:文件名不好看,但文件是好的
+ }
+ return file.uri;
+}
diff --git a/src/lib/shareFile.ts b/src/lib/shareFile.ts
new file mode 100644
index 0000000..54f655a
--- /dev/null
+++ b/src/lib/shareFile.ts
@@ -0,0 +1,47 @@
+/**
+ * 系统分享面板(原生能力封装)。
+ *
+ * `Sharing.shareAsync` 在**面板关闭**后 resolve,用户到底发给谁、有没有发出去,
+ * 应用无从得知 —— 所以 `ok: true` 只表示「面板已拉起」,页面不得据此提示「分享成功」。
+ */
+import * as Sharing from 'expo-sharing';
+import { Share } from 'react-native';
+
+export type ShareOutcome = { ok: true } | { ok: false; message: string };
+
+/**
+ * 拉起系统分享面板分享一段文本(没有文件可分享时用,如导出清单)。
+ * 与 shareFile 同样只表示「面板已拉起」:用户取消或没发出去,应用都无从得知。
+ * @param message 要分享的文本
+ * @param title 分享面板标题(Android)
+ */
+export async function shareText(message: string, title?: string): Promise {
+ try {
+ await Share.share(title ? { message, title } : { message });
+ return { ok: true };
+ } catch {
+ return { ok: false, message: '分享面板未能拉起,请稍后重试' };
+ }
+}
+
+/**
+ * 拉起系统分享面板分享一个本机文件。
+ * @param uri 本机文件地址(`file://`)
+ * @param mimeType Android 用的 MIME 类型,如 `application/pdf`
+ * @param UTI iOS 用的统一类型标识,如 `com.adobe.pdf`
+ * @param dialogTitle Android/web 的分享面板标题
+ */
+export async function shareFile(
+ uri: string,
+ options: { mimeType: string; UTI: string; dialogTitle?: string },
+): Promise {
+ try {
+ if (!(await Sharing.isAvailableAsync())) {
+ return { ok: false, message: '当前设备不支持系统分享面板' };
+ }
+ await Sharing.shareAsync(uri, options);
+ return { ok: true };
+ } catch {
+ return { ok: false, message: '分享面板未能拉起,请稍后重试' };
+ }
+}
diff --git a/src/theme/colors.ts b/src/theme/colors.ts
index bd1bce3..c37d81b 100644
--- a/src/theme/colors.ts
+++ b/src/theme/colors.ts
@@ -116,6 +116,19 @@ export const colors = {
scrim: 'rgba(0, 0, 0, 0.35)',
} as const;
+/**
+ * 简历模板的强调色(模板选择卡片与导出 PDF 的页眉/标题线共用同一份)。
+ *
+ * 模板色是导出物里的**印刷色**,不随亮/暗模式变化,因此固定取亮色家族。
+ * 放在 theme 而不是页面里:模板卡片与 PDF 两处都要用,各写一份必然分叉,
+ * 而色值是本文件独占的职责。
+ */
+export const templateAccentColors: Record