Spaces:
Running
Running
superxuu commited on
Commit ·
57864b1
1
Parent(s): 19b1032
feat: implement user auth, daily limits, and built-in Vmq payment gateway
Browse files- .codebuddy/plans/Paper_Trading_项目初始化与开发_57815873.md +0 -219
- backend/app/api.py +313 -17
- backend/app/database_user.py +96 -3
- backend/app/main.py +9 -2
- backend/backend/data/user_data.db +0 -0
- frontend/src/app/page.tsx +22 -17
- frontend/src/components/AuthModal.tsx +168 -0
- frontend/src/components/LoginPage.tsx +214 -0
- frontend/src/components/PaymentModal.tsx +256 -0
- frontend/src/components/TradePanel.tsx +146 -61
- frontend/src/components/UserMenu.tsx +143 -0
- frontend/src/lib/api.ts +66 -5
- frontend/src/store/authStore.ts +181 -0
- package.json +4 -1
- scripts/upload_users.py +17 -0
- 用户鉴权与支付方案.md → user_auth_payment_plan.md +0 -0
.codebuddy/plans/Paper_Trading_项目初始化与开发_57815873.md
DELETED
|
@@ -1,219 +0,0 @@
|
|
| 1 |
-
---
|
| 2 |
-
name: Paper_Trading 项目初始化与开发
|
| 3 |
-
overview: 根据 DEVELOPMENT_PLAN.md 文档,初始化并开发一个基于 Web 的A股历史行情回放与模拟交易应用,包含后端(FastAPI + DuckDB)和前端(Next.js + Lightweight Charts)的完整功能。
|
| 4 |
-
design:
|
| 5 |
-
architecture:
|
| 6 |
-
framework: react
|
| 7 |
-
component: tdesign
|
| 8 |
-
styleKeywords:
|
| 9 |
-
- Dark Theme
|
| 10 |
-
- Financial Tech
|
| 11 |
-
- Data Visualization
|
| 12 |
-
- Professional Trading
|
| 13 |
-
fontSystem:
|
| 14 |
-
fontFamily: Roboto
|
| 15 |
-
heading:
|
| 16 |
-
size: 24px
|
| 17 |
-
weight: 600
|
| 18 |
-
subheading:
|
| 19 |
-
size: 16px
|
| 20 |
-
weight: 500
|
| 21 |
-
body:
|
| 22 |
-
size: 14px
|
| 23 |
-
weight: 400
|
| 24 |
-
colorSystem:
|
| 25 |
-
primary:
|
| 26 |
-
- "#1E88E5"
|
| 27 |
-
- "#42A5F5"
|
| 28 |
-
background:
|
| 29 |
-
- "#0D1421"
|
| 30 |
-
- "#1A2332"
|
| 31 |
-
- "#242F3D"
|
| 32 |
-
text:
|
| 33 |
-
- "#FFFFFF"
|
| 34 |
-
- "#B0BEC5"
|
| 35 |
-
- "#78909C"
|
| 36 |
-
functional:
|
| 37 |
-
- "#4CAF50"
|
| 38 |
-
- "#EF5350"
|
| 39 |
-
- "#FFC107"
|
| 40 |
-
todos:
|
| 41 |
-
- id: init-project
|
| 42 |
-
content: 创建项目目录结构,编写 requirements.txt 和 frontend/package.json
|
| 43 |
-
status: completed
|
| 44 |
-
- id: database-module
|
| 45 |
-
content: 实现 backend/app/database.py,包含 init_db、upload_db 和单例连接
|
| 46 |
-
status: completed
|
| 47 |
-
dependencies:
|
| 48 |
-
- init-project
|
| 49 |
-
- id: sync-script
|
| 50 |
-
content: 编写 backend/scripts/sync_data.py,实现 AkShare 数据获取和入库逻辑
|
| 51 |
-
status: completed
|
| 52 |
-
dependencies:
|
| 53 |
-
- database-module
|
| 54 |
-
- id: backend-api
|
| 55 |
-
content: 开发 backend/app/main.py、api.py、core.py,实现盲盒选股和 K 线查询 API
|
| 56 |
-
status: completed
|
| 57 |
-
dependencies:
|
| 58 |
-
- sync-script
|
| 59 |
-
- id: frontend-setup
|
| 60 |
-
content: 初始化 Next.js 项目,配置 TypeScript、Tailwind CSS 和 next.config.mjs
|
| 61 |
-
status: completed
|
| 62 |
-
dependencies:
|
| 63 |
-
- init-project
|
| 64 |
-
- id: frontend-components
|
| 65 |
-
content: 开发 Chart 组件、TradePanel 组件和 Zustand Store
|
| 66 |
-
status: completed
|
| 67 |
-
dependencies:
|
| 68 |
-
- frontend-setup
|
| 69 |
-
- id: integration-deploy
|
| 70 |
-
content: 编写 Dockerfile,本地 Docker 构建测试
|
| 71 |
-
status: completed
|
| 72 |
-
dependencies:
|
| 73 |
-
- backend-api
|
| 74 |
-
- frontend-components
|
| 75 |
-
---
|
| 76 |
-
|
| 77 |
-
## 产品概述
|
| 78 |
-
|
| 79 |
-
StockReplay-A 是一款基于 Web 的 A 股历史行情回放与模拟交易应用,提供"盲盒式"选股训练,帮助用户在随机历史行情中验证交易策略,部署到 Hugging Face Spaces 实现零成本运行。
|
| 80 |
-
|
| 81 |
-
## 核心功能
|
| 82 |
-
|
| 83 |
-
- **盲盒式选股**:随机选择上市满3年的股票,随机起点,保证至少250个交易日数据,脱敏隐藏股票代码直到游戏结束
|
| 84 |
-
- **K线回放**:使用 Lightweight Charts 展示K线图,前端逐根推进,模拟实盘体验
|
| 85 |
-
- **模拟交易**:纯前端计算买卖逻辑,支持买入/卖出/下一根K线操作,记录交易历史和收益率
|
| 86 |
-
- **数据持久化**:DuckDB 嵌入式存储 + Hugging Face Datasets 同步,解决 HF Space 重启数据丢失问题
|
| 87 |
-
|
| 88 |
-
## 技术栈选型
|
| 89 |
-
|
| 90 |
-
### 后端技术
|
| 91 |
-
|
| 92 |
-
- **Python 3.11**:高性能异步支持
|
| 93 |
-
- **FastAPI**:异步 API 框架
|
| 94 |
-
- **DuckDB**:嵌入式 OLAP 数据库,单文件存储
|
| 95 |
-
- **AkShare**:A股开源数据接口
|
| 96 |
-
- **huggingface_hub**:Dataset 同步 SDK
|
| 97 |
-
|
| 98 |
-
### 前端技术
|
| 99 |
-
|
| 100 |
-
- **Next.js 14**:App Router 模式
|
| 101 |
-
- **TypeScript**:严格类型检查
|
| 102 |
-
- **Tailwind CSS**:Utility-first CSS
|
| 103 |
-
- **Lightweight Charts**:TradingView 出品的 Canvas 渲染图表库
|
| 104 |
-
- **Zustand**:轻量级状态管理
|
| 105 |
-
|
| 106 |
-
### 部署架构
|
| 107 |
-
|
| 108 |
-
- **Docker**:多阶段构建,前端构建静态资源后由 FastAPI 托管
|
| 109 |
-
- **Hugging Face Spaces**:免费容器运行环境
|
| 110 |
-
|
| 111 |
-
## 实现方案
|
| 112 |
-
|
| 113 |
-
### 系统架构
|
| 114 |
-
|
| 115 |
-
采用前后端分离的单体应用架构,通过 Docker 多阶段构建打包:
|
| 116 |
-
|
| 117 |
-
1. Stage 1:构建 Next.js 静态资源(output: 'export')
|
| 118 |
-
2. Stage 2:FastAPI 后端 + 托管前端静态文件
|
| 119 |
-
|
| 120 |
-
### 数据流设计
|
| 121 |
-
|
| 122 |
-
```
|
| 123 |
-
启动时 → hf_hub_download 下载 DuckDB 文件
|
| 124 |
-
运行时 → FastAPI 读取 DuckDB 返回 K 线数据
|
| 125 |
-
定时任务 → AkShare 增量更新 → upload_file 推送到 Dataset
|
| 126 |
-
```
|
| 127 |
-
|
| 128 |
-
### 盲盒选股算法
|
| 129 |
-
|
| 130 |
-
1. 从 stock_list 筛选 list_date 早于3年前的股票
|
| 131 |
-
2. 随机选取一只股票
|
| 132 |
-
3. 随机选择起始点(确保剩余 >= 250 天)
|
| 133 |
-
4. 返回后续 500 条 K 线数据(脱敏处理)
|
| 134 |
-
|
| 135 |
-
### 模拟交易引擎
|
| 136 |
-
|
| 137 |
-
纯前端 Zustand Store 管理:
|
| 138 |
-
|
| 139 |
-
- 买入:计算成本均价、扣减现金、增加持仓
|
| 140 |
-
- 卖出:按当前价结算、增加现金、清空持仓
|
| 141 |
-
- 收益率:(现金 + 持仓市值 - 初始资金) / 初始资金
|
| 142 |
-
|
| 143 |
-
## 目录结构
|
| 144 |
-
|
| 145 |
-
```
|
| 146 |
-
c:/python_project/Paper_Trading/
|
| 147 |
-
├── backend/ # Python 后端
|
| 148 |
-
│ ├── app/
|
| 149 |
-
│ │ ├── main.py # [NEW] FastAPI 入口,配置静态文件托管、CORS、生命周期事件
|
| 150 |
-
│ │ ├── api.py # [NEW] 路由定义,实现 /api/health、/api/game/start、/api/kline、/api/admin/sync
|
| 151 |
-
│ │ ├── core.py # [NEW] 核心业务逻辑,实现盲盒选股算法、K线数据查询
|
| 152 |
-
│ │ └── database.py # [NEW] DuckDB 管理模块,实现 init_db、upload_db、单例连接池
|
| 153 |
-
│ ├── scripts/
|
| 154 |
-
│ │ └── sync_data.py # [NEW] 数据同步脚本,AkShare 获取 A 股列表和日线数据
|
| 155 |
-
│ └── requirements.txt # [NEW] Python 依赖:fastapi, uvicorn, duckdb, pandas, akshare, huggingface_hub
|
| 156 |
-
├── frontend/ # Next.js 前端
|
| 157 |
-
│ ├── src/
|
| 158 |
-
│ │ ├── app/
|
| 159 |
-
│ │ │ ├── layout.tsx # [NEW] 根布局,配置 Tailwind 和全局样式
|
| 160 |
-
│ │ │ ├── page.tsx # [NEW] 主页面,整合 Chart 和 TradePanel
|
| 161 |
-
│ │ │ └── globals.css # [NEW] 全局 CSS 样式
|
| 162 |
-
│ │ ├── components/
|
| 163 |
-
│ │ │ ├── Chart.tsx # [NEW] Lightweight Charts 封装,支持 ResizeObserver
|
| 164 |
-
│ │ │ └── TradePanel.tsx # [NEW] 交易面板组件,买入/卖出/下一根K线交互
|
| 165 |
-
│ │ ├── hooks/
|
| 166 |
-
│ │ │ └── useGame.ts # [NEW] 游戏逻辑 Hook,封装 startGame、nextCandle 等方法
|
| 167 |
-
│ │ ├── store/
|
| 168 |
-
│ │ │ └── gameStore.ts # [NEW] Zustand Store,管理 cash、holdings、avgPrice、history 状态
|
| 169 |
-
│ │ └── lib/
|
| 170 |
-
│ │ └── api.ts # [NEW] API 客户端,封装 fetch 请求
|
| 171 |
-
│ ├── package.json # [NEW] 前端依赖配置
|
| 172 |
-
│ ├── tsconfig.json # [NEW] TypeScript 配置
|
| 173 |
-
│ ├── tailwind.config.ts # [NEW] Tailwind CSS 配置
|
| 174 |
-
│ ├── next.config.mjs # [NEW] Next.js 配置,output: 'export'
|
| 175 |
-
│ └── postcss.config.mjs # [NEW] PostCSS 配置
|
| 176 |
-
├── Dockerfile # [NEW] 多阶段构建配置
|
| 177 |
-
└── README.md # [MODIFY] 项目说明文档
|
| 178 |
-
```
|
| 179 |
-
|
| 180 |
-
## 实现要点
|
| 181 |
-
|
| 182 |
-
### 数据库持久化
|
| 183 |
-
|
| 184 |
-
- 启动时检查 `/tmp/stock_data.duckdb` 是否存在,不存在则从 HF Dataset 下载
|
| 185 |
-
- 数据更新后立即调用 `upload_file` 推送到 Dataset Repo
|
| 186 |
-
- 使用环境变量 `HF_TOKEN`、`DATASET_REPO_ID` 配置认证
|
| 187 |
-
|
| 188 |
-
### 性能优化
|
| 189 |
-
|
| 190 |
-
- DuckDB 使用列式存储,索引优化查询速度
|
| 191 |
-
- 前端使用 Canvas 渲染(Lightweight Charts),避免 DOM 重绘开销
|
| 192 |
-
- K 线数据分批加载,避免一次性传输大量数据
|
| 193 |
-
|
| 194 |
-
### 错误处理
|
| 195 |
-
|
| 196 |
-
- API 返回标准错误格式 `{ "error": "message" }`
|
| 197 |
-
- 前端全局错误边界捕获渲染错误
|
| 198 |
-
- 数据同步失败时记录日志但不中断服务
|
| 199 |
-
|
| 200 |
-
## 设计风格
|
| 201 |
-
|
| 202 |
-
采用现代金融科技风格,深色主题为主,强调数据可视化和专业交易体验。使用 React + TypeScript + Tailwind CSS + TDesign 组件库构建。
|
| 203 |
-
|
| 204 |
-
## 页面规划
|
| 205 |
-
|
| 206 |
-
### 主页面(游戏页面)
|
| 207 |
-
|
| 208 |
-
分为四个核心区块:
|
| 209 |
-
|
| 210 |
-
1. **顶部导航栏**:Logo、游戏状态、收益率显示
|
| 211 |
-
2. **K线图表区**:Lightweight Charts 全屏展示,支持缩放和十字光标
|
| 212 |
-
3. **交易面板区**:资金余额、持仓信息、买入/卖出按钮、数量输入框
|
| 213 |
-
4. **底部控制栏**:下一根K线、重新开始、揭示答案按钮
|
| 214 |
-
|
| 215 |
-
## 交互设计
|
| 216 |
-
|
| 217 |
-
- K线推进时有平滑动画效果
|
| 218 |
-
- 交易按钮点击有即时反馈
|
| 219 |
-
- 持仓变化时数字有高亮闪烁效果
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/api.py
CHANGED
|
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta
|
|
| 10 |
from typing import Optional
|
| 11 |
|
| 12 |
from fastapi import APIRouter, HTTPException, Header, Query, Depends, Request
|
|
|
|
| 13 |
from pydantic import BaseModel
|
| 14 |
from sqlalchemy.orm import Session
|
| 15 |
|
|
@@ -27,8 +28,17 @@ from .database_user import (
|
|
| 27 |
get_user_by_token,
|
| 28 |
verify_vmq_signature,
|
| 29 |
extend_vip_membership,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
)
|
| 31 |
|
|
|
|
|
|
|
| 32 |
# 导入同步函数
|
| 33 |
import sys
|
| 34 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
@@ -83,6 +93,32 @@ class VipStatusResponse(BaseModel):
|
|
| 83 |
vip_expire_at: Optional[str]
|
| 84 |
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
def _get_token_from_header(authorization: Optional[str]) -> Optional[str]:
|
| 87 |
if not authorization:
|
| 88 |
return None
|
|
@@ -149,10 +185,7 @@ async def register(payload: RegisterRequest, db: Session = Depends(get_user_db))
|
|
| 149 |
if exists:
|
| 150 |
raise HTTPException(status_code=400, detail="用户名已存在")
|
| 151 |
|
| 152 |
-
user =
|
| 153 |
-
db.add(user)
|
| 154 |
-
db.commit()
|
| 155 |
-
db.refresh(user)
|
| 156 |
|
| 157 |
token = create_session_token()
|
| 158 |
session = UserSession(
|
|
@@ -201,6 +234,22 @@ async def vip_status(current_user: User = Depends(get_current_user), db: Session
|
|
| 201 |
return VipStatusResponse(is_vip=vip["is_vip"], vip_expire_at=vip["vip_expire_at"])
|
| 202 |
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
@router.post("/v1/payment/callback")
|
| 205 |
async def payment_callback(request: Request, db: Session = Depends(get_user_db)):
|
| 206 |
"""
|
|
@@ -257,31 +306,259 @@ async def payment_callback(request: Request, db: Session = Depends(get_user_db))
|
|
| 257 |
}
|
| 258 |
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
@router.get("/game/start", response_model=GameStartResponse)
|
| 261 |
async def game_start(
|
| 262 |
mode: str = Query(default="random", description="游戏模式"),
|
| 263 |
market: Optional[str] = Query(default=None, description="板块类型"),
|
| 264 |
-
|
| 265 |
-
db: Session = Depends(get_user_db)
|
| 266 |
):
|
| 267 |
"""
|
| 268 |
开始游戏 - 获取盲盒数据
|
|
|
|
|
|
|
|
|
|
| 269 |
"""
|
| 270 |
-
|
| 271 |
-
is_vip =
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
if not is_vip and market in ['科创板', '北交所', '可转债']:
|
| 281 |
-
|
| 282 |
|
| 283 |
try:
|
| 284 |
result = start_game(mode=mode, mask=True, market_type=market)
|
|
|
|
|
|
|
|
|
|
| 285 |
return result
|
| 286 |
except ValueError as e:
|
| 287 |
raise HTTPException(status_code=400, detail=str(e))
|
|
@@ -290,6 +567,25 @@ async def game_start(
|
|
| 290 |
raise HTTPException(status_code=500, detail="Internal server error")
|
| 291 |
|
| 292 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
@router.get("/kline", response_model=list[KLineData])
|
| 294 |
async def get_kline(
|
| 295 |
code: str = Query(..., description="股票代码"),
|
|
|
|
| 10 |
from typing import Optional
|
| 11 |
|
| 12 |
from fastapi import APIRouter, HTTPException, Header, Query, Depends, Request
|
| 13 |
+
from fastapi.responses import HTMLResponse
|
| 14 |
from pydantic import BaseModel
|
| 15 |
from sqlalchemy.orm import Session
|
| 16 |
|
|
|
|
| 28 |
get_user_by_token,
|
| 29 |
verify_vmq_signature,
|
| 30 |
extend_vip_membership,
|
| 31 |
+
get_daily_usage,
|
| 32 |
+
increment_daily_usage,
|
| 33 |
+
FREE_DAILY_LIMIT,
|
| 34 |
+
create_payment_order,
|
| 35 |
+
sign_vmq_create_payload,
|
| 36 |
+
match_pending_payment_order,
|
| 37 |
+
register_user,
|
| 38 |
)
|
| 39 |
|
| 40 |
+
VMQ_GATEWAY_URL = os.getenv("VMQ_GATEWAY_URL", "")
|
| 41 |
+
|
| 42 |
# 导入同步函数
|
| 43 |
import sys
|
| 44 |
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
| 93 |
vip_expire_at: Optional[str]
|
| 94 |
|
| 95 |
|
| 96 |
+
class CreatePaymentRequest(BaseModel):
|
| 97 |
+
type: int = 2 # 1: 支付宝, 2: 微信 (默认为2)
|
| 98 |
+
|
| 99 |
+
class CreatePaymentResponse(BaseModel):
|
| 100 |
+
order_id: str
|
| 101 |
+
pay_url: str
|
| 102 |
+
price: float
|
| 103 |
+
type: int
|
| 104 |
+
param: str
|
| 105 |
+
sign: str
|
| 106 |
+
is_mock: bool
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class CreatePaymentRequest(BaseModel):
|
| 110 |
+
type: int = 2 # 1: 支付宝, 2: 微信 (默认为2)
|
| 111 |
+
|
| 112 |
+
class CreatePaymentResponse(BaseModel):
|
| 113 |
+
order_id: str
|
| 114 |
+
pay_url: str
|
| 115 |
+
price: float
|
| 116 |
+
type: int
|
| 117 |
+
param: str
|
| 118 |
+
sign: str
|
| 119 |
+
is_mock: bool
|
| 120 |
+
|
| 121 |
+
|
| 122 |
def _get_token_from_header(authorization: Optional[str]) -> Optional[str]:
|
| 123 |
if not authorization:
|
| 124 |
return None
|
|
|
|
| 185 |
if exists:
|
| 186 |
raise HTTPException(status_code=400, detail="用户名已存在")
|
| 187 |
|
| 188 |
+
user = register_user(db, username, hash_password(payload.password))
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
token = create_session_token()
|
| 191 |
session = UserSession(
|
|
|
|
| 234 |
return VipStatusResponse(is_vip=vip["is_vip"], vip_expire_at=vip["vip_expire_at"])
|
| 235 |
|
| 236 |
|
| 237 |
+
@router.post("/v1/auth/logout")
|
| 238 |
+
async def logout(
|
| 239 |
+
current_user: User = Depends(get_current_user),
|
| 240 |
+
authorization: Optional[str] = Header(None, alias="Authorization"),
|
| 241 |
+
db: Session = Depends(get_user_db),
|
| 242 |
+
):
|
| 243 |
+
"""退出登录 - 删除当前 session token"""
|
| 244 |
+
token = _get_token_from_header(authorization)
|
| 245 |
+
if token:
|
| 246 |
+
session_row = db.get(UserSession, token)
|
| 247 |
+
if session_row:
|
| 248 |
+
db.delete(session_row)
|
| 249 |
+
db.commit()
|
| 250 |
+
return {"status": "ok"}
|
| 251 |
+
|
| 252 |
+
|
| 253 |
@router.post("/v1/payment/callback")
|
| 254 |
async def payment_callback(request: Request, db: Session = Depends(get_user_db)):
|
| 255 |
"""
|
|
|
|
| 306 |
}
|
| 307 |
|
| 308 |
|
| 309 |
+
@router.post("/v1/payment/create", response_model=CreatePaymentResponse)
|
| 310 |
+
async def create_payment(
|
| 311 |
+
payload: CreatePaymentRequest,
|
| 312 |
+
request: Request,
|
| 313 |
+
current_user: User = Depends(get_current_user),
|
| 314 |
+
db: Session = Depends(get_user_db)
|
| 315 |
+
):
|
| 316 |
+
"""创建支付订单"""
|
| 317 |
+
# 价格配置 (后续可移至数据库或配置)
|
| 318 |
+
price = 9.90
|
| 319 |
+
|
| 320 |
+
# 1. 创建本地订单
|
| 321 |
+
order_id = create_payment_order(db, current_user.id, price, payload.type)
|
| 322 |
+
|
| 323 |
+
# 2. 构造 V免签参数
|
| 324 |
+
# standard VMQ params
|
| 325 |
+
params = {
|
| 326 |
+
"payId": order_id,
|
| 327 |
+
"type": payload.type,
|
| 328 |
+
"price": price,
|
| 329 |
+
"param": str(current_user.id),
|
| 330 |
+
"isHtml": 1,
|
| 331 |
+
"notifyUrl": "",
|
| 332 |
+
"returnUrl": "",
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
# 3. 签名
|
| 336 |
+
sign = sign_vmq_create_payload(params)
|
| 337 |
+
params["sign"] = sign
|
| 338 |
+
|
| 339 |
+
# 4. 构造跳转 URL
|
| 340 |
+
pay_url = ""
|
| 341 |
+
is_mock = False
|
| 342 |
+
|
| 343 |
+
from urllib.parse import urlencode
|
| 344 |
+
query_string = urlencode({k: v for k, v in params.items() if v is not None and str(v) != ""})
|
| 345 |
+
|
| 346 |
+
if VMQ_GATEWAY_URL:
|
| 347 |
+
base_url = VMQ_GATEWAY_URL.rstrip("/")
|
| 348 |
+
# 假设网关接口为 /createOrder,如果用户配置的 URL 包含路径则直接使用
|
| 349 |
+
if base_url.endswith("/createOrder") or base_url.endswith(".php") or base_url.endswith("/"):
|
| 350 |
+
pay_url = f"{base_url}?{query_string}"
|
| 351 |
+
else:
|
| 352 |
+
pay_url = f"{base_url}/createOrder?{query_string}"
|
| 353 |
+
else:
|
| 354 |
+
# 使用内置网关
|
| 355 |
+
base_url = str(request.base_url).rstrip("/")
|
| 356 |
+
pay_url = f"{base_url}/vmq/createOrder?{query_string}"
|
| 357 |
+
# 注意: 如果用户还是想用最简单的 Mock 模式(不扫码),可以将 is_mock 设为 True
|
| 358 |
+
# 这里默认进入内置网关流程
|
| 359 |
+
is_mock = False
|
| 360 |
+
|
| 361 |
+
return CreatePaymentResponse(
|
| 362 |
+
order_id=order_id,
|
| 363 |
+
pay_url=pay_url,
|
| 364 |
+
price=price,
|
| 365 |
+
type=payload.type,
|
| 366 |
+
param=params["param"],
|
| 367 |
+
sign=sign,
|
| 368 |
+
is_mock=is_mock
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
# --- Vmq Built-in Gateway Endpoints ---
|
| 373 |
+
|
| 374 |
+
@router.get("/vmq/addOrder")
|
| 375 |
+
async def vmq_add_order(
|
| 376 |
+
type: int = Query(...),
|
| 377 |
+
price: float = Query(...),
|
| 378 |
+
payId: str = Query(None),
|
| 379 |
+
sign: str = Query(...),
|
| 380 |
+
db: Session = Depends(get_user_db)
|
| 381 |
+
):
|
| 382 |
+
"""
|
| 383 |
+
V免签监控 App 推送接口
|
| 384 |
+
协议: sign = md5(type + price + payId + key)
|
| 385 |
+
注意: Vmq App 推送参数格式固定,payId 可能为空
|
| 386 |
+
"""
|
| 387 |
+
# 1. 签名校验
|
| 388 |
+
# 注意: price 转字符串可能涉及精度,Vmq 通常保留两位小数
|
| 389 |
+
price_str = f"{price:.2f}"
|
| 390 |
+
pay_id_str = payId if payId else ""
|
| 391 |
+
raw = f"{type}{price_str}{pay_id_str}{VMQ_SECRET}"
|
| 392 |
+
expected = hashlib.md5(raw.encode("utf-8")).hexdigest()
|
| 393 |
+
|
| 394 |
+
if sign.lower() != expected.lower():
|
| 395 |
+
logger.warning(f"Vmq App push sign mismatch: {sign} != {expected}")
|
| 396 |
+
return "error: sign mismatch"
|
| 397 |
+
|
| 398 |
+
# 2. 匹配订单
|
| 399 |
+
order = None
|
| 400 |
+
if payId:
|
| 401 |
+
order = db.query(PaymentOrder).filter(PaymentOrder.order_id == payId).first()
|
| 402 |
+
else:
|
| 403 |
+
# App 没传订单号,按金额和类型匹配最近的
|
| 404 |
+
order = match_pending_payment_order(db, price, type)
|
| 405 |
+
|
| 406 |
+
if not order:
|
| 407 |
+
logger.warning(f"Vmq App push: order not found for price={price}, type={type}")
|
| 408 |
+
return "error: order not found"
|
| 409 |
+
|
| 410 |
+
if order.status == "paid":
|
| 411 |
+
return "success"
|
| 412 |
+
|
| 413 |
+
# 3. 更新订单
|
| 414 |
+
order.status = "paid"
|
| 415 |
+
order.paid_at = datetime.utcnow()
|
| 416 |
+
db.commit()
|
| 417 |
+
|
| 418 |
+
# 4. 延长 VIP (extend_vip_membership 包含数据库同步逻辑)
|
| 419 |
+
extend_vip_membership(db, order.user_id, days=30)
|
| 420 |
+
|
| 421 |
+
logger.info(f"Vmq App push success: user_id={order.user_id}, amount={price}")
|
| 422 |
+
return "success"
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
@router.get("/vmq/checkOrder")
|
| 426 |
+
async def vmq_check_order(
|
| 427 |
+
payId: str = Query(...),
|
| 428 |
+
db: Session = Depends(get_user_db)
|
| 429 |
+
):
|
| 430 |
+
"""前端轮询订单状态接口"""
|
| 431 |
+
order = db.query(PaymentOrder).filter(PaymentOrder.order_id == payId).first()
|
| 432 |
+
if not order:
|
| 433 |
+
return {"code": -1, "msg": "not found"}
|
| 434 |
+
|
| 435 |
+
status = 1 # 1: 等待支付
|
| 436 |
+
if order.status == "paid":
|
| 437 |
+
status = 2 # 2: 支付成功
|
| 438 |
+
elif order.status == "expired":
|
| 439 |
+
status = -1 # -1: 订单过期
|
| 440 |
+
|
| 441 |
+
return {
|
| 442 |
+
"code": 1,
|
| 443 |
+
"msg": "ok",
|
| 444 |
+
"data": {
|
| 445 |
+
"status": status,
|
| 446 |
+
"order_id": order.order_id
|
| 447 |
+
}
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
@router.get("/vmq/createOrder", response_class=HTMLResponse)
|
| 452 |
+
async def vmq_create_order_page(
|
| 453 |
+
payId: str = Query(...),
|
| 454 |
+
type: int = Query(...),
|
| 455 |
+
price: float = Query(...),
|
| 456 |
+
sign: str = Query(...),
|
| 457 |
+
param: str = Query(""),
|
| 458 |
+
db: Session = Depends(get_user_db)
|
| 459 |
+
):
|
| 460 |
+
"""内置网关的支付收银台页面"""
|
| 461 |
+
# 1. 校验签名 (由商户端发往网关端的签名)
|
| 462 |
+
check_params = {
|
| 463 |
+
"payId": payId,
|
| 464 |
+
"type": type,
|
| 465 |
+
"price": price,
|
| 466 |
+
"param": param,
|
| 467 |
+
}
|
| 468 |
+
expected = sign_vmq_create_payload(check_params)
|
| 469 |
+
if sign != expected:
|
| 470 |
+
return "<html><body><h1>签名错误</h1></body></html>"
|
| 471 |
+
|
| 472 |
+
# 2. 这里的二维码应该是用户预先上传的个人收款码
|
| 473 |
+
# 提示用户将 qrcode_alipay.png / qrcode_wechat.png 放在 backend/static/images/ 下
|
| 474 |
+
qr_image = "/static/images/qrcode_alipay.png" if type == 1 else "/static/images/qrcode_wechat.png"
|
| 475 |
+
|
| 476 |
+
return f"""
|
| 477 |
+
<!DOCTYPE html>
|
| 478 |
+
<html>
|
| 479 |
+
<head>
|
| 480 |
+
<meta charset="UTF-8">
|
| 481 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 482 |
+
<title>扫码支付</title>
|
| 483 |
+
<style>
|
| 484 |
+
body {{ font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto; background: #f5f5f5; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }}
|
| 485 |
+
.card {{ background: white; padding: 30px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.1); text-align: center; max-width: 320px; width: 90%; }}
|
| 486 |
+
.price {{ font-size: 36px; font-weight: bold; color: #333; margin: 20px 0; }}
|
| 487 |
+
.price span {{ font-size: 18px; }}
|
| 488 |
+
.qr-box {{ background: #fff; padding: 10px; border: 1px solid #eee; border-radius: 12px; margin: 20px 0; }}
|
| 489 |
+
.qr-box img {{ width: 100%; display: block; }}
|
| 490 |
+
.hint {{ color: #666; font-size: 14px; line-height: 1.6; }}
|
| 491 |
+
.footer {{ margin-top: 20px; font-size: 12px; color: #999; }}
|
| 492 |
+
.type-badge {{ display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: bold; color: white; }}
|
| 493 |
+
.alipay {{ background: #1677FF; }}
|
| 494 |
+
.wechat {{ background: #07C160; }}
|
| 495 |
+
</style>
|
| 496 |
+
</head>
|
| 497 |
+
<body>
|
| 498 |
+
<div class="card">
|
| 499 |
+
<div class="type-badge {'alipay' if type==1 else 'wechat'}">
|
| 500 |
+
{'支付宝' if type==1 else '微信支付'}
|
| 501 |
+
</div>
|
| 502 |
+
<div class="price"><span>¥</span>{price:.2f}</div>
|
| 503 |
+
<div class="qr-box">
|
| 504 |
+
<img src="{qr_image}" alt="请上传收款码到指定目录">
|
| 505 |
+
</div>
|
| 506 |
+
<div class="hint">
|
| 507 |
+
请保��二维码或直接扫码支付<br>
|
| 508 |
+
<strong>支付金额必须完全一致</strong>
|
| 509 |
+
</div>
|
| 510 |
+
<div class="footer">
|
| 511 |
+
订单号: {payId}<br>
|
| 512 |
+
支付后请返回原页面查看状态
|
| 513 |
+
</div>
|
| 514 |
+
</div>
|
| 515 |
+
<script>
|
| 516 |
+
// 如果是在 Modal 中内嵌,可以定时检查状态跳转,但目前由前端 Modal 自己轮询
|
| 517 |
+
</script>
|
| 518 |
+
</body>
|
| 519 |
+
</html>
|
| 520 |
+
"""
|
| 521 |
+
|
| 522 |
+
|
| 523 |
@router.get("/game/start", response_model=GameStartResponse)
|
| 524 |
async def game_start(
|
| 525 |
mode: str = Query(default="random", description="游戏模式"),
|
| 526 |
market: Optional[str] = Query(default=None, description="板块类型"),
|
| 527 |
+
current_user: User = Depends(get_current_user),
|
| 528 |
+
db: Session = Depends(get_user_db),
|
| 529 |
):
|
| 530 |
"""
|
| 531 |
开始游戏 - 获取盲盒数据
|
| 532 |
+
- 必须登录(未登录返回 401)
|
| 533 |
+
- 非 VIP 每天限 FREE_DAILY_LIMIT 次
|
| 534 |
+
- VIP 无限制
|
| 535 |
"""
|
| 536 |
+
vip_info = check_vip_status(current_user.id, db)
|
| 537 |
+
is_vip = vip_info["is_vip"]
|
| 538 |
+
|
| 539 |
+
# 非 VIP:检查每日次数
|
| 540 |
+
if not is_vip:
|
| 541 |
+
used_today = get_daily_usage(db, current_user.id)
|
| 542 |
+
if used_today >= FREE_DAILY_LIMIT:
|
| 543 |
+
raise HTTPException(
|
| 544 |
+
status_code=403,
|
| 545 |
+
detail={
|
| 546 |
+
"code": "DAILY_LIMIT_EXCEEDED",
|
| 547 |
+
"message": f"今日免费次数({FREE_DAILY_LIMIT}次)已用完,开通会员可无限使用",
|
| 548 |
+
"used": used_today,
|
| 549 |
+
"limit": FREE_DAILY_LIMIT,
|
| 550 |
+
}
|
| 551 |
+
)
|
| 552 |
+
|
| 553 |
+
# VIP 受限板块校验
|
| 554 |
if not is_vip and market in ['科创板', '北交所', '可转债']:
|
| 555 |
+
raise HTTPException(status_code=403, detail="该板块仅限 VIP 会员使用")
|
| 556 |
|
| 557 |
try:
|
| 558 |
result = start_game(mode=mode, mask=True, market_type=market)
|
| 559 |
+
# 成功后记录使用次数(VIP 不计)
|
| 560 |
+
if not is_vip:
|
| 561 |
+
increment_daily_usage(db, current_user.id)
|
| 562 |
return result
|
| 563 |
except ValueError as e:
|
| 564 |
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
| 567 |
raise HTTPException(status_code=500, detail="Internal server error")
|
| 568 |
|
| 569 |
|
| 570 |
+
@router.get("/v1/usage/today")
|
| 571 |
+
async def get_usage_today(
|
| 572 |
+
current_user: User = Depends(get_current_user),
|
| 573 |
+
db: Session = Depends(get_user_db),
|
| 574 |
+
):
|
| 575 |
+
"""获取今日使用情况"""
|
| 576 |
+
vip_info = check_vip_status(current_user.id, db)
|
| 577 |
+
is_vip = vip_info["is_vip"]
|
| 578 |
+
used = get_daily_usage(db, current_user.id)
|
| 579 |
+
return {
|
| 580 |
+
"used": used,
|
| 581 |
+
"limit": None if is_vip else FREE_DAILY_LIMIT,
|
| 582 |
+
"remaining": None if is_vip else max(0, FREE_DAILY_LIMIT - used),
|
| 583 |
+
"is_vip": is_vip,
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
|
| 587 |
+
|
| 588 |
+
|
| 589 |
@router.get("/kline", response_model=list[KLineData])
|
| 590 |
async def get_kline(
|
| 591 |
code: str = Query(..., description="股票代码"),
|
backend/app/database_user.py
CHANGED
|
@@ -25,6 +25,7 @@ from sqlalchemy import (
|
|
| 25 |
Float,
|
| 26 |
ForeignKey,
|
| 27 |
Text,
|
|
|
|
| 28 |
)
|
| 29 |
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker, Session
|
| 30 |
from huggingface_hub import hf_hub_download, upload_file
|
|
@@ -68,7 +69,8 @@ class PaymentOrder(Base):
|
|
| 68 |
order_id: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
| 69 |
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
| 70 |
amount: Mapped[float] = mapped_column(Float, nullable=False)
|
| 71 |
-
|
|
|
|
| 72 |
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 73 |
paid_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
| 74 |
raw_payload: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
@@ -83,6 +85,15 @@ class UserSession(Base):
|
|
| 83 |
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 84 |
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
def _ensure_db_dir() -> None:
|
| 87 |
Path(USER_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
| 88 |
|
|
@@ -165,6 +176,15 @@ def sync_user_db_after_update(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
| 165 |
return wrapper
|
| 166 |
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
def hash_password(password: str) -> str:
|
| 169 |
salt = secrets.token_hex(16)
|
| 170 |
digest = hashlib.sha256(f"{salt}:{password}".encode("utf-8")).hexdigest()
|
|
@@ -189,11 +209,59 @@ def verify_vmq_signature(payload: dict[str, Any], sign: str) -> bool:
|
|
| 189 |
return False
|
| 190 |
|
| 191 |
sign_data = payload.copy()
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
keys = sorted(sign_data.keys())
|
| 194 |
raw = "&".join([f"{k}={sign_data[k]}" for k in keys]) + VMQ_SECRET
|
| 195 |
expected = hashlib.md5(raw.encode("utf-8")).hexdigest()
|
| 196 |
-
return hmac.compare_digest
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
|
| 199 |
@sync_user_db_after_update
|
|
@@ -222,3 +290,28 @@ def get_user_by_token(db: Session, token: str) -> Optional[User]:
|
|
| 222 |
db.commit()
|
| 223 |
return None
|
| 224 |
return db.get(User, session_row.user_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
Float,
|
| 26 |
ForeignKey,
|
| 27 |
Text,
|
| 28 |
+
Date,
|
| 29 |
)
|
| 30 |
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker, Session
|
| 31 |
from huggingface_hub import hf_hub_download, upload_file
|
|
|
|
| 69 |
order_id: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
| 70 |
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False)
|
| 71 |
amount: Mapped[float] = mapped_column(Float, nullable=False)
|
| 72 |
+
pay_type: Mapped[int] = mapped_column(Integer, default=1) # 1: 支付宝, 2: 微信
|
| 73 |
+
status: Mapped[str] = mapped_column(String(32), default="pending", index=True) # pending, paid, expired
|
| 74 |
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 75 |
paid_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
| 76 |
raw_payload: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
|
|
|
| 85 |
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
| 86 |
|
| 87 |
|
| 88 |
+
class DailyUsage(Base):
|
| 89 |
+
"""每日使用次数记录"""
|
| 90 |
+
__tablename__ = "daily_usage"
|
| 91 |
+
|
| 92 |
+
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), primary_key=True)
|
| 93 |
+
use_date: Mapped[datetime] = mapped_column(Date, primary_key=True)
|
| 94 |
+
count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
def _ensure_db_dir() -> None:
|
| 98 |
Path(USER_DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
| 99 |
|
|
|
|
| 176 |
return wrapper
|
| 177 |
|
| 178 |
|
| 179 |
+
@sync_user_db_after_update
|
| 180 |
+
def register_user(db: Session, username: str, password_hash: str) -> User:
|
| 181 |
+
user = User(username=username, password_hash=password_hash)
|
| 182 |
+
db.add(user)
|
| 183 |
+
db.commit()
|
| 184 |
+
db.refresh(user)
|
| 185 |
+
return user
|
| 186 |
+
|
| 187 |
+
|
| 188 |
def hash_password(password: str) -> str:
|
| 189 |
salt = secrets.token_hex(16)
|
| 190 |
digest = hashlib.sha256(f"{salt}:{password}".encode("utf-8")).hexdigest()
|
|
|
|
| 209 |
return False
|
| 210 |
|
| 211 |
sign_data = payload.copy()
|
| 212 |
+
if "sign" in sign_data:
|
| 213 |
+
sign_data.pop("sign")
|
| 214 |
+
# 过滤空值
|
| 215 |
+
sign_data = {k: v for k, v in sign_data.items() if v is not None and str(v) != ""}
|
| 216 |
+
|
| 217 |
keys = sorted(sign_data.keys())
|
| 218 |
raw = "&".join([f"{k}={sign_data[k]}" for k in keys]) + VMQ_SECRET
|
| 219 |
expected = hashlib.md5(raw.encode("utf-8")).hexdigest()
|
| 220 |
+
return expected == sign # V免签有时返回小写有时大写,建议对比时统一但是 verify_vmq_signature 里 hmac.compare_digest 是安全的
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def sign_vmq_create_payload(payload: dict[str, Any]) -> str:
|
| 224 |
+
"""生成创建订单时的签名"""
|
| 225 |
+
if not VMQ_SECRET:
|
| 226 |
+
return ""
|
| 227 |
+
sign_data = {k: v for k, v in payload.items() if v is not None and str(v) != "" and k != "sign"}
|
| 228 |
+
keys = sorted(sign_data.keys())
|
| 229 |
+
raw = "&".join([f"{k}={sign_data[k]}" for k in keys]) + VMQ_SECRET
|
| 230 |
+
return hashlib.md5(raw.encode("utf-8")).hexdigest()
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
@sync_user_db_after_update
|
| 234 |
+
def create_payment_order(db: Session, user_id: int, amount: float, pay_type: int = 1) -> str:
|
| 235 |
+
"""创建待支付订单"""
|
| 236 |
+
# 生成订单号: YYYYMMDDHHMMSS + 6位随机
|
| 237 |
+
order_id = datetime.utcnow().strftime("%Y%m%d%H%M%S") + secrets.token_hex(3)
|
| 238 |
+
|
| 239 |
+
order = PaymentOrder(
|
| 240 |
+
order_id=order_id,
|
| 241 |
+
user_id=user_id,
|
| 242 |
+
amount=amount,
|
| 243 |
+
pay_type=pay_type,
|
| 244 |
+
status="pending",
|
| 245 |
+
raw_payload="",
|
| 246 |
+
)
|
| 247 |
+
db.add(order)
|
| 248 |
+
db.commit()
|
| 249 |
+
return order_id
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def match_pending_payment_order(db: Session, amount: float, pay_type: int) -> Optional[PaymentOrder]:
|
| 253 |
+
"""
|
| 254 |
+
匹配最近的待支付订单
|
| 255 |
+
逻辑: 在过去 1 小时内,查找状态为 pending 且金额和类型匹配的最新一个订单
|
| 256 |
+
"""
|
| 257 |
+
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
| 258 |
+
|
| 259 |
+
return db.query(PaymentOrder).filter(
|
| 260 |
+
PaymentOrder.status == "pending",
|
| 261 |
+
PaymentOrder.amount == amount,
|
| 262 |
+
PaymentOrder.pay_type == pay_type,
|
| 263 |
+
PaymentOrder.created_at >= one_hour_ago
|
| 264 |
+
).order_by(PaymentOrder.created_at.desc()).first()
|
| 265 |
|
| 266 |
|
| 267 |
@sync_user_db_after_update
|
|
|
|
| 290 |
db.commit()
|
| 291 |
return None
|
| 292 |
return db.get(User, session_row.user_id)
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
FREE_DAILY_LIMIT = int(os.getenv("FREE_DAILY_LIMIT", "3"))
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def get_daily_usage(db: Session, user_id: int) -> int:
|
| 299 |
+
"""获取今日已使用次数(UTC 日期)"""
|
| 300 |
+
today = datetime.utcnow().date()
|
| 301 |
+
row = db.get(DailyUsage, {"user_id": user_id, "use_date": today})
|
| 302 |
+
return row.count if row else 0
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
@sync_user_db_after_update
|
| 306 |
+
def increment_daily_usage(db: Session, user_id: int) -> int:
|
| 307 |
+
"""今日��用次数 +1,返回更新后的次数"""
|
| 308 |
+
today = datetime.utcnow().date()
|
| 309 |
+
row = db.get(DailyUsage, {"user_id": user_id, "use_date": today})
|
| 310 |
+
if row is None:
|
| 311 |
+
row = DailyUsage(user_id=user_id, use_date=today, count=1)
|
| 312 |
+
db.add(row)
|
| 313 |
+
else:
|
| 314 |
+
row.count += 1
|
| 315 |
+
db.commit()
|
| 316 |
+
db.refresh(row)
|
| 317 |
+
return row.count
|
backend/app/main.py
CHANGED
|
@@ -11,9 +11,8 @@ from fastapi import FastAPI
|
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
|
| 14 |
-
from .api import router
|
| 15 |
from .database import get_db
|
| 16 |
-
from .database_user import init_user_db
|
| 17 |
from scripts.sync_data import main as run_sync_task
|
| 18 |
from apscheduler.schedulers.background import BackgroundScheduler
|
| 19 |
from pytz import timezone
|
|
@@ -57,6 +56,14 @@ async def lifespan(app: FastAPI):
|
|
| 57 |
|
| 58 |
# 关闭时
|
| 59 |
logger.info("Application shutting down...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
scheduler.shutdown()
|
| 61 |
db.close()
|
| 62 |
logger.info("Database connection closed")
|
|
|
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
from fastapi.middleware.cors import CORSMiddleware
|
| 13 |
|
|
|
|
| 14 |
from .database import get_db
|
| 15 |
+
from .database_user import init_user_db, upload_user_db_to_hf
|
| 16 |
from scripts.sync_data import main as run_sync_task
|
| 17 |
from apscheduler.schedulers.background import BackgroundScheduler
|
| 18 |
from pytz import timezone
|
|
|
|
| 56 |
|
| 57 |
# 关闭时
|
| 58 |
logger.info("Application shutting down...")
|
| 59 |
+
|
| 60 |
+
# 停机前最后一次备份用户数据库
|
| 61 |
+
try:
|
| 62 |
+
upload_user_db_to_hf()
|
| 63 |
+
logger.info("Final user db backup completed before shutdown")
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(f"Final user db backup failed: {e}")
|
| 66 |
+
|
| 67 |
scheduler.shutdown()
|
| 68 |
db.close()
|
| 69 |
logger.info("Database connection closed")
|
backend/backend/data/user_data.db
ADDED
|
Binary file (49.2 kB). View file
|
|
|
frontend/src/app/page.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import dynamic from 'next/dynamic';
|
|
| 5 |
import TradePanel from '@/components/TradePanel';
|
| 6 |
import StockHeader from '@/components/StockHeader';
|
| 7 |
import ChartControls from '@/components/ChartControls';
|
|
|
|
| 8 |
import { useGameStore } from '@/store/gameStore';
|
| 9 |
import { resample, Period } from '@/lib/resample';
|
| 10 |
|
|
@@ -13,24 +14,24 @@ const Chart = dynamic(() => import('@/components/Chart'), { ssr: false });
|
|
| 13 |
|
| 14 |
export default function Home() {
|
| 15 |
const { allKlines, currentIndex, isPlaying, initialIndex, history } = useGameStore();
|
| 16 |
-
|
| 17 |
// 指标状态
|
| 18 |
const [mainIndicator, setMainIndicator] = useState('MA');
|
| 19 |
const [subIndicator1, setSubIndicator1] = useState('VOL');
|
| 20 |
const [subIndicator2, setSubIndicator2] = useState('MACD');
|
| 21 |
const [period, setPeriod] = useState<Period>('day');
|
| 22 |
-
|
| 23 |
// 光标数据状态
|
| 24 |
const [cursorData, setCursorData] = useState<any>(null);
|
| 25 |
-
|
| 26 |
// 计算显示数据 (重采样)
|
| 27 |
const { displayKlines, displayIndex, displayInitialIndex } = useMemo(() => {
|
| 28 |
if (allKlines.length === 0) return { displayKlines: [], displayIndex: 0, displayInitialIndex: 0 };
|
| 29 |
-
|
| 30 |
const { klines, indexMap } = resample(allKlines, period);
|
| 31 |
const idx = indexMap[currentIndex] ?? 0;
|
| 32 |
const initIdx = indexMap[initialIndex] ?? 0;
|
| 33 |
-
|
| 34 |
return { displayKlines: klines, displayIndex: idx, displayInitialIndex: initIdx };
|
| 35 |
}, [allKlines, currentIndex, initialIndex, period]);
|
| 36 |
|
|
@@ -39,11 +40,16 @@ export default function Home() {
|
|
| 39 |
{/* 左侧/上方:行情区域 - 仅在游戏开始后显示 */}
|
| 40 |
{isPlaying && (
|
| 41 |
<div className="flex-[1.2] lg:flex-1 flex flex-col min-w-0 min-h-0 relative">
|
| 42 |
-
{/* 顶部行情头 */}
|
| 43 |
-
<
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
{/* 指标控制条 */}
|
| 46 |
-
<ChartControls
|
| 47 |
mainIndicator={mainIndicator}
|
| 48 |
setMainIndicator={setMainIndicator}
|
| 49 |
subIndicator1={subIndicator1}
|
|
@@ -53,12 +59,12 @@ export default function Home() {
|
|
| 53 |
period={period}
|
| 54 |
setPeriod={setPeriod}
|
| 55 |
/>
|
| 56 |
-
|
| 57 |
{/* K 线图表 */}
|
| 58 |
<div className="flex-1 relative bg-[#0D1421] min-h-0">
|
| 59 |
{displayKlines.length > 0 && (
|
| 60 |
-
<Chart
|
| 61 |
-
data={displayKlines}
|
| 62 |
currentIndex={displayIndex}
|
| 63 |
className="w-full h-full"
|
| 64 |
mainIndicator={mainIndicator}
|
|
@@ -72,13 +78,12 @@ export default function Home() {
|
|
| 72 |
</div>
|
| 73 |
</div>
|
| 74 |
)}
|
| 75 |
-
|
| 76 |
{/* 右侧/下方:交易面板 */}
|
| 77 |
-
<div className={`min-h-0 bg-[#1E212B] z-20 shadow-xl transition-all duration-300 ${
|
| 78 |
-
|
| 79 |
-
? 'w-full h-full'
|
| 80 |
: 'w-full lg:w-[380px] flex-none border-t lg:border-t-0 lg:border-l border-[#2A2D3C] h-auto lg:h-full'
|
| 81 |
-
|
| 82 |
<TradePanel />
|
| 83 |
</div>
|
| 84 |
</main>
|
|
|
|
| 5 |
import TradePanel from '@/components/TradePanel';
|
| 6 |
import StockHeader from '@/components/StockHeader';
|
| 7 |
import ChartControls from '@/components/ChartControls';
|
| 8 |
+
import UserMenu from '@/components/UserMenu';
|
| 9 |
import { useGameStore } from '@/store/gameStore';
|
| 10 |
import { resample, Period } from '@/lib/resample';
|
| 11 |
|
|
|
|
| 14 |
|
| 15 |
export default function Home() {
|
| 16 |
const { allKlines, currentIndex, isPlaying, initialIndex, history } = useGameStore();
|
| 17 |
+
|
| 18 |
// 指标状态
|
| 19 |
const [mainIndicator, setMainIndicator] = useState('MA');
|
| 20 |
const [subIndicator1, setSubIndicator1] = useState('VOL');
|
| 21 |
const [subIndicator2, setSubIndicator2] = useState('MACD');
|
| 22 |
const [period, setPeriod] = useState<Period>('day');
|
| 23 |
+
|
| 24 |
// 光标数据状态
|
| 25 |
const [cursorData, setCursorData] = useState<any>(null);
|
| 26 |
+
|
| 27 |
// 计算显示数据 (重采样)
|
| 28 |
const { displayKlines, displayIndex, displayInitialIndex } = useMemo(() => {
|
| 29 |
if (allKlines.length === 0) return { displayKlines: [], displayIndex: 0, displayInitialIndex: 0 };
|
| 30 |
+
|
| 31 |
const { klines, indexMap } = resample(allKlines, period);
|
| 32 |
const idx = indexMap[currentIndex] ?? 0;
|
| 33 |
const initIdx = indexMap[initialIndex] ?? 0;
|
| 34 |
+
|
| 35 |
return { displayKlines: klines, displayIndex: idx, displayInitialIndex: initIdx };
|
| 36 |
}, [allKlines, currentIndex, initialIndex, period]);
|
| 37 |
|
|
|
|
| 40 |
{/* 左侧/上方:行情区域 - 仅在游戏开始后显示 */}
|
| 41 |
{isPlaying && (
|
| 42 |
<div className="flex-[1.2] lg:flex-1 flex flex-col min-w-0 min-h-0 relative">
|
| 43 |
+
{/* 顶部行情头 + 用户菜单 */}
|
| 44 |
+
<div className="relative">
|
| 45 |
+
<StockHeader cursorData={cursorData} />
|
| 46 |
+
<div className="absolute right-2 top-1.5 lg:right-4 lg:top-2 z-10">
|
| 47 |
+
<UserMenu compact />
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
|
| 51 |
{/* 指标控制条 */}
|
| 52 |
+
<ChartControls
|
| 53 |
mainIndicator={mainIndicator}
|
| 54 |
setMainIndicator={setMainIndicator}
|
| 55 |
subIndicator1={subIndicator1}
|
|
|
|
| 59 |
period={period}
|
| 60 |
setPeriod={setPeriod}
|
| 61 |
/>
|
| 62 |
+
|
| 63 |
{/* K 线图表 */}
|
| 64 |
<div className="flex-1 relative bg-[#0D1421] min-h-0">
|
| 65 |
{displayKlines.length > 0 && (
|
| 66 |
+
<Chart
|
| 67 |
+
data={displayKlines}
|
| 68 |
currentIndex={displayIndex}
|
| 69 |
className="w-full h-full"
|
| 70 |
mainIndicator={mainIndicator}
|
|
|
|
| 78 |
</div>
|
| 79 |
</div>
|
| 80 |
)}
|
| 81 |
+
|
| 82 |
{/* 右侧/下方:交易面板 */}
|
| 83 |
+
<div className={`min-h-0 bg-[#1E212B] z-20 shadow-xl transition-all duration-300 ${!isPlaying
|
| 84 |
+
? 'w-full h-full'
|
|
|
|
| 85 |
: 'w-full lg:w-[380px] flex-none border-t lg:border-t-0 lg:border-l border-[#2A2D3C] h-auto lg:h-full'
|
| 86 |
+
}`}>
|
| 87 |
<TradePanel />
|
| 88 |
</div>
|
| 89 |
</main>
|
frontend/src/components/AuthModal.tsx
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* 登录/注册弹窗组件
|
| 5 |
+
*/
|
| 6 |
+
|
| 7 |
+
import { useState } from 'react';
|
| 8 |
+
import { X, User, Lock, Eye, EyeOff } from 'lucide-react';
|
| 9 |
+
import { useAuthStore } from '@/store/authStore';
|
| 10 |
+
|
| 11 |
+
interface AuthModalProps {
|
| 12 |
+
onClose: () => void;
|
| 13 |
+
initialMode?: 'login' | 'register';
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export default function AuthModal({ onClose, initialMode = 'login' }: AuthModalProps) {
|
| 17 |
+
const [mode, setMode] = useState<'login' | 'register'>(initialMode);
|
| 18 |
+
const [username, setUsername] = useState('');
|
| 19 |
+
const [password, setPassword] = useState('');
|
| 20 |
+
const [confirmPassword, setConfirmPassword] = useState('');
|
| 21 |
+
const [showPassword, setShowPassword] = useState(false);
|
| 22 |
+
const [error, setError] = useState('');
|
| 23 |
+
const { login, register, isLoading } = useAuthStore();
|
| 24 |
+
|
| 25 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 26 |
+
e.preventDefault();
|
| 27 |
+
setError('');
|
| 28 |
+
|
| 29 |
+
// 前端验证
|
| 30 |
+
if (username.trim().length < 3) {
|
| 31 |
+
setError('用户名至少3个字符');
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
if (password.length < 6) {
|
| 35 |
+
setError('密码至少6位');
|
| 36 |
+
return;
|
| 37 |
+
}
|
| 38 |
+
if (mode === 'register' && password !== confirmPassword) {
|
| 39 |
+
setError('两次密码输入不一致');
|
| 40 |
+
return;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
try {
|
| 44 |
+
if (mode === 'login') {
|
| 45 |
+
await login(username.trim(), password);
|
| 46 |
+
} else {
|
| 47 |
+
await register(username.trim(), password);
|
| 48 |
+
}
|
| 49 |
+
onClose();
|
| 50 |
+
} catch (err: any) {
|
| 51 |
+
setError(err.message || (mode === 'login' ? '登录失败' : '注册失败'));
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const switchMode = () => {
|
| 56 |
+
setMode(mode === 'login' ? 'register' : 'login');
|
| 57 |
+
setError('');
|
| 58 |
+
setConfirmPassword('');
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" onClick={onClose}>
|
| 63 |
+
<div
|
| 64 |
+
className="bg-[#1E212B] rounded-2xl w-full max-w-sm shadow-2xl border border-[#2A2D3C] overflow-hidden"
|
| 65 |
+
onClick={e => e.stopPropagation()}
|
| 66 |
+
>
|
| 67 |
+
{/* Header */}
|
| 68 |
+
<div className="px-5 py-4 border-b border-[#2A2D3C] flex items-center justify-between">
|
| 69 |
+
<h3 className="text-lg font-bold text-white">
|
| 70 |
+
{mode === 'login' ? '登录' : '注册'}
|
| 71 |
+
</h3>
|
| 72 |
+
<button
|
| 73 |
+
onClick={onClose}
|
| 74 |
+
className="w-8 h-8 flex items-center justify-center rounded-lg bg-[#2A2D3C] hover:bg-[#35394B] text-gray-400 hover:text-white transition-colors"
|
| 75 |
+
>
|
| 76 |
+
<X size={16} />
|
| 77 |
+
</button>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
{/* Form */}
|
| 81 |
+
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
| 82 |
+
{/* Error */}
|
| 83 |
+
{error && (
|
| 84 |
+
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-2.5 rounded-lg">
|
| 85 |
+
{error}
|
| 86 |
+
</div>
|
| 87 |
+
)}
|
| 88 |
+
|
| 89 |
+
{/* Username */}
|
| 90 |
+
<div>
|
| 91 |
+
<label className="text-gray-400 text-xs mb-1.5 block">用户名</label>
|
| 92 |
+
<div className="relative">
|
| 93 |
+
<User size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
| 94 |
+
<input
|
| 95 |
+
type="text"
|
| 96 |
+
value={username}
|
| 97 |
+
onChange={e => setUsername(e.target.value)}
|
| 98 |
+
placeholder="请输入用户名(至少3位)"
|
| 99 |
+
className="w-full bg-[#12141C] border border-gray-700/50 rounded-xl pl-10 pr-4 py-3 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-blue-500 transition-colors"
|
| 100 |
+
autoComplete="username"
|
| 101 |
+
autoFocus
|
| 102 |
+
/>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* Password */}
|
| 107 |
+
<div>
|
| 108 |
+
<label className="text-gray-400 text-xs mb-1.5 block">密码</label>
|
| 109 |
+
<div className="relative">
|
| 110 |
+
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
| 111 |
+
<input
|
| 112 |
+
type={showPassword ? 'text' : 'password'}
|
| 113 |
+
value={password}
|
| 114 |
+
onChange={e => setPassword(e.target.value)}
|
| 115 |
+
placeholder="请输入密码(至少6位)"
|
| 116 |
+
className="w-full bg-[#12141C] border border-gray-700/50 rounded-xl pl-10 pr-10 py-3 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-blue-500 transition-colors"
|
| 117 |
+
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
| 118 |
+
/>
|
| 119 |
+
<button
|
| 120 |
+
type="button"
|
| 121 |
+
onClick={() => setShowPassword(!showPassword)}
|
| 122 |
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
| 123 |
+
>
|
| 124 |
+
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
| 125 |
+
</button>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
{/* Confirm Password (register only) */}
|
| 130 |
+
{mode === 'register' && (
|
| 131 |
+
<div>
|
| 132 |
+
<label className="text-gray-400 text-xs mb-1.5 block">确认密码</label>
|
| 133 |
+
<div className="relative">
|
| 134 |
+
<Lock size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
| 135 |
+
<input
|
| 136 |
+
type={showPassword ? 'text' : 'password'}
|
| 137 |
+
value={confirmPassword}
|
| 138 |
+
onChange={e => setConfirmPassword(e.target.value)}
|
| 139 |
+
placeholder="请再次输入密码"
|
| 140 |
+
className="w-full bg-[#12141C] border border-gray-700/50 rounded-xl pl-10 pr-4 py-3 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-blue-500 transition-colors"
|
| 141 |
+
autoComplete="new-password"
|
| 142 |
+
/>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
)}
|
| 146 |
+
|
| 147 |
+
{/* Submit */}
|
| 148 |
+
<button
|
| 149 |
+
type="submit"
|
| 150 |
+
disabled={isLoading}
|
| 151 |
+
className="w-full bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white font-bold py-3 rounded-xl transition-all duration-200 disabled:opacity-50 shadow-lg text-sm"
|
| 152 |
+
>
|
| 153 |
+
{isLoading ? '处理中...' : (mode === 'login' ? '登录' : '注册')}
|
| 154 |
+
</button>
|
| 155 |
+
|
| 156 |
+
{/* Switch mode */}
|
| 157 |
+
<div className="text-center text-sm text-gray-500">
|
| 158 |
+
{mode === 'login' ? (
|
| 159 |
+
<>还没有账号?<button type="button" onClick={switchMode} className="text-blue-400 hover:text-blue-300 ml-1">立即注册</button></>
|
| 160 |
+
) : (
|
| 161 |
+
<>已有账号?<button type="button" onClick={switchMode} className="text-blue-400 hover:text-blue-300 ml-1">立即登录</button></>
|
| 162 |
+
)}
|
| 163 |
+
</div>
|
| 164 |
+
</form>
|
| 165 |
+
</div>
|
| 166 |
+
</div>
|
| 167 |
+
);
|
| 168 |
+
}
|
frontend/src/components/LoginPage.tsx
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* 全屏登录/注册页面
|
| 5 |
+
* 未登录时显示,替代游戏开始界面
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { useState } from 'react';
|
| 9 |
+
import { User, Lock, Eye, EyeOff, TrendingUp, BarChart3, Shield } from 'lucide-react';
|
| 10 |
+
import { useAuthStore } from '@/store/authStore';
|
| 11 |
+
|
| 12 |
+
export default function LoginPage() {
|
| 13 |
+
const [mode, setMode] = useState<'login' | 'register'>('login');
|
| 14 |
+
const [username, setUsername] = useState('');
|
| 15 |
+
const [password, setPassword] = useState('');
|
| 16 |
+
const [confirmPassword, setConfirmPassword] = useState('');
|
| 17 |
+
const [showPassword, setShowPassword] = useState(false);
|
| 18 |
+
const [error, setError] = useState('');
|
| 19 |
+
const { login, register, isLoading } = useAuthStore();
|
| 20 |
+
|
| 21 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 22 |
+
e.preventDefault();
|
| 23 |
+
setError('');
|
| 24 |
+
|
| 25 |
+
if (username.trim().length < 3) {
|
| 26 |
+
setError('用户名至少3个字符');
|
| 27 |
+
return;
|
| 28 |
+
}
|
| 29 |
+
if (password.length < 6) {
|
| 30 |
+
setError('密码至少6位');
|
| 31 |
+
return;
|
| 32 |
+
}
|
| 33 |
+
if (mode === 'register' && password !== confirmPassword) {
|
| 34 |
+
setError('两次密码输入不一致');
|
| 35 |
+
return;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
try {
|
| 39 |
+
if (mode === 'login') {
|
| 40 |
+
await login(username.trim(), password);
|
| 41 |
+
} else {
|
| 42 |
+
await register(username.trim(), password);
|
| 43 |
+
}
|
| 44 |
+
} catch (err: any) {
|
| 45 |
+
setError(err.message || (mode === 'login' ? '登录失败,请检查用户名和密码' : '注册失败,用户名可能已存在'));
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<div className="flex h-full bg-[#0D1117] text-white overflow-hidden">
|
| 51 |
+
{/* 左侧品牌区 - 大屏显示 */}
|
| 52 |
+
<div className="hidden lg:flex flex-col justify-center items-center flex-1 bg-gradient-to-br from-[#0D1421] to-[#1a1f35] p-12 relative overflow-hidden">
|
| 53 |
+
{/* 背景装饰 */}
|
| 54 |
+
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
| 55 |
+
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-blue-600/5 rounded-full blur-3xl" />
|
| 56 |
+
<div className="absolute bottom-1/4 right-1/4 w-48 h-48 bg-purple-600/5 rounded-full blur-3xl" />
|
| 57 |
+
</div>
|
| 58 |
+
|
| 59 |
+
<div className="relative z-10 max-w-sm text-center space-y-8">
|
| 60 |
+
{/* Logo */}
|
| 61 |
+
<div className="flex items-center justify-center gap-3 mb-2">
|
| 62 |
+
<div className="w-14 h-14 bg-blue-600/20 rounded-2xl flex items-center justify-center">
|
| 63 |
+
<TrendingUp size={28} className="text-blue-400" />
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
<div>
|
| 67 |
+
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
| 68 |
+
StockReplay
|
| 69 |
+
</h1>
|
| 70 |
+
<p className="text-gray-400 mt-2">A股历史行情复盘训练系统</p>
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
{/* 特性列表 */}
|
| 74 |
+
<div className="space-y-4 text-left">
|
| 75 |
+
{[
|
| 76 |
+
{ icon: BarChart3, title: '盲盒式选股', desc: '随机抽取历史行情,消除幸存者偏差' },
|
| 77 |
+
{ icon: TrendingUp, title: '模拟交易', desc: '100万初始资金,真实买卖体验' },
|
| 78 |
+
{ icon: Shield, title: '数据覆盖10年', desc: '全A股历史数据,训练你的盘感' },
|
| 79 |
+
].map(({ icon: Icon, title, desc }) => (
|
| 80 |
+
<div key={title} className="flex items-start gap-3">
|
| 81 |
+
<div className="w-8 h-8 bg-blue-600/15 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
| 82 |
+
<Icon size={15} className="text-blue-400" />
|
| 83 |
+
</div>
|
| 84 |
+
<div>
|
| 85 |
+
<div className="text-sm font-medium text-gray-200">{title}</div>
|
| 86 |
+
<div className="text-xs text-gray-500 mt-0.5">{desc}</div>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
))}
|
| 90 |
+
</div>
|
| 91 |
+
|
| 92 |
+
{/* 免费次数说明 */}
|
| 93 |
+
<div className="bg-blue-500/10 border border-blue-500/20 rounded-xl p-4 text-sm text-left">
|
| 94 |
+
<div className="text-blue-400 font-medium mb-1">免费用户</div>
|
| 95 |
+
<div className="text-gray-400 text-xs">每日 3 次免费回测机会,开通 VIP 可无限使用</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
{/* 右侧登录区 */}
|
| 101 |
+
<div className="flex-1 lg:max-w-md flex flex-col justify-center px-6 py-10 lg:px-12 bg-[#191B28]">
|
| 102 |
+
{/* 移动端 Logo */}
|
| 103 |
+
<div className="lg:hidden text-center mb-8">
|
| 104 |
+
<h1 className="text-3xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
|
| 105 |
+
StockReplay
|
| 106 |
+
</h1>
|
| 107 |
+
<p className="text-gray-500 text-sm mt-1">A股历史行情复盘训练</p>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<div className="w-full max-w-sm mx-auto">
|
| 111 |
+
<h2 className="text-2xl font-bold mb-2">
|
| 112 |
+
{mode === 'login' ? '欢迎回来' : '创建账号'}
|
| 113 |
+
</h2>
|
| 114 |
+
<p className="text-gray-500 text-sm mb-8">
|
| 115 |
+
{mode === 'login' ? '登录后开始你的复盘训练' : '注册即可获得每日 3 次免费回测'}
|
| 116 |
+
</p>
|
| 117 |
+
|
| 118 |
+
<form onSubmit={handleSubmit} className="space-y-4">
|
| 119 |
+
{/* 错误提示 */}
|
| 120 |
+
{error && (
|
| 121 |
+
<div className="bg-red-500/10 border border-red-500/30 text-red-400 text-sm px-4 py-3 rounded-xl">
|
| 122 |
+
{error}
|
| 123 |
+
</div>
|
| 124 |
+
)}
|
| 125 |
+
|
| 126 |
+
{/* 用户名 */}
|
| 127 |
+
<div>
|
| 128 |
+
<label className="text-gray-400 text-xs mb-1.5 block">用户名</label>
|
| 129 |
+
<div className="relative">
|
| 130 |
+
<User size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-500" />
|
| 131 |
+
<input
|
| 132 |
+
type="text"
|
| 133 |
+
value={username}
|
| 134 |
+
onChange={e => setUsername(e.target.value)}
|
| 135 |
+
placeholder="请输入用户名(至少3位)"
|
| 136 |
+
className="w-full bg-[#12141C] border border-gray-700/50 rounded-xl pl-10 pr-4 py-3.5 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-blue-500 transition-colors"
|
| 137 |
+
autoComplete="username"
|
| 138 |
+
autoFocus
|
| 139 |
+
/>
|
| 140 |
+
</div>
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
{/* 密码 */}
|
| 144 |
+
<div>
|
| 145 |
+
<label className="text-gray-400 text-xs mb-1.5 block">密码</label>
|
| 146 |
+
<div className="relative">
|
| 147 |
+
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-500" />
|
| 148 |
+
<input
|
| 149 |
+
type={showPassword ? 'text' : 'password'}
|
| 150 |
+
value={password}
|
| 151 |
+
onChange={e => setPassword(e.target.value)}
|
| 152 |
+
placeholder="请输入密码(至少6位)"
|
| 153 |
+
className="w-full bg-[#12141C] border border-gray-700/50 rounded-xl pl-10 pr-11 py-3.5 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-blue-500 transition-colors"
|
| 154 |
+
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
| 155 |
+
/>
|
| 156 |
+
<button
|
| 157 |
+
type="button"
|
| 158 |
+
onClick={() => setShowPassword(!showPassword)}
|
| 159 |
+
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
|
| 160 |
+
>
|
| 161 |
+
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
| 162 |
+
</button>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
{/* 确认密码(注册) */}
|
| 167 |
+
{mode === 'register' && (
|
| 168 |
+
<div>
|
| 169 |
+
<label className="text-gray-400 text-xs mb-1.5 block">确认密码</label>
|
| 170 |
+
<div className="relative">
|
| 171 |
+
<Lock size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-gray-500" />
|
| 172 |
+
<input
|
| 173 |
+
type={showPassword ? 'text' : 'password'}
|
| 174 |
+
value={confirmPassword}
|
| 175 |
+
onChange={e => setConfirmPassword(e.target.value)}
|
| 176 |
+
placeholder="请再次输入密码"
|
| 177 |
+
className="w-full bg-[#12141C] border border-gray-700/50 rounded-xl pl-10 pr-4 py-3.5 text-white text-sm placeholder-gray-600 focus:outline-none focus:border-blue-500 transition-colors"
|
| 178 |
+
autoComplete="new-password"
|
| 179 |
+
/>
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
)}
|
| 183 |
+
|
| 184 |
+
{/* 提交按钮 */}
|
| 185 |
+
<button
|
| 186 |
+
type="submit"
|
| 187 |
+
disabled={isLoading}
|
| 188 |
+
className="w-full bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white font-bold py-3.5 rounded-xl transition-all duration-200 disabled:opacity-50 shadow-lg mt-2"
|
| 189 |
+
>
|
| 190 |
+
{isLoading ? '处理中...' : (mode === 'login' ? '登录' : '注册')}
|
| 191 |
+
</button>
|
| 192 |
+
</form>
|
| 193 |
+
|
| 194 |
+
{/* 切换模式 */}
|
| 195 |
+
<div className="text-center mt-6 text-sm text-gray-500">
|
| 196 |
+
{mode === 'login' ? (
|
| 197 |
+
<>还没有账号?
|
| 198 |
+
<button onClick={() => { setMode('register'); setError(''); setConfirmPassword(''); }} className="text-blue-400 hover:text-blue-300 ml-1 font-medium">
|
| 199 |
+
立即注册
|
| 200 |
+
</button>
|
| 201 |
+
</>
|
| 202 |
+
) : (
|
| 203 |
+
<>已有账号?
|
| 204 |
+
<button onClick={() => { setMode('login'); setError(''); setConfirmPassword(''); }} className="text-blue-400 hover:text-blue-300 ml-1 font-medium">
|
| 205 |
+
立即登录
|
| 206 |
+
</button>
|
| 207 |
+
</>
|
| 208 |
+
)}
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
</div>
|
| 213 |
+
);
|
| 214 |
+
}
|
frontend/src/components/PaymentModal.tsx
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* 支付弹窗组件
|
| 5 |
+
*
|
| 6 |
+
* 功能:
|
| 7 |
+
* 1. 展示VIP权益与价格 (¥9.9/30天)
|
| 8 |
+
* 2. 选择支付方式 (支付宝/微信)
|
| 9 |
+
* 3. 发起支付请求 (api.createPayment)
|
| 10 |
+
* 4. 展示支付二维码或跳转链接
|
| 11 |
+
* 5. 支付结果确认 (mock模式或轮询)
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { useState, useEffect } from 'react';
|
| 15 |
+
import { X, Check, Loader2, CreditCard, ExternalLink, Zap } from 'lucide-react';
|
| 16 |
+
import { QRCodeSVG } from 'qrcode.react';
|
| 17 |
+
import { useAuthStore } from '@/store/authStore';
|
| 18 |
+
import { api, CreatePaymentResponse } from '@/lib/api';
|
| 19 |
+
|
| 20 |
+
interface PaymentModalProps {
|
| 21 |
+
isOpen: boolean;
|
| 22 |
+
onClose: () => void;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export default function PaymentModal({ isOpen, onClose }: PaymentModalProps) {
|
| 26 |
+
const [loading, setLoading] = useState(false);
|
| 27 |
+
const [paymentType, setPaymentType] = useState<1 | 2>(2); // 1: Alipay, 2: WeChat
|
| 28 |
+
const [paymentData, setPaymentData] = useState<CreatePaymentResponse | null>(null);
|
| 29 |
+
const [error, setError] = useState<string | null>(null);
|
| 30 |
+
|
| 31 |
+
const { token, fetchUserInfo } = useAuthStore();
|
| 32 |
+
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
if (isOpen) {
|
| 35 |
+
// 重置状态
|
| 36 |
+
setPaymentData(null);
|
| 37 |
+
setError(null);
|
| 38 |
+
setLoading(false);
|
| 39 |
+
}
|
| 40 |
+
}, [isOpen]);
|
| 41 |
+
|
| 42 |
+
// 轮询支付状态
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
let timer: NodeJS.Timeout;
|
| 45 |
+
if (paymentData && !paymentData.is_mock) {
|
| 46 |
+
timer = setInterval(async () => {
|
| 47 |
+
try {
|
| 48 |
+
const res = await api.checkPaymentStatus(paymentData.order_id);
|
| 49 |
+
if (res.code === 1 && res.data.status === 2) {
|
| 50 |
+
handlePaymentSuccess();
|
| 51 |
+
clearInterval(timer);
|
| 52 |
+
}
|
| 53 |
+
} catch (err) {
|
| 54 |
+
console.error('Polling payment status failed:', err);
|
| 55 |
+
}
|
| 56 |
+
}, 3000); // 3秒轮询一次
|
| 57 |
+
}
|
| 58 |
+
return () => {
|
| 59 |
+
if (timer) clearInterval(timer);
|
| 60 |
+
};
|
| 61 |
+
}, [paymentData]);
|
| 62 |
+
|
| 63 |
+
if (!isOpen) return null;
|
| 64 |
+
|
| 65 |
+
const handleCreatePayment = async () => {
|
| 66 |
+
if (!token) return;
|
| 67 |
+
setLoading(true);
|
| 68 |
+
setError(null);
|
| 69 |
+
|
| 70 |
+
try {
|
| 71 |
+
const res = await api.createPayment(paymentType, token);
|
| 72 |
+
setPaymentData(res);
|
| 73 |
+
|
| 74 |
+
// 如果是 mock 模式,直接完成
|
| 75 |
+
if (res.is_mock) {
|
| 76 |
+
// 等待1秒模拟处理
|
| 77 |
+
setTimeout(() => {
|
| 78 |
+
handlePaymentSuccess();
|
| 79 |
+
}, 1000);
|
| 80 |
+
}
|
| 81 |
+
} catch (err: any) {
|
| 82 |
+
setError(err.message || '创建订单失败,请重试');
|
| 83 |
+
} finally {
|
| 84 |
+
setLoading(false);
|
| 85 |
+
}
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
const handlePaymentSuccess = async () => {
|
| 89 |
+
setLoading(true);
|
| 90 |
+
try {
|
| 91 |
+
// 刷新用户信息以获取最新 VIP 状态
|
| 92 |
+
await fetchUserInfo();
|
| 93 |
+
alert('支付成功!VIP权益已生效');
|
| 94 |
+
onClose();
|
| 95 |
+
} catch (err) {
|
| 96 |
+
setError('刷新会员状态失败,请稍后刷新页面');
|
| 97 |
+
} finally {
|
| 98 |
+
setLoading(false);
|
| 99 |
+
}
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
return (
|
| 103 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
| 104 |
+
<div className="bg-[#1E2030] rounded-2xl w-full max-w-md overflow-hidden border border-gray-800 shadow-2xl relative">
|
| 105 |
+
{/* 关闭按钮 */}
|
| 106 |
+
<button
|
| 107 |
+
onClick={onClose}
|
| 108 |
+
className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors z-10"
|
| 109 |
+
>
|
| 110 |
+
<X size={20} />
|
| 111 |
+
</button>
|
| 112 |
+
|
| 113 |
+
{/* 标题 */}
|
| 114 |
+
<div className="p-6 bg-gradient-to-r from-blue-900/40 to-purple-900/40 border-b border-gray-800">
|
| 115 |
+
<div className="flex items-center gap-3 mb-2">
|
| 116 |
+
<div className="w-10 h-10 bg-gradient-to-br from-yellow-400 to-orange-500 rounded-lg flex items-center justify-center text-white font-bold shadow-lg">
|
| 117 |
+
VIP
|
| 118 |
+
</div>
|
| 119 |
+
<div>
|
| 120 |
+
<h2 className="text-xl font-bold text-white">开通 VIP 会员</h2>
|
| 121 |
+
<div className="text-xs text-yellow-500 flex items-center gap-1">
|
| 122 |
+
<Zap size={12} fill="currentColor" />
|
| 123 |
+
<span>无限畅玩 · 数据解锁 · 专属标志</span>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
{/* 内容区 */}
|
| 130 |
+
<div className="p-6">
|
| 131 |
+
{paymentData ? (
|
| 132 |
+
// 支付展示阶段
|
| 133 |
+
<div className="flex flex-col items-center animate-in fade-in slide-in-from-bottom-4 duration-300">
|
| 134 |
+
{paymentData.is_mock ? (
|
| 135 |
+
<div className="py-8 text-center text-green-400">
|
| 136 |
+
<Check size={48} className="mx-auto mb-4" />
|
| 137 |
+
<div className="text-lg font-bold">模拟支付成功</div>
|
| 138 |
+
<div className="text-sm text-gray-400 mt-2">正在激活权益...</div>
|
| 139 |
+
</div>
|
| 140 |
+
) : (
|
| 141 |
+
<>
|
| 142 |
+
<div className="mb-6 bg-white p-3 rounded-xl shadow-inner">
|
| 143 |
+
<QRCodeSVG value={paymentData.pay_url} size={180} />
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<div className="text-center space-y-4 w-full">
|
| 147 |
+
<div>
|
| 148 |
+
<div className="text-sm text-gray-400 mb-1">请使用{paymentType === 1 ? '支付宝' : '微信'}扫码支付</div>
|
| 149 |
+
<div className="text-2xl font-bold text-white">¥{paymentData.price.toFixed(2)}</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<div className="flex gap-3">
|
| 153 |
+
<a
|
| 154 |
+
href={paymentData.pay_url}
|
| 155 |
+
target="_blank"
|
| 156 |
+
rel="noopener noreferrer"
|
| 157 |
+
className="flex-1 bg-gray-700/50 hover:bg-gray-700 text-white py-3 rounded-xl flex items-center justify-center gap-2 transition-colors text-sm font-medium"
|
| 158 |
+
>
|
| 159 |
+
<ExternalLink size={16} />
|
| 160 |
+
打开支付页
|
| 161 |
+
</a>
|
| 162 |
+
<button
|
| 163 |
+
onClick={handlePaymentSuccess}
|
| 164 |
+
className="flex-1 bg-green-600 hover:bg-green-500 text-white py-3 rounded-xl font-bold transition-colors shadow-lg shadow-green-900/20"
|
| 165 |
+
>
|
| 166 |
+
我已支付
|
| 167 |
+
</button>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</>
|
| 171 |
+
)}
|
| 172 |
+
</div>
|
| 173 |
+
) : (
|
| 174 |
+
// 订单创建阶段
|
| 175 |
+
<div className="space-y-6">
|
| 176 |
+
{/* 商品卡片 */}
|
| 177 |
+
<div className="bg-[#2A2D3C] border border-gray-700/50 rounded-xl p-4 flex justify-between items-center relative overflow-hidden group hover:border-blue-500/50 transition-colors cursor-pointer">
|
| 178 |
+
<div className="relative z-10">
|
| 179 |
+
<div className="text-lg font-bold text-white mb-1">30 天会员</div>
|
| 180 |
+
<div className="text-xs text-gray-400">平均仅需 0.33元/天</div>
|
| 181 |
+
</div>
|
| 182 |
+
<div className="relative z-10 text-right">
|
| 183 |
+
<div className="text-2xl font-bold text-white">
|
| 184 |
+
<span className="text-sm text-gray-400 font-normal mr-1">¥</span>
|
| 185 |
+
9.9
|
| 186 |
+
</div>
|
| 187 |
+
<div className="text-xs text-gray-500 line-through">原价 ¥19.9</div>
|
| 188 |
+
</div>
|
| 189 |
+
{/* 选中效果 */}
|
| 190 |
+
<div className="absolute inset-0 bg-blue-500/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
| 191 |
+
</div>
|
| 192 |
+
|
| 193 |
+
{/* 支付方式 */}
|
| 194 |
+
<div>
|
| 195 |
+
<div className="text-xs text-gray-500 mb-3 uppercase tracking-wider font-medium">选择支付方式</div>
|
| 196 |
+
<div className="grid grid-cols-2 gap-3">
|
| 197 |
+
<button
|
| 198 |
+
onClick={() => setPaymentType(2)}
|
| 199 |
+
className={`flex items-center justify-center gap-2 p-3 rounded-xl border transition-all ${paymentType === 2
|
| 200 |
+
? 'bg-[#00C250]/10 border-[#00C250] text-[#00C250]'
|
| 201 |
+
: 'bg-[#2A2D3C] border-gray-700/50 text-gray-400 hover:bg-[#2A2D3C]/80'
|
| 202 |
+
}`}
|
| 203 |
+
>
|
| 204 |
+
<div className="w-5 h-5 rounded-full bg-current flex items-center justify-center text-[10px] text-black font-bold">W</div>
|
| 205 |
+
<span className="font-medium">微信支付</span>
|
| 206 |
+
</button>
|
| 207 |
+
<button
|
| 208 |
+
onClick={() => setPaymentType(1)}
|
| 209 |
+
className={`flex items-center justify-center gap-2 p-3 rounded-xl border transition-all ${paymentType === 1
|
| 210 |
+
? 'bg-[#1677FF]/10 border-[#1677FF] text-[#1677FF]'
|
| 211 |
+
: 'bg-[#2A2D3C] border-gray-700/50 text-gray-400 hover:bg-[#2A2D3C]/80'
|
| 212 |
+
}`}
|
| 213 |
+
>
|
| 214 |
+
<div className="w-5 h-5 rounded-full bg-current flex items-center justify-center text-[10px] text-white font-bold">支</div>
|
| 215 |
+
<span className="font-medium">支付宝</span>
|
| 216 |
+
</button>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
|
| 220 |
+
{/* 错误显示 */}
|
| 221 |
+
{error && (
|
| 222 |
+
<div className="text-red-400 text-sm bg-red-500/10 p-3 rounded-lg flex items-center gap-2">
|
| 223 |
+
<div className="w-1 h-4 bg-red-400 rounded-full" />
|
| 224 |
+
{error}
|
| 225 |
+
</div>
|
| 226 |
+
)}
|
| 227 |
+
|
| 228 |
+
{/* 支付按钮 */}
|
| 229 |
+
<button
|
| 230 |
+
onClick={handleCreatePayment}
|
| 231 |
+
disabled={loading}
|
| 232 |
+
className="w-full bg-gradient-to-r from-yellow-500 to-orange-600 hover:from-yellow-400 hover:to-orange-500 text-white font-bold py-4 rounded-xl shadow-lg shadow-orange-900/20 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform active:scale-[0.98] flex items-center justify-center gap-2"
|
| 233 |
+
>
|
| 234 |
+
{loading ? (
|
| 235 |
+
<>
|
| 236 |
+
<Loader2 size={20} className="animate-spin" />
|
| 237 |
+
<span>处理中...</span>
|
| 238 |
+
</>
|
| 239 |
+
) : (
|
| 240 |
+
<>
|
| 241 |
+
<span>立即支付 ¥9.9</span>
|
| 242 |
+
<CreditCard size={18} className="opacity-80" />
|
| 243 |
+
</>
|
| 244 |
+
)}
|
| 245 |
+
</button>
|
| 246 |
+
|
| 247 |
+
<div className="text-center text-xs text-gray-500">
|
| 248 |
+
支付即代表同意《会员服务协议》
|
| 249 |
+
</div>
|
| 250 |
+
</div>
|
| 251 |
+
)}
|
| 252 |
+
</div>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
);
|
| 256 |
+
}
|
frontend/src/components/TradePanel.tsx
CHANGED
|
@@ -8,7 +8,11 @@
|
|
| 8 |
import { useState, useEffect, useMemo, useRef } from 'react';
|
| 9 |
import { TrendingUp, TrendingDown, Wallet, Package, RotateCcw, Eye, ChevronRight, Play, Pause, BarChart3, Trophy, X, TrendingUpIcon, Share2 } from 'lucide-react';
|
| 10 |
import { useGameStore, calculateReturnRate, calculateTotalAsset } from '@/store/gameStore';
|
|
|
|
| 11 |
import { api } from '@/lib/api';
|
|
|
|
|
|
|
|
|
|
| 12 |
import { createChart, ColorType, LineData, Time, LineStyle } from 'lightweight-charts';
|
| 13 |
import html2canvas from 'html2canvas';
|
| 14 |
import { QRCodeSVG } from 'qrcode.react';
|
|
@@ -19,11 +23,11 @@ export default function TradePanel() {
|
|
| 19 |
const [showReturnChart, setShowReturnChart] = useState(false);
|
| 20 |
const [autoPlay, setAutoPlay] = useState(false);
|
| 21 |
const [selectedMarket, setSelectedMarket] = useState('全部');
|
| 22 |
-
const [
|
| 23 |
-
const
|
| 24 |
-
|
| 25 |
const markets = ['全部', '全A股', '主板', '创业板', '科创板', '北交所', 'ETF', 'LOF', '可转债', 'REITs'];
|
| 26 |
-
|
| 27 |
const {
|
| 28 |
isPlaying,
|
| 29 |
isRevealed,
|
|
@@ -44,20 +48,20 @@ export default function TradePanel() {
|
|
| 44 |
finish,
|
| 45 |
reset,
|
| 46 |
} = useGameStore();
|
| 47 |
-
|
| 48 |
const currentPrice = allKlines[currentIndex]?.close || 0;
|
| 49 |
const returnRate = calculateReturnRate(useGameStore.getState());
|
| 50 |
const totalAsset = calculateTotalAsset(useGameStore.getState());
|
| 51 |
const profit = totalAsset - initialCapital;
|
| 52 |
const isProfit = profit >= 0;
|
| 53 |
-
|
| 54 |
const canFinish = isRevealed || currentIndex >= allKlines.length - 1;
|
| 55 |
const tradingDays = currentIndex - initialIndex + 1;
|
| 56 |
-
|
| 57 |
// 计算收益统计
|
| 58 |
const stats = useMemo(() => {
|
| 59 |
if (!isFinished) return null;
|
| 60 |
-
|
| 61 |
let maxValue = initialCapital;
|
| 62 |
let maxDrawdown = 0;
|
| 63 |
let totalProfit = 0;
|
|
@@ -66,17 +70,17 @@ export default function TradePanel() {
|
|
| 66 |
let currentCost = 0;
|
| 67 |
let winCount = 0;
|
| 68 |
let sellCount = 0;
|
| 69 |
-
|
| 70 |
// 模拟交易流来计算最大回撤和每笔卖出的盈亏
|
| 71 |
// 我们需要遍历从开始到结束的每一天来计算资产曲线
|
| 72 |
let tempCash = initialCapital;
|
| 73 |
let tempHoldings = 0;
|
| 74 |
let tempCost = 0;
|
| 75 |
-
|
| 76 |
for (let i = initialIndex; i <= currentIndex; i++) {
|
| 77 |
const kline = allKlines[i];
|
| 78 |
if (!kline) continue;
|
| 79 |
-
|
| 80 |
// 处理当天的交易
|
| 81 |
const tradesAtPoint = history.filter(t => t.date === kline.date);
|
| 82 |
tradesAtPoint.forEach(trade => {
|
|
@@ -88,38 +92,38 @@ export default function TradePanel() {
|
|
| 88 |
const pnl = (trade.price - tempCost) * trade.volume;
|
| 89 |
if (pnl > 0) winCount++;
|
| 90 |
sellCount++;
|
| 91 |
-
|
| 92 |
if (pnl > 0) totalProfit += pnl;
|
| 93 |
else totalLoss += Math.abs(pnl);
|
| 94 |
-
|
| 95 |
tempCash += trade.totalCost;
|
| 96 |
tempHoldings -= trade.volume;
|
| 97 |
if (tempHoldings === 0) tempCost = 0;
|
| 98 |
}
|
| 99 |
});
|
| 100 |
-
|
| 101 |
// 计算当日总资产
|
| 102 |
const currentAsset = tempCash + tempHoldings * kline.close;
|
| 103 |
-
|
| 104 |
// 更新最大回撤逻辑
|
| 105 |
if (currentAsset > maxValue) maxValue = currentAsset;
|
| 106 |
const drawdown = (maxValue - currentAsset) / maxValue;
|
| 107 |
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
| 108 |
}
|
| 109 |
-
|
| 110 |
const winRate = sellCount > 0 ? winCount / sellCount : 0;
|
| 111 |
-
|
| 112 |
// 优化盈亏比计算:平均每笔盈利 / 平均每笔亏损
|
| 113 |
const avgProfit = winCount > 0 ? totalProfit / winCount : 0;
|
| 114 |
const lossCount = sellCount - winCount;
|
| 115 |
const avgLoss = lossCount > 0 ? totalLoss / lossCount : 0;
|
| 116 |
const profitLossRatio = avgLoss > 0 ? avgProfit / avgLoss : (avgProfit > 0 ? 999 : 0);
|
| 117 |
-
|
| 118 |
// 计算年化收益率 (平滑处理,至少按 1 个月计)
|
| 119 |
const effectiveDays = Math.max(20, tradingDays);
|
| 120 |
const years = effectiveDays / 250;
|
| 121 |
const annualReturn = Math.pow(totalAsset / initialCapital, 1 / years) - 1;
|
| 122 |
-
|
| 123 |
return {
|
| 124 |
totalReturn: returnRate,
|
| 125 |
annualReturn,
|
|
@@ -143,19 +147,35 @@ export default function TradePanel() {
|
|
| 143 |
return () => clearInterval(interval);
|
| 144 |
}, [autoPlay, isPlaying, currentIndex, allKlines.length, nextCandle]);
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
const handleStartGame = async () => {
|
| 147 |
setIsLoading(true);
|
| 148 |
try {
|
| 149 |
-
const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
| 150 |
const data = await api.startGame('random', selectedMarket, token || undefined);
|
| 151 |
startGame(data);
|
|
|
|
|
|
|
| 152 |
} catch (error: any) {
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
} finally {
|
| 155 |
setIsLoading(false);
|
| 156 |
}
|
| 157 |
};
|
| 158 |
-
|
| 159 |
const handleBuy = () => {
|
| 160 |
if (volume > 0 && currentPrice > 0) {
|
| 161 |
const success = buy(volume);
|
|
@@ -169,7 +189,7 @@ export default function TradePanel() {
|
|
| 169 |
if (!success) alert('持仓不足');
|
| 170 |
}
|
| 171 |
};
|
| 172 |
-
|
| 173 |
const setBuyPosition = (ratio: number) => {
|
| 174 |
const maxBuy = Math.floor(cash / currentPrice / 100) * 100;
|
| 175 |
setVolume(Math.max(100, Math.floor(maxBuy * ratio / 100) * 100));
|
|
@@ -181,43 +201,106 @@ export default function TradePanel() {
|
|
| 181 |
|
| 182 |
// 未开始游戏界面
|
| 183 |
if (!isPlaying) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
return (
|
| 185 |
-
<div className="flex flex-col items-center justify-center h-full gap-6 lg:gap-8 p-4 lg:p-8 bg-[#191B28] text-white">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
<div className="text-center space-y-2">
|
| 187 |
<h1 className="text-4xl lg:text-5xl font-bold tracking-tight text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">StockReplay</h1>
|
| 188 |
<p className="text-gray-400 text-base lg:text-lg">A股历史行情复盘训练系统</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
</div>
|
| 190 |
-
|
| 191 |
<div className="bg-[#2A2D3C] rounded-2xl p-6 lg:p-8 max-w-md w-full shadow-2xl border border-gray-800">
|
| 192 |
<div className="flex items-center justify-center mb-6 lg:mb-8">
|
| 193 |
<div className="w-12 h-12 lg:w-16 lg:h-16 bg-blue-500/20 rounded-full flex items-center justify-center text-blue-400">
|
| 194 |
<TrendingUp size={28} />
|
| 195 |
</div>
|
| 196 |
</div>
|
| 197 |
-
|
| 198 |
<h2 className="text-xl lg:text-2xl font-bold mb-3 lg:mb-4 text-center">盲盒挑战</h2>
|
| 199 |
<p className="text-gray-400 mb-6 lg:mb-8 text-center text-sm lg:text-base leading-relaxed">
|
| 200 |
系统将随机抽取一只A股的历史行情,你需要根据K线走势进行模拟交易,验证你的盘感与策略。
|
| 201 |
</p>
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
</div>
|
| 211 |
-
|
| 212 |
<div className="grid grid-cols-3 gap-3 lg:gap-6 w-full max-w-md text-center">
|
| 213 |
<div className="bg-[#2A2D3C] p-3 lg:p-4 rounded-xl border border-gray-800 flex flex-col justify-center">
|
| 214 |
<div className="text-gray-500 text-[10px] lg:text-xs mb-1">初始资金</div>
|
| 215 |
<div className="text-sm lg:text-xl font-bold text-white whitespace-nowrap">100万</div>
|
| 216 |
</div>
|
| 217 |
-
|
| 218 |
<div className="bg-[#2A2D3C] p-3 lg:p-4 rounded-xl border border-gray-800 relative group cursor-pointer">
|
| 219 |
<div className="text-gray-500 text-[10px] lg:text-xs mb-1">标的范围</div>
|
| 220 |
-
<select
|
| 221 |
value={selectedMarket}
|
| 222 |
onChange={(e) => setSelectedMarket(e.target.value)}
|
| 223 |
className="bg-transparent text-white font-bold text-sm lg:text-xl outline-none cursor-pointer w-full text-center appearance-none"
|
|
@@ -229,16 +312,18 @@ export default function TradePanel() {
|
|
| 229 |
<ChevronRight size={12} className="rotate-90" />
|
| 230 |
</div>
|
| 231 |
</div>
|
| 232 |
-
|
| 233 |
<div className="bg-[#2A2D3C] p-3 lg:p-4 rounded-xl border border-gray-800 flex flex-col justify-center">
|
| 234 |
<div className="text-gray-500 text-[10px] lg:text-xs mb-1">数据周期</div>
|
| 235 |
<div className="text-sm lg:text-xl font-bold text-white whitespace-nowrap">10年</div>
|
| 236 |
</div>
|
| 237 |
</div>
|
|
|
|
| 238 |
</div>
|
| 239 |
);
|
| 240 |
}
|
| 241 |
|
|
|
|
| 242 |
// 回测结束界面
|
| 243 |
if (isFinished && stats) {
|
| 244 |
return (
|
|
@@ -252,8 +337,8 @@ export default function TradePanel() {
|
|
| 252 |
<p className="text-gray-400 text-xs lg:text-sm">{useGameStore.getState().realName} ({useGameStore.getState().realCode})</p>
|
| 253 |
</div>
|
| 254 |
</div>
|
| 255 |
-
<button
|
| 256 |
-
onClick={() => { setAutoPlay(false); reset(); }}
|
| 257 |
disabled={isLoading}
|
| 258 |
className="bg-gradient-to-r from-[#FD1050] to-[#FF4081] hover:from-[#FF4081] hover:to-[#FD1050] text-white font-bold py-2 px-4 lg:px-6 rounded-lg transition-all shadow-lg text-sm lg:text-base flex items-center gap-2"
|
| 259 |
>
|
|
@@ -323,7 +408,7 @@ export default function TradePanel() {
|
|
| 323 |
{isProfit ? '+' : ''}{(returnRate * 100).toFixed(2)}%
|
| 324 |
</div>
|
| 325 |
</div>
|
| 326 |
-
|
| 327 |
<div className="flex justify-between items-center text-[10px] lg:text-sm text-gray-500">
|
| 328 |
<div className="flex gap-4">
|
| 329 |
<div className="flex items-baseline gap-1">
|
|
@@ -345,7 +430,7 @@ export default function TradePanel() {
|
|
| 345 |
)}
|
| 346 |
</div>
|
| 347 |
</div>
|
| 348 |
-
|
| 349 |
<div className="lg:flex-1 p-2 lg:p-5 flex flex-col gap-2 lg:gap-6 overflow-y-auto overflow-x-hidden w-full box-border scrollbar-thin scrollbar-thumb-gray-700">
|
| 350 |
<div className="grid grid-cols-2 gap-2 lg:gap-4">
|
| 351 |
{/* 价格 - 手机端标签与输入框同行 */}
|
|
@@ -356,7 +441,7 @@ export default function TradePanel() {
|
|
| 356 |
<span className="font-mono text-xs lg:text-xl ml-auto">{currentPrice.toFixed(2)}</span>
|
| 357 |
</div>
|
| 358 |
</div>
|
| 359 |
-
|
| 360 |
{/* 数量 - 手机端标签与输入框同行 */}
|
| 361 |
<div className="flex items-center gap-1 lg:flex-col lg:items-start lg:gap-2 min-w-0">
|
| 362 |
<label className="text-[10px] lg:text-sm text-gray-500 shrink-0">数量</label>
|
|
@@ -369,30 +454,30 @@ export default function TradePanel() {
|
|
| 369 |
/>
|
| 370 |
</div>
|
| 371 |
</div>
|
| 372 |
-
|
| 373 |
<div className="grid grid-cols-2 gap-2 lg:gap-8 lg:block lg:space-y-4">
|
| 374 |
<div className="grid grid-cols-4 gap-1">
|
| 375 |
-
{[1/4, 1/2, 3/4, 1].map((ratio) => (
|
| 376 |
<button key={`buy-${ratio}`} onClick={() => setBuyPosition(ratio)} className="bg-[#2A2D3C] text-gray-400 text-[10px] py-1 rounded">
|
| 377 |
-
{ratio === 1 ? '全' : `${ratio*4}/4`}
|
| 378 |
</button>
|
| 379 |
))}
|
| 380 |
</div>
|
| 381 |
<div className="grid grid-cols-4 gap-1">
|
| 382 |
-
{[1/4, 1/2, 3/4, 1].map((ratio) => (
|
| 383 |
<button key={`sell-${ratio}`} onClick={() => setSellPosition(ratio)} className="bg-[#2A2D3C] text-gray-400 text-[10px] py-1 rounded">
|
| 384 |
-
{ratio === 1 ? '全' : `${ratio*4}/4`}
|
| 385 |
</button>
|
| 386 |
))}
|
| 387 |
</div>
|
| 388 |
</div>
|
| 389 |
-
|
| 390 |
<div className="flex gap-2 lg:gap-4">
|
| 391 |
<button onClick={handleBuy} className="flex-1 py-2.5 lg:py-5 rounded-xl lg:rounded-2xl font-bold text-sm lg:text-2xl bg-gradient-to-r from-[#FD1050] to-[#FF4081] text-white">买入</button>
|
| 392 |
<button onClick={handleSell} className="flex-1 py-2.5 lg:py-5 rounded-xl lg:rounded-2xl font-bold text-sm lg:text-2xl bg-gradient-to-r from-[#00F0F0] to-[#00E5FF] text-[#12141C]">卖出</button>
|
| 393 |
</div>
|
| 394 |
</div>
|
| 395 |
-
|
| 396 |
<div className="p-1.5 lg:p-4 border-t border-[#2A2D3C] bg-[#191B28]">
|
| 397 |
<div className="flex flex-row gap-1 lg:flex-col lg:gap-3">
|
| 398 |
<div className="flex-1 flex gap-1 lg:gap-3">
|
|
@@ -428,7 +513,7 @@ function ReturnChartModal({ onClose, allKlines, history, initialIndex, currentIn
|
|
| 428 |
const chartContainerRef = useRef<HTMLDivElement>(null);
|
| 429 |
const modalRef = useRef<HTMLDivElement>(null);
|
| 430 |
const chartRef = useRef<any>(null);
|
| 431 |
-
const [hs300Data, setHS300Data] = useState<{date: string, close: number}[]>([]);
|
| 432 |
const [isLoading, setIsLoading] = useState(true);
|
| 433 |
const [isSharing, setIsSharing] = useState(false);
|
| 434 |
|
|
@@ -497,7 +582,7 @@ function ReturnChartModal({ onClose, allKlines, history, initialIndex, currentIn
|
|
| 497 |
const hs300Map = new Map(hs300Data.map(d => [d.date, d.close]));
|
| 498 |
const hs300StartPrice = hs300Data[0]?.close || 4000;
|
| 499 |
let currentCost = 0;
|
| 500 |
-
|
| 501 |
for (let i = 0; i < visibleKlines.length; i++) {
|
| 502 |
const kline = visibleKlines[i];
|
| 503 |
const tradesAtPoint = history.filter(t => t.date === kline.date);
|
|
@@ -522,23 +607,23 @@ function ReturnChartModal({ onClose, allKlines, history, initialIndex, currentIn
|
|
| 522 |
}
|
| 523 |
if (asset > maxValue) maxValue = asset;
|
| 524 |
}
|
| 525 |
-
|
| 526 |
// 计算详细指标
|
| 527 |
const finalReturn = (strategy[strategy.length - 1]?.value || 0) / 100;
|
| 528 |
const benchmarkReturn = benchmark.length > 0 ? (benchmark[benchmark.length - 1].value || 0) / 100 : 0;
|
| 529 |
-
|
| 530 |
// 1. 计算每日收益率 (用于 Beta, Sharpe, Volatility)
|
| 531 |
const strategyDailyReturns = strategy.slice(1).map((s, i) => (s.value - strategy[i].value) / 100);
|
| 532 |
const benchmarkDailyReturns = benchmark.slice(1).map((b, i) => (b.value - benchmark[i].value) / 100);
|
| 533 |
-
|
| 534 |
const riskFreeRate = 0.03; // 无风险利率 3%
|
| 535 |
-
|
| 536 |
// 2. 计算贝塔 (Beta)
|
| 537 |
let beta = 1.0;
|
| 538 |
if (strategyDailyReturns.length > 2 && benchmarkDailyReturns.length > 2) {
|
| 539 |
const meanS = strategyDailyReturns.reduce((a, b) => a + b, 0) / strategyDailyReturns.length;
|
| 540 |
const meanB = benchmarkDailyReturns.reduce((a, b) => a + b, 0) / benchmarkDailyReturns.length;
|
| 541 |
-
|
| 542 |
let covariance = 0;
|
| 543 |
let varianceB = 0;
|
| 544 |
for (let i = 0; i < Math.min(strategyDailyReturns.length, benchmarkDailyReturns.length); i++) {
|
|
@@ -547,10 +632,10 @@ function ReturnChartModal({ onClose, allKlines, history, initialIndex, currentIn
|
|
| 547 |
}
|
| 548 |
if (varianceB > 0) beta = covariance / varianceB;
|
| 549 |
}
|
| 550 |
-
|
| 551 |
// 3. 计算阿尔法 (Alpha - 詹森指数)
|
| 552 |
const alpha = stats.annualReturn - (riskFreeRate + beta * (benchmarkReturn / (visibleKlines.length / 250) - riskFreeRate));
|
| 553 |
-
|
| 554 |
// 4. 计算夏普比率 (Sharpe Ratio)
|
| 555 |
let sharpeRatio = 0;
|
| 556 |
if (strategyDailyReturns.length > 2) {
|
|
@@ -561,7 +646,7 @@ function ReturnChartModal({ onClose, allKlines, history, initialIndex, currentIn
|
|
| 561 |
sharpeRatio = (stats.annualReturn - riskFreeRate) / annualVol;
|
| 562 |
}
|
| 563 |
}
|
| 564 |
-
|
| 565 |
return {
|
| 566 |
strategyData: strategy,
|
| 567 |
benchmarkData: benchmark,
|
|
@@ -596,7 +681,7 @@ function ReturnChartModal({ onClose, allKlines, history, initialIndex, currentIn
|
|
| 596 |
grid: { vertLines: { color: '#2A2D3C' }, horzLines: { color: '#2A2D3C' } },
|
| 597 |
width: chartContainerRef.current.clientWidth, height: 240,
|
| 598 |
crosshair: { mode: 1, vertLine: { color: '#6B7280', width: 1, style: 3 }, horzLine: { color: '#6B7280', width: 1, style: 3 } },
|
| 599 |
-
rightPriceScale: {
|
| 600 |
borderColor: '#374151', alignLabels: true, autoScale: true,
|
| 601 |
scaleMargins: { top: 0.1, bottom: 0.1 }, minimumWidth: 35,
|
| 602 |
},
|
|
|
|
| 8 |
import { useState, useEffect, useMemo, useRef } from 'react';
|
| 9 |
import { TrendingUp, TrendingDown, Wallet, Package, RotateCcw, Eye, ChevronRight, Play, Pause, BarChart3, Trophy, X, TrendingUpIcon, Share2 } from 'lucide-react';
|
| 10 |
import { useGameStore, calculateReturnRate, calculateTotalAsset } from '@/store/gameStore';
|
| 11 |
+
import { useAuthStore } from '@/store/authStore';
|
| 12 |
import { api } from '@/lib/api';
|
| 13 |
+
import UserMenu from './UserMenu';
|
| 14 |
+
import LoginPage from './LoginPage';
|
| 15 |
+
import PaymentModal from './PaymentModal';
|
| 16 |
import { createChart, ColorType, LineData, Time, LineStyle } from 'lightweight-charts';
|
| 17 |
import html2canvas from 'html2canvas';
|
| 18 |
import { QRCodeSVG } from 'qrcode.react';
|
|
|
|
| 23 |
const [showReturnChart, setShowReturnChart] = useState(false);
|
| 24 |
const [autoPlay, setAutoPlay] = useState(false);
|
| 25 |
const [selectedMarket, setSelectedMarket] = useState('全部');
|
| 26 |
+
const [showPayment, setShowPayment] = useState(false);
|
| 27 |
+
const { token, isVip, vipExpireAt, username, isInitialized, dailyUsed, dailyRemaining, dailyLimit } = useAuthStore();
|
| 28 |
+
|
| 29 |
const markets = ['全部', '全A股', '主板', '创业板', '科创板', '北交所', 'ETF', 'LOF', '可转债', 'REITs'];
|
| 30 |
+
|
| 31 |
const {
|
| 32 |
isPlaying,
|
| 33 |
isRevealed,
|
|
|
|
| 48 |
finish,
|
| 49 |
reset,
|
| 50 |
} = useGameStore();
|
| 51 |
+
|
| 52 |
const currentPrice = allKlines[currentIndex]?.close || 0;
|
| 53 |
const returnRate = calculateReturnRate(useGameStore.getState());
|
| 54 |
const totalAsset = calculateTotalAsset(useGameStore.getState());
|
| 55 |
const profit = totalAsset - initialCapital;
|
| 56 |
const isProfit = profit >= 0;
|
| 57 |
+
|
| 58 |
const canFinish = isRevealed || currentIndex >= allKlines.length - 1;
|
| 59 |
const tradingDays = currentIndex - initialIndex + 1;
|
| 60 |
+
|
| 61 |
// 计算收益统计
|
| 62 |
const stats = useMemo(() => {
|
| 63 |
if (!isFinished) return null;
|
| 64 |
+
|
| 65 |
let maxValue = initialCapital;
|
| 66 |
let maxDrawdown = 0;
|
| 67 |
let totalProfit = 0;
|
|
|
|
| 70 |
let currentCost = 0;
|
| 71 |
let winCount = 0;
|
| 72 |
let sellCount = 0;
|
| 73 |
+
|
| 74 |
// 模拟交易流来计算最大回撤和每笔卖出的盈亏
|
| 75 |
// 我们需要遍历从开始到结束的每一天来计算资产曲线
|
| 76 |
let tempCash = initialCapital;
|
| 77 |
let tempHoldings = 0;
|
| 78 |
let tempCost = 0;
|
| 79 |
+
|
| 80 |
for (let i = initialIndex; i <= currentIndex; i++) {
|
| 81 |
const kline = allKlines[i];
|
| 82 |
if (!kline) continue;
|
| 83 |
+
|
| 84 |
// 处理当天的交易
|
| 85 |
const tradesAtPoint = history.filter(t => t.date === kline.date);
|
| 86 |
tradesAtPoint.forEach(trade => {
|
|
|
|
| 92 |
const pnl = (trade.price - tempCost) * trade.volume;
|
| 93 |
if (pnl > 0) winCount++;
|
| 94 |
sellCount++;
|
| 95 |
+
|
| 96 |
if (pnl > 0) totalProfit += pnl;
|
| 97 |
else totalLoss += Math.abs(pnl);
|
| 98 |
+
|
| 99 |
tempCash += trade.totalCost;
|
| 100 |
tempHoldings -= trade.volume;
|
| 101 |
if (tempHoldings === 0) tempCost = 0;
|
| 102 |
}
|
| 103 |
});
|
| 104 |
+
|
| 105 |
// 计算当日总资产
|
| 106 |
const currentAsset = tempCash + tempHoldings * kline.close;
|
| 107 |
+
|
| 108 |
// 更新最大回撤逻辑
|
| 109 |
if (currentAsset > maxValue) maxValue = currentAsset;
|
| 110 |
const drawdown = (maxValue - currentAsset) / maxValue;
|
| 111 |
if (drawdown > maxDrawdown) maxDrawdown = drawdown;
|
| 112 |
}
|
| 113 |
+
|
| 114 |
const winRate = sellCount > 0 ? winCount / sellCount : 0;
|
| 115 |
+
|
| 116 |
// 优化盈亏比计算:平均每笔盈利 / 平均每笔亏损
|
| 117 |
const avgProfit = winCount > 0 ? totalProfit / winCount : 0;
|
| 118 |
const lossCount = sellCount - winCount;
|
| 119 |
const avgLoss = lossCount > 0 ? totalLoss / lossCount : 0;
|
| 120 |
const profitLossRatio = avgLoss > 0 ? avgProfit / avgLoss : (avgProfit > 0 ? 999 : 0);
|
| 121 |
+
|
| 122 |
// 计算年化收益率 (平滑处理,至少按 1 个月计)
|
| 123 |
const effectiveDays = Math.max(20, tradingDays);
|
| 124 |
const years = effectiveDays / 250;
|
| 125 |
const annualReturn = Math.pow(totalAsset / initialCapital, 1 / years) - 1;
|
| 126 |
+
|
| 127 |
return {
|
| 128 |
totalReturn: returnRate,
|
| 129 |
annualReturn,
|
|
|
|
| 147 |
return () => clearInterval(interval);
|
| 148 |
}, [autoPlay, isPlaying, currentIndex, allKlines.length, nextCandle]);
|
| 149 |
|
| 150 |
+
// 初始化认证状态 - 解决 UserMenu 被 loading 阻塞导致无法初始化的问题
|
| 151 |
+
useEffect(() => {
|
| 152 |
+
const { isInitialized, initialize } = useAuthStore.getState();
|
| 153 |
+
if (!isInitialized) {
|
| 154 |
+
initialize();
|
| 155 |
+
}
|
| 156 |
+
}, []);
|
| 157 |
+
|
| 158 |
const handleStartGame = async () => {
|
| 159 |
setIsLoading(true);
|
| 160 |
try {
|
|
|
|
| 161 |
const data = await api.startGame('random', selectedMarket, token || undefined);
|
| 162 |
startGame(data);
|
| 163 |
+
// 刷新今日使用次数
|
| 164 |
+
useAuthStore.getState().fetchUsage();
|
| 165 |
} catch (error: any) {
|
| 166 |
+
// 检查 ApiError 结构: error.data.detail.code
|
| 167 |
+
const detail = error.data?.detail;
|
| 168 |
+
if (detail && detail.code === 'DAILY_LIMIT_EXCEEDED') {
|
| 169 |
+
// 已经在 UI 中处理了显示,这里强制刷新一下 usage
|
| 170 |
+
useAuthStore.getState().fetchUsage();
|
| 171 |
+
} else {
|
| 172 |
+
alert(error.message || '启动游戏失败,请检查后端服务');
|
| 173 |
+
}
|
| 174 |
} finally {
|
| 175 |
setIsLoading(false);
|
| 176 |
}
|
| 177 |
};
|
| 178 |
+
|
| 179 |
const handleBuy = () => {
|
| 180 |
if (volume > 0 && currentPrice > 0) {
|
| 181 |
const success = buy(volume);
|
|
|
|
| 189 |
if (!success) alert('持仓不足');
|
| 190 |
}
|
| 191 |
};
|
| 192 |
+
|
| 193 |
const setBuyPosition = (ratio: number) => {
|
| 194 |
const maxBuy = Math.floor(cash / currentPrice / 100) * 100;
|
| 195 |
setVolume(Math.max(100, Math.floor(maxBuy * ratio / 100) * 100));
|
|
|
|
| 201 |
|
| 202 |
// 未开始游戏界面
|
| 203 |
if (!isPlaying) {
|
| 204 |
+
// 未初始化(加载中)
|
| 205 |
+
if (!isInitialized) {
|
| 206 |
+
return (
|
| 207 |
+
<div className="flex items-center justify-center h-full bg-[#191B28]">
|
| 208 |
+
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
| 209 |
+
</div>
|
| 210 |
+
);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// ===== 未登录:显示全屏登录/注册页 =====
|
| 214 |
+
if (!username) {
|
| 215 |
+
return <LoginPage />;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// ===== 已登录:显示游戏开始界面 =====
|
| 219 |
+
const limitExceeded = !isVip && dailyLimit !== null && dailyUsed >= dailyLimit;
|
| 220 |
+
|
| 221 |
return (
|
| 222 |
+
<div className="flex flex-col items-center justify-center h-full gap-6 lg:gap-8 p-4 lg:p-8 bg-[#191B28] text-white relative">
|
| 223 |
+
{/* 用户菜单 - 右上角 */}
|
| 224 |
+
<div className="absolute top-4 right-4">
|
| 225 |
+
<UserMenu />
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
<div className="text-center space-y-2">
|
| 229 |
<h1 className="text-4xl lg:text-5xl font-bold tracking-tight text-transparent bg-clip-text bg-gradient-to-r from-blue-400 to-purple-500">StockReplay</h1>
|
| 230 |
<p className="text-gray-400 text-base lg:text-lg">A股历史行情复盘训练系统</p>
|
| 231 |
+
{isVip && vipExpireAt && (
|
| 232 |
+
<div className="flex items-center justify-center gap-1 text-yellow-500 text-xs">
|
| 233 |
+
<span>⭐ VIP 会员</span>
|
| 234 |
+
<span className="text-gray-500">· 到期: {vipExpireAt.split(' ')[0]}</span>
|
| 235 |
+
</div>
|
| 236 |
+
)}
|
| 237 |
</div>
|
| 238 |
+
|
| 239 |
<div className="bg-[#2A2D3C] rounded-2xl p-6 lg:p-8 max-w-md w-full shadow-2xl border border-gray-800">
|
| 240 |
<div className="flex items-center justify-center mb-6 lg:mb-8">
|
| 241 |
<div className="w-12 h-12 lg:w-16 lg:h-16 bg-blue-500/20 rounded-full flex items-center justify-center text-blue-400">
|
| 242 |
<TrendingUp size={28} />
|
| 243 |
</div>
|
| 244 |
</div>
|
| 245 |
+
|
| 246 |
<h2 className="text-xl lg:text-2xl font-bold mb-3 lg:mb-4 text-center">盲盒挑战</h2>
|
| 247 |
<p className="text-gray-400 mb-6 lg:mb-8 text-center text-sm lg:text-base leading-relaxed">
|
| 248 |
系统将随机抽取一只A股的历史行情,你需要根据K线走势进行模拟交易,验证你的盘感与策略。
|
| 249 |
</p>
|
| 250 |
+
|
| 251 |
+
{/* 每日次数提示 */}
|
| 252 |
+
{!isVip && dailyLimit !== null && (
|
| 253 |
+
<div className={`mb-4 rounded-xl px-4 py-3 text-sm flex items-center justify-between ${limitExceeded
|
| 254 |
+
? 'bg-red-500/10 border border-red-500/30 text-red-400'
|
| 255 |
+
: dailyUsed >= dailyLimit - 1
|
| 256 |
+
? 'bg-yellow-500/10 border border-yellow-500/30 text-yellow-400'
|
| 257 |
+
: 'bg-blue-500/10 border border-blue-500/30 text-blue-400'
|
| 258 |
+
}`}>
|
| 259 |
+
<span>
|
| 260 |
+
{limitExceeded
|
| 261 |
+
? '今日免费次数已用完'
|
| 262 |
+
: `今日剩余次数:${dailyLimit - dailyUsed} / ${dailyLimit}`}
|
| 263 |
+
</span>
|
| 264 |
+
{limitExceeded && (
|
| 265 |
+
<span className="text-xs text-gray-500">明日重置</span>
|
| 266 |
+
)}
|
| 267 |
+
</div>
|
| 268 |
+
)}
|
| 269 |
+
|
| 270 |
+
{/* 次数用完时的 VIP 引导 */}
|
| 271 |
+
{limitExceeded ? (
|
| 272 |
+
<div className="space-y-3">
|
| 273 |
+
<div className="bg-gradient-to-r from-yellow-500/10 to-orange-500/10 border border-yellow-500/30 rounded-xl p-4 text-center">
|
| 274 |
+
<div className="text-yellow-400 font-bold mb-1">⭐ 开通 VIP 会员</div>
|
| 275 |
+
<div className="text-gray-400 text-xs">无限次回测 · 解锁科创板/北交所等特色板块</div>
|
| 276 |
+
</div>
|
| 277 |
+
<button
|
| 278 |
+
onClick={() => setShowPayment(true)}
|
| 279 |
+
className="w-full bg-gradient-to-r from-yellow-600 to-orange-600 hover:from-yellow-500 hover:to-orange-500 text-white font-bold py-3.5 lg:py-4 px-8 rounded-xl shadow-lg transition-all transform hover:scale-[1.02] active:scale-[0.98]"
|
| 280 |
+
>
|
| 281 |
+
立即开通 VIP (¥9.9/月)
|
| 282 |
+
</button>
|
| 283 |
+
</div>
|
| 284 |
+
) : (
|
| 285 |
+
<button
|
| 286 |
+
onClick={handleStartGame}
|
| 287 |
+
disabled={isLoading}
|
| 288 |
+
className="w-full bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-400 text-white font-bold py-3.5 lg:py-4 px-8 rounded-xl transition-all duration-200 disabled:opacity-50 shadow-lg transform hover:scale-[1.02] active:scale-[0.98]"
|
| 289 |
+
>
|
| 290 |
+
{isLoading ? '加载数据中...' : '开始挑战'}
|
| 291 |
+
</button>
|
| 292 |
+
)}
|
| 293 |
</div>
|
| 294 |
+
|
| 295 |
<div className="grid grid-cols-3 gap-3 lg:gap-6 w-full max-w-md text-center">
|
| 296 |
<div className="bg-[#2A2D3C] p-3 lg:p-4 rounded-xl border border-gray-800 flex flex-col justify-center">
|
| 297 |
<div className="text-gray-500 text-[10px] lg:text-xs mb-1">初始资金</div>
|
| 298 |
<div className="text-sm lg:text-xl font-bold text-white whitespace-nowrap">100万</div>
|
| 299 |
</div>
|
| 300 |
+
|
| 301 |
<div className="bg-[#2A2D3C] p-3 lg:p-4 rounded-xl border border-gray-800 relative group cursor-pointer">
|
| 302 |
<div className="text-gray-500 text-[10px] lg:text-xs mb-1">标的范围</div>
|
| 303 |
+
<select
|
| 304 |
value={selectedMarket}
|
| 305 |
onChange={(e) => setSelectedMarket(e.target.value)}
|
| 306 |
className="bg-transparent text-white font-bold text-sm lg:text-xl outline-none cursor-pointer w-full text-center appearance-none"
|
|
|
|
| 312 |
<ChevronRight size={12} className="rotate-90" />
|
| 313 |
</div>
|
| 314 |
</div>
|
| 315 |
+
|
| 316 |
<div className="bg-[#2A2D3C] p-3 lg:p-4 rounded-xl border border-gray-800 flex flex-col justify-center">
|
| 317 |
<div className="text-gray-500 text-[10px] lg:text-xs mb-1">数据周期</div>
|
| 318 |
<div className="text-sm lg:text-xl font-bold text-white whitespace-nowrap">10年</div>
|
| 319 |
</div>
|
| 320 |
</div>
|
| 321 |
+
<PaymentModal isOpen={showPayment} onClose={() => setShowPayment(false)} />
|
| 322 |
</div>
|
| 323 |
);
|
| 324 |
}
|
| 325 |
|
| 326 |
+
|
| 327 |
// 回测结束界面
|
| 328 |
if (isFinished && stats) {
|
| 329 |
return (
|
|
|
|
| 337 |
<p className="text-gray-400 text-xs lg:text-sm">{useGameStore.getState().realName} ({useGameStore.getState().realCode})</p>
|
| 338 |
</div>
|
| 339 |
</div>
|
| 340 |
+
<button
|
| 341 |
+
onClick={() => { setAutoPlay(false); reset(); }}
|
| 342 |
disabled={isLoading}
|
| 343 |
className="bg-gradient-to-r from-[#FD1050] to-[#FF4081] hover:from-[#FF4081] hover:to-[#FD1050] text-white font-bold py-2 px-4 lg:px-6 rounded-lg transition-all shadow-lg text-sm lg:text-base flex items-center gap-2"
|
| 344 |
>
|
|
|
|
| 408 |
{isProfit ? '+' : ''}{(returnRate * 100).toFixed(2)}%
|
| 409 |
</div>
|
| 410 |
</div>
|
| 411 |
+
|
| 412 |
<div className="flex justify-between items-center text-[10px] lg:text-sm text-gray-500">
|
| 413 |
<div className="flex gap-4">
|
| 414 |
<div className="flex items-baseline gap-1">
|
|
|
|
| 430 |
)}
|
| 431 |
</div>
|
| 432 |
</div>
|
| 433 |
+
|
| 434 |
<div className="lg:flex-1 p-2 lg:p-5 flex flex-col gap-2 lg:gap-6 overflow-y-auto overflow-x-hidden w-full box-border scrollbar-thin scrollbar-thumb-gray-700">
|
| 435 |
<div className="grid grid-cols-2 gap-2 lg:gap-4">
|
| 436 |
{/* 价格 - 手机端标签与输入框同行 */}
|
|
|
|
| 441 |
<span className="font-mono text-xs lg:text-xl ml-auto">{currentPrice.toFixed(2)}</span>
|
| 442 |
</div>
|
| 443 |
</div>
|
| 444 |
+
|
| 445 |
{/* 数量 - 手机端标签与输入框同行 */}
|
| 446 |
<div className="flex items-center gap-1 lg:flex-col lg:items-start lg:gap-2 min-w-0">
|
| 447 |
<label className="text-[10px] lg:text-sm text-gray-500 shrink-0">数量</label>
|
|
|
|
| 454 |
/>
|
| 455 |
</div>
|
| 456 |
</div>
|
| 457 |
+
|
| 458 |
<div className="grid grid-cols-2 gap-2 lg:gap-8 lg:block lg:space-y-4">
|
| 459 |
<div className="grid grid-cols-4 gap-1">
|
| 460 |
+
{[1 / 4, 1 / 2, 3 / 4, 1].map((ratio) => (
|
| 461 |
<button key={`buy-${ratio}`} onClick={() => setBuyPosition(ratio)} className="bg-[#2A2D3C] text-gray-400 text-[10px] py-1 rounded">
|
| 462 |
+
{ratio === 1 ? '全' : `${ratio * 4}/4`}
|
| 463 |
</button>
|
| 464 |
))}
|
| 465 |
</div>
|
| 466 |
<div className="grid grid-cols-4 gap-1">
|
| 467 |
+
{[1 / 4, 1 / 2, 3 / 4, 1].map((ratio) => (
|
| 468 |
<button key={`sell-${ratio}`} onClick={() => setSellPosition(ratio)} className="bg-[#2A2D3C] text-gray-400 text-[10px] py-1 rounded">
|
| 469 |
+
{ratio === 1 ? '全' : `${ratio * 4}/4`}
|
| 470 |
</button>
|
| 471 |
))}
|
| 472 |
</div>
|
| 473 |
</div>
|
| 474 |
+
|
| 475 |
<div className="flex gap-2 lg:gap-4">
|
| 476 |
<button onClick={handleBuy} className="flex-1 py-2.5 lg:py-5 rounded-xl lg:rounded-2xl font-bold text-sm lg:text-2xl bg-gradient-to-r from-[#FD1050] to-[#FF4081] text-white">买入</button>
|
| 477 |
<button onClick={handleSell} className="flex-1 py-2.5 lg:py-5 rounded-xl lg:rounded-2xl font-bold text-sm lg:text-2xl bg-gradient-to-r from-[#00F0F0] to-[#00E5FF] text-[#12141C]">卖出</button>
|
| 478 |
</div>
|
| 479 |
</div>
|
| 480 |
+
|
| 481 |
<div className="p-1.5 lg:p-4 border-t border-[#2A2D3C] bg-[#191B28]">
|
| 482 |
<div className="flex flex-row gap-1 lg:flex-col lg:gap-3">
|
| 483 |
<div className="flex-1 flex gap-1 lg:gap-3">
|
|
|
|
| 513 |
const chartContainerRef = useRef<HTMLDivElement>(null);
|
| 514 |
const modalRef = useRef<HTMLDivElement>(null);
|
| 515 |
const chartRef = useRef<any>(null);
|
| 516 |
+
const [hs300Data, setHS300Data] = useState<{ date: string, close: number }[]>([]);
|
| 517 |
const [isLoading, setIsLoading] = useState(true);
|
| 518 |
const [isSharing, setIsSharing] = useState(false);
|
| 519 |
|
|
|
|
| 582 |
const hs300Map = new Map(hs300Data.map(d => [d.date, d.close]));
|
| 583 |
const hs300StartPrice = hs300Data[0]?.close || 4000;
|
| 584 |
let currentCost = 0;
|
| 585 |
+
|
| 586 |
for (let i = 0; i < visibleKlines.length; i++) {
|
| 587 |
const kline = visibleKlines[i];
|
| 588 |
const tradesAtPoint = history.filter(t => t.date === kline.date);
|
|
|
|
| 607 |
}
|
| 608 |
if (asset > maxValue) maxValue = asset;
|
| 609 |
}
|
| 610 |
+
|
| 611 |
// 计算详细指标
|
| 612 |
const finalReturn = (strategy[strategy.length - 1]?.value || 0) / 100;
|
| 613 |
const benchmarkReturn = benchmark.length > 0 ? (benchmark[benchmark.length - 1].value || 0) / 100 : 0;
|
| 614 |
+
|
| 615 |
// 1. 计算每日收益率 (用于 Beta, Sharpe, Volatility)
|
| 616 |
const strategyDailyReturns = strategy.slice(1).map((s, i) => (s.value - strategy[i].value) / 100);
|
| 617 |
const benchmarkDailyReturns = benchmark.slice(1).map((b, i) => (b.value - benchmark[i].value) / 100);
|
| 618 |
+
|
| 619 |
const riskFreeRate = 0.03; // 无风险利率 3%
|
| 620 |
+
|
| 621 |
// 2. 计算贝塔 (Beta)
|
| 622 |
let beta = 1.0;
|
| 623 |
if (strategyDailyReturns.length > 2 && benchmarkDailyReturns.length > 2) {
|
| 624 |
const meanS = strategyDailyReturns.reduce((a, b) => a + b, 0) / strategyDailyReturns.length;
|
| 625 |
const meanB = benchmarkDailyReturns.reduce((a, b) => a + b, 0) / benchmarkDailyReturns.length;
|
| 626 |
+
|
| 627 |
let covariance = 0;
|
| 628 |
let varianceB = 0;
|
| 629 |
for (let i = 0; i < Math.min(strategyDailyReturns.length, benchmarkDailyReturns.length); i++) {
|
|
|
|
| 632 |
}
|
| 633 |
if (varianceB > 0) beta = covariance / varianceB;
|
| 634 |
}
|
| 635 |
+
|
| 636 |
// 3. 计算阿尔法 (Alpha - 詹森指数)
|
| 637 |
const alpha = stats.annualReturn - (riskFreeRate + beta * (benchmarkReturn / (visibleKlines.length / 250) - riskFreeRate));
|
| 638 |
+
|
| 639 |
// 4. 计算夏普比率 (Sharpe Ratio)
|
| 640 |
let sharpeRatio = 0;
|
| 641 |
if (strategyDailyReturns.length > 2) {
|
|
|
|
| 646 |
sharpeRatio = (stats.annualReturn - riskFreeRate) / annualVol;
|
| 647 |
}
|
| 648 |
}
|
| 649 |
+
|
| 650 |
return {
|
| 651 |
strategyData: strategy,
|
| 652 |
benchmarkData: benchmark,
|
|
|
|
| 681 |
grid: { vertLines: { color: '#2A2D3C' }, horzLines: { color: '#2A2D3C' } },
|
| 682 |
width: chartContainerRef.current.clientWidth, height: 240,
|
| 683 |
crosshair: { mode: 1, vertLine: { color: '#6B7280', width: 1, style: 3 }, horzLine: { color: '#6B7280', width: 1, style: 3 } },
|
| 684 |
+
rightPriceScale: {
|
| 685 |
borderColor: '#374151', alignLabels: true, autoScale: true,
|
| 686 |
scaleMargins: { top: 0.1, bottom: 0.1 }, minimumWidth: 35,
|
| 687 |
},
|
frontend/src/components/UserMenu.tsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* 用户菜单组件
|
| 5 |
+
* - 已登录:显示用户名、VIP 徽章、退出按钮
|
| 6 |
+
* - 未登录:显示登录/注册按钮
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
import { useState, useEffect, useRef } from 'react';
|
| 10 |
+
import { User, LogOut, Crown, LogIn } from 'lucide-react';
|
| 11 |
+
import { useAuthStore } from '@/store/authStore';
|
| 12 |
+
import AuthModal from './AuthModal';
|
| 13 |
+
import PaymentModal from './PaymentModal';
|
| 14 |
+
|
| 15 |
+
interface UserMenuProps {
|
| 16 |
+
compact?: boolean; // 紧凑模式(游戏进行中)
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export default function UserMenu({ compact = false }: UserMenuProps) {
|
| 20 |
+
const { username, isVip, vipExpireAt, isInitialized, logout, initialize } = useAuthStore();
|
| 21 |
+
const [showAuthModal, setShowAuthModal] = useState(false);
|
| 22 |
+
const [showPayment, setShowPayment] = useState(false);
|
| 23 |
+
const [showDropdown, setShowDropdown] = useState(false);
|
| 24 |
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
| 25 |
+
|
| 26 |
+
// 初始化认证状态
|
| 27 |
+
useEffect(() => {
|
| 28 |
+
if (!isInitialized) {
|
| 29 |
+
initialize();
|
| 30 |
+
}
|
| 31 |
+
}, [isInitialized, initialize]);
|
| 32 |
+
|
| 33 |
+
// 点击外部关闭下拉
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
const handleClickOutside = (event: MouseEvent) => {
|
| 36 |
+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
| 37 |
+
setShowDropdown(false);
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
document.addEventListener('mousedown', handleClickOutside);
|
| 41 |
+
return () => document.removeEventListener('mousedown', handleClickOutside);
|
| 42 |
+
}, []);
|
| 43 |
+
|
| 44 |
+
const handleLogout = async () => {
|
| 45 |
+
setShowDropdown(false);
|
| 46 |
+
await logout();
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
// 格式化 VIP 到期时间
|
| 50 |
+
const formatExpireDate = (dateStr: string | null) => {
|
| 51 |
+
if (!dateStr) return '';
|
| 52 |
+
const date = new Date(dateStr);
|
| 53 |
+
const now = new Date();
|
| 54 |
+
const diffDays = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
| 55 |
+
if (diffDays <= 0) return '已过期';
|
| 56 |
+
if (diffDays <= 7) return `${diffDays}天后到期`;
|
| 57 |
+
return dateStr.split(' ')[0]; // 只显示日期部分
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
if (!isInitialized) return null;
|
| 61 |
+
|
| 62 |
+
// 未登录状态
|
| 63 |
+
if (!username) {
|
| 64 |
+
return (
|
| 65 |
+
<>
|
| 66 |
+
<button
|
| 67 |
+
onClick={() => setShowAuthModal(true)}
|
| 68 |
+
className={`flex items-center gap-1.5 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 hover:text-blue-300 rounded-lg transition-colors ${compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm'
|
| 69 |
+
}`}
|
| 70 |
+
>
|
| 71 |
+
<LogIn size={compact ? 12 : 14} />
|
| 72 |
+
<span>登录</span>
|
| 73 |
+
</button>
|
| 74 |
+
{showAuthModal && <AuthModal onClose={() => setShowAuthModal(false)} />}
|
| 75 |
+
</>
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// 已登录状态
|
| 80 |
+
return (
|
| 81 |
+
<>
|
| 82 |
+
<div className="relative" ref={dropdownRef}>
|
| 83 |
+
<button
|
| 84 |
+
onClick={() => setShowDropdown(!showDropdown)}
|
| 85 |
+
className={`flex items-center gap-1.5 bg-[#2A2D3C] hover:bg-[#35394B] rounded-lg transition-colors ${compact ? 'px-2 py-1 text-xs' : 'px-3 py-2 text-sm'
|
| 86 |
+
}`}
|
| 87 |
+
>
|
| 88 |
+
<div className="w-5 h-5 bg-blue-600/30 rounded-full flex items-center justify-center">
|
| 89 |
+
<User size={12} className="text-blue-400" />
|
| 90 |
+
</div>
|
| 91 |
+
<span className="text-gray-200 font-medium max-w-[80px] truncate">{username}</span>
|
| 92 |
+
{isVip && (
|
| 93 |
+
<Crown size={compact ? 10 : 12} className="text-yellow-500" />
|
| 94 |
+
)}
|
| 95 |
+
</button>
|
| 96 |
+
|
| 97 |
+
{/* 下拉菜单 */}
|
| 98 |
+
{showDropdown && (
|
| 99 |
+
<div className="absolute right-0 top-full mt-1 w-48 bg-[#2A2D3C] rounded-xl shadow-2xl border border-gray-700/50 z-50 overflow-hidden">
|
| 100 |
+
{/* 用户信息 */}
|
| 101 |
+
<div className="px-4 py-3 border-b border-gray-700/30">
|
| 102 |
+
<div className="text-white text-sm font-medium">{username}</div>
|
| 103 |
+
{isVip ? (
|
| 104 |
+
<div className="flex items-center gap-1 mt-1">
|
| 105 |
+
<Crown size={11} className="text-yellow-500" />
|
| 106 |
+
<span className="text-yellow-500 text-xs">VIP 会员</span>
|
| 107 |
+
<span className="text-gray-500 text-xs ml-1">
|
| 108 |
+
{formatExpireDate(vipExpireAt)}
|
| 109 |
+
</span>
|
| 110 |
+
</div>
|
| 111 |
+
) : (
|
| 112 |
+
<div className="text-gray-500 text-xs mt-1">普通用户</div>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
{/* 操作 */}
|
| 117 |
+
<div className="p-1 space-y-1">
|
| 118 |
+
{!isVip && (
|
| 119 |
+
<button
|
| 120 |
+
onClick={() => { setShowDropdown(false); setShowPayment(true); }}
|
| 121 |
+
className="w-full flex items-center gap-2 px-3 py-2 text-yellow-500 hover:bg-yellow-500/10 rounded-lg text-sm transition-colors font-medium border border-yellow-500/20"
|
| 122 |
+
>
|
| 123 |
+
<Crown size={14} />
|
| 124 |
+
开通会员
|
| 125 |
+
</button>
|
| 126 |
+
)}
|
| 127 |
+
<button
|
| 128 |
+
onClick={handleLogout}
|
| 129 |
+
className="w-full flex items-center gap-2 px-3 py-2 text-gray-400 hover:text-white hover:bg-gray-700/50 rounded-lg text-sm transition-colors"
|
| 130 |
+
>
|
| 131 |
+
<LogOut size={14} />
|
| 132 |
+
退出登录
|
| 133 |
+
</button>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
)}
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
{showAuthModal && <AuthModal onClose={() => setShowAuthModal(false)} />}
|
| 140 |
+
<PaymentModal isOpen={showPayment} onClose={() => setShowPayment(false)} />
|
| 141 |
+
</>
|
| 142 |
+
);
|
| 143 |
+
}
|
frontend/src/lib/api.ts
CHANGED
|
@@ -34,6 +34,18 @@ export interface HealthResponse {
|
|
| 34 |
stocks_count: number;
|
| 35 |
}
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
async function fetchAPI<T>(path: string, options?: RequestInit): Promise<T> {
|
| 38 |
const url = `${API_BASE}${path}`;
|
| 39 |
const response = await fetch(url, {
|
|
@@ -45,8 +57,18 @@ async function fetchAPI<T>(path: string, options?: RequestInit): Promise<T> {
|
|
| 45 |
});
|
| 46 |
|
| 47 |
if (!response.ok) {
|
| 48 |
-
const
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
}
|
| 51 |
|
| 52 |
return response.json();
|
|
@@ -75,9 +97,25 @@ export interface VipStatusResponse {
|
|
| 75 |
vip_expire_at: string | null;
|
| 76 |
}
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
export const api = {
|
| 79 |
health: () => fetchAPI<HealthResponse>('/api/health'),
|
| 80 |
-
|
| 81 |
startGame: (mode = 'random', market?: string, token?: string) => {
|
| 82 |
const params = new URLSearchParams({ mode });
|
| 83 |
if (market) params.append('market', market);
|
|
@@ -85,14 +123,14 @@ export const api = {
|
|
| 85 |
if (token) headers['Authorization'] = `Bearer ${token}`;
|
| 86 |
return fetchAPI<GameStartResponse>(`/api/game/start?${params}`, { headers });
|
| 87 |
},
|
| 88 |
-
|
| 89 |
getKline: (code: string, start?: string, end?: string) => {
|
| 90 |
const params = new URLSearchParams({ code });
|
| 91 |
if (start) params.append('start', start);
|
| 92 |
if (end) params.append('end', end);
|
| 93 |
return fetchAPI<KLine[]>(`/api/kline?${params}`);
|
| 94 |
},
|
| 95 |
-
|
| 96 |
getHS300Index: (start?: string, end?: string) => {
|
| 97 |
const params = new URLSearchParams();
|
| 98 |
if (start) params.append('start', start);
|
|
@@ -121,4 +159,27 @@ export const api = {
|
|
| 121 |
fetchAPI<VipStatusResponse>('/api/v1/vip/status', {
|
| 122 |
headers: { Authorization: `Bearer ${token}` },
|
| 123 |
}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
};
|
|
|
|
| 34 |
stocks_count: number;
|
| 35 |
}
|
| 36 |
|
| 37 |
+
export class ApiError extends Error {
|
| 38 |
+
status: number;
|
| 39 |
+
data: any;
|
| 40 |
+
|
| 41 |
+
constructor(message: string, status: number, data: any) {
|
| 42 |
+
super(message);
|
| 43 |
+
this.name = 'ApiError';
|
| 44 |
+
this.status = status;
|
| 45 |
+
this.data = data;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
async function fetchAPI<T>(path: string, options?: RequestInit): Promise<T> {
|
| 50 |
const url = `${API_BASE}${path}`;
|
| 51 |
const response = await fetch(url, {
|
|
|
|
| 57 |
});
|
| 58 |
|
| 59 |
if (!response.ok) {
|
| 60 |
+
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
| 61 |
+
// 尝试提取错误信息
|
| 62 |
+
let message = 'API request failed';
|
| 63 |
+
if (typeof errorData.detail === 'string') {
|
| 64 |
+
message = errorData.detail;
|
| 65 |
+
} else if (errorData.detail && errorData.detail.message) {
|
| 66 |
+
message = errorData.detail.message;
|
| 67 |
+
} else if (errorData.error) {
|
| 68 |
+
message = errorData.error;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
throw new ApiError(message, response.status, errorData);
|
| 72 |
}
|
| 73 |
|
| 74 |
return response.json();
|
|
|
|
| 97 |
vip_expire_at: string | null;
|
| 98 |
}
|
| 99 |
|
| 100 |
+
export interface VipStatusResponse {
|
| 101 |
+
is_vip: boolean;
|
| 102 |
+
vip_expire_at: string | null;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
export interface CreatePaymentResponse {
|
| 106 |
+
order_id: string;
|
| 107 |
+
pay_url: string;
|
| 108 |
+
price: number;
|
| 109 |
+
type: number;
|
| 110 |
+
param: string;
|
| 111 |
+
sign: string;
|
| 112 |
+
is_mock: boolean;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
export const api = {
|
| 117 |
health: () => fetchAPI<HealthResponse>('/api/health'),
|
| 118 |
+
|
| 119 |
startGame: (mode = 'random', market?: string, token?: string) => {
|
| 120 |
const params = new URLSearchParams({ mode });
|
| 121 |
if (market) params.append('market', market);
|
|
|
|
| 123 |
if (token) headers['Authorization'] = `Bearer ${token}`;
|
| 124 |
return fetchAPI<GameStartResponse>(`/api/game/start?${params}`, { headers });
|
| 125 |
},
|
| 126 |
+
|
| 127 |
getKline: (code: string, start?: string, end?: string) => {
|
| 128 |
const params = new URLSearchParams({ code });
|
| 129 |
if (start) params.append('start', start);
|
| 130 |
if (end) params.append('end', end);
|
| 131 |
return fetchAPI<KLine[]>(`/api/kline?${params}`);
|
| 132 |
},
|
| 133 |
+
|
| 134 |
getHS300Index: (start?: string, end?: string) => {
|
| 135 |
const params = new URLSearchParams();
|
| 136 |
if (start) params.append('start', start);
|
|
|
|
| 159 |
fetchAPI<VipStatusResponse>('/api/v1/vip/status', {
|
| 160 |
headers: { Authorization: `Bearer ${token}` },
|
| 161 |
}),
|
| 162 |
+
|
| 163 |
+
logout: (token: string) =>
|
| 164 |
+
fetchAPI<{ status: string }>('/api/v1/auth/logout', {
|
| 165 |
+
method: 'POST',
|
| 166 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 167 |
+
}),
|
| 168 |
+
|
| 169 |
+
getUsageToday: (token: string) =>
|
| 170 |
+
fetchAPI<{ used: number; limit: number | null; remaining: number | null; is_vip: boolean }>(
|
| 171 |
+
'/api/v1/usage/today',
|
| 172 |
+
{ headers: { Authorization: `Bearer ${token}` } }
|
| 173 |
+
),
|
| 174 |
+
|
| 175 |
+
createPayment: (type: number, token: string) =>
|
| 176 |
+
fetchAPI<CreatePaymentResponse>('/v1/payment/create', {
|
| 177 |
+
method: 'POST',
|
| 178 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 179 |
+
body: JSON.stringify({ type }),
|
| 180 |
+
}),
|
| 181 |
+
|
| 182 |
+
async checkPaymentStatus(orderId: string): Promise<{ code: number; data: { status: number } }> {
|
| 183 |
+
return fetchAPI(`/vmq/checkOrder?payId=${orderId}`);
|
| 184 |
+
},
|
| 185 |
};
|
frontend/src/store/authStore.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* 用户认证状态管理 Store
|
| 3 |
+
* 管理 token、用户信息、VIP 状态,持久化到 localStorage
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { create } from 'zustand';
|
| 7 |
+
import { api } from '@/lib/api';
|
| 8 |
+
|
| 9 |
+
export interface AuthState {
|
| 10 |
+
// 状态
|
| 11 |
+
token: string | null;
|
| 12 |
+
userId: number | null;
|
| 13 |
+
username: string | null;
|
| 14 |
+
isVip: boolean;
|
| 15 |
+
vipExpireAt: string | null;
|
| 16 |
+
dailyUsed: number;
|
| 17 |
+
dailyRemaining: number | null; // null = VIP 无限制
|
| 18 |
+
dailyLimit: number | null;
|
| 19 |
+
isLoading: boolean;
|
| 20 |
+
isInitialized: boolean;
|
| 21 |
+
|
| 22 |
+
// Actions
|
| 23 |
+
login: (username: string, password: string) => Promise<void>;
|
| 24 |
+
register: (username: string, password: string) => Promise<void>;
|
| 25 |
+
logout: () => Promise<void>;
|
| 26 |
+
fetchUserInfo: () => Promise<void>;
|
| 27 |
+
fetchUsage: () => Promise<void>;
|
| 28 |
+
initialize: () => Promise<void>;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const TOKEN_KEY = 'auth_token';
|
| 32 |
+
|
| 33 |
+
export const useAuthStore = create<AuthState>((set, get) => ({
|
| 34 |
+
token: null,
|
| 35 |
+
userId: null,
|
| 36 |
+
username: null,
|
| 37 |
+
isVip: false,
|
| 38 |
+
vipExpireAt: null,
|
| 39 |
+
dailyUsed: 0,
|
| 40 |
+
dailyRemaining: null,
|
| 41 |
+
dailyLimit: null,
|
| 42 |
+
isLoading: false,
|
| 43 |
+
isInitialized: false,
|
| 44 |
+
|
| 45 |
+
initialize: async () => {
|
| 46 |
+
if (typeof window === 'undefined') {
|
| 47 |
+
set({ isInitialized: true });
|
| 48 |
+
return;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const savedToken = localStorage.getItem(TOKEN_KEY);
|
| 52 |
+
if (!savedToken) {
|
| 53 |
+
set({ isInitialized: true });
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
set({ token: savedToken, isLoading: true });
|
| 58 |
+
|
| 59 |
+
try {
|
| 60 |
+
const userInfo = await api.me(savedToken);
|
| 61 |
+
set({
|
| 62 |
+
userId: userInfo.user_id,
|
| 63 |
+
username: userInfo.username,
|
| 64 |
+
isVip: userInfo.is_vip,
|
| 65 |
+
vipExpireAt: userInfo.vip_expire_at,
|
| 66 |
+
isInitialized: true,
|
| 67 |
+
isLoading: false,
|
| 68 |
+
});
|
| 69 |
+
// 同时获取今日使用量
|
| 70 |
+
try {
|
| 71 |
+
const usage = await api.getUsageToday(savedToken);
|
| 72 |
+
set({ dailyUsed: usage.used, dailyRemaining: usage.remaining, dailyLimit: usage.limit });
|
| 73 |
+
} catch { /* 静默失败 */ }
|
| 74 |
+
} catch {
|
| 75 |
+
// Token 过期或无效,清除
|
| 76 |
+
localStorage.removeItem(TOKEN_KEY);
|
| 77 |
+
set({
|
| 78 |
+
token: null,
|
| 79 |
+
userId: null,
|
| 80 |
+
username: null,
|
| 81 |
+
isVip: false,
|
| 82 |
+
vipExpireAt: null,
|
| 83 |
+
isInitialized: true,
|
| 84 |
+
isLoading: false,
|
| 85 |
+
});
|
| 86 |
+
}
|
| 87 |
+
},
|
| 88 |
+
|
| 89 |
+
login: async (username: string, password: string) => {
|
| 90 |
+
set({ isLoading: true });
|
| 91 |
+
try {
|
| 92 |
+
const res = await api.login(username, password);
|
| 93 |
+
localStorage.setItem(TOKEN_KEY, res.token);
|
| 94 |
+
set({
|
| 95 |
+
token: res.token,
|
| 96 |
+
userId: res.user_id,
|
| 97 |
+
username: res.username,
|
| 98 |
+
isLoading: false,
|
| 99 |
+
});
|
| 100 |
+
// Fetch VIP info and usage
|
| 101 |
+
try {
|
| 102 |
+
const vipInfo = await api.vipStatus(res.token);
|
| 103 |
+
set({ isVip: vipInfo.is_vip, vipExpireAt: vipInfo.vip_expire_at });
|
| 104 |
+
const usage = await api.getUsageToday(res.token);
|
| 105 |
+
set({ dailyUsed: usage.used, dailyRemaining: usage.remaining, dailyLimit: usage.limit });
|
| 106 |
+
} catch {
|
| 107 |
+
// VIP/usage 查询失败不影响登录
|
| 108 |
+
}
|
| 109 |
+
} catch (error) {
|
| 110 |
+
set({ isLoading: false });
|
| 111 |
+
throw error;
|
| 112 |
+
}
|
| 113 |
+
},
|
| 114 |
+
|
| 115 |
+
register: async (username: string, password: string) => {
|
| 116 |
+
set({ isLoading: true });
|
| 117 |
+
try {
|
| 118 |
+
const res = await api.register(username, password);
|
| 119 |
+
localStorage.setItem(TOKEN_KEY, res.token);
|
| 120 |
+
set({
|
| 121 |
+
token: res.token,
|
| 122 |
+
userId: res.user_id,
|
| 123 |
+
username: res.username,
|
| 124 |
+
isVip: false,
|
| 125 |
+
vipExpireAt: null,
|
| 126 |
+
isLoading: false,
|
| 127 |
+
});
|
| 128 |
+
} catch (error) {
|
| 129 |
+
set({ isLoading: false });
|
| 130 |
+
throw error;
|
| 131 |
+
}
|
| 132 |
+
},
|
| 133 |
+
|
| 134 |
+
logout: async () => {
|
| 135 |
+
const { token } = get();
|
| 136 |
+
if (token) {
|
| 137 |
+
try {
|
| 138 |
+
await api.logout(token);
|
| 139 |
+
} catch {
|
| 140 |
+
// 忽略登出接口错误
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
localStorage.removeItem(TOKEN_KEY);
|
| 144 |
+
set({
|
| 145 |
+
token: null,
|
| 146 |
+
userId: null,
|
| 147 |
+
username: null,
|
| 148 |
+
isVip: false,
|
| 149 |
+
vipExpireAt: null,
|
| 150 |
+
dailyUsed: 0,
|
| 151 |
+
dailyRemaining: null,
|
| 152 |
+
dailyLimit: null,
|
| 153 |
+
});
|
| 154 |
+
},
|
| 155 |
+
|
| 156 |
+
fetchUsage: async () => {
|
| 157 |
+
const { token } = get();
|
| 158 |
+
if (!token) return;
|
| 159 |
+
try {
|
| 160 |
+
const usage = await api.getUsageToday(token);
|
| 161 |
+
set({ dailyUsed: usage.used, dailyRemaining: usage.remaining, dailyLimit: usage.limit });
|
| 162 |
+
} catch { /* 静默失败 */ }
|
| 163 |
+
},
|
| 164 |
+
|
| 165 |
+
fetchUserInfo: async () => {
|
| 166 |
+
const { token } = get();
|
| 167 |
+
if (!token) return;
|
| 168 |
+
|
| 169 |
+
try {
|
| 170 |
+
const userInfo = await api.me(token);
|
| 171 |
+
set({
|
| 172 |
+
userId: userInfo.user_id,
|
| 173 |
+
username: userInfo.username,
|
| 174 |
+
isVip: userInfo.is_vip,
|
| 175 |
+
vipExpireAt: userInfo.vip_expire_at,
|
| 176 |
+
});
|
| 177 |
+
} catch {
|
| 178 |
+
// 静默失败
|
| 179 |
+
}
|
| 180 |
+
},
|
| 181 |
+
}));
|
package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
{
|
|
|
|
|
|
|
|
|
|
| 2 |
"dependencies": {
|
| 3 |
"html2canvas": "^1.4.1",
|
| 4 |
"qrcode.react": "^4.2.0"
|
| 5 |
}
|
| 6 |
-
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"scripts": {
|
| 3 |
+
"sync-users": "python backend/scripts/upload_users.py"
|
| 4 |
+
},
|
| 5 |
"dependencies": {
|
| 6 |
"html2canvas": "^1.4.1",
|
| 7 |
"qrcode.react": "^4.2.0"
|
| 8 |
}
|
| 9 |
+
}
|
scripts/upload_users.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
# 添加项目根目录到路径
|
| 6 |
+
project_root = Path(__file__).parent.parent
|
| 7 |
+
sys.path.insert(0, str(project_root / "backend"))
|
| 8 |
+
|
| 9 |
+
from dotenv import load_dotenv
|
| 10 |
+
load_dotenv(project_root / ".env")
|
| 11 |
+
|
| 12 |
+
from app.database_user import upload_user_db_to_hf
|
| 13 |
+
|
| 14 |
+
if __name__ == "__main__":
|
| 15 |
+
print("Starting user database upload to HF Dataset...")
|
| 16 |
+
upload_user_db_to_hf()
|
| 17 |
+
print("Upload completed!")
|
用户鉴权与支付方案.md → user_auth_payment_plan.md
RENAMED
|
File without changes
|