fix: 增加管理员界面;增加用户管理

master
Lxy 3 months ago
parent 5410a0b8ab
commit c56ccf5fb2

@ -0,0 +1,60 @@
# Dependencies
node_modules/
# Build output
dist/
build/
# Environment variables
.env
.env.local
.env.*.local
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Database
*.sqlite
*.sqlite3
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Testing
coverage/
*.test.ts
*.spec.ts
# Misc
.cache/
temp/
tmp/
# Docker
docker-compose*.yml
Dockerfile*
.dockerignore
# Git
.git/
.gitignore
# Documentation
README.md
*.md
STARTUP_GUIDE.md
# Init scripts (will be mounted as volume)
init-scripts/

@ -0,0 +1,28 @@
# ============================================
# A股智投分析平台 - Docker 环境配置
# ============================================
# 服务器配置
PORT=3000
NODE_ENV=production
# 数据库配置Docker 内部网络)
DATABASE_URL=mysql://root:1qazse42W3@mysql:3306/aguzhitou
# Redis配置Docker 内部网络)
REDIS_URL=redis://redis:6379
# JWT配置
JWT_SECRET=aguzhitou-docker-secret-key-2024-prod-only-min-32-characters
JWT_EXPIRES_IN=7d
# AKShare配置
AKSHARE_URL=http://akshare:8000
# 日志配置
LOG_LEVEL=info
LOG_DIR=./logs
# 限流配置
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100

@ -0,0 +1,28 @@
# ============================================
# A股智投分析平台 - Docker 环境配置
# ============================================
# 服务器配置
PORT=3000
NODE_ENV=production
# 数据库配置Docker 内部网络)
DATABASE_URL=mysql://root:1qazse42W3@mysql:3306/aguzhitou
# Redis配置Docker 内部网络)
REDIS_URL=redis://redis:6379
# JWT配置
JWT_SECRET=aguzhitou-docker-secret-key-2024-prod-only-min-32-characters
JWT_EXPIRES_IN=7d
# AKShare配置
AKSHARE_URL=http://akshare:8000
# 日志配置
LOG_LEVEL=info
LOG_DIR=./logs
# 限流配置
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100

@ -0,0 +1,132 @@
# Docker 部署文件清单
## 📁 文件列表
| 文件 | 说明 | 大小 |
|------|------|------|
| `docker-compose.yml` | Docker 编排配置 | ~3KB |
| `Dockerfile` | 后端应用镜像构建 | ~1.5KB |
| `.env.docker` | Docker 环境变量 | ~600B |
| `.dockerignore` | Docker 忽略文件 | ~600B |
| `docker-start.sh` | Linux/Mac 启动脚本 | ~2.5KB |
| `docker-start.bat` | Windows 启动脚本 | ~1.8KB |
| `DOCKER_README.md` | 部署文档 | ~6KB |
| `verify-docker.js` | 部署验证脚本 | ~7KB |
## 📂 目录结构
```
app/backend/
├── docker-compose.yml # Docker 编排配置
├── Dockerfile # 后端应用镜像
├── .env.docker # Docker 环境变量
├── .dockerignore # Docker 忽略文件
├── docker-start.sh # Linux/Mac 启动脚本 ⭐
├── docker-start.bat # Windows 启动脚本 ⭐
├── DOCKER_README.md # 完整部署文档
├── DOCKER_FILES.md # 本文档
├── verify-docker.js # 部署验证脚本
├── init-scripts/ # 数据库初始化脚本
│ ├── 01-init-database.sql # 创建表结构(含分区)⭐
│ ├── 02-seed-data.sql # 种子数据 ⭐
│ └── 03-partition-maintenance.sql # 分区维护工具
├── src/ # 后端源代码
├── prisma/ # Prisma 配置
└── ...
```
## 🚀 快速使用
### 1. 一键启动
**Windows:**
```bash
docker-start.bat
```
**Linux/Mac:**
```bash
./docker-start.sh
```
### 2. 手动启动
```bash
docker-compose up --build -d
```
### 3. 验证部署
```bash
node verify-docker.js
```
## 📊 包含的服务
| 服务 | 镜像 | 端口 | 说明 |
|------|------|------|------|
| MySQL | mysql:8.0 | 3306 | 主数据库(含分区表) |
| Redis | redis:7-alpine | 6379 | 缓存服务 |
| App | 本地构建 | 3000 | 后端 API 服务 |
| AKShare | 阿里云镜像 | 8000 | 数据源(可选) |
## 🗄️ 数据库分区详情
### 分区表4张
| 表名 | 分区数 | 分区范围 |
|------|-------|---------|
| `stock_quotes_history` | 37 | 2024-01 ~ 2026-12 + future |
| `sector_quotes` | 37 | 2024-01 ~ 2026-12 + future |
| `high_low_stocks` | 37 | 2024-01 ~ 2026-12 + future |
| `momentum_stocks` | 37 | 2024-01 ~ 2026-12 + future |
### 普通表8张
- `market_indices` - 市场指数
- `sectors` - 版块信息
- `stocks` - 股票信息
- `stock_quotes_hot` - 热数据
- `stock_klines` - 股票K线
- `sector_klines` - 版块K线
- `users` - 用户表
- `user_favorites` - 自选股
## 📈 数据卷
| 卷名 | 用途 |
|------|------|
| `mysql_data` | MySQL 持久化数据 |
| `redis_data` | Redis 持久化数据 |
| `./logs` | 应用日志 |
## 🔧 默认配置
- **MySQL**: root / 1qazse42W3
- **API**: http://localhost:3000
- **数据库**: aguzhitou
- **字符集**: utf8mb4
## 📝 注意事项
1. 首次启动需要下载镜像,请耐心等待
2. MySQL 初始化脚本会自动执行(约需 30 秒)
3. 确保 3306、6379、3000 端口未被占用
4. 数据会持久化到 Docker Volumes
## 🆘 故障排查
```bash
# 查看日志
docker-compose logs -f
# 重启服务
docker-compose restart
# 完全重置(删除所有数据)
docker-compose down -v
```
## 📞 技术支持
如有问题,请参考 `DOCKER_README.md` 或检查日志文件。

@ -0,0 +1,88 @@
# Docker 快速参考卡片
## 🎯 常用命令速查
### 开发模式(热重载)
```bash
# 启动(代码修改自动同步)
./dev-start.sh # Linux/Mac
-dev-start.bat # Windows
# 查看日志
docker-compose -f docker-compose.dev.yml logs -f app
# 重启
docker-compose -f docker-compose.dev.yml restart app
# 停止
docker-compose -f docker-compose.dev.yml stop
```
### 生产模式
```bash
# 启动(重建镜像)
./docker-start.sh # Linux/Mac
docker-start.bat # Windows
# 或手动
docker-compose up --build -d
# 查看状态
docker-compose ps
# 停止
docker-compose down
```
### 代码同步
| 场景 | 命令 | 时间 |
|------|------|------|
| 修改代码 | 自动同步 | 1秒 |
| 修改配置 | `docker-compose restart app` | 10秒 |
| 修改依赖 | `docker-compose up --build -d` | 2-3分钟 |
| 修改数据库 | `docker-compose down -v && up -d` | 2-3分钟 |
---
## 🗂️ 文件说明
| 文件 | 用途 |
|------|------|
| `docker-compose.yml` | 生产环境配置 |
| `docker-compose.dev.yml` | 开发环境配置(热重载) |
| `dev-start.sh/bat` | 开发模式启动脚本 |
| `docker-start.sh/bat` | 生产模式启动脚本 |
| `verify-docker.js` | 部署验证脚本 |
| `DOCKER_SYNC.md` | 完整同步指南 |
---
## 🔗 访问地址
| 服务 | 地址 |
|------|------|
| API | http://localhost:3000/api/v1 |
| Health | http://localhost:3000/api/v1/health |
| MySQL | localhost:3306 (root/1qazse42W3) |
| Redis | localhost:6379 |
---
## 💡 快速诊断
```bash
# 查看所有服务状态
docker-compose ps
# 查看应用日志
docker-compose logs -f app
# 进入容器调试
docker-compose exec app sh
# 测试 API
curl http://localhost:3000/api/v1/health
```

@ -0,0 +1,295 @@
# A股智投分析平台 - Docker 部署指南
## 📋 概述
本项目提供完整的 Docker 化部署方案,包含:
- **MySQL 8.0** - 带分区表的数据库
- **Redis 7** - 缓存服务
- **后端应用** - Node.js + Express + TypeScript
- **AKShare** - 可选的数据源服务
## 🚀 快速开始
### 方式一:一键启动(推荐)
**Linux/Mac:**
```bash
./docker-start.sh
```
**Windows:**
```bash
docker-start.bat
```
### 方式二:手动启动
```bash
# 1. 进入后端目录
cd app/backend
# 2. 启动服务
docker-compose up --build -d
# 3. 查看日志
docker-compose logs -f app
```
## 📁 文件结构
```
app/backend/
├── docker-compose.yml # Docker 编排配置
├── Dockerfile # 后端应用镜像
├── .env.docker # Docker 环境变量
├── .dockerignore # Docker 忽略文件
├── docker-start.sh # Linux/Mac 启动脚本
├── docker-start.bat # Windows 启动脚本
├── init-scripts/ # 数据库初始化脚本
│ ├── 01-init-database.sql # 创建表结构(含分区)
│ └── 02-seed-data.sql # 种子数据
└── DOCKER_README.md # 本文档
```
## 🔧 配置说明
### 环境变量 (`.env.docker`)
| 变量名 | 默认值 | 说明 |
|-------|-------|------|
| `DATABASE_URL` | mysql://root:1qazse42W3@mysql:3306/aguzhitou | MySQL 连接 |
| `REDIS_URL` | redis://redis:6379 | Redis 连接 |
| `JWT_SECRET` | aguzhitou-docker... | JWT 密钥 |
| `PORT` | 3000 | 服务端口 |
### 数据库分区设计
| 表名 | 分区字段 | 分区范围 | 分区数 |
|------|---------|---------|-------|
| `stock_quotes_history` | quote_time | 2024-01 ~ 2026-12 | 37 |
| `sector_quotes` | quote_time | 2024-01 ~ 2026-12 | 37 |
| `high_low_stocks` | date | 2024-01 ~ 2026-12 | 37 |
| `momentum_stocks` | date | 2024-01 ~ 2026-12 | 37 |
## 📊 服务访问
启动后可通过以下地址访问:
| 服务 | 地址 | 说明 |
|------|------|------|
| API 接口 | http://localhost:3000/api/v1 | 后端 API |
| 健康检查 | http://localhost:3000/api/v1/health | 服务状态 |
| MySQL | localhost:3306 | 数据库 |
| Redis | localhost:6379 | 缓存 |
### 默认账号
- **MySQL**: root / 1qazse42W3
## 🔍 常用命令
### 查看状态
```bash
# 查看所有容器状态
docker-compose ps
# 查看资源使用
docker-compose top
```
### 查看日志
```bash
# 查看所有服务日志
docker-compose logs
# 查看后端应用日志(实时)
docker-compose logs -f app
# 查看 MySQL 日志
docker-compose logs -f mysql
# 查看 Redis 日志
docker-compose logs -f redis
```
### 服务管理
```bash
# 停止服务
docker-compose stop
# 启动服务
docker-compose start
# 重启服务
docker-compose restart
# 停止并删除容器(保留数据)
docker-compose down
# 完全重置(删除容器和数据卷)
docker-compose down -v
```
### 数据库操作
```bash
# 进入 MySQL 容器
mysql -h localhost -P 3306 -u root -p1qazse42W3
# 备份数据库
docker-compose exec mysql mysqldump -u root -p1qazse42W3 aguzhitou > backup.sql
# 恢复数据库
docker-compose exec -T mysql mysql -u root -p1qazse42W3 aguzhitou < backup.sql
```
## 🗄️ 数据持久化
数据通过 Docker Volumes 持久化:
| 卷名 | 用途 | 位置 |
|------|------|------|
| `mysql_data` | MySQL 数据 | /var/lib/mysql |
| `redis_data` | Redis 数据 | /data |
| `./logs` | 应用日志 | /app/logs |
## 🛠️ 自定义配置
### 修改数据库密码
1. 编辑 `docker-compose.yml`
```yaml
mysql:
environment:
MYSQL_ROOT_PASSWORD: your-new-password
```
2. 编辑 `.env.docker`
```env
DATABASE_URL=mysql://root:your-new-password@mysql:3306/aguzhitou
```
3. 重启服务:
```bash
docker-compose down -v
docker-compose up --build -d
```
### 修改服务端口
编辑 `docker-compose.yml`
```yaml
app:
ports:
- "8080:3000" # 改为 8080 端口
```
### 添加 AKShare 数据源
```bash
docker-compose --profile with-akshare up -d
```
## 📈 性能优化
### MySQL 配置
已针对生产环境优化:
- `innodb_buffer_pool_size=512M`
- `max_connections=200`
- 字符集utf8mb4
### Redis 配置
使用 Alpine 版本,轻量级:
- 数据持久化
- 自动清理策略
## 🔒 安全配置
### 生产环境建议
1. **修改默认密码**
- MySQL root 密码
- JWT Secret
2. **使用 HTTPS**
- 配置 Nginx 反向代理
- 使用 Let's Encrypt 证书
3. **限制端口访问**
- 仅开放必要端口
- 使用防火墙规则
## 🐛 故障排查
### 问题1数据库连接失败
**现象**
```
Error: Can't reach database server
```
**解决方案**
```bash
# 检查 MySQL 容器状态
docker-compose ps mysql
# 查看 MySQL 日志
docker-compose logs mysql
# 手动测试连接
docker-compose exec mysql mysql -u root -p1qazse42W3 -e "SHOW DATABASES;"
```
### 问题2端口被占用
**现象**
```
Bind for 0.0.0.0:3306 failed: port is already allocated
```
**解决方案**
```bash
# 查找占用进程
sudo lsof -i :3306
# 停止占用进程或修改端口
# 编辑 docker-compose.yml 修改端口映射
```
### 问题3内存不足
**现象**
```
Error: Out of memory
```
**解决方案**
```bash
# 查看内存使用
docker stats
# 减少 MySQL 内存占用
# 编辑 docker-compose.yml 调整 innodb_buffer_pool_size
```
## 📝 更新记录
### v1.0.0
- ✨ 初始版本
- ✨ MySQL 8.0 带分区表
- ✨ Redis 7 缓存
- ✨ 自动初始化脚本
- ✨ 健康检查
## 📞 技术支持
如有问题,请检查:
1. Docker 和 Docker Compose 版本
2. 端口占用情况
3. 日志文件中的错误信息
4. 系统资源(内存、磁盘)
## 📄 许可证
MIT License

@ -0,0 +1,343 @@
# Docker 代码同步指南
本文档说明如何在代码更改后同步到 Docker 容器中。
## 🚀 三种同步方案
| 方案 | 适用场景 | 同步速度 | 特点 |
|------|---------|---------|------|
| **开发模式** | 日常开发 | 实时 | 热重载,自动同步 |
| **重建模式** | 生产部署 | 2-3分钟 | 全新镜像,干净环境 |
| **快速重启** | 配置更改 | 10秒 | 重启容器,保留数据 |
---
## 方案一:开发模式(推荐)
使用 Volume 挂载实现代码实时同步,支持热重载。
### 启动开发环境
```bash
cd app/backend
# Windows
-dev-start.bat
# Linux/Mac
./dev-start.sh
```
### 工作原理
```
宿主机 Docker 容器
├─ src/ ────────► /app/src/ (只读挂载)
├─ prisma/ ────────► /app/prisma/ (只读挂载)
├─ package.json ───────► /app/package.json (只读挂载)
└─ logs/ ◄──────── /app/logs/ (读写挂载)
```
### 特性
- ✅ **实时同步**:修改 `src/` 下的代码立即生效
- ✅ **热重载**:使用 `tsx watch` 自动重启服务
- ✅ **无需重建**:无需 `docker build`,秒级同步
- ✅ **调试友好**:支持 `console.log` 实时输出
### 常用命令
```bash
# 查看实时日志
docker-compose -f docker-compose.dev.yml logs -f app
# 重启应用服务
docker-compose -f docker-compose.dev.yml restart app
# 停止开发环境
docker-compose -f docker-compose.dev.yml stop
# 完全删除(包括数据)
docker-compose -f docker-compose.dev.yml down -v
```
### 文件变更自动同步
| 操作 | 效果 |
|------|------|
| 修改 `src/**/*.ts` | 服务自动重启,变更立即生效 |
| 修改 `prisma/schema.prisma` | 需要重启容器重新生成 Client |
| 修改 `package.json` | 需要重启容器重新安装依赖 |
| 修改 `.env` | 需要重启容器加载新配置 |
---
## 方案二:重建模式(生产部署)
代码更改后重新构建镜像,适合生产环境部署。
### 完整重建流程
```bash
cd app/backend
# 1. 停止现有服务
docker-compose down
# 2. 重新构建镜像(无缓存)
docker-compose build --no-cache
# 3. 启动服务
docker-compose up -d
```
### 快速重建(使用缓存)
```bash
# 一键重建并启动
docker-compose up --build -d
# 或
./docker-start.sh
```
### 仅更新应用代码
```bash
# 如果只有代码变更,可以快速重建
docker-compose build app --no-cache
docker-compose up -d
```
---
## 方案三:快速重启(配置更改)
仅重启容器,不重建镜像,保留所有数据。
```bash
# 重启所有服务
docker-compose restart
# 仅重启应用服务
docker-compose restart app
# 重启并查看日志
docker-compose restart app && docker-compose logs -f app
```
---
## 📋 不同场景的推荐方案
### 场景 1修改业务逻辑代码
**推荐**:开发模式
```bash
# 启动开发环境
./dev-start.sh
# 修改 src/services/stockService.ts
# 保存后自动同步,无需其他操作
```
### 场景 2修改数据库模型Prisma Schema
**推荐**:重建模式
```bash
# 1. 修改 prisma/schema.prisma
# 2. 重新构建并启动
docker-compose down
docker-compose up --build -d
# 3. 执行数据库迁移
docker-compose exec app npx prisma migrate dev
```
### 场景 3添加新依赖package.json
**推荐**:重建模式
```bash
# 1. 修改 package.json添加依赖
# 2. 重新构建
docker-compose down
docker-compose build app --no-cache
docker-compose up -d
```
### 场景 4修改配置文件.env
**推荐**:快速重启
```bash
# 1. 修改 .env.docker
# 2. 重启服务
docker-compose restart app
```
### 场景 5生产环境部署
**推荐**:生产模式
```bash
# 使用生产配置
docker-compose -f docker-compose.yml up --build -d
# 验证部署
node verify-docker.js
```
---
## 🔧 高级技巧
### 1. 进入容器内部调试
```bash
# 进入应用容器
docker-compose exec app sh
# 查看容器内的代码
ls -la /app/src/
# 手动运行命令
npx tsx src/app.ts
```
### 2. 查看容器内的日志文件
```bash
# 进入容器查看日志
docker-compose exec app sh -c "tail -f /app/logs/combined-*.log"
```
### 3. 强制刷新(清除缓存)
```bash
# 清除 Docker 构建缓存
docker builder prune -f
# 重新构建
docker-compose up --build -d
```
### 4. 同时运行开发和生产环境
```bash
# 终端 1启动开发环境端口 3000
./dev-start.sh
# 终端 2修改代码实时查看效果
```
### 5. 数据库变更后同步
```bash
# 如果修改了数据库初始化脚本
docker-compose down -v # 删除数据卷
docker-compose up -d # 重新初始化
```
---
## 🐛 常见问题
### 问题 1代码修改后没有生效
**原因**:可能是缓存或挂载问题
**解决**
```bash
# 方案 A重启应用服务
docker-compose -f docker-compose.dev.yml restart app
# 方案 B强制重新创建容器
docker-compose -f docker-compose.dev.yml up -d --force-recreate app
```
### 问题 2node_modules 不同步
**原因**Volume 挂载覆盖了容器内的 node_modules
**解决**
```bash
# 开发模式已使用命名卷隔离 node_modules通常无需处理
# 如需重新安装依赖:
docker-compose -f docker-compose.dev.yml exec app npm install
```
### 问题 3Prisma Client 未更新
**原因**:修改 schema 后需要重新生成 Client
**解决**
```bash
# 进入容器重新生成
docker-compose -f docker-compose.dev.yml exec app npx prisma generate
# 或重启容器
docker-compose -f docker-compose.dev.yml restart app
```
### 问题 4端口被占用
**原因**:其他服务占用了 3000/3306/6379 端口
**解决**
```bash
# 查看占用进程
lsof -i :3000
# 或修改 docker-compose.dev.yml 的端口映射
ports:
- "3001:3000" # 改为 3001 端口
```
---
## 📝 最佳实践
### 开发流程
1. **日常开发**:使用开发模式
```bash
./dev-start.sh
```
2. **修改代码**:在宿主机编辑,容器内自动同步
3. **测试验证**:访问 http://localhost:3000
4. **提交代码**:正常 git 提交流程
5. **生产部署**:重建模式
```bash
docker-compose up --build -d
```
### 文件修改 checklist
| 文件类型 | 同步方式 | 是否需要重建 |
|---------|---------|------------|
| `src/**/*.ts` | 自动同步 | ❌ 否 |
| `src/**/*.js` | 自动同步 | ❌ 否 |
| `prisma/schema.prisma` | 需重启 | ⚠️ 建议重建 |
| `package.json` | 需重启 | ✅ 需要重建 |
| `.env` | 需重启 | ⚠️ 重启即可 |
| `Dockerfile` | 需重建 | ✅ 需要重建 |
| `docker-compose.yml` | 需重建 | ✅ 需要重建 |
---
## 📚 相关文档
- [Docker 部署指南](./DOCKER_README.md)
- [开发启动脚本](./dev-start.sh)
- [生产启动脚本](./docker-start.sh)
- [部署验证脚本](./verify-docker.js)

@ -0,0 +1,226 @@
# A股智投分析平台 - Docker 完整指南
## 📦 包含的内容
### 1. 启动脚本3套环境
| 脚本 | 用途 | 命令 |
|------|------|------|
| `dev-start.sh/bat` | 开发模式(热重载) | `./dev-start.sh` |
| `docker-start.sh/bat` | 生产模式 | `./docker-start.sh` |
| `verify-docker.js` | 部署验证 | `node verify-docker.js` |
### 2. Docker 配置2套
| 配置 | 用途 | 特点 |
|------|------|------|
| `docker-compose.dev.yml` | 开发环境 | Volume挂载热重载 |
| `docker-compose.yml` | 生产环境 | 完整构建,性能优化 |
### 3. 初始化脚本
| 脚本 | 用途 |
|------|------|
| `init-scripts/01-init-database.sql` | 创建分区表结构 |
| `init-scripts/02-seed-data.sql` | 插入种子数据 |
| `init-scripts/03-partition-maintenance.sql` | 分区维护工具 |
### 4. 文档
| 文档 | 内容 |
|------|------|
| `DOCKER_README.md` | 完整部署文档 |
| `DOCKER_SYNC.md` | 代码同步指南 |
| `DOCKER_QUICKREF.md` | 快速参考卡片 |
| `DOCKER_FILES.md` | 文件清单 |
---
## 🚀 快速开始
### 开发环境(推荐日常开发)
```bash
cd app/backend
./dev-start.sh # Linux/Mac
# 或
-dev-start.bat # Windows
```
特点:
- ✅ 代码修改自动同步
- ✅ 热重载,秒级生效
- ✅ 无需重建镜像
### 生产环境(部署测试)
```bash
cd app/backend
./docker-start.sh # Linux/Mac
# 或
docker-start.bat # Windows
```
特点:
- ✅ 完整构建,干净环境
- ✅ 性能优化
- ✅ 适合生产部署
---
## 📋 代码更改后的同步方式
### 方案对比
| 方案 | 启动命令 | 同步速度 | 适用场景 |
|------|---------|---------|---------|
| **开发模式** | `dev-start.sh` | 实时 | 日常开发 |
| **重建模式** | `docker-compose up --build` | 2-3分钟 | 生产部署 |
| **快速重启** | `docker-compose restart` | 10秒 | 配置更改 |
### 决策树
```
修改了代码?
├── 是
│ ├── 开发阶段 → 使用 dev-start.sh自动同步
│ └── 生产部署 → 使用 docker-start.sh重建镜像
└── 否(仅配置)
└── docker-compose restart app
```
---
## 🔧 常用操作
### 查看日志
```bash
# 开发环境日志
docker-compose -f docker-compose.dev.yml logs -f app
# 生产环境日志
docker-compose logs -f app
```
### 重启服务
```bash
# 开发环境
docker-compose -f docker-compose.dev.yml restart app
# 生产环境
docker-compose restart app
```
### 停止服务
```bash
# 开发环境
docker-compose -f docker-compose.dev.yml stop
# 生产环境
docker-compose stop
# 完全删除(包括数据)
docker-compose down -v
```
### 进入容器
```bash
# 进入应用容器
docker-compose exec app sh
# 进入 MySQL
docker-compose exec mysql mysql -u root -p1qazse42W3
# 进入 Redis
docker-compose exec redis redis-cli
```
---
## 📁 目录结构
```
app/backend/
├── docker-compose.yml # 生产配置
├── docker-compose.dev.yml # 开发配置 ⭐
├── Dockerfile # 应用镜像
├── .env.docker # 环境变量
├── .dockerignore # 忽略文件
├── dev-start.sh # 开发启动脚本 ⭐
├── dev-start.bat # 开发启动脚本(Win)
├── docker-start.sh # 生产启动脚本
├── docker-start.bat # 生产启动脚本(Win)
├── verify-docker.js # 验证脚本
├── DOCKER_README.md # 部署文档
├── DOCKER_SYNC.md # 同步指南 ⭐
├── DOCKER_QUICKREF.md # 速查卡片
├── DOCKER_FILES.md # 文件清单
├── init-scripts/ # 初始化脚本
│ ├── 01-init-database.sql # 分区表结构
│ ├── 02-seed-data.sql # 种子数据
│ └── 03-partition-maintenance.sql
└── src/ # 源代码
```
---
## 🎯 推荐工作流
### 日常开发流程
```bash
# 1. 启动开发环境
./dev-start.sh
# 2. 修改代码src/ 目录)
# 修改会自动同步到容器
# 3. 查看效果
# 访问 http://localhost:3000
# 4. 提交代码
# 正常使用 git
```
### 部署流程
```bash
# 1. 代码测试完成
# 2. 构建并启动生产环境
./docker-start.sh
# 3. 验证部署
node verify-docker.js
# 4. 完成
```
---
## 📞 故障排查
| 问题 | 解决方案 |
|------|---------|
| 代码不生效 | `docker-compose restart app` |
| 端口占用 | 修改 `docker-compose.yml` 端口映射 |
| 数据库错误 | `docker-compose down -v` 重新初始化 |
| 构建失败 | `docker builder prune -f` 清除缓存 |
---
## 📚 相关文档
- [完整部署指南](./DOCKER_README.md)
- [代码同步指南](./DOCKER_SYNC.md) ⭐
- [快速参考卡片](./DOCKER_QUICKREF.md)
- [Docker 文件清单](./DOCKER_FILES.md)

@ -0,0 +1,75 @@
@echo off
chcp 65001 >nul
echo ==============================================
echo A股智投分析平台 - 开发模式启动
echo 特点: 代码修改自动同步,无需重建镜像
echo ==============================================
REM 检查 Docker
docker --version >nul 2>&1
if errorlevel 1 (
echo ❌ Docker 未安装
exit /b 1
)
docker-compose --version >nul 2>&1
if errorlevel 1 (
echo ❌ Docker Compose 未安装
exit /b 1
)
echo ✓ Docker 环境检查通过
REM 创建必要目录
if not exist logs mkdir logs
echo.
echo ==============================================
echo 启动开发环境...
echo ==============================================
REM 使用开发配置启动
docker-compose -f docker-compose.dev.yml up -d
echo.
echo ⏳ 等待服务启动...
timeout /t 5 /nobreak >nul
REM 检查状态
echo.
echo ==============================================
echo 服务状态
echo ==============================================
docker-compose -f docker-compose.dev.yml ps
echo.
echo ==============================================
echo ✅ 开发环境已启动!
echo ==============================================
echo.
echo 访问地址:
echo • API: http://localhost:3000/api/v1
echo • Health: http://localhost:3000/api/v1/health
echo.
echo 开发特性:
echo ✓ 代码修改自动同步(无需重启容器)
echo ✓ 支持热重载(自动重启服务)
echo ✓ 调试日志实时输出
echo.
echo 常用命令:
echo 查看日志: docker-compose -f docker-compose.dev.yml logs -f app
echo 停止服务: docker-compose -f docker-compose.dev.yml stop
echo 重启服务: docker-compose -f docker-compose.dev.yml restart app
echo 完全删除: docker-compose -f docker-compose.dev.yml down -v
echo.
echo 💡 提示:修改 src/ 目录下的代码会立即生效!
echo ==============================================
REM 询问是否查看日志
echo.
set /p show_logs=是否查看实时日志?(y/n):
if /i "%show_logs%"=="y" (
docker-compose -f docker-compose.dev.yml logs -f app
)
pause

@ -0,0 +1,77 @@
#!/bin/bash
# ============================================
# A股智投分析平台 - 开发模式启动脚本
# 支持代码热重载,修改后自动同步
# ============================================
set -e
echo "=============================================="
echo "A股智投分析平台 - 开发模式启动"
echo "特点: 代码修改自动同步,无需重建镜像"
echo "=============================================="
# 检查 Docker
if ! command -v docker &> /dev/null; then
echo "❌ Docker 未安装"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose 未安装"
exit 1
fi
echo "✓ Docker 环境检查通过"
# 创建必要目录
mkdir -p logs
echo ""
echo "=============================================="
echo "启动开发环境..."
echo "=============================================="
# 使用开发配置启动
docker-compose -f docker-compose.dev.yml up -d
echo ""
echo "⏳ 等待服务启动..."
sleep 5
# 检查状态
echo ""
echo "=============================================="
echo "服务状态"
echo "=============================================="
docker-compose -f docker-compose.dev.yml ps
echo ""
echo "=============================================="
echo "✅ 开发环境已启动!"
echo "=============================================="
echo ""
echo "访问地址:"
echo " • API: http://localhost:3000/api/v1"
echo " • Health: http://localhost:3000/api/v1/health"
echo ""
echo "开发特性:"
echo " ✓ 代码修改自动同步(无需重启容器)"
echo " ✓ 支持热重载(自动重启服务)"
echo " ✓ 调试日志实时输出"
echo ""
echo "常用命令:"
echo " 查看日志: docker-compose -f docker-compose.dev.yml logs -f app"
echo " 停止服务: docker-compose -f docker-compose.dev.yml stop"
echo " 重启服务: docker-compose -f docker-compose.dev.yml restart app"
echo " 完全删除: docker-compose -f docker-compose.dev.yml down -v"
echo ""
echo "💡 提示:修改 src/ 目录下的代码会立即生效!"
echo "=============================================="
# 显示实时日志
echo ""
read -p "是否查看实时日志?(y/n): " show_logs
if [ "$show_logs" = "y" ]; then
docker-compose -f docker-compose.dev.yml logs -f app
fi

@ -0,0 +1,110 @@
version: '3.8'
services:
# MySQL 8.0 数据库
mysql:
image: mysql:8.0
container_name: aguzhitou-mysql-dev
environment:
MYSQL_ROOT_PASSWORD: 1qazse42W3
MYSQL_DATABASE: aguzhitou
MYSQL_CHARSET: utf8mb4
MYSQL_COLLATION: utf8mb4_unicode_ci
TZ: Asia/Shanghai
volumes:
- mysql_dev_data:/var/lib/mysql
# 注:移除 init-scripts使用 Prisma db push 创建表结构
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p1qazse42W3"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
restart: always
networks:
- aguzhitou-dev-network
command: >
--default-authentication-plugin=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--innodb_buffer_pool_size=256M
# Redis 7 缓存
redis:
image: redis:7-alpine
container_name: aguzhitou-redis-dev
volumes:
- redis_dev_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
restart: always
networks:
- aguzhitou-dev-network
# 后端应用 - 开发模式(热重载)
app:
image: node:20-alpine
container_name: aguzhitou-app-dev
working_dir: /app
environment:
NODE_ENV: development
PORT: 3000
DATABASE_URL: mysql://root:1qazse42W3@mysql:3306/aguzhitou
REDIS_URL: redis://redis:6379
JWT_SECRET: aguzhitou-dev-secret-key-2024-development-only
JWT_EXPIRES_IN: 7d
LOG_LEVEL: debug
ports:
- "3000:3000"
volumes:
# 挂载源代码(关键:实现代码同步)
- ./src:/app/src:ro
- ./prisma:/app/prisma:ro
- ./package.json:/app/package.json:ro
- ./tsconfig.json:/app/tsconfig.json:ro
- ./logs:/app/logs
# 不覆盖 node_modules
- app_dev_node_modules:/app/node_modules
- app_dev_dist:/app/dist
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
networks:
- aguzhitou-dev-network
command: >
sh -c "
echo '[Dev] Installing system dependencies...' &&
apk add --no-cache openssl &&
echo '[Dev] Installing dependencies...' &&
npm install &&
npx prisma generate &&
echo '[Dev] Creating database tables...' &&
npx prisma db push --accept-data-loss &&
echo '[Dev] Starting with hot reload...' &&
npx tsx watch src/app.ts
"
# 使用 tsx watch 实现热重载
volumes:
mysql_dev_data:
driver: local
redis_dev_data:
driver: local
app_dev_node_modules:
driver: local
app_dev_dist:
driver: local
networks:
aguzhitou-dev-network:
driver: bridge

@ -0,0 +1,63 @@
@echo off
chcp 65001 >nul
echo ==============================================
echo A股智投分析平台 - Docker 快速启动
echo ==============================================
REM 检查 Docker 是否安装
docker --version >nul 2>&1
if errorlevel 1 (
echo ❌ Docker 未安装,请先安装 Docker
exit /b 1
)
docker-compose --version >nul 2>&1
if errorlevel 1 (
echo ❌ Docker Compose 未安装,请先安装 Docker Compose
exit /b 1
)
echo ✓ Docker 环境检查通过
REM 创建必要目录
if not exist logs mkdir logs
echo.
echo ==============================================
echo 启动服务...
echo ==============================================
REM 使用 .env.docker 覆盖默认配置
copy /Y .env.docker .env.production >nul
REM 构建并启动服务
docker-compose -f docker-compose.yml up --build -d
echo.
echo ==============================================
echo 等待服务启动...
echo ==============================================
timeout /t 10 /nobreak >nul
echo.
echo ==============================================
echo ✅ 所有服务启动成功!
echo ==============================================
echo.
echo 服务访问地址:
echo • API 接口: http://localhost:3000/api/v1
echo • 健康检查: http://localhost:3000/api/v1/health
echo • MySQL: localhost:3306
echo • Redis: localhost:6379
echo.
echo 默认账号:
echo • MySQL: root / 1qazse42W3
echo.
echo 常用命令:
echo • 查看日志: docker-compose logs -f app
echo • 停止服务: docker-compose down
echo • 完全重置: docker-compose down -v
echo.
echo ==============================================
pause

@ -0,0 +1,84 @@
#!/bin/bash
# ============================================
# A股智投分析平台 - Docker 快速启动脚本
# ============================================
set -e
echo "=============================================="
echo "A股智投分析平台 - Docker 一键启动"
echo "=============================================="
# 检查 Docker 是否安装
if ! command -v docker &> /dev/null; then
echo "❌ Docker 未安装,请先安装 Docker"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
echo "❌ Docker Compose 未安装,请先安装 Docker Compose"
exit 1
fi
echo "✓ Docker 环境检查通过"
# 创建必要目录
mkdir -p logs
echo ""
echo "=============================================="
echo "启动服务..."
echo "=============================================="
# 使用 .env.docker 覆盖默认配置
cp .env.docker .env.production
# 构建并启动服务
docker-compose -f docker-compose.yml up --build -d
echo ""
echo "=============================================="
echo "等待服务启动..."
echo "=============================================="
# 等待 MySQL 启动
for i in {1..30}; do
if docker-compose exec -T mysql mysqladmin ping -h localhost -u root -p1qazse42W3 --silent 2>/dev/null; then
echo "✓ MySQL 已启动"
break
fi
echo -n "."
sleep 2
done
# 等待应用启动
for i in {1..30}; do
if curl -s http://localhost:3000/api/v1/health | grep -q "healthy"; then
echo ""
echo "✓ 后端服务已启动"
break
fi
echo -n "."
sleep 2
done
echo ""
echo "=============================================="
echo "✅ 所有服务启动成功!"
echo "=============================================="
echo ""
echo "服务访问地址:"
echo " • API 接口: http://localhost:3000/api/v1"
echo " • 健康检查: http://localhost:3000/api/v1/health"
echo " • MySQL: localhost:3306"
echo " • Redis: localhost:6379"
echo ""
echo "默认账号:"
echo " • MySQL: root / 1qazse42W3"
echo ""
echo "常用命令:"
echo " • 查看日志: docker-compose logs -f app"
echo " • 停止服务: docker-compose down"
echo " • 完全重置: docker-compose down -v"
echo ""
echo "=============================================="

@ -0,0 +1,388 @@
-- ============================================
-- A股智投分析平台 - 数据库初始化脚本
-- 在Docker容器启动时自动执行
-- 分区范围2024年1月 至 2026年12月
-- ============================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- 使用已创建的数据库
USE aguzhitou;
-- ============================================
-- 1. 市场指数表 (数据量小,无需分区)
-- ============================================
CREATE TABLE IF NOT EXISTS `market_indices` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(50) NOT NULL COMMENT '指数名称',
`code` VARCHAR(10) NOT NULL COMMENT '指数代码',
`current` DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT '当前点数',
`change` DECIMAL(10, 2) NOT NULL DEFAULT 0 COMMENT '涨跌额',
`changePercent` DECIMAL(5, 2) NOT NULL DEFAULT 0 COMMENT '涨跌幅(%)',
`volume` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交量',
`turnover` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '成交额',
`sortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序',
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_code` (`code`),
UNIQUE KEY `uk_name` (`name`),
KEY `idx_sort` (`sortOrder`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='市场指数表';
-- ============================================
-- 2. 版块表 (数据量小,无需分区)
-- ============================================
CREATE TABLE IF NOT EXISTS `sectors` (
`id` VARCHAR(36) NOT NULL PRIMARY KEY DEFAULT (UUID()),
`name` VARCHAR(50) NOT NULL COMMENT '版块名称',
`code` VARCHAR(10) NOT NULL COMMENT '版块代码',
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_code` (`code`),
UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='版块表';
-- ============================================
-- 3. 股票表 (数据量小,无需分区)
-- ============================================
CREATE TABLE IF NOT EXISTS `stocks` (
`id` VARCHAR(36) NOT NULL PRIMARY KEY DEFAULT (UUID()),
`code` VARCHAR(10) NOT NULL COMMENT '股票代码',
`name` VARCHAR(50) NOT NULL COMMENT '股票名称',
`sector_code` VARCHAR(10) DEFAULT NULL COMMENT '所属版块代码',
`market_cap` BIGINT UNSIGNED DEFAULT NULL COMMENT '总市值',
`pe` DECIMAL(8, 2) DEFAULT NULL COMMENT '市盈率',
`pb` DECIMAL(8, 2) DEFAULT NULL COMMENT '市净率',
`updatedAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`createdAt` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_code` (`code`),
KEY `idx_sector` (`sector_code`),
CONSTRAINT `fk_stock_sector` FOREIGN KEY (`sector_code`) REFERENCES `sectors` (`code`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='股票表';
-- ============================================
-- 4. 股票行情表 - 热数据 (最近7天高频查询)
-- ============================================
CREATE TABLE IF NOT EXISTS `stock_quotes_hot` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`stock_code` VARCHAR(10) NOT NULL COMMENT '股票代码',
`price` DECIMAL(10, 2) NOT NULL COMMENT '当前价格',
`open` DECIMAL(10, 2) NOT NULL COMMENT '开盘价',
`high` DECIMAL(10, 2) NOT NULL COMMENT '最高价',
`low` DECIMAL(10, 2) NOT NULL COMMENT '最低价',
`pre_close` DECIMAL(10, 2) NOT NULL COMMENT '昨收价',
`volume` BIGINT UNSIGNED NOT NULL COMMENT '成交量',
`turnover` BIGINT UNSIGNED NOT NULL COMMENT '成交额',
`change_percent` DECIMAL(5, 2) NOT NULL COMMENT '涨跌幅(%)',
`turnover_rate` DECIMAL(5, 2) DEFAULT NULL COMMENT '换手率(%)',
`amplitude` DECIMAL(5, 2) DEFAULT NULL COMMENT '振幅(%)',
`quote_time` DATETIME NOT NULL COMMENT '行情时间',
KEY `idx_stock_time` (`stock_code`, `quote_time`),
KEY `idx_time` (`quote_time`),
KEY `idx_stock` (`stock_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='股票行情热数据表(最近7天)';
-- ============================================
-- 5. 股票行情表 - 历史数据 (2024-01 至 2026-12)
-- ============================================
CREATE TABLE IF NOT EXISTS `stock_quotes_history` (
`id` BIGINT UNSIGNED AUTO_INCREMENT,
`stock_code` VARCHAR(10) NOT NULL COMMENT '股票代码',
`price` DECIMAL(10, 2) NOT NULL COMMENT '当前价格',
`open` DECIMAL(10, 2) NOT NULL COMMENT '开盘价',
`high` DECIMAL(10, 2) NOT NULL COMMENT '最高价',
`low` DECIMAL(10, 2) NOT NULL COMMENT '最低价',
`pre_close` DECIMAL(10, 2) NOT NULL COMMENT '昨收价',
`volume` BIGINT UNSIGNED NOT NULL COMMENT '成交量',
`turnover` BIGINT UNSIGNED NOT NULL COMMENT '成交额',
`change_percent` DECIMAL(5, 2) NOT NULL COMMENT '涨跌幅(%)',
`turnover_rate` DECIMAL(5, 2) DEFAULT NULL COMMENT '换手率(%)',
`amplitude` DECIMAL(5, 2) DEFAULT NULL COMMENT '振幅(%)',
`quote_time` DATETIME NOT NULL COMMENT '行情时间',
PRIMARY KEY (`id`, `quote_time`),
KEY `idx_stock_time` (`stock_code`, `quote_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='股票行情历史表'
PARTITION BY RANGE COLUMNS(`quote_time`) (
PARTITION `p_2024_01` VALUES LESS THAN ('2024-02-01'),
PARTITION `p_2024_02` VALUES LESS THAN ('2024-03-01'),
PARTITION `p_2024_03` VALUES LESS THAN ('2024-04-01'),
PARTITION `p_2024_04` VALUES LESS THAN ('2024-05-01'),
PARTITION `p_2024_05` VALUES LESS THAN ('2024-06-01'),
PARTITION `p_2024_06` VALUES LESS THAN ('2024-07-01'),
PARTITION `p_2024_07` VALUES LESS THAN ('2024-08-01'),
PARTITION `p_2024_08` VALUES LESS THAN ('2024-09-01'),
PARTITION `p_2024_09` VALUES LESS THAN ('2024-10-01'),
PARTITION `p_2024_10` VALUES LESS THAN ('2024-11-01'),
PARTITION `p_2024_11` VALUES LESS THAN ('2024-12-01'),
PARTITION `p_2024_12` VALUES LESS THAN ('2025-01-01'),
PARTITION `p_2025_01` VALUES LESS THAN ('2025-02-01'),
PARTITION `p_2025_02` VALUES LESS THAN ('2025-03-01'),
PARTITION `p_2025_03` VALUES LESS THAN ('2025-04-01'),
PARTITION `p_2025_04` VALUES LESS THAN ('2025-05-01'),
PARTITION `p_2025_05` VALUES LESS THAN ('2025-06-01'),
PARTITION `p_2025_06` VALUES LESS THAN ('2025-07-01'),
PARTITION `p_2025_07` VALUES LESS THAN ('2025-08-01'),
PARTITION `p_2025_08` VALUES LESS THAN ('2025-09-01'),
PARTITION `p_2025_09` VALUES LESS THAN ('2025-10-01'),
PARTITION `p_2025_10` VALUES LESS THAN ('2025-11-01'),
PARTITION `p_2025_11` VALUES LESS THAN ('2025-12-01'),
PARTITION `p_2025_12` VALUES LESS THAN ('2026-01-01'),
PARTITION `p_2026_01` VALUES LESS THAN ('2026-02-01'),
PARTITION `p_2026_02` VALUES LESS THAN ('2026-03-01'),
PARTITION `p_2026_03` VALUES LESS THAN ('2026-04-01'),
PARTITION `p_2026_04` VALUES LESS THAN ('2026-05-01'),
PARTITION `p_2026_05` VALUES LESS THAN ('2026-06-01'),
PARTITION `p_2026_06` VALUES LESS THAN ('2026-07-01'),
PARTITION `p_2026_07` VALUES LESS THAN ('2026-08-01'),
PARTITION `p_2026_08` VALUES LESS THAN ('2026-09-01'),
PARTITION `p_2026_09` VALUES LESS THAN ('2026-10-01'),
PARTITION `p_2026_10` VALUES LESS THAN ('2026-11-01'),
PARTITION `p_2026_11` VALUES LESS THAN ('2026-12-01'),
PARTITION `p_2026_12` VALUES LESS THAN ('2027-01-01'),
PARTITION `p_future` VALUES LESS THAN (MAXVALUE)
);
-- ============================================
-- 6. 股票K线表
-- ============================================
CREATE TABLE IF NOT EXISTS `stock_klines` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`stock_code` VARCHAR(10) NOT NULL COMMENT '股票代码',
`period` VARCHAR(10) NOT NULL COMMENT '周期:day/week/month',
`date` DATE NOT NULL COMMENT '日期',
`open` DECIMAL(10, 2) NOT NULL COMMENT '开盘价',
`high` DECIMAL(10, 2) NOT NULL COMMENT '最高价',
`low` DECIMAL(10, 2) NOT NULL COMMENT '最低价',
`close` DECIMAL(10, 2) NOT NULL COMMENT '收盘价',
`volume` BIGINT UNSIGNED NOT NULL COMMENT '成交量',
`ma5` DECIMAL(10, 2) DEFAULT NULL COMMENT 'MA5',
`ma10` DECIMAL(10, 2) DEFAULT NULL COMMENT 'MA10',
`ma20` DECIMAL(10, 2) DEFAULT NULL COMMENT 'MA20',
`ma30` DECIMAL(10, 2) DEFAULT NULL COMMENT 'MA30',
`ma60` DECIMAL(10, 2) DEFAULT NULL COMMENT 'MA60',
UNIQUE KEY `uk_stock_period_date` (`stock_code`, `period`, `date`),
KEY `idx_stock` (`stock_code`),
KEY `idx_date` (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='股票K线表';
-- ============================================
-- 7. 版块行情表 (2024-01 至 2026-12)
-- ============================================
CREATE TABLE IF NOT EXISTS `sector_quotes` (
`id` INT UNSIGNED AUTO_INCREMENT,
`sector_code` VARCHAR(10) NOT NULL COMMENT '版块代码',
`current` DECIMAL(10, 2) NOT NULL COMMENT '当前点数',
`change` DECIMAL(10, 2) NOT NULL COMMENT '涨跌额',
`changePercent` DECIMAL(5, 2) NOT NULL COMMENT '涨跌幅(%)',
`volume` BIGINT UNSIGNED NOT NULL COMMENT '成交量',
`turnover` BIGINT UNSIGNED NOT NULL COMMENT '成交额',
`momentumScore` DECIMAL(5, 2) NOT NULL DEFAULT 50 COMMENT '动量分数',
`rank` INT NOT NULL DEFAULT 0 COMMENT '当前排名',
`previous_rank` INT NOT NULL DEFAULT 0 COMMENT '昨日排名',
`quote_time` DATETIME NOT NULL COMMENT '行情时间',
PRIMARY KEY (`id`, `quote_time`),
KEY `idx_sector_time` (`sector_code`, `quote_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='版块行情表'
PARTITION BY RANGE COLUMNS(`quote_time`) (
PARTITION `p_sector_2024_01` VALUES LESS THAN ('2024-02-01'),
PARTITION `p_sector_2024_02` VALUES LESS THAN ('2024-03-01'),
PARTITION `p_sector_2024_03` VALUES LESS THAN ('2024-04-01'),
PARTITION `p_sector_2024_04` VALUES LESS THAN ('2024-05-01'),
PARTITION `p_sector_2024_05` VALUES LESS THAN ('2024-06-01'),
PARTITION `p_sector_2024_06` VALUES LESS THAN ('2024-07-01'),
PARTITION `p_sector_2024_07` VALUES LESS THAN ('2024-08-01'),
PARTITION `p_sector_2024_08` VALUES LESS THAN ('2024-09-01'),
PARTITION `p_sector_2024_09` VALUES LESS THAN ('2024-10-01'),
PARTITION `p_sector_2024_10` VALUES LESS THAN ('2024-11-01'),
PARTITION `p_sector_2024_11` VALUES LESS THAN ('2024-12-01'),
PARTITION `p_sector_2024_12` VALUES LESS THAN ('2025-01-01'),
PARTITION `p_sector_2025_01` VALUES LESS THAN ('2025-02-01'),
PARTITION `p_sector_2025_02` VALUES LESS THAN ('2025-03-01'),
PARTITION `p_sector_2025_03` VALUES LESS THAN ('2025-04-01'),
PARTITION `p_sector_2025_04` VALUES LESS THAN ('2025-05-01'),
PARTITION `p_sector_2025_05` VALUES LESS THAN ('2025-06-01'),
PARTITION `p_sector_2025_06` VALUES LESS THAN ('2025-07-01'),
PARTITION `p_sector_2025_07` VALUES LESS THAN ('2025-08-01'),
PARTITION `p_sector_2025_08` VALUES LESS THAN ('2025-09-01'),
PARTITION `p_sector_2025_09` VALUES LESS THAN ('2025-10-01'),
PARTITION `p_sector_2025_10` VALUES LESS THAN ('2025-11-01'),
PARTITION `p_sector_2025_11` VALUES LESS THAN ('2025-12-01'),
PARTITION `p_sector_2025_12` VALUES LESS THAN ('2026-01-01'),
PARTITION `p_sector_2026_01` VALUES LESS THAN ('2026-02-01'),
PARTITION `p_sector_2026_02` VALUES LESS THAN ('2026-03-01'),
PARTITION `p_sector_2026_03` VALUES LESS THAN ('2026-04-01'),
PARTITION `p_sector_2026_04` VALUES LESS THAN ('2026-05-01'),
PARTITION `p_sector_2026_05` VALUES LESS THAN ('2026-06-01'),
PARTITION `p_sector_2026_06` VALUES LESS THAN ('2026-07-01'),
PARTITION `p_sector_2026_07` VALUES LESS THAN ('2026-08-01'),
PARTITION `p_sector_2026_08` VALUES LESS THAN ('2026-09-01'),
PARTITION `p_sector_2026_09` VALUES LESS THAN ('2026-10-01'),
PARTITION `p_sector_2026_10` VALUES LESS THAN ('2026-11-01'),
PARTITION `p_sector_2026_11` VALUES LESS THAN ('2026-12-01'),
PARTITION `p_sector_2026_12` VALUES LESS THAN ('2027-01-01'),
PARTITION `p_sector_future` VALUES LESS THAN (MAXVALUE)
);
-- ============================================
-- 8. 版块K线表
-- ============================================
CREATE TABLE IF NOT EXISTS `sector_klines` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`sector_code` VARCHAR(10) NOT NULL COMMENT '版块代码',
`period` VARCHAR(10) NOT NULL COMMENT '周期:day/week/month',
`date` DATE NOT NULL COMMENT '日期',
`open` DECIMAL(10, 2) NOT NULL COMMENT '开盘价',
`high` DECIMAL(10, 2) NOT NULL COMMENT '最高价',
`low` DECIMAL(10, 2) NOT NULL COMMENT '最低价',
`close` DECIMAL(10, 2) NOT NULL COMMENT '收盘价',
`volume` BIGINT UNSIGNED NOT NULL COMMENT '成交量',
UNIQUE KEY `uk_sector_period_date` (`sector_code`, `period`, `date`),
KEY `idx_sector` (`sector_code`),
KEY `idx_date` (`date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='版块K线表';
-- ============================================
-- 9. 用户表
-- ============================================
CREATE TABLE IF NOT EXISTS `users` (
`id` VARCHAR(36) NOT NULL PRIMARY KEY DEFAULT (UUID()),
`username` VARCHAR(20) NOT NULL COMMENT '用户名',
`email` VARCHAR(100) NOT NULL COMMENT '邮箱',
`password` VARCHAR(255) NOT NULL COMMENT '加密密码',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_username` (`username`),
UNIQUE KEY `uk_email` (`email`),
KEY `idx_created` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
-- ============================================
-- 10. 用户自选股表
-- ============================================
CREATE TABLE IF NOT EXISTS `user_favorites` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`user_id` VARCHAR(36) NOT NULL COMMENT '用户ID',
`stock_code` VARCHAR(10) NOT NULL COMMENT '股票代码',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `uk_user_stock` (`user_id`, `stock_code`),
KEY `idx_user` (`user_id`),
KEY `idx_stock` (`stock_code`),
CONSTRAINT `fk_fav_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户自选股表';
-- ============================================
-- 11. 新高新低股票记录表 (2024-01 至 2026-12)
-- ============================================
CREATE TABLE IF NOT EXISTS `high_low_stocks` (
`id` INT UNSIGNED AUTO_INCREMENT,
`stock_code` VARCHAR(10) NOT NULL COMMENT '股票代码',
`type` VARCHAR(10) NOT NULL COMMENT '类型:high/low',
`price` DECIMAL(10, 2) NOT NULL COMMENT '价格',
`date` DATE NOT NULL COMMENT '日期',
`days_to_highlow` INT NOT NULL COMMENT '距离新高/低天数',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`, `date`),
KEY `idx_stock` (`stock_code`),
KEY `idx_type_date` (`type`, `date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='新高新低股票记录表'
PARTITION BY RANGE COLUMNS(`date`) (
PARTITION `p_hl_2024_01` VALUES LESS THAN ('2024-02-01'),
PARTITION `p_hl_2024_02` VALUES LESS THAN ('2024-03-01'),
PARTITION `p_hl_2024_03` VALUES LESS THAN ('2024-04-01'),
PARTITION `p_hl_2024_04` VALUES LESS THAN ('2024-05-01'),
PARTITION `p_hl_2024_05` VALUES LESS THAN ('2024-06-01'),
PARTITION `p_hl_2024_06` VALUES LESS THAN ('2024-07-01'),
PARTITION `p_hl_2024_07` VALUES LESS THAN ('2024-08-01'),
PARTITION `p_hl_2024_08` VALUES LESS THAN ('2024-09-01'),
PARTITION `p_hl_2024_09` VALUES LESS THAN ('2024-10-01'),
PARTITION `p_hl_2024_10` VALUES LESS THAN ('2024-11-01'),
PARTITION `p_hl_2024_11` VALUES LESS THAN ('2024-12-01'),
PARTITION `p_hl_2024_12` VALUES LESS THAN ('2025-01-01'),
PARTITION `p_hl_2025_01` VALUES LESS THAN ('2025-02-01'),
PARTITION `p_hl_2025_02` VALUES LESS THAN ('2025-03-01'),
PARTITION `p_hl_2025_03` VALUES LESS THAN ('2025-04-01'),
PARTITION `p_hl_2025_04` VALUES LESS THAN ('2025-05-01'),
PARTITION `p_hl_2025_05` VALUES LESS THAN ('2025-06-01'),
PARTITION `p_hl_2025_06` VALUES LESS THAN ('2025-07-01'),
PARTITION `p_hl_2025_07` VALUES LESS THAN ('2025-08-01'),
PARTITION `p_hl_2025_08` VALUES LESS THAN ('2025-09-01'),
PARTITION `p_hl_2025_09` VALUES LESS THAN ('2025-10-01'),
PARTITION `p_hl_2025_10` VALUES LESS THAN ('2025-11-01'),
PARTITION `p_hl_2025_11` VALUES LESS THAN ('2025-12-01'),
PARTITION `p_hl_2025_12` VALUES LESS THAN ('2026-01-01'),
PARTITION `p_hl_2026_01` VALUES LESS THAN ('2026-02-01'),
PARTITION `p_hl_2026_02` VALUES LESS THAN ('2026-03-01'),
PARTITION `p_hl_2026_03` VALUES LESS THAN ('2026-04-01'),
PARTITION `p_hl_2026_04` VALUES LESS THAN ('2026-05-01'),
PARTITION `p_hl_2026_05` VALUES LESS THAN ('2026-06-01'),
PARTITION `p_hl_2026_06` VALUES LESS THAN ('2026-07-01'),
PARTITION `p_hl_2026_07` VALUES LESS THAN ('2026-08-01'),
PARTITION `p_hl_2026_08` VALUES LESS THAN ('2026-09-01'),
PARTITION `p_hl_2026_09` VALUES LESS THAN ('2026-10-01'),
PARTITION `p_hl_2026_10` VALUES LESS THAN ('2026-11-01'),
PARTITION `p_hl_2026_11` VALUES LESS THAN ('2026-12-01'),
PARTITION `p_hl_2026_12` VALUES LESS THAN ('2027-01-01'),
PARTITION `p_hl_future` VALUES LESS THAN (MAXVALUE)
);
-- ============================================
-- 12. 动量股票推荐表 (2024-01 至 2026-12)
-- ============================================
CREATE TABLE IF NOT EXISTS `momentum_stocks` (
`id` INT UNSIGNED AUTO_INCREMENT,
`stock_code` VARCHAR(10) NOT NULL COMMENT '股票代码',
`momentum_score` DECIMAL(5, 2) NOT NULL COMMENT '动量分数',
`tags` VARCHAR(500) DEFAULT NULL COMMENT '标签JSON数组',
`volume_ratio` DECIMAL(5, 2) NOT NULL COMMENT '量比',
`break_through` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否突破',
`date` DATE NOT NULL COMMENT '日期',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`, `date`),
KEY `idx_stock` (`stock_code`),
KEY `idx_date_score` (`date`, `momentum_score`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='动量股票推荐表'
PARTITION BY RANGE COLUMNS(`date`) (
PARTITION `p_ms_2024_01` VALUES LESS THAN ('2024-02-01'),
PARTITION `p_ms_2024_02` VALUES LESS THAN ('2024-03-01'),
PARTITION `p_ms_2024_03` VALUES LESS THAN ('2024-04-01'),
PARTITION `p_ms_2024_04` VALUES LESS THAN ('2024-05-01'),
PARTITION `p_ms_2024_05` VALUES LESS THAN ('2024-06-01'),
PARTITION `p_ms_2024_06` VALUES LESS THAN ('2024-07-01'),
PARTITION `p_ms_2024_07` VALUES LESS THAN ('2024-08-01'),
PARTITION `p_ms_2024_08` VALUES LESS THAN ('2024-09-01'),
PARTITION `p_ms_2024_09` VALUES LESS THAN ('2024-10-01'),
PARTITION `p_ms_2024_10` VALUES LESS THAN ('2024-11-01'),
PARTITION `p_ms_2024_11` VALUES LESS THAN ('2024-12-01'),
PARTITION `p_ms_2024_12` VALUES LESS THAN ('2025-01-01'),
PARTITION `p_ms_2025_01` VALUES LESS THAN ('2025-02-01'),
PARTITION `p_ms_2025_02` VALUES LESS THAN ('2025-03-01'),
PARTITION `p_ms_2025_03` VALUES LESS THAN ('2025-04-01'),
PARTITION `p_ms_2025_04` VALUES LESS THAN ('2025-05-01'),
PARTITION `p_ms_2025_05` VALUES LESS THAN ('2025-06-01'),
PARTITION `p_ms_2025_06` VALUES LESS THAN ('2025-07-01'),
PARTITION `p_ms_2025_07` VALUES LESS THAN ('2025-08-01'),
PARTITION `p_ms_2025_08` VALUES LESS THAN ('2025-09-01'),
PARTITION `p_ms_2025_09` VALUES LESS THAN ('2025-10-01'),
PARTITION `p_ms_2025_10` VALUES LESS THAN ('2025-11-01'),
PARTITION `p_ms_2025_11` VALUES LESS THAN ('2025-12-01'),
PARTITION `p_ms_2025_12` VALUES LESS THAN ('2026-01-01'),
PARTITION `p_ms_2026_01` VALUES LESS THAN ('2026-02-01'),
PARTITION `p_ms_2026_02` VALUES LESS THAN ('2026-03-01'),
PARTITION `p_ms_2026_03` VALUES LESS THAN ('2026-04-01'),
PARTITION `p_ms_2026_04` VALUES LESS THAN ('2026-05-01'),
PARTITION `p_ms_2026_05` VALUES LESS THAN ('2026-06-01'),
PARTITION `p_ms_2026_06` VALUES LESS THAN ('2026-07-01'),
PARTITION `p_ms_2026_07` VALUES LESS THAN ('2026-08-01'),
PARTITION `p_ms_2026_08` VALUES LESS THAN ('2026-09-01'),
PARTITION `p_ms_2026_09` VALUES LESS THAN ('2026-10-01'),
PARTITION `p_ms_2026_10` VALUES LESS THAN ('2026-11-01'),
PARTITION `p_ms_2026_11` VALUES LESS THAN ('2026-12-01'),
PARTITION `p_ms_2026_12` VALUES LESS THAN ('2027-01-01'),
PARTITION `p_ms_future` VALUES LESS THAN (MAXVALUE)
);
SET FOREIGN_KEY_CHECKS = 1;
-- 创建完成标记
SELECT 'Database initialization completed successfully' AS status;

@ -0,0 +1,146 @@
-- ============================================
-- A股智投分析平台 - 种子数据
-- 包含基础版块、股票、市场指数数据
-- ============================================
USE aguzhitou;
-- ============================================
-- 1. 初始化版块数据
-- ============================================
INSERT INTO `sectors` (`name`, `code`) VALUES
('半导体', '880491'),
('新能源', '880952'),
('医药生物', '880122'),
('白酒', '880381'),
('银行', '880471'),
('证券', '880472'),
('保险', '880473'),
('房地产', '880482'),
('汽车', '880391'),
('电子', '880494'),
('计算机', '880498'),
('通信', '880495'),
('传媒', '880499'),
('军工', '880954'),
('有色金属', '880324'),
('钢铁', '880318'),
('煤炭', '880952'),
('化工', '880336'),
('建筑材料', '880344'),
('机械设备', '880440')
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
-- ============================================
-- 2. 初始化股票数据
-- ============================================
INSERT INTO `stocks` (`code`, `name`, `sector_code`, `market_cap`, `pe`, `pb`) VALUES
-- 银行板块
('600000', '浦发银行', '880471', 350000000000, 4.5, 0.45),
('600016', '民生银行', '880471', 280000000000, 4.2, 0.42),
('600036', '招商银行', '880471', 850000000000, 6.8, 1.05),
('601166', '兴业银行', '880471', 420000000000, 4.8, 0.58),
('601288', '农业银行', '880471', 1200000000000, 4.5, 0.55),
('601398', '工商银行', '880471', 1800000000000, 4.8, 0.58),
('601988', '中国银行', '880471', 950000000000, 4.6, 0.52),
-- 白酒板块
('000568', '泸州老窖', '880381', 320000000000, 25.5, 6.8),
('000858', '五粮液', '880381', 650000000000, 22.3, 5.8),
('600519', '贵州茅台', '880381', 2100000000000, 32.5, 9.8),
('600809', '山西汾酒', '880381', 280000000000, 28.5, 8.5),
-- 半导体板块
('688008', '澜起科技', '880491', 85000000000, 65.2, 8.5),
('688012', '中微公司', '880491', 120000000000, 78.5, 9.2),
('688036', '传音控股', '880491', 95000000000, 22.5, 6.8),
('688981', '中芯国际', '880491', 420000000000, 85.5, 3.8),
-- 新能源板块
('601012', '隆基绿能', '880952', 180000000000, 15.5, 2.8),
('002594', '比亚迪', '880952', 650000000000, 32.5, 5.8),
('300274', '阳光电源', '880952', 120000000000, 28.5, 8.5),
('603659', '璞泰来', '880952', 45000000000, 25.5, 4.2),
-- 医药生物板块
('600196', '复星医药', '880122', 68000000000, 22.5, 2.1),
('600276', '恒瑞医药', '880122', 380000000000, 65.8, 8.5),
('603259', '药明康德', '880122', 280000000000, 35.5, 5.8),
-- 证券板块
('600030', '中信证券', '880472', 320000000000, 18.5, 1.35),
('600837', '海通证券', '880472', 120000000000, 22.5, 0.95),
('601688', '华泰证券', '880472', 85000000000, 15.8, 0.88),
-- 汽车板块
('600104', '上汽集团', '880391', 180000000000, 12.5, 0.85),
('601633', '长城汽车', '880391', 220000000000, 18.5, 2.8),
('601238', '广汽集团', '880391', 95000000000, 12.8, 0.95),
-- 电子板块
('000725', '京东方A', '880494', 165000000000, 45.5, 1.35),
('002415', '海康威视', '880494', 320000000000, 22.5, 4.8),
('601138', '工业富联', '880494', 280000000000, 15.5, 2.1),
-- 有色金属板块
('601899', '紫金矿业', '880324', 320000000000, 15.8, 3.2),
('603993', '洛阳钼业', '880324', 120000000000, 18.5, 2.5),
-- 化工板块
('600309', '万华化学', '880336', 280000000000, 18.5, 3.8),
('002001', '新和成', '880336', 65000000000, 22.5, 2.8)
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`),
`sector_code` = VALUES(`sector_code`),
`market_cap` = VALUES(`market_cap`),
`pe` = VALUES(`pe`),
`pb` = VALUES(`pb`);
-- ============================================
-- 3. 初始化市场指数数据
-- ============================================
INSERT INTO `market_indices` (`name`, `code`, `current`, `change`, `changePercent`, `volume`, `turnover`, `sortOrder`) VALUES
('上证指数', '000001', 3050.32, 15.23, 0.50, 450000000, 4200000000, 1),
('深证成指', '399001', 9850.15, -25.60, -0.26, 520000000, 5100000000, 2),
('创业板指', '399006', 1950.45, 8.75, 0.45, 180000000, 2100000000, 3),
('科创50', '000688', 850.32, -5.23, -0.61, 65000000, 950000000, 4)
ON DUPLICATE KEY UPDATE
`current` = VALUES(`current`),
`change` = VALUES(`change`),
`changePercent` = VALUES(`changePercent`),
`volume` = VALUES(`volume`),
`turnover` = VALUES(`turnover`);
-- ============================================
-- 4. 初始化版块行情数据(当前)
-- ============================================
INSERT INTO `sector_quotes` (`sector_code`, `current`, `change`, `changePercent`, `volume`, `turnover`, `momentumScore`, `rank`, `previous_rank`, `quote_time`) VALUES
('880491', 2850.50, 45.25, 1.61, 850000000, 12500000000, 85.5, 1, 3, NOW()),
('880952', 3250.80, 38.50, 1.20, 1200000000, 18500000000, 82.3, 2, 1, NOW()),
('880122', 2150.35, 22.15, 1.04, 650000000, 9800000000, 78.5, 3, 2, NOW()),
('880381', 4850.60, 35.80, 0.74, 450000000, 7200000000, 75.2, 4, 4, NOW()),
('880472', 1250.25, 8.50, 0.68, 380000000, 5200000000, 72.8, 5, 6, NOW()),
('880471', 1850.40, 10.20, 0.55, 520000000, 6800000000, 68.5, 6, 5, NOW()),
('880391', 2250.75, 12.35, 0.55, 480000000, 6500000000, 65.3, 7, 8, NOW()),
('880494', 1650.90, 7.80, 0.47, 420000000, 5800000000, 62.5, 8, 7, NOW()),
('880498', 2850.15, 12.50, 0.44, 380000000, 5200000000, 58.2, 9, 10, NOW()),
('880324', 1950.45, 6.25, 0.32, 320000000, 4800000000, 55.8, 10, 9, NOW()),
('880473', 1450.60, 3.80, 0.26, 280000000, 3800000000, 52.5, 11, 11, NOW()),
('880495', 1250.35, 2.15, 0.17, 250000000, 3500000000, 48.2, 12, 13, NOW()),
('880954', 1850.80, 2.80, 0.15, 220000000, 3200000000, 45.8, 13, 12, NOW()),
('880336', 2150.25, 2.50, 0.12, 290000000, 4200000000, 42.5, 14, 15, NOW()),
('880440', 1750.90, 1.85, 0.11, 260000000, 3600000000, 38.2, 15, 14, NOW()),
('880318', 850.45, 0.75, 0.09, 180000000, 2200000000, 35.8, 16, 16, NOW()),
('880344', 950.60, 0.60, 0.06, 150000000, 1800000000, 32.5, 17, 18, NOW()),
('880482', 1250.35, -2.50, -0.20, 200000000, 2800000000, 28.2, 18, 17, NOW()),
('880499', 850.25, -2.15, -0.25, 120000000, 1500000000, 25.8, 19, 19, NOW()),
('880952', 450.80, -3.25, -0.72, 95000000, 1200000000, 18.5, 20, 20, NOW());
-- ============================================
-- 5. 初始化完成
-- ============================================
SELECT
(SELECT COUNT(*) FROM sectors) AS sector_count,
(SELECT COUNT(*) FROM stocks) AS stock_count,
(SELECT COUNT(*) FROM market_indices) AS index_count,
'Seed data loaded successfully' AS status;

@ -0,0 +1,166 @@
-- ============================================
-- A股智投分析平台 - 分区维护工具
-- 包含分区查询、添加、删除等操作
-- ============================================
USE aguzhitou;
-- ============================================
-- 1. 查看所有分区表信息
-- ============================================
DROP VIEW IF EXISTS `partition_info`;
CREATE VIEW `partition_info` AS
SELECT
TABLE_NAME,
PARTITION_NAME,
PARTITION_METHOD,
PARTITION_EXPRESSION,
TABLE_ROWS,
ROUND(DATA_LENGTH / 1024 / 1024, 2) AS DATA_SIZE_MB,
ROUND(INDEX_LENGTH / 1024 / 1024, 2) AS INDEX_SIZE_MB,
ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS TOTAL_SIZE_MB
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'aguzhitou'
AND PARTITION_NAME IS NOT NULL
ORDER BY TABLE_NAME, PARTITION_NAME;
-- ============================================
-- 2. 创建添加新分区的存储过程
-- ============================================
DELIMITER $$
DROP PROCEDURE IF EXISTS `AddNewPartition`$$
CREATE PROCEDURE `AddNewPartition`(
IN p_table_name VARCHAR(64),
IN p_partition_name VARCHAR(64),
IN p_less_than_date VARCHAR(10)
)
BEGIN
DECLARE v_sql VARCHAR(500);
SET v_sql = CONCAT(
'ALTER TABLE ', p_table_name,
' ADD PARTITION (PARTITION ', p_partition_name,
' VALUES LESS THAN (\'', p_less_than_date, '\'))'
);
SET @sql = v_sql;
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT CONCAT('Partition ', p_partition_name, ' added to ', p_table_name) AS result;
END$$
-- ============================================
-- 3. 创建删除旧分区的存储过程
-- ============================================
DROP PROCEDURE IF EXISTS `DropOldPartition`$$
CREATE PROCEDURE `DropOldPartition`(
IN p_table_name VARCHAR(64),
IN p_partition_name VARCHAR(64)
)
BEGIN
DECLARE v_sql VARCHAR(500);
SET v_sql = CONCAT(
'ALTER TABLE ', p_table_name,
' DROP PARTITION ', p_partition_name
);
SET @sql = v_sql;
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SELECT CONCAT('Partition ', p_partition_name, ' dropped from ', p_table_name) AS result;
END$$
-- ============================================
-- 4. 创建自动添加未来分区的存储过程
-- ============================================
DROP PROCEDURE IF EXISTS `AddFuturePartitions`$$
CREATE PROCEDURE `AddFuturePartitions`()
BEGIN
DECLARE v_next_month DATE;
DECLARE v_year INT;
DECLARE v_month INT;
DECLARE v_partition_name VARCHAR(20);
DECLARE v_less_than VARCHAR(10);
-- 计算下个月
SET v_next_month = DATE_ADD(DATE_FORMAT(CURDATE(), '%Y-%m-01'), INTERVAL 1 MONTH);
SET v_year = YEAR(v_next_month);
SET v_month = MONTH(v_next_month);
SET v_partition_name = CONCAT('p_', v_year, '_', LPAD(v_month, 2, '0'));
SET v_less_than = DATE_FORMAT(DATE_ADD(v_next_month, INTERVAL 1 MONTH), '%Y-%m-%d');
-- 为股票行情历史表添加分区
IF NOT EXISTS (
SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'aguzhitou'
AND TABLE_NAME = 'stock_quotes_history'
AND PARTITION_NAME = v_partition_name
) THEN
CALL AddNewPartition('stock_quotes_history', v_partition_name, v_less_than);
END IF;
-- 为版块行情表添加分区
SET v_partition_name = CONCAT('p_sector_', v_year, '_', LPAD(v_month, 2, '0'));
IF NOT EXISTS (
SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'aguzhitou'
AND TABLE_NAME = 'sector_quotes'
AND PARTITION_NAME = v_partition_name
) THEN
CALL AddNewPartition('sector_quotes', v_partition_name, v_less_than);
END IF;
-- 为新高新低表添加分区
SET v_partition_name = CONCAT('p_hl_', v_year, '_', LPAD(v_month, 2, '0'));
IF NOT EXISTS (
SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'aguzhitou'
AND TABLE_NAME = 'high_low_stocks'
AND PARTITION_NAME = v_partition_name
) THEN
CALL AddNewPartition('high_low_stocks', v_partition_name, v_less_than);
END IF;
-- 为动量股票表添加分区
SET v_partition_name = CONCAT('p_ms_', v_year, '_', LPAD(v_month, 2, '0'));
IF NOT EXISTS (
SELECT 1 FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'aguzhitou'
AND TABLE_NAME = 'momentum_stocks'
AND PARTITION_NAME = v_partition_name
) THEN
CALL AddNewPartition('momentum_stocks', v_partition_name, v_less_than);
END IF;
SELECT CONCAT('Future partitions for ', DATE_FORMAT(v_next_month, '%Y-%m'), ' added successfully') AS result;
END$$
DELIMITER ;
-- ============================================
-- 5. 创建分区统计视图
-- ============================================
DROP VIEW IF EXISTS `partition_summary`;
CREATE VIEW `partition_summary` AS
SELECT
TABLE_NAME,
COUNT(*) AS partition_count,
SUM(TABLE_ROWS) AS total_rows,
ROUND(SUM(DATA_LENGTH) / 1024 / 1024, 2) AS total_data_mb,
ROUND(SUM(INDEX_LENGTH) / 1024 / 1024, 2) AS total_index_mb,
ROUND(SUM(DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 2) AS total_size_mb
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'aguzhitou'
AND PARTITION_NAME IS NOT NULL
GROUP BY TABLE_NAME
ORDER BY total_size_mb DESC;
-- ============================================
-- 6. 显示当前分区状态
-- ============================================
SELECT '分区表创建完成' AS status;
SELECT '当前分区统计:' AS info;
SELECT * FROM partition_summary;
SELECT '分区详细信息:' AS info;
SELECT * FROM partition_info;

@ -0,0 +1,208 @@
#!/usr/bin/env node
/**
* Docker 部署验证脚本
* 检查所有服务是否正常运行
*/
const http = require('http');
const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);
const DELAY = ms => new Promise(resolve => setTimeout(resolve, ms));
async function checkDocker() {
console.log('[1] 检查 Docker 服务...');
try {
await execPromise('docker ps');
console.log(' ✓ Docker 运行正常');
return true;
} catch (error) {
console.log(' ✗ Docker 未运行');
return false;
}
}
async function checkContainers() {
console.log('[2] 检查容器状态...');
try {
const { stdout } = await execPromise('docker-compose ps');
console.log(stdout);
if (stdout.includes('aguzhitou-mysql') && stdout.includes('aguzhitou-redis') && stdout.includes('aguzhitou-app')) {
console.log(' ✓ 所有容器已启动');
return true;
} else {
console.log(' ✗ 部分容器未启动');
return false;
}
} catch (error) {
console.log(' ✗ 无法获取容器状态');
return false;
}
}
async function checkAPI() {
console.log('[3] 检查 API 服务...');
return new Promise((resolve) => {
const req = http.get('http://localhost:3000/api/v1/health', (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const result = JSON.parse(data);
if (result.code === 200) {
console.log(' ✓ API 服务正常');
console.log(` ✓ 服务时间: ${result.data.timestamp}`);
resolve(true);
} else {
console.log(' ✗ API 返回异常');
resolve(false);
}
} catch (e) {
console.log(' ✗ API 响应解析失败');
resolve(false);
}
});
});
req.on('error', (err) => {
console.log(' ✗ 无法连接 API');
resolve(false);
});
req.setTimeout(5000, () => {
console.log(' ✗ API 连接超时');
req.destroy();
resolve(false);
});
});
}
async function checkMySQL() {
console.log('[4] 检查 MySQL 数据库...');
try {
const { stdout } = await execPromise(
'docker-compose exec -T mysql mysql -u root -p1qazse42W3 -e "SELECT COUNT(*) as tables FROM information_schema.tables WHERE table_schema=\'aguzhitou\';"'
);
const match = stdout.match(/(\d+)/);
if (match && parseInt(match[1]) >= 12) {
console.log(` ✓ MySQL 数据库正常 (${match[1]} 张表)`);
return true;
} else {
console.log(' ✗ 数据库表数量异常');
return false;
}
} catch (error) {
console.log(' ✗ 无法连接 MySQL');
return false;
}
}
async function checkPartitions() {
console.log('[5] 检查数据库分区...');
try {
const { stdout } = await execPromise(
'docker-compose exec -T mysql mysql -u root -p1qazse42W3 -e "SELECT COUNT(DISTINCT table_name) as tables, COUNT(*) as partitions FROM information_schema.partitions WHERE table_schema=\'aguzhitou\' AND partition_name IS NOT NULL;"'
);
const match = stdout.match(/(\d+)\s*\|\s*(\d+)/);
if (match) {
console.log(` ✓ 分区表: ${match[1]}`);
console.log(` ✓ 总分区: ${match[2]}`);
return true;
}
return false;
} catch (error) {
console.log(' ✗ 无法获取分区信息');
return false;
}
}
async function checkRedis() {
console.log('[6] 检查 Redis 缓存...');
try {
const { stdout } = await execPromise('docker-compose exec -T redis redis-cli ping');
if (stdout.includes('PONG')) {
console.log(' ✓ Redis 运行正常');
return true;
}
console.log(' ✗ Redis 响应异常');
return false;
} catch (error) {
console.log(' ✗ 无法连接 Redis');
return false;
}
}
async function testAPIEndpoints() {
console.log('[7] 测试 API 端点...');
const endpoints = [
{ path: '/api/v1/market/indices', name: '市场指数' },
{ path: '/api/v1/sectors', name: '版块列表' },
{ path: '/api/v1/stocks/search?keyword=茅台', name: '股票搜索' }
];
let success = 0;
for (const endpoint of endpoints) {
try {
const result = await new Promise((resolve) => {
http.get(`http://localhost:3000${endpoint.path}`, (res) => {
resolve(res.statusCode === 200);
}).on('error', () => resolve(false));
});
if (result) {
console.log(`${endpoint.name}`);
success++;
} else {
console.log(`${endpoint.name}`);
}
} catch (e) {
console.log(`${endpoint.name}`);
}
}
return success === endpoints.length;
}
async function main() {
console.log('='.repeat(60));
console.log('A股智投分析平台 - Docker 部署验证');
console.log('='.repeat(60));
console.log();
const results = {
docker: await checkDocker(),
containers: await checkContainers(),
api: await checkAPI(),
mysql: await checkMySQL(),
partitions: await checkPartitions(),
redis: await checkRedis(),
endpoints: await testAPIEndpoints()
};
console.log();
console.log('='.repeat(60));
const allPassed = Object.values(results).every(r => r === true);
if (allPassed) {
console.log('✅ 所有检查通过!部署成功!');
console.log('='.repeat(60));
console.log();
console.log('访问地址:');
console.log(' • API 文档: http://localhost:3000/api/v1/health');
console.log(' • MySQL: localhost:3306 (root/1qazse42W3)');
console.log(' • Redis: localhost:6379');
console.log();
} else {
console.log('❌ 部分检查未通过,请查看日志:');
console.log(' docker-compose logs');
console.log('='.repeat(60));
process.exit(1);
}
}
main().catch(console.error);

@ -49,6 +49,7 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-resizable-panels": "^4.2.2",
"react-router-dom": "^7.13.1",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@ -5509,6 +5510,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/core-js-compat": {
"version": "3.48.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz",
@ -7327,6 +7341,44 @@
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-router": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
@ -7633,6 +7685,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

@ -51,6 +51,7 @@
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-resizable-panels": "^4.2.2",
"react-router-dom": "^7.13.1",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",

@ -0,0 +1,215 @@
import { useState, useEffect } from 'react';
import { Routes, Route, NavLink, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import {
LayoutDashboard,
Users,
Brain,
Database,
Activity,
Upload,
Settings,
LogOut,
Shield,
Menu,
X
} from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import Dashboard from './pages/Dashboard';
import UserManagement from './pages/UserManagement';
import AIConfig from './pages/AIConfig';
import DataSourceConfig from './pages/DataSourceConfig';
import DataCheck from './pages/DataCheck';
import DataImport from './pages/DataImport';
const sidebarItems = [
{ path: '/admin', icon: LayoutDashboard, label: '总览' },
{ path: '/admin/users', icon: Users, label: '用户管理' },
{ path: '/admin/ai-config', icon: Brain, label: 'AI模型配置' },
{ path: '/admin/data-source', icon: Database, label: '数据源配置' },
{ path: '/admin/data-check', icon: Activity, label: '数据检测' },
{ path: '/admin/data-import', icon: Upload, label: '数据导入' },
];
export default function Admin() {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [isMobile, setIsMobile] = useState(false);
const { user, isAuthenticated, logout } = useAuth();
const navigate = useNavigate();
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 1024);
if (window.innerWidth < 1024) {
setSidebarOpen(false);
} else {
setSidebarOpen(true);
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// 简单的管理员权限检查
useEffect(() => {
// 这里应该检查用户是否有管理员权限
// 暂时注释掉,方便测试
// if (!isAuthenticated) {
// navigate('/');
// }
}, [isAuthenticated, navigate]);
const handleLogout = () => {
logout();
navigate('/');
};
return (
<div className="min-h-screen bg-[#0a0a0a] flex">
{/* Mobile Overlay */}
{isMobile && sidebarOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<motion.aside
initial={false}
animate={{ width: sidebarOpen ? 260 : isMobile ? 0 : 70 }}
className={`fixed lg:static inset-y-0 left-0 z-50 bg-[#111111] border-r border-[#2a2a2a] flex flex-col ${
isMobile && !sidebarOpen ? 'hidden' : ''
}`}
>
{/* Logo */}
<div className="h-16 flex items-center px-4 border-b border-[#2a2a2a]">
<div className="w-10 h-10 bg-gradient-to-br from-[#ff6b35] to-[#ff9f43] rounded-lg flex items-center justify-center">
<Shield className="w-5 h-5 text-white" />
</div>
{sidebarOpen && (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="ml-3"
>
<h1 className="text-white font-bold"></h1>
<p className="text-xs text-[#666]">Admin Panel</p>
</motion.div>
)}
</div>
{/* Navigation */}
<nav className="flex-1 py-4 px-2 space-y-1 overflow-y-auto">
{sidebarItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/admin'}
onClick={() => isMobile && setSidebarOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-3 rounded-lg transition-all ${
isActive
? 'bg-gradient-to-r from-[#ff6b35]/20 to-transparent text-[#ff6b35] border-l-2 border-[#ff6b35]'
: 'text-[#b0b0b0] hover:bg-[#1a1a1a] hover:text-white'
}`
}
>
<item.icon className="w-5 h-5 flex-shrink-0" />
{sidebarOpen && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-sm font-medium whitespace-nowrap"
>
{item.label}
</motion.span>
)}
</NavLink>
))}
</nav>
{/* User Info */}
<div className="p-4 border-t border-[#2a2a2a]">
{sidebarOpen ? (
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[#2a2a2a] rounded-full flex items-center justify-center">
<span className="text-white font-medium">
{user?.username?.[0]?.toUpperCase() || 'A'}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">
{user?.username || '管理员'}
</p>
<p className="text-xs text-[#666] truncate">
{user?.email || 'admin@example.com'}
</p>
</div>
<button
onClick={handleLogout}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors"
title="退出登录"
>
<LogOut className="w-4 h-4 text-[#b0b0b0]" />
</button>
</div>
) : (
<button
onClick={handleLogout}
className="w-full flex justify-center p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors"
title="退出登录"
>
<LogOut className="w-5 h-5 text-[#b0b0b0]" />
</button>
)}
</div>
</motion.aside>
{/* Main Content */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<header className="h-16 bg-[#111111] border-b border-[#2a2a2a] flex items-center justify-between px-4 lg:px-6">
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors"
>
{sidebarOpen ? (
<X className="w-5 h-5 text-[#b0b0b0]" />
) : (
<Menu className="w-5 h-5 text-[#b0b0b0]" />
)}
</button>
<div className="flex items-center gap-4">
<a
href="/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-[#b0b0b0] hover:text-[#ff6b35] transition-colors"
>
访
</a>
</div>
</header>
{/* Page Content */}
<main className="flex-1 overflow-auto p-4 lg:p-6">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/users" element={<UserManagement />} />
<Route path="/ai-config" element={<AIConfig />} />
<Route path="/data-source" element={<DataSourceConfig />} />
<Route path="/data-check" element={<DataCheck />} />
<Route path="/data-import" element={<DataImport />} />
</Routes>
</main>
</div>
</div>
);
}

@ -0,0 +1,438 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import {
Brain,
Save,
RefreshCw,
CheckCircle,
AlertCircle,
SlidersHorizontal
} from 'lucide-react';
interface AIModelConfig {
provider: 'openai' | 'anthropic' | 'local' | 'custom';
model: string;
apiKey: string;
apiUrl: string;
temperature: number;
maxTokens: number;
enabled: boolean;
}
interface MomentumConfig {
calculationPeriod: number;
weightPriceChange: number;
weightVolume: number;
weightTechnical: number;
thresholdStrong: number;
thresholdWeak: number;
}
export default function AIConfig() {
const [aiConfig, setAiConfig] = useState<AIModelConfig>({
provider: 'openai',
model: 'gpt-4',
apiKey: '',
apiUrl: 'https://api.openai.com/v1',
temperature: 0.7,
maxTokens: 2000,
enabled: true,
});
const [momentumConfig, setMomentumConfig] = useState<MomentumConfig>({
calculationPeriod: 20,
weightPriceChange: 0.4,
weightVolume: 0.3,
weightTechnical: 0.3,
thresholdStrong: 80,
thresholdWeak: 40,
});
const [saving, setSaving] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const handleSave = async () => {
setSaving(true);
setSaveStatus('idle');
try {
// 模拟 API 调用
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('AI Config:', aiConfig);
console.log('Momentum Config:', momentumConfig);
setSaveStatus('success');
setTimeout(() => setSaveStatus('idle'), 3000);
} catch (error) {
setSaveStatus('error');
} finally {
setSaving(false);
}
};
const handleTestConnection = async () => {
setTesting(true);
setTestResult(null);
try {
await new Promise(resolve => setTimeout(resolve, 1500));
setTestResult({
success: true,
message: '连接成功API 响应正常,模型可正常使用。'
});
} catch (error) {
setTestResult({
success: false,
message: '连接失败,请检查 API Key 和 URL 配置。'
});
} finally {
setTesting(false);
}
};
const modelOptions = {
openai: ['gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo'],
anthropic: ['claude-3-opus', 'claude-3-sonnet', 'claude-3-haiku'],
local: ['llama2-7b', 'llama2-13b', 'chatglm3-6b'],
custom: ['custom-model'],
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">AI</h1>
<p className="text-[#b0b0b0] mt-1">AI</p>
</div>
<div className="flex gap-3">
<button
onClick={handleTestConnection}
disabled={testing}
className="flex items-center gap-2 px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors disabled:opacity-50"
>
{testing ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<CheckCircle className="w-4 h-4" />
)}
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors disabled:opacity-50"
>
{saving ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* Test Result */}
{testResult && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`p-4 rounded-lg flex items-center gap-3 ${
testResult.success
? 'bg-green-500/10 border border-green-500/30'
: 'bg-red-500/10 border border-red-500/30'
}`}
>
{testResult.success ? (
<CheckCircle className="w-5 h-5 text-green-400" />
) : (
<AlertCircle className="w-5 h-5 text-red-400" />
)}
<span className={testResult.success ? 'text-green-400' : 'text-red-400'}>
{testResult.message}
</span>
</motion.div>
)}
{/* Save Status */}
{saveStatus !== 'idle' && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`p-4 rounded-lg flex items-center gap-3 ${
saveStatus === 'success'
? 'bg-green-500/10 border border-green-500/30'
: 'bg-red-500/10 border border-red-500/30'
}`}
>
{saveStatus === 'success' ? (
<>
<CheckCircle className="w-5 h-5 text-green-400" />
<span className="text-green-400"></span>
</>
) : (
<>
<AlertCircle className="w-5 h-5 text-red-400" />
<span className="text-red-400"></span>
</>
)}
</motion.div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* AI Provider Config */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center gap-3 mb-6">
<Brain className="w-5 h-5 text-[#ff6b35]" />
<h2 className="text-white font-semibold">AI </h2>
</div>
<div className="space-y-4">
{/* Enable/Disable */}
<div className="flex items-center justify-between p-3 bg-[#0a0a0a] rounded-lg">
<span className="text-white"> AI </span>
<button
onClick={() => setAiConfig(prev => ({ ...prev, enabled: !prev.enabled }))}
className={`w-12 h-6 rounded-full transition-colors relative ${
aiConfig.enabled ? 'bg-[#ff6b35]' : 'bg-[#2a2a2a]'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${
aiConfig.enabled ? 'left-7' : 'left-1'
}`} />
</button>
</div>
{/* Provider */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<select
value={aiConfig.provider}
onChange={(e) => setAiConfig(prev => ({
...prev,
provider: e.target.value as AIModelConfig['provider'],
model: modelOptions[e.target.value as keyof typeof modelOptions][0]
}))}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2.5 text-white outline-none focus:border-[#ff6b35]"
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic Claude</option>
<option value="local"></option>
<option value="custom"></option>
</select>
</div>
{/* Model */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<select
value={aiConfig.model}
onChange={(e) => setAiConfig(prev => ({ ...prev, model: e.target.value }))}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2.5 text-white outline-none focus:border-[#ff6b35]"
>
{modelOptions[aiConfig.provider].map(model => (
<option key={model} value={model}>{model}</option>
))}
</select>
</div>
{/* API Key */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">API Key</label>
<input
type="password"
value={aiConfig.apiKey}
onChange={(e) => setAiConfig(prev => ({ ...prev, apiKey: e.target.value }))}
placeholder="输入 API Key"
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2.5 text-white placeholder-[#666] outline-none focus:border-[#ff6b35]"
/>
</div>
{/* API URL */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">API URL</label>
<input
type="text"
value={aiConfig.apiUrl}
onChange={(e) => setAiConfig(prev => ({ ...prev, apiUrl: e.target.value }))}
placeholder="https://api.example.com/v1"
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2.5 text-white placeholder-[#666] outline-none focus:border-[#ff6b35]"
/>
</div>
{/* Temperature */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">
Temperature: {aiConfig.temperature}
</label>
<input
type="range"
min="0"
max="2"
step="0.1"
value={aiConfig.temperature}
onChange={(e) => setAiConfig(prev => ({ ...prev, temperature: parseFloat(e.target.value) }))}
className="w-full accent-[#ff6b35]"
/>
<div className="flex justify-between text-xs text-[#666] mt-1">
<span></span>
<span></span>
<span></span>
</div>
</div>
{/* Max Tokens */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">Max Tokens</label>
<input
type="number"
value={aiConfig.maxTokens}
onChange={(e) => setAiConfig(prev => ({ ...prev, maxTokens: parseInt(e.target.value) }))}
min="100"
max="8000"
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2.5 text-white outline-none focus:border-[#ff6b35]"
/>
</div>
</div>
</motion.div>
{/* Momentum Calculation Config */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center gap-3 mb-6">
<SlidersHorizontal className="w-5 h-5 text-[#ff6b35]" />
<h2 className="text-white font-semibold"></h2>
</div>
<div className="space-y-4">
{/* Calculation Period */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">
: {momentumConfig.calculationPeriod}
</label>
<input
type="range"
min="5"
max="60"
value={momentumConfig.calculationPeriod}
onChange={(e) => setMomentumConfig(prev => ({
...prev,
calculationPeriod: parseInt(e.target.value)
}))}
className="w-full accent-[#ff6b35]"
/>
</div>
{/* Weight Settings */}
<div className="space-y-4 pt-4 border-t border-[#2a2a2a]">
<h3 className="text-white font-medium"></h3>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">
: {(momentumConfig.weightPriceChange * 100).toFixed(0)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.05"
value={momentumConfig.weightPriceChange}
onChange={(e) => setMomentumConfig(prev => ({
...prev,
weightPriceChange: parseFloat(e.target.value)
}))}
className="w-full accent-[#ff6b35]"
/>
</div>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">
: {(momentumConfig.weightVolume * 100).toFixed(0)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.05"
value={momentumConfig.weightVolume}
onChange={(e) => setMomentumConfig(prev => ({
...prev,
weightVolume: parseFloat(e.target.value)
}))}
className="w-full accent-[#ff6b35]"
/>
</div>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">
: {(momentumConfig.weightTechnical * 100).toFixed(0)}%
</label>
<input
type="range"
min="0"
max="1"
step="0.05"
value={momentumConfig.weightTechnical}
onChange={(e) => setMomentumConfig(prev => ({
...prev,
weightTechnical: parseFloat(e.target.value)
}))}
className="w-full accent-[#ff6b35]"
/>
</div>
</div>
{/* Threshold Settings */}
<div className="space-y-4 pt-4 border-t border-[#2a2a2a]">
<h3 className="text-white font-medium"></h3>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">
: {momentumConfig.thresholdStrong}
</label>
<input
type="range"
min="60"
max="95"
value={momentumConfig.thresholdStrong}
onChange={(e) => setMomentumConfig(prev => ({
...prev,
thresholdStrong: parseInt(e.target.value)
}))}
className="w-full accent-[#ff6b35]"
/>
</div>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">
: {momentumConfig.thresholdWeak}
</label>
<input
type="range"
min="20"
max="50"
value={momentumConfig.thresholdWeak}
onChange={(e) => setMomentumConfig(prev => ({
...prev,
thresholdWeak: parseInt(e.target.value)
}))}
className="w-full accent-[#ff6b35]"
/>
</div>
</div>
</div>
</motion.div>
</div>
</div>
);
}

@ -0,0 +1,223 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import {
Users,
Database,
Activity,
TrendingUp,
Server,
Clock
} from 'lucide-react';
interface SystemStats {
totalUsers: number;
totalStocks: number;
totalSectors: number;
dataCompleteness: number;
lastSync: string;
apiStatus: {
akshare: boolean;
database: boolean;
redis: boolean;
};
}
export default function Dashboard() {
const [stats, setStats] = useState<SystemStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 模拟获取系统统计信息
const fetchStats = async () => {
setLoading(true);
try {
// 这里应该调用后端 API
// const response = await fetch('/api/v1/admin/stats');
// const data = await response.json();
// 模拟数据
await new Promise(resolve => setTimeout(resolve, 500));
setStats({
totalUsers: 128,
totalStocks: 5234,
totalSectors: 86,
dataCompleteness: 87.5,
lastSync: '2024-03-07 14:30:00',
apiStatus: {
akshare: true,
database: true,
redis: true,
},
});
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
setLoading(false);
}
};
fetchStats();
}, []);
const statCards = [
{
label: '总用户数',
value: stats?.totalUsers || 0,
icon: Users,
color: 'from-blue-500 to-blue-600',
trend: '+12%'
},
{
label: '股票总数',
value: stats?.totalStocks || 0,
icon: TrendingUp,
color: 'from-green-500 to-green-600',
trend: '+3.2%'
},
{
label: '版块总数',
value: stats?.totalSectors || 0,
icon: Database,
color: 'from-purple-500 to-purple-600',
trend: '0%'
},
{
label: '数据完整度',
value: `${stats?.dataCompleteness || 0}%`,
icon: Activity,
color: 'from-orange-500 to-orange-600',
trend: '-2.1%'
},
];
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-[#b0b0b0] mt-1"></p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{statCards.map((card, index) => (
<motion.div
key={card.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-start justify-between">
<div>
<p className="text-[#b0b0b0] text-sm">{card.label}</p>
<p className="text-2xl font-bold text-white mt-1">{card.value}</p>
<span className={`text-xs mt-2 inline-block ${
card.trend.startsWith('+') ? 'text-green-400' :
card.trend.startsWith('-') ? 'text-red-400' : 'text-[#666]'
}`}>
{card.trend}
</span>
</div>
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${card.color} flex items-center justify-center`}>
<card.icon className="w-6 h-6 text-white" />
</div>
</div>
</motion.div>
))}
</div>
{/* System Status */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* API Status */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center gap-3 mb-4">
<Server className="w-5 h-5 text-[#ff6b35]" />
<h2 className="text-white font-semibold"></h2>
</div>
<div className="space-y-3">
{[
{ name: 'AKShare 数据服务', status: stats?.apiStatus.akshare },
{ name: 'MySQL 数据库', status: stats?.apiStatus.database },
{ name: 'Redis 缓存', status: stats?.apiStatus.redis },
].map((service) => (
<div key={service.name} className="flex items-center justify-between py-2">
<span className="text-[#b0b0b0]">{service.name}</span>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
service.status ? 'bg-green-500' : 'bg-red-500'
}`} />
<span className={`text-sm ${
service.status ? 'text-green-400' : 'text-red-400'
}`}>
{service.status ? '正常' : '异常'}
</span>
</div>
</div>
))}
</div>
</motion.div>
{/* Recent Activity */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center gap-3 mb-4">
<Clock className="w-5 h-5 text-[#ff6b35]" />
<h2 className="text-white font-semibold"></h2>
</div>
<div className="space-y-3">
{[
{ time: '14:30', action: '数据同步完成', detail: '共更新 5234 只股票数据' },
{ time: '12:15', action: '新用户注册', detail: '用户 user_128 注册成功' },
{ time: '10:00', action: '系统备份', detail: '数据库自动备份完成' },
{ time: '08:30', action: 'AI模型更新', detail: '动量计算模型已更新' },
].map((activity, index) => (
<div key={index} className="flex gap-3 py-2 border-b border-[#2a2a2a] last:border-0">
<span className="text-[#666] text-sm w-12">{activity.time}</span>
<div>
<p className="text-white text-sm">{activity.action}</p>
<p className="text-[#666] text-xs">{activity.detail}</p>
</div>
</div>
))}
</div>
</motion.div>
</div>
{/* Quick Actions */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<h2 className="text-white font-semibold mb-4"></h2>
<div className="flex flex-wrap gap-3">
<button className="px-4 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors text-sm">
</button>
<button className="px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors text-sm">
</button>
<button className="px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors text-sm">
</button>
<button className="px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors text-sm">
</button>
</div>
</motion.div>
</div>
);
}

@ -0,0 +1,409 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import {
Activity,
CheckCircle,
AlertCircle,
XCircle,
RefreshCw,
Play,
Database,
TrendingUp,
Calendar,
Clock
} from 'lucide-react';
interface DataCheckItem {
id: string;
name: string;
type: 'stock' | 'sector' | 'index' | 'kline';
total: number;
current: number;
lastUpdate: string;
status: 'complete' | 'incomplete' | 'missing';
details?: string;
}
interface CheckProgress {
isChecking: boolean;
isBuffering: boolean;
progress: number;
currentTask: string;
}
export default function DataCheck() {
const [dataStatus, setDataStatus] = useState<DataCheckItem[]>([]);
const [loading, setLoading] = useState(true);
const [progress, setProgress] = useState<CheckProgress>({
isChecking: false,
isBuffering: false,
progress: 0,
currentTask: '',
});
useEffect(() => {
fetchDataStatus();
}, []);
const fetchDataStatus = async () => {
setLoading(true);
try {
// 模拟 API 调用
await new Promise(resolve => setTimeout(resolve, 800));
const mockData: DataCheckItem[] = [
{
id: '1',
name: '股票基础数据',
type: 'stock',
total: 5234,
current: 5234,
lastUpdate: '2024-03-07 14:30:00',
status: 'complete',
},
{
id: '2',
name: '版块数据',
type: 'sector',
total: 86,
current: 86,
lastUpdate: '2024-03-07 14:30:00',
status: 'complete',
},
{
id: '3',
name: '市场指数',
type: 'index',
total: 4,
current: 4,
lastUpdate: '2024-03-07 14:30:00',
status: 'complete',
},
{
id: '4',
name: '股票K线数据',
type: 'kline',
total: 5234 * 60,
current: 4800 * 60,
lastUpdate: '2024-03-07 10:00:00',
status: 'incomplete',
details: '部分股票缺少近期K线数据',
},
{
id: '5',
name: '版块K线数据',
type: 'kline',
total: 86 * 60,
current: 80 * 60,
lastUpdate: '2024-03-07 10:00:00',
status: 'incomplete',
details: '6个版块数据待更新',
},
{
id: '6',
name: '实时行情数据',
type: 'stock',
total: 5234,
current: 0,
lastUpdate: '-',
status: 'missing',
details: '今日行情数据未获取',
},
];
setDataStatus(mockData);
} catch (error) {
console.error('Failed to fetch data status:', error);
} finally {
setLoading(false);
}
};
const handleCheckData = async () => {
setProgress({
isChecking: true,
isBuffering: false,
progress: 0,
currentTask: '正在检查数据完整性...',
});
// 模拟检查进度
for (let i = 0; i <= 100; i += 10) {
await new Promise(resolve => setTimeout(resolve, 300));
setProgress(prev => ({ ...prev, progress: i }));
}
setProgress(prev => ({ ...prev, isChecking: false }));
await fetchDataStatus();
};
const handleBufferData = async () => {
setProgress({
isChecking: false,
isBuffering: true,
progress: 0,
currentTask: '正在获取缺失数据...',
});
const tasks = [
'获取股票基础数据...',
'获取版块数据...',
'获取市场指数...',
'获取K线数据...',
'计算动量指标...',
'更新缓存...',
];
for (let i = 0; i < tasks.length; i++) {
setProgress(prev => ({
...prev,
currentTask: tasks[i],
progress: Math.round((i / tasks.length) * 100),
}));
await new Promise(resolve => setTimeout(resolve, 1500));
}
setProgress(prev => ({ ...prev, progress: 100 }));
await new Promise(resolve => setTimeout(resolve, 500));
setProgress({
isChecking: false,
isBuffering: false,
progress: 0,
currentTask: '',
});
await fetchDataStatus();
};
const getStatusIcon = (status: DataCheckItem['status']) => {
switch (status) {
case 'complete':
return <CheckCircle className="w-5 h-5 text-green-400" />;
case 'incomplete':
return <AlertCircle className="w-5 h-5 text-yellow-400" />;
case 'missing':
return <XCircle className="w-5 h-5 text-red-400" />;
}
};
const getStatusText = (status: DataCheckItem['status']) => {
switch (status) {
case 'complete':
return '完整';
case 'incomplete':
return '部分缺失';
case 'missing':
return '缺失';
}
};
const completionRate = dataStatus.length > 0
? Math.round((dataStatus.filter(d => d.status === 'complete').length / dataStatus.length) * 100)
: 0;
const totalMissing = dataStatus.reduce((acc, item) => {
if (item.status !== 'complete') {
return acc + (item.total - item.current);
}
return acc;
}, 0);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-[#b0b0b0] mt-1"></p>
</div>
<div className="flex gap-3">
<button
onClick={handleCheckData}
disabled={progress.isChecking || progress.isBuffering}
className="flex items-center gap-2 px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${progress.isChecking ? 'animate-spin' : ''}`} />
</button>
<button
onClick={handleBufferData}
disabled={progress.isChecking || progress.isBuffering || totalMissing === 0}
className="flex items-center gap-2 px-4 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors disabled:opacity-50"
>
<Play className="w-4 h-4" />
</button>
</div>
</div>
{/* Progress Bar */}
{(progress.isChecking || progress.isBuffering) && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center justify-between mb-3">
<span className="text-white">{progress.currentTask}</span>
<span className="text-[#ff6b35] font-medium">{progress.progress}%</span>
</div>
<div className="h-2 bg-[#2a2a2a] rounded-full overflow-hidden">
<motion.div
className="h-full bg-gradient-to-r from-[#ff6b35] to-[#ff9f43]"
initial={{ width: 0 }}
animate={{ width: `${progress.progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
</motion.div>
)}
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center justify-between">
<div>
<p className="text-[#b0b0b0] text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{completionRate}%</p>
</div>
<div className="w-12 h-12 bg-green-500/20 rounded-xl flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-green-400" />
</div>
</div>
<div className="mt-3 h-2 bg-[#2a2a2a] rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${completionRate}%` }}
/>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center justify-between">
<div>
<p className="text-[#b0b0b0] text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{totalMissing.toLocaleString()}</p>
</div>
<div className="w-12 h-12 bg-red-500/20 rounded-xl flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-red-400" />
</div>
</div>
<p className="text-sm text-[#666] mt-3">
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center justify-between">
<div>
<p className="text-[#b0b0b0] text-sm"></p>
<p className="text-lg font-bold text-white mt-1">
{new Date().toLocaleTimeString('zh-CN')}
</p>
</div>
<div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center">
<Clock className="w-6 h-6 text-blue-400" />
</div>
</div>
<p className="text-sm text-[#666] mt-3">
</p>
</motion.div>
</div>
{/* Data Status Table */}
<div className="bg-[#111111] border border-[#2a2a2a] rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-[#2a2a2a] flex items-center gap-3">
<Database className="w-5 h-5 text-[#ff6b35]" />
<h2 className="text-white font-semibold"></h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-[#2a2a2a]">
<th className="px-5 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-5 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-5 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-5 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-5 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-5 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} className="px-5 py-8 text-center text-[#b0b0b0]">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
...
</td>
</tr>
) : (
dataStatus.map((item) => (
<tr key={item.id} className="border-b border-[#2a2a2a] last:border-0 hover:bg-[#1a1a1a]">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
{item.type === 'stock' && <TrendingUp className="w-4 h-4 text-blue-400" />}
{item.type === 'sector' && <Database className="w-4 h-4 text-purple-400" />}
{item.type === 'index' && <Activity className="w-4 h-4 text-green-400" />}
{item.type === 'kline' && <Calendar className="w-4 h-4 text-orange-400" />}
<span className="text-white">{item.name}</span>
</div>
</td>
<td className="px-5 py-4 text-[#b0b0b0] capitalize">{item.type}</td>
<td className="px-5 py-4">
<div className="flex items-center gap-2">
<div className="w-24 h-2 bg-[#2a2a2a] rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
item.status === 'complete' ? 'bg-green-500' :
item.status === 'incomplete' ? 'bg-yellow-500' :
'bg-red-500'
}`}
style={{ width: `${(item.current / item.total) * 100}%` }}
/>
</div>
<span className="text-sm text-[#b0b0b0]">
{((item.current / item.total) * 100).toFixed(1)}%
</span>
</div>
</td>
<td className="px-5 py-4">
<div className="flex items-center gap-2">
{getStatusIcon(item.status)}
<span className={`text-sm ${
item.status === 'complete' ? 'text-green-400' :
item.status === 'incomplete' ? 'text-yellow-400' :
'text-red-400'
}`}>
{getStatusText(item.status)}
</span>
</div>
</td>
<td className="px-5 py-4 text-[#b0b0b0] text-sm">{item.lastUpdate}</td>
<td className="px-5 py-4 text-[#666] text-sm">{item.details || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

@ -0,0 +1,403 @@
import { useState, useRef } from 'react';
import { motion } from 'framer-motion';
import {
Upload,
FileSpreadsheet,
Database,
History,
CheckCircle,
AlertCircle,
X,
Download,
Calendar,
Settings
} from 'lucide-react';
interface ImportTask {
id: string;
type: 'stock' | 'sector' | 'trade' | 'kline';
name: string;
fileName: string;
status: 'pending' | 'processing' | 'completed' | 'error';
progress: number;
totalRecords: number;
importedRecords: number;
errorMessage?: string;
createdAt: string;
}
const importTemplates = [
{ type: 'stock', name: '股票基础数据', format: 'CSV/Excel', fields: ['code', 'name', 'industry', 'market_cap'] },
{ type: 'sector', name: '版块数据', format: 'CSV/Excel', fields: ['code', 'name', 'parent_code'] },
{ type: 'trade', name: '交易数据', format: 'CSV/Excel', fields: ['code', 'date', 'open', 'high', 'low', 'close', 'volume'] },
{ type: 'kline', name: 'K线数据', format: 'CSV/Excel', fields: ['code', 'date', 'open', 'high', 'low', 'close', 'volume'] },
];
export default function DataImport() {
const [tasks, setTasks] = useState<ImportTask[]>([]);
const [dragActive, setDragActive] = useState(false);
const [selectedType, setSelectedType] = useState<string>('stock');
const [showTemplateModal, setShowTemplateModal] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
handleFile(e.dataTransfer.files[0]);
}
};
const handleFile = (file: File) => {
const newTask: ImportTask = {
id: Date.now().toString(),
type: selectedType as ImportTask['type'],
name: importTemplates.find(t => t.type === selectedType)?.name || '数据导入',
fileName: file.name,
status: 'pending',
progress: 0,
totalRecords: 0,
importedRecords: 0,
createdAt: new Date().toLocaleString('zh-CN'),
};
setTasks(prev => [newTask, ...prev]);
// 模拟开始导入
setTimeout(() => {
startImport(newTask.id);
}, 500);
};
const startImport = async (taskId: string) => {
setTasks(prev => prev.map(t =>
t.id === taskId ? { ...t, status: 'processing' } : t
));
// 模拟导入进度
const totalSteps = 10;
for (let i = 1; i <= totalSteps; i++) {
await new Promise(resolve => setTimeout(resolve, 500));
setTasks(prev => prev.map(t =>
t.id === taskId ? {
...t,
progress: (i / totalSteps) * 100,
totalRecords: 10000,
importedRecords: Math.round((i / totalSteps) * 10000),
} : t
));
}
// 模拟随机成功或失败
const success = Math.random() > 0.2;
setTasks(prev => prev.map(t =>
t.id === taskId ? {
...t,
status: success ? 'completed' : 'error',
progress: success ? 100 : 60,
errorMessage: success ? undefined : '部分数据格式错误,请检查文件格式',
} : t
));
};
const handleDeleteTask = (taskId: string) => {
setTasks(prev => prev.filter(t => t.id !== taskId));
};
const getStatusIcon = (status: ImportTask['status']) => {
switch (status) {
case 'completed':
return <CheckCircle className="w-5 h-5 text-green-400" />;
case 'error':
return <AlertCircle className="w-5 h-5 text-red-400" />;
case 'processing':
return <div className="w-5 h-5 border-2 border-[#ff6b35] border-t-transparent rounded-full animate-spin" />;
default:
return <div className="w-5 h-5 rounded-full border-2 border-[#666]" />;
}
};
const downloadTemplate = (type: string) => {
const template = importTemplates.find(t => t.type === type);
if (!template) return;
const headers = template.fields.join(',');
const sample = template.fields.map(() => '示例数据').join(',');
const content = `${headers}\n${sample}`;
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `${type}_template.csv`;
link.click();
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-[#b0b0b0] mt-1"></p>
</div>
<button
onClick={() => setShowTemplateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors"
>
<Download className="w-4 h-4" />
</button>
</div>
{/* Upload Area */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={`bg-[#111111] border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
dragActive ? 'border-[#ff6b35] bg-[#ff6b35]/5' : 'border-[#2a2a2a]'
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
className="hidden"
/>
<div className="w-16 h-16 bg-[#ff6b35]/20 rounded-full flex items-center justify-center mx-auto mb-4">
<Upload className="w-8 h-8 text-[#ff6b35]" />
</div>
<p className="text-white text-lg mb-2"></p>
<p className="text-[#666] text-sm mb-4"> CSVExcel </p>
{/* Data Type Selector */}
<div className="flex flex-wrap justify-center gap-2 mb-4">
{importTemplates.map(template => (
<button
key={template.type}
onClick={() => setSelectedType(template.type)}
className={`px-4 py-2 rounded-lg text-sm transition-colors ${
selectedType === template.type
? 'bg-[#ff6b35] text-white'
: 'bg-[#2a2a2a] text-[#b0b0b0] hover:bg-[#333]'
}`}
>
{template.name}
</button>
))}
</div>
<button
onClick={() => fileInputRef.current?.click()}
className="px-6 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors"
>
</button>
</motion.div>
{/* Import Tasks */}
{tasks.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl overflow-hidden"
>
<div className="px-5 py-4 border-b border-[#2a2a2a] flex items-center justify-between">
<div className="flex items-center gap-3">
<History className="w-5 h-5 text-[#ff6b35]" />
<h2 className="text-white font-semibold"></h2>
</div>
<button
onClick={() => setTasks([])}
className="text-sm text-[#666] hover:text-white transition-colors"
>
</button>
</div>
<div className="divide-y divide-[#2a2a2a]">
{tasks.map((task) => (
<div key={task.id} className="p-5 hover:bg-[#1a1a1a] transition-colors">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
{getStatusIcon(task.status)}
<div>
<h3 className="text-white font-medium">{task.name}</h3>
<p className="text-sm text-[#666] mt-1">{task.fileName}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-[#666]">
<span>{task.createdAt}</span>
<span>: {task.type}</span>
{task.status !== 'pending' && (
<span>
: {task.importedRecords.toLocaleString()} / {task.totalRecords.toLocaleString()}
</span>
)}
</div>
{task.errorMessage && (
<p className="text-sm text-red-400 mt-2">{task.errorMessage}</p>
)}
</div>
</div>
<button
onClick={() => handleDeleteTask(task.id)}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors"
>
<X className="w-4 h-4 text-[#666]" />
</button>
</div>
{task.status === 'processing' && (
<div className="mt-4">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-[#b0b0b0]"></span>
<span className="text-[#ff6b35]">{task.progress.toFixed(0)}%</span>
</div>
<div className="h-2 bg-[#2a2a2a] rounded-full overflow-hidden">
<motion.div
className="h-full bg-gradient-to-r from-[#ff6b35] to-[#ff9f43]"
initial={{ width: 0 }}
animate={{ width: `${task.progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
</div>
)}
{task.status === 'completed' && (
<div className="mt-4 flex items-center gap-2 text-green-400 text-sm">
<CheckCircle className="w-4 h-4" />
</div>
)}
</div>
))}
</div>
</motion.div>
)}
{/* Import Guide */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<h2 className="text-white font-semibold mb-4"></h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<FileSpreadsheet className="w-4 h-4 text-blue-400" />
</div>
<div>
<h3 className="text-white font-medium"></h3>
<p className="text-sm text-[#666] mt-1">
CSV (.csv) Excel (.xlsx, .xls) 100MB
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-green-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<Database className="w-4 h-4 text-green-400" />
</div>
<div>
<h3 className="text-white font-medium"></h3>
<p className="text-sm text-[#666] mt-1">
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-purple-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<Calendar className="w-4 h-4 text-purple-400" />
</div>
<div>
<h3 className="text-white font-medium"></h3>
<p className="text-sm text-[#666] mt-1">
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-orange-500/20 rounded-lg flex items-center justify-center flex-shrink-0">
<Settings className="w-4 h-4 text-orange-400" />
</div>
<div>
<h3 className="text-white font-medium"></h3>
<p className="text-sm text-[#666] mt-1">
</p>
</div>
</div>
</div>
</motion.div>
{/* Template Modal */}
{showTemplateModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
onClick={() => setShowTemplateModal(false)}
>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl w-full max-w-lg"
onClick={e => e.stopPropagation()}
>
<div className="p-5 border-b border-[#2a2a2a] flex items-center justify-between">
<h2 className="text-white font-semibold"></h2>
<button
onClick={() => setShowTemplateModal(false)}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors"
>
<X className="w-5 h-5 text-[#666]" />
</button>
</div>
<div className="p-5 space-y-3">
{importTemplates.map(template => (
<div
key={template.type}
className="flex items-center justify-between p-4 bg-[#0a0a0a] rounded-lg"
>
<div>
<h3 className="text-white font-medium">{template.name}</h3>
<p className="text-sm text-[#666] mt-1">
: {template.fields.join(', ')}
</p>
</div>
<button
onClick={() => downloadTemplate(template.type)}
className="px-4 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors"
>
</button>
</div>
))}
</div>
</motion.div>
</motion.div>
)}
</div>
);
}

@ -0,0 +1,374 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import {
Database,
Save,
RefreshCw,
CheckCircle,
AlertCircle,
Globe,
Clock,
Shield
} from 'lucide-react';
interface DataSource {
id: string;
name: string;
type: 'akshare' | 'tushare' | 'custom';
url: string;
apiKey: string;
enabled: boolean;
syncInterval: number;
lastSync: string;
status: 'connected' | 'disconnected' | 'error';
}
interface MarketConfig {
tradingHours: {
preMarket: string;
open: string;
close: string;
postMarket: string;
};
dataRetention: number;
enablePreMarket: boolean;
enableAfterHours: boolean;
}
export default function DataSourceConfig() {
const [sources, setSources] = useState<DataSource[]>([
{
id: '1',
name: 'AKShare 官方',
type: 'akshare',
url: 'http://localhost:8000',
apiKey: '',
enabled: true,
syncInterval: 5,
lastSync: '2024-03-07 14:30:00',
status: 'connected',
},
{
id: '2',
name: 'Tushare Pro',
type: 'tushare',
url: 'https://api.tushare.pro',
apiKey: 'ts_xxxxxxxxxxxxxxxx',
enabled: false,
syncInterval: 15,
lastSync: '-',
status: 'disconnected',
},
]);
const [marketConfig, setMarketConfig] = useState<MarketConfig>({
tradingHours: {
preMarket: '09:15',
open: '09:30',
close: '15:00',
postMarket: '15:30',
},
dataRetention: 365,
enablePreMarket: true,
enableAfterHours: false,
});
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const handleTestConnection = async (sourceId: string) => {
setTesting(sourceId);
await new Promise(resolve => setTimeout(resolve, 1500));
setSources(prev => prev.map(s =>
s.id === sourceId
? { ...s, status: Math.random() > 0.3 ? 'connected' : 'error' }
: s
));
setTesting(null);
};
const handleSave = async () => {
setSaving(true);
await new Promise(resolve => setTimeout(resolve, 1000));
setSaving(false);
};
const handleToggleSource = (sourceId: string) => {
setSources(prev => prev.map(s =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s
));
};
const handleUpdateSource = (sourceId: string, updates: Partial<DataSource>) => {
setSources(prev => prev.map(s =>
s.id === sourceId ? { ...s, ...updates } : s
));
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-[#b0b0b0] mt-1"></p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors disabled:opacity-50"
>
{saving ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
</button>
</div>
{/* Data Sources */}
<div className="space-y-4">
{sources.map((source, index) => (
<motion.div
key={source.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
source.type === 'akshare' ? 'bg-green-500/20' :
source.type === 'tushare' ? 'bg-blue-500/20' :
'bg-purple-500/20'
}`}>
<Database className={`w-5 h-5 ${
source.type === 'akshare' ? 'text-green-400' :
source.type === 'tushare' ? 'text-blue-400' :
'text-purple-400'
}`} />
</div>
<div>
<h3 className="text-white font-medium">{source.name}</h3>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${
source.status === 'connected' ? 'bg-green-500/20 text-green-400' :
source.status === 'error' ? 'bg-red-500/20 text-red-400' :
'bg-gray-500/20 text-gray-400'
}`}>
{source.status === 'connected' ? '已连接' :
source.status === 'error' ? '连接错误' : '未连接'}
</span>
<span className="text-xs text-[#666]">
: {source.lastSync}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleTestConnection(source.id)}
disabled={testing === source.id}
className="px-3 py-1.5 text-sm bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors disabled:opacity-50"
>
{testing === source.id ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
'测试连接'
)}
</button>
<button
onClick={() => handleToggleSource(source.id)}
className={`w-12 h-6 rounded-full transition-colors relative ${
source.enabled ? 'bg-[#ff6b35]' : 'bg-[#2a2a2a]'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${
source.enabled ? 'left-7' : 'left-1'
}`} />
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">API URL</label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#666]" />
<input
type="text"
value={source.url}
onChange={(e) => handleUpdateSource(source.id, { url: e.target.value })}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg pl-10 pr-4 py-2 text-white outline-none focus:border-[#ff6b35]"
/>
</div>
</div>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">API Key</label>
<div className="relative">
<Shield className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#666]" />
<input
type="password"
value={source.apiKey}
onChange={(e) => handleUpdateSource(source.id, { apiKey: e.target.value })}
placeholder={source.type === 'akshare' ? '无需 API Key' : '输入 API Key'}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg pl-10 pr-4 py-2 text-white placeholder-[#666] outline-none focus:border-[#ff6b35]"
/>
</div>
</div>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"> ()</label>
<input
type="number"
value={source.syncInterval}
onChange={(e) => handleUpdateSource(source.id, { syncInterval: parseInt(e.target.value) })}
min="1"
max="60"
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2 text-white outline-none focus:border-[#ff6b35]"
/>
</div>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<select
value={source.type}
onChange={(e) => handleUpdateSource(source.id, { type: e.target.value as DataSource['type'] })}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2 text-white outline-none focus:border-[#ff6b35]"
>
<option value="akshare">AKShare</option>
<option value="tushare">Tushare</option>
<option value="custom"></option>
</select>
</div>
</div>
</motion.div>
))}
{/* Add New Source Button */}
<button className="w-full py-4 border-2 border-dashed border-[#2a2a2a] rounded-xl text-[#b0b0b0] hover:border-[#ff6b35] hover:text-[#ff6b35] transition-colors flex items-center justify-center gap-2">
<Database className="w-5 h-5" />
</button>
</div>
{/* Market Config */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center gap-3 mb-6">
<Clock className="w-5 h-5 text-[#ff6b35]" />
<h2 className="text-white font-semibold"></h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Trading Hours */}
<div className="space-y-4">
<h3 className="text-white font-medium"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<input
type="time"
value={marketConfig.tradingHours.open}
onChange={(e) => setMarketConfig(prev => ({
...prev,
tradingHours: { ...prev.tradingHours, open: e.target.value }
}))}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2 text-white outline-none focus:border-[#ff6b35]"
/>
</div>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<input
type="time"
value={marketConfig.tradingHours.close}
onChange={(e) => setMarketConfig(prev => ({
...prev,
tradingHours: { ...prev.tradingHours, close: e.target.value }
}))}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2 text-white outline-none focus:border-[#ff6b35]"
/>
</div>
</div>
<div className="flex items-center justify-between p-3 bg-[#0a0a0a] rounded-lg">
<span className="text-white"></span>
<button
onClick={() => setMarketConfig(prev => ({ ...prev, enablePreMarket: !prev.enablePreMarket }))}
className={`w-12 h-6 rounded-full transition-colors relative ${
marketConfig.enablePreMarket ? 'bg-[#ff6b35]' : 'bg-[#2a2a2a]'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${
marketConfig.enablePreMarket ? 'left-7' : 'left-1'
}`} />
</button>
</div>
<div className="flex items-center justify-between p-3 bg-[#0a0a0a] rounded-lg">
<span className="text-white"></span>
<button
onClick={() => setMarketConfig(prev => ({ ...prev, enableAfterHours: !prev.enableAfterHours }))}
className={`w-12 h-6 rounded-full transition-colors relative ${
marketConfig.enableAfterHours ? 'bg-[#ff6b35]' : 'bg-[#2a2a2a]'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${
marketConfig.enableAfterHours ? 'left-7' : 'left-1'
}`} />
</button>
</div>
</div>
{/* Data Retention */}
<div className="space-y-4">
<h3 className="text-white font-medium"></h3>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">
: {marketConfig.dataRetention}
</label>
<input
type="range"
min="30"
max="1095"
step="30"
value={marketConfig.dataRetention}
onChange={(e) => setMarketConfig(prev => ({
...prev,
dataRetention: parseInt(e.target.value)
}))}
className="w-full accent-[#ff6b35]"
/>
<div className="flex justify-between text-xs text-[#666] mt-1">
<span>30</span>
<span>1</span>
<span>3</span>
</div>
</div>
<div className="p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-blue-400 font-medium"></p>
<p className="text-sm text-blue-300/80 mt-1">
1
</p>
</div>
</div>
</div>
</div>
</div>
</motion.div>
</div>
);
}

@ -0,0 +1,337 @@
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import {
Search,
Filter,
MoreHorizontal,
Ban,
CheckCircle,
Trash2,
UserPlus,
ChevronLeft,
ChevronRight,
RefreshCw
} from 'lucide-react';
interface User {
id: string;
username: string;
email: string;
role: 'admin' | 'user';
status: 'active' | 'banned';
createdAt: string;
lastLogin: string;
favoritesCount: number;
}
export default function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [filterRole, setFilterRole] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const pageSize = 10;
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
setLoading(true);
try {
// 模拟 API 调用
await new Promise(resolve => setTimeout(resolve, 800));
const mockUsers: User[] = Array.from({ length: 25 }, (_, i) => ({
id: `user_${i + 1}`,
username: `用户${i + 1}`,
email: `user${i + 1}@example.com`,
role: i < 3 ? 'admin' : 'user',
status: i === 5 ? 'banned' : 'active',
createdAt: '2024-03-01',
lastLogin: '2024-03-07',
favoritesCount: Math.floor(Math.random() * 20),
}));
setUsers(mockUsers);
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
setLoading(false);
}
};
const filteredUsers = users.filter(user => {
const matchesSearch =
user.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.email.toLowerCase().includes(searchQuery.toLowerCase());
const matchesRole = filterRole === 'all' || user.role === filterRole;
const matchesStatus = filterStatus === 'all' || user.status === filterStatus;
return matchesSearch && matchesRole && matchesStatus;
});
const paginatedUsers = filteredUsers.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize
);
const totalPages = Math.ceil(filteredUsers.length / pageSize);
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
setSelectedUsers(paginatedUsers.map(u => u.id));
} else {
setSelectedUsers([]);
}
};
const handleSelectUser = (userId: string) => {
setSelectedUsers(prev =>
prev.includes(userId)
? prev.filter(id => id !== userId)
: [...prev, userId]
);
};
const handleBanUser = async (userId: string) => {
// 实现封禁用户逻辑
console.log('Ban user:', userId);
};
const handleDeleteUser = async (userId: string) => {
if (confirm('确定要删除该用户吗?此操作不可恢复。')) {
setUsers(prev => prev.filter(u => u.id !== userId));
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-[#b0b0b0] mt-1"></p>
</div>
<button className="flex items-center gap-2 px-4 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors">
<UserPlus className="w-4 h-4" />
</button>
</div>
{/* Filters */}
<div className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-4 space-y-4">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索用户名或邮箱..."
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg pl-10 pr-4 py-2 text-white placeholder-[#666] outline-none focus:border-[#ff6b35]"
/>
</div>
{/* Filters */}
<div className="flex gap-3">
<select
value={filterRole}
onChange={(e) => setFilterRole(e.target.value)}
className="bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2 text-white outline-none focus:border-[#ff6b35]"
>
<option value="all"></option>
<option value="admin"></option>
<option value="user"></option>
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2 text-white outline-none focus:border-[#ff6b35]"
>
<option value="all"></option>
<option value="active"></option>
<option value="banned"></option>
</select>
<button
onClick={fetchUsers}
className="p-2 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg hover:bg-[#2a2a2a] transition-colors"
>
<RefreshCw className="w-5 h-5 text-[#b0b0b0]" />
</button>
</div>
</div>
{/* Batch Actions */}
{selectedUsers.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="flex items-center gap-3 pt-4 border-t border-[#2a2a2a]"
>
<span className="text-[#b0b0b0] text-sm"> {selectedUsers.length} </span>
<button className="px-3 py-1.5 text-sm bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 transition-colors">
</button>
<button className="px-3 py-1.5 text-sm bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors">
</button>
</motion.div>
)}
</div>
{/* Users Table */}
<div className="bg-[#111111] border border-[#2a2a2a] rounded-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-[#2a2a2a]">
<th className="px-4 py-3 text-left">
<input
type="checkbox"
onChange={handleSelectAll}
checked={selectedUsers.length === paginatedUsers.length && paginatedUsers.length > 0}
className="w-4 h-4 rounded border-[#2a2a2a] bg-[#0a0a0a] text-[#ff6b35] focus:ring-[#ff6b35]"
/>
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-4 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-4 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-4 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-4 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-4 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-4 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-[#b0b0b0]">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
...
</td>
</tr>
) : paginatedUsers.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-[#666]">
</td>
</tr>
) : (
paginatedUsers.map((user) => (
<tr key={user.id} className="border-b border-[#2a2a2a] last:border-0 hover:bg-[#1a1a1a]">
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedUsers.includes(user.id)}
onChange={() => handleSelectUser(user.id)}
className="w-4 h-4 rounded border-[#2a2a2a] bg-[#0a0a0a] text-[#ff6b35] focus:ring-[#ff6b35]"
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-[#ff6b35] to-[#ff9f43] rounded-full flex items-center justify-center">
<span className="text-white font-medium">
{user.username[0]}
</span>
</div>
<div>
<p className="text-white font-medium">{user.username}</p>
<p className="text-xs text-[#666]">{user.email}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
user.role === 'admin'
? 'bg-purple-500/20 text-purple-400'
: 'bg-blue-500/20 text-blue-400'
}`}>
{user.role === 'admin' ? '管理员' : '普通用户'}
</span>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
user.status === 'active'
? 'bg-green-500/20 text-green-400'
: 'bg-red-500/20 text-red-400'
}`}>
{user.status === 'active' ? '正常' : '已封禁'}
</span>
</td>
<td className="px-4 py-3 text-[#b0b0b0] text-sm">{user.createdAt}</td>
<td className="px-4 py-3 text-[#b0b0b0] text-sm">{user.lastLogin}</td>
<td className="px-4 py-3 text-[#b0b0b0] text-sm">{user.favoritesCount}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => handleBanUser(user.id)}
className="p-1.5 hover:bg-[#2a2a2a] rounded-lg transition-colors"
title={user.status === 'active' ? '封禁' : '解封'}
>
{user.status === 'active' ? (
<Ban className="w-4 h-4 text-red-400" />
) : (
<CheckCircle className="w-4 h-4 text-green-400" />
)}
</button>
<button
onClick={() => handleDeleteUser(user.id)}
className="p-1.5 hover:bg-[#2a2a2a] rounded-lg transition-colors"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-400" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-[#2a2a2a]">
<p className="text-sm text-[#b0b0b0]">
{filteredUsers.length} {currentPage}/{totalPages}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors disabled:opacity-50"
>
<ChevronLeft className="w-4 h-4 text-[#b0b0b0]" />
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => (
<button
key={i + 1}
onClick={() => setCurrentPage(i + 1)}
className={`w-8 h-8 rounded-lg text-sm transition-colors ${
currentPage === i + 1
? 'bg-[#ff6b35] text-white'
: 'hover:bg-[#2a2a2a] text-[#b0b0b0]'
}`}
>
{i + 1}
</button>
))}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors disabled:opacity-50"
>
<ChevronRight className="w-4 h-4 text-[#b0b0b0]" />
</button>
</div>
</div>
</div>
</div>
);
}

@ -0,0 +1,354 @@
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, User, Lock, Mail, Eye, EyeOff, Loader2 } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
interface AuthModalProps {
isOpen: boolean;
onClose: () => void;
defaultTab?: 'login' | 'register';
}
export function AuthModal({ isOpen, onClose, defaultTab = 'login' }: AuthModalProps) {
const [activeTab, setActiveTab] = useState<'login' | 'register'>(defaultTab);
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
// 表单数据
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const [registerUsername, setRegisterUsername] = useState('');
const [registerEmail, setRegisterEmail] = useState('');
const [registerPassword, setRegisterPassword] = useState('');
const [registerConfirmPassword, setRegisterConfirmPassword] = useState('');
const { login, register } = useAuth();
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(loginEmail, loginPassword);
onClose();
// 清空表单
setLoginEmail('');
setLoginPassword('');
} catch (err: any) {
setError(err.message || '登录失败,请检查邮箱和密码');
} finally {
setIsLoading(false);
}
};
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// 验证
if (registerPassword !== registerConfirmPassword) {
setError('两次输入的密码不一致');
return;
}
if (registerPassword.length < 6) {
setError('密码长度至少为6位');
return;
}
setIsLoading(true);
try {
await register(registerUsername, registerEmail, registerPassword);
onClose();
// 清空表单
setRegisterUsername('');
setRegisterEmail('');
setRegisterPassword('');
setRegisterConfirmPassword('');
} catch (err: any) {
setError(err.message || '注册失败,请检查输入信息');
} finally {
setIsLoading(false);
}
};
const switchTab = (tab: 'login' | 'register') => {
setActiveTab(tab);
setError('');
};
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl w-full max-w-md overflow-hidden"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[#2a2a2a]">
<div className="flex gap-6">
<button
onClick={() => switchTab('login')}
className={`text-lg font-medium transition-colors ${
activeTab === 'login' ? 'text-[#ff6b35]' : 'text-[#b0b0b0] hover:text-white'
}`}
>
</button>
<button
onClick={() => switchTab('register')}
className={`text-lg font-medium transition-colors ${
activeTab === 'register' ? 'text-[#ff6b35]' : 'text-[#b0b0b0] hover:text-white'
}`}
>
</button>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors"
>
<X className="w-5 h-5 text-[#b0b0b0]" />
</button>
</div>
{/* Error Message */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="px-6 pt-4"
>
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-red-400 text-sm">
{error}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Content */}
<div className="p-6">
<AnimatePresence mode="wait">
{activeTab === 'login' ? (
<motion.form
key="login"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.15 }}
onSubmit={handleLogin}
className="space-y-4"
>
{/* Email */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type="email"
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="请输入邮箱"
required
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg pl-10 pr-4 py-3 text-white placeholder-[#666] outline-none focus:border-[#ff6b35] transition-colors"
/>
</div>
</div>
{/* Password */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type={showPassword ? 'text' : 'password'}
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="请输入密码"
required
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg pl-10 pr-12 py-3 text-white placeholder-[#666] outline-none focus:border-[#ff6b35] transition-colors"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-[#2a2a2a] rounded transition-colors"
>
{showPassword ? (
<EyeOff className="w-4 h-4 text-[#666]" />
) : (
<Eye className="w-4 h-4 text-[#666]" />
)}
</button>
</div>
</div>
{/* Submit */}
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-[#ff6b35] to-[#ff9f43] text-white font-medium py-3 rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
...
</>
) : (
'登录'
)}
</button>
{/* Switch */}
<p className="text-center text-sm text-[#b0b0b0]">
<button
type="button"
onClick={() => switchTab('register')}
className="text-[#ff6b35] hover:underline ml-1"
>
</button>
</p>
</motion.form>
) : (
<motion.form
key="register"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.15 }}
onSubmit={handleRegister}
className="space-y-4"
>
{/* Username */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type="text"
value={registerUsername}
onChange={(e) => setRegisterUsername(e.target.value)}
placeholder="请输入用户名"
required
minLength={2}
maxLength={20}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg pl-10 pr-4 py-3 text-white placeholder-[#666] outline-none focus:border-[#ff6b35] transition-colors"
/>
</div>
</div>
{/* Email */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type="email"
value={registerEmail}
onChange={(e) => setRegisterEmail(e.target.value)}
placeholder="请输入邮箱"
required
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg pl-10 pr-4 py-3 text-white placeholder-[#666] outline-none focus:border-[#ff6b35] transition-colors"
/>
</div>
</div>
{/* Password */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type={showPassword ? 'text' : 'password'}
value={registerPassword}
onChange={(e) => setRegisterPassword(e.target.value)}
placeholder="请输入密码至少6位"
required
minLength={6}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg pl-10 pr-12 py-3 text-white placeholder-[#666] outline-none focus:border-[#ff6b35] transition-colors"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-[#2a2a2a] rounded transition-colors"
>
{showPassword ? (
<EyeOff className="w-4 h-4 text-[#666]" />
) : (
<Eye className="w-4 h-4 text-[#666]" />
)}
</button>
</div>
</div>
{/* Confirm Password */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type={showPassword ? 'text' : 'password'}
value={registerConfirmPassword}
onChange={(e) => setRegisterConfirmPassword(e.target.value)}
placeholder="请再次输入密码"
required
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg pl-10 pr-4 py-3 text-white placeholder-[#666] outline-none focus:border-[#ff6b35] transition-colors"
/>
</div>
</div>
{/* Submit */}
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-[#ff6b35] to-[#ff9f43] text-white font-medium py-3 rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
...
</>
) : (
'注册'
)}
</button>
{/* Switch */}
<p className="text-center text-sm text-[#b0b0b0]">
<button
type="button"
onClick={() => switchTab('login')}
className="text-[#ff6b35] hover:underline ml-1"
>
</button>
</p>
</motion.form>
)}
</AnimatePresence>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

@ -1,7 +1,12 @@
import { useState, useEffect, useRef } from 'react';
import { TrendingUp, Search, Clock, X, Building2, TrendingUp as TrendingUpIcon } from 'lucide-react';
import {
TrendingUp, Search, Clock, X, Building2, TrendingUp as TrendingUpIcon,
User, LogOut, ChevronDown, Heart
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { stockDataService } from '@/services/stockData';
import { stockApi } from '@/services/api';
import { useAuth } from '@/contexts/AuthContext';
import { AuthModal } from './AuthModal';
import type { Sector, Stock } from '@/types';
interface NavbarProps {
@ -15,8 +20,14 @@ export function Navbar({ onSectorClick, onStockClick }: NavbarProps) {
const [searchOpen, setSearchOpen] = useState(false);
const [searchKeyword, setSearchKeyword] = useState('');
const [searchResults, setSearchResults] = useState<{ sectors: Sector[]; stocks: Stock[] }>({ sectors: [], stocks: [] });
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
const [authDefaultTab, setAuthDefaultTab] = useState<'login' | 'register'>('login');
const [userMenuOpen, setUserMenuOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const searchContainerRef = useRef<HTMLDivElement>(null);
const userMenuRef = useRef<HTMLDivElement>(null);
const { user, isAuthenticated, logout } = useAuth();
useEffect(() => {
const handleScroll = () => {
@ -40,20 +51,32 @@ export function Navbar({ onSectorClick, onStockClick }: NavbarProps) {
if (searchContainerRef.current && !searchContainerRef.current.contains(e.target as Node)) {
setSearchOpen(false);
}
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
setUserMenuOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 搜索逻辑
// 搜索逻辑 - 使用后端API
useEffect(() => {
if (searchKeyword.trim().length >= 1) {
const sectors = stockDataService.searchSectors(searchKeyword);
const stocks = stockDataService.searchStocks(searchKeyword);
setSearchResults({ sectors, stocks });
} else {
setSearchResults({ sectors: [], stocks: [] });
}
const fetchSearchResults = async () => {
if (searchKeyword.trim().length >= 1) {
try {
const results = await stockApi.search(searchKeyword);
setSearchResults(results);
} catch (error) {
console.error('Search failed:', error);
setSearchResults({ sectors: [], stocks: [] });
}
} else {
setSearchResults({ sectors: [], stocks: [] });
}
};
const debounceTimer = setTimeout(fetchSearchResults, 300);
return () => clearTimeout(debounceTimer);
}, [searchKeyword]);
// 打开搜索时聚焦输入框
@ -75,6 +98,21 @@ export function Navbar({ onSectorClick, onStockClick }: NavbarProps) {
onStockClick?.(stock.code);
};
const openLogin = () => {
setAuthDefaultTab('login');
setIsAuthModalOpen(true);
};
const openRegister = () => {
setAuthDefaultTab('register');
setIsAuthModalOpen(true);
};
const handleLogout = () => {
logout();
setUserMenuOpen(false);
};
const navItems = [
{ label: '首页', href: '#overview' },
{ label: '动量分析', href: '#momentum' },
@ -86,174 +124,250 @@ export function Navbar({ onSectorClick, onStockClick }: NavbarProps) {
const hasResults = searchResults.sectors.length > 0 || searchResults.stocks.length > 0;
return (
<motion.nav
initial={{ y: -100 }}
animate={{ y: 0 }}
transition={{ duration: 0.5, ease: [0.165, 0.84, 0.44, 1] }}
className={`fixed top-0 left-0 right-0 z-50 h-16 transition-all duration-300 ${
scrolled ? 'glass border-b border-[#2a2a2a]' : ''
}`}
>
<div className="max-w-7xl mx-auto px-4 h-full flex items-center justify-between">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-[#ff6b35] to-[#ff9f43] rounded-lg flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-white" />
<>
<motion.nav
initial={{ y: -100 }}
animate={{ y: 0 }}
transition={{ duration: 0.5, ease: [0.165, 0.84, 0.44, 1] }}
className={`fixed top-0 left-0 right-0 z-50 h-16 transition-all duration-300 ${
scrolled ? 'glass border-b border-[#2a2a2a]' : ''
}`}
>
<div className="max-w-7xl mx-auto px-4 h-full flex items-center justify-between">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-[#ff6b35] to-[#ff9f43] rounded-lg flex items-center justify-center">
<TrendingUp className="w-6 h-6 text-white" />
</div>
<span className="text-xl font-bold text-white">A</span>
</div>
<span className="text-xl font-bold text-white">A</span>
</div>
{/* Navigation */}
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<a
key={item.label}
href={item.href}
className="px-4 py-2 text-sm text-[#b0b0b0] hover:text-[#ff6b35] transition-colors rounded-lg hover:bg-[#1a1a1a]"
>
{item.label}
</a>
))}
</div>
{/* Right Section */}
<div className="flex items-center gap-4">
{/* Search */}
<div className="relative" ref={searchContainerRef}>
<AnimatePresence>
{searchOpen ? (
<motion.div
initial={{ width: 40, opacity: 0 }}
animate={{ width: 320, opacity: 1 }}
exit={{ width: 40, opacity: 0 }}
transition={{ duration: 0.2 }}
className="relative"
>
<div className="flex items-center bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg overflow-hidden">
<Search className="w-5 h-5 text-[#b0b0b0] ml-3" />
<input
ref={searchInputRef}
type="text"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="搜索版块或个股..."
className="flex-1 bg-transparent px-3 py-2 text-sm text-white placeholder-[#666] outline-none"
/>
<button
onClick={() => {
setSearchOpen(false);
setSearchKeyword('');
}}
className="p-2 hover:bg-[#2a2a2a] transition-colors"
>
<X className="w-4 h-4 text-[#b0b0b0]" />
</button>
</div>
{/* Navigation */}
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<a
key={item.label}
href={item.href}
className="px-4 py-2 text-sm text-[#b0b0b0] hover:text-[#ff6b35] transition-colors rounded-lg hover:bg-[#1a1a1a]"
>
{item.label}
</a>
))}
</div>
{/* Search Results Dropdown */}
<AnimatePresence>
{hasResults && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute top-full left-0 right-0 mt-2 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl shadow-2xl overflow-hidden max-h-80 overflow-y-auto"
{/* Right Section */}
<div className="flex items-center gap-4">
{/* Search */}
<div className="relative" ref={searchContainerRef}>
<AnimatePresence>
{searchOpen ? (
<motion.div
initial={{ width: 40, opacity: 0 }}
animate={{ width: 320, opacity: 1 }}
exit={{ width: 40, opacity: 0 }}
transition={{ duration: 0.2 }}
className="relative"
>
<div className="flex items-center bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg overflow-hidden">
<Search className="w-5 h-5 text-[#b0b0b0] ml-3" />
<input
ref={searchInputRef}
type="text"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
placeholder="搜索版块或个股..."
className="flex-1 bg-transparent px-3 py-2 text-sm text-white placeholder-[#666] outline-none"
/>
<button
onClick={() => {
setSearchOpen(false);
setSearchKeyword('');
}}
className="p-2 hover:bg-[#2a2a2a] transition-colors"
>
{/* Sectors */}
{searchResults.sectors.length > 0 && (
<div>
<div className="px-4 py-2 bg-[#0a0a0a] text-xs text-[#b0b0b0] flex items-center gap-2">
<Building2 className="w-3 h-3" />
({searchResults.sectors.length})
</div>
{searchResults.sectors.map((sector) => (
<button
key={sector.code}
onClick={() => handleSectorSelect(sector)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-[#2a2a2a] transition-colors text-left"
>
<div>
<div className="text-white font-medium">{sector.name}</div>
<div className="text-xs text-[#b0b0b0]"> #{sector.rank} · {sector.momentumScore?.toFixed(0)}</div>
</div>
<div className={`text-sm font-medium number-font ${
sector.changePercent >= 0 ? 'text-[#ff3b30]' : 'text-[#00c853]'
}`}>
{sector.changePercent >= 0 ? '+' : ''}{sector.changePercent.toFixed(2)}%
</div>
</button>
))}
</div>
)}
<X className="w-4 h-4 text-[#b0b0b0]" />
</button>
</div>
{/* Stocks */}
{searchResults.stocks.length > 0 && (
<div>
<div className="px-4 py-2 bg-[#0a0a0a] text-xs text-[#b0b0b0] flex items-center gap-2">
<TrendingUpIcon className="w-3 h-3" />
({searchResults.stocks.length})
</div>
{searchResults.stocks.map((stock) => (
<button
key={stock.code}
onClick={() => handleStockSelect(stock)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-[#2a2a2a] transition-colors text-left"
>
<div>
<div className="text-white font-medium">{stock.name}</div>
<div className="text-xs text-[#b0b0b0]">{stock.code} · {stock.industry}</div>
</div>
<div className="text-right">
<div className="text-white number-font">{stock.price.toFixed(2)}</div>
<div className={`text-xs number-font ${
stock.changePercent >= 0 ? 'text-[#ff3b30]' : 'text-[#00c853]'
{/* Search Results Dropdown */}
<AnimatePresence>
{hasResults && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute top-full left-0 right-0 mt-2 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl shadow-2xl overflow-hidden max-h-80 overflow-y-auto"
>
{/* Sectors */}
{searchResults.sectors.length > 0 && (
<div>
<div className="px-4 py-2 bg-[#0a0a0a] text-xs text-[#b0b0b0] flex items-center gap-2">
<Building2 className="w-3 h-3" />
({searchResults.sectors.length})
</div>
{searchResults.sectors.map((sector) => (
<button
key={sector.code}
onClick={() => handleSectorSelect(sector)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-[#2a2a2a] transition-colors text-left"
>
<div>
<div className="text-white font-medium">{sector.name}</div>
<div className="text-xs text-[#b0b0b0]"> #{sector.rank} · {sector.momentumScore?.toFixed(0)}</div>
</div>
<div className={`text-sm font-medium number-font ${
sector.changePercent >= 0 ? 'text-[#ff3b30]' : 'text-[#00c853]'
}`}>
{stock.changePercent >= 0 ? '+' : ''}{stock.changePercent.toFixed(2)}%
{sector.changePercent >= 0 ? '+' : ''}{sector.changePercent.toFixed(2)}%
</div>
</div>
</button>
))}
</div>
)}
</motion.div>
)}
</button>
))}
</div>
)}
{/* No Results */}
{searchKeyword.trim().length >= 1 && !hasResults && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute top-full left-0 right-0 mt-2 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl shadow-2xl p-4 text-center"
>
<div className="text-[#b0b0b0] text-sm"></div>
<div className="text-xs text-[#666] mt-1"> "半导体" "茅台"</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
) : (
<motion.button
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
onClick={() => setSearchOpen(true)}
className="p-2 hover:bg-[#1a1a1a] rounded-lg transition-colors"
{/* Stocks */}
{searchResults.stocks.length > 0 && (
<div>
<div className="px-4 py-2 bg-[#0a0a0a] text-xs text-[#b0b0b0] flex items-center gap-2">
<TrendingUpIcon className="w-3 h-3" />
({searchResults.stocks.length})
</div>
{searchResults.stocks.map((stock) => (
<button
key={stock.code}
onClick={() => handleStockSelect(stock)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-[#2a2a2a] transition-colors text-left"
>
<div>
<div className="text-white font-medium">{stock.name}</div>
<div className="text-xs text-[#b0b0b0]">{stock.code} · {stock.industry}</div>
</div>
<div className="text-right">
<div className="text-white number-font">{stock.price.toFixed(2)}</div>
<div className={`text-xs number-font ${
stock.changePercent >= 0 ? 'text-[#ff3b30]' : 'text-[#00c853]'
}`}>
{stock.changePercent >= 0 ? '+' : ''}{stock.changePercent.toFixed(2)}%
</div>
</div>
</button>
))}
</div>
)}
</motion.div>
)}
{/* No Results */}
{searchKeyword.trim().length >= 1 && !hasResults && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute top-full left-0 right-0 mt-2 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl shadow-2xl p-4 text-center"
>
<div className="text-[#b0b0b0] text-sm"></div>
<div className="text-xs text-[#666] mt-1"> "半导体" "茅台"</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
) : (
<motion.button
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
onClick={() => setSearchOpen(true)}
className="p-2 hover:bg-[#1a1a1a] rounded-lg transition-colors"
>
<Search className="w-5 h-5 text-[#b0b0b0]" />
</motion.button>
)}
</AnimatePresence>
</div>
{/* Time */}
<div className="hidden sm:flex items-center gap-2 text-sm text-[#b0b0b0]">
<Clock className="w-4 h-4" />
<span className="number-font">
{currentTime.toLocaleTimeString('zh-CN', { hour12: false })}
</span>
</div>
{/* User Section */}
{isAuthenticated && user ? (
<div className="relative" ref={userMenuRef}>
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-2 px-3 py-2 hover:bg-[#1a1a1a] rounded-lg transition-colors"
>
<Search className="w-5 h-5 text-[#b0b0b0]" />
</motion.button>
)}
</AnimatePresence>
</div>
<div className="w-8 h-8 bg-gradient-to-br from-[#ff6b35] to-[#ff9f43] rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-white" />
</div>
<span className="text-sm text-white hidden sm:block">{user.username}</span>
<ChevronDown className={`w-4 h-4 text-[#b0b0b0] transition-transform ${userMenuOpen ? 'rotate-180' : ''}`} />
</button>
{/* User Dropdown Menu */}
<AnimatePresence>
{userMenuOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute top-full right-0 mt-2 w-48 bg-[#1a1a1a] border border-[#2a2a2a] rounded-xl shadow-2xl overflow-hidden"
>
<div className="px-4 py-3 border-b border-[#2a2a2a]">
<div className="text-white font-medium">{user.username}</div>
<div className="text-xs text-[#b0b0b0] truncate">{user.email}</div>
</div>
<button
onClick={() => {
setUserMenuOpen(false);
// TODO: 打开自选股页面
}}
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-[#2a2a2a] transition-colors text-left"
>
<Heart className="w-4 h-4 text-[#b0b0b0]" />
<span className="text-sm text-white"></span>
</button>
{/* Time */}
<div className="hidden sm:flex items-center gap-2 text-sm text-[#b0b0b0]">
<Clock className="w-4 h-4" />
<span className="number-font">
{currentTime.toLocaleTimeString('zh-CN', { hour12: false })}
</span>
<button
onClick={handleLogout}
className="w-full px-4 py-3 flex items-center gap-3 hover:bg-[#2a2a2a] transition-colors text-left border-t border-[#2a2a2a]"
>
<LogOut className="w-4 h-4 text-[#b0b0b0]" />
<span className="text-sm text-white">退</span>
</button>
</motion.div>
)}
</AnimatePresence>
</div>
) : (
<div className="flex items-center gap-2">
<button
onClick={openLogin}
className="px-4 py-2 text-sm text-[#b0b0b0] hover:text-white transition-colors"
>
</button>
<button
onClick={openRegister}
className="px-4 py-2 text-sm bg-gradient-to-r from-[#ff6b35] to-[#ff9f43] text-white rounded-lg hover:opacity-90 transition-opacity"
>
</button>
</div>
)}
</div>
</div>
</div>
</motion.nav>
</motion.nav>
{/* Auth Modal */}
<AuthModal
isOpen={isAuthModalOpen}
onClose={() => setIsAuthModalOpen(false)}
defaultTab={authDefaultTab}
/>
</>
);
}

@ -6,7 +6,7 @@ import {
ComposedChart, Bar, Line
} from 'recharts';
import { CandlestickChart } from './CandlestickChart';
import { stockDataService } from '@/services/stockData';
import { sectorApi } from '@/services/api';
import type { Sector, MomentumStock, KLineData, SectorMomentumHistory } from '@/types';
interface SectorDetailModalProps {
@ -37,9 +37,23 @@ export function SectorDetailModal({ sector, isOpen, onClose, onStockClick }: Sec
useEffect(() => {
if (sector && isOpen) {
setRankHistory(stockDataService.getSectorRankHistory(sector.name));
setMomentumStocks(stockDataService.getSectorMomentumStocks(sector.name));
setKlineData(stockDataService.getSectorKLineData(sector.name, 60));
// 从后端 API 获取数据
const fetchData = async () => {
try {
const [historyData, momentumData, kline] = await Promise.all([
sectorApi.getRankHistory(sector.code, 30),
sectorApi.getMomentumStocks(sector.code),
sectorApi.getKLine(sector.code, 'day', 60)
]);
setRankHistory(historyData);
setMomentumStocks(momentumData);
setKlineData(kline);
} catch (error) {
console.error('Failed to fetch sector detail:', error);
}
};
fetchData();
}
}, [sector, isOpen]);

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, BarChart3, Activity, Target } from 'lucide-react';
import { CandlestickChart } from './CandlestickChart';
import { stockDataService } from '@/services/stockData';
import { stockApi } from '@/services/api';
import type { StockDetail, KLineData } from '@/types';
interface StockDetailModalProps {
@ -18,11 +18,40 @@ export function StockDetailModal({ stockCode, isOpen, onClose }: StockDetailModa
useEffect(() => {
if (stockCode && isOpen) {
setStock(stockDataService.getStockDetail(stockCode));
setKlineData(stockDataService.getKLineData(stockCode, 60));
// 从后端 API 获取数据
const fetchData = async () => {
try {
const [stockData, kline] = await Promise.all([
stockApi.getDetail(stockCode),
stockApi.getKLine(stockCode, timeRange, 60)
]);
setStock(stockData);
setKlineData(kline);
} catch (error) {
console.error('Failed to fetch stock detail:', error);
}
};
fetchData();
}
}, [stockCode, isOpen]);
// 当时间周期变化时重新获取K线数据
useEffect(() => {
if (stockCode && isOpen) {
const fetchKLine = async () => {
try {
const kline = await stockApi.getKLine(stockCode, timeRange, 60);
setKlineData(kline);
} catch (error) {
console.error('Failed to fetch kline:', error);
}
};
fetchKLine();
}
}, [timeRange, stockCode, isOpen]);
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();

@ -0,0 +1,113 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { userApi } from '@/services/api';
interface User {
id: string;
username: string;
email: string;
createdAt?: string;
}
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string) => Promise<void>;
logout: () => void;
fetchProfile: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// 检查本地存储的 token 并获取用户信息
useEffect(() => {
const initAuth = async () => {
const token = localStorage.getItem('token');
if (token) {
try {
await fetchProfile();
} catch (error) {
// Token 无效,清除存储
localStorage.removeItem('token');
}
}
setIsLoading(false);
};
initAuth();
}, []);
const fetchProfile = async () => {
try {
const profile = await userApi.getProfile();
setUser(profile);
} catch (error) {
console.error('Failed to fetch profile:', error);
throw error;
}
};
const login = async (email: string, password: string) => {
try {
const response = await userApi.login({ email, password });
localStorage.setItem('token', response.token);
setUser({
id: response.id,
username: response.username,
email: response.email,
});
} catch (error) {
console.error('Login failed:', error);
throw error;
}
};
const register = async (username: string, email: string, password: string) => {
try {
const response = await userApi.register({ username, email, password });
localStorage.setItem('token', response.token);
setUser({
id: response.id,
username: response.username,
email: response.email,
});
} catch (error) {
console.error('Register failed:', error);
throw error;
}
};
const logout = () => {
localStorage.removeItem('token');
setUser(null);
};
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
fetchProfile,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

@ -1,10 +1,20 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from '@/contexts/AuthContext'
import './index.css'
import App from './App.tsx'
import Admin from './admin/Admin.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/" element={<App />} />
<Route path="/admin/*" element={<Admin />} />
</Routes>
</AuthProvider>
</BrowserRouter>
</StrictMode>,
)

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { ArrowUp, ArrowDown, Calendar } from 'lucide-react';
import { stockDataService } from '@/services/stockData';
import { stockApi } from '@/services/api';
import { StockDetailModal } from '@/components/StockDetailModal';
import type { HighLowStock } from '@/types';
@ -112,13 +112,24 @@ export function HighLowStocks() {
useEffect(() => {
setMounted(true);
setNewHighStocks(stockDataService.getNewHighStocks());
setNewLowStocks(stockDataService.getNewLowStocks());
const interval = setInterval(() => {
setNewHighStocks(stockDataService.getNewHighStocks());
setNewLowStocks(stockDataService.getNewLowStocks());
}, 30000);
// 从后端 API 获取数据
const fetchData = async () => {
try {
const [highStocks, lowStocks] = await Promise.all([
stockApi.getNewHighStocks(20, 20),
stockApi.getNewLowStocks(20, 20)
]);
setNewHighStocks(highStocks);
setNewLowStocks(lowStocks);
} catch (error) {
console.error('Failed to fetch high/low stocks:', error);
}
};
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);

@ -1,7 +1,7 @@
import { useEffect, useState, useRef } from 'react';
import { motion } from 'framer-motion';
import { TrendingUp, TrendingDown, Activity } from 'lucide-react';
import { stockDataService } from '@/services/stockData';
import { marketApi } from '@/services/api';
import type { MarketIndex } from '@/types';
function AnimatedNumber({ value, decimals = 2 }: { value: number; decimals?: number }) {
@ -48,14 +48,25 @@ export function MarketOverview() {
useEffect(() => {
setMounted(true);
setIndices(stockDataService.getMarketIndices());
setUpDownStats(stockDataService.getUpDownStats());
// 从后端 API 获取数据
const fetchData = async () => {
try {
const [indicesData, upDownData] = await Promise.all([
marketApi.getIndices(),
marketApi.getUpDownStats()
]);
setIndices(indicesData);
setUpDownStats(upDownData);
} catch (error) {
console.error('Failed to fetch market data:', error);
}
};
fetchData();
// Update every 30 seconds
const interval = setInterval(() => {
setIndices(stockDataService.getMarketIndices());
setUpDownStats(stockDataService.getUpDownStats());
}, 30000);
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);

@ -1,7 +1,7 @@
import { useEffect, useState, useRef } from 'react';
import { motion } from 'framer-motion';
import { Zap, ChevronLeft, ChevronRight, BarChart2, Target } from 'lucide-react';
import { stockDataService } from '@/services/stockData';
import { stockApi } from '@/services/api';
import { StockDetailModal } from '@/components/StockDetailModal';
import type { MomentumStock } from '@/types';
@ -102,11 +102,20 @@ export function MomentumRecommendation() {
useEffect(() => {
setMounted(true);
setStocks(stockDataService.getMomentumStocks());
const interval = setInterval(() => {
setStocks(stockDataService.getMomentumStocks());
}, 30000);
// 从后端 API 获取数据
const fetchData = async () => {
try {
const stocksData = await stockApi.getMomentumRecommendation(15);
setStocks(stocksData);
} catch (error) {
console.error('Failed to fetch momentum stocks:', error);
}
};
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { Flame, ChevronRight, Trophy, ArrowUp, ArrowDown, Minus, TrendingDown } from 'lucide-react';
import { stockDataService } from '@/services/stockData';
import { sectorApi } from '@/services/api';
import { SectorDetailModal } from '@/components/SectorDetailModal';
import { StockDetailModal } from '@/components/StockDetailModal';
import type { Sector } from '@/types';
@ -16,11 +16,20 @@ export function MomentumSectors() {
useEffect(() => {
setMounted(true);
setSectors(stockDataService.getSectorsWithMomentum());
const interval = setInterval(() => {
setSectors(stockDataService.getSectorsWithMomentum());
}, 30000);
// 从后端 API 获取数据
const fetchData = async () => {
try {
const sectorsData = await sectorApi.getSectors('momentumScore', 'desc');
setSectors(sectorsData);
} catch (error) {
console.error('Failed to fetch sectors:', error);
}
};
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { BarChart3 } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import { stockDataService } from '@/services/stockData';
import { marketApi } from '@/services/api';
import type { PriceDistribution } from '@/types';
const CustomTooltip = ({ active, payload, label }: any) => {
@ -25,11 +25,20 @@ export function PriceDistribution() {
useEffect(() => {
setMounted(true);
setDistribution(stockDataService.getPriceDistribution());
const interval = setInterval(() => {
setDistribution(stockDataService.getPriceDistribution());
}, 30000);
// 从后端 API 获取数据
const fetchData = async () => {
try {
const distributionData = await marketApi.getPriceDistribution();
setDistribution(distributionData);
} catch (error) {
console.error('Failed to fetch price distribution:', error);
}
};
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);

Loading…
Cancel
Save