1
This commit is contained in:
parent
6ce504dbb4
commit
02b4f79137
@ -1,6 +1,6 @@
|
||||
"""认证 API
|
||||
|
||||
登录、密码修改、用户管理(管理员)
|
||||
登录、密码修改、用户管理(管理员)、数据重置(管理员)
|
||||
"""
|
||||
|
||||
import secrets
|
||||
@ -8,7 +8,7 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy import select, update, text, func
|
||||
|
||||
from app.core.auth import hash_password, verify_password, create_access_token
|
||||
from app.core.deps import get_current_user, get_current_admin
|
||||
@ -36,6 +36,12 @@ class CreateUserRequest(BaseModel):
|
||||
role: str = "user"
|
||||
|
||||
|
||||
class DataResetRequest(BaseModel):
|
||||
mode: str # "all", "recommendations", "date_range", "low_score"
|
||||
before_date: str | None = None # for date_range mode, e.g. "2026-04-10"
|
||||
min_score: int | None = None # for low_score mode, default 60
|
||||
|
||||
|
||||
# ---------- Public Endpoints ----------
|
||||
|
||||
@router.post("/login")
|
||||
@ -209,3 +215,125 @@ async def reset_password(user_id: int, admin: dict = Depends(get_current_admin))
|
||||
"password": raw_password,
|
||||
"message": "请妥善保管新密码,此密码仅显示一次",
|
||||
}
|
||||
|
||||
|
||||
# ---------- Data Reset Endpoints (Admin Only) ----------
|
||||
|
||||
@router.get("/data-stats")
|
||||
async def get_data_stats(admin: dict = Depends(get_current_admin)):
|
||||
"""获取数据统计(管理员)"""
|
||||
async with get_db() as db:
|
||||
rec_count = (await db.execute(text("SELECT COUNT(*) FROM recommendations"))).scalar() or 0
|
||||
track_count = (await db.execute(text("SELECT COUNT(*) FROM recommendation_tracking"))).scalar() or 0
|
||||
sector_count = (await db.execute(text("SELECT COUNT(*) FROM sector_heat"))).scalar() or 0
|
||||
temp_count = (await db.execute(text("SELECT COUNT(*) FROM market_temperature"))).scalar() or 0
|
||||
review_count = (await db.execute(text("SELECT COUNT(*) FROM daily_reviews"))).scalar() or 0
|
||||
low_score = (await db.execute(text("SELECT COUNT(*) FROM recommendations WHERE score < 60"))).scalar() or 0
|
||||
|
||||
# 最新日期
|
||||
latest_rec = (await db.execute(text("SELECT MAX(date(created_at)) FROM recommendations"))).scalar() or ""
|
||||
earliest_rec = (await db.execute(text("SELECT MIN(date(created_at)) FROM recommendations"))).scalar() or ""
|
||||
|
||||
return {
|
||||
"recommendations": rec_count,
|
||||
"tracking": track_count,
|
||||
"sector_heat": sector_count,
|
||||
"market_temperature": temp_count,
|
||||
"daily_reviews": review_count,
|
||||
"low_score_count": low_score,
|
||||
"latest_date": str(latest_rec),
|
||||
"earliest_date": str(earliest_rec),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/data-reset")
|
||||
async def data_reset(req: DataResetRequest, admin: dict = Depends(get_current_admin)):
|
||||
"""数据重置(管理员)
|
||||
|
||||
mode:
|
||||
- "all": 清除所有业务数据(推荐、跟踪、板块热度、市场温度、复盘)
|
||||
- "recommendations": 清除推荐记录和跟踪数据,保留板块和市场温度
|
||||
- "date_range": 清除指定日期之前的数据
|
||||
- "low_score": 清除低分推荐(score < min_score)和过期跟踪数据
|
||||
"""
|
||||
deleted = {}
|
||||
|
||||
async with get_db() as db:
|
||||
if req.mode == "all":
|
||||
# 清除所有业务数据(保留用户)
|
||||
result = await db.execute(text("DELETE FROM recommendation_tracking"))
|
||||
deleted["tracking"] = result.rowcount or 0
|
||||
result = await db.execute(text("DELETE FROM recommendations"))
|
||||
deleted["recommendations"] = result.rowcount or 0
|
||||
result = await db.execute(text("DELETE FROM sector_heat"))
|
||||
deleted["sector_heat"] = result.rowcount or 0
|
||||
result = await db.execute(text("DELETE FROM market_temperature"))
|
||||
deleted["market_temperature"] = result.rowcount or 0
|
||||
result = await db.execute(text("DELETE FROM daily_reviews"))
|
||||
deleted["daily_reviews"] = result.rowcount or 0
|
||||
result = await db.execute(text("DELETE FROM stock_diagnoses"))
|
||||
deleted["stock_diagnoses"] = result.rowcount or 0
|
||||
|
||||
elif req.mode == "recommendations":
|
||||
result = await db.execute(text("DELETE FROM recommendation_tracking"))
|
||||
deleted["tracking"] = result.rowcount or 0
|
||||
result = await db.execute(text("DELETE FROM recommendations"))
|
||||
deleted["recommendations"] = result.rowcount or 0
|
||||
|
||||
elif req.mode == "date_range":
|
||||
if not req.before_date:
|
||||
raise HTTPException(status_code=400, detail="date_range 模式需要 before_date 参数")
|
||||
# 删除 before_date 之前的推荐和跟踪
|
||||
result = await db.execute(
|
||||
text("DELETE FROM recommendation_tracking WHERE track_date < :bd"),
|
||||
{"bd": req.before_date}
|
||||
)
|
||||
deleted["tracking"] = result.rowcount or 0
|
||||
result = await db.execute(
|
||||
text("DELETE FROM recommendations WHERE date(created_at) < :bd"),
|
||||
{"bd": req.before_date}
|
||||
)
|
||||
deleted["recommendations"] = result.rowcount or 0
|
||||
result = await db.execute(
|
||||
text("DELETE FROM sector_heat WHERE trade_date < :bd"),
|
||||
{"bd": req.before_date}
|
||||
)
|
||||
deleted["sector_heat"] = result.rowcount or 0
|
||||
result = await db.execute(
|
||||
text("DELETE FROM market_temperature WHERE trade_date < :bd"),
|
||||
{"bd": req.before_date}
|
||||
)
|
||||
deleted["market_temperature"] = result.rowcount or 0
|
||||
|
||||
elif req.mode == "low_score":
|
||||
threshold = req.min_score or 60
|
||||
# 删除低分推荐及其跟踪数据
|
||||
result = await db.execute(
|
||||
text("DELETE FROM recommendation_tracking WHERE recommendation_id IN "
|
||||
"(SELECT id FROM recommendations WHERE score < :ms)"),
|
||||
{"ms": threshold}
|
||||
)
|
||||
deleted["tracking"] = result.rowcount or 0
|
||||
result = await db.execute(
|
||||
text("DELETE FROM recommendations WHERE score < :ms"),
|
||||
{"ms": threshold}
|
||||
)
|
||||
deleted["recommendations"] = result.rowcount or 0
|
||||
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的模式: {req.mode}")
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 清除内存缓存
|
||||
from app.engine.recommender import _latest_result
|
||||
import app.engine.recommender as recommender_mod
|
||||
recommender_mod._latest_result = None
|
||||
|
||||
logger.info(f"管理员 {admin['username']} 执行数据重置: mode={req.mode}, deleted={deleted}")
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"mode": req.mode,
|
||||
"deleted": deleted,
|
||||
}
|
||||
|
||||
@ -1,15 +1,25 @@
|
||||
{
|
||||
"pages": {
|
||||
"/page": [
|
||||
"/recommendations/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/page.js"
|
||||
"static/chunks/app/recommendations/page.js"
|
||||
],
|
||||
"/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/css/app/layout.css",
|
||||
"static/chunks/app/layout.js"
|
||||
],
|
||||
"/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/page.js"
|
||||
],
|
||||
"/users/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/users/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,3 +1,5 @@
|
||||
{
|
||||
"/users/page": "app/users/page.js",
|
||||
"/recommendations/page": "app/recommendations/page.js",
|
||||
"/page": "app/page.js"
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "4dlovStTHcLljKBPVgo4Nvr4F//yhFdsjyUsGgBQwBY="
|
||||
"encryptionKey": "vMdxWQqJbs8lx1fn0MelZryKaOD9juru8raACaKF7MY="
|
||||
}
|
||||
@ -125,7 +125,7 @@
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.h = () => ("cd14067d5a0d1652")
|
||||
/******/ __webpack_require__.h = () => ("51f130523eab8ec6")
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -7,7 +7,10 @@ import {
|
||||
createUserAPI,
|
||||
disableUserAPI,
|
||||
resetPasswordAPI,
|
||||
getDataStatsAPI,
|
||||
dataResetAPI,
|
||||
type UserItem,
|
||||
type DataStats,
|
||||
} from "@/lib/api";
|
||||
|
||||
export default function UsersPage() {
|
||||
@ -30,6 +33,14 @@ export default function UsersPage() {
|
||||
// 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(() => {
|
||||
@ -49,11 +60,21 @@ export default function UsersPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const stats = await getDataStatsAPI();
|
||||
setDataStats(stats);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.role === "admin") {
|
||||
fetchUsers();
|
||||
fetchStats();
|
||||
}
|
||||
}, [currentUser, fetchUsers]);
|
||||
}, [currentUser, fetchUsers, fetchStats]);
|
||||
|
||||
// Non-admin: show nothing (AuthGuard + route should prevent this)
|
||||
if (currentUser?.role !== "admin") {
|
||||
@ -104,6 +125,28 @@ export default function UsersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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 */}
|
||||
@ -125,6 +168,118 @@ export default function UsersPage() {
|
||||
<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">
|
||||
|
||||
@ -347,3 +347,34 @@ export async function changePasswordAPI(oldPassword: string, newPassword: string
|
||||
new_password: newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Data Reset (Admin) ----------
|
||||
|
||||
export interface DataStats {
|
||||
recommendations: number;
|
||||
tracking: number;
|
||||
sector_heat: number;
|
||||
market_temperature: number;
|
||||
daily_reviews: number;
|
||||
low_score_count: number;
|
||||
latest_date: string;
|
||||
earliest_date: string;
|
||||
}
|
||||
|
||||
export interface DataResetResult {
|
||||
status: string;
|
||||
mode: string;
|
||||
deleted: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function getDataStatsAPI(): Promise<DataStats> {
|
||||
return fetchAPI<DataStats>("/api/auth/data-stats");
|
||||
}
|
||||
|
||||
export async function dataResetAPI(mode: string, beforeDate?: string, minScore?: number): Promise<DataResetResult> {
|
||||
return postAPI<DataResetResult>("/api/auth/data-reset", {
|
||||
mode,
|
||||
before_date: beforeDate,
|
||||
min_score: minScore,
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user