fix: 修复后台文章计数为0 + 分类编辑补全描述字段 + CSP放行外部图片 + 更新关于页描述

- 文章管理:计数请求补 page=1 参数,命中分页接口返回正确的 total
- 分类管理:编辑模式新增描述输入框,保存时一并提交 description
- CSP:img-src 加入 https: 允许加载外部图片
- 关于页:数据存储描述从 JSON 文件更正为 SQLite 数据库
- Footer:添加 ICP 备案号
This commit is contained in:
胡旭
2026-06-24 13:51:48 +08:00
parent 3707eddfd4
commit 18e915bcbb
69 changed files with 6818 additions and 1422 deletions
+70
View File
@@ -0,0 +1,70 @@
import { cookies } from "next/headers";
/**
* 认证相关常量与校验函数。
*
* 会话采用 HMAC 签名的 tokenpayload + "." + signature),服务端校验签名,
* 避免使用固定字符串 cookie 被伪造。签名密钥来自环境变量,缺失时回退到
* 开发用默认值(仅用于本地)。
*/
const SESSION_KEY = "admin_session";
function getSecret(): string {
return process.env.SESSION_SECRET || "dev-only-insecure-secret-change-me";
}
/**
* 用 Web Crypto API 计算字符串的 HMAC-SHA256,返回 base64url。
* 不依赖外部库,Node 18+ 与 edge runtime 均可用。
*/
async function hmac(message: string, secret: string): Promise<string> {
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
const bytes = new Uint8Array(sig);
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** 签发一个带过期时间的会话 token。 */
export async function createSession(maxAgeSeconds: number): Promise<string> {
const payload = JSON.stringify({
v: "1",
exp: Date.now() + maxAgeSeconds * 1000,
});
const b64 = btoa(unescape(encodeURIComponent(payload)));
const sig = await hmac(b64, getSecret());
return `${b64}.${sig}`;
}
/** 校验 token 签名与过期时间,通过返回 true。 */
async function verifySession(token: string): Promise<boolean> {
const [b64, sig] = token.split(".");
if (!b64 || !sig) return false;
const expected = await hmac(b64, getSecret());
if (sig !== expected) return false;
try {
const payload = JSON.parse(decodeURIComponent(escape(atob(b64))));
return typeof payload.exp === "number" && payload.exp > Date.now();
} catch {
return false;
}
}
/** 在 Server Component / Route Handler 中检查当前是否已登录。 */
export async function checkAuth(): Promise<boolean> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_KEY)?.value;
if (!token) return false;
return verifySession(token);
}
export { SESSION_KEY };
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { checkAuth } from "./auth";
/** 统一的未授权响应。 */
export function unauthorized() {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
/** 解析并校验请求体,失败时返回 422 响应(由调用方 return)。 */
export async function parseBody<T>(
request: Request,
schema: { parse: (d: unknown) => T }
): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> {
try {
const json = await request.json();
const data = schema.parse(json);
return { ok: true, data };
} catch (err) {
if (err instanceof ZodError) {
return {
ok: false,
response: NextResponse.json(
{ error: "输入校验失败", issues: err.issues.map((i) => i.message) },
{ status: 422 }
),
};
}
return {
ok: false,
response: NextResponse.json({ error: "请求格式错误" }, { status: 400 }),
};
}
}
/** 在受保护路由开头做鉴权,未通过则返回 401 响应。 */
export async function requireAuth(): Promise<NextResponse | null> {
if (!(await checkAuth())) return unauthorized();
return null;
}
+45
View File
@@ -0,0 +1,45 @@
/**
* 极简的内存级速率限制,针对登录接口防暴力破解。
*
* 注:基于进程内存,多实例部署下不共享;对单实例个人博客足够。
* 生产环境若需更强保护,可换用 Redis 或 upstash ratelimit。
*/
const attempts = new Map<string, { count: number; lockedUntil: number }>();
const MAX_ATTEMPTS = 5;
const LOCK_MS = 15 * 60 * 1000; // 锁定 15 分钟
/** 记录一次失败尝试;返回当前是否已被锁定。 */
export function registerFailedAttempt(key: string): { locked: boolean; retryAfterSec: number } {
const now = Date.now();
const entry = attempts.get(key);
if (entry && entry.lockedUntil > now) {
return { locked: true, retryAfterSec: Math.ceil((entry.lockedUntil - now) / 1000) };
}
const count = entry && entry.lockedUntil > 0 ? entry.count + 1 : 1;
if (count >= MAX_ATTEMPTS) {
attempts.set(key, { count, lockedUntil: now + LOCK_MS });
return { locked: true, retryAfterSec: Math.ceil(LOCK_MS / 1000) };
}
attempts.set(key, { count, lockedUntil: 0 });
return { locked: false, retryAfterSec: 0 };
}
/** 登录成功后清除记录。 */
export function clearAttempts(key: string): void {
attempts.delete(key);
}
/** 检查 key 是否仍处于锁定状态。 */
export function isLocked(key: string): { locked: boolean; retryAfterSec: number } {
const entry = attempts.get(key);
const now = Date.now();
if (entry && entry.lockedUntil > now) {
return { locked: true, retryAfterSec: Math.ceil((entry.lockedUntil - now) / 1000) };
}
return { locked: false, retryAfterSec: 0 };
}
+34
View File
@@ -0,0 +1,34 @@
import sanitize from "sanitize-html";
/**
* 净化用户提交的 HTML 内容,移除脚本、事件处理器等危险标签,
* 但保留博客正文所需的语义化标签与 class(用于 prose 排版)。
*/
const SANITIZE_CONFIG: sanitize.IOptions = {
allowedTags: [
"p", "br", "hr", "span", "div", "section", "article",
"h1", "h2", "h3", "h4", "h5", "h6",
"strong", "b", "em", "i", "u", "s", "del", "ins", "mark", "small", "sub", "sup",
"blockquote", "q", "cite",
"ul", "ol", "li", "dl", "dt", "dd",
"a", "img",
"pre", "code",
"table", "thead", "tbody", "tr", "th", "td",
"figure", "figcaption",
"abbr", "address", "time", "kbd", "var", "samp",
],
allowedAttributes: {
"a": ["href", "target", "rel"],
"img": ["src", "alt", "title", "width", "height"],
"*": ["class", "datetime", "cite", "lang", "dir"],
"td": ["colspan", "rowspan"],
"th": ["colspan", "rowspan"],
},
allowedSchemes: ["http", "https", "mailto"],
disallowedTagsMode: "discard",
};
export function sanitizeHtml(dirty: string): string {
if (!dirty) return "";
return sanitize(dirty, SANITIZE_CONFIG);
}
+19 -136
View File
@@ -1,141 +1,24 @@
/**
* Seed script — run once to populate initial data from mock posts.
* Usage: npx tsx src/lib/seed.ts
* Seed 脚本入口 — 可手动运行:`npx tsx src/lib/seed.ts`
*
* 运行时自动初始化已在 store.ts 的 ensureSeed() 中处理,
* 本脚本仅作为显式重置/查看用途。
*/
import { createPost, createCategory, createTag, getPosts, getCategories, getTags } from "./store";
import { ensureSeed, getPosts, getCategories, getTags } from "./store";
const seedPosts = [
{
slug: "on-writing-and-silence",
title: "论写作与沉默",
excerpt: "有些话适合写在纸上,有些话适合留在风里。写作不是填满空白的过程,而是从空白中提炼意义的旅程。",
content: "<p>有些话适合写在纸上,有些话适合留在风里。</p><p>我常常觉得,沉默是一种被低估的能力。在这个信息过载的时代,我们急于表达、急于分享,却很少给自己留出沉默的空间。写作不是填满空白的过程,而是从空白中提炼意义的旅程。</p><p>每一次落笔,都是一次与自己的对话。那些在深夜里涌现的念头,像潮水一样涌来,又像退潮后的贝壳,最终留下的才是最珍贵的。</p><p>我开始学会在写作之前先沉默。让想法在脑海中沉淀,让语言在时间里发酵。好的文字从来不是急出来的。</p>",
date: "2026-06-15",
category: "随笔",
tags: ["写作", "思考", "生活哲学"],
readingTime: 4,
featured: true,
status: "published" as const,
},
{
slug: "a-walk-in-the-mountains",
title: "山中漫步",
excerpt: "大别山的清晨,雾气从谷底升起,像是大地在缓缓呼吸。脚下的石板路被露水打湿,每一步都需要格外小心。",
content: "<p>大别山的清晨,雾气从谷底升起,像是大地在缓缓呼吸。</p><p>脚下的石板路被露水打湿,每一步都需要格外小心。路旁的野花在薄雾中若隐若现,紫色和白色交替出现,像是大自然精心编排的欢迎仪式。</p><p>山里的时间过得格外慢。没有手机的信号,没有城市的喧嚣,只有鸟鸣和溪流的声音。这种安静让我想起小时候在外婆家的日子。</p>",
date: "2026-06-10",
category: "旅行",
tags: ["旅行", "自然", "六安"],
readingTime: 6,
featured: true,
status: "published" as const,
},
{
slug: "notes-on-digital-twin",
title: "数字孪生笔记:从3D建模到Web可视化",
excerpt: "从 Three.js 到 React Three FiberWeb 3D 的门槛比想象中低很多,但要做好,需要理解的东西远不止代码。",
content: "<p>从 Three.js 到 React Three FiberWeb 3D 的门槛比想象中低很多。</p><p>但要做好,需要理解的东西远不止代码。光照、材质、相机、性能优化,每一个都是深坑。这篇文章记录我在数字孪生项目中的一些实践和思考。</p>",
date: "2026-06-05",
category: "技术",
tags: ["Web3D", "React", "Three.js", "前端"],
readingTime: 8,
featured: false,
status: "published" as const,
},
{
slug: "reading-list-spring",
title: "春日书单:五本改变我看世界方式的书",
excerpt: "春天适合读一些柔软的书。不是那种让人醍醐灌顶的大部头,而是像春雨一样润物细无声的文字。",
content: "<p>春天适合读一些柔软的书。</p><p>不是那种让人醍醐灌顶的大部头,而是像春雨一样润物细无声的文字。以下五本书,在这个春天给了我很多安静的力量。</p>",
date: "2026-05-28",
category: "阅读",
tags: ["阅读", "书单", "生活"],
readingTime: 5,
featured: true,
status: "published" as const,
},
{
slug: "stable-diffusion-local-setup",
title: "本地部署 Stable Diffusion 踩坑记",
excerpt: "M3 16GB 的统一内存是优势也是限制。记录在 Apple Silicon 上跑 SD 1.5 和 SDXL 的完整过程。",
content: "<p>M3 16GB 的统一内存是优势也是限制。</p><p>这篇文章记录在 Apple Silicon 上跑 SD 1.5 和 SDXL 的完整过程,包括环境搭建、模型选择、LoRA 训练的一些尝试。</p>",
date: "2026-05-20",
category: "技术",
tags: ["AI", "Stable Diffusion", "Apple Silicon"],
readingTime: 10,
featured: false,
status: "published" as const,
},
{
slug: "lightbox-dream",
title: "灯箱:一个小城青年的创业梦",
excerpt: "标识灯箱这个行业,外行人觉得简单,内行人知道水深。从3D预览到商业模式,记录 sui_lightbox 的诞生过程。",
content: "<p>标识灯箱这个行业,外行人觉得简单,内行人知道水深。</p><p>从最初的一个想法,到3D预览原型的实现,再到商业模式的探索。这篇文章记录 sui_lightbox 项目从0到1的过程。</p>",
date: "2026-05-12",
category: "创业",
tags: ["创业", "灯箱", "产品", "sui_lightbox"],
readingTime: 7,
featured: true,
status: "published" as const,
},
{
slug: "rainy-day-thoughts",
title: "雨天杂记",
excerpt: "六月的梅雨季,窗外的梧桐叶被雨打得东倒西歪。泡一壶六安瓜片,坐在窗前看雨,什么都不想。",
content: "<p>六月的梅雨季,窗外的梧桐叶被雨打得东倒西歪。</p><p>泡一壶六安瓜片,坐在窗前看雨,什么都不想。这种无所事事的下午,反而是一周中最有创造力的时刻。</p>",
date: "2026-05-05",
category: "随笔",
tags: ["随笔", "生活", "六安"],
readingTime: 3,
featured: false,
status: "published" as const,
},
{
slug: "next-js-blog-from-scratch",
title: "从零搭建一个博客系统",
excerpt: "为什么选择 Next.js + Halo CMS?为什么不用 WordPress?这篇文章聊聊技术选型的思考过程。",
content: "<p>为什么选择 Next.js + Halo CMS?为什么不用 WordPress</p><p>作为一个前端开发者,我希望能完全掌控博客的UI和交互体验。Halo CMS 提供了足够好的内容管理API,而 Next.js 则让我可以自由设计前端展示。</p>",
date: "2026-04-28",
category: "技术",
tags: ["Next.js", "博客", "Halo CMS", "前端"],
readingTime: 6,
featured: false,
status: "published" as const,
},
];
const seedCategories = [
{ name: "技术", description: "代码、架构与技术探索" },
{ name: "随笔", description: "生活感悟与碎片思考" },
{ name: "旅行", description: "在路上看到的风景与人" },
{ name: "阅读", description: "书中世界与阅读心得" },
{ name: "创业", description: "产品思考与创业记录" },
];
const seedTags = [
"写作", "思考", "生活哲学", "旅行", "自然", "六安",
"Web3D", "React", "Three.js", "前端", "阅读", "书单",
"生活", "AI", "Stable Diffusion", "Apple Silicon",
"创业", "灯箱", "产品", "sui_lightbox", "随笔",
"Next.js", "博客", "Halo CMS",
];
// Only seed if empty
if (getPosts().length === 0) {
console.log("Seeding posts...");
seedPosts.forEach((p) => createPost(p));
console.log(` Created ${seedPosts.length} posts`);
async function main() {
await ensureSeed();
const [posts, categories, tags] = await Promise.all([
getPosts(),
getCategories(),
getTags(),
]);
console.log("Seed complete:", {
posts: posts.length,
categories: categories.length,
tags: tags.length,
});
process.exit(0);
}
if (getCategories().length === 0) {
console.log("Seeding categories...");
seedCategories.forEach((c) => createCategory(c));
console.log(` Created ${seedCategories.length} categories`);
}
if (getTags().length === 0) {
console.log("Seeding tags...");
seedTags.forEach((t) => createTag({ name: t }));
console.log(` Created ${seedTags.length} tags`);
}
console.log("Seed complete.");
main();
+311 -85
View File
@@ -1,13 +1,20 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import path from "path";
import { PrismaClient } from "@prisma/client";
import { sanitizeHtml } from "./sanitize";
import { seedPosts, seedCategories, seedTags } from "@/data/posts";
const DATA_DIR = path.join(process.cwd(), "src/data/storage");
// ── Prisma 单例 ──
// Ensure storage directory exists
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
// ── 类型定义(保持不变) ──
/** 统一的文章类型,前台与后台共用。 */
export interface Post {
id: string;
slug: string;
@@ -17,6 +24,8 @@ export interface Post {
date: string;
category: string;
tags: string[];
/** 可选封面图,前台卡片可在有值时展示。 */
coverImage?: string;
readingTime: number;
featured: boolean;
status: "draft" | "published";
@@ -35,119 +44,336 @@ export interface Tag {
name: string;
}
function readJSON<T>(filename: string, fallback: T): T {
const filepath = path.join(DATA_DIR, filename);
if (!existsSync(filepath)) return fallback;
try {
return JSON.parse(readFileSync(filepath, "utf-8"));
} catch {
return fallback;
}
}
/** 文章可见性:前台只展示已发布。 */
export type PublicPost = Omit<Post, "status"> & { status: "published" };
function writeJSON(filename: string, data: unknown) {
const filepath = path.join(DATA_DIR, filename);
writeFileSync(filepath, JSON.stringify(data, null, 2), "utf-8");
}
// ── 内部工具 ──
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
/** 写入前对内容做净化,剥离任意可执行脚本。 */
function sanitizePostContent<T extends { content?: string }>(data: T): T {
if (typeof data.content === "string") {
return { ...data, content: sanitizeHtml(data.content) };
}
return data;
}
/** 将数据库行转为应用层 Post 类型(tags JSON → string[])。 */
function toPost(row: {
id: string;
slug: string;
title: string;
excerpt: string;
content: string;
date: string;
category: string;
tags: string;
coverImage: string | null;
readingTime: number;
featured: boolean;
status: string;
createdAt: string;
updatedAt: string;
}): Post {
return {
...row,
coverImage: row.coverImage ?? undefined,
tags: JSON.parse(row.tags) as string[],
status: row.status as Post["status"],
};
}
// ── Posts ──
export function getPosts(): Post[] {
return readJSON<Post[]>("posts.json", []);
/** 分页查询结果。 */
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
pageSize: number;
totalPages: number;
}
export function getPost(id: string): Post | undefined {
return getPosts().find((p) => p.id === id);
/** 后台用:读取全部文章(含草稿)。 */
export async function getPosts(): Promise<Post[]> {
const rows = await prisma.post.findMany({ orderBy: { createdAt: "desc" } });
return rows.map(toPost);
}
export function getPostBySlug(slug: string): Post | undefined {
return getPosts().find((p) => p.slug === slug);
}
/** 后台用:分页 + 搜索 + 排序查询。 */
export async function getPostsPaginated(options: {
page?: number;
pageSize?: number;
status?: "draft" | "published";
search?: string;
sortBy?: "date" | "createdAt" | "title" | "readingTime";
sortDir?: "asc" | "desc";
}): Promise<PaginatedResult<Post>> {
const {
page = 1,
pageSize = 20,
status,
search,
sortBy = "date",
sortDir = "desc",
} = options;
export function createPost(data: Omit<Post, "id" | "createdAt" | "updatedAt">): Post {
const posts = getPosts();
const now = new Date().toISOString();
const post: Post = {
...data,
id: generateId(),
createdAt: now,
updatedAt: now,
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (search) {
where.OR = [
{ title: { contains: search } },
{ excerpt: { contains: search } },
{ category: { contains: search } },
];
}
const [rows, total] = await Promise.all([
prisma.post.findMany({
where,
orderBy: { [sortBy]: sortDir },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.post.count({ where }),
]);
return {
data: rows.map(toPost),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
posts.unshift(post);
writeJSON("posts.json", posts);
return post;
}
export function updatePost(id: string, data: Partial<Post>): Post | null {
const posts = getPosts();
const index = posts.findIndex((p) => p.id === id);
if (index === -1) return null;
posts[index] = { ...posts[index], ...data, updatedAt: new Date().toISOString() };
writeJSON("posts.json", posts);
return posts[index];
/** 后台用:获取统计数据。 */
export async function getStats(): Promise<{
total: number;
published: number;
draft: number;
featured: number;
categories: number;
tags: number;
}> {
const [total, published, draft, featured, categories, tags] = await Promise.all([
prisma.post.count(),
prisma.post.count({ where: { status: "published" } }),
prisma.post.count({ where: { status: "draft" } }),
prisma.post.count({ where: { featured: true } }),
prisma.category.count(),
prisma.tag.count(),
]);
return { total, published, draft, featured, categories, tags };
}
export function deletePost(id: string): boolean {
const posts = getPosts();
const filtered = posts.filter((p) => p.id !== id);
if (filtered.length === posts.length) return false;
writeJSON("posts.json", filtered);
return true;
/** 前台用:只返回已发布文章,按日期倒序。 */
export async function getPublishedPosts(): Promise<PublicPost[]> {
const rows = await prisma.post.findMany({
where: { status: "published" },
orderBy: { date: "desc" },
});
return rows.map(toPost) as PublicPost[];
}
export async function getPost(id: string): Promise<Post | undefined> {
const row = await prisma.post.findUnique({ where: { id } });
return row ? toPost(row) : undefined;
}
export async function getPostBySlug(slug: string): Promise<PublicPost | undefined> {
const row = await prisma.post.findFirst({
where: { slug, status: "published" },
});
return row ? (toPost(row) as PublicPost) : undefined;
}
/** 按分类名过滤已发布文章。 */
export async function getPostsByCategory(category: string): Promise<PublicPost[]> {
const rows = await prisma.post.findMany({
where: { category, status: "published" },
orderBy: { date: "desc" },
});
return rows.map(toPost) as PublicPost[];
}
/** 按标签名过滤已发布文章。 */
export async function getPostsByTag(tag: string): Promise<PublicPost[]> {
// SQLite 的 tags 字段是 JSON 字符串,需要全量读取后在内存过滤
const all = await getPublishedPosts();
return all.filter((p) => p.tags.includes(tag));
}
/** 全部已发布文章用到的标签及计数,按计数倒序。 */
export async function getAllTags(): Promise<{ name: string; count: number }[]> {
const map = new Map<string, number>();
for (const p of await getPublishedPosts()) {
for (const t of p.tags) {
map.set(t, (map.get(t) || 0) + 1);
}
}
return [...map.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "zh"));
}
/** 前台用分类列表(含文章计数,按计数倒序)。 */
export async function getPublicCategories(): Promise<(Category & { count: number })[]> {
const cats = await getCategories();
const published = await getPublishedPosts();
return cats
.map((c) => ({
...c,
count: published.filter((p) => p.category === c.name).length,
}))
.sort((a, b) => b.count - a.count);
}
export async function createPost(
data: Omit<Post, "id" | "createdAt" | "updatedAt">
): Promise<Post> {
const sanitized = sanitizePostContent(data);
const now = new Date().toISOString();
const row = await prisma.post.create({
data: {
id: generateId(),
slug: sanitized.slug,
title: sanitized.title,
excerpt: sanitized.excerpt ?? "",
content: sanitized.content,
date: sanitized.date,
category: sanitized.category,
tags: JSON.stringify(sanitized.tags ?? []),
coverImage: sanitized.coverImage ?? null,
readingTime: sanitized.readingTime ?? 5,
featured: sanitized.featured ?? false,
status: sanitized.status ?? "draft",
createdAt: now,
updatedAt: now,
},
});
return toPost(row);
}
export async function updatePost(id: string, data: Partial<Post>): Promise<Post | null> {
const existing = await prisma.post.findUnique({ where: { id } });
if (!existing) return null;
// 禁止通过 update 覆盖不可变字段
const { id: _id, createdAt: _createdAt, ...rest } = data;
const sanitized = sanitizePostContent(rest);
const updateData: Record<string, unknown> = { updatedAt: new Date().toISOString() };
if (sanitized.slug !== undefined) updateData.slug = sanitized.slug;
if (sanitized.title !== undefined) updateData.title = sanitized.title;
if (sanitized.excerpt !== undefined) updateData.excerpt = sanitized.excerpt;
if (sanitized.content !== undefined) updateData.content = sanitized.content;
if (sanitized.date !== undefined) updateData.date = sanitized.date;
if (sanitized.category !== undefined) updateData.category = sanitized.category;
if (sanitized.tags !== undefined) updateData.tags = JSON.stringify(sanitized.tags);
if (sanitized.coverImage !== undefined) updateData.coverImage = sanitized.coverImage;
if (sanitized.readingTime !== undefined) updateData.readingTime = sanitized.readingTime;
if (sanitized.featured !== undefined) updateData.featured = sanitized.featured;
if (sanitized.status !== undefined) updateData.status = sanitized.status;
const row = await prisma.post.update({ where: { id }, data: updateData });
return toPost(row);
}
export async function deletePost(id: string): Promise<boolean> {
try {
await prisma.post.delete({ where: { id } });
return true;
} catch {
return false;
}
}
// ── Categories ──
export function getCategories(): Category[] {
return readJSON<Category[]>("categories.json", []);
export async function getCategories(): Promise<Category[]> {
return prisma.category.findMany({ orderBy: { name: "asc" } });
}
export function createCategory(data: Omit<Category, "id">): Category {
const categories = getCategories();
const cat: Category = { ...data, id: generateId() };
categories.push(cat);
writeJSON("categories.json", categories);
return cat;
export async function createCategory(data: Omit<Category, "id">): Promise<Category> {
return prisma.category.create({
data: { id: generateId(), ...data },
});
}
export function updateCategory(id: string, data: Partial<Category>): Category | null {
const categories = getCategories();
const index = categories.findIndex((c) => c.id === id);
if (index === -1) return null;
categories[index] = { ...categories[index], ...data };
writeJSON("categories.json", categories);
return categories[index];
export async function updateCategory(
id: string,
data: Partial<Category>
): Promise<Category | null> {
const existing = await prisma.category.findUnique({ where: { id } });
if (!existing) return null;
const { id: _id, ...rest } = data;
return prisma.category.update({ where: { id }, data: rest });
}
export function deleteCategory(id: string): boolean {
const categories = getCategories();
const filtered = categories.filter((c) => c.id !== id);
if (filtered.length === categories.length) return false;
writeJSON("categories.json", filtered);
return true;
export async function deleteCategory(id: string): Promise<boolean> {
try {
await prisma.category.delete({ where: { id } });
return true;
} catch {
return false;
}
}
// ── Tags ──
export function getTags(): Tag[] {
return readJSON<Tag[]>("tags.json", []);
export async function getTags(): Promise<Tag[]> {
return prisma.tag.findMany({ orderBy: { name: "asc" } });
}
export function createTag(data: Omit<Tag, "id">): Tag {
const tags = getTags();
const tag: Tag = { ...data, id: generateId() };
tags.push(tag);
writeJSON("tags.json", tags);
return tag;
export async function createTag(data: Omit<Tag, "id">): Promise<Tag> {
return prisma.tag.create({
data: { id: generateId(), ...data },
});
}
export function deleteTag(id: string): boolean {
const tags = getTags();
const filtered = tags.filter((t) => t.id !== id);
if (filtered.length === tags.length) return false;
writeJSON("tags.json", filtered);
return true;
export async function deleteTag(id: string): Promise<boolean> {
try {
await prisma.tag.delete({ where: { id } });
return true;
} catch {
return false;
}
}
// ── Auto seed ──
/**
* 模块加载时确保种子数据存在。幂等:仅在数据库为空时写入。
* 这样首次运行 / 部署后无需手动执行 seed 脚本。
*/
export async function ensureSeed(): Promise<void> {
const postCount = await prisma.post.count();
if (postCount === 0) {
for (const p of seedPosts) {
await createPost(p);
}
}
const catCount = await prisma.category.count();
if (catCount === 0) {
for (const c of seedCategories) {
await createCategory(c);
}
}
const tagCount = await prisma.tag.count();
if (tagCount === 0) {
for (const t of seedTags) {
await createTag({ name: t });
}
}
}
// 模块加载时自动 seed(异步,不阻塞导入)
ensureSeed().catch((err) => {
console.error("[store] ensureSeed failed:", err);
});
+26
View File
@@ -0,0 +1,26 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** 合并类名(不做 tailwind merge,保留简单拼接)。 */
export function cx(...inputs: (string | false | null | undefined)[]) {
return inputs.filter(Boolean).join(" ");
}
/** 将 ISO 日期格式化为中文友好的显示形式。 */
export function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
});
}
/** 阅读时间标签,如 "5 分钟阅读"。 */
export function readingTimeLabel(minutes: number): string {
return `${minutes} 分钟阅读`;
}
+30
View File
@@ -0,0 +1,30 @@
import { z } from "zod";
/**
* 输入校验 schema。所有 API 写操作都必须先通过对应 schema 解析,
* 拒绝越权字段(如 id / createdAt / updatedAt)。
*/
export const createPostSchema = z.object({
title: z.string().min(1).max(200),
slug: z.string().min(1).max(200).regex(/^[a-zA-Z0-9\u4e00-\u9fff_-]+$/),
excerpt: z.string().max(500).optional().default(""),
content: z.string().min(1),
date: z.string().min(1),
category: z.string().min(1).max(50),
tags: z.array(z.string().max(50)).max(20).default([]),
readingTime: z.number().int().min(1).max(600).default(5),
featured: z.boolean().default(false),
status: z.enum(["draft", "published"]).default("draft"),
});
export const updatePostSchema = createPostSchema.partial();
export const categorySchema = z.object({
name: z.string().min(1).max(50),
description: z.string().max(200).optional().default(""),
});
export const tagSchema = z.object({
name: z.string().min(1).max(50),
});