"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
Ban,
ChevronLeft,
ChevronRight,
ExternalLink,
Loader2,
RotateCcw,
Search,
ShieldCheck,
ShieldOff,
Users as UsersIcon,
UserPlus,
X,
} from "lucide-react";
import {
apiAdminListUsers,
apiAdminSetUserBan,
apiAdminSetUserRole,
type AdminUser,
type AdminUserSummary,
type AdminUsersResponse,
} from "@/lib/api";
import { formatDate, formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
export type UserFilter = "all" | "admin" | "banned";
const PAGE_SIZE = 20;
const FILTER_TABS: { key: UserFilter; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "admin", label: "管理员" },
{ key: "banned", label: "已封禁" },
];
function filterToParams(filter: UserFilter): { role?: string; status?: string } {
if (filter === "admin") return { role: "admin" };
if (filter === "banned") return { status: "banned" };
return {};
}
// 待二次确认的危险操作种类
export type PendingKind = "promote" | "demote" | "ban" | "unban";
export interface PendingAction {
kind: PendingKind;
user: AdminUser;
}
// 确认弹窗的文案与视觉配置(说明需拼入用户名,故 desc 为函数)
const CONFIRM_CONFIG: Record<
PendingKind,
{
title: string;
desc: (u: AdminUser) => string;
confirmText: string;
tone: "accent" | "danger" | "ok";
icon: React.ReactNode;
}
> = {
promote: {
title: "设为管理员",
desc: (u) =>
`@${u.username} 将获得管理面板访问权限,可管理公告、外观设置与用户账号。`,
confirmText: "设为管理员",
tone: "accent",
icon: ,
},
demote: {
title: "取消管理员",
desc: (u) =>
`将管理员 @${u.username} 降级为普通用户。操作后该账号会被立即强制下线,需要重新登录。`,
confirmText: "确认降级",
tone: "danger",
icon: ,
},
ban: {
title: "封禁账号",
desc: (u) =>
`封禁后 @${u.username} 将被立即强制下线,无法登录、发帖与回复;其已发布内容保留。`,
confirmText: "确认封禁",
tone: "danger",
icon: ,
},
unban: {
title: "解除封禁",
desc: (u) => `解除后 @${u.username} 可以重新登录,并恢复发帖、回复等全部功能。`,
confirmText: "解除封禁",
tone: "ok",
icon: ,
},
};
// 汇总小卡
function MiniStat({
label,
value,
sub,
icon,
color,
soft,
}: {
label: string;
value: string;
sub?: string;
icon: React.ReactNode;
color: string;
soft: string;
}) {
return (
{icon}
{label}
{value}
{sub && (
{sub}
)}
);
}
// 角色/状态徽章
function Badge({
color,
soft,
icon,
children,
}: {
color: string;
soft: string;
icon: React.ReactNode;
children: React.ReactNode;
}) {
return (
{icon}
{children}
);
}
export default function UsersAdmin({
initial,
initError,
initialQuery,
currentUserId,
}: {
initial: AdminUsersResponse | null;
initError: string;
initialQuery: { q: string; filter: UserFilter; page: number };
currentUserId: number;
}) {
const [users, setUsers] = useState(initial?.users ?? []);
const [summary, setSummary] = useState(
initial?.summary ?? { total: 0, admins: 0, banned: 0, today_new: 0 }
);
const [total, setTotal] = useState(initial?.total ?? 0);
const [page, setPage] = useState(initialQuery.page);
const [filter, setFilter] = useState(initialQuery.filter);
const [keyword, setKeyword] = useState(initialQuery.q);
const [appliedQ, setAppliedQ] = useState(initialQuery.q);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [actingId, setActingId] = useState(null);
// 待确认的危险操作(提权/降级/封禁/解封);null 表示弹窗关闭
const [pending, setPending] = useState(null);
// 防止快速翻页/输入时旧响应覆盖新结果
const seqRef = useRef(0);
const debounceRef = useRef | null>(null);
const urlFirstRun = useRef(true);
const router = useRouter();
const load = useCallback(async (opts: { page: number; filter: UserFilter; q: string }) => {
const seq = ++seqRef.current;
setLoading(true);
try {
const res = await apiAdminListUsers({
page: opts.page,
size: PAGE_SIZE,
q: opts.q || undefined,
...filterToParams(opts.filter),
});
if (seq !== seqRef.current) return;
setUsers(res.users);
setSummary(res.summary);
setTotal(res.total);
setError("");
} catch (e) {
if (seq !== seqRef.current) return;
setError(e instanceof Error ? e.message : "获取用户列表失败");
} finally {
if (seq === seqRef.current) setLoading(false);
}
}, []);
// 搜索框防抖(首次渲染因 keyword===appliedQ 不会触发请求)
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const q = keyword.trim();
if (q !== appliedQ) {
setAppliedQ(q);
setPage(1);
load({ page: 1, filter, q });
}
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword]);
// 查询状态同步到 URL(首屏由 SSR 给出,跳过首次),刷新/分享可复现当前视图
useEffect(() => {
if (urlFirstRun.current) {
urlFirstRun.current = false;
return;
}
const p = new URLSearchParams();
if (appliedQ) p.set("q", appliedQ);
if (filter !== "all") p.set("filter", filter);
if (page > 1) p.set("page", String(page));
const qs = p.toString();
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
}, [appliedQ, filter, page, router]);
const submitSearch = (e: React.FormEvent) => {
e.preventDefault();
const q = keyword.trim();
if (q === appliedQ) return;
setAppliedQ(q);
setPage(1);
load({ page: 1, filter, q });
};
// 清除按钮:立即复位查询并重拉,不等待防抖,保证标题与列表同步
const clearSearch = () => {
setKeyword("");
if (appliedQ !== "" || page !== 1) {
setAppliedQ("");
setPage(1);
load({ page: 1, filter, q: "" });
}
};
const changeFilter = (f: UserFilter) => {
if (f === filter) return;
setFilter(f);
setPage(1);
load({ page: 1, filter: f, q: appliedQ });
};
const goPage = (p: number) => {
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const next = Math.min(Math.max(1, p), totalPages);
if (next === page) return;
setPage(next);
load({ page: next, filter, q: appliedQ });
};
// 危险操作不再使用浏览器原生 confirm,统一走站点风格的二次确认弹窗
const requestRoleChange = (u: AdminUser) => {
setPending({ kind: u.role === "admin" ? "demote" : "promote", user: u });
};
const requestBanChange = (u: AdminUser) => {
setPending({ kind: u.banned ? "unban" : "ban", user: u });
};
// 确认弹窗中点击"确认"后执行;成功关闭弹窗并重拉列表,
// 保证筛选结果集与汇总计数一致;失败保留弹窗,错误走 toast
const confirmPending = async () => {
const action = pending;
if (!action) return;
const { kind, user: u } = action;
setActingId(u.id);
try {
if (kind === "promote" || kind === "demote") {
await apiAdminSetUserRole(u.id, kind === "promote" ? "admin" : "user");
toast(kind === "promote" ? "已设为管理员" : "已取消管理员", "ok");
} else {
await apiAdminSetUserBan(u.id, kind === "ban");
toast(kind === "ban" ? "已封禁" : "已解封", "ok");
}
setPending(null);
await load({ page, filter, q: appliedQ });
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
} finally {
setActingId(null);
}
};
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const tabCount = (f: UserFilter) =>
f === "all" ? summary.total : f === "admin" ? summary.admins : summary.banned;
// 有关键词或非默认筛选时,空态文案与"无成员"区分
const hasFilter = appliedQ !== "" || filter !== "all";
// 行内操作按钮(圆形幽灵按钮,32px)
const ghostBtn =
"w-8 h-8 shrink-0 inline-flex items-center justify-center rounded-full transition-colors disabled:opacity-40 disabled:cursor-not-allowed";
const renderActions = (u: AdminUser, isSelf: boolean) => {
if (isSelf) {
return (
当前账号
);
}
const busy = actingId === u.id;
return (
);
};
const renderBadges = (u: AdminUser) => (
{u.role === "admin" ? (
}>
管理员
) : (
}>
普通用户
)}
{u.banned && (
}>
已封禁
)}
);
// 桌面端列模板(与表头一致)
const rowGrid =
"hidden md:grid md:grid-cols-[minmax(0,1.7fr)_88px_92px_104px_100px_108px_150px] gap-3 items-center px-4 sm:px-5 py-3.5";
return (
{/* 页头 */}
用户管理
检索全部成员,管理管理员角色与封禁状态;操作即时生效
{/* 汇总 */}
0 ? `今日 +${summary.today_new}` : undefined}
icon={}
color="var(--accent)"
soft="var(--accent-soft)"
/>
}
color="var(--gold)"
soft="var(--gold-soft)"
/>
}
color="var(--danger)"
soft="var(--danger-soft)"
/>
}
color="var(--ok)"
soft="var(--ok-soft)"
/>
{/* 搜索 + 筛选 */}
{FILTER_TABS.map((t) => {
const active = filter === t.key;
return (
);
})}
{/* 用户列表 */}
{appliedQ ? (
<>
搜索“{appliedQ}”
>
) : filter === "admin" ? (
"管理员"
) : filter === "banned" ? (
"已封禁成员"
) : (
"全部成员"
)}
共 {total} 人
{/* 桌面表头 */}
{users.length > 0 && (
成员
角色
状态
内容
注册
最近活跃
操作
)}
{error ? (
{error}
) : loading && users.length === 0 ? (
加载中…
) : users.length === 0 ? (
{hasFilter ? "没有匹配的成员" : "暂无成员"}
{hasFilter ? "换个关键词或筛选条件试试" : "新注册的用户会出现在这里"}
) : (
users.map((u) => {
const isSelf = u.id === currentUserId;
return (
{/* 桌面行 */}
{u.nickname}
@{u.username}
{u.email ? ` · ${u.email}` : ""}
{u.role === "admin" ? (
}>
管理员
) : (
普通用户
)}
{u.banned ? (
}>
已封禁
) : (
正常
)}
帖 {u.post_count}
·
评 {u.comment_count}
{formatDate(u.created_at)}
{u.last_seen_at ? formatRelative(u.last_seen_at) : "从未活跃"}
{renderActions(u, isSelf)}
{/* 移动卡片 */}
{u.nickname}
{renderBadges(u)}
{isSelf && (
当前账号
)}
@{u.username}
{u.email ? ` · ${u.email}` : ""}
帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)} · 活跃{" "}
{u.last_seen_at ? formatRelative(u.last_seen_at) : "从未"}
{!isSelf && (
主页
)}
);
})
)}
{/* 分页 */}
{total > 0 && (
第 {page} / {totalPages} 页 · 共 {total} 人
)}
{/* 危险操作二次确认(替代浏览器原生 confirm,与站点视觉语言一致) */}
setPending(null)}
onConfirm={confirmPending}
/>
);
}
// 危险操作确认弹窗:遮罩 + 居中卡片;ESC/点遮罩等同取消,执行中锁定所有关闭途径
function ActionConfirmDialog({
action,
busy,
onCancel,
onConfirm,
}: {
action: PendingAction | null;
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
useEffect(() => {
if (!action) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !busy) onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [action, busy, onCancel]);
if (!action) return null;
const cfg = CONFIRM_CONFIG[action.kind];
const iconColor =
cfg.tone === "danger" ? "var(--danger)" : cfg.tone === "ok" ? "var(--ok)" : "var(--accent)";
const iconSoft =
cfg.tone === "danger"
? "var(--danger-soft)"
: cfg.tone === "ok"
? "var(--ok-soft)"
: "var(--accent-soft)";
return (
!busy && onCancel()}
>
e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="user-action-title"
aria-describedby="user-action-desc"
>
{cfg.icon}
{cfg.title}
{cfg.desc(action.user)}
{cfg.tone === "danger" ? (
) : (
)}
);
}