feat(banner): 首页常驻滚动通知与管理配置及类型样式支持

This commit is contained in:
✨await
2026-09-17 23:17:09 +08:00
parent 772c551989
commit a3b6761ba9
34 changed files with 3345 additions and 10 deletions

View File

@@ -1,6 +1,7 @@
import { lazy, Suspense, useEffect, useState, type ReactNode } from "react";
import { useLocation, useNavigate } from "react-router";
import { BannerAnnouncementsSlider } from "@/components/layout/banner-announcements-slider";
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
import { WorkspaceSidebarNav } from "@/components/layout/workspace-sidebar-nav";
import { readWorkspaceSidebarCollapsed, writeWorkspaceSidebarCollapsed } from "@/components/layout/workspace-sidebar-state";
@@ -80,6 +81,8 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
<div className={cn("app-workspace-shell flex h-dvh min-h-0 w-full flex-col overflow-hidden", spatialWorkbench && "is-spatial", creationWorkspace && "is-creation-workspace")}>
{!hideChrome && mobileSidebarExpanded ? <button type="button" className="app-workspace-sidebar-scrim lg:hidden" aria-label="收起侧栏" onClick={() => setMobileSidebarExpanded(false)} /> : null}
{showGlobalTopBar ? <BannerAnnouncementsSlider /> : null}
<div className="app-workspace-main-row flex min-h-0 min-w-0 flex-1 overflow-hidden">
{!hideChrome ? (
<aside

View File

@@ -0,0 +1,53 @@
import { ChevronRight } from "lucide-react";
import { bannerNoticeSurfaceCSS } from "@/lib/announcements/banner-notice";
import { bannerTitleRunStyle, type BannerTitleRun } from "@/lib/announcements/banner-title";
/**
* 通知条的展示单元:底色、富文本标题、详情入口。
*
* 首页通知条与后台「前台效果预览」共用这里的每一个部件——后台 DIY 出来的结果之所以能原样回显,
* 靠的就是两处不各写一份渲染,而不是靠两边样式对齐。
* 图标素材(emoji)是标题文本的一部分,随 BannerAnnouncementTitle 一起渲染,这里不单独处理。
*/
/** 通知条底色:由通知类型决定,明暗两档交给 token 在各自主题下解析,组件不感知主题。 */
export function bannerAnnouncementBarStyle(noticeType?: string | null) {
return { backgroundColor: bannerNoticeSurfaceCSS(noticeType) };
}
/** 「详情 ›」入口;仅在通知可点击时由调用方渲染。 */
export function BannerAnnouncementLinkHint({ chevronClassName }: { chevronClassName?: string }) {
return (
<span className="flex shrink-0 items-center gap-1 text-[var(--fs-label)] font-semibold text-white">
<span>详情</span>
<ChevronRight className={chevronClassName || "size-3.5"} aria-hidden="true" />
</span>
);
}
/**
* 通知标题:按样式分段输出行内 span,未设置样式的维度沿用外层类名(通知条为白色 12px 半粗)。
* 分段为空时回落到纯文本标题,避免旧数据不显示。
*/
export function BannerAnnouncementTitle({ runs, fallbackText }: { runs?: BannerTitleRun[] | null; fallbackText?: string }) {
if (!runs || !runs.length) {
return <>{fallbackText || ""}</>;
}
return (
<>
{runs.map((run, index) => {
const style = bannerTitleRunStyle(run);
return (
<span
key={`${index}:${run.text}`}
style={style}
className={!run.color ? "text-white" : undefined}
>
{run.text}
</span>
);
})}
</>
);
}

View File

@@ -0,0 +1,144 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { getActiveBanners, type BannerAnnouncement } from "@/services/api/announcements";
import {
BannerAnnouncementLinkHint,
BannerAnnouncementTitle,
bannerAnnouncementBarStyle,
} from "@/components/layout/banner-announcement-content";
import { useUserStore } from "@/stores/use-user-store";
const BANNER_AUTO_SLIDE_MS = 4000;
const BANNER_DISMISS_STORAGE_PREFIX = "yingce.banner-announcements.dismissed";
export function BannerAnnouncementsSlider() {
const navigate = useNavigate();
const reducedMotion = useReducedMotion();
const userId = useUserStore((state) => state.user?.id);
const [currentIndex, setCurrentIndex] = useState(0);
const [dismissed, setDismissed] = useState(false);
const { data } = useQuery({
queryKey: ["active-banner-announcements"],
queryFn: getActiveBanners,
staleTime: 60_000,
refetchInterval: 3 * 60_000,
});
const banners = data?.banners || [];
const fingerprint = bannerFeedFingerprint(banners);
const hasLink = Boolean(banners[currentIndex % Math.max(banners.length, 1)]?.link);
useEffect(() => {
if (banners.length <= 1) return;
const timer = window.setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % banners.length);
}, BANNER_AUTO_SLIDE_MS);
return () => window.clearInterval(timer);
}, [banners.length]);
const handleClick = () => {
const current = banners[currentIndex % Math.max(banners.length, 1)];
if (!current?.link) return;
if (/^https?:\/\//i.test(current.link)) {
window.open(current.link, "_blank", "noopener,noreferrer");
return;
}
navigate(current.link);
};
// banners 数量变化时把下标收拢到有效范围,避免残留越界值。
useEffect(() => {
if (banners.length > 0 && currentIndex >= banners.length) {
setCurrentIndex(0);
}
}, [banners.length, currentIndex]);
// 关闭状态按「用户 + 通知快照」记录:后台新增或重新编辑通知后快照变化,通知条会再次出现。
useEffect(() => {
setDismissed(fingerprint ? hasBannerDismissal(userId, fingerprint) : false);
}, [fingerprint, userId]);
const handleDismiss = () => {
rememberBannerDismissal(userId, fingerprint);
setDismissed(true);
};
if (!banners.length || dismissed) return null;
const currentBanner = banners[currentIndex % banners.length];
// 底色随每条通知的类型切换:加过渡让不同风格的条目轮播时平滑换色,与标题动画节奏一致。
return (
<div
className="relative flex min-h-10 w-full shrink-0 items-center justify-center px-4 py-2 text-white transition-colors duration-300 ease-out"
style={bannerAnnouncementBarStyle(currentBanner.noticeType)}
>
<button
type="button"
onClick={handleClick}
disabled={!hasLink}
className="group flex h-full w-full min-w-0 items-center justify-center px-8 transition-opacity enabled:hover:opacity-90 disabled:cursor-default"
title={hasLink ? "点击查看详情" : undefined}
>
<span className="flex min-w-0 items-center justify-center gap-2.5">
{/* 标题内容区:通过外层弹性居中对齐,不用固定高度控制 */}
<span className="relative flex min-w-0 items-center justify-center">
<AnimatePresence mode="wait">
<motion.span
key={currentBanner.id}
initial={reducedMotion ? { opacity: 0, y: 0 } : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={reducedMotion ? { opacity: 0, y: 0 } : { opacity: 0, y: -10 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="inline-flex items-center justify-center truncate font-semibold tracking-wide drop-shadow-sm"
>
<BannerAnnouncementTitle runs={currentBanner.titleRuns} fallbackText={currentBanner.title} />
</motion.span>
</AnimatePresence>
</span>
{hasLink ? <BannerAnnouncementLinkHint chevronClassName="size-3.5 transition-transform group-hover:translate-x-0.5" /> : null}
</span>
</button>
<button
type="button"
onClick={handleDismiss}
aria-label="关闭通知"
title="关闭"
className="absolute right-3 top-1/2 grid size-6 -translate-y-1/2 place-items-center rounded-full text-white transition-colors hover:bg-white/20"
>
<X className="size-3.5" aria-hidden="true" />
</button>
</div>
);
}
function bannerFeedFingerprint(banners: BannerAnnouncement[]) {
return banners
.map((banner) => `${banner.id}:${banner.updatedAt}`)
.sort()
.join("|");
}
function bannerDismissalKey(userId?: string) {
return `${BANNER_DISMISS_STORAGE_PREFIX}.${userId || "anonymous"}`;
}
function hasBannerDismissal(userId: string | undefined, fingerprint: string) {
try {
return localStorage.getItem(bannerDismissalKey(userId)) === fingerprint;
} catch {
return false;
}
}
function rememberBannerDismissal(userId: string | undefined, fingerprint: string) {
try {
localStorage.setItem(bannerDismissalKey(userId), fingerprint);
} catch {
// 浏览器禁用存储时仍允许关闭,本次组件生命周期内不会重复出现。
}
}