feat: continue lovart rebuild with public data integration
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-12 12:03:35 +08:00
parent 7686999c79
commit d089d3a37d
10 changed files with 1188 additions and 5 deletions

View File

@ -0,0 +1,96 @@
import { type NextRequest, NextResponse } from "next/server";
import {
getEnabledAiModelsWithProvider,
getNavLinks,
getPublicFeaturedImages,
getPublicFeaturedRanking,
getSiteSettings,
getTutorialVideos,
} from "@/lib/db/queries";
function formatProviderStats(models: Awaited<ReturnType<typeof getEnabledAiModelsWithProvider>>) {
const providerMap = new Map<string, number>();
for (const item of models) {
const providerName = item.provider.name;
providerMap.set(providerName, (providerMap.get(providerName) ?? 0) + 1);
}
return Array.from(providerMap.entries()).map(([name, count]) => ({ name, count }));
}
export async function GET(request: NextRequest) {
try {
const view = request.nextUrl.searchParams.get("view") || "home";
const [settings, navLinks, models, videos] = await Promise.all([
getSiteSettings(),
getNavLinks(),
getEnabledAiModelsWithProvider(),
getTutorialVideos(),
]);
if (view === "pricing") {
const featuredImages = await getPublicFeaturedImages(8);
const response = NextResponse.json({
view,
settings,
pricing: {
models: models.length,
tutorials: videos.length,
featuredSamples: featuredImages,
},
});
response.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=300");
return response;
}
if (view === "news") {
const [featuredImages, ranking] = await Promise.all([
getPublicFeaturedImages(12),
settings.rankingEnabled ? getPublicFeaturedRanking(6) : Promise.resolve([]),
]);
const response = NextResponse.json({
view,
settings,
news: {
videos,
featuredImages,
ranking,
},
});
response.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=300");
return response;
}
const [featuredImages, ranking] = await Promise.all([
getPublicFeaturedImages(12),
settings.rankingEnabled ? getPublicFeaturedRanking(5) : Promise.resolve([]),
]);
const response = NextResponse.json({
view: "home",
settings,
navLinks,
providers: formatProviderStats(models),
stats: {
modelCount: models.length,
tutorialCount: videos.length,
featuredCount: featuredImages.length,
},
featuredImages,
ranking,
videos: settings.tutorialEnabled ? videos : [],
});
response.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=300");
return response;
} catch (error) {
console.error("[Public Lovart API] Error:", error);
return NextResponse.json({ error: "Failed to fetch Lovart public payload" }, { status: 500 });
}
}

15
app/lovart/layout.tsx Normal file
View File

@ -0,0 +1,15 @@
import type { Metadata } from "next";
import type { ReactNode } from "react";
export const metadata: Metadata = {
title: "Lovart Style Rebuild",
description: "Rebuilt marketing pages connected to current backend data.",
};
export default function LovartLayout({
children,
}: {
children: ReactNode;
}) {
return children;
}

192
app/lovart/news/page.tsx Normal file
View File

@ -0,0 +1,192 @@
import Link from "next/link";
import { Calendar, Newspaper, PlayCircle, TrendingUp } from "lucide-react";
import { MarketingShell } from "@/components/lovart/marketing-shell";
import { Reveal } from "@/components/lovart/reveal";
import {
getNavLinks,
getPublicFeaturedImages,
getPublicFeaturedRanking,
getSiteSettings,
getTutorialVideos,
} from "@/lib/db/queries";
function formatDate(value: Date | null | undefined) {
if (!value) {
return "最近更新";
}
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date(value));
}
export default async function LovartNewsPage() {
const [settings, navLinks, videos, featuredImages, ranking] = await Promise.all([
getSiteSettings(),
getNavLinks(),
getTutorialVideos(),
getPublicFeaturedImages(12),
getPublicFeaturedRanking(6),
]);
const [headline, ...restVideos] = videos;
return (
<MarketingShell
active="news"
bannerText={settings.bannerEnabled ? settings.bannerText : null}
navLinks={navLinks}
siteLogo={settings.siteLogo || settings.siteLogoDark}
siteName={settings.siteName || "AI Chatbot"}
stats={[
{ label: "新闻/教程", value: String(videos.length) },
{ label: "精选更新", value: String(featuredImages.length) },
{ label: "榜单作者", value: String(ranking.length) },
]}
>
<Reveal>
<section className="rounded-3xl border border-white/15 bg-white/[0.04] p-6 sm:p-10">
<p className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/5 px-3 py-1 text-xs text-zinc-200">
<Newspaper className="h-3.5 w-3.5 text-cyan-300" />
News & Tutorials Feed
</p>
<h1 className="mt-4 text-3xl font-semibold text-white sm:text-5xl">Lovart 新闻中心</h1>
<p className="mt-4 max-w-2xl text-sm text-zinc-300 sm:text-base">
页面内容实时读取后台教程管理与精选图流,运营发布后无需改代码即可同步到前台。
</p>
</section>
</Reveal>
<Reveal delay={0.06}>
<section className="mt-8 grid gap-4 lg:grid-cols-[1.3fr_1fr]">
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-xs text-zinc-300">
<Calendar className="h-3.5 w-3.5" />
{formatDate(headline?.updatedAt || headline?.createdAt)}
</div>
<h2 className="text-2xl font-semibold text-white">
{headline?.title || "等待发布第一条动态"}
</h2>
<p className="mt-3 text-sm leading-6 text-zinc-300">
{headline?.description ||
"你可以在后台添加教程视频、标题和描述,前台会自动展示为新闻中心头条。"}
</p>
{headline ? (
<a
href={headline.videoUrl}
target="_blank"
rel="noreferrer"
className="mt-5 inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2 text-sm font-semibold text-zinc-900 hover:bg-zinc-200"
>
<PlayCircle className="h-4 w-4" />
观看头条内容
</a>
) : null}
</article>
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
<h3 className="mb-3 text-lg font-semibold text-white">精选更新流</h3>
{featuredImages.length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{featuredImages.slice(0, 9).map((image) => (
<img
key={image.id}
src={image.thumbnailUrl || image.originalUrl}
alt={image.prompt || "featured"}
className="h-20 w-full rounded-lg object-cover"
/>
))}
</div>
) : (
<p className="text-sm text-zinc-300">暂无精选更新</p>
)}
</article>
</section>
</Reveal>
<Reveal delay={0.12}>
<section className="mt-8">
<h2 className="mb-4 text-xl font-semibold text-white">全部新闻</h2>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{restVideos.length > 0 ? (
restVideos.map((video, index) => (
<article key={video.id} className="rounded-2xl border border-white/15 bg-black/30 p-4">
<div className="mb-3 flex items-center justify-between text-xs text-zinc-400">
<span className="inline-flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{formatDate(video.updatedAt || video.createdAt)}
</span>
<span>#{String(index + 1).padStart(2, "0")}</span>
</div>
<p className="line-clamp-2 text-base font-medium text-zinc-100">{video.title}</p>
<p className="mt-2 line-clamp-3 text-sm text-zinc-300">{video.description || "暂无描述"}</p>
<a
href={video.videoUrl}
target="_blank"
rel="noreferrer"
className="mt-4 inline-flex items-center gap-2 text-sm text-cyan-300 hover:text-cyan-200"
>
<PlayCircle className="h-4 w-4" />
立即观看
</a>
</article>
))
) : (
<div className="col-span-full rounded-2xl border border-dashed border-white/20 bg-white/[0.03] p-8 text-sm text-zinc-300">
暂无更多新闻内容,可在后台「教程设置」中继续发布。
</div>
)}
</div>
</section>
</Reveal>
{settings.rankingEnabled ? (
<Reveal delay={0.16}>
<section className="mt-8 rounded-2xl border border-white/15 bg-black/25 p-5">
<div className="mb-4 flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-cyan-300" />
<h2 className="text-lg font-semibold text-white">创作者热度榜</h2>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{ranking.length > 0 ? (
ranking.map((item, index) => (
<div
key={item.userId}
className="flex items-center justify-between rounded-lg border border-white/10 bg-white/[0.03] px-3 py-2 text-sm"
>
<span className="text-zinc-200">
{index + 1}. {item.userName || item.userEmail?.split("@")[0] || "未知用户"}
</span>
<span className="font-medium text-amber-200">{item.featuredCount}</span>
</div>
))
) : (
<p className="text-sm text-zinc-300">暂无榜单数据</p>
)}
</div>
</section>
</Reveal>
) : null}
<Reveal delay={0.2}>
<section className="mt-10 rounded-2xl border border-white/15 bg-black/25 p-5">
<h2 className="text-lg font-semibold text-white">下一步开发建议</h2>
<div className="mt-3 flex flex-wrap gap-3 text-sm text-zinc-300">
<Link className="rounded-lg border border-white/20 px-3 py-1.5 hover:border-white/40" href="/lovart">
回到首页调样式
</Link>
<Link className="rounded-lg border border-white/20 px-3 py-1.5 hover:border-white/40" href="/lovart/pricing">
定价页联动支付
</Link>
<Link className="rounded-lg border border-white/20 px-3 py-1.5 hover:border-white/40" href="/admin/settings">
去后台更新内容
</Link>
</div>
</section>
</Reveal>
</MarketingShell>
);
}

310
app/lovart/page.tsx Normal file
View File

@ -0,0 +1,310 @@
import Link from "next/link";
import { ArrowRight, Bot, Crown, Film, Layers3, Sparkles, WandSparkles } from "lucide-react";
import type { ReactNode } from "react";
import { MarketingShell } from "@/components/lovart/marketing-shell";
import { Reveal } from "@/components/lovart/reveal";
import {
getEnabledAiModelsWithProvider,
getNavLinks,
getPublicFeaturedImages,
getPublicFeaturedRanking,
getSiteSettings,
getTutorialVideos,
} from "@/lib/db/queries";
export default async function LovartHomePage() {
const [settings, navLinks, videos, models, featuredImages, ranking] = await Promise.all([
getSiteSettings(),
getNavLinks(),
getTutorialVideos(),
getEnabledAiModelsWithProvider(),
getPublicFeaturedImages(14),
getPublicFeaturedRanking(5),
]);
const siteName = settings.siteName || "AI Chatbot";
const toolboxOneLinks = navLinks.filter((link) => link.toolboxGroup === 1);
const toolboxTwoLinks = navLinks.filter((link) => link.toolboxGroup === 2);
const providerStats = Array.from(
models.reduce((acc, item) => {
const key = item.provider.name;
acc.set(key, (acc.get(key) ?? 0) + 1);
return acc;
}, new Map<string, number>())
);
const featureStories = [
{
title: "懂你所想,精准编辑",
desc: "上传参考图、文案和品牌设定,系统会自动串联模型能力并保持统一风格。",
icon: <WandSparkles className="h-4 w-4 text-cyan-200" />,
},
{
title: "每一次创作,风格始终如一",
desc: "根据当前可用模型和工具箱规则自动生成工作流,不再手动重复搭建步骤。",
icon: <Bot className="h-4 w-4 text-emerald-200" />,
},
{
title: "设计参考,贵在精准",
desc: "精选图库与作者榜实时联动后台数据,运营改动后页面立即同步。",
icon: <Crown className="h-4 w-4 text-amber-200" />,
},
];
return (
<MarketingShell
active="home"
bannerText={settings.bannerEnabled ? settings.bannerText : null}
navLinks={navLinks}
siteLogo={settings.siteLogo || settings.siteLogoDark}
siteName={siteName}
stats={[
{ label: "在线模型", value: String(models.length) },
{ label: settings.toolbox1Name || "工具箱 1", value: String(toolboxOneLinks.length) },
{ label: settings.toolbox2Name || "工具箱 2", value: String(toolboxTwoLinks.length) },
{ label: "教程内容", value: String(videos.length) },
]}
>
<Reveal>
<section className="grid gap-6 rounded-3xl border border-white/15 bg-white/[0.03] p-6 shadow-[0_18px_80px_-26px_rgba(6,182,212,0.45)] sm:p-10 lg:grid-cols-[1.16fr_1fr]">
<div className="space-y-5">
<p className="inline-flex items-center gap-2 rounded-full border border-cyan-300/25 bg-cyan-300/10 px-3 py-1 text-xs text-cyan-100">
<Sparkles className="h-3.5 w-3.5" />
Lovart Rebuild v1 · 全链路联调
</p>
<h1 className="text-3xl font-semibold leading-tight text-white sm:text-5xl">
以系统思维设计
<span className="mt-2 block bg-gradient-to-r from-cyan-300 via-blue-300 to-amber-200 bg-clip-text text-transparent">
{siteName}
</span>
</h1>
<p className="max-w-xl text-sm text-zinc-300 sm:text-base">
已连接站点配置、模型中心、精选图库、教程源与作者榜数据。
你可以直接把 `/lovart` 作为新的营销主页继续迭代细节。
</p>
<div className="flex flex-wrap items-center gap-3">
<Link
href="/register"
className="inline-flex items-center gap-2 rounded-xl bg-white px-4 py-2 text-sm font-semibold text-zinc-900 hover:bg-zinc-200"
>
立即体验
<ArrowRight className="h-4 w-4" />
</Link>
<Link
href="/lovart/pricing"
className="rounded-xl border border-white/20 px-4 py-2 text-sm text-zinc-200 hover:border-white/40 hover:text-white"
>
查看定价方案
</Link>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<MetricCard icon={<Bot className="h-4 w-4" />} label="模型可用" value={String(models.length)} />
<MetricCard icon={<Layers3 className="h-4 w-4" />} label="导航入口" value={String(navLinks.length)} />
<MetricCard icon={<Film className="h-4 w-4" />} label="教程总数" value={String(videos.length)} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
{featuredImages.slice(0, 4).map((image, index) => (
<article
key={image.id}
className={`group overflow-hidden rounded-2xl border border-white/15 bg-black/30 ${
index === 0 ? "col-span-2" : ""
}`}
>
<img
alt={image.prompt || "featured image"}
className={`w-full object-cover transition duration-300 group-hover:scale-105 ${
index === 0 ? "h-44" : "h-32"
}`}
src={image.thumbnailUrl || image.originalUrl}
/>
<div className="space-y-1 p-3">
<p className="line-clamp-2 text-xs text-zinc-200">{image.prompt || "AI 品牌视觉草图"}</p>
<p className="text-[11px] text-zinc-400">{image.modelId || "AI Model"}</p>
</div>
</article>
))}
</div>
</section>
</Reveal>
<Reveal delay={0.05}>
<section className="mt-10 grid gap-4 md:grid-cols-3">
{featureStories.map((item) => (
<article key={item.title} className="rounded-2xl border border-white/15 bg-black/30 p-5">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-white/15 bg-white/[0.04]">
{item.icon}
</span>
<h2 className="mt-4 text-lg font-semibold text-white">{item.title}</h2>
<p className="mt-2 text-sm leading-6 text-zinc-300">{item.desc}</p>
</article>
))}
</section>
</Reveal>
<Reveal delay={0.09}>
<section className="mt-10 grid gap-4 lg:grid-cols-[1.05fr_1fr]">
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
<h2 className="mb-4 text-xl font-semibold text-white">模型能力分布</h2>
<div className="space-y-3">
{providerStats.length > 0 ? (
providerStats.map(([providerName, count]) => (
<div key={providerName} className="rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="mb-2 flex items-center justify-between text-sm">
<span className="text-zinc-200">{providerName}</span>
<span className="font-medium text-white">{count}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-gradient-to-r from-cyan-300 to-blue-400"
style={{ width: `${Math.max(10, (count / Math.max(models.length, 1)) * 100)}%` }}
/>
</div>
</div>
))
) : (
<p className="text-sm text-zinc-300">暂无可用模型</p>
)}
</div>
</article>
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white">精选画廊</h2>
<Link href="/featured" className="text-xs text-zinc-300 hover:text-white">
查看全部
</Link>
</div>
{featuredImages.length > 0 ? (
<div className="grid grid-cols-3 gap-2">
{featuredImages.slice(0, 9).map((image) => (
<img
key={image.id}
alt={image.prompt || "featured image"}
className="h-20 w-full rounded-lg object-cover"
src={image.thumbnailUrl || image.originalUrl}
/>
))}
</div>
) : (
<p className="text-sm text-zinc-300">暂无精选数据</p>
)}
</article>
</section>
</Reveal>
{settings.rankingEnabled ? (
<Reveal delay={0.13}>
<section className="mt-10 rounded-2xl border border-white/15 bg-black/25 p-5">
<h2 className="mb-4 text-xl font-semibold text-white">精选作者榜</h2>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{ranking.length > 0 ? (
ranking.map((item, index) => (
<div
key={item.userId}
className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2"
>
<div className="flex items-center gap-2">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/10 text-xs text-zinc-100">
{index + 1}
</span>
<div>
<p className="text-sm text-zinc-100">
{item.userName || item.userEmail?.split("@")[0] || "未知用户"}
</p>
<p className="text-xs text-zinc-400">{item.userDepartment || "未设置部门"}</p>
</div>
</div>
<span className="text-sm font-medium text-amber-200">{item.featuredCount}</span>
</div>
))
) : (
<p className="text-sm text-zinc-300">暂无榜单数据</p>
)}
</div>
</section>
</Reveal>
) : null}
{settings.tutorialEnabled ? (
<Reveal delay={0.17}>
<section className="mt-10">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white">最新教程内容</h2>
<Link className="text-xs text-zinc-300 hover:text-white" href="/lovart/news">
查看全部动态
</Link>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{videos.slice(0, 6).map((video) => (
<article key={video.id} className="rounded-2xl border border-white/15 bg-white/[0.04] p-4">
<p className="line-clamp-2 text-sm font-medium text-zinc-100">{video.title}</p>
<p className="mt-2 line-clamp-2 text-xs text-zinc-400">{video.description || "视频教程"}</p>
<a
className="mt-3 inline-flex text-xs text-cyan-300 hover:text-cyan-200"
href={video.videoUrl}
rel="noreferrer"
target="_blank"
>
观看视频
</a>
</article>
))}
</div>
</section>
</Reveal>
) : null}
<Reveal delay={0.2}>
<section className="mt-10 rounded-2xl border border-white/15 bg-white/[0.03] p-6">
<h2 className="text-2xl font-semibold text-white">用 Lovart 设计</h2>
<p className="mt-2 max-w-2xl text-sm text-zinc-300">
当前页面已完成核心数据联调,可继续进行 1:1 交互动效精修、国际化文案和支付系统接入。
</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link
href="/register"
className="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-zinc-900 hover:bg-zinc-200"
>
免费开始
</Link>
<Link
href="/admin/settings"
className="rounded-xl border border-white/20 px-4 py-2 text-sm text-zinc-200 hover:border-white/40 hover:text-white"
>
打开后台继续配置
</Link>
</div>
</section>
</Reveal>
</MarketingShell>
);
}
function MetricCard({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<div className="rounded-xl border border-white/15 bg-white/[0.03] p-3">
<p className="mb-2 inline-flex items-center gap-2 text-xs text-zinc-400">
{icon}
{label}
</p>
<p className="text-lg font-semibold text-white">{value}</p>
</div>
);
}

228
app/lovart/pricing/page.tsx Normal file
View File

@ -0,0 +1,228 @@
import Link from "next/link";
import { Check, Rocket, ShieldCheck } from "lucide-react";
import { MarketingShell } from "@/components/lovart/marketing-shell";
import { Reveal } from "@/components/lovart/reveal";
import {
getEnabledAiModelsWithProvider,
getNavLinks,
getPublicFeaturedImages,
getSiteSettings,
getTutorialVideos,
} from "@/lib/db/queries";
export default async function LovartPricingPage() {
const [settings, navLinks, models, videos, featuredImages] = await Promise.all([
getSiteSettings(),
getNavLinks(),
getEnabledAiModelsWithProvider(),
getTutorialVideos(),
getPublicFeaturedImages(8),
]);
const modelCount = models.length;
const tutorialCount = videos.length;
const plans = [
{
name: "Starter",
price: "¥0",
period: "/月",
desc: "适合快速体验 AI 设计流程",
features: [
"基础文生图与简单编辑",
`最多 ${Math.max(3, Math.floor(modelCount / 3))} 个模型试用`,
"社区支持与基础教程",
],
},
{
name: "Basic",
price: "¥69",
period: "/月",
desc: "适合独立创作者日常出图",
features: ["高清导出", "多风格模板", "历史记录与作品管理"],
},
{
name: "Pro",
price: "¥199",
period: "/月",
desc: "适合高频生产内容的个人与团队",
features: [
`完整 ${modelCount} 个在线模型能力`,
`附赠 ${tutorialCount} 条教程与工作流示例`,
"优先队列与高级编辑功能",
],
highlight: true,
},
{
name: "Ultimate",
price: "¥599",
period: "/月",
desc: "适合企业协作与品牌运营",
features: ["团队权限管理", "批量任务与自动归档", "可定制品牌资产与 SSO 接入"],
},
];
const faqs = [
{
q: "可以随时升级或降级套餐吗?",
a: "支持。套餐调整后会立刻刷新权限,已生成内容不会丢失。",
},
{
q: "定价文案和能力是固定的吗?",
a: "不是。当前页面已与后台模型和教程配置联动,后续接入支付后会直接映射到真实权益。",
},
{
q: "企业版是否支持私有部署?",
a: "支持按需评估,可在现有系统基础上扩展存储、鉴权和审计策略。",
},
];
return (
<MarketingShell
active="pricing"
bannerText={settings.bannerEnabled ? settings.bannerText : null}
navLinks={navLinks}
siteLogo={settings.siteLogo || settings.siteLogoDark}
siteName={settings.siteName || "AI Chatbot"}
stats={[
{ label: "在线模型", value: String(modelCount) },
{ label: "教程内容", value: String(tutorialCount) },
{ label: "精选样例", value: String(featuredImages.length) },
]}
>
<Reveal>
<section className="rounded-3xl border border-white/15 bg-white/[0.04] p-6 sm:p-10">
<p className="inline-flex items-center gap-2 rounded-full border border-white/20 bg-white/5 px-3 py-1 text-xs text-zinc-200">
<Rocket className="h-3.5 w-3.5 text-cyan-300" />
Pricing Blueprint
</p>
<h1 className="mt-4 text-3xl font-semibold text-white sm:text-5xl">选择你的专属方案</h1>
<p className="mt-4 max-w-2xl text-sm text-zinc-300 sm:text-base">
套餐文案已与后端模型、教程和精选样例联动。下一步接入支付接口后即可直接上线。
</p>
</section>
</Reveal>
<Reveal delay={0.06}>
<section className="mt-8 grid gap-4 xl:grid-cols-4">
{plans.map((plan) => (
<article
key={plan.name}
className={`rounded-2xl border p-5 ${
plan.highlight
? "border-cyan-300/60 bg-gradient-to-b from-cyan-500/20 to-black/40 shadow-xl shadow-cyan-500/20"
: "border-white/15 bg-black/30"
}`}
>
<p className="text-lg font-semibold text-white">{plan.name}</p>
<div className="mt-2 flex items-end gap-1">
<span className="text-3xl font-semibold text-white">{plan.price}</span>
<span className="text-sm text-zinc-300">{plan.period}</span>
</div>
<p className="mt-2 text-sm text-zinc-300">{plan.desc}</p>
<ul className="mt-5 space-y-2">
{plan.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm text-zinc-200">
<Check className="mt-0.5 h-4 w-4 text-cyan-300" />
<span>{feature}</span>
</li>
))}
</ul>
<Link
href="/register"
className={`mt-6 inline-flex w-full items-center justify-center rounded-xl px-4 py-2 text-sm font-semibold ${
plan.highlight
? "bg-white text-zinc-900 hover:bg-zinc-200"
: "border border-white/20 text-zinc-100 hover:border-white/40"
}`}
>
选择 {plan.name}
</Link>
</article>
))}
</section>
</Reveal>
<Reveal delay={0.12}>
<section className="mt-8 grid gap-4 lg:grid-cols-[1.1fr_1fr]">
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
<h2 className="mb-3 text-lg font-semibold text-white">计费与能力说明</h2>
<ul className="space-y-2 text-sm text-zinc-300">
<li className="flex items-start gap-2">
<ShieldCheck className="mt-0.5 h-4 w-4 text-cyan-300" />
套餐可在后台自由调整模型与功能开关,页面会自动反映实际能力。
</li>
<li className="flex items-start gap-2">
<ShieldCheck className="mt-0.5 h-4 w-4 text-cyan-300" />
当前在线模型:{modelCount},教程内容:{tutorialCount},用于驱动套餐文案。
</li>
<li className="flex items-start gap-2">
<ShieldCheck className="mt-0.5 h-4 w-4 text-cyan-300" />
支持接入 Stripe / 微信支付,并保留当前视觉样式。
</li>
</ul>
</article>
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
<h2 className="mb-3 text-lg font-semibold text-white">精选样例</h2>
{featuredImages.length > 0 ? (
<div className="grid grid-cols-4 gap-2">
{featuredImages.map((image) => (
<img
key={image.id}
src={image.thumbnailUrl || image.originalUrl}
alt={image.prompt || "featured image"}
className="h-20 w-full rounded-lg object-cover"
/>
))}
</div>
) : (
<p className="text-sm text-zinc-300">暂无精选图片</p>
)}
</article>
</section>
</Reveal>
<Reveal delay={0.16}>
<section className="mt-8 rounded-2xl border border-white/15 bg-black/25 p-5">
<h2 className="mb-4 text-lg font-semibold text-white">常见问题</h2>
<div className="space-y-2">
{faqs.map((item) => (
<details key={item.q} className="group rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
<summary className="cursor-pointer list-none text-sm font-medium text-zinc-100">
{item.q}
</summary>
<p className="mt-2 text-sm text-zinc-300">{item.a}</p>
</details>
))}
</div>
</section>
</Reveal>
<Reveal delay={0.2}>
<section className="mt-8 rounded-2xl border border-white/15 bg-white/[0.03] p-6">
<h2 className="text-2xl font-semibold text-white">用 Lovart 设计</h2>
<p className="mt-2 text-sm text-zinc-300">
目前已完成高保真定价页重建和后端联调,下一步建议直接接入订阅支付与权益同步。
</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link
className="rounded-xl bg-white px-4 py-2 text-sm font-semibold text-zinc-900 hover:bg-zinc-200"
href="/register"
>
开始免费试用
</Link>
<Link
className="rounded-xl border border-white/20 px-4 py-2 text-sm text-zinc-100 hover:border-white/40"
href="/admin/settings"
>
去后台调整套餐文案
</Link>
</div>
</section>
</Reveal>
</MarketingShell>
);
}

View File

@ -0,0 +1,201 @@
import Link from "next/link";
import { ArrowUpRight, Sparkles } from "lucide-react";
import type { ReactNode } from "react";
type NavLinkItem = {
id: string;
name: string;
url: string;
};
type StatItem = {
label: string;
value: string;
};
const mainTabs = [
{ key: "home", label: "首页", href: "/lovart" },
{ key: "pricing", label: "定价", href: "/lovart/pricing" },
{ key: "news", label: "新闻", href: "/lovart/news" },
] as const;
const socialLinks = [
{ label: "Discord", href: "https://discord.com/invite/3Fr4KPrwbA" },
{ label: "X", href: "https://twitter.com/lovart_ai" },
{ label: "YouTube", href: "https://www.youtube.com/@lovart_ai" },
{ label: "LinkedIn", href: "https://www.linkedin.com/company/lovart-ai" },
{ label: "Instagram", href: "https://www.instagram.com/lovart.ai" },
{ label: "TikTok", href: "https://www.tiktok.com/@lovart_ai" },
];
function resolveDocsLink(navLinks: NavLinkItem[], keywords: string[]) {
const loweredKeywords = keywords.map((keyword) => keyword.toLowerCase());
return (
navLinks.find((link) =>
loweredKeywords.some((keyword) => link.name.toLowerCase().includes(keyword))
)?.url ?? null
);
}
export function MarketingShell({
siteName,
siteLogo,
navLinks,
active,
bannerText,
stats,
children,
}: {
siteName: string;
siteLogo?: string | null;
navLinks: NavLinkItem[];
active: "home" | "pricing" | "news";
bannerText?: string | null;
stats?: StatItem[];
children: ReactNode;
}) {
const docsUrl = resolveDocsLink(navLinks, ["文档", "docs"]);
const blogUrl = resolveDocsLink(navLinks, ["博客", "blog"]);
const changelogUrl = resolveDocsLink(navLinks, ["更新", "changelog", "release"]);
return (
<div className="min-h-dvh bg-[#050814] text-zinc-100">
<div className="pointer-events-none fixed inset-0 bg-[radial-gradient(1200px_620px_at_14%_-12%,rgba(14,165,233,0.35),transparent_52%),radial-gradient(920px_560px_at_92%_4%,rgba(251,146,60,0.2),transparent_48%),linear-gradient(160deg,#090f1f_0%,#050814_48%,#080b15_100%)]" />
<div className="pointer-events-none fixed inset-0 bg-[linear-gradient(to_right,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.03)_1px,transparent_1px)] bg-[size:44px_44px] opacity-15" />
<header className="sticky top-0 z-30 border-b border-white/10 bg-[#050814]/80 backdrop-blur-xl">
{bannerText ? (
<div className="border-b border-cyan-300/20 bg-cyan-300/10 px-4 py-1 text-center text-[11px] text-cyan-50 sm:text-xs">
{bannerText}
</div>
) : null}
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4 sm:px-6">
<Link href="/lovart" className="group inline-flex items-center gap-2.5">
{siteLogo ? (
<img src={siteLogo} alt={siteName} className="h-8 w-8 rounded-lg object-cover" />
) : (
<span className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-cyan-300 to-blue-500">
<Sparkles className="h-4 w-4 text-zinc-900" />
</span>
)}
<span className="text-sm font-semibold tracking-wide text-zinc-100 transition group-hover:text-white">
{siteName}
</span>
</Link>
<nav className="hidden items-center gap-5 text-sm lg:flex">
{mainTabs.map((tab) => (
<Link
key={tab.key}
href={tab.href}
className={
active === tab.key
? "text-white"
: "text-zinc-300 transition hover:text-white"
}
>
{tab.label}
</Link>
))}
{blogUrl ? (
<a className="text-zinc-300 transition hover:text-white" href={blogUrl} rel="noreferrer" target="_blank">
博客
</a>
) : null}
{changelogUrl ? (
<a className="text-zinc-300 transition hover:text-white" href={changelogUrl} rel="noreferrer" target="_blank">
更新日志
</a>
) : null}
{docsUrl ? (
<a className="inline-flex items-center gap-1 text-zinc-300 transition hover:text-white" href={docsUrl} rel="noreferrer" target="_blank">
文档
<ArrowUpRight className="h-3.5 w-3.5" />
</a>
) : null}
</nav>
<div className="flex items-center gap-2">
<Link
href="/login"
className="rounded-lg border border-white/20 px-3 py-1.5 text-xs text-zinc-200 hover:border-white/40 hover:text-white"
>
登录
</Link>
<Link
href="/register"
className="rounded-lg bg-white px-3 py-1.5 text-xs font-semibold text-zinc-900 hover:bg-zinc-200"
>
免费开始
</Link>
</div>
</div>
{stats && stats.length > 0 ? (
<div className="border-t border-white/10">
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center gap-2 px-4 py-2 sm:px-6">
{stats.map((item) => (
<span
key={item.label}
className="inline-flex items-center gap-1 rounded-full border border-white/15 bg-white/[0.03] px-2.5 py-1 text-[11px] text-zinc-200"
>
<span className="text-zinc-400">{item.label}</span>
<strong className="font-semibold text-white">{item.value}</strong>
</span>
))}
</div>
</div>
) : null}
</header>
<main className="relative z-10 mx-auto w-full max-w-6xl px-4 pb-20 pt-8 sm:px-6">{children}</main>
<div className="fixed bottom-3 left-1/2 z-30 flex -translate-x-1/2 items-center gap-1 rounded-full border border-white/15 bg-[#0b1020]/80 p-1 backdrop-blur sm:hidden">
{mainTabs.map((tab) => (
<Link
key={tab.key}
className={`rounded-full px-3 py-1 text-xs ${
active === tab.key ? "bg-white text-zinc-900" : "text-zinc-200"
}`}
href={tab.href}
>
{tab.label}
</Link>
))}
</div>
<footer className="relative z-10 border-t border-white/10 px-4 py-7 text-xs text-zinc-400 sm:px-6">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
{socialLinks.map((item) => (
<a
key={item.label}
href={item.href}
rel="noreferrer"
target="_blank"
className="transition hover:text-zinc-100"
>
{item.label}
</a>
))}
</div>
<div className="flex flex-wrap items-center gap-3">
{navLinks.slice(0, 5).map((link) => (
<a
key={link.id}
href={link.url}
target="_blank"
rel="noreferrer"
className="transition hover:text-zinc-100"
>
{link.name}
</a>
))}
</div>
</div>
</footer>
</div>
);
}

View File

@ -0,0 +1,28 @@
"use client";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
export function Reveal({
children,
delay = 0,
y = 22,
className,
}: {
children: ReactNode;
delay?: number;
y?: number;
className?: string;
}) {
return (
<motion.div
initial={{ opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.2 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1], delay }}
className={className}
>
{children}
</motion.div>
);
}

View File

@ -0,0 +1,57 @@
# Lovart 重建接口联调清单
更新时间:2026-02-12
## 已接入(当前可用)
| 页面 | 数据来源 | 方式 | 状态 |
|---|---|---|---|
| `/lovart` | `getSiteSettings` | DB 直连(RSC) | 已接入 |
| `/lovart` | `getNavLinks` | DB 直连(RSC) | 已接入 |
| `/lovart` | `getEnabledAiModelsWithProvider` | DB 直连(RSC) | 已接入 |
| `/lovart` | `getTutorialVideos` | DB 直连(RSC) | 已接入 |
| `/lovart` | `getPublicFeaturedImages` | DB 直连(RSC) | 已接入 |
| `/lovart` | `getPublicFeaturedRanking` | DB 直连(RSC) | 已接入 |
| `/lovart/pricing` | 模型、教程、精选样例 | DB 直连(RSC) | 已接入 |
| `/lovart/news` | 教程流 + 精选流 + 榜单 | DB 直连(RSC) | 已接入 |
| `/lovart*` 公开访问 | `proxy.ts` 放行 | 中间件路由白名单 | 已接入 |
| `/api/public/lovart?view=home` | 首页聚合数据 | Public API | 已接入 |
| `/api/public/lovart?view=pricing` | 定价页聚合数据 | Public API | 已接入 |
| `/api/public/lovart?view=news` | 新闻页聚合数据 | Public API | 已接入 |
## 新增查询与 API
- 查询函数(`lib/db/queries.ts`)
- `getPublicFeaturedImages(limit)`
- `getPublicFeaturedRanking(limit)`
- `getTutorialVideos()`(改为静态导入,减少运行时开销)
- 公开接口(`app/api/public/lovart/route.ts`)
- `GET /api/public/lovart?view=home|pricing|news`
- 返回公共营销页所需聚合数据(含短缓存头)
## 页面层联动点
| 页面 | 联动项 | 说明 |
|---|---|---|
| `/lovart` | `siteSettings.bannerEnabled/bannerText` | 顶部公告条同步后台配置 |
| `/lovart` | `siteSettings.rankingEnabled` | 控制是否显示作者榜 |
| `/lovart` | `siteSettings.tutorialEnabled` | 控制是否展示教程区块 |
| `/lovart` | `siteSettings.toolbox1Name/toolbox2Name` + `navLinks.toolboxGroup` | 统计工具箱入口并展示在顶部指标条 |
| `/lovart/pricing` | 模型/教程/精选数量 | 套餐文案与样例数量实时同步 |
| `/lovart/news` | 视频更新时间 + 精选流 + 榜单 | 新闻头条和列表动态生成 |
## 暂未接入(后续)
| 项目 | 说明 | 后续动作 |
|---|---|---|
| 转化事件埋点 | 参考站有较重埋点(GTM/像素) | 接入统一埋点层,避免散落脚本 |
| 支付与订阅 | 当前仅展示定价 UI | 接 Stripe/微信支付并写回用户权益 |
| 多语言内容 | 参考站多语言切换 | 增加 i18n 路由和文案资源 |
| 复杂交互动效 | 目前是首版时序动效 | 按参考页面做逐区块微交互调优 |
| 页面 AB 参数化 | 参考站含实验参数 | 结合后台配置做模块开关 |
## 风险与注意
- 当前公开页直接走服务端查询,性能可控;高并发建议在查询层增加缓存。
- 新增 Public API 仅输出营销页公共数据,不含私密用户字段。
- 若后续改成纯客户端渲染,可直接消费 `/api/public/lovart`,减少页面层重复聚合逻辑。

View File

@ -49,6 +49,7 @@ import {
authSettings,
loginToken,
generatedImage,
tutorialVideos,
} from "./schema";
import { generateHashedPassword } from "./utils";
@ -1638,12 +1639,51 @@ export async function checkUserImageQuota(userId: string): Promise<{
}
}
export async function getPublicFeaturedImages(limit = 12) {
try {
return await db
.select({
id: generatedImage.id,
thumbnailUrl: generatedImage.thumbnailUrl,
originalUrl: generatedImage.originalUrl,
prompt: generatedImage.prompt,
imageSize: generatedImage.imageSize,
modelId: generatedImage.modelId,
createdAt: generatedImage.createdAt,
})
.from(generatedImage)
.where(eq(generatedImage.isFeatured, true))
.orderBy(desc(generatedImage.featuredAt), desc(generatedImage.createdAt))
.limit(limit);
} catch (_error) {
return [];
}
}
export async function getPublicFeaturedRanking(limit = 5) {
try {
return await db
.select({
userId: generatedImage.userId,
userName: user.name,
userEmail: user.email,
userDepartment: user.department,
featuredCount: sql<number>`count(*)::int`,
})
.from(generatedImage)
.leftJoin(user, eq(generatedImage.userId, user.id))
.where(eq(generatedImage.isFeatured, true))
.groupBy(generatedImage.userId, user.name, user.email, user.department)
.orderBy(desc(sql`count(*)`))
.limit(limit);
} catch (_error) {
return [];
}
}
// 获取视频教程列表
export async function getTutorialVideos() {
try {
const { tutorialVideos } = await import("./schema");
const { eq, asc } = await import("drizzle-orm");
const videos = await db
.select()
.from(tutorialVideos)

View File

@ -6,6 +6,12 @@ import { isSuspiciousRequest, logSuspiciousRequest } from "@/lib/security/reques
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
const isPublicMarketingRoute =
pathname === "/lovart" ||
pathname.startsWith("/lovart/") ||
pathname === "/lovart/pricing" ||
pathname === "/lovart/news";
const isPublicMarketingApi = pathname === "/api/public/lovart" || pathname.startsWith("/api/public/lovart/");
// 🛡️ 安全检查1:阻止黑名单IP
const clientIP = getClientIP(request);
@ -69,6 +75,11 @@ export async function proxy(request: NextRequest) {
return NextResponse.next();
}
// 允许公开营销数据 API(Lovart 重建页)
if (isPublicMarketingApi) {
return NextResponse.next();
}
// 允许访问钉钉回调页面(public 文件夹中的文件)
if (pathname === "/dingtalk-callback.html") {
return NextResponse.next();
@ -89,7 +100,12 @@ export async function proxy(request: NextRequest) {
`${request.nextUrl.protocol}//${request.headers.get("host") ?? request.nextUrl.host}`;
// 未登录用户只能访问 /login 和 /register
if (!token && !["/login", "/register"].includes(pathname)) {
if (
!token &&
!["/login", "/register"].includes(pathname) &&
!isPublicMarketingRoute &&
!isPublicMarketingApi
) {
// 对于 API 路由的非 GET 请求,直接返回 401 JSON 响应(不重定向)
if (pathname.startsWith("/api/") && request.method !== "GET") {
return NextResponse.json(