feat(banner): 首页常驻滚动通知与管理配置及类型样式支持
This commit is contained in:
@@ -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
|
||||
|
||||
53
web/src/components/layout/banner-announcement-content.tsx
Normal file
53
web/src/components/layout/banner-announcement-content.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
144
web/src/components/layout/banner-announcements-slider.tsx
Normal file
144
web/src/components/layout/banner-announcements-slider.tsx
Normal 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 {
|
||||
// 浏览器禁用存储时仍允许关闭,本次组件生命周期内不会重复出现。
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user