1
This commit is contained in:
parent
6ce504dbb4
commit
02b4f79137
@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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"
|
"/page": "app/page.js"
|
||||||
}
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"node": {},
|
"node": {},
|
||||||
"edge": {},
|
"edge": {},
|
||||||
"encryptionKey": "4dlovStTHcLljKBPVgo4Nvr4F//yhFdsjyUsGgBQwBY="
|
"encryptionKey": "vMdxWQqJbs8lx1fn0MelZryKaOD9juru8raACaKF7MY="
|
||||||
}
|
}
|
||||||
@ -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
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user