fix(*): 邮箱与系统公告 - 合并 PR #417 并修复迁移冲突和提醒关闭逻辑

保留历史数据库迁移谱系与公告按需加载,复用邮件配置快照校验注册域名,补充白名单及公告回归测试。
This commit is contained in:
ddCat
2026-09-05 16:39:45 +08:00
18 changed files with 560 additions and 79 deletions

View File

@@ -1,7 +1,7 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Tag } from "antd";
import { Bell } from "lucide-react";
import { lazy, Suspense, useState, type CSSProperties } from "react";
import { lazy, Suspense, useEffect, useState, type CSSProperties } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { aceternityMotion } from "@/lib/aceternity-motion";
@@ -11,6 +11,7 @@ const AnnouncementTimelineModal = lazy(() => import("@/components/ui/aceternity/
const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000;
const ANNOUNCEMENT_CACHE_TTL_MS = 60_000;
const ANNOUNCEMENT_DISMISS_TODAY_PREFIX = "yingce.announcements.dismiss-today";
type AnnouncementFeed = Awaited<ReturnType<typeof getAnnouncementFeed>>;
@@ -21,12 +22,15 @@ type SystemAnnouncementCenterProps = {
showLabel?: boolean;
labelClassName?: string;
staticMotion?: boolean;
autoOpen?: boolean;
};
export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false }: SystemAnnouncementCenterProps) {
export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false, autoOpen = false }: SystemAnnouncementCenterProps) {
const reducedMotion = useReducedMotion();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [automaticPrompt, setAutomaticPrompt] = useState(false);
const [dismissedFingerprint, setDismissedFingerprint] = useState("");
const queryKey = ["system-announcements", userId] as const;
const feedQuery = useQuery({
queryKey,
@@ -41,7 +45,16 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
const unreadCount = Math.max(0, feedQuery.data?.unreadCount || 0);
const error = feedQuery.error instanceof Error ? feedQuery.error.message : feedQuery.error ? "读取公告失败" : "";
useEffect(() => {
if (!autoOpen || open || !feedQuery.isSuccess || unreadCount <= 0 || announcements.length === 0) return;
const fingerprint = announcementFeedFingerprint(announcements);
if (!fingerprint || fingerprint === dismissedFingerprint || announcementAutoPromptSuppressed(userId)) return;
setAutomaticPrompt(true);
setOpen(true);
}, [announcements, autoOpen, dismissedFingerprint, feedQuery.isSuccess, open, unreadCount, userId]);
const openAnnouncements = async () => {
setAutomaticPrompt(false);
setOpen(true);
const feed = (await feedQuery.refetch()).data;
if (!feed?.unreadCount) return;
@@ -55,6 +68,14 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
}
};
const dismissAutomaticPrompt = (duration: "once" | "today") => {
const fingerprint = announcementFeedFingerprint(announcements);
if (fingerprint) setDismissedFingerprint(fingerprint);
if (duration === "today") rememberAnnouncementDismissalToday(userId);
setAutomaticPrompt(false);
setOpen(false);
};
return (
<>
<motion.button
@@ -93,9 +114,47 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel =
</motion.button>
{open ? (
<Suspense fallback={null}>
<AnnouncementTimelineModal open announcements={announcements} loading={feedQuery.isFetching} error={announcements.length ? "" : error} onClose={() => setOpen(false)} onRetry={() => void feedQuery.refetch()} />
<AnnouncementTimelineModal
open
announcements={announcements}
loading={feedQuery.isFetching}
error={announcements.length ? "" : error}
automaticPrompt={automaticPrompt}
onClose={() => dismissAutomaticPrompt("once")}
onDismissOnce={() => dismissAutomaticPrompt("once")}
onDismissToday={() => dismissAutomaticPrompt("today")}
onRetry={() => void feedQuery.refetch()}
/>
</Suspense>
) : null}
</>
);
}
function announcementFeedFingerprint(announcements: AnnouncementFeed["announcements"]) {
return announcements
.map((announcement) => `${announcement.id}:${announcement.publishedAt}`)
.sort()
.join("|");
}
function announcementAutoPromptSuppressed(userId: string) {
try {
return localStorage.getItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`) === localDateKey();
} catch {
return false;
}
}
function rememberAnnouncementDismissalToday(userId: string) {
try {
localStorage.setItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`, localDateKey());
} catch {
// 浏览器禁用存储时仍允许关闭弹窗,本次组件生命周期内不会重复打开。
}
}
function localDateKey() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
}

View File

@@ -47,7 +47,7 @@ export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen:
</div>
<div className="flex shrink-0 items-center gap-1">
{user ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-topbar-icon-button" /> : null}
{user ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-topbar-icon-button" autoOpen /> : null}
<AnimatedThemeToggler className="app-workspace-topbar-icon-button" theme={theme} onThemeChange={setTheme} aria-label="切换主题" />
<WorkspaceAccountMenu />
</div>