优化新版首页打字特效并支持固定前缀配置
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-02-14 13:32:24 +08:00
parent b7a9c30e70
commit a6f55c7c21
3 changed files with 176 additions and 33 deletions

View File

@ -176,6 +176,7 @@ type SiteSettings = {
canvasEnabled: boolean;
canvasDefaultModelId: string | null;
canvasAutoModeDefaultModelId: string | null;
lovartComposerPrefix: string;
lovartComposerPreviewTexts: string[];
lovartPromoText: string;
lovartPromoActionText: string;
@ -408,8 +409,9 @@ export function AdminSettingsClient() {
canvasEnabled: true,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
lovartComposerPrefix: "让Buyi-ai",
lovartComposerPreviewTexts: [
"让 Lovart 打造引人注目的社交媒体视觉",
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
],
@ -462,12 +464,15 @@ export function AdminSettingsClient() {
canvasEnabled: siteSettingsData.canvasEnabled ?? true,
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
canvasAutoModeDefaultModelId: siteSettingsData.canvasAutoModeDefaultModelId || null,
lovartComposerPrefix:
String((siteSettingsData as any).lovartComposerPrefix || "").trim() ||
"让Buyi-ai",
lovartComposerPreviewTexts: Array.isArray(siteSettingsData.lovartComposerPreviewTexts)
? siteSettingsData.lovartComposerPreviewTexts
.map((item: any) => String(item).trim())
.filter(Boolean)
: [
"让 Lovart 打造引人注目的社交媒体视觉",
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
],
@ -2713,6 +2718,23 @@ export function AdminSettingsClient() {
</div>
<div className="grid gap-4 rounded-lg border border-dashed p-4">
<div className="text-sm font-medium">新版首页文案配置</div>
<div className="space-y-2">
<Label htmlFor="lovartComposerPrefix">对话框固定前缀文案</Label>
<Input
id="lovartComposerPrefix"
value={siteSettings.lovartComposerPrefix}
onChange={(e) =>
setSiteSettings({
...siteSettings,
lovartComposerPrefix: e.target.value.slice(0, 40),
})
}
placeholder="让Buyi-ai"
/>
<div className="text-xs text-muted-foreground">
该前缀会固定显示在打字文案前面,切换文案时不删除。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lovartComposerPreviewTexts">
对话框预览文案(每行一条,按顺序轮播打字显示)
@ -2733,17 +2755,17 @@ export function AdminSettingsClient() {
values.length > 0
? values
: [
"让 Lovart 打造引人注目的社交媒体视觉",
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
],
});
}}
rows={5}
placeholder={"让 Lovart 打造引人注目的社交媒体视觉\n帮我把这张产品图做成高级电商海报"}
placeholder={"打造引人注目的社交媒体视觉\n帮我把这张产品图做成高级电商海报"}
/>
<div className="text-xs text-muted-foreground">
前端输入框将按此顺序循环打字显示,支持最多 20 条。
仅配置动态尾文案;前端会固定显示前缀后再进行打字与删字切换。支持最多 20 条。
</div>
</div>
<div className="space-y-2">

View File

@ -15,6 +15,7 @@ const HOME_VARIANT_KEY = "home_variant";
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
const LOVART_COMPOSER_PREVIEW_TEXTS_KEY = "lovart_composer_preview_texts";
const LOVART_COMPOSER_PREFIX_KEY = "lovart_composer_prefix";
const MEMBERSHIP_CONFIG_KEY = "membership_config";
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
@ -48,7 +49,7 @@ function normalizeSurpriseCategories(value: unknown): string[] {
function normalizeLovartComposerPreviewTexts(value: unknown): string[] {
const defaults = [
"让 Lovart 打造引人注目的社交媒体视觉",
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
];
@ -65,6 +66,14 @@ function normalizeLovartComposerPreviewTexts(value: unknown): string[] {
return normalized.length > 0 ? normalized : defaults;
}
function normalizeLovartComposerPrefix(value: unknown): string {
const normalized = String(value || "").trim();
if (!normalized) {
return "让Buyi-ai";
}
return normalized.slice(0, 40);
}
// GET /api/site-settings - 获取网站设置 (公开)
export async function GET() {
try {
@ -74,6 +83,7 @@ export async function GET() {
homeVariantSetting,
surpriseCategoriesSetting,
lovartComposerPreviewTextsSetting,
lovartComposerPrefixSetting,
membershipConfigSetting,
membershipOverridesSetting,
] = await Promise.all([
@ -102,6 +112,12 @@ export async function GET() {
.where(eq(systemSettings.key, LOVART_COMPOSER_PREVIEW_TEXTS_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, LOVART_COMPOSER_PREFIX_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
@ -122,6 +138,9 @@ export async function GET() {
const lovartComposerPreviewTexts = normalizeLovartComposerPreviewTexts(
lovartComposerPreviewTextsSetting?.value
);
const lovartComposerPrefix = normalizeLovartComposerPrefix(
lovartComposerPrefixSetting?.value
);
const membershipConfig = normalizeMembershipConfig(membershipConfigSetting?.value);
const membershipUserOverrides = normalizeMembershipUserOverrides(
membershipOverridesSetting?.value
@ -158,6 +177,7 @@ export async function GET() {
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
homeVariant,
surpriseCategories,
lovartComposerPrefix,
lovartComposerPreviewTexts,
membershipConfig,
membershipUserOverrides,
@ -174,6 +194,7 @@ export async function GET() {
notification,
homeVariant,
surpriseCategories,
lovartComposerPrefix,
lovartComposerPreviewTexts,
membershipConfig,
membershipUserOverrides,
@ -224,6 +245,7 @@ const updateSchema = z.object({
lovartHeroSubtitle: z.string().max(300).optional(),
homeVariant: z.enum(["classic", "lovart"]).optional(),
surpriseCategories: z.array(z.string().trim().min(1).max(20)).max(12).optional(),
lovartComposerPrefix: z.string().trim().min(1).max(40).optional(),
lovartComposerPreviewTexts: z.array(z.string().trim().min(1).max(120)).max(20).optional(),
membershipConfig: z.unknown().optional(),
membershipUserOverrides: z.unknown().optional(),
@ -247,6 +269,7 @@ export async function PUT(request: NextRequest) {
const {
homeVariant,
surpriseCategories,
lovartComposerPrefix,
lovartComposerPreviewTexts,
membershipConfig,
membershipUserOverrides,
@ -327,6 +350,26 @@ export async function PUT(request: NextRequest) {
});
}
if (lovartComposerPrefix !== undefined) {
const normalized = normalizeLovartComposerPrefix(lovartComposerPrefix);
await db
.insert(systemSettings)
.values({
key: LOVART_COMPOSER_PREFIX_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig);
await db
@ -374,6 +417,10 @@ export async function PUT(request: NextRequest) {
surpriseCategories !== undefined
? normalizeSurpriseCategories(surpriseCategories)
: normalizeSurpriseCategories(undefined),
lovartComposerPrefix:
lovartComposerPrefix !== undefined
? normalizeLovartComposerPrefix(lovartComposerPrefix)
: normalizeLovartComposerPrefix(undefined),
lovartComposerPreviewTexts:
lovartComposerPreviewTexts !== undefined
? normalizeLovartComposerPreviewTexts(lovartComposerPreviewTexts)
@ -484,6 +531,26 @@ export async function PUT(request: NextRequest) {
});
}
if (lovartComposerPrefix !== undefined) {
const normalized = normalizeLovartComposerPrefix(lovartComposerPrefix);
await db
.insert(systemSettings)
.values({
key: LOVART_COMPOSER_PREFIX_KEY,
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value: normalized,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
}
if (membershipConfig !== undefined) {
const normalized = normalizeMembershipConfig(membershipConfig);
await db
@ -531,6 +598,10 @@ export async function PUT(request: NextRequest) {
surpriseCategories !== undefined
? normalizeSurpriseCategories(surpriseCategories)
: normalizeSurpriseCategories(undefined),
lovartComposerPrefix:
lovartComposerPrefix !== undefined
? normalizeLovartComposerPrefix(lovartComposerPrefix)
: normalizeLovartComposerPrefix(undefined),
lovartComposerPreviewTexts:
lovartComposerPreviewTexts !== undefined
? normalizeLovartComposerPreviewTexts(lovartComposerPreviewTexts)

View File

@ -12,6 +12,7 @@ import {
ChevronDown,
Clapperboard,
Folder,
Heart,
Home,
ImageIcon,
Info,
@ -162,6 +163,27 @@ const PROMPT_TAGS = [
const MASONRY_HEIGHTS = [420, 290, 340, 470, 300, 360, 430, 320];
const FEATURED_PAGE_SIZE = 24;
const COMPOSER_PREFIX_DEFAULT = "让Buyi-ai";
function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
const trimmed = text.trim();
if (!trimmed) return "";
const sanitizedPrefix = fixedPrefix.trim();
if (sanitizedPrefix && trimmed.startsWith(sanitizedPrefix)) {
return trimmed.slice(sanitizedPrefix.length).trimStart();
}
const legacyPrefixRemoved = trimmed
.replace(/^让\s*buyi[-_ ]?ai\s*/i, "")
.replace(/^让\s*lovart\s*/i, "")
.trimStart();
if (legacyPrefixRemoved !== trimmed) {
return legacyPrefixRemoved;
}
return trimmed;
}
function getUserInitials(user: HomeUser | null) {
const source = (user?.name || user?.email || "mu").trim();
@ -280,6 +302,7 @@ export function LovartHome({
const { data: siteSettingsData } = useSWR<{
canvasDefaultModelId?: string | null;
lovartComposerPrefix?: string | null;
lovartComposerPreviewTexts?: string[];
}>("/api/site-settings", fetcher);
@ -358,12 +381,25 @@ export function LovartHome({
}
return [
"让 Lovart 打造引人注目的社交媒体视觉",
"打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
];
}, [siteSettingsData?.lovartComposerPreviewTexts]);
const composerFixedPrefix = useMemo(() => {
return (
String(siteSettingsData?.lovartComposerPrefix || "").trim() ||
COMPOSER_PREFIX_DEFAULT
);
}, [siteSettingsData?.lovartComposerPrefix]);
const composerPreviewTails = useMemo(() => {
return composerPreviewTexts
.map((text) => normalizeComposerPreviewTail(text, composerFixedPrefix))
.filter(Boolean);
}, [composerFixedPrefix, composerPreviewTexts]);
const loadedFeaturedImages = useMemo(() => {
return (featuredPages || []).flatMap((page) => page.images || []);
}, [featuredPages]);
@ -474,44 +510,58 @@ export function LovartHome({
let disposed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
if (composerPreviewTexts.length === 0) {
if (composerPreviewTails.length === 0) {
setTypedPreviewText("");
return () => {
if (timer) clearTimeout(timer);
};
}
const runTyping = (textIndex: number) => {
let textIndex = 0;
let charIndex = 0;
const typeForward = () => {
if (disposed) return;
const currentText = composerPreviewTexts[textIndex] || "";
let charIndex = 0;
const currentText = composerPreviewTails[textIndex] || "";
const typeNextChar = () => {
if (disposed) return;
setTypedPreviewText(currentText.slice(0, charIndex));
if (charIndex < currentText.length) {
charIndex += 1;
setTypedPreviewText(currentText.slice(0, charIndex));
timer = setTimeout(typeForward, 55);
return;
}
if (charIndex <= currentText.length) {
timer = setTimeout(typeNextChar, 55);
return;
}
if (composerPreviewTails.length === 1) {
setTypedPreviewText(currentText);
return;
}
timer = setTimeout(() => {
runTyping((textIndex + 1) % composerPreviewTexts.length);
}, 1300);
};
typeNextChar();
timer = setTimeout(typeBackward, 1200);
};
runTyping(0);
const typeBackward = () => {
if (disposed) return;
const currentText = composerPreviewTails[textIndex] || "";
if (charIndex > 0) {
charIndex -= 1;
setTypedPreviewText(currentText.slice(0, charIndex));
timer = setTimeout(typeBackward, 35);
return;
}
textIndex = (textIndex + 1) % composerPreviewTails.length;
timer = setTimeout(typeForward, 260);
};
setTypedPreviewText("");
timer = setTimeout(typeForward, 260);
return () => {
disposed = true;
if (timer) clearTimeout(timer);
};
}, [composerPreviewTexts]);
}, [composerPreviewTails]);
useEffect(() => {
const blinkTimer = setInterval(() => {
@ -708,7 +758,7 @@ export function LovartHome({
projectSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const composerPlaceholder = `${typedPreviewText || composerPreviewTexts[0] || "描述你想创作的内容"}${
const composerPlaceholder = `${composerFixedPrefix}${typedPreviewText}${
previewCursorVisible ? " |" : ""
}`;
@ -1111,7 +1161,7 @@ export function LovartHome({
<button
type="button"
onClick={openNewCanvas}
className="rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5"
className="cursor-pointer rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5"
>
<div className="flex aspect-[4/3] items-center justify-center rounded-2xl bg-[#efefef]">
<div className="text-center">
@ -1126,14 +1176,14 @@ export function LovartHome({
key={item.id}
type="button"
onClick={() => router.push(`/canvas/${item.id}`)}
className="group rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5"
className="group cursor-pointer rounded-[22px] border border-black/8 bg-white p-3 text-left shadow-sm transition hover:-translate-y-0.5"
>
<div className="aspect-[4/3] overflow-hidden rounded-2xl bg-[#efefef]">
{item.thumbnail ? (
<img
src={item.thumbnail}
alt={item.title}
className="h-full w-full object-cover transition-transform duration-300 ease-out group-hover:scale-[1.03]"
className="h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.045]"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-black/35">Untitled</div>
@ -1183,7 +1233,7 @@ export function LovartHome({
key={item.id}
type="button"
onClick={() => window.open(item.originalUrl || item.thumbnailUrl, "_blank")}
className="group mb-4 block w-full break-inside-avoid rounded-2xl border border-black/8 bg-white text-left shadow-sm"
className="group mb-4 block w-full cursor-pointer break-inside-avoid rounded-2xl border border-black/8 bg-white text-left shadow-sm transition hover:-translate-y-0.5"
>
<div
className="overflow-hidden rounded-t-2xl bg-[#e8e8e8]"
@ -1192,7 +1242,7 @@ export function LovartHome({
<img
src={item.thumbnailUrl || item.originalUrl}
alt={item.prompt || "灵感图"}
className="h-full w-full object-cover transition-transform duration-300 ease-out group-hover:scale-[1.02]"
className="h-full w-full object-cover transition-transform duration-300 ease-out will-change-transform group-hover:scale-[1.04]"
/>
</div>
<div className="flex items-center justify-between px-3 py-2 text-[12px] text-black/60">