43 changed files with 7129 additions and 264 deletions
@ -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: '北京', |
|||
}, |
|||
]; |
|||
@ -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', |
|||
}, |
|||
}); |
|||
@ -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', |
|||
}, |
|||
}, |
|||
}; |
|||
@ -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" |
|||
} |
|||
} |
|||
} |
|||
@ -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, |
|||
}, |
|||
}); |
|||
@ -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}>立即查看 ></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}>全部日程 ></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, |
|||
}, |
|||
}); |
|||
@ -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}>查看报告 ></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}>更多 ></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, |
|||
}, |
|||
}); |
|||
@ -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', |
|||
}, |
|||
}); |
|||
@ -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}>查看详情 ></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, |
|||
}, |
|||
}); |
|||
@ -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}>></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', |
|||
}, |
|||
}); |
|||
@ -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> |
|||
</> |
|||
); |
|||
} |
|||
@ -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, |
|||
}, |
|||
}); |
|||
@ -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, |
|||
}, |
|||
}); |
|||
@ -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, |
|||
}, |
|||
}); |
|||
@ -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, |
|||
}, |
|||
}); |
|||
@ -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, |
|||
}, |
|||
}); |
|||
@ -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); |
|||
File diff suppressed because it is too large
@ -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); |
|||
} |
|||
@ -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'; |
|||
@ -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[] = ['北京', '上海', '深圳', '杭州', '广州', '成都', '南京', '武汉']; |
|||
@ -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'; |
|||
@ -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 }; |
|||
} |
|||
@ -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 }; |
|||
} |
|||
@ -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, |
|||
}; |
|||
} |
|||
@ -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, |
|||
}; |
|||
} |
|||
@ -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 }; |
|||
} |
|||
@ -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 }; |
|||
} |
|||
@ -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, |
|||
}; |
|||
} |
|||
@ -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, |
|||
}; |
|||
} |
|||
@ -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 }; |
|||
} |
|||
@ -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; |
|||
@ -0,0 +1,4 @@ |
|||
/** |
|||
* 主题统一导出 |
|||
*/ |
|||
export { colors, darkColors } from './colors'; |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
@ -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'; |
|||
@ -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; |
|||
} |
|||
|
|||
@ -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[]; |
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
{ |
|||
"extends": "expo/tsconfig.base", |
|||
"compilerOptions": { |
|||
"strict": true, |
|||
"noUnusedLocals": true, |
|||
"noUnusedParameters": true, |
|||
"noImplicitReturns": true |
|||
}, |
|||
"include": [ |
|||
"**/*.ts", |
|||
"**/*.tsx" |
|||
] |
|||
} |
|||
Loading…
Reference in new issue