Merge branch 'feature' into develop
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 支持**:✅ 已实现
|
||||
1879
AUTHENTICATION_SYSTEM_GUIDE.md
Normal file
1879
AUTHENTICATION_SYSTEM_GUIDE.md
Normal file
File diff suppressed because it is too large
Load Diff
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! 🎭
|
||||
2
app.py
2
app.py
@@ -3696,6 +3696,7 @@ class RelatedStock(db.Model):
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
correlation = db.Column(db.Float())
|
||||
momentum = db.Column(db.String(1024)) # 动量
|
||||
retrieved_sources = db.Column(db.JSON) # 动量
|
||||
|
||||
|
||||
class RelatedData(db.Model):
|
||||
@@ -4014,6 +4015,7 @@ def get_related_stocks(event_id):
|
||||
'stock_name': stock.stock_name,
|
||||
'sector': stock.sector,
|
||||
'relation_desc': stock.relation_desc,
|
||||
'retrieved_sources': stock.retrieved_sources,
|
||||
'correlation': stock.correlation,
|
||||
'momentum': stock.momentum,
|
||||
'created_at': stock.created_at.isoformat() if stock.created_at else None,
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
168
src/App.js
168
src/App.js
@@ -26,6 +26,7 @@ import PageLoader from "components/Loading/PageLoader";
|
||||
import Admin from "layouts/Admin";
|
||||
import Auth from "layouts/Auth";
|
||||
import HomeLayout from "layouts/Home";
|
||||
import MainLayout from "layouts/MainLayout";
|
||||
|
||||
// ⚡ 使用 React.lazy() 实现路由懒加载
|
||||
// 首屏不需要的组件按需加载,大幅减少初始 JS 包大小
|
||||
@@ -48,95 +49,113 @@ import { AuthModalProvider } from "contexts/AuthModalContext";
|
||||
import ProtectedRoute from "components/ProtectedRoute";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import AuthModalManager from "components/Auth/AuthModalManager";
|
||||
import ScrollToTop from "components/ScrollToTop";
|
||||
|
||||
function AppContent() {
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={colorMode === 'dark' ? 'gray.800' : 'white'}>
|
||||
{/* ⚡ Suspense 边界:懒加载组件加载时显示 Loading */}
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Routes>
|
||||
{/* 首页路由 */}
|
||||
<Route path="home/*" element={<HomeLayout />} />
|
||||
{/* 路由切换时自动滚动到顶部 */}
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
{/* 带导航栏的主布局 - 所有需要导航栏的页面都在这里 */}
|
||||
{/* MainLayout 内部有 Suspense,确保导航栏始终可见 */}
|
||||
<Route element={<MainLayout />}>
|
||||
{/* 首页路由 */}
|
||||
<Route path="home/*" element={<HomeLayout />} />
|
||||
|
||||
{/* Community页面路由 - 需要登录 */}
|
||||
<Route
|
||||
path="community"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Community />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="forecast-report" element={<ForecastReport />} />
|
||||
<Route path="Financial" element={<FinancialPanorama />} />
|
||||
<Route path="company" element={<CompanyIndex />} />
|
||||
<Route path="company/:code" element={<CompanyIndex />} />
|
||||
<Route path="market-data" element={<MarketDataView />} />
|
||||
{/* 概念中心路由 - 需要登录 */}
|
||||
<Route
|
||||
path="concepts"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ConceptCenter />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="concept"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ConceptCenter />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* 股票概览页面路由 - 需要登录 */}
|
||||
<Route
|
||||
path="stocks"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<StockOverview />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="stock-overview"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<StockOverview />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Limit Analyse页面路由 - 需要登录 */}
|
||||
<Route
|
||||
path="limit-analyse"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LimitAnalyse />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* 事件详情独立页面路由(不经 Admin 布局) */}
|
||||
<Route path="event-detail/:eventId" element={<EventDetail />} />
|
||||
{/* Community页面路由 - 需要登录 */}
|
||||
<Route
|
||||
path="community"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Community />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 模拟盘交易系统路由 - 无需登录 */}
|
||||
<Route
|
||||
path="trading-simulation"
|
||||
element={<TradingSimulation />}
|
||||
/>
|
||||
{/* 概念中心路由 - 需要登录 */}
|
||||
<Route
|
||||
path="concepts"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ConceptCenter />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="concept"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ConceptCenter />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 管理后台路由 - 需要登录 */}
|
||||
{/* 股票概览页面路由 - 需要登录 */}
|
||||
<Route
|
||||
path="stocks"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<StockOverview />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="stock-overview"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<StockOverview />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Limit Analyse页面路由 - 需要登录 */}
|
||||
<Route
|
||||
path="limit-analyse"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LimitAnalyse />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 模拟盘交易系统路由 - 需要登录 */}
|
||||
<Route
|
||||
path="trading-simulation"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TradingSimulation />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 事件详情独立页面路由 (不经 Admin 布局) */}
|
||||
<Route path="event-detail/:eventId" element={<EventDetail />} />
|
||||
|
||||
{/* 公司相关页面 */}
|
||||
<Route path="forecast-report" element={<ForecastReport />} />
|
||||
<Route path="Financial" element={<FinancialPanorama />} />
|
||||
<Route path="company" element={<CompanyIndex />} />
|
||||
<Route path="company/:code" element={<CompanyIndex />} />
|
||||
<Route path="market-data" element={<MarketDataView />} />
|
||||
</Route>
|
||||
|
||||
{/* 管理后台路由 - 需要登录,不使用 MainLayout */}
|
||||
{/* 这些路由有自己的加载状态处理 */}
|
||||
<Route
|
||||
path="admin/*"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Admin />
|
||||
</ProtectedRoute>
|
||||
<Suspense fallback={<PageLoader message="加载中..." />}>
|
||||
<ProtectedRoute>
|
||||
<Admin />
|
||||
</ProtectedRoute>
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 认证页面路由 */}
|
||||
{/* 认证页面路由 - 不使用 MainLayout */}
|
||||
<Route path="auth/*" element={<Auth />} />
|
||||
|
||||
{/* 默认重定向到首页 */}
|
||||
@@ -145,7 +164,6 @@ function AppContent() {
|
||||
{/* 404 页面 */}
|
||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { authService } from "../../services/authService";
|
||||
import AuthHeader from './AuthHeader';
|
||||
import VerificationCodeInput from './VerificationCodeInput';
|
||||
import WechatRegister from './WechatRegister';
|
||||
import { setCurrentUser } from '../../mocks/data/users';
|
||||
|
||||
// 统一配置对象
|
||||
const AUTH_CONFIG = {
|
||||
@@ -261,6 +262,12 @@ export default function AuthFormContent() {
|
||||
}
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// ⚡ Mock 模式:先在前端侧写入 localStorage,确保时序正确
|
||||
if (process.env.REACT_APP_ENABLE_MOCK === 'true' && data.user) {
|
||||
setCurrentUser(data.user);
|
||||
console.log('[Auth] 前端侧设置当前用户(Mock模式):', data.user);
|
||||
}
|
||||
|
||||
// 更新session
|
||||
await checkSession();
|
||||
|
||||
@@ -446,10 +453,10 @@ export default function AuthFormContent() {
|
||||
<AlertDialogOverlay>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">完善个人信息</AlertDialogHeader>
|
||||
<AlertDialogBody>您已成功注册!是否前往个人中心设置昵称和其他信息?</AlertDialogBody>
|
||||
<AlertDialogBody>您已成功注册!是否前往个人资料设置昵称和其他信息?</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<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>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
|
||||
@@ -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 (
|
||||
<FormControl isRequired={isRequired} isInvalid={!!error}>
|
||||
<HStack>
|
||||
@@ -41,13 +52,14 @@ export default function VerificationCodeInput({
|
||||
maxLength={6}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
colorScheme={colorScheme}
|
||||
onClick={handleSendCode}
|
||||
isDisabled={countdown > 0 || isLoading}
|
||||
isLoading={isSending}
|
||||
isDisabled={countdown > 0 || isLoading || isSending}
|
||||
minW="120px"
|
||||
leftIcon={isSending ? <Spinner size="sm" /> : undefined}
|
||||
>
|
||||
{countdown > 0 ? countdownText(countdown) : buttonText}
|
||||
{getButtonText()}
|
||||
</Button>
|
||||
</HStack>
|
||||
<FormErrorMessage>{error}</FormErrorMessage>
|
||||
|
||||
@@ -27,52 +27,65 @@ const CitationMark = ({ citationId, citation }) => {
|
||||
|
||||
// 引用卡片内容
|
||||
const citationContent = (
|
||||
<div style={{ maxWidth: 350, padding: '8px 4px' }}>
|
||||
{/* 作者 */}
|
||||
<Space align="start" style={{ marginBottom: 8 }}>
|
||||
<UserOutlined style={{ color: '#1890ff', marginTop: 4 }} />
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>作者</Text>
|
||||
<br />
|
||||
<Text strong style={{ fontSize: 13 }}>{citation.author}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
<div style={{ maxWidth: 320, padding: '8px 10px' }}>
|
||||
{/* 报告标题 - 顶部突出显示 */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: '#262626',
|
||||
display: 'block',
|
||||
lineHeight: 1.3
|
||||
}}
|
||||
>
|
||||
{citation.report_title}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
{/* 作者和日期 - 左右分布 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
paddingBottom: 8,
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
{/* 左侧:作者 */}
|
||||
<Space size={4}>
|
||||
<UserOutlined style={{ color: '#1890ff', fontSize: 12 }} />
|
||||
<Text style={{ fontSize: 12, color: '#595959' }}>
|
||||
{citation.author}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
{/* 报告标题 */}
|
||||
<Space align="start" style={{ marginBottom: 8 }}>
|
||||
<FileTextOutlined style={{ color: '#52c41a', marginTop: 4 }} />
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>报告标题</Text>
|
||||
<br />
|
||||
<Text strong style={{ fontSize: 13 }}>{citation.report_title}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
{/* 发布日期 */}
|
||||
<Space align="start" style={{ marginBottom: 8 }}>
|
||||
<CalendarOutlined style={{ color: '#faad14', marginTop: 4 }} />
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>发布日期</Text>
|
||||
<br />
|
||||
<Text style={{ fontSize: 13 }}>{citation.declare_date}</Text>
|
||||
</div>
|
||||
</Space>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
{/* 右侧:发布日期(重点标注) */}
|
||||
<Space size={4}>
|
||||
<CalendarOutlined style={{ color: '#fa8c16', fontSize: 12 }} />
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#fa8c16'
|
||||
}}
|
||||
>
|
||||
{citation.declare_date}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 摘要片段 */}
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 4 }}>
|
||||
摘要片段
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
display: 'block',
|
||||
color: '#595959'
|
||||
}}
|
||||
@@ -100,7 +113,7 @@ const CitationMark = ({ citationId, citation }) => {
|
||||
title={`引用来源 [${citationId}]`}
|
||||
trigger={triggerType}
|
||||
placement="top"
|
||||
overlayInnerStyle={{ maxWidth: 380 }}
|
||||
overlayInnerStyle={{ maxWidth: 340, padding: '8px' }}
|
||||
open={popoverVisible}
|
||||
onOpenChange={setPopoverVisible}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<Container maxW="lg" py={20}>
|
||||
@@ -57,7 +78,7 @@ class ErrorBoundary extends React.Component {
|
||||
<Box fontWeight="bold" mb={2}>错误详情:</Box>
|
||||
<Box as="pre" whiteSpace="pre-wrap">
|
||||
{this.state.error && this.state.error.toString()}
|
||||
{this.state.errorInfo.componentStack}
|
||||
{this.state.errorInfo && this.state.errorInfo.componentStack}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -34,106 +34,269 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, HamburgerIcon, SunIcon, MoonIcon } from '@chakra-ui/icons';
|
||||
import { FiStar, FiCalendar } from 'react-icons/fi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useAuthModal } from '../../contexts/AuthModalContext';
|
||||
|
||||
/** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */
|
||||
const SecondaryNav = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const navbarBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const itemHoverBg = useColorModeValue('white', 'gray.600');
|
||||
|
||||
// 定义二级导航结构
|
||||
const secondaryNavConfig = {
|
||||
'/community': {
|
||||
title: '高频跟踪',
|
||||
items: [
|
||||
{ path: '/community', label: '新闻催化分析', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
|
||||
{ path: '/concepts', label: '概念中心', badges: [{ text: 'NEW', colorScheme: 'red' }] }
|
||||
]
|
||||
},
|
||||
'/concepts': {
|
||||
title: '高频跟踪',
|
||||
items: [
|
||||
{ path: '/community', label: '新闻催化分析', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
|
||||
{ path: '/concepts', label: '概念中心', badges: [{ text: 'NEW', colorScheme: 'red' }] }
|
||||
]
|
||||
},
|
||||
'/limit-analyse': {
|
||||
title: '行情复盘',
|
||||
items: [
|
||||
{ path: '/limit-analyse', label: '涨停分析', badges: [{ text: 'FREE', colorScheme: 'blue' }] },
|
||||
{ path: '/stocks', label: '个股中心', badges: [{ text: 'HOT', colorScheme: 'green' }] },
|
||||
{ path: '/trading-simulation', label: '模拟盘', badges: [{ text: 'NEW', colorScheme: 'red' }] }
|
||||
]
|
||||
},
|
||||
'/stocks': {
|
||||
title: '行情复盘',
|
||||
items: [
|
||||
{ path: '/limit-analyse', label: '涨停分析', badges: [{ text: 'FREE', colorScheme: 'blue' }] },
|
||||
{ path: '/stocks', label: '个股中心', badges: [{ text: 'HOT', colorScheme: 'green' }] },
|
||||
{ path: '/trading-simulation', label: '模拟盘', badges: [{ text: 'NEW', colorScheme: 'red' }] }
|
||||
]
|
||||
},
|
||||
'/trading-simulation': {
|
||||
title: '行情复盘',
|
||||
items: [
|
||||
{ path: '/limit-analyse', label: '涨停分析', badges: [{ text: 'FREE', colorScheme: 'blue' }] },
|
||||
{ path: '/stocks', label: '个股中心', badges: [{ text: 'HOT', colorScheme: 'green' }] },
|
||||
{ path: '/trading-simulation', label: '模拟盘', badges: [{ text: 'NEW', colorScheme: 'red' }] }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// 找到当前路径对应的二级导航配置
|
||||
const currentConfig = Object.keys(secondaryNavConfig).find(key =>
|
||||
location.pathname.includes(key)
|
||||
);
|
||||
|
||||
// 如果没有匹配的二级导航,不显示
|
||||
if (!currentConfig) return null;
|
||||
|
||||
const config = secondaryNavConfig[currentConfig];
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={navbarBg}
|
||||
borderBottom="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.600')}
|
||||
py={2}
|
||||
position="sticky"
|
||||
top="60px"
|
||||
zIndex={100}
|
||||
>
|
||||
<Container maxW="container.xl" px={4}>
|
||||
<HStack spacing={1}>
|
||||
{/* 显示一级菜单标题 */}
|
||||
<Text fontSize="sm" color="gray.500" mr={2}>
|
||||
{config.title}:
|
||||
</Text>
|
||||
{/* 二级菜单项 */}
|
||||
{config.items.map((item, index) => {
|
||||
const isActive = location.pathname.includes(item.path);
|
||||
return item.external ? (
|
||||
<Button
|
||||
key={index}
|
||||
as="a"
|
||||
href={item.path}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
bg="transparent"
|
||||
color="inherit"
|
||||
fontWeight="normal"
|
||||
_hover={{ bg: itemHoverBg }}
|
||||
borderRadius="md"
|
||||
px={3}
|
||||
>
|
||||
<Flex align="center" gap={2}>
|
||||
<Text>{item.label}</Text>
|
||||
{item.badges && item.badges.length > 0 && (
|
||||
<HStack spacing={1}>
|
||||
{item.badges.map((badge, bIndex) => (
|
||||
<Badge key={bIndex} size="xs" colorScheme={badge.colorScheme}>
|
||||
{badge.text}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={() => navigate(item.path)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
bg={isActive ? 'blue.50' : 'transparent'}
|
||||
color={isActive ? 'blue.600' : 'inherit'}
|
||||
fontWeight={isActive ? 'bold' : 'normal'}
|
||||
borderBottom={isActive ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
borderRadius={isActive ? '0' : 'md'}
|
||||
_hover={{ bg: isActive ? 'blue.100' : itemHoverBg }}
|
||||
px={3}
|
||||
>
|
||||
<Flex align="center" gap={2}>
|
||||
<Text>{item.label}</Text>
|
||||
{item.badges && item.badges.length > 0 && (
|
||||
<HStack spacing={1}>
|
||||
{item.badges.map((badge, bIndex) => (
|
||||
<Badge key={bIndex} size="xs" colorScheme={badge.colorScheme}>
|
||||
{badge.text}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Flex>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
/** 桌面端导航 - 完全按照原网站
|
||||
* @TODO 添加逻辑 不展示导航case
|
||||
* 1.未登陆状态 && 是首页
|
||||
* 1.未登陆状态 && 是首页
|
||||
* 2. !isMobile
|
||||
*/
|
||||
const NavItems = ({ isAuthenticated, user }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// 辅助函数:判断导航项是否激活
|
||||
const isActive = useCallback((paths) => {
|
||||
return paths.some(path => location.pathname.includes(path));
|
||||
}, [location.pathname]);
|
||||
|
||||
if (isAuthenticated && user) {
|
||||
return (
|
||||
<HStack spacing={8}>
|
||||
<Menu>
|
||||
<MenuButton as={Button} variant="ghost" rightIcon={<ChevronDownIcon />}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
bg={isActive(['/community', '/concepts']) ? 'blue.50' : 'transparent'}
|
||||
color={isActive(['/community', '/concepts']) ? 'blue.600' : 'inherit'}
|
||||
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
|
||||
borderBottom={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.100' : 'gray.50' }}
|
||||
>
|
||||
高频跟踪
|
||||
</MenuButton>
|
||||
<MenuList minW="260px" p={2}>
|
||||
<VStack spacing={1} align="stretch">
|
||||
<Link
|
||||
onClick={() => navigate('/community')}
|
||||
py={2}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
cursor="pointer"
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text fontSize="sm">新闻催化分析</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="green">HOT</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</Link>
|
||||
<Link
|
||||
onClick={() => navigate('/concepts')}
|
||||
py={2}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
cursor="pointer"
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text fontSize="sm">概念中心</Text>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/community')}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/community') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/community') ? 'bold' : 'normal'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">新闻催化分析</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="green">HOT</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</Flex>
|
||||
</Link>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/concepts')}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/concepts') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/concepts') ? 'bold' : 'normal'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">概念中心</Text>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
<Menu>
|
||||
<MenuButton as={Button} variant="ghost" rightIcon={<ChevronDownIcon />}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
bg={isActive(['/limit-analyse', '/stocks']) ? 'blue.50' : 'transparent'}
|
||||
color={isActive(['/limit-analyse', '/stocks']) ? 'blue.600' : 'inherit'}
|
||||
fontWeight={isActive(['/limit-analyse', '/stocks']) ? 'bold' : 'normal'}
|
||||
borderBottom={isActive(['/limit-analyse', '/stocks']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/limit-analyse', '/stocks']) ? 'blue.100' : 'gray.50' }}
|
||||
>
|
||||
行情复盘
|
||||
</MenuButton>
|
||||
<MenuList minW="260px" p={2}>
|
||||
<VStack spacing={1} align="stretch">
|
||||
<Link
|
||||
onClick={() => navigate('/limit-analyse')}
|
||||
py={2}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
cursor="pointer"
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text fontSize="sm">涨停分析</Text>
|
||||
<Badge size="sm" colorScheme="blue">FREE</Badge>
|
||||
</Flex>
|
||||
</Link>
|
||||
<Link
|
||||
onClick={() => navigate('/stocks')}
|
||||
py={2}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
cursor="pointer"
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text fontSize="sm">个股中心</Text>
|
||||
<Badge size="sm" colorScheme="green">HOT</Badge>
|
||||
</Flex>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://valuefrontier.cn/trading-simulation"
|
||||
isExternal
|
||||
py={2}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text fontSize="sm">模拟盘</Text>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</Flex>
|
||||
</Link>
|
||||
</VStack>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/limit-analyse')}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/limit-analyse') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/limit-analyse') ? 'bold' : 'normal'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">涨停分析</Text>
|
||||
<Badge size="sm" colorScheme="blue">FREE</Badge>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/stocks')}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/stocks') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/stocks') ? 'bold' : 'normal'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">个股中心</Text>
|
||||
<Badge size="sm" colorScheme="green">HOT</Badge>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/trading-simulation')}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/trading-simulation') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/trading-simulation') ? 'bold' : 'normal'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">模拟盘</Text>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
@@ -148,30 +311,20 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
AGENT社群
|
||||
</MenuButton>
|
||||
<MenuList minW="300px" p={4}>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Link
|
||||
py={2}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{}}
|
||||
cursor="not-allowed"
|
||||
color="gray.400"
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
||||
</Link>
|
||||
<Link
|
||||
py={2}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{}}
|
||||
cursor="not-allowed"
|
||||
color="gray.400"
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Text fontSize="sm" color="gray.400">个股社区</Text>
|
||||
</Link>
|
||||
</VStack>
|
||||
<MenuItem
|
||||
isDisabled
|
||||
cursor="not-allowed"
|
||||
color="gray.400"
|
||||
>
|
||||
<Text fontSize="sm" color="gray.400">今日热议</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
isDisabled
|
||||
cursor="not-allowed"
|
||||
color="gray.400"
|
||||
>
|
||||
<Text fontSize="sm" color="gray.400">个股社区</Text>
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
@@ -194,7 +347,10 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 计算 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();
|
||||
@@ -227,6 +383,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);
|
||||
@@ -251,13 +411,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',
|
||||
@@ -279,7 +439,7 @@ export default function HomeNavbar() {
|
||||
} finally {
|
||||
setWatchlistLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, []); // getApiBase 是外部函数,不需要作为依赖
|
||||
|
||||
const loadFollowingEvents = useCallback(async () => {
|
||||
try {
|
||||
@@ -327,7 +487,7 @@ export default function HomeNavbar() {
|
||||
} finally {
|
||||
setEventsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, []); // getApiBase 是外部函数,不需要作为依赖
|
||||
|
||||
// 从自选股移除
|
||||
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
|
||||
@@ -357,7 +517,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) => {
|
||||
@@ -382,13 +542,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'
|
||||
@@ -400,12 +567,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(() => {
|
||||
@@ -809,6 +989,9 @@ export default function HomeNavbar() {
|
||||
cursor="pointer"
|
||||
color="blue.500"
|
||||
fontWeight="bold"
|
||||
bg={location.pathname === '/home' ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname === '/home' ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
>
|
||||
<Text fontSize="md">🏠 首页</Text>
|
||||
</Link>
|
||||
@@ -826,6 +1009,10 @@ export default function HomeNavbar() {
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
cursor="pointer"
|
||||
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/community') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/community') ? 'bold' : 'normal'}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">新闻催化分析</Text>
|
||||
@@ -845,6 +1032,10 @@ export default function HomeNavbar() {
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
cursor="pointer"
|
||||
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/concepts') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/concepts') ? 'bold' : 'normal'}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">概念中心</Text>
|
||||
@@ -867,6 +1058,10 @@ export default function HomeNavbar() {
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
cursor="pointer"
|
||||
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/limit-analyse') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/limit-analyse') ? 'bold' : 'normal'}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">涨停分析</Text>
|
||||
@@ -883,6 +1078,10 @@ export default function HomeNavbar() {
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
cursor="pointer"
|
||||
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/stocks') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/stocks') ? 'bold' : 'normal'}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">个股中心</Text>
|
||||
@@ -890,12 +1089,19 @@ export default function HomeNavbar() {
|
||||
</HStack>
|
||||
</Link>
|
||||
<Link
|
||||
href="/trading-simulation"
|
||||
isExternal
|
||||
onClick={() => {
|
||||
navigate('/trading-simulation');
|
||||
onClose();
|
||||
}}
|
||||
py={1}
|
||||
px={3}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
cursor="pointer"
|
||||
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/trading-simulation') ? '3px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
fontWeight={location.pathname.includes('/trading-simulation') ? 'bold' : 'normal'}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">模拟盘</Text>
|
||||
@@ -970,6 +1176,9 @@ export default function HomeNavbar() {
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</Box>
|
||||
|
||||
{/* 二级导航栏 - 显示当前页面所属的二级菜单 */}
|
||||
{!isMobile && <SecondaryNav />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
src/components/ScrollToTop/index.js
Normal file
27
src/components/ScrollToTop/index.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// src/components/ScrollToTop/index.js
|
||||
// 路由切换时自动滚动到页面顶部
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* ScrollToTop - 路由切换时自动滚动到顶部
|
||||
*
|
||||
* 使用方式:在 App.js 的 Router 内部添加此组件
|
||||
*
|
||||
* @example
|
||||
* <BrowserRouter>
|
||||
* <ScrollToTop />
|
||||
* <Routes>...</Routes>
|
||||
* </BrowserRouter>
|
||||
*/
|
||||
export default function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// 路径改变时滚动到顶部
|
||||
window.scrollTo(0, 0);
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -528,7 +528,7 @@ const StockChartAntdModal = ({
|
||||
{stock?.relation_desc?.data ? (
|
||||
// 使用引用组件(带研报来源)
|
||||
<CitedContent
|
||||
data={stock.relation_desc.data}
|
||||
data={stock.relation_desc}
|
||||
title="关联描述"
|
||||
showAIBadge={true}
|
||||
containerStyle={{ marginTop: 16 }}
|
||||
|
||||
32
src/index.js
32
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(
|
||||
<React.StrictMode>
|
||||
<Router>
|
||||
<App />
|
||||
</Router>
|
||||
</React.StrictMode>
|
||||
);
|
||||
// Create root
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
// Render the app with Router wrapper
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Router>
|
||||
<App />
|
||||
</Router>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
// 启动应用
|
||||
startApp();
|
||||
@@ -3,8 +3,8 @@ import React from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
// 导入导航栏组件 - 使用我们之前修复的版本
|
||||
import HomeNavbar from "../components/Navbars/HomeNavbar";
|
||||
// 导航栏已由 MainLayout 提供,此处不再导入
|
||||
// import HomeNavbar from "../components/Navbars/HomeNavbar";
|
||||
|
||||
// 导入页面组件
|
||||
import HomePage from "views/Home/HomePage";
|
||||
@@ -13,10 +13,11 @@ import SettingsPage from "views/Settings/SettingsPage";
|
||||
import CenterDashboard from "views/Dashboard/Center";
|
||||
import Subscription from "views/Pages/Account/Subscription";
|
||||
|
||||
// 懒加载隐私政策、用户协议和微信回调页面
|
||||
// 懒加载隐私政策、用户协议、微信回调和模拟交易页面
|
||||
const PrivacyPolicy = React.lazy(() => import("views/Pages/PrivacyPolicy"));
|
||||
const UserAgreement = React.lazy(() => import("views/Pages/UserAgreement"));
|
||||
const WechatCallback = React.lazy(() => import("views/Pages/WechatCallback"));
|
||||
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
|
||||
|
||||
// 导入保护路由组件
|
||||
import ProtectedRoute from "../components/ProtectedRoute";
|
||||
@@ -24,8 +25,7 @@ import ProtectedRoute from "../components/ProtectedRoute";
|
||||
export default function Home() {
|
||||
return (
|
||||
<Box minH="100vh">
|
||||
{/* 导航栏 - 这是关键,确保HomeNavbar被正确包含 */}
|
||||
<HomeNavbar />
|
||||
{/* 导航栏已由 MainLayout 提供,此处不再渲染 */}
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<Box>
|
||||
@@ -71,6 +71,16 @@ export default function Home() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 模拟盘交易页面 */}
|
||||
<Route
|
||||
path="/trading-simulation"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TradingSimulation />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 隐私政策页面 - 无需登录 */}
|
||||
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
||||
|
||||
|
||||
31
src/layouts/MainLayout.js
Normal file
31
src/layouts/MainLayout.js
Normal file
@@ -0,0 +1,31 @@
|
||||
// src/layouts/MainLayout.js
|
||||
// 主布局组件 - 为所有带导航栏的页面提供统一布局
|
||||
import React, { Suspense } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import HomeNavbar from "../components/Navbars/HomeNavbar";
|
||||
import PageLoader from "../components/Loading/PageLoader";
|
||||
|
||||
/**
|
||||
* MainLayout - 带导航栏的主布局
|
||||
*
|
||||
* 使用 <Outlet /> 渲染子路由,确保导航栏只渲染一次
|
||||
* 页面切换时只有 Outlet 内的内容会更新,导航栏保持不变
|
||||
* Suspense 边界确保导航栏始终可见,只有内容区域显示 loading
|
||||
*/
|
||||
export default function MainLayout() {
|
||||
return (
|
||||
<Box minH="100vh">
|
||||
{/* 导航栏 - 在所有页面间共享,不会重新渲染 */}
|
||||
<HomeNavbar />
|
||||
|
||||
{/* 页面内容区域 - 通过 Outlet 渲染当前路由对应的组件 */}
|
||||
{/* Suspense 只包裹内容区域,导航栏保持可见 */}
|
||||
<Box>
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
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 已重置');
|
||||
}
|
||||
547
src/mocks/data/events.js
Normal file
547
src/mocks/data/events.js
Normal file
@@ -0,0 +1,547 @@
|
||||
// Mock 事件相关数据
|
||||
|
||||
// Mock 股票池 - 常见的A股股票
|
||||
const stockPool = [
|
||||
{ stock_code: '600000.SH', stock_name: '浦发银行', industry: '银行' },
|
||||
{ stock_code: '600519.SH', stock_name: '贵州茅台', industry: '白酒' },
|
||||
{ stock_code: '600036.SH', stock_name: '招商银行', industry: '银行' },
|
||||
{ stock_code: '601318.SH', stock_name: '中国平安', industry: '保险' },
|
||||
{ stock_code: '600016.SH', stock_name: '民生银行', industry: '银行' },
|
||||
{ stock_code: '601398.SH', stock_name: '工商银行', industry: '银行' },
|
||||
{ stock_code: '601288.SH', stock_name: '农业银行', industry: '银行' },
|
||||
{ stock_code: '601166.SH', stock_name: '兴业银行', industry: '银行' },
|
||||
{ stock_code: '000001.SZ', stock_name: '平安银行', industry: '银行' },
|
||||
{ stock_code: '000002.SZ', stock_name: '万科A', industry: '房地产' },
|
||||
{ stock_code: '000858.SZ', stock_name: '五粮液', industry: '白酒' },
|
||||
{ stock_code: '000333.SZ', stock_name: '美的集团', industry: '家电' },
|
||||
{ stock_code: '002415.SZ', stock_name: '海康威视', industry: '安防' },
|
||||
{ stock_code: '002594.SZ', stock_name: 'BYD比亚迪', industry: '新能源汽车' },
|
||||
{ stock_code: '300750.SZ', stock_name: '宁德时代', industry: '新能源' },
|
||||
{ stock_code: '300059.SZ', stock_name: '东方财富', industry: '证券' },
|
||||
{ stock_code: '601888.SH', stock_name: '中国中免', industry: '免税' },
|
||||
{ stock_code: '600276.SH', stock_name: '恒瑞医药', industry: '医药' },
|
||||
{ stock_code: '600887.SH', stock_name: '伊利股份', industry: '乳制品' },
|
||||
{ stock_code: '601012.SH', stock_name: '隆基绿能', industry: '光伏' },
|
||||
{ stock_code: '688981.SH', stock_name: '中芯国际', industry: '半导体' },
|
||||
{ stock_code: '600309.SH', stock_name: '万华化学', industry: '化工' },
|
||||
{ stock_code: '603259.SH', stock_name: '药明康德', industry: '医药研发' },
|
||||
{ stock_code: '002475.SZ', stock_name: '立讯精密', industry: '电子' },
|
||||
{ stock_code: '000063.SZ', stock_name: '中兴通讯', industry: '通信设备' },
|
||||
];
|
||||
|
||||
// 关联描述模板 - 更详细和专业的描述
|
||||
const relationDescTemplates = [
|
||||
'主营业务直接相关,预计受事件影响较大。公司在该领域拥有领先的市场地位,事件催化有望带动业绩增长。',
|
||||
'产业链上游核心供应商,间接受益明显。随着下游需求提升,公司产品销量和价格有望双升。',
|
||||
'产业链下游龙头企业,需求端直接受益。事件将推动行业景气度提升,公司订单量预计大幅增长。',
|
||||
'同行业竞争对手,市场份额有望提升。行业整体向好背景下,公司凭借技术优势可能获得更多市场机会。',
|
||||
'参股该领域优质企业,投资收益可期。被投企业在事件催化下估值提升,将为公司带来可观的投资回报。',
|
||||
'业务板块深度布局相关领域,多项产品受益。公司提前布局的战略眼光将在此次事件中得到验证。',
|
||||
'控股子公司主营相关业务,贡献利润占比较高。子公司业绩改善将直接提升上市公司整体盈利能力。',
|
||||
'近期公告加大投资布局该领域,潜在受益标的。公司前瞻性布局正逢政策东风,项目落地进度有望加快。',
|
||||
'行业绝对龙头,市场关注度极高。事件影响下,资金有望持续流入,股价弹性较大。',
|
||||
'技术储备充足且研发投入领先,有望抢占市场先机。公司多项核心技术处于行业领先地位。',
|
||||
'已有成熟产品线和完善销售渠道,短期内可实现业绩兑现。公司产能充足,可快速响应市场需求。',
|
||||
'战略转型方向高度契合事件主题,转型进程有望提速。管理层明确表态将加大相关业务投入力度。',
|
||||
'拥有稀缺资源或独家技术,竞争壁垒显著。事件催化下,公司核心竞争优势将进一步凸显。',
|
||||
'区域市场领导者,地方政策支持力度大。公司深耕区域市场多年,具备先发优势和政府资源。',
|
||||
'新增产能即将释放,业绩拐点临近。事件催化恰逢产能爬坡期,盈利能力有望超预期。',
|
||||
'与行业巨头建立战略合作,订单保障充足。大客户资源优势明显,业务增长确定性强。',
|
||||
];
|
||||
|
||||
// 模拟作者列表
|
||||
const authorPool = [
|
||||
"张明", "李华", "王芳", "陈强", "赵磊", "孙杰", "周磊", "吴洋",
|
||||
"刘畅", "林芳", "郑华", "钱敏", "张敏", "赵强", "张华", "李明"
|
||||
];
|
||||
|
||||
// 生成随机关联股票数据
|
||||
export function generateRelatedStocks(eventId, count = 5) {
|
||||
// 使用事件ID作为随机种子,确保相同事件ID返回相同的股票列表
|
||||
const seed = parseInt(eventId) || 1;
|
||||
const selectedStocks = [];
|
||||
|
||||
// 伪随机选择股票(基于事件ID)
|
||||
for (let i = 0; i < Math.min(count, stockPool.length); i++) {
|
||||
const index = (seed * 17 + i * 13) % stockPool.length;
|
||||
const stock = stockPool[index];
|
||||
const descIndex = (seed * 7 + i * 11) % relationDescTemplates.length;
|
||||
const authorIndex1 = (seed * 3 + i * 5) % authorPool.length;
|
||||
const authorIndex2 = (seed * 5 + i * 7) % authorPool.length;
|
||||
|
||||
// 获取模板文本
|
||||
const templateText = relationDescTemplates[descIndex];
|
||||
|
||||
// 将模板文本分成两部分作为query_part
|
||||
const sentences = templateText.split('。');
|
||||
const queryPart1 = sentences[0] || templateText.substring(0, 30);
|
||||
const queryPart2 = sentences[1] || templateText.substring(30, 60);
|
||||
|
||||
// 生成随机日期(基于seed)
|
||||
const baseDate = new Date('2025-08-01');
|
||||
const daysOffset1 = (seed * i * 3) % 30;
|
||||
const daysOffset2 = (seed * i * 5) % 30;
|
||||
const date1 = new Date(baseDate);
|
||||
date1.setDate(date1.getDate() + daysOffset1);
|
||||
const date2 = new Date(baseDate);
|
||||
date2.setDate(date2.getDate() + daysOffset2);
|
||||
|
||||
selectedStocks.push({
|
||||
stock_code: stock.stock_code,
|
||||
stock_name: stock.stock_name,
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: authorPool[authorIndex1],
|
||||
sentences: sentences[0] + '。' + (sentences[1] || ''),
|
||||
query_part: queryPart1,
|
||||
match_score: i < 2 ? "优" : "良",
|
||||
declare_date: date1.toISOString(),
|
||||
report_title: `${stock.stock_name}:行业分析与投资价值研究-深度报告`
|
||||
},
|
||||
{
|
||||
author: authorPool[authorIndex2],
|
||||
sentences: sentences.slice(2).join('。') || templateText,
|
||||
query_part: queryPart2 || '政策催化,市场关注度提升',
|
||||
match_score: i < 3 ? "优" : "良",
|
||||
declare_date: date2.toISOString(),
|
||||
report_title: `${stock.industry}行业:事件驱动下的投资机会分析`
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: stock.industry,
|
||||
// 可选字段 - 用于前端显示更多信息
|
||||
relevance_score: Math.max(60, 100 - i * 8), // 相关性评分,递减
|
||||
impact_level: i < 2 ? 'high' : i < 4 ? 'medium' : 'low', // 影响程度
|
||||
});
|
||||
}
|
||||
|
||||
return selectedStocks;
|
||||
}
|
||||
|
||||
// Mock 事件相关股票数据映射
|
||||
// 这里可以为特定事件ID预设特定的股票列表
|
||||
export const mockEventStocks = {
|
||||
// 示例:事件ID为1的预设股票(消费刺激政策)
|
||||
'1': [
|
||||
{
|
||||
stock_code: '600519.SH',
|
||||
stock_name: '贵州茅台',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "张明",
|
||||
sentences: "贵州茅台作为白酒行业绝对龙头,品牌溢价能力强,提价预期明确。2024年产能持续释放,叠加渠道库存处于低位,业绩增长确定性高。",
|
||||
query_part: "白酒行业绝对龙头,高端消费代表性标的",
|
||||
match_score: "优",
|
||||
declare_date: "2025-03-15T00:00:00",
|
||||
report_title: "贵州茅台:高端白酒龙头,消费复苏核心受益标的-深度报告"
|
||||
},
|
||||
{
|
||||
author: "李华",
|
||||
sentences: "消费刺激政策将直接提振高端白酒需求,茅台作为高端消费代表性品牌,需求弹性大,定价权强。机构持仓集中度高,资金关注度极高。",
|
||||
query_part: "消费刺激政策直接受益,品牌溢价能力行业领先",
|
||||
match_score: "优",
|
||||
declare_date: "2025-03-20T00:00:00",
|
||||
report_title: "白酒行业:消费政策催化,高端白酒迎来配置良机"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '白酒',
|
||||
relevance_score: 95,
|
||||
impact_level: 'high',
|
||||
},
|
||||
{
|
||||
stock_code: '000858.SZ',
|
||||
stock_name: '五粮液',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "王芳",
|
||||
sentences: "五粮液作为白酒行业第二梯队领军企业,产品矩阵完善,中高端产品结构优化进程加快。管理层改革成效显著,渠道改革红利持续释放。",
|
||||
query_part: "白酒行业第二梯队领军企业,产品矩阵完善",
|
||||
match_score: "良",
|
||||
declare_date: "2025-03-18T00:00:00",
|
||||
report_title: "五粮液:改革红利释放,次高端市场份额稳步提升"
|
||||
},
|
||||
{
|
||||
author: "陈强",
|
||||
sentences: "消费复苏背景下,五粮液次高端市场份额稳步提升。估值修复空间较大,股价弹性优于行业平均水平。",
|
||||
query_part: "消费复苏受益明显,估值修复空间大",
|
||||
match_score: "良",
|
||||
declare_date: "2025-03-22T00:00:00",
|
||||
report_title: "白酒行业复盘:次高端品牌估值修复进行时"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '白酒',
|
||||
relevance_score: 90,
|
||||
impact_level: 'high',
|
||||
},
|
||||
{
|
||||
stock_code: '600887.SH',
|
||||
stock_name: '伊利股份',
|
||||
relation_desc: '乳制品行业龙头,市占率稳居第一。消费品类中必选消费属性强,受政策刺激需求提升明显。公司全国化布局完善,冷链物流体系成熟,新品推出节奏加快。常温、低温双轮驱动,盈利能力持续改善。分红率稳定,股息收益率具有吸引力。',
|
||||
industry: '乳制品',
|
||||
relevance_score: 82,
|
||||
impact_level: 'medium',
|
||||
},
|
||||
{
|
||||
stock_code: '000333.SZ',
|
||||
stock_name: '美的集团',
|
||||
relation_desc: '家电行业龙头,以旧换新政策核心受益标的。公司产品线覆盖全品类,渠道布局线上线下协同优势明显。智能家居战略推进顺利,高端化、国际化双线并进。成本控制能力行业领先,ROE水平稳定在20%以上。',
|
||||
industry: '家电',
|
||||
relevance_score: 78,
|
||||
impact_level: 'medium',
|
||||
},
|
||||
],
|
||||
|
||||
// 事件ID为2的预设股票(AI人工智能发展政策)
|
||||
'2': [
|
||||
{
|
||||
stock_code: '002415.SZ',
|
||||
stock_name: '海康威视',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "赵敏",
|
||||
sentences: "海康威视作为AI+安防龙头企业,智能视觉技术全球领先。公司AI芯片、算法、云平台业务增长迅速,研发投入占比保持10%以上。",
|
||||
query_part: "AI+安防龙头企业,智能视觉技术全球领先",
|
||||
match_score: "优",
|
||||
declare_date: "2025-04-10T00:00:00",
|
||||
report_title: "海康威视:AI赋能安防,技术护城河持续加深-深度报告"
|
||||
},
|
||||
{
|
||||
author: "孙杰",
|
||||
sentences: "人工智能政策支持下,智慧城市、智能交通等政府项目订单充足。海外市场拓展提速,国际化战略成效显著。",
|
||||
query_part: "人工智能政策支持,政府项目订单充足",
|
||||
match_score: "优",
|
||||
declare_date: "2025-04-12T00:00:00",
|
||||
report_title: "AI产业链深度:政策催化下的投资机遇分析"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '安防',
|
||||
relevance_score: 92,
|
||||
impact_level: 'high',
|
||||
},
|
||||
{
|
||||
stock_code: '000063.SZ',
|
||||
stock_name: '中兴通讯',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "周磊",
|
||||
sentences: "中兴通讯作为5G通信设备商,是算力网络建设核心受益者。AI大模型训练和推理需要海量算力支撑,公司服务器、交换机等产品需求激增。",
|
||||
query_part: "5G通信设备商,算力网络建设核心受益者",
|
||||
match_score: "优",
|
||||
declare_date: "2025-04-08T00:00:00",
|
||||
report_title: "中兴通讯:算力基础设施建设加速,订单饱满-点评报告"
|
||||
},
|
||||
{
|
||||
author: "吴洋",
|
||||
sentences: "运营商资本开支回暖,5G-A、算力网络投资加速。国产替代进程加快,中兴通讯市场份额持续提升,盈利能力改善明显。",
|
||||
query_part: "国产替代加速,市场份额持续提升",
|
||||
match_score: "良",
|
||||
declare_date: "2025-04-15T00:00:00",
|
||||
report_title: "通信设备行业:运营商资本开支拐点已现"
|
||||
},
|
||||
{
|
||||
author: "刘畅",
|
||||
sentences: "AI产业链中,算力基础设施投资是重中之重。中兴通讯在数据中心交换机、服务器等领域布局完善,技术实力强劲。",
|
||||
query_part: "AI算力基础设施投资核心标的",
|
||||
match_score: "优",
|
||||
declare_date: "2025-04-18T00:00:00",
|
||||
report_title: "AI算力产业链投资机会深度解析"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '通信设备',
|
||||
relevance_score: 88,
|
||||
impact_level: 'high',
|
||||
},
|
||||
{
|
||||
stock_code: '688981.SH',
|
||||
stock_name: '中芯国际',
|
||||
relation_desc: '国内半导体制造龙头,AI芯片代工核心标的。人工智能发展带动高端芯片需求爆发,公司先进制程产能利用率高位运行。政策支持力度空前,产业基金持续注资,扩产进度超预期。国产替代空间巨大,长期成长确定性强。',
|
||||
industry: '半导体',
|
||||
relevance_score: 85,
|
||||
impact_level: 'high',
|
||||
},
|
||||
{
|
||||
stock_code: '002475.SZ',
|
||||
stock_name: '立讯精密',
|
||||
relation_desc: '精密制造龙头,AI终端设备供应链核心企业。AI眼镜、AI手机等新型终端设备放量,公司作为苹果、Meta等巨头供应商直接受益。自动化生产水平行业领先,成本优势明显。新业务拓展顺利,汽车电子、服务器连接器增长快速。',
|
||||
industry: '电子',
|
||||
relevance_score: 80,
|
||||
impact_level: 'medium',
|
||||
},
|
||||
],
|
||||
|
||||
// 事件ID为3的预设股票(新能源汽车补贴延续)
|
||||
'3': [
|
||||
{
|
||||
stock_code: '300750.SZ',
|
||||
stock_name: '宁德时代',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "张华",
|
||||
sentences: "宁德时代作为全球动力电池绝对龙头,市占率超35%,技术路线覆盖三元、磷酸铁锂、钠电池等全品类。客户资源优质,特斯拉、比亚迪等头部车企深度绑定。",
|
||||
query_part: "动力电池绝对龙头,全球市占率超35%",
|
||||
match_score: "优",
|
||||
declare_date: "2025-05-10T00:00:00",
|
||||
report_title: "宁德时代:全球动力电池龙头,新能源汽车核心受益标的"
|
||||
},
|
||||
{
|
||||
author: "李明",
|
||||
sentences: "新能源汽车补贴延续政策出台,将直接刺激终端需求,宁德时代作为产业链核心环节,电池出货量有望大幅提升。储能业务高速增长,打开第二增长曲线。",
|
||||
query_part: "补贴政策核心受益,储能业务打开第二曲线",
|
||||
match_score: "优",
|
||||
declare_date: "2025-05-12T00:00:00",
|
||||
report_title: "新能源汽车产业链:补贴延续下的投资机遇"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '新能源',
|
||||
relevance_score: 98,
|
||||
impact_level: 'high',
|
||||
},
|
||||
{
|
||||
stock_code: '002594.SZ',
|
||||
stock_name: 'BYD比亚迪',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "王芳",
|
||||
sentences: "比亚迪月销量持续突破30万辆,市占率稳步提升。王朝、海洋、腾势三大品牌矩阵完善,价格带覆盖10-50万元,产品竞争力强。",
|
||||
query_part: "新能源汽车销量冠军,月销超30万辆",
|
||||
match_score: "优",
|
||||
declare_date: "2025-05-08T00:00:00",
|
||||
report_title: "比亚迪:新能源汽车销量王者,产业链垂直整合优势显著"
|
||||
},
|
||||
{
|
||||
author: "陈强",
|
||||
sentences: "电池、电机、电控自主可控,成本优势明显。出海战略推进顺利,欧洲、东南亚市场表现亮眼,国际化进程加速。",
|
||||
query_part: "垂直整合成本优势,出海战略成效显著",
|
||||
match_score: "良",
|
||||
declare_date: "2025-05-15T00:00:00",
|
||||
report_title: "比亚迪国际化战略深度解析"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '新能源汽车',
|
||||
relevance_score: 95,
|
||||
impact_level: 'high',
|
||||
},
|
||||
{
|
||||
stock_code: '601012.SH',
|
||||
stock_name: '隆基绿能',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "赵磊",
|
||||
sentences: "隆基绿能作为光伏组件龙头,单晶硅片市占率第一。BC电池技术领先,产品溢价能力强,一体化产能布局完善。",
|
||||
query_part: "光伏组件龙头,BC电池技术领先",
|
||||
match_score: "良",
|
||||
declare_date: "2025-05-05T00:00:00",
|
||||
report_title: "隆基绿能:光伏技术引领者,一体化优势突出"
|
||||
},
|
||||
{
|
||||
author: "孙杰",
|
||||
sentences: "新能源汽车补贴延续,带动绿色能源需求增长。隆基海外收入占比超50%,全球化布局分散风险,盈利稳定性强。",
|
||||
query_part: "绿色能源需求增长,全球化布局优势",
|
||||
match_score: "良",
|
||||
declare_date: "2025-05-18T00:00:00",
|
||||
report_title: "光伏行业:新能源政策催化下的配置机会"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '光伏',
|
||||
relevance_score: 85,
|
||||
impact_level: 'medium',
|
||||
},
|
||||
{
|
||||
stock_code: '688187.SH',
|
||||
stock_name: '天齐锂业',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "刘畅",
|
||||
sentences: "天齐锂业拥有优质锂矿资源,锂资源自给率高,成本优势显著。澳洲、智利矿山产能稳定,国内锂盐产能持续扩张。",
|
||||
query_part: "锂资源龙头,优质矿山资源储备充足",
|
||||
match_score: "优",
|
||||
declare_date: "2025-05-20T00:00:00",
|
||||
report_title: "天齐锂业:锂资源龙头,成本优势突出"
|
||||
},
|
||||
{
|
||||
author: "吴洋",
|
||||
sentences: "新能源汽车、储能需求增长带动锂盐价格中枢上移。锂价回暖周期开启,天齐锂业业绩弹性巨大,是锂价上行核心受益标的。",
|
||||
query_part: "锂价回暖周期受益,业绩弹性大",
|
||||
match_score: "优",
|
||||
declare_date: "2025-05-22T00:00:00",
|
||||
report_title: "锂行业:供需格局改善,价格拐点已现"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '有色金属',
|
||||
relevance_score: 82,
|
||||
impact_level: 'high',
|
||||
},
|
||||
],
|
||||
|
||||
// 事件ID为4的预设股票(医药创新支持政策)
|
||||
'4': [
|
||||
{
|
||||
stock_code: '600276.SH',
|
||||
stock_name: '恒瑞医药',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "周磊",
|
||||
sentences: "恒瑞医药作为创新药龙头,研发管线最丰富,涵盖肿瘤、麻醉、造影等多领域。PD-1、PARP抑制剂等重磅产品进入收获期,放量迅速。",
|
||||
query_part: "创新药龙头,研发管线最丰富",
|
||||
match_score: "优",
|
||||
declare_date: "2025-06-10T00:00:00",
|
||||
report_title: "恒瑞医药:创新药进入收获期,业绩拐点显现"
|
||||
},
|
||||
{
|
||||
author: "钱敏",
|
||||
sentences: "创新药政策支持力度加大,集采影响逐步消化。恒瑞研发投入占比超20%,海外授权合作频繁,国际化进程加速。",
|
||||
query_part: "政策支持加码,国际化进程加速",
|
||||
match_score: "优",
|
||||
declare_date: "2025-06-12T00:00:00",
|
||||
report_title: "医药创新政策解读:龙头企业核心受益"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '医药',
|
||||
relevance_score: 93,
|
||||
impact_level: 'high',
|
||||
},
|
||||
{
|
||||
stock_code: '603259.SH',
|
||||
stock_name: '药明康德',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "孙杰",
|
||||
sentences: "药明康德作为CRO/CDMO龙头,全球制药产业链核心服务商。客户覆盖全球TOP20药企,粘性强,订单饱满。一体化平台优势明显。",
|
||||
query_part: "CRO/CDMO龙头,全球制药核心服务商",
|
||||
match_score: "优",
|
||||
declare_date: "2025-06-08T00:00:00",
|
||||
report_title: "药明康德:CRO龙头地位稳固,订单饱满"
|
||||
},
|
||||
{
|
||||
author: "林芳",
|
||||
sentences: "创新药研发投入增加,外包渗透率提升。药明从研发到商业化全流程服务能力强,海外收入占比高,人民币贬值受益。",
|
||||
query_part: "外包渗透率提升,全流程服务优势",
|
||||
match_score: "良",
|
||||
declare_date: "2025-06-15T00:00:00",
|
||||
report_title: "CRO行业:创新药外包需求持续增长"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '医药研发',
|
||||
relevance_score: 90,
|
||||
impact_level: 'high',
|
||||
},
|
||||
{
|
||||
stock_code: '300760.SZ',
|
||||
stock_name: '迈瑞医疗',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "赵强",
|
||||
sentences: "迈瑞医疗产品线覆盖生命信息与支持、体外诊断、医学影像三大领域。高端医疗设备国产替代加速,市占率持续提升。",
|
||||
query_part: "医疗器械龙头,国产替代加速",
|
||||
match_score: "良",
|
||||
declare_date: "2025-06-05T00:00:00",
|
||||
report_title: "迈瑞医疗:医疗器械龙头,国产化进程提速"
|
||||
},
|
||||
{
|
||||
author: "吴洋",
|
||||
sentences: "海外市场突破进展顺利,进入更多顶级医院。研发能力强,新品推出节奏加快,盈利能力稳定,现金流充沛。",
|
||||
query_part: "海外突破顺利,研发能力强劲",
|
||||
match_score: "良",
|
||||
declare_date: "2025-06-18T00:00:00",
|
||||
report_title: "医疗器械行业:国产品牌全球化加速"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '医疗器械',
|
||||
relevance_score: 87,
|
||||
impact_level: 'medium',
|
||||
},
|
||||
],
|
||||
|
||||
// 事件ID为5的预设股票(数字经济发展规划)
|
||||
'5': [
|
||||
{
|
||||
stock_code: '300059.SZ',
|
||||
stock_name: '东方财富',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "郑华",
|
||||
sentences: "东方财富作为互联网金融龙头,流量优势显著,APP月活超亿。券商、基金代销、数据服务多业务协同,形成完整生态闭环。",
|
||||
query_part: "互联网金融龙头,流量优势显著",
|
||||
match_score: "优",
|
||||
declare_date: "2025-07-10T00:00:00",
|
||||
report_title: "东方财富:互联网金融龙头,生态优势突出"
|
||||
},
|
||||
{
|
||||
author: "刘明",
|
||||
sentences: "数字经济发展推动线上理财渗透率提升。市场交易活跃度提升,佣金收入和利息收入双增长,低成本负债优势明显,ROE水平行业领先。",
|
||||
query_part: "数字经济受益,线上理财渗透率提升",
|
||||
match_score: "优",
|
||||
declare_date: "2025-07-12T00:00:00",
|
||||
report_title: "数字经济政策解读:互联网金融核心受益"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '证券',
|
||||
relevance_score: 88,
|
||||
impact_level: 'high',
|
||||
},
|
||||
{
|
||||
stock_code: '002410.SZ',
|
||||
stock_name: '广联达',
|
||||
relation_desc: {
|
||||
data: [
|
||||
{
|
||||
author: "张敏",
|
||||
sentences: "广联达作为建筑信息化龙头,工程造价软件市占率第一。云转型进入收获期,订阅模式收入占比提升,现金流改善明显。",
|
||||
query_part: "建筑信息化龙头,云转型收获期",
|
||||
match_score: "良",
|
||||
declare_date: "2025-07-08T00:00:00",
|
||||
report_title: "广联达:建筑信息化龙头,云转型成效显著"
|
||||
},
|
||||
{
|
||||
author: "李芳",
|
||||
sentences: "数字化转型加速,建筑行业信息化需求旺盛。施工、设计等新业务拓展顺利,成长空间广阔,政策支持力度大,行业壁垒高。",
|
||||
query_part: "数字化转型加速,新业务拓展顺利",
|
||||
match_score: "良",
|
||||
declare_date: "2025-07-15T00:00:00",
|
||||
report_title: "建筑信息化:数字经济下的产业升级机遇"
|
||||
}
|
||||
]
|
||||
},
|
||||
industry: '软件',
|
||||
relevance_score: 85,
|
||||
impact_level: 'medium',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 获取事件相关股票
|
||||
export function getEventRelatedStocks(eventId) {
|
||||
// 优先返回预设的股票列表
|
||||
if (mockEventStocks[eventId]) {
|
||||
return mockEventStocks[eventId];
|
||||
}
|
||||
|
||||
// 否则生成随机股票列表(3-6只股票)
|
||||
const count = 3 + (parseInt(eventId) % 4);
|
||||
return generateRelatedStocks(eventId, count);
|
||||
}
|
||||
118
src/mocks/data/users.js
Normal file
118
src/mocks/data/users.js
Normal file
@@ -0,0 +1,118 @@
|
||||
// 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 模式下使用 localStorage 持久化登录状态
|
||||
|
||||
// 设置当前登录用户
|
||||
export function setCurrentUser(user) {
|
||||
if (user) {
|
||||
localStorage.setItem('mock_current_user', JSON.stringify(user));
|
||||
console.log('[Mock State] 设置当前登录用户:', user);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前登录用户
|
||||
export function getCurrentUser() {
|
||||
try {
|
||||
const stored = localStorage.getItem('mock_current_user');
|
||||
if (stored) {
|
||||
const user = JSON.parse(stored);
|
||||
console.log('[Mock State] 获取当前登录用户:', user);
|
||||
return user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Mock State] 解析用户数据失败:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 清除当前登录用户
|
||||
export function clearCurrentUser() {
|
||||
localStorage.removeItem('mock_current_user');
|
||||
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: '退出成功'
|
||||
});
|
||||
})
|
||||
];
|
||||
39
src/mocks/handlers/event.js
Normal file
39
src/mocks/handlers/event.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// src/mocks/handlers/event.js
|
||||
// 事件相关的 Mock API Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { getEventRelatedStocks } from '../data/events';
|
||||
|
||||
// 模拟网络延迟
|
||||
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export const eventHandlers = [
|
||||
// 获取事件相关股票
|
||||
http.get('/api/events/:eventId/stocks', async ({ params }) => {
|
||||
await delay(300);
|
||||
|
||||
const { eventId } = params;
|
||||
|
||||
console.log('[Mock] 获取事件相关股票, eventId:', eventId);
|
||||
|
||||
try {
|
||||
const relatedStocks = getEventRelatedStocks(eventId);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: relatedStocks,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取事件相关股票失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取事件相关股票失败',
|
||||
data: []
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
18
src/mocks/handlers/index.js
Normal file
18
src/mocks/handlers/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// src/mocks/handlers/index.js
|
||||
// 汇总所有 Mock Handlers
|
||||
|
||||
import { authHandlers } from './auth';
|
||||
import { accountHandlers } from './account';
|
||||
import { simulationHandlers } from './simulation';
|
||||
import { eventHandlers } from './event';
|
||||
|
||||
// 可以在这里添加更多的 handlers
|
||||
// import { userHandlers } from './user';
|
||||
|
||||
export const handlers = [
|
||||
...authHandlers,
|
||||
...accountHandlers,
|
||||
...simulationHandlers,
|
||||
...eventHandlers,
|
||||
// ...userHandlers,
|
||||
];
|
||||
374
src/mocks/handlers/simulation.js
Normal file
374
src/mocks/handlers/simulation.js
Normal file
@@ -0,0 +1,374 @@
|
||||
// src/mocks/handlers/simulation.js
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
import { getCurrentUser } from '../data/users';
|
||||
|
||||
// 模拟网络延迟(毫秒)
|
||||
const NETWORK_DELAY = 300;
|
||||
|
||||
// 模拟交易账户数据
|
||||
let mockTradingAccount = {
|
||||
account_id: 'sim_001',
|
||||
account_name: '模拟交易账户',
|
||||
initial_capital: 1000000,
|
||||
available_cash: 850000,
|
||||
frozen_cash: 0,
|
||||
position_value: 150000,
|
||||
total_assets: 1000000,
|
||||
total_profit: 0,
|
||||
total_profit_rate: 0,
|
||||
daily_profit: 0,
|
||||
daily_profit_rate: 0,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 模拟持仓数据
|
||||
let mockPositions = [
|
||||
{
|
||||
id: 1,
|
||||
stock_code: '600036',
|
||||
stock_name: '招商银行',
|
||||
position_qty: 1000,
|
||||
available_qty: 1000,
|
||||
frozen_qty: 0,
|
||||
avg_cost: 42.50,
|
||||
current_price: 42.80,
|
||||
market_value: 42800,
|
||||
profit: 300,
|
||||
profit_rate: 0.71,
|
||||
today_profit: 100,
|
||||
today_profit_rate: 0.23,
|
||||
updated_at: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
stock_code: '000001',
|
||||
stock_name: '平安银行',
|
||||
position_qty: 2000,
|
||||
available_qty: 2000,
|
||||
frozen_qty: 0,
|
||||
avg_cost: 12.30,
|
||||
current_price: 12.50,
|
||||
market_value: 25000,
|
||||
profit: 400,
|
||||
profit_rate: 1.63,
|
||||
today_profit: -50,
|
||||
today_profit_rate: -0.20,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
|
||||
// 模拟交易历史
|
||||
let mockOrders = [
|
||||
{
|
||||
id: 1,
|
||||
order_no: 'ORD20240101001',
|
||||
stock_code: '600036',
|
||||
stock_name: '招商银行',
|
||||
order_type: 'BUY',
|
||||
price_type: 'MARKET',
|
||||
order_price: 42.50,
|
||||
order_qty: 1000,
|
||||
filled_qty: 1000,
|
||||
filled_price: 42.50,
|
||||
filled_amount: 42500,
|
||||
commission: 12.75,
|
||||
stamp_tax: 0,
|
||||
transfer_fee: 0.42,
|
||||
total_fee: 13.17,
|
||||
status: 'FILLED',
|
||||
reject_reason: null,
|
||||
order_time: '2024-01-15T09:30:00Z',
|
||||
filled_time: '2024-01-15T09:30:05Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
order_no: 'ORD20240102001',
|
||||
stock_code: '000001',
|
||||
stock_name: '平安银行',
|
||||
order_type: 'BUY',
|
||||
price_type: 'LIMIT',
|
||||
order_price: 12.30,
|
||||
order_qty: 2000,
|
||||
filled_qty: 2000,
|
||||
filled_price: 12.30,
|
||||
filled_amount: 24600,
|
||||
commission: 7.38,
|
||||
stamp_tax: 0,
|
||||
transfer_fee: 0.25,
|
||||
total_fee: 7.63,
|
||||
status: 'FILLED',
|
||||
reject_reason: null,
|
||||
order_time: '2024-01-16T10:15:00Z',
|
||||
filled_time: '2024-01-16T10:15:10Z'
|
||||
}
|
||||
];
|
||||
|
||||
export const simulationHandlers = [
|
||||
// ==================== 获取模拟账户信息 ====================
|
||||
http.get('/api/simulation/account', async () => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
// 未登录时返回401
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
console.log('[Mock] 获取模拟账户信息:', currentUser);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: mockTradingAccount
|
||||
});
|
||||
}),
|
||||
|
||||
// ==================== 获取持仓列表 ====================
|
||||
http.get('/api/simulation/positions', async () => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
console.log('[Mock] 获取持仓列表');
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: mockPositions
|
||||
});
|
||||
}),
|
||||
|
||||
// ==================== 获取交易订单历史 ====================
|
||||
http.get('/api/simulation/orders', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '100');
|
||||
|
||||
console.log('[Mock] 获取交易订单历史, limit:', limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: mockOrders.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// ==================== 下单(买入/卖出)====================
|
||||
http.post('/api/simulation/place-order', 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);
|
||||
|
||||
const { stock_code, order_type, order_qty, price_type } = body;
|
||||
|
||||
// 生成订单号
|
||||
const orderNo = 'ORD' + Date.now();
|
||||
|
||||
// 创建新订单
|
||||
const newOrder = {
|
||||
id: mockOrders.length + 1,
|
||||
order_no: orderNo,
|
||||
stock_code: stock_code,
|
||||
stock_name: '模拟股票', // 实际应该查询股票名称
|
||||
order_type: order_type,
|
||||
price_type: price_type,
|
||||
order_price: 0,
|
||||
order_qty: order_qty,
|
||||
filled_qty: order_qty,
|
||||
filled_price: 0,
|
||||
filled_amount: 0,
|
||||
commission: 0,
|
||||
stamp_tax: 0,
|
||||
transfer_fee: 0,
|
||||
total_fee: 0,
|
||||
status: 'FILLED',
|
||||
reject_reason: null,
|
||||
order_time: new Date().toISOString(),
|
||||
filled_time: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 添加到订单列表
|
||||
mockOrders.unshift(newOrder);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: '下单成功',
|
||||
data: {
|
||||
order_no: orderNo,
|
||||
order_id: newOrder.id
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// ==================== 撤销订单 ====================
|
||||
http.post('/api/simulation/cancel-order/:orderId', async ({ params }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const { orderId } = params;
|
||||
console.log('[Mock] 撤销订单:', orderId);
|
||||
|
||||
// 查找并更新订单状态
|
||||
const order = mockOrders.find(o => o.id.toString() === orderId || o.order_no === orderId);
|
||||
if (order) {
|
||||
order.status = 'CANCELLED';
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: '撤单成功'
|
||||
});
|
||||
}),
|
||||
|
||||
// ==================== 获取资产统计数据 ====================
|
||||
http.get('/api/simulation/statistics', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const days = parseInt(url.searchParams.get('days') || '30');
|
||||
|
||||
console.log('[Mock] 获取资产统计, days:', days);
|
||||
|
||||
// 生成模拟的资产历史数据
|
||||
const dailyReturns = [];
|
||||
const baseAssets = 1000000;
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - (days - 1 - i));
|
||||
|
||||
// 生成随机波动
|
||||
const randomChange = (Math.random() - 0.5) * 0.02; // ±1%
|
||||
const assets = baseAssets * (1 + randomChange * i / days);
|
||||
|
||||
dailyReturns.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
closing_assets: assets,
|
||||
total_assets: assets,
|
||||
daily_profit: assets - baseAssets,
|
||||
daily_profit_rate: ((assets - baseAssets) / baseAssets * 100).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
daily_returns: dailyReturns,
|
||||
summary: {
|
||||
total_profit: 0,
|
||||
total_profit_rate: 0,
|
||||
win_rate: 50,
|
||||
max_drawdown: -5.2
|
||||
}
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// ==================== 获取交易记录 ====================
|
||||
http.get('/api/simulation/transactions', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50');
|
||||
|
||||
console.log('[Mock] 获取交易记录, limit:', limit);
|
||||
|
||||
// 返回已成交的订单作为交易记录
|
||||
const transactions = mockOrders
|
||||
.filter(order => order.status === 'FILLED')
|
||||
.slice(0, limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: transactions
|
||||
});
|
||||
}),
|
||||
|
||||
// ==================== 搜索股票 ====================
|
||||
http.get('/api/stocks/search', async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const keyword = url.searchParams.get('q') || '';
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10');
|
||||
|
||||
console.log('[Mock] 搜索股票:', keyword);
|
||||
|
||||
// 模拟股票数据
|
||||
const allStocks = [
|
||||
{ stock_code: '000001', stock_name: '平安银行', current_price: 12.50, pinyin_abbr: 'payh', security_type: 'A股', exchange: '深交所' },
|
||||
{ stock_code: '000002', stock_name: '万科A', current_price: 8.32, pinyin_abbr: 'wka', security_type: 'A股', exchange: '深交所' },
|
||||
{ stock_code: '600036', stock_name: '招商银行', current_price: 42.80, pinyin_abbr: 'zsyh', security_type: 'A股', exchange: '上交所' },
|
||||
{ stock_code: '600519', stock_name: '贵州茅台', current_price: 1680.50, pinyin_abbr: 'gzmt', security_type: 'A股', exchange: '上交所' },
|
||||
{ stock_code: '601318', stock_name: '中国平安', current_price: 45.20, pinyin_abbr: 'zgpa', security_type: 'A股', exchange: '上交所' },
|
||||
{ stock_code: '688256', stock_name: '寒武纪', current_price: 1394.94, pinyin_abbr: 'hwj', security_type: 'A股', exchange: '上交所科创板' },
|
||||
];
|
||||
|
||||
// 过滤股票
|
||||
const results = allStocks.filter(stock =>
|
||||
stock.stock_code.includes(keyword) ||
|
||||
stock.stock_name.includes(keyword) ||
|
||||
stock.pinyin_abbr.includes(keyword.toLowerCase())
|
||||
).slice(0, limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: results
|
||||
});
|
||||
})
|
||||
];
|
||||
@@ -26,14 +26,14 @@
|
||||
* 输出格式:
|
||||
* {
|
||||
* segments: [
|
||||
* { text: "核心结论:...", citationId: 1 }
|
||||
* { text: "国内领先的IT解决方案提供商", citationId: 1 } // 优先使用 query_part
|
||||
* ],
|
||||
* citations: {
|
||||
* 1: {
|
||||
* author: "陈彤",
|
||||
* report_title: "深度布局...",
|
||||
* declare_date: "2025-04-17",
|
||||
* sentences: "核心结论:..."
|
||||
* sentences: "核心结论:..." // sentences 显示在弹窗中
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
@@ -55,9 +55,9 @@ export const processCitationData = (rawData) => {
|
||||
|
||||
// 处理每个引用数据项
|
||||
rawData.data.forEach((item, index) => {
|
||||
// 验证必需字段
|
||||
if (!item.sentences) {
|
||||
console.warn(`citationUtils: Missing 'sentences' field in item ${index}`);
|
||||
// 验证必需字段(至少需要 query_part 或 sentences 之一)
|
||||
if (!item.query_part && !item.sentences) {
|
||||
console.warn(`citationUtils: Missing both 'query_part' and 'sentences' fields in item ${index}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export const processCitationData = (rawData) => {
|
||||
|
||||
// 构建文本片段
|
||||
segments.push({
|
||||
text: item.sentences,
|
||||
text: item.query_part || item.sentences, // 优先使用 query_part,降级到 sentences
|
||||
citationId: citationId
|
||||
});
|
||||
|
||||
@@ -126,6 +126,6 @@ export const isValidCitationData = (data) => {
|
||||
if (!data.data || !Array.isArray(data.data)) return false;
|
||||
if (data.data.length === 0) return false;
|
||||
|
||||
// 检查至少有一个有效的 sentences 字段
|
||||
return data.data.some(item => item && item.sentences);
|
||||
// 检查至少有一个有效的 query_part 或 sentences 字段
|
||||
return data.data.some(item => item && (item.query_part || item.sentences));
|
||||
};
|
||||
|
||||
@@ -452,6 +452,7 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
|
||||
// 初始化数据加载
|
||||
useEffect(() => {
|
||||
console.log('[StockDetailPanel] useEffect 触发, visible:', visible, 'event:', event?.id);
|
||||
if (visible && event) {
|
||||
setActiveTab('stocks');
|
||||
loadAllData();
|
||||
@@ -460,8 +461,9 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
|
||||
// 加载所有数据的函数
|
||||
const loadAllData = useCallback(() => {
|
||||
console.log('[StockDetailPanel] loadAllData 被调用, event:', event?.id);
|
||||
if (!event) return;
|
||||
|
||||
|
||||
// 加载自选股列表
|
||||
loadWatchlist();
|
||||
|
||||
@@ -469,7 +471,11 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
setLoading(true);
|
||||
eventService.getRelatedStocks(event.id)
|
||||
.then(res => {
|
||||
console.log('[前端] 接收到事件相关股票数据:', res);
|
||||
if (res.success) {
|
||||
console.log('[前端] 股票数据数组:', res.data);
|
||||
console.log('[前端] 第一只股票:', res.data[0]);
|
||||
console.log('[前端] 第一只股票的 relation_desc:', res.data[0]?.relation_desc);
|
||||
setRelatedStocks(res.data);
|
||||
if (res.data.length > 0) {
|
||||
const codes = res.data.map(s => s.stock_code);
|
||||
@@ -585,17 +591,39 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
title: '关联描述',
|
||||
dataIndex: 'relation_desc',
|
||||
key: 'relation_desc',
|
||||
width: 200,
|
||||
render: (text, record) => {
|
||||
if (!text) return '--';
|
||||
|
||||
width: 300,
|
||||
render: (relationDesc, record) => {
|
||||
console.log('[表格渲染] 股票:', record.stock_code, 'relation_desc:', relationDesc);
|
||||
|
||||
// 处理 relation_desc 的两种格式
|
||||
let text = '';
|
||||
|
||||
if (!relationDesc) {
|
||||
return '--';
|
||||
} else if (typeof relationDesc === 'string') {
|
||||
// 旧格式:直接是字符串
|
||||
text = relationDesc;
|
||||
} else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
// 新格式:{data: [{query_part: "...", sentences: "..."}]}
|
||||
// 提取所有 query_part,用逗号连接
|
||||
text = relationDesc.data
|
||||
.map(item => item.query_part || item.sentences || '')
|
||||
.filter(s => s)
|
||||
.join(';') || '--';
|
||||
} else {
|
||||
console.warn('[表格渲染] 未知的 relation_desc 格式:', relationDesc);
|
||||
return '--';
|
||||
}
|
||||
|
||||
if (!text || text === '--') return '--';
|
||||
|
||||
const isExpanded = expandedRows.has(record.stock_code);
|
||||
const maxLength = 30; // 收缩时显示的最大字符数
|
||||
const needTruncate = text.length > maxLength;
|
||||
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{
|
||||
<div style={{
|
||||
whiteSpace: isExpanded ? 'normal' : 'nowrap',
|
||||
overflow: isExpanded ? 'visible' : 'hidden',
|
||||
textOverflow: isExpanded ? 'clip' : 'ellipsis',
|
||||
@@ -614,7 +642,7 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
e.stopPropagation(); // 防止触发行点击事件
|
||||
toggleRowExpand(record.stock_code);
|
||||
}}
|
||||
style={{
|
||||
style={{
|
||||
position: isExpanded ? 'static' : 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
|
||||
@@ -72,8 +72,7 @@ import ImportanceLegend from './components/ImportanceLegend';
|
||||
import InvestmentCalendar from './components/InvestmentCalendar';
|
||||
import { eventService } from '../../services/eventService';
|
||||
|
||||
// 导入导航栏组件 (如果需要保留原有的导航栏)
|
||||
import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
|
||||
const filterLabelMap = {
|
||||
date_range: v => v ? `日期: ${v}` : '',
|
||||
@@ -141,6 +140,8 @@ const Community = () => {
|
||||
|
||||
// 加载事件列表
|
||||
const loadEvents = useCallback(async (page = 1) => {
|
||||
console.log('[Community] loadEvents 被调用,页码:', page);
|
||||
console.log('[Community] 调用栈:', new Error().stack);
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters = getFiltersFromUrl();
|
||||
@@ -255,19 +256,23 @@ const Community = () => {
|
||||
});
|
||||
|
||||
// 初始化加载
|
||||
// 注意: 只监听 searchParams 变化,不监听 loadEvents 等函数
|
||||
// 这是为了避免 StockDetailPanel 打开时触发不必要的重新加载
|
||||
// 如果未来 loadEvents 添加了新的状态依赖,需要在此处同步更新
|
||||
useEffect(() => {
|
||||
console.log('[Community] useEffect 触发,searchParams 变化:', searchParams.toString());
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
loadEvents(page);
|
||||
loadPopularKeywords();
|
||||
loadHotEvents();
|
||||
}, [searchParams, loadEvents, loadPopularKeywords, loadHotEvents]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]); // 只监听 URL 参数变化
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 导航栏 - 可以保留原有的或改用Chakra UI版本 */}
|
||||
<HomeNavbar />
|
||||
{/* 导航栏已由 MainLayout 提供 */}
|
||||
|
||||
{/* Midjourney风格英雄区域 */}
|
||||
<MidjourneyHeroSection />
|
||||
|
||||
@@ -83,8 +83,7 @@ import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import ConceptTimelineModal from './ConceptTimelineModal';
|
||||
import ConceptStatsPanel from './components/ConceptStatsPanel';
|
||||
// 导入导航栏组件
|
||||
import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
// 导入订阅权限管理
|
||||
import { useSubscription } from '../../hooks/useSubscription';
|
||||
import SubscriptionUpgradeModal from '../../components/SubscriptionUpgradeModal';
|
||||
@@ -1080,8 +1079,7 @@ const ConceptCenter = () => {
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg="gray.50">
|
||||
{/* 导航栏 */}
|
||||
<HomeNavbar />
|
||||
{/* 导航栏已由 MainLayout 提供 */}
|
||||
|
||||
{/* Hero Section */}
|
||||
<Box
|
||||
|
||||
@@ -44,8 +44,7 @@ import SectorDetails from './components/SectorDetails';
|
||||
import { DataAnalysis, StockDetailModal } from './components/DataVisualizationComponents';
|
||||
import { AdvancedSearch, SearchResultsModal } from './components/SearchComponents';
|
||||
|
||||
// 导入导航栏组件
|
||||
import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
|
||||
// 导入高位股统计组件
|
||||
import HighPositionStocks from './components/HighPositionStocks';
|
||||
@@ -303,9 +302,8 @@ export default function LimitAnalyse() {
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 导航栏 */}
|
||||
<HomeNavbar />
|
||||
|
||||
{/* 导航栏已由 MainLayout 提供 */}
|
||||
|
||||
{/* 顶部Header */}
|
||||
<Box bgGradient="linear(to-br, blue.500, purple.600)" color="white" py={8}>
|
||||
<Container maxW="container.xl">
|
||||
|
||||
@@ -61,7 +61,8 @@ import { FaChartLine, FaFire, FaRocket, FaBrain, FaCalendarAlt, FaChevronRight,
|
||||
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
|
||||
import { keyframes } from '@emotion/react';
|
||||
import * as echarts from 'echarts';
|
||||
import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
// Navigation bar now provided by MainLayout
|
||||
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
|
||||
// 动画定义
|
||||
const pulseAnimation = keyframes`
|
||||
@@ -524,10 +525,9 @@ const StockOverview = () => {
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 导航栏 */}
|
||||
<HomeNavbar />
|
||||
{/* 导航栏已由 MainLayout 提供 */}
|
||||
|
||||
|
||||
|
||||
|
||||
{/* Hero Section */}
|
||||
<Box
|
||||
|
||||
@@ -45,8 +45,7 @@ import MarginTrading from './components/MarginTrading';
|
||||
// 导入现有的高质量组件
|
||||
import LineChart from '../../components/Charts/LineChart';
|
||||
|
||||
// 导入导航栏组件
|
||||
import HomeNavbar from '../../components/Navbars/HomeNavbar';
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
|
||||
// 模拟盘账户管理 Hook
|
||||
import { useTradingAccount } from './hooks/useTradingAccount';
|
||||
@@ -221,9 +220,7 @@ export default function TradingSimulation() {
|
||||
// ========== 5. 主要渲染逻辑 ==========
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 导航栏 */}
|
||||
<HomeNavbar />
|
||||
|
||||
{/* 导航栏已由 MainLayout 提供 */}
|
||||
<Container maxW="7xl" py={8}>
|
||||
{!isAuthenticated ? (
|
||||
<Alert status="warning">
|
||||
|
||||
Reference in New Issue
Block a user