diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index dba9be50b..d4e380bf0 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -1164,6 +1164,7 @@ type SiteSettings = { bannerEnabled: boolean; bannerText: string; bannerLink: string | null; + bannerActionText: string; toolbox1Name: string; toolbox1Enabled: boolean; toolbox2Name: string; @@ -2150,6 +2151,7 @@ export function AdminSettingsClient() { bannerEnabled: true, bannerText: "限时特惠:升级会员最高立享 45% OFF!", bannerLink: null, + bannerActionText: "立即查看", toolbox1Name: "工具箱", toolbox1Enabled: true, toolbox2Name: "工具箱2", @@ -2270,6 +2272,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", @@ -12327,7 +12330,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 8720029e7..a6db2a86b 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 折优惠", @@ -1444,6 +1454,7 @@ export async function GET(request: NextRequest) { bannerEnabled: true, bannerText: "限时特惠:升级会员最高立享 45% OFF!", bannerLink: null, + bannerActionText: "立即查看", toolbox1Name: "工具箱", toolbox1Enabled: true, toolbox2Name: "工具箱2", @@ -1625,6 +1636,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(), @@ -3085,6 +3097,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 6f9928930..d29c05920 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -70,6 +70,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"; type ImageAnnotatorComponent = typeof import("@/components/image-annotator").ImageAnnotator; @@ -431,6 +437,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" @@ -604,6 +614,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[]; @@ -643,6 +657,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 = @@ -654,6 +694,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" @@ -1522,9 +1604,19 @@ export function LovartHome({ return (
+ {shouldShowHomeBanner ? ( + + ) : null} window.scrollTo({ top: 0, behavior: "smooth" })} showBrand={false} siteName={siteName || "Lovart"} @@ -1779,6 +1871,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} @@ -1789,7 +1882,12 @@ export function LovartHome({ tone={resolvedTheme === "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 6b76a41bb..d9f817e59 100644 --- a/components/home/lovart/lovart-sidebar.tsx +++ b/components/home/lovart/lovart-sidebar.tsx @@ -51,6 +51,7 @@ type LovartSidebarProps = { toolbox1Links: NavLink[]; toolbox2Enabled: boolean; toolbox2Links: NavLink[]; + topOffset?: number; tutorialEnabled: boolean; tutorialVideos: TutorialVideo[]; videoCanvasEnabled: boolean; @@ -81,6 +82,7 @@ export function LovartSidebar({ toolbox1Links, toolbox2Enabled, toolbox2Links, + topOffset = 0, tutorialEnabled, tutorialVideos, videoCanvasEnabled, @@ -180,6 +182,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 32af236b0..6d369f7b1 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-[68px] bg-white/90 backdrop-blur dark:bg-[#0a0a0c]/90", className )} + style={position === "fixed" ? { top: offsetTop } : undefined} >