fix(*): 邮箱与系统公告 - 合并 PR #417 并修复迁移冲突和提醒关闭逻辑
保留历史数据库迁移谱系与公告按需加载,复用邮件配置快照校验注册域名,补充白名单及公告回归测试。
This commit is contained in:
@@ -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")}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user