feat(admin): 用户主页增加封禁与改角色快捷菜单
超管/站长可在他人主页就地管理账号;待审评论支持拒绝;角色弹窗前后台共用。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -108,17 +108,23 @@ func (h *Handlers) UserProfile(c *gin.Context) {
|
|||||||
boardIDs = []uint{}
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"user": gin.H{
|
"user": userPayload,
|
||||||
"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,
|
|
||||||
},
|
|
||||||
"stats": gin.H{
|
"stats": gin.H{
|
||||||
"post_count": postCount,
|
"post_count": postCount,
|
||||||
"comment_count": commentCount,
|
"comment_count": commentCount,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Ban,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
History,
|
History,
|
||||||
Loader2,
|
Loader2,
|
||||||
Monitor,
|
Monitor,
|
||||||
RotateCcw,
|
|
||||||
Search,
|
Search,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -24,8 +22,6 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
apiAdminListUsers,
|
apiAdminListUsers,
|
||||||
apiAdminSetUserBan,
|
|
||||||
apiAdminSetUserRole,
|
|
||||||
apiAdminUserLoginLogs,
|
apiAdminUserLoginLogs,
|
||||||
apiListBoards,
|
apiListBoards,
|
||||||
type AdminUser,
|
type AdminUser,
|
||||||
@@ -34,13 +30,14 @@ import {
|
|||||||
type Board,
|
type Board,
|
||||||
type LoginLog,
|
type LoginLog,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { isOwner, ROLES, ROLE_META } from "@/lib/roles";
|
import { isOwner, ROLES } from "@/lib/roles";
|
||||||
import { formatDate, formatRelative } from "@/lib/format";
|
import { formatDate, formatRelative } from "@/lib/format";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
import { realtime } from "@/lib/realtime";
|
import { realtime } from "@/lib/realtime";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import RoleBadge from "@/components/RoleBadge";
|
import RoleBadge from "@/components/RoleBadge";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
|
import RoleAccountModal from "@/components/RoleAccountModal";
|
||||||
|
|
||||||
export type UserFilter = "all" | "staff" | "banned";
|
export type UserFilter = "all" | "staff" | "banned";
|
||||||
|
|
||||||
@@ -58,14 +55,6 @@ function filterToParams(filter: UserFilter): { role?: string; status?: string }
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 可授予的角色选项(超级管理员仅站长可见可授;owner 不出现也不可授予)
|
|
||||||
interface RoleOption {
|
|
||||||
value: string;
|
|
||||||
title: string;
|
|
||||||
desc: string;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 汇总小卡
|
// 汇总小卡
|
||||||
function MiniStat({
|
function MiniStat({
|
||||||
label,
|
label,
|
||||||
@@ -774,7 +763,7 @@ export default function UsersAdmin({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RoleAssignDialog
|
<RoleAccountModal
|
||||||
target={roleTarget}
|
target={roleTarget}
|
||||||
boards={boards}
|
boards={boards}
|
||||||
canGrantSuper={currentUserRole === ROLES.OWNER}
|
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 }) {
|
function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Avatar from "@/components/Avatar";
|
|||||||
import RoleBadge from "@/components/RoleBadge";
|
import RoleBadge from "@/components/RoleBadge";
|
||||||
import { roleMeta } from "@/lib/roles";
|
import { roleMeta } from "@/lib/roles";
|
||||||
import StartDMButton from "./StartDMButton";
|
import StartDMButton from "./StartDMButton";
|
||||||
|
import StaffUserMenu from "@/components/StaffUserMenu";
|
||||||
|
|
||||||
interface UserPageProps {
|
interface UserPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -50,9 +51,13 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
rawTab === "settings" ? "settings" :
|
rawTab === "settings" ? "settings" :
|
||||||
rawTab === "security" ? "security" : "posts";
|
rawTab === "security" ? "security" : "posts";
|
||||||
|
|
||||||
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
|
||||||
let data: UserProfile;
|
let data: UserProfile;
|
||||||
try {
|
try {
|
||||||
data = await fetchUserProfile(id, page);
|
// 带 cookie:超管/站长才能拿到 banned 字段
|
||||||
|
data = await fetchUserProfile(id, page, cookie || undefined);
|
||||||
} catch {
|
} catch {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-md mx-auto text-center py-24">
|
<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)
|
// 判断是否本人(决定能否看到 settings / security tab)
|
||||||
const cookie = authCookieHeader(await cookies());
|
|
||||||
const me = await getMeCached(cookie || undefined);
|
|
||||||
const isOwner = me.user?.id === data.user.id;
|
const isOwner = me.user?.id === data.user.id;
|
||||||
const currentUser = me.user;
|
const currentUser = me.user;
|
||||||
const effectiveTab: UserTab =
|
const effectiveTab: UserTab =
|
||||||
@@ -116,8 +119,8 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* ═══ Hero:石墨面板 + 淡靛几何装饰 + 身份信息 + 统计条 ═══ */}
|
{/* ═══ 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]">
|
<div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]">
|
||||||
<span
|
<span
|
||||||
className="absolute -top-28 -right-24 w-[440px] h-[440px] rounded-full"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : currentUser ? (
|
) : 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} />
|
<StartDMButton userId={data.user.id} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -186,6 +199,14 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
|
|||||||
{u.role !== "user" && (
|
{u.role !== "user" && (
|
||||||
<RoleBadge role={u.role} boardIds={u.board_ids} />
|
<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>
|
</div>
|
||||||
<p className="meta mt-1 flex items-center gap-1.5 text-[12.5px] flex-wrap">
|
<p className="meta mt-1 flex items-center gap-1.5 text-[12.5px] flex-wrap">
|
||||||
<span>@{u.username}</span>
|
<span>@{u.username}</span>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
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 {
|
import {
|
||||||
apiCreateComment,
|
apiCreateComment,
|
||||||
apiDeleteComment,
|
apiDeleteComment,
|
||||||
apiAcceptBounty,
|
apiAcceptBounty,
|
||||||
apiAcceptQuestion,
|
apiAcceptQuestion,
|
||||||
apiAdminApproveComment,
|
apiAdminApproveComment,
|
||||||
|
apiAdminRejectComment,
|
||||||
type CommentNode,
|
type CommentNode,
|
||||||
type User,
|
type User,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
@@ -18,6 +19,7 @@ import { toast } from "@/lib/toast";
|
|||||||
import Avatar from "./Avatar";
|
import Avatar from "./Avatar";
|
||||||
import Pagination from "./Pagination";
|
import Pagination from "./Pagination";
|
||||||
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
|
||||||
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
|
|
||||||
interface CommentSectionProps {
|
interface CommentSectionProps {
|
||||||
postId: string;
|
postId: string;
|
||||||
@@ -68,6 +70,8 @@ export default function CommentSection({
|
|||||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
||||||
const [acceptingId, setAcceptingId] = useState<number | null>(null);
|
const [acceptingId, setAcceptingId] = useState<number | null>(null);
|
||||||
const [approvingId, setApprovingId] = useState<number | null>(null);
|
const [approvingId, setApprovingId] = useState<number | null>(null);
|
||||||
|
const [rejectTargetId, setRejectTargetId] = useState<number | null>(null);
|
||||||
|
const [rejecting, setRejecting] = useState(false);
|
||||||
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
// 折叠状态:哪些楼层展开了子回复(默认全部折叠,SSR 初始为收起)
|
||||||
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
const [expandedFloors, setExpandedFloors] = useState<Set<number>>(new Set());
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(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) =>
|
const canDelete = (comment: CommentNode) =>
|
||||||
@@ -331,16 +350,28 @@ export default function CommentSection({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canApprove(c) && (
|
{canApprove(c) && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
className="cmt-reply-btn !text-[var(--accent)] inline-flex items-center gap-1"
|
type="button"
|
||||||
disabled={approvingId === c.id}
|
className="cmt-reply-btn !text-[var(--accent)] inline-flex items-center gap-1"
|
||||||
onClick={() => void handleApprove(c.id)}
|
disabled={approvingId === c.id || rejecting}
|
||||||
data-tip="通过评审,公开此评论"
|
onClick={() => void handleApprove(c.id)}
|
||||||
>
|
data-tip="通过评审,公开此评论"
|
||||||
<BadgeCheck size={13} />
|
>
|
||||||
{approvingId === c.id ? "通过中…" : "通过评审"}
|
<BadgeCheck size={13} />
|
||||||
</button>
|
{approvingId === c.id ? "通过中…" : "通过评审"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cmt-reply-btn !text-[var(--danger)] inline-flex items-center gap-1"
|
||||||
|
disabled={approvingId === c.id || rejecting}
|
||||||
|
onClick={() => setRejectTargetId(c.id)}
|
||||||
|
data-tip="拒绝此评论,不公开"
|
||||||
|
>
|
||||||
|
<XCircle size={13} />
|
||||||
|
拒绝
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canDelete(c) && (
|
{canDelete(c) && (
|
||||||
@@ -436,16 +467,28 @@ export default function CommentSection({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{canApprove(c) && (
|
{canApprove(c) && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
className="cmt-reply-btn !text-[var(--accent)] inline-flex items-center gap-1"
|
type="button"
|
||||||
disabled={approvingId === c.id}
|
className="cmt-reply-btn !text-[var(--accent)] inline-flex items-center gap-1"
|
||||||
onClick={() => void handleApprove(c.id)}
|
disabled={approvingId === c.id || rejecting}
|
||||||
data-tip="通过评审,公开此评论"
|
onClick={() => void handleApprove(c.id)}
|
||||||
>
|
data-tip="通过评审,公开此评论"
|
||||||
<BadgeCheck size={13} />
|
>
|
||||||
{approvingId === c.id ? "通过中…" : "通过评审"}
|
<BadgeCheck size={13} />
|
||||||
</button>
|
{approvingId === c.id ? "通过中…" : "通过评审"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="cmt-reply-btn !text-[var(--danger)] inline-flex items-center gap-1"
|
||||||
|
disabled={approvingId === c.id || rejecting}
|
||||||
|
onClick={() => setRejectTargetId(c.id)}
|
||||||
|
data-tip="拒绝此评论,不公开"
|
||||||
|
>
|
||||||
|
<XCircle size={13} />
|
||||||
|
拒绝
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{canDelete(c) && (
|
{canDelete(c) && (
|
||||||
@@ -565,6 +608,19 @@ export default function CommentSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Pagination total={total} page={page} size={size} basePath={`/post/${postId}`} />
|
<Pagination total={total} page={page} size={size} basePath={`/post/${postId}`} />
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={rejectTargetId != null}
|
||||||
|
title="拒绝评论"
|
||||||
|
message="确定拒绝这条待审评论?拒绝后不会公开,作者会收到通知。"
|
||||||
|
confirmLabel="确认拒绝"
|
||||||
|
danger
|
||||||
|
busy={rejecting}
|
||||||
|
onConfirm={handleRejectConfirm}
|
||||||
|
onCancel={() => {
|
||||||
|
if (!rejecting) setRejectTargetId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
338
frontend/components/RoleAccountModal.tsx
Normal file
338
frontend/components/RoleAccountModal.tsx
Normal file
@@ -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<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 {
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
246
frontend/components/StaffUserMenu.tsx
Normal file
246
frontend/components/StaffUserMenu.tsx
Normal file
@@ -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<HTMLDivElement>(null);
|
||||||
|
const [me, setMe] = useState<User | null>(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<Board[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
disabled={acting}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
className={compact ? "btn btn-line btn-sm" : "btn btn-line btn-sm"}
|
||||||
|
style={open ? { borderColor: "var(--accent)", color: "var(--accent)" } : undefined}
|
||||||
|
data-tip="管理该用户"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={13} />
|
||||||
|
{compact ? "管理" : "管理用户"}
|
||||||
|
<ChevronDown
|
||||||
|
size={13}
|
||||||
|
style={{ transition: "transform .15s", transform: open ? "rotate(180deg)" : "none" }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 mt-2 w-48 overflow-hidden z-50 j13-toast-in"
|
||||||
|
style={{
|
||||||
|
background: "var(--panel)",
|
||||||
|
border: "1px solid var(--line)",
|
||||||
|
borderRadius: "var(--r)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
setConfirmBan(true);
|
||||||
|
}}
|
||||||
|
className={itemCls}
|
||||||
|
style={{ color: banned ? "var(--ok)" : "var(--danger)" }}
|
||||||
|
>
|
||||||
|
{banned ? <RotateCcw size={14} className="shrink-0" /> : <Ban size={14} className="shrink-0" />}
|
||||||
|
<span className="flex-1 text-left">{banned ? "解封账号" : "封禁账号"}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={openRoleModal}
|
||||||
|
className={itemCls}
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
>
|
||||||
|
<Shield size={14} className="shrink-0" />
|
||||||
|
<span className="flex-1 text-left">角色与账号</span>
|
||||||
|
</button>
|
||||||
|
<div style={{ height: 1, background: "var(--line)" }} />
|
||||||
|
{isOwner(me.role) && (
|
||||||
|
<Link
|
||||||
|
href={`/admin/users/${target.id}`}
|
||||||
|
role="menuitem"
|
||||||
|
className={itemCls}
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<FolderSearch size={14} className="shrink-0" />
|
||||||
|
<span className="flex-1 text-left">内容档案</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href="/admin/users"
|
||||||
|
role="menuitem"
|
||||||
|
className={itemCls}
|
||||||
|
style={{ color: "var(--ink)" }}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<Users size={14} className="shrink-0" />
|
||||||
|
<span className="flex-1 text-left">后台用户列表</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmBan}
|
||||||
|
title={banned ? "解封账号" : "封禁账号"}
|
||||||
|
message={
|
||||||
|
banned
|
||||||
|
? `确定解封 @${target.username}?解封后可立即登录与互动。`
|
||||||
|
: `确定封禁 @${target.username}?将立即强制下线,无法登录、发帖与回复;已发布内容保留。`
|
||||||
|
}
|
||||||
|
confirmLabel={banned ? "确认解封" : "确认封禁"}
|
||||||
|
danger={!banned}
|
||||||
|
busy={acting}
|
||||||
|
onConfirm={handleBanConfirm}
|
||||||
|
onCancel={() => setConfirmBan(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RoleAccountModal
|
||||||
|
target={roleOpen ? roleTarget : null}
|
||||||
|
boards={boards}
|
||||||
|
canGrantSuper={isOwner(me.role)}
|
||||||
|
acting={acting}
|
||||||
|
onClose={() => setRoleOpen(false)}
|
||||||
|
onSaved={(next) => {
|
||||||
|
setBanned(next.banned);
|
||||||
|
onBannedChange?.(next.banned);
|
||||||
|
setRoleOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
setActing={setActing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -339,6 +339,8 @@ export interface UserProfile {
|
|||||||
role: string;
|
role: string;
|
||||||
board_ids?: number[];
|
board_ids?: number[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/** 仅超管/站长访客可见(公开资料不下发) */
|
||||||
|
banned?: boolean;
|
||||||
};
|
};
|
||||||
stats: {
|
stats: {
|
||||||
post_count: number;
|
post_count: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user