Files
jiang13-bbs/frontend/app/chat/ChatShell.tsx
freefire 5507552ca5 polish(frontend): 侧栏与徽章可读性,智能悬停提示
降权全站字重;优化右栏公告/数据块;类型状态徽章用固定浅色区分;TipHost 仅在文案不一致、非文字或省略时显示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 03:54:14 +08:00

717 lines
24 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, 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<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);
/** 拉列表/SSR 回填时,当前打开的会话未读一律置 0(已读请求可能尚未落库) */
const applyRooms = useCallback(
(list: ChatRoomView[]) => {
const next = (list || []).map((r) =>
activeRoomId && r.id === activeRoomId ? { ...r, unread_count: 0 } : 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();
}, [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;
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<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;
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 (
<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(--accent-soft)] 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(--accent-soft)] 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="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(--accent-soft)] 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-[13.5px] 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="w-full px-3.5 h-10 rounded-lg outline-none"
style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
/>
</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="w-full px-3.5 py-2.5 rounded-lg outline-none resize-none"
style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
/>
</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="w-full px-3.5 h-10 rounded-lg outline-none"
style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
/>
</div>
)}
</div>
</Modal>
);
}