feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -19,21 +19,26 @@ import {
|
||||
User as UserIcon,
|
||||
ChevronDown,
|
||||
ShieldCheck,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import { apiLogout, apiMe, type User } from "@/lib/api";
|
||||
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
|
||||
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
||||
import type { RtChatUnreadData } from "@/lib/realtime";
|
||||
import NotificationBell from "./NotificationBell";
|
||||
import Avatar from "./Avatar";
|
||||
|
||||
export default function Header({
|
||||
initialUser,
|
||||
initialUnread = 0,
|
||||
initialChatUnread = 0,
|
||||
initialTheme = "light",
|
||||
}: {
|
||||
initialUser: User | null;
|
||||
initialUnread?: number;
|
||||
initialChatUnread?: number;
|
||||
initialTheme?: "light" | "dark";
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -43,6 +48,7 @@ export default function Header({
|
||||
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
|
||||
const [user, setUser] = useState<User | null>(initialUser);
|
||||
const [unread, setUnread] = useState(initialUnread);
|
||||
const [chatUnread, setChatUnread] = useState(initialChatUnread);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -55,6 +61,10 @@ export default function Header({
|
||||
setUnread(initialUnread);
|
||||
}, [initialUnread]);
|
||||
|
||||
useEffect(() => {
|
||||
setChatUnread(initialChatUnread);
|
||||
}, [initialChatUnread]);
|
||||
|
||||
// 同页内资料变更(如上传/删除头像)立即生效,不依赖 router.refresh 时序
|
||||
useEffect(() => onUserUpdate((patch) => {
|
||||
setUser((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||
@@ -84,10 +94,49 @@ export default function Header({
|
||||
.then((res) => {
|
||||
setUser(res.user);
|
||||
if (typeof res.unread_count === "number") setUnread(res.unread_count);
|
||||
if (typeof res.chat_unread_count === "number")
|
||||
setChatUnread(res.chat_unread_count);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 实时未读:chat:unread 推送给所有成员(含未打开会话页);在当前会话页则忽略
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const off = realtime.on<RtChatUnreadData>(RT_CHAT_UNREAD, (data) => {
|
||||
if (!data || typeof data.room_id !== "number") return;
|
||||
if (pathname === `/chat/${data.room_id}`) return;
|
||||
const delta = typeof data.delta === "number" ? data.delta : 1;
|
||||
setChatUnread((c) => Math.max(0, c + delta));
|
||||
});
|
||||
return off;
|
||||
}, [user, pathname]);
|
||||
|
||||
// 会话内已读后主动对账(ChatRoomClient 派发)
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const onSync = () => {
|
||||
apiChatUnreadSummary()
|
||||
.then((res) => setChatUnread(res.total || 0))
|
||||
.catch(() => {});
|
||||
};
|
||||
window.addEventListener("j13-chat-unread-sync", onSync);
|
||||
return () => window.removeEventListener("j13-chat-unread-sync", onSync);
|
||||
}, [user]);
|
||||
|
||||
// 切回标签页时重新对账未读
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
apiChatUnreadSummary()
|
||||
.then((res) => setChatUnread(res.total || 0))
|
||||
.catch(() => {});
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisible);
|
||||
return () => document.removeEventListener("visibilitychange", onVisible);
|
||||
}, [user]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const q = searchQuery.trim();
|
||||
@@ -237,6 +286,39 @@ export default function Header({
|
||||
>
|
||||
{theme === "dark" ? <Sun size={17} /> : <Moon size={17} />}
|
||||
</button>
|
||||
{user && (
|
||||
<Link
|
||||
href="/chat"
|
||||
prefetch={false}
|
||||
aria-label="消息"
|
||||
className="relative inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
|
||||
style={{
|
||||
color: pathname?.startsWith("/chat")
|
||||
? "var(--accent)"
|
||||
: "var(--ink-2)",
|
||||
border: `1px solid ${
|
||||
pathname?.startsWith("/chat") ? "var(--accent)" : "var(--line)"
|
||||
}`,
|
||||
background: pathname?.startsWith("/chat")
|
||||
? "var(--accent-soft)"
|
||||
: "var(--panel)",
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={17} />
|
||||
{chatUnread > 0 && (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-bold flex items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "var(--clay)",
|
||||
color: "#fff",
|
||||
border: "2px solid var(--bg)",
|
||||
}}
|
||||
>
|
||||
{chatUnread > 99 ? "99+" : chatUnread}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
{user && <NotificationBell initialUnread={unread} />}
|
||||
{user ? (
|
||||
<>
|
||||
@@ -289,6 +371,21 @@ export default function Header({
|
||||
<div className="flex flex-col">
|
||||
<MobileLink href="/" active={pathname === "/"}>首页</MobileLink>
|
||||
{user && <MobileLink href="/compose">发帖</MobileLink>}
|
||||
{user && (
|
||||
<MobileLink href="/chat" active={pathname?.startsWith("/chat") ?? false}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<MessageSquare size={15} /> 消息
|
||||
{chatUnread > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold rounded-full"
|
||||
style={{ background: "var(--clay)", color: "#fff" }}
|
||||
>
|
||||
{chatUnread > 99 ? "99+" : chatUnread}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</MobileLink>
|
||||
)}
|
||||
{user && (
|
||||
<MobileLink href="/notifications">
|
||||
<span className="inline-flex items-center gap-2"><Bell size={15} /> 通知中心</span>
|
||||
|
||||
Reference in New Issue
Block a user