From b6e9966ba8cbad920a1335b02c3dfca38e905bd7 Mon Sep 17 00:00:00 2001 From: freefire Date: Wed, 16 Sep 2026 06:29:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=94=A8=E6=88=B7=E4=B8=BB?= =?UTF-8?q?=E9=A1=B5=E5=A2=9E=E5=8A=A0=E5=B0=81=E7=A6=81=E4=B8=8E=E6=94=B9?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E5=BF=AB=E6=8D=B7=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 超管/站长可在他人主页就地管理账号;待审评论支持拒绝;角色弹窗前后台共用。 Co-authored-by: Cursor --- backend/handler/user.go | 26 +- frontend/app/admin/users/UsersAdmin.tsx | 312 +-------------------- frontend/app/u/[id]/page.tsx | 33 ++- frontend/components/CommentSection.tsx | 98 +++++-- frontend/components/RoleAccountModal.tsx | 338 +++++++++++++++++++++++ frontend/components/StaffUserMenu.tsx | 246 +++++++++++++++++ frontend/lib/api.ts | 2 + 7 files changed, 709 insertions(+), 346 deletions(-) create mode 100644 frontend/components/RoleAccountModal.tsx create mode 100644 frontend/components/StaffUserMenu.tsx diff --git a/backend/handler/user.go b/backend/handler/user.go index c0ed1c6..ed232f7 100644 --- a/backend/handler/user.go +++ b/backend/handler/user.go @@ -108,17 +108,23 @@ func (h *Handlers) UserProfile(c *gin.Context) { boardIDs = []uint{} } + userPayload := gin.H{ + "id": user.ID, + "username": user.Username, + "nickname": user.Nickname, + "avatar": user.Avatar, + "signature": user.Signature, + "role": user.Role, + "board_ids": boardIDs, + "created_at": user.CreatedAt, + } + // 仅对具备用户管理权限的访客下发封禁态(避免信息公开) + if actor != nil && actor.HasPerm(service.PermUsers) { + userPayload["banned"] = user.Banned + } + c.JSON(http.StatusOK, gin.H{ - "user": gin.H{ - "id": user.ID, - "username": user.Username, - "nickname": user.Nickname, - "avatar": user.Avatar, - "signature": user.Signature, - "role": user.Role, - "board_ids": boardIDs, - "created_at": user.CreatedAt, - }, + "user": userPayload, "stats": gin.H{ "post_count": postCount, "comment_count": commentCount, diff --git a/frontend/app/admin/users/UsersAdmin.tsx b/frontend/app/admin/users/UsersAdmin.tsx index 0aaa5f6..6eed1e2 100644 --- a/frontend/app/admin/users/UsersAdmin.tsx +++ b/frontend/app/admin/users/UsersAdmin.tsx @@ -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({ )} - 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(ROLES.USER); - const [boardIds, setBoardIds] = useState([]); - 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 ( - - - @{target.username}({target.nickname}) - - - 角色决定后台入口与前台管理能力;封禁立即强制下线。站长内容档案仍走列表入口。 - - - } - onClose={onClose} - busy={acting} - maxWidthClass="max-w-[500px]" - panelClassName="p-6 max-h-[88vh] overflow-y-auto" - ariaLabel="角色与账号" - footer={ - <> - - - - } - > -

- 管理角色 -

-
- {options.map((o) => { - const active = role === o.value; - return ( - - ); - })} -
- - {role === ROLES.BOARD_ADMIN && ( -
-

- 授权板块 * -

- {boards.length === 0 ? ( -

板块加载中…

- ) : ( -
- {boards.map((b) => { - const checked = boardIds.includes(b.id); - return ( - - ); - })} -
- )} - {err && ( -

- {err} -

- )} -
- )} - -
-

- 账号状态 -

-
- - -
- {willBan && ( -

- 封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。 -

- )} -
-
- ); -} - - // ===== 登录历史弹窗 ===== function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) { diff --git a/frontend/app/u/[id]/page.tsx b/frontend/app/u/[id]/page.tsx index 79db28f..7745e76 100644 --- a/frontend/app/u/[id]/page.tsx +++ b/frontend/app/u/[id]/page.tsx @@ -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 (
@@ -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) {/* ═══ Hero:石墨面板 + 淡靛几何装饰 + 身份信息 + 统计条 ═══ */} -
- {/* 装饰层:裁在面板圆角内 */} +
+ {/* 装饰层单独裁圆角,避免 overflow-hidden 裁掉管理下拉菜单 */}
) : currentUser ? ( -
+
+
) : null} @@ -186,6 +199,14 @@ export default async function UserPage({ params, searchParams }: UserPageProps) {u.role !== "user" && ( )} + {u.banned === true && ( + + 已封禁 + + )}

@{u.username} diff --git a/frontend/components/CommentSection.tsx b/frontend/components/CommentSection.tsx index e9d8836..0ee5861 100644 --- a/frontend/components/CommentSection.tsx +++ b/frontend/components/CommentSection.tsx @@ -3,13 +3,14 @@ import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; -import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck } from "lucide-react"; +import { Trash2, MessagesSquare, ChevronRight, ChevronDown, BadgeCheck, XCircle } from "lucide-react"; import { apiCreateComment, apiDeleteComment, apiAcceptBounty, apiAcceptQuestion, apiAdminApproveComment, + apiAdminRejectComment, type CommentNode, type User, } from "@/lib/api"; @@ -18,6 +19,7 @@ import { toast } from "@/lib/toast"; import Avatar from "./Avatar"; import Pagination from "./Pagination"; import MarkdownBodyClient from "@/components/MarkdownBodyClient"; +import ConfirmDialog from "@/components/ConfirmDialog"; interface CommentSectionProps { postId: string; @@ -68,6 +70,8 @@ export default function CommentSection({ const [confirmingId, setConfirmingId] = useState(null); const [acceptingId, setAcceptingId] = useState(null); const [approvingId, setApprovingId] = useState(null); + const [rejectTargetId, setRejectTargetId] = useState(null); + const [rejecting, setRejecting] = useState(false); // 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起) const [expandedFloors, setExpandedFloors] = useState>(new Set()); const textareaRef = useRef(null); @@ -185,6 +189,21 @@ export default function CommentSection({ } }; + const handleRejectConfirm = async () => { + if (rejectTargetId == null) return; + setRejecting(true); + try { + await apiAdminRejectComment(rejectTargetId); + toast("已拒绝该评论", "ok"); + setRejectTargetId(null); + router.refresh(); + } catch (e) { + toast(e instanceof Error ? e.message : "拒绝失败"); + } finally { + setRejecting(false); + } + }; + // 引用功能已移除:与"回复"功能重叠,且会割裂讨论上下文(抖音风格无此功能) const canDelete = (comment: CommentNode) => @@ -331,16 +350,28 @@ export default function CommentSection({ )} {canApprove(c) && ( - + <> + + + )}

{canDelete(c) && ( @@ -436,16 +467,28 @@ export default function CommentSection({ )} {canApprove(c) && ( - + <> + + + )} {canDelete(c) && ( @@ -565,6 +608,19 @@ export default function CommentSection({ + + { + if (!rejecting) setRejectTargetId(null); + }} + /> ); } diff --git a/frontend/components/RoleAccountModal.tsx b/frontend/components/RoleAccountModal.tsx new file mode 100644 index 0000000..2613ea6 --- /dev/null +++ b/frontend/components/RoleAccountModal.tsx @@ -0,0 +1,338 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Ban, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react"; +import { apiAdminSetUserBan, apiAdminSetUserRole, type Board } from "@/lib/api"; +import { ROLES, ROLE_META } from "@/lib/roles"; +import { toast } from "@/lib/toast"; +import Modal from "@/components/Modal"; + +/** 角色与账号弹窗所需的最小用户形状(后台列表项 / 公开资料均可) */ +export interface RoleAccountTarget { + id: number; + username: string; + nickname: string; + role: string; + board_ids?: number[]; + banned: boolean; +} + +interface RoleOption { + value: string; + title: string; + desc: string; + token: string; +} + +/** + * 角色授权 + 封禁状态弹窗。 + * 后台用户管理与前台 StaffUserMenu 共用,避免两套表单。 + */ +export default function RoleAccountModal({ + target, + boards, + canGrantSuper, + acting, + onClose, + onSaved, + setActing, +}: { + target: RoleAccountTarget | null; + boards: Board[]; + canGrantSuper: boolean; + acting: boolean; + onClose: () => void; + onSaved: (next: RoleAccountTarget) => 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(ROLES.USER); + const [boardIds, setBoardIds] = useState([]); + 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 { + let nextRole = target.role; + let nextBoards = target.board_ids ?? []; + let nextBanned = !!target.banned; + if (roleChanged) { + const res = await apiAdminSetUserRole( + target.id, + role, + role === ROLES.BOARD_ADMIN ? boardIds : [] + ); + nextRole = res.user.role; + nextBoards = res.user.board_ids ?? []; + } + if (banChanged) { + const res = await apiAdminSetUserBan(target.id, banned); + nextBanned = !!res.user.banned; + } + const parts: string[] = []; + if (roleChanged) parts.push("角色已更新"); + if (willBan) parts.push("已封禁"); + if (willUnban) parts.push("已解封"); + toast(parts.join(" · ") || "已保存", "ok"); + onSaved({ + id: target.id, + username: target.username, + nickname: target.nickname, + role: nextRole, + board_ids: nextBoards, + banned: nextBanned, + }); + } catch (e) { + toast(e instanceof Error ? e.message : "操作失败"); + } finally { + setActing(false); + } + }; + + const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存"; + + return ( + + + @{target.username}({target.nickname}) + + + 角色决定后台入口与前台管理能力;封禁立即强制下线。站长内容档案仍走列表入口。 + + + } + onClose={onClose} + busy={acting} + maxWidthClass="max-w-[500px]" + panelClassName="p-6 max-h-[88vh] overflow-y-auto" + ariaLabel="角色与账号" + footer={ + <> + + + + } + > +

+ 管理角色 +

+
+ {options.map((o) => { + const active = role === o.value; + return ( + + ); + })} +
+ + {role === ROLES.BOARD_ADMIN && ( +
+

+ 授权板块 * +

+ {boards.length === 0 ? ( +

板块加载中…

+ ) : ( +
+ {boards.map((b) => { + const checked = boardIds.includes(b.id); + return ( + + ); + })} +
+ )} + {err && ( +

+ {err} +

+ )} +
+ )} + +
+

+ 账号状态 +

+
+ + +
+ {willBan && ( +

+ 封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。 +

+ )} +
+
+ ); +} diff --git a/frontend/components/StaffUserMenu.tsx b/frontend/components/StaffUserMenu.tsx new file mode 100644 index 0000000..0761b3c --- /dev/null +++ b/frontend/components/StaffUserMenu.tsx @@ -0,0 +1,246 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { + Ban, + ChevronDown, + FolderSearch, + RotateCcw, + Shield, + SlidersHorizontal, + Users, +} from "lucide-react"; +import { + apiAdminSetUserBan, + apiListBoards, + apiMe, + type Board, + type User, +} from "@/lib/api"; +import { isOwner, isSuperOrOwner, ROLES } from "@/lib/roles"; +import { toast } from "@/lib/toast"; +import ConfirmDialog from "@/components/ConfirmDialog"; +import RoleAccountModal, { type RoleAccountTarget } from "@/components/RoleAccountModal"; + +export interface StaffUserTarget { + id: number; + username: string; + nickname: string; + role: string; + board_ids?: number[]; + /** 有权访客资料接口下发;未知时按未封禁处理 */ + banned?: boolean; +} + +/** + * 超管/站长对用户的前台快捷菜单:封禁、改角色、内容档案。 + * 权限仅控显隐;写操作以后端为准。 + */ +export default function StaffUserMenu({ + target, + compact = false, + onBannedChange, +}: { + target: StaffUserTarget; + /** 作者栏等窄位用更小按钮 */ + compact?: boolean; + /** 封禁态变化回调(主页徽章等) */ + onBannedChange?: (banned: boolean) => void; +}) { + const router = useRouter(); + const menuRef = useRef(null); + const [me, setMe] = useState(null); + const [open, setOpen] = useState(false); + const [banned, setBanned] = useState(!!target.banned); + const [confirmBan, setConfirmBan] = useState(false); + const [roleOpen, setRoleOpen] = useState(false); + const [boards, setBoards] = useState([]); + const [acting, setActing] = useState(false); + + useEffect(() => { + apiMe() + .then((res) => setMe(res.user || null)) + .catch(() => setMe(null)); + }, []); + + useEffect(() => { + setBanned(!!target.banned); + }, [target.banned, target.id]); + + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + const canManage = + !!me && + isSuperOrOwner(me.role) && + me.id !== target.id && + target.role !== ROLES.OWNER; + + if (!canManage) return null; + + const openRoleModal = () => { + setOpen(false); + setRoleOpen(true); + if (boards.length === 0) { + apiListBoards() + .then((res) => setBoards(res.boards ?? [])) + .catch(() => toast("板块列表加载失败")); + } + }; + + const handleBanConfirm = async () => { + const next = !banned; + setActing(true); + try { + await apiAdminSetUserBan(target.id, next); + setBanned(next); + onBannedChange?.(next); + setConfirmBan(false); + toast(next ? "已封禁" : "已解封", "ok"); + router.refresh(); + } catch (e) { + toast(e instanceof Error ? e.message : "操作失败"); + } finally { + setActing(false); + } + }; + + const roleTarget: RoleAccountTarget = { + id: target.id, + username: target.username, + nickname: target.nickname, + role: target.role, + board_ids: target.board_ids, + banned, + }; + + const itemCls = + "w-full flex items-center gap-2.5 px-4 py-2.5 text-[13px] transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"; + + return ( +
+ + + {open && ( +
+ + +
+ {isOwner(me.role) && ( + setOpen(false)} + > + + 内容档案 + + )} + setOpen(false)} + > + + 后台用户列表 + +
+ )} + + setConfirmBan(false)} + /> + + setRoleOpen(false)} + onSaved={(next) => { + setBanned(next.banned); + onBannedChange?.(next.banned); + setRoleOpen(false); + router.refresh(); + }} + setActing={setActing} + /> +
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index df50a00..501ad41 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -339,6 +339,8 @@ export interface UserProfile { role: string; board_ids?: number[]; created_at: string; + /** 仅超管/站长访客可见(公开资料不下发) */ + banned?: boolean; }; stats: { post_count: number;