feat: 重构博客为水墨纸质风格 + 搭建后台管理系统

- 重新设计全站 UI:parchment/ink/terracotta 水墨纸质色系,宋式 serif 排版
- 新增页面:文章列表、文章详情、分类、标签、关于
- GSAP ScrollTrigger 滚动动画 + 逐字揭示效果
- 后台管理系统 /admin:文章/分类/标签 CRUD,JSON 文件存储
- 登录认证(cookie session)
- 设计系统文档 UI.md
This commit is contained in:
胡旭
2026-06-24 08:45:28 +08:00
parent 507f12e501
commit dce8fe62ea
35 changed files with 3124 additions and 96 deletions
+187
View File
@@ -0,0 +1,187 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import type { Post, Category, Tag } from "@/lib/store";
export default function EditPostPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const [post, setPost] = useState<Post | null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [allTags, setAllTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({
title: "", slug: "", excerpt: "", content: "",
category: "", tags: [] as string[], readingTime: 5,
featured: false, status: "draft" as "draft" | "published",
date: "",
});
useEffect(() => {
Promise.all([
fetch(`/api/posts/${id}`).then((r) => r.json()),
fetch("/api/categories").then((r) => r.json()),
fetch("/api/tags").then((r) => r.json()),
]).then(([p, c, t]) => {
setPost(p);
setCategories(c);
setAllTags(t);
setForm({
title: p.title, slug: p.slug, excerpt: p.excerpt,
content: p.content, category: p.category, tags: p.tags,
readingTime: p.readingTime, featured: p.featured,
status: p.status, date: p.date,
});
setLoading(false);
});
}, [id]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const res = await fetch(`/api/posts/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (res.ok) router.push("/admin/posts");
}
function toggleTag(tagName: string) {
setForm((prev) => ({
...prev,
tags: prev.tags.includes(tagName)
? prev.tags.filter((t) => t !== tagName)
: [...prev.tags, tagName],
}));
}
if (loading) return <div className="font-sans text-ink-muted">...</div>;
if (!post) return <div className="font-sans text-red-600"></div>;
return (
<div>
<div className="flex items-center gap-4 mb-8">
<button onClick={() => router.back()} className="font-sans text-sm text-ink-muted hover:text-ink transition-colors"> </button>
<h1 className="font-display text-3xl font-medium text-ink"></h1>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-display text-lg text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5">Slug</label>
<input
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
type="date"
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<textarea
value={form.excerpt}
onChange={(e) => setForm({ ...form, excerpt: e.target.value })}
rows={2}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors resize-none"
/>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5">HTML</label>
<textarea
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
rows={12}
className="w-full px-4 py-3 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors resize-y font-mono"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<select
value={form.category}
onChange={(e) => setForm({ ...form, category: e.target.value })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
required
>
<option value=""></option>
{categories.map((c) => (
<option key={c.id} value={c.name}>{c.name}</option>
))}
</select>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<input
type="number" min={1}
value={form.readingTime}
onChange={(e) => setForm({ ...form, readingTime: Number(e.target.value) })}
className="w-full px-4 py-2.5 rounded-xl border border-warm-gray/20 bg-cream font-sans text-sm text-ink focus:outline-none focus:border-terracotta/40 transition-colors"
/>
</div>
</div>
<div>
<label className="block font-sans text-sm text-ink-muted mb-1.5"></label>
<div className="flex flex-wrap gap-2">
{allTags.map((tag) => (
<button key={tag.id} type="button" onClick={() => toggleTag(tag.name)}
className={`font-sans text-xs px-3 py-1.5 rounded-full border transition-colors ${
form.tags.includes(tag.name)
? "bg-terracotta/10 border-terracotta/30 text-terracotta"
: "border-warm-gray/20 text-ink-muted hover:border-terracotta/20"
}`}
>{tag.name}</button>
))}
</div>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input type="checkbox" checked={form.featured} onChange={(e) => setForm({ ...form, featured: e.target.checked })} className="accent-terracotta" />
</label>
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input type="radio" name="status" checked={form.status === "draft"} onChange={() => setForm({ ...form, status: "draft" })} className="accent-terracotta" />
稿
</label>
<label className="flex items-center gap-2 font-sans text-sm text-ink cursor-pointer">
<input type="radio" name="status" checked={form.status === "published"} onChange={() => setForm({ ...form, status: "published" })} className="accent-terracotta" />
</label>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" className="px-6 py-2.5 rounded-lg bg-ink text-cream font-sans text-sm hover:bg-terracotta transition-colors"></button>
<button type="button" onClick={() => router.back()} className="px-6 py-2.5 rounded-lg border border-warm-gray/20 font-sans text-sm text-ink-muted hover:text-ink transition-colors"></button>
</div>
</form>
</div>
);
}