feat: 写作体验优化 + 封面图 + AI辅助写作 + 博客分页
写作体验优化: - 自动保存草稿到 localStorage(debounce 2s,刷新不丢内容) - 浏览器原生全屏专注写作模式 - Markdown 编辑模式(左右分栏,实时预览) - 快捷键:Ctrl+S / Ctrl+Enter 保存,ESC 退出全屏 封面图功能: - PostForm 新增封面图 URL 输入 + 实时预览 - BlogList 文章卡片显示封面缩略图 - PostContent 文章详情页显示封面大图 AI 辅助写作: - OpenAI 兼容接口,SSE 流式返回 - 8 种预设操作:润色、扩写、精简、续写、纠错、翻译、摘要 - 自定义指令输入,结果可复制/替换/追加 其他: - 后台文章列表改为一页10篇 - 前台 /blog 页面添加分页功能(一页10篇)
This commit is contained in:
@@ -8,6 +8,7 @@ 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);
|
||||
|
||||
@@ -20,6 +21,8 @@ export default function BlogList({ posts }: BlogListProps) {
|
||||
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;
|
||||
@@ -66,6 +69,13 @@ export default function BlogList({ posts }: BlogListProps) {
|
||||
});
|
||||
}, [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);
|
||||
@@ -73,6 +83,15 @@ export default function BlogList({ posts }: BlogListProps) {
|
||||
// 切换一个维度时清除另一个,避免组合空结果困惑
|
||||
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 });
|
||||
}
|
||||
@@ -136,12 +155,12 @@ export default function BlogList({ posts }: BlogListProps) {
|
||||
</div>
|
||||
|
||||
{/* Post list */}
|
||||
{filtered.length > 0 ? (
|
||||
{paged.length > 0 ? (
|
||||
<div ref={listRef as React.RefObject<HTMLDivElement>} className="space-y-0">
|
||||
{filtered.map((post) => (
|
||||
{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-8">
|
||||
<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)}
|
||||
@@ -168,6 +187,12 @@ export default function BlogList({ posts }: BlogListProps) {
|
||||
))}
|
||||
</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" />
|
||||
@@ -184,6 +209,39 @@ export default function BlogList({ posts }: BlogListProps) {
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user