This commit is contained in:
aaron 2026-04-17 01:03:27 +08:00
parent 1eaf608ece
commit 6d741a7ec2
27 changed files with 804 additions and 504 deletions

148
backend/app/api/debug.py Normal file
View 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,
}

View File

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

View File

@ -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 ''",

View 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 # 写日志失败不应影响主业务

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

@ -1,5 +1,5 @@
{
"node": {},
"edge": {},
"encryptionKey": "ENqzQP8wBFubYL1/ouTvw/MyHD/YS9oWsYiV09Obmq8="
"encryptionKey": "q9MinLsRBzgOwAUsxhHLGTEgY9kBDWvoZzf7iynqzyI="
}

File diff suppressed because one or more lines are too long

View 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>
);
}

View File

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

View File

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

View File

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