This commit is contained in:
aaron 2026-04-16 17:37:14 +08:00
parent 6ce504dbb4
commit 02b4f79137
9 changed files with 354 additions and 26 deletions

View File

@ -1,6 +1,6 @@
"""认证 API """认证 API
登录密码修改用户管理管理员 登录密码修改用户管理管理员数据重置管理员
""" """
import secrets import secrets
@ -8,7 +8,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel 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.auth import hash_password, verify_password, create_access_token
from app.core.deps import get_current_user, get_current_admin from app.core.deps import get_current_user, get_current_admin
@ -36,6 +36,12 @@ class CreateUserRequest(BaseModel):
role: str = "user" 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 ---------- # ---------- Public Endpoints ----------
@router.post("/login") @router.post("/login")
@ -209,3 +215,125 @@ async def reset_password(user_id: int, admin: dict = Depends(get_current_admin))
"password": raw_password, "password": raw_password,
"message": "请妥善保管新密码,此密码仅显示一次", "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,
}

View File

@ -1,15 +1,25 @@
{ {
"pages": { "pages": {
"/page": [ "/recommendations/page": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/chunks/app/page.js" "static/chunks/app/recommendations/page.js"
], ],
"/layout": [ "/layout": [
"static/chunks/webpack.js", "static/chunks/webpack.js",
"static/chunks/main-app.js", "static/chunks/main-app.js",
"static/css/app/layout.css", "static/css/app/layout.css",
"static/chunks/app/layout.js" "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"
] ]
} }
} }

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,5 @@
{ {
"/users/page": "app/users/page.js",
"/recommendations/page": "app/recommendations/page.js",
"/page": "app/page.js" "/page": "app/page.js"
} }

View File

@ -1,5 +1,5 @@
{ {
"node": {}, "node": {},
"edge": {}, "edge": {},
"encryptionKey": "4dlovStTHcLljKBPVgo4Nvr4F//yhFdsjyUsGgBQwBY=" "encryptionKey": "vMdxWQqJbs8lx1fn0MelZryKaOD9juru8raACaKF7MY="
} }

View File

@ -125,7 +125,7 @@
/******/ /******/
/******/ /* webpack/runtime/getFullHash */ /******/ /* webpack/runtime/getFullHash */
/******/ (() => { /******/ (() => {
/******/ __webpack_require__.h = () => ("cd14067d5a0d1652") /******/ __webpack_require__.h = () => ("51f130523eab8ec6")
/******/ })(); /******/ })();
/******/ /******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */ /******/ /* webpack/runtime/hasOwnProperty shorthand */

File diff suppressed because one or more lines are too long

View File

@ -7,7 +7,10 @@ import {
createUserAPI, createUserAPI,
disableUserAPI, disableUserAPI,
resetPasswordAPI, resetPasswordAPI,
getDataStatsAPI,
dataResetAPI,
type UserItem, type UserItem,
type DataStats,
} from "@/lib/api"; } from "@/lib/api";
export default function UsersPage() { export default function UsersPage() {
@ -30,6 +33,14 @@ export default function UsersPage() {
// Copy feedback // Copy feedback
const [copied, setCopied] = useState(false); 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) { function copyCredential(username: string, password: string) {
const text = `用户名:${username}\n密码${password}`; const text = `用户名:${username}\n密码${password}`;
navigator.clipboard.writeText(text).then(() => { 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(() => { useEffect(() => {
if (currentUser?.role === "admin") { if (currentUser?.role === "admin") {
fetchUsers(); fetchUsers();
fetchStats();
} }
}, [currentUser, fetchUsers]); }, [currentUser, fetchUsers, fetchStats]);
// Non-admin: show nothing (AuthGuard + route should prevent this) // Non-admin: show nothing (AuthGuard + route should prevent this)
if (currentUser?.role !== "admin") { 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 ( return (
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6"> <div className="max-w-4xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6">
{/* Header */} {/* Header */}
@ -125,6 +168,118 @@ export default function UsersPage() {
<p className="text-sm text-amber-400/80 animate-fade-in-up">{error}</p> <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 */} {/* User list */}
{loading ? ( {loading ? (
<div className="space-y-3"> <div className="space-y-3">
@ -319,4 +474,4 @@ export default function UsersPage() {
)} )}
</div> </div>
); );
} }

View File

@ -347,3 +347,34 @@ export async function changePasswordAPI(oldPassword: string, newPassword: string
new_password: newPassword, 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,
});
}