feat: extend lovart rebuild with blog and changelog experiences
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:11:47 +08:00
parent d089d3a37d
commit f6b7758ec9
8 changed files with 548 additions and 50 deletions

View File

@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from "next/server"; import { type NextRequest, NextResponse } from "next/server";
import { import {
getPublicChangelogEvents,
getEnabledAiModelsWithProvider, getEnabledAiModelsWithProvider,
getNavLinks, getNavLinks,
getPublicFeaturedImages, getPublicFeaturedImages,
@ -67,6 +68,41 @@ export async function GET(request: NextRequest) {
return response; return response;
} }
if (view === "blog") {
const featuredImages = await getPublicFeaturedImages(8);
const response = NextResponse.json({
view,
settings,
blog: {
videos,
featuredImages,
},
});
response.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=300");
return response;
}
if (view === "changelog") {
const [events, ranking] = await Promise.all([
getPublicChangelogEvents(18),
settings.rankingEnabled ? getPublicFeaturedRanking(6) : Promise.resolve([]),
]);
const response = NextResponse.json({
view,
settings,
changelog: {
events,
ranking,
},
});
response.headers.set("Cache-Control", "public, s-maxage=60, stale-while-revalidate=300");
return response;
}
const [featuredImages, ranking] = await Promise.all([ const [featuredImages, ranking] = await Promise.all([
getPublicFeaturedImages(12), getPublicFeaturedImages(12),
settings.rankingEnabled ? getPublicFeaturedRanking(5) : Promise.resolve([]), settings.rankingEnabled ? getPublicFeaturedRanking(5) : Promise.resolve([]),

148
app/lovart/blog/page.tsx Normal file
View File

@ -0,0 +1,148 @@
import Link from "next/link";
import { BookOpen, Calendar, PlayCircle } from "lucide-react";
import { MarketingShell } from "@/components/lovart/marketing-shell";
import { Reveal } from "@/components/lovart/reveal";
import {
getNavLinks,
getPublicFeaturedImages,
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 LovartBlogPage() {
const [settings, navLinks, videos, featuredImages] = await Promise.all([
getSiteSettings(),
getNavLinks(),
getTutorialVideos(),
getPublicFeaturedImages(8),
]);
const [headline, ...restVideos] = videos;
return (
<MarketingShell
active="blog"
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) },
]}
>
<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">
<BookOpen className="h-3.5 w-3.5 text-cyan-300" />
Design Blog
</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">
这里聚合后台教程与案例内容,可作为对外 Blog 页持续运营更新。
</p>
</section>
</Reveal>
<Reveal delay={0.06}>
<section className="mt-8 grid gap-4 lg:grid-cols-[1.2fr_1fr]">
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
<div className="mb-3 inline-flex items-center gap-1 rounded-full border border-white/10 bg-white/[0.03] 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, 6).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) => (
<article key={video.id} className="rounded-2xl border border-white/15 bg-black/30 p-4">
<p className="text-xs text-zinc-400">{formatDate(video.updatedAt || video.createdAt)}</p>
<p className="mt-2 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>
<Reveal delay={0.18}>
<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/changelog">
查看更新日志
</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>
);
}

View File

@ -0,0 +1,152 @@
import Link from "next/link";
import { Calendar, WandSparkles } from "lucide-react";
import { MarketingShell } from "@/components/lovart/marketing-shell";
import { Reveal } from "@/components/lovart/reveal";
import {
getNavLinks,
getPublicChangelogEvents,
getPublicFeaturedRanking,
getSiteSettings,
} 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 LovartChangelogPage() {
const [settings, navLinks, events, ranking] = await Promise.all([
getSiteSettings(),
getNavLinks(),
getPublicChangelogEvents(18),
getPublicFeaturedRanking(6),
]);
const tutorialCount = events.filter((event) => event.kind === "tutorial").length;
const featuredCount = events.filter((event) => event.kind === "featured").length;
return (
<MarketingShell
active="changelog"
bannerText={settings.bannerEnabled ? settings.bannerText : null}
navLinks={navLinks}
siteLogo={settings.siteLogo || settings.siteLogoDark}
siteName={settings.siteName || "AI Chatbot"}
stats={[
{ label: "更新事件", value: String(events.length) },
{ label: "教程更新", value: String(tutorialCount) },
{ label: "精选更新", value: String(featuredCount) },
]}
>
<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">
<WandSparkles className="h-3.5 w-3.5 text-cyan-300" />
Product Changelog
</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.07}>
<section className="mt-8 grid gap-4 lg:grid-cols-[1.25fr_1fr]">
<article className="rounded-2xl border border-white/15 bg-black/30 p-5">
<h2 className="mb-4 text-lg font-semibold text-white">近期更新时间线</h2>
{events.length > 0 ? (
<div className="space-y-3">
{events.map((event) => (
<div
key={event.id}
className="rounded-xl border border-white/10 bg-white/[0.03] p-3"
>
<div className="mb-1 flex items-center justify-between text-[11px] text-zinc-400">
<span className="inline-flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(event.occurredAt)}
</span>
<span className="rounded-full border border-white/15 px-2 py-0.5 text-[10px] uppercase tracking-wide text-zinc-300">
{event.kind}
</span>
</div>
<p className="line-clamp-2 text-sm font-medium text-zinc-100">{event.title}</p>
<p className="mt-1 text-xs text-zinc-300">{event.summary}</p>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="line-clamp-1 text-[11px] text-zinc-400">{event.detail}</span>
{event.href ? (
<a
href={event.href}
target="_blank"
rel="noreferrer"
className="text-[11px] text-cyan-300 hover:text-cyan-200"
>
查看
</a>
) : null}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-300">暂无更新记录</p>
)}
</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>
<div className="space-y-2">
{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>
<div className="mt-4 rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-zinc-300">
<p className="font-medium text-zinc-100">说明</p>
<p className="mt-1">
该榜单来自精选图库实时统计,可用于运营激励展示。
</p>
</div>
</article>
</section>
</Reveal>
<Reveal delay={0.14}>
<section className="mt-8 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/pricing">
继续接支付与订阅
</Link>
<Link className="rounded-lg border border-white/20 px-3 py-1.5 hover:border-white/40" href="/lovart/news">
查看新闻中心
</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>
);
}

View File

@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { ArrowRight, Bot, Crown, Film, Layers3, Sparkles, WandSparkles } from "lucide-react"; import { ArrowRight, Bot, Crown, Film, Layers3, Sparkles, WandSparkles } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { HoverStack } from "@/components/lovart/hover-stack";
import { MarketingShell } from "@/components/lovart/marketing-shell"; import { MarketingShell } from "@/components/lovart/marketing-shell";
import { Reveal } from "@/components/lovart/reveal"; import { Reveal } from "@/components/lovart/reveal";
import { import {
@ -109,28 +110,14 @@ export default async function LovartHomePage() {
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <HoverStack
{featuredImages.slice(0, 4).map((image, index) => ( items={featuredImages.slice(0, 6).map((image) => ({
<article id: image.id,
key={image.id} imageUrl: image.thumbnailUrl || image.originalUrl,
className={`group overflow-hidden rounded-2xl border border-white/15 bg-black/30 ${ modelId: image.modelId || "AI Model",
index === 0 ? "col-span-2" : "" prompt: image.prompt || "AI 品牌视觉草图",
}`} }))}
> />
<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> </section>
</Reveal> </Reveal>

View File

@ -0,0 +1,77 @@
"use client";
import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
type StackItem = {
id: string;
imageUrl: string;
prompt: string;
modelId: string;
};
export function HoverStack({ items }: { items: StackItem[] }) {
const offsetX = useMotionValue(0);
const offsetY = useMotionValue(0);
const smoothX = useSpring(offsetX, { stiffness: 180, damping: 20, mass: 0.5 });
const smoothY = useSpring(offsetY, { stiffness: 180, damping: 20, mass: 0.5 });
const rotateY = useTransform(smoothX, [-160, 160], [-8, 8]);
const rotateX = useTransform(smoothY, [-120, 120], [7, -7]);
const cards = items.slice(0, 3);
if (cards.length === 0) {
return (
<div className="rounded-2xl border border-white/15 bg-black/30 p-6 text-sm text-zinc-300">
暂无可展示样例
</div>
);
}
return (
<motion.div
className="relative h-[290px] w-full [perspective:1200px]"
onMouseLeave={() => {
offsetX.set(0);
offsetY.set(0);
}}
onMouseMove={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
const x = event.clientX - (rect.left + rect.width / 2);
const y = event.clientY - (rect.top + rect.height / 2);
offsetX.set(x);
offsetY.set(y);
}}
style={{ rotateX, rotateY, transformStyle: "preserve-3d" }}
>
{cards.map((item, index) => {
const offset = index * 18;
return (
<motion.article
key={item.id}
animate={{ y: [offset, offset - 7, offset], scale: [1, 1.01, 1] }}
className="absolute left-0 right-0 mx-auto w-[92%] overflow-hidden rounded-2xl border border-white/15 bg-black/35 shadow-[0_20px_60px_-24px_rgba(14,165,233,0.55)]"
style={{
zIndex: cards.length - index,
transform: `translateZ(${(cards.length - index) * 28}px)`,
top: `${index * 24}px`,
}}
transition={{
duration: 4 + index,
ease: "easeInOut",
repeat: Number.POSITIVE_INFINITY,
}}
>
<img alt={item.prompt} className="h-36 w-full object-cover" src={item.imageUrl} />
<div className="space-y-1 p-3">
<p className="line-clamp-2 text-xs text-zinc-200">{item.prompt}</p>
<p className="text-[11px] text-zinc-400">{item.modelId}</p>
</div>
</motion.article>
);
})}
</motion.div>
);
}

View File

@ -17,6 +17,8 @@ const mainTabs = [
{ key: "home", label: "首页", href: "/lovart" }, { key: "home", label: "首页", href: "/lovart" },
{ key: "pricing", label: "定价", href: "/lovart/pricing" }, { key: "pricing", label: "定价", href: "/lovart/pricing" },
{ key: "news", label: "新闻", href: "/lovart/news" }, { key: "news", label: "新闻", href: "/lovart/news" },
{ key: "blog", label: "博客", href: "/lovart/blog" },
{ key: "changelog", label: "更新日志", href: "/lovart/changelog" },
] as const; ] as const;
const socialLinks = [ const socialLinks = [
@ -37,6 +39,10 @@ function resolveDocsLink(navLinks: NavLinkItem[], keywords: string[]) {
); );
} }
function isExternal(url: string) {
return /^https?:\/\//i.test(url);
}
export function MarketingShell({ export function MarketingShell({
siteName, siteName,
siteLogo, siteLogo,
@ -49,14 +55,14 @@ export function MarketingShell({
siteName: string; siteName: string;
siteLogo?: string | null; siteLogo?: string | null;
navLinks: NavLinkItem[]; navLinks: NavLinkItem[];
active: "home" | "pricing" | "news"; active: "home" | "pricing" | "news" | "blog" | "changelog";
bannerText?: string | null; bannerText?: string | null;
stats?: StatItem[]; stats?: StatItem[];
children: ReactNode; children: ReactNode;
}) { }) {
const docsUrl = resolveDocsLink(navLinks, ["文档", "docs"]); const docsUrl = resolveDocsLink(navLinks, ["文档", "docs"]);
const blogUrl = resolveDocsLink(navLinks, ["博客", "blog"]); const blogUrl = resolveDocsLink(navLinks, ["博客", "blog"]) || "/lovart/blog";
const changelogUrl = resolveDocsLink(navLinks, ["更新", "changelog", "release"]); const changelogUrl = resolveDocsLink(navLinks, ["更新", "changelog", "release"]) || "/lovart/changelog";
return ( return (
<div className="min-h-dvh bg-[#050814] text-zinc-100"> <div className="min-h-dvh bg-[#050814] text-zinc-100">
@ -85,29 +91,52 @@ export function MarketingShell({
</Link> </Link>
<nav className="hidden items-center gap-5 text-sm lg:flex"> <nav className="hidden items-center gap-5 text-sm lg:flex">
{mainTabs.map((tab) => ( {mainTabs.map((tab) => {
<Link if (tab.key === "blog" && isExternal(blogUrl)) {
key={tab.key} return (
href={tab.href} <a
className={ key={tab.key}
active === tab.key className={active === tab.key ? "text-white" : "text-zinc-300 transition hover:text-white"}
? "text-white" href={blogUrl}
: "text-zinc-300 transition hover:text-white" rel="noreferrer"
} target="_blank"
> >
{tab.label} {tab.label}
</Link> </a>
))} );
{blogUrl ? ( }
<a className="text-zinc-300 transition hover:text-white" href={blogUrl} rel="noreferrer" target="_blank">
博客 if (tab.key === "changelog" && isExternal(changelogUrl)) {
</a> return (
) : null} <a
{changelogUrl ? ( key={tab.key}
<a className="text-zinc-300 transition hover:text-white" href={changelogUrl} rel="noreferrer" target="_blank"> className={active === tab.key ? "text-white" : "text-zinc-300 transition hover:text-white"}
更新日志 href={changelogUrl}
</a> rel="noreferrer"
) : null} target="_blank"
>
{tab.label}
</a>
);
}
const href =
tab.key === "blog"
? blogUrl
: tab.key === "changelog"
? changelogUrl
: tab.href;
return (
<Link
key={tab.key}
href={href}
className={active === tab.key ? "text-white" : "text-zinc-300 transition hover:text-white"}
>
{tab.label}
</Link>
);
})}
{docsUrl ? ( {docsUrl ? (
<a className="inline-flex items-center gap-1 text-zinc-300 transition hover:text-white" href={docsUrl} rel="noreferrer" target="_blank"> <a className="inline-flex items-center gap-1 text-zinc-300 transition hover:text-white" href={docsUrl} rel="noreferrer" target="_blank">
文档 文档
@ -152,7 +181,7 @@ export function MarketingShell({
<main className="relative z-10 mx-auto w-full max-w-6xl px-4 pb-20 pt-8 sm:px-6">{children}</main> <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"> <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) => ( {mainTabs.slice(0, 3).map((tab) => (
<Link <Link
key={tab.key} key={tab.key}
className={`rounded-full px-3 py-1 text-xs ${ className={`rounded-full px-3 py-1 text-xs ${

View File

@ -14,19 +14,24 @@
| `/lovart` | `getPublicFeaturedRanking` | DB 直连(RSC) | 已接入 | | `/lovart` | `getPublicFeaturedRanking` | DB 直连(RSC) | 已接入 |
| `/lovart/pricing` | 模型、教程、精选样例 | DB 直连(RSC) | 已接入 | | `/lovart/pricing` | 模型、教程、精选样例 | DB 直连(RSC) | 已接入 |
| `/lovart/news` | 教程流 + 精选流 + 榜单 | DB 直连(RSC) | 已接入 | | `/lovart/news` | 教程流 + 精选流 + 榜单 | DB 直连(RSC) | 已接入 |
| `/lovart/blog` | 教程流 + 灵感墙 | DB 直连(RSC) | 已接入 |
| `/lovart/changelog` | 教程更新 + 精选更新时间线 | DB 直连(RSC) | 已接入 |
| `/lovart*` 公开访问 | `proxy.ts` 放行 | 中间件路由白名单 | 已接入 | | `/lovart*` 公开访问 | `proxy.ts` 放行 | 中间件路由白名单 | 已接入 |
| `/api/public/lovart?view=home` | 首页聚合数据 | Public API | 已接入 | | `/api/public/lovart?view=home` | 首页聚合数据 | Public API | 已接入 |
| `/api/public/lovart?view=pricing` | 定价页聚合数据 | Public API | 已接入 | | `/api/public/lovart?view=pricing` | 定价页聚合数据 | Public API | 已接入 |
| `/api/public/lovart?view=news` | 新闻页聚合数据 | Public API | 已接入 | | `/api/public/lovart?view=news` | 新闻页聚合数据 | Public API | 已接入 |
| `/api/public/lovart?view=blog` | 博客页聚合数据 | Public API | 已接入 |
| `/api/public/lovart?view=changelog` | 更新日志聚合数据 | Public API | 已接入 |
## 新增查询与 API ## 新增查询与 API
- 查询函数(`lib/db/queries.ts`) - 查询函数(`lib/db/queries.ts`)
- `getPublicFeaturedImages(limit)` - `getPublicFeaturedImages(limit)`
- `getPublicFeaturedRanking(limit)` - `getPublicFeaturedRanking(limit)`
- `getPublicChangelogEvents(limit)`
- `getTutorialVideos()`(改为静态导入,减少运行时开销) - `getTutorialVideos()`(改为静态导入,减少运行时开销)
- 公开接口(`app/api/public/lovart/route.ts`) - 公开接口(`app/api/public/lovart/route.ts`)
- `GET /api/public/lovart?view=home|pricing|news` - `GET /api/public/lovart?view=home|pricing|news|blog|changelog`
- 返回公共营销页所需聚合数据(含短缓存头) - 返回公共营销页所需聚合数据(含短缓存头)
## 页面层联动点 ## 页面层联动点

View File

@ -1681,6 +1681,70 @@ export async function getPublicFeaturedRanking(limit = 5) {
} }
} }
export async function getPublicChangelogEvents(limit = 18) {
try {
const [tutorialEvents, featuredEvents] = await Promise.all([
db
.select({
id: tutorialVideos.id,
title: tutorialVideos.title,
description: tutorialVideos.description,
link: tutorialVideos.videoUrl,
imageUrl: tutorialVideos.coverUrl,
occurredAt: tutorialVideos.updatedAt,
createdAt: tutorialVideos.createdAt,
})
.from(tutorialVideos)
.where(eq(tutorialVideos.isEnabled, true))
.orderBy(desc(tutorialVideos.updatedAt), desc(tutorialVideos.createdAt))
.limit(limit),
db
.select({
id: generatedImage.id,
prompt: generatedImage.prompt,
modelId: generatedImage.modelId,
imageUrl: generatedImage.thumbnailUrl,
link: generatedImage.originalUrl,
occurredAt: generatedImage.featuredAt,
createdAt: generatedImage.createdAt,
})
.from(generatedImage)
.where(eq(generatedImage.isFeatured, true))
.orderBy(desc(generatedImage.featuredAt), desc(generatedImage.createdAt))
.limit(limit),
]);
const events = [
...tutorialEvents.map((item) => ({
id: `tutorial-${item.id}`,
kind: "tutorial" as const,
title: item.title,
summary: item.description || "新增教程内容",
detail: "教程内容更新",
imageUrl: item.imageUrl,
href: item.link,
occurredAt: item.occurredAt || item.createdAt,
})),
...featuredEvents.map((item) => ({
id: `featured-${item.id}`,
kind: "featured" as const,
title: item.prompt || "新增精选作品",
summary: "精选画廊新增作品",
detail: item.modelId || "AI model",
imageUrl: item.imageUrl,
href: item.link,
occurredAt: item.occurredAt || item.createdAt,
})),
];
return events
.sort((a, b) => (b.occurredAt?.getTime() ?? 0) - (a.occurredAt?.getTime() ?? 0))
.slice(0, limit);
} catch (_error) {
return [];
}
}
// 获取视频教程列表 // 获取视频教程列表
export async function getTutorialVideos() { export async function getTutorialVideos() {
try { try {