feat: add configurable home animation module
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-24 22:53:16 +08:00
parent 900a1c9e38
commit 5568325a0d
7 changed files with 1841 additions and 4 deletions

View File

@ -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<HomeAnimationConfig>({
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<string, unknown>)
: {};
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<HTMLInputElement>
) => {
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() {
)}
</div>
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-4">
<div className="space-y-0.5">
<Label htmlFor="homeAnimationEnabled">首页对话框下互动动画模块</Label>
<div className="text-sm text-muted-foreground">
在首页对话框下方展示可交互的 HTML 动效模块,支持后续导入新 HTML 进行替换。
</div>
</div>
<Switch
id="homeAnimationEnabled"
checked={homeAnimationConfig.enabled}
onCheckedChange={(checked) =>
setHomeAnimationConfig((prev) => ({ ...prev, enabled: checked }))
}
/>
</div>
<div className="rounded-xl border bg-muted/10 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium text-black">HTML 动效文件</div>
<div className="text-xs text-muted-foreground">
支持导入 `.html` 文件。未上传自定义文件时,系统会尝试读取项目根目录的
`动画效果.html` 作为默认兜底。
</div>
<div className="text-xs text-muted-foreground">
当前文件:
{homeAnimationConfig.fileName
? ` ${homeAnimationConfig.fileName}`
: " 未上传自定义 HTML"}
{homeAnimationConfig.updatedAt
? ` · 上次更新 ${formatBeijingTime(homeAnimationConfig.updatedAt)}`
: ""}
</div>
</div>
<div className="flex flex-wrap gap-2">
<label className="inline-flex cursor-pointer">
<input
type="file"
accept=".html,text/html"
className="hidden"
onChange={handleImportHomeAnimationFile}
/>
<Button type="button" variant="outline" className="pointer-events-none">
{homeAnimationConfig.fileName ? "替换 HTML" : "导入 HTML"}
</Button>
</label>
<Button
type="button"
variant="outline"
onClick={() =>
setHomeAnimationConfig((prev) => ({
...prev,
fileName: "",
htmlContent: "",
updatedAt: null,
}))
}
disabled={!homeAnimationConfig.fileName && !homeAnimationConfig.htmlContent}
>
清空自定义 HTML
</Button>
</div>
</div>
{homeAnimationConfig.htmlContent ? (
<div className="mt-4">
<HomeAnimationEmbed
htmlContent={homeAnimationConfig.htmlContent}
title="当前导入动效预览"
description="导入后点击页面底部的保存设置即可正式生效。"
frameClassName="h-[240px] md:h-[300px]"
className="shadow-none"
/>
</div>
) : (
<div className="mt-4 rounded-xl border border-dashed border-black/10 bg-white/60 px-4 py-5 text-sm text-muted-foreground">
当前未上传自定义 HTML。启用后会优先使用数据库中的自定义动效;如果为空,则尝试读取项目根目录的
`动画效果.html`。
</div>
)}
</div>
</div>
{/* 无限画布设置 */}
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-4">

View File

@ -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",
},
},
);
}
}

View File

@ -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" },

View File

@ -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 (
<div
className={cn(
"overflow-hidden rounded-[24px] border border-black/10 bg-white shadow-[0_10px_24px_rgba(0,0,0,0.05)]",
className,
)}
>
{title || description ? (
<div className="flex items-start justify-between gap-4 border-b border-black/8 px-4 py-3">
<div className="min-w-0">
{title ? (
<div className="text-[14px] font-semibold text-black">{title}</div>
) : null}
{description ? (
<div className="mt-1 text-[12px] text-black/45">{description}</div>
) : null}
</div>
</div>
) : null}
<div className={cn("h-[360px] w-full bg-[#f6f6f6] md:h-[420px]", frameClassName)}>
<iframe
title={title || "首页互动动画"}
srcDoc={htmlContent}
sandbox="allow-scripts allow-forms allow-pointer-lock"
loading="lazy"
className="h-full w-full border-0"
/>
</div>
</div>
);
}

View File

@ -52,6 +52,7 @@ import {
} from "@/lib/canvas-launch";
import type { MembershipConfig } from "@/lib/membership/config";
import { ImageAnnotator } from "@/components/image-annotator";
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
type HomeUser = {
@ -463,6 +464,11 @@ export function LovartHome({
"/api/auth-settings",
fetcher
);
const { data: homeAnimationData } = useSWR<{
enabled?: boolean;
htmlContent?: string;
fileName?: string;
}>("/api/home-animation", fetcher);
const membershipConfig = membershipData?.config;
const membershipPlans = membershipConfig?.plans || [];
@ -1745,6 +1751,16 @@ export function LovartHome({
</div>
</div>
{homeAnimationData?.enabled && homeAnimationData?.htmlContent ? (
<div className="mx-auto mt-6 w-full max-w-[780px]">
<HomeAnimationEmbed
htmlContent={homeAnimationData.htmlContent}
title="互动演示"
description="支持拖拽、滚轮与实时交互,模块内容可在系统设置中替换 HTML 更新。"
/>
</div>
) : null}
<div className="mt-4 flex flex-wrap items-center justify-center gap-1.5">
{visiblePromptTags.map((tag) => (
<button

View File

@ -0,0 +1,101 @@
import { existsSync, readFileSync } from "fs";
import { join } from "path";
export const HOME_CHAT_ANIMATION_CONFIG_KEY = "home_chat_animation_config";
export const HOME_CHAT_ANIMATION_MAX_BYTES = 1024 * 1024;
export type HomeChatAnimationConfig = {
enabled: boolean;
fileName: string;
htmlContent: string;
updatedAt: string | null;
};
export function normalizeHomeChatAnimationHtml(value: unknown): string {
if (typeof value !== "string") {
return "";
}
return value
.replace(/^\uFEFF/, "")
.replace(/\u0000/g, "")
.replace(/\u2028|\u2029/g, "\n");
}
export function getHomeChatAnimationHtmlBytes(htmlContent: string): number {
return Buffer.byteLength(htmlContent, "utf8");
}
export function normalizeHomeChatAnimationConfig(
value: unknown,
): HomeChatAnimationConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const enabled = raw.enabled === true;
const fileName = String(raw.fileName || "").trim().slice(0, 255);
const htmlContent = normalizeHomeChatAnimationHtml(raw.htmlContent);
const updatedAt = String(raw.updatedAt || "").trim() || null;
return {
enabled,
fileName,
htmlContent,
updatedAt,
};
}
let cachedDefaultHomeAnimationHtml: string | null | undefined;
export function getDefaultHomeChatAnimationHtml(): string {
if (cachedDefaultHomeAnimationHtml !== undefined) {
return cachedDefaultHomeAnimationHtml || "";
}
const candidatePaths = [join(process.cwd(), "动画效果.html")];
for (const candidatePath of candidatePaths) {
if (!existsSync(candidatePath)) {
continue;
}
cachedDefaultHomeAnimationHtml = normalizeHomeChatAnimationHtml(
readFileSync(candidatePath, "utf8"),
);
return cachedDefaultHomeAnimationHtml;
}
cachedDefaultHomeAnimationHtml = "";
return "";
}
export function resolvePublicHomeChatAnimationConfig(
value: unknown,
): HomeChatAnimationConfig & { usesDefaultFallback: boolean } {
const normalized = normalizeHomeChatAnimationConfig(value);
const fallbackHtml = getDefaultHomeChatAnimationHtml();
const htmlContent = normalized.htmlContent || fallbackHtml;
const usesDefaultFallback =
!normalized.htmlContent && Boolean(fallbackHtml);
if (!normalized.enabled || !htmlContent) {
return {
enabled: false,
fileName: normalized.fileName,
htmlContent: "",
updatedAt: normalized.updatedAt,
usesDefaultFallback,
};
}
return {
enabled: true,
fileName:
normalized.fileName || (usesDefaultFallback ? "动画效果.html" : ""),
htmlContent,
updatedAt: normalized.updatedAt,
usesDefaultFallback,
};
}

1361
动画效果.html Normal file

File diff suppressed because it is too large Load Diff