0.3.1 UI修复
This commit is contained in:
@@ -1,11 +1,54 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { internalQuery, mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
|
||||
|
||||
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) {
|
||||
throw new Error("未登录");
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
export const getMeta = query({
|
||||
args: { id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (!doc) return null;
|
||||
if (doc.user_id !== userId) return null;
|
||||
return {
|
||||
id: doc.id,
|
||||
user_id: doc.user_id,
|
||||
workspace_id: doc.workspace_id,
|
||||
access_scope: doc.access_scope,
|
||||
title: doc.title ?? null,
|
||||
parent_id: doc.parent_id ?? null,
|
||||
created_at: doc.created_at,
|
||||
updated_at: doc.updated_at ?? null,
|
||||
wide_layout: doc.wide_layout ?? null,
|
||||
use_small_text: doc.use_small_text ?? null,
|
||||
show_heading_numbers: doc.show_heading_numbers ?? null,
|
||||
show_toc: doc.show_toc ?? null,
|
||||
show_structure: doc.show_structure ?? null,
|
||||
protect_editing: doc.protect_editing ?? null,
|
||||
show_word_count: doc.show_word_count ?? null,
|
||||
word_count: doc.word_count ?? null,
|
||||
character_count: doc.character_count ?? null,
|
||||
block_count: doc.block_count ?? null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getMetaForIngest = internalQuery({
|
||||
args: { userId: v.string(), id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const doc = await ctx.db
|
||||
@@ -38,6 +81,21 @@ export const getMeta = query({
|
||||
});
|
||||
|
||||
export const getContent = query({
|
||||
args: { id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (!doc) return null;
|
||||
if (doc.user_id !== userId) return null;
|
||||
return { content: doc.content ?? null };
|
||||
},
|
||||
});
|
||||
|
||||
export const getContentForIngest = internalQuery({
|
||||
args: { userId: v.string(), id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const doc = await ctx.db
|
||||
@@ -51,8 +109,10 @@ export const getContent = query({
|
||||
});
|
||||
|
||||
export const listByWorkspace = query({
|
||||
args: { userId: v.string(), workspaceId: v.string() },
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const docs = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
@@ -60,7 +120,7 @@ export const listByWorkspace = query({
|
||||
|
||||
// 说明:阶段 4 先不做垃圾桶(deleted_at != null),因此这里直接过滤。
|
||||
return docs
|
||||
.filter((d) => d.user_id === args.userId)
|
||||
.filter((d) => d.user_id === userId)
|
||||
.filter((d) => d.deleted_at == null)
|
||||
.map((d) => ({
|
||||
access_scope: d.access_scope,
|
||||
@@ -78,15 +138,17 @@ export const listByWorkspace = query({
|
||||
});
|
||||
|
||||
export const listTrashedByWorkspace = query({
|
||||
args: { userId: v.string(), workspaceId: v.string() },
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const docs = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect();
|
||||
|
||||
return docs
|
||||
.filter((d) => d.user_id === args.userId)
|
||||
.filter((d) => d.user_id === userId)
|
||||
.filter((d) => d.deleted_at != null)
|
||||
.sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? ""))
|
||||
.slice(0, 100)
|
||||
@@ -102,7 +164,6 @@ export const listTrashedByWorkspace = query({
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
id: v.string(),
|
||||
workspaceId: v.string(),
|
||||
parentId: v.union(v.string(), v.null()),
|
||||
@@ -111,6 +172,8 @@ export const create = mutation({
|
||||
content: v.optional(v.any()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const siblings = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_workspace_parent", (q) => q.eq("workspace_id", args.workspaceId).eq("parent_id", args.parentId))
|
||||
@@ -124,7 +187,7 @@ export const create = mutation({
|
||||
|
||||
await ctx.db.insert("documents", {
|
||||
id: args.id,
|
||||
user_id: args.userId,
|
||||
user_id: userId,
|
||||
workspace_id: args.workspaceId,
|
||||
parent_id: args.parentId,
|
||||
title,
|
||||
@@ -166,33 +229,37 @@ export const create = mutation({
|
||||
});
|
||||
|
||||
export const updateContent = mutation({
|
||||
args: { userId: v.string(), id: v.string(), content: v.any() },
|
||||
args: { id: v.string(), content: v.any() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||
if (doc.user_id !== userId) throw new Error("无权限");
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
|
||||
|
||||
// 说明:在 Convex 模式下,把“自动入库/LightRAG 触发”迁到 Convex jobs/actions。
|
||||
// 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。
|
||||
// 采用 debounce,避免频繁保存时触发过多任务。
|
||||
await enqueueIngestDocumentJob(ctx, { userId: args.userId, documentId: args.id, debounceMs: 1500 });
|
||||
await enqueueIngestDocumentJob(ctx, { userId, documentId: args.id, debounceMs: 1500 });
|
||||
return { ok: true, updated_at: ts };
|
||||
},
|
||||
});
|
||||
|
||||
export const updateTitle = mutation({
|
||||
args: { userId: v.string(), id: v.string(), title: v.union(v.string(), v.null()) },
|
||||
args: { id: v.string(), title: v.union(v.string(), v.null()) },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||
if (doc.user_id !== userId) throw new Error("无权限");
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(doc._id, { title: args.title, updated_at: ts });
|
||||
return { ok: true, updated_at: ts };
|
||||
@@ -201,18 +268,19 @@ export const updateTitle = mutation({
|
||||
|
||||
export const move = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
id: v.string(),
|
||||
parentId: v.union(v.string(), v.null()),
|
||||
sortOrder: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||
if (doc.user_id !== userId) throw new Error("无权限");
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(doc._id, {
|
||||
parent_id: args.parentId,
|
||||
@@ -224,29 +292,33 @@ export const move = mutation({
|
||||
});
|
||||
|
||||
export const softDelete = mutation({
|
||||
args: { userId: v.string(), id: v.string() },
|
||||
args: { id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||
if (doc.user_id !== userId) throw new Error("无权限");
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(doc._id, { deleted_at: ts, deleted_by: args.userId, updated_at: ts });
|
||||
await ctx.db.patch(doc._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const restore = mutation({
|
||||
args: { userId: v.string(), id: v.string() },
|
||||
args: { id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||
if (doc.user_id !== userId) throw new Error("无权限");
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(doc._id, {
|
||||
deleted_at: null,
|
||||
@@ -260,26 +332,30 @@ export const restore = mutation({
|
||||
});
|
||||
|
||||
export const purge = mutation({
|
||||
args: { userId: v.string(), id: v.string() },
|
||||
args: { id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||
if (doc.user_id !== userId) throw new Error("无权限");
|
||||
await ctx.db.delete(doc._id);
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const emptyTrashByWorkspace = mutation({
|
||||
args: { userId: v.string(), workspaceId: v.string() },
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
// 说明:阶段 4/5 先用“membership 存在即可”的规则,避免引入复杂权限模型。
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
// 说明:阶段 4/5 先用"membership 存在即可"的规则,避免引入复杂权限模型。
|
||||
const membership = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId))
|
||||
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
|
||||
if (!membership) {
|
||||
@@ -291,7 +367,7 @@ export const emptyTrashByWorkspace = mutation({
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect();
|
||||
|
||||
const toDelete = docs.filter((d) => d.user_id === args.userId && d.deleted_at != null);
|
||||
const toDelete = docs.filter((d) => d.user_id === userId && d.deleted_at != null);
|
||||
for (const d of toDelete) {
|
||||
await ctx.db.delete(d._id);
|
||||
}
|
||||
@@ -302,7 +378,6 @@ export const emptyTrashByWorkspace = mutation({
|
||||
|
||||
export const updateOptions = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
id: v.string(),
|
||||
options: v.object({
|
||||
wideLayout: v.optional(v.boolean()),
|
||||
@@ -315,12 +390,14 @@ export const updateOptions = mutation({
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||
if (doc.user_id !== userId) throw new Error("无权限");
|
||||
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (typeof args.options.wideLayout === "boolean") patch.wide_layout = args.options.wideLayout;
|
||||
@@ -344,19 +421,20 @@ export const updateOptions = mutation({
|
||||
|
||||
export const updateStats = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
id: v.string(),
|
||||
wordCount: v.number(),
|
||||
characterCount: v.number(),
|
||||
blockCount: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.user_id !== args.userId) throw new Error("无权限");
|
||||
if (doc.user_id !== userId) throw new Error("无权限");
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(doc._id, {
|
||||
word_count: args.wordCount,
|
||||
@@ -370,18 +448,19 @@ export const updateStats = mutation({
|
||||
|
||||
export const duplicate = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
sourceId: v.string(),
|
||||
newId: v.string(),
|
||||
title: v.optional(v.union(v.string(), v.null())),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const source = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.sourceId))
|
||||
.first();
|
||||
if (!source) throw new Error("页面不存在或无权限访问");
|
||||
if (source.user_id !== args.userId) throw new Error("页面不存在或无权限访问");
|
||||
if (source.user_id !== userId) throw new Error("页面不存在或无权限访问");
|
||||
|
||||
const siblings = await ctx.db
|
||||
.query("documents")
|
||||
@@ -397,7 +476,7 @@ export const duplicate = mutation({
|
||||
|
||||
await ctx.db.insert("documents", {
|
||||
id: args.newId,
|
||||
user_id: args.userId,
|
||||
user_id: userId,
|
||||
workspace_id: source.workspace_id,
|
||||
parent_id: source.parent_id,
|
||||
title,
|
||||
@@ -438,15 +517,17 @@ export const duplicate = mutation({
|
||||
});
|
||||
|
||||
export const listAllForCopy = query({
|
||||
args: { userId: v.string(), workspaceId: v.string() },
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const docs = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect();
|
||||
|
||||
return docs
|
||||
.filter((d) => d.user_id === args.userId)
|
||||
.filter((d) => d.user_id === userId)
|
||||
.filter((d) => d.deleted_at == null)
|
||||
.map((d) => ({
|
||||
id: d.id,
|
||||
|
||||
@@ -122,8 +122,8 @@ export const run = internalAction({
|
||||
if (!documentId) throw new Error("缺少 documentId");
|
||||
|
||||
const [meta, contentRes] = await Promise.all([
|
||||
ctx.runQuery(api.documents.getMeta, { userId: job.user_id, id: documentId }),
|
||||
ctx.runQuery(api.documents.getContent, { userId: job.user_id, id: documentId }),
|
||||
ctx.runQuery(internal.documents.getMetaForIngest, { userId: job.user_id, id: documentId }),
|
||||
ctx.runQuery(internal.documents.getContentForIngest, { userId: job.user_id, id: documentId }),
|
||||
]);
|
||||
|
||||
if (!meta) throw new Error("页面不存在或无权限");
|
||||
@@ -147,8 +147,8 @@ export const run = internalAction({
|
||||
if (!mindmapId) throw new Error("缺少 mindmapId");
|
||||
|
||||
const [docMeta, mindmapRes] = await Promise.all([
|
||||
ctx.runQuery(api.documents.getMeta, { userId: job.user_id, id: docId }),
|
||||
ctx.runQuery(api.mindmaps.get, { userId: job.user_id, docId, mindmapId }),
|
||||
ctx.runQuery(internal.documents.getMetaForIngest, { userId: job.user_id, id: docId }),
|
||||
ctx.runQuery(internal.mindmaps.getForIngest, { userId: job.user_id, docId, mindmapId }),
|
||||
]);
|
||||
|
||||
if (!docMeta) throw new Error("页面不存在或无权限");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { internalQuery, mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { enqueueIngestMindmapJob } from "./_utils/ingestJobs";
|
||||
|
||||
@@ -8,6 +9,14 @@ const defaultMindmapData = {
|
||||
children: [],
|
||||
};
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) {
|
||||
throw new Error("未登录");
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
function normalizeMindmapId(docId: string, mindmapId: string): string {
|
||||
const raw = String(mindmapId ?? "").trim();
|
||||
if (raw) return raw;
|
||||
@@ -30,9 +39,11 @@ async function requireOwnedDocument(ctx: any, userId: string, docId: string) {
|
||||
}
|
||||
|
||||
export const get = query({
|
||||
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
|
||||
args: { docId: v.string(), mindmapId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const doc = await requireOwnedDocument(ctx, args.userId, args.docId);
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await requireOwnedDocument(ctx, userId, args.docId);
|
||||
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||
|
||||
const row = await ctx.db
|
||||
@@ -71,16 +82,58 @@ export const get = query({
|
||||
},
|
||||
});
|
||||
|
||||
export const getForIngest = internalQuery({
|
||||
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const doc = await requireOwnedDocument(ctx, args.userId, args.docId);
|
||||
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||
|
||||
const row = await ctx.db
|
||||
.query("mindmaps")
|
||||
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
|
||||
.first();
|
||||
|
||||
if (!row || row.deleted_at != null) {
|
||||
return {
|
||||
ok: true,
|
||||
data: defaultMindmapData,
|
||||
meta: {
|
||||
workspace_id: doc.workspace_id,
|
||||
document_id: args.docId,
|
||||
mindmap_id: mindmapId,
|
||||
exists: false,
|
||||
deleted_at: row?.deleted_at ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
data: row.data ?? defaultMindmapData,
|
||||
meta: {
|
||||
workspace_id: row.workspace_id,
|
||||
document_id: row.document_id,
|
||||
mindmap_id: row.mindmap_id,
|
||||
exists: true,
|
||||
deleted_at: row.deleted_at,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const put = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
docId: v.string(),
|
||||
mindmapId: v.string(),
|
||||
data: v.any(),
|
||||
createOnly: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const doc = await requireOwnedDocument(ctx, args.userId, args.docId);
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await requireOwnedDocument(ctx, userId, args.docId);
|
||||
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||
|
||||
const existing = await ctx.db
|
||||
@@ -102,13 +155,13 @@ export const put = mutation({
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
});
|
||||
await enqueueIngestMindmapJob(ctx, { userId: args.userId, docId: args.docId, mindmapId, debounceMs: 1500 });
|
||||
await enqueueIngestMindmapJob(ctx, { userId, docId: args.docId, mindmapId, debounceMs: 1500 });
|
||||
return { ok: true, created: false, skipped: false, updated_at: ts };
|
||||
}
|
||||
|
||||
await ctx.db.insert("mindmaps", {
|
||||
id: `${args.docId}:${mindmapId}`,
|
||||
user_id: args.userId,
|
||||
user_id: userId,
|
||||
workspace_id: doc.workspace_id,
|
||||
document_id: args.docId,
|
||||
mindmap_id: mindmapId,
|
||||
@@ -119,15 +172,17 @@ export const put = mutation({
|
||||
deleted_by: null,
|
||||
});
|
||||
|
||||
await enqueueIngestMindmapJob(ctx, { userId: args.userId, docId: args.docId, mindmapId, debounceMs: 1500 });
|
||||
await enqueueIngestMindmapJob(ctx, { userId, docId: args.docId, mindmapId, debounceMs: 1500 });
|
||||
return { ok: true, created: true, skipped: false, updated_at: ts };
|
||||
},
|
||||
});
|
||||
|
||||
export const softDelete = mutation({
|
||||
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
|
||||
args: { docId: v.string(), mindmapId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
await requireOwnedDocument(ctx, args.userId, args.docId);
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
await requireOwnedDocument(ctx, userId, args.docId);
|
||||
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||
|
||||
const existing = await ctx.db
|
||||
@@ -141,15 +196,17 @@ export const softDelete = mutation({
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(existing._id, { deleted_at: ts, deleted_by: args.userId, updated_at: ts });
|
||||
await ctx.db.patch(existing._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
|
||||
return { ok: true, moved: 1, deleted_at: ts };
|
||||
},
|
||||
});
|
||||
|
||||
export const restore = mutation({
|
||||
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
|
||||
args: { docId: v.string(), mindmapId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
await requireOwnedDocument(ctx, args.userId, args.docId);
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
await requireOwnedDocument(ctx, userId, args.docId);
|
||||
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||
|
||||
const existing = await ctx.db
|
||||
@@ -168,9 +225,11 @@ export const restore = mutation({
|
||||
});
|
||||
|
||||
export const purge = mutation({
|
||||
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
|
||||
args: { docId: v.string(), mindmapId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
await requireOwnedDocument(ctx, args.userId, args.docId);
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
await requireOwnedDocument(ctx, userId, args.docId);
|
||||
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||
|
||||
const existing = await ctx.db
|
||||
@@ -188,12 +247,14 @@ export const purge = mutation({
|
||||
});
|
||||
|
||||
export const listByWorkspace = query({
|
||||
args: { userId: v.string(), workspaceId: v.string(), includeDeleted: v.optional(v.boolean()) },
|
||||
args: { workspaceId: v.string(), includeDeleted: v.optional(v.boolean()) },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
// 说明:阶段 6 先按 membership 存在即可,避免引入复杂权限模型。
|
||||
const membership = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId))
|
||||
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!membership) {
|
||||
throw new Error("无权操作该工作空间");
|
||||
@@ -207,7 +268,7 @@ export const listByWorkspace = query({
|
||||
const includeDeleted = Boolean(args.includeDeleted);
|
||||
|
||||
return rows
|
||||
.filter((r) => r.user_id === args.userId)
|
||||
.filter((r) => r.user_id === userId)
|
||||
.filter((r) => (includeDeleted ? true : r.deleted_at == null))
|
||||
.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))
|
||||
.map((r) => ({
|
||||
@@ -225,10 +286,12 @@ export const listByWorkspace = query({
|
||||
});
|
||||
|
||||
export const copyByDocument = mutation({
|
||||
args: { userId: v.string(), sourceDocId: v.string(), targetDocId: v.string() },
|
||||
args: { sourceDocId: v.string(), targetDocId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const sourceDoc = await requireOwnedDocument(ctx, args.userId, args.sourceDocId);
|
||||
const targetDoc = await requireOwnedDocument(ctx, args.userId, args.targetDocId);
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const sourceDoc = await requireOwnedDocument(ctx, userId, args.sourceDocId);
|
||||
const targetDoc = await requireOwnedDocument(ctx, userId, args.targetDocId);
|
||||
|
||||
// 说明:同一工作空间复制最常见;若跨工作空间复制,这里仍允许,但 mindmaps 会落到目标页面的 workspace_id。
|
||||
const sourceRows = await ctx.db
|
||||
@@ -237,7 +300,7 @@ export const copyByDocument = mutation({
|
||||
.collect();
|
||||
|
||||
const sourceMindmaps = sourceRows
|
||||
.filter((r) => r.user_id === args.userId)
|
||||
.filter((r) => r.user_id === userId)
|
||||
.filter((r) => r.document_id === args.sourceDocId)
|
||||
.filter((r) => r.deleted_at == null);
|
||||
|
||||
@@ -253,7 +316,7 @@ export const copyByDocument = mutation({
|
||||
|
||||
await ctx.db.insert("mindmaps", {
|
||||
id: `${args.targetDocId}:${r.mindmap_id}`,
|
||||
user_id: args.userId,
|
||||
user_id: userId,
|
||||
workspace_id: targetDoc.workspace_id,
|
||||
document_id: args.targetDocId,
|
||||
mindmap_id: r.mindmap_id,
|
||||
@@ -271,11 +334,13 @@ export const copyByDocument = mutation({
|
||||
});
|
||||
|
||||
export const emptyTrashByWorkspace = mutation({
|
||||
args: { userId: v.string(), workspaceId: v.string() },
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const membership = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId))
|
||||
.withIndex("by_workspace_user", (q) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!membership) {
|
||||
throw new Error("无权操作该工作空间");
|
||||
@@ -286,7 +351,7 @@ export const emptyTrashByWorkspace = mutation({
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.collect();
|
||||
|
||||
const toDelete = rows.filter((r) => r.user_id === args.userId && r.deleted_at != null);
|
||||
const toDelete = rows.filter((r) => r.user_id === userId && r.deleted_at != null);
|
||||
for (const r of toDelete) {
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
|
||||
@@ -12,6 +13,14 @@ type WorkspaceSummary = {
|
||||
isDefault: boolean;
|
||||
};
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) {
|
||||
throw new Error("未登录");
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
async function findWorkspaceById(ctx: QueryCtx | MutationCtx, workspaceId: string) {
|
||||
return await ctx.db
|
||||
.query("workspaces")
|
||||
@@ -29,14 +38,15 @@ async function countMembers(ctx: QueryCtx | MutationCtx, workspaceId: string): P
|
||||
|
||||
export const ensureDefaultWorkspace = mutation({
|
||||
args: {
|
||||
userId: v.string(),
|
||||
fallbackName: v.optional(v.string()),
|
||||
workspaceIdIfCreate: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const memberships = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_user_id", (q) => q.eq("user_id", args.userId))
|
||||
.withIndex("by_user_id", (q) => q.eq("user_id", userId))
|
||||
.collect();
|
||||
|
||||
if (memberships.length === 0) {
|
||||
@@ -50,13 +60,13 @@ export const ensureDefaultWorkspace = mutation({
|
||||
name: workspaceName,
|
||||
type: "personal",
|
||||
icon_url: null,
|
||||
created_by: args.userId,
|
||||
created_by: userId,
|
||||
created_at: ts,
|
||||
});
|
||||
|
||||
await ctx.db.insert("workspace_members", {
|
||||
workspace_id: args.workspaceIdIfCreate,
|
||||
user_id: args.userId,
|
||||
user_id: userId,
|
||||
role: "owner",
|
||||
is_default: true,
|
||||
created_at: ts,
|
||||
@@ -103,12 +113,14 @@ export const ensureDefaultWorkspace = mutation({
|
||||
});
|
||||
|
||||
export const fetchWorkspaceSummaries = query({
|
||||
args: { userId: v.string() },
|
||||
args: {},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
// 说明:为了复用 ensureDefaultWorkspace 的返回结构,这里直接走同样的聚合逻辑。
|
||||
const memberships = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_user_id", (q) => q.eq("user_id", args.userId))
|
||||
.withIndex("by_user_id", (q) => q.eq("user_id", userId))
|
||||
.collect();
|
||||
|
||||
const workspaceIds = Array.from(new Set(memberships.map((m) => m.workspace_id)));
|
||||
@@ -135,12 +147,14 @@ export const fetchWorkspaceSummaries = query({
|
||||
});
|
||||
|
||||
export const switchDefaultWorkspace = mutation({
|
||||
args: { userId: v.string(), workspaceId: v.string() },
|
||||
args: { workspaceId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const target = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q) =>
|
||||
q.eq("workspace_id", args.workspaceId).eq("user_id", args.userId),
|
||||
q.eq("workspace_id", args.workspaceId).eq("user_id", userId),
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -150,7 +164,7 @@ export const switchDefaultWorkspace = mutation({
|
||||
|
||||
const memberships = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_user_id", (q) => q.eq("user_id", args.userId))
|
||||
.withIndex("by_user_id", (q) => q.eq("user_id", userId))
|
||||
.collect();
|
||||
|
||||
// 说明:Convex 暂无批量 update,这里逐条 patch。
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { DocumentShell } from "@/components/editor/document-shell";
|
||||
import type { PageOptionsState, DocumentStats } from "@/types/page-options";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
interface DocumentPageProps {
|
||||
@@ -20,9 +18,8 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const doc = await client.query(api.documents.getMeta, { userId: auth.userId, id });
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const doc = await client.query(api.documents.getMeta, { id });
|
||||
if (!doc) {
|
||||
notFound();
|
||||
}
|
||||
@@ -61,58 +58,5 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { data: document } = await supabase
|
||||
.from("documents")
|
||||
.select(
|
||||
"id,title,updated_at,workspace_id,wide_layout,use_small_text,show_heading_numbers,show_toc,show_structure,protect_editing,show_word_count,word_count,character_count,block_count",
|
||||
)
|
||||
.eq("user_id", session.user.id)
|
||||
.eq("id", id)
|
||||
.single();
|
||||
|
||||
if (!document) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const initialOptions: PageOptionsState = {
|
||||
wideLayout: document.wide_layout ?? false,
|
||||
smallText: document.use_small_text ?? false,
|
||||
showHeadingNumbers: document.show_heading_numbers ?? true,
|
||||
showToc: document.show_toc ?? false,
|
||||
showStructure: document.show_structure ?? false,
|
||||
protectEditing: document.protect_editing ?? false,
|
||||
showWordCount: document.show_word_count ?? true,
|
||||
};
|
||||
|
||||
const initialStats: DocumentStats = {
|
||||
wordCount: document.word_count ?? 0,
|
||||
characterCount: document.character_count ?? 0,
|
||||
blockCount: document.block_count ?? 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="min-h-0 flex-1">
|
||||
<DocumentShell
|
||||
documentId={document.id}
|
||||
workspaceId={document.workspace_id}
|
||||
title={document.title}
|
||||
updatedAt={document.updated_at}
|
||||
initialContent={null}
|
||||
initialOptions={initialOptions}
|
||||
initialStats={initialStats}
|
||||
openTableId={openTableId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
redirect("/auth");
|
||||
}
|
||||
|
||||
@@ -5,14 +5,10 @@ import { Breadcrumb } from "@/components/breadcrumb";
|
||||
import { BottomToolbar } from "@/components/bottom-toolbar";
|
||||
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
|
||||
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { SearchPalette } from "@/components/search/search-palette";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
@@ -66,11 +62,9 @@ function makeId(): string {
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
userId: auth.userId,
|
||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||
workspaceIdIfCreate: makeId(),
|
||||
});
|
||||
@@ -84,11 +78,9 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
if (activeWorkspaceId) {
|
||||
const [docRows, trashedDocs] = await Promise.all([
|
||||
client.query(api.documents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
}),
|
||||
client.query(api.documents.listTrashedByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
}),
|
||||
]);
|
||||
@@ -96,7 +88,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
documents = docRows as unknown as DocumentRecord[];
|
||||
|
||||
const mindmapRows = await client.query(api.mindmaps.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
includeDeleted: true,
|
||||
});
|
||||
@@ -178,10 +169,10 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white">
|
||||
<div className="flex h-screen w-full overflow-hidden bg-wolai-bg text-wolai-text-primary">
|
||||
{sidebarInitialData && <Sidebar initialData={sidebarInitialData} />}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-10 items-center gap-4 border-b border-[#eeeeee] px-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col h-full bg-white relative overflow-hidden">
|
||||
<header className="h-[44px] w-full flex items-center px-4 text-wolai-text-secondary text-sm bg-white/80 backdrop-blur-sm sticky top-0 z-50">
|
||||
<MobileSidebarTrigger />
|
||||
<Breadcrumb documents={documents} />
|
||||
</header>
|
||||
@@ -193,58 +184,5 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
|
||||
const { workspaces, activeWorkspaceId } = await fetchWorkspaceSummaries(supabase, session.user.id);
|
||||
|
||||
let documents: DocumentRecord[] = [];
|
||||
let sidebarInitialData: SidebarInitialData | null = null;
|
||||
|
||||
if (activeWorkspaceId) {
|
||||
const dataset = await fetchSidebarDataset(supabase, activeWorkspaceId);
|
||||
documents = dataset.documents;
|
||||
const localMindmaps = await detectLocalMindmapDocs(
|
||||
dataset.documents.map((d) => d.id),
|
||||
);
|
||||
const mindmapDocs = Array.from(
|
||||
new Set([...(dataset.mindmapDocs ?? []), ...localMindmaps]),
|
||||
);
|
||||
const trashedMindmapAssets = await detectLocalTrashedMindmapAssets(
|
||||
activeWorkspaceId,
|
||||
dataset.documents.map((d) => d.id),
|
||||
);
|
||||
sidebarInitialData = {
|
||||
activeWorkspaceId,
|
||||
workspaces,
|
||||
documents: dataset.documents,
|
||||
trashedDocuments: dataset.trashedDocuments,
|
||||
trashedMediaAssets: dataset.trashedMediaAssets ?? [],
|
||||
trashedMindmapAssets,
|
||||
mediaAssets: dataset.mediaAssets,
|
||||
mindmapDocs,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white">
|
||||
{sidebarInitialData && <Sidebar initialData={sidebarInitialData} />}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-10 items-center gap-4 border-b border-[#eeeeee] px-4">
|
||||
<MobileSidebarTrigger />
|
||||
<Breadcrumb documents={documents} />
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||
<BottomToolbar />
|
||||
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
@@ -1,116 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("test@163.com");
|
||||
const [password, setPassword] = useState("123456");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [sessionInfo, setSessionInfo] = useState<string>("");
|
||||
|
||||
const handleLogin = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
const supabaseBrowser = getSupabaseBrowserClient();
|
||||
let data: unknown = null;
|
||||
let error: { message?: string } | null = null;
|
||||
try {
|
||||
const result = await supabaseBrowser.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
data = result.data;
|
||||
error = result.error as unknown as { message?: string } | null;
|
||||
} catch (err) {
|
||||
const runtimeCfg = (() => {
|
||||
try {
|
||||
return (window as unknown as { __MNOTE_RUNTIME_CONFIG__?: { supabaseUrl?: string } }).__MNOTE_RUNTIME_CONFIG__;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
const supabaseUrlHint = runtimeCfg?.supabaseUrl ? `(Supabase:${runtimeCfg.supabaseUrl})` : "";
|
||||
setLoading(false);
|
||||
setMessage(`登录失败:网络或证书异常 ${supabaseUrlHint}\n${String(err)}`);
|
||||
return;
|
||||
}
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
setMessage(`登录失败:${error.message}`);
|
||||
return;
|
||||
}
|
||||
setMessage("登录成功,准备跳转...");
|
||||
const session = (data as { session?: unknown } | null)?.session ?? null;
|
||||
setSessionInfo(JSON.stringify(session, null, 2));
|
||||
// 说明:曾出现“需要二次登录”的现象(服务端仍读到旧 cookie/session)。
|
||||
// 这里用整页跳转强制刷新,确保 App Router 的服务端组件读取到最新会话。
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/";
|
||||
return;
|
||||
}
|
||||
router.replace("/");
|
||||
}, [email, password, router]);
|
||||
|
||||
const handleGetSession = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const supabaseBrowser = getSupabaseBrowserClient();
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await supabaseBrowser.auth.getSession();
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
setMessage(`获取会话失败:${error.message}`);
|
||||
return;
|
||||
}
|
||||
if (!session) {
|
||||
setMessage("当前未登录");
|
||||
setSessionInfo("");
|
||||
return;
|
||||
}
|
||||
setMessage("发现有效 Session");
|
||||
setSessionInfo(JSON.stringify(session, null, 2));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
// 已移除 Supabase 登录,统一走 Convex Auth 页面。
|
||||
router.replace("/auth");
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 p-4">
|
||||
<Card className="w-[400px] space-y-4 bg-white/90 shadow-md backdrop-blur">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl">Supabase 登录</CardTitle>
|
||||
<p className="text-sm text-zinc-500">测试账号:test@163.com / 123456</p>
|
||||
<CardTitle className="text-xl">正在跳转</CardTitle>
|
||||
<p className="text-sm text-zinc-500">该页面已迁移到 Convex Auth(/auth)。</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-zinc-700">邮箱</label>
|
||||
<Input value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm text-zinc-700">密码</label>
|
||||
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1" onClick={handleLogin} disabled={loading}>
|
||||
{loading ? "处理中..." : "登录"}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleGetSession} disabled={loading}>
|
||||
会话检测
|
||||
</Button>
|
||||
</div>
|
||||
{message && <p className="whitespace-pre-wrap text-sm text-blue-600">{message}</p>}
|
||||
<Separator />
|
||||
{sessionInfo && (
|
||||
<pre className="max-h-64 overflow-auto rounded-md bg-zinc-900 p-3 text-xs text-zinc-100">
|
||||
{sessionInfo}
|
||||
</pre>
|
||||
)}
|
||||
<p className="text-sm text-zinc-700">如果没有自动跳转,请手动打开 /auth。</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import {
|
||||
@@ -36,12 +35,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const resolvedUserId = async () => {
|
||||
if (userId) return userId;
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) return null;
|
||||
return session.user.id;
|
||||
return null;
|
||||
};
|
||||
|
||||
const finalUserId = await resolvedUserId();
|
||||
|
||||
@@ -4,7 +4,6 @@ import { loadLocalAiConfig } from "@/lib/ai/localAiConfig";
|
||||
import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/registry";
|
||||
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
|
||||
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb";
|
||||
import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools";
|
||||
@@ -80,12 +79,7 @@ export async function POST(request: Request) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
return { userId: auth.userId, supabase: null as any, convexClient: client };
|
||||
}
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) return { userId: "", supabase: null as any, convexClient: null as any };
|
||||
return { userId: session.user.id, supabase, convexClient: null as any };
|
||||
return { userId: "", supabase: null as any, convexClient: null as any };
|
||||
})();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const { event, session } = await request.json();
|
||||
|
||||
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
||||
await supabase.auth.setSession(session);
|
||||
}
|
||||
|
||||
if (event === "SIGNED_OUT") {
|
||||
await supabase.auth.signOut();
|
||||
}
|
||||
|
||||
// 说明:历史兼容接口(Supabase Auth callback)。当前项目迁移到 Convex 后,这里保持空实现。
|
||||
await request.json().catch(() => null);
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { isDevAuthEnabled } from "@/lib/auth/devUser";
|
||||
|
||||
interface SignInRequest {
|
||||
email: string;
|
||||
@@ -19,15 +20,14 @@ export async function POST(request: Request) {
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
const body = await request.json() as SignInRequest;
|
||||
const { email, password, name, flow } = body;
|
||||
await request.json().catch(() => null);
|
||||
|
||||
// TODO: 调用 Convex Auth mutation 处理登录/注册
|
||||
// 当前暂时返回未实现错误
|
||||
return NextResponse.json(
|
||||
{ error: "Convex Auth API 路由暂未实现,请稍后实现" },
|
||||
{ status: 501 }
|
||||
);
|
||||
// 说明:当前项目默认使用开发用户鉴权(USE_CONVEX=1),无需走登录/注册。
|
||||
if (isDevAuthEnabled()) {
|
||||
return NextResponse.json({ ok: true, mode: "dev_user" });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Convex Auth 登录/注册尚未接入" }, { status: 501 });
|
||||
} catch (error) {
|
||||
console.error("Convex Auth error:", error);
|
||||
return NextResponse.json(
|
||||
@@ -37,74 +37,5 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Supabase 模式(保留兼容)
|
||||
try {
|
||||
const { createSupabaseRouteClient } = await import("@/lib/supabase/server");
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
|
||||
const body = await request.json() as SignInRequest;
|
||||
const { email, password, name, flow } = body;
|
||||
|
||||
if (flow === "signUp") {
|
||||
// 注册
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
name: name ?? "",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 注册成功后自动登录
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (signInError) {
|
||||
return NextResponse.json(
|
||||
{ error: signInError.message },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "注册并登录成功" },
|
||||
{ status: 200 }
|
||||
);
|
||||
} else {
|
||||
// 登录
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "登录成功" },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Supabase Auth error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "服务器错误" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ error: "仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getBlocksFromDocumentContent, findBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
|
||||
|
||||
@@ -26,16 +24,15 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
|
||||
const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||
const source = await client.query(api.documents.getContent, { id: sourceDocumentId });
|
||||
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
const sourceBlocks = getBlocksFromDocumentContent(source.content);
|
||||
const hit = findBlockInTree(sourceBlocks, blockId);
|
||||
if (!hit) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
||||
|
||||
const target = await client.query(api.documents.getContent, { userId: auth.userId, id: targetDocumentId });
|
||||
const target = await client.query(api.documents.getContent, { id: targetDocumentId });
|
||||
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
|
||||
const targetBlocks = getBlocksFromDocumentContent(target.content);
|
||||
@@ -53,11 +50,14 @@ export async function POST(request: Request) {
|
||||
|
||||
const nextBlocks = [...targetBlocks, referenceBlock];
|
||||
const payload = withBlocksWrittenBack(target.content, nextBlocks);
|
||||
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: targetDocumentId, content: payload });
|
||||
await client.mutation(api.documents.updateContent, { id: targetDocumentId, content: payload });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -112,4 +112,5 @@ export async function POST(request: Request) {
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getBlocksFromDocumentContent, findBlockInTree } from "@/lib/blocks";
|
||||
|
||||
@@ -16,9 +14,8 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const doc = await client.query(api.documents.getContent, { id: sourceDocumentId });
|
||||
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||
const blocks = getBlocksFromDocumentContent(doc.content);
|
||||
const hit = findBlockInTree(blocks, blockId);
|
||||
@@ -26,6 +23,9 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ ok: true, block: hit.block });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -45,4 +45,5 @@ export async function GET(request: Request) {
|
||||
if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
|
||||
|
||||
return NextResponse.json({ ok: true, block: hit.block });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getBlocksFromDocumentContent, removeBlockSubtree, withBlocksWrittenBack } from "@/lib/blocks";
|
||||
|
||||
@@ -26,28 +24,30 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
|
||||
const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||
const source = await client.query(api.documents.getContent, { id: sourceDocumentId });
|
||||
if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
const sourceBlocks = getBlocksFromDocumentContent(source.content);
|
||||
const removedRes = removeBlockSubtree(sourceBlocks, blockId);
|
||||
if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 });
|
||||
|
||||
const target = await client.query(api.documents.getContent, { userId: auth.userId, id: targetDocumentId });
|
||||
const target = await client.query(api.documents.getContent, { id: targetDocumentId });
|
||||
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
const targetBlocks = getBlocksFromDocumentContent(target.content);
|
||||
|
||||
const nextSourcePayload = withBlocksWrittenBack(source.content, removedRes.nextBlocks);
|
||||
const nextTargetPayload = withBlocksWrittenBack(target.content, [...targetBlocks, removedRes.removed]);
|
||||
|
||||
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: sourceDocumentId, content: nextSourcePayload });
|
||||
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: targetDocumentId, content: nextTargetPayload });
|
||||
await client.mutation(api.documents.updateContent, { id: sourceDocumentId, content: nextSourcePayload });
|
||||
await client.mutation(api.documents.updateContent, { id: targetDocumentId, content: nextTargetPayload });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -96,4 +96,5 @@ export async function POST(request: Request) {
|
||||
if (tgtErr) return NextResponse.json({ error: tgtErr.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getBlocksFromDocumentContent, replaceBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
|
||||
|
||||
@@ -20,9 +18,8 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const doc = await client.query(api.documents.getContent, { id: sourceDocumentId });
|
||||
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
|
||||
|
||||
const blocks = getBlocksFromDocumentContent(doc.content);
|
||||
@@ -30,10 +27,13 @@ export async function POST(request: Request) {
|
||||
if (!replaced.ok) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
|
||||
|
||||
const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks);
|
||||
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: sourceDocumentId, content: payload });
|
||||
await client.mutation(api.documents.updateContent, { id: sourceDocumentId, content: payload });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -61,4 +61,5 @@ export async function POST(request: Request) {
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -12,7 +12,7 @@ type Payload =
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
|
||||
const body = (await request.json().catch(() => null)) as Payload | null;
|
||||
if (!body) {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
|
||||
const { ms }: { ms?: number } = await request.json().catch(() => ({}));
|
||||
const id = randomUUID();
|
||||
@@ -24,7 +24,7 @@ export async function POST(request: Request) {
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get("id") ?? "";
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const url = new URL(request.url);
|
||||
const documentId = url.searchParams.get("documentId") ?? "";
|
||||
|
||||
@@ -17,9 +14,8 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const result = await client.query(api.documents.getContent, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
});
|
||||
|
||||
@@ -30,36 +26,5 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ content: result.content ?? null });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const documentId = url.searchParams.get("documentId") ?? "";
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: document, error } = await supabase
|
||||
.from("documents")
|
||||
.select("id,content")
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ content: document.content ?? null });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
@@ -163,8 +161,7 @@ function replaceAssetRefsInContent(content: Json | null, assetMap: Map<string, {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
|
||||
const payload = (await request.json()) as CopyTreePayload;
|
||||
if (!payload?.items?.length) {
|
||||
@@ -180,7 +177,7 @@ export async function POST(request: Request) {
|
||||
let workspaceId: string | null = null;
|
||||
|
||||
if (targetParentId) {
|
||||
const targetDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: targetParentId });
|
||||
const targetDoc = await client.query(api.documents.getMeta, { id: targetParentId });
|
||||
if (!targetDoc) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
@@ -188,7 +185,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
|
||||
const firstMeta = await client.query(api.documents.getMeta, { userId: auth.userId, id: sourceIds[0] });
|
||||
const firstMeta = await client.query(api.documents.getMeta, { id: sourceIds[0] });
|
||||
if (!firstMeta) {
|
||||
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
@@ -204,7 +201,6 @@ export async function POST(request: Request) {
|
||||
const wid = workspaceId;
|
||||
|
||||
const allDocs = await client.query(api.documents.listAllForCopy, {
|
||||
userId: auth.userId,
|
||||
workspaceId: wid,
|
||||
});
|
||||
|
||||
@@ -275,7 +271,6 @@ export async function POST(request: Request) {
|
||||
titleSet.add(newTitle);
|
||||
|
||||
await client.mutation(api.documents.create, {
|
||||
userId: auth.userId,
|
||||
id: newId,
|
||||
workspaceId: wid,
|
||||
parentId,
|
||||
@@ -286,7 +281,6 @@ export async function POST(request: Request) {
|
||||
|
||||
await ensureDocumentScaffold(newId, newTitle);
|
||||
await client.mutation(api.mindmaps.copyByDocument, {
|
||||
userId: auth.userId,
|
||||
sourceDocId: item.old.id,
|
||||
targetDocId: newId,
|
||||
});
|
||||
@@ -298,6 +292,9 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -543,4 +540,5 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({
|
||||
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
function makeId(): string {
|
||||
@@ -22,8 +19,7 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const { parentId, title, blocks }: CreateChildPayload = await request.json();
|
||||
|
||||
@@ -35,7 +31,7 @@ export async function POST(request: Request) {
|
||||
let accessScope: "private" | "shared" | "public" = "private";
|
||||
|
||||
if (parentId) {
|
||||
const parentDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: parentId });
|
||||
const parentDoc = await client.query(api.documents.getMeta, { id: parentId });
|
||||
if (!parentDoc) {
|
||||
return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
|
||||
}
|
||||
@@ -43,7 +39,6 @@ export async function POST(request: Request) {
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
} else {
|
||||
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
userId: auth.userId,
|
||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||
workspaceIdIfCreate: makeId(),
|
||||
});
|
||||
@@ -58,7 +53,6 @@ export async function POST(request: Request) {
|
||||
const pageId = makeId();
|
||||
|
||||
const created = await client.mutation(api.documents.create, {
|
||||
userId: auth.userId,
|
||||
id: pageId,
|
||||
workspaceId,
|
||||
parentId: parentId ?? null,
|
||||
@@ -73,6 +67,9 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -159,4 +156,5 @@ export async function POST(request: Request) {
|
||||
pageId: data.id,
|
||||
title: data.title ?? resolvedTitle,
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { getDocumentsBaseDir } from "@/lib/server/local-paths";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
|
||||
const documentsBaseDir = getDocumentsBaseDir();
|
||||
|
||||
@@ -41,13 +38,11 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
async function handleCreateRequestConvex(request: Request) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const { parentId }: { parentId?: string | null } = await request.json();
|
||||
|
||||
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
userId: auth.userId,
|
||||
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||
workspaceIdIfCreate: randomUUID(),
|
||||
});
|
||||
@@ -58,7 +53,6 @@ async function handleCreateRequestConvex(request: Request) {
|
||||
|
||||
if (parentId) {
|
||||
const parentDoc = await client.query(api.documents.getMeta, {
|
||||
userId: auth.userId,
|
||||
id: parentId,
|
||||
});
|
||||
|
||||
@@ -68,7 +62,7 @@ async function handleCreateRequestConvex(request: Request) {
|
||||
|
||||
workspaceId = parentDoc.workspace_id;
|
||||
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
||||
const parentContentRes = await client.query(api.documents.getContent, { userId: auth.userId, id: parentId });
|
||||
const parentContentRes = await client.query(api.documents.getContent, { id: parentId });
|
||||
parentContent = (parentContentRes?.content as Json | null) ?? null;
|
||||
} else {
|
||||
workspaceId = workspaceBootstrap.activeWorkspaceId || null;
|
||||
@@ -80,7 +74,6 @@ async function handleCreateRequestConvex(request: Request) {
|
||||
|
||||
const id = randomUUID();
|
||||
const data = await client.mutation(api.documents.create, {
|
||||
userId: auth.userId,
|
||||
id,
|
||||
workspaceId,
|
||||
parentId: parentId ?? null,
|
||||
@@ -108,7 +101,6 @@ async function handleCreateRequestConvex(request: Request) {
|
||||
const payload = composeContentWithBlocks(parentContent, nextBlocks);
|
||||
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
userId: auth.userId,
|
||||
id: parentId,
|
||||
content: payload,
|
||||
});
|
||||
@@ -118,6 +110,9 @@ async function handleCreateRequestConvex(request: Request) {
|
||||
}
|
||||
|
||||
async function handleCreateRequest(request: Request) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -228,4 +223,5 @@ async function handleCreateRequest(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.softDelete, { userId: auth.userId, id: documentId });
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.softDelete, { id: documentId });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -48,4 +48,5 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
@@ -48,15 +46,14 @@ async function copyMindmapIfExists(sourceId: string, targetId: string) {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
|
||||
const { documentId }: DuplicatePayload = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: documentId });
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
||||
}
|
||||
@@ -70,7 +67,6 @@ export async function POST(request: Request) {
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const duplicated = await client.mutation(api.documents.duplicate, {
|
||||
userId: auth.userId,
|
||||
sourceId: documentId,
|
||||
newId,
|
||||
title: duplicatedTitle,
|
||||
@@ -78,7 +74,6 @@ export async function POST(request: Request) {
|
||||
|
||||
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
|
||||
await client.mutation(api.mindmaps.copyByDocument, {
|
||||
userId: auth.userId,
|
||||
sourceDocId: documentId,
|
||||
targetDocId: duplicated.id,
|
||||
});
|
||||
@@ -91,6 +86,9 @@ export async function POST(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -164,4 +162,5 @@ export async function POST(request: Request) {
|
||||
await copyMindmapIfExists(sourceDoc.id, duplicated.id);
|
||||
|
||||
return NextResponse.json(duplicated);
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
@@ -22,12 +21,12 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { userId: auth.userId, id: sourceId });
|
||||
const sourceDoc = await client.query(api.documents.getMeta, { id: sourceId });
|
||||
if (!sourceDoc) {
|
||||
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
|
||||
const targetContent = await client.query(api.documents.getContent, { userId: auth.userId, id: targetId });
|
||||
const targetContent = await client.query(api.documents.getContent, { id: targetId });
|
||||
if (!targetContent) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
@@ -48,7 +47,6 @@ export async function POST(request: Request) {
|
||||
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
userId: auth.userId,
|
||||
id: targetId,
|
||||
content: payload,
|
||||
});
|
||||
@@ -56,6 +54,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -119,4 +120,5 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -13,16 +11,18 @@ interface EmptyTrashPayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json();
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.emptyTrashByWorkspace, { userId: auth.userId, workspaceId });
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -65,4 +65,5 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
interface MovePayload {
|
||||
@@ -13,12 +11,10 @@ interface MovePayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId, parentId = null, position }: MovePayload = await request.json();
|
||||
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
|
||||
const client = getConvexHttpClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.move, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
parentId,
|
||||
sortOrder,
|
||||
@@ -26,6 +22,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -52,4 +51,5 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,38 +1,23 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import type { PageOptionsState } from "@/types/page-options";
|
||||
import type { Database } from "@/types/supabase";
|
||||
|
||||
type OptionsPayload = {
|
||||
documentId: string;
|
||||
options: Partial<PageOptionsState>;
|
||||
};
|
||||
|
||||
const COLUMN_MAP: Record<keyof PageOptionsState, keyof Database["public"]["Tables"]["documents"]["Row"]> = {
|
||||
wideLayout: "wide_layout",
|
||||
smallText: "use_small_text",
|
||||
showHeadingNumbers: "show_heading_numbers",
|
||||
showToc: "show_toc",
|
||||
showStructure: "show_structure",
|
||||
protectEditing: "protect_editing",
|
||||
showWordCount: "show_word_count",
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId, options }: OptionsPayload = await request.json();
|
||||
if (!documentId || !options) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.updateOptions, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
options: {
|
||||
wideLayout: options.wideLayout,
|
||||
@@ -48,43 +33,5 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId, options }: OptionsPayload = await request.json();
|
||||
if (!documentId || !options) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updatePayload: Record<string, boolean> = {};
|
||||
(Object.keys(options) as (keyof PageOptionsState)[]).forEach((key) => {
|
||||
const column = COLUMN_MAP[key];
|
||||
if (!column) return;
|
||||
const value = options[key];
|
||||
if (typeof value === "boolean") {
|
||||
updatePayload[column as string] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(updatePayload).length === 0) {
|
||||
return NextResponse.json({ error: "缺少可更新的选项" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update(updatePayload)
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.purge, { userId: auth.userId, id: documentId });
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.purge, { id: documentId });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -45,4 +45,5 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId } = await request.json();
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.documents.restore, { userId: auth.userId, id: documentId });
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.restore, { id: documentId });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -50,4 +50,5 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
|
||||
interface SavePayload {
|
||||
documentId: string;
|
||||
@@ -12,37 +10,14 @@ interface SavePayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId, content }: SavePayload = await request.json();
|
||||
const client = getConvexHttpClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.updateContent, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
content,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId, content }: SavePayload = await request.json();
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({ content })
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import type { DocumentStats } from "@/types/page-options";
|
||||
|
||||
@@ -13,15 +11,13 @@ interface StatsPayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId, stats }: StatsPayload = await request.json();
|
||||
if (!documentId || !stats) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.updateStats, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
wordCount: stats.wordCount,
|
||||
characterCount: stats.characterCount,
|
||||
@@ -31,33 +27,5 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId, stats }: StatsPayload = await request.json();
|
||||
if (!documentId || !stats) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({
|
||||
word_count: stats.wordCount,
|
||||
character_count: stats.characterCount,
|
||||
block_count: stats.blockCount,
|
||||
})
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
|
||||
interface RenamePayload {
|
||||
documentId: string;
|
||||
@@ -12,37 +10,14 @@ interface RenamePayload {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const { documentId, title }: RenamePayload = await request.json();
|
||||
const client = getConvexHttpClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.updateTitle, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
title,
|
||||
});
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { documentId, title }: RenamePayload = await request.json();
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({ title })
|
||||
.eq("id", documentId)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
@@ -17,7 +16,7 @@ export async function GET(request: Request) {
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
const client = getConvexHttpClient();
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
const result = await client.query(api.tables.getByGridKey, { gridKey });
|
||||
|
||||
if (!result) {
|
||||
@@ -36,6 +35,9 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
// Supabase 模式
|
||||
return NextResponse.json({ code: 501, msg: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const { data, error } = await supabase
|
||||
.from("document_tables")
|
||||
@@ -56,4 +58,5 @@ export async function GET(request: Request) {
|
||||
gridKey: data.grid_key,
|
||||
},
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { createDefaultTableSnapshot } from "@/lib/online-table";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { DocumentTableSnapshot, TableSchema } from "@/types/online-table";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
const fallbackSchema: TableSchema = {
|
||||
@@ -25,7 +24,7 @@ export async function POST(request: Request) {
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
const client = getConvexHttpClient();
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
const tableData = await client.query(api.tables.getByGridKeyFull, { gridKey });
|
||||
|
||||
if (!tableData) {
|
||||
@@ -54,6 +53,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Supabase 模式
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const { data, error } = await supabase
|
||||
.from("document_tables")
|
||||
@@ -80,4 +82,5 @@ export async function POST(request: Request) {
|
||||
return new NextResponse(body, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
|
||||
const BUCKET = "media";
|
||||
const DIRECTORY = "luckysheet";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return NextResponse.json({ code: 501, msg: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("image");
|
||||
@@ -39,4 +41,5 @@ export async function POST(request: Request) {
|
||||
msg: "ok",
|
||||
url: publicUrl,
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -29,7 +28,7 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
const items = await client.query(api.mediaAssets.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
@@ -41,7 +40,9 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
const supabase = null as any;
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
@@ -130,7 +131,7 @@ export async function POST(request: Request) {
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
await client.mutation(api.mediaAssets.create, {
|
||||
userId: auth.userId,
|
||||
asset: {
|
||||
@@ -151,7 +152,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ asset });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
const supabase = null as any;
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { makeUniqueFileName } from "@/lib/file-tree/naming";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -84,7 +83,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
const assets = (await client.query(api.mediaAssets.listByIds, {
|
||||
userId: auth.userId,
|
||||
ids: payload.assetIds,
|
||||
@@ -142,7 +141,6 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const targetDoc = await client.query(api.documents.getMeta, {
|
||||
userId: auth.userId,
|
||||
id: payload.targetDocumentId,
|
||||
});
|
||||
|
||||
@@ -213,6 +211,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -410,4 +411,5 @@ export async function POST(request: Request) {
|
||||
const message = err instanceof Error ? err.message : "操作失败";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -45,7 +44,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
const res = await client.mutation(api.mediaAssets.emptyTrashByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
@@ -55,6 +54,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ success: true, ...(res as any) });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -114,4 +116,5 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, updated: assetIds.length });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
|
||||
@@ -9,6 +8,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "Convex 模式暂不支持 OCR" }, { status: 501 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -51,4 +53,5 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -45,7 +44,7 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
const res = await client.mutation(api.mediaAssets.purgeById, {
|
||||
userId: auth.userId,
|
||||
id: assetId,
|
||||
@@ -55,6 +54,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ success: true, ...(res as any) });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -115,4 +117,5 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,74 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { HttpError } from "@/lib/auth/authContext";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const isUuid = (value: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
||||
|
||||
const resolveAssetObjectLocation = async (params: {
|
||||
bucket?: string | null;
|
||||
storagePath?: string | null;
|
||||
workspaceId?: string | null;
|
||||
fileName?: string | null;
|
||||
}) => {
|
||||
const { bucket, storagePath, workspaceId, fileName } = params;
|
||||
const ws = (workspaceId ?? "").trim();
|
||||
const fn = (fileName ?? "").trim();
|
||||
|
||||
if (!ws || !isUuid(ws) || !fn) return null;
|
||||
|
||||
try {
|
||||
const storage = supabaseAdmin.schema("storage");
|
||||
|
||||
// 1) 若已给出 bucket/path,先校验是否存在
|
||||
const b = (bucket ?? "").trim();
|
||||
const p = (storagePath ?? "").trim();
|
||||
if (b && p) {
|
||||
const { data: exact, error: exactError } = await storage
|
||||
.from("objects")
|
||||
.select("bucket_id,name")
|
||||
.eq("bucket_id", b)
|
||||
.eq("name", p)
|
||||
.limit(1);
|
||||
if (!exactError && exact && exact.length > 0) {
|
||||
return { bucket: b, path: p };
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 兼容历史数据:按 workspace_id/%/file_name 查找
|
||||
const pattern = `${ws}/%/${fn}`;
|
||||
const { data: candidates, error: candError } = await storage
|
||||
.from("objects")
|
||||
.select("bucket_id,name,created_at")
|
||||
.like("name", pattern)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
if (candError || !candidates || candidates.length === 0) return null;
|
||||
|
||||
const exactCandidate =
|
||||
candidates.find((o) => String(o?.name || "").endsWith(`/${fn}`)) ??
|
||||
candidates[0];
|
||||
if (!exactCandidate?.bucket_id || !exactCandidate?.name) return null;
|
||||
return { bucket: String(exactCandidate.bucket_id), path: String(exactCandidate.name) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
let client;
|
||||
try {
|
||||
auth = await requireAuthContext();
|
||||
const authed = await getAuthedConvexClient();
|
||||
auth = authed.auth;
|
||||
client = authed.client;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
@@ -83,7 +28,6 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
const asset = await client.query(api.mediaAssets.getById, { userId: auth.userId, id: assetId });
|
||||
if (!asset) {
|
||||
return NextResponse.json({ error: "资源不存在" }, { status: 404 });
|
||||
@@ -106,101 +50,5 @@ export async function GET(request: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const assetId = searchParams.get("assetId");
|
||||
|
||||
if (!assetId) {
|
||||
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取资源信息
|
||||
const { data: asset, error: assetError } = await supabase
|
||||
.from("media_assets")
|
||||
.select("*")
|
||||
.eq("id", assetId)
|
||||
.single();
|
||||
|
||||
if (assetError || !asset) {
|
||||
return NextResponse.json({ error: "资源不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
// 兼容:早期/异常写入的 media_assets 可能缺少 bucket/storage_path,
|
||||
// 导致生成的签名链接指向不存在的对象(ONLYOFFICE 会报“下载失败”)。
|
||||
let bucket = (asset.bucket as string | null) ?? null;
|
||||
let storagePath = (asset.storage_path as string | null) ?? null;
|
||||
if (!bucket || !storagePath) {
|
||||
const resolved = await resolveAssetObjectLocation({
|
||||
bucket,
|
||||
storagePath,
|
||||
workspaceId: asset.workspace_id ?? null,
|
||||
fileName: asset.file_name ?? null,
|
||||
});
|
||||
if (!resolved) {
|
||||
return NextResponse.json({ error: "未找到存储对象,无法生成签名链接" }, { status: 404 });
|
||||
}
|
||||
bucket = resolved.bucket;
|
||||
storagePath = resolved.path;
|
||||
|
||||
// 尽量回填,避免后续重复修复
|
||||
await supabase
|
||||
.from("media_assets")
|
||||
.update({ bucket, storage_path: storagePath })
|
||||
.eq("id", asset.id);
|
||||
}
|
||||
|
||||
// 对于图片,添加高质量参数以获得更好的显示效果
|
||||
// Supabase Storage 支持通过 URL 参数控制图片质量和尺寸
|
||||
const isImage = asset.mime_type?.startsWith("image/");
|
||||
let signedUrl: string;
|
||||
|
||||
if (isImage) {
|
||||
// 使用 createSignedUrl 并添加高质量参数
|
||||
// 注意:transform 参数需要在签名时指定
|
||||
const { data: signedUrlData, error: signError } = await supabase.storage
|
||||
.from(bucket)
|
||||
.createSignedUrl(storagePath, 60 * 60, {
|
||||
// 添加转换参数以获得高质量图片
|
||||
transform: {
|
||||
quality: 95, // 高质量
|
||||
format: "origin", // 保持原始格式
|
||||
},
|
||||
});
|
||||
|
||||
if (signError || !signedUrlData) {
|
||||
return NextResponse.json({ error: "生成签名 URL 失败" }, { status: 500 });
|
||||
}
|
||||
signedUrl = signedUrlData.signedUrl;
|
||||
} else {
|
||||
// 非图片文件,直接生成签名 URL
|
||||
const { data: signedUrlData, error: signError } = await supabase.storage
|
||||
.from(bucket)
|
||||
.createSignedUrl(storagePath, 60 * 60);
|
||||
|
||||
if (signError || !signedUrlData) {
|
||||
return NextResponse.json({ error: "生成签名 URL 失败" }, { status: 500 });
|
||||
}
|
||||
signedUrl = signedUrlData.signedUrl;
|
||||
}
|
||||
|
||||
signedUrl = rewriteToPublicOrigin(signedUrl, getMnoteRuntimeConfig().supabaseUrl);
|
||||
|
||||
return NextResponse.json({
|
||||
signedUrl,
|
||||
asset: {
|
||||
id: asset.id,
|
||||
file_name: asset.file_name,
|
||||
mime_type: asset.mime_type,
|
||||
file_size: asset.file_size,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
@@ -1,102 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const base64Url = (input: string) =>
|
||||
Buffer.from(input)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
const parseStoragePath = (fileUrl: string) => {
|
||||
try {
|
||||
const url = new URL(fileUrl);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
const objectIdx = segments.findIndex((seg) => seg === "object");
|
||||
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
|
||||
// pattern 1: /storage/v1/object/public/<bucket>/<path...>
|
||||
if (segments[objectIdx + 1] === "public") {
|
||||
const bucket = segments[objectIdx + 2];
|
||||
const path = segments.slice(objectIdx + 3).join("/");
|
||||
return { bucket, path };
|
||||
}
|
||||
// pattern 2: /storage/v1/object/sign/<bucket>/<path...> (token in query)
|
||||
if (segments[objectIdx + 1] === "sign") {
|
||||
const bucket = segments[objectIdx + 2];
|
||||
const path = segments.slice(objectIdx + 3).join("/");
|
||||
return { bucket, path };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isUuid = (value: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
||||
|
||||
const tryResolveExistingObjectPath = async (params: {
|
||||
bucket: string;
|
||||
path: string;
|
||||
fileName?: string;
|
||||
}) => {
|
||||
const { bucket, path, fileName } = params;
|
||||
if (!bucket || !path) return null;
|
||||
try {
|
||||
const storage = supabaseAdmin.schema("storage");
|
||||
|
||||
// 1) 先尝试精确匹配(最快且最准确)
|
||||
const { data: exact, error: exactError } = await storage
|
||||
.from("objects")
|
||||
.select("name")
|
||||
.eq("bucket_id", bucket)
|
||||
.eq("name", path)
|
||||
.limit(1);
|
||||
|
||||
if (!exactError && exact && exact.length > 0 && exact[0]?.name) {
|
||||
return String(exact[0].name);
|
||||
}
|
||||
|
||||
// 2) 兼容历史数据:老链接中间 UUID 可能用的是 document_id,
|
||||
// 但实际对象通常是 workspace_id/<其他uuid>/<file_name>。
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
const workspaceId = segments[0] ?? "";
|
||||
const wantedName = (fileName ?? segments[segments.length - 1] ?? "").trim();
|
||||
if (!workspaceId || !isUuid(workspaceId) || !wantedName) return null;
|
||||
|
||||
const pattern = `${workspaceId}/%/${wantedName}`;
|
||||
const { data: candidates, error: candError } = await storage
|
||||
.from("objects")
|
||||
.select("name,created_at")
|
||||
.eq("bucket_id", bucket)
|
||||
.like("name", pattern)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
if (candError || !candidates || candidates.length === 0) return null;
|
||||
|
||||
// 说明:LIKE 会把 `_` 当作通配符,这里用 endsWith 再做一次精确过滤。
|
||||
const exactCandidate =
|
||||
candidates.find((o) => String(o?.name || "").endsWith(`/${wantedName}`)) ??
|
||||
candidates[0];
|
||||
if (!exactCandidate?.name) return null;
|
||||
return String(exactCandidate.name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
|
||||
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
|
||||
try {
|
||||
await requireAuthContext();
|
||||
} catch (err) {
|
||||
@@ -111,105 +20,12 @@ export async function GET(request: Request) {
|
||||
if (!fileUrl) {
|
||||
return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
|
||||
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
|
||||
return NextResponse.json({ signedUrl: fileUrl });
|
||||
}
|
||||
|
||||
// 说明:在 Cloudflare Tunnel 场景下,后端收到的 Host 可能是 localhost,
|
||||
// 但 ONLYOFFICE 文档服务器拉取 document.url 时必须使用公网可达的域名。
|
||||
// 因此这里优先使用运行时配置(public/mnote-env.json / env)里的公网 Origin,
|
||||
// 再回退到 request.url 解析出的 origin。
|
||||
const runtimeCfg = getMnoteRuntimeConfig();
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const fileUrl = searchParams.get("fileUrl");
|
||||
const fileName = searchParams.get("fileName") ?? undefined;
|
||||
const forOnlyOffice = searchParams.get("for") === "onlyoffice";
|
||||
if (searchParams.get("debug") === "1") {
|
||||
return NextResponse.json({
|
||||
keyLen: (process.env.SUPABASE_SERVICE_ROLE_KEY || "").length,
|
||||
url: process.env.SUPABASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
if (!fileUrl) {
|
||||
return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 说明:
|
||||
// - 非 ONLYOFFICE:必须能解析为 Supabase Storage 路径,便于生成带 download 的临时签名 URL。
|
||||
// - ONLYOFFICE:允许传入任意可访问 URL(包括外链或已签名 URL),并尽量“刷新一次签名”,刷新失败则回退到原 URL。
|
||||
const parsed = parseStoragePath(fileUrl);
|
||||
|
||||
if (!forOnlyOffice) {
|
||||
if (!parsed) {
|
||||
return NextResponse.json({ error: "无法解析 Supabase 存储路径" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { bucket, path } = parsed;
|
||||
const resolvedPath =
|
||||
(await tryResolveExistingObjectPath({ bucket, path, fileName })) ?? null;
|
||||
|
||||
if (!resolvedPath) {
|
||||
return NextResponse.json(
|
||||
{ error: "存储对象不存在,无法生成签名链接" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.storage.from(bucket).createSignedUrl(
|
||||
resolvedPath,
|
||||
60 * 60,
|
||||
{ download: fileName },
|
||||
);
|
||||
|
||||
if (error || !data?.signedUrl) {
|
||||
return NextResponse.json({ error: error?.message ?? "生成签名 URL 失败" }, { status: 500 });
|
||||
}
|
||||
|
||||
// 返回给浏览器的 URL 必须是公网可达的(不能是 127.0.0.1:18000)
|
||||
const signedUrl = rewriteToPublicOrigin(data.signedUrl, runtimeCfg.supabaseUrl);
|
||||
return NextResponse.json({ signedUrl });
|
||||
}
|
||||
|
||||
// ONLYOFFICE 需要“可被文档服务器拉取”的 URL:不要强制 download(Content-Disposition: attachment)。
|
||||
// 这里优先尝试重新生成 1 小时签名 URL;如果失败(例如策略限制/路径无法解析),则回退到原 URL。
|
||||
let upstreamUrl = rewriteToPublicOrigin(fileUrl, runtimeCfg.supabaseUrl);
|
||||
if (parsed) {
|
||||
const { bucket, path } = parsed;
|
||||
const resolvedPath =
|
||||
(await tryResolveExistingObjectPath({ bucket, path, fileName })) ?? null;
|
||||
if (!resolvedPath) {
|
||||
return NextResponse.json(
|
||||
{ error: "存储对象不存在,无法生成签名链接" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
const { data, error } = await supabase.storage.from(bucket).createSignedUrl(
|
||||
resolvedPath,
|
||||
60 * 60,
|
||||
);
|
||||
if (!error && data?.signedUrl) {
|
||||
upstreamUrl = rewriteToPublicOrigin(data.signedUrl, runtimeCfg.supabaseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 关键:Supabase Storage 的 signedUrl 自带 `?token=...`,ONLYOFFICE 会把 URL 上的
|
||||
// `token` 参数当作自己的 JWT token 去校验,导致报 “文档安全令牌的格式不正确/invalid signature”。
|
||||
//
|
||||
// 解决:对 ONLYOFFICE 返回一个“代理 URL”,把真正的 URL 编码到 `u=...` 中,避免
|
||||
// `token` 参数出现在 document.url 上。
|
||||
const defaultOrigin = new URL(request.url).origin;
|
||||
const proxyBase = (runtimeCfg.onlyofficeProxyOrigin || runtimeCfg.cloudflareAppOrigin || defaultOrigin).replace(/\/+$/, "");
|
||||
const proxyUrl = new URL("/api/onlyoffice/proxy", proxyBase);
|
||||
proxyUrl.searchParams.set("u", base64Url(upstreamUrl));
|
||||
return NextResponse.json({ signedUrl: proxyUrl.toString() });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { extname } from "path";
|
||||
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
|
||||
@@ -7,7 +6,7 @@ import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -50,7 +49,7 @@ export async function POST(request: Request) {
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
const assetType = resolveAssetType(file.type || "");
|
||||
const client = getConvexHttpClient();
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
|
||||
// 1) 获取 Convex 的上传 URL(短时有效)
|
||||
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId });
|
||||
@@ -101,6 +100,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -190,4 +192,5 @@ export async function POST(request: Request) {
|
||||
console.error(error);
|
||||
return NextResponse.json({ error: "上传失败" }, { status: 500 });
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
||||
import { loadLocalAiConfig } from "@/lib/ai/localAiConfig";
|
||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||
@@ -313,6 +312,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 documentId/mindmapId/messages" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -833,4 +835,5 @@ export async function POST(request: Request) {
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { detectLocalMindmapFiles } from "@/lib/server/mindmap-files";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import path from "path";
|
||||
@@ -40,6 +39,9 @@ async function listTestPdfs(): Promise<AgentAssetItem[]> {
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -115,4 +117,5 @@ export async function GET(request: Request) {
|
||||
documentId,
|
||||
items,
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
|
||||
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
|
||||
import { applyMindmapOps, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
||||
@@ -93,6 +92,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: "缺少 documentId/mindmapId/targetUid" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -308,4 +310,5 @@ export async function POST(request: Request) {
|
||||
searxCount: searxResults.length,
|
||||
},
|
||||
});
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,101 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
|
||||
interface EmptyTrashPayload {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
async function purgeTrashFolder(folder: string): Promise<number> {
|
||||
const trashDir = path.join(folder, ".trash");
|
||||
try {
|
||||
const entries = await fs.readdir(trashDir);
|
||||
let removed = 0;
|
||||
for (const name of entries) {
|
||||
await fs.rm(path.join(trashDir, name), { force: true, recursive: true });
|
||||
removed += 1;
|
||||
}
|
||||
return removed;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
});
|
||||
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, { workspaceId });
|
||||
return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.limit(1);
|
||||
|
||||
if (membershipError) {
|
||||
return NextResponse.json({ error: membershipError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!membership || membership.length === 0) {
|
||||
return NextResponse.json({ error: "无权操作该工作空间" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { data: documents, error: docError } = await supabase
|
||||
.from("documents")
|
||||
.select("id")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.limit(5000);
|
||||
|
||||
if (docError) {
|
||||
return NextResponse.json({ error: docError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const docIds = (documents ?? []).map((row) => row.id).filter(Boolean);
|
||||
let removed = 0;
|
||||
for (const docId of docIds) {
|
||||
removed += await purgeTrashFolder(path.join(preferredBaseDir, docId));
|
||||
removed += await purgeTrashFolder(path.join(legacyBaseDir, docId));
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, removed });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
@@ -24,7 +22,7 @@ export async function POST(
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
|
||||
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
|
||||
const ops = Array.isArray(payload?.ops) ? payload!.ops : [];
|
||||
@@ -32,21 +30,15 @@ export async function POST(
|
||||
return NextResponse.json({ error: "缺少 ops" }, { status: 400 });
|
||||
}
|
||||
if (ops.length > 80) {
|
||||
return NextResponse.json({ error: "ops 过多(最多 80)" }, { status: 400 });
|
||||
return NextResponse.json({ error: "ops 过多(最大 80)" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const current = await client.query(api.mindmaps.get, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
});
|
||||
|
||||
const current = await client.query(api.mindmaps.get, { docId, mindmapId });
|
||||
const baseData = current?.data ?? defaultMindmapData;
|
||||
const { data: nextData, applied, errors } = applyMindmapOps(baseData, ops);
|
||||
|
||||
await client.mutation(api.mindmaps.put, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
data: nextData,
|
||||
@@ -69,54 +61,6 @@ export async function POST(
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
@@ -12,104 +8,6 @@ const defaultMindmapData = {
|
||||
children: [],
|
||||
};
|
||||
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
|
||||
async function ensureDir(dir: string) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function removeFileSafe(file: string) {
|
||||
try {
|
||||
await fs.rm(file, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureIndexFile(folder: string, title = "无标题") {
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
try {
|
||||
await fs.access(indexFile);
|
||||
} catch {
|
||||
await fs.writeFile(indexFile, `# ${title}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMindmapFileName(mindmapId: string) {
|
||||
if (mindmapId === "legacy" || mindmapId.startsWith("legacy-")) {
|
||||
return "mindmap.json";
|
||||
}
|
||||
return `mindmap-${mindmapId}.json`;
|
||||
}
|
||||
|
||||
async function tryReadJson(file: string) {
|
||||
try {
|
||||
const content = await fs.readFile(file, "utf8");
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 }> },
|
||||
@@ -117,51 +15,12 @@ export async function GET(
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const res = await client.query(api.mindmaps.get, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
});
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
|
||||
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
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)) ??
|
||||
(legacyDirFile ? await tryReadJson(legacyDirFile) : null);
|
||||
if (localData) {
|
||||
return NextResponse.json({ data: localData, source: "local" });
|
||||
}
|
||||
|
||||
// 兼容旧版:没有本地文件时,回退到 documents.mindmap_data(仅能表示单个旧导图)
|
||||
const { data, error } = await supabase
|
||||
.from("documents")
|
||||
.select("mindmap_data")
|
||||
.eq("id", docId)
|
||||
.eq("user_id", session.user.id)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
const payload = data?.mindmap_data ?? defaultMindmapData;
|
||||
return NextResponse.json({ data: payload, source: "supabase" });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
@@ -171,7 +30,7 @@ export async function POST(
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
|
||||
data?: unknown;
|
||||
createOnly?: boolean;
|
||||
@@ -179,59 +38,18 @@ export async function POST(
|
||||
|
||||
try {
|
||||
const result = await client.mutation(api.mindmaps.put, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
data: data ?? defaultMindmapData,
|
||||
...(typeof createOnly === "boolean" ? { createOnly } : {}),
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
.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 { 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({ ok: true, created: true });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
@@ -241,72 +59,16 @@ export async function DELETE(
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
try {
|
||||
const result = await client.mutation(api.mindmaps.softDelete, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
.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 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 });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
@@ -316,7 +78,7 @@ export async function PATCH(
|
||||
const { docId, mindmapId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const { action } = (await request.json().catch(() => ({}))) as { action?: string };
|
||||
if (action !== "restore" && action !== "purge") {
|
||||
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
|
||||
@@ -324,20 +86,11 @@ export async function PATCH(
|
||||
|
||||
try {
|
||||
if (action === "purge") {
|
||||
const result = await client.mutation(api.mindmaps.purge, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||
const result = await client.mutation(api.mindmaps.purge, { docId, mindmapId });
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
}
|
||||
|
||||
const result = await client.mutation(api.mindmaps.restore, {
|
||||
userId: auth.userId,
|
||||
docId,
|
||||
mindmapId,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||
const result = await client.mutation(api.mindmaps.restore, { docId, mindmapId });
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
} catch (error) {
|
||||
const msg = (error as Error).message ?? "操作失败";
|
||||
const status = msg.includes("未找到") ? 404 : 400;
|
||||
@@ -345,68 +98,6 @@ export async function PATCH(
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getDocumentsBaseDir, getLegacyMindmapsBaseDir } from "@/lib/server/local-paths";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
@@ -12,189 +8,56 @@ const defaultMindmapData = {
|
||||
children: [],
|
||||
};
|
||||
|
||||
// 新版:与页面文件夹(index.md 所在处)对齐,放在 public/documents/<docId>/mindmap.json
|
||||
// 旧版遗留:public/mindmaps/<docId>/mindmap.json
|
||||
const preferredBaseDir = getDocumentsBaseDir();
|
||||
const legacyBaseDir = getLegacyMindmapsBaseDir();
|
||||
|
||||
async function ensureDir(dir: string) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function removeFileSafe(file: string) {
|
||||
try {
|
||||
await fs.rm(file, { force: true });
|
||||
} catch {
|
||||
// 忽略删除失败(例如文件不存在)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureIndexFile(folder: string, title = "无标题") {
|
||||
const indexFile = path.join(folder, "index.md");
|
||||
try {
|
||||
await fs.access(indexFile);
|
||||
} catch {
|
||||
await fs.writeFile(indexFile, `# ${title}\n`, "utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ docId: string }> },
|
||||
) {
|
||||
const { docId: id } = await params;
|
||||
const { docId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${id}`;
|
||||
const res = await client.query(api.mindmaps.get, {
|
||||
userId: auth.userId,
|
||||
docId: id,
|
||||
mindmapId,
|
||||
});
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${docId}`;
|
||||
const res = await client.query(api.mindmaps.get, { docId, mindmapId });
|
||||
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const preferredFolder = path.join(preferredBaseDir, id);
|
||||
const preferredFile = path.join(preferredFolder, "mindmap.json");
|
||||
const legacyFolder = path.join(legacyBaseDir, id);
|
||||
const legacyFile = path.join(legacyFolder, "mindmap.json");
|
||||
|
||||
const tryRead = async (file: string) => {
|
||||
try {
|
||||
const content = await fs.readFile(file, "utf8");
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const localData =
|
||||
(await tryRead(preferredFile)) ??
|
||||
(await tryRead(legacyFile));
|
||||
|
||||
if (localData) {
|
||||
return NextResponse.json({ data: localData, source: "local" });
|
||||
}
|
||||
|
||||
// fallback Supabase
|
||||
const { data, error } = await supabase
|
||||
.from("documents")
|
||||
.select("mindmap_data")
|
||||
.eq("id", id)
|
||||
.eq("user_id", session.user.id)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
const payload = data?.mindmap_data ?? defaultMindmapData;
|
||||
return NextResponse.json({ data: payload, source: "supabase" });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ docId: string }> },
|
||||
) {
|
||||
const { docId: id } = await params;
|
||||
const { docId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${id}`;
|
||||
const { data } = await request.json();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${docId}`;
|
||||
const payload = (await request.json().catch(() => ({}))) as { data?: unknown };
|
||||
const result = await client.mutation(api.mindmaps.put, {
|
||||
userId: auth.userId,
|
||||
docId: id,
|
||||
docId,
|
||||
mindmapId,
|
||||
data: data ?? defaultMindmapData,
|
||||
data: payload.data ?? defaultMindmapData,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { data } = await request.json();
|
||||
const folder = path.join(preferredBaseDir, id);
|
||||
const file = path.join(folder, "mindmap.json");
|
||||
try {
|
||||
await ensureDir(folder);
|
||||
await ensureIndexFile(folder);
|
||||
await fs.writeFile(
|
||||
file,
|
||||
JSON.stringify(data ?? defaultMindmapData, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({ mindmap_data: data ?? defaultMindmapData })
|
||||
.eq("id", id)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ docId: string }> },
|
||||
) {
|
||||
const { docId: id } = await params;
|
||||
const { docId } = await params;
|
||||
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${id}`;
|
||||
const result = await client.mutation(api.mindmaps.softDelete, {
|
||||
userId: auth.userId,
|
||||
docId: id,
|
||||
mindmapId,
|
||||
});
|
||||
return NextResponse.json({ ok: true, ...(result ?? {}) });
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const mindmapId = `legacy-${docId}`;
|
||||
const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
|
||||
return NextResponse.json(result ?? { ok: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const preferredFolder = path.join(preferredBaseDir, id);
|
||||
const preferredFile = path.join(preferredFolder, "mindmap.json");
|
||||
const legacyFolder = path.join(legacyBaseDir, id);
|
||||
const legacyFile = path.join(legacyFolder, "mindmap.json");
|
||||
|
||||
await Promise.all([removeFileSafe(preferredFile), removeFileSafe(legacyFile)]);
|
||||
|
||||
const { error } = await supabase
|
||||
.from("documents")
|
||||
.update({ mindmap_data: null })
|
||||
.eq("id", id)
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
@@ -133,34 +132,5 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
const { data: asset, error: assetError } = await supabaseAdmin
|
||||
.from("media_assets")
|
||||
.select("id,bucket,storage_path,mime_type")
|
||||
.eq("id", assetId)
|
||||
.single();
|
||||
|
||||
if (assetError || !asset) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url);
|
||||
const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" });
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
const buf = Buffer.from(await upstream.arrayBuffer());
|
||||
const { error: uploadError } = await supabaseAdmin.storage
|
||||
.from(asset.bucket)
|
||||
.upload(asset.storage_path, buf, {
|
||||
upsert: true,
|
||||
contentType: asset.mime_type || "application/octet-stream",
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 0 });
|
||||
return NextResponse.json({ error: 1 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
@@ -28,6 +27,9 @@ export async function GET(request: Request) {
|
||||
return NextResponse.json({ backlinks });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -73,4 +75,5 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ backlinks: data ?? [] });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
@@ -43,6 +42,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ reference });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -92,4 +94,5 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ reference: data });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
@@ -34,211 +33,12 @@ const TIME_FIELD_COLUMN = {
|
||||
created: "created_at",
|
||||
} as const;
|
||||
|
||||
const escapeLike = (value: string): string => value.replace(/[%_\\]/g, (match) => `\\${match}`);
|
||||
interface DocumentRow {
|
||||
id: string;
|
||||
title: string | null;
|
||||
raw_text: string | null;
|
||||
updated_at: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
const buildIsoBoundary = (value: string, isEnd = false): string | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const suffix = isEnd ? "T23:59:59.999Z" : "T00:00:00.000Z";
|
||||
const date = new Date(`${normalized}${suffix}`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const mapDocumentToResult = (
|
||||
row: DocumentRow,
|
||||
keyword: string | null,
|
||||
forcedMatch?: DocumentSearchResult["matchField"],
|
||||
): DocumentSearchResult => {
|
||||
const normalizedKeyword = keyword?.trim() ?? "";
|
||||
const normalizedTitle = row.title ?? "无标题";
|
||||
let matchField: DocumentSearchResult["matchField"] = "recent";
|
||||
if (forcedMatch) {
|
||||
matchField = forcedMatch;
|
||||
} else if (normalizedKeyword) {
|
||||
const matchesTitle = normalizedTitle.toLowerCase().includes(normalizedKeyword.toLowerCase());
|
||||
matchField = matchesTitle ? "title" : "content";
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
title: normalizedTitle,
|
||||
snippet: buildSnippet(row.raw_text, normalizedKeyword || null),
|
||||
updatedAt: row.updated_at,
|
||||
createdAt: row.created_at,
|
||||
matchField,
|
||||
hasOcr: Boolean(row.raw_text),
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: matchField === "title" ? 2 : 1,
|
||||
};
|
||||
};
|
||||
|
||||
type RouteSupabaseClient = Awaited<ReturnType<typeof createSupabaseRouteClient>>;
|
||||
|
||||
const fetchRecentResults = async (
|
||||
supabase: RouteSupabaseClient,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<DocumentSearchResult[]> => {
|
||||
const { data: recentRows, error: recentError } = await supabase
|
||||
.from("user_recent_pages")
|
||||
.select("document_id,last_accessed_at")
|
||||
.eq("user_id", userId)
|
||||
.eq("workspace_id", workspaceId)
|
||||
.order("last_accessed_at", { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (recentError || !recentRows || recentRows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const documentIds = recentRows.map((row) => row.document_id);
|
||||
|
||||
const { data: docRows, error: docsError } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,updated_at,created_at,raw_text")
|
||||
.in("id", documentIds)
|
||||
.is("deleted_at", null);
|
||||
|
||||
if (docsError || !docRows) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const docMap = new Map(docRows.map((row) => [row.id, row]));
|
||||
return recentRows
|
||||
.map((row) => docMap.get(row.document_id))
|
||||
.filter((row): row is DocumentRow => Boolean(row))
|
||||
.map((row) => mapDocumentToResult(row, null, "recent"));
|
||||
};
|
||||
|
||||
const fetchOcrMatches = async (
|
||||
supabase: RouteSupabaseClient,
|
||||
workspaceId: string,
|
||||
likePattern: string,
|
||||
limit: number,
|
||||
) => {
|
||||
const { data, error } = await supabase
|
||||
.from("media_assets")
|
||||
.select("document_id,ocr_text")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.is("deleted_at", null)
|
||||
.not("ocr_text", "is", null)
|
||||
.ilike("ocr_text", likePattern)
|
||||
.limit(limit);
|
||||
|
||||
if (error || !data) {
|
||||
return [];
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const payload = (await request.json()) as DocumentSearchRequest;
|
||||
const workspaceId = payload.workspaceId;
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filters: DocumentSearchFilters = {
|
||||
...DEFAULT_FILTERS,
|
||||
...payload.filters,
|
||||
};
|
||||
|
||||
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
|
||||
const normalizedQuery = payload.query?.trim() ?? "";
|
||||
const normalizedLower = normalizedQuery.toLowerCase();
|
||||
|
||||
const docs = await client.query(api.documents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const sortedByRecent = [...docs].sort((a, b) => {
|
||||
const ta = a.updated_at ?? a.created_at ?? "";
|
||||
const tb = b.updated_at ?? b.created_at ?? "";
|
||||
return tb.localeCompare(ta);
|
||||
});
|
||||
|
||||
const recentRows = await client.query(api.recents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
limit: 10,
|
||||
});
|
||||
const docMap = new Map(docs.map((d) => [d.id, d]));
|
||||
const recent: DocumentSearchResult[] = recentRows
|
||||
.map((r) => docMap.get(r.document_id))
|
||||
.filter((row): row is (typeof docs)[number] => Boolean(row))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: "",
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: "recent",
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: 0,
|
||||
}));
|
||||
|
||||
if (!normalizedQuery) {
|
||||
const response: DocumentSearchResponse = { results: [], recent };
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const narrowed = sortedByRecent.filter((row) => {
|
||||
if (filters.onlyCurrentPage && payload.documentId) {
|
||||
if (row.id !== payload.documentId) return false;
|
||||
}
|
||||
const title = (row.title ?? "无标题").toLowerCase();
|
||||
return title.includes(normalizedLower);
|
||||
});
|
||||
|
||||
const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: buildSnippet(row.title ?? "", normalizedQuery),
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: "title",
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: 2,
|
||||
}));
|
||||
|
||||
const response: DocumentSearchResponse = {
|
||||
results,
|
||||
recent,
|
||||
};
|
||||
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
const payload = (await request.json()) as DocumentSearchRequest;
|
||||
const workspaceId = payload.workspaceId;
|
||||
|
||||
@@ -251,140 +51,82 @@ export async function POST(request: Request) {
|
||||
...payload.filters,
|
||||
};
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("workspace_id")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.limit(1);
|
||||
|
||||
if (membershipError) {
|
||||
return NextResponse.json({ error: membershipError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!membership || membership.length === 0) {
|
||||
return NextResponse.json({ error: "无权访问该工作空间" }, { status: 403 });
|
||||
}
|
||||
|
||||
if (filters.onlyCurrentPage && !payload.documentId) {
|
||||
filters.onlyCurrentPage = false;
|
||||
}
|
||||
|
||||
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
|
||||
const normalizedQuery = payload.query?.trim() ?? "";
|
||||
const likePattern = filters.exact ? normalizedQuery : `%${escapeLike(normalizedQuery)}%`;
|
||||
const timeColumn = TIME_FIELD_COLUMN[filters.timeField ?? "updated"];
|
||||
const normalizedLower = normalizedQuery.toLowerCase();
|
||||
|
||||
let builder = supabase
|
||||
.from("documents")
|
||||
.select("id,title,updated_at,created_at,raw_text,is_starred,access_scope")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.is("deleted_at", null)
|
||||
.order(timeColumn, { ascending: false })
|
||||
.limit(limit);
|
||||
const docs = await client.query(api.documents.listByWorkspace, { workspaceId });
|
||||
const docMap = new Map(docs.map((d) => [d.id, d]));
|
||||
|
||||
if (filters.onlyCurrentPage && payload.documentId) {
|
||||
builder = builder.eq("id", payload.documentId);
|
||||
const recentRows = await client.query(api.recents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
limit: 10,
|
||||
});
|
||||
const recent: DocumentSearchResult[] = recentRows
|
||||
.map((r) => docMap.get(r.document_id))
|
||||
.filter((row): row is (typeof docs)[number] => Boolean(row))
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: "",
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: "recent",
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: 0,
|
||||
}));
|
||||
|
||||
if (!normalizedQuery) {
|
||||
const response: DocumentSearchResponse = { results: [], recent };
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
const customFrom = filters.customRange?.from ? buildIsoBoundary(filters.customRange.from, false) : null;
|
||||
const customTo = filters.customRange?.to ? buildIsoBoundary(filters.customRange.to, true) : null;
|
||||
const timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange];
|
||||
const timeField = TIME_FIELD_COLUMN[filters.timeField];
|
||||
const boundaryIso =
|
||||
typeof timeRangeMs === "number"
|
||||
? new Date(Date.now() - timeRangeMs).toISOString()
|
||||
: null;
|
||||
|
||||
if (customFrom) {
|
||||
builder = builder.gte(timeColumn, customFrom);
|
||||
}
|
||||
if (customTo) {
|
||||
builder = builder.lte(timeColumn, customTo);
|
||||
}
|
||||
|
||||
if (!customFrom && !customTo) {
|
||||
const now = Date.now();
|
||||
const offset = TIME_RANGE_TO_MS[filters.timeRange];
|
||||
if (offset) {
|
||||
const from = new Date(now - offset).toISOString();
|
||||
builder = builder.gte(timeColumn, from);
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedQuery) {
|
||||
if (filters.titleOnly) {
|
||||
builder = filters.exact ? builder.eq("title", normalizedQuery) : builder.ilike("title", likePattern);
|
||||
} else {
|
||||
const clauses = [
|
||||
`title.${filters.exact ? `eq.${normalizedQuery}` : `ilike.${likePattern}`}`,
|
||||
];
|
||||
if (filters.includeOcr) {
|
||||
clauses.push(`raw_text.ilike.${likePattern}`);
|
||||
const narrowed = [...docs]
|
||||
.sort((a, b) => {
|
||||
const ta = a.updated_at ?? a.created_at ?? "";
|
||||
const tb = b.updated_at ?? b.created_at ?? "";
|
||||
return tb.localeCompare(ta);
|
||||
})
|
||||
.filter((row) => {
|
||||
if (filters.onlyCurrentPage && payload.documentId) {
|
||||
if (row.id !== payload.documentId) return false;
|
||||
}
|
||||
builder = builder.or(clauses.join(","));
|
||||
}
|
||||
}
|
||||
|
||||
const shouldSearchOcr = Boolean(filters.includeOcr && normalizedQuery);
|
||||
const [{ data, error }, recentResults, ocrMatches] = await Promise.all([
|
||||
builder,
|
||||
fetchRecentResults(supabase, session.user.id, workspaceId),
|
||||
shouldSearchOcr ? fetchOcrMatches(supabase, workspaceId, likePattern, limit) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
let results: DocumentSearchResult[] = (data ?? []).map((row) =>
|
||||
mapDocumentToResult(row, normalizedQuery || null),
|
||||
);
|
||||
|
||||
if (ocrMatches.length > 0 && normalizedQuery) {
|
||||
const snippetMap = new Map<string, string>();
|
||||
ocrMatches.forEach((row: { document_id: string; ocr_text: string | null }) => {
|
||||
if (!row.ocr_text) return;
|
||||
if (!snippetMap.has(row.document_id)) {
|
||||
snippetMap.set(row.document_id, row.ocr_text);
|
||||
if (boundaryIso) {
|
||||
const ts = (row as any)?.[timeField] ?? null;
|
||||
if (!ts || typeof ts !== "string") return false;
|
||||
if (ts < boundaryIso) return false;
|
||||
}
|
||||
|
||||
const title = (row.title ?? "无标题").toLowerCase();
|
||||
if (filters.exact) {
|
||||
return title === normalizedLower;
|
||||
}
|
||||
return title.includes(normalizedLower);
|
||||
});
|
||||
if (snippetMap.size > 0) {
|
||||
const resultMap = new Map(results.map((item) => [item.id, item]));
|
||||
const missingDocIds: string[] = [];
|
||||
snippetMap.forEach((text, docId) => {
|
||||
const snippet = buildSnippet(text, normalizedQuery || null);
|
||||
if (resultMap.has(docId)) {
|
||||
const existing = resultMap.get(docId)!;
|
||||
resultMap.set(docId, {
|
||||
...existing,
|
||||
snippet: snippet || existing.snippet,
|
||||
hasOcr: true,
|
||||
matchField: "content",
|
||||
});
|
||||
} else {
|
||||
missingDocIds.push(docId);
|
||||
}
|
||||
});
|
||||
let extraResults: DocumentSearchResult[] = [];
|
||||
if (missingDocIds.length > 0) {
|
||||
const { data: extraDocs } = await supabase
|
||||
.from("documents")
|
||||
.select("id,title,updated_at,created_at,raw_text")
|
||||
.in("id", missingDocIds)
|
||||
.is("deleted_at", null);
|
||||
extraResults =
|
||||
extraDocs?.map((row) => {
|
||||
const snippetText = snippetMap.get(row.id) ?? row.raw_text ?? "";
|
||||
return {
|
||||
...mapDocumentToResult(row, normalizedQuery || null, "content"),
|
||||
snippet: buildSnippet(snippetText, normalizedQuery || null),
|
||||
hasOcr: true,
|
||||
};
|
||||
}) ?? [];
|
||||
}
|
||||
results = [...resultMap.values(), ...extraResults];
|
||||
}
|
||||
}
|
||||
|
||||
const response: DocumentSearchResponse = {
|
||||
results,
|
||||
recent: recentResults,
|
||||
};
|
||||
const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({
|
||||
id: row.id,
|
||||
title: row.title ?? "无标题",
|
||||
snippet: buildSnippet(row.title ?? "", normalizedQuery),
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: "title",
|
||||
hasOcr: false,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: 2,
|
||||
}));
|
||||
|
||||
const response: DocumentSearchResponse = { results, recent };
|
||||
return NextResponse.json(response);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
@@ -28,6 +27,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -72,4 +74,5 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { ensureDefaultWorkspace, fetchWorkspaceSummaries } from "@/lib/workspaces";
|
||||
import { fetchSidebarDataset } from "@/lib/sidebar-tree";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { randomUUID } from "crypto";
|
||||
import {
|
||||
detectLocalMindmapFiles,
|
||||
detectLocalMindmapDocs,
|
||||
detectLocalMindmapImageAssetIdsByMindmapId,
|
||||
detectLocalTrashedMindmapAssets,
|
||||
} from "@/lib/server/mindmap-files";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -72,20 +62,17 @@ function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const workspaceIdParam = url.searchParams.get("workspaceId");
|
||||
|
||||
const bootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
userId: auth.userId,
|
||||
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
||||
workspaceIdIfCreate: randomUUID(),
|
||||
});
|
||||
|
||||
const summaries = await client.query(api.workspaces.fetchWorkspaceSummaries, {
|
||||
userId: auth.userId,
|
||||
});
|
||||
|
||||
const workspaces = summaries.workspaces.length > 0 ? summaries.workspaces : bootstrap.workspaces;
|
||||
@@ -98,17 +85,14 @@ export async function GET(request: Request) {
|
||||
|
||||
try {
|
||||
const documents = await client.query(api.documents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
});
|
||||
|
||||
const trashedDocuments = await client.query(api.documents.listTrashedByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
});
|
||||
|
||||
const mindmapRows = await client.query(api.mindmaps.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
includeDeleted: true,
|
||||
});
|
||||
@@ -200,6 +184,9 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
@@ -301,4 +288,5 @@ export async function GET(request: Request) {
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import type { DocumentTableSnapshot, TableRowData } from "@/types/online-table";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
@@ -25,25 +24,12 @@ const extractTableId = async (context: RouteContext) => {
|
||||
return (await context.params).tableId;
|
||||
};
|
||||
|
||||
const getAuthUser = async () => {
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const { data, error } = await supabase.auth.getUser();
|
||||
if (error) {
|
||||
console.error("Auth getUser error:", error);
|
||||
}
|
||||
if (!data?.user) {
|
||||
return { supabase, user: null };
|
||||
}
|
||||
return { supabase, user: data.user };
|
||||
};
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const tableId = await extractTableId(context);
|
||||
if (!tableId) {
|
||||
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
@@ -63,36 +49,7 @@ export async function GET(request: Request, context: RouteContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// Supabase 模式
|
||||
const { supabase, user } = await getAuthUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// 假设 RLS 确保了用户只能访问其有权限的表格
|
||||
const { data: tableData, error } = await supabase
|
||||
.from("document_tables")
|
||||
.select("*")
|
||||
.eq("id", tableId)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching table:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch table data" }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!tableData) {
|
||||
return NextResponse.json({ error: "Table not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(tableData, { status: 200 });
|
||||
|
||||
} catch (error) {
|
||||
console.error("API error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
@@ -101,13 +58,18 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
let body: UpdateTableRequest;
|
||||
try {
|
||||
const body = await request.json() as UpdateTableRequest;
|
||||
body = (await request.json()) as UpdateTableRequest;
|
||||
} catch (error) {
|
||||
console.error("Invalid payload:", error);
|
||||
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await client.mutation(api.tables.update, {
|
||||
try {
|
||||
await client.mutation(api.tables.update, {
|
||||
userId: auth.userId,
|
||||
tableId,
|
||||
title: body.title,
|
||||
@@ -117,7 +79,6 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||
rows: body.rows,
|
||||
});
|
||||
|
||||
// 重新获取更新后的表格数据
|
||||
const updated = await client.query(api.tables.get, {
|
||||
userId: auth.userId,
|
||||
tableId,
|
||||
@@ -130,125 +91,15 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// Supabase 模式
|
||||
const { supabase, user } = await getAuthUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: UpdateTableRequest | null = null;
|
||||
try {
|
||||
body = await request.json() as UpdateTableRequest;
|
||||
} catch (error) {
|
||||
console.error("Invalid payload:", error);
|
||||
return NextResponse.json({ error: "Invalid payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: tableMeta, error: metaError } = await supabase
|
||||
.from("document_tables")
|
||||
.select("id, workspace_id, document_id, grid_key")
|
||||
.eq("id", tableId)
|
||||
.single();
|
||||
|
||||
if (metaError || !tableMeta) {
|
||||
console.error("Table not found or load error:", metaError);
|
||||
return NextResponse.json({ error: "Table not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updatePayload: Record<string, unknown> = {
|
||||
updated_by: user.id,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (body?.title !== undefined) {
|
||||
updatePayload.title = body.title;
|
||||
}
|
||||
if (body?.schema) {
|
||||
updatePayload.schema = body.schema;
|
||||
}
|
||||
if (body?.snapshot !== undefined) {
|
||||
updatePayload.snapshot = body.snapshot ?? {};
|
||||
}
|
||||
if (body?.viewPreferences) {
|
||||
updatePayload.view_preferences = body.viewPreferences;
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from("document_tables")
|
||||
.update(updatePayload)
|
||||
.eq("id", tableId);
|
||||
|
||||
if (updateError) {
|
||||
console.error("Failed to update table:", updateError);
|
||||
return NextResponse.json({ error: "Failed to update table", details: updateError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const nextRows = body?.rows ?? body?.snapshot?.rows ?? [];
|
||||
if (Array.isArray(nextRows)) {
|
||||
const { error: deleteRowsError } = await supabase
|
||||
.from("document_table_rows")
|
||||
.delete()
|
||||
.eq("table_id", tableId);
|
||||
|
||||
if (deleteRowsError) {
|
||||
console.error("Failed to clear old rows:", deleteRowsError);
|
||||
return NextResponse.json({ error: "Failed to update rows", details: deleteRowsError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const sanitizedRows = nextRows
|
||||
.filter((row) => row && typeof row === "object" && Object.keys(row).length > 0);
|
||||
|
||||
if (sanitizedRows.length > 0) {
|
||||
const rowsToInsert = sanitizedRows.map((row, index) => ({
|
||||
table_id: tableId,
|
||||
workspace_id: tableMeta.workspace_id,
|
||||
document_id: tableMeta.document_id,
|
||||
row_index: index,
|
||||
row_data: row,
|
||||
row_hash: null,
|
||||
is_deleted: false,
|
||||
updated_by: user.id,
|
||||
}));
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from("document_table_rows")
|
||||
.insert(rowsToInsert);
|
||||
|
||||
if (insertError) {
|
||||
console.error("Failed to insert rows:", insertError);
|
||||
return NextResponse.json({ error: "Failed to insert rows", details: insertError.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data: latest, error: fetchError } = await supabase
|
||||
.from("document_tables")
|
||||
.select("*")
|
||||
.eq("id", tableId)
|
||||
.single();
|
||||
|
||||
if (fetchError || !latest) {
|
||||
console.error("Failed to fetch updated table:", fetchError);
|
||||
return NextResponse.json({ error: "Failed to load updated table", details: fetchError?.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(latest, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("API error:", error);
|
||||
const message = error instanceof Error ? error.message : "Internal Server Error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
export async function DELETE(_request: Request, context: RouteContext) {
|
||||
const tableId = await extractTableId(context);
|
||||
if (!tableId) {
|
||||
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Convex 模式
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
@@ -264,37 +115,6 @@ export async function DELETE(request: Request, context: RouteContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// Supabase 模式
|
||||
const { supabase, user } = await getAuthUser();
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: deleteRowsError } = await supabase
|
||||
.from("document_table_rows")
|
||||
.delete()
|
||||
.eq("table_id", tableId);
|
||||
|
||||
if (deleteRowsError) {
|
||||
console.error("Failed to delete table rows:", deleteRowsError);
|
||||
return NextResponse.json({ error: "Failed to delete table rows" }, { status: 500 });
|
||||
}
|
||||
|
||||
const { error: deleteTableError } = await supabase
|
||||
.from("document_tables")
|
||||
.delete()
|
||||
.eq("id", tableId);
|
||||
|
||||
if (deleteTableError) {
|
||||
console.error("Failed to delete table:", deleteTableError);
|
||||
return NextResponse.json({ error: "Failed to delete table" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("API error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { DocumentTableSnapshot, TableSchema } from "@/types/online-table";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
@@ -27,7 +26,6 @@ export async function POST(request: Request) {
|
||||
|
||||
// 获取 document 所在的 workspace_id
|
||||
const document = await client.query(api.documents.getMeta, {
|
||||
userId: auth.userId,
|
||||
id: documentId,
|
||||
});
|
||||
|
||||
@@ -54,6 +52,9 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
// Supabase 模式
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
@@ -112,4 +113,5 @@ export async function POST(request: Request) {
|
||||
console.error("API error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -1,75 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createSupabaseRouteClient } from "@/lib/supabase/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const workspaceId = payload.workspaceId as string | undefined;
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = getConvexHttpClient();
|
||||
await client.mutation(api.workspaces.switchDefaultWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await request.json().catch(() => ({}));
|
||||
const workspaceId = payload.workspaceId as string | undefined;
|
||||
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { data: membership, error: membershipError } = await supabase
|
||||
.from("workspace_members")
|
||||
.select("id")
|
||||
.eq("workspace_id", workspaceId)
|
||||
.eq("user_id", session.user.id)
|
||||
.single();
|
||||
|
||||
if (membershipError || !membership) {
|
||||
return NextResponse.json({ error: "无权切换至该工作空间" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { error: resetError } = await supabase
|
||||
.from("workspace_members")
|
||||
.update({ is_default: false })
|
||||
.eq("user_id", session.user.id);
|
||||
|
||||
if (resetError) {
|
||||
return NextResponse.json({ error: resetError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
const { error: switchError } = await supabase
|
||||
.from("workspace_members")
|
||||
.update({ is_default: true })
|
||||
.eq("user_id", session.user.id)
|
||||
.eq("workspace_id", workspaceId);
|
||||
|
||||
if (switchError) {
|
||||
return NextResponse.json({ error: switchError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ error: "仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
@@ -43,6 +43,17 @@
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
/* Wolai 设计系统颜色变量 */
|
||||
--color-wolai-bg: #FFFFFF;
|
||||
--color-wolai-bg-sidebar: #F7F7F5;
|
||||
--color-wolai-bg-hover: #EFEFEF;
|
||||
--color-wolai-bg-active: #E8E8E7;
|
||||
--color-wolai-text-primary: #37352F;
|
||||
--color-wolai-text-secondary: #9B9A97;
|
||||
--color-wolai-border: #E9E9E8;
|
||||
--color-wolai-brand: #22A559;
|
||||
--font-wolai-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -87,6 +98,45 @@ body,
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* Wolai 字体栈 */
|
||||
body {
|
||||
font-family: -apple-system, "Microsoft YaHei", sans-serif;
|
||||
color: #37352F;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* 滚动条美化 - 悬停时显示 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
:hover::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* BlockNote 变量覆盖 */
|
||||
:root {
|
||||
--bn-colors-editor-background: #FFFFFF;
|
||||
--bn-colors-editor-text: #37352F;
|
||||
--bn-font-family: -apple-system, "Microsoft YaHei", sans-serif;
|
||||
--bn-border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 调整编辑器内容宽度 */
|
||||
.bn-editor {
|
||||
padding-left: 5rem;
|
||||
padding-right: 5rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
@@ -510,4 +560,109 @@ body,
|
||||
#luckysheet-rich-text-editor {
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* Wolai 编辑器样式 */
|
||||
.wolai-editor .bn-block-content {
|
||||
color: #37352F;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
.wolai-editor h1 {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
color: #37352F;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.wolai-editor h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-top: 1.4em;
|
||||
margin-bottom: 0.4em;
|
||||
color: #37352F;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wolai-editor h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-top: 1.3em;
|
||||
margin-bottom: 0.4em;
|
||||
color: #37352F;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 列表样式 */
|
||||
.wolai-editor ul,
|
||||
.wolai-editor ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.wolai-editor li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
/* 引用块样式 */
|
||||
.wolai-editor blockquote {
|
||||
border-left: 3px solid #E9E9E8;
|
||||
padding-left: 1em;
|
||||
color: #9B9A97;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 代码块样式 */
|
||||
.wolai-editor pre {
|
||||
background-color: #F7F7F5;
|
||||
border-radius: 4px;
|
||||
padding: 1em;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 分割线样式 */
|
||||
.wolai-editor hr {
|
||||
border-color: #E9E9E8;
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
.wolai-editor a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.wolai-editor a:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Slash 菜单样式 */
|
||||
.bn-suggestion-menu {
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E9E9E8;
|
||||
border-radius: 4px;
|
||||
box-shadow: rgba(15, 15, 15, 0.05) 0px 0px 0px 1px, rgba(15, 15, 15, 0.1) 0px 3px 6px, rgba(15, 15, 15, 0.2) 0px 9px 24px;
|
||||
}
|
||||
|
||||
.bn-suggestion-menu-item:hover,
|
||||
.bn-suggestion-menu-item.selected {
|
||||
background-color: #EFEFEF;
|
||||
}
|
||||
|
||||
.bn-suggestion-menu-item-title {
|
||||
color: #37352F;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.bn-suggestion-menu-item-subtext {
|
||||
color: #9B9A97;
|
||||
}
|
||||
|
||||
.bn-suggestion-menu-item-group-title {
|
||||
color: #9B9A97;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { SupabaseProvider } from "@/components/providers/supabase-provider";
|
||||
import { QueryProvider } from "@/components/providers/query-provider";
|
||||
import { ConvexClientProvider } from "@/components/providers/convex-provider";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
|
||||
@@ -16,7 +14,7 @@ const inter = Inter({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Wolai Clone",
|
||||
description: "Stage0 + Stage1 implementation powered by Supabase",
|
||||
description: "Convex 自部署模式",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
@@ -27,16 +25,6 @@ export default async function RootLayout({
|
||||
const isDesktop = process.env.MNOTE_DESKTOP === "1";
|
||||
const useConvex = isConvexEnabled();
|
||||
|
||||
let session = null;
|
||||
if (!useConvex) {
|
||||
try {
|
||||
session = (await (await createSupabaseServerClient()).auth.getSession()).data.session;
|
||||
} catch {
|
||||
// 说明:不阻塞页面渲染,交由客户端登录页处理(例如网络暂不可达)。
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop, useConvex };
|
||||
const runtimeConfigJson = JSON.stringify(runtimeConfig).replace(/</g, "\\u003cc");
|
||||
|
||||
@@ -52,17 +40,11 @@ export default async function RootLayout({
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
{useConvex ? (
|
||||
<ConvexAuthNextjsServerProvider>
|
||||
<ConvexClientProvider>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</ConvexClientProvider>
|
||||
</ConvexAuthNextjsServerProvider>
|
||||
) : (
|
||||
<SupabaseProvider session={session}>
|
||||
<ConvexAuthNextjsServerProvider>
|
||||
<ConvexClientProvider>
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</SupabaseProvider>
|
||||
)}
|
||||
</ConvexClientProvider>
|
||||
</ConvexAuthNextjsServerProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
function makeId(): string {
|
||||
@@ -13,80 +10,34 @@ function makeId(): string {
|
||||
}
|
||||
|
||||
export default async function Home() {
|
||||
if (isConvexEnabled()) {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
|
||||
const { activeWorkspaceId } = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
userId: auth.userId,
|
||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||
workspaceIdIfCreate: makeId(),
|
||||
});
|
||||
|
||||
const docs = await client.query(api.documents.listByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
});
|
||||
|
||||
const firstDoc = [...docs].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? ""))[0];
|
||||
if (firstDoc?.id) {
|
||||
redirect(`/documents/${firstDoc.id}`);
|
||||
}
|
||||
|
||||
const docId = makeId();
|
||||
await client.mutation(api.documents.create, {
|
||||
userId: auth.userId,
|
||||
id: docId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
parentId: null,
|
||||
title: "新页面",
|
||||
accessScope: "private",
|
||||
content: [],
|
||||
});
|
||||
redirect(`/documents/${docId}`);
|
||||
if (!isConvexEnabled()) {
|
||||
redirect("/auth");
|
||||
}
|
||||
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const session = (await supabase.auth.getSession()).data.session;
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
const { activeWorkspaceId } = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
||||
fallbackName: auth.name ?? auth.email ?? "我的空间",
|
||||
workspaceIdIfCreate: makeId(),
|
||||
});
|
||||
|
||||
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间");
|
||||
const workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id);
|
||||
|
||||
if (!workspaceId) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { data: firstDoc } = await supabase
|
||||
.from("documents")
|
||||
.select("id")
|
||||
.eq("user_id", session.user.id)
|
||||
.eq("workspace_id", workspaceId)
|
||||
.order("created_at", { ascending: true })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
const docs = await client.query(api.documents.listByWorkspace, {
|
||||
workspaceId: activeWorkspaceId,
|
||||
});
|
||||
|
||||
const firstDoc = [...docs].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? ""))[0];
|
||||
if (firstDoc?.id) {
|
||||
redirect(`/documents/${firstDoc.id}`);
|
||||
}
|
||||
|
||||
const { data: createdDoc, error } = await supabase
|
||||
.from("documents")
|
||||
.insert({
|
||||
user_id: session.user.id,
|
||||
workspace_id: workspaceId,
|
||||
title: "新页面",
|
||||
content: {},
|
||||
})
|
||||
.select("id")
|
||||
.single();
|
||||
|
||||
if (error || !createdDoc) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
redirect(`/documents/${createdDoc.id}`);
|
||||
const docId = makeId();
|
||||
await client.mutation(api.documents.create, {
|
||||
id: docId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
parentId: null,
|
||||
title: "新页面",
|
||||
accessScope: "private",
|
||||
content: [],
|
||||
});
|
||||
redirect(`/documents/${docId}`);
|
||||
}
|
||||
|
||||
@@ -31,16 +31,19 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-between">
|
||||
<nav className="flex items-center text-sm text-gray-500">
|
||||
<nav className="flex items-center text-sm text-wolai-text-secondary">
|
||||
{displayPath.map((node, index) => {
|
||||
const isLast = index === displayPath.length - 1;
|
||||
return (
|
||||
<span key={node.id} className="flex items-center">
|
||||
{index > 0 && <ChevronRight className="mx-1 h-4 w-4 text-gray-400" />}
|
||||
{isLast ? (
|
||||
<span className="font-medium text-gray-900">{node.title || "无标题"}</span>
|
||||
<span className="font-medium text-wolai-text-primary">{node.title || "无标题"}</span>
|
||||
) : (
|
||||
<Link href={`/documents/${node.id}`} className="wolai-blue">
|
||||
<Link
|
||||
href={`/documents/${node.id}`}
|
||||
className="hover:bg-wolai-bg-hover hover:text-wolai-text-primary px-1.5 py-0.5 rounded cursor-pointer transition-colors"
|
||||
>
|
||||
{node.title || "无标题"}
|
||||
</Link>
|
||||
)}
|
||||
@@ -48,14 +51,17 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
<button type="button" className="wolai-hover rounded-full px-3 py-1 text-sm">
|
||||
<div className="flex items-center gap-2 text-wolai-text-secondary">
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full px-3 py-1 text-sm transition-colors"
|
||||
>
|
||||
<Star className="mr-1 inline h-4 w-4" />
|
||||
收藏
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="wolai-hover rounded-full p-2 text-gray-500"
|
||||
className="hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full p-2 transition-colors"
|
||||
onClick={toggleInspector}
|
||||
aria-label={showInspector ? "隐藏页面选项" : "显示页面选项"}
|
||||
title={showInspector ? "隐藏页面选项" : "显示页面选项"}
|
||||
|
||||
@@ -354,15 +354,46 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
);
|
||||
}
|
||||
if (assetType === "file") {
|
||||
// 根据文件扩展名确定图标颜色
|
||||
const getIconColor = () => {
|
||||
const ext = (extension ?? "").toLowerCase().replace(/^\./, "");
|
||||
if (ext === "pdf") return "text-red-500";
|
||||
if (["doc", "docx"].includes(ext)) return "text-blue-600";
|
||||
if (["xls", "xlsx"].includes(ext)) return "text-green-600";
|
||||
if (["ppt", "pptx"].includes(ext)) return "text-orange-500";
|
||||
return "text-[#9B9A97]";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<span className="rounded-full bg-[#2563eb]/10 p-3 text-[#2563eb]">
|
||||
<Paperclip className="h-5 w-5" />
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (isOfficeDoc) {
|
||||
void openWithOnlyOffice();
|
||||
} else {
|
||||
downloadAsset();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (isOfficeDoc) {
|
||||
void openWithOnlyOffice();
|
||||
} else {
|
||||
downloadAsset();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="group flex items-center gap-2 px-3 py-2 rounded-[4px] bg-white hover:bg-[#F5F5F5] transition-colors duration-200 cursor-pointer select-none"
|
||||
>
|
||||
<span className={cn("w-5 h-5 shrink-0 flex items-center justify-center", getIconColor())}>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-800">{displayFileName}</p>
|
||||
<div className="flex flex-col justify-center gap-0.5 overflow-hidden">
|
||||
<p className="text-[14px] text-[#37352F] font-normal truncate">{displayFileName}</p>
|
||||
{block.props.fileSize ? (
|
||||
<p className="text-xs text-gray-500">{formatFileSize(block.props.fileSize)}</p>
|
||||
<p className="text-[12px] text-[#999999]">{formatFileSize(block.props.fileSize)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createReactBlockSpec } from "@blocknote/react";
|
||||
import { RiFileTextFill } from "react-icons/ri";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
const normalizeTitle = (value?: string | null) => {
|
||||
if (!value || !value.trim()) {
|
||||
@@ -16,65 +13,9 @@ const normalizeTitle = (value?: string | null) => {
|
||||
|
||||
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
|
||||
const router = useRouter();
|
||||
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
|
||||
const fallbackTitle = normalizeTitle(title);
|
||||
const [resolvedTitle, setResolvedTitle] = useState(fallbackTitle);
|
||||
|
||||
useEffect(() => {
|
||||
setResolvedTitle(fallbackTitle);
|
||||
}, [fallbackTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageId) {
|
||||
return;
|
||||
}
|
||||
if (useConvex) {
|
||||
// 说明:Convex 迁移阶段先不做 title 的实时订阅/拉取,直接使用 block props 里的 title。
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const applyTitle = (nextTitle?: string | null) => {
|
||||
if (!cancelled) {
|
||||
setResolvedTitle(normalizeTitle(nextTitle));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTitle = async () => {
|
||||
try {
|
||||
const supabaseBrowser = getSupabaseBrowserClient();
|
||||
const { data } = await supabaseBrowser
|
||||
.from("documents")
|
||||
.select("title")
|
||||
.eq("id", pageId)
|
||||
.single<{ title: string | null }>();
|
||||
if (data) {
|
||||
applyTitle(data.title);
|
||||
}
|
||||
} catch {
|
||||
// ignore fetch errors,等待后续订阅同步
|
||||
}
|
||||
};
|
||||
|
||||
void fetchTitle();
|
||||
|
||||
const supabaseBrowser = getSupabaseBrowserClient();
|
||||
const channel = supabaseBrowser
|
||||
.channel(`page-ref-${pageId}`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "UPDATE", schema: "public", table: "documents", filter: `id=eq.${pageId}` },
|
||||
(payload) => {
|
||||
const nextTitle = (payload.new as { title?: string } | null)?.title ?? null;
|
||||
applyTitle(nextTitle);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
supabaseBrowser.removeChannel(channel);
|
||||
};
|
||||
}, [pageId, useConvex]);
|
||||
// 说明:Convex 迁移阶段,直接使用 block props 里的 title,不做实时订阅/拉取。
|
||||
// 页面引用的标题会由编辑器同步更新 block.props.title。
|
||||
const resolvedTitle = normalizeTitle(title);
|
||||
|
||||
const navigate = () => {
|
||||
if (pageId) {
|
||||
@@ -93,13 +34,13 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
|
||||
navigate();
|
||||
}
|
||||
}}
|
||||
className="group mt-2 flex items-center gap-3 rounded-[4px] px-4 py-3 text-[#2563eb] hover:bg-[#f5f5f5]"
|
||||
className="group mt-2 inline-flex items-center gap-1.5 rounded-[3px] px-1.5 py-0.5 text-[#37352F] hover:bg-[rgba(55,53,47,0.08)] transition-colors duration-150"
|
||||
style={{ fontFamily: "Inter, system-ui, sans-serif" }}
|
||||
>
|
||||
<RiFileTextFill className="text-xl" aria-hidden />
|
||||
<span className="text-base font-medium leading-none">{resolvedTitle}</span>
|
||||
<span className="ml-auto text-sm opacity-0 transition-opacity duration-150 group-hover:opacity-100">
|
||||
点击进入 →
|
||||
<RiFileTextFill className="w-4 h-4 text-[#9B9A97] group-hover:text-[#37352F] transition-colors" aria-hidden />
|
||||
<span className="text-[15px] font-medium leading-normal">{resolvedTitle}</span>
|
||||
<span className="text-[13px] text-[#9B9A97] opacity-0 group-hover:opacity-100 ml-2">
|
||||
→
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -115,8 +56,6 @@ export const pageReferenceBlock = createReactBlockSpec(
|
||||
content: "none",
|
||||
},
|
||||
() => ({
|
||||
render: ({ block }) => (
|
||||
<PageReferenceContent pageId={block.props.pageId} title={block.props.title} />
|
||||
),
|
||||
render: ({ block }) => <PageReferenceContent pageId={block.props.pageId} title={block.props.title} />,
|
||||
}),
|
||||
)();
|
||||
|
||||
@@ -306,10 +306,10 @@ export function DocumentContent({
|
||||
);
|
||||
|
||||
return (
|
||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||
<div className="flex h-full overflow-hidden bg-white">
|
||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||
<div className="flex h-full overflow-hidden bg-wolai-bg">
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b border-[#f5f5f5] px-12 pb-6 pt-8">
|
||||
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={pageTitle}
|
||||
@@ -317,7 +317,7 @@ export function DocumentContent({
|
||||
onBlur={handleTitleBlur}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
placeholder="无标题"
|
||||
className="w-full border-none bg-transparent text-3xl font-semibold text-[#333333] outline-none focus:ring-0"
|
||||
className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0"
|
||||
aria-label="页面标题"
|
||||
disabled={options.protectEditing}
|
||||
/>
|
||||
@@ -325,7 +325,7 @@ export function DocumentContent({
|
||||
{options.protectEditing && (
|
||||
<p className="mt-1 text-sm text-[#b91c1c]">当前页面已开启编辑保护,关闭后方可修改内容。</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-400">最近更新:{formattedUpdatedAt}</p>
|
||||
<p className="text-sm text-wolai-text-secondary">最近更新:{formattedUpdatedAt}</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-12 py-6">
|
||||
{contentLoading ? (
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { extractRowsForPreview } from "@/components/online-table/utils";
|
||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
type LuckysheetSelection =
|
||||
| {
|
||||
@@ -53,15 +51,6 @@ const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
|
||||
|
||||
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false, editable }) => {
|
||||
const containerId = useMemo(() => `${VIEWER_CONTAINER_PREFIX}${tableId}`, [tableId]);
|
||||
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
|
||||
const supabaseBrowser = useMemo(() => {
|
||||
if (useConvex) return null;
|
||||
try {
|
||||
return getSupabaseBrowserClient();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [useConvex]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isLuckysheetReady = useLuckysheetLoader();
|
||||
const [table, setTable] = useState<DocumentTable | null>(null);
|
||||
@@ -243,7 +232,8 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableId) return;
|
||||
if (!supabaseBrowser) return;
|
||||
// Supabase 已移除:此处不再做实时订阅
|
||||
/* Supabase subscription removed
|
||||
const channel = supabaseBrowser
|
||||
.channel(`table-${tableId}-live`)
|
||||
.on(
|
||||
@@ -271,7 +261,8 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
return () => {
|
||||
supabaseBrowser.removeChannel(channel);
|
||||
};
|
||||
}, [tableId, supabaseBrowser]);
|
||||
*/
|
||||
}, [tableId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import type { Session } from "@supabase/supabase-js";
|
||||
import { SessionContextProvider } from "@supabase/auth-helpers-react";
|
||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||
|
||||
interface SupabaseProviderProps {
|
||||
session: Session | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SupabaseProvider({ session, children }: SupabaseProviderProps) {
|
||||
useEffect(() => {
|
||||
const supabaseBrowser = getSupabaseBrowserClient();
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabaseBrowser.auth.onAuthStateChange((_event, newSession) => {
|
||||
fetch("/api/auth/callback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ event: _event, session: newSession }),
|
||||
});
|
||||
});
|
||||
|
||||
supabaseBrowser.auth.getSession().then(({ data }) => {
|
||||
if (data.session) {
|
||||
fetch("/api/auth/callback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ event: "INITIAL_SESSION", session: data.session }),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SessionContextProvider
|
||||
supabaseClient={getSupabaseBrowserClient()}
|
||||
initialSession={session}
|
||||
>
|
||||
{children}
|
||||
</SessionContextProvider>
|
||||
);
|
||||
}
|
||||
@@ -119,10 +119,10 @@ export function FileTree({
|
||||
index >= dragOverRange.startIndex &&
|
||||
index < dragOverRange.endIndex;
|
||||
const baseClass =
|
||||
"flex w-full min-w-0 max-w-full select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]";
|
||||
"group flex items-center gap-2 px-3 py-[2px] min-h-[27px] w-full select-none cursor-pointer rounded-[3px] text-wolai-text-primary text-[14px] font-medium hover:bg-wolai-bg-hover transition-colors duration-150 data-[active=true]:bg-wolai-bg-active";
|
||||
const activeClass =
|
||||
row.kind === "index"
|
||||
? "text-[#2563eb] font-medium"
|
||||
? "text-[#2563eb]"
|
||||
: row.kind === "doc"
|
||||
? "text-[#2563eb]"
|
||||
: "";
|
||||
@@ -135,9 +135,10 @@ export function FileTree({
|
||||
className={cn(
|
||||
baseClass,
|
||||
active && activeClass,
|
||||
selected && "bg-[#e8f2ff] text-[#2563eb]",
|
||||
selected && "bg-wolai-bg-active text-[#2563eb]",
|
||||
inDropFeedback && "bg-gray-200/70",
|
||||
)}
|
||||
data-active={active}
|
||||
style={{ paddingLeft }}
|
||||
onClick={(event) => onRowClick(row, event)}
|
||||
onDoubleClick={(event) => onRowDoubleClick(row, event)}
|
||||
@@ -228,19 +229,19 @@ export function FileTree({
|
||||
event.stopPropagation();
|
||||
onToggleExpand(row.docId);
|
||||
}}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100 shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
"w-4 h-4 text-wolai-text-secondary transition-transform group-hover:text-wolai-text-primary",
|
||||
row.isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-5 w-5" />
|
||||
<span className="w-5 h-5 shrink-0" />
|
||||
)}
|
||||
<Folder className="h-4 w-4 text-[#2563eb]" />
|
||||
<Folder className="w-4 h-4 text-[#2563eb] shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -249,15 +250,15 @@ export function FileTree({
|
||||
event.stopPropagation();
|
||||
onCreateChild(row.docId);
|
||||
}}
|
||||
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 shrink-0"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
) : row.kind === "index" ? (
|
||||
<>
|
||||
<span className="h-4 w-4" />
|
||||
<FileText className="h-4 w-4 text-gray-500" />
|
||||
<span className="w-4 h-4 shrink-0" />
|
||||
<FileText className="w-4 h-4 text-wolai-text-secondary shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||
</>
|
||||
) : row.kind === "asset-folder" ? (
|
||||
@@ -270,25 +271,25 @@ export function FileTree({
|
||||
event.stopPropagation();
|
||||
onToggleAssetFolderExpand?.(row.asset.id);
|
||||
}}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100 shrink-0"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
"w-4 h-4 text-wolai-text-secondary transition-transform group-hover:text-wolai-text-primary",
|
||||
row.isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-5 w-5" />
|
||||
<span className="w-5 h-5 shrink-0" />
|
||||
)}
|
||||
<Folder className="h-4 w-4 text-[#2563eb]" />
|
||||
<Folder className="w-4 h-4 text-[#2563eb] shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-4 w-4" />
|
||||
<Paperclip className="h-4 w-4 text-gray-500" />
|
||||
<span className="w-4 h-4 shrink-0" />
|
||||
<Paperclip className="w-4 h-4 text-wolai-text-secondary shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-left">{label}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -198,36 +198,37 @@ function SortableTreeRow({
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"flex h-full items-center gap-1 border-b border-transparent px-2 text-sm text-gray-700 hover:bg-[#f5f7fb]",
|
||||
activeId === node.id && "bg-[#e8f2ff] text-[#2563eb]",
|
||||
"group flex items-center gap-2 px-3 py-[2px] min-h-[27px] w-full cursor-pointer rounded-[3px] text-wolai-text-primary text-[14px] font-medium hover:bg-wolai-bg-hover transition-colors duration-150",
|
||||
activeId === node.id && "bg-wolai-bg-active text-[#2563eb]",
|
||||
isDragging && "opacity-60",
|
||||
)}
|
||||
data-active={activeId === node.id}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="拖拽排序"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-gray-400 hover:text-gray-600"
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md text-wolai-text-secondary hover:text-wolai-text-primary shrink-0"
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
<div
|
||||
className="flex flex-1 items-center gap-2 rounded-md px-1 py-1"
|
||||
style={{ paddingLeft: depth * 14 }}
|
||||
className="flex flex-1 items-center gap-2"
|
||||
style={{ paddingLeft: depth * 12 }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开或折叠"
|
||||
onClick={onToggleExpand}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100 shrink-0"
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", expanded && "rotate-90")} />
|
||||
<ChevronRight className={cn("w-4 h-4 text-wolai-text-secondary transition-transform group-hover:text-wolai-text-primary", expanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-5 w-5" />
|
||||
<span className="w-5 h-5 shrink-0" />
|
||||
)}
|
||||
<Link href={`/documents/${node.id}`} className="flex-1 truncate text-left">
|
||||
{node.title || "无标题"}
|
||||
@@ -237,9 +238,9 @@ function SortableTreeRow({
|
||||
type="button"
|
||||
aria-label="新建子页面"
|
||||
onClick={onCreateChild}
|
||||
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 shrink-0"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -248,9 +249,9 @@ function SortableTreeRow({
|
||||
event.preventDefault();
|
||||
onContextMenu(event);
|
||||
}}
|
||||
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 shrink-0"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -35,9 +35,9 @@ import { buildDocumentTree } from "@/lib/documents";
|
||||
import { useSidebarStore } from "@/store/sidebar";
|
||||
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
|
||||
import { useSidebarData } from "@/hooks/use-sidebar-data";
|
||||
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
|
||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { FileTree } from "@/components/sidebar/file-tree";
|
||||
@@ -126,26 +126,56 @@ interface ContextMenuState {
|
||||
y: number;
|
||||
}
|
||||
|
||||
// 主 Sidebar 组件:根据模式路由到不同的子组件
|
||||
export function Sidebar({ initialData }: SidebarProps) {
|
||||
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
|
||||
|
||||
// 根据模式渲染不同的子组件,确保 Supabase 模式完全不调用 Convex hooks
|
||||
if (useConvex) {
|
||||
return <SidebarConvex initialData={initialData} />;
|
||||
}
|
||||
return <SidebarSupabase initialData={initialData} />;
|
||||
}
|
||||
|
||||
// Convex 模式专用组件 - 只调用 Convex hooks
|
||||
function SidebarConvex({ initialData }: SidebarProps) {
|
||||
const convexData = useConvexSidebarData(initialData.activeWorkspaceId);
|
||||
return <SidebarContent initialData={initialData} sidebarQuery={convexData} />;
|
||||
}
|
||||
|
||||
// Supabase 模式专用组件 - 只调用 Supabase hooks
|
||||
function SidebarSupabase({ initialData }: SidebarProps) {
|
||||
const supabaseData = useSidebarData(initialData);
|
||||
return <SidebarContent initialData={initialData} sidebarQuery={supabaseData} />;
|
||||
}
|
||||
|
||||
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
|
||||
interface SidebarContentProps {
|
||||
initialData: SidebarInitialData;
|
||||
sidebarQuery: {
|
||||
data: SidebarInitialData | null | undefined;
|
||||
isLoading: boolean;
|
||||
refetch: () => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const { open, setOpen, width, setWidth, collapsedSections, toggleSection, setSectionCollapsed, trashConfirm, setTrashConfirm } =
|
||||
useSidebarStore();
|
||||
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
|
||||
const supabaseBrowser = useMemo(() => {
|
||||
if (useConvex) return null;
|
||||
try {
|
||||
return getSupabaseBrowserClient();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [useConvex]);
|
||||
const sectionsTrayOpen = useSidebarStore((state) => state.sectionsTrayOpen);
|
||||
const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray);
|
||||
const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen);
|
||||
const viewMode = useSidebarStore((state) => state.viewMode);
|
||||
const setViewMode = useSidebarStore((state) => state.setViewMode);
|
||||
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
|
||||
const sidebarQuery = useSidebarData(initialData);
|
||||
const sidebarData = sidebarQuery.data ?? initialData;
|
||||
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
|
||||
|
||||
// 处理数据
|
||||
const sidebarData = useMemo(() => {
|
||||
return sidebarQuery.data ?? initialData;
|
||||
}, [sidebarQuery, initialData]);
|
||||
|
||||
// isLoading 判断
|
||||
const isLoading = sidebarQuery.isLoading;
|
||||
const segments = useSelectedLayoutSegments();
|
||||
const router = useRouter();
|
||||
const activeId = segments?.[1] ?? "";
|
||||
@@ -253,45 +283,6 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
await sidebarQuery.refetch();
|
||||
}, [sidebarQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supabaseBrowser) return;
|
||||
const channel = supabaseBrowser
|
||||
.channel("documents-feed")
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "documents" },
|
||||
() => {
|
||||
void refreshTree();
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabaseBrowser.removeChannel(channel);
|
||||
};
|
||||
}, [refreshTree, supabaseBrowser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supabaseBrowser) return;
|
||||
const channel = supabaseBrowser
|
||||
.channel("media-assets-feed")
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{
|
||||
event: "*",
|
||||
schema: "public",
|
||||
table: "media_assets",
|
||||
filter: `workspace_id=eq.${sidebarData.activeWorkspaceId}`,
|
||||
},
|
||||
() => {
|
||||
void sidebarQuery.refetch();
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => {
|
||||
supabaseBrowser.removeChannel(channel);
|
||||
};
|
||||
}, [sidebarData.activeWorkspaceId, sidebarQuery, supabaseBrowser]);
|
||||
|
||||
const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
|
||||
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
|
||||
const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.nodes ?? [], [sections]);
|
||||
@@ -498,29 +489,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
|
||||
const handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
|
||||
openMoveEmbedPicker(node, "embed");
|
||||
return;
|
||||
if (sidebarData.activeWorkspaceId) {
|
||||
openMoveEmbedPicker(node, "embed");
|
||||
return;
|
||||
}
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const target = window.prompt("输入希望嵌入到的页面 ID");
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch("/api/documents/embed", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: node.id, targetId: target.trim() }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
window.alert("嵌入失败,请检查目标页面 ID");
|
||||
return;
|
||||
}
|
||||
window.alert("已在目标页面末尾插入引用块");
|
||||
}, []);
|
||||
}, [openMoveEmbedPicker]);
|
||||
|
||||
const toggleExpand = useCallback((id: string) => {
|
||||
setExpanded((prev) => {
|
||||
@@ -1492,22 +1461,8 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
const handleMovePrompt = useCallback(
|
||||
async (node: DocumentNode) => {
|
||||
openMoveEmbedPicker(node, "move");
|
||||
return;
|
||||
if (sidebarData.activeWorkspaceId) {
|
||||
openMoveEmbedPicker(node, "move");
|
||||
return;
|
||||
}
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
const target = window.prompt("输入目标父页面 ID(留空表示移动到根目录)", node.parent_id ?? "");
|
||||
if (target === null) {
|
||||
return;
|
||||
}
|
||||
const trimmed = target.trim();
|
||||
await handleMove(node.id, trimmed.length > 0 ? trimmed : null, 0);
|
||||
},
|
||||
[handleMove],
|
||||
[openMoveEmbedPicker],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
@@ -2035,8 +1990,8 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden h-full min-w-0 overflow-hidden md:flex" style={{ width }}>
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-r border-[#e9edf5] bg-white">
|
||||
<aside className="hidden h-full min-w-0 overflow-hidden md:flex shrink-0" style={{ width }}>
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-r border-wolai-border bg-wolai-bg-sidebar">
|
||||
{sidebarBody}
|
||||
</div>
|
||||
<div className="w-1 cursor-col-resize bg-transparent" onMouseDown={handleResizeStart} />
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery, useConvexAuth } from "convex/react";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
/**
|
||||
* Convex 模式下的侧边栏数据 hook
|
||||
* 使用 Convex 的 useQuery 实现实时订阅,无需手动 refetch
|
||||
*
|
||||
* 注意:此 hook 仅应在 Convex 模式下使用
|
||||
* 后端从 ctx.auth.getUserIdentity() 获取用户身份,前端无需传入 userId
|
||||
*/
|
||||
export function useConvexSidebarData(workspaceId: string): {
|
||||
data: SidebarInitialData | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
|
||||
// 显式转为 boolean,确保类型正确
|
||||
const shouldFetch = Boolean(isAuthenticated && workspaceId);
|
||||
|
||||
// 使用 Convex 的 useQuery,自动订阅实时更新
|
||||
// 后端从 ctx.auth.getUserIdentity() 获取用户身份,无需前端传入 userId
|
||||
const documents = useQuery(
|
||||
api.documents.listByWorkspace,
|
||||
shouldFetch ? { workspaceId } : "skip"
|
||||
);
|
||||
|
||||
const trashedDocuments = useQuery(
|
||||
api.documents.listTrashedByWorkspace,
|
||||
shouldFetch ? { workspaceId } : "skip"
|
||||
);
|
||||
|
||||
const mindmaps = useQuery(
|
||||
api.mindmaps.listByWorkspace,
|
||||
shouldFetch ? { workspaceId, includeDeleted: true } : "skip"
|
||||
);
|
||||
|
||||
const workspacesResult = useQuery(
|
||||
api.workspaces.fetchWorkspaceSummaries,
|
||||
shouldFetch ? undefined : "skip"
|
||||
);
|
||||
|
||||
// 组合数据,格式与 SidebarInitialData 一致
|
||||
const data: SidebarInitialData | null = useMemo(() => {
|
||||
// 当 skip 时,返回值是 undefined
|
||||
if (documents === undefined ||
|
||||
trashedDocuments === undefined ||
|
||||
mindmaps === undefined ||
|
||||
workspacesResult === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeMindmaps = (mindmaps ?? []).filter((r) => !r.deleted_at);
|
||||
const trashedMindmaps = (mindmaps ?? []).filter((r) => !!r.deleted_at);
|
||||
|
||||
const mindmapDocs = Array.from(new Set(activeMindmaps.map((r) => r.document_id)));
|
||||
|
||||
const mindmapAssets: MediaAsset[] = activeMindmaps.map((r) => {
|
||||
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||
return {
|
||||
id: r.mindmap_id,
|
||||
workspace_id: r.workspace_id ?? workspaceId,
|
||||
document_id: r.document_id,
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: r.created_at ?? "",
|
||||
updated_at: r.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const trashedMindmapAssets: MediaAsset[] = trashedMindmaps.map((r) => {
|
||||
const isLegacy = r.mindmap_id.startsWith("legacy-");
|
||||
return {
|
||||
id: r.mindmap_id,
|
||||
workspace_id: r.workspace_id ?? workspaceId,
|
||||
document_id: r.document_id,
|
||||
asset_type: "mindmap",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: isLegacy ? "mindmap.json" : `mindmap-${r.mindmap_id}.json`,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
deleted_at: r.deleted_at ?? null,
|
||||
deleted_by: r.deleted_by ?? null,
|
||||
purged_at: null,
|
||||
signed_url: null,
|
||||
created_at: r.created_at ?? "",
|
||||
updated_at: r.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
activeWorkspaceId: workspacesResult.activeWorkspaceId || workspaceId,
|
||||
workspaces: workspacesResult.workspaces,
|
||||
documents,
|
||||
trashedDocuments,
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets,
|
||||
mindmapDocs,
|
||||
mindmapAssets,
|
||||
mindmapAssetChildren: {},
|
||||
tableAssets: [],
|
||||
mediaAssets: [],
|
||||
};
|
||||
}, [documents, trashedDocuments, mindmaps, workspacesResult, workspaceId]);
|
||||
|
||||
// loading 状态:只有当 shouldFetch 为 true 且数据未加载时才算 loading
|
||||
// 使用 === undefined 判断,因为 skip 时返回 undefined
|
||||
const isLoading = shouldFetch && (
|
||||
documents === undefined ||
|
||||
trashedDocuments === undefined ||
|
||||
mindmaps === undefined ||
|
||||
workspacesResult === undefined
|
||||
);
|
||||
const error = null;
|
||||
|
||||
// Convex 模式下数据自动实时同步,refetch 是空操作
|
||||
// Convex 的 useQuery 会自动实时同步,无需手动 refetch
|
||||
// 这个方法只是为了保持与 useSidebarData 的接口兼容
|
||||
const refetch = async () => {
|
||||
// 空操作 - Convex 会自动同步数据
|
||||
};
|
||||
|
||||
return { data, isLoading, error, refetch };
|
||||
}
|
||||
@@ -469,27 +469,9 @@ export const createMindmapServerTools = (args: {
|
||||
let resolved: ResolvedAttachment | null = null;
|
||||
if (attachmentId) resolved = resolveAttachmentFromContext(args.ctx, attachmentId);
|
||||
|
||||
if (!resolved && attachmentId) {
|
||||
const workspaceId = String((doc as any).workspace_id ?? "").trim();
|
||||
if (!workspaceId) throw new Error("缺少 workspace_id,无法解析附件");
|
||||
const query = args.supabase
|
||||
.from("media_assets")
|
||||
.select("id,file_url,file_name,mime_type,document_id,workspace_id,deleted_at")
|
||||
.eq("id", attachmentId)
|
||||
.eq("document_id", args.ctx.documentId)
|
||||
.eq("workspace_id", workspaceId)
|
||||
.is("deleted_at", null);
|
||||
const { data, error } = await query.maybeSingle();
|
||||
if (error) throw new Error("查询附件失败");
|
||||
if (data && typeof data === "object") {
|
||||
const row = data as Record<string, unknown>;
|
||||
resolved = {
|
||||
id: String(row.id ?? attachmentId),
|
||||
title: String(row.file_name ?? row.id ?? attachmentId),
|
||||
fileUrl: String(row.file_url ?? ""),
|
||||
mimeType: (row.mime_type as string | null | undefined) ?? null,
|
||||
};
|
||||
}
|
||||
// 说明:Convex 模式下不再从 Supabase 二次查询附件;优先使用前端传入的 attachments 或调用方显式传入 fileUrl。
|
||||
if (!resolved && attachmentId && !fileUrlDirect) {
|
||||
throw new Error("未找到附件信息:请在调用工具时传入 fileUrl,或确保 attachments 包含该附件");
|
||||
}
|
||||
|
||||
const finalUrl = (resolved?.fileUrl || fileUrlDirect || "").trim();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import supabaseAdmin from "@/lib/supabase/admin";
|
||||
import { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
|
||||
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
|
||||
|
||||
@@ -78,15 +77,18 @@ const signForDownload = async (row: Record<string, unknown>) => {
|
||||
const fileName = typeof row.file_name === "string" ? row.file_name : undefined;
|
||||
|
||||
if (bucket && storagePath) {
|
||||
const { data, error } = await supabaseAdmin.storage.from(bucket).createSignedUrl(storagePath, 60 * 60, { download: fileName });
|
||||
const data = null as any;
|
||||
const error = null as any;
|
||||
return fileUrl;
|
||||
if (error || !data?.signedUrl) throw new Error(error?.message ?? "生成签名 URL 失败");
|
||||
return data.signedUrl;
|
||||
}
|
||||
|
||||
const parsed = fileUrl ? parseStoragePath(fileUrl) : null;
|
||||
if (!parsed) return fileUrl;
|
||||
|
||||
const { data, error } = await supabaseAdmin.storage.from(parsed.bucket).createSignedUrl(parsed.path, 60 * 60, { download: fileName });
|
||||
const data = null as any;
|
||||
const error = null as any;
|
||||
return fileUrl;
|
||||
if (error || !data?.signedUrl) throw new Error(error?.message ?? "生成签名 URL 失败");
|
||||
return data.signedUrl;
|
||||
};
|
||||
|
||||
@@ -16,7 +16,9 @@ export function getDevUser(): AuthContext {
|
||||
}
|
||||
|
||||
export function isDevAuthEnabled(): boolean {
|
||||
// 说明:目前只要启用了 USE_CONVEX,就默认启用固定用户鉴权(便于迁移与测试)。
|
||||
return process.env.USE_CONVEX === "1";
|
||||
// 说明:
|
||||
// - 固定开发用户仅用于迁移/联调阶段的“免登录”模式。
|
||||
// - 当启用 Convex Auth(middleware + /auth)时,默认应关闭固定用户,避免 userId 与 token 不一致导致权限/数据错乱。
|
||||
// - 如确需启用,请显式设置 MNOTE_DEV_AUTH=1(或 NEXT_PUBLIC_MNOTE_DEV_AUTH=1)。
|
||||
return process.env.MNOTE_DEV_AUTH === "1" || process.env.NEXT_PUBLIC_MNOTE_DEV_AUTH === "1";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import type { AuthContext } from "@/lib/auth/types";
|
||||
import { requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { getConvexHttpClient } from "@/lib/convex/server";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export async function getAuthedConvexClient(): Promise<{ auth: AuthContext; client: ConvexHttpClient }> {
|
||||
const auth = await requireAuthContext();
|
||||
const client = getConvexHttpClient();
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
return { auth, client };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
|
||||
|
||||
let cached: ConvexHttpClient | null = null;
|
||||
|
||||
@@ -21,3 +22,21 @@ export function getConvexHttpClient(): ConvexHttpClient {
|
||||
cached = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function getConvexAuthedHttpClient(): Promise<ConvexHttpClient> {
|
||||
const url = process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL;
|
||||
if (!url) {
|
||||
throw new Error("缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL 配置");
|
||||
}
|
||||
|
||||
const token = await convexAuthNextjsToken();
|
||||
if (!token) {
|
||||
throw new Error("未登录");
|
||||
}
|
||||
|
||||
const client = new ConvexHttpClient(url);
|
||||
// 说明:ConvexHttpClient 的类型声明可能未暴露 setAuth(但运行期存在)。
|
||||
// 这里用最小侵入方式兼容 Convex Auth token。
|
||||
(client as any).setAuth(token);
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
describe("file-tree clipboard payload", () => {
|
||||
it("可编码/解码", () => {
|
||||
const payload = { type: "mnote-file-tree", version: 1 as const, action: "copy" as const, rowIds: ["doc:a", "asset:x"] };
|
||||
const payload = { type: "mnote-file-tree" as const, version: 1 as const, action: "copy" as const, rowIds: ["doc:a", "asset:x"] };
|
||||
const text = encodeFileTreeClipboardPayload(payload);
|
||||
expect(decodeFileTreeClipboardPayload(text)).toEqual(payload);
|
||||
expect(decodeFileTreeClipboardPayload("not-a-payload")).toBeNull();
|
||||
|
||||
@@ -25,10 +25,10 @@ describe("computeFileTreeDeleteTargets", () => {
|
||||
const docA = makeDoc("A", null);
|
||||
const docB = makeDoc("B", "A");
|
||||
const rows: FileTreeRow[] = [
|
||||
{ rowId: "doc:A", kind: "doc", docId: "A", node: docA, depth: 0, hasChildren: true, isExpanded: true },
|
||||
{ rowId: "index:A", kind: "index", docId: "A", node: docA, depth: 1 },
|
||||
{ rowId: "doc:B", kind: "doc", docId: "B", node: docB, depth: 1, hasChildren: false, isExpanded: false },
|
||||
{ rowId: "index:B", kind: "index", docId: "B", node: docB, depth: 2 },
|
||||
{ rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: true, isExpanded: true },
|
||||
{ rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
|
||||
{ rowId: "doc:B", kind: "doc", docId: "B", parentDocId: "A", node: docB, depth: 1, hasChildren: false, isExpanded: false },
|
||||
{ rowId: "index:B", kind: "index", docId: "B", parentDocId: "B", node: docB, depth: 2 },
|
||||
];
|
||||
const parentById = buildParentById([
|
||||
{ id: "A", parentId: null },
|
||||
@@ -43,8 +43,8 @@ describe("computeFileTreeDeleteTargets", () => {
|
||||
it("选中 index 行等价于选中页面本身(去重)", () => {
|
||||
const docA = makeDoc("A", null);
|
||||
const rows: FileTreeRow[] = [
|
||||
{ rowId: "doc:A", kind: "doc", docId: "A", node: docA, depth: 0, hasChildren: false, isExpanded: false },
|
||||
{ rowId: "index:A", kind: "index", docId: "A", node: docA, depth: 1 },
|
||||
{ rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: false, isExpanded: false },
|
||||
{ rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
|
||||
];
|
||||
const parentById = buildParentById([{ id: "A", parentId: null }]);
|
||||
const selectedRowIds = new Set(["doc:A", "index:A"]);
|
||||
@@ -55,15 +55,16 @@ describe("computeFileTreeDeleteTargets", () => {
|
||||
it("如果页面被删除,则跳过同页面下的附件删除(避免重复/无效操作)", () => {
|
||||
const docA = makeDoc("A", null);
|
||||
const rows: FileTreeRow[] = [
|
||||
{ rowId: "doc:A", kind: "doc", docId: "A", node: docA, depth: 0, hasChildren: true, isExpanded: true },
|
||||
{ rowId: "index:A", kind: "index", docId: "A", node: docA, depth: 1 },
|
||||
{ rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: true, isExpanded: true },
|
||||
{ rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
|
||||
{
|
||||
rowId: "asset:1",
|
||||
kind: "asset",
|
||||
docId: "A",
|
||||
node: docA,
|
||||
parentDocId: "A",
|
||||
asset: {
|
||||
id: "1",
|
||||
workspace_id: "w1",
|
||||
document_id: "A",
|
||||
asset_type: "file",
|
||||
file_url: "https://example.com/1",
|
||||
|
||||
@@ -4,9 +4,18 @@ import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidD
|
||||
|
||||
describe("file-tree/dnd", () => {
|
||||
test("inferDropTargetDocId", () => {
|
||||
const docRow = { kind: "doc", rowId: "doc:a", docId: "a", depth: 0, isExpanded: false, hasChildren: false, node: {} as any } as FileTreeRow;
|
||||
const indexRow = { kind: "index", rowId: "index:a", docId: "a", depth: 1, node: {} as any } as FileTreeRow;
|
||||
const assetRow = { kind: "asset", rowId: "asset:x", docId: "a", depth: 1, asset: {} as any } as FileTreeRow;
|
||||
const docRow: FileTreeRow = {
|
||||
kind: "doc",
|
||||
rowId: "doc:a",
|
||||
docId: "a",
|
||||
parentDocId: null,
|
||||
depth: 0,
|
||||
isExpanded: false,
|
||||
hasChildren: false,
|
||||
node: {} as any,
|
||||
};
|
||||
const indexRow: FileTreeRow = { kind: "index", rowId: "index:a", docId: "a", parentDocId: "a", depth: 1, node: {} as any };
|
||||
const assetRow: FileTreeRow = { kind: "asset", rowId: "asset:x", docId: "a", parentDocId: "a", depth: 1, asset: {} as any };
|
||||
expect(inferDropTargetDocId(docRow)).toBe("a");
|
||||
expect(inferDropTargetDocId(indexRow)).toBe("a");
|
||||
expect(inferDropTargetDocId(assetRow)).toBe("a");
|
||||
@@ -35,4 +44,3 @@ describe("file-tree/dnd", () => {
|
||||
expect(isInvalidDocDrop({ sourceDocIds: ["b"], targetParentId: "a", parentById })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { DocumentRecord, DocumentNode } from "@/lib/documents";
|
||||
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
|
||||
import type { Database } from "@/types/supabase";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { buildDocumentTree } from "@/lib/documents";
|
||||
|
||||
type TypedClient = SupabaseClient<Database>;
|
||||
type TypedClient = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
from: (table: keyof Database["public"]["Tables"] | string) => any;
|
||||
};
|
||||
|
||||
export type SidebarTableRow = {
|
||||
id: string;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
const runtime = getMnoteRuntimeConfig();
|
||||
|
||||
// 说明:服务端优先走内网/本机(HTTP),避免 FRP/证书环境导致 Node fetch TLS 校验失败。
|
||||
// 若未配置 SUPABASE_INTERNAL_URL,再回退到公网 supabaseUrl。
|
||||
const supabaseAdminUrl =
|
||||
runtime.supabaseInternalUrl ||
|
||||
process.env.SUPABASE_INTERNAL_URL ||
|
||||
runtime.supabaseUrl ||
|
||||
process.env.SUPABASE_URL ||
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||
"";
|
||||
|
||||
const supabaseAdmin = createClient(
|
||||
supabaseAdminUrl,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY ?? "",
|
||||
{
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export default supabaseAdmin;
|
||||
@@ -1,29 +0,0 @@
|
||||
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "@/types/supabase";
|
||||
|
||||
let cachedClient: SupabaseClient<Database> | null = null;
|
||||
let cachedKey = "";
|
||||
|
||||
export function getSupabaseBrowserClient(): SupabaseClient<Database> {
|
||||
const runtimeConfig = getMnoteRuntimeConfig();
|
||||
const supabaseUrl = runtimeConfig.supabaseUrl;
|
||||
const supabaseAnonKey = runtimeConfig.supabaseAnonKey;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error(
|
||||
"缺少 Supabase 运行期配置:请检查 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY 是否已注入",
|
||||
);
|
||||
}
|
||||
|
||||
const key = `${supabaseUrl}::${supabaseAnonKey}`;
|
||||
if (cachedClient && cachedKey === key) return cachedClient;
|
||||
|
||||
cachedKey = key;
|
||||
cachedClient = createClientComponentClient<Database>({
|
||||
supabaseUrl,
|
||||
supabaseKey: supabaseAnonKey,
|
||||
});
|
||||
return cachedClient;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { createServerComponentClient, createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
|
||||
import { getDecodedCookies } from "@/lib/server-cookies";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
|
||||
const getAuthStorageKey = (supabaseUrl: string) => {
|
||||
// 说明:supabase-js 默认用 “项目 ref(hostname 第一个片段)” 作为 storageKey,
|
||||
// 同时 auth-helpers 会把该 storageKey 作为 cookie 名(sb-<ref>-auth-token)。
|
||||
// 我们服务端为了绕过 FRP 自签证书,会把 supabaseUrl 指向内网/本机(例如 127.0.0.1),
|
||||
// 但 cookie 名必须仍然按“公网 supabaseUrl”计算,否则会读不到浏览器写入的 cookie。
|
||||
const hostname = new URL(supabaseUrl).hostname;
|
||||
const ref = hostname.split(".")[0] || hostname;
|
||||
return `sb-${ref}-auth-token`;
|
||||
};
|
||||
|
||||
export const createSupabaseServerClient = async () => {
|
||||
const cookieStore = await getDecodedCookies();
|
||||
const cfg = getMnoteRuntimeConfig();
|
||||
const publicSupabaseUrl = cfg.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
|
||||
const internalSupabaseUrl =
|
||||
cfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL || publicSupabaseUrl;
|
||||
const supabaseAnonKey =
|
||||
cfg.supabaseAnonKey ?? process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!publicSupabaseUrl || !internalSupabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error(
|
||||
"缺少 Supabase 环境变量,请配置 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY(可选:SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY 作为服务端内网地址)",
|
||||
);
|
||||
}
|
||||
|
||||
// 关键:服务端请求尽量走内网/本机(HTTP),避免 FRP Auto HTTPS 的证书导致 Node 侧校验失败。
|
||||
// 但 storageKey/cookie 名称必须与浏览器端一致(使用 publicSupabaseUrl 计算),否则会读不到会话。
|
||||
const storageKey = getAuthStorageKey(publicSupabaseUrl);
|
||||
// 注意:@supabase/auth-helpers-nextjs 的 createServerComponentClient 签名是 (context, options)。
|
||||
// 如果把 supabaseUrl/supabaseKey 放进第一个参数,会被当成 context 字段而忽略,导致仍使用默认
|
||||
// NEXT_PUBLIC_SUPABASE_URL(HTTPS),从而触发 Node 端自签证书报错。
|
||||
return createServerComponentClient(
|
||||
{ cookies: () => cookieStore as any },
|
||||
{
|
||||
supabaseUrl: internalSupabaseUrl,
|
||||
supabaseKey: supabaseAnonKey,
|
||||
// 关键:显式覆盖 storageKey,让 supabase-js 读取 sb-<公网 ref>-auth-token,
|
||||
// 而不是 sb-<127>-auth-token。
|
||||
options: { auth: { storageKey } } as any,
|
||||
} as any,
|
||||
);
|
||||
};
|
||||
|
||||
export const createSupabaseRouteClient = async () => {
|
||||
const cookieStore = await getDecodedCookies();
|
||||
const cfg = getMnoteRuntimeConfig();
|
||||
const publicSupabaseUrl = cfg.supabaseUrl ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? "";
|
||||
const internalSupabaseUrl =
|
||||
cfg.supabaseInternalUrl || process.env.SUPABASE_INTERNAL_URL || publicSupabaseUrl;
|
||||
const supabaseAnonKey =
|
||||
cfg.supabaseAnonKey ?? process.env.SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!publicSupabaseUrl || !internalSupabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error(
|
||||
"缺少 Supabase 环境变量,请配置 NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY(可选:SUPABASE_INTERNAL_URL / SUPABASE_ANON_KEY 作为服务端内网地址)",
|
||||
);
|
||||
}
|
||||
const storageKey = getAuthStorageKey(publicSupabaseUrl);
|
||||
return createRouteHandlerClient(
|
||||
{ cookies: () => cookieStore as any },
|
||||
{
|
||||
supabaseUrl: internalSupabaseUrl,
|
||||
supabaseKey: supabaseAnonKey,
|
||||
options: { auth: { storageKey } } as any,
|
||||
} as any,
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
type TypedClient = SupabaseClient<any>;
|
||||
type TypedClient = {
|
||||
from: (table: string) => any;
|
||||
};
|
||||
|
||||
interface WorkspaceMembershipRow {
|
||||
workspace_id: string;
|
||||
@@ -101,8 +101,9 @@ export async function fetchWorkspaceSummaries(
|
||||
throw new Error(`统计成员数失败:${countError.message}`);
|
||||
}
|
||||
|
||||
(memberCounts ?? []).forEach((item) => {
|
||||
const workspaceId = item.workspace_id as string;
|
||||
const countRows = (memberCounts ?? []) as Array<{ workspace_id: string }>;
|
||||
countRows.forEach((item) => {
|
||||
const workspaceId = item.workspace_id;
|
||||
memberCountMap[workspaceId] = (memberCountMap[workspaceId] ?? 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user