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 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 = User(username=username, password_hash=hash_password(payload.password))
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
- authorization: Optional[str] = Header(None, alias="Authorization"),
265
- db: Session = Depends(get_user_db)
266
  ):
267
  """
268
  开始游戏 - 获取盲盒数据
 
 
 
269
  """
270
- # 校验 VIP 权限(可选:针对特定市场如 '科创板' 要求 VIP)
271
- is_vip = False
272
- token = _get_token_from_header(authorization)
273
- if token:
274
- user = get_user_by_token(db, token)
275
- if user:
276
- vip_info = check_vip_status(user.id, db)
277
- is_vip = vip_info["is_vip"]
278
-
279
- # 如果不是 VIP 且选择了受限板块(示例逻辑)
 
 
 
 
 
 
 
 
280
  if not is_vip and market in ['科创板', '北交所', '可转债']:
281
- raise HTTPException(status_code=403, detail="该板块仅限 VIP 会员使用")
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
- status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
 
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
- sign_data.pop("sign", None)
 
 
 
 
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(expected, sign)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <StockHeader cursorData={cursorData} />
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
- !isPlaying
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 [vipExpireAt, setVipExpireAt] = useState<string | null>(null);
23
- const [isVip, setIsVip] = useState(false);
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
- alert(error.message || '启动游戏失败,请检查后端服务');
 
 
 
 
 
 
 
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
- <button
204
- onClick={handleStartGame}
205
- disabled={isLoading}
206
- 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]"
207
- >
208
- {isLoading ? '加载数据中...' : '开始挑战'}
209
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 error = await response.json().catch(() => ({ error: 'Unknown error' }));
49
- throw new Error(error.error || error.detail || 'API request failed');
 
 
 
 
 
 
 
 
 
 
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