Files
jiang13-bbs/frontend/app/chat/[id]/ChatRoomClient.tsx
freefire 1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00

2278 lines
78 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,
} from "lucide-react";
import {
apiListChatMessages,
apiSendChatMessage,
apiMarkChatRead,
apiListChatMembers,
apiLeaveChatRoom,
apiDissolveChatRoom,
apiUpdateChatRoom,
apiKickChatMember,
apiMuteChatMember,
apiSetChatMemberRole,
apiInviteChatMembers,
apiRecallChatMessage,
type ChatMessage,
type ChatRoomMember,
type User,
} from "@/lib/api";
import {
realtime,
RT_CHAT_MESSAGE,
RT_CHAT_MEMBERSHIP,
RT_CHAT_RECALLED,
} from "@/lib/realtime";
import type {
RtChatMessageData,
RtChatMembershipData,
RtChatRecalledData,
} from "@/lib/realtime";
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
import { toast } from "@/lib/toast";
import { scrollBehavior } from "@/lib/motion";
import Avatar from "@/components/Avatar";
import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
/** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */
const MARK_READ_GAP_MS = 2_000;
const markReadInflight = new Set<number>();
const markReadLastAt = new Map<number, number>();
function requestMarkChatRead(roomId: number, opts?: { force?: boolean }): void {
const now = Date.now();
const last = markReadLastAt.get(roomId) || 0;
if (markReadInflight.has(roomId)) return;
if (!opts?.force && now - last < MARK_READ_GAP_MS) return;
markReadInflight.add(roomId);
markReadLastAt.set(roomId, now);
apiMarkChatRead(roomId, 0)
.then(() => {
window.dispatchEvent(new Event("j13-chat-unread-sync"));
})
.catch(() => {})
.finally(() => {
markReadInflight.delete(roomId);
});
}
/** 成员列表:并发去重(进房 effect 与面板刷新) */
const membersInflight = new Set<number>();
type RightPanel = null | "members" | "settings" | "invite";
type ConfirmState = {
title: string;
message: string;
confirmLabel?: string;
danger?: boolean;
action: () => Promise<void> | void;
} | null;
/** 撤回提示:本人 / 他人撤回 / 管理员代撤 */
function RecallNotice({
message,
meId,
members,
}: {
message: ChatMessage;
meId: number;
members: ChatRoomMember[];
}) {
if (!message.recalled_at) return null;
const by =
message.recalled_by ||
message.recaller?.id ||
message.sender_id;
// 操作者不是原发送者 → 监管代撤
const adminRecall = !!(by && by !== message.sender_id);
if (by === meId) {
return (
<span>
{adminRecall ? "你撤回了一条成员消息" : "你撤回了一条消息"}
</span>
);
}
let recaller: User | null | undefined =
message.recaller && message.recaller.id
? message.recaller
: by === message.sender_id
? message.sender
: null;
if ((!recaller || !recaller.id) && by > 0) {
const hit = members.find((x) => x.user_id === by);
if (hit) recaller = hit.user;
}
const name =
(recaller?.nickname || recaller?.username || "").trim() || "有人";
const uid = recaller?.id || by;
const nameStyle = {
color: "color-mix(in srgb, var(--accent) 72%, var(--ink))",
} as const;
const nameNode =
uid > 0 ? (
<Link
href={`/u/${uid}`}
className="transition-opacity hover:opacity-80"
style={nameStyle}
>
{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
) {
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 = users.get(m[1].toLowerCase());
parts.push(
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>
)
);
last = m.index + m[0].length;
i++;
}
if (last < content.length) parts.push(content.slice(last));
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);
}
/** 输入区镜像层:已确认的 @ 以蓝色昵称呈现(胶囊样式在发出后的气泡里) */
function renderComposerMirror(content: string, tokens: Map<string, string>) {
if (!content) return null;
const spans = collectMentionSpans(content, tokens);
const parts: React.ReactNode[] = [];
let last = 0;
spans.forEach((s, i) => {
if (s.start > last) parts.push(content.slice(last, s.start));
parts.push(
<span key={`t-${i}`} className="j13-mention-token">
@{s.key}
</span>
);
last = s.end;
});
if (last < content.length) parts.push(content.slice(last));
// 末尾换行在 pre-wrap 镜像里需要占位,否则高度会少一行
if (content.endsWith("\n")) parts.push("\n");
return parts;
}
/** 发送前翻译:@昵称 → @用户名(后端按用户名解析提及) */
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 };
}
/** 光标前是否紧贴一个已确认的 @token(含可选尾随空格) */
function mentionTokenBefore(
text: string,
cursor: number,
tokens: Map<string, string>
): { start: number; end: number } | null {
for (const s of collectMentionSpans(text, tokens)) {
if (s.end === cursor) return { start: s.start, end: cursor };
if (s.end + 1 === cursor && text[s.end] === " ") {
return { start: s.start, end: cursor };
}
}
return null;
}
/** 光标后是否紧跟一个已确认的 @token(含可选尾随空格) */
function mentionTokenAfter(
text: string,
cursor: number,
tokens: Map<string, string>
): { start: number; end: number } | null {
for (const s of collectMentionSpans(text, tokens)) {
if (s.start !== cursor) continue;
const end = s.end < text.length && text[s.end] === " " ? s.end + 1 : s.end;
return { start: cursor, end };
}
return null;
}
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 [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);
/** 从选择器/悬浮确认插入的 @: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<HTMLTextAreaElement>(null);
const mirrorRef = useRef<HTMLDivElement>(null);
const msgRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const isOwner = myRole === "owner";
const isRoomAdmin = myRole === "admin";
const isDirect = roomType === "direct";
const isRealMember = myRole === "owner" || myRole === "admin" || myRole === "member";
const canView = joined;
const oversee = canOverseeChat(meRole);
const siteOwner = isSiteOwner(meRole);
const canModerateChat =
oversee || isOwner || isRoomAdmin || (!!canManageMessages && !isDirect);
const canManageInvite = !isDirect && (isOwner || isRoomAdmin || oversee);
const canKickMembers = isOwner || isRoomAdmin || oversee;
/** @解析索引:成员表 + 消息发送者(成员未加载/已退群仍能显示昵称) */
const 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 });
},
[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]);
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);
})();
/**
* 插入 @提及:
* - replaceTrigger:替换光标前未完成的 "@xx" 触发串(选择器路径)
* - 否则在光标处追加(昵称悬浮 @ 路径),必要时补前导空格
*/
const insertMention = (
username: string,
nickname: string,
opts?: { replaceTrigger?: boolean }
) => {
const el = inputRef.current;
const val = content;
const cursor = el?.selectionStart ?? val.length;
let before = val.slice(0, cursor);
const after = val.slice(cursor);
if (opts?.replaceTrigger) {
const at = before.lastIndexOf("@");
if (at >= 0) before = before.slice(0, at);
} else if (before && !/\s$/.test(before)) {
before = `${before} `;
}
// 编辑框直接写入昵称(宽度即昵称宽度),映射表记录昵称→用户名
const display = nickname || username;
const next = `${before}@${display} ${after}`;
setContent(next);
setMentionTokens((prev) => new Map(prev).set(display, username));
setMentionOpen(false);
setMentionFilter("");
requestAnimationFrame(() => {
const pos = before.length + display.length + 2;
el?.focus();
el?.setSelectionRange(pos, pos);
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;
};
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(/@([^\s@]{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,
pick.user.nickname || pick.user.username,
{ replaceTrigger: true }
);
}
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();
}
};
/** 光标落点在已确认 @token 内部时吸附到 token 末尾(token 原子化,飞书体验) */
const onComposerSelect = () => {
const el = inputRef.current;
if (!el || mentionTokens.size === 0) return;
const start = el.selectionStart;
const end = el.selectionEnd;
if (start !== end) return;
for (const s of collectMentionSpans(content, mentionTokens)) {
if (start > s.start && start < s.end) {
el.setSelectionRange(s.end, s.end);
return;
}
}
};
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
if (m.recalled_at) return;
setCtxMenu({ x, y, message: m });
};
const clearLongPress = () => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
};
const togglePanel = (panel: Exclude<RightPanel, null>) => {
setRightPanel((cur) => (cur === panel ? null : panel));
};
const runConfirm = async () => {
if (!confirm) return;
setConfirmBusy(true);
try {
await confirm.action();
setConfirm(null);
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
} finally {
setConfirmBusy(false);
}
};
const handleLeave = () => {
if (isDefault) {
toast("默认大厅不可退出");
return;
}
setConfirm({
title: isDirect ? "关闭私聊" : "退出群聊",
message: isDirect
? "确定关闭该私聊?之后可从对方主页再次打开。"
: "确定退出该群?退出后可重新加入。",
confirmLabel: isDirect ? "关闭" : "退出",
danger: true,
action: async () => {
await apiLeaveChatRoom(roomId);
toast(isDirect ? "已关闭私聊" : "已退出群聊", "ok");
router.push("/chat");
},
});
};
const handleDissolve = () => {
if (isDefault) {
toast("默认大厅不可解散");
return;
}
setConfirm({
title: "解散群聊",
message: "确定解散该群?所有成员将被移出,消息历史保留但不再可达。",
confirmLabel: "解散",
danger: true,
action: async () => {
await apiDissolveChatRoom(roomId);
toast("群已解散", "ok");
router.push("/chat");
},
});
};
const handleKick = (uid: number, nickname: string) => {
if (!canKickMembers) return;
setConfirm({
title: "移出成员",
message: `确定将 ${nickname} 移出该群?`,
confirmLabel: "移出",
danger: true,
action: async () => {
await apiKickChatMember(roomId, uid);
setMembers((prev) => prev.filter((m) => m.user_id !== uid));
setMemberCount((c) => Math.max(0, c - 1));
toast("已移出", "ok");
},
});
};
const handleMute = (uid: number, nickname: string, muted: boolean) => {
if (!canKickMembers) return;
setConfirm({
title: muted ? "禁言成员" : "解除禁言",
message: muted
? `确定禁言 ${nickname}?禁言后无法在本群发言。`
: `确定解除 ${nickname} 的禁言?`,
confirmLabel: muted ? "禁言" : "解禁",
danger: muted,
action: async () => {
await apiMuteChatMember(roomId, uid, muted);
setMembers((prev) =>
prev.map((m) => (m.user_id === uid ? { ...m, muted } : m))
);
toast(muted ? "已禁言" : "已解禁", "ok");
},
});
};
const handleSetAdmin = (uid: number, nickname: string, makeAdmin: boolean) => {
if (!siteOwner) return;
setConfirm({
title: makeAdmin ? "设为群管理员" : "取消群管理员",
message: makeAdmin
? `确定将 ${nickname} 设为本群管理员?其可撤回消息、踢人与禁言。`
: `确定取消 ${nickname} 的群管理员身份?`,
confirmLabel: makeAdmin ? "任命" : "撤销",
danger: !makeAdmin,
action: async () => {
await apiSetChatMemberRole(roomId, uid, makeAdmin ? "admin" : "member");
setMembers((prev) =>
prev.map((m) =>
m.user_id === uid ? { ...m, role: makeAdmin ? "admin" : "member" } : m
)
);
toast(makeAdmin ? "已设为群管理员" : "已取消群管理员", "ok");
},
});
};
const canRecallMsg = (m: ChatMessage) => {
if (m.recalled_at) return false;
if (canModerateChat) return true;
return m.sender_id === meId && isRealMember;
};
const handleRecall = (mid: number) => {
const snapshot = messages.find((m) => m.id === mid);
setCtxMenu(null);
setConfirm({
title: "撤回消息",
message: "确定撤回这条消息?",
confirmLabel: "撤回",
danger: true,
action: async () => {
const { message } = await apiRecallChatMessage(roomId, mid);
const body = (message.content || snapshot?.content || "").trim();
const mine = (snapshot?.sender_id ?? message.sender_id) === meId;
if (mine && body) rememberReEdit(mid, body);
setMessages((prev) =>
prev.map((m) =>
m.id === mid
? {
...m,
content: canModerateChat ? body || m.content : "",
recalled_at: message.recalled_at || new Date().toISOString(),
recalled_by: message.recalled_by,
recaller: message.recaller || m.recaller,
}
: m
)
);
toast("已撤回", "ok");
},
});
};
const handleReEdit = (mid: number) => {
const text = takeReEditDraft(mid);
if (!text) {
toast("重新编辑已过期");
return;
}
// 草稿存的是服务端文本(@用户名),回填时翻译为 @昵称 并重建 token
const { text: display, tokens } = hydrateMentionsIn(text, userIndex);
setReplyTo(null);
setContent(display);
setMentionTokens(tokens);
requestAnimationFrame(() => {
const el = inputRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(display.length, display.length);
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
});
};
const scrollToMsg = (id: number) => {
const el = msgRefs.current.get(id);
el?.scrollIntoView({ behavior: scrollBehavior(), block: "center" });
};
const msgMenuItems = (m: ChatMessage): CtxMenuItem[] => {
const items: CtxMenuItem[] = [
{
key: "reply",
label: "回复",
icon: <Reply size={14} />,
onClick: () => {
setReplyTo(m);
inputRef.current?.focus();
},
},
{
key: "copy",
label: "复制",
icon: <Copy size={14} />,
onClick: async () => {
try {
await navigator.clipboard.writeText(m.content);
toast("已复制", "ok");
} catch {
toast("复制失败");
}
},
},
];
if (!isDirect && isRealMember && m.sender_id !== meId) {
items.push({
key: "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;
};
// 点击空白关闭右键菜单 — 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 (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 && (
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
onClick={() => openUserCard(m.sender)}
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
>
<Avatar
name={m.sender.nickname || m.sender.username}
src={m.sender.avatar || undefined}
size={32}
/>
</button>
)}
</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 && (
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
onClick={() => openUserCard(m.sender)}
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
>
<Avatar
name={m.sender.nickname || m.sender.username}
src={m.sender.avatar || undefined}
size={32}
/>
</button>
)}
</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
className="shrink-0 px-3 sm:px-4 py-3"
style={{ borderTop: "1px solid var(--line)" }}
>
{replyTo && (
<div
className="flex items-start gap-2 mb-2 px-3 py-2 rounded-xl"
style={{
background: "var(--bg)",
border: "1px solid var(--line)",
}}
>
<div
className="w-1 self-stretch rounded-full shrink-0"
style={{ background: "var(--accent)" }}
/>
<div className="flex-1 min-w-0">
<p className="text-[11px]" style={{ color: "var(--accent)" }}>
回复 {replyTo.sender.nickname || replyTo.sender.username}
</p>
<p className="text-[12px] truncate meta mt-0.5">
{replyTo.recalled_at ? "(已撤回的消息)" : replyTo.content}
</p>
</div>
<button
type="button"
onClick={() => setReplyTo(null)}
aria-label="取消回复"
className="w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
<X size={13} />
</button>
</div>
)}
<div className="relative">
{mentionOpen && mentionCandidates.length > 0 && (
<div
className="absolute bottom-full left-0 right-0 mb-2 rounded-xl overflow-hidden shadow-lg z-10"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
}}
>
{mentionCandidates.map((m, i) => (
<button
key={m.id}
type="button"
className="w-full flex items-center gap-2.5 px-3 py-2 text-left"
style={{
background: i === mentionIndex ? "var(--accent-soft)" : "transparent",
}}
onMouseDown={(e) => {
e.preventDefault();
insertMention(
m.user.username,
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.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}
onSelect={onComposerSelect}
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-scroll 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)" }}
onFocus={(e) => {
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
const composer = e.currentTarget.closest(".j13-chat-composer");
composer?.scrollIntoView({ block: "end", behavior: "smooth" });
}}
onInput={(e) => {
const t = e.currentTarget;
t.style.height = "auto";
t.style.height = Math.min(t.scrollHeight, 128) + "px";
}}
/>
</div>
<button
type="button"
onClick={send}
disabled={sending || !content.trim()}
className="btn btn-accent btn-sm gap-1.5 h-9 px-3.5 shrink-0 rounded-full"
aria-label="发送"
>
<Send size={14} strokeWidth={2.25} />
发送
</button>
</div>
</div>
</div>
) : (
<div
className="px-4 sm:px-5 py-4 shrink-0 text-center"
style={{ borderTop: "1px solid var(--line)" }}
>
{oversee && !isRealMember ? (
<p className="text-[13px] meta" style={{ color: "var(--ink-3)" }}>
监管旁路查看中(未正式入群,不可发言)
</p>
) : isPrivate ? (
<p className="text-[13px] meta" style={{ color: "var(--ink-3)" }}>
私密群需邀请才能加入
</p>
) : (
<>
<p
className="text-[13px] meta mb-2.5"
style={{ color: "var(--ink-3)" }}
>
你还不是该群成员
</p>
<JoinButton roomId={roomId} onJoined={() => router.refresh()} />
</>
)}
</div>
)}
</div>
{/* 飞书式右栏:成员 / 邀请 / 设置 */}
{canView && !isDirect && rightPanel && (
<aside
className={
embedded
? "absolute inset-0 z-30 flex flex-col sm:static sm:inset-auto sm:w-[280px] sm:shrink-0 h-full min-h-0"
: "hidden lg:flex flex-col w-[260px] panel min-h-0"
}
style={{
background: "var(--panel)",
borderLeft: "1px solid var(--line)",
}}
>
<div
className="flex items-center justify-between px-4 py-3 shrink-0"
style={{ borderBottom: "1px solid var(--line)" }}
>
<p className="text-[13px] font-medium" style={{ color: "var(--ink)" }}>
{rightPanel === "members"
? `成员 · ${memberCount}`
: rightPanel === "invite"
? "邀请成员"
: "群设置"}
</p>
<button
type="button"
onClick={() => setRightPanel(null)}
aria-label="关闭面板"
className="w-7 h-7 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
</button>
</div>
<div className="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,
})
}
/>
)}
{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,
})
}
showTitle
/>
</aside>
)}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={msgMenuItems(ctxMenu.message)}
onClose={() => setCtxMenu(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);
}}
/>
</div>
);
}
function JoinButton({
roomId,
onJoined,
}: {
roomId: number;
onJoined: () => void;
}) {
const [joining, setJoining] = useState(false);
const join = async () => {
setJoining(true);
try {
const { apiJoinChatRoom } = await import("@/lib/api");
await apiJoinChatRoom(roomId);
toast("已加入群聊", "ok");
onJoined();
} catch (e) {
toast(e instanceof Error ? e.message : "加入失败");
} finally {
setJoining(false);
}
};
return (
<button
onClick={join}
disabled={joining}
className="btn btn-accent btn-sm"
>
{joining ? "加入中..." : "加入群聊"}
</button>
);
}
function MembersPanel({
members,
meId,
canKick,
isChatOwner,
siteOwner,
onRefresh,
onKick,
onMute,
onSetAdmin,
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;
}) {
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={`/u/${m.user.id}`}
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{name}
{m.muted ? (
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
· 禁言
</span>
) : null}
</Link>
{isTargetOwner ? (
<Crown size={12} style={{ color: "var(--gold)" }} aria-label="群主" />
) : isTargetAdmin ? (
<span className="text-[10px] font-medium" style={{ color: "var(--accent)" }}>
管
</span>
) : null}
{siteOwner && !isTargetOwner && m.user_id !== meId && (
<button
type="button"
onClick={() => onSetAdmin(m.user_id, name, !isTargetAdmin)}
className="opacity-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>
);
}