fix: 增加管理员页面;增加注册登陆等功能

master
Lxy 3 months ago
parent c56ccf5fb2
commit 9fd50387cb

@ -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"]

@ -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)

@ -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"
)

@ -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,
});
};

@ -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;

@ -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({

@ -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<void> {
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<void> {
try {
logger.info('Starting sync sectors...');
await this.syncSectorQuotes();
} catch (error) {
logger.error('Failed to sync sectors:', error);
throw error;
}
}
// 计算动量分数
async calculateMomentumScores(): Promise<void> {
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<void> {
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' },

@ -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;

@ -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 (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-8 h-8 text-[#ff6b35] animate-spin" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}

@ -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<SystemStats | null>(null);
const [activities, setActivities] = useState<ActivityItem[]>([]);
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 (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-[#b0b0b0] mt-1"></p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-[#b0b0b0] mt-1"></p>
</div>
<button
onClick={fetchStats}
disabled={loading}
className="p-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Stats Grid */}
@ -111,7 +136,7 @@ export default function Dashboard() {
<div className="flex items-start justify-between">
<div>
<p className="text-[#b0b0b0] text-sm">{card.label}</p>
<p className="text-2xl font-bold text-white mt-1">{card.value}</p>
<p className="text-2xl font-bold text-white mt-1">{loading ? '-' : card.value}</p>
<span className={`text-xs mt-2 inline-block ${
card.trend.startsWith('+') ? 'text-green-400' :
card.trend.startsWith('-') ? 'text-red-400' : 'text-[#666]'
@ -142,25 +167,29 @@ export default function Dashboard() {
</div>
<div className="space-y-3">
{[
{ name: 'AKShare 数据服务', status: stats?.apiStatus.akshare },
{ name: 'MySQL 数据库', status: stats?.apiStatus.database },
{ name: 'Redis 缓存', status: stats?.apiStatus.redis },
].map((service) => (
<div key={service.name} className="flex items-center justify-between py-2">
<span className="text-[#b0b0b0]">{service.name}</span>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
service.status ? 'bg-green-500' : 'bg-red-500'
}`} />
<span className={`text-sm ${
service.status ? 'text-green-400' : 'text-red-400'
}`}>
{service.status ? '正常' : '异常'}
</span>
{loading ? (
<div className="py-4 text-center text-[#666]">...</div>
) : (
[
{ name: 'AKShare 数据服务', status: stats?.apiStatus.akshare },
{ name: 'MySQL 数据库', status: stats?.apiStatus.database },
{ name: 'Redis 缓存', status: stats?.apiStatus.redis },
].map((service) => (
<div key={service.name} className="flex items-center justify-between py-2">
<span className="text-[#b0b0b0]">{service.name}</span>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${
service.status ? 'bg-green-500' : 'bg-red-500'
}`} />
<span className={`text-sm ${
service.status ? 'text-green-400' : 'text-red-400'
}`}>
{service.status ? '正常' : '异常'}
</span>
</div>
</div>
</div>
))}
))
)}
</div>
</motion.div>
@ -177,12 +206,7 @@ export default function Dashboard() {
</div>
<div className="space-y-3">
{[
{ time: '14:30', action: '数据同步完成', detail: '共更新 5234 只股票数据' },
{ time: '12:15', action: '新用户注册', detail: '用户 user_128 注册成功' },
{ time: '10:00', action: '系统备份', detail: '数据库自动备份完成' },
{ time: '08:30', action: 'AI模型更新', detail: '动量计算模型已更新' },
].map((activity, index) => (
{activities.map((activity, index) => (
<div key={index} className="flex gap-3 py-2 border-b border-[#2a2a2a] last:border-0">
<span className="text-[#666] text-sm w-12">{activity.time}</span>
<div>
@ -204,8 +228,12 @@ export default function Dashboard() {
>
<h2 className="text-white font-semibold mb-4"></h2>
<div className="flex flex-wrap gap-3">
<button className="px-4 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors text-sm">
<button
onClick={handleSync}
disabled={syncing}
className="px-4 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors text-sm disabled:opacity-50"
>
{syncing ? '同步中...' : '立即同步数据'}
</button>
<button className="px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors text-sm">

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { motion } from 'framer-motion';
import {
Activity,
@ -10,170 +10,198 @@ import {
Database,
TrendingUp,
Calendar,
Clock
Clock,
Pause,
ChevronDown,
ChevronUp,
BarChart3,
Layers,
Zap
} from 'lucide-react';
import { adminApi, createSyncTaskWebSocket, type DataCheckItem, type SyncTask } from '@/services/adminApi';
import { Settings } 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;
interface BufferOptions {
startDate: string;
endDate: string;
types: ('stock' | 'sector' | 'kline' | 'momentum')[];
autoCalculate: boolean;
}
export default function DataCheck() {
const [dataStatus, setDataStatus] = useState<DataCheckItem[]>([]);
const [loading, setLoading] = useState(true);
const [progress, setProgress] = useState<CheckProgress>({
isChecking: false,
isBuffering: false,
progress: 0,
currentTask: '',
const [checking, setChecking] = useState(false);
const [buffering, setBuffering] = useState(false);
const [currentTask, setCurrentTask] = useState<SyncTask | null>(null);
const [showOptions, setShowOptions] = useState(false);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
// 缓冲选项
const [bufferOptions, setBufferOptions] = useState<BufferOptions>({
startDate: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
endDate: new Date().toISOString().split('T')[0],
types: ['stock', 'sector', 'kline', 'momentum'],
autoCalculate: true,
});
useEffect(() => {
fetchDataStatus();
}, []);
const wsRef = useRef<WebSocket | null>(null);
const checkIntervalRef = useRef<number | null>(null);
const fetchDataStatus = async () => {
setLoading(true);
// 获取数据状态
const fetchDataStatus = useCallback(async () => {
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);
const data = await adminApi.getDataCheck();
setDataStatus(data);
} catch (error) {
console.error('Failed to fetch data status:', error);
} finally {
setLoading(false);
// 使用模拟数据
setDataStatus([
{ 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: 'stock', total: 4, current: 4, lastUpdate: '2024-03-07 14:30:00', status: 'complete' },
{ id: '4', name: '股票K线数据', type: 'kline', total: 5234 * 365, current: 4800 * 365, lastUpdate: '2024-03-07 10:00:00', status: 'incomplete', details: '部分股票缺少近期K线数据' },
{ id: '5', name: '动量指标计算', type: 'stock', total: 5234, current: 4800, lastUpdate: '2024-03-06 10:00:00', status: 'incomplete', details: '需要重新计算' },
]);
}
};
}, []);
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 }));
useEffect(() => {
fetchDataStatus();
setLoading(false);
// 定时刷新状态
checkIntervalRef.current = window.setInterval(() => {
if (!buffering) {
fetchDataStatus();
}
}, 30000);
return () => {
if (checkIntervalRef.current) {
clearInterval(checkIntervalRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [fetchDataStatus, buffering]);
// 连接 WebSocket 获取实时进度
const connectWebSocket = useCallback((taskId: string) => {
if (wsRef.current) {
wsRef.current.close();
}
setProgress(prev => ({ ...prev, isChecking: false }));
await fetchDataStatus();
const ws = createSyncTaskWebSocket(taskId);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
setCurrentTask(data);
if (data.status === 'completed' || data.status === 'failed') {
setBuffering(false);
setTimeout(() => fetchDataStatus(), 1000);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
ws.onclose = () => {
console.log('WebSocket closed');
};
wsRef.current = ws;
}, [fetchDataStatus]);
// 执行数据检查
const handleCheckData = async () => {
setChecking(true);
try {
const result = await adminApi.runDataCheck();
// 轮询任务状态
const checkTask = async () => {
try {
const task = await adminApi.getSyncTask(result.taskId);
if (task.status === 'completed') {
setChecking(false);
fetchDataStatus();
} else if (task.status === 'failed') {
setChecking(false);
} else {
setTimeout(checkTask, 1000);
}
} catch (error) {
setChecking(false);
}
};
checkTask();
} catch (error) {
console.error('Check failed:', error);
setChecking(false);
}
};
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));
// 一键缓冲缺失数据(自动缓冲一年内)
const handleBufferMissingData = async () => {
setBuffering(true);
setCurrentTask(null);
try {
const result = await adminApi.bufferMissingData({
startDate: bufferOptions.startDate,
endDate: bufferOptions.endDate,
types: bufferOptions.types.filter(t => t !== 'momentum') as ('stock' | 'sector' | 'kline')[],
});
// 连接 WebSocket 获取实时进度
connectWebSocket(result.taskId);
// 同时启动动量计算(如果选中)
if (bufferOptions.autoCalculate && bufferOptions.types.includes('momentum')) {
setTimeout(async () => {
try {
await adminApi.calculateMomentum();
} catch (error) {
console.error('Calculate momentum failed:', error);
}
}, 1000);
}
} catch (error: any) {
console.error('Buffer failed:', error);
setBuffering(false);
}
};
setProgress(prev => ({ ...prev, progress: 100 }));
await new Promise(resolve => setTimeout(resolve, 500));
// 缓冲特定类型数据
const handleBufferType = async (type: 'stock' | 'sector' | 'kline') => {
setBuffering(true);
setCurrentTask(null);
setProgress({
isChecking: false,
isBuffering: false,
progress: 0,
currentTask: '',
});
try {
let result;
switch (type) {
case 'stock':
result = await adminApi.bufferAllStocks();
break;
case 'sector':
result = await adminApi.bufferSectors();
break;
case 'kline':
result = await adminApi.bufferKLines({
startDate: bufferOptions.startDate,
endDate: bufferOptions.endDate,
});
break;
}
await fetchDataStatus();
connectWebSocket(result.taskId);
} catch (error: any) {
console.error('Buffer failed:', error);
setBuffering(false);
}
};
const getStatusIcon = (status: DataCheckItem['status']) => {
@ -198,6 +226,13 @@ export default function DataCheck() {
}
};
const toggleExpand = (id: string) => {
setExpandedItems(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
);
};
// 计算统计数据
const completionRate = dataStatus.length > 0
? Math.round((dataStatus.filter(d => d.status === 'complete').length / dataStatus.length) * 100)
: 0;
@ -209,6 +244,8 @@ export default function DataCheck() {
return acc;
}, 0);
const estimatedTime = Math.ceil(totalMissing / 1000); // 估算时间(分钟)
return (
<div className="space-y-6">
{/* Header */}
@ -220,45 +257,167 @@ export default function DataCheck() {
<div className="flex gap-3">
<button
onClick={handleCheckData}
disabled={progress.isChecking || progress.isBuffering}
disabled={checking || buffering}
className="flex items-center gap-2 px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${progress.isChecking ? 'animate-spin' : ''}`} />
<RefreshCw className={`w-4 h-4 ${checking ? 'animate-spin' : ''}`} />
</button>
<button
onClick={handleBufferData}
disabled={progress.isChecking || progress.isBuffering || totalMissing === 0}
onClick={handleBufferMissingData}
disabled={checking || buffering || totalMissing === 0}
className="flex items-center gap-2 px-4 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors disabled:opacity-50"
>
<Play className="w-4 h-4" />
{buffering ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
{buffering ? '缓冲中...' : '一键缓冲'}
</button>
</div>
</div>
{/* Progress Bar */}
{(progress.isChecking || progress.isBuffering) && (
{/* Progress Card */}
{(buffering || currentTask) && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center justify-between mb-3">
<span className="text-white">{progress.currentTask}</span>
<span className="text-[#ff6b35] font-medium">{progress.progress}%</span>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-white font-medium">{currentTask?.currentTask || '准备中...'}</h3>
<p className="text-sm text-[#666] mt-1">
{currentTask && (
<>
: {currentTask.processedRecords.toLocaleString()} / {currentTask.totalRecords.toLocaleString()}
</>
)}
</p>
</div>
<div className="text-2xl font-bold text-[#ff6b35]">
{currentTask?.progress || 0}%
</div>
</div>
<div className="h-2 bg-[#2a2a2a] rounded-full overflow-hidden">
<div className="h-3 bg-[#2a2a2a] rounded-full overflow-hidden">
<motion.div
className="h-full bg-gradient-to-r from-[#ff6b35] to-[#ff9f43]"
initial={{ width: 0 }}
animate={{ width: `${progress.progress}%` }}
transition={{ duration: 0.3 }}
animate={{ width: `${currentTask?.progress || 0}%` }}
transition={{ duration: 0.5 }}
/>
</div>
{currentTask?.status === 'failed' && currentTask.error && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg">
<p className="text-red-400 text-sm">{currentTask.error}</p>
</div>
)}
</motion.div>
)}
{/* Buffer Options */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl overflow-hidden"
>
<button
onClick={() => setShowOptions(!showOptions)}
className="w-full px-5 py-4 flex items-center justify-between hover:bg-[#1a1a1a] transition-colors"
>
<div className="flex items-center gap-3">
<Settings className="w-5 h-5 text-[#ff6b35]" />
<span className="text-white font-medium"></span>
</div>
{showOptions ? (
<ChevronUp className="w-5 h-5 text-[#666]" />
) : (
<ChevronDown className="w-5 h-5 text-[#666]" />
)}
</button>
{showOptions && (
<div className="px-5 pb-5 border-t border-[#2a2a2a]">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<input
type="date"
value={bufferOptions.startDate}
onChange={(e) => 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]"
/>
</div>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<input
type="date"
value={bufferOptions.endDate}
onChange={(e) => 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]"
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="flex flex-wrap gap-2">
{[
{ 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 }) => (
<button
key={key}
onClick={() => {
setBufferOptions(prev => ({
...prev,
types: prev.types.includes(key as any)
? prev.types.filter(t => t !== key)
: [...prev.types, key as any]
}));
}}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
bufferOptions.types.includes(key as any)
? 'bg-[#ff6b35] text-white'
: 'bg-[#2a2a2a] text-[#b0b0b0] hover:bg-[#333]'
}`}
>
<Icon className="w-4 h-4" />
{label}
</button>
))}
</div>
</div>
<div className="mt-4 flex items-center justify-between p-3 bg-[#0a0a0a] rounded-lg">
<span className="text-white"></span>
<button
onClick={() => setBufferOptions(prev => ({ ...prev, autoCalculate: !prev.autoCalculate }))}
className={`w-12 h-6 rounded-full transition-colors relative ${
bufferOptions.autoCalculate ? 'bg-[#ff6b35]' : 'bg-[#2a2a2a]'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${
bufferOptions.autoCalculate ? 'left-7' : 'left-1'
}`} />
</button>
</div>
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<p className="text-sm text-blue-400">
<Info className="w-4 h-4 inline mr-1" />
{estimatedTime} {totalMissing.toLocaleString()}
</p>
</div>
</div>
)}
</motion.div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<motion.div
@ -276,10 +435,7 @@ export default function DataCheck() {
</div>
</div>
<div className="mt-3 h-2 bg-[#2a2a2a] rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${completionRate}%` }}
/>
<div className="h-full bg-green-500 rounded-full transition-all" style={{ width: `${completionRate}%` }} />
</div>
</motion.div>
@ -299,7 +455,7 @@ export default function DataCheck() {
</div>
</div>
<p className="text-sm text-[#666] mt-3">
{totalMissing > 0 ? '建议执行一键缓冲' : '数据完整无需缓冲'}
</p>
</motion.div>
@ -311,62 +467,81 @@ export default function DataCheck() {
>
<div className="flex items-center justify-between">
<div>
<p className="text-[#b0b0b0] text-sm"></p>
<p className="text-[#b0b0b0] text-sm"></p>
<p className="text-lg font-bold text-white mt-1">
{new Date().toLocaleTimeString('zh-CN')}
{Math.ceil((new Date(bufferOptions.endDate).getTime() - new Date(bufferOptions.startDate).getTime()) / (1000 * 60 * 60 * 24))}
</p>
</div>
<div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center">
<Clock className="w-6 h-6 text-blue-400" />
<Calendar className="w-6 h-6 text-blue-400" />
</div>
</div>
<p className="text-sm text-[#666] mt-3">
{bufferOptions.startDate} {bufferOptions.endDate}
</p>
</motion.div>
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-3">
<button
onClick={() => handleBufferType('stock')}
disabled={buffering}
className="flex items-center gap-2 px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors disabled:opacity-50"
>
<Database className="w-4 h-4" />
</button>
<button
onClick={() => handleBufferType('sector')}
disabled={buffering}
className="flex items-center gap-2 px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors disabled:opacity-50"
>
<Layers className="w-4 h-4" />
</button>
<button
onClick={() => handleBufferType('kline')}
disabled={buffering}
className="flex items-center gap-2 px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors disabled:opacity-50"
>
<BarChart3 className="w-4 h-4" />
K线
</button>
</div>
{/* Data Status Table */}
<div className="bg-[#111111] border border-[#2a2a2a] rounded-xl overflow-hidden">
<div className="px-5 py-4 border-b border-[#2a2a2a] flex items-center gap-3">
<Database className="w-5 h-5 text-[#ff6b35]" />
<Activity className="w-5 h-5 text-[#ff6b35]" />
<h2 className="text-white font-semibold"></h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-[#2a2a2a]">
<th className="px-5 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-5 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-5 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-5 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-5 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
<th className="px-5 py-3 text-left text-sm font-medium text-[#b0b0b0]"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} className="px-5 py-8 text-center text-[#b0b0b0]">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
...
</td>
</tr>
) : (
dataStatus.map((item) => (
<tr key={item.id} className="border-b border-[#2a2a2a] last:border-0 hover:bg-[#1a1a1a]">
<td className="px-5 py-4">
<div className="flex items-center gap-3">
{item.type === 'stock' && <TrendingUp className="w-4 h-4 text-blue-400" />}
{item.type === 'sector' && <Database className="w-4 h-4 text-purple-400" />}
{item.type === 'index' && <Activity className="w-4 h-4 text-green-400" />}
{item.type === 'kline' && <Calendar className="w-4 h-4 text-orange-400" />}
<span className="text-white">{item.name}</span>
</div>
</td>
<td className="px-5 py-4 text-[#b0b0b0] capitalize">{item.type}</td>
<td className="px-5 py-4">
<div className="divide-y divide-[#2a2a2a]">
{loading ? (
<div className="px-5 py-8 text-center text-[#b0b0b0]">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
...
</div>
) : dataStatus.length === 0 ? (
<div className="px-5 py-8 text-center text-[#666]">
</div>
) : (
dataStatus.map((item) => (
<div key={item.id} className="p-5 hover:bg-[#1a1a1a] transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{getStatusIcon(item.status)}
<div>
<h3 className="text-white font-medium">{item.name}</h3>
<p className="text-sm text-[#666] mt-1">
: {item.type} | : {item.lastUpdate}
</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="flex items-center gap-2">
<div className="w-24 h-2 bg-[#2a2a2a] rounded-full overflow-hidden">
<div
@ -375,33 +550,44 @@ export default function DataCheck() {
item.status === 'incomplete' ? 'bg-yellow-500' :
'bg-red-500'
}`}
style={{ width: `${(item.current / item.total) * 100}%` }}
style={{ width: `${Math.min(100, (item.current / item.total) * 100)}%` }}
/>
</div>
<span className="text-sm text-[#b0b0b0]">
{((item.current / item.total) * 100).toFixed(1)}%
</span>
</div>
</td>
<td className="px-5 py-4">
<div className="flex items-center gap-2">
{getStatusIcon(item.status)}
<span className={`text-sm ${
item.status === 'complete' ? 'text-green-400' :
item.status === 'incomplete' ? 'text-yellow-400' :
'text-red-400'
}`}>
{getStatusText(item.status)}
</span>
</div>
</td>
<td className="px-5 py-4 text-[#b0b0b0] text-sm">{item.lastUpdate}</td>
<td className="px-5 py-4 text-[#666] text-sm">{item.details || '-'}</td>
</tr>
))
)}
</tbody>
</table>
<p className="text-xs text-[#666] mt-1">
{item.current.toLocaleString()} / {item.total.toLocaleString()}
</p>
</div>
{item.details && (
<button
onClick={() => toggleExpand(item.id)}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors"
>
{expandedItems.includes(item.id) ? (
<ChevronUp className="w-4 h-4 text-[#666]" />
) : (
<ChevronDown className="w-4 h-4 text-[#666]" />
)}
</button>
)}
</div>
</div>
{expandedItems.includes(item.id) && item.details && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="mt-3 p-3 bg-[#0a0a0a] rounded-lg"
>
<p className="text-sm text-[#b0b0b0]">{item.details}</p>
</motion.div>
)}
</div>
))
)}
</div>
</div>
</div>

@ -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<ImportTask[]>([]);
const [loading, setLoading] = useState(true);
const [dragActive, setDragActive] = useState(false);
const [selectedType, setSelectedType] = useState<string>('stock');
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const wsRef = useRef<WebSocket | null>(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'),
};
const handleFile = async (file: File) => {
setUploading(true);
try {
const result = await adminApi.uploadImportFile(file, selectedType);
setTasks(prev => [newTask, ...prev]);
// 添加新任务到列表
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'),
};
// 模拟开始导入
setTimeout(() => {
startImport(newTask.id);
}, 500);
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 ws = createSyncTaskWebSocket(serverTaskId);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// 模拟导入进度
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.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() {
/>
<div className="w-16 h-16 bg-[#ff6b35]/20 rounded-full flex items-center justify-center mx-auto mb-4">
<Upload className="w-8 h-8 text-[#ff6b35]" />
{uploading ? (
<div className="w-8 h-8 border-2 border-[#ff6b35] border-t-transparent rounded-full animate-spin" />
) : (
<Upload className="w-8 h-8 text-[#ff6b35]" />
)}
</div>
<p className="text-white text-lg mb-2"></p>
<p className="text-white text-lg mb-2">
{uploading ? '上传中...' : '拖拽文件到此处上传'}
</p>
<p className="text-[#666] text-sm mb-4"> CSVExcel </p>
{/* Data Type Selector */}
@ -194,7 +249,8 @@ export default function DataImport() {
<button
key={template.type}
onClick={() => setSelectedType(template.type)}
className={`px-4 py-2 rounded-lg text-sm transition-colors ${
disabled={uploading}
className={`px-4 py-2 rounded-lg text-sm transition-colors disabled:opacity-50 ${
selectedType === template.type
? 'bg-[#ff6b35] text-white'
: 'bg-[#2a2a2a] text-[#b0b0b0] hover:bg-[#333]'
@ -207,24 +263,32 @@ export default function DataImport() {
<button
onClick={() => fileInputRef.current?.click()}
className="px-6 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors"
disabled={uploading}
className="px-6 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors disabled:opacity-50"
>
</button>
</motion.div>
{/* Import Tasks */}
{tasks.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl overflow-hidden"
>
<div className="px-5 py-4 border-b border-[#2a2a2a] flex items-center justify-between">
<div className="flex items-center gap-3">
<History className="w-5 h-5 text-[#ff6b35]" />
<h2 className="text-white font-semibold"></h2>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl overflow-hidden"
>
<div className="px-5 py-4 border-b border-[#2a2a2a] flex items-center justify-between">
<div className="flex items-center gap-3">
<History className="w-5 h-5 text-[#ff6b35]" />
<h2 className="text-white font-semibold"></h2>
</div>
<div className="flex gap-2">
<button
onClick={fetchTasks}
disabled={loading}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 text-[#666] ${loading ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setTasks([])}
className="text-sm text-[#666] hover:text-white transition-colors"
@ -232,9 +296,20 @@ export default function DataImport() {
</button>
</div>
</div>
<div className="divide-y divide-[#2a2a2a]">
{tasks.map((task) => (
<div className="divide-y divide-[#2a2a2a]">
{loading ? (
<div className="p-8 text-center text-[#b0b0b0]">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
...
</div>
) : tasks.length === 0 ? (
<div className="p-8 text-center text-[#666]">
</div>
) : (
tasks.map((task) => (
<div key={task.id} className="p-5 hover:bg-[#1a1a1a] transition-colors">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
@ -245,7 +320,7 @@ export default function DataImport() {
<div className="flex items-center gap-4 mt-2 text-xs text-[#666]">
<span>{task.createdAt}</span>
<span>: {task.type}</span>
{task.status !== 'pending' && (
{task.status !== 'pending' && task.totalRecords > 0 && (
<span>
: {task.importedRecords.toLocaleString()} / {task.totalRecords.toLocaleString()}
</span>
@ -288,10 +363,10 @@ export default function DataImport() {
</div>
)}
</div>
))}
</div>
</motion.div>
)}
))
)}
</div>
</motion.div>
{/* Import Guide */}
<motion.div

@ -1,371 +1,476 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { useNavigate } from 'react-router-dom';
import {
Database,
Save,
RefreshCw,
CheckCircle,
AlertCircle,
Play,
Pause,
Info,
Zap,
LogIn,
Globe,
Clock,
Shield
Edit2,
Save,
X
} from 'lucide-react';
import { adminApi, type DataSourceConfig as DataSourceConfigType } from '@/services/adminApi';
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 AKShareStatus {
connected: boolean;
version?: string;
supportedApis?: string[];
}
interface MarketConfig {
tradingHours: {
preMarket: string;
open: string;
close: string;
postMarket: string;
};
dataRetention: number;
enablePreMarket: boolean;
enableAfterHours: boolean;
interface AKShareConfig {
baseUrl: string;
}
export default function DataSourceConfig() {
const [sources, setSources] = useState<DataSource[]>([
{
id: '1',
name: 'AKShare 官方',
type: 'akshare',
url: 'http://localhost:8000',
apiKey: '',
enabled: true,
syncInterval: 5,
lastSync: '2024-03-07 14:30:00',
status: 'connected',
},
{
id: '2',
name: 'Tushare Pro',
type: 'tushare',
url: 'https://api.tushare.pro',
apiKey: 'ts_xxxxxxxxxxxxxxxx',
enabled: false,
syncInterval: 15,
lastSync: '-',
status: 'disconnected',
},
]);
const [marketConfig, setMarketConfig] = useState<MarketConfig>({
tradingHours: {
preMarket: '09:15',
open: '09:30',
close: '15:00',
postMarket: '15:30',
},
dataRetention: 365,
enablePreMarket: true,
enableAfterHours: false,
});
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<string | null>(null);
const handleTestConnection = async (sourceId: string) => {
setTesting(sourceId);
await new Promise(resolve => setTimeout(resolve, 1500));
setSources(prev => prev.map(s =>
s.id === sourceId
? { ...s, status: Math.random() > 0.3 ? 'connected' : 'error' }
: s
));
setTesting(null);
const navigate = useNavigate();
const [sources, setSources] = useState<DataSourceConfigType[]>([]);
const [akshareStatus, setAkshareStatus] = useState<AKShareStatus | null>(null);
const [akshareConfig, setAkshareConfig] = useState<AKShareConfig>({ 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 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' })),
]);
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);
}
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);
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 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 = 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 || '操作失败' });
}
};
const handleSave = async () => {
setSaving(true);
await new Promise(resolve => setTimeout(resolve, 1000));
setSaving(false);
// 开始编辑 URL
const handleStartEditUrl = () => {
setEditingUrl(akshareConfig.baseUrl);
setIsEditingUrl(true);
};
const handleToggleSource = (sourceId: string) => {
setSources(prev => prev.map(s =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s
));
// 取消编辑 URL
const handleCancelEditUrl = () => {
setIsEditingUrl(false);
setEditingUrl('');
};
const handleUpdateSource = (sourceId: string, updates: Partial<DataSource>) => {
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 (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<div className="w-12 h-12 border-2 border-[#ff6b35] border-t-transparent rounded-full animate-spin mb-4" />
<p className="text-[#666]">...</p>
</div>
);
}
// 未登录提示
if (!isLoggedIn) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<div className="w-20 h-20 bg-[#2a2a2a] rounded-full flex items-center justify-center mb-6">
<LogIn className="w-10 h-10 text-[#666]" />
</div>
<h2 className="text-xl font-bold text-white mb-2"></h2>
<p className="text-[#666] mb-6"></p>
<button
onClick={() => navigate('/login')}
className="flex items-center gap-2 px-6 py-3 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors"
>
<LogIn className="w-5 h-5" />
</button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-[#b0b0b0] mt-1"></p>
<p className="text-[#b0b0b0] mt-1"> AKShare </p>
</div>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors disabled:opacity-50"
</div>
{/* Message */}
{message && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className={`p-4 rounded-lg flex items-center gap-3 ${
message.type === 'success'
? 'bg-green-500/10 border border-green-500/30'
: 'bg-red-500/10 border border-red-500/30'
}`}
>
{saving ? (
<RefreshCw className="w-4 h-4 animate-spin" />
{message.type === 'success' ? (
<CheckCircle className="w-5 h-5 text-green-400" />
) : (
<Save className="w-4 h-4" />
<AlertCircle className="w-5 h-5 text-red-400" />
)}
</button>
</div>
<span className={message.type === 'success' ? 'text-green-400' : 'text-red-400'}>
{message.text}
</span>
</motion.div>
)}
{/* Data Sources */}
<div className="space-y-4">
{sources.map((source, index) => (
<motion.div
key={source.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
source.type === 'akshare' ? 'bg-green-500/20' :
source.type === 'tushare' ? 'bg-blue-500/20' :
'bg-purple-500/20'
{/* AKShare Status Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-6"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
akshareStatus?.connected
? 'bg-green-500/20'
: 'bg-red-500/20'
}`}>
<Database className={`w-7 h-7 ${
akshareStatus?.connected ? 'text-green-400' : 'text-red-400'
}`} />
</div>
<div>
<h2 className="text-lg font-semibold text-white">AKShare </h2>
<div className="flex items-center gap-2 mt-1">
<div className={`w-2 h-2 rounded-full ${
akshareStatus?.connected ? 'bg-green-500' : 'bg-red-500'
}`} />
<span className={`text-sm ${
akshareStatus?.connected ? 'text-green-400' : 'text-red-400'
}`}>
<Database className={`w-5 h-5 ${
source.type === 'akshare' ? 'text-green-400' :
source.type === 'tushare' ? 'text-blue-400' :
'text-purple-400'
}`} />
</div>
<div>
<h3 className="text-white font-medium">{source.name}</h3>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${
source.status === 'connected' ? 'bg-green-500/20 text-green-400' :
source.status === 'error' ? 'bg-red-500/20 text-red-400' :
'bg-gray-500/20 text-gray-400'
}`}>
{source.status === 'connected' ? '已连接' :
source.status === 'error' ? '连接错误' : '未连接'}
</span>
<span className="text-xs text-[#666]">
: {source.lastSync}
</span>
</div>
</div>
{akshareStatus?.connected ? '连接正常' : '未连接'}
</span>
{akshareStatus?.version && (
<span className="text-sm text-[#666]">: {akshareStatus.version}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{/* Enable/Disable Switch */}
{akshareSource && (
<button
onClick={() => handleToggleSource(akshareSource.id)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
akshareSource.enabled
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
: 'bg-[#2a2a2a] text-[#666] hover:bg-[#333]'
}`}
>
{akshareSource.enabled ? (
<>
<Pause className="w-4 h-4" />
<span></span>
</>
) : (
<>
<Play className="w-4 h-4" />
<span></span>
</>
)}
</button>
)}
{/* Test Button */}
<button
onClick={handleTestConnection}
disabled={testing}
className="flex items-center gap-2 px-4 py-2 bg-[#ff6b35] text-white rounded-lg hover:bg-[#ff6b35]/90 transition-colors disabled:opacity-50"
>
{testing ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Zap className="w-4 h-4" />
)}
</button>
{/* Sync Button */}
<button
onClick={handleManualSync}
disabled={syncing || !akshareSource?.enabled}
className="flex items-center gap-2 px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors disabled:opacity-50"
>
{syncing ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
</button>
</div>
</div>
{/* AKShare URL Config */}
<div className="mt-4 pt-4 border-t border-[#2a2a2a]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="w-4 h-4 text-[#666]" />
<span className="text-sm text-[#b0b0b0]"></span>
</div>
{!isEditingUrl ? (
<div className="flex items-center gap-2">
<span className="text-sm text-white font-mono">{akshareConfig.baseUrl}</span>
<button
onClick={handleStartEditUrl}
className="p-1.5 hover:bg-[#2a2a2a] rounded transition-colors"
title="修改地址"
>
<Edit2 className="w-4 h-4 text-[#666]" />
</button>
</div>
) : (
<div className="flex items-center gap-2">
<input
type="text"
value={editingUrl}
onChange={(e) => 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]"
/>
<button
onClick={() => handleTestConnection(source.id)}
disabled={testing === source.id}
className="px-3 py-1.5 text-sm bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors disabled:opacity-50"
onClick={handleSaveUrl}
disabled={savingUrl}
className="p-1.5 bg-green-500/20 text-green-400 rounded hover:bg-green-500/30 transition-colors disabled:opacity-50"
title="保存"
>
{testing === source.id ? (
{savingUrl ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
'测试连接'
<Save className="w-4 h-4" />
)}
</button>
<button
onClick={() => handleToggleSource(source.id)}
className={`w-12 h-6 rounded-full transition-colors relative ${
source.enabled ? 'bg-[#ff6b35]' : 'bg-[#2a2a2a]'
}`}
onClick={handleCancelEditUrl}
className="p-1.5 hover:bg-[#2a2a2a] rounded transition-colors"
title="取消"
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${
source.enabled ? 'left-7' : 'left-1'
}`} />
<X className="w-4 h-4 text-[#666]" />
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">API URL</label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#666]" />
<input
type="text"
value={source.url}
onChange={(e) => handleUpdateSource(source.id, { url: e.target.value })}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg pl-10 pr-4 py-2 text-white outline-none focus:border-[#ff6b35]"
/>
</div>
</div>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">API Key</label>
<div className="relative">
<Shield className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#666]" />
<input
type="password"
value={source.apiKey}
onChange={(e) => handleUpdateSource(source.id, { apiKey: e.target.value })}
placeholder={source.type === 'akshare' ? '无需 API Key' : '输入 API Key'}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg pl-10 pr-4 py-2 text-white placeholder-[#666] outline-none focus:border-[#ff6b35]"
/>
</div>
</div>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"> ()</label>
<input
type="number"
value={source.syncInterval}
onChange={(e) => handleUpdateSource(source.id, { syncInterval: parseInt(e.target.value) })}
min="1"
max="60"
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2 text-white outline-none focus:border-[#ff6b35]"
/>
</div>
)}
</div>
<p className="text-xs text-[#666] mt-2">
Docker 使 http://host.docker.internal:8000
</p>
</div>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<select
value={source.type}
onChange={(e) => handleUpdateSource(source.id, { type: e.target.value as DataSource['type'] })}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2 text-white outline-none focus:border-[#ff6b35]"
>
<option value="akshare">AKShare</option>
<option value="tushare">Tushare</option>
<option value="custom"></option>
</select>
</div>
{/* Supported APIs */}
{akshareStatus?.supportedApis && akshareStatus.supportedApis.length > 0 && (
<div className="mt-4 pt-4 border-t border-[#2a2a2a]">
<h3 className="text-sm text-[#b0b0b0] mb-2"> API</h3>
<div className="flex flex-wrap gap-2">
{akshareStatus.supportedApis.map(api => (
<span key={api} className="px-3 py-1 bg-[#0a0a0a] rounded-full text-xs text-[#666]">
{api}
</span>
))}
</div>
</motion.div>
))}
{/* Add New Source Button */}
<button className="w-full py-4 border-2 border-dashed border-[#2a2a2a] rounded-xl text-[#b0b0b0] hover:border-[#ff6b35] hover:text-[#ff6b35] transition-colors flex items-center justify-center gap-2">
<Database className="w-5 h-5" />
</button>
</div>
</div>
)}
</motion.div>
{/* Market Config */}
{/* Data Source List */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
transition={{ delay: 0.1 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-6"
>
<div className="flex items-center gap-3 mb-6">
<Clock className="w-5 h-5 text-[#ff6b35]" />
<h2 className="text-white font-semibold"></h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Trading Hours */}
<div className="space-y-4">
<h3 className="text-white font-medium"></h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<input
type="time"
value={marketConfig.tradingHours.open}
onChange={(e) => setMarketConfig(prev => ({
...prev,
tradingHours: { ...prev.tradingHours, open: e.target.value }
}))}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2 text-white outline-none focus:border-[#ff6b35]"
/>
</div>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<input
type="time"
value={marketConfig.tradingHours.close}
onChange={(e) => setMarketConfig(prev => ({
...prev,
tradingHours: { ...prev.tradingHours, close: e.target.value }
}))}
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2 text-white outline-none focus:border-[#ff6b35]"
/>
</div>
</div>
<h2 className="text-lg font-semibold text-white mb-4"></h2>
<div className="flex items-center justify-between p-3 bg-[#0a0a0a] rounded-lg">
<span className="text-white"></span>
<button
onClick={() => setMarketConfig(prev => ({ ...prev, enablePreMarket: !prev.enablePreMarket }))}
className={`w-12 h-6 rounded-full transition-colors relative ${
marketConfig.enablePreMarket ? 'bg-[#ff6b35]' : 'bg-[#2a2a2a]'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${
marketConfig.enablePreMarket ? 'left-7' : 'left-1'
}`} />
</button>
</div>
<div className="flex items-center justify-between p-3 bg-[#0a0a0a] rounded-lg">
<span className="text-white"></span>
<button
onClick={() => setMarketConfig(prev => ({ ...prev, enableAfterHours: !prev.enableAfterHours }))}
className={`w-12 h-6 rounded-full transition-colors relative ${
marketConfig.enableAfterHours ? 'bg-[#ff6b35]' : 'bg-[#2a2a2a]'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${
marketConfig.enableAfterHours ? 'left-7' : 'left-1'
<div className="space-y-3">
{sources.map(source => (
<div
key={source.id}
className="flex items-center justify-between p-4 bg-[#0a0a0a] rounded-lg"
>
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${
source.enabled ? 'bg-green-500' : 'bg-[#666]'
}`} />
</button>
</div>
</div>
{/* Data Retention */}
<div className="space-y-4">
<h3 className="text-white font-medium"></h3>
<div>
<label className="block text-sm text-[#b0b0b0] mb-2">
: {marketConfig.dataRetention}
</label>
<input
type="range"
min="30"
max="1095"
step="30"
value={marketConfig.dataRetention}
onChange={(e) => setMarketConfig(prev => ({
...prev,
dataRetention: parseInt(e.target.value)
}))}
className="w-full accent-[#ff6b35]"
/>
<div className="flex justify-between text-xs text-[#666] mt-1">
<span>30</span>
<span>1</span>
<span>3</span>
</div>
</div>
<div className="p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-blue-400 font-medium"></p>
<p className="text-sm text-blue-300/80 mt-1">
1
</p>
<span className="text-white font-medium">{source.name}</span>
<p className="text-xs text-[#666]">{source.url}</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-[#b0b0b0]">
: {source.syncInterval}
</span>
<button
onClick={() => handleToggleSource(source.id)}
className={`w-12 h-6 rounded-full transition-colors relative ${
source.enabled ? 'bg-[#ff6b35]' : 'bg-[#2a2a2a]'
}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-all ${
source.enabled ? 'left-7' : 'left-1'
}`} />
</button>
</div>
</div>
))}
</div>
</motion.div>
{/* Info Tips */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-5"
>
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-blue-400 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-blue-400 font-medium"> AKShare</h3>
<ul className="text-sm text-blue-300/80 mt-2 space-y-1 list-disc list-inside">
<li>AKShare API Key 使</li>
<li></li>
<li> AKShare HTTP API 8000</li>
<li>"测试连接"</li>
</ul>
</div>
</div>
</motion.div>

@ -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<User[]>([]);
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalUsers, setTotalUsers] = useState(0);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [filterRole, setFilterRole] = useState<string>('all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [actionLoading, setActionLoading] = useState<string | null>(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 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),
}));
const response = await adminApi.getUsers({
page: currentPage,
pageSize,
search: searchQuery,
role: filterRole,
status: filterStatus,
});
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<HTMLInputElement>) => {
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() {
<button
onClick={fetchUsers}
className="p-2 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg hover:bg-[#2a2a2a] transition-colors"
disabled={loading}
className="p-2 bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg hover:bg-[#2a2a2a] transition-colors disabled:opacity-50"
>
<RefreshCw className="w-5 h-5 text-[#b0b0b0]" />
<RefreshCw className={`w-5 h-5 text-[#b0b0b0] ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
@ -175,10 +209,16 @@ export default function UserManagement() {
className="flex items-center gap-3 pt-4 border-t border-[#2a2a2a]"
>
<span className="text-[#b0b0b0] text-sm"> {selectedUsers.length} </span>
<button className="px-3 py-1.5 text-sm bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 transition-colors">
<button
onClick={handleBatchBan}
className="px-3 py-1.5 text-sm bg-red-500/10 text-red-400 rounded-lg hover:bg-red-500/20 transition-colors"
>
</button>
<button className="px-3 py-1.5 text-sm bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors">
<button
onClick={handleBatchDelete}
className="px-3 py-1.5 text-sm bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors"
>
</button>
</motion.div>
@ -195,7 +235,7 @@ export default function UserManagement() {
<input
type="checkbox"
onChange={handleSelectAll}
checked={selectedUsers.length === paginatedUsers.length && paginatedUsers.length > 0}
checked={selectedUsers.length === users.length && users.length > 0}
className="w-4 h-4 rounded border-[#2a2a2a] bg-[#0a0a0a] text-[#ff6b35] focus:ring-[#ff6b35]"
/>
</th>
@ -216,14 +256,14 @@ export default function UserManagement() {
...
</td>
</tr>
) : paginatedUsers.length === 0 ? (
) : users.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-[#666]">
</td>
</tr>
) : (
paginatedUsers.map((user) => (
users.map((user) => (
<tr key={user.id} className="border-b border-[#2a2a2a] last:border-0 hover:bg-[#1a1a1a]">
<td className="px-4 py-3">
<input
@ -270,8 +310,9 @@ export default function UserManagement() {
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() => handleBanUser(user.id)}
className="p-1.5 hover:bg-[#2a2a2a] rounded-lg transition-colors"
onClick={() => handleBanUser(user.id, user.status)}
disabled={actionLoading === user.id}
className="p-1.5 hover:bg-[#2a2a2a] rounded-lg transition-colors disabled:opacity-50"
title={user.status === 'active' ? '封禁' : '解封'}
>
{user.status === 'active' ? (
@ -282,7 +323,8 @@ export default function UserManagement() {
</button>
<button
onClick={() => handleDeleteUser(user.id)}
className="p-1.5 hover:bg-[#2a2a2a] rounded-lg transition-colors"
disabled={actionLoading === user.id}
className="p-1.5 hover:bg-[#2a2a2a] rounded-lg transition-colors disabled:opacity-50"
title="删除"
>
<Trash2 className="w-4 h-4 text-red-400" />
@ -299,7 +341,7 @@ export default function UserManagement() {
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-[#2a2a2a]">
<p className="text-sm text-[#b0b0b0]">
{filteredUsers.length} {currentPage}/{totalPages}
{totalUsers} {currentPage}/{totalPages || 1}
</p>
<div className="flex items-center gap-2">
<button
@ -309,22 +351,25 @@ export default function UserManagement() {
>
<ChevronLeft className="w-4 h-4 text-[#b0b0b0]" />
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => (
<button
key={i + 1}
onClick={() => setCurrentPage(i + 1)}
className={`w-8 h-8 rounded-lg text-sm transition-colors ${
currentPage === i + 1
? 'bg-[#ff6b35] text-white'
: 'hover:bg-[#2a2a2a] text-[#b0b0b0]'
}`}
>
{i + 1}
</button>
))}
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const page = i + 1;
return (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`w-8 h-8 rounded-lg text-sm transition-colors ${
currentPage === page
? 'bg-[#ff6b35] text-white'
: 'hover:bg-[#2a2a2a] text-[#b0b0b0]'
}`}
>
{page}
</button>
);
})}
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
disabled={currentPage === totalPages || totalPages === 0}
className="p-2 hover:bg-[#2a2a2a] rounded-lg transition-colors disabled:opacity-50"
>
<ChevronRight className="w-4 h-4 text-[#b0b0b0]" />

@ -5,6 +5,8 @@ import { AuthProvider } from '@/contexts/AuthContext'
import './index.css'
import App from './App.tsx'
import Admin from './admin/Admin.tsx'
import Login from './pages/Login.tsx'
import Register from './pages/Register.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
@ -12,6 +14,8 @@ createRoot(document.getElementById('root')!).render(
<AuthProvider>
<Routes>
<Route path="/" element={<App />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/admin/*" element={<Admin />} />
</Routes>
</AuthProvider>

@ -0,0 +1,176 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { useNavigate, Link } from 'react-router-dom';
import { Mail, Lock, Eye, EyeOff, Loader2, ArrowLeft } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
export default function Login() {
const navigate = useNavigate();
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(email, password);
// 登录成功,返回上一页或首页
const from = sessionStorage.getItem('loginRedirect') || '/';
sessionStorage.removeItem('loginRedirect');
navigate(from);
} catch (err: any) {
setError(err.message || '登录失败,请检查邮箱和密码');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center p-4">
{/* Background Pattern */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-[#ff6b35]/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-[#ff9f43]/5 rounded-full blur-3xl" />
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="relative w-full max-w-md"
>
{/* Back Button */}
<Link
to="/"
className="absolute -top-12 left-0 flex items-center gap-2 text-[#b0b0b0] hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm"></span>
</Link>
{/* Card */}
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl p-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-white mb-2"></h1>
<p className="text-[#b0b0b0] text-sm"></p>
</div>
{/* Error */}
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="mb-4"
>
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-red-400 text-sm">
{error}
</div>
</motion.div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Email */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
</div>
{/* Password */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
required
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg pl-10 pr-12 py-3 text-white placeholder-[#666] outline-none focus:border-[#ff6b35] transition-colors"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-[#2a2a2a] rounded transition-colors"
>
{showPassword ? (
<EyeOff className="w-4 h-4 text-[#666]" />
) : (
<Eye className="w-4 h-4 text-[#666]" />
)}
</button>
</div>
</div>
{/* Forgot Password */}
<div className="flex justify-end">
<Link
to="#"
className="text-sm text-[#ff6b35] hover:underline"
>
</Link>
</div>
{/* Submit */}
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-[#ff6b35] to-[#ff9f43] text-white font-medium py-3 rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
...
</>
) : (
'登录'
)}
</button>
</form>
{/* Divider */}
<div className="flex items-center gap-4 my-6">
<div className="flex-1 h-px bg-[#2a2a2a]" />
<span className="text-[#666] text-sm"></span>
<div className="flex-1 h-px bg-[#2a2a2a]" />
</div>
{/* Register Link */}
<Link
to="/register"
className="w-full flex items-center justify-center gap-2 py-3 border border-[#2a2a2a] rounded-lg text-white hover:bg-[#2a2a2a] transition-colors"
>
</Link>
</div>
{/* Footer */}
<p className="text-center text-[#666] text-sm mt-8">
<Link to="#" className="text-[#ff6b35] hover:underline mx-1"></Link>
<Link to="#" className="text-[#ff6b35] hover:underline mx-1"></Link>
</p>
</motion.div>
</div>
);
}

@ -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 (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center"
>
<div className="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
<CheckCircle className="w-10 h-10 text-green-400" />
</div>
<h1 className="text-2xl font-bold text-white mb-2"></h1>
<p className="text-[#b0b0b0] mb-6">...</p>
<Link
to="/login"
className="text-[#ff6b35] hover:underline"
>
</Link>
</motion.div>
</div>
);
}
return (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center p-4">
{/* Background Pattern */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-[#ff6b35]/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-[#ff9f43]/5 rounded-full blur-3xl" />
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="relative w-full max-w-md"
>
{/* Back Button */}
<Link
to="/"
className="absolute -top-12 left-0 flex items-center gap-2 text-[#b0b0b0] hover:text-white transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm"></span>
</Link>
{/* Card */}
<div className="bg-[#1a1a1a] border border-[#2a2a2a] rounded-2xl p-8">
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-white mb-2"></h1>
<p className="text-[#b0b0b0] text-sm"></p>
</div>
{/* Error */}
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="mb-4"
>
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-red-400 text-sm">
{error}
</div>
</motion.div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Username */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type="text"
value={username}
onChange={(e) => 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"
/>
</div>
</div>
{/* Email */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type="email"
value={email}
onChange={(e) => 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"
/>
</div>
</div>
{/* Password */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-[#2a2a2a] rounded transition-colors"
>
{showPassword ? (
<EyeOff className="w-4 h-4 text-[#666]" />
) : (
<Eye className="w-4 h-4 text-[#666]" />
)}
</button>
</div>
</div>
{/* Confirm Password */}
<div>
<label className="block text-sm text-[#b0b0b0] mb-2"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-[#666]" />
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => 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"
/>
</div>
</div>
{/* Submit */}
<button
type="submit"
disabled={isLoading}
className="w-full bg-gradient-to-r from-[#ff6b35] to-[#ff9f43] text-white font-medium py-3 rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
...
</>
) : (
'注册'
)}
</button>
</form>
{/* Divider */}
<div className="flex items-center gap-4 my-6">
<div className="flex-1 h-px bg-[#2a2a2a]" />
<span className="text-[#666] text-sm"></span>
<div className="flex-1 h-px bg-[#2a2a2a]" />
</div>
{/* Login Link */}
<Link
to="/login"
className="w-full flex items-center justify-center gap-2 py-3 border border-[#2a2a2a] rounded-lg text-white hover:bg-[#2a2a2a] transition-colors"
>
</Link>
</div>
{/* Footer */}
<p className="text-center text-[#666] text-sm mt-8">
<Link to="#" className="text-[#ff6b35] hover:underline mx-1"></Link>
<Link to="#" className="text-[#ff6b35] hover:underline mx-1"></Link>
</p>
</motion.div>
</div>
);
}

@ -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<T>(path: string, options: RequestInit = {}): Promise<T> {
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<T> = 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<DataSourceConfig[]> {
return adminRequest('/admin/data-sources');
},
// 更新数据源配置
updateDataSource(id: string, config: Partial<DataSourceConfig>): Promise<DataSourceConfig> {
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<void> {
return adminRequest('/admin/akshare/config', {
method: 'PUT',
body: JSON.stringify(config),
});
},
// ========== 数据检测与缓冲 ==========
// 获取数据完整性检查
getDataCheck(): Promise<DataCheckItem[]> {
return adminRequest('/admin/data-check');
},
// 执行数据完整性检查
runDataCheck(): Promise<{ taskId: string }> {
return adminRequest('/admin/data-check', {
method: 'POST',
});
},
// 获取同步任务进度
getSyncTask(taskId: string): Promise<SyncTask> {
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<void> {
return adminRequest(`/admin/users/${userId}/status`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
},
// 删除用户
deleteUser(userId: string): Promise<void> {
return adminRequest(`/admin/users/${userId}`, {
method: 'DELETE',
});
},
// 批量操作用户
batchUpdateUsers(userIds: string[], action: 'ban' | 'unban' | 'delete'): Promise<void> {
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<void> {
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<void> {
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<void> {
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;
Loading…
Cancel
Save