feat: 添加mock数据,给导航添加选中标识
This commit is contained in:
20
.env.development
Normal file
20
.env.development
Normal file
@@ -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
|
||||||
20
.env.mock
Normal file
20
.env.mock
Normal file
@@ -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
|
||||||
458
API_DOCS_profile_completeness.md
Normal file
458
API_DOCS_profile_completeness.md
Normal file
@@ -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 && (
|
||||||
|
<Alert status="info" variant="subtle">
|
||||||
|
<AlertIcon />
|
||||||
|
<Box flex="1">
|
||||||
|
<AlertTitle>完善资料,享受更好服务</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
您还需要设置:{completeness.missingItems.join('、')}
|
||||||
|
({completeness.completenessPercentage}% 完成)
|
||||||
|
</AlertDescription>
|
||||||
|
</Box>
|
||||||
|
<Button size="sm" onClick={() => navigate('/settings')}>
|
||||||
|
立即完善
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 主要内容 */}
|
||||||
|
<MainContent />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 版本历史
|
||||||
|
|
||||||
|
| 版本 | 日期 | 变更说明 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 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 支持**:✅ 已实现
|
||||||
405
MOCK_GUIDE.md
Normal file
405
MOCK_GUIDE.md
Normal file
@@ -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! 🎭
|
||||||
@@ -91,6 +91,8 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco start",
|
"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": "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",
|
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
|
||||||
"test": "craco test --env=jsdom",
|
"test": "craco test --env=jsdom",
|
||||||
@@ -104,6 +106,7 @@
|
|||||||
"@craco/craco": "^7.1.0",
|
"@craco/craco": "^7.1.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
|
"env-cmd": "^11.0.0",
|
||||||
"eslint-config-prettier": "8.3.0",
|
"eslint-config-prettier": "8.3.0",
|
||||||
"eslint-plugin-prettier": "3.4.0",
|
"eslint-plugin-prettier": "3.4.0",
|
||||||
"gulp": "4.0.2",
|
"gulp": "4.0.2",
|
||||||
@@ -111,6 +114,7 @@
|
|||||||
"imagemin": "^9.0.1",
|
"imagemin": "^9.0.1",
|
||||||
"imagemin-mozjpeg": "^10.0.0",
|
"imagemin-mozjpeg": "^10.0.0",
|
||||||
"imagemin-pngquant": "^10.0.0",
|
"imagemin-pngquant": "^10.0.0",
|
||||||
|
"msw": "^2.11.5",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "2.2.1",
|
"prettier": "2.2.1",
|
||||||
"react-error-overlay": "6.0.9",
|
"react-error-overlay": "6.0.9",
|
||||||
@@ -131,5 +135,10 @@
|
|||||||
"not dead",
|
"not dead",
|
||||||
"not op_mini all"
|
"not op_mini all"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"msw": {
|
||||||
|
"workerDirectory": [
|
||||||
|
"public"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
349
public/mockServiceWorker.js
Normal file
349
public/mockServiceWorker.js
Normal file
@@ -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<Client | undefined>}
|
||||||
|
*/
|
||||||
|
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<Response>}
|
||||||
|
*/
|
||||||
|
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<Transferable>} transferrables
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -446,10 +446,10 @@ export default function AuthFormContent() {
|
|||||||
<AlertDialogOverlay>
|
<AlertDialogOverlay>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">完善个人信息</AlertDialogHeader>
|
<AlertDialogHeader fontSize="lg" fontWeight="bold">完善个人信息</AlertDialogHeader>
|
||||||
<AlertDialogBody>您已成功注册!是否前往个人中心设置昵称和其他信息?</AlertDialogBody>
|
<AlertDialogBody>您已成功注册!是否前往个人资料设置昵称和其他信息?</AlertDialogBody>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<Button ref={cancelRef} onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); }}>稍后再说</Button>
|
<Button ref={cancelRef} onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); }}>稍后再说</Button>
|
||||||
<Button colorScheme="green" onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); setTimeout(() => { navigate('/admin/profile'); }, 300); }} ml={3}>去设置</Button>
|
<Button colorScheme="green" onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); setTimeout(() => { navigate('/home/profile'); }, 300); }} ml={3}>去设置</Button>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialogOverlay>
|
</AlertDialogOverlay>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
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 (
|
return (
|
||||||
<FormControl isRequired={isRequired} isInvalid={!!error}>
|
<FormControl isRequired={isRequired} isInvalid={!!error}>
|
||||||
<HStack>
|
<HStack>
|
||||||
@@ -41,13 +52,14 @@ export default function VerificationCodeInput({
|
|||||||
maxLength={6}
|
maxLength={6}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
colorScheme={colorScheme}
|
colorScheme={colorScheme}
|
||||||
onClick={handleSendCode}
|
onClick={handleSendCode}
|
||||||
isDisabled={countdown > 0 || isLoading}
|
isDisabled={countdown > 0 || isLoading || isSending}
|
||||||
isLoading={isSending}
|
|
||||||
minW="120px"
|
minW="120px"
|
||||||
|
leftIcon={isSending ? <Spinner size="sm" /> : undefined}
|
||||||
>
|
>
|
||||||
{countdown > 0 ? countdownText(countdown) : buttonText}
|
{getButtonText()}
|
||||||
</Button>
|
</Button>
|
||||||
</HStack>
|
</HStack>
|
||||||
<FormErrorMessage>{error}</FormErrorMessage>
|
<FormErrorMessage>{error}</FormErrorMessage>
|
||||||
|
|||||||
@@ -17,10 +17,25 @@ class ErrorBoundary extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getDerivedStateFromError(error) {
|
static getDerivedStateFromError(error) {
|
||||||
|
// 开发环境:不拦截错误,让 React DevTools 显示完整堆栈
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return { hasError: false };
|
||||||
|
}
|
||||||
|
// 生产环境:拦截错误,显示友好界面
|
||||||
return { hasError: true };
|
return { hasError: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error, errorInfo) {
|
componentDidCatch(error, errorInfo) {
|
||||||
|
// 开发环境:打印错误到控制台,但不显示错误边界
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.error('🔴 ErrorBoundary 捕获到错误(开发模式,不拦截):');
|
||||||
|
console.error('错误:', error);
|
||||||
|
console.error('错误信息:', errorInfo);
|
||||||
|
// 不更新 state,让错误继续抛出
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生产环境:保存错误信息到 state
|
||||||
this.setState({
|
this.setState({
|
||||||
error: error,
|
error: error,
|
||||||
errorInfo: errorInfo
|
errorInfo: errorInfo
|
||||||
@@ -28,6 +43,12 @@ class ErrorBoundary extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
// 开发环境:直接渲染子组件,不显示错误边界
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生产环境:如果有错误,显示错误边界
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
return (
|
return (
|
||||||
<Container maxW="lg" py={20}>
|
<Container maxW="lg" py={20}>
|
||||||
@@ -57,7 +78,7 @@ class ErrorBoundary extends React.Component {
|
|||||||
<Box fontWeight="bold" mb={2}>错误详情:</Box>
|
<Box fontWeight="bold" mb={2}>错误详情:</Box>
|
||||||
<Box as="pre" whiteSpace="pre-wrap">
|
<Box as="pre" whiteSpace="pre-wrap">
|
||||||
{this.state.error && this.state.error.toString()}
|
{this.state.error && this.state.error.toString()}
|
||||||
{this.state.errorInfo.componentStack}
|
{this.state.errorInfo && this.state.errorInfo.componentStack}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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() {
|
export default function HomeNavbar() {
|
||||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -269,6 +272,10 @@ export default function HomeNavbar() {
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await logout();
|
await logout();
|
||||||
|
// 重置资料完整性检查标志
|
||||||
|
hasCheckedCompleteness.current = false;
|
||||||
|
setProfileCompleteness(null);
|
||||||
|
setShowCompletenessAlert(false);
|
||||||
// logout函数已经包含了跳转逻辑,这里不需要额外处理
|
// logout函数已经包含了跳转逻辑,这里不需要额外处理
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', error);
|
console.error('Logout error:', error);
|
||||||
@@ -293,13 +300,13 @@ export default function HomeNavbar() {
|
|||||||
const [profileCompleteness, setProfileCompleteness] = useState(null);
|
const [profileCompleteness, setProfileCompleteness] = useState(null);
|
||||||
const [showCompletenessAlert, setShowCompletenessAlert] = useState(false);
|
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 () => {
|
const loadWatchlistQuotes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setWatchlistLoading(true);
|
setWatchlistLoading(true);
|
||||||
const base = getApiBase();
|
const base = getApiBase(); // 使用外部函数
|
||||||
const resp = await fetch(base + '/api/account/watchlist/realtime', {
|
const resp = await fetch(base + '/api/account/watchlist/realtime', {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
@@ -321,7 +328,7 @@ export default function HomeNavbar() {
|
|||||||
} finally {
|
} finally {
|
||||||
setWatchlistLoading(false);
|
setWatchlistLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []); // getApiBase 是外部函数,不需要作为依赖
|
||||||
|
|
||||||
const loadFollowingEvents = useCallback(async () => {
|
const loadFollowingEvents = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -369,7 +376,7 @@ export default function HomeNavbar() {
|
|||||||
} finally {
|
} finally {
|
||||||
setEventsLoading(false);
|
setEventsLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []); // getApiBase 是外部函数,不需要作为依赖
|
||||||
|
|
||||||
// 从自选股移除
|
// 从自选股移除
|
||||||
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
|
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
|
||||||
@@ -399,7 +406,7 @@ export default function HomeNavbar() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
|
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
|
||||||
}
|
}
|
||||||
}, [getApiBase, toast, WATCHLIST_PAGE_SIZE]);
|
}, [toast]); // WATCHLIST_PAGE_SIZE 是常量,getApiBase 是外部函数,不需要作为依赖
|
||||||
|
|
||||||
// 取消关注事件
|
// 取消关注事件
|
||||||
const handleUnfollowEvent = useCallback(async (eventId) => {
|
const handleUnfollowEvent = useCallback(async (eventId) => {
|
||||||
@@ -424,13 +431,20 @@ export default function HomeNavbar() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ title: '网络错误,操作失败', status: 'error', duration: 2000 });
|
toast({ title: '网络错误,操作失败', status: 'error', duration: 2000 });
|
||||||
}
|
}
|
||||||
}, [getApiBase, toast, EVENTS_PAGE_SIZE]);
|
}, [toast]); // EVENTS_PAGE_SIZE 是常量,getApiBase 是外部函数,不需要作为依赖
|
||||||
|
|
||||||
// 检查用户资料完整性
|
// 检查用户资料完整性
|
||||||
const checkProfileCompleteness = useCallback(async () => {
|
const checkProfileCompleteness = useCallback(async () => {
|
||||||
if (!isAuthenticated || !user) return;
|
if (!isAuthenticated || !user) return;
|
||||||
|
|
||||||
|
// 如果已经检查过,跳过(避免重复请求)
|
||||||
|
if (hasCheckedCompleteness.current) {
|
||||||
|
console.log('[Profile] 已检查过资料完整性,跳过重复请求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[Profile] 开始检查资料完整性...');
|
||||||
const base = getApiBase();
|
const base = getApiBase();
|
||||||
const resp = await fetch(base + '/api/account/profile-completeness', {
|
const resp = await fetch(base + '/api/account/profile-completeness', {
|
||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
@@ -442,12 +456,25 @@ export default function HomeNavbar() {
|
|||||||
setProfileCompleteness(data.data);
|
setProfileCompleteness(data.data);
|
||||||
// 只有微信用户且资料不完整时才显示提醒
|
// 只有微信用户且资料不完整时才显示提醒
|
||||||
setShowCompletenessAlert(data.data.needsAttention);
|
setShowCompletenessAlert(data.data.needsAttention);
|
||||||
|
// 标记为已检查
|
||||||
|
hasCheckedCompleteness.current = true;
|
||||||
|
console.log('[Profile] 资料完整性检查完成:', data.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('检查资料完整性失败:', 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(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
22
src/index.js
22
src/index.js
@@ -11,14 +11,26 @@ import './styles/brainwave-colors.css';
|
|||||||
// Import the main App component
|
// Import the main App component
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
// Create root
|
// 启动 Mock Service Worker(如果启用)
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
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
|
// Create root
|
||||||
root.render(
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
|
||||||
|
// Render the app with Router wrapper
|
||||||
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<Router>
|
<Router>
|
||||||
<App />
|
<App />
|
||||||
</Router>
|
</Router>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动应用
|
||||||
|
startApp();
|
||||||
61
src/mocks/browser.js
Normal file
61
src/mocks/browser.js
Normal file
@@ -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 已重置');
|
||||||
|
}
|
||||||
107
src/mocks/data/users.js
Normal file
107
src/mocks/data/users.js
Normal file
@@ -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] 清除当前登录用户');
|
||||||
|
}
|
||||||
248
src/mocks/handlers/account.js
Normal file
248
src/mocks/handlers/account.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
];
|
||||||
330
src/mocks/handlers/auth.js
Normal file
330
src/mocks/handlers/auth.js
Normal file
@@ -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: '退出成功'
|
||||||
|
});
|
||||||
|
})
|
||||||
|
];
|
||||||
16
src/mocks/handlers/index.js
Normal file
16
src/mocks/handlers/index.js
Normal file
@@ -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,
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user