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

811 lines
28 KiB
TypeScript
Raw Permalink 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, 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<ChatRoomView[]>(initialRooms);
const [query, setQuery] = useState("");
const [userHits, setUserHits] = useState<User[]>([]);
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<ReturnType<typeof setTimeout> | null>(null);
/** 本人刚发出的消息 ID:拉列表时若水位尚未跟上,避免把这条算成未读 */
const lastSentIdByRoom = useRef<Map<number, number>>(new Map());
/** 搜索重开私聊:router.push 完成前 activeRoomId 仍为 null,列表刷新需据此抑制目标会话未读 */
const enteringRoomIdRef = useRef<number | null>(null);
/** 拉列表/SSR 回填时,当前打开的会话未读一律置 0(已读请求可能尚未落库) */
const applyRooms = useCallback(
(list: ChatRoomView[]) => {
const next = (list || []).map((r) => {
if ((activeRoomId && r.id === activeRoomId) || r.id === enteringRoomIdRef.current) {
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<RtChatUnreadData>(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<RtChatRecalledData>(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<RtChatMembershipData>(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;
if (enteringRoomIdRef.current === activeRoomId) {
enteringRoomIdRef.current = null;
}
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);
enteringRoomIdRef.current = room.id;
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 (
<div
className="panel overflow-hidden grid grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)] flex-1 min-h-0"
style={{
borderColor: "var(--line)",
}}
>
{/* 左栏:会话列表 */}
<aside
className={`${listClass} flex-col min-h-0`}
style={{ borderRight: "1px solid var(--line)" }}
>
<div
className="flex items-center justify-between gap-2 px-4 py-3 shrink-0"
style={{ borderBottom: "1px solid var(--line)" }}
>
<h1 className="font-extrabold text-[17px]" style={{ color: "var(--ink)" }}>
消息
</h1>
<button
onClick={() => setShowCreate(true)}
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--accent)" }}
aria-label="新建群聊"
data-tip="新建群聊"
>
<Plus size={18} />
</button>
</div>
<div className="px-3 py-2.5 shrink-0 space-y-1.5">
<label
className="j13-chat-search flex items-center gap-2 px-3 h-10 rounded-xl cursor-text transition-[border-color,box-shadow] duration-150"
style={{
background: "var(--bg)",
border: "1px solid var(--line)",
}}
>
<Search size={15} className="shrink-0 pointer-events-none" style={{ color: query ? "var(--accent)" : "var(--ink-3)" }} />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索会话、用户名或昵称"
className="j13-chat-search-input bg-transparent flex-1 self-stretch h-full text-[13px] min-w-0"
style={{ color: "var(--ink)" }}
/>
{query && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
setQuery("");
}}
aria-label="清除搜索"
className="w-5 h-5 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] shrink-0"
style={{ color: "var(--ink-3)" }}
>
<X size={12} />
</button>
)}
</label>
{query.trim() && (
<p className="px-1 text-[11px] meta">
{searching
? "正在搜索用户…"
: `会话 ${filteredRooms.length} · 用户 ${userHits.length}`}
</p>
)}
</div>
<div className="j13-chat-scroll flex-1 overflow-y-auto min-h-0">
{query.trim() && (
<div className="px-2 pb-2">
<p
className="px-2 py-1.5 text-[11px] uppercase tracking-wider"
style={{ color: "var(--ink-3)" }}
>
用户 {searching ? "…" : ""}
</p>
{userHits.length === 0 && !searching ? (
<p className="px-2 py-2 text-[12px] meta">无匹配用户</p>
) : (
userHits.map((u) => (
<button
key={u.id}
onClick={() => openDM(u.id)}
className="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-left hover:bg-[var(--panel-2)] transition-colors"
>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={36} />
<div className="min-w-0">
<p className="text-[13px] truncate" style={{ color: "var(--ink)" }}>
{u.nickname || u.username}
</p>
<p className="text-[11px] meta truncate">@{u.username}</p>
</div>
</button>
))
)}
<p
className="px-2 py-1.5 mt-1 text-[11px] uppercase tracking-wider"
style={{ color: "var(--ink-3)" }}
>
会话
</p>
</div>
)}
{filteredRooms.length === 0 ? (
<div className="text-center py-16 px-4">
<MessageSquare size={28} style={{ color: "var(--ink-3)", margin: "0 auto 8px" }} />
<p className="text-[13px] meta">
{query ? "无匹配会话" : "还没有会话,搜索用户发起私聊或新建群聊"}
</p>
</div>
) : (
<ul className="px-1.5 pb-2">
{filteredRooms.map((room) => {
const active = room.id === activeRoomId;
const pinned = isPinnedView(room);
return (
<li key={room.id}>
<Link
href={`/chat/${room.id}`}
className="flex items-center gap-2.5 px-2.5 py-2.5 rounded-xl transition-colors"
style={{
background: active ? "var(--accent-soft)" : "transparent",
}}
onContextMenu={(e) => {
if (room.is_default || room.pin_forced) return;
e.preventDefault();
openRoomMenu(room, e.clientX, e.clientY);
}}
onTouchStart={(e) => {
if (room.is_default || room.pin_forced) return;
const t = e.touches[0];
if (!t) return;
clearLongPress();
longPressTimer.current = setTimeout(() => {
openRoomMenu(room, t.clientX, t.clientY);
}, 480);
}}
onTouchEnd={clearLongPress}
onTouchMove={clearLongPress}
>
<span
className="w-10 h-10 shrink-0 rounded-xl flex items-center justify-center overflow-hidden"
style={{ background: "var(--panel-2)" }}
>
{room.room_type === "direct" && room.peer ? (
<Avatar
name={room.peer.nickname || room.peer.username}
src={room.peer.avatar || undefined}
size={40}
/>
) : (
<Users size={18} style={{ color: "var(--accent)" }} />
)}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span
className="text-[15px] truncate"
style={{ color: "var(--ink)" }}
>
{roomTitle(room)}
</span>
{pinned && (
<span
className="inline-flex items-center gap-0.5 text-[10px] shrink-0 px-1.5 py-0.5 rounded"
style={{
background: "var(--accent-soft)",
color: "var(--accent)",
}}
data-tip={room.is_default ? "强制置顶" : "置顶"}
>
<Pin size={10} /> 置顶
</span>
)}
{room.is_default && (
<span className="text-[10px] shrink-0" style={{ color: "var(--gold)" }}>
大厅
</span>
)}
{room.is_private && room.room_type === "group" && (
<span className="text-[10px] shrink-0 meta">私密</span>
)}
<span className="ml-auto meta text-[10.5px] shrink-0">
{formatTime(room.last_message?.created_at || room.updated_at)}
</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-[12px] truncate meta flex-1">{roomPreview(room)}</p>
{room.unread_count > 0 && (
<span
className="shrink-0 min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-medium flex items-center justify-center"
style={{ background: "var(--clay)", color: "#fff" }}
>
{room.unread_count > 99 ? "99+" : room.unread_count}
</span>
)}
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</aside>
{/* 右栏:会话内容 */}
<section className={`${paneClass} flex-col min-h-0 min-w-0`}>
{children ? (
children
) : (
<div className="flex-1 flex flex-col items-center justify-center px-6 text-center">
<span
className="w-16 h-16 rounded-2xl flex items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<MessageSquare size={28} />
</span>
<p className="text-[16px]" style={{ color: "var(--ink)" }}>
选择一个会话开始聊天
</p>
<p className="meta mt-1.5 text-[13px] max-w-xs">
在左侧搜索用户发起私聊,或新建群聊。全站大厅已为你自动加入。
</p>
{showList && (
<p className="lg:hidden meta mt-4 text-[12px]">请从上方列表选择会话</p>
)}
</div>
)}
</section>
{showCreate && (
<CreateGroupModal
onClose={() => setShowCreate(false)}
onCreated={(id) => {
setShowCreate(false);
toast("群已创建", "ok");
router.push(`/chat/${id}`);
void refreshRooms({ force: true });
}}
/>
)}
{ctxMenu && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
items={
[
{
key: "pin",
label: ctxMenu.room.pinned ? "取消置顶" : "置顶",
icon: <Pin size={14} />,
onClick: () => togglePin(ctxMenu.room),
},
] as CtxMenuItem[]
}
onClose={() => setCtxMenu(null)}
/>
)}
</div>
);
}
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 (
<Modal
open
title="新建群聊"
onClose={onClose}
busy={submitting}
maxWidthClass="max-w-md"
footer={
<>
<button type="button" onClick={onClose} disabled={submitting} className="btn btn-line btn-sm">
取消
</button>
<button type="button" onClick={submit} disabled={submitting} className="btn btn-primary btn-sm">
{submitting ? "创建中..." : "创建"}
</button>
</>
}
>
<div className="space-y-4">
<div>
<label className="block text-[12px] mb-1.5" style={{ color: "var(--ink-2)" }}>
群名称 <span style={{ color: "var(--danger)" }}>*</span>
</label>
<input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={32}
placeholder="2-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={3}
className="field resize-none"
/>
</div>
<div>
<label className="block text-[12px] mb-2" style={{ color: "var(--ink-2)" }}>
可见性
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setIsPrivate(false)}
className="flex-1 h-9 rounded-lg text-[13px]"
style={{
background: !isPrivate ? "var(--accent-soft)" : "var(--bg)",
color: !isPrivate ? "var(--accent)" : "var(--ink-2)",
border: `1px solid ${!isPrivate ? "var(--accent)" : "var(--line)"}`,
}}
>
公开
</button>
<button
type="button"
onClick={() => setIsPrivate(true)}
className="flex-1 h-9 rounded-lg text-[13px]"
style={{
background: isPrivate ? "var(--accent-soft)" : "var(--bg)",
color: isPrivate ? "var(--accent)" : "var(--ink-2)",
border: `1px solid ${isPrivate ? "var(--accent)" : "var(--line)"}`,
}}
>
私密
</button>
</div>
</div>
{isPrivate && (
<div>
<label className="block text-[12px] mb-1.5" style={{ color: "var(--ink-2)" }}>
初始成员(选填)
</label>
<input
type="text"
value={memberInput}
onChange={(e) => setMemberInput(e.target.value)}
placeholder="用户名,多个用空格或逗号分隔"
className="field"
/>
</div>
)}
</div>
</Modal>
);
}