diff --git a/.env.development b/.env.development new file mode 100644 index 00000000..a7e93534 --- /dev/null +++ b/.env.development @@ -0,0 +1,20 @@ +# 开发环境配置(连接真实后端) +# 使用方式: npm start + +# React 构建优化配置 +GENERATE_SOURCEMAP=false +SKIP_PREFLIGHT_CHECK=true +DISABLE_ESLINT_PLUGIN=true +TSC_COMPILE_ON_ERROR=true +IMAGE_INLINE_SIZE_LIMIT=10000 +NODE_OPTIONS=--max_old_space_size=4096 + +# API 配置 +# 后端 API 地址(开发环境会代理到这个地址) +REACT_APP_API_URL=http://49.232.185.254:5001 + +# 禁用 Mock 数据(使用真实API) +REACT_APP_ENABLE_MOCK=false + +# 开发环境标识 +REACT_APP_ENV=development diff --git a/.env.mock b/.env.mock new file mode 100644 index 00000000..94147c2c --- /dev/null +++ b/.env.mock @@ -0,0 +1,20 @@ +# Mock 测试环境配置 +# 使用方式: npm run start:mock + +# React 构建优化配置 +GENERATE_SOURCEMAP=false +SKIP_PREFLIGHT_CHECK=true +DISABLE_ESLINT_PLUGIN=true +TSC_COMPILE_ON_ERROR=true +IMAGE_INLINE_SIZE_LIMIT=10000 +NODE_OPTIONS=--max_old_space_size=4096 + +# API 配置 +# Mock 模式下不需要真实的后端地址 +REACT_APP_API_URL=http://localhost:3000 + +# 启用 Mock 数据(核心配置) +REACT_APP_ENABLE_MOCK=true + +# Mock 环境标识 +REACT_APP_ENV=mock diff --git a/API_DOCS_profile_completeness.md b/API_DOCS_profile_completeness.md new file mode 100644 index 00000000..1a1e7780 --- /dev/null +++ b/API_DOCS_profile_completeness.md @@ -0,0 +1,458 @@ +# 用户资料完整度 API 文档 + +## 接口概述 + +**接口名称**:获取用户资料完整度 +**接口路径**:`/api/account/profile-completeness` +**请求方法**:`GET` +**接口描述**:获取当前登录用户的资料完整度信息,包括各项必填信息的完成状态、完整度百分比、缺失项列表等。 +**业务场景**:用于在用户登录后提醒用户完善个人资料,提升平台服务质量。 + +--- + +## 请求参数 + +### Headers + +| 参数名 | 类型 | 必填 | 描述 | +|--------|------|------|------| +| `Cookie` | string | 是 | 包含用户会话信息(session cookie),用于身份认证 | + +### Query Parameters + +无 + +### Body Parameters + +无(GET 请求无 Body) + +--- + +## 响应格式 + +### 成功响应 (200 OK) + +**Content-Type**: `application/json` + +```json +{ + "success": true, + "data": { + "completeness": { + "hasPassword": true, + "hasPhone": true, + "hasEmail": false, + "isWechatUser": false + }, + "completenessPercentage": 66, + "needsAttention": false, + "missingItems": ["邮箱"], + "isComplete": false, + "showReminder": false + } +} +``` + +### 响应字段说明 + +#### 顶层字段 + +| 字段名 | 类型 | 描述 | +|--------|------|------| +| `success` | boolean | 请求是否成功,`true` 表示成功 | +| `data` | object | 资料完整度数据对象 | + +#### `data` 对象字段 + +| 字段名 | 类型 | 描述 | +|--------|------|------| +| `completeness` | object | 各项资料的完成状态详情 | +| `completenessPercentage` | number | 资料完整度百分比(0-100) | +| `needsAttention` | boolean | 是否需要用户注意(提醒用户完善) | +| `missingItems` | array[string] | 缺失项的中文描述列表 | +| `isComplete` | boolean | 资料是否完全完整(100%) | +| `showReminder` | boolean | 是否显示提醒横幅(同 `needsAttention`) | + +#### `completeness` 对象字段 + +| 字段名 | 类型 | 描述 | +|--------|------|------| +| `hasPassword` | boolean | 是否已设置登录密码 | +| `hasPhone` | boolean | 是否已绑定手机号 | +| `hasEmail` | boolean | 是否已设置有效邮箱(排除临时邮箱) | +| `isWechatUser` | boolean | 是否为微信登录用户 | + +--- + +## 业务逻辑说明 + +### 资料完整度计算规则 + +1. **必填项**(共 3 项): + - 登录密码(`hasPassword`) + - 手机号(`hasPhone`) + - 邮箱(`hasEmail`) + +2. **完整度计算公式**: + ``` + completenessPercentage = (已完成项数 / 3) × 100 + ``` + 示例: + - 已完成 2 项 → 66% + - 已完成 3 项 → 100% + +3. **邮箱有效性判断**: + - 必须包含 `@` 符号 + - 不能是临时邮箱(如 `*@valuefrontier.temp`) + +### 提醒逻辑(`needsAttention`) + +**仅对微信登录用户进行提醒**,需同时满足以下条件: + +1. `isWechatUser === true`(微信登录用户) +2. `completenessPercentage < 100`(资料不完整) + +**后端额外的智能提醒策略**(Mock 模式未实现): + +- 新用户(注册 7 天内):更容易触发提醒 +- 每 7 天最多提醒一次(通过 session 记录) +- 完整度低于 50% 时优先提醒 + +### 缺失项列表(`missingItems`) + +根据 `completeness` 对象生成中文描述: + +| 条件 | 添加到 `missingItems` | +|------|----------------------| +| `!hasPassword` | `"登录密码"` | +| `!hasPhone` | `"手机号"` | +| `!hasEmail` | `"邮箱"` | + +--- + +## 响应示例 + +### 示例 1:手机号登录用户,资料完整 + +**场景**:手机号登录,已设置密码和邮箱 + +```json +{ + "success": true, + "data": { + "completeness": { + "hasPassword": true, + "hasPhone": true, + "hasEmail": true, + "isWechatUser": false + }, + "completenessPercentage": 100, + "needsAttention": false, + "missingItems": [], + "isComplete": true, + "showReminder": false + } +} +``` + +### 示例 2:微信登录用户,未绑定手机号 + +**场景**:微信登录,未设置密码和手机号,触发提醒 + +```json +{ + "success": true, + "data": { + "completeness": { + "hasPassword": false, + "hasPhone": false, + "hasEmail": true, + "isWechatUser": true + }, + "completenessPercentage": 33, + "needsAttention": true, + "missingItems": ["登录密码", "手机号"], + "isComplete": false, + "showReminder": true + } +} +``` + +### 示例 3:微信登录用户,只缺邮箱 + +**场景**:微信登录,已设置密码和手机号,只缺邮箱 + +```json +{ + "success": true, + "data": { + "completeness": { + "hasPassword": true, + "hasPhone": true, + "hasEmail": false, + "isWechatUser": true + }, + "completenessPercentage": 66, + "needsAttention": true, + "missingItems": ["邮箱"], + "isComplete": false, + "showReminder": true + } +} +``` + +--- + +## 错误响应 + +### 401 Unauthorized - 未登录 + +**场景**:用户未登录或 Session 已过期 + +```json +{ + "success": false, + "error": "用户未登录" +} +``` + +**HTTP 状态码**:`401` + +### 500 Internal Server Error - 服务器错误 + +**场景**:服务器内部错误(如数据库连接失败) + +```json +{ + "success": false, + "error": "获取资料完整性错误: [错误详情]" +} +``` + +**HTTP 状态码**:`500` + +--- + +## 调用示例 + +### JavaScript (Fetch API) + +```javascript +async function checkProfileCompleteness() { + try { + const response = await fetch('/api/account/profile-completeness', { + method: 'GET', + credentials: 'include', // 重要:携带 Cookie + headers: { + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + if (data.success) { + console.log('资料完整度:', data.data.completenessPercentage + '%'); + console.log('是否需要提醒:', data.data.needsAttention); + + if (data.data.needsAttention) { + console.log('缺失项:', data.data.missingItems.join('、')); + } + } + } catch (error) { + console.error('检查资料完整性失败:', error); + } +} +``` + +### cURL + +```bash +curl -X GET 'http://localhost:5001/api/account/profile-completeness' \ + -H 'Cookie: session=your_session_cookie_here' \ + -H 'Content-Type: application/json' +``` + +### Axios + +```javascript +import axios from 'axios'; + +async function checkProfileCompleteness() { + try { + const { data } = await axios.get('/api/account/profile-completeness', { + withCredentials: true // 携带 Cookie + }); + + if (data.success) { + return data.data; + } + } catch (error) { + if (error.response?.status === 401) { + console.error('用户未登录'); + } else { + console.error('检查失败:', error.message); + } + } +} +``` + +--- + +## 调用时机建议 + +### ✅ 推荐调用场景 + +1. **用户登录后**:首次登录或刷新页面后检查一次 +2. **资料更新后**:用户修改个人资料后重新检查 +3. **手动触发**:用户点击"检查资料完整度"按钮 + +### ❌ 避免的场景 + +1. **导航栏每次 render 时**:会导致频繁请求 +2. **组件重新渲染时**:应使用缓存或标志位避免重复 +3. **轮询调用**:此接口不需要轮询,用户资料变化频率低 + +### 最佳实践 + +```javascript +// 使用 React Hooks 的最佳实践 +function useProfileCompleteness() { + const [completeness, setCompleteness] = useState(null); + const hasChecked = useRef(false); + const { isAuthenticated, user } = useAuth(); + + const check = useCallback(async () => { + // 避免重复检查 + if (hasChecked.current) return; + + try { + const response = await fetch('/api/account/profile-completeness', { + credentials: 'include' + }); + + if (response.ok) { + const data = await response.json(); + if (data.success) { + setCompleteness(data.data); + hasChecked.current = true; // 标记已检查 + } + } + } catch (error) { + console.warn('检查失败:', error); + } + }, []); + + // 仅在登录后检查一次 + useEffect(() => { + if (isAuthenticated && user && !hasChecked.current) { + check(); + } + }, [isAuthenticated, user, check]); + + // 提供手动刷新方法 + const refresh = useCallback(() => { + hasChecked.current = false; + check(); + }, [check]); + + return { completeness, refresh }; +} +``` + +--- + +## Mock 模式说明 + +在 Mock 模式下(`REACT_APP_ENABLE_MOCK=true`),此接口由 MSW (Mock Service Worker) 拦截: + +### Mock 实现位置 + +- **Handler**: `src/mocks/handlers/account.js` +- **数据源**: `src/mocks/data/users.js` (getCurrentUser) + +### Mock 特点 + +1. **真实计算**:基于当前登录用户的实际数据计算完整度 +2. **状态同步**:与登录状态同步,登录后才返回真实用户数据 +3. **未登录返回 401**:模拟真实后端行为 +4. **延迟模拟**:300ms 网络延迟,模拟真实请求 + +### Mock 测试数据 + +| 测试账号 | 手机号 | 密码 | 邮箱 | 微信 | 完整度 | +|---------|--------|------|------|------|--------| +| 测试用户 | 13800138000 | ✅ | ❌ | ❌ | 66% | +| 投资达人 | 13900139000 | ✅ | ✅ | ✅ | 100% | + +--- + +## 前端集成示例 + +### 显示资料完整度横幅 + +```jsx +import { useProfileCompleteness } from './hooks/useProfileCompleteness'; + +function App() { + const { completeness } = useProfileCompleteness(); + + return ( + <> + {/* 资料完整度提醒横幅 */} + {completeness?.showReminder && ( + + + + 完善资料,享受更好服务 + + 您还需要设置:{completeness.missingItems.join('、')} + ({completeness.completenessPercentage}% 完成) + + + + + )} + + {/* 主要内容 */} + + + ); +} +``` + +--- + +## 版本历史 + +| 版本 | 日期 | 变更说明 | +|------|------|----------| +| v1.0 | 2024-10-17 | 初始版本,支持资料完整度检查和智能提醒 | + +--- + +## 相关接口 + +- `GET /api/auth/session` - 检查登录状态 +- `GET /api/account/profile` - 获取完整用户资料 +- `PUT /api/account/profile` - 更新用户资料 +- `POST /api/auth/logout` - 退出登录 + +--- + +## 技术支持 + +如有问题,请联系开发团队或查看: +- **Mock 配置指南**: [MOCK_GUIDE.md](./MOCK_GUIDE.md) +- **项目文档**: [CLAUDE.md](./CLAUDE.md) + +--- + +**文档生成日期**:2024-10-17 +**API 版本**:v1.0 +**Mock 支持**:✅ 已实现 diff --git a/MOCK_GUIDE.md b/MOCK_GUIDE.md new file mode 100644 index 00000000..8b0960cd --- /dev/null +++ b/MOCK_GUIDE.md @@ -0,0 +1,405 @@ +# Mock Service Worker 使用指南 + +本项目已集成 **Mock Service Worker (MSW)**,提供本地 Mock API 能力,无需依赖后端即可进行前端开发和测试。 + +## 📖 目录 + +1. [快速开始](#快速开始) +2. [启动方式](#启动方式) +3. [环境配置](#环境配置) +4. [Mock 数据说明](#mock-数据说明) +5. [如何添加新的 Mock API](#如何添加新的-mock-api) +6. [调试技巧](#调试技巧) +7. [常见问题](#常见问题) + +--- + +## 🚀 快速开始 + +### 方式一:启动 Mock 环境(使用本地 Mock 数据) + +```bash +npm run start:mock +``` + +启动后,浏览器控制台会显示: +``` +[MSW] Mock Service Worker 已启动 🎭 +提示: 所有 API 请求将使用本地 Mock 数据 +要禁用 Mock,请设置 REACT_APP_ENABLE_MOCK=false +``` + +### 方式二:启动开发环境(连接真实后端) + +```bash +npm run start:dev +# 或者直接使用 +npm start +``` + +--- + +## 📝 启动方式 + +| 命令 | 环境文件 | Mock 状态 | 用途 | +|------|---------|----------|------| +| `npm run start:mock` | `.env.mock` | ✅ 启用 | 本地开发,使用 Mock 数据 | +| `npm run start:dev` | `.env.development` | ❌ 禁用 | 连接真实后端 API | +| `npm start` | `.env` | ❌ 禁用 | 默认启动(连接后端) | + +--- + +## ⚙️ 环境配置 + +### `.env.mock` - Mock 测试环境 + +```env +# 启用 Mock 数据 +REACT_APP_ENABLE_MOCK=true + +# Mock 模式下不需要真实的后端地址 +REACT_APP_API_URL=http://localhost:3000 + +# Mock 环境标识 +REACT_APP_ENV=mock +``` + +### `.env.development` - 开发环境 + +```env +# 禁用 Mock 数据 +REACT_APP_ENABLE_MOCK=false + +# 真实的后端 API 地址 +REACT_APP_API_URL=http://49.232.185.254:5001 + +# 开发环境标识 +REACT_APP_ENV=development +``` + +### 如何切换环境? + +只需修改 `.env` 文件中的 `REACT_APP_ENABLE_MOCK` 参数: + +```env +# 启用 Mock +REACT_APP_ENABLE_MOCK=true + +# 禁用 Mock,使用真实 API +REACT_APP_ENABLE_MOCK=false +``` + +--- + +## 📦 Mock 数据说明 + +### 已实现的 Mock API + +#### 1. **认证相关 API** + +| API | 方法 | Mock 说明 | +|-----|------|----------| +| `/api/auth/send-verification-code` | POST | 发送验证码(控制台会打印验证码) | +| `/api/auth/login-with-code` | POST | 验证码登录(自动设置当前登录用户) | +| `/api/auth/wechat/qrcode` | GET | 获取微信二维码(10秒后自动模拟扫码) | +| `/api/auth/wechat/check-status` | POST | 检查微信扫码状态 | +| `/api/auth/wechat/login` | POST | 微信登录确认(自动设置当前登录用户) | +| `/api/auth/wechat/h5-auth-url` | POST | 获取微信 H5 授权链接 | +| `/api/auth/session` | GET | 检查 Session 状态(返回当前登录用户) | +| `/api/auth/check-session` | GET | 检查 Session 状态(旧端点,保留兼容) | +| `/api/auth/logout` | POST | 退出登录(清除当前登录用户) | + +**登录状态管理**: +- Mock 系统会跟踪当前登录的用户 +- 登录成功后,用户信息会保存到 Mock 状态中 +- `/api/auth/session` 会返回当前登录用户的真实信息 +- 退出登录会清除登录状态,下次检查 Session 返回未登录 + +#### 2. **账户管理 API** + +| API | 方法 | Mock 说明 | +|-----|------|----------| +| `/api/account/profile-completeness` | GET | 获取用户资料完整度(需要登录) | +| `/api/account/profile` | GET | 获取用户资料(需要登录) | +| `/api/account/profile` | PUT | 更新用户资料(需要登录) | +| `/api/subscription/info` | GET | 获取订阅信息(会员类型、状态、到期时间) | +| `/api/subscription/permissions` | GET | 获取订阅权限(各功能的访问权限) | + +**资料完整度说明**: +- 返回用户资料的完整度百分比(0-100%) +- 包含缺失项列表(密码、手机号、邮箱) +- 对微信登录用户,如果资料不完整会提示需要完善 +- Mock 模式会根据当前登录用户的真实信息计算完整度 + +**订阅信息说明**: +- 返回当前用户的会员类型(free/pro/max) +- 包含订阅状态(active/expired) +- 返回到期时间和剩余天数 +- 未登录用户默认返回 free 类型 + +### 测试账号 + +**手机号登录测试账号**: + +| 手机号 | 验证码 | 用户昵称 | 会员类型 | 状态 | 到期时间 | 剩余天数 | 功能权限 | +|--------|--------|---------|---------|------|---------|---------|----------| +| `13800138000` | 控制台查看 | 测试用户 | **Free**(免费) | ✅ 激活 | - | - | 基础功能 | +| `13900139000` | 控制台查看 | Pro会员 | **Pro** | ✅ 激活 | 2025-12-31 | 90天 | 高级功能(除传导链外) | +| `13700137000` | 控制台查看 | Max会员 | **Max** | ✅ 激活 | 2026-12-31 | 365天 | 🎉 全部功能 | +| `13600136000` | 控制台查看 | 过期会员 | Pro(已过期) | ❌ 过期 | 2024-01-01 | -300天 | 基础功能 | + +**会员权限对比**: + +| 功能 | Free | Pro | Max | +|------|------|-----|-----| +| 相关标的 | ❌ | ✅ | ✅ | +| 相关概念 | ❌ | ✅ | ✅ | +| 事件传导链 | ❌ | ❌ | ✅ | +| 历史事件对比 | 🔒 限制版 | ✅ 完整版 | ✅ 完整版 | +| 概念详情 | ❌ | ✅ | ✅ | +| 概念统计中心 | ❌ | ✅ | ✅ | +| 概念相关股票 | ❌ | ✅ | ✅ | +| 概念历史时间轴 | ❌ | ❌ | ✅ | +| 热门个股 | ❌ | ✅ | ✅ | + +**验证码说明**: +- 发送验证码后,控制台会打印验证码 +- 示例:`[Mock] 验证码已生成: 13800138000 -> 123456` +- 验证码有效期:5分钟 +- 所有测试账号都可以使用相同的验证码登录 + +**微信登录测试**: +1. 点击"获取二维码" +2. 等待 10 秒,自动模拟用户扫码 +3. 再等待 5 秒,自动模拟用户确认 +4. 登录成功 + +--- + +## 🛠️ 如何添加新的 Mock API + +### 步骤 1:创建新的 Handler 文件 + +在 `src/mocks/handlers/` 目录下创建新文件,例如 `user.js`: + +```javascript +// src/mocks/handlers/user.js +import { http, HttpResponse, delay } from 'msw'; + +const NETWORK_DELAY = 500; + +export const userHandlers = [ + // 获取用户信息 + http.get('/api/user/profile', async () => { + await delay(NETWORK_DELAY); + + return HttpResponse.json({ + success: true, + data: { + id: 1, + nickname: '测试用户', + email: 'test@example.com', + avatar_url: 'https://i.pravatar.cc/150?img=1' + } + }); + }), + + // 更新用户信息 + http.put('/api/user/profile', async ({ request }) => { + await delay(NETWORK_DELAY); + + const body = await request.json(); + + return HttpResponse.json({ + success: true, + message: '更新成功', + data: body + }); + }) +]; +``` + +### 步骤 2:注册 Handler + +在 `src/mocks/handlers/index.js` 中导入并注册: + +```javascript +// src/mocks/handlers/index.js +import { authHandlers } from './auth'; +import { userHandlers } from './user'; // 导入新的 handler + +export const handlers = [ + ...authHandlers, + ...userHandlers, // 注册新的 handler +]; +``` + +### 步骤 3:重启应用 + +```bash +# 停止当前服务(Ctrl+C) +# 重新启动 +npm run start:mock +``` + +--- + +## 🐛 调试技巧 + +### 1. 查看 Mock 日志 + +所有 Mock API 请求都会在浏览器控制台打印日志: + +``` +[Mock] 发送验证码: {credential: "13800138000", type: "phone", purpose: "login"} +[Mock] 验证码已生成: 13800138000 -> 654321 +[Mock] 登录成功: {id: 1, phone: "13800138000", nickname: "测试用户", ...} +``` + +### 2. 检查 MSW 是否启动 + +打开浏览器控制台,查找以下消息: + +``` +[MSW] Mock Service Worker 已启动 🎭 +``` + +如果没有看到此消息,检查: +1. `.env.mock` 文件中 `REACT_APP_ENABLE_MOCK=true` +2. 是否使用 `npm run start:mock` 启动 + +### 3. 网络面板调试 + +打开浏览器开发者工具 → Network 标签页: +- Mock 的请求会显示 `(from ServiceWorker)` 标签 +- 可以查看请求和响应的详细信息 + +### 4. 模拟网络延迟 + +在 `src/mocks/handlers/*.js` 文件中修改延迟时间: + +```javascript +const NETWORK_DELAY = 2000; // 改为 2 秒 +``` + +### 5. 模拟错误响应 + +```javascript +http.post('/api/some-endpoint', async () => { + await delay(NETWORK_DELAY); + + // 返回 400 错误 + return HttpResponse.json({ + success: false, + error: '参数错误' + }, { status: 400 }); +}); +``` + +--- + +## ❓ 常见问题 + +### Q1: Mock 没有生效,请求仍然发送到真实服务器 + +**解决方案**: +1. 检查 `.env.mock` 文件中 `REACT_APP_ENABLE_MOCK=true` +2. 确保使用 `npm run start:mock` 启动 +3. 清除浏览器缓存并刷新页面 +4. 检查控制台是否有 MSW 启动消息 + +### Q2: 控制台显示 `[MSW] 启动失败` + +**解决方案**: +1. 确保 `public/mockServiceWorker.js` 文件存在 +2. 重新初始化 MSW: + ```bash + npx msw init public/ --save + ``` +3. 重启开发服务器 + +### Q3: 如何禁用某个特定 API 的 Mock? + +在 `src/mocks/handlers/index.js` 中注释掉相应的 handler: + +```javascript +export const handlers = [ + ...authHandlers, + // ...userHandlers, // 禁用 user 相关的 Mock +]; +``` + +### Q4: 验证码是什么? + +发送验证码后,控制台会打印验证码: + +``` +[Mock] 验证码已生成: 13800138000 -> 123456 +``` + +复制 `123456` 并填入验证码输入框即可。 + +### Q5: 微信登录如何测试? + +1. 点击"获取二维码" +2. 等待 10 秒(自动模拟扫码) +3. 再等待 5 秒(自动模拟确认) +4. 自动完成登录 + +或者在控制台查看 Mock 日志: +``` +[Mock] 生成微信二维码: {sessionId: "wx_abc123", ...} +[Mock] 模拟用户扫码: wx_abc123 +[Mock] 模拟用户确认登录: wx_abc123 +``` + +### Q6: 生产环境会使用 Mock 数据吗? + +**不会**。Mock 只在以下情况启用: +1. `NODE_ENV === 'development'`(开发环境) +2. `REACT_APP_ENABLE_MOCK === 'true'` + +生产环境 (`npm run build`) 会自动排除 MSW 代码。 + +--- + +## 📁 项目结构 + +``` +src/ +├── mocks/ +│ ├── handlers/ +│ │ ├── auth.js # 认证相关 Mock +│ │ ├── index.js # Handler 总入口 +│ │ └── ... # 其他 Handler 文件 +│ ├── data/ +│ │ └── users.js # Mock 用户数据 +│ └── browser.js # MSW 浏览器 Worker +├── index.js # 应用入口(集成 MSW) +└── ... + +public/ +└── mockServiceWorker.js # MSW Service Worker 文件 +``` + +--- + +## 📚 相关资源 + +- [MSW 官方文档](https://mswjs.io/) +- [MSW 快速开始](https://mswjs.io/docs/getting-started) +- [MSW API 参考](https://mswjs.io/docs/api) + +--- + +## 🎯 最佳实践 + +1. **使用真实的响应结构**:Mock 数据应与真实 API 返回的数据结构一致 +2. **添加网络延迟**:模拟真实的网络请求延迟,测试加载状态 +3. **测试边界情况**:创建错误响应的 Mock,测试错误处理逻辑 +4. **保持 Mock 数据更新**:当真实 API 变化时,及时更新 Mock handlers +5. **团队协作**:将 Mock 配置提交到 Git,团队成员共享 + +--- + +**提示**:如有任何问题或建议,请联系开发团队。Happy Mocking! 🎭 diff --git a/package.json b/package.json index d7633a9d..5ef170c7 100755 --- a/package.json +++ b/package.json @@ -91,6 +91,8 @@ }, "scripts": { "start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco start", + "start:mock": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.mock craco start", + "start:dev": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.development craco start", "build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco build && gulp licenses", "build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build", "test": "craco test --env=jsdom", @@ -104,6 +106,7 @@ "@craco/craco": "^7.1.0", "ajv": "^8.17.1", "autoprefixer": "^10.4.21", + "env-cmd": "^11.0.0", "eslint-config-prettier": "8.3.0", "eslint-plugin-prettier": "3.4.0", "gulp": "4.0.2", @@ -111,6 +114,7 @@ "imagemin": "^9.0.1", "imagemin-mozjpeg": "^10.0.0", "imagemin-pngquant": "^10.0.0", + "msw": "^2.11.5", "postcss": "^8.5.6", "prettier": "2.2.1", "react-error-overlay": "6.0.9", @@ -131,5 +135,10 @@ "not dead", "not op_mini all" ] + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 00000000..73b493ca --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,349 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.11.5' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/src/components/Auth/AuthFormContent.js b/src/components/Auth/AuthFormContent.js index 38feb673..84ea9987 100644 --- a/src/components/Auth/AuthFormContent.js +++ b/src/components/Auth/AuthFormContent.js @@ -446,10 +446,10 @@ export default function AuthFormContent() { 完善个人信息 - 您已成功注册!是否前往个人中心设置昵称和其他信息? + 您已成功注册!是否前往个人资料设置昵称和其他信息? - + diff --git a/src/components/Auth/VerificationCodeInput.js b/src/components/Auth/VerificationCodeInput.js index bc251055..9ee6b923 100644 --- a/src/components/Auth/VerificationCodeInput.js +++ b/src/components/Auth/VerificationCodeInput.js @@ -1,5 +1,5 @@ import React from "react"; -import { FormControl, FormErrorMessage, HStack, Input, Button } from "@chakra-ui/react"; +import { FormControl, FormErrorMessage, HStack, Input, Button, Spinner } from "@chakra-ui/react"; /** * 通用验证码输入组件 @@ -30,6 +30,17 @@ export default function VerificationCodeInput({ } }; + // 计算按钮显示的文本(避免在 JSX 中使用条件渲染) + const getButtonText = () => { + if (isSending) { + return "发送中"; + } + if (countdown > 0) { + return countdownText(countdown); + } + return buttonText; + }; + return ( @@ -41,13 +52,14 @@ export default function VerificationCodeInput({ maxLength={6} /> {error} diff --git a/src/components/ErrorBoundary.js b/src/components/ErrorBoundary.js index de2176b5..317e79ae 100755 --- a/src/components/ErrorBoundary.js +++ b/src/components/ErrorBoundary.js @@ -17,10 +17,25 @@ class ErrorBoundary extends React.Component { } static getDerivedStateFromError(error) { + // 开发环境:不拦截错误,让 React DevTools 显示完整堆栈 + if (process.env.NODE_ENV === 'development') { + return { hasError: false }; + } + // 生产环境:拦截错误,显示友好界面 return { hasError: true }; } componentDidCatch(error, errorInfo) { + // 开发环境:打印错误到控制台,但不显示错误边界 + if (process.env.NODE_ENV === 'development') { + console.error('🔴 ErrorBoundary 捕获到错误(开发模式,不拦截):'); + console.error('错误:', error); + console.error('错误信息:', errorInfo); + // 不更新 state,让错误继续抛出 + return; + } + + // 生产环境:保存错误信息到 state this.setState({ error: error, errorInfo: errorInfo @@ -28,6 +43,12 @@ class ErrorBoundary extends React.Component { } render() { + // 开发环境:直接渲染子组件,不显示错误边界 + if (process.env.NODE_ENV === 'development') { + return this.props.children; + } + + // 生产环境:如果有错误,显示错误边界 if (this.state.hasError) { return ( @@ -57,7 +78,7 @@ class ErrorBoundary extends React.Component { 错误详情: {this.state.error && this.state.error.toString()} - {this.state.errorInfo.componentStack} + {this.state.errorInfo && this.state.errorInfo.componentStack} )} diff --git a/src/components/Navbars/HomeNavbar.js b/src/components/Navbars/HomeNavbar.js index f9bcf1a0..9f02f7a6 100644 --- a/src/components/Navbars/HomeNavbar.js +++ b/src/components/Navbars/HomeNavbar.js @@ -238,6 +238,9 @@ const NavItems = ({ isAuthenticated, user }) => { } } +// 计算 API 基础地址(移到组件外部,避免每次 render 重新创建) +const getApiBase = () => (process.env.NODE_ENV === 'production' ? '' : (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001')); + export default function HomeNavbar() { const { isOpen, onOpen, onClose } = useDisclosure(); const navigate = useNavigate(); @@ -269,6 +272,10 @@ export default function HomeNavbar() { const handleLogout = async () => { try { await logout(); + // 重置资料完整性检查标志 + hasCheckedCompleteness.current = false; + setProfileCompleteness(null); + setShowCompletenessAlert(false); // logout函数已经包含了跳转逻辑,这里不需要额外处理 } catch (error) { console.error('Logout error:', error); @@ -293,13 +300,13 @@ export default function HomeNavbar() { const [profileCompleteness, setProfileCompleteness] = useState(null); const [showCompletenessAlert, setShowCompletenessAlert] = useState(false); - // 计算 API 基础地址(与 Center.js 一致的策略) - const getApiBase = () => (process.env.NODE_ENV === 'production' ? '' : (process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001')); + // 添加标志位:追踪是否已经检查过资料完整性(避免重复请求) + const hasCheckedCompleteness = React.useRef(false); const loadWatchlistQuotes = useCallback(async () => { try { setWatchlistLoading(true); - const base = getApiBase(); + const base = getApiBase(); // 使用外部函数 const resp = await fetch(base + '/api/account/watchlist/realtime', { credentials: 'include', cache: 'no-store', @@ -321,7 +328,7 @@ export default function HomeNavbar() { } finally { setWatchlistLoading(false); } - }, []); + }, []); // getApiBase 是外部函数,不需要作为依赖 const loadFollowingEvents = useCallback(async () => { try { @@ -369,7 +376,7 @@ export default function HomeNavbar() { } finally { setEventsLoading(false); } - }, []); + }, []); // getApiBase 是外部函数,不需要作为依赖 // 从自选股移除 const handleRemoveFromWatchlist = useCallback(async (stockCode) => { @@ -399,7 +406,7 @@ export default function HomeNavbar() { } catch (e) { toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 }); } - }, [getApiBase, toast, WATCHLIST_PAGE_SIZE]); + }, [toast]); // WATCHLIST_PAGE_SIZE 是常量,getApiBase 是外部函数,不需要作为依赖 // 取消关注事件 const handleUnfollowEvent = useCallback(async (eventId) => { @@ -424,13 +431,20 @@ export default function HomeNavbar() { } catch (e) { toast({ title: '网络错误,操作失败', status: 'error', duration: 2000 }); } - }, [getApiBase, toast, EVENTS_PAGE_SIZE]); + }, [toast]); // EVENTS_PAGE_SIZE 是常量,getApiBase 是外部函数,不需要作为依赖 // 检查用户资料完整性 const checkProfileCompleteness = useCallback(async () => { if (!isAuthenticated || !user) return; + // 如果已经检查过,跳过(避免重复请求) + if (hasCheckedCompleteness.current) { + console.log('[Profile] 已检查过资料完整性,跳过重复请求'); + return; + } + try { + console.log('[Profile] 开始检查资料完整性...'); const base = getApiBase(); const resp = await fetch(base + '/api/account/profile-completeness', { credentials: 'include' @@ -442,12 +456,25 @@ export default function HomeNavbar() { setProfileCompleteness(data.data); // 只有微信用户且资料不完整时才显示提醒 setShowCompletenessAlert(data.data.needsAttention); + // 标记为已检查 + hasCheckedCompleteness.current = true; + console.log('[Profile] 资料完整性检查完成:', data.data); } } } catch (error) { console.warn('检查资料完整性失败:', error); } - }, [isAuthenticated, user, getApiBase]); + }, [isAuthenticated, user]); // 移除 getApiBase 依赖,因为它现在在组件外部 + + // 监听用户变化,重置检查标志(用户切换或退出登录时) + React.useEffect(() => { + if (!isAuthenticated || !user) { + // 用户退出登录,重置标志 + hasCheckedCompleteness.current = false; + setProfileCompleteness(null); + setShowCompletenessAlert(false); + } + }, [isAuthenticated, user?.id]); // 监听用户 ID 变化 // 用户登录后检查资料完整性 React.useEffect(() => { diff --git a/src/index.js b/src/index.js index 610df594..5fd7dfcb 100755 --- a/src/index.js +++ b/src/index.js @@ -11,14 +11,26 @@ import './styles/brainwave-colors.css'; // Import the main App component import App from './App'; -// Create root -const root = ReactDOM.createRoot(document.getElementById('root')); +// 启动 Mock Service Worker(如果启用) +async function startApp() { + // 只在开发环境启动 MSW + if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_ENABLE_MOCK === 'true') { + const { startMockServiceWorker } = await import('./mocks/browser'); + await startMockServiceWorker(); + } -// Render the app with Router wrapper -root.render( - - - - - -); \ No newline at end of file + // Create root + const root = ReactDOM.createRoot(document.getElementById('root')); + + // Render the app with Router wrapper + root.render( + + + + + + ); +} + +// 启动应用 +startApp(); \ No newline at end of file diff --git a/src/mocks/browser.js b/src/mocks/browser.js new file mode 100644 index 00000000..ebdb6f96 --- /dev/null +++ b/src/mocks/browser.js @@ -0,0 +1,61 @@ +// src/mocks/browser.js +// 浏览器环境的 MSW Worker + +import { setupWorker } from 'msw/browser'; +import { handlers } from './handlers'; + +// 创建 Service Worker 实例 +export const worker = setupWorker(...handlers); + +// 启动 Mock Service Worker +export async function startMockServiceWorker() { + // 只在开发环境且 REACT_APP_ENABLE_MOCK=true 时启动 + const shouldEnableMock = process.env.REACT_APP_ENABLE_MOCK === 'true'; + + if (!shouldEnableMock) { + console.log('[MSW] Mock 已禁用 (REACT_APP_ENABLE_MOCK=false)'); + return; + } + + try { + await worker.start({ + // 不显示未拦截的请求警告(可选) + onUnhandledRequest: 'bypass', + + // 自定义 Service Worker URL(如果需要) + serviceWorker: { + url: '/mockServiceWorker.js', + }, + + // 静默模式(不在控制台打印启动消息) + quiet: false, + }); + + console.log( + '%c[MSW] Mock Service Worker 已启动 🎭', + 'color: #4CAF50; font-weight: bold; font-size: 14px;' + ); + console.log( + '%c提示: 所有 API 请求将使用本地 Mock 数据', + 'color: #FF9800; font-size: 12px;' + ); + console.log( + '%c要禁用 Mock,请设置 REACT_APP_ENABLE_MOCK=false', + 'color: #2196F3; font-size: 12px;' + ); + } catch (error) { + console.error('[MSW] 启动失败:', error); + } +} + +// 停止 Mock Service Worker +export function stopMockServiceWorker() { + worker.stop(); + console.log('[MSW] Mock Service Worker 已停止'); +} + +// 重置所有 Handlers +export function resetMockHandlers() { + worker.resetHandlers(); + console.log('[MSW] Handlers 已重置'); +} diff --git a/src/mocks/data/users.js b/src/mocks/data/users.js new file mode 100644 index 00000000..cd1f1efb --- /dev/null +++ b/src/mocks/data/users.js @@ -0,0 +1,107 @@ +// Mock 用户数据 +export const mockUsers = { + // 免费用户 - 手机号登录 + '13800138000': { + id: 1, + phone: '13800138000', + nickname: '测试用户', + email: 'test@example.com', + avatar_url: 'https://i.pravatar.cc/150?img=1', + has_wechat: false, + created_at: '2024-01-01T00:00:00Z', + // 会员信息 - 免费用户 + subscription_type: 'free', + subscription_status: 'active', + subscription_end_date: null, + is_subscription_active: true, + subscription_days_left: 0 + }, + + // Pro 会员 - 手机号登录 + '13900139000': { + id: 2, + phone: '13900139000', + nickname: 'Pro会员', + email: 'pro@example.com', + avatar_url: 'https://i.pravatar.cc/150?img=2', + has_wechat: true, + created_at: '2024-01-15T00:00:00Z', + // 会员信息 - Pro 会员 + subscription_type: 'pro', + subscription_status: 'active', + subscription_end_date: '2025-12-31T23:59:59Z', + is_subscription_active: true, + subscription_days_left: 90 + }, + + // Max 会员 - 手机号登录 + '13700137000': { + id: 3, + phone: '13700137000', + nickname: 'Max会员', + email: 'max@example.com', + avatar_url: 'https://i.pravatar.cc/150?img=3', + has_wechat: false, + created_at: '2024-02-01T00:00:00Z', + // 会员信息 - Max 会员 + subscription_type: 'max', + subscription_status: 'active', + subscription_end_date: '2026-12-31T23:59:59Z', + is_subscription_active: true, + subscription_days_left: 365 + }, + + // 过期会员 - 测试过期状态 + '13600136000': { + id: 4, + phone: '13600136000', + nickname: '过期会员', + email: 'expired@example.com', + avatar_url: 'https://i.pravatar.cc/150?img=4', + has_wechat: false, + created_at: '2023-01-01T00:00:00Z', + // 会员信息 - 已过期 + subscription_type: 'pro', + subscription_status: 'expired', + subscription_end_date: '2024-01-01T00:00:00Z', + is_subscription_active: false, + subscription_days_left: -300 + } +}; + +// Mock 验证码存储(实际项目中应该在后端验证) +export const mockVerificationCodes = new Map(); + +// 生成随机6位验证码 +export function generateVerificationCode() { + return Math.floor(100000 + Math.random() * 900000).toString(); +} + +// 微信 session 存储 +export const mockWechatSessions = new Map(); + +// 生成微信 session ID +export function generateWechatSessionId() { + return 'wx_' + Math.random().toString(36).substring(2, 15); +} + +// ==================== 当前登录用户状态管理 ==================== +// 用于跟踪当前登录的用户(Mock 模式下的全局状态) +let currentLoggedInUser = null; + +// 设置当前登录用户 +export function setCurrentUser(user) { + currentLoggedInUser = user; + console.log('[Mock State] 设置当前登录用户:', user); +} + +// 获取当前登录用户 +export function getCurrentUser() { + return currentLoggedInUser; +} + +// 清除当前登录用户 +export function clearCurrentUser() { + currentLoggedInUser = null; + console.log('[Mock State] 清除当前登录用户'); +} diff --git a/src/mocks/handlers/account.js b/src/mocks/handlers/account.js new file mode 100644 index 00000000..650faaef --- /dev/null +++ b/src/mocks/handlers/account.js @@ -0,0 +1,248 @@ +// src/mocks/handlers/account.js +import { http, HttpResponse, delay } from 'msw'; +import { getCurrentUser } from '../data/users'; + +// 模拟网络延迟(毫秒) +const NETWORK_DELAY = 300; + +export const accountHandlers = [ + // ==================== 用户资料管理 ==================== + + // 1. 获取资料完整度 + http.get('/api/account/profile-completeness', async () => { + await delay(NETWORK_DELAY); + + // 获取当前登录用户 + const currentUser = getCurrentUser(); + + // 如果没有登录,返回 401 + if (!currentUser) { + return HttpResponse.json({ + success: false, + error: '用户未登录' + }, { status: 401 }); + } + + console.log('[Mock] 获取资料完整度:', currentUser); + + // 检查用户是否是微信用户 + const isWechatUser = currentUser.has_wechat || !!currentUser.wechat_openid; + + // 检查各项信息 + const completeness = { + hasPassword: !!currentUser.password_hash || !isWechatUser, // 非微信用户默认有密码 + hasPhone: !!currentUser.phone, + hasEmail: !!currentUser.email && currentUser.email.includes('@') && !currentUser.email.endsWith('@valuefrontier.temp'), + isWechatUser: isWechatUser + }; + + // 计算完整度 + const totalItems = 3; + const completedItems = [completeness.hasPassword, completeness.hasPhone, completeness.hasEmail].filter(Boolean).length; + const completenessPercentage = Math.round((completedItems / totalItems) * 100); + + // 智能判断是否需要提醒 + let needsAttention = false; + const missingItems = []; + + // Mock 模式下,对微信用户进行简化判断 + if (isWechatUser && completenessPercentage < 100) { + needsAttention = true; + if (!completeness.hasPassword) { + missingItems.push('登录密码'); + } + if (!completeness.hasPhone) { + missingItems.push('手机号'); + } + if (!completeness.hasEmail) { + missingItems.push('邮箱'); + } + } + + const result = { + success: true, + data: { + completeness, + completenessPercentage, + needsAttention, + missingItems, + isComplete: completedItems === totalItems, + showReminder: needsAttention + } + }; + + console.log('[Mock] 资料完整度结果:', result.data); + + return HttpResponse.json(result); + }), + + // 2. 更新用户资料 + http.put('/api/account/profile', async ({ request }) => { + await delay(NETWORK_DELAY); + + // 获取当前登录用户 + const currentUser = getCurrentUser(); + + if (!currentUser) { + return HttpResponse.json({ + success: false, + error: '用户未登录' + }, { status: 401 }); + } + + const body = await request.json(); + + console.log('[Mock] 更新用户资料:', body); + + // 在 Mock 模式下,我们直接更新当前用户对象(实际应该调用 setCurrentUser) + Object.assign(currentUser, body); + + return HttpResponse.json({ + success: true, + message: '资料更新成功', + data: currentUser + }); + }), + + // 3. 获取用户资料 + http.get('/api/account/profile', async () => { + await delay(NETWORK_DELAY); + + const currentUser = getCurrentUser(); + + if (!currentUser) { + return HttpResponse.json({ + success: false, + error: '用户未登录' + }, { status: 401 }); + } + + console.log('[Mock] 获取用户资料:', currentUser); + + return HttpResponse.json({ + success: true, + data: currentUser + }); + }), + + // ==================== 订阅管理 ==================== + + // 4. 获取订阅信息 + http.get('/api/subscription/info', async () => { + await delay(NETWORK_DELAY); + + const currentUser = getCurrentUser(); + + // 未登录时返回免费用户信息 + if (!currentUser) { + return HttpResponse.json({ + success: true, + data: { + type: 'free', + status: 'active', + is_active: true, + days_left: 0, + end_date: null + } + }); + } + + console.log('[Mock] 获取订阅信息:', currentUser); + + // 从当前用户对象中获取订阅信息 + const subscriptionInfo = { + type: currentUser.subscription_type || 'free', + status: currentUser.subscription_status || 'active', + is_active: currentUser.is_subscription_active !== false, + days_left: currentUser.subscription_days_left || 0, + end_date: currentUser.subscription_end_date || null + }; + + console.log('[Mock] 订阅信息结果:', subscriptionInfo); + + return HttpResponse.json({ + success: true, + data: subscriptionInfo + }); + }), + + // 5. 获取订阅权限 + http.get('/api/subscription/permissions', async () => { + await delay(NETWORK_DELAY); + + const currentUser = getCurrentUser(); + + // 未登录时返回免费权限 + if (!currentUser) { + return HttpResponse.json({ + success: true, + data: { + permissions: { + 'related_stocks': false, + 'related_concepts': false, + 'transmission_chain': false, + 'historical_events': 'limited', + 'concept_html_detail': false, + 'concept_stats_panel': false, + 'concept_related_stocks': false, + 'concept_timeline': false, + 'hot_stocks': false + } + } + }); + } + + const subscriptionType = (currentUser.subscription_type || 'free').toLowerCase(); + + // 根据订阅类型返回对应权限 + let permissions = {}; + + if (subscriptionType === 'free') { + permissions = { + 'related_stocks': false, + 'related_concepts': false, + 'transmission_chain': false, + 'historical_events': 'limited', + 'concept_html_detail': false, + 'concept_stats_panel': false, + 'concept_related_stocks': false, + 'concept_timeline': false, + 'hot_stocks': false + }; + } else if (subscriptionType === 'pro') { + permissions = { + 'related_stocks': true, + 'related_concepts': true, + 'transmission_chain': false, + 'historical_events': 'full', + 'concept_html_detail': true, + 'concept_stats_panel': true, + 'concept_related_stocks': true, + 'concept_timeline': false, + 'hot_stocks': true + }; + } else if (subscriptionType === 'max') { + permissions = { + 'related_stocks': true, + 'related_concepts': true, + 'transmission_chain': true, + 'historical_events': 'full', + 'concept_html_detail': true, + 'concept_stats_panel': true, + 'concept_related_stocks': true, + 'concept_timeline': true, + 'hot_stocks': true + }; + } + + console.log('[Mock] 订阅权限:', { subscriptionType, permissions }); + + return HttpResponse.json({ + success: true, + data: { + subscription_type: subscriptionType, + permissions + } + }); + }) +]; diff --git a/src/mocks/handlers/auth.js b/src/mocks/handlers/auth.js new file mode 100644 index 00000000..1177e760 --- /dev/null +++ b/src/mocks/handlers/auth.js @@ -0,0 +1,330 @@ +// src/mocks/handlers/auth.js +import { http, HttpResponse, delay } from 'msw'; +import { + mockUsers, + mockVerificationCodes, + generateVerificationCode, + mockWechatSessions, + generateWechatSessionId, + setCurrentUser, + getCurrentUser, + clearCurrentUser +} from '../data/users'; + +// 模拟网络延迟(毫秒) +const NETWORK_DELAY = 500; + +export const authHandlers = [ + // ==================== 手机验证码登录 ==================== + + // 1. 发送验证码 + http.post('/api/auth/send-verification-code', async ({ request }) => { + await delay(NETWORK_DELAY); + + const body = await request.json(); + const { credential, type, purpose } = body; + + console.log('[Mock] 发送验证码:', { credential, type, purpose }); + + // 生成验证码 + const code = generateVerificationCode(); + mockVerificationCodes.set(credential, { + code, + expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期 + }); + + console.log(`[Mock] 验证码已生成: ${credential} -> ${code}`); + + return HttpResponse.json({ + success: true, + message: `验证码已发送到 ${credential}(Mock: ${code})`, + // 开发环境下返回验证码,方便测试 + dev_code: code + }); + }), + + // 2. 验证码登录 + http.post('/api/auth/login-with-code', async ({ request }) => { + await delay(NETWORK_DELAY); + + const body = await request.json(); + const { credential, verification_code, login_type } = body; + + console.log('[Mock] 验证码登录:', { credential, verification_code, login_type }); + + // 验证验证码 + const storedCode = mockVerificationCodes.get(credential); + if (!storedCode) { + return HttpResponse.json({ + success: false, + error: '验证码不存在或已过期' + }, { status: 400 }); + } + + if (storedCode.expiresAt < Date.now()) { + mockVerificationCodes.delete(credential); + return HttpResponse.json({ + success: false, + error: '验证码已过期' + }, { status: 400 }); + } + + if (storedCode.code !== verification_code) { + return HttpResponse.json({ + success: false, + error: '验证码错误' + }, { status: 400 }); + } + + // 验证成功,删除验证码 + mockVerificationCodes.delete(credential); + + // 查找或创建用户 + let user = mockUsers[credential]; + let isNewUser = false; + + if (!user) { + // 新用户 + isNewUser = true; + const id = Object.keys(mockUsers).length + 1; + user = { + id, + phone: credential, + nickname: `用户${id}`, + email: null, + avatar_url: `https://i.pravatar.cc/150?img=${id}`, + has_wechat: false, + created_at: new Date().toISOString() + }; + mockUsers[credential] = user; + console.log('[Mock] 创建新用户:', user); + } + + console.log('[Mock] 登录成功:', user); + + // 设置当前登录用户 + setCurrentUser(user); + + return HttpResponse.json({ + success: true, + message: isNewUser ? '注册成功' : '登录成功', + isNewUser, + user, + token: `mock_token_${user.id}_${Date.now()}` + }); + }), + + // ==================== 微信登录 ==================== + + // 3. 获取微信 PC 二维码 + http.get('/api/auth/wechat/qrcode', async () => { + await delay(NETWORK_DELAY); + + const sessionId = generateWechatSessionId(); + + // 创建微信 session + mockWechatSessions.set(sessionId, { + status: 'waiting', // waiting, scanned, confirmed, expired + createdAt: Date.now(), + user: null + }); + + // 模拟微信授权 URL(实际是微信的 URL) + const authUrl = `https://open.weixin.qq.com/connect/qrconnect?appid=mock&redirect_uri=&response_type=code&scope=snsapi_login&state=${sessionId}#wechat_redirect`; + + console.log('[Mock] 生成微信二维码:', { sessionId, authUrl }); + + // 10秒后自动模拟扫码(方便测试) + setTimeout(() => { + const session = mockWechatSessions.get(sessionId); + if (session && session.status === 'waiting') { + session.status = 'scanned'; + console.log(`[Mock] 模拟用户扫码: ${sessionId}`); + + // 再过5秒自动确认登录 + setTimeout(() => { + const session2 = mockWechatSessions.get(sessionId); + if (session2 && session2.status === 'scanned') { + session2.status = 'confirmed'; + session2.user = { + id: 999, + nickname: '微信用户', + wechat_openid: 'mock_openid_' + sessionId, + avatar_url: 'https://i.pravatar.cc/150?img=99', + phone: null, + email: null, + has_wechat: true, + created_at: new Date().toISOString() + }; + console.log(`[Mock] 模拟用户确认登录: ${sessionId}`, session2.user); + } + }, 5000); + } + }, 10000); + + return HttpResponse.json({ + code: 0, + message: '成功', + data: { + auth_url: authUrl, + session_id: sessionId + } + }); + }), + + // 4. 检查微信扫码状态 + http.post('/api/auth/wechat/check-status', async ({ request }) => { + await delay(200); // 轮询请求,延迟短一些 + + const body = await request.json(); + const { session_id } = body; + + const session = mockWechatSessions.get(session_id); + + if (!session) { + return HttpResponse.json({ + code: 404, + message: 'Session 不存在', + data: { status: 'expired' } + }); + } + + // 检查是否过期(5分钟) + if (Date.now() - session.createdAt > 5 * 60 * 1000) { + session.status = 'expired'; + mockWechatSessions.delete(session_id); + } + + console.log('[Mock] 检查微信状态:', { session_id, status: session.status }); + + return HttpResponse.json({ + code: 0, + message: '成功', + data: { + status: session.status, + user: session.user + } + }); + }), + + // 5. 微信登录确认 + http.post('/api/auth/wechat/login', async ({ request }) => { + await delay(NETWORK_DELAY); + + const body = await request.json(); + const { session_id } = body; + + const session = mockWechatSessions.get(session_id); + + if (!session || session.status !== 'confirmed') { + return HttpResponse.json({ + success: false, + error: '微信登录未确认或已过期' + }, { status: 400 }); + } + + const user = session.user; + + // 清理 session + mockWechatSessions.delete(session_id); + + console.log('[Mock] 微信登录成功:', user); + + // 设置当前登录用户 + setCurrentUser(user); + + return HttpResponse.json({ + success: true, + message: '微信登录成功', + user, + token: `mock_wechat_token_${user.id}_${Date.now()}` + }); + }), + + // 6. 获取微信 H5 授权 URL + http.post('/api/auth/wechat/h5-auth-url', async ({ request }) => { + await delay(NETWORK_DELAY); + + const body = await request.json(); + const { redirect_url } = body; + + const state = generateWechatSessionId(); + const authUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=mock&redirect_uri=${encodeURIComponent(redirect_url)}&response_type=code&scope=snsapi_userinfo&state=${state}#wechat_redirect`; + + console.log('[Mock] 生成微信 H5 授权 URL:', authUrl); + + return HttpResponse.json({ + code: 0, + message: '成功', + data: { + auth_url: authUrl, + state + } + }); + }), + + // ==================== Session 管理 ==================== + + // 7. 检查 Session(AuthContext 使用的正确端点) + http.get('/api/auth/session', async () => { + await delay(300); + + // 获取当前登录用户 + const currentUser = getCurrentUser(); + + console.log('[Mock] 检查 Session:', currentUser); + + if (currentUser) { + return HttpResponse.json({ + success: true, + isAuthenticated: true, + user: currentUser + }); + } else { + return HttpResponse.json({ + success: true, + isAuthenticated: false, + user: null + }); + } + }), + + // 8. 检查 Session(旧端点,保留兼容) + http.get('/api/auth/check-session', async () => { + await delay(300); + + // 获取当前登录用户 + const currentUser = getCurrentUser(); + + console.log('[Mock] 检查 Session (旧端点):', currentUser); + + if (currentUser) { + return HttpResponse.json({ + success: true, + isAuthenticated: true, + user: currentUser + }); + } else { + return HttpResponse.json({ + success: true, + isAuthenticated: false, + user: null + }); + } + }), + + // 9. 退出登录 + http.post('/api/auth/logout', async () => { + await delay(NETWORK_DELAY); + + console.log('[Mock] 退出登录'); + + // 清除当前登录用户 + clearCurrentUser(); + + return HttpResponse.json({ + success: true, + message: '退出成功' + }); + }) +]; diff --git a/src/mocks/handlers/index.js b/src/mocks/handlers/index.js new file mode 100644 index 00000000..5f24e595 --- /dev/null +++ b/src/mocks/handlers/index.js @@ -0,0 +1,16 @@ +// src/mocks/handlers/index.js +// 汇总所有 Mock Handlers + +import { authHandlers } from './auth'; +import { accountHandlers } from './account'; + +// 可以在这里添加更多的 handlers +// import { userHandlers } from './user'; +// import { eventHandlers } from './event'; + +export const handlers = [ + ...authHandlers, + ...accountHandlers, + // ...userHandlers, + // ...eventHandlers, +];