diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index ce81cfce2..92bca7c8f 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -1160,6 +1160,7 @@ type SiteSettings = { bannerEnabled: boolean; bannerText: string; bannerLink: string | null; + bannerActionText: string; toolbox1Name: string; toolbox1Enabled: boolean; toolbox2Name: string; @@ -2153,6 +2154,7 @@ export function AdminSettingsClient() { bannerEnabled: true, bannerText: "限时特惠:升级会员最高立享 45% OFF!", bannerLink: null, + bannerActionText: "立即查看", toolbox1Name: "工具箱", toolbox1Enabled: true, toolbox2Name: "工具箱2", @@ -2273,6 +2275,7 @@ export function AdminSettingsClient() { bannerText: siteSettingsData.bannerText || "限时特惠:升级会员最高立享 45% OFF!", bannerLink: siteSettingsData.bannerLink || null, + bannerActionText: siteSettingsData.bannerActionText || "立即查看", toolbox1Name: siteSettingsData.toolbox1Name || "工具箱", toolbox1Enabled: siteSettingsData.toolbox1Enabled ?? true, toolbox2Name: siteSettingsData.toolbox2Name || "工具箱2", @@ -12254,7 +12257,7 @@ export function AdminSettingsClient() {
- 在无限画布等页面的聊天面板中显示广告横幅 + 在首页顶部和无限画布聊天面板中显示广告横幅
+
+ + + setSiteSettings({ + ...siteSettings, + bannerActionText: e.target.value, + }) + } + placeholder="例如:立即升级" + value={siteSettings.bannerActionText} + /> +
- 如果不填写链接,点击横幅将不会跳转 + 首页按钮和无限画布横幅将打开此链接;不填写时只展示文案
diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 0323829a5..36a5256f0 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -579,6 +579,10 @@ const HOME_SITE_SETTINGS_FALLBACK: Record = { favicon: null, lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", lovartPromoActionText: "立即升级", + bannerEnabled: true, + bannerText: "限时特惠:升级会员最高立享 45% OFF!", + bannerLink: null, + bannerActionText: "立即查看", lovartHeroTitle: "由壹点AI 让设计更简单", lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切", surpriseCategories: ["全部", "精选"], @@ -897,6 +901,7 @@ async function getScopedPublicSiteSettings( bannerText: typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!", bannerLink: typedSettings?.bannerLink || null, + bannerActionText: typedSettings?.bannerActionText || "立即查看", lovartComposerPrefix: normalizeLovartComposerPrefix( valueByKey.get(LOVART_COMPOSER_PREFIX_KEY) ), @@ -939,6 +944,11 @@ async function getScopedPublicSiteSettings( return createScopedSiteSettingsResponse(scope, { ...createBrandingSettings(typedSettings), + bannerEnabled: typedSettings?.bannerEnabled ?? true, + bannerText: + typedSettings?.bannerText || "限时特惠:升级会员最高立享 45% OFF!", + bannerLink: typedSettings?.bannerLink || null, + bannerActionText: typedSettings?.bannerActionText || "立即查看", lovartPromoText: typedSettings?.lovartPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠", @@ -1470,6 +1480,7 @@ export async function GET(request: NextRequest) { bannerEnabled: true, bannerText: "限时特惠:升级会员最高立享 45% OFF!", bannerLink: null, + bannerActionText: "立即查看", toolbox1Name: "工具箱", toolbox1Enabled: true, toolbox2Name: "工具箱2", @@ -1651,6 +1662,7 @@ const updateSchema = z.object({ bannerEnabled: z.boolean().optional(), bannerText: z.string().optional(), bannerLink: z.string().nullable().optional(), + bannerActionText: z.string().max(32).optional(), toolbox1Name: z.string().max(64).optional(), toolbox1Enabled: z.boolean().optional(), toolbox2Name: z.string().max(64).optional(), @@ -3111,6 +3123,7 @@ export async function PUT(request: NextRequest) { bannerEnabled: siteSettingsPayload.bannerEnabled ?? true, bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!", bannerLink: siteSettingsPayload.bannerLink, + bannerActionText: siteSettingsPayload.bannerActionText ?? "立即查看", toolbox1Name: siteSettingsPayload.toolbox1Name ?? "工具箱", toolbox1Enabled: siteSettingsPayload.toolbox1Enabled ?? true, toolbox2Name: siteSettingsPayload.toolbox2Name ?? "工具箱2", diff --git a/components/home/lovart-home.tsx b/components/home/lovart-home.tsx index b83343934..afa9f43fc 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -74,6 +74,12 @@ import { LovartFeaturedGallery } from "@/components/home/lovart/lovart-featured- import { LovartHeroComposer } from "@/components/home/lovart/lovart-hero-composer"; import { LovartProjectsSection } from "@/components/home/lovart/lovart-projects-section"; import { LovartSidebar } from "@/components/home/lovart/lovart-sidebar"; +import { + LOVART_PROMO_BANNER_HEIGHT, + LovartPromoBanner, +} from "@/components/home/lovart/lovart-promo-banner"; + +const HOME_PROMO_DISMISS_STORAGE_KEY = "lovart-home-promo-dismissed-v1"; const ImageAnnotatorLazy = dynamic( () => import("@/components/image-annotator").then((module) => module.ImageAnnotator), @@ -447,6 +453,10 @@ export function LovartHome({ const [activeCategory, setActiveCategory] = useState("全部"); const [isProjectsOnlyView, setIsProjectsOnlyView] = useState(false); const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false); + const [dismissedHomeBannerIdentity, setDismissedHomeBannerIdentity] = + useState(null); + const [hasResolvedHomeBannerDismissal, setHasResolvedHomeBannerDismissal] = + useState(false); const [accountCenterOpen, setAccountCenterOpen] = useState(false); const [accountCenterTab, setAccountCenterTab] = useState<"account" | "usage" | "billing">( "account" @@ -625,6 +635,10 @@ export function LovartHome({ siteLogoDark?: string | null; lovartPromoText?: string | null; lovartPromoActionText?: string | null; + bannerEnabled?: boolean; + bannerText?: string | null; + bannerLink?: string | null; + bannerActionText?: string | null; lovartHeroTitle?: string | null; lovartHeroSubtitle?: string | null; surpriseCategories?: string[]; @@ -664,6 +678,32 @@ export function LovartHome({ siteSettingsData?.lovartPromoText ?? initialHeroPromoText; const heroPromoActionText = siteSettingsData?.lovartPromoActionText ?? initialHeroPromoActionText; + const homeBannerText = String(siteSettingsData?.bannerText || "").trim(); + const homeBannerActionText = String( + siteSettingsData?.bannerActionText || "立即查看" + ).trim(); + const rawHomeBannerLink = String(siteSettingsData?.bannerLink || "").trim(); + const homeBannerLink = + /^(https?:\/\/|\/(?!\/))/i.test(rawHomeBannerLink) + ? rawHomeBannerLink + : ""; + const homeBannerIdentity = useMemo( + () => + homeBannerText + ? JSON.stringify({ + actionText: homeBannerActionText, + link: homeBannerLink, + text: homeBannerText, + }) + : "", + [homeBannerActionText, homeBannerLink, homeBannerText] + ); + const shouldShowHomeBanner = Boolean( + siteSettingsData?.bannerEnabled === true && + homeBannerIdentity && + hasResolvedHomeBannerDismissal && + dismissedHomeBannerIdentity !== homeBannerIdentity + ); const heroTitleText = siteSettingsData?.lovartHeroTitle ?? initialHeroTitleText; const heroSubtitleText = @@ -676,6 +716,48 @@ export function LovartHome({ ? siteLogoDark || siteLogo || "" : siteLogo || siteLogoDark || ""; + useEffect(() => { + if (!homeBannerIdentity) { + setDismissedHomeBannerIdentity(null); + setHasResolvedHomeBannerDismissal(true); + return; + } + + try { + const storedIdentity = window.localStorage.getItem( + HOME_PROMO_DISMISS_STORAGE_KEY + ); + setDismissedHomeBannerIdentity( + storedIdentity === homeBannerIdentity ? homeBannerIdentity : null + ); + } catch { + setDismissedHomeBannerIdentity(null); + } + setHasResolvedHomeBannerDismissal(true); + }, [homeBannerIdentity]); + + const dismissHomeBanner = useCallback(() => { + if (!homeBannerIdentity) return; + try { + window.localStorage.setItem( + HOME_PROMO_DISMISS_STORAGE_KEY, + homeBannerIdentity + ); + } catch { + // The banner still closes for this page when storage is unavailable. + } + setDismissedHomeBannerIdentity(homeBannerIdentity); + }, [homeBannerIdentity]); + + const openHomeBannerLink = useCallback(() => { + if (!homeBannerLink) return; + if (homeBannerLink.startsWith("/")) { + router.push(homeBannerLink); + return; + } + window.open(homeBannerLink, "_blank", "noopener,noreferrer"); + }, [homeBannerLink, router]); + const { data: tutorialVideosData } = useSWR<{ videos: TutorialVideo[] }>( isSecondaryDataReady && siteSettingsData?.tutorialEnabled ? "/api/tutorial-videos" @@ -1623,9 +1705,19 @@ export function LovartHome({ return (
+ {shouldShowHomeBanner ? ( + + ) : null} window.scrollTo({ top: 0, behavior: "smooth" })} showBrand={false} siteName={siteName || "Lovart"} @@ -1878,6 +1970,7 @@ export function LovartHome({ } siteLogo={displayLogo} siteName={siteName || "Lovart"} + topOffset={shouldShowHomeBanner ? LOVART_PROMO_BANNER_HEIGHT : 0} toolbox1Enabled={siteSettingsData?.toolbox1Enabled !== false} toolbox1Links={toolbox1Links} toolbox2Enabled={siteSettingsData?.toolbox2Enabled === true} @@ -1888,7 +1981,12 @@ export function LovartHome({ tone={effectiveResolvedTheme === "dark" ? "dark" : "light"} /> -
+
{!isProjectsOnlyView && ( void; + onDismiss: () => void; + text: string; +}; + +export const LOVART_PROMO_BANNER_HEIGHT = 45; + +export function LovartPromoBanner({ + actionText, + link, + onAction, + onDismiss, + text, +}: LovartPromoBannerProps) { + const showAction = Boolean(link?.trim() && actionText?.trim()); + + return ( +
+
+ +

+ {text} +

+ {showAction ? ( + + ) : null} + +
+
+ ); +} diff --git a/components/home/lovart/lovart-sidebar.tsx b/components/home/lovart/lovart-sidebar.tsx index b8ae9e46a..e91d39be9 100644 --- a/components/home/lovart/lovart-sidebar.tsx +++ b/components/home/lovart/lovart-sidebar.tsx @@ -48,6 +48,7 @@ type LovartSidebarProps = { toolbox1Links: NavLink[]; toolbox2Enabled: boolean; toolbox2Links: NavLink[]; + topOffset?: number; tutorialEnabled: boolean; tutorialVideos: TutorialVideo[]; videoCanvasEnabled: boolean; @@ -76,6 +77,7 @@ export function LovartSidebar({ toolbox1Links, toolbox2Enabled, toolbox2Links, + topOffset = 0, tutorialEnabled, tutorialVideos, videoCanvasEnabled, @@ -167,6 +169,7 @@ export function LovartSidebar({ ? "border-white/10 bg-black" : "border-[#e9e9e9] bg-white", ].join(" ")} + style={{ top: topOffset }} >
diff --git a/components/layout/brand-top-bar.tsx b/components/layout/brand-top-bar.tsx index b904b48e9..1518f87e3 100644 --- a/components/layout/brand-top-bar.tsx +++ b/components/layout/brand-top-bar.tsx @@ -10,6 +10,7 @@ type BrandTopBarProps = { className?: string; contentClassName?: string; logo?: string | null; + offsetTop?: number; onLogoClick?: () => void; position?: "fixed" | "sticky" | "static"; showBrand?: boolean; @@ -22,6 +23,7 @@ export function BrandTopBar({ className, contentClassName, logo, + offsetTop = 0, onLogoClick, position = "fixed", showBrand = true, @@ -42,6 +44,7 @@ export function BrandTopBar({ "h-14 bg-white/90 backdrop-blur dark:bg-[#0a0a0c]/90", className )} + style={position === "fixed" ? { top: offsetTop } : undefined} >