完善角色与消息权限:收紧板管内容处置,彻底删除仅站长,并统一管理删帖/评弹窗。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 14:30:08 +08:00
parent 1f2e4b2a33
commit c0462a3500
37 changed files with 1340 additions and 472 deletions

View File

@@ -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>

View File

@@ -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}
/>
);

View File

@@ -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}

View File

@@ -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

View File

@@ -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} />;
}

View File

@@ -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)"

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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}