diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..50d5101 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,7 @@ +# 复制为 .env.local 并按实际环境填写。 + +# Wagtail 后端 API 地址 +NEXT_PUBLIC_API_URL=http://localhost:8000 + +# 接收后端发布 Webhook 的密钥(需与后端 REVALIDATE_SECRET 一致) +REVALIDATE_SECRET=change-me diff --git a/.gitignore b/.gitignore index 5ef6a52..3639bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env*.example # vercel .vercel diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 643577d..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,9 +0,0 @@ - - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. - -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. - - diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 43c994c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/app/api/revalidate/route.ts b/app/api/revalidate/route.ts new file mode 100644 index 0000000..6416aec --- /dev/null +++ b/app/api/revalidate/route.ts @@ -0,0 +1,34 @@ +import { revalidateTag } from "next/cache"; +import { NextRequest, NextResponse } from "next/server"; + +/** + * 接收 Wagtail `page_published` signal 发出的发布通知, + * 对 fetch 缓存标签做精准失效(详见 documents/设计方案分析与完善版.md §2.7)。 + * + * 后端调用示例(apps/core/signals.py): + * POST {FRONTEND_REVALIDATE_URL} + * Headers: Authorization: Bearer {REVALIDATE_SECRET} + * Body: { "tags": ["page:1", "/some/url/"] } + */ +export async function POST(request: NextRequest) { + const authHeader = request.headers.get("authorization"); + const expected = `Bearer ${process.env.REVALIDATE_SECRET}`; + + if (!process.env.REVALIDATE_SECRET || authHeader !== expected) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json().catch(() => null); + const tags: string[] = body?.tags ?? []; + + if (!Array.isArray(tags) || tags.length === 0) { + return NextResponse.json({ error: "No tags provided" }, { status: 400 }); + } + + for (const tag of tags) { + // Next.js 16 要求传入 cache profile;"max" 表示立即使该标签下的缓存完全失效。 + revalidateTag(tag, "max"); + } + + return NextResponse.json({ revalidated: tags }); +} diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..a8f5922 --- /dev/null +++ b/app/blog/[slug]/page.tsx @@ -0,0 +1,35 @@ +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { getBlogBySlug } from "@/services/blog.service"; +import { BlockRenderer } from "@/blocks/BlockRenderer"; + +interface BlogDetailPageProps { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ + params, +}: BlogDetailPageProps): Promise { + const { slug } = await params; + const post = await getBlogBySlug(slug).catch(() => null); + return { title: post?.title ?? "文章未找到" }; +} + +export default async function BlogDetailPage({ params }: BlogDetailPageProps) { + const { slug } = await params; + const post = await getBlogBySlug(slug).catch(() => null); + + if (!post) { + notFound(); + } + + return ( +
+

{post.title}

+ {post.published_at && ( +

{post.published_at}

+ )} + +
+ ); +} diff --git a/app/blog/page.tsx b/app/blog/page.tsx new file mode 100644 index 0000000..8ce23e8 --- /dev/null +++ b/app/blog/page.tsx @@ -0,0 +1,33 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { getBlogList } from "@/services/blog.service"; + +export const metadata: Metadata = { + title: "技术博客", +}; + +export default async function BlogListPage() { + const posts = await getBlogList().catch(() => []); + + return ( +
+

技术博客

+ +
+ ); +} diff --git a/app/globals.css b/app/globals.css index a2dc41e..40aa456 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,13 +3,18 @@ :root { --background: #ffffff; --foreground: #171717; + /* 主色调,对应 documents/设计方案分析与完善版.md UI 风格约定 */ + --color-primary: #2563eb; + --color-dark: #0f172a; } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --color-primary: var(--color-primary); + --color-dark: var(--color-dark); + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Microsoft YaHei", sans-serif; } @media (prefers-color-scheme: dark) { @@ -22,5 +27,4 @@ body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; } diff --git a/app/layout.tsx b/app/layout.tsx index 9852c15..9d4a192 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,29 +1,30 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import Header from "@/components/layout/Header"; +import Footer from "@/components/layout/Footer"; +import ReactQueryProvider from "@/providers/react-query-provider"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +// 面向中国大陆用户,避免依赖 next/font/google(构建期需访问 Google 服务器)。 +// 生产环境建议自托管思源黑体 / HarmonyOS Sans 子集,这里先用系统字体栈占位。 export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: { + default: "企业官网", + template: "%s | 企业官网", + }, + description: "企业级 Headless CMS 驱动的现代化官网", }; export default function RootLayout({ children }: LayoutProps<"/">) { return ( - - {children} + + + +
+
{children}
+