Files
sui_blog/src/components/BlogList.tsx
T
胡旭 43e1c2f61d feat: 写作体验优化 + 封面图 + AI辅助写作 + 博客分页
写作体验优化:
- 自动保存草稿到 localStorage(debounce 2s,刷新不丢内容)
- 浏览器原生全屏专注写作模式
- Markdown 编辑模式(左右分栏,实时预览)
- 快捷键:Ctrl+S / Ctrl+Enter 保存,ESC 退出全屏

封面图功能:
- PostForm 新增封面图 URL 输入 + 实时预览
- BlogList 文章卡片显示封面缩略图
- PostContent 文章详情页显示封面大图

AI 辅助写作:
- OpenAI 兼容接口,SSE 流式返回
- 8 种预设操作:润色、扩写、精简、续写、纠错、翻译、摘要
- 自定义指令输入,结果可复制/替换/追加

其他:
- 后台文章列表改为一页10篇
- 前台 /blog 页面添加分页功能(一页10篇)
2026-06-24 15:22:03 +08:00

271 lines
11 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { PublicPost } from "@/lib/store";
import { formatDate, readingTimeLabel } from "@/lib/utils";
import { useGsapAnimation } from "./useGsapAnimation";
import { ChevronLeft, ChevronRight } from "lucide-react";
gsap.registerPlugin(ScrollTrigger);
interface BlogListProps {
posts: PublicPost[];
}
export default function BlogList({ posts }: BlogListProps) {
const router = useRouter();
const searchParams = useSearchParams();
const activeCategory = searchParams.get("category") || "";
const activeTag = searchParams.get("tag") || "";
const page = Number(searchParams.get("page")) || 1;
const pageSize = 10;
const headerRef = useGsapAnimation<HTMLDivElement>((_, isReduced) => {
if (isReduced) return;
gsap.from(".blog-header-el", {
y: 30,
opacity: 0,
duration: 0.7,
stagger: 0.1,
ease: "power3.out",
});
}, []);
const listRef = useGsapAnimation<HTMLDivElement>((scope, isReduced) => {
if (isReduced) return;
gsap.from(".blog-list-item", {
y: 40,
opacity: 0,
duration: 0.7,
stagger: 0.08,
ease: "power3.out",
scrollTrigger: { trigger: scope, start: "top 88%" },
});
}, [activeCategory, activeTag]);
// 从文章动态聚合分类与标签
const { categories, tags } = useMemo(() => {
const catSet = new Map<string, number>();
const tagSet = new Map<string, number>();
for (const p of posts) {
catSet.set(p.category, (catSet.get(p.category) || 0) + 1);
for (const t of p.tags) tagSet.set(t, (tagSet.get(t) || 0) + 1);
}
return {
categories: [...catSet.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name),
tags: [...tagSet.entries()].sort((a, b) => b[1] - a[1]).map(([name]) => name),
};
}, [posts]);
const filtered = useMemo(() => {
return posts.filter((p) => {
if (activeCategory && p.category !== activeCategory) return false;
if (activeTag && !p.tags.includes(activeTag)) return false;
return true;
});
}, [posts, activeCategory, activeTag]);
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const currentPage = Math.min(page, totalPages);
const paged = useMemo(() => {
const start = (currentPage - 1) * pageSize;
return filtered.slice(start, start + pageSize);
}, [filtered, currentPage]);
function setFilter(key: "category" | "tag", value: string) {
const params = new URLSearchParams(searchParams.toString());
if (value) params.set(key, value);
else params.delete(key);
// 切换一个维度时清除另一个,避免组合空结果困惑
if (key === "category") params.delete("tag");
if (key === "tag") params.delete("category");
params.delete("page"); // 切换筛选时重置分页
const qs = params.toString();
router.push(qs ? `/blog?${qs}` : "/blog", { scroll: false });
}
function setPage(p: number) {
const params = new URLSearchParams(searchParams.toString());
if (p > 1) params.set("page", String(p));
else params.delete("page");
const qs = params.toString();
router.push(qs ? `/blog?${qs}` : "/blog", { scroll: false });
}
const hasFilter = Boolean(activeCategory || activeTag);
return (
<div className="px-page max-w-5xl mx-auto pt-16 pb-24">
{/* Header */}
<div ref={headerRef as React.RefObject<HTMLDivElement>} className="mb-10">
<h1 className="blog-header-el font-display text-4xl md:text-5xl font-light text-ink tracking-tight">
文章
</h1>
<p className="blog-header-el mt-3 font-body text-ink-muted max-w-md">
所有的文字,按时间排列。通过下方的分类或标签来筛选探索。
</p>
</div>
{/* Filters */}
<div className="mb-10 space-y-4">
{/* Category filter */}
<div className="flex flex-wrap items-center gap-2">
<span className="font-sans text-xs text-ink-muted tracking-widest uppercase mr-1">分类</span>
<FilterChip active={!activeCategory} onClick={() => setFilter("category", "")}>
全部
</FilterChip>
{categories.map((cat) => (
<FilterChip key={cat} active={activeCategory === cat} onClick={() => setFilter("category", cat)}>
{cat}
</FilterChip>
))}
</div>
{/* Tag filter */}
<div className="flex flex-wrap items-center gap-2">
<span className="font-sans text-xs text-ink-muted tracking-widest uppercase mr-1">标签</span>
<FilterChip active={!activeTag} onClick={() => setFilter("tag", "")}>
全部
</FilterChip>
{tags.slice(0, 12).map((tag) => (
<FilterChip key={tag} active={activeTag === tag} onClick={() => setFilter("tag", tag)}>
{tag}
</FilterChip>
))}
</div>
</div>
{/* Result meta */}
<div className="mb-6 font-sans text-sm text-ink-muted">
{hasFilter ? (
<span>
{activeCategory && <>分类:<span className="text-terracotta">{activeCategory}</span> </>}
{activeTag && <>标签:<span className="text-terracotta">#{activeTag}</span> </>}
· {filtered.length}
<button onClick={() => router.push("/blog", { scroll: false })} className="ml-3 text-ink-muted hover:text-terracotta transition-colors underline underline-offset-2">
清除筛选
</button>
</span>
) : (
<span> {filtered.length} 篇文章</span>
)}
</div>
{/* Post list */}
{paged.length > 0 ? (
<div ref={listRef as React.RefObject<HTMLDivElement>} className="space-y-0">
{paged.map((post) => (
<Link key={post.slug} href={`/posts/${post.slug}`} className="blog-list-item group block">
<article className="relative py-8 border-b border-warm-gray/10 hover:bg-cream -mx-4 px-4 rounded-xl transition-all duration-300">
<div className="flex flex-col md:flex-row md:items-start gap-3 md:gap-6">
<div className="shrink-0 md:w-36 md:pt-1">
<time className="font-sans text-sm text-ink-muted tabular-nums">
{formatDate(post.date)}
</time>
<div className="mt-1 flex items-center gap-2 md:flex-col md:items-start md:gap-1">
<span className="font-sans text-sm text-terracotta">{post.category}</span>
<span className="hidden md:block font-sans text-sm text-ink-muted">
{readingTimeLabel(post.readingTime)}
</span>
</div>
</div>
<div className="flex-1 min-w-0">
<h2 className="font-display text-xl md:text-2xl font-medium text-ink group-hover:text-terracotta transition-colors duration-300 leading-snug">
{post.title}
</h2>
<p className="mt-2 font-body text-base text-ink-muted leading-relaxed line-clamp-2 max-w-xl">
{post.excerpt}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{post.tags.slice(0, 4).map((tag) => (
<span key={tag} className="font-sans text-xs px-2.5 py-0.5 rounded-full bg-parchment-deep text-ink-muted">
{tag}
</span>
))}
</div>
</div>
{post.coverImage && (
<div className="hidden md:block shrink-0 w-28 h-20 rounded-lg overflow-hidden bg-parchment-deep">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={post.coverImage} alt="" className="w-full h-full object-cover" />
</div>
)}
<div className="hidden md:flex shrink-0 items-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 pt-2">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" className="text-terracotta">
<path d="M7 12h10M13 8l4 4-4 4" />
</svg>
</div>
</div>
</article>
</Link>
))}
</div>
) : (
<div className="py-24 text-center">
<p className="font-display text-2xl text-ink-muted mb-2">空空如也</p>
<p className="font-sans text-sm text-ink-muted">该筛选条件下暂无文章。</p>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-12 font-sans text-sm">
<button
onClick={() => setPage(currentPage - 1)}
disabled={currentPage <= 1}
className="p-2 rounded-lg text-ink-muted hover:text-terracotta disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`w-8 h-8 rounded-lg transition-colors ${
p === currentPage
? "bg-ink text-cream"
: "text-ink-muted hover:text-terracotta hover:bg-cream"
}`}
>
{p}
</button>
))}
<button
onClick={() => setPage(currentPage + 1)}
disabled={currentPage >= totalPages}
className="p-2 rounded-lg text-ink-muted hover:text-terracotta disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
);
}
function FilterChip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
onClick={onClick}
className={`font-sans text-xs px-3 py-1.5 rounded-full border transition-colors duration-300 ${
active
? "bg-ink text-cream border-ink"
: "border-warm-gray/20 text-ink-muted hover:border-terracotta/40 hover:text-terracotta"
}`}
>
{children}
</button>
);
}