Files
ttstd-infinite-canvas/web/src/components/layout/system-announcement-center.tsx

97 lines
4.8 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.
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Tag } from "antd";
import { Bell } from "lucide-react";
import { useState, type CSSProperties } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AnnouncementTimelineModal } from "@/components/ui/aceternity/announcement-timeline-modal";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { getAnnouncementFeed, markAnnouncementsRead } from "@/services/api/announcements";
const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000;
const ANNOUNCEMENT_CACHE_TTL_MS = 60_000;
type AnnouncementFeed = Awaited<ReturnType<typeof getAnnouncementFeed>>;
type SystemAnnouncementCenterProps = {
userId: string;
className?: string;
style?: CSSProperties;
showLabel?: boolean;
labelClassName?: string;
staticMotion?: boolean;
};
export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false }: SystemAnnouncementCenterProps) {
const reducedMotion = useReducedMotion();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const queryKey = ["system-announcements", userId] as const;
const feedQuery = useQuery({
queryKey,
queryFn: getAnnouncementFeed,
enabled: Boolean(userId),
staleTime: ANNOUNCEMENT_CACHE_TTL_MS,
refetchInterval: ANNOUNCEMENT_REFRESH_INTERVAL_MS,
// 公告在打开面板时会显式 refetch;不把浏览器 focus 变成每个工作区实例的请求触发器。
refetchOnWindowFocus: false,
});
const announcements = feedQuery.data?.announcements || [];
const unreadCount = Math.max(0, feedQuery.data?.unreadCount || 0);
const error = feedQuery.error instanceof Error ? feedQuery.error.message : feedQuery.error ? "读取公告失败" : "";
const openAnnouncements = async () => {
setOpen(true);
const feed = (await feedQuery.refetch()).data;
if (!feed?.unreadCount) return;
try {
const result = await markAnnouncementsRead(feed.announcements.map((announcement) => announcement.id));
const nextUnreadCount = Math.max(0, result.unreadCount || 0);
queryClient.setQueryData<AnnouncementFeed>(queryKey, (current) => current ? { ...current, unreadCount: nextUnreadCount } : current);
if (nextUnreadCount > 0) void queryClient.invalidateQueries({ queryKey });
} catch {
// 已读状态是辅助读路径,失败时保留角标,下一次打开或轮询会继续尝试同步。
}
};
return (
<>
<motion.button
type="button"
className={className}
style={style}
whileHover={reducedMotion || staticMotion ? undefined : { y: -1, scale: 1.035 }}
whileTap={reducedMotion || staticMotion ? undefined : { scale: 0.94 }}
transition={aceternityMotion.spring.dock}
onClick={() => void openAnnouncements()}
aria-label={unreadCount ? `系统公告,${unreadCount} 条未读` : "系统公告"}
title="系统公告"
>
<span className="relative shrink-0">
<Bell className="size-4" />
<AnimatePresence initial={false}>
{unreadCount > 0 ? (
<motion.span
key="unread-dot"
initial={reducedMotion ? false : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={aceternityMotion.spring.dock}
className="absolute -right-1 -top-1 size-2 rounded-full border border-background bg-red-500"
aria-hidden
/>
) : null}
</AnimatePresence>
</span>
{showLabel ? (
<span className={`min-w-0 flex-1 items-center justify-between gap-2 whitespace-nowrap ${labelClassName || ""}`}>
<span>系统公告</span>
<Tag color={unreadCount > 0 ? "gold" : undefined} className="!m-0 !min-w-6 !px-1.5 !text-center !text-[var(--fs-micro)] !font-medium !leading-[18px] tabular-nums">{announcements.length}</Tag>
</span>
) : null}
</motion.button>
<AnnouncementTimelineModal open={open} announcements={announcements} loading={feedQuery.isFetching} error={announcements.length ? "" : error} onClose={() => setOpen(false)} onRetry={() => void feedQuery.refetch()} />
</>
);
}