1
This commit is contained in:
parent
1eaf608ece
commit
6d741a7ec2
Binary file not shown.
Binary file not shown.
148
backend/app/api/debug.py
Normal file
148
backend/app/api/debug.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""Debug API — 系统日志与运行状态"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.deps import get_current_admin
|
||||
from app.db.database import get_db
|
||||
from app.config import settings, is_trading_hours
|
||||
|
||||
router = APIRouter(prefix="/api/debug", tags=["debug"])
|
||||
|
||||
|
||||
@router.get("/errors")
|
||||
async def get_errors(
|
||||
limit: int = 50,
|
||||
source: str = None,
|
||||
level: str = None,
|
||||
days: int = 7,
|
||||
_admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
"""获取错误日志(管理员)"""
|
||||
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
async with get_db() as db:
|
||||
conditions = ["created_at >= :start"]
|
||||
params = {"start": start}
|
||||
|
||||
if source:
|
||||
conditions.append("source = :source")
|
||||
params["source"] = source
|
||||
if level:
|
||||
conditions.append("level = :level")
|
||||
params["level"] = level
|
||||
|
||||
where = " AND " + " AND ".join(conditions)
|
||||
|
||||
# 总数
|
||||
count_result = await db.execute(
|
||||
text(f"SELECT COUNT(*) FROM error_logs WHERE {where}"), params
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# 查询
|
||||
params["limit"] = limit
|
||||
result = await db.execute(
|
||||
text(
|
||||
f"SELECT id, source, level, message, detail, created_at "
|
||||
f"FROM error_logs WHERE {where} "
|
||||
f"ORDER BY created_at DESC LIMIT :limit"
|
||||
),
|
||||
params,
|
||||
)
|
||||
rows = result.fetchall()
|
||||
errors = []
|
||||
for row in rows:
|
||||
r = row._mapping
|
||||
errors.append({
|
||||
"id": r["id"],
|
||||
"source": r["source"],
|
||||
"level": r["level"],
|
||||
"message": r["message"],
|
||||
"detail": r["detail"] or "",
|
||||
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
||||
})
|
||||
|
||||
# 可选的 source/level 列表(用于前端过滤)
|
||||
sources_result = await db.execute(
|
||||
text("SELECT DISTINCT source FROM error_logs ORDER BY source")
|
||||
)
|
||||
sources = [r[0] for r in sources_result.fetchall()]
|
||||
|
||||
levels_result = await db.execute(
|
||||
text("SELECT DISTINCT level FROM error_logs ORDER BY level")
|
||||
)
|
||||
levels = [r[0] for r in levels_result.fetchall()]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"errors": errors,
|
||||
"sources": sources,
|
||||
"levels": levels,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/errors")
|
||||
async def clear_errors(
|
||||
days: int = 30,
|
||||
_admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
"""清除旧错误日志(管理员)"""
|
||||
cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
text("DELETE FROM error_logs WHERE created_at < :cutoff"),
|
||||
{"cutoff": cutoff},
|
||||
)
|
||||
deleted = result.rowcount
|
||||
await db.commit()
|
||||
return {"status": "ok", "deleted": deleted}
|
||||
|
||||
|
||||
@router.get("/system")
|
||||
async def system_status(_admin: dict = Depends(get_current_admin)):
|
||||
"""系统运行状态摘要(管理员)"""
|
||||
from app.engine.recommender import _scan_running, _scan_lock
|
||||
|
||||
async with get_db() as db:
|
||||
# 各表数据量
|
||||
tables_counts = {}
|
||||
for t in ["recommendations", "sector_heat", "market_temperature",
|
||||
"recommendation_tracking", "daily_reviews", "stock_diagnoses",
|
||||
"error_logs", "users"]:
|
||||
result = await db.execute(text(f"SELECT COUNT(*) FROM {t}"))
|
||||
tables_counts[t] = result.scalar() or 0
|
||||
|
||||
# 最近 24h 错误数
|
||||
since = (datetime.now() - timedelta(hours=24)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
result = await db.execute(
|
||||
text("SELECT COUNT(*) FROM error_logs WHERE created_at >= :since"),
|
||||
{"since": since},
|
||||
)
|
||||
recent_errors = result.scalar() or 0
|
||||
|
||||
# 最近错误
|
||||
result = await db.execute(
|
||||
text("SELECT source, message, created_at FROM error_logs ORDER BY created_at DESC LIMIT 5")
|
||||
)
|
||||
last_errors = [
|
||||
{"source": r[0], "message": r[1], "created_at": str(r[2])}
|
||||
for r in result.fetchall()
|
||||
]
|
||||
|
||||
# 数据库文件大小
|
||||
db_path = settings.database_url.replace("sqlite:///", "")
|
||||
db_size_mb = 0
|
||||
if os.path.exists(db_path):
|
||||
db_size_mb = round(os.path.getsize(db_path) / 1024 / 1024, 2)
|
||||
|
||||
return {
|
||||
"is_trading": is_trading_hours(),
|
||||
"scan_running": _scan_running,
|
||||
"scan_locked": _scan_lock.locked(),
|
||||
"recent_errors": recent_errors,
|
||||
"last_errors": last_errors,
|
||||
"tables_counts": tables_counts,
|
||||
"db_size_mb": db_size_mb,
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter
|
||||
@ -413,6 +414,8 @@ async def diagnose_stock(ts_code: str):
|
||||
logger.info(f"已保存诊断结果到数据库: {ts_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"保存诊断结果到数据库失败: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("stocks", f"保存诊断结果到数据库失败: {e}", detail=traceback.format_exc())
|
||||
|
||||
yield f"data: {json.dumps({'done': True, 'ts_code': ts_code}, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -26,6 +26,10 @@ async def init_db():
|
||||
await conn.run_sync(metadata.create_all)
|
||||
# 补充新增列(SQLite ALTER TABLE ADD COLUMN,已存在会忽略)
|
||||
for col_sql in [
|
||||
"ALTER TABLE recommendations ADD COLUMN supply_demand_score REAL DEFAULT 0",
|
||||
"ALTER TABLE recommendations ADD COLUMN price_action_score REAL DEFAULT 0",
|
||||
"ALTER TABLE recommendations ADD COLUMN supply_demand_score REAL DEFAULT 0",
|
||||
"ALTER TABLE recommendations ADD COLUMN price_action_score REAL DEFAULT 0",
|
||||
"ALTER TABLE recommendations ADD COLUMN position_score REAL",
|
||||
"ALTER TABLE recommendations ADD COLUMN valuation_score REAL",
|
||||
"ALTER TABLE recommendations ADD COLUMN llm_analysis TEXT DEFAULT ''",
|
||||
|
||||
23
backend/app/db/error_logger.py
Normal file
23
backend/app/db/error_logger.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""错误日志持久化"""
|
||||
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from app.db.database import get_db
|
||||
from app.db import tables
|
||||
|
||||
|
||||
async def log_error(source: str, message: str, detail: str = "", level: str = "error"):
|
||||
"""将错误写入数据库,失败时静默(不影响主流程)"""
|
||||
try:
|
||||
async with get_db() as db:
|
||||
stmt = tables.error_logs_table.insert().values(
|
||||
source=source,
|
||||
level=level,
|
||||
message=message,
|
||||
detail=detail,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
await db.execute(stmt)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass # 写日志失败不应影响主业务
|
||||
@ -109,3 +109,13 @@ stock_diagnoses_table = Table(
|
||||
Column("diagnosis", Text, nullable=False),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
error_logs_table = Table(
|
||||
"error_logs", metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("source", Text, nullable=False), # 模块来源,如 "recommender", "screener"
|
||||
Column("level", Text, default="error"), # error / warning
|
||||
Column("message", Text, nullable=False), # 错误消息
|
||||
Column("detail", Text, default=""), # 完整异常信息(traceback)
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -7,6 +7,7 @@
|
||||
import logging
|
||||
import json
|
||||
import asyncio
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from app.engine.screener import run_screening
|
||||
from app.data.models import Recommendation, MarketTemperature, SectorInfo
|
||||
@ -126,6 +127,8 @@ async def _update_tracking():
|
||||
logger.info(f"已更新 {tracked} 条推荐跟踪记录")
|
||||
except Exception as e:
|
||||
logger.error(f"更新推荐跟踪失败: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("recommender", f"更新推荐跟踪失败: {e}", detail=traceback.format_exc())
|
||||
|
||||
|
||||
async def get_performance_stats() -> dict:
|
||||
@ -247,6 +250,8 @@ async def get_performance_stats() -> dict:
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取胜率统计失败: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("recommender", f"获取胜率统计失败: {e}", detail=traceback.format_exc())
|
||||
return {
|
||||
"total_recommendations": 0, "tracked": 0, "winning": 0,
|
||||
"win_rate": 0, "avg_return": 0, "hit_target_count": 0,
|
||||
@ -456,6 +461,8 @@ async def _save_to_db(result: dict):
|
||||
logger.info(f"已保存 {saved_count} 条推荐到数据库(共 {len(result.get('recommendations', []))} 条,过滤掉 <60 分)")
|
||||
except Exception as e:
|
||||
logger.error(f"保存推荐到数据库失败: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("recommender", f"保存推荐到数据库失败: {e}", detail=traceback.format_exc())
|
||||
|
||||
|
||||
async def _load_today_from_db() -> dict:
|
||||
@ -532,6 +539,8 @@ async def _load_today_from_db() -> dict:
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"从数据库加载推荐失败: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("recommender", f"从数据库加载推荐失败: {e}", detail=traceback.format_exc())
|
||||
return {"market_temp": None, "hot_sectors": [], "capital_filtered": [], "recommendations": []}
|
||||
|
||||
|
||||
@ -577,4 +586,6 @@ async def _load_sectors_from_db() -> list[SectorInfo]:
|
||||
return sectors
|
||||
except Exception as e:
|
||||
logger.error(f"从数据库加载板块数据失败: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("recommender", f"从数据库加载板块数据失败: {e}", detail=traceback.format_exc())
|
||||
return []
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
@ -33,6 +34,8 @@ async def _run_scan(session_name: str):
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"定时扫描失败 ({session_name}): {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("scheduler", f"定时扫描失败 ({session_name}): {e}", detail=traceback.format_exc())
|
||||
|
||||
|
||||
async def _generate_daily_review():
|
||||
@ -47,6 +50,8 @@ async def _generate_daily_review():
|
||||
logger.warning(f"复盘报告生成失败: {result.get('message')}")
|
||||
except Exception as e:
|
||||
logger.error(f"复盘报告生成异常: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("scheduler", f"复盘报告生成异常: {e}", detail=traceback.format_exc())
|
||||
|
||||
|
||||
def setup_scheduler():
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
"""
|
||||
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
import pandas as pd
|
||||
|
||||
@ -684,6 +685,8 @@ async def _build_recommendations(
|
||||
logger.info(f"LLM 逐股分析完成, 综合评分后保留 {len(recommendations)} 只")
|
||||
except Exception as e:
|
||||
logger.error(f"LLM 逐股分析失败, 仅使用量化评分: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("screener", f"LLM 逐股分析失败, 仅使用量化评分: {e}", detail=traceback.format_exc())
|
||||
|
||||
return recommendations
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import traceback
|
||||
|
||||
from app.llm.client import chat_completion
|
||||
from app.llm.prompts import TREND_BREAKOUT_ANALYSIS_PROMPT
|
||||
@ -26,6 +27,8 @@ async def analyze_recommendations(result: dict) -> None:
|
||||
await _do_analyze(result, recommendations)
|
||||
except Exception as e:
|
||||
logger.error(f"AI 分析任务异常: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("analysis_agent", f"AI 分析任务异常: {e}", detail=traceback.format_exc())
|
||||
for rec in recommendations:
|
||||
if not rec.llm_analysis:
|
||||
rec.llm_analysis = "AI 分析暂时不可用"
|
||||
@ -233,6 +236,8 @@ async def _save_llm_analysis_to_db(recommendations: list) -> None:
|
||||
await db.commit()
|
||||
except Exception as e:
|
||||
logger.error(f"保存 AI 分析到数据库失败: {e}")
|
||||
from app.db.error_logger import log_error
|
||||
await log_error("analysis_agent", f"保存 AI 分析到数据库失败: {e}", detail=traceback.format_exc())
|
||||
|
||||
|
||||
async def _broadcast_llm_ready(recommendations: list) -> None:
|
||||
|
||||
@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import settings
|
||||
from app.db.database import init_db
|
||||
from app.engine.scheduler import start_scheduler, stop_scheduler
|
||||
from app.api import market, sectors, recommendations, stocks, websocket, chat, auth
|
||||
from app.api import market, sectors, recommendations, stocks, websocket, chat, auth, debug
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if settings.debug else logging.INFO,
|
||||
@ -80,6 +80,7 @@ app.include_router(recommendations.router)
|
||||
app.include_router(stocks.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(debug.router)
|
||||
|
||||
# WebSocket
|
||||
app.websocket("/ws")(websocket.ws_endpoint)
|
||||
|
||||
Binary file not shown.
@ -1,14 +1,14 @@
|
||||
{
|
||||
"pages": {
|
||||
"/(auth)/recommendations/page": [
|
||||
"/(public)/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/recommendations/page.js"
|
||||
"static/chunks/app/(public)/page.js"
|
||||
],
|
||||
"/(auth)/layout": [
|
||||
"/(public)/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/layout.js"
|
||||
"static/chunks/app/(public)/layout.js"
|
||||
],
|
||||
"/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
@ -20,6 +20,21 @@
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/dashboard/page.js"
|
||||
],
|
||||
"/(auth)/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/layout.js"
|
||||
],
|
||||
"/(auth)/recommendations/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/recommendations/page.js"
|
||||
],
|
||||
"/(auth)/settings/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/settings/page.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
frontend/.next/cache/.tsbuildinfo
vendored
2
frontend/.next/cache/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
@ -1,4 +1,6 @@
|
||||
{
|
||||
"/(public)/page": "app/(public)/page.js",
|
||||
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
|
||||
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
|
||||
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js"
|
||||
"/(auth)/settings/page": "app/(auth)/settings/page.js"
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "ENqzQP8wBFubYL1/ouTvw/MyHD/YS9oWsYiV09Obmq8="
|
||||
"encryptionKey": "q9MinLsRBzgOwAUsxhHLGTEgY9kBDWvoZzf7iynqzyI="
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
493
frontend/src/app/(auth)/settings/page.tsx
Normal file
493
frontend/src/app/(auth)/settings/page.tsx
Normal file
@ -0,0 +1,493 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
listUsersAPI,
|
||||
createUserAPI,
|
||||
disableUserAPI,
|
||||
resetPasswordAPI,
|
||||
getDataStatsAPI,
|
||||
dataResetAPI,
|
||||
getErrorLogsAPI,
|
||||
clearErrorLogsAPI,
|
||||
getSystemStatusAPI,
|
||||
type UserItem,
|
||||
type DataStats,
|
||||
type ErrorLog,
|
||||
type SystemStatus,
|
||||
} from "@/lib/api";
|
||||
|
||||
type Tab = "users" | "data" | "logs";
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [tab, setTab] = useState<Tab>("users");
|
||||
|
||||
// ── Users state ──
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newRole, setNewRole] = useState("user");
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
const [createdResult, setCreatedResult] = useState<{ username: string; password: string } | null>(null);
|
||||
const [resetResult, setResetResult] = useState<{ username: string; password: string } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// ── Data reset state ──
|
||||
const [dataStats, setDataStats] = useState<DataStats | null>(null);
|
||||
const [resetMode, setResetMode] = useState<"all" | "recommendations" | "date_range" | "low_score">("low_score");
|
||||
const [beforeDate, setBeforeDate] = useState("");
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [resetResultMsg, setResetResultMsg] = useState<string | null>(null);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
|
||||
// ── Logs state ──
|
||||
const [logs, setLogs] = useState<ErrorLog[]>([]);
|
||||
const [logsTotal, setLogsTotal] = useState(0);
|
||||
const [logSources, setLogSources] = useState<string[]>([]);
|
||||
const [logLevels, setLogLevels] = useState<string[]>([]);
|
||||
const [logFilterSource, setLogFilterSource] = useState<string>("");
|
||||
const [logFilterLevel, setLogFilterLevel] = useState<string>("");
|
||||
const [logDays, setLogDays] = useState(7);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
||||
|
||||
function copyCredential(username: string, password: string) {
|
||||
const text = `用户名:${username}\n密码:${password}`;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const data = await listUsersAPI();
|
||||
setUsers(data);
|
||||
} catch {
|
||||
setError("加载用户列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const stats = await getDataStatsAPI();
|
||||
setDataStats(stats);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const result = await getErrorLogsAPI(50, logFilterSource, logFilterLevel, logDays);
|
||||
setLogs(result.errors);
|
||||
setLogsTotal(result.total);
|
||||
setLogSources(result.sources);
|
||||
setLogLevels(result.levels);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}, [logFilterSource, logFilterLevel, logDays]);
|
||||
|
||||
const fetchSystemStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await getSystemStatusAPI();
|
||||
setSystemStatus(status);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.role === "admin") {
|
||||
fetchUsers();
|
||||
fetchStats();
|
||||
fetchSystemStatus();
|
||||
}
|
||||
}, [currentUser, fetchUsers, fetchStats, fetchSystemStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.role === "admin" && tab === "logs") {
|
||||
fetchLogs();
|
||||
}
|
||||
}, [currentUser, tab, fetchLogs]);
|
||||
|
||||
if (currentUser?.role !== "admin") {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
||||
<p className="text-text-muted text-sm">需要管理员权限</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreateError("");
|
||||
if (!newUsername.trim()) {
|
||||
setCreateError("请输入用户名");
|
||||
return;
|
||||
}
|
||||
setCreateLoading(true);
|
||||
try {
|
||||
const result = await createUserAPI(newUsername.trim(), newRole);
|
||||
setCreatedResult({ username: result.username, password: result.password });
|
||||
setNewUsername("");
|
||||
setNewRole("user");
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : "创建失败");
|
||||
} finally {
|
||||
setCreateLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable(userId: number) {
|
||||
try {
|
||||
await disableUserAPI(userId);
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(userId: number) {
|
||||
try {
|
||||
const result = await resetPasswordAPI(userId);
|
||||
setResetResult({ username: result.username, password: result.password });
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDataReset() {
|
||||
setConfirmReset(false);
|
||||
setResetLoading(true);
|
||||
setResetResultMsg(null);
|
||||
try {
|
||||
const result = await dataResetAPI(
|
||||
resetMode,
|
||||
resetMode === "date_range" ? beforeDate : undefined,
|
||||
resetMode === "low_score" ? 60 : undefined,
|
||||
);
|
||||
const parts = Object.entries(result.deleted)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => `${k}: ${v}条`);
|
||||
setResetResultMsg(parts.length > 0 ? `已删除: ${parts.join(", ")}` : "没有需要删除的数据");
|
||||
fetchStats();
|
||||
} catch (err) {
|
||||
setResetResultMsg(err instanceof Error ? err.message : "重置失败");
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearLogs() {
|
||||
try {
|
||||
const result = await clearErrorLogsAPI(30);
|
||||
fetchLogs();
|
||||
fetchSystemStatus();
|
||||
alert(`已清除 ${result.deleted} 条旧日志`);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "清除失败");
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: "users", label: "用户管理" },
|
||||
{ key: "data", label: "数据管理" },
|
||||
{ key: "logs", label: "系统日志" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6">
|
||||
{/* Header + Tabs */}
|
||||
<div className="animate-fade-in-up">
|
||||
<h1 className="text-xl font-semibold tracking-tight">系统设置</h1>
|
||||
<div className="flex gap-1.5 mt-4 overflow-x-auto pb-1">
|
||||
{tabs.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`text-sm px-4 py-2 rounded-xl font-medium transition-all ${
|
||||
tab === key
|
||||
? "bg-gradient-to-r from-amber-500/25 to-amber-600/20 text-amber-400 border border-amber-500/15"
|
||||
: "bg-surface-2 text-text-muted hover:text-text-secondary hover:bg-surface-3 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tab: Users ── */}
|
||||
{tab === "users" && (
|
||||
<>
|
||||
<div className="flex items-center justify-between animate-fade-in-up">
|
||||
<button
|
||||
onClick={() => { setShowCreate(true); setCreateError(""); setCreatedResult(null); }}
|
||||
className="px-4 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
|
||||
>
|
||||
+ 创建用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-amber-400/80 animate-fade-in-up">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-16" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 animate-fade-in-up delay-75">
|
||||
{users.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className={`glass-card p-4 rounded-xl flex items-center justify-between gap-4 ${
|
||||
!u.is_active ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-9 h-9 rounded-full bg-surface-3 border border-border-default flex items-center justify-center text-sm font-medium text-text-secondary shrink-0">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text-primary truncate">{u.username}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${u.role === "admin" ? "bg-amber-500/10 text-amber-400/80" : "bg-surface-3 text-text-muted"}`}>{u.role}</span>
|
||||
{!u.is_active && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400/80">已禁用</span>}
|
||||
</div>
|
||||
{u.created_at && <p className="text-xs text-text-muted mt-0.5">创建于 {new Date(u.created_at).toLocaleDateString("zh-CN")}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{u.id !== currentUser!.id && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => handleResetPassword(u.id)} className="px-3 py-1.5 rounded-lg text-xs text-text-secondary hover:text-text-primary bg-surface-2 hover:bg-surface-4 border border-border-subtle transition-all">重置密码</button>
|
||||
{u.is_active ? (
|
||||
<button onClick={() => handleDisable(u.id)} className="px-3 py-1.5 rounded-lg text-xs text-red-400/60 hover:text-red-400 bg-red-500/[0.03] hover:bg-red-500/[0.08] border border-red-500/[0.06] transition-all">禁用</button>
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">已禁用</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{users.length === 0 && <div className="glass-card-static p-8 rounded-xl text-center"><p className="text-sm text-text-muted">暂无用户</p></div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create User Dialog */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)} />
|
||||
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card">
|
||||
{createdResult ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-base font-semibold text-text-primary">用户创建成功</h3>
|
||||
<div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2">
|
||||
<div className="flex justify-between text-sm"><span className="text-text-muted">用户名</span><span className="text-text-primary font-medium">{createdResult.username}</span></div>
|
||||
<div className="flex justify-between text-sm items-center"><span className="text-text-muted">密码</span><span className="text-amber-400 font-mono text-xs">{createdResult.password}</span></div>
|
||||
</div>
|
||||
<p className="text-xs text-amber-400/60">请妥善保管密码,此密码仅显示一次</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => copyCredential(createdResult.username, createdResult.password)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all">{copied ? "已复制" : "一键复制"}</button>
|
||||
<button onClick={() => setShowCreate(false)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-base font-semibold text-text-primary mb-5">创建新用户</h3>
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
<input type="text" placeholder="用户名" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40" />
|
||||
<select value={newRole} onChange={(e) => setNewRole(e.target.value)} className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none">
|
||||
<option value="user" className="bg-bg-card text-text-primary">普通用户</option>
|
||||
<option value="admin" className="bg-bg-card text-text-primary">管理员</option>
|
||||
</select>
|
||||
{createError && <p className="text-xs text-amber-400/80">{createError}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowCreate(false)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all">取消</button>
|
||||
<button type="submit" disabled={createLoading} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all disabled:opacity-50">{createLoading ? "创建中..." : "创建"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset Password Result Dialog */}
|
||||
{resetResult && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetResult(null)} />
|
||||
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card space-y-4">
|
||||
<h3 className="text-base font-semibold text-text-primary">密码已重置</h3>
|
||||
<div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2">
|
||||
<div className="flex justify-between text-sm"><span className="text-text-muted">用户名</span><span className="text-text-primary font-medium">{resetResult.username}</span></div>
|
||||
<div className="flex justify-between text-sm items-center"><span className="text-text-muted">新密码</span><span className="text-amber-400 font-mono text-xs">{resetResult.password}</span></div>
|
||||
</div>
|
||||
<p className="text-xs text-amber-400/60">请妥善保管新密码,此密码仅显示一次</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => copyCredential(resetResult.username, resetResult.password)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all">{copied ? "已复制" : "一键复制"}</button>
|
||||
<button onClick={() => setResetResult(null)} className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Tab: Data ── */}
|
||||
{tab === "data" && dataStats && (
|
||||
<div className="glass-card-static p-4 rounded-xl animate-fade-in-up">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">数据统计 & 重置</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-4">
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2"><div className="text-[10px] text-text-muted/50">推荐记录</div><div className="text-lg font-bold font-mono tabular-nums text-text-primary">{dataStats.recommendations}</div></div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2"><div className="text-[10px] text-text-muted/50">跟踪数据</div><div className="text-lg font-bold font-mono tabular-nums text-text-secondary">{dataStats.tracking}</div></div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2"><div className="text-[10px] text-text-muted/50">低分记录</div><div className="text-lg font-bold font-mono tabular-nums text-amber-400">{dataStats.low_score_count}</div></div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2"><div className="text-[10px] text-text-muted/50">板块热度</div><div className="text-sm font-mono tabular-nums text-text-secondary">{dataStats.sector_heat}</div></div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2"><div className="text-[10px] text-text-muted/50">市场温度</div><div className="text-sm font-mono tabular-nums text-text-secondary">{dataStats.market_temperature}</div></div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2"><div className="text-[10px] text-text-muted/50">日期范围</div><div className="text-[10px] font-mono tabular-nums text-text-muted">{dataStats.earliest_date || "-"} ~ {dataStats.latest_date || "-"}</div></div>
|
||||
</div>
|
||||
<div className="flex gap-1.5 mb-3 overflow-x-auto pb-1">
|
||||
{[
|
||||
{ key: "low_score", label: "清理低分 (<60)" },
|
||||
{ key: "date_range", label: "按日期清除" },
|
||||
{ key: "recommendations", label: "清除推荐" },
|
||||
{ key: "all", label: "全部重置" },
|
||||
].map(({ key, label }) => (
|
||||
<button key={key} onClick={() => { setResetMode(key as typeof resetMode); setConfirmReset(false); setResetResultMsg(null); }} className={`text-xs px-3 py-1.5 rounded-lg whitespace-nowrap transition-all font-medium ${resetMode === key ? "bg-red-500/15 text-red-400 border border-red-500/15" : "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"}`}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
{resetMode === "date_range" && (
|
||||
<div className="mb-3">
|
||||
<label className="text-xs text-text-muted mb-1 block">删除此日期之前的数据</label>
|
||||
<input type="date" value={beforeDate} onChange={(e) => setBeforeDate(e.target.value)} className="w-full sm:w-auto bg-surface-2 border border-border-default rounded-lg px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30" />
|
||||
</div>
|
||||
)}
|
||||
{resetResultMsg && (
|
||||
<div className={`text-xs px-3 py-2 rounded-lg mb-3 ${resetResultMsg.includes("失败") ? "bg-amber-500/10 text-amber-400" : "bg-surface-2 text-text-secondary"}`}>{resetResultMsg}</div>
|
||||
)}
|
||||
{confirmReset ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-xs text-red-400 font-medium">
|
||||
{resetMode === "all" ? "确认清除所有数据?此操作不可撤销!" : resetMode === "recommendations" ? "确认清除推荐和跟踪数据?" : resetMode === "date_range" ? `确认清除 ${beforeDate} 之前的数据?` : "确认删除评分<60的推荐?"}
|
||||
</p>
|
||||
<button onClick={handleDataReset} disabled={resetLoading} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-red-500/20 text-red-400 border border-red-500/15 hover:bg-red-500/30 transition-all disabled:opacity-50">{resetLoading ? "执行中..." : "确认执行"}</button>
|
||||
<button onClick={() => setConfirmReset(false)} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-surface-3 text-text-secondary hover:text-text-primary border border-border-default transition-all">取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setConfirmReset(true)} disabled={resetMode === "date_range" && !beforeDate} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-red-500/[0.03] text-red-400/60 hover:text-red-400 hover:bg-red-500/[0.08] border border-red-500/[0.06] transition-all disabled:opacity-30 disabled:pointer-events-none">执行重置</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Tab: Logs ── */}
|
||||
{tab === "logs" && (
|
||||
<div className="space-y-4 animate-fade-in-up">
|
||||
{/* System Status */}
|
||||
{systemStatus && (
|
||||
<div className="glass-card-static p-4 rounded-xl">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">系统状态</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/50">交易状态</div>
|
||||
<div className={`text-sm font-medium ${systemStatus.is_trading ? "text-emerald-400" : "text-text-muted"}`}>
|
||||
{systemStatus.is_trading ? "交易中" : "已收盘"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/50">扫描状态</div>
|
||||
<div className={`text-sm font-medium ${systemStatus.scan_running ? "text-amber-400" : "text-text-secondary"}`}>
|
||||
{systemStatus.scan_running ? "扫描中" : systemStatus.scan_locked ? "锁定中" : "空闲"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/50">24h 错误</div>
|
||||
<div className={`text-sm font-bold font-mono tabular-nums ${systemStatus.recent_errors > 0 ? "text-red-400" : "text-text-secondary"}`}>{systemStatus.recent_errors}</div>
|
||||
</div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/50">数据库大小</div>
|
||||
<div className="text-sm font-mono tabular-nums text-text-secondary">{systemStatus.db_size_mb} MB</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select value={logFilterSource} onChange={(e) => setLogFilterSource(e.target.value)} className="bg-surface-2 border border-border-default rounded-lg px-3 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none">
|
||||
<option value="">全部来源</option>
|
||||
{logSources.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select value={logFilterLevel} onChange={(e) => setLogFilterLevel(e.target.value)} className="bg-surface-2 border border-border-default rounded-lg px-3 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none">
|
||||
<option value="">全部级别</option>
|
||||
{logLevels.map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
<select value={logDays} onChange={(e) => setLogDays(Number(e.target.value))} className="bg-surface-2 border border-border-default rounded-lg px-3 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none">
|
||||
<option value={1}>1天</option>
|
||||
<option value={3}>3天</option>
|
||||
<option value={7}>7天</option>
|
||||
<option value={14}>14天</option>
|
||||
<option value={30}>30天</option>
|
||||
</select>
|
||||
<button onClick={() => fetchLogs()} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-surface-2 text-text-secondary hover:text-text-primary hover:bg-surface-4 border border-border-subtle transition-all">
|
||||
刷新
|
||||
</button>
|
||||
<button onClick={handleClearLogs} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-red-500/[0.03] text-red-400/60 hover:text-red-400 hover:bg-red-500/[0.08] border border-red-500/[0.06] transition-all">
|
||||
清除30天前
|
||||
</button>
|
||||
<span className="text-xs text-text-muted ml-auto">{logsTotal} 条记录</span>
|
||||
</div>
|
||||
|
||||
{/* Error list */}
|
||||
{logsLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-20" />)}
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="glass-card-static p-8 rounded-xl text-center">
|
||||
<p className="text-sm text-text-muted">暂无错误日志</p>
|
||||
<p className="text-xs text-text-muted/50 mt-1">系统运行正常</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="glass-card-static p-3 rounded-xl">
|
||||
<button onClick={() => setExpandedLogId(expandedLogId === log.id ? null : log.id)} className="w-full text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
||||
log.level === "error" ? "bg-red-500/15 text-red-400" : "bg-amber-500/15 text-amber-400"
|
||||
}`}>{log.level}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-3 text-text-muted">{log.source}</span>
|
||||
<span className="text-xs text-text-secondary flex-1 truncate">{log.message}</span>
|
||||
<span className="text-[10px] text-text-muted/50 font-mono tabular-nums shrink-0">{log.created_at.slice(0, 16)}</span>
|
||||
</div>
|
||||
</button>
|
||||
{expandedLogId === log.id && log.detail && (
|
||||
<div className="mt-2 p-3 rounded-lg bg-surface-1 border border-border-subtle">
|
||||
<pre className="text-xs text-text-muted whitespace-pre-wrap break-all font-mono leading-relaxed">{log.detail}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,477 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
listUsersAPI,
|
||||
createUserAPI,
|
||||
disableUserAPI,
|
||||
resetPasswordAPI,
|
||||
getDataStatsAPI,
|
||||
dataResetAPI,
|
||||
type UserItem,
|
||||
type DataStats,
|
||||
} from "@/lib/api";
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Create user dialog state
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newRole, setNewRole] = useState("user");
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
const [createdResult, setCreatedResult] = useState<{ username: string; password: string } | null>(null);
|
||||
|
||||
// Reset password result
|
||||
const [resetResult, setResetResult] = useState<{ username: string; password: string } | null>(null);
|
||||
|
||||
// Copy feedback
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Data reset state
|
||||
const [dataStats, setDataStats] = useState<DataStats | null>(null);
|
||||
const [resetMode, setResetMode] = useState<"all" | "recommendations" | "date_range" | "low_score">("low_score");
|
||||
const [beforeDate, setBeforeDate] = useState("");
|
||||
const [resetLoading, setResetLoading] = useState(false);
|
||||
const [resetResultMsg, setResetResultMsg] = useState<string | null>(null);
|
||||
const [confirmReset, setConfirmReset] = useState(false);
|
||||
|
||||
function copyCredential(username: string, password: string) {
|
||||
const text = `用户名:${username}\n密码:${password}`;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const data = await listUsersAPI();
|
||||
setUsers(data);
|
||||
} catch {
|
||||
setError("加载用户列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const stats = await getDataStatsAPI();
|
||||
setDataStats(stats);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.role === "admin") {
|
||||
fetchUsers();
|
||||
fetchStats();
|
||||
}
|
||||
}, [currentUser, fetchUsers, fetchStats]);
|
||||
|
||||
// Non-admin: show nothing (AuthGuard + route should prevent this)
|
||||
if (currentUser?.role !== "admin") {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
||||
<p className="text-text-muted text-sm">需要管理员权限</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreateError("");
|
||||
if (!newUsername.trim()) {
|
||||
setCreateError("请输入用户名");
|
||||
return;
|
||||
}
|
||||
setCreateLoading(true);
|
||||
try {
|
||||
const result = await createUserAPI(newUsername.trim(), newRole);
|
||||
setCreatedResult({ username: result.username, password: result.password });
|
||||
setNewUsername("");
|
||||
setNewRole("user");
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : "创建失败");
|
||||
} finally {
|
||||
setCreateLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable(userId: number) {
|
||||
try {
|
||||
await disableUserAPI(userId);
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(userId: number) {
|
||||
try {
|
||||
const result = await resetPasswordAPI(userId);
|
||||
setResetResult({ username: result.username, password: result.password });
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDataReset() {
|
||||
setConfirmReset(false);
|
||||
setResetLoading(true);
|
||||
setResetResultMsg(null);
|
||||
try {
|
||||
const result = await dataResetAPI(
|
||||
resetMode,
|
||||
resetMode === "date_range" ? beforeDate : undefined,
|
||||
resetMode === "low_score" ? 60 : undefined,
|
||||
);
|
||||
const parts = Object.entries(result.deleted)
|
||||
.filter(([, v]) => v > 0)
|
||||
.map(([k, v]) => `${k}: ${v}条`);
|
||||
setResetResultMsg(parts.length > 0 ? `已删除: ${parts.join(", ")}` : "没有需要删除的数据");
|
||||
fetchStats();
|
||||
} catch (err) {
|
||||
setResetResultMsg(err instanceof Error ? err.message : "重置失败");
|
||||
} finally {
|
||||
setResetLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between animate-fade-in-up">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">用户管理</h1>
|
||||
<p className="text-sm text-text-muted mt-1">创建和管理系统用户</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowCreate(true); setCreateError(""); setCreatedResult(null); }}
|
||||
className="px-4 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
|
||||
>
|
||||
+ 创建用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-sm text-amber-400/80 animate-fade-in-up">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Data Reset Section */}
|
||||
{dataStats && (
|
||||
<div className="glass-card-static p-4 rounded-xl animate-fade-in-up">
|
||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">数据统计 & 重置</h2>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mb-4">
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/50">推荐记录</div>
|
||||
<div className="text-lg font-bold font-mono tabular-nums text-text-primary">{dataStats.recommendations}</div>
|
||||
</div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/50">跟踪数据</div>
|
||||
<div className="text-lg font-bold font-mono tabular-nums text-text-secondary">{dataStats.tracking}</div>
|
||||
</div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/50">低分记录</div>
|
||||
<div className="text-lg font-bold font-mono tabular-nums text-amber-400">{dataStats.low_score_count}</div>
|
||||
</div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/50">板块热度</div>
|
||||
<div className="text-sm font-mono tabular-nums text-text-secondary">{dataStats.sector_heat}</div>
|
||||
</div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/50">市场温度</div>
|
||||
<div className="text-sm font-mono tabular-nums text-text-secondary">{dataStats.market_temperature}</div>
|
||||
</div>
|
||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
||||
<div className="text-[10px] text-text-muted/50">日期范围</div>
|
||||
<div className="text-[10px] font-mono tabular-nums text-text-muted">{dataStats.earliest_date || "-"} ~ {dataStats.latest_date || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset mode selection */}
|
||||
<div className="flex gap-1.5 mb-3 overflow-x-auto pb-1">
|
||||
{[
|
||||
{ key: "low_score", label: "清理低分 (<60)", desc: "删除评分低于60的推荐" },
|
||||
{ key: "date_range", label: "按日期清除", desc: "删除指定日期之前的数据" },
|
||||
{ key: "recommendations", label: "清除推荐", desc: "删除推荐和跟踪,保留板块温度" },
|
||||
{ key: "all", label: "全部重置", desc: "清除所有业务数据" },
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => { setResetMode(key as typeof resetMode); setConfirmReset(false); setResetResultMsg(null); }}
|
||||
className={`text-xs px-3 py-1.5 rounded-lg whitespace-nowrap transition-all font-medium ${
|
||||
resetMode === key
|
||||
? "bg-red-500/15 text-red-400 border border-red-500/15"
|
||||
: "bg-surface-2 text-text-muted hover:text-text-secondary border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Date range input */}
|
||||
{resetMode === "date_range" && (
|
||||
<div className="mb-3">
|
||||
<label className="text-xs text-text-muted mb-1 block">删除此日期之前的数据</label>
|
||||
<input
|
||||
type="date"
|
||||
value={beforeDate}
|
||||
onChange={(e) => setBeforeDate(e.target.value)}
|
||||
className="w-full sm:w-auto bg-surface-2 border border-border-default rounded-lg px-3 py-1.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset result */}
|
||||
{resetResultMsg && (
|
||||
<div className={`text-xs px-3 py-2 rounded-lg mb-3 ${
|
||||
resetResultMsg.includes("失败") ? "bg-amber-500/10 text-amber-400" : "bg-surface-2 text-text-secondary"
|
||||
}`}>
|
||||
{resetResultMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm + Execute */}
|
||||
{confirmReset ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-xs text-red-400 font-medium">
|
||||
{resetMode === "all" ? "确认清除所有数据?此操作不可撤销!" :
|
||||
resetMode === "recommendations" ? "确认清除推荐和跟踪数据?" :
|
||||
resetMode === "date_range" ? `确认清除 ${beforeDate} 之前的数据?` :
|
||||
"确认删除评分<60的推荐?"}
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDataReset}
|
||||
disabled={resetLoading}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-red-500/20 text-red-400 border border-red-500/15 hover:bg-red-500/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
{resetLoading ? "执行中..." : "确认执行"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmReset(false)}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-surface-3 text-text-secondary hover:text-text-primary border border-border-default transition-all"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmReset(true)}
|
||||
disabled={resetMode === "date_range" && !beforeDate}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-red-500/[0.03] text-red-400/60 hover:text-red-400 hover:bg-red-500/[0.08] border border-red-500/[0.06] transition-all disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
执行重置
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User list */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-16" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 animate-fade-in-up delay-75">
|
||||
{users.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className={`glass-card p-4 rounded-xl flex items-center justify-between gap-4 ${
|
||||
!u.is_active ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Avatar */}
|
||||
<div className="w-9 h-9 rounded-full bg-surface-3 border border-border-default flex items-center justify-center text-sm font-medium text-text-secondary shrink-0">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text-primary truncate">
|
||||
{u.username}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded ${
|
||||
u.role === "admin"
|
||||
? "bg-amber-500/10 text-amber-400/80"
|
||||
: "bg-surface-3 text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{u.role}
|
||||
</span>
|
||||
{!u.is_active && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400/80">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{u.created_at && (
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
创建于 {new Date(u.created_at).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{u.id !== currentUser!.id && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => handleResetPassword(u.id)}
|
||||
className="px-3 py-1.5 rounded-lg text-xs text-text-secondary hover:text-text-primary bg-surface-2 hover:bg-surface-4 border border-border-subtle transition-all"
|
||||
>
|
||||
重置密码
|
||||
</button>
|
||||
{u.is_active ? (
|
||||
<button
|
||||
onClick={() => handleDisable(u.id)}
|
||||
className="px-3 py-1.5 rounded-lg text-xs text-red-400/60 hover:text-red-400 bg-red-500/[0.03] hover:bg-red-500/[0.08] border border-red-500/[0.06] transition-all"
|
||||
>
|
||||
禁用
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">已禁用</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{users.length === 0 && (
|
||||
<div className="glass-card-static p-8 rounded-xl text-center">
|
||||
<p className="text-sm text-text-muted">暂无用户</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create User Dialog */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)} />
|
||||
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card">
|
||||
{createdResult ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-base font-semibold text-text-primary">用户创建成功</h3>
|
||||
<div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">用户名</span>
|
||||
<span className="text-text-primary font-medium">{createdResult.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm items-center">
|
||||
<span className="text-text-muted">密码</span>
|
||||
<span className="text-amber-400 font-mono text-xs">{createdResult.password}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-amber-400/60">请妥善保管密码,此密码仅显示一次</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => copyCredential(createdResult.username, createdResult.password)}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
|
||||
>
|
||||
{copied ? "已复制" : "一键复制"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-base font-semibold text-text-primary mb-5">创建新用户</h3>
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
|
||||
/>
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
className="w-full bg-surface-2 border border-border-default rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none"
|
||||
>
|
||||
<option value="user" className="bg-bg-card text-text-primary">普通用户</option>
|
||||
<option value="admin" className="bg-bg-card text-text-primary">管理员</option>
|
||||
</select>
|
||||
{createError && <p className="text-xs text-amber-400/80">{createError}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createLoading}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all disabled:opacity-50"
|
||||
>
|
||||
{createLoading ? "创建中..." : "创建"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset Password Result Dialog */}
|
||||
{resetResult && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetResult(null)} />
|
||||
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-border-default shadow-card space-y-4">
|
||||
<h3 className="text-base font-semibold text-text-primary">密码已重置</h3>
|
||||
<div className="p-4 rounded-xl bg-surface-1 border border-border-subtle space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">用户名</span>
|
||||
<span className="text-text-primary font-medium">{resetResult.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm items-center">
|
||||
<span className="text-text-muted">新密码</span>
|
||||
<span className="text-amber-400 font-mono text-xs">{resetResult.password}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-amber-400/60">请妥善保管新密码,此密码仅显示一次</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => copyCredential(resetResult.username, resetResult.password)}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
|
||||
>
|
||||
{copied ? "已复制" : "一键复制"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setResetResult(null)}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-surface-3 border border-border-default text-text-secondary hover:text-text-primary hover:bg-surface-4 transition-all"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -63,6 +63,15 @@ function UsersIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SideNavItem({ href, icon, label }: { href: string; icon: React.ReactNode; label: string }) {
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname === href || (href !== "/dashboard" && pathname.startsWith(href));
|
||||
@ -92,7 +101,7 @@ export function SidebarNav() {
|
||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" />
|
||||
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="AI 诊断" />
|
||||
{user?.role === "admin" && (
|
||||
<SideNavItem href="/users" icon={<UsersIcon />} label="用户管理" />
|
||||
<SideNavItem href="/settings" icon={<SettingsIcon />} label="系统设置" />
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
|
||||
@ -378,3 +378,46 @@ export async function dataResetAPI(mode: string, beforeDate?: string, minScore?:
|
||||
min_score: minScore,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Debug Logs (Admin) ----------
|
||||
|
||||
export interface ErrorLog {
|
||||
id: number;
|
||||
source: string;
|
||||
level: string;
|
||||
message: string;
|
||||
detail: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ErrorLogsResult {
|
||||
total: number;
|
||||
errors: ErrorLog[];
|
||||
sources: string[];
|
||||
levels: string[];
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
is_trading: boolean;
|
||||
scan_running: boolean;
|
||||
scan_locked: boolean;
|
||||
recent_errors: number;
|
||||
last_errors: { source: string; message: string; created_at: string }[];
|
||||
tables_counts: Record<string, number>;
|
||||
db_size_mb: number;
|
||||
}
|
||||
|
||||
export async function getErrorLogsAPI(limit: number = 50, source?: string, level?: string, days: number = 7): Promise<ErrorLogsResult> {
|
||||
const params = new URLSearchParams({ limit: String(limit), days: String(days) });
|
||||
if (source) params.set("source", source);
|
||||
if (level) params.set("level", level);
|
||||
return fetchAPI<ErrorLogsResult>(`/api/debug/errors?${params}`);
|
||||
}
|
||||
|
||||
export async function clearErrorLogsAPI(days: number = 30): Promise<{ status: string; deleted: number }> {
|
||||
return deleteAPI<{ status: string; deleted: number }>(`/api/debug/errors?days=${days}`);
|
||||
}
|
||||
|
||||
export async function getSystemStatusAPI(): Promise<SystemStatus> {
|
||||
return fetchAPI<SystemStatus>("/api/debug/system");
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user