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>

View File

@@ -1,5 +1,5 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Image as AntImage, Modal } from "antd";
import { Button, Image as AntImage, Modal } from "antd";
import { Bell, BellOff, CircleAlert, Info, Pin, ShieldAlert, Wrench } from "lucide-react";
import { aceternityMotion } from "@/lib/aceternity-motion";
@@ -11,7 +11,10 @@ type AnnouncementTimelineModalProps = {
announcements: SystemAnnouncement[];
loading?: boolean;
error?: string;
automaticPrompt?: boolean;
onClose: () => void;
onDismissOnce?: () => void;
onDismissToday?: () => void;
onRetry?: () => void;
};
@@ -22,7 +25,7 @@ const levelMeta: Record<AnnouncementLevel, { label: string; dot: string; icon: t
critical: { label: "重要通知", dot: "bg-red-500", icon: ShieldAlert },
};
export function AnnouncementTimelineModal({ open, announcements, loading = false, error = "", onClose, onRetry }: AnnouncementTimelineModalProps) {
export function AnnouncementTimelineModal({ open, announcements, loading = false, error = "", automaticPrompt = false, onClose, onDismissOnce, onDismissToday, onRetry }: AnnouncementTimelineModalProps) {
const reducedMotion = useReducedMotion();
return (
@@ -30,7 +33,12 @@ export function AnnouncementTimelineModal({ open, announcements, loading = false
open={open}
width={960}
centered
footer={null}
footer={automaticPrompt ? (
<div className="flex flex-wrap justify-end gap-2">
<Button onClick={onDismissToday}>今日不再提醒</Button>
<Button type="primary" onClick={onDismissOnce}>关闭本次</Button>
</div>
) : null}
onCancel={onClose}
title={
<div className="flex min-w-0 items-center gap-3 pr-8">
@@ -111,7 +119,7 @@ function AnnouncementTimelineItem({ announcement, last, reducedMotion }: { annou
{announcement.pinned ? <span className="inline-flex items-center gap-1 text-[var(--fs-label)] font-medium text-amber-500"><Pin className="size-3" />置顶</span> : null}
</div>
{announcement.imageUrl ? <AntImage src={announcementImageUrl(announcement)} alt={`${announcement.title} 配图`} preview className="mt-3 max-h-36 w-56 max-w-full rounded-lg border border-border/70 bg-muted/20 object-contain p-1" /> : null}
<AnnouncementContent content={announcement.content} className="mt-1 text-sm leading-6 text-foreground/75 sm:text-[var(--fs-body-lg)]" />
{announcement.content ? <AnnouncementContent content={announcement.content} className="mt-1 text-sm leading-6 text-foreground/75 sm:text-[var(--fs-body-lg)]" /> : null}
<time dateTime={announcement.publishedAt} className="mt-2 block text-xs tabular-nums text-foreground/40">{relativeTime(announcement.publishedAt)} · {formatDateTime(announcement.publishedAt)}</time>
</div>
</motion.article>