站点/后台分轨背景与用户列表排序一并落地;生产 CORS 改走 SITE_URL,健康检查带版本号。 Co-authored-by: Cursor <cursoragent@cursor.com>
425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import Link from "next/link";
|
||
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
|
||
import { apiAdminSetUserBan, apiAdminSetUserRole, type Board } from "@/lib/api";
|
||
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
|
||
import { formatDate } from "@/lib/format";
|
||
import { toast } from "@/lib/toast";
|
||
import Avatar from "@/components/Avatar";
|
||
import Modal from "@/components/Modal";
|
||
import RoleBadge from "@/components/RoleBadge";
|
||
|
||
/** 角色与账号弹窗所需的最小用户形状(后台列表项 / 公开资料均可) */
|
||
export interface RoleAccountTarget {
|
||
id: number;
|
||
username: string;
|
||
nickname: string;
|
||
role: string;
|
||
board_ids?: number[];
|
||
banned: boolean;
|
||
avatar?: string;
|
||
email?: string;
|
||
points?: number;
|
||
created_at?: string;
|
||
}
|
||
|
||
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({
|
||
...target,
|
||
role: nextRole,
|
||
board_ids: nextBoards,
|
||
banned: nextBanned,
|
||
});
|
||
} catch (e) {
|
||
toast(e instanceof Error ? e.message : "操作失败");
|
||
} finally {
|
||
setActing(false);
|
||
}
|
||
};
|
||
|
||
const saveLabel = willBan ? "保存并封禁" : willUnban ? "保存并解封" : "保存";
|
||
const displayName = target.nickname || target.username;
|
||
const currentMeta = roleMeta(target.role);
|
||
|
||
return (
|
||
<Modal
|
||
open
|
||
title="角色与账号"
|
||
header={<IdentityHeader target={target} displayName={displayName} currentMetaLabel={currentMeta.label} />}
|
||
onClose={onClose}
|
||
busy={acting}
|
||
maxWidthClass="max-w-[540px]"
|
||
panelClassName="p-6 max-h-[88vh] flex flex-col overflow-hidden"
|
||
bodyClassName="overflow-y-auto flex-1 min-h-0 -mx-1 px-1"
|
||
ariaLabel={`设置 ${displayName} 的角色与账号`}
|
||
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.5 text-[12px] font-medium tracking-wide" style={{ color: "var(--ink-3)" }}>
|
||
管理角色
|
||
</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-[12px] font-medium tracking-wide mb-2" style={{ color: "var(--ink-3)" }}>
|
||
授权板块 <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-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
|
||
账号状态
|
||
</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.5 py-3 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-[13.5px] font-medium"
|
||
style={{ color: !banned ? "var(--ok)" : "var(--ink)" }}
|
||
>
|
||
<RotateCcw size={15} /> 正常
|
||
</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.5 py-3 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-[13.5px] font-medium"
|
||
style={{ color: banned ? "var(--danger)" : "var(--ink)" }}
|
||
>
|
||
<Ban size={15} /> 封禁
|
||
</span>
|
||
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
|
||
强制下线,禁止登录
|
||
</span>
|
||
</button>
|
||
</div>
|
||
{willBan && (
|
||
<p className="mt-2.5 text-[12px] leading-relaxed" style={{ color: "var(--danger)" }}>
|
||
封禁后立即强制下线,无法登录、发帖与回复;已发布内容保留。角色变更与封禁将一并保存。
|
||
</p>
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
function IdentityHeader({
|
||
target,
|
||
displayName,
|
||
currentMetaLabel,
|
||
}: {
|
||
target: RoleAccountTarget;
|
||
displayName: string;
|
||
currentMetaLabel: string;
|
||
}) {
|
||
const metaBits: string[] = [`ID ${target.id}`];
|
||
if (typeof target.points === "number") {
|
||
metaBits.push(`积分 ${target.points.toLocaleString("zh-CN")}`);
|
||
}
|
||
if (target.created_at) {
|
||
metaBits.push(`注册 ${formatDate(target.created_at)}`);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<p
|
||
className="text-[11px] font-medium uppercase tracking-[0.14em] mb-3"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
角色与账号
|
||
</p>
|
||
<div
|
||
className="flex items-start gap-4 min-w-0 rounded-2xl p-3.5"
|
||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||
>
|
||
{/* 描边用 box-shadow,勿把实色垫在默认头像后面:.av-* 底是半透明,会透出垫色把字母对比打翻 */}
|
||
<span
|
||
className="inline-flex items-center justify-center shrink-0 rounded-full"
|
||
style={{
|
||
boxShadow: target.banned
|
||
? "0 0 0 3px var(--panel-2), 0 0 0 4.5px var(--danger)"
|
||
: "0 0 0 3px var(--panel-2), 0 0 0 4.5px color-mix(in srgb, var(--accent) 38%, transparent)",
|
||
}}
|
||
aria-hidden
|
||
>
|
||
<Avatar
|
||
name={displayName}
|
||
src={target.avatar || undefined}
|
||
size={68}
|
||
/>
|
||
</span>
|
||
<div className="min-w-0 flex-1 pt-0.5">
|
||
<p
|
||
className="text-[18px] sm:text-[20px] font-extrabold tracking-tight leading-tight truncate"
|
||
style={{ color: "var(--ink)" }}
|
||
>
|
||
{displayName}
|
||
</p>
|
||
<p className="mt-0.5 text-[13px] truncate" style={{ color: "var(--ink-2)" }}>
|
||
@{target.username}
|
||
{target.email ? ` · ${target.email}` : ""}
|
||
</p>
|
||
<div className="mt-2 flex items-center gap-1.5 flex-wrap">
|
||
<RoleBadge role={target.role} boardIds={target.board_ids} />
|
||
<span
|
||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6]"
|
||
style={
|
||
target.banned
|
||
? { background: "var(--danger-soft)", color: "var(--danger)" }
|
||
: { background: "var(--ok-soft)", color: "var(--ok)" }
|
||
}
|
||
>
|
||
{target.banned ? "已封禁" : "正常"}
|
||
</span>
|
||
</div>
|
||
<p className="mt-2 text-[11.5px] tabular-nums leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||
{metaBits.join(" · ")}
|
||
<span className="sr-only">当前角色 {currentMetaLabel}</span>
|
||
</p>
|
||
<Link
|
||
href={`/u/${target.id}`}
|
||
className="mt-2.5 inline-flex items-center gap-1 text-[12.5px] font-medium hover:underline"
|
||
style={{ color: "var(--accent)" }}
|
||
>
|
||
查看个人主页 <ExternalLink size={12} aria-hidden />
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|