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
登录密码修改用户管理管理员
登录密码修改用户管理管理员数据重置管理员
"""
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,
}

View File

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

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

View File

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

View File

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

View File

@ -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">
@ -319,4 +474,4 @@ export default function UsersPage() {
)}
</div>
);
}
}

View File

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