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,
+];