fix: 统一服务接入,并调整命名;增加数据检测,但是目前存在问题

master
Lxy 3 months ago
parent f176610102
commit eab9f3ce9b

@ -3,9 +3,35 @@ import prisma from '../config/database';
import { cache } from '../config/redis';
import logger from '../utils/logger';
import { DataSyncService } from '../services/dataSyncService';
import { externalDataSourceService } from '../services/externalDataSourceService';
import { getTradingDaysCount, getDailyStockCount } from '../utils/dateUtils';
const dataSyncService = new DataSyncService();
// 内存任务存储
interface Task {
id: string;
type: 'check' | 'buffer' | 'sync';
status: 'pending' | 'running' | 'completed' | 'failed';
progress?: number;
result?: any;
error?: string;
createdAt: Date;
completedAt?: Date;
}
const tasks = new Map<string, Task>();
// 清理旧任务
setInterval(() => {
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
for (const [id, task] of tasks.entries()) {
if (task.completedAt && task.completedAt < oneHourAgo) {
tasks.delete(id);
}
}
}, 60 * 60 * 1000); // 每小时清理一次
// ========== 系统统计 ==========
export const getSystemStats = async (_req: Request, res: Response) => {
@ -44,6 +70,15 @@ export const getSystemStats = async (_req: Request, res: Response) => {
redisStatus = false;
}
// 检查外部数据源连接
let externalDataSourceStatus = false;
try {
const health = await externalDataSourceService.healthCheck();
externalDataSourceStatus = health.status === 'healthy';
} catch {
externalDataSourceStatus = false;
}
res.json({
code: 200,
message: 'success',
@ -56,6 +91,7 @@ export const getSystemStats = async (_req: Request, res: Response) => {
apiStatus: {
database: databaseStatus,
redis: redisStatus,
externalDataSource: externalDataSourceStatus,
},
},
});
@ -71,27 +107,94 @@ export const getSystemStats = async (_req: Request, res: Response) => {
// ========== 数据检测 ==========
// 辅助函数:计算一年内可交易日期
function getTradingDaysInRange(startDate: Date, endDate: Date): Date[] {
const tradingDays: Date[] = [];
const current = new Date(startDate);
while (current <= endDate) {
const dayOfWeek = current.getDay();
// 周一到周五为交易日(简化计算,未考虑节假日)
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
tradingDays.push(new Date(current));
}
current.setDate(current.getDate() + 1);
}
return tradingDays;
}
// 辅助函数:格式化日期为 YYYYMMDD
function formatDateYYYYMMDD(date: Date): string {
return date.toISOString().split('T')[0].replace(/-/g, '');
}
export const getDataCheck = async (_req: Request, res: Response) => {
try {
const totalStocks = await prisma.stock.count();
const totalSectors = await prisma.sector.count();
// 获取本地数据库最新日期
const localLatestQuote = await prisma.stockQuote.findFirst({
orderBy: { quoteTime: 'desc' },
select: { quoteTime: true },
});
const localLatestDate = localLatestQuote?.quoteTime;
// 获取外部数据源最新日期
let externalLatestDate: Date | null = null;
let externalStatus = 'disabled';
let daysBehind = 0;
// 获取各类数据的统计
const stockQuotesCount = await prisma.stockQuote.count();
const sectorQuotesCount = await prisma.sectorQuote.count();
if (externalDataSourceService.isEnabled()) {
try {
const nearestTradingDate = await externalDataSourceService.getNearestTradingDate();
if (nearestTradingDate) {
externalLatestDate = new Date(nearestTradingDate);
externalStatus = 'connected';
// 计算本地落后天数
if (localLatestDate) {
const diffTime = externalLatestDate.getTime() - localLatestDate.getTime();
daysBehind = Math.max(0, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
} else {
daysBehind = -1; // 本地无数据
}
}
} catch (error) {
logger.error('Failed to get external data source latest date:', error);
externalStatus = 'error';
}
}
// 计算数据同步状态
let syncStatus: 'complete' | 'incomplete' | 'missing';
if (externalStatus === 'disabled') {
syncStatus = 'incomplete';
} else if (externalStatus === 'error') {
syncStatus = 'missing';
} else if (daysBehind === 0) {
syncStatus = 'complete';
} else if (daysBehind <= 3) {
syncStatus = 'incomplete';
} else {
syncStatus = 'missing';
}
// 获取统计数据
const stockCount = await prisma.stock.count();
const totalSectors = await prisma.sector.count();
const klineCount = await prisma.stockKLine.count();
const quoteCount = await prisma.stockQuote.count();
const now = new Date().toISOString();
const nowISO = new Date().toISOString();
const checks = [
{
id: '1',
name: '股票基础数据',
type: 'stock',
total: totalStocks,
current: totalStocks,
lastUpdate: now,
status: totalStocks > 0 ? 'complete' : 'missing',
total: stockCount,
current: stockCount,
lastUpdate: nowISO,
status: stockCount > 0 ? 'complete' : 'missing',
},
{
id: '2',
@ -99,7 +202,7 @@ export const getDataCheck = async (_req: Request, res: Response) => {
type: 'sector',
total: totalSectors,
current: totalSectors,
lastUpdate: now,
lastUpdate: nowISO,
status: totalSectors > 0 ? 'complete' : 'missing',
},
{
@ -108,27 +211,50 @@ export const getDataCheck = async (_req: Request, res: Response) => {
type: 'kline',
total: klineCount,
current: klineCount,
lastUpdate: now,
lastUpdate: nowISO,
status: klineCount > 0 ? 'complete' : 'missing',
},
{
id: '4',
name: '实时行情数据',
name: '本地行情数据',
type: 'stock',
total: stockQuotesCount,
current: stockQuotesCount,
lastUpdate: now,
status: stockQuotesCount > 0 ? 'complete' : 'missing',
total: quoteCount,
current: quoteCount,
lastUpdate: localLatestDate?.toISOString() || nowISO,
status: localLatestDate ? 'complete' : 'missing',
details: `最新数据日期: ${localLatestDate?.toISOString().split('T')[0] || '无'}`,
},
{
id: '5',
name: '数据同步状态',
type: 'index',
total: externalStatus === 'connected' ? 100 : 0,
current: externalStatus === 'connected' && daysBehind >= 0 ? Math.max(0, 100 - daysBehind * 10) : 0,
lastUpdate: nowISO,
status: syncStatus,
details: `数据源: ${externalStatus === 'connected' ? '已连接' : externalStatus === 'disabled' ? '未配置' : '连接失败'}, 本地最新: ${localLatestDate?.toISOString().split('T')[0] || '无'}, 数据源最新: ${externalLatestDate?.toISOString().split('T')[0] || '无'}, ${daysBehind === -1 ? '需要初始化' : daysBehind === 0 ? '已同步' : `落后 ${daysBehind}`}`,
},
];
res.json({
code: 200,
message: 'success',
data: checks,
data: {
checks,
summary: {
stockCount,
quoteCount,
klineCount,
localLatestDate: localLatestDate?.toISOString().split('T')[0] || null,
externalLatestDate: externalLatestDate?.toISOString().split('T')[0] || null,
externalStatus,
daysBehind: daysBehind === -1 ? null : daysBehind,
syncStatus,
},
},
});
} catch (error) {
logger.error('Failed to get data check:', error);
logger.error('Failed to get data检查:', error);
res.status(500).json({
code: 500,
message: '获取数据检查失败',
@ -141,6 +267,121 @@ export const runDataCheck = async (_req: Request, res: Response) => {
try {
const taskId = `check_${Date.now()}`;
// 创建任务
const task: Task = {
id: taskId,
type: 'check',
status: 'running',
createdAt: new Date(),
};
tasks.set(taskId, task);
// 异步执行检查
setImmediate(async () => {
try {
// 获取本地数据库最新日期
const localLatestQuote = await prisma.stockQuote.findFirst({
orderBy: { quoteTime: 'desc' },
select: { quoteTime: true },
});
const localLatestDate = localLatestQuote?.quoteTime;
// 获取外部数据源最新日期
let externalLatestDate: Date | null = null;
let externalStatus = 'disabled';
let daysBehind = 0;
if (externalDataSourceService.isEnabled()) {
try {
// 尝试获取外部数据源的最新交易日期
const nearestTradingDate = await externalDataSourceService.getNearestTradingDate();
if (nearestTradingDate) {
externalLatestDate = new Date(nearestTradingDate);
externalStatus = 'connected';
// 计算本地落后天数
if (localLatestDate) {
const diffTime = externalLatestDate.getTime() - localLatestDate.getTime();
daysBehind = Math.max(0, Math.ceil(diffTime / (1000 * 60 * 60 * 24)));
} else {
daysBehind = -1; // 本地无数据
}
}
} catch (error) {
logger.error('Failed to get external data source latest date:', error);
externalStatus = 'error';
}
}
// 计算数据同步状态
let syncStatus: 'success' | 'warning' | 'error';
if (externalStatus === 'disabled') {
syncStatus = 'warning';
} else if (externalStatus === 'error') {
syncStatus = 'error';
} else if (daysBehind === 0) {
syncStatus = 'success';
} else if (daysBehind <= 3) {
syncStatus = 'warning';
} else {
syncStatus = 'error';
}
// 获取统计数据
const stockCount = await prisma.stock.count();
const quoteCount = await prisma.stockQuote.count();
// 更新任务状态
task.status = 'completed';
task.completedAt = new Date();
task.result = {
checks: [
{
name: '股票基础数据',
status: stockCount > 0 ? 'success' : 'warning',
count: stockCount,
message: stockCount > 0 ? `已加载 ${stockCount} 只股票` : '暂无数据',
},
{
name: '本地行情数据',
status: localLatestDate ? 'success' : 'warning',
count: quoteCount,
latestDate: localLatestDate?.toISOString().split('T')[0] || '无',
},
{
name: '数据源连接状态',
status: externalStatus === 'connected' ? 'success' : externalStatus === 'disabled' ? 'warning' : 'error',
message: externalStatus === 'connected' ? '已连接' : externalStatus === 'disabled' ? '未配置外部数据源' : '连接失败',
},
{
name: '数据同步状态',
status: syncStatus,
localLatestDate: localLatestDate?.toISOString().split('T')[0] || '无',
externalLatestDate: externalLatestDate?.toISOString().split('T')[0] || '无',
daysBehind: daysBehind === -1 ? '本地无数据' : `${daysBehind}`,
message: daysBehind === 0 ? '数据已同步' : daysBehind === -1 ? '需要初始化数据' : `落后 ${daysBehind}`,
},
],
summary: {
stockCount,
quoteCount,
localLatestDate: localLatestDate?.toISOString().split('T')[0] || null,
externalLatestDate: externalLatestDate?.toISOString().split('T')[0] || null,
externalStatus,
daysBehind: daysBehind === -1 ? null : daysBehind,
syncStatus,
},
};
logger.info(`Data check task ${taskId} completed: local=${localLatestDate?.toISOString().split('T')[0]}, external=${externalLatestDate?.toISOString().split('T')[0]}, behind=${daysBehind}`);
} catch (error) {
logger.error(`Data check task ${taskId} failed:`, error);
task.status = 'failed';
task.error = error instanceof Error ? error.message : 'Unknown error';
task.completedAt = new Date();
}
});
res.json({
code: 200,
message: 'success',
@ -321,18 +562,32 @@ export const calculateMomentum = async (_req: Request, res: Response) => {
export const getSyncTask = async (req: Request, res: Response) => {
try {
const { taskId } = req.params;
const task = tasks.get(taskId);
if (!task) {
res.status(404).json({
code: 404,
message: '任务不存在或已过期',
data: null,
});
return;
}
// 这里应该从缓存或数据库获取任务状态
res.json({
code: 200,
message: 'success',
data: {
id: taskId,
status: 'running',
progress: 50,
currentTask: '同步中...',
totalRecords: 1000,
processedRecords: 500,
id: task.id,
type: task.type,
status: task.status,
progress: task.progress ?? (task.status === 'completed' ? 100 : task.status === 'running' ? 50 : 0),
currentTask: task.type === 'check' ? '数据检查中...' : '同步中...',
totalRecords: task.result?.summary?.expectedTotalQuotes ?? 1000,
processedRecords: task.result?.summary?.actualTotalQuotes ?? 500,
result: task.result,
error: task.error,
createdAt: task.createdAt,
completedAt: task.completedAt,
},
});
} catch (error) {
@ -603,3 +858,187 @@ export const updateDataRetention = async (req: Request, res: Response) => {
data: null,
});
};
// ========== 数据源管理 ==========
// 内存存储数据源配置(实际项目中应该存储在数据库中)
const dataSources = new Map([
['marketDataService', {
id: 'marketDataService',
name: '统一数据服务',
type: 'custom',
url: process.env.EXTERNAL_DATA_SOURCE_URL || 'http://localhost:8080',
enabled: true,
syncInterval: 5,
status: 'connected',
}]
]);
export const getDataSources = async (_req: Request, res: Response) => {
try {
// 检查外部数据源实际状态
let externalStatus = 'disconnected';
try {
const health = await externalDataSourceService.healthCheck();
externalStatus = health.status === 'healthy' ? 'connected' : 'error';
} catch {
externalStatus = 'error';
}
const sources = Array.from(dataSources.values()).map(source => ({
...source,
status: source.id === 'marketDataService' ? externalStatus : source.status,
}));
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 { url, enabled } = req.body;
const source = dataSources.get(id);
if (!source) {
res.status(404).json({
code: 404,
message: '数据源不存在',
data: null,
});
return;
}
if (url !== undefined) {
source.url = url;
}
if (enabled !== undefined) {
source.enabled = enabled;
}
dataSources.set(id, source);
res.json({
code: 200,
message: 'success',
data: source,
});
} 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 === 'marketDataService' || id === 'custom') {
// 测试外部数据源连接
try {
const health = await externalDataSourceService.healthCheck();
if (health.status === 'healthy') {
res.json({
code: 200,
message: 'success',
data: { success: true, message: '统一数据服务连接正常' },
});
} else {
res.json({
code: 200,
message: 'success',
data: { success: false, message: '统一数据服务未启用或连接失败' },
});
}
} catch (error) {
res.json({
code: 200,
message: 'success',
data: { success: false, message: '统一数据服务连接失败: ' + (error instanceof Error ? error.message : '未知错误') },
});
}
return;
}
res.status(404).json({
code: 404,
message: '数据源不存在',
data: null,
});
} catch (error) {
logger.error('Failed to test data source:', error);
res.status(500).json({
code: 500,
message: '测试数据源连接失败',
data: null,
});
}
};
export const triggerSync = async (req: Request, res: Response) => {
try {
const { id } = req.params;
if (id !== 'marketDataService' && id !== 'custom') {
res.status(404).json({
code: 404,
message: '数据源不存在',
data: null,
});
return;
}
// 创建同步任务
const taskId = `sync_${Date.now()}`;
const task: Task = {
id: taskId,
type: 'sync',
status: 'running',
createdAt: new Date(),
};
tasks.set(taskId, task);
// 异步执行同步
setImmediate(async () => {
try {
await dataSyncService.syncRealTimeQuotes();
task.status = 'completed';
task.completedAt = new Date();
logger.info(`Sync task ${taskId} completed`);
} catch (error) {
logger.error(`Sync task ${taskId} failed:`, error);
task.status = 'failed';
task.error = error instanceof Error ? error.message : 'Unknown error';
task.completedAt = new Date();
}
});
res.json({
code: 200,
message: 'success',
data: { taskId },
});
} catch (error) {
logger.error('Failed to trigger sync:', error);
res.status(500).json({
code: 500,
message: '触发同步失败',
data: null,
});
}
};

@ -4,10 +4,26 @@ import logger from '../utils/logger';
// 请求日志中间件
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
const start = Date.now();
const requestId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// 在请求对象上附加 requestId
(req as any).requestId = requestId;
logger.info(`[${requestId}] Request Started`, {
method: req.method,
url: req.url,
ip: req.ip,
userAgent: req.get('user-agent'),
userId: req.user?.id,
params: req.params,
query: req.query,
body: req.method !== 'GET' ? JSON.stringify(req.body).substring(0, 500) : undefined,
});
res.on('finish', () => {
const duration = Date.now() - start;
const logData = {
requestId,
method: req.method,
url: req.url,
status: res.statusCode,
@ -18,9 +34,9 @@ export function requestLogger(req: Request, res: Response, next: NextFunction):
};
if (res.statusCode >= 400) {
logger.warn('HTTP Request', logData);
logger.warn(`[${requestId}] Request Failed`, logData);
} else {
logger.info('HTTP Request', logData);
logger.info(`[${requestId}] Request Completed`, logData);
}
});

@ -10,6 +10,12 @@ router.use(authMiddleware);
// ========== 系统统计 ==========
router.get('/stats', adminController.getSystemStats);
// ========== 数据源管理 ==========
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);

@ -220,25 +220,45 @@ export class ExternalDataSourceService {
/**
*
* 使 trading-dates
*/
async getNearestTradingDate(date?: string): Promise<string | null> {
if (!this.enabled) {
throw new Error('External data source is not enabled');
}
const params: Record<string, any> = {};
if (date) params.date = date;
// 构造查询日期范围:从指定日期往前推 10 天
const targetDate = date ? new Date(date) : new Date();
const startDate = new Date(targetDate);
startDate.setDate(startDate.getDate() - 10);
// 格式化为 YYYYMMDD
const formatDate = (d: Date) => d.toISOString().split('T')[0].replace(/-/g, '');
const response = await axios.get(
`${this.baseUrl}/v1/stock/nearest-trading-date`,
{ ...this.getRequestConfig(), params }
`${this.baseUrl}/v1/stock/trading-dates`,
{
...this.getRequestConfig(),
params: {
start: formatDate(startDate),
end: formatDate(targetDate),
},
}
);
if (response.data.code !== 0) {
throw new Error(response.data.message || 'Failed to get nearest trading date');
}
return response.data.data?.nearestTradingDate || null;
// 返回最后一个交易日(最近的)
const tradingDates = response.data.data?.trading_dates || [];
if (tradingDates.length === 0) {
return null;
}
// 将 YYYYMMDD 转换为 YYYY-MM-DD
const lastDate = tradingDates[tradingDates.length - 1];
return `${lastDate.slice(0, 4)}-${lastDate.slice(4, 6)}-${lastDate.slice(6, 8)}`;
}
/**

@ -0,0 +1,42 @@
/**
*
*/
/**
*
*/
export function getTradingDaysCount(startDate: Date, endDate: Date): number {
let count = 0;
const current = new Date(startDate);
while (current <= endDate) {
const dayOfWeek = current.getDay();
// 周一到周五为交易日
if (dayOfWeek >= 1 && dayOfWeek <= 5) {
count++;
}
current.setDate(current.getDate() + 1);
}
return count;
}
/**
*
*/
export function isHoliday(date: Date): boolean {
// 这里可以实现更复杂的节假日判断逻辑
// 暂时只排除周末
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6;
}
/**
*
*
*/
export async function getDailyStockCount(date: Date): Promise<number> {
// 返回一个估算的每日股票数量
// 实际应该查询历史数据表,这里使用一个合理的估算值
return 5200; // A股大约5200只股票
}

@ -18,7 +18,7 @@ interface SystemStats {
dataCompleteness: number;
lastSync: string;
apiStatus: {
akshare: boolean;
externalDataSource: boolean;
database: boolean;
redis: boolean;
};
@ -50,7 +50,7 @@ export default function Dashboard() {
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: '10:00', action: '服务状态检查', detail: `外部数据源: ${data.apiStatus.externalDataSource ? '正常' : '异常'}` },
{ time: '08:30', action: '缓存更新', detail: 'Redis 缓存已刷新' },
]);
} catch (error) {

@ -18,7 +18,7 @@ import {
Layers,
Zap
} from 'lucide-react';
import { adminApi, createSyncTaskWebSocket, type DataCheckItem, type SyncTask } from '@/services/adminApi';
import { adminApi, createSyncTaskWebSocket, type DataCheckItem, type DataCheckSummary, type SyncTask } from '@/services/adminApi';
import { Settings } from 'lucide-react';
interface BufferOptions {
@ -30,6 +30,7 @@ interface BufferOptions {
export default function DataCheck() {
const [dataStatus, setDataStatus] = useState<DataCheckItem[]>([]);
const [summary, setSummary] = useState<DataCheckSummary | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [buffering, setBuffering] = useState(false);
@ -47,16 +48,26 @@ export default function DataCheck() {
const wsRef = useRef<WebSocket | null>(null);
const checkIntervalRef = useRef<number | null>(null);
const checkTimeoutRef = useRef<number | null>(null);
const [checkTimeout, setCheckTimeout] = useState(false);
// 获取数据状态
const fetchDataStatus = useCallback(async () => {
try {
const data = await adminApi.getDataCheck();
setDataStatus(data);
const response = await adminApi.getDataCheck();
// 适配新的响应格式
if (response.checks) {
setDataStatus(response.checks);
setSummary(response.summary || null);
} else {
// 兼容旧格式
setDataStatus(response);
}
} catch (error) {
console.error('Failed to fetch data status:', error);
// API 失败时显示空数据
setDataStatus([]);
setSummary(null);
}
}, []);
@ -113,28 +124,49 @@ export default function DataCheck() {
// 执行数据检查
const handleCheckData = async () => {
setChecking(true);
setCheckTimeout(false);
// 设置30秒超时
const TIMEOUT_MS = 30000;
const startTime = Date.now();
try {
const result = await adminApi.runDataCheck();
// 轮询任务状态
const checkTask = async () => {
try {
const elapsed = Date.now() - startTime;
// 检查是否超时
if (elapsed > TIMEOUT_MS) {
setChecking(false);
setCheckTimeout(true);
console.warn('数据检查超时');
return;
}
const task = await adminApi.getSyncTask(result.taskId);
if (task.status === 'completed') {
setChecking(false);
setCheckTimeout(false);
fetchDataStatus();
} else if (task.status === 'failed') {
setChecking(false);
setCheckTimeout(false);
} else {
setTimeout(checkTask, 1000);
}
} catch (error) {
setChecking(false);
setCheckTimeout(false);
}
};
checkTask();
} catch (error) {
console.error('Check failed:', error);
setChecking(false);
setCheckTimeout(false);
}
};
@ -226,12 +258,12 @@ export default function DataCheck() {
);
};
// 计算统计数据
const completionRate = dataStatus.length > 0
// 计算统计数据(优先使用后端返回的 summary 数据)
const completionRate = summary?.completenessRate ?? (dataStatus.length > 0
? Math.round((dataStatus.filter(d => d.status === 'complete').length / dataStatus.length) * 100)
: 0;
: 0);
const totalMissing = dataStatus.reduce((acc, item) => {
const totalMissing = summary?.missingQuotesCount ?? dataStatus.reduce((acc, item) => {
if (item.status !== 'complete') {
return acc + (item.total - item.current);
}
@ -272,6 +304,25 @@ export default function DataCheck() {
</div>
</div>
{/* Timeout Warning */}
{checkTimeout && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-yellow-500/10 border border-yellow-500/30 rounded-xl p-4"
>
<div className="flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-yellow-400" />
<div>
<p className="text-yellow-400 font-medium"></p>
<p className="text-sm text-[#b0b0b0] mt-1">
</p>
</div>
</div>
</motion.div>
)}
{/* Progress Card */}
{(buffering || currentTask) && (
<motion.div
@ -413,68 +464,104 @@ export default function DataCheck() {
</motion.div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center justify-between">
<div>
<p className="text-[#b0b0b0] text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{completionRate}%</p>
{summary && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center justify-between">
<div>
<p className="text-[#b0b0b0] text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{summary.completenessRate}%</p>
</div>
<div className="w-12 h-12 bg-green-500/20 rounded-xl flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-green-400" />
</div>
</div>
<div className="w-12 h-12 bg-green-500/20 rounded-xl flex items-center justify-center">
<CheckCircle className="w-6 h-6 text-green-400" />
<div className="mt-3 h-2 bg-[#2a2a2a] rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full transition-all" style={{ width: `${summary.completenessRate}%` }} />
</div>
</div>
<div className="mt-3 h-2 bg-[#2a2a2a] rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full transition-all" style={{ width: `${completionRate}%` }} />
</div>
</motion.div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center justify-between">
<div>
<p className="text-[#b0b0b0] text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{totalMissing.toLocaleString()}</p>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center justify-between">
<div>
<p className="text-[#b0b0b0] text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{summary.missingQuotesCount.toLocaleString()}</p>
</div>
<div className="w-12 h-12 bg-red-500/20 rounded-xl flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-red-400" />
</div>
</div>
<div className="w-12 h-12 bg-red-500/20 rounded-xl flex items-center justify-center">
<AlertCircle className="w-6 h-6 text-red-400" />
<p className="text-sm text-[#666] mt-3">
{summary.missingQuotesCount > 0 ? '建议执行一键缓冲' : '数据完整无需缓冲'}
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center justify-between">
<div>
<p className="text-[#b0b0b0] text-sm"></p>
<p className="text-2xl font-bold text-white mt-1">{summary.tradingDaysCount} </p>
</div>
<div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center">
<Calendar className="w-6 h-6 text-blue-400" />
</div>
</div>
</div>
<p className="text-sm text-[#666] mt-3">
{totalMissing > 0 ? '建议执行一键缓冲' : '数据完整无需缓冲'}
</p>
</motion.div>
<p className="text-sm text-[#666] mt-3">
: {summary.referenceDate}, : {summary.dailyStockCount}
</p>
</motion.div>
</div>
)}
{/* 详细统计信息 */}
{summary && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-5"
>
<div className="flex items-center justify-between">
<div>
<p className="text-[#b0b0b0] text-sm"></p>
<p className="text-lg font-bold text-white mt-1">
{Math.ceil((new Date(bufferOptions.endDate).getTime() - new Date(bufferOptions.startDate).getTime()) / (1000 * 60 * 60 * 24))}
<h3 className="text-white font-semibold mb-4"></h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-3 bg-[#0a0a0a] rounded-lg">
<p className="text-[#666] text-xs"></p>
<p className="text-white text-lg font-medium">{summary.expectedTotalQuotes.toLocaleString()}</p>
</div>
<div className="p-3 bg-[#0a0a0a] rounded-lg">
<p className="text-[#666] text-xs"></p>
<p className="text-white text-lg font-medium">{summary.actualTotalQuotes.toLocaleString()}</p>
</div>
<div className="p-3 bg-[#0a0a0a] rounded-lg">
<p className="text-[#666] text-xs"></p>
<p className={`text-lg font-medium ${summary.missingQuotesCount > 0 ? 'text-red-400' : 'text-green-400'}`}>
{summary.missingQuotesCount.toLocaleString()}
</p>
</div>
<div className="w-12 h-12 bg-blue-500/20 rounded-xl flex items-center justify-center">
<Calendar className="w-6 h-6 text-blue-400" />
<div className="p-3 bg-[#0a0a0a] rounded-lg">
<p className="text-[#666] text-xs"></p>
<p className={`text-lg font-medium ${summary.completenessRate >= 90 ? 'text-green-400' : summary.completenessRate >= 50 ? 'text-yellow-400' : 'text-red-400'}`}>
{summary.completenessRate}%
</p>
</div>
</div>
<p className="text-sm text-[#666] mt-3">
{bufferOptions.startDate} {bufferOptions.endDate}
<p className="text-xs text-[#666] mt-3">
* : = ({summary.dailyStockCount}) × ({summary.tradingDaysCount}) = {summary.expectedTotalQuotes.toLocaleString()}
</p>
</motion.div>
</div>
)}
{/* Quick Actions */}
<div className="flex flex-wrap gap-3">

@ -20,35 +20,21 @@ import {
} from 'lucide-react';
import { adminApi, type DataSourceConfig as DataSourceConfigType } from '@/services/adminApi';
interface AKShareStatus {
connected: boolean;
version?: string;
supportedApis?: string[];
}
interface AKShareConfig {
baseUrl: string;
}
const MARKET_DATA_SERVICE_ID = 'marketDataService';
export default function DataSourceConfig() {
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 [editingCustom, setEditingCustom] = useState(false);
const [customUrl, setCustomUrl] = useState('http://localhost:8000');
const [customEnabled, setCustomEnabled] = useState(false);
const [savingCustom, setSavingCustom] = useState(false);
// 统一数据服务编辑状态
const [editingService, setEditingService] = useState(false);
const [serviceUrl, setServiceUrl] = useState('http://localhost:8080');
const [serviceEnabled, setServiceEnabled] = useState(false);
const [savingService, setSavingService] = useState(false);
const [loading, setLoading] = useState(true);
const [testing, setTesting] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// 检查登录状态
@ -68,54 +54,30 @@ export default function DataSourceConfig() {
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);
// 初始化自定义数据源状态
const customSource = sourcesData.value.find(s => s.id === 'custom');
if (customSource) {
setCustomUrl(customSource.url);
setCustomEnabled(customSource.enabled);
}
} else if (sourcesData.reason?.message?.includes('登录')) {
setIsLoggedIn(false);
localStorage.removeItem('token');
localStorage.removeItem('user');
}
const sourcesData = await adminApi.getDataSources();
setSources(sourcesData);
if (statusData.status === 'fulfilled') {
setAkshareStatus(statusData.value);
}
if (configData.status === 'fulfilled') {
setAkshareConfig(configData.value);
// 初始化统一数据服务状态
const serviceSource = sourcesData.find(s => s.id === MARKET_DATA_SERVICE_ID || s.type === 'custom');
if (serviceSource) {
setServiceUrl(serviceSource.url);
setServiceEnabled(serviceSource.enabled);
}
} catch (error: any) {
console.error('Failed to fetch data:', error);
if (error.message?.includes('登录')) {
setIsLoggedIn(false);
localStorage.removeItem('token');
localStorage.removeItem('user');
}
// 使用默认配置
setSources([
{
id: 'akshare',
name: 'AKShare 官方',
type: 'akshare',
url: akshareConfig.baseUrl,
enabled: true,
syncInterval: 5,
status: 'disconnected',
},
{
id: 'custom',
name: '自定义数据源',
id: MARKET_DATA_SERVICE_ID,
name: '统一数据服务',
type: 'custom',
url: 'http://localhost:8080',
enabled: false,
enabled: true,
syncInterval: 5,
status: 'disconnected',
}
@ -125,7 +87,7 @@ export default function DataSourceConfig() {
}
};
const handleTestConnection = async (sourceId: string = 'akshare') => {
const handleTestConnection = async (sourceId: string = MARKET_DATA_SERVICE_ID) => {
setTesting(sourceId);
setMessage(null);
@ -133,15 +95,9 @@ export default function DataSourceConfig() {
const result = await adminApi.testDataSource(sourceId);
setMessage({
type: result.success ? 'success' : 'error',
text: `[${sourceId === 'custom' ? '自定义数据源' : 'AKShare'}] ${result.message}`,
text: `[统一数据服务] ${result.message}`,
});
// 如果是AKShare刷新状态
if (sourceId === 'akshare') {
const status = await adminApi.getAKShareStatus();
setAkshareStatus(status);
}
// 刷新数据源列表
await fetchData();
} catch (error: any) {
@ -154,20 +110,6 @@ export default function DataSourceConfig() {
}
};
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;
@ -179,86 +121,59 @@ export default function DataSourceConfig() {
setSources(prev => prev.map(s =>
s.id === sourceId ? { ...s, enabled: newEnabled } : s
));
setMessage({ type: 'success', text: `${source.name}${newEnabled ? '启用' : '禁用'}` });
setCustomEnabled(newEnabled);
setServiceEnabled(newEnabled);
setMessage({ type: 'success', text: `统一数据服务已${newEnabled ? '启用' : '禁用'}` });
} catch (error: any) {
setMessage({ type: 'error', text: error.message || '操作失败' });
}
};
// 开始编辑 URL
const handleStartEditUrl = () => {
setEditingUrl(akshareConfig.baseUrl);
setIsEditingUrl(true);
};
// 取消编辑 URL
const handleCancelEditUrl = () => {
setIsEditingUrl(false);
setEditingUrl('');
};
// 保存 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 handleStartEditService = () => {
const serviceSource = sources.find(s => s.id === MARKET_DATA_SERVICE_ID || s.type === 'custom');
if (serviceSource) {
setServiceUrl(serviceSource.url);
setServiceEnabled(serviceSource.enabled);
}
setEditingService(true);
};
// 开始编辑自定义数据源
const handleStartEditCustom = () => {
const customSource = sources.find(s => s.id === 'custom');
if (customSource) {
setCustomUrl(customSource.url);
setCustomEnabled(customSource.enabled);
}
setEditingCustom(true);
// 取消编辑统一数据服务
const handleCancelEditService = () => {
setEditingService(false);
};
// 取消编辑自定义数据源
const handleCancelEditCustom = () => {
setEditingCustom(false);
};
// 保存自定义数据源配置
const handleSaveCustom = async () => {
if (!customUrl.trim()) {
// 保存统一数据服务配置
const handleSaveService = async () => {
if (!serviceUrl.trim()) {
setMessage({ type: 'error', text: '请输入有效的 URL' });
return;
}
setSavingCustom(true);
setSavingService(true);
try {
await adminApi.updateDataSource('custom', {
url: customUrl.trim(),
enabled: customEnabled,
const serviceSource = sources.find(s => s.id === MARKET_DATA_SERVICE_ID || s.type === 'custom');
const sourceId = serviceSource?.id || MARKET_DATA_SERVICE_ID;
await adminApi.updateDataSource(sourceId, {
url: serviceUrl.trim(),
enabled: serviceEnabled,
});
setMessage({
type: 'success',
text: `自定义数据源已${customEnabled ? '启用' : '禁用'},地址: ${customUrl.trim()}`
text: `统一数据服务已${serviceEnabled ? '启用' : '禁用'},地址: ${serviceUrl.trim()}`
});
setEditingCustom(false);
setEditingService(false);
await fetchData();
} catch (error: any) {
setMessage({ type: 'error', text: error.message || '保存失败' });
} finally {
setSavingCustom(false);
setSavingService(false);
}
};
const akshareSource = sources.find(s => s.type === 'akshare');
const marketDataService = sources.find(s => s.id === MARKET_DATA_SERVICE_ID || s.type === 'custom');
// 加载中
if (loading) {
@ -296,7 +211,7 @@ export default function DataSourceConfig() {
<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"> AKShare </p>
<p className="text-[#b0b0b0] mt-1"></p>
</div>
</div>
@ -322,7 +237,7 @@ export default function DataSourceConfig() {
</motion.div>
)}
{/* 自定义数据源配置卡片 */}
{/* 统一数据服务配置卡片 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@ -331,27 +246,27 @@ export default function DataSourceConfig() {
<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 ${
customEnabled
serviceEnabled
? 'bg-[#ff6b35]/20'
: 'bg-[#2a2a2a]'
}`}>
<Server className={`w-7 h-7 ${
customEnabled ? 'text-[#ff6b35]' : 'text-[#666]'
serviceEnabled ? 'text-[#ff6b35]' : 'text-[#666]'
}`} />
</div>
<div>
<h2 className="text-lg font-semibold text-white"></h2>
<h2 className="text-lg font-semibold text-white"></h2>
<div className="flex items-center gap-2 mt-1">
<div className={`w-2 h-2 rounded-full ${
customEnabled ? 'bg-[#ff6b35]' : 'bg-[#666]'
serviceEnabled ? 'bg-[#ff6b35]' : 'bg-[#666]'
}`} />
<span className={`text-sm ${
customEnabled ? 'text-[#ff6b35]' : 'text-[#666]'
serviceEnabled ? 'text-[#ff6b35]' : 'text-[#666]'
}`}>
{customEnabled ? '已启用' : '未启用'}
{serviceEnabled ? '已启用' : '未启用'}
</span>
{customEnabled && (
<span className="text-xs text-green-400">(使)</span>
{serviceEnabled && (
<span className="text-xs text-green-400">(使)</span>
)}
</div>
</div>
@ -360,14 +275,14 @@ export default function DataSourceConfig() {
<div className="flex items-center gap-3">
{/* Enable/Disable Switch */}
<button
onClick={() => handleToggleSource('custom')}
onClick={() => handleToggleSource(marketDataService?.id || MARKET_DATA_SERVICE_ID)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
customEnabled
serviceEnabled
? 'bg-[#ff6b35]/20 text-[#ff6b35] hover:bg-[#ff6b35]/30'
: 'bg-[#2a2a2a] text-[#666] hover:bg-[#333]'
}`}
>
{customEnabled ? (
{serviceEnabled ? (
<>
<Pause className="w-4 h-4" />
<span></span>
@ -382,11 +297,11 @@ export default function DataSourceConfig() {
{/* Test Button */}
<button
onClick={() => handleTestConnection('custom')}
disabled={testing === 'custom'}
onClick={() => handleTestConnection(marketDataService?.id || MARKET_DATA_SERVICE_ID)}
disabled={testing === (marketDataService?.id || MARKET_DATA_SERVICE_ID)}
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 === 'custom' ? (
{testing === (marketDataService?.id || MARKET_DATA_SERVICE_ID) ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Zap className="w-4 h-4" />
@ -395,9 +310,9 @@ export default function DataSourceConfig() {
</button>
{/* Edit Button */}
{!editingCustom ? (
{!editingService ? (
<button
onClick={handleStartEditCustom}
onClick={handleStartEditService}
className="flex items-center gap-2 px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors"
>
<Settings className="w-4 h-4" />
@ -405,7 +320,7 @@ export default function DataSourceConfig() {
</button>
) : (
<button
onClick={handleCancelEditCustom}
onClick={handleCancelEditService}
className="flex items-center gap-2 px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors"
>
<X className="w-4 h-4" />
@ -415,8 +330,8 @@ export default function DataSourceConfig() {
</div>
</div>
{/* Custom Data Source Config */}
{editingCustom && (
{/* Market Data Service Config */}
{editingService && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
@ -426,13 +341,13 @@ export default function DataSourceConfig() {
<label className="text-sm text-[#b0b0b0]"></label>
<input
type="text"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
placeholder="http://localhost:8000"
value={serviceUrl}
onChange={(e) => setServiceUrl(e.target.value)}
placeholder="http://localhost:8080"
className="w-full bg-[#0a0a0a] border border-[#2a2a2a] rounded-lg px-4 py-2.5 text-white outline-none focus:border-[#ff6b35]"
/>
<p className="text-xs text-[#666]">
Python: http://localhost:8000 或 http://192.168.1.100:8000
: http://localhost:8080 或 http://192.168.1.100:8080
</p>
</div>
@ -440,27 +355,27 @@ export default function DataSourceConfig() {
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={customEnabled}
onChange={(e) => setCustomEnabled(e.target.checked)}
checked={serviceEnabled}
onChange={(e) => setServiceEnabled(e.target.checked)}
className="w-4 h-4 rounded border-[#2a2a2a] bg-[#0a0a0a] text-[#ff6b35] focus:ring-[#ff6b35]"
/>
<span className="text-sm text-white"></span>
<span className="text-sm text-white"></span>
</label>
</div>
<div className="flex justify-end gap-3">
<button
onClick={handleCancelEditCustom}
onClick={handleCancelEditService}
className="px-4 py-2 bg-[#2a2a2a] text-white rounded-lg hover:bg-[#333] transition-colors"
>
</button>
<button
onClick={handleSaveCustom}
disabled={savingCustom}
onClick={handleSaveService}
disabled={savingService}
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"
>
{savingCustom ? (
{savingService ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
@ -472,179 +387,14 @@ export default function DataSourceConfig() {
)}
{/* Current Config Display */}
{!editingCustom && (
{!editingService && (
<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>
<span className="text-sm text-white font-mono">{customUrl}</span>
</div>
</div>
)}
</motion.div>
{/* AKShare Status Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-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'
}`}>
{akshareStatus?.connected ? '连接正常' : '未连接'}
</span>
{!customEnabled && akshareStatus?.connected && (
<span className="text-xs text-green-400">(使)</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('akshare')}
disabled={testing === 'akshare'}
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 === 'akshare' ? (
<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={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="保存"
>
{savingUrl ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
</button>
<button
onClick={handleCancelEditUrl}
className="p-1.5 hover:bg-[#2a2a2a] rounded transition-colors"
title="取消"
>
<X className="w-4 h-4 text-[#666]" />
</button>
</div>
)}
</div>
<p className="text-xs text-[#666] mt-2">
{customEnabled
? '自定义数据源已启用AKShare将作为备用数据源'
: '修改后需要重启后端服务才能生效。Docker 环境可使用 http://host.docker.internal:8000'}
</p>
</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>
))}
<span className="text-sm text-white font-mono">{serviceUrl}</span>
</div>
</div>
)}
@ -654,7 +404,7 @@ export default function DataSourceConfig() {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
transition={{ delay: 0.1 }}
className="bg-[#111111] border border-[#2a2a2a] rounded-xl p-6"
>
<h2 className="text-lg font-semibold text-white mb-4"></h2>
@ -664,29 +414,20 @@ export default function DataSourceConfig() {
<div
key={source.id}
className={`flex items-center justify-between p-4 bg-[#0a0a0a] rounded-lg border ${
source.enabled && source.id === 'custom'
source.enabled
? 'border-[#ff6b35]/50'
: source.enabled
? 'border-green-500/30'
: 'border-transparent'
: 'border-transparent'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${
source.enabled
? source.id === 'custom' ? 'bg-[#ff6b35]' : 'bg-green-500'
: 'bg-[#666]'
source.enabled ? 'bg-[#ff6b35]' : 'bg-[#666]'
}`} />
<div>
<div className="flex items-center gap-2">
<span className="text-white font-medium">{source.name}</span>
{source.enabled && source.id === 'custom' && (
{source.enabled && (
<span className="px-2 py-0.5 bg-[#ff6b35]/20 text-[#ff6b35] text-xs rounded">
</span>
)}
{source.enabled && source.id === 'akshare' && !customEnabled && (
<span className="px-2 py-0.5 bg-green-500/20 text-green-400 text-xs rounded">
使
</span>
)}
@ -718,7 +459,7 @@ export default function DataSourceConfig() {
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
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">
@ -727,10 +468,9 @@ export default function DataSourceConfig() {
<h3 className="text-blue-400 font-medium"></h3>
<ul className="text-sm text-blue-300/80 mt-2 space-y-1 list-disc list-inside">
<li><strong></strong>: Python IP </li>
<li><strong>AKShare </strong>: API Key</li>
<li>使</li>
<li>退 AKShare </li>
<li> http://localhost:8080可根据实际情况修改</li>
<li>"测试连接"</li>
<li></li>
</ul>
</div>
</div>

@ -55,7 +55,7 @@ export interface AdminUser {
export interface DataSourceConfig {
id: string;
name: string;
type: 'akshare' | 'tushare' | 'custom';
type: 'custom';
url: string;
apiKey?: string;
enabled: boolean;
@ -76,6 +76,23 @@ export interface DataCheckItem {
details?: string;
}
// 数据检查结果汇总
export interface DataCheckSummary {
tradingDaysCount: number;
referenceDate: string;
dailyStockCount: number;
expectedTotalQuotes: number;
actualTotalQuotes: number;
missingQuotesCount: number;
completenessRate: number;
}
// 数据检查响应
export interface DataCheckResponse {
checks: DataCheckItem[];
summary: DataCheckSummary;
}
// 同步任务
export interface SyncTask {
id: string;
@ -121,44 +138,10 @@ export const adminApi = {
});
},
// ========== 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[]> {
getDataCheck(): Promise<DataCheckResponse> {
return adminRequest('/admin/data-check');
},

Loading…
Cancel
Save