新版首页文案可配置并部署侧边栏管理入口
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 00:10:08 +08:00
parent f2c6002969
commit bc5838f9e1
10 changed files with 581 additions and 51 deletions

View File

@ -176,6 +176,11 @@ type SiteSettings = {
canvasEnabled: boolean;
canvasDefaultModelId: string | null;
canvasAutoModeDefaultModelId: string | null;
lovartComposerPreviewTexts: string[];
lovartPromoText: string;
lovartPromoActionText: string;
lovartHeroTitle: string;
lovartHeroSubtitle: string;
homeVariant: "classic" | "lovart";
surpriseCategories: string[];
membershipConfig: ReturnType<typeof normalizeMembershipConfig>;
@ -399,6 +404,15 @@ export function AdminSettingsClient() {
canvasEnabled: true,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
lovartComposerPreviewTexts: [
"让 Lovart 打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
],
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级",
lovartHeroTitle: "由壹点AI 让设计更简单",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
homeVariant: "classic",
surpriseCategories: ["全部", "惊喜"],
membershipConfig: defaultMembershipConfig,
@ -444,6 +458,21 @@ export function AdminSettingsClient() {
canvasEnabled: siteSettingsData.canvasEnabled ?? true,
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
canvasAutoModeDefaultModelId: siteSettingsData.canvasAutoModeDefaultModelId || null,
lovartComposerPreviewTexts: Array.isArray(siteSettingsData.lovartComposerPreviewTexts)
? siteSettingsData.lovartComposerPreviewTexts
.map((item: any) => String(item).trim())
.filter(Boolean)
: [
"让 Lovart 打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
],
lovartPromoText:
siteSettingsData.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: siteSettingsData.lovartPromoActionText || "立即升级",
lovartHeroTitle: siteSettingsData.lovartHeroTitle || "由壹点AI 让设计更简单",
lovartHeroSubtitle: siteSettingsData.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
homeVariant: siteSettingsData.homeVariant === "lovart" ? "lovart" : "classic",
surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories)
? siteSettingsData.surpriseCategories.map((item: any) => String(item).trim()).filter(Boolean)
@ -2668,6 +2697,86 @@ export function AdminSettingsClient() {
新版首页“精选”模块会按这些分类显示筛选标签。
</div>
</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="lovartComposerPreviewTexts">
对话框预览文案(每行一条,按顺序轮播打字显示)
</Label>
<Textarea
id="lovartComposerPreviewTexts"
value={siteSettings.lovartComposerPreviewTexts.join("\n")}
onChange={(e) => {
const values = e.target.value
.split("\n")
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 20);
setSiteSettings({
...siteSettings,
lovartComposerPreviewTexts:
values.length > 0
? values
: [
"让 Lovart 打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
],
});
}}
rows={5}
placeholder={"让 Lovart 打造引人注目的社交媒体视觉\n帮我把这张产品图做成高级电商海报"}
/>
<div className="text-xs text-muted-foreground">
前端输入框将按此顺序循环打字显示,支持最多 20 条。
</div>
</div>
<div className="space-y-2">
<Label htmlFor="lovartPromoText">顶部升级提示文案</Label>
<Input
id="lovartPromoText"
value={siteSettings.lovartPromoText}
onChange={(e) =>
setSiteSettings({ ...siteSettings, lovartPromoText: e.target.value })
}
placeholder="现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lovartPromoActionText">顶部升级按钮文案</Label>
<Input
id="lovartPromoActionText"
value={siteSettings.lovartPromoActionText}
onChange={(e) =>
setSiteSettings({ ...siteSettings, lovartPromoActionText: e.target.value })
}
placeholder="立即升级"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lovartHeroTitle">首页主标题</Label>
<Input
id="lovartHeroTitle"
value={siteSettings.lovartHeroTitle}
onChange={(e) =>
setSiteSettings({ ...siteSettings, lovartHeroTitle: e.target.value })
}
placeholder="由壹点AI 让设计更简单"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lovartHeroSubtitle">首页副标题</Label>
<Input
id="lovartHeroSubtitle"
value={siteSettings.lovartHeroSubtitle}
onChange={(e) =>
setSiteSettings({ ...siteSettings, lovartHeroSubtitle: e.target.value })
}
placeholder="懂你的设计代理,帮你搞定一切"
/>
</div>
</div>
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>

View File

@ -53,10 +53,15 @@ async function NewChatPage() {
id: session.user.id,
email: session.user.email,
name: session.user.name,
type: session.user.type,
}}
siteName={siteSettings.siteName || "AI Chatbot"}
siteLogo={siteSettings.siteLogo || siteSettings.siteLogoDark || ""}
surpriseCategories={surpriseCategories}
heroPromoText={siteSettings.lovartPromoText}
heroPromoActionText={siteSettings.lovartPromoActionText}
heroTitleText={siteSettings.lovartHeroTitle}
heroSubtitleText={siteSettings.lovartHeroSubtitle}
/>
);
}

View File

@ -13,6 +13,7 @@ import {
const HOME_VARIANT_KEY = "home_variant";
const HOME_SURPRISE_CATEGORIES_KEY = "home_surprise_categories";
const LOVART_COMPOSER_PREVIEW_TEXTS_KEY = "lovart_composer_preview_texts";
const MEMBERSHIP_CONFIG_KEY = "membership_config";
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
@ -42,6 +43,25 @@ function normalizeSurpriseCategories(value: unknown): string[] {
return unique;
}
function normalizeLovartComposerPreviewTexts(value: unknown): string[] {
const defaults = [
"让 Lovart 打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
];
if (!Array.isArray(value)) {
return defaults;
}
const normalized = value
.map((item) => String(item || "").trim())
.filter(Boolean)
.slice(0, 20);
return normalized.length > 0 ? normalized : defaults;
}
// GET /api/site-settings - 获取网站设置 (公开)
export async function GET() {
try {
@ -50,6 +70,7 @@ export async function GET() {
notificationSetting,
homeVariantSetting,
surpriseCategoriesSetting,
lovartComposerPreviewTextsSetting,
membershipConfigSetting,
membershipOverridesSetting,
] = await Promise.all([
@ -72,6 +93,12 @@ export async function GET() {
.where(eq(systemSettings.key, HOME_SURPRISE_CATEGORIES_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, LOVART_COMPOSER_PREVIEW_TEXTS_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
@ -89,6 +116,9 @@ export async function GET() {
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
const homeVariant = normalizeHomeVariant(homeVariantSetting?.value);
const surpriseCategories = normalizeSurpriseCategories(surpriseCategoriesSetting?.value);
const lovartComposerPreviewTexts = normalizeLovartComposerPreviewTexts(
lovartComposerPreviewTextsSetting?.value
);
const membershipConfig = normalizeMembershipConfig(membershipConfigSetting?.value);
const membershipUserOverrides = normalizeMembershipUserOverrides(
membershipOverridesSetting?.value
@ -119,17 +149,29 @@ export async function GET() {
canvasEnabled: true,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级",
lovartHeroTitle: "由壹点AI 让设计更简单",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
homeVariant,
surpriseCategories,
lovartComposerPreviewTexts,
membershipConfig,
membershipUserOverrides,
};
const response = NextResponse.json({
...baseSettings,
lovartPromoText:
baseSettings.lovartPromoText ||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: baseSettings.lovartPromoActionText || "立即升级",
lovartHeroTitle: baseSettings.lovartHeroTitle || "由壹点AI 让设计更简单",
lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
notification,
homeVariant,
surpriseCategories,
lovartComposerPreviewTexts,
membershipConfig,
membershipUserOverrides,
});
@ -173,8 +215,13 @@ const updateSchema = z.object({
canvasEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
canvasAutoModeDefaultModelId: z.string().nullable().optional(),
lovartPromoText: z.string().max(300).optional(),
lovartPromoActionText: z.string().max(32).optional(),
lovartHeroTitle: z.string().max(300).optional(),
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(),
lovartComposerPreviewTexts: z.array(z.string().trim().min(1).max(120)).max(20).optional(),
membershipConfig: z.unknown().optional(),
membershipUserOverrides: z.unknown().optional(),
});
@ -197,6 +244,7 @@ export async function PUT(request: NextRequest) {
const {
homeVariant,
surpriseCategories,
lovartComposerPreviewTexts,
membershipConfig,
membershipUserOverrides,
...siteSettingsPayload
@ -256,6 +304,26 @@ export async function PUT(request: NextRequest) {
});
}
if (lovartComposerPreviewTexts !== undefined) {
const normalized = normalizeLovartComposerPreviewTexts(lovartComposerPreviewTexts);
await db
.insert(systemSettings)
.values({
key: LOVART_COMPOSER_PREVIEW_TEXTS_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
@ -303,6 +371,10 @@ export async function PUT(request: NextRequest) {
surpriseCategories !== undefined
? normalizeSurpriseCategories(surpriseCategories)
: normalizeSurpriseCategories(undefined),
lovartComposerPreviewTexts:
lovartComposerPreviewTexts !== undefined
? normalizeLovartComposerPreviewTexts(lovartComposerPreviewTexts)
: normalizeLovartComposerPreviewTexts(undefined),
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)
@ -340,6 +412,12 @@ export async function PUT(request: NextRequest) {
canvasEnabled: siteSettingsPayload.canvasEnabled ?? true,
canvasDefaultModelId: siteSettingsPayload.canvasDefaultModelId,
canvasAutoModeDefaultModelId: siteSettingsPayload.canvasAutoModeDefaultModelId,
lovartPromoText:
siteSettingsPayload.lovartPromoText ??
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: siteSettingsPayload.lovartPromoActionText ?? "立即升级",
lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "由壹点AI 让设计更简单",
lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "懂你的设计代理,帮你搞定一切",
updatedBy: session.user.id,
})
.returning();
@ -383,6 +461,26 @@ export async function PUT(request: NextRequest) {
});
}
if (lovartComposerPreviewTexts !== undefined) {
const normalized = normalizeLovartComposerPreviewTexts(lovartComposerPreviewTexts);
await db
.insert(systemSettings)
.values({
key: LOVART_COMPOSER_PREVIEW_TEXTS_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
@ -430,6 +528,10 @@ export async function PUT(request: NextRequest) {
surpriseCategories !== undefined
? normalizeSurpriseCategories(surpriseCategories)
: normalizeSurpriseCategories(undefined),
lovartComposerPreviewTexts:
lovartComposerPreviewTexts !== undefined
? normalizeLovartComposerPreviewTexts(lovartComposerPreviewTexts)
: normalizeLovartComposerPreviewTexts(undefined),
membershipConfig:
membershipConfig !== undefined
? normalizeMembershipConfig(membershipConfig)

View File

@ -479,6 +479,17 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
setAttachments(normalizedAttachments as PickedObjectAttachment[]);
setInput(normalizedPrompt);
if (parsed.selectedModelId) {
setSelectedModelId(String(parsed.selectedModelId));
}
if (parsed.imageParams && typeof parsed.imageParams === "object") {
setImageParams((prev) => ({
...prev,
...parsed.imageParams,
}));
}
setTimeout(() => {
normalizedAttachments.forEach((attachment, index) => {
insertImageTagInEditor(attachment.url, attachment.name, index);
@ -491,6 +502,8 @@ export function CanvasChatPanel({ chatId, initialMessages = [], onClose, onOpenF
attachments: normalizedAttachments,
autoSend: true,
createdAt: parsed.createdAt,
selectedModelId: parsed.selectedModelId,
imageParams: parsed.imageParams,
};
}

View File

@ -3,6 +3,7 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import useSWR from "swr";
import useSWRInfinite from "swr/infinite";
import {
ArrowRight,
ArrowUp,
@ -10,17 +11,16 @@ import {
ChevronDown,
Clapperboard,
Folder,
Globe,
Heart,
Hexagon,
Home,
ImageIcon,
Info,
Lightbulb,
Loader2,
Monitor,
Palette,
Plus,
Send,
Settings,
Sparkles,
Star,
Upload,
@ -31,6 +31,8 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { fetcher } from "@/lib/utils";
import {
@ -44,6 +46,7 @@ type HomeUser = {
email?: string | null;
name?: string | null;
image?: string | null;
type?: string | null;
};
type CanvasProject = {
@ -66,6 +69,24 @@ type FeaturedImage = {
};
};
type FeaturedResponse = {
images: FeaturedImage[];
pagination?: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
};
type ModelOption = {
id: string;
modelId: string;
displayName: string;
providerType: string;
supportsImageGen?: boolean;
};
type MembershipSummaryResponse = {
config: MembershipConfig;
currentPlanId: string;
@ -95,6 +116,10 @@ type LovartHomeProps = {
siteName: string;
siteLogo?: string | null;
surpriseCategories: string[];
heroPromoText?: string | null;
heroPromoActionText?: string | null;
heroTitleText?: string | null;
heroSubtitleText?: string | null;
};
type UploadFileItem = {
@ -124,6 +149,7 @@ const PROMPT_TAGS = [
];
const MASONRY_HEIGHTS = [420, 290, 340, 470, 300, 360, 430, 320];
const FEATURED_PAGE_SIZE = 24;
function formatRelativeTime(value?: string) {
if (!value) return "更新于今天";
@ -148,6 +174,10 @@ export function LovartHome({
siteName,
siteLogo,
surpriseCategories,
heroPromoText,
heroPromoActionText,
heroTitleText,
heroSubtitleText,
}: LovartHomeProps) {
const router = useRouter();
const [prompt, setPrompt] = useState("");
@ -157,7 +187,16 @@ export function LovartHome({
const [showAllProjects, setShowAllProjects] = useState(false);
const [upgradeDialogOpen, setUpgradeDialogOpen] = useState(false);
const [billingCycle, setBillingCycle] = useState<"monthly" | "annual">("monthly");
const [selectedModelId, setSelectedModelId] = useState<string>("");
const [imageParams, setImageParams] = useState<ImageGenerationParams>({
aspectRatio: "auto",
imageSize: "2K",
imageCount: 1,
});
const [typedPreviewText, setTypedPreviewText] = useState("");
const [previewCursorVisible, setPreviewCursorVisible] = useState(true);
const projectSectionRef = useRef<HTMLDivElement | null>(null);
const featuredLoadMoreRef = useRef<HTMLDivElement | null>(null);
const filesRef = useRef<UploadFileItem[]>([]);
const { data: canvasData, mutate: mutateCanvas } = useSWR<{
@ -165,15 +204,39 @@ export function LovartHome({
data: CanvasProject[];
}>("/api/canvas", fetcher);
const { data: featuredData } = useSWR<{
images: FeaturedImage[];
}>("/api/featured?page=1&pageSize=40", fetcher);
const { data: featuredPages, size: featuredPageCount, setSize: setFeaturedPageCount, isValidating: isFeaturedValidating } =
useSWRInfinite<FeaturedResponse>(
(pageIndex, previousPageData) => {
if (
previousPageData?.pagination &&
previousPageData.pagination.page >= previousPageData.pagination.totalPages
) {
return null;
}
return `/api/featured?page=${pageIndex + 1}&pageSize=${FEATURED_PAGE_SIZE}`;
},
fetcher,
{
revalidateFirstPage: false,
parallel: false,
}
);
const { data: membershipData } = useSWR<MembershipSummaryResponse>(
"/api/user/membership-summary",
fetcher
);
const { data: modelsData } = useSWR<{
models: ModelOption[];
}>("/api/models", fetcher);
const { data: siteSettingsData } = useSWR<{
canvasDefaultModelId?: string | null;
lovartComposerPreviewTexts?: string[];
}>("/api/site-settings", fetcher);
const membershipConfig = membershipData?.config;
const membershipPlans = membershipConfig?.plans || [];
const currentPlan = membershipData?.currentPlan;
@ -189,6 +252,30 @@ export function LovartHome({
}, {});
}, [membershipConfig?.modelRows]);
const imageModels = useMemo(
() => (modelsData?.models || []).filter((model) => model.supportsImageGen),
[modelsData?.models]
);
const allComposerModels = useMemo<ModelOption[]>(
() => [
{
id: "auto",
modelId: "auto",
displayName: "智能自动",
providerType: "auto",
supportsImageGen: true,
},
...imageModels,
],
[imageModels]
);
const currentComposerModel = useMemo(
() => allComposerModels.find((model) => model.id === selectedModelId),
[allComposerModels, selectedModelId]
);
const discoveryTabs = useMemo(() => {
const merged = [
"全部",
@ -198,8 +285,39 @@ export function LovartHome({
return Array.from(new Set(merged)).slice(0, 12);
}, [surpriseCategories]);
const composerPreviewTexts = useMemo(() => {
const configured = (siteSettingsData?.lovartComposerPreviewTexts || [])
.map((item) => String(item || "").trim())
.filter(Boolean);
if (configured.length > 0) {
return configured;
}
return [
"让 Lovart 打造引人注目的社交媒体视觉",
"帮我把这张产品图做成高级电商海报",
"基于参考图延展 3 套同风格方案",
];
}, [siteSettingsData?.lovartComposerPreviewTexts]);
const loadedFeaturedImages = useMemo(() => {
return (featuredPages || []).flatMap((page) => page.images || []);
}, [featuredPages]);
const featuredPagination = featuredPages?.[featuredPages.length - 1]?.pagination;
const hasMoreFeatured = Boolean(
featuredPagination && featuredPagination.page < featuredPagination.totalPages
);
const isLoadingFeatured = !featuredPages && isFeaturedValidating;
const isLoadingMoreFeatured =
isFeaturedValidating &&
!!featuredPages &&
featuredPageCount > 0 &&
featuredPageCount > featuredPages.length;
const featuredItems = useMemo(() => {
const images = featuredData?.images || [];
const images = loadedFeaturedImages;
if (activeCategory === "全部") {
return images;
}
@ -208,7 +326,7 @@ export function LovartHome({
? String(item.featuredCategory) === activeCategory
: String(item.prompt || "").toLowerCase().includes(activeCategory.toLowerCase())
);
}, [featuredData?.images, activeCategory]);
}, [loadedFeaturedImages, activeCategory]);
const recentProjects = useMemo(() => {
const projects = canvasData?.data || [];
@ -219,6 +337,109 @@ export function LovartHome({
return (canvasData?.data || []).length > 4;
}, [canvasData?.data]);
useEffect(() => {
if (selectedModelId) return;
const defaultModelId = siteSettingsData?.canvasDefaultModelId || "auto";
setSelectedModelId(defaultModelId);
}, [selectedModelId, siteSettingsData?.canvasDefaultModelId]);
useEffect(() => {
if (!selectedModelId) return;
if (!modelsData) return;
if (allComposerModels.length === 0) return;
if (allComposerModels.some((model) => model.id === selectedModelId)) return;
setSelectedModelId("auto");
}, [allComposerModels, selectedModelId, modelsData]);
useEffect(() => {
if (typeof window === "undefined") return;
try {
const saved = localStorage.getItem("lovart-home-image-params");
if (!saved) return;
const parsed = JSON.parse(saved) as Partial<ImageGenerationParams>;
setImageParams((prev) => ({ ...prev, ...parsed }));
} catch (error) {
console.error("[LovartHome] Failed to load image params:", error);
}
}, []);
const handleImageParamsChange = (nextParams: ImageGenerationParams) => {
setImageParams(nextParams);
if (typeof window === "undefined") return;
try {
localStorage.setItem("lovart-home-image-params", JSON.stringify(nextParams));
} catch (error) {
console.error("[LovartHome] Failed to save image params:", error);
}
};
useEffect(() => {
if (!featuredLoadMoreRef.current || !hasMoreFeatured) return;
const observer = new IntersectionObserver(
(entries) => {
if (!entries[0]?.isIntersecting || isLoadingMoreFeatured) return;
setFeaturedPageCount((current) => current + 1);
},
{ rootMargin: "1000px 0px 1200px 0px" }
);
observer.observe(featuredLoadMoreRef.current);
return () => observer.disconnect();
}, [hasMoreFeatured, isLoadingMoreFeatured, setFeaturedPageCount]);
useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
if (composerPreviewTexts.length === 0) {
setTypedPreviewText("");
return () => {
if (timer) clearTimeout(timer);
};
}
const runTyping = (textIndex: number) => {
if (disposed) return;
const currentText = composerPreviewTexts[textIndex] || "";
let charIndex = 0;
const typeNextChar = () => {
if (disposed) return;
setTypedPreviewText(currentText.slice(0, charIndex));
charIndex += 1;
if (charIndex <= currentText.length) {
timer = setTimeout(typeNextChar, 55);
return;
}
timer = setTimeout(() => {
runTyping((textIndex + 1) % composerPreviewTexts.length);
}, 1300);
};
typeNextChar();
};
runTyping(0);
return () => {
disposed = true;
if (timer) clearTimeout(timer);
};
}, [composerPreviewTexts]);
useEffect(() => {
const blinkTimer = setInterval(() => {
setPreviewCursorVisible((value) => !value);
}, 450);
return () => clearInterval(blinkTimer);
}, []);
useEffect(() => {
filesRef.current = files;
}, [files]);
@ -322,6 +543,8 @@ export function LovartHome({
attachments: uploadedAttachments,
autoSend: true,
createdAt: Date.now(),
selectedModelId: selectedModelId || "auto",
imageParams,
})
);
}
@ -342,9 +565,18 @@ export function LovartHome({
router.push("/canvas?new=1");
};
const openAllProjectsInPage = () => {
setShowAllProjects(true);
projectSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const composerPlaceholder = `${typedPreviewText || composerPreviewTexts[0] || "描述你想创作的内容"}${
previewCursorVisible ? " |" : ""
}`;
return (
<div className="min-h-svh bg-[#f4f4f4] text-[#171717]">
<header className="fixed left-0 top-0 z-40 h-[68px] w-full border-b border-black/5 bg-[#f4f4f4]/90 backdrop-blur">
<header className="fixed left-0 top-0 z-40 h-[68px] w-full bg-[#f4f4f4]/90 backdrop-blur">
<div className="flex h-full items-center justify-between px-7">
<button
type="button"
@ -364,11 +596,6 @@ export function LovartHome({
</button>
<div className="flex items-center gap-2">
<button className="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[12px] text-black/80 hover:bg-black/5">
简体中文
<ChevronDown className="h-3 w-3" />
</button>
<button className="flex h-8 w-8 items-center justify-center rounded-lg bg-black/5 text-black/75 hover:bg-black/10">
<Bell className="h-3.5 w-3.5" />
</button>
@ -460,9 +687,7 @@ export function LovartHome({
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={() =>
projectSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })
}
onClick={openAllProjectsInPage}
>
<Folder className="h-[18px] w-[18px]" />
</button>
@ -480,6 +705,15 @@ export function LovartHome({
>
<Info className="h-[18px] w-[18px]" />
</button>
{user.type === "admin" ? (
<button
type="button"
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-black/5"
onClick={() => router.push("/settings")}
>
<Settings className="h-[18px] w-[18px]" />
</button>
) : null}
</div>
</aside>
@ -491,8 +725,8 @@ export function LovartHome({
className="inline-flex items-center gap-2 rounded-full bg-[#fde8bf] px-4 py-2 text-sm text-black/80"
>
<span className="rounded-full bg-[#ff9d3d] px-2 py-0.5 text-[10px] font-semibold text-white">NEW</span>
现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠
<span className="text-[#ff7a00]">立即升级</span>
{heroPromoText || "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"}
<span className="text-[#ff7a00]">{heroPromoActionText || "立即升级"}</span>
</button>
<div className="mt-8 flex items-center justify-center gap-2">
@ -504,16 +738,18 @@ export function LovartHome({
</div>
)}
<h1 className="text-[34px] font-semibold tracking-tight">
{siteName || "Lovart"} 让设计更简单
{heroTitleText || `${siteName || "Lovart"} 让设计更简单`}
</h1>
</div>
<p className="mt-2 text-[13px] text-black/35">懂你的设计代理,帮你搞定一切</p>
<p className="mt-2 text-[13px] text-black/35">
{heroSubtitleText || "懂你的设计代理,帮你搞定一切"}
</p>
<div className="mx-auto mt-6 w-full max-w-[780px] rounded-[22px] border border-black/10 bg-white px-4 pb-3 pt-4 shadow-[0_10px_24px_rgba(0,0,0,0.05)]">
<Textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="让 Lovart 打造引人注目的社交媒体视觉"
placeholder={composerPlaceholder}
className="min-h-[76px] resize-none border-0 bg-transparent px-1 text-[12px] shadow-none focus-visible:ring-0"
/>
@ -535,39 +771,61 @@ export function LovartHome({
)}
<div className="flex items-center justify-between border-t border-black/8 pt-3">
<label className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-black/10 text-black/60 hover:bg-black/5">
<Upload className="h-4 w-4" />
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
handleAddFiles(event.target.files);
event.target.value = "";
}}
/>
</label>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<label className="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border border-black/10 text-black/60 hover:bg-black/5">
<Upload className="h-4 w-4" />
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(event) => {
handleAddFiles(event.target.files);
event.target.value = "";
}}
/>
</label>
<div className="flex items-center gap-1.5">
<button className="flex h-8 w-8 items-center justify-center rounded-full border border-black/10 text-black/50 hover:bg-black/5">
<Lightbulb className="h-3.5 w-3.5" />
</button>
<button className="flex h-8 w-8 items-center justify-center rounded-full border border-black/10 text-black/50 hover:bg-black/5">
<Zap className="h-3.5 w-3.5" />
</button>
<button className="flex h-8 w-8 items-center justify-center rounded-full border border-black/10 text-black/50 hover:bg-black/5">
<Globe className="h-3.5 w-3.5" />
</button>
<button className="flex h-8 w-8 items-center justify-center rounded-full border border-[#72a3ff] text-[#3a78f2]">
<Hexagon className="h-3.5 w-3.5" />
</button>
<Select value={selectedModelId || "auto"} onValueChange={setSelectedModelId}>
<SelectTrigger className="h-8 w-auto min-w-[110px] rounded-full border border-blue-100 bg-blue-50 px-3 text-xs text-blue-600">
<span className="truncate max-w-[100px]">
{currentComposerModel?.displayName || "智能自动"}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto" className="text-xs">
智能自动
</SelectItem>
{imageModels.map((model) => (
<SelectItem key={model.id} value={model.id} className="text-xs">
{model.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="min-w-0 flex-1 overflow-hidden">
<ImageParamsBar
modelName={currentComposerModel?.displayName}
initialParams={imageParams}
onParamsChange={handleImageParamsChange}
inline
providerType={currentComposerModel?.providerType}
/>
</div>
</div>
<div className="flex items-center gap-1.5 pl-2">
<Button
onClick={createCanvasFromComposer}
disabled={isSubmitting || (!prompt.trim() && files.length === 0)}
className="h-8 w-8 rounded-full bg-[#9ca3af] p-0 text-white hover:bg-black"
>
<ArrowUp className="h-3.5 w-3.5" />
{isSubmitting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
@ -672,7 +930,7 @@ export function LovartHome({
</div>
<div className="mt-5 columns-1 gap-4 md:columns-2 xl:columns-4">
{featuredItems.slice(0, 20).map((item, index) => (
{featuredItems.map((item, index) => (
<button
key={item.id}
type="button"
@ -709,10 +967,18 @@ export function LovartHome({
{featuredItems.length === 0 && (
<div className="rounded-xl border border-dashed border-black/15 bg-white p-10 text-center text-black/35">
当前没有可展示的灵感数据。
{isLoadingFeatured ? "灵感内容加载中..." : "当前没有可展示的灵感数据。"}
</div>
)}
</div>
<div ref={featuredLoadMoreRef} className="mt-3 flex h-10 items-center justify-center">
{isLoadingMoreFeatured ? (
<div className="inline-flex items-center gap-2 text-sm text-black/45">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
正在加载更多灵感...
</div>
) : null}
</div>
</section>
</main>

View File

@ -4,11 +4,24 @@ export interface CanvasLaunchAttachment {
contentType: string;
}
export interface CanvasLaunchImageParams {
aspectRatio?: string;
imageSize?: string;
imageCount?: number;
size?: string;
negativePrompt?: string;
promptExtend?: boolean;
watermark?: boolean;
seed?: number;
}
export interface CanvasLaunchPayload {
prompt: string;
attachments: CanvasLaunchAttachment[];
autoSend?: boolean;
createdAt?: number;
selectedModelId?: string;
imageParams?: CanvasLaunchImageParams;
}
export function getCanvasLaunchStorageKey(canvasId: string): string {

View File

@ -0,0 +1,5 @@
ALTER TABLE "SiteSettings"
ADD COLUMN IF NOT EXISTS "lovartPromoText" text DEFAULT '现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠',
ADD COLUMN IF NOT EXISTS "lovartPromoActionText" varchar(32) DEFAULT '立即升级',
ADD COLUMN IF NOT EXISTS "lovartHeroTitle" text DEFAULT '由壹点AI 让设计更简单',
ADD COLUMN IF NOT EXISTS "lovartHeroSubtitle" text DEFAULT '懂你的设计代理,帮你搞定一切';

View File

@ -106,6 +106,13 @@
"when": 1769247486014,
"tag": "0028_add_featured_category",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1769251200000,
"tag": "0029_add_lovart_home_copy_settings",
"breakpoints": true
}
]
}

View File

@ -1566,6 +1566,10 @@ export async function getSiteSettings() {
tutorialVideoUrl: null,
tutorialEnabled: false,
rankingEnabled: true,
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
lovartPromoActionText: "立即升级",
lovartHeroTitle: "由壹点AI 让设计更简单",
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
};
try {
const [settings] = await db.select().from(siteSettings).limit(1);

View File

@ -310,6 +310,12 @@ export const siteSettings = pgTable("SiteSettings", {
canvasEnabled: boolean("canvasEnabled").notNull().default(true), // 是否启用无限画布功能
canvasDefaultModelId: varchar("canvasDefaultModelId", { length: 128 }), // 画布默认选择的模型ID(null表示智能自动)
canvasAutoModeDefaultModelId: varchar("canvasAutoModeDefaultModelId", { length: 128 }), // 智能自动模式下实际使用的默认生图模型ID
lovartPromoText: text("lovartPromoText").default(
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"
),
lovartPromoActionText: varchar("lovartPromoActionText", { length: 32 }).default("立即升级"),
lovartHeroTitle: text("lovartHeroTitle").default("由壹点AI 让设计更简单"),
lovartHeroSubtitle: text("lovartHeroSubtitle").default("懂你的设计代理,帮你搞定一切"),
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
updatedBy: uuid("updatedBy").references(() => user.id),
});