Files
jiang13-bbs/frontend/app/chat/[id]/ChatRoomClient.tsx
freefire 42c395a864 feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 22:58:16 +08:00

1590 lines
51 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,
apiInviteChatMembers,
apiRecallChatMessage,
type ChatMessage,
type ChatRoomMember,
} 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 } from "@/lib/roles";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import ConfirmDialog from "@/components/ConfirmDialog";
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
type RightPanel = null | "members" | "settings" | "invite";
type ConfirmState = {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
action: () => Promise<void> | void;
} | null;
function recallLabel(m: ChatMessage, meId: number): string {
if (!m.recalled_at) return "";
// 操作者不是发送者 → 管理员/监管撤回
if (m.recalled_by && m.recalled_by !== m.sender_id) {
return "管理员撤回了此消息";
}
if (m.sender_id === meId || m.recalled_by === meId) {
return "你撤回了一条消息";
}
return "此消息已撤回";
}
interface Props {
roomId: number;
roomName: string;
roomDescription: string;
ownerId: number;
memberCount: number;
myRole: "" | "owner" | "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;
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"
title={`查看 ${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;
}
/** 输入区镜像层:已确认的 @ 显示为飞书风 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,
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 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 isDirect = roomType === "direct";
const isRealMember = myRole === "owner" || myRole === "member";
const canView = joined;
const oversee = canOverseeChat(meRole);
const canManageInvite = !isDirect && (isOwner || oversee);
const scrollToBottom = useCallback((smooth = false) => {
const el = listRef.current;
if (!el) return;
el.scrollTo({
top: el.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
}, []);
const refreshMembers = useCallback(async () => {
if (!canView) return;
try {
const res = await apiListChatMembers(roomId);
setMembers(res.members || []);
if (res.members) setMemberCount(res.members.length);
} catch {
// 静默:@ 功能降级为纯文本
}
}, [roomId, canView]);
const markReadToLatest = useCallback(() => {
if (!isRealMember) return;
apiMarkChatRead(roomId, 0)
.then(() => {
window.dispatchEvent(new Event("j13-chat-unread-sync"));
})
.catch(() => {});
}, [roomId, isRealMember]);
useEffect(() => {
if (!canView) return;
realtime.joinRoom(roomId);
refreshMembers();
requestAnimationFrame(() => scrollToBottom(false));
markReadToLatest();
return () => {
realtime.leaveRoom(roomId);
};
}, [roomId, canView, refreshMembers, scrollToBottom, markReadToLatest]);
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;
const nearBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < 120;
if (nearBottom) {
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) =>
m.id === data.id
? {
...m,
content: "",
recalled_at: data.recalled_at || new Date().toISOString(),
recalled_by: data.recalled_by,
sender_id: data.sender_id || m.sender_id,
}
: m
)
);
});
return off;
}, [roomId]);
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 prevHeight = listRef.current?.scrollHeight ?? 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;
el.scrollTop = 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;
if (el.scrollTop < 60) {
loadMore();
}
if (el.scrollHeight - el.scrollTop - el.clientHeight < 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));
markReadToLatest();
} 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) => {
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 canRecallMsg = (m: ChatMessage) => {
if (m.recalled_at) return false;
if (oversee) return true;
return m.sender_id === meId && isRealMember;
};
const handleRecall = (mid: number) => {
setCtxMenu(null);
setConfirm({
title: "撤回消息",
message: "确定撤回这条消息?",
confirmLabel: "撤回",
danger: true,
action: async () => {
const { message } = await apiRecallChatMessage(roomId, mid);
setMessages((prev) =>
prev.map((m) =>
m.id === mid
? {
...m,
content: "",
recalled_at: message.recalled_at || new Date().toISOString(),
recalled_by: message.recalled_by,
}
: m
)
);
toast("已撤回", "ok");
},
});
};
const scrollToMsg = (id: number) => {
const el = msgRefs.current.get(id);
el?.scrollIntoView({ behavior: "smooth", 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: "calc(100vh - 12rem)", minHeight: "420px" }
}
>
<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(--accent-soft)]"
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] font-bold px-2 py-0.5 rounded-full"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
大厅
</span>
)}
{isPrivate && !isDirect && (
<span
className="text-[10.5px] font-bold 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(--accent-soft)]"
style={{
color: rightPanel === "members" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "members" ? "var(--accent-soft)" : undefined,
}}
title="成员"
>
<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(--accent-soft)]"
style={{
color: rightPanel === "invite" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "invite" ? "var(--accent-soft)" : undefined,
}}
title="邀请成员"
>
<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(--accent-soft)]"
style={{
color: rightPanel === "settings" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "settings" ? "var(--accent-soft)" : undefined,
}}
title="群设置"
>
<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)" }}
title={isDirect ? "关闭私聊" : "退出群聊"}
>
<LogOut size={16} />
</button>
)}
</div>
</div>
<div
ref={listRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-4 sm:px-5 py-4"
style={{ minHeight: 0 }}
>
{!canView ? (
<div className="text-center py-12 px-4">
<p className="font-semibold" style={{ color: "var(--ink)" }}>
{isPrivate ? "此群为私密群" : "预览群信息"}
</p>
<p className="meta mt-1.5">
{isPrivate
? "需群主邀请后才能查看消息"
: "加入后可查看历史与发言"}
</p>
</div>
) : (
<>
{hasMore && (
<button
onClick={loadMore}
disabled={loadingMore}
className="mx-auto block text-[12px] meta hover:text-[var(--accent)] transition-colors py-2"
>
{loadingMore ? "加载中..." : "加载更早消息"}
</button>
)}
<div className="flex flex-col gap-3">
{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;
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] font-semibold truncate"
style={{ color: "var(--ink-2)" }}
>
{m.sender.nickname}
</span>
<span className="meta text-[10.5px]">
{formatTime(m.created_at)}
</span>
</div>
)}
{recalled ? (
<div
className="px-3 py-2 rounded-xl text-[13px] italic"
style={{
background: "var(--panel-2)",
color: "var(--ink-3)",
}}
>
{recallLabel(m, meId)}
</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>
)}
{highlightMentions(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] font-bold" 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(--accent-soft)]"
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] font-semibold 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-bold" 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(--accent-soft)]"
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}
isOwner={isOwner}
onRefresh={refreshMembers}
onKick={handleKick}
/>
)}
{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}
isOwner={isOwner}
onRefresh={refreshMembers}
onKick={handleKick}
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,
isOwner,
onRefresh,
onKick,
showTitle = false,
}: {
members: ChatRoomMember[];
meId: number;
isOwner: boolean;
onRefresh: () => void;
onKick: (uid: number, nickname: string) => void;
showTitle?: boolean;
}) {
return (
<div>
{showTitle && (
<div className="flex items-center justify-between mb-3">
<p
className="text-[12px] font-bold 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) => (
<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] font-semibold hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{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)" }}
>
<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="w-full px-3.5 h-10 rounded-lg outline-none"
style={{
background: "var(--bg)",
border: "1px solid var(--line)",
color: "var(--ink)",
}}
/>
<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] font-bold mb-1.5"
style={{ color: "var(--ink-2)" }}
>
群名称
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={32}
className="w-full px-3.5 h-10 rounded-lg outline-none"
style={{
background: "var(--bg)",
border: "1px solid var(--line)",
color: "var(--ink)",
}}
/>
</div>
<div>
<label
className="block text-[12px] font-bold mb-1.5"
style={{ color: "var(--ink-2)" }}
>
群简介
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={200}
rows={4}
className="w-full px-3.5 py-2.5 rounded-lg outline-none resize-none"
style={{
background: "var(--bg)",
border: "1px solid var(--line)",
color: "var(--ink)",
}}
/>
</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>
);
}