"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 User, } from "@/lib/api"; import { realtime, RT_CHAT_UNREAD, RT_CHAT_MEMBERSHIP } from "@/lib/realtime"; import type { RtChatUnreadData, RtChatMembershipData } 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); useEffect(() => { setRooms(sortRooms(initialRooms)); }, [initialRooms]); 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"); setRooms(sortRooms(res.rooms || [])); } catch { // 静默 } finally { roomsInFlight.current = false; } }, []); // 进入消息壳 / 切会话:HTTP 拉最新排序(避开预取缓存) useEffect(() => { void refreshRooms(); }, [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; 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]; next[idx] = { ...cur, unread_count: (cur.unread_count || 0) + (data.delta || 1), 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]); 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]); // 进入会话后清零该会话未读 useEffect(() => { if (!activeRoomId) return; setRooms((prev) => prev.map((r) => (r.id === activeRoomId ? { ...r, unread_count: 0 } : r)) ); }, [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)" }} />