Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
### 🛡️ 坚实的技术架构
- **全栈类型安全**: TypeScript + Supabase 生成类型,确保前后端数据链路安全。
- **企业级认证**: 完整的用户认证流程,支持头像上传(自动压缩与清理)、资料管理。
- **高性能**: Next.js 服务端组件 (RSC) + 骨架屏 (Skeleton) 加载策略,秒级响应
- **高性能**: Next.js 服务端组件 (RSC)、服务端 Markdown、流式评论与按需资源加载;基线和回归标准见 [页面加载性能与服务端边界](docs/performance.md)
- **SEO 友好**: 自动生成 Sitemap、Robots 配置与 JSON-LD 结构化数据,搜索引擎完美抓取。

## 🛠️ 技术栈 (Tech Stack)
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,6 @@ git diff --check
npm run build
```

页面加载、Markdown 服务端化、slug 服务端边界和客户端分块的详细约束见 [页面加载性能与服务端边界](performance.md)。

数据库改动还必须遵循 [审计与整改计划](audit-remediation-plan.md) 中的 Supabase 验证要求。
56 changes: 56 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 页面加载性能与服务端边界

本文记录 2026-08-03 完成的页面加载性能优化、关键架构约束和后续回归标准。生产构建体积会随依赖版本和业务代码变化,本文数据用于保留同口径基线。

## 优化结果

| 指标 | 优化前 | 优化后 | 变化 |
| --- | ---: | ---: | ---: |
| 首页 JavaScript(gzip) | 353,499 B | 315,067 B | -10.9% |
| 文章详情客户端引用(raw) | 8,533,843 B | 574,479 B | -93.3% |
| Markdown 服务端化后的文章客户端引用(raw) | 6,969,562 B | 574,479 B | -91.8% |

公开文章不再向浏览器发送约 1.46 MB 的 Markdown 解析与高亮实现,也不再发送原先约 6.39 MB raw / 1.98 MB gzip 的拼音字典分块。优化后的生产客户端资源中不得出现 `pinyin` 实现。

## 实现边界

### 公开页面

- Markdown 正文、GFM 表格、代码高亮、标题锚点和 HTML 清理在服务端完成,初始 HTML 必须包含可阅读正文。
- 客户端只保留代码复制、展开/收起和文章选区复制等必要交互,不能让整个 Markdown 树跨越 Client Component 边界。
- 评论查询通过 `Suspense` 流式加载,不阻塞文章正文和侧栏输出。
- 文章详情直接使用数据库保存的标签名称与 slug 构造链接,不在请求期间重复执行拼音转换。

### Slug 与图片上传

- `pinyin` 只能由 `src/server/slug.ts` 引入,该模块必须声明 `server-only`。
- 文章创建、更新和标签管理由服务端生成最终 slug,客户端表单只提交用户显式填写的别名或空值。
- 图片上传优先使用显式 `articleSlug`;未填写时由上传 API 根据 `articleTitle` 在服务端生成目录 slug;无法生成时回退到 `blog/YYYY/MM`。
- 不得修改既有文章和标签 slug,也不得因性能优化改变公开 URL。

### 首屏资源

- 页面转场、站点加载器和精选文章描边使用 CSS 动画,并遵循 `prefers-reduced-motion`。
- 设置弹窗和文章预览按需加载,可在用户悬停或聚焦入口时预取。
- 首页视频仅预加载当前首要资源,其余视频在首帧缓冲完成并进入空闲时段后加载。
- `/videos` 与 `/images` 静态资源使用可重验证的长期缓存响应头。

## 回归标准

提交涉及路由、Server/Client 边界或资源加载的改动时,至少执行:

```bash
npm run lint
npm run typecheck
npm test
npm run build
git diff --check
```

生产构建后还需检查:

1. `.next/static/chunks` 中不存在拼音词典或 `pinyin` 客户端实现。
2. 文章初始响应包含正文、表格和高亮代码,不依赖客户端解析后才可阅读。
3. 首页、文章列表、搜索、标签和文章详情没有页面级横向溢出或控制台错误。
4. 桌面端与移动端的明暗主题、代码复制、代码展开/收起和标签跳转保持正常。
5. 后台文章保存、标签管理、封面上传和编辑器图片上传继续由服务端生成安全 slug。
18 changes: 18 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ const supabaseHostname = getSupabaseHostname();
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/videos/:asset*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=86400, stale-while-revalidate=604800",
},
],
},
{
source: "/videos/about/:asset*",
headers: [
Expand All @@ -22,6 +31,15 @@ const nextConfig: NextConfig = {
},
],
},
Comment on lines 16 to 33
{
source: "/images/:asset*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=86400, stale-while-revalidate=604800",
},
],
},
];
},
images: {
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/posts/[slug]/update/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse, type NextRequest } from 'next/server'
import { generatePostSlug, getPostExcerpt } from '@/lib/markdown'
import { getPostExcerpt } from '@/lib/markdown'
import { generatePostSlug } from '@/server/slug'
import { AccessError, requireAdmin } from '@/lib/server-auth'
import { getValidationMessage, postPayloadSchema } from '@/lib/validation'
import { invalidatePublishedPosts } from '@/server/cache'
Expand Down
3 changes: 2 additions & 1 deletion src/app/api/posts/create/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse, type NextRequest } from 'next/server'
import { generatePostSlug, getPostExcerpt } from '@/lib/markdown'
import { getPostExcerpt } from '@/lib/markdown'
import { generatePostSlug } from '@/server/slug'
import { AccessError, requireAdmin } from '@/lib/server-auth'
import { getValidationMessage, postPayloadSchema } from '@/lib/validation'
import { invalidatePublishedPosts } from '@/server/cache'
Expand Down
17 changes: 9 additions & 8 deletions src/app/api/upload-image/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
ImageStorageError,
storeBlogImage,
} from "@/server/image-storage"
import { resolveArticleUploadSlug } from "@/server/slug"

export const runtime = "nodejs"

Expand Down Expand Up @@ -46,15 +47,15 @@ export async function POST(request: NextRequest) {
return apiError("未选择图片文件", 400, "IMAGE_REQUIRED")
}

const folder = formData.get("folder")
const articleSlug = formData.get("articleSlug")
const articleTitle = formData.get("articleTitle")
const stored = await storeBlogImage(supabase, value, {
folder:
typeof formData.get("folder") === "string"
? String(formData.get("folder"))
: null,
articleSlug:
typeof formData.get("articleSlug") === "string"
? String(formData.get("articleSlug"))
: null,
folder: typeof folder === "string" ? folder : null,
articleSlug: resolveArticleUploadSlug({
articleSlug: typeof articleSlug === "string" ? articleSlug : null,
articleTitle: typeof articleTitle === "string" ? articleTitle : null,
}),
})

return NextResponse.json(stored)
Expand Down
41 changes: 41 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,47 @@ html, body {
font-family: var(--font-instrument-serif), Georgia, serif;
}

.page-enter-transition {
animation: page-enter 0.4s cubic-bezier(0.2, 0.01, 0, 1) both;
}

@keyframes page-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

.featured-border-draw {
stroke-dasharray: 1;
stroke-dashoffset: 1;
opacity: 0;
animation: featured-border-draw 1.5s ease-in-out forwards;
}

@keyframes featured-border-draw {
to {
stroke-dashoffset: 0;
opacity: 1;
}
}

@media (prefers-reduced-motion: reduce) {
.page-enter-transition {
animation: none;
}

.featured-border-draw {
animation: none;
stroke-dashoffset: 0;
opacity: 1;
}
}

/* Liquid Glass Effect */
.liquid-glass {
background: rgba(255, 255, 255, 0.04);
Expand Down
54 changes: 40 additions & 14 deletions src/app/post/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { notFound } from 'next/navigation'
import { Metadata } from 'next'
import Link from 'next/link'
import { ArrowLeft, Calendar, Tag, Eye, Clock, User } from 'lucide-react'
import { calculateReadingTime, formatDateString, generatePostSlug } from '@/lib/markdown'
import { calculateReadingTime, formatDateString } from '@/lib/markdown'
import { Button } from '@/components/ui/button'
import { ViewCounter } from '@/features/posts/components/view-counter'
import { CommentSection } from '@/features/posts/components/comment-section'
Expand All @@ -19,6 +19,29 @@ import { PostPageBackground } from '@/features/posts/components/post-page-backgr

import { BackToTop } from '@/components/ui/back-to-top'
import { GoToComments } from '@/components/ui/go-to-comments'
import { Suspense } from 'react'

async function PostComments({
postId,
commentsPromise,
}: {
postId: string
commentsPromise: ReturnType<typeof getComments>
}) {
const comments = await commentsPromise
return <CommentSection postId={postId} initialComments={comments} />
}

function PostCommentsFallback() {
return (
<div
id="comments"
aria-busy="true"
aria-label="评论加载中"
className="mt-8 min-h-32 border-t border-black/5 pt-8 dark:border-white/5 md:mt-16 md:pt-12"
/>
)
}

export async function generateMetadata({
params,
Expand Down Expand Up @@ -68,13 +91,14 @@ export default async function PostPage({
const post = await getVisiblePost(decodedSlug)
if (!post) notFound()

const [author, comments] = await Promise.all([
post.author_id ? getPublicProfile(post.author_id) : Promise.resolve(null),
getComments(post.id),
])
const commentsPromise = getComments(post.id)
const authorPromise = post.author_id
? getPublicProfile(post.author_id)
: Promise.resolve(null)
const author = await authorPromise

const content = post.content || ''
const tags = post.tags
const tags = post.tagLinks

const readingTime = calculateReadingTime(content)
const formattedDate = formatDateString(post.created_at)
Expand Down Expand Up @@ -143,10 +167,10 @@ export default async function PostPage({

{tags.length > 0 && (
<div className="post-hero-emphasis flex flex-wrap gap-2 pt-1">
{tags.map((tag: string) => {
const styles = getTagStyles(tag)
{tags.map((tag) => {
const styles = getTagStyles(tag.name)
return (
<Link key={tag} href={'/tag/' + generatePostSlug(tag)}>
<Link key={tag.slug} href={'/tag/' + tag.slug}>
<span
className="group relative overflow-hidden backdrop-blur-md px-3 py-1 rounded-full transition-all flex items-center gap-1.5 hover:scale-105 duration-300"
style={{
Expand All @@ -157,7 +181,7 @@ export default async function PostPage({
>
<div className="absolute inset-0 bg-gradient-to-br from-white/30 to-transparent opacity-50 group-hover:opacity-70 transition-opacity" />
<Tag className="w-3 h-3 relative z-10 opacity-70" />
<span className="text-xs font-bold uppercase tracking-wider relative z-10 shadow-sm">{tag}</span>
<span className="text-xs font-bold uppercase tracking-wider relative z-10 shadow-sm">{tag.name}</span>
</span>
</Link>
)
Expand Down Expand Up @@ -225,10 +249,12 @@ export default async function PostPage({
</div>
</div>

<CommentSection
postId={post.id}
initialComments={comments}
/>
<Suspense fallback={<PostCommentsFallback />}>
<PostComments
postId={post.id}
commentsPromise={commentsPromise}
/>
</Suspense>
</div>

{/* Sidebar */}
Expand Down
17 changes: 2 additions & 15 deletions src/app/template.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,7 @@
"use client"

import { motion, useReducedMotion } from "framer-motion"

export default function Template({ children }: { children: React.ReactNode }) {
const shouldReduceMotion = useReducedMotion()

return (
<motion.div
initial={shouldReduceMotion ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: shouldReduceMotion ? 0 : 0.4,
ease: [0.2, 0.01, 0, 1], // Custom cubic-bezier for a "premium" feel
}}
>
<div className="page-enter-transition">
{children}
</motion.div>
</div>
)
}
22 changes: 20 additions & 2 deletions src/components/layout/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useState, type FormEvent } from "react";
import Link from "next/link";
import dynamic from "next/dynamic";
import { usePathname, useRouter } from "next/navigation";
import { Plus, User, Search, Settings, LayoutDashboard, FileText, Menu, ChevronUp, ChevronDown } from "lucide-react";
import { type User as SupabaseUser } from "@supabase/supabase-js";
Expand Down Expand Up @@ -29,9 +30,20 @@ import { SignOutButton } from "@/features/auth/components/sign-out-button";
import { LoginModal } from "@/features/auth/components/login-modal";
import { Logo } from "@/components/ui/logo";

import { SettingsModal } from "@/features/settings/components/settings-modal";
import { SafeImage } from "@/components/ui/safe-image";

const SettingsModal = dynamic(
() =>
import("@/features/settings/components/settings-modal").then(
(module) => module.SettingsModal,
),
{ ssr: false },
);

function preloadSettingsModal() {
void import("@/features/settings/components/settings-modal");
}

const navItems = [
{ href: "/", label: "首页" },
{ href: "/post", label: "文章" },
Expand Down Expand Up @@ -176,6 +188,8 @@ export function Navbar({ user: initialUser }: { user?: SupabaseUser | null }) {
setIsSheetOpen(false);
setIsSettingsOpen(true);
}}
onMouseEnter={preloadSettingsModal}
onFocus={preloadSettingsModal}
className="px-4 py-3 text-sm font-medium text-neutral-500 hover:text-black dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl transition-all flex items-center gap-3 w-full text-left"
>
<Settings className="w-4 h-4" />
Expand Down Expand Up @@ -298,6 +312,8 @@ export function Navbar({ user: initialUser }: { user?: SupabaseUser | null }) {
)}
<DropdownMenuItem
onClick={() => setIsSettingsOpen(true)}
onMouseEnter={preloadSettingsModal}
onFocus={preloadSettingsModal}
className="cursor-pointer flex items-center w-full"
>
<Settings className="w-4 h-4 mr-2" />
Expand Down Expand Up @@ -361,7 +377,9 @@ export function Navbar({ user: initialUser }: { user?: SupabaseUser | null }) {
)} />

{/* Settings Modal */}
<SettingsModal open={isSettingsOpen} onOpenChange={setIsSettingsOpen} />
{isSettingsOpen ? (
<SettingsModal open onOpenChange={setIsSettingsOpen} />
) : null}
</>
);
}
Loading
Loading