"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Search, Plus, MessageSquare, Users, X, Pin, } from "lucide-react"; import { apiListChatRooms, apiSearchChatUsers, apiOpenDirectChat, apiCreateChatRoom, apiPinChatRoom, type ChatRoomView, type ChatMessage, type User, } from "@/lib/api"; import { realtime, RT_CHAT_UNREAD, RT_CHAT_MEMBERSHIP, RT_CHAT_RECALLED } from "@/lib/realtime"; import type { RtChatUnreadData, RtChatMembershipData, RtChatRecalledData } from "@/lib/realtime"; import { onDocumentVisible } from "@/lib/visibilityReconcile"; import { toast } from "@/lib/toast"; import Avatar from "@/components/Avatar"; import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu"; import Modal from "@/components/Modal"; function roomTitle(room: ChatRoomView) { if (room.room_type === "direct") { return room.peer?.nickname || room.peer?.username || room.name || "私聊"; } return room.name || "未命名群"; } function roomPreview(room: ChatRoomView) { const lm = room.last_message; if (!lm) return room.description || "暂无消息"; if (lm.recalled_at) { if (lm.recalled_by && lm.recalled_by !== lm.sender_id) { return "管理员撤回了此消息"; } return "此消息已撤回"; } const who = room.room_type === "direct" ? "" : `${lm.sender?.nickname || lm.sender?.username || ""}:`; return `${who}${lm.content || ""}`.slice(0, 48); } function formatTime(dateStr?: string) { if (!dateStr) return ""; const d = new Date(dateStr); const now = new Date(); const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate(); if (sameDay) { return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; } return `${d.getMonth() + 1}/${d.getDate()}`; } /** 与后端一致:大厅强制置顶 > 个人置顶 > last_message_id */ function sortRooms(list: ChatRoomView[]): ChatRoomView[] { return [...list].sort((a, b) => { const aHall = !!a.is_default; const bHall = !!b.is_default; if (aHall !== bHall) return aHall ? -1 : 1; const aPin = !aHall && !!a.pinned; const bPin = !bHall && !!b.pinned; if (aPin !== bPin) return aPin ? -1 : 1; if (a.last_message_id !== b.last_message_id) { return (b.last_message_id || 0) - (a.last_message_id || 0); } return b.id - a.id; }); } function isPinnedView(room: ChatRoomView) { return !!room.is_default || !!room.pinned || !!room.pin_forced; } /** * PC 端飞书式消息壳:左会话列表 + 右会话区;移动端列表/会话互斥全屏。 */ export default function ChatShell({ meId, initialRooms, activeRoomId = null, children, }: { meId: number; initialRooms: ChatRoomView[]; activeRoomId?: number | null; children?: React.ReactNode; }) { const router = useRouter(); const [rooms, setRooms] = useState(initialRooms); const [query, setQuery] = useState(""); const [userHits, setUserHits] = useState([]); const [searching, setSearching] = useState(false); const [showCreate, setShowCreate] = useState(false); const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; room: ChatRoomView; } | null>(null); const longPressTimer = useRef | null>(null); /** 本人刚发出的消息 ID:拉列表时若水位尚未跟上,避免把这条算成未读 */ const lastSentIdByRoom = useRef>(new Map()); /** 拉列表/SSR 回填时,当前打开的会话未读一律置 0(已读请求可能尚未落库) */ const applyRooms = useCallback( (list: ChatRoomView[]) => { const next = (list || []).map((r) => { if (activeRoomId && r.id === activeRoomId) { return { ...r, unread_count: 0 }; } const sentId = lastSentIdByRoom.current.get(r.id); if (sentId && (r.last_message_id || 0) <= sentId && (r.unread_count || 0) > 0) { return { ...r, unread_count: 0 }; } return r; }); setRooms(sortRooms(next)); }, [activeRoomId] ); useEffect(() => { applyRooms(initialRooms); }, [initialRooms, applyRooms]); const roomsInFlight = useRef(false); const lastRoomsFetchAt = useRef(0); const refreshRooms = useCallback( async (opts?: { force?: boolean }) => { const now = Date.now(); if (!opts?.force) { if (roomsInFlight.current) return; // 切房/回前台可合并;建群、被邀请等走 force if (now - lastRoomsFetchAt.current < 2_000) return; } roomsInFlight.current = true; lastRoomsFetchAt.current = now; try { const res = await apiListChatRooms(1, 50, "", "joined"); applyRooms(res.rooms || []); } catch { // 静默 } finally { roomsInFlight.current = false; } }, [applyRooms] ); // 进入消息壳 / 切会话:HTTP 拉最新排序(避开预取缓存;切房强制拉取以免把刚发出的消息当成未读) useEffect(() => { void refreshRooms({ force: true }); }, [refreshRooms, activeRoomId]); // 从其他标签页切回时对账(10s 限频 + 上方 2s 合并) useEffect(() => onDocumentVisible(() => void refreshRooms()), [refreshRooms]); // 实时未读:更新侧栏角标与预览,按置顶规则重排(不盲目置顶到最前) useEffect(() => { const off = realtime.on(RT_CHAT_UNREAD, (data) => { if (!data?.room_id) return; if (activeRoomId === data.room_id) return; const delta = typeof data.delta === "number" ? data.delta : 1; setRooms((prev) => { const idx = prev.findIndex((r) => r.id === data.room_id); if (idx < 0) { void refreshRooms({ force: true }); return prev; } const next = [...prev]; const cur = next[idx]; // 撤回回退:只改角标,预览由 chat:message_recalled 负责,避免覆盖「已撤回」 if (delta < 0) { next[idx] = { ...cur, unread_count: Math.max(0, (cur.unread_count || 0) + delta), }; return next; } next[idx] = { ...cur, unread_count: (cur.unread_count || 0) + delta, last_message_id: data.message_id, last_message: { id: data.message_id, room_id: data.room_id, sender_id: data.sender_id, content: data.preview || "", created_at: new Date().toISOString(), sender: cur.last_message?.sender || ({ id: data.sender_id, username: "", nickname: "", avatar: "", role: "user", } as User), }, }; return sortRooms(next); }); }); return off; }, [activeRoomId, refreshRooms]); // 本人刚发出的消息:立刻更新侧栏预览并清角标(发送者收不到 chat:unread) useEffect(() => { const onSent = (ev: Event) => { const detail = (ev as CustomEvent<{ roomId?: number; message?: ChatMessage }>).detail; if (!detail?.roomId || !detail.message) return; const { roomId, message } = detail; lastSentIdByRoom.current.set(roomId, message.id); setRooms((prev) => { const idx = prev.findIndex((r) => r.id === roomId); if (idx < 0) return prev; const next = [...prev]; const cur = next[idx]; next[idx] = { ...cur, unread_count: 0, last_message_id: message.id, last_message: message, updated_at: message.created_at || cur.updated_at, }; return sortRooms(next); }); }; window.addEventListener("j13-chat-sent", onSent); return () => window.removeEventListener("j13-chat-sent", onSent); }, []); // 撤回(含管理端):若撤回的是侧栏预览那条,实时换成撤回文案 useEffect(() => { const off = realtime.on(RT_CHAT_RECALLED, (data) => { if (!data?.room_id || !data.id) return; setRooms((prev) => { const idx = prev.findIndex((r) => r.id === data.room_id); if (idx < 0) return prev; const cur = prev[idx]; if (cur.last_message?.id !== data.id && cur.last_message_id !== data.id) { return prev; } const next = [...prev]; next[idx] = { ...cur, last_message: { id: data.id, room_id: data.room_id, sender_id: data.sender_id || cur.last_message?.sender_id || 0, content: "", created_at: cur.last_message?.created_at || new Date().toISOString(), recalled_at: data.recalled_at || new Date().toISOString(), recalled_by: data.recalled_by, sender: cur.last_message?.sender || ({ id: data.sender_id || 0, username: "", nickname: "", avatar: "", role: "user", } as User), recaller: data.recaller ? { id: data.recaller.id, username: data.recaller.username, nickname: data.recaller.nickname, avatar: data.recaller.avatar, role: data.recaller.role || "user", } : cur.last_message?.recaller, }, }; return next; }); }); return off; }, []); useEffect(() => { const off = realtime.on(RT_CHAT_MEMBERSHIP, (data) => { if (!data) return; if (data.status === "invited" || data.status === "dissolved" || data.status === "kicked") { void refreshRooms({ force: true }); } }); return off; }, [refreshRooms]); // 进房即清角标;MarkRead 成功后再清一次(防 refresh 抢在已读落库前写回旧未读) useEffect(() => { if (!activeRoomId) return; const clearActiveUnread = () => { setRooms((prev) => prev.map((r) => (r.id === activeRoomId ? { ...r, unread_count: 0 } : r)) ); }; clearActiveUnread(); window.addEventListener("j13-chat-unread-sync", clearActiveUnread); return () => window.removeEventListener("j13-chat-unread-sync", clearActiveUnread); }, [activeRoomId]); // 搜索:会话本地过滤 + 用户远程搜索 useEffect(() => { const q = query.trim(); if (!q) { setUserHits([]); return; } const t = setTimeout(async () => { setSearching(true); try { const res = await apiSearchChatUsers(q); setUserHits(res.users || []); } catch { setUserHits([]); } finally { setSearching(false); } }, 300); return () => clearTimeout(t); }, [query]); const filteredRooms = (() => { const q = query.trim().toLowerCase(); if (!q) return rooms; return rooms.filter((r) => { const title = roomTitle(r).toLowerCase(); const uname = r.peer?.username?.toLowerCase() || ""; return title.includes(q) || uname.includes(q) || r.name.toLowerCase().includes(q); }); })(); const openDM = async (uid: number) => { try { const { room } = await apiOpenDirectChat(uid); setQuery(""); setUserHits([]); router.push(`/chat/${room.id}`); void refreshRooms({ force: true }); } catch (e) { toast(e instanceof Error ? e.message : "无法发起私聊"); } }; const togglePin = async (room: ChatRoomView) => { setCtxMenu(null); if (room.is_default || room.pin_forced) { toast("大厅已置顶且不可取消"); return; } const next = !room.pinned; try { await apiPinChatRoom(room.id, next); setRooms((prev) => sortRooms( prev.map((r) => r.id === room.id ? { ...r, pinned: next } : r ) ) ); toast(next ? "已置顶" : "已取消置顶", "ok"); } catch (err) { toast(err instanceof Error ? err.message : "操作失败"); } }; const openRoomMenu = (room: ChatRoomView, x: number, y: number) => { if (room.is_default || room.pin_forced) return; setCtxMenu({ x, y, room }); }; const clearLongPress = () => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } }; const showList = !activeRoomId; // 移动端:有会话时隐藏列表;桌面始终显示列表 const listClass = activeRoomId ? "hidden lg:flex" : "flex"; const paneClass = activeRoomId ? "flex" : "hidden lg:flex"; return (
{/* 左栏:会话列表 */} {/* 右栏:会话内容 */}
{children ? ( children ) : (

选择一个会话开始聊天

在左侧搜索用户发起私聊,或新建群聊。全站大厅已为你自动加入。

{showList && (

请从上方列表选择会话

)}
)}
{showCreate && ( setShowCreate(false)} onCreated={(id) => { setShowCreate(false); toast("群已创建", "ok"); router.push(`/chat/${id}`); void refreshRooms({ force: true }); }} /> )} {ctxMenu && ( , onClick: () => togglePin(ctxMenu.room), }, ] as CtxMenuItem[] } onClose={() => setCtxMenu(null)} /> )}
); } function CreateGroupModal({ onClose, onCreated, }: { onClose: () => void; onCreated: (roomId: number) => void; }) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [isPrivate, setIsPrivate] = useState(false); const [memberInput, setMemberInput] = useState(""); const [submitting, setSubmitting] = useState(false); const submit = async () => { const trimmed = name.trim(); if (trimmed.length < 2 || trimmed.length > 32) { toast("群名称需为 2-32 个字符"); return; } if (description.length > 200) { toast("群简介不能超过 200 个字符"); return; } const usernames = memberInput .split(/[\s,,]+/) .map((s) => s.trim()) .filter(Boolean); setSubmitting(true); try { const { room } = await apiCreateChatRoom(trimmed, description.trim(), { is_private: isPrivate, member_usernames: isPrivate ? usernames : [], }); onCreated(room.id); } catch (e) { toast(e instanceof Error ? e.message : "建群失败"); } finally { setSubmitting(false); } }; return ( } >
setName(e.target.value)} maxLength={32} placeholder="2-32 个字符" className="w-full px-3.5 h-10 rounded-lg outline-none" style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }} />