feat: continue lovart rebuild with public data integration
This commit is contained in:
parent
7686999c79
commit
d089d3a37d
96
app/api/public/lovart/route.ts
Normal file
96
app/api/public/lovart/route.ts
Normal 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
15
app/lovart/layout.tsx
Normal 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
192
app/lovart/news/page.tsx
Normal 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
310
app/lovart/page.tsx
Normal 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
228
app/lovart/pricing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
201
components/lovart/marketing-shell.tsx
Normal file
201
components/lovart/marketing-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
components/lovart/reveal.tsx
Normal file
28
components/lovart/reveal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
docs/lovart-重建接口联调清单.md
Normal file
57
docs/lovart-重建接口联调清单.md
Normal 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`,减少页面层重复聚合逻辑。
|
||||
@ -49,6 +49,7 @@ import {
|
||||
authSettings,
|
||||
loginToken,
|
||||
generatedImage,
|
||||
tutorialVideos,
|
||||
} from "./schema";
|
||||
import { generateHashedPassword } from "./utils";
|
||||
|
||||
@ -1638,18 +1639,57 @@ 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)
|
||||
.where(eq(tutorialVideos.isEnabled, true))
|
||||
.orderBy(asc(tutorialVideos.order));
|
||||
|
||||
|
||||
return videos;
|
||||
} catch (_error) {
|
||||
return [];
|
||||
|
||||
18
proxy.ts
18
proxy.ts
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user