fix: 修复后台文章计数为0 + 分类编辑补全描述字段 + CSP放行外部图片 + 更新关于页描述
- 文章管理:计数请求补 page=1 参数,命中分页接口返回正确的 total - 分类管理:编辑模式新增描述输入框,保存时一并提交 description - CSP:img-src 加入 https: 允许加载外部图片 - 关于页:数据存储描述从 JSON 文件更正为 SQLite 数据库 - Footer:添加 ICP 备案号
This commit is contained in:
+185
-101
@@ -1,128 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import type { Post } from "@/data/posts";
|
||||
import type { PublicPost } from "@/lib/store";
|
||||
import { formatDate, readingTimeLabel } from "@/lib/utils";
|
||||
import { useGsapAnimation } from "./useGsapAnimation";
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" });
|
||||
interface BlogListProps {
|
||||
posts: PublicPost[];
|
||||
}
|
||||
|
||||
export default function BlogList({ posts }: { posts: Post[] }) {
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
export default function BlogList({ posts }: BlogListProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const activeCategory = searchParams.get("category") || "";
|
||||
const activeTag = searchParams.get("tag") || "";
|
||||
|
||||
useEffect(() => {
|
||||
const ctxs: gsap.Context[] = [];
|
||||
|
||||
// Header animation
|
||||
if (headerRef.current) {
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(".blog-header-el", {
|
||||
y: 30,
|
||||
opacity: 0,
|
||||
duration: 0.7,
|
||||
stagger: 0.1,
|
||||
ease: "power3.out",
|
||||
});
|
||||
}, headerRef.current);
|
||||
ctxs.push(ctx);
|
||||
}
|
||||
|
||||
// List items stagger on scroll
|
||||
if (listRef.current) {
|
||||
const ctx = gsap.context(() => {
|
||||
gsap.from(".blog-list-item", {
|
||||
y: 40,
|
||||
opacity: 0,
|
||||
duration: 0.7,
|
||||
stagger: 0.1,
|
||||
ease: "power3.out",
|
||||
scrollTrigger: {
|
||||
trigger: listRef.current,
|
||||
start: "top 88%",
|
||||
},
|
||||
});
|
||||
}, listRef.current);
|
||||
ctxs.push(ctx);
|
||||
}
|
||||
|
||||
return () => ctxs.forEach((c) => c.revert());
|
||||
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]);
|
||||
|
||||
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");
|
||||
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} className="mb-14">
|
||||
<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">
|
||||
所有的文字,按时间排列。你也可以通过{" "}
|
||||
<Link href="/categories" className="text-terracotta hover:underline underline-offset-2">
|
||||
分类
|
||||
</Link>{" "}
|
||||
或{" "}
|
||||
<Link href="/tags" className="text-terracotta hover:underline underline-offset-2">
|
||||
标签
|
||||
</Link>{" "}
|
||||
来探索。
|
||||
所有的文字,按时间排列。通过下方的分类或标签来筛选探索。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Post list */}
|
||||
<div ref={listRef} className="space-y-0">
|
||||
{posts.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-6 px-6 rounded-xl transition-all duration-400">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-3 md:gap-8">
|
||||
<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">
|
||||
{post.readingTime} min
|
||||
</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>
|
||||
<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>
|
||||
))}
|
||||
{/* 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 */}
|
||||
{filtered.length > 0 ? (
|
||||
<div ref={listRef as React.RefObject<HTMLDivElement>} className="space-y-0">
|
||||
{filtered.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="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>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user