Files
jiang13-bbs/frontend/app/chat/[id]/ChatRoomClient.tsx

1964 lines
65 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Send,
Users,
Crown,
LogOut,
Settings,
Trash2,
UserX,
UserPlus,
X,
Undo2,
Reply,
Copy,
AtSign,
} from "lucide-react";
import {
apiListChatMessages,
apiSendChatMessage,
apiMarkChatRead,
apiListChatMembers,
apiLeaveChatRoom,
apiDissolveChatRoom,
apiUpdateChatRoom,
apiKickChatMember,
apiMuteChatMember,
apiSetChatMemberRole,
apiInviteChatMembers,
apiRecallChatMessage,
type ChatMessage,
type ChatRoomMember,
type User,
} from "@/lib/api";
import {
realtime,
RT_CHAT_MESSAGE,
RT_CHAT_MEMBERSHIP,
RT_CHAT_RECALLED,
} from "@/lib/realtime";
import type {
RtChatMessageData,
RtChatMembershipData,
RtChatRecalledData,
} from "@/lib/realtime";
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
import { toast } from "@/lib/toast";
import { scrollBehavior } from "@/lib/motion";
import Avatar from "@/components/Avatar";
import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
/** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */
const MARK_READ_GAP_MS = 2_000;
const markReadInflight = new Set<number>();
const markReadLastAt = new Map<number, number>();
function requestMarkChatRead(roomId: number, opts?: { force?: boolean }): void {
const now = Date.now();
const last = markReadLastAt.get(roomId) || 0;
if (markReadInflight.has(roomId)) return;
if (!opts?.force && now - last < MARK_READ_GAP_MS) return;
markReadInflight.add(roomId);
markReadLastAt.set(roomId, now);
apiMarkChatRead(roomId, 0)
.then(() => {
window.dispatchEvent(new Event("j13-chat-unread-sync"));
})
.catch(() => {})
.finally(() => {
markReadInflight.delete(roomId);
});
}
/** 成员列表:并发去重(进房 effect 与面板刷新) */
const membersInflight = new Set<number>();
type RightPanel = null | "members" | "settings" | "invite";
type ConfirmState = {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
action: () => Promise<void> | void;
} | null;
/** 撤回提示:本人 / 他人撤回 / 管理员代撤 */
function RecallNotice({
message,
meId,
members,
}: {
message: ChatMessage;
meId: number;
members: ChatRoomMember[];
}) {
if (!message.recalled_at) return null;
const by =
message.recalled_by ||
message.recaller?.id ||
message.sender_id;
// 操作者不是原发送者 → 监管代撤
const adminRecall = !!(by && by !== message.sender_id);
if (by === meId) {
return (
<span>
{adminRecall ? "你撤回了一条成员消息" : "你撤回了一条消息"}
</span>
);
}
let recaller: User | null | undefined =
message.recaller && message.recaller.id
? message.recaller
: by === message.sender_id
? message.sender
: null;
if ((!recaller || !recaller.id) && by > 0) {
const hit = members.find((x) => x.user_id === by);
if (hit) recaller = hit.user;
}
const name =
(recaller?.nickname || recaller?.username || "").trim() || "有人";
const uid = recaller?.id || by;
const nameStyle = {
color: "color-mix(in srgb, var(--accent) 72%, var(--ink))",
} as const;
const nameNode =
uid > 0 ? (
<Link
href={`/u/${uid}`}
className="transition-opacity hover:opacity-80"
style={nameStyle}
data-tip={`查看 ${name} 的主页`}
>
{name}
</Link>
) : (
<span style={nameStyle}>
{name}
</span>
);
if (adminRecall) {
return (
<span>
管理员
{nameNode}
撤回了一条成员消息
</span>
);
}
return (
<span>
{nameNode}
撤回了一条消息
</span>
);
}
/** 撤回后「重新编辑」有效窗口(接近微信体验) */
const RE_EDIT_MS = 2 * 60 * 1000;
interface Props {
roomId: number;
roomName: string;
roomDescription: string;
ownerId: number;
memberCount: number;
myRole: "" | "owner" | "admin" | "member" | "overseer";
joined: boolean;
isPrivate: boolean;
isDefault: boolean;
roomType: "group" | "direct";
peer: { id: number; username: string; nickname: string; avatar: string } | null;
meId: number;
meRole: string;
/** 站点消息管理 flag */
canManageMessages?: boolean;
meName: string;
meAvatar: string;
initialMessages: ChatMessage[];
initialHasMore: boolean;
/** 嵌入 IM 壳时铺满右栏,不自带外层卡片高度 */
embedded?: boolean;
}
function formatTime(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
const sameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
const hm = `${String(d.getHours()).padStart(2, "0")}:${String(
d.getMinutes()
).padStart(2, "0")}`;
if (sameDay) return hm;
const md = `${d.getMonth() + 1}/${d.getDate()}`;
return `${md} ${hm}`;
}
function highlightMentions(content: string, members: ChatRoomMember[]) {
const byUsername = new Map(
members.map((m) => [m.user.username.toLowerCase(), m.user])
);
const parts: React.ReactNode[] = [];
const re = /@([A-Za-z0-9_-]{3,32})/g;
let last = 0;
let m: RegExpExecArray | null;
let i = 0;
while ((m = re.exec(content)) !== null) {
if (m.index > last) parts.push(content.slice(last, m.index));
const user = byUsername.get(m[1].toLowerCase());
parts.push(
user ? (
<Link
key={`m-${i}`}
href={`/u/${user.id}`}
className="j13-mention-token"
data-tip={`查看 ${user.nickname || user.username} 的主页`}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.stopPropagation()}
>
@{m[1]}
</Link>
) : (
<span key={`m-${i}`}>@{m[1]}</span>
)
);
last = m.index + m[0].length;
i++;
}
if (last < content.length) parts.push(content.slice(last));
return parts;
}
/** 消息正文:URL 自动识别 + @提及;外链新开跟随站点策略 */
function renderChatContent(content: string, members: ChatRoomMember[]) {
return (
<AutolinkText
text={content}
forceNewTab
renderText={(seg) => <>{highlightMentions(seg, members)}</>}
/>
);
}
/** 输入区镜像层:已确认的 @ 显示为飞书风 token */
function renderComposerMirror(content: string, tokens: Set<string>) {
if (!content) return null;
const parts: React.ReactNode[] = [];
const re = /@([A-Za-z0-9_-]{3,32})/g;
let last = 0;
let m: RegExpExecArray | null;
let i = 0;
while ((m = re.exec(content)) !== null) {
if (m.index > last) parts.push(content.slice(last, m.index));
const key = m[1].toLowerCase();
parts.push(
tokens.has(key) ? (
<span key={`t-${i}`} className="j13-mention-token">
@{m[1]}
</span>
) : (
<span key={`t-${i}`}>@{m[1]}</span>
)
);
last = m.index + m[0].length;
i++;
}
if (last < content.length) parts.push(content.slice(last));
// 末尾换行在 pre-wrap 镜像里需要占位,否则高度会少一行
if (content.endsWith("\n")) parts.push("\n");
return parts;
}
/** 光标前是否紧贴一个已确认的 @token(含可选尾随空格) */
function mentionTokenBefore(
text: string,
cursor: number,
tokens: Set<string>
): { start: number; end: number } | null {
const before = text.slice(0, cursor);
const m = before.match(/@([A-Za-z0-9_-]{3,32})( ?)$/);
if (!m || !tokens.has(m[1].toLowerCase())) return null;
return { start: cursor - m[0].length, end: cursor };
}
/** 光标后是否紧跟一个已确认的 @token(含可选尾随空格) */
function mentionTokenAfter(
text: string,
cursor: number,
tokens: Set<string>
): { start: number; end: number } | null {
const after = text.slice(cursor);
const m = after.match(/^@([A-Za-z0-9_-]{3,32})( ?)/);
if (!m || !tokens.has(m[1].toLowerCase())) return null;
return { start: cursor, end: cursor + m[0].length };
}
export default function ChatRoomClient({
roomId,
roomName,
roomDescription,
ownerId,
memberCount: initialMemberCount,
myRole,
joined,
isPrivate,
isDefault,
roomType,
peer,
meId,
meRole,
canManageMessages = false,
initialMessages,
initialHasMore,
embedded = false,
}: Props) {
const router = useRouter();
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [hasMore, setHasMore] = useState(initialHasMore);
const [loadingMore, setLoadingMore] = useState(false);
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
const [members, setMembers] = useState<ChatRoomMember[]>([]);
const [memberCount, setMemberCount] = useState(initialMemberCount);
const [rightPanel, setRightPanel] = useState<RightPanel>(null);
const [ctxMenu, setCtxMenu] = useState<{
x: number;
y: number;
message: ChatMessage;
} | null>(null);
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionFilter, setMentionFilter] = useState("");
const [mentionIndex, setMentionIndex] = useState(0);
/** 从选择器确认插入的 @,在输入区以 token 整体展示/删除 */
const [mentionTokens, setMentionTokens] = useState<Set<string>>(() => new Set());
/** 本人撤回后的可重新编辑草稿(仅本端内存,不展示正文) */
const [reEditDrafts, setReEditDrafts] = useState<
Record<number, { text: string; until: number }>
>({});
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const mirrorRef = useRef<HTMLDivElement>(null);
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 === "admin" || myRole === "member";
const canView = joined;
const oversee = canOverseeChat(meRole);
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();
if (!t) return;
setReEditDrafts((prev) => ({
...prev,
[id]: { text: t, until: Date.now() + RE_EDIT_MS },
}));
}, []);
const takeReEditDraft = useCallback((id: number): string | null => {
const d = reEditDrafts[id];
if (!d || Date.now() > d.until) return null;
return d.text;
}, [reEditDrafts]);
// 到期后清掉「重新编辑」,避免按钮残留
useEffect(() => {
const ids = Object.keys(reEditDrafts);
if (ids.length === 0) return;
const nearest = Math.min(...Object.values(reEditDrafts).map((d) => d.until));
const wait = Math.max(0, nearest - Date.now()) + 50;
const t = setTimeout(() => {
setReEditDrafts((prev) => {
const now = Date.now();
const next: typeof prev = {};
for (const [k, v] of Object.entries(prev)) {
if (v.until > now) next[Number(k)] = v;
}
return next;
});
}, wait);
return () => clearTimeout(t);
}, [reEditDrafts]);
const scrollToBottom = useCallback((smooth = false) => {
const el = listRef.current;
if (!el) return;
// column-reverse:scrollTop=0 即视觉底部(最新消息)
el.scrollTo({
top: 0,
behavior: smooth ? "smooth" : "auto",
});
}, []);
const refreshMembers = useCallback(async () => {
if (!canView) return;
if (membersInflight.has(roomId)) return;
membersInflight.add(roomId);
try {
const res = await apiListChatMembers(roomId);
setMembers(res.members || []);
if (res.members) setMemberCount(res.members.length);
} catch {
// 静默:@ 功能降级为纯文本
} finally {
membersInflight.delete(roomId);
}
}, [roomId, canView]);
const markReadToLatest = useCallback(
(opts?: { force?: boolean }) => {
if (!isRealMember) return;
requestMarkChatRead(roomId, opts);
},
[roomId, isRealMember]
);
useEffect(() => {
if (!canView) return;
realtime.joinRoom(roomId);
void refreshMembers();
// 列表用 flex-col-reverse,SSR/无 JS 默认即贴底,无需首屏 scrollToBottom(避免 F5 闪一下)
markReadToLatest();
return () => {
realtime.leaveRoom(roomId);
};
// 只随房间切换订阅/首拉;避免回调身份变化重复打 members/read
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roomId, canView]);
useEffect(() => {
const off = realtime.on<RtChatMessageData>(RT_CHAT_MESSAGE, (data) => {
if (!data || data.room_id !== roomId) return;
setMessages((prev) => {
if (prev.some((m) => m.id === data.id)) return prev;
return [...prev, data as unknown as ChatMessage];
});
requestAnimationFrame(() => {
const el = listRef.current;
if (!el) return;
// column-reverse:靠近 scrollTop=0 即贴底
if (el.scrollTop < 120) {
scrollToBottom(true);
markReadToLatest();
}
});
});
return off;
}, [roomId, scrollToBottom, markReadToLatest]);
useEffect(() => {
const off = realtime.on<RtChatRecalledData>(RT_CHAT_RECALLED, (data) => {
if (!data || data.room_id !== roomId) return;
setMessages((prev) =>
prev.map((m) => {
if (m.id !== data.id) return m;
// 本人撤回:草稿留给「重新编辑」,气泡不展示正文
if (m.sender_id === meId && m.content) {
rememberReEdit(m.id, m.content);
}
return {
...m,
// 仅监管界面保留正文;普通成员(含撤回者自己)气泡不展示
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,
recaller: data.recaller
? {
id: data.recaller.id,
username: data.recaller.username,
nickname: data.recaller.nickname,
avatar: data.recaller.avatar,
role: data.recaller.role || "user",
}
: m.recaller,
};
})
);
});
return off;
}, [roomId, canModerateChat, meId, rememberReEdit]);
useEffect(() => {
const off = realtime.on<RtChatMembershipData>(
RT_CHAT_MEMBERSHIP,
(data) => {
if (!data || data.room_id !== roomId) return;
if (data.status === "invited") {
toast("你已被邀请加入本群", "ok");
router.refresh();
return;
}
if (data.status === "kicked") {
toast("你已被移出该群");
} else {
toast("该群已被解散");
}
setTimeout(() => router.push("/chat"), 1200);
}
);
return off;
}, [roomId, router]);
const loadMore = useCallback(async () => {
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
setLoadingMore(true);
const el0 = listRef.current;
const prevHeight = el0?.scrollHeight ?? 0;
const prevTop = el0?.scrollTop ?? 0;
try {
const before = messages[0].id;
const res = await apiListChatMessages(roomId, before, 30);
if ((res.messages || []).length > 0) {
setMessages((prev) => [...res.messages, ...prev]);
setHasMore(res.has_more);
requestAnimationFrame(() => {
const el = listRef.current;
if (!el) return;
// column-reverse:历史插在上方,用高度差补偿保持视口锚点
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
});
} else {
setHasMore(false);
}
} catch {
toast("加载历史消息失败");
} finally {
setLoadingMore(false);
}
}, [canView, hasMore, loadingMore, messages, roomId]);
const handleScroll = useCallback(() => {
const el = listRef.current;
if (!el) return;
// column-reverse:滚向上方历史 ≈ 接近 max scrollTop
if (el.scrollHeight - el.scrollTop - el.clientHeight < 60) {
loadMore();
}
if (el.scrollTop < 60) {
markReadToLatest();
}
}, [loadMore, markReadToLatest]);
const syncComposerHeight = () => {
const t = inputRef.current;
if (!t) return;
t.style.height = "auto";
t.style.height = Math.min(t.scrollHeight, 128) + "px";
};
const send = async () => {
const trimmed = content.trim();
if (!trimmed || sending || !isRealMember) return;
setSending(true);
const prevContent = trimmed;
const prevTokens = mentionTokens;
const replyId = replyTo?.id || 0;
setContent("");
setMentionTokens(new Set());
setReplyTo(null);
setMentionOpen(false);
requestAnimationFrame(() => syncComposerHeight());
try {
const { message } = await apiSendChatMessage(roomId, prevContent, replyId);
setMessages((prev) => {
if (prev.some((m) => m.id === message.id)) return prev;
return [...prev, message];
});
requestAnimationFrame(() => scrollToBottom(true));
// 发送成功立刻同步侧栏预览,并强制推进已读(避开进房 MarkRead 的 2s 节流)
window.dispatchEvent(
new CustomEvent("j13-chat-sent", { detail: { roomId, message } })
);
markReadToLatest({ force: true });
} catch (e) {
setContent(prevContent);
setMentionTokens(prevTokens);
if (replyId) {
const found = messages.find((m) => m.id === replyId);
if (found) setReplyTo(found);
}
toast(e instanceof Error ? e.message : "发送失败");
requestAnimationFrame(() => syncComposerHeight());
} finally {
setSending(false);
inputRef.current?.focus();
}
};
const mentionCandidates = (() => {
if (!mentionOpen) return [];
const q = mentionFilter.toLowerCase();
return members
.filter((m) => m.user_id !== meId)
.filter((m) => {
if (!q) return true;
return (
m.user.username.toLowerCase().includes(q) ||
(m.user.nickname || "").toLowerCase().includes(q)
);
})
.slice(0, 8);
})();
const insertMention = (username: string) => {
const el = inputRef.current;
const val = content;
const cursor = el?.selectionStart ?? val.length;
const before = val.slice(0, cursor);
const after = val.slice(cursor);
const at = before.lastIndexOf("@");
const head = at >= 0 ? before.slice(0, at) : before;
const next = `${head}@${username} ${after}`;
setContent(next);
setMentionTokens((prev) => {
const n = new Set(prev);
n.add(username.toLowerCase());
return n;
});
setMentionOpen(false);
setMentionFilter("");
requestAnimationFrame(() => {
const pos = head.length + username.length + 2;
el?.focus();
el?.setSelectionRange(pos, pos);
syncComposerHeight();
});
};
const pruneMentionTokens = (text: string, tokens: Set<string>) => {
if (tokens.size === 0) return tokens;
const present = new Set<string>();
const re = /@([A-Za-z0-9_-]{3,32})/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
const key = m[1].toLowerCase();
if (tokens.has(key)) present.add(key);
}
if (present.size === tokens.size) return tokens;
return present;
};
const onComposerChange = (raw: string) => {
setContent(raw);
setMentionTokens((prev) => pruneMentionTokens(raw, prev));
const el = inputRef.current;
const cursor = el?.selectionStart ?? raw.length;
const before = raw.slice(0, cursor);
const m = before.match(/@([A-Za-z0-9_-]{0,32})$/);
if (m && isRealMember && !isDirect) {
setMentionOpen(true);
setMentionFilter(m[1] || "");
setMentionIndex(0);
} else {
setMentionOpen(false);
setMentionFilter("");
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (mentionOpen && mentionCandidates.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setMentionIndex((i) => (i + 1) % mentionCandidates.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setMentionIndex(
(i) => (i - 1 + mentionCandidates.length) % mentionCandidates.length
);
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const pick = mentionCandidates[mentionIndex];
if (pick) insertMention(pick.user.username);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setMentionOpen(false);
return;
}
}
const el = e.currentTarget;
const start = el.selectionStart;
const end = el.selectionEnd;
// 已确认 @token 按整体跳过 / 删除(飞书体验)
if (start === end && mentionTokens.size > 0) {
if (e.key === "Backspace") {
const range = mentionTokenBefore(content, start, mentionTokens);
if (range) {
e.preventDefault();
const next = content.slice(0, range.start) + content.slice(range.end);
onComposerChange(next);
requestAnimationFrame(() => {
el.setSelectionRange(range.start, range.start);
syncComposerHeight();
});
return;
}
}
if (e.key === "Delete") {
const range = mentionTokenAfter(content, start, mentionTokens);
if (range) {
e.preventDefault();
const next = content.slice(0, range.start) + content.slice(range.end);
onComposerChange(next);
requestAnimationFrame(() => {
el.setSelectionRange(range.start, range.start);
syncComposerHeight();
});
return;
}
}
if (e.key === "ArrowLeft" && !e.shiftKey) {
const range = mentionTokenBefore(content, start, mentionTokens);
if (range) {
e.preventDefault();
el.setSelectionRange(range.start, range.start);
return;
}
}
if (e.key === "ArrowRight" && !e.shiftKey) {
const range = mentionTokenAfter(content, start, mentionTokens);
if (range) {
e.preventDefault();
el.setSelectionRange(range.end, range.end);
return;
}
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
if (m.recalled_at) return;
setCtxMenu({ x, y, message: m });
};
const clearLongPress = () => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
};
const togglePanel = (panel: Exclude<RightPanel, null>) => {
setRightPanel((cur) => (cur === panel ? null : panel));
};
const runConfirm = async () => {
if (!confirm) return;
setConfirmBusy(true);
try {
await confirm.action();
setConfirm(null);
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
} finally {
setConfirmBusy(false);
}
};
const handleLeave = () => {
if (isDefault) {
toast("默认大厅不可退出");
return;
}
setConfirm({
title: isDirect ? "关闭私聊" : "退出群聊",
message: isDirect
? "确定关闭该私聊?之后可从对方主页再次打开。"
: "确定退出该群?退出后可重新加入。",
confirmLabel: isDirect ? "关闭" : "退出",
danger: true,
action: async () => {
await apiLeaveChatRoom(roomId);
toast(isDirect ? "已关闭私聊" : "已退出群聊", "ok");
router.push("/chat");
},
});
};
const handleDissolve = () => {
if (isDefault) {
toast("默认大厅不可解散");
return;
}
setConfirm({
title: "解散群聊",
message: "确定解散该群?所有成员将被移出,消息历史保留但不再可达。",
confirmLabel: "解散",
danger: true,
action: async () => {
await apiDissolveChatRoom(roomId);
toast("群已解散", "ok");
router.push("/chat");
},
});
};
const handleKick = (uid: number, nickname: string) => {
if (!canKickMembers) return;
setConfirm({
title: "移出成员",
message: `确定将 ${nickname} 移出该群?`,
confirmLabel: "移出",
danger: true,
action: async () => {
await apiKickChatMember(roomId, uid);
setMembers((prev) => prev.filter((m) => m.user_id !== uid));
setMemberCount((c) => Math.max(0, c - 1));
toast("已移出", "ok");
},
});
};
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 (canModerateChat) return true;
return m.sender_id === meId && isRealMember;
};
const handleRecall = (mid: number) => {
const snapshot = messages.find((m) => m.id === mid);
setCtxMenu(null);
setConfirm({
title: "撤回消息",
message: "确定撤回这条消息?",
confirmLabel: "撤回",
danger: true,
action: async () => {
const { message } = await apiRecallChatMessage(roomId, mid);
const body = (message.content || snapshot?.content || "").trim();
const mine = (snapshot?.sender_id ?? message.sender_id) === meId;
if (mine && body) rememberReEdit(mid, body);
setMessages((prev) =>
prev.map((m) =>
m.id === mid
? {
...m,
content: canModerateChat ? body || m.content : "",
recalled_at: message.recalled_at || new Date().toISOString(),
recalled_by: message.recalled_by,
recaller: message.recaller || m.recaller,
}
: m
)
);
toast("已撤回", "ok");
},
});
};
const handleReEdit = (mid: number) => {
const text = takeReEditDraft(mid);
if (!text) {
toast("重新编辑已过期");
return;
}
setReplyTo(null);
setContent(text);
requestAnimationFrame(() => {
const el = inputRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(text.length, text.length);
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
});
};
const scrollToMsg = (id: number) => {
const el = msgRefs.current.get(id);
el?.scrollIntoView({ behavior: scrollBehavior(), block: "center" });
};
const msgMenuItems = (m: ChatMessage): CtxMenuItem[] => {
const items: CtxMenuItem[] = [
{
key: "reply",
label: "回复",
icon: <Reply size={14} />,
onClick: () => {
setReplyTo(m);
inputRef.current?.focus();
},
},
{
key: "copy",
label: "复制",
icon: <Copy size={14} />,
onClick: async () => {
try {
await navigator.clipboard.writeText(m.content);
toast("已复制", "ok");
} catch {
toast("复制失败");
}
},
},
];
if (!isDirect && isRealMember && m.sender_id !== meId) {
items.push({
key: "at",
label: `提及 @${m.sender.username}`,
icon: <AtSign size={14} />,
onClick: () => {
setContent((c) => `${c}${c && !c.endsWith(" ") ? " " : ""}@${m.sender.username} `);
inputRef.current?.focus();
},
});
}
if (canRecallMsg(m)) {
items.push({
key: "recall",
label: "撤回",
icon: <Undo2 size={14} />,
danger: true,
onClick: () => handleRecall(m.id),
});
}
return items;
};
// 点击空白关闭右键菜单 — ContextMenu 自带
const shellClass = embedded
? "relative flex flex-row h-full min-h-0"
: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_260px] gap-4";
return (
<div className={shellClass}>
<div
className={
embedded
? "flex flex-col h-full min-h-0 min-w-0 flex-1"
: "panel flex flex-col"
}
style={
embedded
? undefined
: { height: "100%", minHeight: 0 }
}
>
<div
className="flex items-center justify-between gap-3 px-4 sm:px-5 py-3 shrink-0"
style={{ borderBottom: "1px solid var(--line)" }}
>
<div className="min-w-0 flex items-center gap-2">
{embedded && (
<Link
href="/chat"
className="lg:hidden w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)" }}
aria-label="返回消息列表"
>
←
</Link>
)}
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1
className="font-extrabold text-[16px] truncate"
style={{ color: "var(--ink)" }}
>
{roomName}
</h1>
{isDefault && (
<span
className="text-[10.5px] px-2 py-0.5 rounded-full"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
大厅
</span>
)}
{isPrivate && !isDirect && (
<span
className="text-[10.5px] px-2 py-0.5 rounded-full"
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
>
私密
</span>
)}
</div>
<p className="meta text-[11.5px] mt-0.5 truncate">
{isDirect
? peer
? `@${peer.username}`
: "私聊"
: `${memberCount} 位成员`}
{!isDirect && roomDescription && ` · ${roomDescription}`}
</p>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{canView && !isDirect && (
<button
onClick={() => togglePanel("members")}
aria-label="成员列表"
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
style={{
color: rightPanel === "members" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "members" ? "var(--accent-soft)" : undefined,
}}
data-tip="成员"
>
<Users size={16} />
</button>
)}
{canManageInvite && (
<button
onClick={() => togglePanel("invite")}
aria-label="邀请成员"
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
style={{
color: rightPanel === "invite" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "invite" ? "var(--accent-soft)" : undefined,
}}
data-tip="邀请成员"
>
<UserPlus size={16} />
</button>
)}
{isOwner && !isDirect && (
<button
onClick={() => togglePanel("settings")}
aria-label="群设置"
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
style={{
color: rightPanel === "settings" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "settings" ? "var(--accent-soft)" : undefined,
}}
data-tip="群设置"
>
<Settings size={16} />
</button>
)}
{isRealMember && !isDefault && (isDirect || !isOwner) && (
<button
onClick={handleLeave}
aria-label={isDirect ? "关闭私聊" : "退出群聊"}
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)]"
style={{ color: "var(--ink-3)" }}
data-tip={isDirect ? "关闭私聊" : "退出群聊"}
>
<LogOut size={16} />
</button>
)}
</div>
</div>
<div
ref={listRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto flex flex-col-reverse px-4 sm:px-5 py-4"
style={{ minHeight: 0 }}
>
{!canView ? (
<div className="text-center py-12 px-4">
<p style={{ color: "var(--ink)" }}>
{isPrivate ? "此群为私密群" : "预览群信息"}
</p>
<p className="meta mt-1.5">
{isPrivate
? "需群主邀请后才能查看消息"
: "加入后可查看历史与发言"}
</p>
</div>
) : (
<div className="flex flex-col gap-3">
{hasMore && (
<button
onClick={loadMore}
disabled={loadingMore}
className="mx-auto block text-[12px] meta hover:text-[var(--accent)] transition-colors py-2"
>
{loadingMore ? "加载中..." : "加载更早消息"}
</button>
)}
{messages.length === 0 ? (
<div className="text-center py-12">
<p className="meta" style={{ color: "var(--ink-3)" }}>
还没有消息,发第一条吧
</p>
</div>
) : (
messages.map((m, i) => {
const recalled = !!m.recalled_at;
const mine = m.sender_id === meId;
const prev = messages[i - 1];
const grouped =
!recalled &&
prev &&
!prev.recalled_at &&
prev.sender_id === m.sender_id &&
new Date(m.created_at).getTime() -
new Date(prev.created_at).getTime() <
5 * 60_000;
const reEditText = mine ? takeReEditDraft(m.id) : null;
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
if (recalled && !(canModerateChat && m.content)) {
return (
<div
key={m.id}
ref={(node) => {
if (node) msgRefs.current.set(m.id, node);
else msgRefs.current.delete(m.id);
}}
className="flex justify-center px-2 py-0.5"
>
<p
className="text-[12px] leading-relaxed text-center max-w-[90%]"
style={{ color: "var(--ink-3)" }}
>
<RecallNotice
message={m}
meId={meId}
members={members}
/>
{reEditText ? (
<>
{" "}
<button
type="button"
onClick={() => handleReEdit(m.id)}
className="transition-opacity hover:opacity-80"
style={{
color:
"color-mix(in srgb, var(--accent) 72%, var(--ink))",
}}
>
重新编辑
</button>
</>
) : null}
</p>
</div>
);
}
// 站长/超管:单气泡呈现撤回原文(可读,非双气泡)
if (recalled && canModerateChat && m.content) {
return (
<div
key={m.id}
ref={(node) => {
if (node) msgRefs.current.set(m.id, node);
else msgRefs.current.delete(m.id);
}}
className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`}
>
<div className="w-8 shrink-0">
<Avatar
name={m.sender.nickname || m.sender.username}
src={m.sender.avatar || undefined}
size={32}
/>
</div>
<div
className={`flex flex-col min-w-0 max-w-[78%] ${
mine ? "items-end" : "items-start"
}`}
>
<div className="flex items-center gap-2 mb-1">
{!isDirect && m.sender_id === ownerId && (
<Crown size={11} style={{ color: "var(--gold)" }} />
)}
<span
className="text-[12px] truncate"
style={{ color: "var(--ink-2)" }}
>
{m.sender.nickname}
</span>
<span className="meta text-[10.5px]">
{formatTime(m.created_at)}
</span>
</div>
<div
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text w-full"
style={{
background: "var(--panel-2)",
border:
"1px solid color-mix(in srgb, var(--line) 80%, var(--ink-3))",
color: "var(--ink)",
borderBottomRightRadius: mine ? 4 : undefined,
borderBottomLeftRadius: mine ? undefined : 4,
opacity: 0.92,
}}
>
<div
className="inline-flex items-center gap-1 mb-1.5 text-[11px]"
style={{ color: "var(--ink-3)" }}
>
<Undo2 size={11} strokeWidth={2.4} />
已撤回
</div>
<div style={{ color: "var(--ink-2)" }}>
{renderChatContent(m.content, members)}
</div>
</div>
</div>
</div>
);
}
return (
<div
key={m.id}
ref={(node) => {
if (node) msgRefs.current.set(m.id, node);
else msgRefs.current.delete(m.id);
}}
className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`}
>
<div className="w-8 shrink-0">
{!grouped && (
<Avatar
name={m.sender.nickname || m.sender.username}
src={m.sender.avatar || undefined}
size={32}
/>
)}
</div>
<div
className={`flex flex-col min-w-0 max-w-[78%] ${
mine ? "items-end" : "items-start"
}`}
>
{!grouped && (
<div className="flex items-center gap-2 mb-1">
{!isDirect && m.sender_id === ownerId && (
<Crown size={11} style={{ color: "var(--gold)" }} />
)}
<span
className="text-[12px] truncate"
style={{ color: "var(--ink-2)" }}
>
{m.sender.nickname}
</span>
<span className="meta text-[10.5px]">
{formatTime(m.created_at)}
</span>
</div>
)}
<div
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text"
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
openMsgMenu(m, e.clientX, e.clientY);
}}
onTouchStart={(e) => {
const t = e.touches[0];
if (!t) return;
clearLongPress();
longPressTimer.current = setTimeout(() => {
openMsgMenu(m, t.clientX, t.clientY);
}, 480);
}}
onTouchEnd={clearLongPress}
onTouchMove={clearLongPress}
style={
mine
? {
background:
"color-mix(in srgb, var(--accent) 14%, var(--panel))",
color: "var(--ink)",
borderBottomRightRadius: 4,
}
: {
background: "var(--tint)",
color: "var(--ink)",
borderBottomLeftRadius: 4,
}
}
>
{!!m.reply_to_id && m.reply_snap && (
<button
type="button"
onClick={() => scrollToMsg(m.reply_to_id!)}
className="block w-full text-left mb-1.5 px-2 py-1.5 rounded-lg text-[12px] leading-snug"
style={{
background: mine
? "color-mix(in srgb, var(--accent) 10%, transparent)"
: "var(--bg)",
borderLeft: "3px solid var(--accent)",
color: "var(--ink-2)",
}}
>
{m.reply_snap}
</button>
)}
{renderChatContent(m.content, members)}
</div>
</div>
</div>
);
})
)}
</div>
)}
</div>
{isRealMember ? (
<div
className="shrink-0 px-3 sm:px-4 py-3"
style={{ borderTop: "1px solid var(--line)" }}
>
{replyTo && (
<div
className="flex items-start gap-2 mb-2 px-3 py-2 rounded-xl"
style={{
background: "var(--bg)",
border: "1px solid var(--line)",
}}
>
<div
className="w-1 self-stretch rounded-full shrink-0"
style={{ background: "var(--accent)" }}
/>
<div className="flex-1 min-w-0">
<p className="text-[11px]" style={{ color: "var(--accent)" }}>
回复 {replyTo.sender.nickname || replyTo.sender.username}
</p>
<p className="text-[12px] truncate meta mt-0.5">
{replyTo.recalled_at ? "(已撤回的消息)" : replyTo.content}
</p>
</div>
<button
type="button"
onClick={() => setReplyTo(null)}
aria-label="取消回复"
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
<X size={13} />
</button>
</div>
)}
<div className="relative">
{mentionOpen && mentionCandidates.length > 0 && (
<div
className="absolute bottom-full left-0 right-0 mb-2 rounded-xl overflow-hidden shadow-lg z-10"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
}}
>
{mentionCandidates.map((m, i) => (
<button
key={m.id}
type="button"
className="w-full flex items-center gap-2.5 px-3 py-2 text-left"
style={{
background: i === mentionIndex ? "var(--accent-soft)" : "transparent",
}}
onMouseDown={(e) => {
e.preventDefault();
insertMention(m.user.username);
}}
>
<Avatar
name={m.user.nickname || m.user.username}
src={m.user.avatar || undefined}
size={28}
/>
<div className="min-w-0">
<p className="text-[13px] truncate" style={{ color: "var(--ink)" }}>
{m.user.nickname || m.user.username}
</p>
<p className="text-[11px] meta truncate">@{m.user.username}</p>
</div>
</button>
))}
</div>
)}
<div
className="j13-chat-composer flex items-center gap-2 rounded-2xl pl-3.5 pr-2 py-1.5 min-h-[48px] cursor-text transition-[border-color,box-shadow] duration-150"
style={{
background: "var(--bg)",
border: "1px solid var(--line)",
}}
onMouseDown={(e) => {
// 点空白/内边距时聚焦输入,避免抢走发送按钮的点击
const t = e.target as HTMLElement;
if (t.closest("button, a, [role='button']")) return;
if (t.tagName === "TEXTAREA") return;
e.preventDefault();
inputRef.current?.focus();
}}
>
<div className="relative flex-1 min-w-0 self-stretch flex items-center">
{content ? (
<div
ref={mirrorRef}
className="j13-chat-composer-mirror text-[14px] leading-5 py-2"
aria-hidden
>
{renderComposerMirror(content, mentionTokens)}
</div>
) : null}
<textarea
ref={inputRef}
value={content}
onChange={(e) => onComposerChange(e.target.value)}
onKeyDown={onKeyDown}
onScroll={(e) => {
if (mirrorRef.current) {
mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
mirrorRef.current.scrollLeft = e.currentTarget.scrollLeft;
}
}}
rows={1}
placeholder={
isDirect
? "输入消息…"
: "输入消息,@ 可提醒成员"
}
maxLength={2000}
className={`j13-chat-composer-input relative w-full bg-transparent text-[14px] leading-5 py-2 max-h-32 min-h-[36px]${
content ? " has-value" : ""
}`}
style={{ color: content ? undefined : "var(--ink)" }}
onInput={(e) => {
const t = e.currentTarget;
t.style.height = "auto";
t.style.height = Math.min(t.scrollHeight, 128) + "px";
}}
/>
</div>
<button
type="button"
onClick={send}
disabled={sending || !content.trim()}
className="btn btn-accent btn-sm gap-1.5 h-9 px-3.5 shrink-0 rounded-full"
aria-label="发送"
>
<Send size={14} strokeWidth={2.25} />
发送
</button>
</div>
</div>
</div>
) : (
<div
className="px-4 sm:px-5 py-4 shrink-0 text-center"
style={{ borderTop: "1px solid var(--line)" }}
>
{oversee && !isRealMember ? (
<p className="text-[13px] meta" style={{ color: "var(--ink-3)" }}>
监管旁路查看中(未正式入群,不可发言)
</p>
) : isPrivate ? (
<p className="text-[13px] meta" style={{ color: "var(--ink-3)" }}>
私密群需邀请才能加入
</p>
) : (
<>
<p
className="text-[13px] meta mb-2.5"
style={{ color: "var(--ink-3)" }}
>
你还不是该群成员
</p>
<JoinButton roomId={roomId} onJoined={() => router.refresh()} />
</>
)}
</div>
)}
</div>
{/* 飞书式右栏:成员 / 邀请 / 设置 */}
{canView && !isDirect && rightPanel && (
<aside
className={
embedded
? "absolute inset-0 z-30 flex flex-col sm:static sm:inset-auto sm:w-[280px] sm:shrink-0 h-full min-h-0"
: "hidden lg:flex flex-col w-[260px] panel min-h-0"
}
style={{
background: "var(--panel)",
borderLeft: "1px solid var(--line)",
}}
>
<div
className="flex items-center justify-between px-4 py-3 shrink-0"
style={{ borderBottom: "1px solid var(--line)" }}
>
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
{rightPanel === "members"
? `成员 · ${memberCount}`
: rightPanel === "invite"
? "邀请成员"
: "群设置"}
</p>
<button
type="button"
onClick={() => setRightPanel(null)}
aria-label="关闭面板"
className="w-7 h-7 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto min-h-0 p-4">
{rightPanel === "members" && (
<MembersPanel
members={members}
meId={meId}
canKick={canKickMembers}
isChatOwner={isOwner}
siteOwner={siteOwner}
onRefresh={refreshMembers}
onKick={handleKick}
onMute={handleMute}
onSetAdmin={handleSetAdmin}
/>
)}
{rightPanel === "invite" && (
<InvitePanel
roomId={roomId}
onInvited={() => {
refreshMembers();
setRightPanel("members");
}}
/>
)}
{rightPanel === "settings" && (
<SettingsPanel
roomId={roomId}
initialName={roomName}
initialDescription={roomDescription}
canDissolve={!isDefault}
onSaved={() => {
setRightPanel(null);
router.refresh();
}}
onDissolve={handleDissolve}
/>
)}
</div>
</aside>
)}
{/* 非嵌入模式:未开右栏时桌面仍显示成员常驻栏 */}
{!embedded && canView && !isDirect && !rightPanel && (
<aside className="hidden lg:flex flex-col panel p-4 overflow-y-auto min-h-0">
<MembersPanel
members={members}
meId={meId}
canKick={canKickMembers}
isChatOwner={isOwner}
siteOwner={siteOwner}
onRefresh={refreshMembers}
onKick={handleKick}
onMute={handleMute}
onSetAdmin={handleSetAdmin}
showTitle
/>
</aside>
)}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={msgMenuItems(ctxMenu.message)}
onClose={() => setCtxMenu(null)}
/>
)}
<ConfirmDialog
open={!!confirm}
title={confirm?.title || ""}
message={confirm?.message || ""}
confirmLabel={confirm?.confirmLabel}
danger={confirm?.danger}
busy={confirmBusy}
onConfirm={runConfirm}
onCancel={() => {
if (!confirmBusy) setConfirm(null);
}}
/>
</div>
);
}
function JoinButton({
roomId,
onJoined,
}: {
roomId: number;
onJoined: () => void;
}) {
const [joining, setJoining] = useState(false);
const join = async () => {
setJoining(true);
try {
const { apiJoinChatRoom } = await import("@/lib/api");
await apiJoinChatRoom(roomId);
toast("已加入群聊", "ok");
onJoined();
} catch (e) {
toast(e instanceof Error ? e.message : "加入失败");
} finally {
setJoining(false);
}
};
return (
<button
onClick={join}
disabled={joining}
className="btn btn-accent btn-sm"
>
{joining ? "加入中..." : "加入群聊"}
</button>
);
}
function MembersPanel({
members,
meId,
canKick,
isChatOwner,
siteOwner,
onRefresh,
onKick,
onMute,
onSetAdmin,
showTitle = false,
}: {
members: ChatRoomMember[];
meId: number;
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 (
<div>
{showTitle && (
<div className="flex items-center justify-between mb-3">
<p
className="text-[12px] tracking-[0.08em] uppercase"
style={{ color: "var(--ink-3)" }}
>
成员 · {members.length}
</p>
<button
onClick={onRefresh}
className="meta hover:text-[var(--accent)] transition-colors text-[11px]"
>
刷新
</button>
</div>
)}
{!showTitle && (
<div className="flex justify-end mb-2">
<button
onClick={onRefresh}
className="meta hover:text-[var(--accent)] transition-colors text-[11px]"
>
刷新
</button>
</div>
)}
<ul className="flex flex-col gap-1.5">
{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"
>
<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)" }}
>
{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>
);
}
function InvitePanel({
roomId,
onInvited,
}: {
roomId: number;
onInvited: () => void;
}) {
const [input, setInput] = useState("");
const [submitting, setSubmitting] = useState(false);
const submit = async () => {
const usernames = input
.split(/[\s,,]+/)
.map((s) => s.trim())
.filter(Boolean);
if (usernames.length === 0) {
toast("请输入要邀请的用户名");
return;
}
setSubmitting(true);
try {
const { added } = await apiInviteChatMembers(roomId, usernames);
toast(added.length > 0 ? `已邀请 ${added.length} 人` : "没有新成员加入", "ok");
setInput("");
onInvited();
} catch (e) {
toast(e instanceof Error ? e.message : "邀请失败");
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-4">
<p className="text-[12.5px] meta">输入用户名,多个用空格或逗号分隔</p>
<input
autoFocus
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="username1, username2"
className="field"
/>
<button
onClick={submit}
disabled={submitting}
className="btn btn-accent btn-sm w-full justify-center"
>
{submitting ? "邀请中..." : "发送邀请"}
</button>
</div>
);
}
function SettingsPanel({
roomId,
initialName,
initialDescription,
canDissolve,
onSaved,
onDissolve,
}: {
roomId: number;
initialName: string;
initialDescription: string;
canDissolve: boolean;
onSaved: () => void;
onDissolve: () => void;
}) {
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [saving, setSaving] = useState(false);
const save = async () => {
const trimmed = name.trim();
if (trimmed.length < 2 || trimmed.length > 32) {
toast("群名称需为 2-32 个字符");
return;
}
if (description.length > 200) {
toast("群简介不能超过 200 个字符");
return;
}
setSaving(true);
try {
await apiUpdateChatRoom(roomId, trimmed, description.trim());
toast("已保存", "ok");
onSaved();
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="space-y-4">
<div>
<label
className="block text-[12px] mb-1.5"
style={{ color: "var(--ink-2)" }}
>
群名称
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={32}
className="field"
/>
</div>
<div>
<label
className="block text-[12px] mb-1.5"
style={{ color: "var(--ink-2)" }}
>
群简介
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={200}
rows={4}
className="field resize-none"
/>
</div>
<button
onClick={save}
disabled={saving}
className="btn btn-accent btn-sm w-full justify-center"
>
{saving ? "保存中..." : "保存修改"}
</button>
{canDissolve && (
<button
onClick={onDissolve}
disabled={saving}
className="btn btn-danger btn-sm w-full justify-center gap-1.5"
>
<Trash2 size={13} /> 解散群
</button>
)}
</div>
);
}