Browse Source

feat: 完成求职App核心页面与数据层并迁移至 Expo Router

master
doublex001 2 months ago
parent
commit
30cc3dc106
  1. 3
      .gitignore
  2. 584
      AGENTS.md
  3. 20
      App.js
  4. 39
      app.config.js
  5. 24
      app.json
  6. 62
      app/(tabs)/_layout.tsx
  7. 541
      app/(tabs)/chat.tsx
  8. 592
      app/(tabs)/growth.tsx
  9. 528
      app/(tabs)/index.tsx
  10. 691
      app/(tabs)/jobs.tsx
  11. 468
      app/(tabs)/profile.tsx
  12. 21
      app/_layout.tsx
  13. 36
      app/assessment.tsx
  14. 36
      app/favorites.tsx
  15. 36
      app/history.tsx
  16. 36
      app/resume.tsx
  17. 36
      app/settings.tsx
  18. 8
      index.js
  19. 1778
      package-lock.json
  20. 19
      package.json
  21. 876
      src/api/api.ts
  22. 28
      src/api/index.ts
  23. 36
      src/constants/index.ts
  24. 12
      src/hooks/index.ts
  25. 61
      src/hooks/useAsyncData.ts
  26. 10
      src/hooks/useCategoryData.ts
  27. 45
      src/hooks/useGrowthData.ts
  28. 60
      src/hooks/useHomeData.ts
  29. 45
      src/hooks/useJobList.ts
  30. 10
      src/hooks/useMatchData.ts
  31. 48
      src/hooks/useMessageData.ts
  32. 55
      src/hooks/useProfileData.ts
  33. 10
      src/hooks/useUserProfile.ts
  34. 166
      src/theme/colors.ts
  35. 4
      src/theme/index.ts
  36. 58
      src/types/growth.ts
  37. 46
      src/types/home.ts
  38. 31
      src/types/index.ts
  39. 79
      src/types/job.ts
  40. 62
      src/types/message.ts
  41. 101
      src/types/user.ts
  42. 21
      src/utils/index.ts
  43. 13
      tsconfig.json

3
.gitignore

@ -39,3 +39,6 @@ yarn-error.*
# generated native folders
/ios
/android
# IDE
.idea/

584
AGENTS.md

@ -1,3 +1,583 @@
# Expo HAS CHANGED
Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
项目通用开发文档
========================================
一、架构约定(数据层与 UI 层分离)
========================================
【核心原则】
做任务前,先检查问题有没有错误前提、逻辑跳跃和信息缺失。 不要迎合我,要独立判断,区分事实、预测和主观观点。 涉及数字、人物和结论时尽量核实来源。 不同意就直接指出并给出依据、风险和替代解释。 还要主动提醒我忽略的变量、成本和偏差。
请在每周的周一-周五17:50提交一次git仓库,说明今天改动的内容
业务逻辑分层管理,不是没有,而是被妥善组织:
+------------------+--------------------------------------------------+----------------------------------------------------------------------------------------+
| 层级 | 职责 | 业务逻辑示例 |
+------------------+--------------------------------------------------+----------------------------------------------------------------------------------------+
| 页面组件 | 渲染UI + 处理用户交互事件 | 点击排序按钮 → 调用Hook切换排序;输入搜索词 → 触发搜索;切换Tab高亮 |
| Hook层 | 管理数据状态 + 编排数据逻辑 | 根据排序/筛选参数重新获取数据;组合多个API数据;管理loading/error/empty |
| API层 | 发起请求 + 数据转换 + 业务规则 | 计算匹配度;根据分数映射等级(优秀/良好/中等);薪资范围格式化 |
| types层 | 定义数据结构 | 用联合类型建模派生状态,如 Job.level: 'excellent' | 'good' | 'medium' |
+------------------+--------------------------------------------------+----------------------------------------------------------------------------------------+
【目录结构】
src/
types/ # 类型定义(完整领域模型 + 派生状态联合类型)
api/ # 接口层:发起请求、数据转换、业务规则计算
hooks/ # 数据hooks:调用API、管理状态、编排数据逻辑
theme/ # 设计令牌(colors)
utils/ # 工具函数(日期格式化、字符串处理等纯函数)
constants/ # 常量定义(枚举、配置项等)
app/
(tabs)/ # 底部Tab页面
index.tsx # 首页
jobs.tsx # 岗位列表页
chat.tsx # 沟通页
growth.tsx # 成长页
profile.tsx # 我的页
_layout.tsx # Tab导航配置
_layout.tsx # 根布局
所有模拟数据统一放在 src/api/api.ts 中管理。未来接入真实接口时,仅需替换该文件中的函数体,无需新增或删除其他文件。
【数据链路(页面 → Hook → API → 后端)】
新增任何数据按此链路实现:
第1步:src/types/index.ts 定义类型
- 完整领域模型(Job、User、Category 等)
- 派生状态用联合类型建模,如 Job.level: 'excellent' | 'good' | 'medium'
- 禁止在组件里写死判断逻辑
第2步:src/api/api.ts 定义接口函数
- JSDoc 标注:@method、@url、@param、@returns
- 当前用 setTimeout 返回 mock 数据
- 函数体内用注释预留真实 fetch 示例
- 业务规则计算放在这层:匹配度算法、等级映射、数据转换
第3步:src/hooks/ 封装数据 hook
- 通用取数复用 useAsyncData<T>(fetcher),返回:
{ data: T | null, loading: boolean, error: Error | null, refresh: () => void }
- 支持自动重试(如重试3次)和请求去重(防止重复请求)
- 再包一层语义化命名 hook(如 useJobData() 返回 { jobs, loading, error, refresh, isEmpty })
- 数据编排逻辑放在这层:根据排序/筛选参数重组数据
第4步:页面只调用 hook 取数
- UI交互逻辑放在页面:点击切换Tab、表单输入、按钮高亮状态
- 页面组件统一处理四种状态:加载中、错误、空状态、成功(渲染数据)
【状态与错误处理约定】
+------------------+------------------------------------------+----------------------------------+
| 状态 | UI表现 | 说明 |
+------------------+------------------------------------------+----------------------------------+
| loading === true | 显示骨架屏或Loading指示器 | 首次加载和刷新时展示 |
| error !== null | 显示错误提示 + 重试按钮 | 调用 refresh() 重试 |
| isEmpty === true | 显示空状态占位图 + 提示文字 | 数据为空数组时展示 |
| data !== null | 正常渲染数据列表 | 成功状态 |
+------------------+------------------------------------------+----------------------------------+
========================================
二、编码规范
========================================
【命名规范】
Hook:
- 以 use 开头,采用 use + 名词 + 动词
- 示例:useJobList、useUserProfile、useCategoryData
API函数:
- 以 fetch 开头,采用 fetch + 名词
- 示例:fetchJobList、fetchUserInfo、fetchMatchData
类型/接口:
- 使用名词,不添加 I 前缀
- 示例:interface Job,而非 interface IJob
- 示例:type SortType = 'comprehensive' | 'latest' | 'salary'
组件文件:
- 使用 PascalCase(大驼峰)
- 示例:JobListScreen.tsx、SearchBar.tsx
- 页面组件以 Screen 结尾:HomeScreen.tsx、JobsScreen.tsx
常量:
- 使用 UPPER_SNAKE_CASE(全大写下划线)
- 示例:MAX_RETRY_COUNT、API_TIMEOUT、DEFAULT_PAGE_SIZE
工具函数:
- 使用 camelCase(小驼峰)
- 示例:formatDate、capitalizeString、debounce
【TypeScript 配置】
推荐 tsconfig.json 核心配置:
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true
}
}
【禁止行为(反模式)】
❌ 页面组件中直接使用 fetch 或 axios 调用API
❌ 在组件内使用 useState + useEffect 管理数据加载和错误状态
❌ 在组件内通过 if (score >= 85) 这样的硬编码判断等级
❌ 在组件内写死十六进制颜色值(如 #FF3B30)
❌ 在组件内直接操作 localStorage / AsyncStorage
❌ 在循环或条件语句中调用 Hook
❌ 在组件内定义不需要的状态(能用派生值就不用 state)
❌ 在 useEffect 中遗漏清理函数(事件监听、定时器等)
✅ 正确做法:
- 所有数据请求和业务规则计算,必须通过 Hook 和 API 层完成
- 颜色统一从 theme/colors.ts 导入
- 存储操作封装在 API 层或独立的 storage 模块中
- useEffect 中注册的事件监听必须在清理函数中移除
========================================
三、环境配置
========================================
使用 expo-constants 管理环境变量。在 app.config.js 中定义 extra 字段:
// app.config.js
export default {
extra: {
apiBaseUrl: process.env.API_BASE_URL || 'https://dev.api.com',
},
};
在 src/api/api.ts 中通过 Constants.expoConfig?.extra?.apiBaseUrl 读取。
环境变量示例:
API_BASE_URL=https://api.production.com # 生产环境
API_BASE_URL=https://api.test.com # 测试环境
========================================
四、通用约定
========================================
- 每个 src/ 子目录用 index.ts 统一导出(barrel)
- 页面从 ../../src/hooks、../../src/theme 等导入,不直接引用子文件
- 导入用相对路径(../../src/...),不引入 path alias / babel 配置
导入示例:
// ✅ 正确
import { useJobList } from '../../src/hooks';
import { colors } from '../../src/theme';
// ❌ 错误
import { useJobList } from '../../src/hooks/useJobList';
========================================
五、真实后端就绪后
========================================
仅替换 src/api/api.ts 中函数体为真实 fetch(签名不变),页面与 hooks 无需改动。
迁移步骤:
1. 确认后端接口地址和数据结构
2. 在 src/api/api.ts 中替换 fetch 函数体
3. 根据后端实际返回结构调整数据转换逻辑
4. 删除 mock 数据(或保留用于本地开发)
5. 测试验证各功能正常
========================================
六、Git 提交规范
========================================
采用 Conventional Commits 规范:
<type>(<scope>): <subject>
type 类型:
- feat: 新功能
- fix: 修复Bug
- docs: 文档更新
- style: 代码格式(不影响功能)
- refactor: 重构(既不是新功能也不是修复)
- test: 测试相关
- chore: 构建/工具变动
scope 范围(可选):
- jobs: 岗位相关
- user: 用户相关
- api: 接口相关
- hook: Hook相关
- ui: UI组件相关
示例:
feat(jobs): 添加岗位列表排序功能
fix(hook): 修复useJobList刷新时数据未重置的问题
docs(readme): 更新环境配置说明
refactor(api): 重构等级映射逻辑,提取为独立函数
========================================
七、测试策略
========================================
API层测试:
- 测试 mock 数据返回结构是否符合 types 定义
- 测试等级映射逻辑是否正确(如 92分 → 'excellent')
Hook层测试:
- 测试排序、筛选等数据逻辑是否正确
- 测试 loading/error 状态切换是否正确
页面层测试:
- 测试 UI 交互是否正常(点击、输入、切换)
- 测试四种状态(加载中/错误/空状态/成功)渲染是否正确
========================================
八、Code Review 检查清单
========================================
提交PR前自查:
【架构规范】
- [ ] 页面组件是否直接调用了API?(应通过Hook调用)
- [ ] 数据请求是否走通了 页面 → Hook → API → 后端 链路?
【业务逻辑】
- [ ] 是否有硬编码的业务规则判断?(应在API层处理)
- [ ] 等级/状态等派生值是否通过联合类型建模?
【UI规范】
- [ ] 组件内是否有硬编码的颜色值?(应从 theme 导入)
- [ ] 是否处理了四种UI状态?(加载中/错误/空状态/成功)
【命名规范】
- [ ] Hook 是否以 use 开头?
- [ ] API 函数是否以 fetch 开头?
- [ ] 组件文件名是否为 PascalCase?
- [ ] 常量是否为 UPPER_SNAKE_CASE?
【代码质量】
- [ ] 是否添加了必要的 JSDoc 注释?
- [ ] useEffect 中是否遗漏了清理函数?
- [ ] 是否存在未使用的导入或变量?
【提交规范】
- [ ] Commit message 是否符合 Conventional Commits 格式?
========================================
九、配色规范
========================================
全局配色集中在 src/theme/colors.ts,组件内不写死十六进制色值,统一 import { colors } from '../../src/theme'。
### 🔵 主色家族(蓝)
| 变量名 | 色值 | 说明 |
|---|---|---|
| **primary** | `#2C83FD` | 主色(蓝)· 品牌锚点 / Logo / ≥24pt 展示字 |
| primaryLight | `#E8F2FF` | 主色浅色调 · 卡片 / 标签底色 / 空状态背景 |
| primaryBorder | `#D1E5FF` | 主色描边 · 标签边框 / 输入框聚焦边 |
| primaryBright | `#5399FD` | 主色亮色调 · 按钮渐变终点 / 装饰图形 |
| primaryDark | `#175AB0` | 主色深色调 · 图标 / ≥18pt 大字 / 正向评级文字 |
| **primaryText** ⭐ | `#104686` | **主色文字专用** · 浅底上写蓝字(白底 9.4:1 AAA) |
| primaryBtn ⭐ | `#1F6FDB` | **主按钮底色** · 白字(对比 4.8:1 AA),hover 切 primaryDark |
---
### 🟠 行动色家族(珊瑚橙)
| 变量名 | 色值 | 说明 |
|---|---|---|
| **action** | `#FF6F61` | 行动色(珊瑚橙)· 品牌锚点 / 装饰 / ≥24pt 大字 |
| actionLight | `#FFEFED` | 行动色浅色调 · 提醒卡片底 / 标签底 |
| actionBorder | `#FFD7D1` | 行动色描边 · 弱警示描边 / 标签边框 |
| **actionBtn** ⭐ | `#F25A4C` | **行动按钮底色** · 白字(对比 5.4:1 AA,比 #FF6F61 稳) |
| **actionText** ⭐ | `#D94A3C` | **行动色文字专用** · 浅底上写橙色字(白底 7.1:1 AA) |
| actionDeep | `#B03A2E` | 行动色极深 · 按下态 / 极小文字底(10.1:1 AAA) |
---
### 🔴 警示色(红 · 严格独立于行动色)
| 变量名 | 色值 | 说明 |
|---|---|---|
| **danger** | `#FF3B30` | 警示色 · 风险 / 警告 / 未读红点 / 错误标(白字 5.1:1 AA) |
| dangerLight | `#FFF1F0` | 警示浅色调 · 错误提示卡片底 |
| dangerBorder | `#FFD6D4` | 警示描边 · 错误输入框描边 |
| dangerBtn | `#E5352B` | 警示按钮底 · 删除 / 危险操作(白字 5.8:1 AA) |
| dangerText | `#C02A22` | 警示正文 · 错误信息文字(白底 8.3:1 AAA) |
---
### ✅ 成功色(暖绿 · 避免"主色=成功"混淆)
| 变量名 | 色值 | 说明 |
|---|---|---|
| success | `#10B981` | 成功色 · 完成 / 通过 图标 / 标签(图形用) |
| successLight | `#E8F8EF` | 成功浅色调 · 成功卡片底 / 已通过标签底 |
| successBorder | `#C7EDD8` | 成功描边 · 已完成卡片边框 |
| successBtn | `#059669` | 成功按钮底 · 白字(对比 5.6:1 AA) |
| successText | `#047857` | 成功正文 · 已达成 / 正向收益文字(白底 7.8:1 AAA) |
---
### 🟣 信息色(紫 · 补冷色锚点,与蓝主色区分)
| 变量名 | 色值 | 说明 |
|---|---|---|
| info | `#7C5CFC` | 信息色 · 通知 / 链接 / 金额数字 / 信息标签(图形用) |
| infoLight | `#F1EEFF` | 信息浅色调 · 通知卡片底 / 系统消息底 |
| infoBorder | `#E0DAFF` | 信息描边 · 信息类标签边框 |
| infoBtn | `#6642E8` | 信息按钮底 · 白字(对比 6.0:1 AA) |
| infoText | `#5B3ED8` | 信息正文 · 链接 / 金额数字(白底 7.4:1 AAA) |
---
### ⚪ 中性色家族(7 档 · 原来 3 档不够用)
| 变量名 | 色值 | 说明 |
|---|---|---|
| **white** | `#FFFFFF` | 中性底色 · 卡片 / 弹窗底 |
| **surface** | `#FFFFFF` | 卡片 / 列表项底(与 pageBg 拉开 1 档层次) |
| **pageBg** | `#F6F8F9` | 页面背景浅灰 · 全局背景 |
| borderLight | `#EFF2F6` | 浅描边 · 卡片分割 / 细分割线 / 列表项分隔 |
| **border** | `#E8ECF4` | 中性描边 · 输入框边框 / 卡片边框 |
| borderStrong | `#D4DAE3` | 强描边 · 输入框聚焦前 / 选中卡片描边 |
| textDisabled | `#B0B8C1` | 禁用文字 · 禁用按钮 / 只读内容 |
| textHint | `#8E99A4` | 占位符文字 · placeholder / 辅助提示 |
| **textSecondary** ⭐ | `#6B7A7B` | **次要文字**(原 #7F8C8D 加深 · 白底 5.1:1 过 AA) |
| **textPrimary** | `#2C3E50` | 正文深色 · 标题 / 正文主阅读文字(13.1:1 AAA) |
| textInverse | `#FFFFFF` | 深色底上的反白文字 |
| focusRing | `#1F6FDB` | 焦点环色 · 键盘聚焦指示器(WCAG 2.4.11) |
---
### 🌙 暗模式配色(2026 默认必做 · 翻色不是简单反白)
| 变量名 | 亮模式 | 暗模式 | 说明 |
|---|---|---|---|
| pageBg | `#F6F8F9` | `#0E1116` | 近黑带蓝灰 · OLED 省电 + 不刺眼 |
| surface | `#FFFFFF` | `#161B22` | 卡片底比背景亮 1 档 · 拉开层次 |
| border | `#E8ECF4` | `#2A313C` | 暗描边灰蓝调 · 不发白 |
| textPrimary | `#2C3E50` | `#E6EDF3` | 暗正文暖白 · 减少眩光 |
| textSecondary | `#6B7A7B` | `#91A0B3` | 暗次文(与 surface 对比 6.2:1 AA) |
| primary | `#2C83FD` | `#5399FD` | 暗模式主色提亮 1 档 · 黑底上不"消失" |
| primaryBtn | `#1F6FDB` | `#1F6FDB` | 按钮底不随暗模式提亮 · 白字 4.8:1 AA |
| action | `#FF6F61` | `#FF8273` | 暗行动色更亮 · 保证抓眼 |
---
### 使用说明
- **primary / action**:品牌锚点、图标、装饰、≥18pt 大字、选中态指示;不作为白字按钮底(对比度不足)。
- **primaryBtn / actionBtn**:按钮底色专用(白字过 WCAG AA),需要按钮时优先使用。
- **primaryText / actionText**:浅底上写彩色字专用(普通 primary/action 直接写字对比度不足)。
- **primaryDark**:正向评级(如「优秀」)文字。
- **primaryLight / primaryBorder**:卡片底色 / 标签边框。
使用指引:
- 按钮主背景使用 primaryBtn,渐变按钮使用 primaryBtn → primaryBright
- 正向评级(如「优秀」)文字使用 primaryDark
- 次要操作按钮(如「取消」)使用 actionBtn
- 卡片底色用 primaryLight,边框用 primaryBorder
========================================
十、技术要求
========================================
- 使用 Expo Router(app 目录结构)
- 使用 TypeScript
- 使用 @expo/vector-icons(Ionicons)
- 使用 expo-linear-gradient(渐变)
- API 层用注释标明:接口地址、请求方式、参数、返回值
========================================
十一、接口定义示例(在 api.ts 中)
========================================
// 接口1:获取用户信息
// GET /api/user/profile
// 返回值:{ name: string, slogan: string }
export const fetchUserInfo = async (): Promise<UserInfo> => {
// TODO: 替换为真实接口
return new Promise((resolve) => {
setTimeout(() => {
resolve({ name: '李明', slogan: '精准高效·全链条一站式求职服务' });
}, 500);
});
};
// 接口2:获取匹配度
// GET /api/match/position
// 参数:{ userId: string }
// 返回值:{ position: string, matchRate: number }
export const fetchMatchData = async (params: { userId: string }): Promise<MatchData> => {
// TODO: 替换为真实接口
return new Promise((resolve) => {
setTimeout(() => {
resolve({ position: '高级前端工程师', matchRate: 95 });
}, 500);
});
};
// 接口3:获取岗位列表(含排序/筛选/搜索/分页)
// GET /api/jobs
// 参数:{ sortBy: string, keyword: string, industry: string, city: string, page: number, pageSize: number }
// 返回值:{ jobs: Job[], total: number }
export const fetchJobList = async (params: {
sortBy: 'comprehensive' | 'latest' | 'salary';
keyword?: string;
industry?: string;
city?: string;
salary?: string;
experience?: string;
page?: number;
pageSize?: number;
}): Promise<{ jobs: Job[]; total: number }> => {
// TODO: 替换为真实接口
return new Promise((resolve) => {
setTimeout(() => {
// 1. 从mock数据源复制
let result = [...mockJobData];
// 2. 关键词搜索
if (params.keyword && params.keyword.trim() !== '') {
const keyword = params.keyword.trim().toLowerCase();
result = result.filter(
(job) =>
job.positionName.toLowerCase().includes(keyword) ||
job.companyName.toLowerCase().includes(keyword)
);
}
// 3. 筛选:行业
if (params.industry && params.industry !== '全部行业') {
result = result.filter((job) => job.industry === params.industry);
}
// 4. 筛选:城市
if (params.city && params.city !== '全部城市') {
result = result.filter((job) => job.city.includes(params.city));
}
// 5. 排序逻辑
switch (params.sortBy) {
case 'salary':
result.sort((a, b) => b.salaryMax - a.salaryMax);
break;
case 'latest':
result.sort((a, b) => parseInt(b.id) - parseInt(a.id));
break;
case 'comprehensive':
default:
result.sort((a, b) => b.score - a.score);
break;
}
// 6. 计算总数
const total = result.length;
// 7. 分页
const page = params.page || 1;
const pageSize = params.pageSize || 10;
const startIndex = (page - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, total);
const paginatedJobs = result.slice(startIndex, endIndex);
// 8. 等级映射(业务规则)
const jobsWithLevel = paginatedJobs.map((job) => ({
...job,
level: job.score >= 85 ? 'excellent' : job.score >= 70 ? 'good' : 'medium',
}));
resolve({ jobs: jobsWithLevel, total });
}, 300);
});
};
// 模拟数据
const mockJobData: Job[] = [
{
id: '1',
companyName: '字节跳动',
companyLocation: '北京 · 海淀区',
positionName: '高级前端工程师',
salaryMin: 25,
salaryMax: 40,
score: 92,
level: 'excellent',
experience: '3-5年',
education: '本科',
benefits: ['五险一金', '年终奖金', '弹性工作'],
industry: '互联网/IT',
city: '北京',
},
{
id: '2',
companyName: '腾讯科技',
companyLocation: '深圳 · 南山区',
positionName: '产品经理',
salaryMin: 20,
salaryMax: 35,
score: 78,
level: 'good',
experience: '3-5年',
education: '本科',
benefits: ['五险一金', '年终奖金', '弹性工作'],
industry: '互联网/IT',
city: '深圳',
},
{
id: '3',
companyName: '阿里巴巴',
companyLocation: '杭州 · 余杭区',
positionName: 'UI设计师',
salaryMin: 15,
salaryMax: 25,
score: 85,
level: 'excellent',
experience: '3-5年',
education: '本科',
benefits: ['五险一金', '年终奖金', '弹性工作'],
industry: '互联网/IT',
city: '杭州',
},
{
id: '4',
companyName: '美团',
companyLocation: '北京 · 朝阳区',
positionName: 'Java开发工程师',
salaryMin: 18,
salaryMax: 30,
score: 65,
level: 'medium',
experience: '3-5年',
education: '本科',
benefits: ['五险一金', '年终奖金', '弹性工作'],
industry: '互联网/IT',
city: '北京',
},
];

20
App.js

@ -1,20 +0,0 @@
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<Text>Open up App.js to start working on your app!</Text>
<StatusBar style="auto" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});

39
app.config.js

@ -0,0 +1,39 @@
/**
* Expo 应用配置(app.config.js)
*
* extra.apiBaseUrl 通过 expo-constants 在运行时读取,见 src/api/api.ts:
* Constants.expoConfig?.extra?.apiBaseUrl
*
* 环境变量示例:
* API_BASE_URL=https://api.production.com # 生产环境
* API_BASE_URL=https://api.test.com # 测试环境
*/
export default {
expo: {
name: 'mydreamjob',
slug: 'mydreamjob',
scheme: 'mydreamjob',
version: '1.0.0',
orientation: 'portrait',
icon: './assets/icon.png',
userInterfaceStyle: 'light',
ios: {
supportsTablet: true,
},
android: {
adaptiveIcon: {
backgroundColor: '#E6F4FE',
foregroundImage: './assets/android-icon-foreground.png',
backgroundImage: './assets/android-icon-background.png',
monochromeImage: './assets/android-icon-monochrome.png',
},
},
web: {
favicon: './assets/favicon.png',
},
plugins: ['expo-router', 'expo-font'],
extra: {
apiBaseUrl: process.env.API_BASE_URL || 'https://dev.api.com',
},
},
};

24
app.json

@ -1,24 +0,0 @@
{
"expo": {
"name": "mydreamjob",
"slug": "mydreamjob",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
}
},
"web": {
"favicon": "./assets/favicon.png"
}
}
}

62
app/(tabs)/_layout.tsx

@ -0,0 +1,62 @@
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import type { ComponentProps } from 'react';
import { StyleSheet, Text } from 'react-native';
import type { ColorValue } from 'react-native';
import { colors } from '../../src/theme';
type IconName = ComponentProps<typeof Ionicons>['name'];
/**
* 根据选中状态返回实心/描边图标
*/
function tabIcon(filled: IconName, outline: IconName) {
return ({ color, size, focused }: { color: ColorValue; size: number; focused: boolean }) => (
<Ionicons name={focused ? filled : outline} size={size} color={color} />
);
}
/**
* 底部 Tab 导航:首页默认选中,蓝色高亮
*/
export default function TabLayout() {
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.primary,
tabBarInactiveTintColor: colors.textSecondary,
tabBarLabel: ({ color, children }) => (
<Text style={[styles.labelText, { color }]}>{children}</Text>
),
}}
>
<Tabs.Screen
name="index"
options={{ title: '首页', tabBarIcon: tabIcon('home', 'home-outline') }}
/>
<Tabs.Screen
name="jobs"
options={{ title: '岗位', tabBarIcon: tabIcon('briefcase', 'briefcase-outline') }}
/>
<Tabs.Screen
name="chat"
options={{ title: '沟通', tabBarIcon: tabIcon('chatbubbles', 'chatbubbles-outline') }}
/>
<Tabs.Screen
name="growth"
options={{ title: '成长', tabBarIcon: tabIcon('trending-up', 'trending-up-outline') }}
/>
<Tabs.Screen
name="profile"
options={{ title: '我的', tabBarIcon: tabIcon('person', 'person-outline') }}
/>
</Tabs>
);
}
const styles = StyleSheet.create({
labelText: {
fontSize: 11,
},
});

541
app/(tabs)/chat.tsx

@ -0,0 +1,541 @@
import { useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useMessageData } from '../../src/hooks';
import type { Conversation, Interview, Notification } from '../../src/types';
import { colors } from '../../src/theme';
/** Tab 键 */
type TabKey = 'message' | 'notification' | 'interview';
/** Tab 配置 */
const TABS: { key: TabKey; label: string }[] = [
{ key: 'message', label: '消息' },
{ key: 'notification', label: '通知' },
{ key: 'interview', label: '面试' },
];
/** 会话卡片 */
function ConversationCard({ conversation }: { conversation: Conversation }) {
return (
<View style={styles.convCard}>
<View style={styles.avatarWrap}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{conversation.avatar}</Text>
</View>
{conversation.unread && <View style={styles.unreadDot} />}
</View>
<View style={styles.convBody}>
<Text style={styles.contactName}>{conversation.contactName}</Text>
<Text style={styles.lastMessage} numberOfLines={1}>
{conversation.lastMessage}
</Text>
</View>
<View style={styles.convRight}>
<Text style={styles.time}>{conversation.timestamp}</Text>
{conversation.matched && (
<View style={styles.matchedTag}>
<Text style={styles.matchedText}>✓ 已匹配</Text>
</View>
)}
</View>
</View>
);
}
/** 通知卡片 */
function NotificationCard({ notification }: { notification: Notification }) {
return (
<View style={styles.notiCard}>
<View style={styles.notiTitleRow}>
{!notification.read && <View style={styles.notiDot} />}
<Text style={styles.notiTitle}>{notification.title}</Text>
</View>
<Text style={styles.notiContent} numberOfLines={2}>
{notification.content}
</Text>
<Text style={styles.notiTime}>{notification.timestamp}</Text>
</View>
);
}
/** 面试卡片 */
function InterviewCard({ interview }: { interview: Interview }) {
const isVideo = interview.type === 'video';
return (
<View style={styles.interviewCard}>
<View style={styles.interviewHeader}>
<Text style={styles.interviewTitle}>
{interview.companyName}·{interview.positionName}
</Text>
<Text style={styles.interviewTime}>{interview.time}</Text>
</View>
<View style={styles.interviewFooter}>
<View style={[styles.typeTag, isVideo ? styles.typeTagVideo : styles.typeTagOffline]}>
<Text style={[styles.typeText, isVideo ? styles.typeTextVideo : styles.typeTextOffline]}>
{isVideo ? '视频面试' : '线下面试'}
</Text>
</View>
<Pressable
style={styles.actionBtn}
onPress={() => {
console.log(isVideo ? '加入会议' : '导航前往', interview.id);
}}
>
<Text style={styles.actionText}>{isVideo ? '加入会议' : '导航前往'}</Text>
</Pressable>
</View>
</View>
);
}
/**
* 消息/沟通页
*/
export default function ChatScreen() {
const [tab, setTab] = useState<TabKey>('message');
const { conversations, notifications, interviews, matchResult, loading, error, refresh } =
useMessageData();
// 加载态
if (loading) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.loading}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
// 错误态
if (error) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.loading}>
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
{/* 顶部导航 */}
<View style={styles.nav}>
<Text style={styles.navTitle}>消息</Text>
</View>
{/* Tab 切换栏 */}
<View style={styles.tabBar}>
{TABS.map((item) => {
const active = tab === item.key;
return (
<Pressable key={item.key} style={styles.tabItem} onPress={() => setTab(item.key)}>
<Text style={[styles.tabLabel, active && styles.tabLabelActive]}>{item.label}</Text>
<View style={[styles.tabIndicator, active && styles.tabIndicatorActive]} />
</Pressable>
);
})}
</View>
{/* 内容区 */}
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{tab === 'message' && (
<>
{/* 双向匹配成功横幅 */}
{matchResult && (
<Pressable
style={styles.matchBanner}
onPress={() => {
console.log('查看匹配详情', matchResult.companies);
}}
>
<Text style={styles.matchBannerTitle}>
恭喜!您与{matchResult.total}家企业双向匹配成功
</Text>
<Text style={styles.matchBannerLink}>点击查看详情</Text>
</Pressable>
)}
{/* 会话列表 */}
{conversations.length === 0 ? (
<Text style={styles.emptyText}>暂无消息</Text>
) : (
conversations.map((conversation) => (
<ConversationCard key={conversation.id} conversation={conversation} />
))
)}
</>
)}
{tab === 'notification' && (
<>
{/* Offer 待处理横幅 */}
<Pressable
style={styles.offerBanner}
onPress={() => {
console.log('查看 Offer 列表');
}}
>
<Text style={styles.offerTitle}>您有2个Offer待处理</Text>
<Text style={styles.offerLink}>立即查看 &gt;</Text>
</Pressable>
{/* 系统通知列表 */}
{notifications.length === 0 ? (
<Text style={styles.emptyText}>暂无通知</Text>
) : (
notifications.map((notification) => (
<NotificationCard key={notification.id} notification={notification} />
))
)}
</>
)}
{tab === 'interview' && (
<>
{/* 面试日程标题 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>今日面试</Text>
<Pressable
onPress={() => {
console.log('查看全部日程');
}}
>
<Text style={styles.sectionLink}>全部日程 &gt;</Text>
</Pressable>
</View>
{/* 面试卡片 */}
{interviews.length === 0 ? (
<Text style={styles.emptyText}>暂无面试</Text>
) : (
interviews.map((interview) => (
<InterviewCard key={interview.id} interview={interview} />
))
)}
</>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
loading: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
emptyText: {
paddingVertical: 24,
fontSize: 14,
color: colors.textSecondary,
textAlign: 'center',
},
nav: {
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 12,
},
navTitle: {
fontSize: 24,
fontWeight: '700',
color: colors.textPrimary,
},
tabBar: {
flexDirection: 'row',
paddingHorizontal: 20,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
tabItem: {
flex: 1,
alignItems: 'center',
paddingTop: 4,
},
tabLabel: {
fontSize: 15,
color: colors.textSecondary,
paddingBottom: 8,
},
tabLabelActive: {
color: colors.textPrimary,
fontWeight: '600',
},
tabIndicator: {
height: 2,
width: 24,
borderRadius: 1,
backgroundColor: 'transparent',
marginBottom: -1,
},
tabIndicatorActive: {
backgroundColor: colors.primary,
},
scroll: {
flex: 1,
},
content: {
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 24,
},
// 匹配横幅
matchBanner: {
backgroundColor: colors.primaryLight,
borderRadius: 16,
padding: 16,
marginBottom: 16,
},
matchBannerTitle: {
fontSize: 15,
fontWeight: '700',
color: colors.primaryDark,
},
matchBannerLink: {
marginTop: 6,
fontSize: 13,
color: colors.primary,
},
// 会话卡片
convCard: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
marginBottom: 12,
},
avatarWrap: {
position: 'relative',
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.primaryLight,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
fontSize: 16,
fontWeight: '700',
color: colors.primary,
},
unreadDot: {
position: 'absolute',
top: 0,
right: 0,
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.danger,
borderWidth: 1.5,
borderColor: colors.white,
},
convBody: {
flex: 1,
marginLeft: 12,
},
contactName: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
},
lastMessage: {
marginTop: 4,
fontSize: 13,
color: colors.textSecondary,
},
convRight: {
alignItems: 'flex-end',
marginLeft: 8,
},
time: {
fontSize: 12,
color: colors.textSecondary,
},
matchedTag: {
marginTop: 6,
backgroundColor: colors.primaryLight,
borderRadius: 8,
paddingHorizontal: 6,
paddingVertical: 2,
},
matchedText: {
fontSize: 11,
color: colors.primaryDark,
},
// Offer 横幅
offerBanner: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: colors.actionLight,
borderRadius: 16,
padding: 16,
marginBottom: 16,
},
offerTitle: {
flex: 1,
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
offerLink: {
fontSize: 13,
fontWeight: '600',
color: colors.action,
},
// 通知卡片
notiCard: {
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
marginBottom: 12,
},
notiTitleRow: {
flexDirection: 'row',
alignItems: 'center',
},
notiDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.danger,
marginRight: 6,
},
notiTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
},
notiContent: {
marginTop: 6,
fontSize: 13,
lineHeight: 18,
color: colors.textSecondary,
},
notiTime: {
marginTop: 8,
fontSize: 12,
color: colors.textSecondary,
},
// 面试
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
},
sectionLink: {
fontSize: 13,
color: colors.primary,
},
interviewCard: {
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
marginBottom: 12,
},
interviewHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
interviewTitle: {
flex: 1,
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
interviewTime: {
marginLeft: 8,
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
interviewFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 12,
},
typeTag: {
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 3,
},
typeTagVideo: {
backgroundColor: colors.primaryLight,
},
typeTagOffline: {
backgroundColor: colors.border,
},
typeText: {
fontSize: 12,
fontWeight: '600',
},
typeTextVideo: {
color: colors.primary,
},
typeTextOffline: {
color: colors.textSecondary,
},
actionBtn: {
backgroundColor: colors.primaryBtn,
borderRadius: 18,
paddingHorizontal: 16,
paddingVertical: 8,
},
actionText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
});

592
app/(tabs)/growth.tsx

@ -0,0 +1,592 @@
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import type { ComponentProps } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Line, Polygon } from 'react-native-svg';
import { useGrowthData } from '../../src/hooks';
import { colors } from '../../src/theme';
import { formatThousands, formatViews } from '../../src/utils';
import { JOB_LEVEL_LABEL } from '../../src/constants';
import type { AbilityDimension, JobLevel } from '../../src/types';
type IconName = ComponentProps<typeof Ionicons>['name'];
/* ===================== 雷达图 ===================== */
const RADAR_SIZE = 132;
const RADAR_CENTER = RADAR_SIZE / 2;
/** 最大绘制半径(留边距,避免顶点被裁切) */
const RADAR_MAX_RADIUS = RADAR_SIZE / 2 - 4;
/** 计算雷达图第 index 个顶点的坐标(从正上方开始,顺时针) */
function radarPoint(index: number, total: number, radius: number) {
const angle = (Math.PI * 2 * index) / total - Math.PI / 2;
return {
x: RADAR_CENTER + radius * Math.cos(angle),
y: RADAR_CENTER + radius * Math.sin(angle),
};
}
/**
* 四维能力雷达图:3 层网格 + 轴线 + 数据填充多边形 + 顶点圆点
*/
function RadarChart({ dimensions }: { dimensions: AbilityDimension[] }) {
const total = dimensions.length;
// 3 层网格(1/3、2/3、3/3 半径)
const gridPolygons = [1, 2, 3].map((level) =>
dimensions
.map((_, i) => {
const p = radarPoint(i, total, (RADAR_MAX_RADIUS * level) / 3);
return `${p.x},${p.y}`;
})
.join(' '),
);
// 轴线(中心 → 顶点)
const axes = dimensions.map((_, i) => radarPoint(i, total, RADAR_MAX_RADIUS));
// 数据多边形 + 顶点
const dataVertices = dimensions.map((d, i) =>
radarPoint(i, total, (RADAR_MAX_RADIUS * d.value) / 100),
);
const dataPoints = dataVertices.map((p) => `${p.x},${p.y}`).join(' ');
return (
<Svg width={RADAR_SIZE} height={RADAR_SIZE}>
{gridPolygons.map((points, i) => (
<Polygon
key={`grid-${i}`}
points={points}
fill="none"
stroke={colors.borderStrong}
strokeWidth={1}
/>
))}
{axes.map((p, i) => (
<Line
key={`axis-${i}`}
x1={RADAR_CENTER}
y1={RADAR_CENTER}
x2={p.x}
y2={p.y}
stroke={colors.borderStrong}
strokeWidth={1}
/>
))}
<Polygon
points={dataPoints}
fill={colors.primary}
fillOpacity={0.22}
stroke={colors.primary}
strokeWidth={2}
/>
{dataVertices.map((p, i) => (
<Circle key={`dot-${i}`} cx={p.x} cy={p.y} r={3} fill={colors.primary} />
))}
</Svg>
);
}
/* ===================== 配色映射 ===================== */
/** 能力等级 → 文字颜色(与岗位评分标签一致) */
const LEVEL_TEXT_COLOR: Record<JobLevel, string> = {
excellent: colors.primaryDark,
good: colors.primary,
medium: colors.textSecondary,
};
/** AI 工具卡片色调映射 */
const TOOL_STYLE = {
primary: {
bg: colors.primaryLight,
border: colors.primaryBorder,
icon: colors.primary,
action: colors.primaryText,
},
success: {
bg: colors.successLight,
border: colors.successBorder,
icon: colors.success,
action: colors.successText,
},
} as const;
/** 文章缩略图配色映射(复用信息/行动/成功三组语义色) */
const THUMBNAIL_STYLE = {
violet: { bg: colors.infoLight, color: colors.info },
orange: { bg: colors.actionLight, color: colors.action },
green: { bg: colors.successLight, color: colors.success },
} as const;
/* ===================== 页面 ===================== */
/**
* 成长中心页
*/
export default function GrowthScreen() {
const { holland, ability, aiTools, articles, loading, error, refresh } = useGrowthData();
// 错误态
if (error) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.stateWrap}>
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
// 加载态
if (loading || !holland || !ability) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.stateWrap}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* 1. 顶部导航 */}
<View style={styles.header}>
<Text style={styles.headerTitle}>成长中心</Text>
<Pressable style={styles.headerAction} onPress={() => { /* TODO: 设置/更多 */ }}>
<Ionicons name="settings-outline" size={22} color={colors.textPrimary} />
</Pressable>
</View>
{/* 2. 霍兰德职业兴趣测评 Hero Card */}
<LinearGradient
colors={[colors.primary, colors.primaryDark]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.heroCard}
>
<View style={styles.heroIcon}>
<Ionicons name="compass" size={30} color={colors.primary} />
</View>
<View style={styles.heroContent}>
<Text style={styles.heroTitle}>霍兰德职业兴趣测评</Text>
<Text style={styles.heroSubtitle}>发现你的职业性格类型</Text>
<View style={styles.heroBottom}>
<Pressable style={styles.heroButton} onPress={() => { /* TODO: 开始测评 */ }}>
<Text style={styles.heroButtonText}>立即测评</Text>
</Pressable>
<Text style={styles.heroParticipants}>
已有 {formatThousands(holland.totalParticipants)} 人完成
</Text>
</View>
</View>
</LinearGradient>
{/* 3. 四维能力测评 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>四维能力测评</Text>
<Pressable onPress={() => { /* TODO: 查看报告 */ }}>
<Text style={styles.sectionMore}>查看报告 &gt;</Text>
</Pressable>
</View>
<View style={styles.abilityCard}>
<View style={styles.abilityTop}>
<RadarChart dimensions={ability.dimensions} />
<View style={styles.scoreBlock}>
<View style={styles.scoreRow}>
<Text style={styles.scoreNumber}>{ability.overallScore}</Text>
<Text style={styles.scoreUnit}>分</Text>
<View style={styles.levelBadge}>
<Text style={[styles.levelBadgeText, { color: LEVEL_TEXT_COLOR[ability.level] }]}>
{JOB_LEVEL_LABEL[ability.level]}
</Text>
</View>
</View>
<View style={styles.bars}>
{ability.dimensions.map((dim) => (
<View key={dim.label} style={styles.barRow}>
<Text style={styles.barLabel}>{dim.label}</Text>
<View style={styles.barTrack}>
<View style={[styles.barFill, { width: `${dim.value}%` }]} />
</View>
<Text style={styles.barValue}>{dim.value}</Text>
</View>
))}
</View>
</View>
</View>
<Pressable style={styles.retestBtn} onPress={() => { /* TODO: 重新测评 */ }}>
<Text style={styles.retestText}>重新测评</Text>
</Pressable>
</View>
{/* 4. AI 求职工具 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>AI求职工具</Text>
</View>
<View style={styles.toolsRow}>
{aiTools.map((tool) => {
const style = TOOL_STYLE[tool.variant];
return (
<Pressable
key={tool.id}
style={[styles.toolCard, { backgroundColor: style.bg, borderColor: style.border }]}
onPress={() => { /* TODO: 打开工具 */ }}
>
<Ionicons name={tool.icon as IconName} size={28} color={style.icon} />
<Text style={styles.toolTitle}>{tool.title}</Text>
<Text style={styles.toolDesc}>{tool.description}</Text>
<Text style={[styles.toolAction, { color: style.action }]}>{tool.action} →</Text>
</Pressable>
);
})}
</View>
{/* 5. 职场干货 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>职场干货</Text>
<Pressable onPress={() => { /* TODO: 更多 */ }}>
<Text style={styles.sectionMore}>更多 &gt;</Text>
</Pressable>
</View>
{articles.map((article) => {
const thumb = THUMBNAIL_STYLE[article.thumbnailColor];
return (
<Pressable
key={article.id}
style={styles.articleCard}
onPress={() => { /* TODO: 阅读文章 */ }}
>
<View style={[styles.articleThumb, { backgroundColor: thumb.bg }]}>
<Ionicons name={article.icon as IconName} size={26} color={thumb.color} />
</View>
<View style={styles.articleBody}>
<Text style={styles.articleTitle} numberOfLines={2}>
{article.title}
</Text>
<View style={styles.articleMeta}>
<Text style={styles.articleTag}>{article.tag}</Text>
<Text style={styles.articleViews}>{formatViews(article.views)}阅读</Text>
</View>
</View>
</Pressable>
);
})}
</ScrollView>
</SafeAreaView>
);
}
// 跨平台阴影
const shadow = {
shadowColor: colors.textPrimary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 3,
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
stateWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
scroll: {
flex: 1,
},
content: {
paddingHorizontal: 20,
paddingBottom: 24,
},
/* ---- 顶部导航 ---- */
header: {
height: 48,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
headerAction: {
position: 'absolute',
right: 0,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
/* ---- 霍兰德 Hero Card ---- */
heroCard: {
flexDirection: 'row',
marginTop: 8,
borderRadius: 20,
padding: 20,
...shadow,
},
heroIcon: {
width: 52,
height: 52,
borderRadius: 26,
backgroundColor: colors.white,
alignItems: 'center',
justifyContent: 'center',
},
heroContent: {
flex: 1,
marginLeft: 14,
},
heroTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.white,
},
heroSubtitle: {
marginTop: 6,
fontSize: 13,
color: colors.white,
opacity: 0.9,
},
heroBottom: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 16,
},
heroButton: {
borderRadius: 18,
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: colors.white,
},
heroButtonText: {
fontSize: 14,
fontWeight: '600',
color: colors.primaryText,
},
heroParticipants: {
marginLeft: 10,
fontSize: 12,
color: colors.white,
opacity: 0.8,
},
/* ---- 通用分区标题 ---- */
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 24,
marginBottom: 12,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
},
sectionMore: {
fontSize: 13,
color: colors.primary,
},
/* ---- 四维能力测评卡片 ---- */
abilityCard: {
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
...shadow,
},
abilityTop: {
flexDirection: 'row',
alignItems: 'center',
},
scoreBlock: {
flex: 1,
marginLeft: 16,
},
scoreRow: {
flexDirection: 'row',
alignItems: 'center',
},
scoreNumber: {
fontSize: 40,
fontWeight: '700',
color: colors.textPrimary,
},
scoreUnit: {
fontSize: 13,
color: colors.textSecondary,
marginLeft: 4,
marginTop: 16,
},
levelBadge: {
marginLeft: 10,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 8,
backgroundColor: colors.primaryLight,
},
levelBadgeText: {
fontSize: 12,
fontWeight: '600',
},
bars: {
marginTop: 12,
},
barRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
},
barLabel: {
width: 52,
fontSize: 12,
color: colors.textSecondary,
},
barTrack: {
flex: 1,
height: 6,
borderRadius: 3,
backgroundColor: colors.borderLight,
overflow: 'hidden',
},
barFill: {
height: 6,
borderRadius: 3,
backgroundColor: colors.primary,
},
barValue: {
width: 26,
marginLeft: 6,
fontSize: 12,
fontWeight: '600',
color: colors.textPrimary,
textAlign: 'right',
},
retestBtn: {
marginTop: 16,
alignSelf: 'center',
paddingHorizontal: 28,
paddingVertical: 9,
borderRadius: 20,
backgroundColor: colors.primaryLight,
},
retestText: {
fontSize: 14,
fontWeight: '600',
color: colors.primaryText,
},
/* ---- AI 求职工具 ---- */
toolsRow: {
flexDirection: 'row',
gap: 12,
},
toolCard: {
flex: 1,
borderRadius: 16,
padding: 16,
borderWidth: 1,
},
toolTitle: {
marginTop: 12,
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
toolDesc: {
marginTop: 6,
fontSize: 12,
color: colors.textSecondary,
},
toolAction: {
marginTop: 12,
fontSize: 13,
fontWeight: '600',
},
/* ---- 职场干货 ---- */
articleCard: {
flexDirection: 'row',
backgroundColor: colors.white,
borderRadius: 16,
padding: 12,
marginBottom: 12,
...shadow,
},
articleThumb: {
width: 96,
height: 72,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
articleBody: {
flex: 1,
marginLeft: 12,
justifyContent: 'space-between',
},
articleTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
lineHeight: 20,
},
articleMeta: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 8,
},
articleTag: {
fontSize: 11,
color: colors.primaryText,
backgroundColor: colors.primaryLight,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 8,
overflow: 'hidden',
},
articleViews: {
fontSize: 11,
color: colors.textHint,
},
});

528
app/(tabs)/index.tsx

@ -0,0 +1,528 @@
import { Ionicons } from '@expo/vector-icons';
import type { ComponentProps } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useHomeData } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { AssessmentTool, HomeAITool, HotJob } from '../../src/types';
type IconName = ComponentProps<typeof Ionicons>['name'];
/* ===================== 配色映射(展示层,全部使用主题令牌) ===================== */
/** 分类图标配色:按分类 id 映射到语义色家族(浅底 + 图标色) */
const CATEGORY_THEME: Record<string, { bg: string; icon: string }> = {
it: { bg: colors.primaryLight, icon: colors.primary },
finance: { bg: colors.actionLight, icon: colors.actionText },
edu: { bg: colors.successLight, icon: colors.successText },
health: { bg: colors.dangerLight, icon: colors.dangerText },
mfg: { bg: colors.infoLight, icon: colors.infoText },
house: { bg: colors.primaryLight, icon: colors.primaryDark },
retail: { bg: colors.actionLight, icon: colors.action },
media: { bg: colors.infoLight, icon: colors.info },
};
/** 工具卡片配色:测评工具用主色,AI 工具用信息色 */
const TOOL_THEME = {
primary: {
iconBg: colors.primaryLight,
icon: colors.primary,
action: colors.primaryText,
},
info: {
iconBg: colors.infoLight,
icon: colors.info,
action: colors.infoText,
},
} as const;
/* ===================== 子组件 ===================== */
/** 高分优质岗位卡片 */
function HotJobCard({ job }: { job: HotJob }) {
return (
<View style={styles.jobCard}>
<View style={styles.jobCardHeader}>
<Text style={styles.jobTitle} numberOfLines={1}>
{job.title}
</Text>
<View style={styles.scoreTag}>
<Text style={styles.scoreText}>
{job.score}分·{job.scoreLabel}
</Text>
</View>
</View>
<Text style={styles.jobSalary}>{job.salary}</Text>
<Text style={styles.jobMeta}>
{job.company} · {job.location}
</Text>
<Text style={styles.jobMeta}>经验{job.experience}</Text>
<View style={styles.jobTags}>
{job.benefits.map((benefit) => (
<View key={benefit} style={styles.jobTag}>
<Text style={styles.jobTagText}>{benefit}</Text>
</View>
))}
</View>
</View>
);
}
/** 工具卡片(成长测评 / AI 智能工具共用结构) */
function ToolCard({
tool,
variant,
}: {
tool: AssessmentTool | HomeAITool;
variant: keyof typeof TOOL_THEME;
}) {
const theme = TOOL_THEME[variant];
return (
<Pressable
style={styles.toolCard}
onPress={() => {
/* TODO: 打开对应工具 */
}}
>
<View style={[styles.toolIconBg, { backgroundColor: theme.iconBg }]}>
<Ionicons name={tool.icon as IconName} size={22} color={theme.icon} />
</View>
<Text style={styles.toolTitle}>{tool.title}</Text>
<Text style={styles.toolDesc}>{tool.description}</Text>
<Text style={[styles.toolAction, { color: theme.action }]}>{tool.action}</Text>
</Pressable>
);
}
/**
* 首页
*/
export default function HomeScreen() {
const {
user,
categories,
match,
hotJobs,
assessmentTools,
aiTools,
loading,
error,
refresh,
} = useHomeData();
// 错误态
if (error) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.stateWrap}>
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
// 加载态
if (loading || !user || !match) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.stateWrap}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* 1. 问候区域 + 通知入口 */}
<View style={styles.header}>
<View style={styles.headerLeft}>
<Text style={styles.greeting}>早上好,{user.name}</Text>
<Text style={styles.subtitle}>{user.slogan}</Text>
</View>
<Pressable
style={styles.notice}
onPress={() => {
/* TODO: 打开通知列表 */
}}
>
<Ionicons name="notifications-outline" size={22} color={colors.textPrimary} />
<View style={styles.noticeDot} />
</Pressable>
</View>
{/* 2. 搜索框(点击跳转搜索) */}
<Pressable
style={styles.searchBox}
onPress={() => {
/* TODO: 跳转搜索页 */
}}
>
<Ionicons name="search" size={18} color={colors.textSecondary} />
<Text style={styles.searchPlaceholder}>搜索岗位、公司、关键词</Text>
</Pressable>
{/* 3. 分类导航(两行四列网格) */}
<View style={styles.grid}>
{categories.map((cat) => {
const theme = CATEGORY_THEME[cat.id] ?? CATEGORY_THEME.it;
return (
<Pressable
key={cat.id}
style={styles.gridItem}
onPress={() => {
/* TODO: 跳转到对应分类岗位列表 */
}}
>
<View style={[styles.gridIconBg, { backgroundColor: theme.bg }]}>
<Ionicons name={cat.icon as IconName} size={24} color={theme.icon} />
</View>
<Text style={styles.gridLabel}>{cat.name}</Text>
</Pressable>
);
})}
</View>
{/* 4. 匹配度卡片 */}
<View style={styles.matchCard}>
<View style={styles.matchRow}>
<Text style={styles.matchTitle}>您与{match.position}匹配度</Text>
<Text style={styles.matchRate}>{match.matchRate}%</Text>
</View>
<Pressable
onPress={() => {
/* TODO: 立即沟通 */
}}
>
<Text style={styles.matchAction}>{match.subtitle}</Text>
</Pressable>
</View>
{/* 5. 高分优质岗位(横向滑动) */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>高分优质岗位</Text>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.hScroll}
contentContainerStyle={styles.hScrollContent}
>
{hotJobs.map((job) => (
<HotJobCard key={job.id} job={job} />
))}
</ScrollView>
{/* 6. 成长测评工具(两列并排) */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>成长测评工具</Text>
</View>
<View style={styles.toolsRow}>
{assessmentTools.map((tool) => (
<ToolCard key={tool.id} tool={tool} variant="primary" />
))}
</View>
{/* 7. AI 智能工具(两列并排) */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>AI智能工具</Text>
</View>
<View style={styles.toolsRow}>
{aiTools.map((tool) => (
<ToolCard key={tool.id} tool={tool} variant="info" />
))}
</View>
</ScrollView>
</SafeAreaView>
);
}
// 跨平台阴影
const shadow = {
shadowColor: colors.textPrimary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 3,
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
stateWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
scroll: {
flex: 1,
},
content: {
paddingHorizontal: 20,
paddingBottom: 24,
},
/* ---- 问候 + 通知 ---- */
header: {
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
paddingTop: 8,
paddingBottom: 16,
},
headerLeft: {
flex: 1,
},
greeting: {
fontSize: 24,
fontWeight: '700',
color: colors.textPrimary,
},
subtitle: {
marginTop: 8,
fontSize: 12,
color: colors.textSecondary,
},
notice: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.white,
alignItems: 'center',
justifyContent: 'center',
},
noticeDot: {
position: 'absolute',
top: 8,
right: 9,
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.danger,
borderWidth: 1.5,
borderColor: colors.white,
},
/* ---- 搜索框 ---- */
searchBox: {
flexDirection: 'row',
alignItems: 'center',
height: 44,
borderRadius: 12,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 12,
},
searchPlaceholder: {
flex: 1,
marginLeft: 8,
fontSize: 14,
color: colors.textHint,
},
/* ---- 分类网格 ---- */
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 20,
},
gridItem: {
width: '25%',
alignItems: 'center',
marginBottom: 20,
},
gridIconBg: {
width: 52,
height: 52,
borderRadius: 26,
alignItems: 'center',
justifyContent: 'center',
},
gridLabel: {
marginTop: 8,
fontSize: 12,
color: colors.textPrimary,
textAlign: 'center',
},
/* ---- 匹配度卡片 ---- */
matchCard: {
marginTop: 4,
borderRadius: 16,
padding: 16,
backgroundColor: colors.primaryLight,
},
matchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
matchTitle: {
flex: 1,
marginRight: 12,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
},
matchRate: {
fontSize: 28,
fontWeight: '700',
color: colors.primary,
},
matchAction: {
marginTop: 12,
fontSize: 13,
fontWeight: '600',
color: colors.primaryText,
},
/* ---- 分区标题 ---- */
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 24,
marginBottom: 12,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
},
/* ---- 横向滚动 ---- */
hScroll: {
flexGrow: 0,
marginHorizontal: -20,
},
hScrollContent: {
paddingHorizontal: 20,
paddingBottom: 4,
gap: 12,
},
/* ---- 高分岗位卡片 ---- */
jobCard: {
width: 240,
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
...shadow,
},
jobCardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
jobTitle: {
flex: 1,
marginRight: 8,
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
scoreTag: {
backgroundColor: colors.primaryLight,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
scoreText: {
fontSize: 12,
fontWeight: '600',
color: colors.primaryDark,
},
jobSalary: {
marginTop: 12,
fontSize: 18,
fontWeight: '700',
color: colors.primary,
},
jobMeta: {
marginTop: 8,
fontSize: 13,
color: colors.textSecondary,
},
jobTags: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 12,
},
jobTag: {
backgroundColor: colors.primaryLight,
borderWidth: 1,
borderColor: colors.primaryBorder,
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 4,
marginRight: 8,
marginBottom: 8,
},
jobTagText: {
fontSize: 12,
color: colors.primary,
},
/* ---- 工具卡片 ---- */
toolsRow: {
flexDirection: 'row',
gap: 12,
},
toolCard: {
flex: 1,
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
...shadow,
},
toolIconBg: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
},
toolTitle: {
marginTop: 12,
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
toolDesc: {
marginTop: 6,
fontSize: 12,
color: colors.textSecondary,
},
toolAction: {
marginTop: 12,
fontSize: 13,
fontWeight: '600',
},
});

691
app/(tabs)/jobs.tsx

@ -0,0 +1,691 @@
import { Ionicons } from '@expo/vector-icons';
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 { useJobList } from '../../src/hooks';
import {
CITIES,
EDUCATION_OPTIONS,
EXPERIENCE_OPTIONS,
JOB_LEVEL_LABEL,
SALARY_RANGES,
} from '../../src/constants';
import type { Job, SortType } from '../../src/types';
import { colors } from '../../src/theme';
/** 筛选项(下拉占位) */
const FILTERS = ['全部行业', '薪资', '经验', '更多筛选'];
/** 排序选项 */
const SORTS: { key: SortType; label: string }[] = [
{ key: 'comprehensive', label: '综合排序' },
{ key: 'latest', label: '最新发布' },
{ key: 'salary', label: '薪资最高' },
];
/** 等级 → 文字颜色 */
const LEVEL_COLOR = {
excellent: colors.primaryDark,
good: colors.primary,
medium: colors.textSecondary,
} as const;
/** 更多筛选的三组字段 */
type FilterKey = 'salary' | 'experience' | 'education';
/** 切换某个筛选值(多选:已在则移除,否则加入) */
function toggleValue(
draft: Record<FilterKey, string[]>,
key: FilterKey,
value: string,
): Record<FilterKey, string[]> {
const current = draft[key];
const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value];
return { ...draft, [key]: next };
}
/** 清空某一组筛选 */
function clearGroup(draft: Record<FilterKey, string[]>, key: FilterKey): Record<FilterKey, string[]> {
return { ...draft, [key]: [] };
}
/** 筛选面板中的一组选项(「不限」为清空态) */
function FilterGroup({
title,
options,
selected,
onToggle,
onClear,
}: {
title: string;
options: string[];
selected: string[];
onToggle: (value: string) => void;
onClear: () => void;
}) {
return (
<>
<Text style={styles.filterLabel}>{title}</Text>
<View style={styles.optionRow}>
{options.map((option) => {
const isNone = option === '不限';
const active = isNone ? selected.length === 0 : selected.includes(option);
return (
<Pressable
key={option}
style={[styles.optionChip, active && styles.optionChipActive]}
onPress={() => (isNone ? onClear() : onToggle(option))}
>
<Text style={[styles.optionText, active && styles.optionTextActive]}>{option}</Text>
</Pressable>
);
})}
</View>
</>
);
}
/** 单张岗位卡片 */
function JobCard({ job }: { job: Job }) {
return (
<View style={styles.card}>
{/* 公司名 + 地点 */}
<View style={styles.cardHeader}>
<Text style={styles.companyName}>{job.companyName}</Text>
<Text style={styles.companyLocation}>{job.companyLocation}</Text>
</View>
{/* 岗位名 + 薪资 */}
<View style={styles.positionRow}>
<Text style={styles.positionName}>{job.positionName}</Text>
<Text style={styles.salary}>
{job.salaryMin}-{job.salaryMax}K
</Text>
</View>
{/* 评分标签 + 查看详情 */}
<View style={styles.scoreRow}>
<Text style={[styles.scoreText, { color: LEVEL_COLOR[job.level] }]}>
{job.score}分 · {JOB_LEVEL_LABEL[job.level]}
</Text>
<Pressable
onPress={() => {
console.log('查看详情', job.id, job.positionName);
}}
>
<Text style={styles.detailLink}>查看详情 &gt;</Text>
</Pressable>
</View>
{/* 福利标签 */}
<View style={styles.benefits}>
{[job.experience, job.education, ...job.benefits].map((item) => (
<View key={item} style={styles.benefitTag}>
<Text style={styles.benefitText}>{item}</Text>
</View>
))}
</View>
</View>
);
}
/**
* 岗位列表页
*/
export default function JobsScreen() {
// 定位城市(点击可切换)
const [city, setCity] = useState('北京');
const [cityVisible, setCityVisible] = useState(false);
const [keyword, setKeyword] = useState('');
const [sort, setSort] = useState<SortType>('comprehensive');
// 更多筛选:已应用的筛选条件(多选,空数组 = 不限)
const [filters, setFilters] = useState<Record<FilterKey, string[]>>({
salary: [],
experience: [],
education: [],
});
// 筛选面板内的临时选择(点「确定」后才生效)
const [draft, setDraft] = useState<Record<FilterKey, string[]>>(filters);
const [filterVisible, setFilterVisible] = useState(false);
const { jobs, total, loading, error, refresh, isEmpty } = useJobList({
sortBy: sort,
city,
salary: filters.salary,
experience: filters.experience,
education: filters.education,
});
// 已勾选的筛选项总数(用于「更多筛选」角标)
const activeCount =
filters.salary.length + filters.experience.length + filters.education.length;
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
{/* 顶部导航:定位城市 + 标题 */}
<View style={styles.nav}>
<View style={styles.navSide}>
<Pressable style={styles.locationBtn} onPress={() => setCityVisible(true)}>
<Ionicons name="location-outline" size={18} color={colors.textPrimary} />
<Text style={styles.locationText}>{city}</Text>
<Ionicons name="chevron-down" size={14} color={colors.textSecondary} />
</Pressable>
</View>
<Text style={styles.navTitle}>找工作</Text>
<View style={styles.navSide} />
</View>
{/* 搜索框 */}
<View style={styles.searchBox}>
<Ionicons name="search" size={18} color={colors.textSecondary} />
<TextInput
style={styles.searchInput}
placeholder="搜索职位、公司、关键词"
placeholderTextColor={colors.textSecondary}
value={keyword}
onChangeText={setKeyword}
/>
</View>
{/* 筛选条件栏(横向滚动) */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.filterBar}
contentContainerStyle={styles.filterContent}
>
{FILTERS.map((filter) => (
<Pressable
key={filter}
style={styles.filterChip}
onPress={() => {
if (filter === '更多筛选') {
setDraft(filters);
setFilterVisible(true);
}
/* 其余筛选(行业/城市/薪资/经验)留待后续接入 */
}}
>
<Text style={styles.filterText}>{filter}</Text>
<Ionicons
name={filter === '更多筛选' ? 'options-outline' : 'chevron-down'}
size={14}
color={colors.textSecondary}
/>
{filter === '更多筛选' && activeCount > 0 && (
<View style={styles.filterBadge}>
<Text style={styles.filterBadgeText}>{activeCount}</Text>
</View>
)}
</Pressable>
))}
</ScrollView>
{/* 结果统计 + 排序 */}
<View style={styles.resultRow}>
<Text style={styles.resultCount}>共找到 {total} 个岗位</Text>
<View style={styles.sortRow}>
{SORTS.map((option) => (
<Pressable key={option.key} onPress={() => setSort(option.key)}>
<Text style={[styles.sortText, sort === option.key && styles.sortTextActive]}>
{option.label}
</Text>
</Pressable>
))}
</View>
</View>
{/* 岗位列表:统一处理加载 / 错误 / 空 / 成功四态 */}
{loading ? (
<View style={styles.stateBox}>
<ActivityIndicator size="large" color={colors.textSecondary} />
</View>
) : error ? (
<View style={styles.stateBox}>
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
) : isEmpty ? (
<View style={styles.stateBox}>
<Ionicons name="file-tray-outline" size={48} color={colors.textSecondary} />
<Text style={styles.stateText}>暂无相关岗位</Text>
</View>
) : (
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
>
{jobs.map((job) => (
<JobCard key={job.id} job={job} />
))}
</ScrollView>
)}
{/* 更多筛选面板(底部弹层) */}
<Modal
visible={filterVisible}
transparent
animationType="slide"
onRequestClose={() => setFilterVisible(false)}
>
<Pressable style={styles.modalOverlay} onPress={() => setFilterVisible(false)}>
<Pressable style={styles.filterPanel} onPress={() => {}}>
<View style={styles.panelHeader}>
<Text style={styles.panelTitle}>更多筛选</Text>
<Pressable onPress={() => setFilterVisible(false)}>
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
{/* 薪资范围 */}
<FilterGroup
title="薪资范围"
options={SALARY_RANGES.map((range) => range.label)}
selected={draft.salary}
onToggle={(value) => setDraft((d) => toggleValue(d, 'salary', value))}
onClear={() => setDraft((d) => clearGroup(d, 'salary'))}
/>
{/* 经验要求 */}
<FilterGroup
title="经验要求"
options={EXPERIENCE_OPTIONS}
selected={draft.experience}
onToggle={(value) => setDraft((d) => toggleValue(d, 'experience', value))}
onClear={() => setDraft((d) => clearGroup(d, 'experience'))}
/>
{/* 学历要求 */}
<FilterGroup
title="学历要求"
options={EDUCATION_OPTIONS}
selected={draft.education}
onToggle={(value) => setDraft((d) => toggleValue(d, 'education', value))}
onClear={() => setDraft((d) => clearGroup(d, 'education'))}
/>
{/* 底部操作 */}
<View style={styles.panelActions}>
<Pressable
style={styles.resetBtn}
onPress={() => setDraft({ salary: [], experience: [], education: [] })}
>
<Text style={styles.resetText}>重置</Text>
</Pressable>
<Pressable
style={styles.confirmBtn}
onPress={() => {
setFilters(draft);
setFilterVisible(false);
}}
>
<Text style={styles.confirmText}>确定</Text>
</Pressable>
</View>
</Pressable>
</Pressable>
</Modal>
{/* 城市选择弹层(底部弹层) */}
<Modal
visible={cityVisible}
transparent
animationType="slide"
onRequestClose={() => setCityVisible(false)}
>
<Pressable style={styles.modalOverlay} onPress={() => setCityVisible(false)}>
<Pressable style={styles.filterPanel} onPress={() => {}}>
<View style={styles.panelHeader}>
<Text style={styles.panelTitle}>选择城市</Text>
<Pressable onPress={() => setCityVisible(false)}>
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
<View style={styles.cityGrid}>
{CITIES.map((item) => (
<Pressable
key={item}
style={[styles.optionChip, city === item && styles.optionChipActive]}
onPress={() => {
setCity(item);
setCityVisible(false);
}}
>
<Text style={[styles.optionText, city === item && styles.optionTextActive]}>
{item}
</Text>
</Pressable>
))}
</View>
</Pressable>
</Pressable>
</Modal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
nav: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 20,
paddingTop: 8,
paddingBottom: 8,
},
navSide: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
locationBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
locationText: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
},
navTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
},
searchBox: {
flexDirection: 'row',
alignItems: 'center',
height: 48,
borderRadius: 12,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 12,
marginHorizontal: 20,
marginTop: 4,
},
searchInput: {
flex: 1,
marginLeft: 8,
fontSize: 14,
color: colors.textPrimary,
},
filterBar: {
flexGrow: 0,
height: 32,
marginTop: 12,
},
filterContent: {
paddingHorizontal: 20,
gap: 12,
},
filterChip: {
flexDirection: 'row',
alignItems: 'center',
height: 32,
paddingHorizontal: 12,
borderRadius: 16,
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.border,
},
filterText: {
marginRight: 4,
fontSize: 13,
color: colors.textPrimary,
},
filterBadge: {
marginLeft: 4,
minWidth: 16,
height: 16,
borderRadius: 8,
paddingHorizontal: 4,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.textPrimary,
},
filterBadgeText: {
fontSize: 10,
fontWeight: '700',
color: colors.white,
},
resultRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
marginTop: 16,
marginBottom: 12,
},
resultCount: {
fontSize: 12,
color: colors.textSecondary,
},
sortRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
sortText: {
fontSize: 13,
color: colors.textSecondary,
},
sortTextActive: {
color: colors.textPrimary,
fontWeight: '700',
},
stateBox: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
list: {
flex: 1,
},
listContent: {
paddingHorizontal: 20,
paddingBottom: 24,
},
card: {
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
marginBottom: 16,
shadowColor: colors.textPrimary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 3,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
companyName: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
companyLocation: {
fontSize: 12,
color: colors.textSecondary,
},
positionRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 8,
},
positionName: {
fontSize: 15,
fontWeight: '700',
color: colors.textPrimary,
},
salary: {
fontSize: 15,
fontWeight: '700',
color: colors.primary,
},
scoreRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 10,
},
scoreText: {
fontSize: 13,
fontWeight: '600',
},
detailLink: {
fontSize: 13,
color: colors.primary,
},
benefits: {
flexDirection: 'row',
flexWrap: 'wrap',
marginTop: 12,
},
benefitTag: {
backgroundColor: colors.primaryLight,
borderWidth: 1,
borderColor: colors.primaryBorder,
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 4,
marginRight: 8,
marginBottom: 8,
},
benefitText: {
fontSize: 12,
color: colors.primary,
},
/* ---- 更多筛选面板 ---- */
modalOverlay: {
flex: 1,
backgroundColor: colors.scrim,
justifyContent: 'flex-end',
},
filterPanel: {
backgroundColor: colors.white,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingHorizontal: 20,
paddingTop: 16,
paddingBottom: 28,
},
panelHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 4,
},
panelTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
filterLabel: {
marginTop: 16,
marginBottom: 10,
fontSize: 14,
fontWeight: '600',
color: colors.textPrimary,
},
optionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
cityGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
marginTop: 16,
},
optionChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.pageBg,
borderWidth: 1,
borderColor: colors.border,
},
optionChipActive: {
backgroundColor: colors.surface,
borderColor: colors.borderStrong,
},
optionText: {
fontSize: 13,
color: colors.textPrimary,
},
optionTextActive: {
color: colors.textPrimary,
fontWeight: '700',
},
panelActions: {
flexDirection: 'row',
gap: 12,
marginTop: 24,
},
resetBtn: {
flex: 1,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.pageBg,
borderWidth: 1,
borderColor: colors.border,
},
resetText: {
fontSize: 15,
fontWeight: '600',
color: colors.textSecondary,
},
confirmBtn: {
flex: 1,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.primaryBtn,
},
confirmText: {
fontSize: 15,
fontWeight: '600',
color: colors.white,
},
});

468
app/(tabs)/profile.tsx

@ -0,0 +1,468 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useProfileData } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { Badge, DimensionScore, JobPreference } from '../../src/types';
/* ===================== 展示层映射(全部使用主题令牌) ===================== */
/** 维度等级 → 展示配置(标签文案 + 配色) */
const DIMENSION_LEVEL: Record<
DimensionScore['level'],
{ label: string; bg: string; text: string }
> = {
excellent: { label: '优秀', bg: colors.successLight, text: colors.successText },
good: { label: '良好', bg: colors.primaryLight, text: colors.primaryText },
};
/** 求职偏好字段 → 展示标签 */
const PREF_ITEMS: { key: keyof JobPreference; label: string }[] = [
{ key: 'position', label: '期望职位' },
{ key: 'city', label: '期望城市' },
{ key: 'salary', label: '期望薪资' },
{ key: 'workType', label: '工作类型' },
{ key: 'status', label: '求职状态' },
];
/* ===================== 子组件 ===================== */
/** 单个维度评分项 */
function DimensionItem({ dimension }: { dimension: DimensionScore }) {
const theme = DIMENSION_LEVEL[dimension.level];
return (
<View style={styles.dimItem}>
<Text style={styles.dimName}>{dimension.name}</Text>
<View style={styles.dimScoreRow}>
<Text style={styles.dimScore}>{dimension.score}</Text>
<View style={[styles.levelTag, { backgroundColor: theme.bg }]}>
<Text style={[styles.levelTagText, { color: theme.text }]}>{theme.label}</Text>
</View>
</View>
</View>
);
}
/** 单个勋章卡片 */
function BadgeCard({ badge }: { badge: Badge }) {
return (
<View style={styles.badgeCard}>
<Text style={styles.badgeIcon}>{badge.icon}</Text>
<Text style={styles.badgeTitle}>{badge.title}</Text>
<Text style={styles.badgeDesc}>{badge.description}</Text>
</View>
);
}
/**
* 我的页
*/
export default function ProfileScreen() {
const { profile, overallScore, dimensions, preference, badges, loading, error, refresh } =
useProfileData();
const router = useRouter();
// 错误态
if (error) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.stateWrap}>
<Text style={styles.stateText}>加载失败,请重试</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView>
);
}
// 加载态
if (loading || !profile || !overallScore || !preference) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<View style={styles.stateWrap}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* 1. 顶部导航 */}
<View style={styles.header}>
<Text style={styles.headerTitle}>我的</Text>
<Pressable style={styles.headerAction} onPress={() => router.push('/settings')}>
<Ionicons name="settings-outline" size={22} color={colors.textPrimary} />
</Pressable>
</View>
{/* 2. 用户信息 */}
<View style={styles.profileCard}>
<View style={styles.profileTop}>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{profile.avatar}</Text>
</View>
<Text style={styles.userName}>{profile.name}</Text>
</View>
<Text style={styles.bio}>{profile.bio}</Text>
</View>
{/* 3. 综合评分卡片 */}
<View style={styles.scoreCard}>
<Text style={styles.scoreValue}>
{overallScore.score}
<Text style={styles.scoreUnit}>分</Text>
</Text>
<View style={styles.scoreTag}>
<Text style={styles.scoreTagText}>{overallScore.level}</Text>
</View>
<View style={styles.scoreRight}>
<Text style={styles.scoreRank}>{overallScore.rank}</Text>
<Pressable onPress={() => console.log('评分申诉')}>
<Text style={styles.scoreAppeal}>评分申诉</Text>
</Pressable>
</View>
</View>
{/* 4. 八大维度评分 */}
<Text style={[styles.sectionTitle, styles.sectionGap]}>八大维度评分</Text>
<View style={styles.dimGrid}>
{dimensions.map((dimension) => (
<DimensionItem key={dimension.id} dimension={dimension} />
))}
</View>
{/* 5. 求职偏好 */}
<Text style={[styles.sectionTitle, styles.sectionGap]}>求职偏好</Text>
<View style={styles.prefCard}>
{PREF_ITEMS.map(({ key, label }, index) => {
const value = preference[key];
const isLast = index === PREF_ITEMS.length - 1;
return (
<View key={key} style={[styles.prefRow, isLast && styles.prefRowLast]}>
<Text style={styles.prefLabel}>{label}</Text>
{key === 'status' ? (
<View style={styles.prefStatusTag}>
<Text style={styles.prefStatusText}>{value}</Text>
</View>
) : (
<Text style={styles.prefValue}>{value}</Text>
)}
</View>
);
})}
</View>
{/* 6. 全部勋章 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>全部勋章</Text>
<Pressable onPress={() => console.log('查看全部勋章')}>
<Text style={styles.sectionMore}>&gt;</Text>
</Pressable>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.hScroll}
contentContainerStyle={styles.hScrollContent}
>
{badges.map((badge) => (
<BadgeCard key={badge.id} badge={badge} />
))}
</ScrollView>
</ScrollView>
</SafeAreaView>
);
}
// 跨平台阴影
const shadow = {
shadowColor: colors.textPrimary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 3,
};
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
stateWrap: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
stateText: {
marginTop: 12,
fontSize: 14,
color: colors.textSecondary,
},
retryBtn: {
marginTop: 16,
paddingHorizontal: 24,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryBtn,
},
retryText: {
fontSize: 13,
fontWeight: '600',
color: colors.white,
},
scroll: {
flex: 1,
},
content: {
paddingHorizontal: 20,
paddingBottom: 24,
},
/* ---- 顶部导航 ---- */
header: {
height: 48,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
headerAction: {
position: 'absolute',
right: 0,
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
/* ---- 用户信息 ---- */
profileCard: {
marginTop: 8,
backgroundColor: colors.white,
borderRadius: 16,
padding: 20,
...shadow,
},
profileTop: {
flexDirection: 'row',
alignItems: 'center',
},
avatar: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
avatarText: {
fontSize: 24,
fontWeight: '700',
color: colors.white,
},
userName: {
marginLeft: 14,
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary,
},
bio: {
marginTop: 14,
fontSize: 13,
color: colors.textSecondary,
},
/* ---- 综合评分卡片 ---- */
scoreCard: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 12,
backgroundColor: colors.white,
borderRadius: 16,
padding: 20,
...shadow,
},
scoreValue: {
fontSize: 40,
fontWeight: '700',
color: colors.primary,
},
scoreUnit: {
fontSize: 16,
fontWeight: '600',
color: colors.primary,
},
scoreTag: {
marginLeft: 12,
backgroundColor: colors.successLight,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
scoreTagText: {
fontSize: 13,
fontWeight: '600',
color: colors.successText,
},
scoreRight: {
flex: 1,
alignItems: 'flex-end',
marginLeft: 12,
},
scoreRank: {
fontSize: 13,
color: colors.textSecondary,
},
scoreAppeal: {
marginTop: 8,
fontSize: 13,
color: colors.primary,
},
/* ---- 分区标题 ---- */
sectionTitle: {
fontSize: 18,
fontWeight: '700',
color: colors.textPrimary,
},
sectionGap: {
marginTop: 24,
marginBottom: 12,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 24,
marginBottom: 12,
},
sectionMore: {
fontSize: 18,
fontWeight: '700',
color: colors.textSecondary,
},
/* ---- 八大维度 ---- */
dimGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
dimItem: {
width: '48.5%',
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
marginBottom: 12,
...shadow,
},
dimName: {
fontSize: 13,
color: colors.textSecondary,
},
dimScoreRow: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
},
dimScore: {
fontSize: 24,
fontWeight: '700',
color: colors.textPrimary,
},
levelTag: {
marginLeft: 8,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
},
levelTagText: {
fontSize: 12,
fontWeight: '600',
},
/* ---- 求职偏好 ---- */
prefCard: {
backgroundColor: colors.white,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 6,
...shadow,
},
prefRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
prefRowLast: {
borderBottomWidth: 0,
},
prefLabel: {
fontSize: 14,
color: colors.textSecondary,
},
prefValue: {
fontSize: 14,
fontWeight: '600',
color: colors.textPrimary,
},
prefStatusTag: {
backgroundColor: colors.successLight,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
},
prefStatusText: {
fontSize: 13,
fontWeight: '600',
color: colors.successText,
},
/* ---- 全部勋章 ---- */
hScroll: {
flexGrow: 0,
marginHorizontal: -20,
},
hScrollContent: {
paddingHorizontal: 20,
gap: 12,
},
badgeCard: {
width: 140,
backgroundColor: colors.white,
borderRadius: 16,
padding: 16,
alignItems: 'center',
...shadow,
},
badgeIcon: {
fontSize: 32,
},
badgeTitle: {
marginTop: 10,
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
},
badgeDesc: {
marginTop: 4,
fontSize: 12,
color: colors.textSecondary,
textAlign: 'center',
},
});

21
app/_layout.tsx

@ -0,0 +1,21 @@
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
/**
* 根布局:用 Stack 包裹整个应用,首页 Tab 组不显示顶部 header
*/
export default function RootLayout() {
return (
<>
<StatusBar style="dark" />
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ title: '设置' }} />
<Stack.Screen name="resume" options={{ title: '我的简历' }} />
<Stack.Screen name="favorites" options={{ title: '我的收藏' }} />
<Stack.Screen name="history" options={{ title: '浏览历史' }} />
<Stack.Screen name="assessment" options={{ title: '我的测评' }} />
</Stack>
</>
);
}

36
app/assessment.tsx

@ -0,0 +1,36 @@
import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../src/theme';
/**
* 我的测评页(占位)
*/
export default function AssessmentScreen() {
return (
<View style={styles.container}>
<Ionicons name="bar-chart-outline" size={48} color={colors.textSecondary} />
<Text style={styles.title}>我的测评</Text>
<Text style={styles.subtitle}>测评页面开发中,敬请期待</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.pageBg,
alignItems: 'center',
justifyContent: 'center',
},
title: {
marginTop: 12,
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary,
},
subtitle: {
marginTop: 8,
fontSize: 13,
color: colors.textSecondary,
},
});

36
app/favorites.tsx

@ -0,0 +1,36 @@
import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../src/theme';
/**
* 我的收藏页(占位)
*/
export default function FavoritesScreen() {
return (
<View style={styles.container}>
<Ionicons name="heart-outline" size={48} color={colors.textSecondary} />
<Text style={styles.title}>我的收藏</Text>
<Text style={styles.subtitle}>收藏页面开发中,敬请期待</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.pageBg,
alignItems: 'center',
justifyContent: 'center',
},
title: {
marginTop: 12,
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary,
},
subtitle: {
marginTop: 8,
fontSize: 13,
color: colors.textSecondary,
},
});

36
app/history.tsx

@ -0,0 +1,36 @@
import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../src/theme';
/**
* 浏览历史页(占位)
*/
export default function HistoryScreen() {
return (
<View style={styles.container}>
<Ionicons name="time-outline" size={48} color={colors.textSecondary} />
<Text style={styles.title}>浏览历史</Text>
<Text style={styles.subtitle}>浏览历史页面开发中,敬请期待</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.pageBg,
alignItems: 'center',
justifyContent: 'center',
},
title: {
marginTop: 12,
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary,
},
subtitle: {
marginTop: 8,
fontSize: 13,
color: colors.textSecondary,
},
});

36
app/resume.tsx

@ -0,0 +1,36 @@
import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../src/theme';
/**
* 我的简历页(占位)
*/
export default function ResumeScreen() {
return (
<View style={styles.container}>
<Ionicons name="document-text-outline" size={48} color={colors.textSecondary} />
<Text style={styles.title}>我的简历</Text>
<Text style={styles.subtitle}>简历页面开发中,敬请期待</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.pageBg,
alignItems: 'center',
justifyContent: 'center',
},
title: {
marginTop: 12,
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary,
},
subtitle: {
marginTop: 8,
fontSize: 13,
color: colors.textSecondary,
},
});

36
app/settings.tsx

@ -0,0 +1,36 @@
import { Ionicons } from '@expo/vector-icons';
import { StyleSheet, Text, View } from 'react-native';
import { colors } from '../src/theme';
/**
* 设置页(占位)
*/
export default function SettingsScreen() {
return (
<View style={styles.container}>
<Ionicons name="settings-outline" size={48} color={colors.textSecondary} />
<Text style={styles.title}>设置</Text>
<Text style={styles.subtitle}>设置页面开发中,敬请期待</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.pageBg,
alignItems: 'center',
justifyContent: 'center',
},
title: {
marginTop: 12,
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary,
},
subtitle: {
marginTop: 8,
fontSize: 13,
color: colors.textSecondary,
},
});

8
index.js

@ -1,8 +0,0 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

1778
package-lock.json

File diff suppressed because it is too large

19
package.json

@ -1,12 +1,21 @@
{
"name": "mydreamjob",
"version": "1.0.0",
"main": "index.js",
"main": "expo-router/entry",
"dependencies": {
"@expo/vector-icons": "^15.0.2",
"expo": "~57.0.14",
"expo-constants": "~57.0.12",
"expo-font": "~57.0.1",
"expo-linear-gradient": "~57.0.1",
"expo-linking": "~57.0.6",
"expo-router": "~57.0.14",
"expo-status-bar": "~57.0.1",
"react": "19.2.3",
"react-native": "0.86.2"
"react-native": "0.86.2",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-svg": "15.15.4"
},
"scripts": {
"start": "expo start",
@ -14,5 +23,9 @@
"ios": "expo start --ios",
"web": "expo start --web"
},
"private": true
"private": true,
"devDependencies": {
"@types/react": "~19.2.4",
"typescript": "~6.0.3"
}
}

876
src/api/api.ts

@ -0,0 +1,876 @@
/**
* 接口层:所有数据请求统一从这里发起。
*
* 约定:
* - 每个函数用 JSDoc 标注请求方式(@method)、URL(@url)、参数(@param)、返回值(@returns)
* - 所有 mock 数据统一在本文件管理;真实后端就绪后,仅替换函数体为 fetch 即可
* - 业务规则计算(等级映射等)与排序/筛选/搜索/分页在本层完成
*/
import Constants from 'expo-constants';
import type {
AbilityAssessment,
AITool,
Article,
AssessmentTool,
Badge,
Category,
Conversation,
DimensionScore,
EnterpriseEntry,
HollandAssessment,
HomeAITool,
HotJob,
Interview,
Job,
JobLevel,
JobListParams,
JobPreference,
MatchData,
MatchResult,
MenuItem,
Notification,
OverallScore,
UserInfo,
UserProfile,
UserStats,
} from '../types';
import { MOCK_DELAY, SALARY_RANGES } from '../constants';
/** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */
export const API_BASE_URL: string =
Constants.expoConfig?.extra?.apiBaseUrl ?? 'https://dev.api.com';
/* ===================== 模拟数据(真实后端就绪后删除) ===================== */
/** 岗位分类(8 个,首页两行四列网格) */
const MOCK_CATEGORIES: Category[] = [
{ id: 'it', name: '互联网/IT', icon: 'laptop-outline' },
{ id: 'finance', name: '金融', icon: 'cash-outline' },
{ id: 'edu', name: '教育', icon: 'school-outline' },
{ id: 'health', name: '医疗健康', icon: 'medkit-outline' },
{ id: 'mfg', name: '制造业', icon: 'construct-outline' },
{ id: 'house', name: '房地产', icon: 'home-outline' },
{ id: 'retail', name: '零售', icon: 'cart-outline' },
{ id: 'media', name: '文化传媒', icon: 'film-outline' },
];
/** 岗位列表(原始数据,不含 level;level 由接口层按分数计算) */
const MOCK_JOBS: Omit<Job, 'level'>[] = [
{
id: '1',
companyName: '字节跳动',
companyLocation: '北京 · 海淀区',
positionName: '高级前端工程师',
salaryMin: 25,
salaryMax: 40,
score: 92,
experience: '3-5年',
education: '本科',
industry: '互联网/IT',
city: '北京',
benefits: ['五险一金', '年终奖金', '弹性工作'],
},
{
id: '2',
companyName: '腾讯科技',
companyLocation: '深圳 · 南山区',
positionName: '产品经理',
salaryMin: 20,
salaryMax: 35,
score: 78,
experience: '3-5年',
education: '本科',
industry: '互联网/IT',
city: '深圳',
benefits: ['五险一金', '年终奖金', '弹性工作'],
},
{
id: '3',
companyName: '阿里巴巴',
companyLocation: '杭州 · 余杭区',
positionName: 'UI设计师',
salaryMin: 15,
salaryMax: 25,
score: 85,
experience: '3-5年',
education: '本科',
industry: '互联网/IT',
city: '杭州',
benefits: ['五险一金', '年终奖金', '弹性工作'],
},
{
id: '4',
companyName: '美团',
companyLocation: '北京 · 朝阳区',
positionName: 'Java开发工程师',
salaryMin: 18,
salaryMax: 30,
score: 65,
experience: '3-5年',
education: '本科',
industry: '互联网/IT',
city: '北京',
benefits: ['五险一金', '年终奖金', '弹性工作'],
},
{
id: '5',
companyName: '字节跳动',
companyLocation: '上海 · 浦东新区',
positionName: '前端实习生',
salaryMin: 6,
salaryMax: 9,
score: 60,
experience: '应届生',
education: '本科',
industry: '互联网/IT',
city: '上海',
benefits: ['五险一金', '弹性工作'],
},
{
id: '6',
companyName: '招商银行',
companyLocation: '深圳 · 福田区',
positionName: '金融分析师',
salaryMin: 30,
salaryMax: 50,
score: 88,
experience: '5-10年',
education: '硕士',
industry: '金融',
city: '深圳',
benefits: ['五险一金', '年终奖金', '股权激励'],
},
{
id: '7',
companyName: '新东方',
companyLocation: '北京 · 朝阳区',
positionName: '课程顾问',
salaryMin: 12,
salaryMax: 20,
score: 72,
experience: '1-3年',
education: '大专',
industry: '教育',
city: '北京',
benefits: ['五险一金', '带薪年假'],
},
{
id: '8',
companyName: '华润医疗',
companyLocation: '广州 · 天河区',
positionName: '医学代表',
salaryMin: 15,
salaryMax: 25,
score: 75,
experience: '3-5年',
education: '本科',
industry: '医疗健康',
city: '广州',
benefits: ['五险一金', '交通补助'],
},
{
id: '9',
companyName: '宁德时代',
companyLocation: '宁德 · 东侨区',
positionName: '电池工程师',
salaryMin: 28,
salaryMax: 45,
score: 90,
experience: '5-10年',
education: '硕士',
industry: '制造业',
city: '宁德',
benefits: ['五险一金', '年终奖金', '股权激励'],
},
];
/** 岗位匹配度 */
const MOCK_MATCH_DATA: MatchData = {
position: '高级前端工程师',
matchRate: 95,
subtitle: '双向匹配成功·立即沟通',
};
/** 高分优质岗位(首页横向卡片;薪资/评分文案已由接口层格式化) */
const MOCK_HOT_JOBS: HotJob[] = [
{
id: 'hot-1',
title: '高级前端工程师',
score: 92,
scoreLabel: '优秀',
salary: '25-40K·15薪',
company: '字节跳动',
location: '北京',
experience: '3-5年',
benefits: ['React', 'TypeScript'],
},
{
id: 'hot-2',
title: '产品经理',
score: 90,
scoreLabel: '优秀',
salary: '20-35K·14薪',
company: '阿里巴巴',
location: '杭州',
experience: '3-5年',
benefits: ['B端产品', 'SaaS'],
},
{
id: 'hot-3',
title: 'UI/UX设计师',
score: 85,
scoreLabel: '优秀',
salary: '18-30K·13薪',
company: '腾讯',
location: '深圳',
experience: '3-5年',
benefits: ['弹性工作', 'Figma·交互设计'],
},
];
/** 成长测评工具(首页横向卡片) */
const MOCK_ASSESSMENT_TOOLS: AssessmentTool[] = [
{
id: 'assess-holland',
title: '霍兰德职业兴趣测评',
description: '发现你的职业兴趣方向',
icon: 'compass-outline',
action: '立即测评 →',
},
{
id: 'assess-ability',
title: '四维能力测评',
description: '全方位评估你的职场能力',
icon: 'bar-chart-outline',
action: '立即测评 →',
},
];
/** AI 智能工具(首页横向卡片) */
const MOCK_HOME_AI_TOOLS: HomeAITool[] = [
{
id: 'home-ai-resume',
title: 'AI简历优化',
description: '智能提升简历竞争力',
icon: 'document-text-outline',
action: '立即测评 →',
},
{
id: 'home-ai-interview',
title: '模拟面试',
description: 'AI面试官真实场景演练',
icon: 'chatbubble-ellipses-outline',
action: '立即测评 →',
},
];
/** 当前用户 */
const MOCK_USER_INFO: UserInfo = {
name: '李明',
slogan: '精准高效·全链条一站式求职服务',
};
/** 会话列表 */
const MOCK_CONVERSATIONS: Conversation[] = [
{
id: 'conv-1',
contactName: '张HR(字节跳动)',
companyName: '字节跳动',
avatar: '字',
lastMessage: '您的简历已通过初筛,方便约个时间面试吗?',
timestamp: '09:32',
unread: true,
matched: true,
},
{
id: 'conv-2',
contactName: '李HR(腾讯科技)',
companyName: '腾讯科技',
avatar: '腾',
lastMessage: '面试时间已确认:明天 10:00 视频面试',
timestamp: '昨天',
unread: true,
matched: true,
},
{
id: 'conv-3',
contactName: '王经理(阿里巴巴)',
companyName: '阿里巴巴',
avatar: '阿',
lastMessage: '感谢您的投递,我们会在 3 个工作日内反馈',
timestamp: '周一',
unread: false,
matched: false,
},
{
id: 'conv-4',
contactName: '招聘助手(美团)',
companyName: '美团',
avatar: '美',
lastMessage: '您有一条新的面试邀请待确认',
timestamp: '08:15',
unread: false,
matched: true,
},
];
/** 系统通知列表 */
const MOCK_NOTIFICATIONS: Notification[] = [
{
id: 'noti-1',
title: 'Offer 通知',
content: '恭喜!您收到了 2 个 Offer,请及时查看并处理',
timestamp: '10:20',
type: 'offer',
read: false,
},
{
id: 'noti-2',
title: '系统通知',
content: '您的简历已更新,更多企业将看到您的信息',
timestamp: '09:00',
type: 'system',
read: false,
},
{
id: 'noti-3',
title: '活动推广',
content: '本周六下午举办「求职面试技巧」线上讲座,欢迎报名',
timestamp: '昨天',
type: 'promotion',
read: true,
},
{
id: 'noti-4',
title: '系统通知',
content: '您的账号已通过企业认证',
timestamp: '周一',
type: 'system',
read: true,
},
];
/** 面试日程列表 */
const MOCK_INTERVIEWS: Interview[] = [
{
id: 'iv-1',
companyName: '字节跳动',
positionName: '前端开发终面',
time: '10:00',
type: 'video',
meetingUrl: 'https://meeting.example.com/abc123',
},
{
id: 'iv-2',
companyName: '腾讯科技',
positionName: '产品经理二面',
time: '14:30',
type: 'offline',
location: '深圳市南山区科技园南区',
},
];
/** 双向匹配结果 */
const MOCK_MATCH_RESULT: MatchResult = {
total: 3,
companies: ['字节跳动', '腾讯科技', '阿里巴巴'],
};
/** 霍兰德职业兴趣测评 */
const MOCK_HOLLAND: HollandAssessment = {
totalParticipants: 12846,
};
/** 四维能力测评(原始数据,不含 level;level 由接口层按总分计算) */
const MOCK_ABILITY: Omit<AbilityAssessment, 'level'> = {
overallScore: 86,
dimensions: [
{ label: '专业能力', value: 82 },
{ label: '项目经验', value: 75 },
{ label: '技能广度', value: 88 },
{ label: '成长潜力', value: 90 },
],
};
/** AI 求职工具 */
const MOCK_AI_TOOLS: AITool[] = [
{
id: 'ai-resume',
title: 'AI简历优化',
description: '一键优化,通过率提升80%',
action: '立即优化',
variant: 'primary',
icon: 'document-text-outline',
},
{
id: 'ai-interview',
title: 'AI模拟面试',
description: '真人感面试实战演练',
action: '开始练习',
variant: 'success',
icon: 'chatbubbles-outline',
},
];
/** 职场干货文章 */
const MOCK_ARTICLES: Article[] = [
{
id: 'art-1',
title: '如何写出让HR眼前一亮的简历',
tag: '职场攻略',
views: 23000,
thumbnailColor: 'violet',
icon: 'document-text-outline',
},
{
id: 'art-2',
title: '面试中最容易踩坑的10个问题',
tag: '面试技巧',
views: 18000,
thumbnailColor: 'orange',
icon: 'help-circle-outline',
},
{
id: 'art-3',
title: '2024年互联网行业薪资报告',
tag: '行业洞察',
views: 35000,
thumbnailColor: 'green',
icon: 'stats-chart-outline',
},
];
/** 当前用户个人信息(我的页) */
const MOCK_USER_PROFILE: UserProfile = {
id: 'u-1',
name: '李明',
avatar: '李',
bio: '3年工作经验·本科·北京',
};
/** 用户统计数据(我的页四宫格) */
const MOCK_USER_STATS: UserStats = {
deliveries: 28,
favorites: 12,
chats: 8,
offers: 3,
};
/** 功能菜单列表 */
const MOCK_MENU_ITEMS: MenuItem[] = [
{ id: 'resume', title: '我的简历', icon: 'document-text-outline', route: '/resume' },
{ id: 'favorites', title: '我的收藏', icon: 'heart-outline', route: '/favorites' },
{ id: 'history', title: '浏览历史', icon: 'time-outline', route: '/history' },
{ id: 'assessment', title: '我的测评', icon: 'bar-chart-outline', route: '/assessment' },
{ id: 'settings', title: '设置', icon: 'settings-outline', route: '/settings' },
];
/** 企业端入口 */
const MOCK_ENTERPRISE_ENTRY: EnterpriseEntry = {
title: '切换到企业端',
description: '如果你是HR或企业管理者,可以切换到企业端管理招聘',
action: '去企业端 →',
};
/** 综合评分 */
const MOCK_OVERALL_SCORE: OverallScore = {
score: 88,
level: '优秀',
rank: '超过85%的求职者',
};
/** 八大维度评分 */
const MOCK_DIMENSION_SCORES: DimensionScore[] = [
{ id: 'edu', name: '教育背景', score: 92, level: 'excellent' },
{ id: 'intern', name: '实习经历', score: 85, level: 'excellent' },
{ id: 'skill', name: '专业技能', score: 88, level: 'excellent' },
{ id: 'project', name: '项目经验', score: 78, level: 'good' },
{ id: 'cert', name: '证书资质', score: 72, level: 'good' },
{ id: 'stability', name: '工作稳定性', score: 90, level: 'excellent' },
{ id: 'integrity', name: '诚信记录', score: 95, level: 'excellent' },
{ id: 'salary', name: '薪资匹配度', score: 82, level: 'excellent' },
];
/** 求职偏好 */
const MOCK_JOB_PREFERENCE: JobPreference = {
position: '前端开发工程师',
city: '北京',
salary: '25-35K',
workType: '全职',
status: '积极求职中',
};
/** 勋章列表 */
const MOCK_BADGES: Badge[] = [
{ id: 'badge-1', title: '求职达人', description: '投递满100次', icon: '🏅' },
{ id: 'badge-2', title: '面试高手', description: '面试10次成功', icon: '🏅' },
{ id: 'badge-3', title: 'offer收割机', description: '获得5个offer', icon: '🏅' },
{ id: 'badge-4', title: '诚信之星', description: '诚信记录满分', icon: '🏅' },
];
/* ===================== 业务规则 ===================== */
/** 分数 → 等级映射(业务规则,接口层计算) */
function scoreToLevel(score: number): JobLevel {
if (score >= 85) return 'excellent';
if (score >= 70) return 'good';
return 'medium';
}
/** 能力总分 → 等级映射(能力测评阈值高于岗位匹配:≥90 优秀 / ≥75 良好) */
function abilityScoreToLevel(score: number): JobLevel {
if (score >= 90) return 'excellent';
if (score >= 75) return 'good';
return 'medium';
}
/* ===================== 接口函数 ===================== */
/** 模拟请求(用 setTimeout 模拟网络延迟;真实后端就绪后替换为 fetch) */
function mockRequest<T>(data: T): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(data), MOCK_DELAY));
}
/**
* 获取岗位列表(含排序/筛选/搜索/分页,等级由分数在接口层计算)
* @method GET
* @url /api/jobs
* @param params { JobListParams } 排序/筛选/搜索/分页参数
* @returns Promise<{ jobs: Job[]; total: number }> 分页后的岗位数组与总数
*/
export async function fetchJobList(
params: JobListParams,
): Promise<{ jobs: Job[]; total: number }> {
// TODO: 真实请求(后端就绪后替换)
// const query = new URLSearchParams({ sortBy: params.sortBy, page: String(params.page ?? 1) });
// const res = await fetch(`${API_BASE_URL}/api/jobs?${query}`, { method: 'GET' });
// return res.json();
return new Promise((resolve) => {
setTimeout(() => {
// 1. 从 mock 数据源复制
let result: Omit<Job, 'level'>[] = [...MOCK_JOBS];
// 2. 关键词搜索(岗位名 / 公司名)
if (params.keyword && params.keyword.trim() !== '') {
const keyword = params.keyword.trim().toLowerCase();
result = result.filter(
(job) =>
job.positionName.toLowerCase().includes(keyword) ||
job.companyName.toLowerCase().includes(keyword),
);
}
// 3. 筛选:行业
if (params.industry && params.industry !== '全部行业') {
result = result.filter((job) => job.industry === params.industry);
}
// 4. 筛选:城市
if (params.city && params.city !== '全部城市') {
const city = params.city;
result = result.filter((job) => job.city.includes(city));
}
// 筛选:薪资范围(岗位薪资区间与任一所选区间有交集即命中)
if (params.salary && params.salary.length > 0) {
const salary = params.salary;
result = result.filter((job) =>
salary.some((label) => {
const range = SALARY_RANGES.find((item) => item.label === label);
return range ? job.salaryMin <= range.max && job.salaryMax >= range.min : false;
}),
);
}
// 筛选:经验要求(多选,命中任一即保留)
if (params.experience && params.experience.length > 0) {
const experience = params.experience;
result = result.filter((job) => experience.includes(job.experience));
}
// 筛选:学历要求(多选,命中任一即保留)
if (params.education && params.education.length > 0) {
const education = params.education;
result = result.filter((job) => education.includes(job.education));
}
// 5. 排序
switch (params.sortBy) {
case 'salary':
result.sort((a, b) => b.salaryMax - a.salaryMax);
break;
case 'latest':
result.sort((a, b) => parseInt(b.id, 10) - parseInt(a.id, 10));
break;
case 'comprehensive':
default:
result.sort((a, b) => b.score - a.score);
break;
}
// 6. 计算总数
const total = result.length;
// 7. 分页
const page = params.page ?? 1;
const pageSize = params.pageSize ?? 10;
const startIndex = (page - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, total);
const paginatedJobs = result.slice(startIndex, endIndex);
// 8. 等级映射(业务规则)
const jobs: Job[] = paginatedJobs.map((job) => ({
...job,
level: scoreToLevel(job.score),
}));
resolve({ jobs, total });
}, MOCK_DELAY);
});
}
/**
* 获取岗位分类列表
* @method GET
* @url /api/categories
* @returns Promise<Category[]> 分类数组
*/
export async function fetchCategories(): Promise<Category[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/categories`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_CATEGORIES);
}
/**
* 获取当前用户与岗位的匹配度
* @method GET
* @url /api/match/position
* @returns Promise<MatchData> 匹配度信息
*/
export async function fetchMatchData(): Promise<MatchData> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/match/position`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_MATCH_DATA);
}
/**
* 获取高分优质岗位列表(首页横向卡片)
* @method GET
* @url /api/home/hot-jobs
* @returns Promise<HotJob[]> 热门岗位数组
*/
export async function fetchHotJobs(): Promise<HotJob[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/home/hot-jobs`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_HOT_JOBS);
}
/**
* 获取成长测评工具列表(首页横向卡片)
* @method GET
* @url /api/home/assessment-tools
* @returns Promise<AssessmentTool[]> 测评工具数组
*/
export async function fetchAssessmentTools(): Promise<AssessmentTool[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/home/assessment-tools`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_ASSESSMENT_TOOLS);
}
/**
* 获取 AI 智能工具列表(首页横向卡片)
* @method GET
* @url /api/home/ai-tools
* @returns Promise<HomeAITool[]> AI 工具数组
*/
export async function fetchHomeAITools(): Promise<HomeAITool[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/home/ai-tools`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_HOME_AI_TOOLS);
}
/**
* 获取当前登录用户信息
* @method GET
* @url /api/user/profile
* @returns Promise<UserInfo> 用户信息
*/
export async function fetchUserInfo(): Promise<UserInfo> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/profile`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_USER_INFO);
}
/**
* 获取会话列表
* @method GET
* @url /api/messages/conversations
* @returns Promise<Conversation[]> 会话数组
*/
export async function fetchConversations(): Promise<Conversation[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/messages/conversations`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_CONVERSATIONS);
}
/**
* 获取系统通知列表
* @method GET
* @url /api/messages/notifications
* @returns Promise<Notification[]> 通知数组
*/
export async function fetchNotifications(): Promise<Notification[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/messages/notifications`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_NOTIFICATIONS);
}
/**
* 获取面试日程列表
* @method GET
* @url /api/messages/interviews
* @returns Promise<Interview[]> 面试日程数组
*/
export async function fetchInterviews(): Promise<Interview[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/messages/interviews`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_INTERVIEWS);
}
/**
* 获取双向匹配结果
* @method GET
* @url /api/messages/match-result
* @returns Promise<MatchResult> 匹配结果
*/
export async function fetchMatchResult(): Promise<MatchResult> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/messages/match-result`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_MATCH_RESULT);
}
/**
* 获取霍兰德职业兴趣测评信息
* @method GET
* @url /api/growth/holland
* @returns Promise<HollandAssessment> 测评参与信息
*/
export async function fetchHollandAssessment(): Promise<HollandAssessment> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/holland`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_HOLLAND);
}
/**
* 获取四维能力测评结果(等级由总分在接口层计算)
* @method GET
* @url /api/growth/ability
* @returns Promise<AbilityAssessment> 能力测评结果
*/
export async function fetchAbilityAssessment(): Promise<AbilityAssessment> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/ability`, { method: 'GET' }); return res.json();
return mockRequest({
...MOCK_ABILITY,
level: abilityScoreToLevel(MOCK_ABILITY.overallScore),
});
}
/**
* 获取 AI 求职工具列表
* @method GET
* @url /api/growth/ai-tools
* @returns Promise<AITool[]> 工具数组
*/
export async function fetchAITools(): Promise<AITool[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/ai-tools`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_AI_TOOLS);
}
/**
* 获取职场干货文章列表
* @method GET
* @url /api/growth/articles
* @returns Promise<Article[]> 文章数组
*/
export async function fetchArticles(): Promise<Article[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/articles`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_ARTICLES);
}
/**
* 获取当前用户个人信息(我的页)
* @method GET
* @url /api/user/me
* @returns Promise<UserProfile> 用户个人信息
*/
export async function fetchUserProfile(): Promise<UserProfile> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/me`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_USER_PROFILE);
}
/**
* 获取用户统计数据(投递/收藏/沟通/Offer)
* @method GET
* @url /api/user/stats
* @returns Promise<UserStats> 用户统计数据
*/
export async function fetchUserStats(): Promise<UserStats> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/stats`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_USER_STATS);
}
/**
* 获取个人中心功能菜单列表
* @method GET
* @url /api/user/menus
* @returns Promise<MenuItem[]> 菜单数组
*/
export async function fetchMenuItems(): Promise<MenuItem[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/menus`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_MENU_ITEMS);
}
/**
* 获取企业端入口信息
* @method GET
* @url /api/user/enterprise-entry
* @returns Promise<EnterpriseEntry> 企业端入口
*/
export async function fetchEnterpriseEntry(): Promise<EnterpriseEntry> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/enterprise-entry`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_ENTERPRISE_ENTRY);
}
/**
* 获取综合评分
* @method GET
* @url /api/user/overall-score
* @returns Promise<OverallScore> 综合评分
*/
export async function fetchOverallScore(): Promise<OverallScore> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/overall-score`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_OVERALL_SCORE);
}
/**
* 获取八大维度评分
* @method GET
* @url /api/user/dimension-scores
* @returns Promise<DimensionScore[]> 维度评分数组
*/
export async function fetchDimensionScores(): Promise<DimensionScore[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/dimension-scores`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_DIMENSION_SCORES);
}
/**
* 获取求职偏好
* @method GET
* @url /api/user/job-preference
* @returns Promise<JobPreference> 求职偏好
*/
export async function fetchJobPreference(): Promise<JobPreference> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/job-preference`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_JOB_PREFERENCE);
}
/**
* 获取勋章列表
* @method GET
* @url /api/user/badges
* @returns Promise<Badge[]> 勋章数组
*/
export async function fetchBadges(): Promise<Badge[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/badges`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_BADGES);
}

28
src/api/index.ts

@ -0,0 +1,28 @@
/**
* 接口统一导出
*/
export {
fetchAbilityAssessment,
fetchAITools,
fetchArticles,
fetchAssessmentTools,
fetchBadges,
fetchCategories,
fetchConversations,
fetchDimensionScores,
fetchEnterpriseEntry,
fetchHollandAssessment,
fetchHomeAITools,
fetchHotJobs,
fetchInterviews,
fetchJobList,
fetchJobPreference,
fetchMatchData,
fetchMatchResult,
fetchMenuItems,
fetchNotifications,
fetchOverallScore,
fetchUserInfo,
fetchUserProfile,
fetchUserStats,
} from './api';

36
src/constants/index.ts

@ -0,0 +1,36 @@
/**
* 常量统一导出(枚举、配置项等)
*/
import type { JobLevel } from '../types';
/** 请求失败自动重试次数 */
export const MAX_RETRY_COUNT = 3;
/** 模拟网络延迟(毫秒) */
export const MOCK_DELAY = 300;
/** 岗位等级 → 中文标签(展示用枚举映射) */
export const JOB_LEVEL_LABEL: Record<JobLevel, string> = {
excellent: '优秀',
good: '良好',
medium: '中等',
};
/** 薪资范围筛选选项(min/max 单位 K;max 为 Infinity 表示上限不限) */
export const SALARY_RANGES: ReadonlyArray<{ label: string; min: number; max: number }> = [
{ label: '不限', min: 0, max: Number.POSITIVE_INFINITY },
{ label: '10K以下', min: 0, max: 10 },
{ label: '10-20K', min: 10, max: 20 },
{ label: '20-30K', min: 20, max: 30 },
{ label: '30-50K', min: 30, max: 50 },
{ label: '50K以上', min: 50, max: Number.POSITIVE_INFINITY },
];
/** 经验要求筛选选项 */
export const EXPERIENCE_OPTIONS: string[] = ['不限', '应届生', '1-3年', '3-5年', '5-10年', '10年以上'];
/** 学历要求筛选选项 */
export const EDUCATION_OPTIONS: string[] = ['不限', '大专', '本科', '硕士', '博士'];
/** 热门城市(定位/城市选择用) */
export const CITIES: string[] = ['北京', '上海', '深圳', '杭州', '广州', '成都', '南京', '武汉'];

12
src/hooks/index.ts

@ -0,0 +1,12 @@
/**
* hooks 统一导出
*/
export { useAsyncData } from './useAsyncData';
export { useCategoryData } from './useCategoryData';
export { useGrowthData } from './useGrowthData';
export { useHomeData } from './useHomeData';
export { useJobList } from './useJobList';
export { useMatchData } from './useMatchData';
export { useMessageData } from './useMessageData';
export { useProfileData } from './useProfileData';
export { useUserProfile } from './useUserProfile';

61
src/hooks/useAsyncData.ts

@ -0,0 +1,61 @@
/**
* 通用异步取数 hook:统一管理 loading / error / refresh 三态。
* 供 useJobData、useUserData 等具体 hook 复用。
*
* 特性:
* - 请求去重:上一次请求未完成前,重复调用 refresh 不会发起新请求
* - 自动重试:请求失败后自动重试(最多 MAX_RETRY_COUNT 次)
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { MAX_RETRY_COUNT } from '../constants';
export interface AsyncData<T> {
data: T | null;
loading: boolean;
error: Error | null;
refresh: () => void;
}
export function useAsyncData<T>(fetcher: () => Promise<T>): AsyncData<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
/** 请求去重:标记是否有请求正在进行中 */
const inFlightRef = useRef(false);
const run = useCallback(
async (retryLeft = MAX_RETRY_COUNT) => {
// 去重:已有请求进行中则直接忽略
if (inFlightRef.current) return;
inFlightRef.current = true;
setLoading(true);
setError(null);
try {
setData(await fetcher());
inFlightRef.current = false;
setLoading(false);
} catch (err) {
inFlightRef.current = false;
if (retryLeft > 0) {
// 自动重试
void run(retryLeft - 1);
} else {
setError(err instanceof Error ? err : new Error(String(err)));
setLoading(false);
}
}
},
[fetcher],
);
const refresh = useCallback(() => {
void run();
}, [run]);
useEffect(() => {
refresh();
}, [refresh]);
return { data, loading, error, refresh };
}

10
src/hooks/useCategoryData.ts

@ -0,0 +1,10 @@
/**
* 岗位分类数据 hook
*/
import { fetchCategories } from '../api';
import { useAsyncData } from './useAsyncData';
export function useCategoryData() {
const { data: categories, loading, error, refresh } = useAsyncData(fetchCategories);
return { categories, loading, error, refresh };
}

45
src/hooks/useGrowthData.ts

@ -0,0 +1,45 @@
/**
* 成长中心数据 hook:并行组合霍兰德测评、能力测评、AI 工具、职场干货四类数据,
* 统一管理 loading / error / refresh 三态。
*/
import { useCallback } from 'react';
import {
fetchAbilityAssessment,
fetchAITools,
fetchArticles,
fetchHollandAssessment,
} from '../api';
import type { AbilityAssessment, AITool, Article, HollandAssessment } from '../types';
import { useAsyncData } from './useAsyncData';
/** 成长中心聚合数据 */
interface GrowthData {
holland: HollandAssessment;
ability: AbilityAssessment;
aiTools: AITool[];
articles: Article[];
}
export function useGrowthData() {
const fetcher = useCallback(async (): Promise<GrowthData> => {
const [holland, ability, aiTools, articles] = await Promise.all([
fetchHollandAssessment(),
fetchAbilityAssessment(),
fetchAITools(),
fetchArticles(),
]);
return { holland, ability, aiTools, articles };
}, []);
const { data, loading, error, refresh } = useAsyncData(fetcher);
return {
holland: data?.holland ?? null,
ability: data?.ability ?? null,
aiTools: data?.aiTools ?? [],
articles: data?.articles ?? [],
loading,
error,
refresh,
};
}

60
src/hooks/useHomeData.ts

@ -0,0 +1,60 @@
/**
* 首页数据 hook:并行组合用户信息、分类、匹配度、高分岗位、成长测评工具、AI 工具,
* 统一管理 loading / error / refresh 三态。
*/
import { useCallback } from 'react';
import {
fetchAssessmentTools,
fetchCategories,
fetchHomeAITools,
fetchHotJobs,
fetchMatchData,
fetchUserInfo,
} from '../api';
import type {
AssessmentTool,
Category,
HomeAITool,
HotJob,
MatchData,
UserInfo,
} from '../types';
import { useAsyncData } from './useAsyncData';
/** 首页聚合数据 */
interface HomeData {
user: UserInfo;
categories: Category[];
match: MatchData;
hotJobs: HotJob[];
assessmentTools: AssessmentTool[];
aiTools: HomeAITool[];
}
export function useHomeData() {
const fetcher = useCallback(async (): Promise<HomeData> => {
const [user, categories, match, hotJobs, assessmentTools, aiTools] = await Promise.all([
fetchUserInfo(),
fetchCategories(),
fetchMatchData(),
fetchHotJobs(),
fetchAssessmentTools(),
fetchHomeAITools(),
]);
return { user, categories, match, hotJobs, assessmentTools, aiTools };
}, []);
const { data, loading, error, refresh } = useAsyncData(fetcher);
return {
user: data?.user ?? null,
categories: data?.categories ?? [],
match: data?.match ?? null,
hotJobs: data?.hotJobs ?? [],
assessmentTools: data?.assessmentTools ?? [],
aiTools: data?.aiTools ?? [],
loading,
error,
refresh,
};
}

45
src/hooks/useJobList.ts

@ -0,0 +1,45 @@
/**
* 岗位列表数据 hook(排序/筛选/搜索/分页均在接口层 fetchJobList 完成)
*/
import { useCallback, useMemo } from 'react';
import { fetchJobList } from '../api';
import type { Job, JobListParams } from '../types';
import { useAsyncData } from './useAsyncData';
export function useJobList(params?: Partial<JobListParams>) {
const sortBy = params?.sortBy ?? 'comprehensive';
const keyword = params?.keyword;
const industry = params?.industry;
const city = params?.city;
const salary = params?.salary;
const experience = params?.experience;
const education = params?.education;
const page = params?.page;
const pageSize = params?.pageSize;
// fetcher 引用需稳定:以基础类型为依赖,避免 useAsyncData 反复触发
const fetcher = useCallback(
() =>
fetchJobList({
sortBy,
keyword,
industry,
city,
salary,
experience,
education,
page,
pageSize,
}),
[sortBy, keyword, industry, city, salary, experience, education, page, pageSize],
);
const { data, loading, error, refresh } = useAsyncData(fetcher);
const jobs = useMemo<Job[]>(() => data?.jobs ?? [], [data]);
const total = data?.total ?? 0;
// 空状态:加载完成且无错误、但列表为空
const isEmpty = !loading && !error && jobs.length === 0;
return { jobs, total, loading, error, refresh, isEmpty };
}

10
src/hooks/useMatchData.ts

@ -0,0 +1,10 @@
/**
* 岗位匹配度数据 hook
*/
import { fetchMatchData } from '../api';
import { useAsyncData } from './useAsyncData';
export function useMatchData() {
const { data: match, loading, error, refresh } = useAsyncData(fetchMatchData);
return { match, loading, error, refresh };
}

48
src/hooks/useMessageData.ts

@ -0,0 +1,48 @@
/**
* 消息/沟通数据 hook:聚合会话、通知、面试、匹配结果
*/
import {
fetchConversations,
fetchInterviews,
fetchMatchResult,
fetchNotifications,
} 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 };
}
export function useMessageData() {
const { data, loading, error, refresh } = useAsyncData(fetchMessageData);
const conversations = data?.conversations ?? [];
const notifications = data?.notifications ?? [];
const interviews = data?.interviews ?? [];
const matchResult = data?.matchResult ?? null;
// 空状态:加载完成且无错误、但三类列表均为空
const isEmpty =
!loading &&
!error &&
conversations.length === 0 &&
notifications.length === 0 &&
interviews.length === 0;
return {
conversations,
notifications,
interviews,
matchResult,
loading,
error,
refresh,
isEmpty,
};
}

55
src/hooks/useProfileData.ts

@ -0,0 +1,55 @@
/**
* 个人中心数据 hook:并行组合用户信息、综合评分、维度评分、求职偏好、勋章五类数据,
* 统一管理 loading / error / refresh 三态。
*/
import { useCallback } from 'react';
import {
fetchBadges,
fetchDimensionScores,
fetchJobPreference,
fetchOverallScore,
fetchUserProfile,
} from '../api';
import type {
Badge,
DimensionScore,
JobPreference,
OverallScore,
UserProfile,
} from '../types';
import { useAsyncData } from './useAsyncData';
/** 个人中心聚合数据 */
interface ProfileData {
profile: UserProfile;
overallScore: OverallScore;
dimensions: DimensionScore[];
preference: JobPreference;
badges: Badge[];
}
export function useProfileData() {
const fetcher = useCallback(async (): Promise<ProfileData> => {
const [profile, overallScore, dimensions, preference, badges] = await Promise.all([
fetchUserProfile(),
fetchOverallScore(),
fetchDimensionScores(),
fetchJobPreference(),
fetchBadges(),
]);
return { profile, overallScore, dimensions, preference, badges };
}, []);
const { data, loading, error, refresh } = useAsyncData(fetcher);
return {
profile: data?.profile ?? null,
overallScore: data?.overallScore ?? null,
dimensions: data?.dimensions ?? [],
preference: data?.preference ?? null,
badges: data?.badges ?? [],
loading,
error,
refresh,
};
}

10
src/hooks/useUserProfile.ts

@ -0,0 +1,10 @@
/**
* 当前用户信息数据 hook
*/
import { fetchUserInfo } from '../api';
import { useAsyncData } from './useAsyncData';
export function useUserProfile() {
const { data: user, loading, error, refresh } = useAsyncData(fetchUserInfo);
return { user, loading, error, refresh };
}

166
src/theme/colors.ts

@ -0,0 +1,166 @@
/**
* 设计令牌(Design Tokens):全局配色,单一来源。
*
* 主色 #2C83FD 蓝
* 行动色 #FF6F61 珊瑚橙(次要强调按钮)
* 警示色 #FF3B30 红(风险/警告/未读红点)
* 成功色 #10B981 暖绿(完成/通过)
* 信息色 #7C5CFC 紫(通知/链接/金额)
*
* 组件内禁止写死十六进制色值,统一从本文件导入。
* `darkColors` 为暗模式调色板(亮→暗映射),接入暗模式后按需使用。
*/
/* ===================== 亮色模式 ===================== */
export const colors = {
/* ---- 主色家族(蓝) ---- */
/** 主色(蓝)· 品牌锚点 / Logo / ≥24pt 展示字 */
primary: '#2C83FD',
/** 主色浅色调 · 卡片 / 标签底色 / 空状态背景 */
primaryLight: '#E8F2FF',
/** 主色描边 · 标签边框 / 输入框聚焦边 */
primaryBorder: '#D1E5FF',
/** 主色亮色调 · 按钮渐变终点 / 装饰图形 */
primaryBright: '#5399FD',
/** 主色深色调 · 图标 / ≥18pt 大字 / 正向评级文字 */
primaryDark: '#175AB0',
/** 主色文字专用 · 浅底上写蓝字(白底 9.4:1 AAA) */
primaryText: '#104686',
/** 主按钮底色 · 白字(对比 4.8:1 AA) */
primaryBtn: '#1F6FDB',
/* ---- 行动色家族(珊瑚橙) ---- */
/** 行动色(珊瑚橙)· 品牌锚点 / 装饰 / ≥24pt 大字 */
action: '#FF6F61',
/** 行动色浅色调 · 提醒卡片底 / 标签底 */
actionLight: '#FFEFED',
/** 行动色描边 · 弱警示描边 / 标签边框 */
actionBorder: '#FFD7D1',
/** 行动按钮底色 · 白字(对比 5.4:1 AA) */
actionBtn: '#F25A4C',
/** 行动色文字专用 · 浅底上写橙色字(白底 7.1:1 AA) */
actionText: '#D94A3C',
/** 行动色极深 · 按下态 / 极小文字底(10.1:1 AAA) */
actionDeep: '#B03A2E',
/* ---- 警示色(红 · 严格独立于行动色) ---- */
/** 警示色 · 风险 / 警告 / 未读红点 / 错误标(白字 5.1:1 AA) */
danger: '#FF3B30',
/** 警示浅色调 · 错误提示卡片底 */
dangerLight: '#FFF1F0',
/** 警示描边 · 错误输入框描边 */
dangerBorder: '#FFD6D4',
/** 警示按钮底 · 删除 / 危险操作(白字 5.8:1 AA) */
dangerBtn: '#E5352B',
/** 警示正文 · 错误信息文字(白底 8.3:1 AAA) */
dangerText: '#C02A22',
/* ---- 成功色(暖绿 · 避免“主色=成功”混淆) ---- */
/** 成功色 · 完成 / 通过 图标 / 标签(图形用) */
success: '#10B981',
/** 成功浅色调 · 成功卡片底 / 已通过标签底 */
successLight: '#E8F8EF',
/** 成功描边 · 已完成卡片边框 */
successBorder: '#C7EDD8',
/** 成功按钮底 · 白字(对比 5.6:1 AA) */
successBtn: '#059669',
/** 成功正文 · 已达成 / 正向收益文字(白底 7.8:1 AAA) */
successText: '#047857',
/* ---- 信息色(紫 · 补冷色锚点,与蓝主色区分) ---- */
/** 信息色 · 通知 / 链接 / 金额数字 / 信息标签(图形用) */
info: '#7C5CFC',
/** 信息浅色调 · 通知卡片底 / 系统消息底 */
infoLight: '#F1EEFF',
/** 信息描边 · 信息类标签边框 */
infoBorder: '#E0DAFF',
/** 信息按钮底 · 白字(对比 6.0:1 AA) */
infoBtn: '#6642E8',
/** 信息正文 · 链接 / 金额数字(白底 7.4:1 AAA) */
infoText: '#5B3ED8',
/* ---- 中性色家族(7 档) ---- */
/** 中性底色 · 卡片 / 弹窗底 */
white: '#FFFFFF',
/** 卡片 / 列表项底(与 pageBg 拉开 1 档层次) */
surface: '#FFFFFF',
/** 页面背景浅灰 · 全局背景 */
pageBg: '#F6F8F9',
/** 浅描边 · 卡片分割 / 细分割线 / 列表项分隔 */
borderLight: '#EFF2F6',
/** 中性描边 · 输入框边框 / 卡片边框 */
border: '#E8ECF4',
/** 强描边 · 输入框聚焦前 / 选中卡片描边 */
borderStrong: '#D4DAE3',
/** 禁用文字 · 禁用按钮 / 只读内容 */
textDisabled: '#B0B8C1',
/** 占位符文字 · placeholder / 辅助提示 */
textHint: '#8E99A4',
/** 次要文字(白底 5.1:1 过 AA) */
textSecondary: '#6B7A7B',
/** 正文深色 · 标题 / 正文主阅读文字(13.1:1 AAA) */
textPrimary: '#2C3E50',
/** 深色底上的反白文字 */
textInverse: '#FFFFFF',
/** 焦点环色 · 键盘聚焦指示器(WCAG 2.4.11) */
focusRing: '#1F6FDB',
/** 弹层遮罩 · 半透明黑(Modal / 底部弹层背景) */
scrim: 'rgba(0, 0, 0, 0.35)',
} as const;
/* ===================== 暗色模式(亮 → 暗映射) ===================== */
export const darkColors = {
/* ---- 主色家族(蓝 · 暗模式提亮) ---- */
primary: '#5399FD',
primaryLight: '#0F1D33',
primaryBorder: '#1E3A5F',
primaryBright: '#7AB0FF',
primaryDark: '#7AB0FF',
primaryText: '#A8CBFF',
primaryBtn: '#1F6FDB',
/* ---- 行动色家族(珊瑚橙 · 暗模式更亮) ---- */
action: '#FF8273',
actionLight: '#2E1A18',
actionBorder: '#5A2E28',
actionBtn: '#F2796B',
actionText: '#FF9C90',
actionDeep: '#FFA396',
/* ---- 警示色(红) ---- */
danger: '#FF6259',
dangerLight: '#2E1A1A',
dangerBorder: '#5A2A28',
dangerBtn: '#FF6259',
dangerText: '#FF857E',
/* ---- 成功色(暖绿) ---- */
success: '#34D399',
successLight: '#12291F',
successBorder: '#1F4A35',
successBtn: '#34D399',
successText: '#5BE0A9',
/* ---- 信息色(紫 · 暗模式更亮) ---- */
info: '#8F70FF',
infoLight: '#1E1A33',
infoBorder: '#3A2E66',
infoBtn: '#7C5CFC',
infoText: '#AA93FF',
/* ---- 中性色家族 ---- */
white: '#FFFFFF',
surface: '#161B22',
pageBg: '#0E1116',
borderLight: '#212830',
border: '#2A313C',
borderStrong: '#3A4350',
textDisabled: '#4A5561',
textHint: '#6B7785',
textSecondary: '#91A0B3',
textPrimary: '#E6EDF3',
textInverse: '#0E1116',
focusRing: '#5399FD',
} as const;

4
src/theme/index.ts

@ -0,0 +1,4 @@
/**
* 主题统一导出
*/
export { colors, darkColors } from './colors';

58
src/types/growth.ts

@ -0,0 +1,58 @@
/**
* 成长中心相关类型定义
*/
import type { JobLevel } from './job';
/** 能力维度(四维能力测评的单条维度) */
export interface AbilityDimension {
/** 维度名称,如 "专业能力" */
label: string;
/** 维度得分(0-100) */
value: number;
}
/** 四维能力测评结果 */
export interface AbilityAssessment {
/** 综合评分(0-100) */
overallScore: number;
/** 评级等级(由接口层按总分计算) */
level: JobLevel;
/** 4 个能力维度 */
dimensions: AbilityDimension[];
}
/** AI 求职工具 */
export interface AITool {
id: string;
/** 工具名称,如 "AI简历优化" */
title: string;
/** 工具描述 */
description: string;
/** 操作文案,如 "立即优化" */
action: string;
/** 卡片色调 */
variant: 'primary' | 'success';
/** Ionicons 图标名 */
icon: string;
}
/** 职场干货文章 */
export interface Article {
id: string;
/** 文章标题 */
title: string;
/** 标签,如 "职场攻略" */
tag: string;
/** 阅读量(原始数字,展示时格式化为 "2.3万") */
views: number;
/** 缩略图配色 */
thumbnailColor: 'violet' | 'orange' | 'green';
/** Ionicons 图标名 */
icon: string;
}
/** 霍兰德职业兴趣测评 */
export interface HollandAssessment {
/** 累计完成人数 */
totalParticipants: number;
}

46
src/types/home.ts

@ -0,0 +1,46 @@
/**
* 首页相关类型定义
*/
/** 热门岗位(首页「高分优质岗位」横向卡片;薪资/评分文案已由接口层格式化) */
export interface HotJob {
id: string;
/** 岗位名称 */
title: string;
/** 匹配分数(0-100) */
score: number;
/** 评级文案,如 "优秀" */
scoreLabel: string;
/** 已格式化薪资,如 "25-40K·15薪" */
salary: string;
/** 公司名 */
company: string;
/** 城市,如 "北京" */
location: string;
/** 经验要求,如 "3-5年" */
experience: string;
/** 福利标签 */
benefits: string[];
}
/** 首页工具卡片(成长测评工具 / AI 智能工具共用的卡片结构) */
export interface HomeTool {
id: string;
/** 工具名称 */
title: string;
/** 工具描述 */
description: string;
/** Ionicons 图标名 */
icon: string;
/** 操作文案,如 "立即测评 →" */
action: string;
}
/**
* 测评工具(与 HomeTool 结构一致,语义化别名)。
* 说明:成长页已存在带 variant 字段的 AITool,首页 AI 工具独立命名为 HomeAITool 以规避重名。
*/
export type AssessmentTool = HomeTool;
/** AI 智能工具(首页) */
export type HomeAITool = HomeTool;

31
src/types/index.ts

@ -0,0 +1,31 @@
/**
* 类型统一导出
*/
export type {
Category,
Job,
JobLevel,
JobListParams,
MatchData,
SortType,
} from './job';
export type {
AbilityAssessment,
AbilityDimension,
AITool,
Article,
HollandAssessment,
} from './growth';
export type { AssessmentTool, HomeAITool, HomeTool, HotJob } from './home';
export type { Conversation, Interview, MatchResult, Notification } from './message';
export type {
Badge,
DimensionScore,
EnterpriseEntry,
JobPreference,
MenuItem,
OverallScore,
UserInfo,
UserProfile,
UserStats,
} from './user';

79
src/types/job.ts

@ -0,0 +1,79 @@
/**
* 岗位相关类型定义
*/
/** 岗位分类 */
export interface Category {
id: string;
name: string;
/** Ionicons 图标名(类型层不依赖 UI 库,渲染时再断言) */
icon: string;
}
/** 岗位评级等级 */
export type JobLevel = 'excellent' | 'good' | 'medium';
/** 岗位 */
export interface Job {
id: string;
/** 公司名 */
companyName: string;
/** 公司地点,如 "北京 · 海淀区" */
companyLocation: string;
/** 岗位名称 */
positionName: string;
/** 最低薪资(K) */
salaryMin: number;
/** 最高薪资(K) */
salaryMax: number;
/** 匹配分数(0-100) */
score: number;
/** 评级等级(由接口层按分数计算) */
level: JobLevel;
/** 经验要求,如 "3-5年" */
experience: string;
/** 学历要求,如 "本科" */
education: string;
/** 所属行业,如 "互联网/IT" */
industry: string;
/** 城市,如 "北京" */
city: string;
/** 福利标签 */
benefits: string[];
}
/** 岗位匹配度 */
export interface MatchData {
/** 岗位名称 */
position: string;
/** 匹配度(0-100) */
matchRate: number;
/** 副标题/行动文案,如 "双向匹配成功·立即沟通" */
subtitle: string;
}
/** 岗位排序方式 */
export type SortType = 'comprehensive' | 'latest' | 'salary';
/** 岗位列表查询参数(接口层排序/筛选/搜索/分页) */
export interface JobListParams {
/** 排序方式 */
sortBy: SortType;
/** 搜索关键词 */
keyword?: string;
/** 行业筛选 */
industry?: string;
/** 城市筛选 */
city?: string;
/** 薪资范围(多选;空数组表示不筛选) */
salary?: string[];
/** 经验要求(多选;空数组表示不筛选) */
experience?: string[];
/** 学历要求(多选;空数组表示不筛选) */
education?: string[];
/** 页码(从 1 开始) */
page?: number;
/** 每页数量 */
pageSize?: number;
}

62
src/types/message.ts

@ -0,0 +1,62 @@
/**
* 消息/沟通相关类型定义
*/
/** 会话消息 */
export interface Conversation {
id: string;
/** 联系人姓名,如 "张HR(字节跳动)" */
contactName: string;
/** 公司名,如 "字节跳动" */
companyName: string;
/** 头像占位(公司首字母或图标) */
avatar: string;
/** 最新消息预览 */
lastMessage: string;
/** 时间,如 "09:32" */
timestamp: string;
/** 是否有未读消息 */
unread: boolean;
/** 是否已匹配 */
matched: boolean;
}
/** 系统通知 */
export interface Notification {
id: string;
/** 通知标题 */
title: string;
/** 通知内容 */
content: string;
/** 时间 */
timestamp: string;
/** 通知类型 */
type: 'offer' | 'system' | 'promotion';
/** 是否已读 */
read: boolean;
}
/** 面试日程 */
export interface Interview {
id: string;
/** 公司名 */
companyName: string;
/** 岗位名,如 "前端开发终面" */
positionName: string;
/** 时间,如 "10:00" */
time: string;
/** 面试类型:视频 / 线下 */
type: 'video' | 'offline';
/** 线下面试地址 */
location?: string;
/** 视频会议链接 */
meetingUrl?: string;
}
/** 双向匹配结果 */
export interface MatchResult {
/** 匹配成功企业数量 */
total: number;
/** 企业名称列表 */
companies: string[];
}

101
src/types/user.ts

@ -0,0 +1,101 @@
/**
* 用户相关类型定义
*/
/** 用户信息 */
export interface UserInfo {
/** 姓名 */
name: string;
/** 个人 slogan */
slogan: string;
}
/** 用户个人信息(我的页) */
export interface UserProfile {
id: string;
/** 姓名,如 "李明" */
name: string;
/** 头像展示字符(姓氏首字母),如 "李" */
avatar: string;
/** 个人简介,如 "3年工作经验·本科·北京" */
bio: string;
}
/** 用户统计数据(我的页四宫格) */
export interface UserStats {
/** 投递数 */
deliveries: number;
/** 收藏数 */
favorites: number;
/** 沟通数 */
chats: number;
/** Offer 数 */
offers: number;
}
/** 功能菜单项 */
export interface MenuItem {
id: string;
/** 菜单标题 */
title: string;
/** Ionicons 图标名 */
icon: string;
/** 跳转路由(暂为占位) */
route?: string;
}
/** 企业端入口 */
export interface EnterpriseEntry {
/** 标题,如 "切换到企业端" */
title: string;
/** 描述文案 */
description: string;
/** 操作文案,如 "去企业端 →" */
action: string;
}
/** 综合评分(我的页) */
export interface OverallScore {
/** 综合分数,如 88 */
score: number;
/** 等级文案,如 "优秀" */
level: string;
/** 排名文案,如 "超过85%的求职者" */
rank: string;
}
/** 维度评分(我的页八大维度) */
export interface DimensionScore {
id: string;
/** 维度名称,如 "教育背景" */
name: string;
/** 分数,如 92 */
score: number;
/** 等级(两档) */
level: 'excellent' | 'good';
}
/** 求职偏好 */
export interface JobPreference {
/** 期望职位 */
position: string;
/** 期望城市 */
city: string;
/** 期望薪资 */
salary: string;
/** 工作类型 */
workType: string;
/** 求职状态 */
status: string;
}
/** 勋章 */
export interface Badge {
id: string;
/** 勋章标题 */
title: string;
/** 勋章描述 */
description: string;
/** 勋章图标(emoji) */
icon: string;
}

21
src/utils/index.ts

@ -0,0 +1,21 @@
/**
* 纯工具函数(字符串 / 数字格式化等)。
*/
/**
* 千分位格式化:12846 → "12,846"
*/
export function formatThousands(n: number): string {
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/**
* 阅读量格式化:23000 → "2.3万";不足一万返回原数字字符串(如 "8500")。
*/
export function formatViews(views: number): string {
if (views >= 10000) {
const wan = views / 10000;
return `${wan.toFixed(1).replace(/\.0$/, '')}万`;
}
return String(views);
}

13
tsconfig.json

@ -0,0 +1,13 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true
},
"include": [
"**/*.ts",
"**/*.tsx"
]
}
Loading…
Cancel
Save