完善角色与消息权限:收紧板管内容处置,彻底删除仅站长,并统一管理删帖/评弹窗。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -41,6 +41,7 @@ import {
|
||||
type AdminContentStatus,
|
||||
type PublicSettings,
|
||||
} from "@/lib/api";
|
||||
import { canModerateAuthor } from "@/lib/roles";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
|
||||
@@ -290,6 +291,8 @@ export default function ContentAdmin({
|
||||
initialCounts,
|
||||
initError,
|
||||
canConfigureSettings,
|
||||
canPurge,
|
||||
viewerRole,
|
||||
initialSettings,
|
||||
}: {
|
||||
initialKind: ContentKind;
|
||||
@@ -301,6 +304,8 @@ export default function ContentAdmin({
|
||||
initialCounts: AdminContentCounts;
|
||||
initError: string;
|
||||
canConfigureSettings: boolean;
|
||||
canPurge: boolean;
|
||||
viewerRole: string;
|
||||
initialSettings: PublicSettings;
|
||||
}) {
|
||||
// 窄屏分段:lg 以下只显示一栏;PC 双栏同时可见
|
||||
@@ -663,46 +668,50 @@ export default function ContentAdmin({
|
||||
) : null}
|
||||
{p.deleted ? (
|
||||
<>
|
||||
<ContentAction
|
||||
label="恢复"
|
||||
ok
|
||||
disabled={busy}
|
||||
icon={
|
||||
busy ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={13} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
key,
|
||||
() => apiAdminRestorePost(p.id),
|
||||
"帖子已恢复",
|
||||
() => reloadPosts()
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ContentAction
|
||||
label="清除"
|
||||
danger
|
||||
disabled={busy}
|
||||
icon={<Trash2 size={13} />}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"彻底删除帖子",
|
||||
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
|
||||
() =>
|
||||
apiAdminPurgePost(p.id).then(() => {
|
||||
toast("帖子已彻底删除", "ok");
|
||||
reloadPosts();
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
/>
|
||||
{canModerateAuthor({ role: viewerRole }, p.user?.role) ? (
|
||||
<ContentAction
|
||||
label="恢复"
|
||||
ok
|
||||
disabled={busy}
|
||||
icon={
|
||||
busy ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={13} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
key,
|
||||
() => apiAdminRestorePost(p.id),
|
||||
"帖子已恢复",
|
||||
() => reloadPosts()
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canPurge ? (
|
||||
<ContentAction
|
||||
label="清除"
|
||||
danger
|
||||
disabled={busy}
|
||||
icon={<Trash2 size={13} />}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"彻底删除帖子",
|
||||
`「${p.title}」将从数据库永久删除,且其下评论一并清除,不可撤销。`,
|
||||
() =>
|
||||
apiAdminPurgePost(p.id).then(() => {
|
||||
toast("帖子已彻底删除", "ok");
|
||||
reloadPosts();
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
) : canModerateAuthor({ role: viewerRole }, p.user?.role) ? (
|
||||
<ContentAction
|
||||
label="删除"
|
||||
danger
|
||||
@@ -710,7 +719,7 @@ export default function ContentAdmin({
|
||||
icon={<Trash2 size={13} />}
|
||||
onClick={() => setStaffDeletePost(p)}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -803,46 +812,50 @@ export default function ContentAdmin({
|
||||
/>
|
||||
{c.deleted ? (
|
||||
<>
|
||||
<ContentAction
|
||||
label="恢复"
|
||||
ok
|
||||
disabled={busy}
|
||||
icon={
|
||||
busy ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={13} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
key,
|
||||
() => apiAdminRestoreComment(c.id),
|
||||
"评论已恢复",
|
||||
() => reloadComments()
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ContentAction
|
||||
label="清除"
|
||||
danger
|
||||
disabled={busy}
|
||||
icon={<Trash2 size={13} />}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"彻底删除评论",
|
||||
"该评论将永久删除且不可恢复。",
|
||||
() =>
|
||||
apiAdminPurgeComment(c.id).then(() => {
|
||||
toast("评论已彻底删除", "ok");
|
||||
reloadComments();
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
/>
|
||||
{canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
|
||||
<ContentAction
|
||||
label="恢复"
|
||||
ok
|
||||
disabled={busy}
|
||||
icon={
|
||||
busy ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<RotateCcw size={13} />
|
||||
)
|
||||
}
|
||||
onClick={() =>
|
||||
runAction(
|
||||
key,
|
||||
() => apiAdminRestoreComment(c.id),
|
||||
"评论已恢复",
|
||||
() => reloadComments()
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{canPurge ? (
|
||||
<ContentAction
|
||||
label="清除"
|
||||
danger
|
||||
disabled={busy}
|
||||
icon={<Trash2 size={13} />}
|
||||
onClick={() =>
|
||||
askConfirm(
|
||||
"彻底删除评论",
|
||||
"该评论将永久删除且不可恢复。",
|
||||
() =>
|
||||
apiAdminPurgeComment(c.id).then(() => {
|
||||
toast("评论已彻底删除", "ok");
|
||||
reloadComments();
|
||||
}),
|
||||
true
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
) : canModerateAuthor({ role: viewerRole }, c.user?.role) ? (
|
||||
<ContentAction
|
||||
label="删除"
|
||||
danger
|
||||
@@ -850,7 +863,7 @@ export default function ContentAdmin({
|
||||
icon={<Trash2 size={13} />}
|
||||
onClick={() => setStaffDeleteComment(c)}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type AdminContentStatus,
|
||||
} from "@/lib/api";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { canModerateAny, isSuperOrOwner } from "@/lib/roles";
|
||||
import { canModerateAny, isSuperOrOwner, isOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import ContentAdmin, { type ContentKind } from "./ContentAdmin";
|
||||
|
||||
@@ -93,6 +93,8 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
|
||||
initialCounts={counts}
|
||||
initError={initError}
|
||||
canConfigureSettings={isSuperOrOwner(me.user.role)}
|
||||
canPurge={isOwner(me.user.role)}
|
||||
viewerRole={me.user.role}
|
||||
initialSettings={settings}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cookies } from "next/headers";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
|
||||
import AdminShell from "@/components/admin/AdminShell";
|
||||
|
||||
// 整个 /admin 树不进入索引(子页面无需重复声明)
|
||||
@@ -12,8 +12,8 @@ export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
// 权限以 Go 端 RequireStaff 为唯一判定;这里仅决定管理界面是否直出,
|
||||
// 非管理团队访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。
|
||||
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
|
||||
// 允许:管理角色,或仅有消息管理/群管权限的用户(仅消息菜单可见)。
|
||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = await cookies();
|
||||
const cookie = authCookieHeader(cookieStore);
|
||||
@@ -21,7 +21,10 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
const settings = await getPublicSettingsCached();
|
||||
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
|
||||
|
||||
if (!me.user || !isStaff(me.user.role)) {
|
||||
const allowed =
|
||||
!!me.user && (isStaff(me.user.role) || canAccessAdminMessages(me.user));
|
||||
|
||||
if (!allowed || !me.user) {
|
||||
return (
|
||||
<div
|
||||
data-admin-viewport
|
||||
@@ -48,12 +51,14 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
||||
);
|
||||
}
|
||||
|
||||
const user = me.user;
|
||||
|
||||
// 未读仅在有 access cookie 时才可信;壳层铃铛用 SSR 直出避免挂载后才出红点
|
||||
const unread = cookieStore.get(TOKEN_COOKIE)?.value ? me.unread_count : 0;
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
user={me.user}
|
||||
user={user}
|
||||
siteName={settings.site_name || "姜十三论坛"}
|
||||
initialTheme={theme}
|
||||
initialUnread={unread}
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
apiAdminRecallChatMessage,
|
||||
type AdminChatMessage,
|
||||
type AdminChatRoomType,
|
||||
type User,
|
||||
} from "@/lib/api";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { canOverseeChat } from "@/lib/roles";
|
||||
import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import AutolinkText from "@/components/AutolinkText";
|
||||
@@ -247,7 +249,8 @@ function useMessageColumn(roomType: MsgKind) {
|
||||
}
|
||||
|
||||
/** 群聊(含大厅)| 私聊 双栏监管 */
|
||||
export default function MessagesAdmin() {
|
||||
export default function MessagesAdmin({ me }: { me: User }) {
|
||||
const canOversee = canOverseeChat(me.role);
|
||||
const [mobileKind, setMobileKind] = useState<MsgKind>("group");
|
||||
const group = useMessageColumn("group");
|
||||
const direct = useMessageColumn("direct");
|
||||
@@ -485,37 +488,43 @@ export default function MessagesAdmin() {
|
||||
<AdminPageHeader
|
||||
icon={MessagesSquare}
|
||||
title="消息管理"
|
||||
description="分栏监管群聊与私聊;可搜索并撤回违规内容"
|
||||
description={
|
||||
canOversee
|
||||
? "分栏监管群聊与私聊;可搜索并撤回违规内容"
|
||||
: "监管授权范围内的群聊消息;可搜索并撤回违规内容(不含私聊)"
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="消息类型">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileKind === "group"}
|
||||
className="admin-content-kind-btn"
|
||||
onClick={() => setMobileKind("group")}
|
||||
>
|
||||
<Users size={14} aria-hidden />
|
||||
群聊
|
||||
<span className="admin-content-kind-count">{group.total}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileKind === "direct"}
|
||||
className="admin-content-kind-btn"
|
||||
onClick={() => setMobileKind("direct")}
|
||||
>
|
||||
<MessageCircle size={14} aria-hidden />
|
||||
私聊
|
||||
<span className="admin-content-kind-count">{direct.total}</span>
|
||||
</button>
|
||||
</div>
|
||||
{canOversee ? (
|
||||
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="消息类型">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileKind === "group"}
|
||||
className="admin-content-kind-btn"
|
||||
onClick={() => setMobileKind("group")}
|
||||
>
|
||||
<Users size={14} aria-hidden />
|
||||
群聊
|
||||
<span className="admin-content-kind-count">{group.total}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mobileKind === "direct"}
|
||||
className="admin-content-kind-btn"
|
||||
onClick={() => setMobileKind("direct")}
|
||||
>
|
||||
<MessageCircle size={14} aria-hidden />
|
||||
私聊
|
||||
<span className="admin-content-kind-count">{direct.total}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-content-triptych">
|
||||
<div className={canOversee ? "admin-content-triptych" : "admin-content-triptych admin-content-triptych--single"}>
|
||||
{groupCol}
|
||||
{directCol}
|
||||
{canOversee ? directCol : null}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import { canAccessAdminMessages } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import MessagesAdmin from "./MessagesAdmin";
|
||||
|
||||
@@ -10,13 +10,13 @@ export const metadata: Metadata = {
|
||||
title: "消息管理",
|
||||
};
|
||||
|
||||
/** 群聊(含大厅)与私聊分栏监管;鉴权与 staff API 一致 */
|
||||
/** 群聊(含大厅)与私聊分栏监管;鉴权与消息管理权限一致 */
|
||||
export default async function AdminMessagesPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isStaff(me.user.role)) {
|
||||
return <Forbidden text="消息管理仅管理团队成员可进入。" />;
|
||||
if (!me.user || !canAccessAdminMessages(me.user)) {
|
||||
return <Forbidden text="消息管理需站长授予权限,或被任命为群管理员。" />;
|
||||
}
|
||||
|
||||
return <MessagesAdmin />;
|
||||
return <MessagesAdmin me={me.user} />;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles";
|
||||
import { canModerateAny, isSuperOrOwner, isAdminOrAbove, ROLES } from "@/lib/roles";
|
||||
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
|
||||
import DashboardClient from "./DashboardClient";
|
||||
|
||||
@@ -115,9 +115,12 @@ export default async function AdminDashboardPage() {
|
||||
const canMod = canModerateAny(me.user);
|
||||
const canUsers = isSuperOrOwner(me.user?.role);
|
||||
const canEconomy = isAdminOrAbove(me.user?.role);
|
||||
const boardScoped = me.user?.role === ROLES.BOARD_ADMIN;
|
||||
|
||||
const [overview, pendingCounts, analytics] = await Promise.all([
|
||||
fetchOverview(cookie).catch(() => null),
|
||||
boardScoped
|
||||
? Promise.resolve(null)
|
||||
: fetchOverview(cookie).catch(() => null),
|
||||
canMod
|
||||
? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 }))
|
||||
: Promise.resolve(null),
|
||||
@@ -133,7 +136,15 @@ export default async function AdminDashboardPage() {
|
||||
}).format(now);
|
||||
|
||||
const stats = overview?.stats;
|
||||
const newUsers = overview?.new_users ?? [];
|
||||
const newUsers = boardScoped ? [] : overview?.new_users ?? [];
|
||||
|
||||
// 板管:今日内容用授权板 analytics 序列今日点;隐藏全站注册/在线卡的误导数字
|
||||
const todayPosts = boardScoped
|
||||
? analytics.posts_series[analytics.posts_series.length - 1]?.count ?? 0
|
||||
: stats?.today_posts ?? 0;
|
||||
const todayComments = boardScoped
|
||||
? analytics.comments_series[analytics.comments_series.length - 1]?.count ?? 0
|
||||
: stats?.today_comments ?? 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -155,30 +166,34 @@ export default async function AdminDashboardPage() {
|
||||
<AdminStatCard
|
||||
label="今日 PV"
|
||||
value={fmt(analytics.today_pv)}
|
||||
hint={`独立访客 UV ${fmt(analytics.today_uv)}`}
|
||||
hint={boardScoped ? "仅全站可见(板管不展示)" : `独立访客 UV ${fmt(analytics.today_uv)}`}
|
||||
icon={<Eye size={16} />}
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="今日 UV"
|
||||
value={fmt(analytics.today_uv)}
|
||||
hint="独立访客"
|
||||
hint={boardScoped ? "板管仪表盘不含全站 UV" : "独立访客"}
|
||||
icon={<Users size={16} />}
|
||||
color="var(--ok)"
|
||||
soft="var(--ok-soft)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="今日内容"
|
||||
value={fmt(stats?.today_posts ?? 0)}
|
||||
value={fmt(todayPosts)}
|
||||
sub="帖"
|
||||
hint={`评论 ${fmt(stats?.today_comments ?? 0)} · 注册 ${fmt(stats?.today_users ?? 0)}`}
|
||||
hint={
|
||||
boardScoped
|
||||
? `评论 ${fmt(todayComments)} · 授权板块`
|
||||
: `评论 ${fmt(todayComments)} · 注册 ${fmt(stats?.today_users ?? 0)}`
|
||||
}
|
||||
icon={<FileText size={16} />}
|
||||
color="var(--gold)"
|
||||
soft="var(--gold-soft)"
|
||||
/>
|
||||
<AdminStatCard
|
||||
label="在线"
|
||||
value={fmt(stats?.online ?? 0)}
|
||||
hint={`峰值 ${fmt(stats?.peak_online ?? 0)}`}
|
||||
value={boardScoped ? "—" : fmt(stats?.online ?? 0)}
|
||||
hint={boardScoped ? "板管不展示全站在线" : `峰值 ${fmt(stats?.peak_online ?? 0)}`}
|
||||
icon={<Activity size={16} />}
|
||||
color="var(--clay)"
|
||||
soft="var(--clay-soft)"
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
apiDissolveChatRoom,
|
||||
apiUpdateChatRoom,
|
||||
apiKickChatMember,
|
||||
apiMuteChatMember,
|
||||
apiSetChatMemberRole,
|
||||
apiInviteChatMembers,
|
||||
apiRecallChatMessage,
|
||||
type ChatMessage,
|
||||
@@ -44,7 +46,7 @@ import type {
|
||||
RtChatMembershipData,
|
||||
RtChatRecalledData,
|
||||
} from "@/lib/realtime";
|
||||
import { canOverseeChat } from "@/lib/roles";
|
||||
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { scrollBehavior } from "@/lib/motion";
|
||||
import Avatar from "@/components/Avatar";
|
||||
@@ -171,7 +173,7 @@ interface Props {
|
||||
roomDescription: string;
|
||||
ownerId: number;
|
||||
memberCount: number;
|
||||
myRole: "" | "owner" | "member" | "overseer";
|
||||
myRole: "" | "owner" | "admin" | "member" | "overseer";
|
||||
joined: boolean;
|
||||
isPrivate: boolean;
|
||||
isDefault: boolean;
|
||||
@@ -179,6 +181,8 @@ interface Props {
|
||||
peer: { id: number; username: string; nickname: string; avatar: string } | null;
|
||||
meId: number;
|
||||
meRole: string;
|
||||
/** 站点消息管理 flag */
|
||||
canManageMessages?: boolean;
|
||||
meName: string;
|
||||
meAvatar: string;
|
||||
initialMessages: ChatMessage[];
|
||||
@@ -315,6 +319,7 @@ export default function ChatRoomClient({
|
||||
peer,
|
||||
meId,
|
||||
meRole,
|
||||
canManageMessages = false,
|
||||
initialMessages,
|
||||
initialHasMore,
|
||||
embedded = false,
|
||||
@@ -353,11 +358,16 @@ export default function ChatRoomClient({
|
||||
const msgRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
|
||||
const isOwner = myRole === "owner";
|
||||
const isRoomAdmin = myRole === "admin";
|
||||
const isDirect = roomType === "direct";
|
||||
const isRealMember = myRole === "owner" || myRole === "member";
|
||||
const isRealMember = myRole === "owner" || myRole === "admin" || myRole === "member";
|
||||
const canView = joined;
|
||||
const oversee = canOverseeChat(meRole);
|
||||
const canManageInvite = !isDirect && (isOwner || oversee);
|
||||
const siteOwner = isSiteOwner(meRole);
|
||||
const canModerateChat =
|
||||
oversee || isOwner || isRoomAdmin || (!!canManageMessages && !isDirect);
|
||||
const canManageInvite = !isDirect && (isOwner || isRoomAdmin || oversee);
|
||||
const canKickMembers = isOwner || isRoomAdmin || oversee;
|
||||
|
||||
const rememberReEdit = useCallback((id: number, text: string) => {
|
||||
const t = text.trim();
|
||||
@@ -472,7 +482,7 @@ export default function ChatRoomClient({
|
||||
return {
|
||||
...m,
|
||||
// 仅监管界面保留正文;普通成员(含撤回者自己)气泡不展示
|
||||
content: oversee ? m.content : "",
|
||||
content: canModerateChat ? m.content : "",
|
||||
recalled_at: data.recalled_at || new Date().toISOString(),
|
||||
recalled_by: data.recalled_by,
|
||||
sender_id: data.sender_id || m.sender_id,
|
||||
@@ -490,7 +500,7 @@ export default function ChatRoomClient({
|
||||
);
|
||||
});
|
||||
return off;
|
||||
}, [roomId, oversee, meId, rememberReEdit]);
|
||||
}, [roomId, canModerateChat, meId, rememberReEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
const off = realtime.on<RtChatMembershipData>(
|
||||
@@ -820,6 +830,7 @@ export default function ChatRoomClient({
|
||||
};
|
||||
|
||||
const handleKick = (uid: number, nickname: string) => {
|
||||
if (!canKickMembers) return;
|
||||
setConfirm({
|
||||
title: "移出成员",
|
||||
message: `确定将 ${nickname} 移出该群?`,
|
||||
@@ -834,9 +845,49 @@ export default function ChatRoomClient({
|
||||
});
|
||||
};
|
||||
|
||||
const handleMute = (uid: number, nickname: string, muted: boolean) => {
|
||||
if (!canKickMembers) return;
|
||||
setConfirm({
|
||||
title: muted ? "禁言成员" : "解除禁言",
|
||||
message: muted
|
||||
? `确定禁言 ${nickname}?禁言后无法在本群发言。`
|
||||
: `确定解除 ${nickname} 的禁言?`,
|
||||
confirmLabel: muted ? "禁言" : "解禁",
|
||||
danger: muted,
|
||||
action: async () => {
|
||||
await apiMuteChatMember(roomId, uid, muted);
|
||||
setMembers((prev) =>
|
||||
prev.map((m) => (m.user_id === uid ? { ...m, muted } : m))
|
||||
);
|
||||
toast(muted ? "已禁言" : "已解禁", "ok");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetAdmin = (uid: number, nickname: string, makeAdmin: boolean) => {
|
||||
if (!siteOwner) return;
|
||||
setConfirm({
|
||||
title: makeAdmin ? "设为群管理员" : "取消群管理员",
|
||||
message: makeAdmin
|
||||
? `确定将 ${nickname} 设为本群管理员?其可撤回消息、踢人与禁言。`
|
||||
: `确定取消 ${nickname} 的群管理员身份?`,
|
||||
confirmLabel: makeAdmin ? "任命" : "撤销",
|
||||
danger: !makeAdmin,
|
||||
action: async () => {
|
||||
await apiSetChatMemberRole(roomId, uid, makeAdmin ? "admin" : "member");
|
||||
setMembers((prev) =>
|
||||
prev.map((m) =>
|
||||
m.user_id === uid ? { ...m, role: makeAdmin ? "admin" : "member" } : m
|
||||
)
|
||||
);
|
||||
toast(makeAdmin ? "已设为群管理员" : "已取消群管理员", "ok");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const canRecallMsg = (m: ChatMessage) => {
|
||||
if (m.recalled_at) return false;
|
||||
if (oversee) return true;
|
||||
if (canModerateChat) return true;
|
||||
return m.sender_id === meId && isRealMember;
|
||||
};
|
||||
|
||||
@@ -858,7 +909,7 @@ export default function ChatRoomClient({
|
||||
m.id === mid
|
||||
? {
|
||||
...m,
|
||||
content: oversee ? body || m.content : "",
|
||||
content: canModerateChat ? body || m.content : "",
|
||||
recalled_at: message.recalled_at || new Date().toISOString(),
|
||||
recalled_by: message.recalled_by,
|
||||
recaller: message.recaller || m.recaller,
|
||||
@@ -1119,7 +1170,7 @@ export default function ChatRoomClient({
|
||||
const reEditText = mine ? takeReEditDraft(m.id) : null;
|
||||
|
||||
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
|
||||
if (recalled && !(oversee && m.content)) {
|
||||
if (recalled && !(canModerateChat && m.content)) {
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
@@ -1160,7 +1211,7 @@ export default function ChatRoomClient({
|
||||
}
|
||||
|
||||
// 站长/超管:单气泡呈现撤回原文(可读,非双气泡)
|
||||
if (recalled && oversee && m.content) {
|
||||
if (recalled && canModerateChat && m.content) {
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
@@ -1529,9 +1580,13 @@ export default function ChatRoomClient({
|
||||
<MembersPanel
|
||||
members={members}
|
||||
meId={meId}
|
||||
isOwner={isOwner}
|
||||
canKick={canKickMembers}
|
||||
isChatOwner={isOwner}
|
||||
siteOwner={siteOwner}
|
||||
onRefresh={refreshMembers}
|
||||
onKick={handleKick}
|
||||
onMute={handleMute}
|
||||
onSetAdmin={handleSetAdmin}
|
||||
/>
|
||||
)}
|
||||
{rightPanel === "invite" && (
|
||||
@@ -1566,9 +1621,13 @@ export default function ChatRoomClient({
|
||||
<MembersPanel
|
||||
members={members}
|
||||
meId={meId}
|
||||
isOwner={isOwner}
|
||||
canKick={canKickMembers}
|
||||
isChatOwner={isOwner}
|
||||
siteOwner={siteOwner}
|
||||
onRefresh={refreshMembers}
|
||||
onKick={handleKick}
|
||||
onMute={handleMute}
|
||||
onSetAdmin={handleSetAdmin}
|
||||
showTitle
|
||||
/>
|
||||
</aside>
|
||||
@@ -1634,16 +1693,24 @@ function JoinButton({
|
||||
function MembersPanel({
|
||||
members,
|
||||
meId,
|
||||
isOwner,
|
||||
canKick,
|
||||
isChatOwner,
|
||||
siteOwner,
|
||||
onRefresh,
|
||||
onKick,
|
||||
onMute,
|
||||
onSetAdmin,
|
||||
showTitle = false,
|
||||
}: {
|
||||
members: ChatRoomMember[];
|
||||
meId: number;
|
||||
isOwner: boolean;
|
||||
canKick: boolean;
|
||||
isChatOwner: boolean;
|
||||
siteOwner: boolean;
|
||||
onRefresh: () => void;
|
||||
onKick: (uid: number, nickname: string) => void;
|
||||
onMute: (uid: number, nickname: string, muted: boolean) => void;
|
||||
onSetAdmin: (uid: number, nickname: string, makeAdmin: boolean) => void;
|
||||
showTitle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -1675,38 +1742,74 @@ function MembersPanel({
|
||||
</div>
|
||||
)}
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{members.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex items-center gap-2.5 px-2 py-2 rounded-lg group"
|
||||
>
|
||||
<Avatar
|
||||
name={m.user.nickname || m.user.username}
|
||||
src={m.user.avatar || undefined}
|
||||
size={28}
|
||||
/>
|
||||
<Link
|
||||
href={`/u/${m.user.id}`}
|
||||
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
{members.map((m) => {
|
||||
const name = m.user.nickname || m.user.username;
|
||||
const isTargetOwner = m.role === "owner";
|
||||
const isTargetAdmin = m.role === "admin";
|
||||
const canActOn =
|
||||
canKick &&
|
||||
m.user_id !== meId &&
|
||||
!isTargetOwner &&
|
||||
(isChatOwner || !isTargetAdmin);
|
||||
return (
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex items-center gap-2.5 px-2 py-2 rounded-lg group"
|
||||
>
|
||||
{m.user.nickname}
|
||||
</Link>
|
||||
{m.role === "owner" ? (
|
||||
<Crown size={12} style={{ color: "var(--gold)" }} />
|
||||
) : null}
|
||||
{isOwner && m.user_id !== meId && (
|
||||
<button
|
||||
onClick={() => onKick(m.user_id, m.user.nickname)}
|
||||
aria-label={`移出 ${m.user.nickname}`}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--danger-soft)]"
|
||||
style={{ color: "var(--danger)" }}
|
||||
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
|
||||
<Link
|
||||
href={`/u/${m.user.id}`}
|
||||
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
<UserX size={12} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{name}
|
||||
{m.muted ? (
|
||||
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
|
||||
· 禁言
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
{isTargetOwner ? (
|
||||
<Crown size={12} style={{ color: "var(--gold)" }} aria-label="群主" />
|
||||
) : isTargetAdmin ? (
|
||||
<span className="text-[10px] font-medium" style={{ color: "var(--accent)" }}>
|
||||
管
|
||||
</span>
|
||||
) : null}
|
||||
{siteOwner && !isTargetOwner && m.user_id !== meId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetAdmin(m.user_id, name, !isTargetAdmin)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-[11px] px-1.5 py-0.5 rounded"
|
||||
style={{ color: "var(--accent)", background: "var(--accent-soft)" }}
|
||||
>
|
||||
{isTargetAdmin ? "取消管" : "设管"}
|
||||
</button>
|
||||
)}
|
||||
{canActOn && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMute(m.user_id, name, !m.muted)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-[11px] px-1"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
{m.muted ? "解禁" : "禁言"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onKick(m.user_id, name)}
|
||||
aria-label={`移出 ${name}`}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--danger-soft)]"
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
<UserX size={12} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -88,6 +88,7 @@ export default async function ChatRoomPage({ params }: PageProps) {
|
||||
peer={room.peer || null}
|
||||
meId={me.user.id}
|
||||
meRole={me.user.role}
|
||||
canManageMessages={!!me.user.can_manage_messages}
|
||||
meName={me.user.nickname || me.user.username}
|
||||
meAvatar={me.user.avatar || ""}
|
||||
initialMessages={initialMessages.messages}
|
||||
|
||||
@@ -684,6 +684,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
<PostActions
|
||||
postId={id}
|
||||
authorId={post.user.id}
|
||||
authorRole={post.user.role}
|
||||
boardId={post.board_id}
|
||||
pinned={post.pinned}
|
||||
recommended={post.recommended}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
type CommentNode,
|
||||
type User,
|
||||
} from "@/lib/api";
|
||||
import { canModerateBoard } from "@/lib/roles";
|
||||
import { canModerateBoard, canStaffSoftDelete, canPurgeContent } from "@/lib/roles";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { useAllowComments } from "@/components/CommentsPolicyProvider";
|
||||
import CommentsUnsupported from "@/components/CommentsUnsupported";
|
||||
@@ -334,19 +334,25 @@ export default function CommentSection({
|
||||
const canSoftDelete = (comment: CommentNode) =>
|
||||
!comment.deleted &&
|
||||
!!user &&
|
||||
(user.id === comment.user?.id || canModerateBoard(user, boardId));
|
||||
(user.id === comment.user?.id ||
|
||||
canStaffSoftDelete(user, boardId, comment.user?.role));
|
||||
|
||||
const canEdit = (comment: CommentNode) => canSoftDelete(comment);
|
||||
|
||||
const canViewHistory = !!user && canModerateBoard(user, boardId);
|
||||
|
||||
const canPurge = (comment: CommentNode) =>
|
||||
!!user && canModerateBoard(user, boardId);
|
||||
!!user && canPurgeContent(user.role) && canModerateBoard(user, boardId);
|
||||
|
||||
/** 作者可恢复自删;版主可恢复本板任意软删(含管理删除) */
|
||||
/** 作者可恢复自删;版主可恢复本板任意软删(含管理删除),板管受作者角色保护 */
|
||||
const canRestore = (comment: CommentNode) => {
|
||||
if (!comment.deleted || !user) return false;
|
||||
if (canModerateBoard(user, boardId)) return true;
|
||||
if (
|
||||
canModerateBoard(user, boardId) &&
|
||||
canStaffSoftDelete(user, boardId, comment.user?.role)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return user.id === (comment.user?.id ?? comment.user_id) && !comment.staff_deleted;
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "@/components/Modal";
|
||||
import { POST_DELETE_TYPES } from "@/lib/api";
|
||||
import StaffDeleteFields from "@/components/StaffDeleteFields";
|
||||
|
||||
export type DeleteCommentPayload = {
|
||||
delete_type: string;
|
||||
@@ -12,7 +12,7 @@ export type DeleteCommentPayload = {
|
||||
/**
|
||||
* 评论软删弹窗:
|
||||
* - 评论者自删:必填理由(仅管理可见)
|
||||
* - 管理删:必选类型;仅「其他」须填理由
|
||||
* - 管理删:与删帖共用类型表单;仅「其他」须填理由
|
||||
*/
|
||||
export default function DeleteCommentModal({
|
||||
open,
|
||||
@@ -89,55 +89,31 @@ export default function DeleteCommentModal({
|
||||
}
|
||||
>
|
||||
{isStaff ? (
|
||||
<>
|
||||
<p className="mb-2 text-[12.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
删除类型
|
||||
</p>
|
||||
<div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 mb-1"
|
||||
role="radiogroup"
|
||||
aria-label="删除类型"
|
||||
>
|
||||
{POST_DELETE_TYPES.map((t) => {
|
||||
const active = deleteType === t.value;
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => {
|
||||
setDeleteType(t.value);
|
||||
setErr("");
|
||||
if (t.value !== "other") setReason("");
|
||||
}}
|
||||
className="text-left rounded-lg px-3 py-2.5 text-[13px] transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
|
||||
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
color: active ? "var(--accent)" : "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="meta text-[11.5px] mb-4 leading-relaxed">
|
||||
选「其他」时需补充理由;其余类型直接按标签展示给读者。
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{reasonRequired ? (
|
||||
<StaffDeleteFields
|
||||
deleteType={deleteType}
|
||||
onTypeChange={(value) => {
|
||||
setDeleteType(value);
|
||||
setErr("");
|
||||
if (value !== "other") setReason("");
|
||||
}}
|
||||
reason={reason}
|
||||
onReasonChange={(value) => {
|
||||
setReason(value);
|
||||
setErr("");
|
||||
}}
|
||||
reasonId="cmt-delete-reason"
|
||||
reasonPlaceholder="向读者说明删除原因(将展示在占位中)"
|
||||
typeHint="选「其他」时需补充理由;其余类型直接按标签展示给读者。"
|
||||
reasonHint="将公开显示在评论占位旁。"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<label
|
||||
className="block mb-1.5 text-[12.5px] font-medium"
|
||||
style={{ color: "var(--ink)" }}
|
||||
htmlFor="cmt-delete-reason"
|
||||
>
|
||||
{isStaff ? "补充理由" : "删除理由"}{" "}
|
||||
<span style={{ color: "var(--danger)" }}>*</span>
|
||||
删除理由 <span style={{ color: "var(--danger)" }}>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="cmt-delete-reason"
|
||||
@@ -148,19 +124,13 @@ export default function DeleteCommentModal({
|
||||
setReason(e.target.value);
|
||||
setErr("");
|
||||
}}
|
||||
placeholder={
|
||||
isStaff
|
||||
? "向读者说明删除原因(将展示在占位中)"
|
||||
: "例如:发错了 / 不想保留(仅管理员可见)"
|
||||
}
|
||||
placeholder="例如:发错了 / 不想保留(仅管理员可见)"
|
||||
maxLength={200}
|
||||
autoFocus={!isStaff}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="meta text-[11.5px] mt-1.5">
|
||||
{isStaff ? "将公开显示在评论占位旁。" : "普通读者看不到这条理由。"}
|
||||
</p>
|
||||
<p className="meta text-[11.5px] mt-1.5">普通读者看不到这条理由。</p>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{err ? (
|
||||
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
|
||||
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
|
||||
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
||||
import type { RtChatUnreadData } from "@/lib/realtime";
|
||||
@@ -396,7 +396,7 @@ export default function Header({
|
||||
</MobileLink>
|
||||
)}
|
||||
{user && <MobileLink href="/settings">账号设置</MobileLink>}
|
||||
{user && isStaff(user.role) && (
|
||||
{user && (isStaff(user.role) || canAccessAdminMessages(user)) && (
|
||||
<MobileLink href="/admin">
|
||||
<span className="inline-flex items-center gap-2"><ShieldCheck size={15} /> 管理面板</span>
|
||||
</MobileLink>
|
||||
@@ -560,7 +560,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
|
||||
</Link>
|
||||
</div>
|
||||
{isStaff(user.role) && (
|
||||
{(isStaff(user.role) || canAccessAdminMessages(user)) && (
|
||||
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<Link href="/admin" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<ShieldCheck size={15} style={{ color: "var(--accent)" }} /> 管理面板
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Pencil, Trash2, Pin, Star, SlidersHorizontal, ChevronDown, Check, BadgeCheck } from "lucide-react";
|
||||
import { apiMe, apiDeletePost, apiTogglePin, apiToggleRecommend, apiAdminApprovePost, type User } from "@/lib/api";
|
||||
import { canModerateBoard, isAdminOrAbove } from "@/lib/roles";
|
||||
import { canStaffSoftDelete, canModerateBoard, isAdminOrAbove } from "@/lib/roles";
|
||||
import { toast } from "@/lib/toast";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import StaffDeletePostModal from "@/components/StaffDeletePostModal";
|
||||
@@ -13,6 +13,7 @@ import StaffDeletePostModal from "@/components/StaffDeletePostModal";
|
||||
interface PostActionsProps {
|
||||
postId: string;
|
||||
authorId: number;
|
||||
authorRole?: string | null;
|
||||
boardId: number;
|
||||
pinned: number;
|
||||
recommended: boolean;
|
||||
@@ -25,6 +26,7 @@ interface PostActionsProps {
|
||||
export default function PostActions({
|
||||
postId,
|
||||
authorId,
|
||||
authorRole,
|
||||
boardId,
|
||||
pinned,
|
||||
recommended,
|
||||
@@ -64,12 +66,15 @@ export default function PostActions({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const canManage = !!user && (user.id === authorId || canModerateBoard(user, boardId));
|
||||
const canManage =
|
||||
!!user &&
|
||||
(user.id === authorId || canStaffSoftDelete(user, boardId, authorRole));
|
||||
const isAdmin = !!user && isAdminOrAbove(user.role);
|
||||
const canApprove = !!user && status === "pending" && canModerateBoard(user, boardId);
|
||||
const canApprove =
|
||||
!!user && status === "pending" && canModerateBoard(user, boardId);
|
||||
// staff 删他人帖:走类型+理由;作者自删:简单确认
|
||||
const staffDeletingOther =
|
||||
!!user && user.id !== authorId && canModerateBoard(user, boardId);
|
||||
!!user && user.id !== authorId && canStaffSoftDelete(user, boardId, authorRole);
|
||||
|
||||
const runDelete = async (opts?: { delete_type?: string; delete_reason?: string }) => {
|
||||
setDeleting(true);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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 { apiAdminSetUserBan, apiAdminSetUserRole, apiAdminSetUserMessages, type Board } from "@/lib/api";
|
||||
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { toast } from "@/lib/toast";
|
||||
@@ -19,6 +19,7 @@ export interface RoleAccountTarget {
|
||||
role: string;
|
||||
board_ids?: number[];
|
||||
banned: boolean;
|
||||
can_manage_messages?: boolean;
|
||||
avatar?: string;
|
||||
email?: string;
|
||||
points?: number;
|
||||
@@ -75,6 +76,7 @@ export default function RoleAccountModal({
|
||||
const [role, setRole] = useState<string>(ROLES.USER);
|
||||
const [boardIds, setBoardIds] = useState<number[]>([]);
|
||||
const [banned, setBanned] = useState(false);
|
||||
const [manageMessages, setManageMessages] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,6 +84,7 @@ export default function RoleAccountModal({
|
||||
setRole(target.role === ROLES.OWNER ? ROLES.USER : target.role);
|
||||
setBoardIds(target.board_ids ?? []);
|
||||
setBanned(!!target.banned);
|
||||
setManageMessages(!!target.can_manage_messages);
|
||||
setErr("");
|
||||
}
|
||||
}, [target]);
|
||||
@@ -97,6 +100,11 @@ export default function RoleAccountModal({
|
||||
JSON.stringify([...boardIds].sort((a, b) => a - b));
|
||||
const roleChanged = role !== target.role || (role === ROLES.BOARD_ADMIN && !sameBoards);
|
||||
const banChanged = banned !== !!target.banned;
|
||||
const messagesChanged =
|
||||
canGrantSuper &&
|
||||
target.role !== ROLES.OWNER &&
|
||||
target.role !== ROLES.SUPER_ADMIN &&
|
||||
manageMessages !== !!target.can_manage_messages;
|
||||
const willBan = banChanged && banned;
|
||||
const willUnban = banChanged && !banned;
|
||||
|
||||
@@ -105,7 +113,7 @@ export default function RoleAccountModal({
|
||||
setErr("请至少选择一个授权板块");
|
||||
return;
|
||||
}
|
||||
if (!roleChanged && !banChanged) {
|
||||
if (!roleChanged && !banChanged && !messagesChanged) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
@@ -114,6 +122,7 @@ export default function RoleAccountModal({
|
||||
let nextRole = target.role;
|
||||
let nextBoards = target.board_ids ?? [];
|
||||
let nextBanned = !!target.banned;
|
||||
let nextMessages = !!target.can_manage_messages;
|
||||
if (roleChanged) {
|
||||
const res = await apiAdminSetUserRole(
|
||||
target.id,
|
||||
@@ -127,19 +136,26 @@ export default function RoleAccountModal({
|
||||
const res = await apiAdminSetUserBan(target.id, banned);
|
||||
nextBanned = !!res.user.banned;
|
||||
}
|
||||
if (messagesChanged) {
|
||||
const res = await apiAdminSetUserMessages(target.id, manageMessages);
|
||||
nextMessages = !!res.user.can_manage_messages;
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (roleChanged) parts.push("角色已更新");
|
||||
if (willBan) parts.push("已封禁");
|
||||
if (willUnban) parts.push("已解封");
|
||||
if (messagesChanged) parts.push(manageMessages ? "已授予消息管理" : "已撤销消息管理");
|
||||
toast(parts.join(" · ") || "已保存", "ok");
|
||||
onSaved({
|
||||
...target,
|
||||
role: nextRole,
|
||||
board_ids: nextBoards,
|
||||
banned: nextBanned,
|
||||
can_manage_messages: nextMessages,
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "操作失败");
|
||||
setErr(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setActing(false);
|
||||
}
|
||||
@@ -279,6 +295,49 @@ export default function RoleAccountModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canGrantSuper &&
|
||||
target.role !== ROLES.OWNER &&
|
||||
target.role !== ROLES.SUPER_ADMIN && (
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={manageMessages}
|
||||
onClick={() => setManageMessages((v) => !v)}
|
||||
className="w-full rounded-xl px-3.5 py-3 text-left transition-colors flex items-center justify-between gap-3"
|
||||
style={{
|
||||
border: `1.5px solid ${manageMessages ? "var(--accent)" : "var(--line)"}`,
|
||||
background: manageMessages ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
className="block text-[13.5px] font-medium"
|
||||
style={{ color: manageMessages ? "var(--accent)" : "var(--ink)" }}
|
||||
>
|
||||
站点群消息管理
|
||||
</span>
|
||||
<span className="block mt-1 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
|
||||
可在后台管理全部群聊消息(不含私聊);站长/超管固有全站监管无需此项。
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className="shrink-0 w-10 h-6 rounded-full relative transition-colors"
|
||||
style={{ background: manageMessages ? "var(--accent)" : "var(--line)" }}
|
||||
aria-hidden
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
|
||||
style={{ left: manageMessages ? "1.125rem" : "0.125rem" }}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</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)" }}>
|
||||
账号状态
|
||||
|
||||
82
frontend/components/StaffDeleteFields.tsx
Normal file
82
frontend/components/StaffDeleteFields.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { POST_DELETE_TYPES } from "@/lib/api";
|
||||
|
||||
/** 管理软删共用:类型单选 + 仅「其他」时的补充理由 */
|
||||
export default function StaffDeleteFields({
|
||||
deleteType,
|
||||
onTypeChange,
|
||||
reason,
|
||||
onReasonChange,
|
||||
reasonId,
|
||||
reasonPlaceholder,
|
||||
typeHint,
|
||||
reasonHint,
|
||||
}: {
|
||||
deleteType: string;
|
||||
onTypeChange: (value: string) => void;
|
||||
reason: string;
|
||||
onReasonChange: (value: string) => void;
|
||||
reasonId: string;
|
||||
reasonPlaceholder: string;
|
||||
typeHint: string;
|
||||
reasonHint: string;
|
||||
}) {
|
||||
const reasonRequired = deleteType === "other";
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2 text-[12.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
删除类型
|
||||
</p>
|
||||
<div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 gap-1.5 mb-1"
|
||||
role="radiogroup"
|
||||
aria-label="删除类型"
|
||||
>
|
||||
{POST_DELETE_TYPES.map((t) => {
|
||||
const active = deleteType === t.value;
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => onTypeChange(t.value)}
|
||||
className="text-left rounded-lg px-3 py-2.5 text-[13px] transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
|
||||
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
color: active ? "var(--accent)" : "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="meta text-[11.5px] mb-4 leading-relaxed">{typeHint}</p>
|
||||
{reasonRequired ? (
|
||||
<>
|
||||
<label
|
||||
className="block mb-1.5 text-[12.5px] font-medium"
|
||||
style={{ color: "var(--ink)" }}
|
||||
htmlFor={reasonId}
|
||||
>
|
||||
补充理由 <span style={{ color: "var(--danger)" }}>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id={reasonId}
|
||||
className="field"
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(e) => onReasonChange(e.target.value)}
|
||||
placeholder={reasonPlaceholder}
|
||||
maxLength={200}
|
||||
/>
|
||||
<p className="meta text-[11.5px] mt-1.5">{reasonHint}</p>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "@/components/Modal";
|
||||
import { POST_DELETE_TYPES } from "@/lib/api";
|
||||
import StaffDeleteFields from "@/components/StaffDeleteFields";
|
||||
|
||||
/**
|
||||
* Staff 软删帖:必填删除类型 + 理由。
|
||||
* Staff 软删帖:必选删除类型;仅「其他」须填理由。
|
||||
* 作者自删仍用 ConfirmDialog,不走此组件。
|
||||
*/
|
||||
export default function StaffDeletePostModal({
|
||||
@@ -36,17 +36,21 @@ export default function StaffDeletePostModal({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const reasonRequired = deleteType === "other";
|
||||
|
||||
const submit = () => {
|
||||
const r = reason.trim();
|
||||
if (!r) {
|
||||
setErr("请填写删除理由");
|
||||
return;
|
||||
if (reasonRequired) {
|
||||
if (!r) {
|
||||
setErr("请填写删除理由");
|
||||
return;
|
||||
}
|
||||
if ([...r].length > 200) {
|
||||
setErr("删除理由不能超过 200 字");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ([...r].length > 200) {
|
||||
setErr("删除理由不能超过 200 字");
|
||||
return;
|
||||
}
|
||||
onConfirm({ delete_type: deleteType, delete_reason: r });
|
||||
onConfirm({ delete_type: deleteType, delete_reason: reasonRequired ? r : "" });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -55,8 +59,8 @@ export default function StaffDeletePostModal({
|
||||
title="删除帖子"
|
||||
description={
|
||||
title
|
||||
? `将删除「${title}」。列表不再展示;原链接会显示删除类型与理由。`
|
||||
: "列表不再展示;原链接会显示删除类型与理由。"
|
||||
? `将删除「${title}」。列表不再展示;原链接会显示删除类型,选「其他」时还会展示补充理由。`
|
||||
: "列表不再展示;原链接会显示删除类型,选「其他」时还会展示补充理由。"
|
||||
}
|
||||
onClose={onClose}
|
||||
busy={busy}
|
||||
@@ -74,51 +78,28 @@ export default function StaffDeletePostModal({
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="mb-2 text-[12.5px]" style={{ color: "var(--ink)" }}>
|
||||
删除类型
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5 mb-4" role="radiogroup" aria-label="删除类型">
|
||||
{POST_DELETE_TYPES.map((t) => {
|
||||
const active = deleteType === t.value;
|
||||
return (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => setDeleteType(t.value)}
|
||||
className="text-left rounded-lg px-3 py-2 text-[13px] transition-colors"
|
||||
style={{
|
||||
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
|
||||
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
color: active ? "var(--accent)" : "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="block mb-1.5 text-[12.5px]" style={{ color: "var(--ink)" }} htmlFor="delete-reason">
|
||||
删除理由 <span style={{ color: "var(--danger)" }}>*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="delete-reason"
|
||||
className="field"
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(e) => {
|
||||
setReason(e.target.value);
|
||||
<StaffDeleteFields
|
||||
deleteType={deleteType}
|
||||
onTypeChange={(value) => {
|
||||
setDeleteType(value);
|
||||
setErr("");
|
||||
if (value !== "other") setReason("");
|
||||
}}
|
||||
reason={reason}
|
||||
onReasonChange={(value) => {
|
||||
setReason(value);
|
||||
setErr("");
|
||||
}}
|
||||
placeholder="将展示在帖子专用页与作者通知中"
|
||||
maxLength={200}
|
||||
reasonId="post-delete-reason"
|
||||
reasonPlaceholder="向读者说明删除原因(将展示在帖子页)"
|
||||
typeHint="选「其他」时需补充理由;其余类型直接按标签展示给读者。"
|
||||
reasonHint="将公开显示在帖子专用页与作者通知中。"
|
||||
/>
|
||||
{err && (
|
||||
{err ? (
|
||||
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||
import {
|
||||
canModerateAny,
|
||||
canAccessAdminMessages,
|
||||
isAdminOrAbove,
|
||||
isStaff,
|
||||
isSuperOrOwner,
|
||||
@@ -76,7 +77,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
href: "/admin/messages",
|
||||
label: "消息管理",
|
||||
icon: MessageCircle,
|
||||
visible: (u) => isStaff(u.role),
|
||||
visible: (u) => canAccessAdminMessages(u),
|
||||
},
|
||||
{
|
||||
href: "/admin/announcements",
|
||||
|
||||
@@ -44,6 +44,10 @@ export interface User {
|
||||
role: string;
|
||||
// 板块管理员被授权的板块(仅 /api/me 返回;前端据此渲染审核入口/按钮)
|
||||
board_ids?: number[];
|
||||
// 站点消息管理(站长授予;站长/超管固有监管不必依赖此字段)
|
||||
can_manage_messages?: boolean;
|
||||
// 作为群管理员的群数量(/me;用于后台消息入口)
|
||||
chat_admin_room_count?: number;
|
||||
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
|
||||
email?: string;
|
||||
signature?: string;
|
||||
@@ -1900,6 +1904,7 @@ export interface AdminUser {
|
||||
role: string; // user | board_admin | admin | super_admin | owner
|
||||
board_ids: number[]; // 板块管理员的授权板块
|
||||
banned: boolean;
|
||||
can_manage_messages?: boolean;
|
||||
points?: number;
|
||||
post_count: number;
|
||||
comment_count: number;
|
||||
@@ -2026,6 +2031,21 @@ export async function apiAdminSetUserRole(
|
||||
return data as { user: AdminUser };
|
||||
}
|
||||
|
||||
// 站长授予/撤销站点消息管理权限
|
||||
export async function apiAdminSetUserMessages(
|
||||
id: number,
|
||||
canManageMessages: boolean
|
||||
): Promise<{ user: AdminUser }> {
|
||||
const res = await fetchWithRefresh(`/api/admin/users/${id}/messages`, {
|
||||
method: "PUT",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ can_manage_messages: canManageMessages }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "消息权限更新失败");
|
||||
return data as { user: AdminUser };
|
||||
}
|
||||
|
||||
// 某用户的登录历史(IP/UA/成败)
|
||||
export async function apiAdminUserLoginLogs(
|
||||
id: number,
|
||||
@@ -2546,7 +2566,7 @@ export interface ChatRoom {
|
||||
// 列表/详情聚合视图:扁平房间 + 当前访问者相关字段
|
||||
export interface ChatRoomView extends ChatRoom {
|
||||
joined: boolean;
|
||||
my_role: "" | "owner" | "member" | "overseer";
|
||||
my_role: "" | "owner" | "admin" | "member" | "overseer";
|
||||
unread_count: number;
|
||||
pinned?: boolean;
|
||||
pin_forced?: boolean;
|
||||
@@ -2558,7 +2578,7 @@ export interface ChatRoomMember {
|
||||
id: number;
|
||||
room_id: number;
|
||||
user_id: number;
|
||||
role: "owner" | "member";
|
||||
role: "owner" | "admin" | "member";
|
||||
last_read_message_id: number;
|
||||
muted: boolean;
|
||||
created_at: string;
|
||||
@@ -2824,7 +2844,7 @@ export async function apiListChatMembers(
|
||||
return data as ChatMembersResponse;
|
||||
}
|
||||
|
||||
// 客户端:群主踢人
|
||||
// 客户端:群主/群管踢人
|
||||
export async function apiKickChatMember(
|
||||
roomId: number,
|
||||
userId: number
|
||||
@@ -2838,7 +2858,45 @@ export async function apiKickChatMember(
|
||||
return data as { ok: boolean };
|
||||
}
|
||||
|
||||
// 客户端:邀请成员(用户名列表,群主/监管)
|
||||
// 客户端:禁言/解禁
|
||||
export async function apiMuteChatMember(
|
||||
roomId: number,
|
||||
userId: number,
|
||||
muted: boolean
|
||||
): Promise<{ ok: boolean; muted: boolean }> {
|
||||
const res = await fetchWithRefresh(
|
||||
`/api/chat/rooms/${roomId}/members/${userId}/mute`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ muted }),
|
||||
}
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "禁言操作失败");
|
||||
return data as { ok: boolean; muted: boolean };
|
||||
}
|
||||
|
||||
// 客户端:站长任命/撤销群管理员
|
||||
export async function apiSetChatMemberRole(
|
||||
roomId: number,
|
||||
userId: number,
|
||||
role: "admin" | "member"
|
||||
): Promise<{ ok: boolean; role: string }> {
|
||||
const res = await fetchWithRefresh(
|
||||
`/api/chat/rooms/${roomId}/members/${userId}/role`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ role }),
|
||||
}
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "设置群角色失败");
|
||||
return data as { ok: boolean; role: string };
|
||||
}
|
||||
|
||||
// 客户端:邀请成员(用户名列表,群主/群管/监管)
|
||||
export async function apiInviteChatMembers(
|
||||
roomId: number,
|
||||
usernames: string[]
|
||||
|
||||
@@ -44,15 +44,23 @@ export function isOwner(role?: string | null): boolean {
|
||||
return role === ROLES.OWNER;
|
||||
}
|
||||
|
||||
/** 站长/超管:群聊监管旁路(可见全部群、撤回任意消息) */
|
||||
/** 站长/超管:群聊监管旁路(可见全部群含私聊、撤回任意消息) */
|
||||
export function canOverseeChat(role?: string | null): boolean {
|
||||
return role === ROLES.OWNER || role === ROLES.SUPER_ADMIN;
|
||||
}
|
||||
|
||||
/** 携带板块授权的最小用户形状(/me、帖子详情作者等均满足) */
|
||||
/** 彻底删除帖/评:仅站长 */
|
||||
export function canPurgeContent(role?: string | null): boolean {
|
||||
return role === ROLES.OWNER;
|
||||
}
|
||||
|
||||
/** 携带板块授权与消息权限的最小用户形状 */
|
||||
export interface RoleUserLike {
|
||||
role?: string | null;
|
||||
board_ids?: number[] | null;
|
||||
can_manage_messages?: boolean | null;
|
||||
/** 是否至少管理一个群(/me 可带;后台入口用) */
|
||||
chat_admin_room_count?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +77,29 @@ export function canModerateBoard(
|
||||
return (user.board_ids ?? []).includes(boardId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 板管不可软删管理员及以上作者的内容;作者本人不受此限(由调用方先判断自删)
|
||||
*/
|
||||
export function canModerateAuthor(
|
||||
actor: RoleUserLike | null | undefined,
|
||||
authorRole?: string | null
|
||||
): boolean {
|
||||
if (!actor) return false;
|
||||
if (actor.role === ROLES.BOARD_ADMIN) {
|
||||
return roleLevel(authorRole) < ROLE_LEVEL[ROLES.ADMIN];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 是否可软删他人内容(板块范围 + 作者角色保护) */
|
||||
export function canStaffSoftDelete(
|
||||
actor: RoleUserLike | null | undefined,
|
||||
boardId: number | null | undefined,
|
||||
authorRole?: string | null
|
||||
): boolean {
|
||||
return canModerateBoard(actor, boardId) && canModerateAuthor(actor, authorRole);
|
||||
}
|
||||
|
||||
/** 是否拥有任意板块的内容审核权(后台「内容管理」入口) */
|
||||
export function canModerateAny(user: RoleUserLike | null | undefined): boolean {
|
||||
if (!user) return false;
|
||||
@@ -76,6 +107,20 @@ export function canModerateAny(user: RoleUserLike | null | undefined): boolean {
|
||||
return user.role === ROLES.BOARD_ADMIN && (user.board_ids?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** 站点级消息管理(站长/超管固有,或 can_manage_messages) */
|
||||
export function hasSiteMessagePerm(user: RoleUserLike | null | undefined): boolean {
|
||||
if (!user) return false;
|
||||
if (canOverseeChat(user.role)) return true;
|
||||
return !!user.can_manage_messages;
|
||||
}
|
||||
|
||||
/** 后台「消息管理」入口:站点消息权或至少是一个群的管理员 */
|
||||
export function canAccessAdminMessages(user: RoleUserLike | null | undefined): boolean {
|
||||
if (!user) return false;
|
||||
if (hasSiteMessagePerm(user)) return true;
|
||||
return (user.chat_admin_room_count ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** 角色图标 key(RoleBadge 负责映射到具体图标,集中配置便于替换) */
|
||||
export type RoleIconKey = "crown" | "shield-star" | "shield" | "shield-board" | "user";
|
||||
|
||||
@@ -102,7 +147,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
||||
colorToken: "--gold",
|
||||
softToken: "--gold-soft",
|
||||
grantDesc:
|
||||
"站点所有者:全部后台能力、群聊监管、用户内容档案;身份不可授予或降级。",
|
||||
"站点所有者:全部后台能力、群聊监管、用户内容档案、彻底删除内容;身份不可授予或降级。",
|
||||
},
|
||||
[ROLES.SUPER_ADMIN]: {
|
||||
key: ROLES.SUPER_ADMIN,
|
||||
@@ -111,7 +156,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
||||
colorToken: "--accent",
|
||||
softToken: "--accent-soft",
|
||||
grantDesc:
|
||||
"用户与角色管理、封禁、外观主题、公告;全站内容审核与置顶/加精;群聊监管(可见全部会话、可撤回任意消息)。不可动站长账号,亦无站长专属的用户内容档案。",
|
||||
"用户与角色管理、封禁、外观主题、公告;全站内容软删与置顶/加精;群聊监管(可见全部会话、可撤回任意消息)。不可彻底删除内容,不可动站长账号,亦无站长专属的用户内容档案。",
|
||||
},
|
||||
[ROLES.ADMIN]: {
|
||||
key: ROLES.ADMIN,
|
||||
@@ -120,7 +165,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
||||
colorToken: "--accent",
|
||||
softToken: "--accent-soft",
|
||||
grantDesc:
|
||||
"公告管理;全站帖子/评论审核;前台置顶与加精。不含用户权限、外观设置与群聊监管旁路。",
|
||||
"公告管理;全站帖子/评论审核与软删;前台置顶与加精。不含用户权限、外观设置、彻底删除与默认的群聊监管(可由站长另授消息管理或任命群管理员)。",
|
||||
},
|
||||
[ROLES.BOARD_ADMIN]: {
|
||||
key: ROLES.BOARD_ADMIN,
|
||||
@@ -129,7 +174,7 @@ export const ROLE_META: Record<RoleKey, RoleMeta> = {
|
||||
colorToken: "--accent",
|
||||
softToken: "--accent-soft",
|
||||
grantDesc:
|
||||
"仅审核并管理被授权板块内的帖子与评论(含删除违规内容)。无公告、置顶/加精、用户管理与外观设置;须至少勾选一个板块。",
|
||||
"仅审核并软删被授权板块内普通用户的帖子与评论;不可处置管理员/站长发布的内容,无消息管理、公告、置顶/加精、用户管理与外观设置;须至少勾选一个板块。",
|
||||
},
|
||||
[ROLES.USER]: {
|
||||
key: ROLES.USER,
|
||||
|
||||
Reference in New Issue
Block a user