From 9fd50387cbbdceaef93d68525cfacf8d6596b364 Mon Sep 17 00:00:00 2001 From: Lxy Date: Sun, 8 Mar 2026 01:14:09 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E9=A1=B5=E9=9D=A2=EF=BC=9B=E5=A2=9E=E5=8A=A0=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E7=99=BB=E9=99=86=E7=AD=89=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/akshare/Dockerfile | 25 + app/akshare/__pycache__/main.cpython-311.pyc | Bin 0 -> 10779 bytes app/akshare/main.py | 214 +++++ app/akshare/start.py | 20 + .../src/controllers/adminController.ts | 879 ++++++++++++++++++ app/backend/src/routes/adminRoutes.ts | 59 ++ app/backend/src/routes/index.ts | 4 + app/backend/src/services/dataSyncService.ts | 113 ++- app/backend/src/services/stockService.ts | 150 +-- app/src/admin/pages/AIConfig.tsx | 67 +- app/src/admin/pages/Dashboard.tsx | 150 +-- app/src/admin/pages/DataCheck.tsx | 630 ++++++++----- app/src/admin/pages/DataImport.tsx | 203 ++-- app/src/admin/pages/DataSourceConfig.tsx | 715 ++++++++------ app/src/admin/pages/UserManagement.tsx | 195 ++-- app/src/main.tsx | 4 + app/src/pages/Login.tsx | 176 ++++ app/src/pages/Register.tsx | 246 +++++ app/src/services/adminApi.ts | 460 +++++++++ 19 files changed, 3504 insertions(+), 806 deletions(-) create mode 100644 app/akshare/Dockerfile create mode 100644 app/akshare/__pycache__/main.cpython-311.pyc create mode 100644 app/akshare/main.py create mode 100644 app/akshare/start.py create mode 100644 app/backend/src/controllers/adminController.ts create mode 100644 app/backend/src/routes/adminRoutes.ts create mode 100644 app/src/pages/Login.tsx create mode 100644 app/src/pages/Register.tsx create mode 100644 app/src/services/adminApi.ts diff --git a/app/akshare/Dockerfile b/app/akshare/Dockerfile new file mode 100644 index 0000000..313d7d9 --- /dev/null +++ b/app/akshare/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.11-slim + +WORKDIR /app + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + && rm -rf /var/lib/apt/lists/* + +# 安装 Python 依赖 +RUN pip install --no-cache-dir \ + akshare \ + fastapi \ + uvicorn \ + pandas \ + numpy + +# 创建启动脚本 +COPY main.py /app/main.py + +EXPOSE 8000 + +# 启动 AKShare HTTP 服务 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/akshare/__pycache__/main.cpython-311.pyc b/app/akshare/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c732fbe9f2fa4ee1de5751baf07f0be33337e3db GIT binary patch literal 10779 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 literal 0 HcmV?d00001 diff --git a/app/akshare/main.py b/app/akshare/main.py new file mode 100644 index 0000000..ac5fb4e --- /dev/null +++ b/app/akshare/main.py @@ -0,0 +1,214 @@ +""" +AKShare HTTP API 服务 +提供股票数据接口 +""" +from fastapi import FastAPI, HTTPException, Query +from fastapi.middleware.cors import CORSMiddleware +import akshare as ak +import pandas as pd +from typing import Optional, List +import json + +app = FastAPI( + title="AKShare HTTP API", + description="AKShare 数据接口 HTTP 服务", + version="1.0.0" +) + +# 配置 CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def dataframe_to_records(df: pd.DataFrame) -> List[dict]: + """将 DataFrame 转换为可 JSON 序列化的记录列表""" + if df is None or df.empty: + return [] + # 处理 NaN 值 + df = df.replace({pd.NaT: None}) + df = df.where(pd.notnull(df), None) + return df.to_dict('records') + + +@app.get("/") +async def root(): + """健康检查""" + return { + "status": "healthy", + "service": "AKShare HTTP API", + "version": "1.0.0" + } + + +@app.get("/stock_zh_a_spot") +async def stock_zh_a_spot(): + """ + 获取 A 股实时行情数据 + """ + try: + df = ak.stock_zh_a_spot_em() + records = dataframe_to_records(df) + # 字段映射,统一返回格式 + mapped_records = [] + for record in records: + mapped_records.append({ + "code": record.get("代码"), + "name": record.get("名称"), + "price": record.get("最新价", 0), + "change": record.get("涨跌额", 0), + "change_percent": record.get("涨跌幅", 0), + "volume": record.get("成交量", 0), + "turnover": record.get("成交额", 0), + "open": record.get("开盘价", 0), + "high": record.get("最高价", 0), + "low": record.get("最低价", 0), + "pre_close": record.get("昨收", 0), + "turnover_rate": record.get("换手率", 0), + "amplitude": record.get("振幅", 0), + "market_cap": record.get("总市值", 0), + "pe": record.get("市盈率-动态", 0), + "pb": record.get("市净率", 0), + "industry": record.get("行业", ""), + }) + return mapped_records + except Exception as e: + raise HTTPException(status_code=500, detail=f"获取数据失败: {str(e)}") + + +@app.get("/stock_zh_a_hist") +async def stock_zh_a_hist( + symbol: str = Query(..., description="股票代码,如 000001"), + period: str = Query("daily", description="周期: daily/weekly/monthly"), + start_date: Optional[str] = Query(None, description="开始日期 YYYYMMDD"), + end_date: Optional[str] = Query(None, description="结束日期 YYYYMMDD"), + adjust: str = Query("qfq", description="复权方式: qfq-前复权, hfq-后复权, 不复权") +): + """ + 获取 A 股历史 K 线数据 + """ + try: + # 转换周期参数 + period_map = { + "daily": "daily", + "weekly": "weekly", + "monthly": "monthly" + } + ak_period = period_map.get(period, "daily") + + # 转换复权参数 + adjust_map = { + "qfq": "qfq", + "hfq": "hfq", + "": "" + } + ak_adjust = adjust_map.get(adjust, "qfq") + + df = ak.stock_zh_a_hist( + symbol=symbol, + period=ak_period, + start_date=start_date or "19700101", + end_date=end_date or "20500101", + adjust=ak_adjust + ) + records = dataframe_to_records(df) + + # 字段映射 + mapped_records = [] + for record in records: + mapped_records.append({ + "date": record.get("日期"), + "open": record.get("开盘", 0), + "high": record.get("最高", 0), + "low": record.get("最低", 0), + "close": record.get("收盘", 0), + "volume": record.get("成交量", 0), + "turnover": record.get("成交额", 0), + "amplitude": record.get("振幅", 0), + "change": record.get("涨跌幅", 0), + "change_amount": record.get("涨跌额", 0), + "turnover_rate": record.get("换手率", 0), + }) + return mapped_records + except Exception as e: + raise HTTPException(status_code=500, detail=f"获取数据失败: {str(e)}") + + +@app.get("/stock_zh_index_spot") +async def stock_zh_index_spot(): + """ + 获取股票指数实时行情 + """ + try: + df = ak.index_zh_a_spot_em() + records = dataframe_to_records(df) + mapped_records = [] + for record in records: + mapped_records.append({ + "code": record.get("代码"), + "name": record.get("名称"), + "price": record.get("最新价", 0), + "change": record.get("涨跌额", 0), + "change_percent": record.get("涨跌幅", 0), + "volume": record.get("成交量", 0), + "turnover": record.get("成交额", 0), + "open": record.get("开盘价", 0), + "high": record.get("最高价", 0), + "low": record.get("最低价", 0), + "pre_close": record.get("昨收", 0), + }) + return mapped_records + except Exception as e: + raise HTTPException(status_code=500, detail=f"获取数据失败: {str(e)}") + + +@app.get("/stock_sector_spot") +async def stock_sector_spot(): + """ + 获取板块行情数据 + """ + try: + df = ak.stock_board_industry_name_em() + records = dataframe_to_records(df) + mapped_records = [] + for record in records: + mapped_records.append({ + "code": record.get("代码"), + "name": record.get("名称"), + "change_percent": record.get("涨跌幅", 0), + }) + return mapped_records + except Exception as e: + raise HTTPException(status_code=500, detail=f"获取数据失败: {str(e)}") + + +@app.get("/stock_sector_cons") +async def stock_sector_cons( + symbol: str = Query(..., description="板块名称") +): + """ + 获取板块成分股 + """ + try: + df = ak.stock_board_industry_cons_em(symbol=symbol) + records = dataframe_to_records(df) + mapped_records = [] + for record in records: + mapped_records.append({ + "code": record.get("代码"), + "name": record.get("名称"), + "price": record.get("最新价", 0), + "change_percent": record.get("涨跌幅", 0), + }) + return mapped_records + except Exception as e: + raise HTTPException(status_code=500, detail=f"获取数据失败: {str(e)}") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/app/akshare/start.py b/app/akshare/start.py new file mode 100644 index 0000000..4cfa39f --- /dev/null +++ b/app/akshare/start.py @@ -0,0 +1,20 @@ +""" +AKShare HTTP 服务启动脚本 +Windows 用户双击运行或 python start.py 启动 +""" +import uvicorn +import sys + +if __name__ == "__main__": + print("Starting AKShare HTTP API Server...") + print("URL: http://localhost:8000") + print("Press Ctrl+C to stop") + print("-" * 50) + + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=False, + log_level="info" + ) diff --git a/app/backend/src/controllers/adminController.ts b/app/backend/src/controllers/adminController.ts new file mode 100644 index 0000000..11e3c43 --- /dev/null +++ b/app/backend/src/controllers/adminController.ts @@ -0,0 +1,879 @@ +import { Request, Response } from 'express'; +import axios from 'axios'; +import config from '../config'; +import prisma from '../config/database'; +import { cache } from '../config/redis'; +import logger from '../utils/logger'; +import { DataSyncService } from '../services/dataSyncService'; + +const dataSyncService = new DataSyncService(); + +// ========== 系统统计 ========== + +export const getSystemStats = async (_req: Request, res: Response) => { + try { + // 获取用户数量 + const totalUsers = await prisma.user.count(); + + // 获取股票数量 + const totalStocks = await prisma.stock.count(); + + // 获取版块数量 + const totalSectors = await prisma.sector.count(); + + // 计算数据完整度(简化计算) + const expectedQuotes = totalStocks * 30; // 假设应该有30天的数据 + const actualQuotes = await prisma.stockQuote.count(); + const dataCompleteness = expectedQuotes > 0 + ? Math.min(100, Math.round((actualQuotes / expectedQuotes) * 100)) + : 0; + + // 检查服务状态 + let akshareStatus = false; + try { + await axios.get(`${config.akshareUrl}/stock_zh_a_spot`, { timeout: 5000 }); + akshareStatus = true; + } catch { + akshareStatus = false; + } + + // 检查数据库连接 + let databaseStatus = false; + try { + await prisma.$queryRaw`SELECT 1`; + databaseStatus = true; + } catch { + databaseStatus = false; + } + + // 检查 Redis 连接 + let redisStatus = false; + try { + await cache.ping(); + redisStatus = true; + } catch { + redisStatus = false; + } + + res.json({ + code: 200, + message: 'success', + data: { + totalUsers, + totalStocks, + totalSectors, + dataCompleteness, + lastSync: new Date().toISOString(), + apiStatus: { + akshare: akshareStatus, + database: databaseStatus, + redis: redisStatus, + }, + }, + }); + } catch (error) { + logger.error('Failed to get system stats:', error); + res.status(500).json({ + code: 500, + message: '获取系统统计失败', + data: null, + }); + } +}; + +// ========== AKShare 数据源 ========== + +export const getAKShareStatus = async (_req: Request, res: Response) => { + try { + const response = await axios.get(`${config.akshareUrl}/stock_zh_a_spot`, { + timeout: 10000, + }); + + res.json({ + code: 200, + message: 'success', + data: { + connected: true, + version: '1.0.0', // AKShare 版本 + supportedApis: ['stock_zh_a_spot', 'stock_zh_a_hist', 'stock_zh_index_spot'], + }, + }); + } catch (error) { + logger.error('AKShare connection test failed:', error); + res.json({ + code: 200, + message: 'success', + data: { + connected: false, + version: null, + supportedApis: [], + }, + }); + } +}; + +export const testAKShareConnection = async (_req: Request, res: Response) => { + try { + const response = await axios.get(`${config.akshareUrl}/stock_zh_a_spot`, { + timeout: 10000, + }); + + if (Array.isArray(response.data) && response.data.length > 0) { + res.json({ + code: 200, + message: 'success', + data: { + success: true, + message: 'AKShare 连接成功,数据返回正常', + }, + }); + } else { + res.json({ + code: 200, + message: 'success', + data: { + success: false, + message: 'AKShare 连接成功,但数据返回异常', + }, + }); + } + } catch (error: any) { + logger.error('AKShare test failed:', error); + res.json({ + code: 200, + message: 'success', + data: { + success: false, + message: `AKShare 连接失败: ${error.message}`, + }, + }); + } +}; + +export const getAKShareConfig = (_req: Request, res: Response) => { + res.json({ + code: 200, + message: 'success', + data: { + baseUrl: config.akshareUrl, + timeout: 30000, + retryTimes: 3, + rateLimit: 100, + }, + }); +}; + +// 动态更新 AKShare URL(内存中,重启后恢复) +export const updateAKShareConfig = (req: Request, res: Response) => { + try { + const { baseUrl } = req.body; + + if (!baseUrl || typeof baseUrl !== 'string') { + res.status(400).json({ + code: 400, + message: '请提供有效的 baseUrl', + data: null, + }); + return; + } + + // 验证 URL 格式 + try { + new URL(baseUrl); + } catch { + res.status(400).json({ + code: 400, + message: 'URL 格式不正确', + data: null, + }); + return; + } + + // 更新内存中的配置 + (config as any).akshareUrl = baseUrl; + + // 更新 dataSyncService 中的地址 + (dataSyncService as any).akshareBaseUrl = baseUrl; + + logger.info(`AKShare URL updated to: ${baseUrl}`); + + res.json({ + code: 200, + message: 'success', + data: { + baseUrl, + note: '配置已更新,新连接将使用新地址', + }, + }); + } catch (error) { + logger.error('Update AKShare config failed:', error); + res.status(500).json({ + code: 500, + message: '更新配置失败', + data: null, + }); + } +}; + +// ========== 数据源管理 ========== + +export const getDataSources = async (_req: Request, res: Response) => { + try { + // 这里可以从数据库读取数据源配置 + const sources = [ + { + id: 'akshare', + name: 'AKShare 官方', + type: 'akshare', + url: config.akshareUrl, + enabled: true, + syncInterval: 5, + lastSync: new Date().toISOString(), + status: 'connected', + }, + ]; + + res.json({ + code: 200, + message: 'success', + data: sources, + }); + } catch (error) { + logger.error('Failed to get data sources:', error); + res.status(500).json({ + code: 500, + message: '获取数据源失败', + data: null, + }); + } +}; + +export const updateDataSource = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const config = req.body; + + // 这里应该更新数据库中的配置 + logger.info(`Updating data source ${id}:`, config); + + res.json({ + code: 200, + message: 'success', + data: { id, ...config }, + }); + } catch (error) { + logger.error('Failed to update data source:', error); + res.status(500).json({ + code: 500, + message: '更新数据源失败', + data: null, + }); + } +}; + +export const testDataSource = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + if (id === 'akshare') { + return testAKShareConnection(req, res); + } + + res.json({ + code: 200, + message: 'success', + data: { + success: false, + message: '未知数据源', + }, + }); + } catch (error) { + logger.error('Test data source failed:', error); + res.status(500).json({ + code: 500, + message: '测试连接失败', + data: null, + }); + } +}; + +export const triggerSync = async (_req: Request, res: Response) => { + try { + // 创建同步任务 + const taskId = `sync_${Date.now()}`; + + // 异步执行同步 + dataSyncService.syncRealTimeQuotes().catch(error => { + logger.error('Background sync failed:', error); + }); + + res.json({ + code: 200, + message: 'success', + data: { taskId }, + }); + } catch (error) { + logger.error('Trigger sync failed:', error); + res.status(500).json({ + code: 500, + message: '触发同步失败', + data: null, + }); + } +}; + +// ========== 数据检测 ========== + +export const getDataCheck = async (_req: Request, res: Response) => { + try { + const totalStocks = await prisma.stock.count(); + const totalSectors = await prisma.sector.count(); + + // 获取各类数据的统计 + const stockQuotesCount = await prisma.stockQuote.count(); + const sectorQuotesCount = await prisma.sectorQuote.count(); + const klineCount = await prisma.stockKLine.count(); + + // 计算预期数据量和实际数据量 + const expectedKlines = totalStocks * 365; // 假设应该有1年的K线 + + const checks = [ + { + id: '1', + name: '股票基础数据', + type: 'stock', + total: totalStocks, + current: totalStocks, + lastUpdate: new Date().toISOString(), + status: totalStocks > 0 ? 'complete' : 'missing', + }, + { + id: '2', + name: '版块数据', + type: 'sector', + total: totalSectors, + current: totalSectors, + lastUpdate: new Date().toISOString(), + status: totalSectors > 0 ? 'complete' : 'missing', + }, + { + id: '3', + name: '股票K线数据', + type: 'kline', + total: expectedKlines, + current: klineCount, + lastUpdate: new Date().toISOString(), + status: klineCount >= expectedKlines * 0.9 ? 'complete' : klineCount > 0 ? 'incomplete' : 'missing', + details: klineCount < expectedKlines ? `缺失 ${expectedKlines - klineCount} 条数据` : undefined, + }, + { + id: '4', + name: '实时行情数据', + type: 'stock', + total: totalStocks, + current: stockQuotesCount, + lastUpdate: new Date().toISOString(), + status: stockQuotesCount >= totalStocks ? 'complete' : 'incomplete', + }, + ]; + + res.json({ + code: 200, + message: 'success', + data: checks, + }); + } catch (error) { + logger.error('Failed to get data check:', error); + res.status(500).json({ + code: 500, + message: '获取数据检查失败', + data: null, + }); + } +}; + +export const runDataCheck = async (_req: Request, res: Response) => { + try { + const taskId = `check_${Date.now()}`; + + res.json({ + code: 200, + message: 'success', + data: { taskId }, + }); + } catch (error) { + logger.error('Run data check failed:', error); + res.status(500).json({ + code: 500, + message: '数据检查失败', + data: null, + }); + } +}; + +// ========== 数据缓冲 ========== + +export const bufferMissingData = async (req: Request, res: Response) => { + try { + const { startDate, endDate, types } = req.body; + const taskId = `buffer_${Date.now()}`; + + logger.info(`Starting buffer task ${taskId}:`, { startDate, endDate, types }); + + // 异步执行缓冲任务 + setImmediate(async () => { + try { + if (types.includes('stock')) { + await dataSyncService.syncAllStocks(); + } + if (types.includes('sector')) { + await dataSyncService.syncSectors(); + } + if (types.includes('kline')) { + // 获取所有股票代码 + const stocks = await prisma.stock.findMany({ select: { code: true } }); + for (const stock of stocks.slice(0, 10)) { // 限制前10只 + await dataSyncService.syncKLineData(stock.code); + } + } + } catch (error) { + logger.error('Buffer task failed:', error); + } + }); + + res.json({ + code: 200, + message: 'success', + data: { taskId }, + }); + } catch (error) { + logger.error('Buffer data failed:', error); + res.status(500).json({ + code: 500, + message: '数据缓冲失败', + data: null, + }); + } +}; + +export const bufferStocks = async (_req: Request, res: Response) => { + try { + const taskId = `buffer_stocks_${Date.now()}`; + + setImmediate(async () => { + try { + await dataSyncService.syncAllStocks(); + } catch (error) { + logger.error('Buffer stocks failed:', error); + } + }); + + res.json({ + code: 200, + message: 'success', + data: { taskId }, + }); + } catch (error) { + logger.error('Buffer stocks failed:', error); + res.status(500).json({ + code: 500, + message: '缓冲股票数据失败', + data: null, + }); + } +}; + +export const bufferSectors = async (_req: Request, res: Response) => { + try { + const taskId = `buffer_sectors_${Date.now()}`; + + setImmediate(async () => { + try { + await dataSyncService.syncSectors(); + } catch (error) { + logger.error('Buffer sectors failed:', error); + } + }); + + res.json({ + code: 200, + message: 'success', + data: { taskId }, + }); + } catch (error) { + logger.error('Buffer sectors failed:', error); + res.status(500).json({ + code: 500, + message: '缓冲版块数据失败', + data: null, + }); + } +}; + +export const bufferKLines = async (req: Request, res: Response) => { + try { + const { stockCodes } = req.body; + const taskId = `buffer_kline_${Date.now()}`; + + setImmediate(async () => { + try { + const stocks = stockCodes + ? await prisma.stock.findMany({ where: { code: { in: stockCodes } } }) + : await prisma.stock.findMany({ take: 10 }); + + for (const stock of stocks) { + await dataSyncService.syncKLineData(stock.code); + } + } catch (error) { + logger.error('Buffer klines failed:', error); + } + }); + + res.json({ + code: 200, + message: 'success', + data: { taskId }, + }); + } catch (error) { + logger.error('Buffer klines failed:', error); + res.status(500).json({ + code: 500, + message: '缓冲K线数据失败', + data: null, + }); + } +}; + +export const calculateMomentum = async (_req: Request, res: Response) => { + try { + const taskId = `calc_momentum_${Date.now()}`; + + setImmediate(async () => { + try { + await dataSyncService.calculateMomentumScores(); + } catch (error) { + logger.error('Calculate momentum failed:', error); + } + }); + + res.json({ + code: 200, + message: 'success', + data: { taskId }, + }); + } catch (error) { + logger.error('Calculate momentum failed:', error); + res.status(500).json({ + code: 500, + message: '计算动量指标失败', + data: null, + }); + } +}; + +// ========== 同步任务 ========== + +export const getSyncTask = async (req: Request, res: Response) => { + try { + const { taskId } = req.params; + + // 这里应该从缓存或数据库获取任务状态 + res.json({ + code: 200, + message: 'success', + data: { + id: taskId, + status: 'running', + progress: 50, + currentTask: '同步中...', + totalRecords: 1000, + processedRecords: 500, + }, + }); + } catch (error) { + logger.error('Get sync task failed:', error); + res.status(500).json({ + code: 500, + message: '获取任务状态失败', + data: null, + }); + } +}; + +// ========== 用户管理 ========== + +export const getUsers = async (req: Request, res: Response) => { + try { + const page = parseInt(req.query.page as string) || 1; + const pageSize = parseInt(req.query.pageSize as string) || 10; + const search = req.query.search as string; + const role = req.query.role as string; + const status = req.query.status as string; + + const where: any = {}; + + if (search) { + where.OR = [ + { username: { contains: search } }, + { email: { contains: search } }, + ]; + } + + if (role && role !== 'all') { + where.role = role; + } + + // Prisma schema 中可能没有 status 字段,这里简化处理 + // if (status && status !== 'all') { + // where.status = status; + // } + + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + username: true, + email: true, + role: true, + createdAt: true, + updatedAt: true, + _count: { + select: { + favorites: true, + }, + }, + }, + orderBy: { createdAt: 'desc' }, + }), + prisma.user.count({ where }), + ]); + + res.json({ + code: 200, + message: 'success', + data: { + users: users.map(u => ({ + ...u, + status: 'active', // 默认状态 + lastLogin: u.updatedAt.toISOString(), + favoritesCount: u._count.favorites, + })), + total, + page, + pageSize, + }, + }); + } catch (error) { + logger.error('Failed to get users:', error); + res.status(500).json({ + code: 500, + message: '获取用户列表失败', + data: null, + }); + } +}; + +export const updateUserStatus = async (req: Request, res: Response) => { + try { + const { userId } = req.params; + const { status } = req.body; + + // 注意:需要先在 Prisma schema 中添加 status 字段 + logger.info(`Updating user ${userId} status to ${status}`); + + res.json({ + code: 200, + message: 'success', + data: null, + }); + } catch (error) { + logger.error('Update user status failed:', error); + res.status(500).json({ + code: 500, + message: '更新用户状态失败', + data: null, + }); + } +}; + +export const deleteUser = async (req: Request, res: Response) => { + try { + const { userId } = req.params; + + await prisma.user.delete({ + where: { id: userId }, + }); + + res.json({ + code: 200, + message: 'success', + data: null, + }); + } catch (error) { + logger.error('Delete user failed:', error); + res.status(500).json({ + code: 500, + message: '删除用户失败', + data: null, + }); + } +}; + +export const batchUpdateUsers = async (req: Request, res: Response) => { + try { + const { userIds, action } = req.body; + + logger.info(`Batch ${action} users:`, userIds); + + if (action === 'delete') { + await prisma.user.deleteMany({ + where: { id: { in: userIds } }, + }); + } + + res.json({ + code: 200, + message: 'success', + data: null, + }); + } catch (error) { + logger.error('Batch update users failed:', error); + res.status(500).json({ + code: 500, + message: '批量操作失败', + data: null, + }); + } +}; + +// ========== 数据导入 ========== + +export const uploadImportFile = async (req: Request, res: Response) => { + try { + const { type } = req.body; + const taskId = `import_${Date.now()}`; + + logger.info(`Import file uploaded, type: ${type}`); + + res.json({ + code: 200, + message: 'success', + data: { + taskId, + filename: req.file?.originalname || 'unknown', + }, + }); + } catch (error) { + logger.error('Upload import file failed:', error); + res.status(500).json({ + code: 500, + message: '上传文件失败', + data: null, + }); + } +}; + +export const getImportTasks = async (_req: Request, res: Response) => { + try { + // 这里应该从缓存或数据库获取导入任务 + res.json({ + code: 200, + message: 'success', + data: [], + }); + } catch (error) { + logger.error('Get import tasks failed:', error); + res.status(500).json({ + code: 500, + message: '获取导入任务失败', + data: null, + }); + } +}; + +// ========== AI 配置 ========== + +export const getAIConfig = async (_req: Request, res: Response) => { + res.json({ + code: 200, + message: 'success', + data: { + provider: 'openai', + model: 'gpt-4', + apiUrl: 'https://api.openai.com/v1', + temperature: 0.7, + maxTokens: 2000, + enabled: false, + }, + }); +}; + +export const updateAIConfig = async (req: Request, res: Response) => { + logger.info('Update AI config:', req.body); + res.json({ + code: 200, + message: 'success', + data: null, + }); +}; + +export const testAIConnection = async (_req: Request, res: Response) => { + res.json({ + code: 200, + message: 'success', + data: { + success: false, + message: 'AI 功能暂未启用', + }, + }); +}; + +export const getMomentumConfig = async (_req: Request, res: Response) => { + res.json({ + code: 200, + message: 'success', + data: { + calculationPeriod: 20, + weightPriceChange: 0.4, + weightVolume: 0.3, + weightTechnical: 0.3, + thresholdStrong: 80, + thresholdWeak: 40, + }, + }); +}; + +export const updateMomentumConfig = async (req: Request, res: Response) => { + logger.info('Update momentum config:', req.body); + res.json({ + code: 200, + message: 'success', + data: null, + }); +}; + +// ========== 数据保留策略 ========== + +export const getDataRetention = async (_req: Request, res: Response) => { + res.json({ + code: 200, + message: 'success', + data: { + stockQuotesDays: 30, + klineDays: 365, + logsDays: 30, + }, + }); +}; + +export const updateDataRetention = async (req: Request, res: Response) => { + logger.info('Update data retention:', req.body); + res.json({ + code: 200, + message: 'success', + data: null, + }); +}; diff --git a/app/backend/src/routes/adminRoutes.ts b/app/backend/src/routes/adminRoutes.ts new file mode 100644 index 0000000..984f4a7 --- /dev/null +++ b/app/backend/src/routes/adminRoutes.ts @@ -0,0 +1,59 @@ +import { Router } from 'express'; +import * as adminController from '../controllers/adminController'; +import { authMiddleware } from '../middleware/auth'; + +const router = Router(); + +// 所有管理员路由需要认证 +router.use(authMiddleware); + +// ========== 系统统计 ========== +router.get('/stats', adminController.getSystemStats); + +// ========== AKShare 数据源 ========== +router.get('/akshare/status', adminController.getAKShareStatus); +router.get('/akshare/config', adminController.getAKShareConfig); +router.put('/akshare/config', adminController.updateAKShareConfig); +router.post('/akshare/test', adminController.testAKShareConnection); + +// ========== 数据源管理 ========== +router.get('/data-sources', adminController.getDataSources); +router.put('/data-sources/:id', adminController.updateDataSource); +router.post('/data-sources/:id/test', adminController.testDataSource); +router.post('/data-sources/:id/sync', adminController.triggerSync); + +// ========== 数据检测 ========== +router.get('/data-check', adminController.getDataCheck); +router.post('/data-check', adminController.runDataCheck); + +// ========== 数据缓冲 ========== +router.post('/buffer', adminController.bufferMissingData); +router.post('/buffer/stocks', adminController.bufferStocks); +router.post('/buffer/sectors', adminController.bufferSectors); +router.post('/buffer/kline', adminController.bufferKLines); +router.post('/calculate/momentum', adminController.calculateMomentum); + +// ========== 同步任务 ========== +router.get('/sync-tasks/:taskId', adminController.getSyncTask); + +// ========== 用户管理 ========== +router.get('/users', adminController.getUsers); +router.put('/users/:userId/status', adminController.updateUserStatus); +router.delete('/users/:userId', adminController.deleteUser); +router.post('/users/batch', adminController.batchUpdateUsers); + +// ========== 数据导入 ========== +router.get('/import/tasks', adminController.getImportTasks); + +// ========== AI 配置 ========== +router.get('/ai-config', adminController.getAIConfig); +router.put('/ai-config', adminController.updateAIConfig); +router.post('/ai-config/test', adminController.testAIConnection); +router.get('/momentum-config', adminController.getMomentumConfig); +router.put('/momentum-config', adminController.updateMomentumConfig); + +// ========== 数据保留策略 ========== +router.get('/data-retention', adminController.getDataRetention); +router.put('/data-retention', adminController.updateDataRetention); + +export default router; diff --git a/app/backend/src/routes/index.ts b/app/backend/src/routes/index.ts index 4e30da7..dae9906 100644 --- a/app/backend/src/routes/index.ts +++ b/app/backend/src/routes/index.ts @@ -3,6 +3,7 @@ import marketRoutes from './marketRoutes'; import sectorRoutes from './sectorRoutes'; import stockRoutes from './stockRoutes'; import userRoutes from './userRoutes'; +import adminRoutes from './adminRoutes'; const router = Router(); @@ -18,6 +19,9 @@ router.use('/stocks', stockRoutes); // 用户路由 router.use('/users', userRoutes); +// 管理员路由 +router.use('/admin', adminRoutes); + // 健康检查 router.get('/health', (_req, res) => { res.json({ diff --git a/app/backend/src/services/dataSyncService.ts b/app/backend/src/services/dataSyncService.ts index 7330532..8257c0b 100644 --- a/app/backend/src/services/dataSyncService.ts +++ b/app/backend/src/services/dataSyncService.ts @@ -239,7 +239,6 @@ export class DataSyncService { changePercent: Math.random() * 2 - 1, volume: BigInt(Math.floor(Math.random() * 500000000)), turnover: BigInt(Math.floor(Math.random() * 5000000000)), - sortOrder: indices.indexOf(index), }, }); } catch (error) { @@ -294,6 +293,116 @@ export class DataSyncService { return new Promise((resolve) => setTimeout(resolve, ms)); } + // 同步所有股票基础信息 + async syncAllStocks(): Promise { + try { + logger.info('Starting sync all stocks...'); + + // 从AKShare获取股票列表 + const response = await axios.get(`${this.akshareBaseUrl}/stock_zh_a_spot`, { + timeout: 30000, + }); + + const stocks: AKShareStockSpot[] = response.data; + + if (!Array.isArray(stocks) || stocks.length === 0) { + logger.warn('No stocks data received'); + return; + } + + let successCount = 0; + + for (const stock of stocks) { + try { + await prisma.stock.upsert({ + where: { code: stock.code }, + update: { + name: stock.name, + industry: stock.industry, + }, + create: { + code: stock.code, + name: stock.name, + industry: stock.industry, + }, + }); + successCount++; + } catch (error) { + logger.error(`Failed to upsert stock ${stock.code}:`, error); + } + } + + logger.info(`Synced ${successCount} stocks`); + } catch (error) { + logger.error('Failed to sync all stocks:', error); + throw error; + } + } + + // 同步版块数据 + async syncSectors(): Promise { + try { + logger.info('Starting sync sectors...'); + await this.syncSectorQuotes(); + } catch (error) { + logger.error('Failed to sync sectors:', error); + throw error; + } + } + + // 计算动量分数 + async calculateMomentumScores(): Promise { + try { + logger.info('Starting calculate momentum scores...'); + + // 获取所有股票 + const stocks = await prisma.stock.findMany(); + + for (const stock of stocks) { + try { + // 获取最近20天的K线数据 + const klines = await prisma.stockKLine.findMany({ + where: { + stockCode: stock.code, + period: 'day', + }, + orderBy: { date: 'desc' }, + take: 20, + }); + + if (klines.length < 20) { + continue; + } + + // 计算动量分数(简化版本) + const latest = klines[0]; + const prev20 = klines[klines.length - 1]; + const priceChange = (latest.close - prev20.close) / prev20.close * 100; + + // 计算成交量变化 + const avgVolume = klines.reduce((sum, k) => sum + Number(k.volume), 0) / klines.length; + const volumeRatio = Number(latest.volume) / avgVolume; + + // 动量分数 = 价格变化 * 0.6 + 成交量比 * 0.4 + const momentumScore = Math.min(100, Math.max(0, priceChange * 0.6 + (volumeRatio - 1) * 10 * 0.4 + 50)); + + // 更新最新报价的动量分数 + await prisma.stockQuote.updateMany({ + where: { stockCode: stock.code }, + data: { momentumScore }, + }); + } catch (error) { + logger.error(`Failed to calculate momentum for ${stock.code}:`, error); + } + } + + logger.info('Momentum scores calculation completed'); + } catch (error) { + logger.error('Failed to calculate momentum scores:', error); + throw error; + } + } + // 初始化基础数据 async initBaseData(): Promise { try { @@ -315,7 +424,7 @@ export class DataSyncService { { name: '房地产', code: '880482' }, { name: '汽车', code: '880391' }, { name: '电子', code: '880494' }, - { name: '计算机', wire: '880952' }, + { name: '计算机', code: '880952' }, { name: '通信', code: '880495' }, { name: '传媒', code: '880952' }, { name: '军工', code: '880954' }, diff --git a/app/backend/src/services/stockService.ts b/app/backend/src/services/stockService.ts index dc777aa..46afa8e 100644 --- a/app/backend/src/services/stockService.ts +++ b/app/backend/src/services/stockService.ts @@ -271,32 +271,39 @@ export class StockService { }, orderBy: { date: 'desc' }, take: limit, + }); + + // 手动获取股票详情 + const stockCodes = records.map(r => r.stockCode); + const stocks = await prisma.stock.findMany({ + where: { code: { in: stockCodes } }, include: { - stock: { - include: { - quotes: { - orderBy: { quoteTime: 'desc' }, - take: 1, - }, - sector: true, - }, + quotes: { + orderBy: { quoteTime: 'desc' }, + take: 1, }, + sector: true, }, }); - return records.map((record) => ({ - code: record.stock.code, - name: record.stock.name, - price: record.stock.quotes[0]?.price || record.price, - change: record.stock.quotes[0]?.change || 0, - changePercent: record.stock.quotes[0]?.changePercent || 0, - volume: record.stock.quotes[0]?.volume ? Number(record.stock.quotes[0].volume) : 0, - turnover: record.stock.quotes[0]?.turnover ? Number(record.stock.quotes[0].turnover) : 0, - industry: record.stock.sector?.name || '', - highLowPrice: record.price, - date: record.date.toISOString().split('T')[0], - daysToHighLow: record.daysToHighLow, - })); + const stockMap = new Map(stocks.map(s => [s.code, s])); + + return records.map((record) => { + const stock = stockMap.get(record.stockCode); + return { + code: record.stockCode, + name: stock?.name || record.stockCode, + price: stock?.quotes[0]?.price || record.price, + change: stock?.quotes[0]?.change || 0, + changePercent: stock?.quotes[0]?.changePercent || 0, + volume: stock?.quotes[0]?.volume ? Number(stock.quotes[0].volume) : 0, + turnover: stock?.quotes[0]?.turnover ? Number(stock.quotes[0].turnover) : 0, + industry: stock?.sector?.name || '', + highLowPrice: record.price, + date: record.date.toISOString().split('T')[0], + daysToHighLow: record.daysToHighLow, + }; + }); } catch (error) { logger.error('Failed to get new high stocks:', error); return this.generateMockHighLowStocks('high', limit); @@ -315,32 +322,39 @@ export class StockService { }, orderBy: { date: 'desc' }, take: limit, + }); + + // 手动获取股票详情 + const stockCodes = records.map(r => r.stockCode); + const stocks = await prisma.stock.findMany({ + where: { code: { in: stockCodes } }, include: { - stock: { - include: { - quotes: { - orderBy: { quoteTime: 'desc' }, - take: 1, - }, - sector: true, - }, + quotes: { + orderBy: { quoteTime: 'desc' }, + take: 1, }, + sector: true, }, }); - return records.map((record) => ({ - code: record.stock.code, - name: record.stock.name, - price: record.stock.quotes[0]?.price || record.price, - change: record.stock.quotes[0]?.change || 0, - changePercent: record.stock.quotes[0]?.changePercent || 0, - volume: record.stock.quotes[0]?.volume ? Number(record.stock.quotes[0].volume) : 0, - turnover: record.stock.quotes[0]?.turnover ? Number(record.stock.quotes[0].turnover) : 0, - industry: record.stock.sector?.name || '', - highLowPrice: record.price, - date: record.date.toISOString().split('T')[0], - daysToHighLow: record.daysToHighLow, - })); + const stockMap = new Map(stocks.map(s => [s.code, s])); + + return records.map((record) => { + const stock = stockMap.get(record.stockCode); + return { + code: record.stockCode, + name: stock?.name || record.stockCode, + price: stock?.quotes[0]?.price || record.price, + change: stock?.quotes[0]?.change || 0, + changePercent: stock?.quotes[0]?.changePercent || 0, + volume: stock?.quotes[0]?.volume ? Number(stock.quotes[0].volume) : 0, + turnover: stock?.quotes[0]?.turnover ? Number(stock.quotes[0].turnover) : 0, + industry: stock?.sector?.name || '', + highLowPrice: record.price, + date: record.date.toISOString().split('T')[0], + daysToHighLow: record.daysToHighLow, + }; + }); } catch (error) { logger.error('Failed to get new low stocks:', error); return this.generateMockHighLowStocks('low', limit); @@ -392,6 +406,7 @@ export class StockService { } try { + // 获取动量股票记录 const records = await prisma.momentumStock.findMany({ where: { date: { @@ -400,33 +415,40 @@ export class StockService { }, orderBy: { momentumScore: 'desc' }, take: limit, + }); + + // 手动获取股票详情 + const stockCodes = records.map(r => r.stockCode); + const stocks = await prisma.stock.findMany({ + where: { code: { in: stockCodes } }, include: { - stock: { - include: { - quotes: { - orderBy: { quoteTime: 'desc' }, - take: 1, - }, - sector: true, - }, + quotes: { + orderBy: { quoteTime: 'desc' }, + take: 1, }, + sector: true, }, }); - const result: MomentumStock[] = records.map((record) => ({ - code: record.stock.code, - name: record.stock.name, - price: record.stock.quotes[0]?.price || 0, - change: record.stock.quotes[0]?.change || 0, - changePercent: record.stock.quotes[0]?.changePercent || 0, - volume: record.stock.quotes[0]?.volume ? Number(record.stock.quotes[0].volume) : 0, - turnover: record.stock.quotes[0]?.turnover ? Number(record.stock.quotes[0].turnover) : 0, - industry: record.stock.sector?.name || '', - momentumScore: record.momentumScore, - tags: record.tags ? JSON.parse(record.tags) : [], - volumeRatio: record.volumeRatio, - breakThrough: record.breakThrough, - })); + const stockMap = new Map(stocks.map(s => [s.code, s])); + + const result: MomentumStock[] = records.map((record) => { + const stock = stockMap.get(record.stockCode); + return { + code: record.stockCode, + name: stock?.name || record.stockCode, + price: stock?.quotes[0]?.price || 0, + change: stock?.quotes[0]?.change || 0, + changePercent: stock?.quotes[0]?.changePercent || 0, + volume: stock?.quotes[0]?.volume ? Number(stock.quotes[0].volume) : 0, + turnover: stock?.quotes[0]?.turnover ? Number(stock.quotes[0].turnover) : 0, + industry: stock?.sector?.name || '', + momentumScore: record.momentumScore, + tags: record.tags ? JSON.parse(record.tags) : [], + volumeRatio: record.volumeRatio, + breakThrough: record.breakThrough, + }; + }); await cache.set(cacheKey, result, config.cacheTtl.sectors); return result; diff --git a/app/src/admin/pages/AIConfig.tsx b/app/src/admin/pages/AIConfig.tsx index e5ff0e4..70dc980 100644 --- a/app/src/admin/pages/AIConfig.tsx +++ b/app/src/admin/pages/AIConfig.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import { Brain, @@ -8,6 +8,7 @@ import { AlertCircle, SlidersHorizontal } from 'lucide-react'; +import { adminApi } from '@/services/adminApi'; interface AIModelConfig { provider: 'openai' | 'anthropic' | 'local' | 'custom'; @@ -48,23 +49,58 @@ export default function AIConfig() { thresholdWeak: 40, }); + const [loading, setLoading] = useState(true); 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); + useEffect(() => { + fetchConfig(); + }, []); + + const fetchConfig = async () => { + setLoading(true); + try { + const [aiData, momentumData] = await Promise.allSettled([ + adminApi.getAIConfig().catch(() => null), + adminApi.getMomentumConfig().catch(() => null), + ]); + + if (aiData.status === 'fulfilled' && aiData.value) { + setAiConfig(prev => ({ ...prev, ...aiData.value })); + } + if (momentumData.status === 'fulfilled' && momentumData.value) { + setMomentumConfig(momentumData.value); + } + } catch (error) { + console.error('Failed to fetch config:', error); + } finally { + setLoading(false); + } + }; + 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); + await Promise.all([ + adminApi.updateAIConfig({ + provider: aiConfig.provider, + model: aiConfig.model, + apiKey: aiConfig.apiKey || undefined, + apiUrl: aiConfig.apiUrl, + temperature: aiConfig.temperature, + maxTokens: aiConfig.maxTokens, + enabled: aiConfig.enabled, + }), + adminApi.updateMomentumConfig(momentumConfig), + ]); + setSaveStatus('success'); setTimeout(() => setSaveStatus('idle'), 3000); - } catch (error) { + } catch (error: any) { setSaveStatus('error'); } finally { setSaving(false); @@ -76,15 +112,12 @@ export default function AIConfig() { setTestResult(null); try { - await new Promise(resolve => setTimeout(resolve, 1500)); - setTestResult({ - success: true, - message: '连接成功!API 响应正常,模型可正常使用。' - }); - } catch (error) { + const result = await adminApi.testAIConnection(); + setTestResult(result); + } catch (error: any) { setTestResult({ success: false, - message: '连接失败,请检查 API Key 和 URL 配置。' + message: error.message || '连接测试失败' }); } finally { setTesting(false); @@ -98,6 +131,14 @@ export default function AIConfig() { custom: ['custom-model'], }; + if (loading) { + return ( +
+ +
+ ); + } + return (
{/* Header */} diff --git a/app/src/admin/pages/Dashboard.tsx b/app/src/admin/pages/Dashboard.tsx index c071103..2d9d3ad 100644 --- a/app/src/admin/pages/Dashboard.tsx +++ b/app/src/admin/pages/Dashboard.tsx @@ -6,8 +6,10 @@ import { Activity, TrendingUp, Server, - Clock + Clock, + RefreshCw } from 'lucide-react'; +import { adminApi } from '@/services/adminApi'; interface SystemStats { totalUsers: number; @@ -22,43 +24,57 @@ interface SystemStats { }; } +interface ActivityItem { + time: string; + action: string; + detail: string; +} + export default function Dashboard() { const [stats, setStats] = useState(null); + const [activities, setActivities] = useState([]); const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); 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 fetchStats = async () => { + setLoading(true); + try { + const data = await adminApi.getSystemStats(); + setStats(data); + + // 获取最近活动(这里使用模拟数据,后端可以添加活动日志接口) + setActivities([ + { time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), action: '数据同步完成', detail: `共更新 ${data.totalStocks} 只股票数据` }, + { time: '12:15', action: '系统检查', detail: `数据完整度 ${data.dataCompleteness}%` }, + { time: '10:00', action: '服务状态检查', detail: `AKShare: ${data.apiStatus.akshare ? '正常' : '异常'}` }, + { time: '08:30', action: '缓存更新', detail: 'Redis 缓存已刷新' }, + ]); + } catch (error) { + console.error('Failed to fetch stats:', error); + } finally { + setLoading(false); + } + }; + + const handleSync = async () => { + setSyncing(true); + try { + await adminApi.triggerSync('akshare'); + // 等待几秒后刷新状态 + setTimeout(() => { + fetchStats(); + setSyncing(false); + }, 3000); + } catch (error) { + console.error('Sync failed:', error); + setSyncing(false); + } + }; + const statCards = [ { label: '总用户数', @@ -86,16 +102,25 @@ export default function Dashboard() { value: `${stats?.dataCompleteness || 0}%`, icon: Activity, color: 'from-orange-500 to-orange-600', - trend: '-2.1%' + trend: stats && stats.dataCompleteness > 90 ? '+5%' : '-2.1%' }, ]; return (
{/* Header */} -
-

管理总览

-

系统运行状态和数据概况

+
+
+

管理总览

+

系统运行状态和数据概况

+
+
{/* Stats Grid */} @@ -111,7 +136,7 @@ export default function Dashboard() {

{card.label}

-

{card.value}

+

{loading ? '-' : card.value}

- {[ - { name: 'AKShare 数据服务', status: stats?.apiStatus.akshare }, - { name: 'MySQL 数据库', status: stats?.apiStatus.database }, - { name: 'Redis 缓存', status: stats?.apiStatus.redis }, - ].map((service) => ( -
- {service.name} -
-
- - {service.status ? '正常' : '异常'} - + {loading ? ( +
加载中...
+ ) : ( + [ + { name: 'AKShare 数据服务', status: stats?.apiStatus.akshare }, + { name: 'MySQL 数据库', status: stats?.apiStatus.database }, + { name: 'Redis 缓存', status: stats?.apiStatus.redis }, + ].map((service) => ( +
+ {service.name} +
+
+ + {service.status ? '正常' : '异常'} + +
-
- ))} + )) + )}
@@ -177,12 +206,7 @@ export default function Dashboard() {
- {[ - { 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) => ( + {activities.map((activity, index) => (
{activity.time}
@@ -204,8 +228,12 @@ export default function Dashboard() { >

快速操作

-
- {/* Progress Bar */} - {(progress.isChecking || progress.isBuffering) && ( + {/* Progress Card */} + {(buffering || currentTask) && ( -
- {progress.currentTask} - {progress.progress}% +
+
+

{currentTask?.currentTask || '准备中...'}

+

+ {currentTask && ( + <> + 已处理: {currentTask.processedRecords.toLocaleString()} / {currentTask.totalRecords.toLocaleString()} + + )} +

+
+
+ {currentTask?.progress || 0}% +
-
+ +
+ + {currentTask?.status === 'failed' && currentTask.error && ( +
+

{currentTask.error}

+
+ )} )} + {/* Buffer Options */} + + + + {showOptions && ( +
+
+
+ + setBufferOptions(prev => ({ ...prev, startDate: e.target.value }))} + className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2.5 text-white outline-none focus:border-[#ff6b35]" + /> +
+
+ + setBufferOptions(prev => ({ ...prev, endDate: e.target.value }))} + className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2.5 text-white outline-none focus:border-[#ff6b35]" + /> +
+
+ +
+ +
+ {[ + { key: 'stock', label: '股票基础', icon: TrendingUp }, + { key: 'sector', label: '版块数据', icon: Layers }, + { key: 'kline', label: 'K线数据', icon: BarChart3 }, + { key: 'momentum', label: '动量计算', icon: Zap }, + ].map(({ key, label, icon: Icon }) => ( + + ))} +
+
+ +
+ 自动计算动量指标 + +
+ +
+

+ + 预计需要 {estimatedTime} 分钟完成缓冲(基于 {totalMissing.toLocaleString()} 条缺失数据) +

+
+
+ )} +
+ {/* Summary Cards */}
-
+
@@ -299,7 +455,7 @@ export default function DataCheck() {

- 需要缓冲补充 + {totalMissing > 0 ? '建议执行一键缓冲' : '数据完整无需缓冲'}

@@ -311,62 +467,81 @@ export default function DataCheck() { >
-

上次检查

+

缓冲范围

- {new Date().toLocaleTimeString('zh-CN')} + {Math.ceil((new Date(bufferOptions.endDate).getTime() - new Date(bufferOptions.startDate).getTime()) / (1000 * 60 * 60 * 24))} 天

- +

- 今天 + {bufferOptions.startDate} 至 {bufferOptions.endDate}

+ {/* Quick Actions */} +
+ + + +
+ {/* Data Status Table */}
- +

数据状态详情

-
- - - - - - - - - - - - - {loading ? ( - - - - ) : ( - dataStatus.map((item) => ( - - - - - - - - - )) - )} - -
数据项类型完整度状态最后更新备注
- - 加载中... -
-
- {item.type === 'stock' && } - {item.type === 'sector' && } - {item.type === 'index' && } - {item.type === 'kline' && } - {item.name} -
-
{item.type} +
+ {loading ? ( +
+ + 加载中... +
+ ) : dataStatus.length === 0 ? ( +
+ 暂无数据 +
+ ) : ( + dataStatus.map((item) => ( +
+
+
+ {getStatusIcon(item.status)} +
+

{item.name}

+

+ 类型: {item.type} | 最后更新: {item.lastUpdate} +

+
+
+
+
{((item.current / item.total) * 100).toFixed(1)}%
-
-
- {getStatusIcon(item.status)} - - {getStatusText(item.status)} - -
-
{item.lastUpdate}{item.details || '-'}
+

+ {item.current.toLocaleString()} / {item.total.toLocaleString()} +

+
+ {item.details && ( + + )} +
+
+ + {expandedItems.includes(item.id) && item.details && ( + +

{item.details}

+
+ )} +
+ )) + )}
diff --git a/app/src/admin/pages/DataImport.tsx b/app/src/admin/pages/DataImport.tsx index 213b608..50ee730 100644 --- a/app/src/admin/pages/DataImport.tsx +++ b/app/src/admin/pages/DataImport.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { motion } from 'framer-motion'; import { Upload, @@ -12,6 +12,7 @@ import { Calendar, Settings } from 'lucide-react'; +import { adminApi, createSyncTaskWebSocket } from '@/services/adminApi'; interface ImportTask { id: string; @@ -35,10 +36,47 @@ const importTemplates = [ export default function DataImport() { const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); const [dragActive, setDragActive] = useState(false); const [selectedType, setSelectedType] = useState('stock'); const [showTemplateModal, setShowTemplateModal] = useState(false); + const [uploading, setUploading] = useState(false); const fileInputRef = useRef(null); + const wsRef = useRef(null); + + useEffect(() => { + fetchTasks(); + }, []); + + useEffect(() => { + return () => { + if (wsRef.current) { + wsRef.current.close(); + } + }; + }, []); + + const fetchTasks = async () => { + setLoading(true); + try { + const data = await adminApi.getImportTasks(); + setTasks(data.map(t => ({ + id: t.id, + type: 'stock' as const, + name: t.name, + fileName: t.fileName, + status: t.status as ImportTask['status'], + progress: t.progress, + totalRecords: t.totalRecords, + importedRecords: t.importedRecords, + createdAt: t.createdAt, + }))); + } catch (error) { + console.error('Failed to fetch tasks:', error); + } finally { + setLoading(false); + } + }; const handleDrag = (e: React.DragEvent) => { e.preventDefault(); @@ -60,56 +98,67 @@ export default function DataImport() { } }; - 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]); + const handleFile = async (file: File) => { + setUploading(true); - // 模拟开始导入 - setTimeout(() => { - startImport(newTask.id); - }, 500); + try { + const result = await adminApi.uploadImportFile(file, selectedType); + + // 添加新任务到列表 + const newTask: ImportTask = { + id: result.taskId, + 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]); + + // 连接 WebSocket 获取进度 + connectWebSocket(result.taskId, newTask.id); + } catch (error: any) { + alert('上传失败: ' + error.message); + } finally { + setUploading(false); + } }; - const startImport = async (taskId: string) => { - setTasks(prev => prev.map(t => - t.id === taskId ? { ...t, status: 'processing' } : t - )); + const connectWebSocket = (serverTaskId: string, localTaskId: string) => { + if (wsRef.current) { + wsRef.current.close(); + } - // 模拟导入进度 - const totalSteps = 10; - for (let i = 1; i <= totalSteps; i++) { - await new Promise(resolve => setTimeout(resolve, 500)); + const ws = createSyncTaskWebSocket(serverTaskId); + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + setTasks(prev => prev.map(t => - t.id === taskId ? { + t.id === localTaskId ? { ...t, - progress: (i / totalSteps) * 100, - totalRecords: 10000, - importedRecords: Math.round((i / totalSteps) * 10000), + status: data.status, + progress: data.progress, + totalRecords: data.totalRecords || t.totalRecords, + importedRecords: data.processedRecords || t.importedRecords, + errorMessage: data.error, } : 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 - )); + if (data.status === 'completed' || data.status === 'failed') { + ws.close(); + } + }; + + ws.onerror = (error) => { + console.error('WebSocket error:', error); + }; + + wsRef.current = ws; }; const handleDeleteTask = (taskId: string) => { @@ -182,10 +231,16 @@ export default function DataImport() { />
- + {uploading ? ( +
+ ) : ( + + )}
-

拖拽文件到此处上传

+

+ {uploading ? '上传中...' : '拖拽文件到此处上传'} +

支持 CSV、Excel 格式文件

{/* Data Type Selector */} @@ -194,7 +249,8 @@ export default function DataImport() { {/* Import Tasks */} - {tasks.length > 0 && ( - -
-
- -

导入记录

-
+ +
+
+ +

导入记录

+
+
+
+
-
- {tasks.map((task) => ( +
+ {loading ? ( +
+ + 加载中... +
+ ) : tasks.length === 0 ? ( +
+ 暂无导入记录 +
+ ) : ( + tasks.map((task) => (
@@ -245,7 +320,7 @@ export default function DataImport() {
{task.createdAt} 类型: {task.type} - {task.status !== 'pending' && ( + {task.status !== 'pending' && task.totalRecords > 0 && ( 记录: {task.importedRecords.toLocaleString()} / {task.totalRecords.toLocaleString()} @@ -288,10 +363,10 @@ export default function DataImport() {
)}
- ))} -
- - )} + )) + )} +
+ {/* Import Guide */} ([ - { - 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 navigate = useNavigate(); + const [sources, setSources] = useState([]); + const [akshareStatus, setAkshareStatus] = useState(null); + const [akshareConfig, setAkshareConfig] = useState({ baseUrl: 'http://localhost:8000' }); + const [isLoggedIn, setIsLoggedIn] = useState(false); + const [isEditingUrl, setIsEditingUrl] = useState(false); + const [editingUrl, setEditingUrl] = useState(''); + const [savingUrl, setSavingUrl] = useState(false); + + const [loading, setLoading] = useState(true); + const [testing, setTesting] = useState(false); + const [syncing, setSyncing] = useState(false); + const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + // 检查登录状态 + useEffect(() => { + const token = localStorage.getItem('token'); + setIsLoggedIn(!!token); + }, []); + + useEffect(() => { + if (isLoggedIn) { + fetchData(); + } else { + setLoading(false); + } + }, [isLoggedIn]); - const [marketConfig, setMarketConfig] = useState({ - tradingHours: { - preMarket: '09:15', - open: '09:30', - close: '15:00', - postMarket: '15:30', - }, - dataRetention: 365, - enablePreMarket: true, - enableAfterHours: false, - }); + const fetchData = async () => { + setLoading(true); + try { + const [sourcesData, statusData, configData] = await Promise.allSettled([ + adminApi.getDataSources(), + adminApi.getAKShareStatus().catch(() => ({ connected: false })), + adminApi.getAKShareConfig().catch(() => ({ baseUrl: 'http://localhost:8000' })), + ]); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(null); + if (sourcesData.status === 'fulfilled') { + setSources(sourcesData.value); + } else if (sourcesData.reason?.message?.includes('登录')) { + setIsLoggedIn(false); + localStorage.removeItem('token'); + localStorage.removeItem('user'); + } + + if (statusData.status === 'fulfilled') { + setAkshareStatus(statusData.value); + } - const handleTestConnection = async (sourceId: string) => { - setTesting(sourceId); - await new Promise(resolve => setTimeout(resolve, 1500)); + if (configData.status === 'fulfilled') { + setAkshareConfig(configData.value); + } + } catch (error: any) { + console.error('Failed to fetch data:', error); + if (error.message?.includes('登录')) { + setIsLoggedIn(false); + } + setSources([{ + id: 'akshare', + name: 'AKShare 官方', + type: 'akshare', + url: akshareConfig.baseUrl, + enabled: true, + syncInterval: 5, + status: 'disconnected', + }]); + } finally { + setLoading(false); + } + }; + + const handleTestConnection = async () => { + setTesting(true); + setMessage(null); - setSources(prev => prev.map(s => - s.id === sourceId - ? { ...s, status: Math.random() > 0.3 ? 'connected' : 'error' } - : s - )); - setTesting(null); + try { + const result = await adminApi.testDataSource('akshare'); + setMessage({ + type: result.success ? 'success' : 'error', + text: result.message, + }); + + // 刷新状态 + const status = await adminApi.getAKShareStatus(); + setAkshareStatus(status); + } catch (error: any) { + setMessage({ + type: 'error', + text: error.message || '连接测试失败', + }); + } finally { + setTesting(false); + } }; - const handleSave = async () => { - setSaving(true); - await new Promise(resolve => setTimeout(resolve, 1000)); - setSaving(false); + const handleManualSync = async () => { + setSyncing(true); + setMessage(null); + + try { + const result = await adminApi.triggerSync('akshare'); + setMessage({ type: 'success', text: `同步任务已启动,任务ID: ${result.taskId}` }); + } catch (error: any) { + setMessage({ type: 'error', text: error.message || '同步失败' }); + } finally { + setSyncing(false); + } }; - const handleToggleSource = (sourceId: string) => { - setSources(prev => prev.map(s => - s.id === sourceId ? { ...s, enabled: !s.enabled } : s - )); + const handleToggleSource = async (sourceId: string) => { + const source = sources.find(s => s.id === sourceId); + if (!source) return; + + const newEnabled = !source.enabled; + + try { + await adminApi.updateDataSource(sourceId, { enabled: newEnabled }); + setSources(prev => prev.map(s => + s.id === sourceId ? { ...s, enabled: newEnabled } : s + )); + setMessage({ type: 'success', text: `${source.name} 已${newEnabled ? '启用' : '禁用'}` }); + } catch (error: any) { + setMessage({ type: 'error', text: error.message || '操作失败' }); + } + }; + + // 开始编辑 URL + const handleStartEditUrl = () => { + setEditingUrl(akshareConfig.baseUrl); + setIsEditingUrl(true); + }; + + // 取消编辑 URL + const handleCancelEditUrl = () => { + setIsEditingUrl(false); + setEditingUrl(''); }; - const handleUpdateSource = (sourceId: string, updates: Partial) => { - setSources(prev => prev.map(s => - s.id === sourceId ? { ...s, ...updates } : s - )); + // 保存 URL + const handleSaveUrl = async () => { + if (!editingUrl.trim()) { + setMessage({ type: 'error', text: '请输入有效的 URL' }); + return; + } + + setSavingUrl(true); + try { + await adminApi.updateAKShareConfig({ baseUrl: editingUrl.trim() }); + setAkshareConfig(prev => ({ ...prev, baseUrl: editingUrl.trim() })); + setMessage({ type: 'success', text: 'AKShare 地址已更新,重启后端服务后生效' }); + setIsEditingUrl(false); + } catch (error: any) { + setMessage({ type: 'error', text: error.message || '保存失败' }); + } finally { + setSavingUrl(false); + } }; + const akshareSource = sources.find(s => s.type === 'akshare'); + + // 加载中 + if (loading) { + return ( +
+
+

加载中...

+
+ ); + } + + // 未登录提示 + if (!isLoggedIn) { + return ( +
+
+ +
+

请先登录

+

数据源配置需要管理员权限,请先登录

+ +
+ ); + } + return (
{/* Header */}

数据源配置

-

配置数据来源和同步参数

+

管理 AKShare 数据源连接

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

AKShare 数据源

+
+
+ - -
-
-

{source.name}

-
- - {source.status === 'connected' ? '已连接' : - source.status === 'error' ? '连接错误' : '未连接'} - - - 最后同步: {source.lastSync} - -
-
+ {akshareStatus?.connected ? '连接正常' : '未连接'} + + {akshareStatus?.version && ( + 版本: {akshareStatus.version} + )}
+
+
+ +
+ {/* Enable/Disable Switch */} + {akshareSource && ( + + )} + + {/* Test Button */} + + + {/* Sync Button */} + +
+
+ + {/* AKShare URL Config */} +
+
+
+ + 服务地址 +
+ {!isEditingUrl ? ( +
+ {akshareConfig.baseUrl} + +
+ ) : (
+ setEditingUrl(e.target.value)} + placeholder="http://localhost:8000" + className="bg-[#0a0a0a] border border-[#2a2a2a] rounded px-3 py-1.5 text-sm text-white w-64 outline-none focus:border-[#ff6b35]" + /> + +
+ )} +
+

+ 修改后需要重启后端服务才能生效。Docker 环境可使用 http://host.docker.internal:8000 +

+
+ + {/* Supported APIs */} + {akshareStatus?.supportedApis && akshareStatus.supportedApis.length > 0 && ( +
+

可用 API

+
+ {akshareStatus.supportedApis.map(api => ( + + {api} + + ))} +
+
+ )} + + + {/* Data Source List */} + +

数据源列表

+ +
+ {sources.map(source => ( +
+
+
+
+ {source.name} +

{source.url}

+
+
+
+ + 同步间隔: {source.syncInterval}分钟 +
+ ))} +
+ -
-
- -
- - 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 */} + {/* Info Tips */} -
- -

市场配置

-
- -
- {/* 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 年的数据用于分析。 -

-
-
-
+
+ +
+

关于 AKShare

+
    +
  • AKShare 是开源财经数据接口库,无需 API Key 即可使用
  • +
  • 数据来源于东方财富、新浪财经等公开渠道
  • +
  • 本地需要部署 AKShare HTTP API 服务(默认端口 8000)
  • +
  • 点击"测试连接"验证是否可以正常获取数据
  • +
diff --git a/app/src/admin/pages/UserManagement.tsx b/app/src/admin/pages/UserManagement.tsx index 0aa63b2..cb0f5be 100644 --- a/app/src/admin/pages/UserManagement.tsx +++ b/app/src/admin/pages/UserManagement.tsx @@ -2,8 +2,6 @@ import { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; import { Search, - Filter, - MoreHorizontal, Ban, CheckCircle, Trash2, @@ -12,51 +10,48 @@ import { 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; -} +import { adminApi, type AdminUser } from '@/services/adminApi'; export default function UserManagement() { - const [users, setUsers] = useState([]); + const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [currentPage, setCurrentPage] = useState(1); + const [totalUsers, setTotalUsers] = useState(0); const [selectedUsers, setSelectedUsers] = useState([]); const [filterRole, setFilterRole] = useState('all'); const [filterStatus, setFilterStatus] = useState('all'); + const [actionLoading, setActionLoading] = useState(null); const pageSize = 10; + const totalPages = Math.ceil(totalUsers / pageSize); useEffect(() => { fetchUsers(); - }, []); + }, [currentPage, filterRole, filterStatus]); + + // 搜索时重置到第一页 + useEffect(() => { + const timer = setTimeout(() => { + setCurrentPage(1); + fetchUsers(); + }, 300); + return () => clearTimeout(timer); + }, [searchQuery]); const fetchUsers = async () => { setLoading(true); try { - // 模拟 API 调用 - await new Promise(resolve => setTimeout(resolve, 800)); + const response = await adminApi.getUsers({ + page: currentPage, + pageSize, + search: searchQuery, + role: filterRole, + status: filterStatus, + }); - 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); + setUsers(response.users); + setTotalUsers(response.total); } catch (error) { console.error('Failed to fetch users:', error); } finally { @@ -64,25 +59,9 @@ export default function UserManagement() { } }; - 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)); + setSelectedUsers(users.map(u => u.id)); } else { setSelectedUsers([]); } @@ -96,14 +75,68 @@ export default function UserManagement() { ); }; - const handleBanUser = async (userId: string) => { - // 实现封禁用户逻辑 - console.log('Ban user:', userId); + const handleBanUser = async (userId: string, currentStatus: string) => { + setActionLoading(userId); + try { + const newStatus = currentStatus === 'active' ? 'banned' : 'active'; + await adminApi.updateUserStatus(userId, newStatus); + + // 更新本地状态 + setUsers(prev => prev.map(u => + u.id === userId ? { ...u, status: newStatus } : u + )); + } catch (error) { + console.error('Failed to update user status:', error); + alert('操作失败,请重试'); + } finally { + setActionLoading(null); + } }; const handleDeleteUser = async (userId: string) => { - if (confirm('确定要删除该用户吗?此操作不可恢复。')) { + if (!confirm('确定要删除该用户吗?此操作不可恢复。')) return; + + setActionLoading(userId); + try { + await adminApi.deleteUser(userId); setUsers(prev => prev.filter(u => u.id !== userId)); + setTotalUsers(prev => prev - 1); + } catch (error) { + console.error('Failed to delete user:', error); + alert('删除失败,请重试'); + } finally { + setActionLoading(null); + } + }; + + const handleBatchBan = async () => { + if (selectedUsers.length === 0) return; + if (!confirm(`确定要封禁选中的 ${selectedUsers.length} 个用户吗?`)) return; + + try { + await adminApi.batchUpdateUsers(selectedUsers, 'ban'); + setUsers(prev => prev.map(u => + selectedUsers.includes(u.id) ? { ...u, status: 'banned' } : u + )); + setSelectedUsers([]); + } catch (error) { + console.error('Batch ban failed:', error); + alert('批量操作失败'); + } + }; + + const handleBatchDelete = async () => { + if (selectedUsers.length === 0) return; + if (!confirm(`确定要删除选中的 ${selectedUsers.length} 个用户吗?此操作不可恢复。`)) return; + + try { + await adminApi.batchUpdateUsers(selectedUsers, 'delete'); + setUsers(prev => prev.filter(u => !selectedUsers.includes(u.id))); + setTotalUsers(prev => prev - selectedUsers.length); + setSelectedUsers([]); + } catch (error) { + console.error('Batch delete failed:', error); + alert('批量删除失败'); } }; @@ -160,9 +193,10 @@ export default function UserManagement() {
@@ -175,10 +209,16 @@ export default function UserManagement() { className="flex items-center gap-3 pt-4 border-t border-[#2a2a2a]" > 已选择 {selectedUsers.length} 项 - -
@@ -195,7 +235,7 @@ export default function UserManagement() { 0} + checked={selectedUsers.length === users.length && users.length > 0} className="w-4 h-4 rounded border-[#2a2a2a] bg-[#0a0a0a] text-[#ff6b35] focus:ring-[#ff6b35]" /> @@ -216,14 +256,14 @@ export default function UserManagement() { 加载中... - ) : paginatedUsers.length === 0 ? ( + ) : users.length === 0 ? ( 暂无数据 ) : ( - paginatedUsers.map((user) => ( + users.map((user) => (
- {Array.from({ length: Math.min(5, totalPages) }, (_, i) => ( - - ))} + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const page = i + 1; + return ( + + ); + })} +
+
+ + {/* Forgot Password */} +
+ + 忘记密码? + +
+ + {/* Submit */} + + + + {/* Divider */} +
+
+ 还没有账号? +
+
+ + {/* Register Link */} + + 立即注册 + +
+ + {/* Footer */} +

+ 登录即表示您同意我们的 + 服务条款 + 和 + 隐私政策 +

+ +
+ ); +} diff --git a/app/src/pages/Register.tsx b/app/src/pages/Register.tsx new file mode 100644 index 0000000..5d4aa25 --- /dev/null +++ b/app/src/pages/Register.tsx @@ -0,0 +1,246 @@ +import { useState } from 'react'; +import { motion } from 'framer-motion'; +import { useNavigate, Link } from 'react-router-dom'; +import { User, Mail, Lock, Eye, EyeOff, Loader2, ArrowLeft, CheckCircle } from 'lucide-react'; +import { useAuth } from '@/contexts/AuthContext'; + +export default function Register() { + const navigate = useNavigate(); + const { register } = useAuth(); + + const [username, setUsername] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + // 验证 + if (password !== confirmPassword) { + setError('两次输入的密码不一致'); + return; + } + + if (password.length < 6) { + setError('密码长度至少为6位'); + return; + } + + if (username.length < 2) { + setError('用户名长度至少为2位'); + return; + } + + setIsLoading(true); + + try { + await register(username, email, password); + setSuccess(true); + // 2秒后跳转到登录页 + setTimeout(() => { + navigate('/login'); + }, 2000); + } catch (err: any) { + setError(err.message || '注册失败,请检查输入信息'); + } finally { + setIsLoading(false); + } + }; + + if (success) { + return ( +
+ +
+ +
+

注册成功!

+

正在跳转到登录页面...

+ + 立即登录 + +
+
+ ); + } + + return ( +
+ {/* Background Pattern */} +
+
+
+
+ + + {/* Back Button */} + + + 返回首页 + + + {/* Card */} +
+ {/* Header */} +
+

创建账户

+

填写以下信息完成注册

+
+ + {/* Error */} + {error && ( + +
+ {error} +
+
+ )} + + {/* Form */} +
+ {/* Username */} +
+ +
+ + setUsername(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 */} +
+ +
+ + setEmail(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 */} +
+ +
+ + setPassword(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 */} +
+ +
+ + setConfirmPassword(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 */} + +
+ + {/* Divider */} +
+
+ 已有账号? +
+
+ + {/* Login Link */} + + 立即登录 + +
+ + {/* Footer */} +

+ 注册即表示您同意我们的 + 服务条款 + 和 + 隐私政策 +

+ +
+ ); +} diff --git a/app/src/services/adminApi.ts b/app/src/services/adminApi.ts new file mode 100644 index 0000000..e90e98e --- /dev/null +++ b/app/src/services/adminApi.ts @@ -0,0 +1,460 @@ +import type { ApiResponse } from '@/types'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1'; + +// 管理员 API 请求 +async function adminRequest(path: string, options: RequestInit = {}): Promise { + const token = localStorage.getItem('token'); + + if (!token) { + throw new Error('请先登录后再操作'); + } + + const response = await fetch(`${API_BASE_URL}${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: '请求失败' })); + if (response.status === 401) { + // Token 过期或无效,清除本地存储 + localStorage.removeItem('token'); + localStorage.removeItem('user'); + throw new Error('登录已过期,请重新登录'); + } + throw new Error(error.message || `HTTP ${response.status}`); + } + + const data: ApiResponse = await response.json(); + + if (data.code !== 200) { + throw new Error(data.message || '请求失败'); + } + + return data.data; +} + +// 用户管理 +export interface AdminUser { + id: string; + username: string; + email: string; + role: 'admin' | 'user'; + status: 'active' | 'banned'; + createdAt: string; + lastLogin: string; + favoritesCount: number; +} + +// 数据源配置 +export interface DataSourceConfig { + id: string; + name: string; + type: 'akshare' | 'tushare' | 'custom'; + url: string; + apiKey?: string; + enabled: boolean; + syncInterval: number; + lastSync?: string; + status: 'connected' | 'disconnected' | 'error'; +} + +// 数据检查项 +export interface DataCheckItem { + id: string; + name: string; + type: 'stock' | 'sector' | 'index' | 'kline'; + total: number; + current: number; + lastUpdate: string; + status: 'complete' | 'incomplete' | 'missing'; + details?: string; +} + +// 同步任务 +export interface SyncTask { + id: string; + type: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + progress: number; + currentTask: string; + totalRecords: number; + processedRecords: number; + createdAt: string; + completedAt?: string; + error?: string; +} + +// 管理员 API +export const adminApi = { + // ========== 数据源管理 ========== + + // 获取数据源列表 + getDataSources(): Promise { + return adminRequest('/admin/data-sources'); + }, + + // 更新数据源配置 + updateDataSource(id: string, config: Partial): Promise { + return adminRequest(`/admin/data-sources/${id}`, { + method: 'PUT', + body: JSON.stringify(config), + }); + }, + + // 测试数据源连接 + testDataSource(id: string): Promise<{ success: boolean; message: string }> { + return adminRequest(`/admin/data-sources/${id}/test`, { + method: 'POST', + }); + }, + + // 手动触发同步 + triggerSync(sourceId: string): Promise<{ taskId: string }> { + return adminRequest(`/admin/data-sources/${sourceId}/sync`, { + method: 'POST', + }); + }, + + // ========== AKShare 特定接口 ========== + + // 获取 AKShare 状态 + getAKShareStatus(): Promise<{ + connected: boolean; + version?: string; + supportedApis: string[]; + }> { + return adminRequest('/admin/akshare/status'); + }, + + // 获取 AKShare 配置 + getAKShareConfig(): Promise<{ + baseUrl: string; + timeout: number; + retryTimes: number; + rateLimit: number; + }> { + return adminRequest('/admin/akshare/config'); + }, + + // 更新 AKShare 配置 + updateAKShareConfig(config: { + baseUrl?: string; + timeout?: number; + retryTimes?: number; + rateLimit?: number; + }): Promise { + return adminRequest('/admin/akshare/config', { + method: 'PUT', + body: JSON.stringify(config), + }); + }, + + // ========== 数据检测与缓冲 ========== + + // 获取数据完整性检查 + getDataCheck(): Promise { + return adminRequest('/admin/data-check'); + }, + + // 执行数据完整性检查 + runDataCheck(): Promise<{ taskId: string }> { + return adminRequest('/admin/data-check', { + method: 'POST', + }); + }, + + // 获取同步任务进度 + getSyncTask(taskId: string): Promise { + return adminRequest(`/admin/sync-tasks/${taskId}`); + }, + + // 一键缓冲缺失数据(自动补全一年内的数据) + bufferMissingData(options?: { + startDate?: string; + endDate?: string; + types?: ('stock' | 'sector' | 'kline')[]; + }): Promise<{ taskId: string }> { + // 默认缓冲一年内数据 + const endDate = new Date(); + const startDate = new Date(); + startDate.setFullYear(startDate.getFullYear() - 1); + + return adminRequest('/admin/buffer', { + method: 'POST', + body: JSON.stringify({ + startDate: startDate.toISOString().split('T')[0], + endDate: endDate.toISOString().split('T')[0], + types: ['stock', 'sector', 'kline'], + autoCalculate: true, // 自动计算动量指标 + ...options, + }), + }); + }, + + // 缓冲特定股票数据 + bufferStockData(stockCode: string, days: number = 365): Promise<{ taskId: string }> { + return adminRequest('/admin/buffer/stock', { + method: 'POST', + body: JSON.stringify({ stockCode, days }), + }); + }, + + // 缓冲所有股票基础数据 + bufferAllStocks(): Promise<{ taskId: string }> { + return adminRequest('/admin/buffer/stocks', { + method: 'POST', + }); + }, + + // 缓冲版块数据 + bufferSectors(): Promise<{ taskId: string }> { + return adminRequest('/admin/buffer/sectors', { + method: 'POST', + }); + }, + + // 缓冲K线数据 + bufferKLines(options?: { + stockCodes?: string[]; + startDate?: string; + endDate?: string; + }): Promise<{ taskId: string }> { + const endDate = new Date(); + const startDate = new Date(); + startDate.setFullYear(startDate.getFullYear() - 1); + + return adminRequest('/admin/buffer/kline', { + method: 'POST', + body: JSON.stringify({ + startDate: startDate.toISOString().split('T')[0], + endDate: endDate.toISOString().split('T')[0], + ...options, + }), + }); + }, + + // 计算动量指标 + calculateMomentum(options?: { + stockCodes?: string[]; + days?: number; + }): Promise<{ taskId: string }> { + return adminRequest('/admin/calculate/momentum', { + method: 'POST', + body: JSON.stringify({ + days: 20, + ...options, + }), + }); + }, + + // ========== 用户管理 ========== + + // 获取用户列表 + getUsers(params?: { + page?: number; + pageSize?: number; + search?: string; + role?: string; + status?: string; + }): Promise<{ + users: AdminUser[]; + total: number; + page: number; + pageSize: number; + }> { + const queryParams = new URLSearchParams(); + if (params?.page) queryParams.append('page', params.page.toString()); + if (params?.pageSize) queryParams.append('pageSize', params.pageSize.toString()); + if (params?.search) queryParams.append('search', params.search); + if (params?.role && params.role !== 'all') queryParams.append('role', params.role); + if (params?.status && params.status !== 'all') queryParams.append('status', params.status); + + return adminRequest(`/admin/users?${queryParams.toString()}`); + }, + + // 更新用户状态(封禁/解封) + updateUserStatus(userId: string, status: 'active' | 'banned'): Promise { + return adminRequest(`/admin/users/${userId}/status`, { + method: 'PUT', + body: JSON.stringify({ status }), + }); + }, + + // 删除用户 + deleteUser(userId: string): Promise { + return adminRequest(`/admin/users/${userId}`, { + method: 'DELETE', + }); + }, + + // 批量操作用户 + batchUpdateUsers(userIds: string[], action: 'ban' | 'unban' | 'delete'): Promise { + return adminRequest('/admin/users/batch', { + method: 'POST', + body: JSON.stringify({ userIds, action }), + }); + }, + + // ========== 数据导入 ========== + + // 上传导入文件 + uploadImportFile(file: File, type: string): Promise<{ taskId: string; filename: string }> { + const formData = new FormData(); + formData.append('file', file); + formData.append('type', type); + + const token = localStorage.getItem('token'); + + return fetch(`${API_BASE_URL}/admin/import`, { + method: 'POST', + headers: { + 'Authorization': token ? `Bearer ${token}` : '', + }, + body: formData, + }).then(async (response) => { + if (!response.ok) { + const error = await response.json().catch(() => ({ message: '上传失败' })); + throw new Error(error.message); + } + const data = await response.json(); + if (data.code !== 200) { + throw new Error(data.message); + } + return data.data; + }); + }, + + // 获取导入任务列表 + getImportTasks(): Promise<{ + id: string; + name: string; + fileName: string; + status: string; + progress: number; + totalRecords: number; + importedRecords: number; + createdAt: string; + }[]> { + return adminRequest('/admin/import/tasks'); + }, + + // ========== AI 配置 ========== + + // 获取 AI 配置 + getAIConfig(): Promise<{ + provider: string; + model: string; + apiUrl: string; + temperature: number; + maxTokens: number; + enabled: boolean; + }> { + return adminRequest('/admin/ai-config'); + }, + + // 更新 AI 配置 + updateAIConfig(config: { + provider?: string; + model?: string; + apiKey?: string; + apiUrl?: string; + temperature?: number; + maxTokens?: number; + enabled?: boolean; + }): Promise { + return adminRequest('/admin/ai-config', { + method: 'PUT', + body: JSON.stringify(config), + }); + }, + + // 测试 AI 连接 + testAIConnection(): Promise<{ success: boolean; message: string }> { + return adminRequest('/admin/ai-config/test', { + method: 'POST', + }); + }, + + // 获取动量计算配置 + getMomentumConfig(): Promise<{ + calculationPeriod: number; + weightPriceChange: number; + weightVolume: number; + weightTechnical: number; + thresholdStrong: number; + thresholdWeak: number; + }> { + return adminRequest('/admin/momentum-config'); + }, + + // 更新动量计算配置 + updateMomentumConfig(config: { + calculationPeriod?: number; + weightPriceChange?: number; + weightVolume?: number; + weightTechnical?: number; + thresholdStrong?: number; + thresholdWeak?: number; + }): Promise { + return adminRequest('/admin/momentum-config', { + method: 'PUT', + body: JSON.stringify(config), + }); + }, + + // ========== 系统统计 ========== + + // 获取系统统计 + getSystemStats(): Promise<{ + totalUsers: number; + totalStocks: number; + totalSectors: number; + dataCompleteness: number; + lastSync: string; + apiStatus: { + akshare: boolean; + database: boolean; + redis: boolean; + }; + }> { + return adminRequest('/admin/stats'); + }, + + // 获取数据保留策略 + getDataRetention(): Promise<{ + stockQuotesDays: number; + klineDays: number; + logsDays: number; + }> { + return adminRequest('/admin/data-retention'); + }, + + // 更新数据保留策略 + updateDataRetention(policy: { + stockQuotesDays?: number; + klineDays?: number; + logsDays?: number; + }): Promise { + return adminRequest('/admin/data-retention', { + method: 'PUT', + body: JSON.stringify(policy), + }); + }, +}; + +// WebSocket 连接用于实时获取同步进度 +export function createSyncTaskWebSocket(taskId: string): WebSocket { + const wsUrl = (import.meta.env.VITE_WS_URL || 'ws://localhost:3000').replace(/^http/, 'ws'); + const token = localStorage.getItem('token'); + + return new WebSocket(`${wsUrl}/ws/sync-tasks/${taskId}?token=${token}`); +} + +// Ensure export +export default adminApi;