astock-agent/frontend/src/app/users/page.tsx
2026-04-16 17:37:14 +08:00

477 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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