diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index fa7283585..900be2cb3 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, type ChangeEvent } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { formatBeijingTime } from "@/lib/utils"; @@ -35,6 +35,7 @@ import { } from "@/lib/sqlserver/config"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Separator } from "@/components/ui/separator"; +import { HomeAnimationEmbed } from "@/components/home/home-animation-embed"; import { Trash2, Save, @@ -127,6 +128,8 @@ import type { AgentWorkflowUiConfig } from "@/types/agent"; const fetcher = (url: string) => fetch(url).then((res) => res.json()); const DEFAULT_BFL_REALTIME_MODEL = "flux-2-klein-4b"; const DEFAULT_BFL_BASE_URL = "https://api.bfl.ai"; +const HOME_CHAT_ANIMATION_MAX_BYTES = 1024 * 1024; +const HOME_CHAT_ANIMATION_CONFIG_KEY = "home_chat_animation_config"; function createEmptyCanvasSkillTag(): CanvasSkillTag { return { @@ -321,6 +324,13 @@ type OssImageMigrationConfig = { batchSize: number; }; +type HomeAnimationConfig = { + enabled: boolean; + fileName: string; + htmlContent: string; + updatedAt: string | null; +}; + const defaultLovartPromptTagVisibility: LovartPromptTagVisibility = { nanoBananaPro: true, design: true, @@ -624,6 +634,11 @@ export function AdminSettingsClient() { `/api/admin/settings?key=${DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY}`, fetcher, ); + const { data: homeAnimationSettingData, mutate: mutateHomeAnimationSetting } = + useSWR<{ setting?: { value?: unknown } }>( + `/api/admin/settings?key=${HOME_CHAT_ANIMATION_CONFIG_KEY}`, + fetcher, + ); // 🆕 生图记录数据 const imageQueryParams = new URLSearchParams({ @@ -724,6 +739,12 @@ export function AdminSettingsClient() { membershipConfig: defaultMembershipConfig, membershipUserOverrides: defaultMembershipUserOverrides, }); + const [homeAnimationConfig, setHomeAnimationConfig] = useState({ + enabled: false, + fileName: "", + htmlContent: "", + updatedAt: null, + }); const [newOverrideUserId, setNewOverrideUserId] = useState(""); const [newOverrideEmail, setNewOverrideEmail] = useState(""); const [newOverridePlanId, setNewOverridePlanId] = useState(""); @@ -890,6 +911,25 @@ export function AdminSettingsClient() { } }, [siteSettingsData]); + useEffect(() => { + const raw = + homeAnimationSettingData?.setting?.value && + typeof homeAnimationSettingData.setting.value === "object" && + !Array.isArray(homeAnimationSettingData.setting.value) + ? (homeAnimationSettingData.setting.value as Record) + : {}; + + setHomeAnimationConfig({ + enabled: raw.enabled === true, + fileName: String(raw.fileName || "").trim(), + htmlContent: + typeof raw.htmlContent === "string" + ? raw.htmlContent.replace(/^\uFEFF/, "").replace(/\u0000/g, "") + : "", + updatedAt: String(raw.updatedAt || "").trim() || null, + }); + }, [homeAnimationSettingData]); + useEffect(() => { const normalized = normalizeDetailAgentLocalMoonshotConfig( moonshotDetailAgentSettingData?.setting?.value, @@ -1865,6 +1905,21 @@ export function AdminSettingsClient() { // --- 网站设置逻辑 --- const handleSaveSiteSettings = async () => { try { + const normalizedHomeAnimationHtml = homeAnimationConfig.htmlContent + .replace(/^\uFEFF/, "") + .replace(/\u0000/g, "") + .replace(/\u2028|\u2029/g, "\n"); + const homeAnimationBytes = new TextEncoder().encode(normalizedHomeAnimationHtml).length; + + if (homeAnimationBytes > HOME_CHAT_ANIMATION_MAX_BYTES) { + toast.error( + `首页动画 HTML 过大,当前 ${(homeAnimationBytes / 1024).toFixed(1)}KB,最大 ${( + HOME_CHAT_ANIMATION_MAX_BYTES / 1024 + ).toFixed(0)}KB` + ); + return; + } + if ( siteSettings.storageFailoverConfig.enabled && !siteSettings.storageFailoverConfig.backupStorageConfigId @@ -1925,6 +1980,12 @@ export function AdminSettingsClient() { const payload = { ...siteSettings, + homeAnimationConfig: { + enabled: homeAnimationConfig.enabled, + fileName: homeAnimationConfig.fileName.trim(), + htmlContent: normalizedHomeAnimationHtml, + updatedAt: homeAnimationConfig.updatedAt, + }, membershipConfig: parsedMembershipConfig, membershipUserOverrides: parsedMembershipOverrides, pointsPackages: parsedPointsPackages, @@ -1935,12 +1996,68 @@ export function AdminSettingsClient() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); - if (!response.ok) throw new Error("保存失败"); - setSiteSettings(payload); + const responseData = await response.json().catch(() => null); + if (!response.ok) throw new Error(responseData?.error || "保存失败"); + const { homeAnimationConfig: _ignoredHomeAnimationConfig, ...nextSiteSettings } = payload; + setSiteSettings(nextSiteSettings); + setHomeAnimationConfig((prev) => ({ + ...prev, + htmlContent: normalizedHomeAnimationHtml, + updatedAt: new Date().toISOString(), + })); toast.success("网站设置保存成功"); mutateSiteSettings(); + mutateHomeAnimationSetting(); } catch (error) { - toast.error("保存网站设置失败"); + toast.error(error instanceof Error ? error.message : "保存网站设置失败"); + } + }; + + const handleImportHomeAnimationFile = async ( + event: ChangeEvent + ) => { + const file = event.target.files?.[0]; + event.target.value = ""; + + if (!file) { + return; + } + + const normalizedFileName = String(file.name || "").trim(); + const isHtmlFile = + file.type === "text/html" || + /\.html?$/i.test(normalizedFileName); + + if (!isHtmlFile) { + toast.error("请上传 .html 文件"); + return; + } + + try { + const htmlContent = (await file.text()) + .replace(/^\uFEFF/, "") + .replace(/\u0000/g, "") + .replace(/\u2028|\u2029/g, "\n"); + const bytes = new TextEncoder().encode(htmlContent).length; + + if (bytes > HOME_CHAT_ANIMATION_MAX_BYTES) { + toast.error( + `HTML 文件过大,当前 ${(bytes / 1024).toFixed(1)}KB,最大 ${( + HOME_CHAT_ANIMATION_MAX_BYTES / 1024 + ).toFixed(0)}KB` + ); + return; + } + + setHomeAnimationConfig((prev) => ({ + ...prev, + fileName: normalizedFileName, + htmlContent, + updatedAt: null, + })); + toast.success("HTML 动效文件已导入,记得点击保存设置"); + } catch (error) { + toast.error("读取 HTML 文件失败"); } }; @@ -5815,6 +5932,90 @@ export function AdminSettingsClient() { )} +
+
+
+ +
+ 在首页对话框下方展示可交互的 HTML 动效模块,支持后续导入新 HTML 进行替换。 +
+
+ + setHomeAnimationConfig((prev) => ({ ...prev, enabled: checked })) + } + /> +
+ +
+
+
+
HTML 动效文件
+
+ 支持导入 `.html` 文件。未上传自定义文件时,系统会尝试读取项目根目录的 + `动画效果.html` 作为默认兜底。 +
+
+ 当前文件: + {homeAnimationConfig.fileName + ? ` ${homeAnimationConfig.fileName}` + : " 未上传自定义 HTML"} + {homeAnimationConfig.updatedAt + ? ` · 上次更新 ${formatBeijingTime(homeAnimationConfig.updatedAt)}` + : ""} +
+
+
+ + +
+
+ + {homeAnimationConfig.htmlContent ? ( +
+ +
+ ) : ( +
+ 当前未上传自定义 HTML。启用后会优先使用数据库中的自定义动效;如果为空,则尝试读取项目根目录的 + `动画效果.html`。 +
+ )} +
+
+ {/* 无限画布设置 */}
diff --git a/app/api/home-animation/route.ts b/app/api/home-animation/route.ts new file mode 100644 index 000000000..de1a5028e --- /dev/null +++ b/app/api/home-animation/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { getSystemSetting } from "@/lib/db/queries"; +import { + HOME_CHAT_ANIMATION_CONFIG_KEY, + resolvePublicHomeChatAnimationConfig, +} from "@/lib/home/home-animation-config"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const setting = await getSystemSetting({ + key: HOME_CHAT_ANIMATION_CONFIG_KEY, + }).catch(() => null); + + const payload = resolvePublicHomeChatAnimationConfig(setting?.value); + + return NextResponse.json(payload, { + headers: { + "Cache-Control": "no-store, max-age=0", + }, + }); + } catch { + return NextResponse.json( + { + enabled: false, + fileName: "", + htmlContent: "", + updatedAt: null, + usesDefaultFallback: false, + }, + { + headers: { + "Cache-Control": "no-store, max-age=0", + }, + }, + ); + } +} diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index b88e97b9f..4a58a5b14 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -67,6 +67,12 @@ import { normalizeOssImageMigrationConfig, OSS_MIGRATION_CONFIG_KEY, } from "@/lib/storage/migration-config"; +import { + getHomeChatAnimationHtmlBytes, + HOME_CHAT_ANIMATION_CONFIG_KEY, + HOME_CHAT_ANIMATION_MAX_BYTES, + normalizeHomeChatAnimationConfig, +} from "@/lib/home/home-animation-config"; import type { AgentCategoryTreeConfig } from "@/types/agent"; const HOME_VARIANT_KEY = "home_variant"; @@ -830,6 +836,14 @@ const updateSchema = z.object({ batchSize: z.number().min(1).max(100).optional(), }) .optional(), + homeAnimationConfig: z + .object({ + enabled: z.boolean().optional(), + fileName: z.string().trim().max(255).optional(), + htmlContent: z.string().max(2_000_000).optional(), + updatedAt: z.string().nullable().optional(), + }) + .optional(), }); // PUT /api/site-settings - 更新网站设置(管理员 / 智能体管理员) @@ -888,8 +902,49 @@ export async function PUT(request: NextRequest) { storageFailoverConfig, ossImageMigrationConfig, canvasAgentModeEnabled, + homeAnimationConfig, ...siteSettingsPayload } = data; + + const persistHomeAnimationConfig = async () => { + if (homeAnimationConfig === undefined) { + return; + } + + const normalized = normalizeHomeChatAnimationConfig(homeAnimationConfig); + const htmlBytes = getHomeChatAnimationHtmlBytes(normalized.htmlContent); + + if (htmlBytes > HOME_CHAT_ANIMATION_MAX_BYTES) { + throw new Error( + `首页动画 HTML 超过限制,当前 ${(htmlBytes / 1024).toFixed(1)}KB,最大 ${( + HOME_CHAT_ANIMATION_MAX_BYTES / 1024 + ).toFixed(0)}KB`, + ); + } + + await db + .insert(systemSettings) + .values({ + key: HOME_CHAT_ANIMATION_CONFIG_KEY, + value: { + ...normalized, + updatedAt: new Date().toISOString(), + }, + updatedAt: new Date(), + updatedBy: session.user.id, + }) + .onConflictDoUpdate({ + target: systemSettings.key, + set: { + value: { + ...normalized, + updatedAt: new Date().toISOString(), + }, + updatedAt: new Date(), + updatedBy: session.user.id, + }, + }); + }; // 查找现有设置 const [existing] = await db.select().from(siteSettings).limit(1); @@ -1371,6 +1426,8 @@ export async function PUT(request: NextRequest) { }); } + await persistHomeAnimationConfig(); + return NextResponse.json({ ...updated, homeVariant: homeVariant ?? normalizeHomeVariant(undefined), @@ -1541,6 +1598,8 @@ export async function PUT(request: NextRequest) { }); } + await persistHomeAnimationConfig(); + if (aiDetailGenerationEnabled !== undefined) { await db .insert(systemSettings) @@ -2043,6 +2102,13 @@ export async function PUT(request: NextRequest) { { status: 400 } ); } + + if (error instanceof Error) { + return NextResponse.json( + { error: error.message || "Failed to update site settings" }, + { status: 500 } + ); + } return NextResponse.json( { error: "Failed to update site settings" }, diff --git a/components/home/home-animation-embed.tsx b/components/home/home-animation-embed.tsx new file mode 100644 index 000000000..be8796fed --- /dev/null +++ b/components/home/home-animation-embed.tsx @@ -0,0 +1,53 @@ +import { cn } from "@/lib/utils"; + +type HomeAnimationEmbedProps = { + htmlContent: string; + title?: string; + description?: string; + className?: string; + frameClassName?: string; +}; + +export function HomeAnimationEmbed({ + htmlContent, + title, + description, + className, + frameClassName, +}: HomeAnimationEmbedProps) { + if (!htmlContent.trim()) { + return null; + } + + return ( +
+ {title || description ? ( +
+
+ {title ? ( +
{title}
+ ) : null} + {description ? ( +
{description}
+ ) : null} +
+
+ ) : null} + +
+