0.1.11 ai修复与全屏

This commit is contained in:
liaibo
2026-01-10 10:35:21 +08:00
parent e74219c802
commit 0bcdc3e730
55 changed files with 6597 additions and 174 deletions
@@ -0,0 +1,73 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { applyMindmapOps, type MindmapOp } from "@/lib/mindmap/mindmapOps";
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
const defaultMindmapData = {
data: { text: "中心主题" },
children: [],
};
type RequestPayload = {
ops: MindmapOp[];
actor?: { kind?: string; provider?: string; model?: string };
reason?: string;
};
export async function POST(
request: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
// 校验页面归属
const { data: doc, error: docError } = await supabase
.from("documents")
.select("id,title,mindmap_data")
.eq("id", docId)
.eq("user_id", session.user.id)
.maybeSingle();
if (docError) {
return NextResponse.json({ error: docError.message }, { status: 400 });
}
if (!doc) {
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
const ops = Array.isArray(payload?.ops) ? payload!.ops : [];
if (!ops.length) {
return NextResponse.json({ error: "缺少 ops" }, { status: 400 });
}
if (ops.length > 80) {
return NextResponse.json({ error: "ops 过多(最多 80" }, { status: 400 });
}
const local = await readMindmapLocal(docId, mindmapId);
const baseData = local.ok ? local.data : (doc.mindmap_data ?? defaultMindmapData);
const { data: nextData, applied, errors } = applyMindmapOps(baseData, ops);
await writeMindmapLocal(docId, mindmapId, nextData, doc.title ?? "无标题");
return NextResponse.json({
ok: true,
applied,
errors,
data: nextData,
meta: {
documentId: docId,
mindmapId,
actor: payload?.actor ?? null,
reason: payload?.reason ?? null,
},
});
}
@@ -8,7 +8,8 @@ const defaultMindmapData = {
children: [],
};
const documentsBaseDir = path.join(process.cwd(), "public", "documents");
const preferredBaseDir = path.join(process.cwd(), "public", "documents");
const legacyBaseDir = path.join(process.cwd(), "public", "mindmaps");
async function ensureDir(dir: string) {
await fs.mkdir(dir, { recursive: true });
@@ -47,6 +48,64 @@ async function tryReadJson(file: string) {
}
}
type TrashedMindmapMeta = {
docId: string;
mindmapId: string;
originalFileName: string;
originalPath: string;
trashedFileName: string;
deleted_at: string;
};
async function fileExists(file: string) {
try {
await fs.access(file);
return true;
} catch {
return false;
}
}
async function moveToTrash(file: string, meta: Omit<TrashedMindmapMeta, "trashedFileName">) {
if (!(await fileExists(file))) {
return null;
}
const dir = path.dirname(file);
const trashDir = path.join(dir, ".trash");
await ensureDir(trashDir);
const originalFileName = path.basename(file);
const trashedFileName = `${originalFileName}.${Date.now()}.deleted`;
const trashedPath = path.join(trashDir, trashedFileName);
const metaPath = path.join(trashDir, `${trashedFileName}.json`);
await fs.rename(file, trashedPath);
await fs.writeFile(
metaPath,
JSON.stringify({ ...meta, originalFileName, trashedFileName }, null, 2),
"utf8",
);
return { trashedPath, metaPath };
}
async function listTrashMetas(folder: string): Promise<Array<{ meta: TrashedMindmapMeta; metaPath: string; trashedPath: string }>> {
const trashDir = path.join(folder, ".trash");
try {
const entries = await fs.readdir(trashDir);
const results: Array<{ meta: TrashedMindmapMeta; metaPath: string; trashedPath: string }> = [];
for (const name of entries) {
if (!name.endsWith(".deleted.json")) continue;
const metaPath = path.join(trashDir, name);
const metaRaw = await tryReadJson(metaPath);
const meta = metaRaw as TrashedMindmapMeta | null;
if (!meta?.docId || !meta?.mindmapId || !meta?.trashedFileName || !meta?.originalPath) continue;
const trashedPath = path.join(trashDir, meta.trashedFileName);
results.push({ meta, metaPath, trashedPath });
}
return results;
} catch {
return [];
}
}
export async function GET(
_req: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
@@ -60,11 +119,18 @@ export async function GET(
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const folder = path.join(documentsBaseDir, docId);
const folder = path.join(preferredBaseDir, docId);
const file = path.join(folder, resolveMindmapFileName(mindmapId));
const legacyFile = path.join(folder, "mindmap.json");
const legacyDirFile =
path.basename(file) === "mindmap.json"
? path.join(legacyBaseDir, docId, "mindmap.json")
: null;
const localData = (await tryReadJson(file)) ?? (await tryReadJson(legacyFile));
const localData =
(await tryReadJson(file)) ??
(await tryReadJson(legacyFile)) ??
(legacyDirFile ? await tryReadJson(legacyDirFile) : null);
if (localData) {
return NextResponse.json({ data: localData, source: "local" });
}
@@ -110,18 +176,25 @@ export async function POST(
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
const { data } = await request.json().catch(() => ({ data: null }));
const folder = path.join(documentsBaseDir, docId);
const file = path.join(folder, resolveMindmapFileName(mindmapId));
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
data?: unknown;
createOnly?: boolean;
};
const folder = path.join(preferredBaseDir, docId);
const file = path.join(folder, resolveMindmapFileName(mindmapId));
try {
await ensureDir(folder);
await ensureIndexFile(folder, doc.title ?? "无标题");
// 仅创建:避免“初始化写入”覆盖用户/AI 刚保存的内容(典型于快速操作 + 异步时序)
if (createOnly && (await fileExists(file))) {
return NextResponse.json({ ok: true, created: false, skipped: true });
}
await fs.writeFile(file, JSON.stringify(data ?? defaultMindmapData, null, 2), "utf8");
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
return NextResponse.json({ error: String(error) }, { status: 500 });
}
return NextResponse.json({ ok: true });
return NextResponse.json({ ok: true, created: true });
}
export async function DELETE(
@@ -151,8 +224,106 @@ export async function DELETE(
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
const folder = path.join(documentsBaseDir, docId);
const file = path.join(folder, resolveMindmapFileName(mindmapId));
await removeFileSafe(file);
const deletedAt = new Date().toISOString();
const fileName = resolveMindmapFileName(mindmapId);
const preferredFolder = path.join(preferredBaseDir, docId);
const preferredFile = path.join(preferredFolder, fileName);
const candidates: string[] = [preferredFile];
if (fileName === "mindmap.json") {
candidates.push(path.join(legacyBaseDir, docId, "mindmap.json"));
}
let moved = 0;
for (const file of candidates) {
const movedInfo = await moveToTrash(file, {
docId,
mindmapId,
originalFileName: path.basename(file),
originalPath: file,
deleted_at: deletedAt,
});
if (movedInfo) {
moved += 1;
}
}
if (moved === 0) {
// 兼容:文件不存在也视为成功(避免前端卡死)
return NextResponse.json({ ok: true, moved: 0 });
}
return NextResponse.json({ ok: true, moved });
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> },
) {
const { docId, mindmapId } = await params;
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const { action } = (await request.json().catch(() => ({}))) as { action?: string };
if (action !== "restore" && action !== "purge") {
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
}
const { data: doc, error: docError } = await supabase
.from("documents")
.select("id")
.eq("id", docId)
.eq("user_id", session.user.id)
.maybeSingle();
if (docError) {
return NextResponse.json({ error: docError.message }, { status: 400 });
}
if (!doc) {
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
}
const preferredFolder = path.join(preferredBaseDir, docId);
const legacyFolder = path.join(legacyBaseDir, docId);
const entries = [
...(await listTrashMetas(preferredFolder)),
...(await listTrashMetas(legacyFolder)),
].filter((item) => item.meta.mindmapId === mindmapId && item.meta.docId === docId);
if (entries.length === 0) {
return NextResponse.json({ error: "未找到可操作的垃圾桶记录" }, { status: 404 });
}
if (action === "purge") {
let purged = 0;
for (const item of entries) {
await removeFileSafe(item.trashedPath);
await removeFileSafe(item.metaPath);
purged += 1;
}
return NextResponse.json({ ok: true, purged });
}
// restore:恢复最新的一条
const latest = entries
.slice()
.sort((a, b) => (b.meta.deleted_at ?? "").localeCompare(a.meta.deleted_at ?? ""))[0];
if (!latest?.meta?.originalPath) {
return NextResponse.json({ error: "垃圾桶记录损坏" }, { status: 500 });
}
if (await fileExists(latest.meta.originalPath)) {
return NextResponse.json({ error: "目标文件已存在,无法恢复" }, { status: 409 });
}
await ensureDir(path.dirname(latest.meta.originalPath));
await fs.rename(latest.trashedPath, latest.meta.originalPath);
await removeFileSafe(latest.metaPath);
return NextResponse.json({ ok: true });
}