diff --git a/app/backend/.dockerignore b/app/backend/.dockerignore new file mode 100644 index 0000000..2f83403 --- /dev/null +++ b/app/backend/.dockerignore @@ -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/ diff --git a/app/backend/.env.docker b/app/backend/.env.docker new file mode 100644 index 0000000..011781a --- /dev/null +++ b/app/backend/.env.docker @@ -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 diff --git a/app/backend/.env.production b/app/backend/.env.production new file mode 100644 index 0000000..011781a --- /dev/null +++ b/app/backend/.env.production @@ -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 diff --git a/app/backend/DOCKER_FILES.md b/app/backend/DOCKER_FILES.md new file mode 100644 index 0000000..c56afc5 --- /dev/null +++ b/app/backend/DOCKER_FILES.md @@ -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` 或检查日志文件。 diff --git a/app/backend/DOCKER_QUICKREF.md b/app/backend/DOCKER_QUICKREF.md new file mode 100644 index 0000000..c027a90 --- /dev/null +++ b/app/backend/DOCKER_QUICKREF.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 +``` diff --git a/app/backend/DOCKER_README.md b/app/backend/DOCKER_README.md new file mode 100644 index 0000000..d2ae780 --- /dev/null +++ b/app/backend/DOCKER_README.md @@ -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 diff --git a/app/backend/DOCKER_SYNC.md b/app/backend/DOCKER_SYNC.md new file mode 100644 index 0000000..2ccd546 --- /dev/null +++ b/app/backend/DOCKER_SYNC.md @@ -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 +``` + +### 问题 2:node_modules 不同步 + +**原因**:Volume 挂载覆盖了容器内的 node_modules + +**解决**: +```bash +# 开发模式已使用命名卷隔离 node_modules,通常无需处理 +# 如需重新安装依赖: +docker-compose -f docker-compose.dev.yml exec app npm install +``` + +### 问题 3:Prisma 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) diff --git a/app/backend/README_DOCKER_FULL.md b/app/backend/README_DOCKER_FULL.md new file mode 100644 index 0000000..5b70d12 --- /dev/null +++ b/app/backend/README_DOCKER_FULL.md @@ -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) diff --git a/app/backend/dev-start.bat b/app/backend/dev-start.bat new file mode 100644 index 0000000..38815a0 --- /dev/null +++ b/app/backend/dev-start.bat @@ -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 diff --git a/app/backend/dev-start.sh b/app/backend/dev-start.sh new file mode 100644 index 0000000..b5ec624 --- /dev/null +++ b/app/backend/dev-start.sh @@ -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 diff --git a/app/backend/docker-compose.dev.yml b/app/backend/docker-compose.dev.yml new file mode 100644 index 0000000..99da8fa --- /dev/null +++ b/app/backend/docker-compose.dev.yml @@ -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 diff --git a/app/backend/docker-start.bat b/app/backend/docker-start.bat new file mode 100644 index 0000000..35a790b --- /dev/null +++ b/app/backend/docker-start.bat @@ -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 diff --git a/app/backend/docker-start.sh b/app/backend/docker-start.sh new file mode 100644 index 0000000..140eaeb --- /dev/null +++ b/app/backend/docker-start.sh @@ -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 "==============================================" diff --git a/app/backend/init-scripts/01-init-database.sql b/app/backend/init-scripts/01-init-database.sql new file mode 100644 index 0000000..2b229a0 --- /dev/null +++ b/app/backend/init-scripts/01-init-database.sql @@ -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; diff --git a/app/backend/init-scripts/02-seed-data.sql b/app/backend/init-scripts/02-seed-data.sql new file mode 100644 index 0000000..80bfae3 --- /dev/null +++ b/app/backend/init-scripts/02-seed-data.sql @@ -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; diff --git a/app/backend/init-scripts/03-partition-maintenance.sql b/app/backend/init-scripts/03-partition-maintenance.sql new file mode 100644 index 0000000..90c6a34 --- /dev/null +++ b/app/backend/init-scripts/03-partition-maintenance.sql @@ -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; diff --git a/app/backend/verify-docker.js b/app/backend/verify-docker.js new file mode 100644 index 0000000..61ad8ee --- /dev/null +++ b/app/backend/verify-docker.js @@ -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); diff --git a/app/package-lock.json b/app/package-lock.json index 97be8e5..5147a97 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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", diff --git a/app/package.json b/app/package.json index 09a5f79..e5f0ee4 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/admin/Admin.tsx b/app/src/admin/Admin.tsx new file mode 100644 index 0000000..17803ce --- /dev/null +++ b/app/src/admin/Admin.tsx @@ -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 ( +
+ {/* Mobile Overlay */} + {isMobile && sidebarOpen && ( + setSidebarOpen(false)} + /> + )} + + {/* Sidebar */} + + {/* Logo */} +
+
+ +
+ {sidebarOpen && ( + +

管理后台

+

Admin Panel

+
+ )} +
+ + {/* Navigation */} + + + {/* User Info */} +
+ {sidebarOpen ? ( +
+
+ + {user?.username?.[0]?.toUpperCase() || 'A'} + +
+
+

+ {user?.username || '管理员'} +

+

+ {user?.email || 'admin@example.com'} +

+
+ +
+ ) : ( + + )} +
+
+ + {/* Main Content */} +
+ {/* Header */} +
+ + +
+ + 访问前台 → + +
+
+ + {/* Page Content */} +
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+
+ ); +} diff --git a/app/src/admin/pages/AIConfig.tsx b/app/src/admin/pages/AIConfig.tsx new file mode 100644 index 0000000..e5ff0e4 --- /dev/null +++ b/app/src/admin/pages/AIConfig.tsx @@ -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({ + provider: 'openai', + model: 'gpt-4', + apiKey: '', + apiUrl: 'https://api.openai.com/v1', + temperature: 0.7, + maxTokens: 2000, + enabled: true, + }); + + const [momentumConfig, setMomentumConfig] = useState({ + 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 ( +
+ {/* Header */} +
+
+

AI模型配置

+

配置AI分析模型和动量计算参数

+
+
+ + +
+
+ + {/* Test Result */} + {testResult && ( + + {testResult.success ? ( + + ) : ( + + )} + + {testResult.message} + + + )} + + {/* Save Status */} + {saveStatus !== 'idle' && ( + + {saveStatus === 'success' ? ( + <> + + 配置保存成功! + + ) : ( + <> + + 保存失败,请重试。 + + )} + + )} + +
+ {/* AI Provider Config */} + +
+ +

AI 服务提供商

+
+ +
+ {/* Enable/Disable */} +
+ 启用 AI 分析 + +
+ + {/* Provider */} +
+ + +
+ + {/* Model */} +
+ + +
+ + {/* API Key */} +
+ + 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]" + /> +
+ + {/* API URL */} +
+ + 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]" + /> +
+ + {/* Temperature */} +
+ + setAiConfig(prev => ({ ...prev, temperature: parseFloat(e.target.value) }))} + className="w-full accent-[#ff6b35]" + /> +
+ 精确 + 平衡 + 创意 +
+
+ + {/* Max Tokens */} +
+ + 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]" + /> +
+
+
+ + {/* Momentum Calculation Config */} + +
+ +

动量计算参数

+
+ +
+ {/* Calculation Period */} +
+ + setMomentumConfig(prev => ({ + ...prev, + calculationPeriod: parseInt(e.target.value) + }))} + className="w-full accent-[#ff6b35]" + /> +
+ + {/* Weight Settings */} +
+

权重配置

+ +
+ + setMomentumConfig(prev => ({ + ...prev, + weightPriceChange: parseFloat(e.target.value) + }))} + className="w-full accent-[#ff6b35]" + /> +
+ +
+ + setMomentumConfig(prev => ({ + ...prev, + weightVolume: parseFloat(e.target.value) + }))} + className="w-full accent-[#ff6b35]" + /> +
+ +
+ + setMomentumConfig(prev => ({ + ...prev, + weightTechnical: parseFloat(e.target.value) + }))} + className="w-full accent-[#ff6b35]" + /> +
+
+ + {/* Threshold Settings */} +
+

阈值配置

+ +
+ + setMomentumConfig(prev => ({ + ...prev, + thresholdStrong: parseInt(e.target.value) + }))} + className="w-full accent-[#ff6b35]" + /> +
+ +
+ + setMomentumConfig(prev => ({ + ...prev, + thresholdWeak: parseInt(e.target.value) + }))} + className="w-full accent-[#ff6b35]" + /> +
+
+
+
+
+
+ ); +} diff --git a/app/src/admin/pages/Dashboard.tsx b/app/src/admin/pages/Dashboard.tsx new file mode 100644 index 0000000..c071103 --- /dev/null +++ b/app/src/admin/pages/Dashboard.tsx @@ -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(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 ( +
+ {/* Header */} +
+

管理总览

+

系统运行状态和数据概况

+
+ + {/* Stats Grid */} +
+ {statCards.map((card, index) => ( + +
+
+

{card.label}

+

{card.value}

+ + {card.trend} 较上周 + +
+
+ +
+
+
+ ))} +
+ + {/* System Status */} +
+ {/* API Status */} + +
+ +

服务状态

+
+ +
+ {[ + { name: 'AKShare 数据服务', status: stats?.apiStatus.akshare }, + { name: 'MySQL 数据库', status: stats?.apiStatus.database }, + { name: 'Redis 缓存', status: stats?.apiStatus.redis }, + ].map((service) => ( +
+ {service.name} +
+
+ + {service.status ? '正常' : '异常'} + +
+
+ ))} +
+ + + {/* Recent Activity */} + +
+ +

最近活动

+
+ +
+ {[ + { 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) => ( +
+ {activity.time} +
+

{activity.action}

+

{activity.detail}

+
+
+ ))} +
+
+
+ + {/* Quick Actions */} + +

快速操作

+
+ + + + +
+
+
+ ); +} diff --git a/app/src/admin/pages/DataCheck.tsx b/app/src/admin/pages/DataCheck.tsx new file mode 100644 index 0000000..f6c6fe4 --- /dev/null +++ b/app/src/admin/pages/DataCheck.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [progress, setProgress] = useState({ + 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 ; + case 'incomplete': + return ; + case 'missing': + return ; + } + }; + + 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 ( +
+ {/* Header */} +
+
+

数据检测

+

检查数据完整性并一键缓冲缺失数据

+
+
+ + +
+
+ + {/* Progress Bar */} + {(progress.isChecking || progress.isBuffering) && ( + +
+ {progress.currentTask} + {progress.progress}% +
+
+ +
+
+ )} + + {/* Summary Cards */} +
+ +
+
+

数据完整度

+

{completionRate}%

+
+
+ +
+
+
+
+
+ + + +
+
+

缺失数据条数

+

{totalMissing.toLocaleString()}

+
+
+ +
+
+

+ 需要缓冲补充 +

+
+ + +
+
+

上次检查

+

+ {new Date().toLocaleTimeString('zh-CN')} +

+
+
+ +
+
+

+ 今天 +

+
+
+ + {/* Data Status Table */} +
+
+ +

数据状态详情

+
+ +
+ + + + + + + + + + + + + {loading ? ( + + + + ) : ( + dataStatus.map((item) => ( + + + + + + + + + )) + )} + +
数据项类型完整度状态最后更新备注
+ + 加载中... +
+
+ {item.type === 'stock' && } + {item.type === 'sector' && } + {item.type === 'index' && } + {item.type === 'kline' && } + {item.name} +
+
{item.type} +
+
+
+
+ + {((item.current / item.total) * 100).toFixed(1)}% + +
+
+
+ {getStatusIcon(item.status)} + + {getStatusText(item.status)} + +
+
{item.lastUpdate}{item.details || '-'}
+
+
+
+ ); +} diff --git a/app/src/admin/pages/DataImport.tsx b/app/src/admin/pages/DataImport.tsx new file mode 100644 index 0000000..213b608 --- /dev/null +++ b/app/src/admin/pages/DataImport.tsx @@ -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([]); + const [dragActive, setDragActive] = useState(false); + const [selectedType, setSelectedType] = useState('stock'); + const [showTemplateModal, setShowTemplateModal] = useState(false); + const fileInputRef = useRef(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 ; + case 'error': + return ; + case 'processing': + return
; + default: + return
; + } + }; + + 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 ( +
+ {/* Header */} +
+
+

数据导入

+

批量导入股票、交易历史等数据

+
+ +
+ + {/* Upload Area */} + + e.target.files?.[0] && handleFile(e.target.files[0])} + className="hidden" + /> + +
+ +
+ +

拖拽文件到此处上传

+

支持 CSV、Excel 格式文件

+ + {/* Data Type Selector */} +
+ {importTemplates.map(template => ( + + ))} +
+ + +
+ + {/* Import Tasks */} + {tasks.length > 0 && ( + +
+
+ +

导入记录

+
+ +
+ +
+ {tasks.map((task) => ( +
+
+
+ {getStatusIcon(task.status)} +
+

{task.name}

+

{task.fileName}

+
+ {task.createdAt} + 类型: {task.type} + {task.status !== 'pending' && ( + + 记录: {task.importedRecords.toLocaleString()} / {task.totalRecords.toLocaleString()} + + )} +
+ {task.errorMessage && ( +

{task.errorMessage}

+ )} +
+
+ +
+ + {task.status === 'processing' && ( +
+
+ 导入进度 + {task.progress.toFixed(0)}% +
+
+ +
+
+ )} + + {task.status === 'completed' && ( +
+ + 导入成功 +
+ )} +
+ ))} +
+
+ )} + + {/* Import Guide */} + +

导入说明

+
+
+
+ +
+
+

文件格式

+

+ 支持 CSV (.csv) 和 Excel (.xlsx, .xls) 格式,文件大小不超过 100MB +

+
+
+
+
+ +
+
+

数据验证

+

+ 系统会自动验证数据格式,错误数据将被跳过并生成报告 +

+
+
+
+
+ +
+
+

数据去重

+

+ 已存在的记录将被更新,新记录将被插入,不会重复导入 +

+
+
+
+
+ +
+
+

自动计算

+

+ 导入交易数据后,系统会自动重新计算动量指标和排名 +

+
+
+
+
+ + {/* Template Modal */} + {showTemplateModal && ( + setShowTemplateModal(false)} + > + e.stopPropagation()} + > +
+

下载导入模板

+ +
+
+ {importTemplates.map(template => ( +
+
+

{template.name}

+

+ 字段: {template.fields.join(', ')} +

+
+ +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/app/src/admin/pages/DataSourceConfig.tsx b/app/src/admin/pages/DataSourceConfig.tsx new file mode 100644 index 0000000..1d4c0aa --- /dev/null +++ b/app/src/admin/pages/DataSourceConfig.tsx @@ -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([ + { + 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({ + 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(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) => { + setSources(prev => prev.map(s => + s.id === sourceId ? { ...s, ...updates } : s + )); + }; + + return ( +
+ {/* Header */} +
+
+

数据源配置

+

配置数据来源和同步参数

+
+ +
+ + {/* Data Sources */} +
+ {sources.map((source, index) => ( + +
+
+
+ +
+
+

{source.name}

+
+ + {source.status === 'connected' ? '已连接' : + source.status === 'error' ? '连接错误' : '未连接'} + + + 最后同步: {source.lastSync} + +
+
+
+
+ + +
+
+ +
+
+ +
+ + 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]" + /> +
+
+ +
+ +
+ + 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]" + /> +
+
+ +
+ + 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]" + /> +
+ +
+ + +
+
+
+ ))} + + {/* Add New Source Button */} + +
+ + {/* Market Config */} + +
+ +

市场配置

+
+ +
+ {/* Trading Hours */} +
+

交易时间

+ +
+
+ + 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]" + /> +
+
+ + 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]" + /> +
+
+ +
+ 启用盘前数据 + +
+ +
+ 启用盘后数据 + +
+
+ + {/* Data Retention */} +
+

数据保留

+ +
+ + setMarketConfig(prev => ({ + ...prev, + dataRetention: parseInt(e.target.value) + }))} + className="w-full accent-[#ff6b35]" + /> +
+ 30天 + 1年 + 3年 +
+
+ +
+
+ +
+

数据保留策略

+

+ 超过保留期的历史数据将被自动归档或删除。建议至少保留 1 年的数据用于分析。 +

+
+
+
+
+
+
+
+ ); +} diff --git a/app/src/admin/pages/UserManagement.tsx b/app/src/admin/pages/UserManagement.tsx new file mode 100644 index 0000000..0aa63b2 --- /dev/null +++ b/app/src/admin/pages/UserManagement.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [currentPage, setCurrentPage] = useState(1); + const [selectedUsers, setSelectedUsers] = useState([]); + const [filterRole, setFilterRole] = useState('all'); + const [filterStatus, setFilterStatus] = useState('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) => { + 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 ( +
+ {/* Header */} +
+
+

用户管理

+

管理系统用户账号和权限

+
+ +
+ + {/* Filters */} +
+
+ {/* Search */} +
+ + 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]" + /> +
+ + {/* Filters */} +
+ + + + + +
+
+ + {/* Batch Actions */} + {selectedUsers.length > 0 && ( + + 已选择 {selectedUsers.length} 项 + + + + )} +
+ + {/* Users Table */} +
+
+ + + + + + + + + + + + + + + {loading ? ( + + + + ) : paginatedUsers.length === 0 ? ( + + + + ) : ( + paginatedUsers.map((user) => ( + + + + + + + + + + + )) + )} + +
+ 0} + className="w-4 h-4 rounded border-[#2a2a2a] bg-[#0a0a0a] text-[#ff6b35] focus:ring-[#ff6b35]" + /> + 用户角色状态注册时间最后登录自选股操作
+ + 加载中... +
+ 暂无数据 +
+ handleSelectUser(user.id)} + className="w-4 h-4 rounded border-[#2a2a2a] bg-[#0a0a0a] text-[#ff6b35] focus:ring-[#ff6b35]" + /> + +
+
+ + {user.username[0]} + +
+
+

{user.username}

+

{user.email}

+
+
+
+ + {user.role === 'admin' ? '管理员' : '普通用户'} + + + + {user.status === 'active' ? '正常' : '已封禁'} + + {user.createdAt}{user.lastLogin}{user.favoritesCount} +
+ + +
+
+
+ + {/* Pagination */} +
+

+ 共 {filteredUsers.length} 条记录,第 {currentPage}/{totalPages} 页 +

+
+ + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => ( + + ))} + +
+
+
+
+ ); +} diff --git a/app/src/components/AuthModal.tsx b/app/src/components/AuthModal.tsx new file mode 100644 index 0000000..a0f54ff --- /dev/null +++ b/app/src/components/AuthModal.tsx @@ -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 ( + + {isOpen && ( + + e.stopPropagation()} + > + {/* Header */} +
+
+ + +
+ +
+ + {/* Error Message */} + + {error && ( + +
+ {error} +
+
+ )} +
+ + {/* Content */} +
+ + {activeTab === 'login' ? ( + + {/* Email */} +
+ +
+ + 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" + /> +
+
+ + {/* Password */} +
+ +
+ + 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" + /> + +
+
+ + {/* Submit */} + + + {/* Switch */} +

+ 还没有账号? + +

+
+ ) : ( + + {/* Username */} +
+ +
+ + 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" + /> +
+
+ + {/* Email */} +
+ +
+ + 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" + /> +
+
+ + {/* Password */} +
+ +
+ + 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" + /> + +
+
+ + {/* Confirm Password */} +
+ +
+ + 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" + /> +
+
+ + {/* Submit */} + + + {/* Switch */} +

+ 已有账号? + +

+
+ )} +
+
+
+
+ )} +
+ ); +} diff --git a/app/src/components/Navbar.tsx b/app/src/components/Navbar.tsx index 0ccf11a..51ea310 100644 --- a/app/src/components/Navbar.tsx +++ b/app/src/components/Navbar.tsx @@ -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(null); const searchContainerRef = useRef(null); + const userMenuRef = useRef(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 ( - -
- {/* Logo */} -
-
- + <> + +
+ {/* Logo */} +
+
+ +
+ A股智投
- A股智投 -
- {/* Navigation */} -
- {navItems.map((item) => ( - - {item.label} - - ))} -
- - {/* Right Section */} -
- {/* Search */} -
- - {searchOpen ? ( - -
- - setSearchKeyword(e.target.value)} - placeholder="搜索版块或个股..." - className="flex-1 bg-transparent px-3 py-2 text-sm text-white placeholder-[#666] outline-none" - /> - -
+ {/* Navigation */} +
+ {navItems.map((item) => ( + + {item.label} + + ))} +
- {/* Search Results Dropdown */} - - {hasResults && ( - + {/* Search */} +
+ + {searchOpen ? ( + +
+ + setSearchKeyword(e.target.value)} + placeholder="搜索版块或个股..." + className="flex-1 bg-transparent px-3 py-2 text-sm text-white placeholder-[#666] outline-none" + /> + - ))} -
- )} + + +
- {/* Stocks */} - {searchResults.stocks.length > 0 && ( -
-
- - 个股 ({searchResults.stocks.length}) -
- {searchResults.stocks.map((stock) => ( -
- - ))} -
- )} - - )} + + ))} +
+ )} - {/* No Results */} - {searchKeyword.trim().length >= 1 && !hasResults && ( - -
未找到相关结果
-
试试搜索 "半导体" 或 "茅台"
-
- )} - - - ) : ( - setSearchOpen(true)} - className="p-2 hover:bg-[#1a1a1a] rounded-lg transition-colors" + {/* Stocks */} + {searchResults.stocks.length > 0 && ( +
+
+ + 个股 ({searchResults.stocks.length}) +
+ {searchResults.stocks.map((stock) => ( + + ))} +
+ )} + + )} + + {/* No Results */} + {searchKeyword.trim().length >= 1 && !hasResults && ( + +
未找到相关结果
+
试试搜索 "半导体" 或 "茅台"
+
+ )} + + + ) : ( + setSearchOpen(true)} + className="p-2 hover:bg-[#1a1a1a] rounded-lg transition-colors" + > + + + )} + +
+ + {/* Time */} +
+ + + {currentTime.toLocaleTimeString('zh-CN', { hour12: false })} + +
+ + {/* User Section */} + {isAuthenticated && user ? ( +
+
+
+ +
+ {user.username} + + - {/* Time */} -
- - - {currentTime.toLocaleTimeString('zh-CN', { hour12: false })} - + {/* User Dropdown Menu */} + + {userMenuOpen && ( + +
+
{user.username}
+
{user.email}
+
+ + + + +
+ )} +
+
+ ) : ( +
+ + +
+ )}
-
- + + + {/* Auth Modal */} + setIsAuthModalOpen(false)} + defaultTab={authDefaultTab} + /> + ); } diff --git a/app/src/components/SectorDetailModal.tsx b/app/src/components/SectorDetailModal.tsx index f512d0c..f2f878b 100644 --- a/app/src/components/SectorDetailModal.tsx +++ b/app/src/components/SectorDetailModal.tsx @@ -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]); diff --git a/app/src/components/StockDetailModal.tsx b/app/src/components/StockDetailModal.tsx index 85eb0bf..89112b8 100644 --- a/app/src/components/StockDetailModal.tsx +++ b/app/src/components/StockDetailModal.tsx @@ -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(); diff --git a/app/src/contexts/AuthContext.tsx b/app/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..527972a --- /dev/null +++ b/app/src/contexts/AuthContext.tsx @@ -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; + register: (username: string, email: string, password: string) => Promise; + logout: () => void; + fetchProfile: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(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 ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +} diff --git a/app/src/main.tsx b/app/src/main.tsx index bef5202..e99bfa3 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -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( - + + + + } /> + } /> + + + , ) diff --git a/app/src/sections/HighLowStocks.tsx b/app/src/sections/HighLowStocks.tsx index 9531539..67aa79f 100644 --- a/app/src/sections/HighLowStocks.tsx +++ b/app/src/sections/HighLowStocks.tsx @@ -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); }, []); diff --git a/app/src/sections/MarketOverview.tsx b/app/src/sections/MarketOverview.tsx index 65870fb..44be698 100644 --- a/app/src/sections/MarketOverview.tsx +++ b/app/src/sections/MarketOverview.tsx @@ -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); }, []); diff --git a/app/src/sections/MomentumRecommendation.tsx b/app/src/sections/MomentumRecommendation.tsx index 8f46a0b..6469a46 100644 --- a/app/src/sections/MomentumRecommendation.tsx +++ b/app/src/sections/MomentumRecommendation.tsx @@ -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); }, []); diff --git a/app/src/sections/MomentumSectors.tsx b/app/src/sections/MomentumSectors.tsx index b37d154..3746236 100644 --- a/app/src/sections/MomentumSectors.tsx +++ b/app/src/sections/MomentumSectors.tsx @@ -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); }, []); diff --git a/app/src/sections/PriceDistribution.tsx b/app/src/sections/PriceDistribution.tsx index 7e4c6b0..6e4907e 100644 --- a/app/src/sections/PriceDistribution.tsx +++ b/app/src/sections/PriceDistribution.tsx @@ -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); }, []);