Files
jiang13-bbs/frontend/app/chat/[id]/ChatRoomClient.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

2961 lines
101 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, useMemo } 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,
ChevronsDown,
Smile,
VolumeX,
} 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,
RT_CHAT_MEMBER_MUTED,
} from "@/lib/realtime";
import type {
RtChatMessageData,
RtChatMembershipData,
RtChatRecalledData,
RtChatMemberMutedData,
} 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 LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import Modal from "@/components/Modal";
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
import EmojiPicker from "@/components/EmojiPicker";
import EmojiImg from "@/components/EmojiImg";
import { collectDyEmojiSpans, dyEmojiUrl } from "@/lib/dyEmoji";
import { userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
/** 同一房间已读:进行中去重 + 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[];
}) {
const urlStyle = useUrlStyle();
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={userPath(urlStyle, uid)}
className="transition-opacity hover:opacity-80"
style={nameStyle}
>
{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,
users: Map<string, User>,
onOpenCard: (u: User) => void
) {
if (!content) return null;
// 合并 @提及 与表情短代码 span(互不重叠:短代码字符集内无 @)
const emojiSpans = collectDyEmojiSpans(content);
const inEmoji = new Array<boolean>(content.length).fill(false);
for (const s of emojiSpans) {
for (let j = s.start; j < s.end; j++) inEmoji[j] = true;
}
// 纯表情消息(去掉短代码后只剩空白)按贴纸大图渲染
let rest = "";
let emojiPrev = 0;
for (const s of emojiSpans) {
rest += content.slice(emojiPrev, s.start);
emojiPrev = s.end;
}
rest += content.slice(emojiPrev);
const emojiOnly = emojiSpans.length > 0 && rest.trim() === "";
type Span = { start: number; end: number; node: React.ReactNode };
const spans: Span[] = [];
const re = /@([A-Za-z0-9_-]{3,32})/g;
let m: RegExpExecArray | null;
let i = 0;
while ((m = re.exec(content)) !== null) {
if (inEmoji[m.index]) continue;
const user = users.get(m[1].toLowerCase());
spans.push({
start: m.index,
end: m.index + m[0].length,
node: user ? (
<button
key={`m-${i}`}
type="button"
className="j13-mention-token"
onClick={(e) => {
e.stopPropagation();
onOpenCard(user);
}}
onContextMenu={(e) => e.stopPropagation()}
>
@{user.nickname || user.username}
</button>
) : (
<span key={`m-${i}`}>@{m[1]}</span>
),
});
i++;
}
for (const s of emojiSpans) {
spans.push({
start: s.start,
end: s.end,
node: (
<EmojiImg
key={`e-${s.start}`}
k={s.key}
className={`j13-chat-emoji${emojiOnly ? " j13-chat-emoji-lg" : ""}`}
/>
),
});
}
spans.sort((a, b) => a.start - b.start);
const parts: React.ReactNode[] = [];
let prev = 0;
spans.forEach((s) => {
if (s.start > prev) parts.push(content.slice(prev, s.start));
parts.push(s.node);
prev = s.end;
});
if (prev < content.length) parts.push(content.slice(prev));
return parts;
}
/** 消息正文:URL 自动识别 + @提及;外链新开跟随站点策略 */
function renderChatContent(
content: string,
users: Map<string, User>,
onOpenCard: (u: User) => void
) {
return (
<AutolinkText
text={content}
forceNewTab
renderText={(seg) => <>{highlightMentions(seg, users, onOpenCard)}</>}
/>
);
}
/**
* 编辑框提及方案:内容直接存「@昵称」(宽度即昵称宽度,光标/选区天然对齐),
* mentionTokens 记录 昵称→用户名 映射,发送时再翻译回 @用户名(后端按用户名解析提醒)。
* 昵称可含空格/@ 等任意字符,因此按字面匹配扫描(最长优先 + 边界校验 + 占位去重)。
*/
function collectMentionSpans(
text: string,
tokens: Map<string, string>
): { start: number; end: number; key: string }[] {
const spans: { start: number; end: number; key: string }[] = [];
if (!text || tokens.size === 0) return spans;
const keys = [...tokens.keys()].sort((a, b) => b.length - a.length);
const occupied = new Array<boolean>(text.length).fill(false);
for (const key of keys) {
if (!key) continue;
const needle = `@${key}`;
let from = 0;
for (;;) {
const idx = text.indexOf(needle, from);
if (idx < 0) break;
from = idx + 1;
const end = idx + needle.length;
// @ 前需为行首/空白,昵称后需为空白/行尾(与插入时的「@昵称 」格式一致)
const prevCh = idx > 0 ? text[idx - 1] : "";
if (prevCh && !/\s/.test(prevCh)) continue;
const nextCh = end < text.length ? text[end] : "";
if (nextCh && !/\s/.test(nextCh)) continue;
let clash = false;
for (let i = idx; i < end; i++) {
if (occupied[i]) {
clash = true;
break;
}
}
if (clash) continue;
for (let i = idx; i < end; i++) occupied[i] = true;
spans.push({ start: idx, end, key });
}
}
return spans.sort((a, b) => a.start - b.start);
}
/**
* 富输入区(contentEditable):编辑框内直接渲染表情图片与 @ 昵称胶囊,
* content 状态始终保存纯文本(表情为 [:key:] 短代码、@ 为 @昵称),
* 发送/提醒/草稿等既有字符串逻辑不变。DOM 为临时视图,外部内容变更时整树重建。
*/
const ZWSP = "\u200B";
/** DOM 子树 → 纯文本长度(表情短代码按 data-sc 长度计,ZWSP 不计) */
function richNodeLen(node: Node): number {
if (node.nodeType === Node.TEXT_NODE) {
return (node as Text).data.split(ZWSP).join("").length;
}
if (node.nodeType !== Node.ELEMENT_NODE) return 0;
const el = node as HTMLElement;
if (el.tagName === "IMG" && el.dataset.sc) return el.dataset.sc.length;
if (el.tagName === "BR") return 1;
let sum = 0;
el.childNodes.forEach((c) => {
sum += richNodeLen(c);
});
return sum;
}
/** contentEditable DOM → content 纯文本 */
function domToText(root: HTMLElement): string {
let out = "";
root.childNodes.forEach((node) => {
out += serializeRichNode(node);
});
return out;
}
function serializeRichNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return (node as Text).data.split(ZWSP).join("");
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const el = node as HTMLElement;
if (el.tagName === "IMG" && el.dataset.sc) return el.dataset.sc;
if (el.tagName === "BR") return "\n";
let out = "";
el.childNodes.forEach((c) => {
out += serializeRichNode(c);
});
return out;
}
/** 光标在 content 纯文本中的偏移(表情按短代码长度、@ 胶囊按昵称长度计) */
function richCaretOffset(root: HTMLElement, container: Node, offsetIdx: number): number {
let count = 0;
let node: Node | null = container;
while (node && node !== root) {
const parent: HTMLElement | null = node.parentElement;
if (!parent) break;
let sib: Node | null = node.previousSibling;
while (sib) {
count += richNodeLen(sib);
sib = sib.previousSibling;
}
node = parent;
}
if (container.nodeType === Node.TEXT_NODE) {
count += (container as Text).data.slice(0, offsetIdx).split(ZWSP).join("").length;
} else if (container.nodeType === Node.ELEMENT_NODE) {
const kids = (container as Element).childNodes;
for (let i = 0; i < offsetIdx && i < kids.length; i++) {
count += richNodeLen(kids[i]);
}
}
return count;
}
/** content 纯文本偏移 → DOM 位置(仅用于定位纯文本区间,如 @ 触发串) */
function richResolveOffset(
root: HTMLElement,
target: number
): { node: Node; offset: number } {
let acc = 0;
const kids = root.childNodes;
for (let i = 0; i < kids.length; i++) {
const child = kids[i];
const len = richNodeLen(child);
if (target < acc + len) {
if (child.nodeType === Node.TEXT_NODE) {
// 文本节点可能以零宽字符开头(表情后输入的文本),偏移需按可见字符折算
const t = child as Text;
const data = t.data;
let j = 0;
while (j < data.length && data[j] === ZWSP) j++;
let need = target - acc;
while (j < data.length && need > 0) {
if (data[j] !== ZWSP) need--;
j++;
}
return { node: t, offset: j };
}
return { node: root, offset: i };
}
acc += len;
}
return { node: root, offset: kids.length };
}
/** content 纯文本 → 富 DOM(表情图 + @ 昵称胶囊 + 文本) */
function rebuildRichInput(
el: HTMLElement,
text: string,
tokens: Map<string, string>
) {
el.textContent = "";
const frag = document.createDocumentFragment();
const pieces = [
...collectMentionSpans(text, tokens).map((s) => ({ ...s, kind: "mention" as const })),
...collectDyEmojiSpans(text).map((s) => ({ ...s, kind: "emoji" as const })),
].sort((a, b) => a.start - b.start);
let last = 0;
for (const p of pieces) {
if (p.start < last) continue; // 与 @ 胶囊重叠的表情跳过
if (p.start > last) {
frag.appendChild(document.createTextNode(text.slice(last, p.start)));
}
if (p.kind === "mention") {
const span = document.createElement("span");
span.className = "j13-mention-token";
span.contentEditable = "false";
span.textContent = `@${p.key}`;
frag.appendChild(span);
} else {
frag.appendChild(makeEmojiNode(p.key));
}
last = p.end;
}
if (last < text.length) {
frag.appendChild(document.createTextNode(text.slice(last)));
}
el.appendChild(frag);
}
function makeEmojiNode(key: string): HTMLImageElement {
const img = document.createElement("img");
img.src = dyEmojiUrl(key);
img.dataset.sc = `[:${key}:]`;
img.alt = "表情";
img.title = `[:${key}:]`;
img.draggable = false;
img.className = "j13-input-emoji";
return img;
}
function placeCaretEnd(el: HTMLElement) {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
/** 发送前翻译:@昵称 → @用户名(后端按用户名解析提及) */
function translateMentionsOut(text: string, tokens: Map<string, string>): string {
if (tokens.size === 0) return text;
const spans = collectMentionSpans(text, tokens);
if (spans.length === 0) return text;
let out = "";
let last = 0;
for (const s of spans) {
out += text.slice(last, s.start) + `@${tokens.get(s.key) || s.key}`;
last = s.end;
}
return out + text.slice(last);
}
/** 服务端文本(@用户名)→ 编辑框文本(@昵称),并为可识别的提及建立 token(重新编辑草稿用) */
function hydrateMentionsIn(
text: string,
userIndex: Map<string, User>
): { text: string; tokens: Map<string, string> } {
const re = /@([A-Za-z0-9_-]{3,32})/g;
const replaces: { start: number; end: number; display: string; username: string }[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
const u = userIndex.get(m[1].toLowerCase());
if (!u) continue;
const display = u.nickname || u.username;
if (!display || display === m[1]) continue;
replaces.push({
start: m.index,
end: m.index + m[0].length,
display,
username: m[1],
});
}
const tokens = new Map<string, string>();
let out = text;
for (let i = replaces.length - 1; i >= 0; i--) {
const r = replaces[i];
out = out.slice(0, r.start) + `@${r.display}` + out.slice(r.end);
tokens.set(r.display, r.username);
}
return { text: out, tokens };
}
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);
// 「回到底部」悬浮钮:column-reverse 下 scrollTop≤0(视觉底部为 0,上翻为负),
// -scrollTop 即距底部距离;newBelowCount 为翻历史期间新到的、尚未“看见”的消息数
const [showJumpBottom, setShowJumpBottom] = useState(false);
const [newBelowCount, setNewBelowCount] = useState(0);
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 [userCtxMenu, setUserCtxMenu] = useState<{
x: number;
y: number;
user: User;
member?: ChatRoomMember;
} | null>(null);
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
/** 禁言弹窗(含时长选择,QQ 风格) */
const [muteTarget, setMuteTarget] = useState<{
uid: number;
nickname: string;
} | null>(null);
const [muteBusy, setMuteBusy] = useState(false);
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionFilter, setMentionFilter] = useState("");
const [mentionIndex, setMentionIndex] = useState(0);
const [emojiOpen, setEmojiOpen] = useState(false);
/** 从选择器/悬浮确认插入的 @:key=展示昵称,value=用户名(发送时翻译回) */
const [mentionTokens, setMentionTokens] = useState<Map<string, string>>(
() => new Map()
);
/** 用户资料卡(头像/@昵称点击弹出) */
const [userCard, setUserCard] = useState<ChatUserCardTarget | null>(null);
/** 本人撤回后的可重新编辑草稿(仅本端内存,不展示正文) */
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<HTMLDivElement>(null);
/** 富输入区最近一次同步到 DOM 的 content 纯文本(区分内部输入与外部内容变更) */
const lastRichText = useRef<string>("");
const msgRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const composerBoxRef = useRef<HTMLDivElement>(null);
/** 表情按钮+面板容器:外点(含点击输入框)即关面板 */
const emojiPanelWrapRef = useRef<HTMLDivElement>(null);
// iOS/WebKit 不支持 interactive-widget=resizes-content(仅 Android Chrome 生效):
// 软键盘弹出时直接盖住锁视口布局的底部输入框,页面 overflow:hidden 又无处可滚。
// 监听 visualViewport,把 composer 整体上移键盘遮挡高度,并给消息列表加同等
// 底部内边距让最新消息保持在输入框上方。Android 下遮挡恒为 0,自动空转。
// 不按聚焦状态门控:键盘未收起期间保持位移,避免点发送按钮时按钮下移丢点击;
// 用 scale===1 排除双指缩放造成的 visualViewport 变化(键盘不改变 scale)。
useEffect(() => {
const vv = window.visualViewport;
const composerBox = composerBoxRef.current;
if (!vv || !composerBox) return;
let raf = 0;
const apply = () => {
raf = 0;
const overlap = Math.round(
window.innerHeight - vv.offsetTop - vv.height
);
const list = listRef.current;
if (overlap > 80 && vv.scale === 1) {
composerBox.style.transform = `translateY(-${overlap}px)`;
if (list) list.style.paddingBottom = `${overlap + 16}px`;
} else {
composerBox.style.transform = "";
if (list) list.style.paddingBottom = "";
}
};
const schedule = () => {
if (!raf) raf = requestAnimationFrame(apply);
};
vv.addEventListener("resize", schedule);
vv.addEventListener("scroll", schedule);
return () => {
vv.removeEventListener("resize", schedule);
vv.removeEventListener("scroll", schedule);
if (raf) cancelAnimationFrame(raf);
composerBox.style.transform = "";
if (listRef.current) listRef.current.style.paddingBottom = "";
};
}, []);
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 userIndex = useMemo(() => {
const idx = new Map<string, User>();
const add = (u?: User | null) => {
if (u?.username) idx.set(u.username.toLowerCase(), u);
};
members.forEach((m) => add(m.user));
messages.forEach((m) => add(m.sender));
return idx;
}, [members, messages]);
const openUserCard = useCallback(
(u: User) => {
const mem = members.find((m) => m.user_id === u.id);
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted, mutedUntil: mem?.muted_until });
},
[members]
);
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]
);
// 悬浮钮/新消息引导回底:跳底 + 推进已读 + 清空待看计数
const jumpToBottom = useCallback(() => {
setNewBelowCount(0);
scrollToBottom(true);
markReadToLatest();
}, [scrollToBottom, markReadToLatest]);
useEffect(() => {
if (!canView) return;
realtime.joinRoom(roomId);
void refreshMembers();
// 列表用 flex-col-reverse,SSR/无 JS 默认即贴底,无需首屏 scrollToBottom(避免 F5 闪一下)
markReadToLatest();
// 换房时重置「回到底部」悬浮钮状态
setShowJumpBottom(false);
setNewBelowCount(0);
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();
} else {
// 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示
setNewBelowCount((c) => c + 1);
}
});
});
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]);
// 禁言/解禁实时通知:更新成员列表 + 插入系统提示
useEffect(() => {
const off = realtime.on<RtChatMemberMutedData>(
RT_CHAT_MEMBER_MUTED,
(data) => {
if (!data || data.room_id !== roomId) return;
setMembers((prev) =>
prev.map((m) =>
m.user_id === data.user_id
? { ...m, muted: data.muted, muted_until: data.muted_until }
: m
)
);
}
);
return off;
}, [roomId, members, meId]);
// 禁言/解禁系统提示(居中展示,类似撤回提示)
const mutedNotice = useMemo(() => {
const mem = members.find((m) => m.user_id === meId);
if (!mem?.muted) return null;
return mem;
}, [members, meId]);
const muteCountdown = useCountdown(mutedNotice?.muted_until, () => {
// 倒计时到期:前端自动解除禁言状态(惰性清理,无需等待 WS 或发消息)
setMembers((prev) =>
prev.map((m) =>
m.user_id === meId ? { ...m, muted: false, muted_until: null } : m
)
);
});
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:历史插在上方,scrollTop 为负;
// 新增高度 H 使锚点内容距底部多 H,需 scrollTop 再减 H(更负)保持视口不动
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:scrollTop≤0(视觉底部为 0,上翻为负),-scrollTop 即距底部距离
const fromBottom = -el.scrollTop;
// 滚向上方历史:距顶部不足 60px 时加载更多
if (el.scrollHeight - el.clientHeight - fromBottom < 60) {
loadMore();
}
if (fromBottom < 60) {
markReadToLatest();
}
// 「回到底部」:离开底部一段距离即出现,接近底部时消失
setShowJumpBottom(fromBottom > 160);
if (fromBottom < 120) {
setNewBelowCount(0);
}
}, [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 Map());
setReplyTo(null);
setMentionOpen(false);
requestAnimationFrame(() => syncComposerHeight());
try {
// 编辑框内存「@昵称」,发出前翻译回 @用户名(后端按用户名解析提及)
const { message } = await apiSendChatMessage(
roomId,
translateMentionsOut(prevContent, prevTokens),
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);
})();
/**
* 插入 @提及(DOM 级操作):
* - replaceTrigger:替换光标前未完成的 "@xx" 触发串(选择器路径)
* - 否则在光标处追加(昵称悬浮 @ 路径),必要时补前导空格
* 插入后序列化 DOM 回 content 纯文本并记录 昵称→用户名 映射
*/
const insertMention = (
username: string,
nickname: string,
opts?: { replaceTrigger?: boolean }
) => {
const el = inputRef.current;
if (!el) return;
el.focus();
const sel = window.getSelection();
let range: Range;
if (sel && sel.rangeCount > 0 && el.contains(sel.getRangeAt(0).startContainer)) {
range = sel.getRangeAt(0);
} else {
range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
}
const caret = richCaretOffset(el, range.startContainer, range.startOffset);
const text = domToText(el);
const display = nickname || username;
let removeStart = caret;
let leadSpace = false;
if (opts?.replaceTrigger) {
const at = text.slice(0, caret).lastIndexOf("@");
if (at >= 0) removeStart = at;
} else if (caret > 0 && text[caret - 1] && !/\s/.test(text[caret - 1])) {
leadSpace = true;
}
const span = document.createElement("span");
span.className = "j13-mention-token";
span.contentEditable = "false";
span.textContent = `@${display}`;
const space = document.createTextNode(" ");
const p1 = richResolveOffset(el, removeStart);
const r2 = document.createRange();
r2.setStart(p1.node, p1.offset);
const p2 = richResolveOffset(el, caret);
r2.setEnd(p2.node, p2.offset);
r2.deleteContents();
const frag = document.createDocumentFragment();
if (leadSpace) frag.appendChild(document.createTextNode(" "));
frag.appendChild(span);
frag.appendChild(space);
r2.insertNode(frag);
const r3 = document.createRange();
r3.setStartAfter(space);
r3.collapse(true);
sel?.removeAllRanges();
sel?.addRange(r3);
const next = domToText(el);
lastRichText.current = next;
setContent(next);
setMentionTokens((prev) => new Map(prev).set(display, username));
setMentionOpen(false);
setMentionFilter("");
requestAnimationFrame(syncComposerHeight);
};
const pruneMentionTokens = (text: string, tokens: Map<string, string>) => {
if (tokens.size === 0) return tokens;
const present = new Set(
collectMentionSpans(text, tokens).map((s) => s.key)
);
if (present.size === tokens.size) return tokens;
const next: Map<string, string> = new Map();
tokens.forEach((username, key) => {
if (present.has(key)) next.set(key, username);
});
return next;
};
/** 表情面板:外点(含输入框)/ Esc 关闭 */
useEffect(() => {
if (!emojiOpen) return;
const onDoc = (e: MouseEvent) => {
if (
emojiPanelWrapRef.current &&
!emojiPanelWrapRef.current.contains(e.target as Node)
) {
setEmojiOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.stopPropagation();
setEmojiOpen(false);
inputRef.current?.focus();
};
document.addEventListener("mousedown", onDoc);
document.addEventListener("keydown", onKey, true);
return () => {
document.removeEventListener("mousedown", onDoc);
document.removeEventListener("keydown", onKey, true);
};
}, [emojiOpen]);
/** 表情面板选表情:光标处插入表情图节点,选完即关面板 */
const insertEmoji = (key: string) => {
const el = inputRef.current;
if (!el) return;
setEmojiOpen(false);
el.focus();
const sel = window.getSelection();
let range: Range;
if (sel && sel.rangeCount > 0 && el.contains(sel.getRangeAt(0).startContainer)) {
range = sel.getRangeAt(0);
} else {
range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
}
range.deleteContents();
const img = makeEmojiNode(key);
range.insertNode(img);
// 表情后补零宽空格,给光标一个落点(序列化时剔除)
const zw = document.createTextNode(ZWSP);
range.setStartAfter(img);
range.collapse(true);
range.insertNode(zw);
range.setStartAfter(zw);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
const next = domToText(el);
lastRichText.current = next;
setContent(next);
setMentionTokens((prev) => pruneMentionTokens(next, prev));
requestAnimationFrame(syncComposerHeight);
};
/** 外部内容变更(清空/草稿/撤回后恢复等)→ 重建富 DOM;内部输入不触发(DOM 即视图) */
useEffect(() => {
const el = inputRef.current;
if (!el || lastRichText.current === content) return;
rebuildRichInput(el, content, mentionTokens);
lastRichText.current = content;
if (document.activeElement === el) placeCaretEnd(el);
requestAnimationFrame(syncComposerHeight);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [content, mentionTokens]);
const handleRichInput = () => {
const el = inputRef.current;
if (!el || mutedNotice) return;
const text = domToText(el);
if (text === lastRichText.current) return;
if (text.length > 2000) {
// 超 2000 字:回滚到上次合法内容
rebuildRichInput(el, lastRichText.current, mentionTokens);
placeCaretEnd(el);
return;
}
lastRichText.current = text;
setContent(text);
setMentionTokens((prev) => pruneMentionTokens(text, prev));
// @ 触发检测(与原 textarea 逻辑一致)
const sel = window.getSelection();
let caret = text.length;
if (sel && sel.rangeCount > 0) {
const r = sel.getRangeAt(0);
caret = richCaretOffset(el, r.startContainer, r.startOffset);
}
const before = text.slice(0, caret);
const m = before.match(/@([^\s@]{0,32})$/);
if (m && isRealMember && !isDirect) {
setMentionOpen(true);
setMentionFilter(m[1] || "");
setMentionIndex(0);
} else {
setMentionOpen(false);
setMentionFilter("");
}
};
const handleRichKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// IME 组合中不拦截按键
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (mutedNotice) {
e.preventDefault();
return;
}
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,
pick.user.nickname || pick.user.username,
{ replaceTrigger: true }
);
}
return;
}
if (e.key === "Escape") {
e.preventDefault();
setMentionOpen(false);
return;
}
}
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) {
document.execCommand("insertLineBreak");
} else {
send();
}
}
};
/** 粘贴一律按纯文本插入(换行转 <br>),避免富文本结构污染序列化 */
const handleRichPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
if (mutedNotice) {
e.preventDefault();
return;
}
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
if (!text) return;
const el = inputRef.current;
if (!el) return;
const sel = window.getSelection();
let range: Range;
if (sel && sel.rangeCount > 0 && el.contains(sel.getRangeAt(0).startContainer)) {
range = sel.getRangeAt(0);
} else {
range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
}
range.deleteContents();
const frag = document.createDocumentFragment();
const lines = text.replace(/\r\n/g, "\n").split("\n");
lines.forEach((line, i) => {
if (i > 0) frag.appendChild(document.createElement("br"));
if (line) frag.appendChild(document.createTextNode(line));
});
range.insertNode(frag);
range.collapse(false);
sel?.removeAllRanges();
sel?.addRange(range);
handleRichInput();
};
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;
if (muted) {
// 打开时长选择弹窗(QQ 风格)
setMuteTarget({ uid, nickname });
return;
}
setConfirm({
title: "解除禁言",
message: `确定解除 ${nickname} 的禁言?`,
confirmLabel: "解禁",
action: async () => {
await apiMuteChatMember(roomId, uid, false);
setMembers((prev) =>
prev.map((m) =>
m.user_id === uid ? { ...m, muted: false, muted_until: null } : m
)
);
toast("已解禁", "ok");
},
});
};
const submitMute = async (durationMinutes: number) => {
if (!muteTarget) return;
setMuteBusy(true);
try {
await apiMuteChatMember(roomId, muteTarget.uid, true, durationMinutes);
const until =
durationMinutes > 0
? new Date(Date.now() + durationMinutes * 60_000).toISOString()
: null;
setMembers((prev) =>
prev.map((m) =>
m.user_id === muteTarget.uid
? { ...m, muted: true, muted_until: until }
: m
)
);
toast(
durationMinutes > 0 ? `已禁言 ${formatMuteDuration(durationMinutes)}` : "已永久禁言",
"ok"
);
setMuteTarget(null);
} catch (e) {
toast(e instanceof Error ? e.message : "禁言失败");
} finally {
setMuteBusy(false);
}
};
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;
}
// 草稿存的是服务端文本(@用户名),回填时翻译为 @昵称 并重建 token
const { text: display, tokens } = hydrateMentionsIn(text, userIndex);
setReplyTo(null);
setContent(display);
setMentionTokens(tokens);
requestAnimationFrame(() => {
const el = inputRef.current;
if (!el) return;
el.focus();
placeCaretEnd(el);
syncComposerHeight();
});
};
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: "profile",
label: "查看资料卡",
icon: <Users size={14} />,
onClick: () => openUserCard(m.sender),
});
}
if (canRecallMsg(m)) {
items.push({
key: "recall",
label: "撤回",
icon: <Undo2 size={14} />,
danger: true,
onClick: () => handleRecall(m.id),
});
}
return items;
};
/** 用户头像右键菜单:按权限展示成员管理项 */
const userMenuItems = (u: User, mem?: ChatRoomMember): CtxMenuItem[] => {
const close = () => setUserCtxMenu(null);
const items: CtxMenuItem[] = [
{
key: "profile",
label: "查看资料卡",
icon: <Users size={14} />,
onClick: () => {
close();
openUserCard(u);
},
},
];
if (u.id === meId || isDirect) return items;
const isTargetOwner = mem?.role === "owner";
const isTargetAdmin = mem?.role === "admin";
if (canKickMembers && !isTargetOwner) {
const muted = mem?.muted ?? false;
items.push({
key: "mute",
label: muted ? "解禁" : "禁言",
icon: <VolumeX size={14} />,
danger: !muted,
onClick: () => {
close();
handleMute(u.id, u.nickname || u.username, !muted);
},
});
items.push({
key: "kick",
label: "移出群聊",
icon: <UserX size={14} />,
danger: true,
onClick: () => {
close();
handleKick(u.id, u.nickname || u.username);
},
});
}
if (siteOwner && !isTargetOwner) {
items.push({
key: "setAdmin",
label: isTargetAdmin ? "取消管理员" : "设为管理员",
icon: <Crown size={14} />,
onClick: () => {
close();
handleSetAdmin(u.id, u.nickname || u.username, !isTargetAdmin);
},
});
}
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">
{isDirect && peer && (
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
onClick={() =>
openUserCard({
...peer,
role: "",
})
}
aria-label={`查看 ${peer.nickname || peer.username} 的资料卡`}
>
<Avatar
name={peer.nickname || peer.username}
src={peer.avatar || undefined}
size={24}
/>
</button>
)}
<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)] focus-visible: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)] focus-visible: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)] focus-visible: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)] focus-visible:bg-[var(--danger-soft)]"
style={{ color: "var(--ink-3)" }}
data-tip={isDirect ? "关闭私聊" : "退出群聊"}
>
<LogOut size={16} />
</button>
)}
</div>
</div>
<div className="relative flex-1 flex flex-col" style={{ minHeight: 0 }}>
<div
ref={listRef}
onScroll={handleScroll}
className="j13-chat-scroll flex-1 overflow-y-auto overflow-x-hidden 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)] focus-visible: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 (m.sender_id === 0) {
return (
<div
key={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)" }}
>
{m.content}
</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 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">
{!grouped && (
<div
role="button"
tabIndex={0}
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
onClick={() => openUserCard(m.sender)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserCard(m.sender);
}}
onContextMenu={(e) => {
if (m.sender_id === meId || isDirect) return;
e.preventDefault();
e.stopPropagation();
const mem = members.find((x) => x.user_id === m.sender_id);
setUserCtxMenu({
x: e.clientX,
y: e.clientY,
user: m.sender,
member: mem,
});
}}
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
>
<Avatar
name={m.sender.nickname || m.sender.username}
src={m.sender.avatar || undefined}
size={32}
/>
</div>
)}
</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>
{!isDirect && (
<>
<LevelBadge level={m.sender.level} />
{(m.sender.badges ?? []).slice(0, 1).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
</>
)}
<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] [overflow-wrap:anywhere] 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, userIndex, openUserCard)}
</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 && (
<div
role="button"
tabIndex={0}
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
onClick={() => openUserCard(m.sender)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserCard(m.sender);
}}
onContextMenu={(e) => {
if (m.sender_id === meId || isDirect) return;
e.preventDefault();
e.stopPropagation();
const mem = members.find((x) => x.user_id === m.sender_id);
setUserCtxMenu({
x: e.clientX,
y: e.clientY,
user: m.sender,
member: mem,
});
}}
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
>
<Avatar
name={m.sender.nickname || m.sender.username}
src={m.sender.avatar || undefined}
size={32}
/>
</div>
)}
</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)" }} />
)}
<button
type="button"
className={`text-[12px] truncate transition-colors ${
!isDirect && isRealMember
? "group/nm cursor-pointer text-[color:var(--ink-2)] hover:text-[color:var(--accent)]"
: "cursor-default text-[color:var(--ink-2)]"
}`}
onClick={() => {
if (!isDirect && isRealMember) {
insertMention(
m.sender.username,
m.sender.nickname || m.sender.username
);
}
}}
disabled={isDirect || !isRealMember}
>
{/* 悬浮时名字前出现 @,整个名字成为可 @ 点击对象 */}
{!isDirect && isRealMember && (
<span
aria-hidden
className="hidden group-hover/nm:inline font-semibold text-[color:var(--accent)]"
>
@
</span>
)}
{m.sender.nickname}
</button>
<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] [overflow-wrap:anywhere] 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 [overflow-wrap:anywhere]"
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, userIndex, openUserCard)}
</div>
</div>
</div>
);
})
)}
</div>
)}
</div>
{/* 回到底部:飞书式悬浮钮,贴近当前对话框右下角;翻历史期间带新消息计数 */}
{canView && showJumpBottom && (
<button
type="button"
onClick={jumpToBottom}
aria-label="回到底部"
data-tip="回到底部"
className="absolute right-4 sm:right-5 bottom-3 z-10 w-10 h-10 rounded-full flex items-center justify-center border border-gray-300 hover:border-gray-400 focus-visible:border-gray-400 transition-colors j13-toast-in text-black hover:bg-gray-50"
style={{
background: "#fff",
boxShadow: "var(--shadow)",
}}
>
<ChevronsDown size={18} />
{newBelowCount > 0 && (
<span
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-medium flex items-center justify-center rounded-full"
style={{
background: "var(--clay)",
color: "#fff",
border: "2px solid var(--bg)",
}}
>
{newBelowCount > 99 ? "99+" : newBelowCount}
</span>
)}
</button>
)}
</div>
{isRealMember ? (
<div
ref={composerBoxRef}
className="shrink-0 px-3 sm:px-4 py-3"
style={{
borderTop: "1px solid var(--line)",
background: "var(--panel)",
}}
>
{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,
m.user.nickname || m.user.username,
{ replaceTrigger: true }
);
}}
>
<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.closest("[contenteditable='true']")) return;
e.preventDefault();
inputRef.current?.focus();
}}
>
<div className="relative shrink-0 flex items-center" ref={emojiPanelWrapRef}>
<button
type="button"
disabled={!!mutedNotice}
className={`w-9 h-9 flex items-center justify-center rounded-full shrink-0 transition-colors${
mutedNotice
? " opacity-40 cursor-not-allowed"
: emojiOpen
? " bg-[var(--accent-soft)]"
: " hover:bg-[var(--panel-2)]"
}`}
style={{
color: mutedNotice
? "var(--ink-3)"
: emojiOpen
? "var(--accent)"
: "var(--ink-2)",
}}
aria-label={mutedNotice ? "禁言中,无法发送表情" : "表情"}
aria-expanded={emojiOpen}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (mutedNotice) return;
setEmojiOpen((v) => !v);
}}
>
<Smile size={19} />
</button>
{emojiOpen && !mutedNotice && (
<div className="j13-chat-emoji-panel">
<EmojiPicker onPick={insertEmoji} />
</div>
)}
</div>
<div
ref={inputRef}
contentEditable={!mutedNotice}
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label={mutedNotice ? "禁言中" : "输入消息"}
data-placeholder={
mutedNotice
? `禁言中${muteCountdown ? `,剩余 ${muteCountdown}` : "(永久)"}`
: isDirect
? "输入消息…"
: "输入消息,@ 可提醒成员"
}
className="j13-chat-scroll j13-chat-composer-input j13-chat-rich-input relative flex-1 min-w-0 text-[14px] leading-5 py-2"
style={mutedNotice ? { color: "var(--ink-3)", cursor: "not-allowed" } : undefined}
onInput={handleRichInput}
onKeyDown={handleRichKeyDown}
onPaste={handleRichPaste}
onFocus={(e) => {
if (mutedNotice) {
e.currentTarget.blur();
return;
}
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
const composer = e.currentTarget.closest(".j13-chat-composer");
composer?.scrollIntoView({ block: "end", behavior: "smooth" });
}}
/>
<button
type="button"
onClick={send}
disabled={sending || !content.trim() || !!mutedNotice}
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="j13-chat-scroll 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}
onOpenCard={(mem) =>
setUserCard({
user: mem.user,
roomRole: mem.role,
muted: mem.muted,
mutedUntil: mem.muted_until,
})
}
/>
)}
{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="j13-chat-scroll 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}
onOpenCard={(mem) =>
setUserCard({
user: mem.user,
roomRole: mem.role,
muted: mem.muted,
mutedUntil: mem.muted_until,
})
}
showTitle
/>
</aside>
)}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={msgMenuItems(ctxMenu.message)}
onClose={() => setCtxMenu(null)}
/>
)}
{userCtxMenu && (
<ContextMenu
x={userCtxMenu.x}
y={userCtxMenu.y}
items={userMenuItems(userCtxMenu.user, userCtxMenu.member)}
onClose={() => setUserCtxMenu(null)}
/>
)}
{userCard && (
<ChatUserCard
target={userCard}
meId={meId}
onClose={() => setUserCard(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);
}}
/>
<MuteModal
open={!!muteTarget}
nickname={muteTarget?.nickname ?? ""}
busy={muteBusy}
onConfirm={submitMute}
onClose={() => {
if (!muteBusy) setMuteTarget(null);
}}
/>
</div>
);
}
/** 禁言时长选项(分钟),0 = 永久,参考 QQ 禁言 */
const MUTE_DURATIONS: { value: number; label: string }[] = [
{ value: 10, label: "10 分钟" },
{ value: 60, label: "1 小时" },
{ value: 12 * 60, label: "12 小时" },
{ value: 24 * 60, label: "1 天" },
{ value: 3 * 24 * 60, label: "3 天" },
{ value: 7 * 24 * 60, label: "7 天" },
{ value: 30 * 24 * 60, label: "30 天" },
{ value: 0, label: "永久" },
];
function formatMuteDuration(minutes: number): string {
return MUTE_DURATIONS.find((d) => d.value === minutes)?.label ?? `${minutes} 分钟`;
}
/** 禁言系统提示用的时长格式:返回「x天x小时x分钟」 */
function formatMuteCountdown(untilIso: string): string {
const until = new Date(untilIso).getTime();
const diff = until - Date.now();
if (diff <= 0) return "";
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
if (days > 0) return `${days} 天 ${hours} 小时`;
if (hours > 0) return `${hours} 小时 ${minutes} 分钟`;
return `${minutes} 分钟`;
}
/** 成员列表禁言徽标内的剩余时长,永久/null 返回空串 */
function formatMuteRemainShort(until?: string | null): string {
if (!until) return "";
const diff = new Date(until).getTime() - Date.now();
if (diff <= 0) return "";
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
if (days > 0) return ` ${days}d`;
if (hours > 0) return ` ${hours}h`;
return ` ${minutes}m`;
}
/** 禁言倒计时:返回「x天x小时x分钟」或 null(永久/未禁言);到期触发 onExpire */
function useCountdown(
until?: string | null,
onExpire?: () => void
): string | null {
const [text, setText] = useState<string | null>(null);
const expiredRef = useRef(false);
useEffect(() => {
expiredRef.current = false;
if (!until) {
setText(null);
return;
}
const update = () => {
const diff = new Date(until).getTime() - Date.now();
if (diff <= 0) {
if (!expiredRef.current) {
expiredRef.current = true;
onExpire?.();
}
setText("已到期");
return;
}
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
const seconds = Math.floor((diff % 60_000) / 1000);
if (days > 0) setText(`${days} 天 ${hours} 小时`);
else if (hours > 0) setText(`${hours} 小时 ${minutes} 分钟`);
else if (minutes > 0) setText(`${minutes} 分 ${seconds} 秒`);
else setText(`${seconds} 秒`);
};
update();
const t = setInterval(update, 1000);
return () => clearInterval(t);
}, [until, onExpire]);
return text;
}
/** 禁言成员弹窗(QQ 风格:选择时长) */
function MuteModal({
open,
nickname,
busy,
onConfirm,
onClose,
}: {
open: boolean;
nickname: string;
busy: boolean;
onConfirm: (durationMinutes: number) => void;
onClose: () => void;
}) {
const [duration, setDuration] = useState<number>(24 * 60);
useEffect(() => {
if (open) setDuration(24 * 60);
}, [open]);
return (
<Modal
open={open}
title="禁言成员"
onClose={onClose}
busy={busy}
maxWidthClass="max-w-sm"
role="alertdialog"
footer={
<>
<button
type="button"
onClick={onClose}
disabled={busy}
className="btn btn-line btn-sm"
>
取消
</button>
<button
type="button"
onClick={() => onConfirm(duration)}
disabled={busy}
className="btn btn-primary btn-sm"
>
{busy ? "禁言中..." : "禁言"}
</button>
</>
}
>
<div className="space-y-3">
<p className="text-[13px]" style={{ color: "var(--ink-2)" }}>
选择 <span style={{ color: "var(--ink)" }}>{nickname}</span> 的禁言时长,到期自动解除。
</p>
<div className="grid grid-cols-2 gap-2">
{MUTE_DURATIONS.map((d) => {
const active = d.value === duration;
return (
<button
key={d.value}
type="button"
onClick={() => setDuration(d.value)}
className="h-9 rounded-lg text-[13px] transition-colors"
style={{
background: active ? "var(--accent-soft)" : "var(--bg)",
color: active ? "var(--accent)" : "var(--ink-2)",
border: `1px solid ${active ? "var(--accent)" : "var(--line)"}`,
}}
>
{d.label}
</button>
);
})}
</div>
</div>
</Modal>
);
}
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,
onOpenCard,
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;
onOpenCard: (m: ChatRoomMember) => void;
showTitle?: boolean;
}) {
const urlStyle = useUrlStyle();
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"
>
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
onClick={() => onOpenCard(m)}
aria-label={`查看 ${name} 的资料卡`}
>
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
</button>
<Link
href={userPath(urlStyle, 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)" }}>
· 禁言{formatMuteRemainShort(m.muted_until)}
</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-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-visible:opacity-100 lg:group-focus-within:opacity-100 transition-opacity text-[11px] px-1.5 py-0.5 rounded hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={{ color: "var(--accent)", background: "var(--accent-soft)" }}
>
{isTargetAdmin ? "取消管" : "设管"}
</button>
)}
{canActOn && (
<>
<button
type="button"
onClick={() => onMute(m.user_id, name, !m.muted)}
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-visible:opacity-100 lg:group-focus-within:opacity-100 transition-opacity text-[11px] px-1.5 py-0.5 rounded hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
{m.muted ? "解禁" : "禁言"}
</button>
<button
type="button"
onClick={() => onKick(m.user_id, name)}
aria-label={`移出 ${name}`}
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-visible:opacity-100 lg:group-focus-within:opacity-100 transition-opacity w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--danger-soft)] focus-visible: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>
);
}