477 lines
21 KiB
TypeScript
477 lines
21 KiB
TypeScript
"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>
|
||
);
|
||
} |