feat(admin): 用户主页增加封禁与改角色快捷菜单
超管/站长可在他人主页就地管理账号;待审评论支持拒绝;角色弹窗前后台共用。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Ban,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
History,
|
||||
Loader2,
|
||||
Monitor,
|
||||
RotateCcw,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
@@ -24,8 +22,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiAdminListUsers,
|
||||
apiAdminSetUserBan,
|
||||
apiAdminSetUserRole,
|
||||
apiAdminUserLoginLogs,
|
||||
apiListBoards,
|
||||
type AdminUser,
|
||||
@@ -34,13 +30,14 @@ import {
|
||||
type Board,
|
||||
type LoginLog,
|
||||
} from "@/lib/api";
|
||||
import { isOwner, ROLES, ROLE_META } from "@/lib/roles";
|
||||
import { isOwner, ROLES } from "@/lib/roles";
|
||||
import { formatDate, formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { realtime } from "@/lib/realtime";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
import Modal from "@/components/Modal";
|
||||
import RoleAccountModal from "@/components/RoleAccountModal";
|
||||
|
||||
export type UserFilter = "all" | "staff" | "banned";
|
||||
|
||||
@@ -58,14 +55,6 @@ function filterToParams(filter: UserFilter): { role?: string; status?: string }
|
||||
return {};
|
||||
}
|
||||
|
||||
// 可授予的角色选项(超级管理员仅站长可见可授;owner 不出现也不可授予)
|
||||
interface RoleOption {
|
||||
value: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
// 汇总小卡
|
||||
function MiniStat({
|
||||
label,
|
||||
@@ -774,7 +763,7 @@ export default function UsersAdmin({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RoleAssignDialog
|
||||
<RoleAccountModal
|
||||
target={roleTarget}
|
||||
boards={boards}
|
||||
canGrantSuper={currentUserRole === ROLES.OWNER}
|
||||
@@ -792,301 +781,6 @@ export default function UsersAdmin({
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 角色与账号弹窗(角色授权 + 封禁状态) =====
|
||||
|
||||
function RoleAssignDialog({
|
||||
target,
|
||||
boards,
|
||||
canGrantSuper,
|
||||
acting,
|
||||
onClose,
|
||||
onSaved,
|
||||
setActing,
|
||||
}: {
|
||||
target: AdminUser | null;
|
||||
boards: Board[];
|
||||
canGrantSuper: boolean;
|
||||
acting: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
setActing: (v: boolean) => void;
|
||||
}) {
|
||||
const grantKeys = [
|
||||
ROLES.SUPER_ADMIN,
|
||||
ROLES.ADMIN,
|
||||
ROLES.BOARD_ADMIN,
|
||||
ROLES.USER,
|
||||
] as const;
|
||||
|
||||
const options: RoleOption[] = grantKeys
|
||||
.filter((k) => canGrantSuper || k !== ROLES.SUPER_ADMIN)
|
||||
.map((k) => {
|
||||
const meta = ROLE_META[k];
|
||||
return {
|
||||
value: k,
|
||||
title: meta.label,
|
||||
desc: meta.grantDesc || "",
|
||||
token: meta.colorToken,
|
||||
};
|
||||
});
|
||||
|
||||
const [role, setRole] = useState<string>(ROLES.USER);
|
||||
const [boardIds, setBoardIds] = useState<number[]>([]);
|
||||
const [banned, setBanned] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (target) {
|
||||
setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role);
|
||||
setBoardIds(target.board_ids ?? []);
|
||||
setBanned(!!target.banned);
|
||||
setErr("");
|
||||
}
|
||||
}, [target]);
|
||||
|
||||
if (!target) return null;
|
||||
|
||||
const toggleBoard = (id: number) => {
|
||||
setBoardIds((prev) => (prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id]));
|
||||
};
|
||||
|
||||
const sameBoards =
|
||||
JSON.stringify([...(target.board_ids ?? [])].sort((a, b) => a - b)) ===
|
||||
JSON.stringify([...boardIds].sort((a, b) => a - b));
|
||||
const roleChanged = role !== target.role || (role === ROLES.BOARD_ADMIN && !sameBoards);
|
||||
const banChanged = banned !== !!target.banned;
|
||||
const willBan = banChanged && banned;
|
||||
const willUnban = banChanged && !banned;
|
||||
|
||||
const submit = async () => {
|
||||
if (role === ROLES.BOARD_ADMIN && boardIds.length === 0) {
|
||||
setErr("请至少选择一个授权板块");
|
||||
return;
|
||||
}
|
||||
if (!roleChanged && !banChanged) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setActing(true);
|
||||
try {
|
||||
if (roleChanged) {
|
||||
await apiAdminSetUserRole(target.id, role, role === ROLES.BOARD_ADMIN ? boardIds : []);
|
||||
}
|
||||
if (banChanged) {
|
||||
await apiAdminSetUserBan(target.id, banned);
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (roleChanged) parts.push("角色已更新");
|
||||
if (willBan) parts.push("已封禁");
|
||||
if (willUnban) parts.push("已解封");
|
||||
toast(parts.join(" · ") || "已保存", "ok");
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "操作失败");
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title="角色与账号"
|
||||
description={
|
||||
<>
|
||||
<span className="truncate block">
|
||||
@{target.username}({target.nickname})
|
||||
</span>
|
||||
<span className="block mt-1">
|
||||
角色决定后台入口与前台管理能力;封禁立即强制下线。站长内容档案仍走列表入口。
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
busy={acting}
|
||||
maxWidthClass="max-w-[500px]"
|
||||
panelClassName="p-6 max-h-[88vh] overflow-y-auto"
|
||||
ariaLabel="角色与账号"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-line" onClick={onClose} disabled={acting}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={willBan ? "btn btn-danger" : "btn btn-primary"}
|
||||
onClick={submit}
|
||||
disabled={acting}
|
||||
>
|
||||
{acting && <Loader2 size={15} className="animate-spin" />}
|
||||
{saveLabel}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-[12.5px]" style={{ color: "var(--ink)" }}>
|
||||
管理角色
|
||||
</p>
|
||||
<div className="flex flex-col gap-2" role="radiogroup" aria-label="管理角色">
|
||||
{options.map((o) => {
|
||||
const active = role === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => {
|
||||
setRole(o.value);
|
||||
setErr("");
|
||||
}}
|
||||
className="text-left rounded-xl p-3.5 flex items-start gap-3 transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
|
||||
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 mt-0.5"
|
||||
style={{
|
||||
background: active ? "var(--accent)" : "var(--panel)",
|
||||
color: active ? "var(--accent-on)" : `var(${o.token})`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{o.value === ROLES.USER ? <UsersIcon size={18} /> : <ShieldCheck size={18} />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{o.title}
|
||||
</span>
|
||||
<span className="block mt-1 text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
{o.desc}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="mt-1 w-4 h-4 rounded-full shrink-0 flex items-center justify-center"
|
||||
style={{ border: `2px solid ${active ? "var(--accent)" : "var(--line-2)"}` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{active && <span className="w-2 h-2 rounded-full" style={{ background: "var(--accent)" }} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{role === ROLES.BOARD_ADMIN && (
|
||||
<div className="mt-4">
|
||||
<p className="text-[12.5px] mb-2" style={{ color: "var(--ink)" }}>
|
||||
授权板块 <span style={{ color: "var(--danger)" }}>*</span>
|
||||
</p>
|
||||
{boards.length === 0 ? (
|
||||
<p className="meta text-[12px]">板块加载中…</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{boards.map((b) => {
|
||||
const checked = boardIds.includes(b.id);
|
||||
return (
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
onClick={() => toggleBoard(b.id)}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-2 text-[12.5px] transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${checked ? "var(--accent)" : "var(--line)"}`,
|
||||
background: checked ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
color: checked ? "var(--accent)" : "var(--ink-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-4 h-4 rounded shrink-0 flex items-center justify-center"
|
||||
style={{ border: `2px solid ${checked ? "var(--accent)" : "var(--line-2)"}` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{checked && (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate">{b.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{err && (
|
||||
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<p className="text-[12.5px] mb-2" style={{ color: "var(--ink)" }}>
|
||||
账号状态
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2" role="radiogroup" aria-label="账号状态">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={!banned}
|
||||
onClick={() => setBanned(false)}
|
||||
className="rounded-xl px-3 py-2.5 text-left transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${!banned ? "var(--ok)" : "var(--line)"}`,
|
||||
background: !banned ? "var(--ok-soft)" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium"
|
||||
style={{ color: !banned ? "var(--ok)" : "var(--ink)" }}
|
||||
>
|
||||
<RotateCcw size={14} /> 正常
|
||||
</span>
|
||||
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
|
||||
可登录与互动
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={banned}
|
||||
onClick={() => setBanned(true)}
|
||||
className="rounded-xl px-3 py-2.5 text-left transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${banned ? "var(--danger)" : "var(--line)"}`,
|
||||
background: banned ? "var(--danger-soft)" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="flex items-center gap-1.5 text-[13px] font-medium"
|
||||
style={{ color: banned ? "var(--danger)" : "var(--ink)" }}
|
||||
>
|
||||
<Ban size={14} /> 封禁
|
||||
</span>
|
||||
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
|
||||
强制下线,禁止登录
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{willBan && (
|
||||
<p className="mt-2 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
|
||||
封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ===== 登录历史弹窗 =====
|
||||
|
||||
function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import Avatar from "@/components/Avatar";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
import { roleMeta } from "@/lib/roles";
|
||||
import StartDMButton from "./StartDMButton";
|
||||
import StaffUserMenu from "@/components/StaffUserMenu";
|
||||
|
||||
interface UserPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
@@ -50,9 +51,13 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
rawTab === "settings" ? "settings" :
|
||||
rawTab === "security" ? "security" : "posts";
|
||||
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
|
||||
let data: UserProfile;
|
||||
try {
|
||||
data = await fetchUserProfile(id, page);
|
||||
// 带 cookie:超管/站长才能拿到 banned 字段
|
||||
data = await fetchUserProfile(id, page, cookie || undefined);
|
||||
} catch {
|
||||
return (
|
||||
<div className="max-w-md mx-auto text-center py-24">
|
||||
@@ -68,8 +73,6 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
}
|
||||
|
||||
// 判断是否本人(决定能否看到 settings / security tab)
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
const isOwner = me.user?.id === data.user.id;
|
||||
const currentUser = me.user;
|
||||
const effectiveTab: UserTab =
|
||||
@@ -116,8 +119,8 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
</nav>
|
||||
|
||||
{/* ═══ Hero:石墨面板 + 淡靛几何装饰 + 身份信息 + 统计条 ═══ */}
|
||||
<section className="panel relative mb-6 overflow-hidden">
|
||||
{/* 装饰层:裁在面板圆角内 */}
|
||||
<section className="panel relative mb-6">
|
||||
{/* 装饰层单独裁圆角,避免 overflow-hidden 裁掉管理下拉菜单 */}
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
|
||||
<span
|
||||
className="absolute -top-28 -right-24 w-[440px] h-[440px] rounded-full"
|
||||
@@ -151,7 +154,17 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
</Link>
|
||||
</div>
|
||||
) : currentUser ? (
|
||||
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 z-20">
|
||||
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 z-20 flex items-center gap-2">
|
||||
<StaffUserMenu
|
||||
target={{
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
nickname: u.nickname,
|
||||
role: u.role,
|
||||
board_ids: u.board_ids,
|
||||
banned: u.banned,
|
||||
}}
|
||||
/>
|
||||
<StartDMButton userId={data.user.id} />
|
||||
</div>
|
||||
) : null}
|
||||
@@ -186,6 +199,14 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
||||
{u.role !== "user" && (
|
||||
<RoleBadge role={u.role} boardIds={u.board_ids} />
|
||||
)}
|
||||
{u.banned === true && (
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold"
|
||||
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
|
||||
>
|
||||
已封禁
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="meta mt-1 flex items-center gap-1.5 text-[12.5px] flex-wrap">
|
||||
<span>@{u.username}</span>
|
||||
|
||||
Reference in New Issue
Block a user