fix: 修复后台文章计数为0 + 分类编辑补全描述字段 + CSP放行外部图片 + 更新关于页描述
- 文章管理:计数请求补 page=1 参数,命中分页接口返回正确的 total - 分类管理:编辑模式新增描述输入框,保存时一并提交 description - CSP:img-src 加入 https: 允许加载外部图片 - 关于页:数据存储描述从 JSON 文件更正为 SQLite 数据库 - Footer:添加 ICP 备案号
This commit is contained in:
+311
-85
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user