完善角色与消息权限:收紧板管内容处置,彻底删除仅站长,并统一管理删帖/评弹窗。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user