增加API分页和轻量级查询支持,完善备份恢复功能

主要改动:
- API: 为 /api/pages 添加 lightweight 模式和分页支持
- 备份恢复: 增强 restore API 的错误处理和验证逻辑
- 工具函数: 新增 page-utils 辅助函数
- 会话管理: 优化 session 和 auth 相关逻辑
- 文档: 添加 CLAUDE.md 项目指南,包含编辑器配置注意事项

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 16:30:31 +08:00
parent 7ea9bd7599
commit ce439db449
8 changed files with 324 additions and 23 deletions
+45 -6
View File
@@ -1,22 +1,61 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireApiAuth } from '@/lib/api-auth';
import { cleanupAccidentalStandaloneInlineCode, markdownToImportHtml } from '@/lib/markdown-import';
import { MAX_TITLE_LENGTH, safeParseTags, normalizeTags } from '@/lib/page-utils';
export async function GET() {
export async function GET(request: NextRequest) {
const authError = await requireApiAuth();
if (authError) return authError;
try {
const pages = await prisma.page.findMany({
orderBy: [{ order: 'asc' }, { createdAt: 'desc' }],
});
const searchParams = request.nextUrl.searchParams;
// lightweight 模式:不返回 content,用于侧边栏等场景
const lightweight = searchParams.get('lightweight') === 'true';
const page = parseInt(searchParams.get('page') || '0', 10);
const limit = parseInt(searchParams.get('limit') || '0', 10);
// 默认行为:返回所有页面(向后兼容)
const usePagination = limit > 0 && limit <= 1000 && page > 0;
if (!usePagination) {
const pages = await prisma.page.findMany({
orderBy: [{ order: 'asc' }, { createdAt: 'desc' }],
});
const parsedPages = pages.map((p) => ({
...p,
content: lightweight ? '' : p.content,
tags: safeParseTags(p.tags),
}));
return NextResponse.json(parsedPages);
}
// 分页查询
const skip = (page - 1) * limit;
const [pages, total] = await Promise.all([
prisma.page.findMany({
skip,
take: limit,
orderBy: [{ order: 'asc' }, { createdAt: 'desc' }],
}),
prisma.page.count(),
]);
const parsedPages = pages.map((p) => ({
...p,
content: lightweight ? '' : p.content,
tags: safeParseTags(p.tags),
}));
return NextResponse.json(parsedPages);
return NextResponse.json({
data: parsedPages,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
});
} catch {
return NextResponse.json({ error: 'Error fetching pages' }, { status: 500 });
}
+75 -3
View File
@@ -3,6 +3,10 @@ import JSZip from "jszip";
import { prisma } from "@/lib/prisma";
import { requireApiAuth } from "@/lib/api-auth";
import { markdownToImportHtml } from "@/lib/markdown-import";
import { pageToMarkdown } from "@/lib/export";
import { sanitizeFilename } from "@/lib/page-utils";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
type ZipEntry = {
path: string;
@@ -93,6 +97,52 @@ function collectFolderPaths(entries: ZipEntry[]): string[] {
return Array.from(folderSet).sort((a, b) => getDepth(a) - getDepth(b));
}
/**
* 创建当前数据库的备份 ZIP
*/
async function createBackupZip(): Promise<Buffer> {
const allPages = await prisma.page.findMany({
orderBy: [{ parentId: "asc" }, { order: "asc" }],
});
const zip = new JSZip();
// 递归添加页面到 ZIP
function addPagesToZip(parentId: string | null, currentFolder: JSZip) {
const children = allPages.filter((p) => p.parentId === parentId);
for (const page of children) {
const cleanTitle = sanitizeFilename(page.title);
if (page.type === "folder") {
const subFolder = currentFolder.folder(cleanTitle);
if (subFolder) {
addPagesToZip(page.id, subFolder);
}
} else {
const markdown = pageToMarkdown({
id: page.id,
title: page.title,
content: page.content,
parentId: page.parentId,
type: page.type as "file" | "folder",
tags: JSON.parse(page.tags),
icon: page.icon,
order: page.order,
isLocked: page.isLocked,
createdAt: page.createdAt.toISOString(),
updatedAt: page.updatedAt.toISOString(),
});
currentFolder.file(`${cleanTitle}.md`, markdown);
}
}
}
addPagesToZip(null, zip);
return await zip.generateAsync({ type: "nodebuffer" });
}
export async function POST(req: NextRequest) {
const authError = await requireApiAuth();
if (authError) return authError;
@@ -152,10 +202,31 @@ export async function POST(req: NextRequest) {
})
);
// 在删除数据前创建自动备份
let backupBuffer: Buffer | null = null;
let backupPath: string | null = null;
try {
backupBuffer = await createBackupZip();
// 保存备份到 backups 目录
const backupsDir = join(process.cwd(), "backups");
await mkdir(backupsDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
backupPath = join(backupsDir, `auto-backup-before-restore-${timestamp}.zip`);
await writeFile(backupPath, backupBuffer);
console.log(`Auto backup created: ${backupPath}`);
} catch (backupError) {
console.error("Failed to create backup before restore:", backupError);
return NextResponse.json(
{ error: "Failed to create backup before restore. Restore aborted for safety." },
{ status: 500 }
);
}
await prisma.$transaction(async (tx) => {
// ⚠️ 注意:这是一个破坏性操作,会删除所有现有页面后重新导入。
// TODO: 后续可在此添加自动备份逻辑(导出当前数据到临时文件),
// 或在前端恢复前强制用户先手动备份。
// 删除所有现有页面(已在上面创建备份)
await tx.page.deleteMany();
const folderIdMap = new Map<string, string>();
@@ -195,6 +266,7 @@ export async function POST(req: NextRequest) {
count: entries.length,
files: parsedFiles.length,
folders: folderPaths.length,
backupPath: backupPath ? backupPath.replace(process.cwd(), "") : null,
});
} catch (error) {
console.error("Restore failed:", error);