0.3.1 UI修复

This commit is contained in:
liaibo
2026-01-18 19:01:31 +08:00
parent 90a38b48cf
commit 8a5b915002
86 changed files with 1160 additions and 2867 deletions
-2
View File
@@ -10,8 +10,6 @@ class Settings(BaseSettings):
environment: str = Field("development", env="RAG_GATEWAY_ENV") environment: str = Field("development", env="RAG_GATEWAY_ENV")
lightrag_url: str = Field("http://127.0.0.1:7777", env="LIGHTRAG_URL") lightrag_url: str = Field("http://127.0.0.1:7777", env="LIGHTRAG_URL")
lightrag_api_key: str = Field("", env="LIGHTRAG_API_KEY") lightrag_api_key: str = Field("", env="LIGHTRAG_API_KEY")
supabase_rest_url: str = Field("http://127.0.0.1:54321/rest/v1", env="SUPABASE_REST_URL")
supabase_key: str = Field("", env="SUPABASE_SERVICE_ROLE_KEY")
embedding_base_url: str = Field("http://127.0.0.1:11434/v1", env="EMBEDDING_BASE_URL") embedding_base_url: str = Field("http://127.0.0.1:11434/v1", env="EMBEDDING_BASE_URL")
embedding_model: str = Field("qwen3-embedding:8b", env="EMBEDDING_MODEL") embedding_model: str = Field("qwen3-embedding:8b", env="EMBEDDING_MODEL")
+116 -35
View File
@@ -1,11 +1,54 @@
import { mutation, query } from "./_generated/server"; import { internalQuery, mutation, query } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
import { nowIso } from "./_utils/time"; import { nowIso } from "./_utils/time";
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs"; import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public")); 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({ 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() }, args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const doc = await ctx.db const doc = await ctx.db
@@ -38,6 +81,21 @@ export const getMeta = query({
}); });
export const getContent = 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() }, args: { userId: v.string(), id: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const doc = await ctx.db const doc = await ctx.db
@@ -51,8 +109,10 @@ export const getContent = query({
}); });
export const listByWorkspace = query({ export const listByWorkspace = query({
args: { userId: v.string(), workspaceId: v.string() }, args: { workspaceId: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const docs = await ctx.db const docs = await ctx.db
.query("documents") .query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
@@ -60,7 +120,7 @@ export const listByWorkspace = query({
// 说明:阶段 4 先不做垃圾桶(deleted_at != null),因此这里直接过滤。 // 说明:阶段 4 先不做垃圾桶(deleted_at != null),因此这里直接过滤。
return docs return docs
.filter((d) => d.user_id === args.userId) .filter((d) => d.user_id === userId)
.filter((d) => d.deleted_at == null) .filter((d) => d.deleted_at == null)
.map((d) => ({ .map((d) => ({
access_scope: d.access_scope, access_scope: d.access_scope,
@@ -78,15 +138,17 @@ export const listByWorkspace = query({
}); });
export const listTrashedByWorkspace = query({ export const listTrashedByWorkspace = query({
args: { userId: v.string(), workspaceId: v.string() }, args: { workspaceId: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const docs = await ctx.db const docs = await ctx.db
.query("documents") .query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect(); .collect();
return docs return docs
.filter((d) => d.user_id === args.userId) .filter((d) => d.user_id === userId)
.filter((d) => d.deleted_at != null) .filter((d) => d.deleted_at != null)
.sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? "")) .sort((a, b) => (b.deleted_at ?? "").localeCompare(a.deleted_at ?? ""))
.slice(0, 100) .slice(0, 100)
@@ -102,7 +164,6 @@ export const listTrashedByWorkspace = query({
export const create = mutation({ export const create = mutation({
args: { args: {
userId: v.string(),
id: v.string(), id: v.string(),
workspaceId: v.string(), workspaceId: v.string(),
parentId: v.union(v.string(), v.null()), parentId: v.union(v.string(), v.null()),
@@ -111,6 +172,8 @@ export const create = mutation({
content: v.optional(v.any()), content: v.optional(v.any()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const siblings = await ctx.db const siblings = await ctx.db
.query("documents") .query("documents")
.withIndex("by_workspace_parent", (q) => q.eq("workspace_id", args.workspaceId).eq("parent_id", args.parentId)) .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", { await ctx.db.insert("documents", {
id: args.id, id: args.id,
user_id: args.userId, user_id: userId,
workspace_id: args.workspaceId, workspace_id: args.workspaceId,
parent_id: args.parentId, parent_id: args.parentId,
title, title,
@@ -166,33 +229,37 @@ export const create = mutation({
}); });
export const updateContent = 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) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db const doc = await ctx.db
.query("documents") .query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id)) .withIndex("by_document_id", (q) => q.eq("id", args.id))
.first(); .first();
if (!doc) throw new Error("页面不存在"); 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(); const ts = nowIso();
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts }); await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
// 说明:在 Convex 模式下,把自动入库/LightRAG 触发迁到 Convex jobs/actions。 // 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。
// 采用 debounce,避免频繁保存时触发过多任务。 // 采用 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 }; return { ok: true, updated_at: ts };
}, },
}); });
export const updateTitle = mutation({ 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) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db const doc = await ctx.db
.query("documents") .query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id)) .withIndex("by_document_id", (q) => q.eq("id", args.id))
.first(); .first();
if (!doc) throw new Error("页面不存在"); 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(); const ts = nowIso();
await ctx.db.patch(doc._id, { title: args.title, updated_at: ts }); await ctx.db.patch(doc._id, { title: args.title, updated_at: ts });
return { ok: true, updated_at: ts }; return { ok: true, updated_at: ts };
@@ -201,18 +268,19 @@ export const updateTitle = mutation({
export const move = mutation({ export const move = mutation({
args: { args: {
userId: v.string(),
id: v.string(), id: v.string(),
parentId: v.union(v.string(), v.null()), parentId: v.union(v.string(), v.null()),
sortOrder: v.number(), sortOrder: v.number(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db const doc = await ctx.db
.query("documents") .query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id)) .withIndex("by_document_id", (q) => q.eq("id", args.id))
.first(); .first();
if (!doc) throw new Error("页面不存在"); 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(); const ts = nowIso();
await ctx.db.patch(doc._id, { await ctx.db.patch(doc._id, {
parent_id: args.parentId, parent_id: args.parentId,
@@ -224,29 +292,33 @@ export const move = mutation({
}); });
export const softDelete = mutation({ export const softDelete = mutation({
args: { userId: v.string(), id: v.string() }, args: { id: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db const doc = await ctx.db
.query("documents") .query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id)) .withIndex("by_document_id", (q) => q.eq("id", args.id))
.first(); .first();
if (!doc) throw new Error("页面不存在"); 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(); 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 }; return { ok: true };
}, },
}); });
export const restore = mutation({ export const restore = mutation({
args: { userId: v.string(), id: v.string() }, args: { id: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db const doc = await ctx.db
.query("documents") .query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id)) .withIndex("by_document_id", (q) => q.eq("id", args.id))
.first(); .first();
if (!doc) throw new Error("页面不存在"); 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(); const ts = nowIso();
await ctx.db.patch(doc._id, { await ctx.db.patch(doc._id, {
deleted_at: null, deleted_at: null,
@@ -260,26 +332,30 @@ export const restore = mutation({
}); });
export const purge = mutation({ export const purge = mutation({
args: { userId: v.string(), id: v.string() }, args: { id: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db const doc = await ctx.db
.query("documents") .query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id)) .withIndex("by_document_id", (q) => q.eq("id", args.id))
.first(); .first();
if (!doc) throw new Error("页面不存在"); 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); await ctx.db.delete(doc._id);
return { ok: true }; return { ok: true };
}, },
}); });
export const emptyTrashByWorkspace = mutation({ export const emptyTrashByWorkspace = mutation({
args: { userId: v.string(), workspaceId: v.string() }, args: { workspaceId: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
// 说明:阶段 4/5 先用“membership 存在即可”的规则,避免引入复杂权限模型。 const userId = await requireUserId(ctx);
// 说明:阶段 4/5 先用"membership 存在即可"的规则,避免引入复杂权限模型。
const membership = await ctx.db const membership = await ctx.db
.query("workspace_members") .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(); .first();
if (!membership) { if (!membership) {
@@ -291,7 +367,7 @@ export const emptyTrashByWorkspace = mutation({
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect(); .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) { for (const d of toDelete) {
await ctx.db.delete(d._id); await ctx.db.delete(d._id);
} }
@@ -302,7 +378,6 @@ export const emptyTrashByWorkspace = mutation({
export const updateOptions = mutation({ export const updateOptions = mutation({
args: { args: {
userId: v.string(),
id: v.string(), id: v.string(),
options: v.object({ options: v.object({
wideLayout: v.optional(v.boolean()), wideLayout: v.optional(v.boolean()),
@@ -315,12 +390,14 @@ export const updateOptions = mutation({
}), }),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db const doc = await ctx.db
.query("documents") .query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id)) .withIndex("by_document_id", (q) => q.eq("id", args.id))
.first(); .first();
if (!doc) throw new Error("页面不存在"); 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> = {}; const patch: Record<string, unknown> = {};
if (typeof args.options.wideLayout === "boolean") patch.wide_layout = args.options.wideLayout; if (typeof args.options.wideLayout === "boolean") patch.wide_layout = args.options.wideLayout;
@@ -344,19 +421,20 @@ export const updateOptions = mutation({
export const updateStats = mutation({ export const updateStats = mutation({
args: { args: {
userId: v.string(),
id: v.string(), id: v.string(),
wordCount: v.number(), wordCount: v.number(),
characterCount: v.number(), characterCount: v.number(),
blockCount: v.number(), blockCount: v.number(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const doc = await ctx.db const doc = await ctx.db
.query("documents") .query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.id)) .withIndex("by_document_id", (q) => q.eq("id", args.id))
.first(); .first();
if (!doc) throw new Error("页面不存在"); 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(); const ts = nowIso();
await ctx.db.patch(doc._id, { await ctx.db.patch(doc._id, {
word_count: args.wordCount, word_count: args.wordCount,
@@ -370,18 +448,19 @@ export const updateStats = mutation({
export const duplicate = mutation({ export const duplicate = mutation({
args: { args: {
userId: v.string(),
sourceId: v.string(), sourceId: v.string(),
newId: v.string(), newId: v.string(),
title: v.optional(v.union(v.string(), v.null())), title: v.optional(v.union(v.string(), v.null())),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const source = await ctx.db const source = await ctx.db
.query("documents") .query("documents")
.withIndex("by_document_id", (q) => q.eq("id", args.sourceId)) .withIndex("by_document_id", (q) => q.eq("id", args.sourceId))
.first(); .first();
if (!source) throw new Error("页面不存在或无权限访问"); 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 const siblings = await ctx.db
.query("documents") .query("documents")
@@ -397,7 +476,7 @@ export const duplicate = mutation({
await ctx.db.insert("documents", { await ctx.db.insert("documents", {
id: args.newId, id: args.newId,
user_id: args.userId, user_id: userId,
workspace_id: source.workspace_id, workspace_id: source.workspace_id,
parent_id: source.parent_id, parent_id: source.parent_id,
title, title,
@@ -438,15 +517,17 @@ export const duplicate = mutation({
}); });
export const listAllForCopy = query({ export const listAllForCopy = query({
args: { userId: v.string(), workspaceId: v.string() }, args: { workspaceId: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const docs = await ctx.db const docs = await ctx.db
.query("documents") .query("documents")
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect(); .collect();
return docs return docs
.filter((d) => d.user_id === args.userId) .filter((d) => d.user_id === userId)
.filter((d) => d.deleted_at == null) .filter((d) => d.deleted_at == null)
.map((d) => ({ .map((d) => ({
id: d.id, id: d.id,
+4 -4
View File
@@ -122,8 +122,8 @@ export const run = internalAction({
if (!documentId) throw new Error("缺少 documentId"); if (!documentId) throw new Error("缺少 documentId");
const [meta, contentRes] = await Promise.all([ const [meta, contentRes] = await Promise.all([
ctx.runQuery(api.documents.getMeta, { userId: job.user_id, id: documentId }), ctx.runQuery(internal.documents.getMetaForIngest, { userId: job.user_id, id: documentId }),
ctx.runQuery(api.documents.getContent, { userId: job.user_id, id: documentId }), ctx.runQuery(internal.documents.getContentForIngest, { userId: job.user_id, id: documentId }),
]); ]);
if (!meta) throw new Error("页面不存在或无权限"); if (!meta) throw new Error("页面不存在或无权限");
@@ -147,8 +147,8 @@ export const run = internalAction({
if (!mindmapId) throw new Error("缺少 mindmapId"); if (!mindmapId) throw new Error("缺少 mindmapId");
const [docMeta, mindmapRes] = await Promise.all([ const [docMeta, mindmapRes] = await Promise.all([
ctx.runQuery(api.documents.getMeta, { userId: job.user_id, id: docId }), ctx.runQuery(internal.documents.getMetaForIngest, { userId: job.user_id, id: docId }),
ctx.runQuery(api.mindmaps.get, { userId: job.user_id, docId, mindmapId }), ctx.runQuery(internal.mindmaps.getForIngest, { userId: job.user_id, docId, mindmapId }),
]); ]);
if (!docMeta) throw new Error("页面不存在或无权限"); if (!docMeta) throw new Error("页面不存在或无权限");
+91 -26
View File
@@ -1,5 +1,6 @@
import { mutation, query } from "./_generated/server"; import { internalQuery, mutation, query } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
import { nowIso } from "./_utils/time"; import { nowIso } from "./_utils/time";
import { enqueueIngestMindmapJob } from "./_utils/ingestJobs"; import { enqueueIngestMindmapJob } from "./_utils/ingestJobs";
@@ -8,6 +9,14 @@ const defaultMindmapData = {
children: [], 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 { function normalizeMindmapId(docId: string, mindmapId: string): string {
const raw = String(mindmapId ?? "").trim(); const raw = String(mindmapId ?? "").trim();
if (raw) return raw; if (raw) return raw;
@@ -30,9 +39,11 @@ async function requireOwnedDocument(ctx: any, userId: string, docId: string) {
} }
export const get = query({ 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) => { 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 mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const row = await ctx.db 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({ export const put = mutation({
args: { args: {
userId: v.string(),
docId: v.string(), docId: v.string(),
mindmapId: v.string(), mindmapId: v.string(),
data: v.any(), data: v.any(),
createOnly: v.optional(v.boolean()), createOnly: v.optional(v.boolean()),
}, },
handler: async (ctx, args) => { 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 mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const existing = await ctx.db const existing = await ctx.db
@@ -102,13 +155,13 @@ export const put = mutation({
deleted_at: null, deleted_at: null,
deleted_by: 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 }; return { ok: true, created: false, skipped: false, updated_at: ts };
} }
await ctx.db.insert("mindmaps", { await ctx.db.insert("mindmaps", {
id: `${args.docId}:${mindmapId}`, id: `${args.docId}:${mindmapId}`,
user_id: args.userId, user_id: userId,
workspace_id: doc.workspace_id, workspace_id: doc.workspace_id,
document_id: args.docId, document_id: args.docId,
mindmap_id: mindmapId, mindmap_id: mindmapId,
@@ -119,15 +172,17 @@ export const put = mutation({
deleted_by: 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: true, skipped: false, updated_at: ts }; return { ok: true, created: true, skipped: false, updated_at: ts };
}, },
}); });
export const softDelete = mutation({ 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) => { 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 mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const existing = await ctx.db const existing = await ctx.db
@@ -141,15 +196,17 @@ export const softDelete = mutation({
} }
const ts = nowIso(); 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 }; return { ok: true, moved: 1, deleted_at: ts };
}, },
}); });
export const restore = mutation({ 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) => { 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 mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const existing = await ctx.db const existing = await ctx.db
@@ -168,9 +225,11 @@ export const restore = mutation({
}); });
export const purge = 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) => { 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 mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
const existing = await ctx.db const existing = await ctx.db
@@ -188,12 +247,14 @@ export const purge = mutation({
}); });
export const listByWorkspace = query({ 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) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
// 说明:阶段 6 先按 membership 存在即可,避免引入复杂权限模型。 // 说明:阶段 6 先按 membership 存在即可,避免引入复杂权限模型。
const membership = await ctx.db const membership = await ctx.db
.query("workspace_members") .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(); .first();
if (!membership) { if (!membership) {
throw new Error("无权操作该工作空间"); throw new Error("无权操作该工作空间");
@@ -207,7 +268,7 @@ export const listByWorkspace = query({
const includeDeleted = Boolean(args.includeDeleted); const includeDeleted = Boolean(args.includeDeleted);
return rows return rows
.filter((r) => r.user_id === args.userId) .filter((r) => r.user_id === userId)
.filter((r) => (includeDeleted ? true : r.deleted_at == null)) .filter((r) => (includeDeleted ? true : r.deleted_at == null))
.sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "")) .sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))
.map((r) => ({ .map((r) => ({
@@ -225,10 +286,12 @@ export const listByWorkspace = query({
}); });
export const copyByDocument = mutation({ 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) => { handler: async (ctx, args) => {
const sourceDoc = await requireOwnedDocument(ctx, args.userId, args.sourceDocId); const userId = await requireUserId(ctx);
const targetDoc = await requireOwnedDocument(ctx, args.userId, args.targetDocId);
const sourceDoc = await requireOwnedDocument(ctx, userId, args.sourceDocId);
const targetDoc = await requireOwnedDocument(ctx, userId, args.targetDocId);
// 说明:同一工作空间复制最常见;若跨工作空间复制,这里仍允许,但 mindmaps 会落到目标页面的 workspace_id。 // 说明:同一工作空间复制最常见;若跨工作空间复制,这里仍允许,但 mindmaps 会落到目标页面的 workspace_id。
const sourceRows = await ctx.db const sourceRows = await ctx.db
@@ -237,7 +300,7 @@ export const copyByDocument = mutation({
.collect(); .collect();
const sourceMindmaps = sourceRows 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.document_id === args.sourceDocId)
.filter((r) => r.deleted_at == null); .filter((r) => r.deleted_at == null);
@@ -253,7 +316,7 @@ export const copyByDocument = mutation({
await ctx.db.insert("mindmaps", { await ctx.db.insert("mindmaps", {
id: `${args.targetDocId}:${r.mindmap_id}`, id: `${args.targetDocId}:${r.mindmap_id}`,
user_id: args.userId, user_id: userId,
workspace_id: targetDoc.workspace_id, workspace_id: targetDoc.workspace_id,
document_id: args.targetDocId, document_id: args.targetDocId,
mindmap_id: r.mindmap_id, mindmap_id: r.mindmap_id,
@@ -271,11 +334,13 @@ export const copyByDocument = mutation({
}); });
export const emptyTrashByWorkspace = mutation({ export const emptyTrashByWorkspace = mutation({
args: { userId: v.string(), workspaceId: v.string() }, args: { workspaceId: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const membership = await ctx.db const membership = await ctx.db
.query("workspace_members") .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(); .first();
if (!membership) { if (!membership) {
throw new Error("无权操作该工作空间"); throw new Error("无权操作该工作空间");
@@ -286,7 +351,7 @@ export const emptyTrashByWorkspace = mutation({
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
.collect(); .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) { for (const r of toDelete) {
await ctx.db.delete(r._id); await ctx.db.delete(r._id);
} }
+23 -9
View File
@@ -1,5 +1,6 @@
import { mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { getAuthUserId } from "@convex-dev/auth/server";
import { nowIso } from "./_utils/time"; import { nowIso } from "./_utils/time";
import type { MutationCtx, QueryCtx } from "./_generated/server"; import type { MutationCtx, QueryCtx } from "./_generated/server";
@@ -12,6 +13,14 @@ type WorkspaceSummary = {
isDefault: boolean; 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) { async function findWorkspaceById(ctx: QueryCtx | MutationCtx, workspaceId: string) {
return await ctx.db return await ctx.db
.query("workspaces") .query("workspaces")
@@ -29,14 +38,15 @@ async function countMembers(ctx: QueryCtx | MutationCtx, workspaceId: string): P
export const ensureDefaultWorkspace = mutation({ export const ensureDefaultWorkspace = mutation({
args: { args: {
userId: v.string(),
fallbackName: v.optional(v.string()), fallbackName: v.optional(v.string()),
workspaceIdIfCreate: v.string(), workspaceIdIfCreate: v.string(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const memberships = await ctx.db const memberships = await ctx.db
.query("workspace_members") .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(); .collect();
if (memberships.length === 0) { if (memberships.length === 0) {
@@ -50,13 +60,13 @@ export const ensureDefaultWorkspace = mutation({
name: workspaceName, name: workspaceName,
type: "personal", type: "personal",
icon_url: null, icon_url: null,
created_by: args.userId, created_by: userId,
created_at: ts, created_at: ts,
}); });
await ctx.db.insert("workspace_members", { await ctx.db.insert("workspace_members", {
workspace_id: args.workspaceIdIfCreate, workspace_id: args.workspaceIdIfCreate,
user_id: args.userId, user_id: userId,
role: "owner", role: "owner",
is_default: true, is_default: true,
created_at: ts, created_at: ts,
@@ -103,12 +113,14 @@ export const ensureDefaultWorkspace = mutation({
}); });
export const fetchWorkspaceSummaries = query({ export const fetchWorkspaceSummaries = query({
args: { userId: v.string() }, args: {},
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
// 说明:为了复用 ensureDefaultWorkspace 的返回结构,这里直接走同样的聚合逻辑。 // 说明:为了复用 ensureDefaultWorkspace 的返回结构,这里直接走同样的聚合逻辑。
const memberships = await ctx.db const memberships = await ctx.db
.query("workspace_members") .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(); .collect();
const workspaceIds = Array.from(new Set(memberships.map((m) => m.workspace_id))); const workspaceIds = Array.from(new Set(memberships.map((m) => m.workspace_id)));
@@ -135,12 +147,14 @@ export const fetchWorkspaceSummaries = query({
}); });
export const switchDefaultWorkspace = mutation({ export const switchDefaultWorkspace = mutation({
args: { userId: v.string(), workspaceId: v.string() }, args: { workspaceId: v.string() },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const userId = await requireUserId(ctx);
const target = await ctx.db const target = await ctx.db
.query("workspace_members") .query("workspace_members")
.withIndex("by_workspace_user", (q) => .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(); .first();
@@ -150,7 +164,7 @@ export const switchDefaultWorkspace = mutation({
const memberships = await ctx.db const memberships = await ctx.db
.query("workspace_members") .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(); .collect();
// 说明:Convex 暂无批量 update,这里逐条 patch。 // 说明:Convex 暂无批量 update,这里逐条 patch。
@@ -1,10 +1,8 @@
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { DocumentShell } from "@/components/editor/document-shell"; import { DocumentShell } from "@/components/editor/document-shell";
import type { PageOptionsState, DocumentStats } from "@/types/page-options"; import type { PageOptionsState, DocumentStats } from "@/types/page-options";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
interface DocumentPageProps { interface DocumentPageProps {
@@ -20,9 +18,8 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null; const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext(); const { client } = await getAuthedConvexClient();
const client = getConvexHttpClient(); const doc = await client.query(api.documents.getMeta, { id });
const doc = await client.query(api.documents.getMeta, { userId: auth.userId, id });
if (!doc) { if (!doc) {
notFound(); notFound();
} }
@@ -61,58 +58,5 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
); );
} }
const supabase = await createSupabaseServerClient(); redirect("/auth");
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>
);
} }
+6 -68
View File
@@ -5,14 +5,10 @@ import { Breadcrumb } from "@/components/breadcrumb";
import { BottomToolbar } from "@/components/bottom-toolbar"; import { BottomToolbar } from "@/components/bottom-toolbar";
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger"; import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
import type { DocumentRecord } from "@/lib/documents"; 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 type { SidebarInitialData } from "@/components/sidebar/types";
import { SearchPalette } from "@/components/search/search-palette"; import { SearchPalette } from "@/components/search/search-palette";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
@@ -66,11 +62,9 @@ function makeId(): string {
export default async function AppLayout({ children }: { children: ReactNode }) { export default async function AppLayout({ children }: { children: ReactNode }) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext(); const { auth, client } = await getAuthedConvexClient();
const client = getConvexHttpClient();
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, { const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
userId: auth.userId,
fallbackName: auth.name ?? auth.email ?? "我的空间", fallbackName: auth.name ?? auth.email ?? "我的空间",
workspaceIdIfCreate: makeId(), workspaceIdIfCreate: makeId(),
}); });
@@ -84,11 +78,9 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
if (activeWorkspaceId) { if (activeWorkspaceId) {
const [docRows, trashedDocs] = await Promise.all([ const [docRows, trashedDocs] = await Promise.all([
client.query(api.documents.listByWorkspace, { client.query(api.documents.listByWorkspace, {
userId: auth.userId,
workspaceId: activeWorkspaceId, workspaceId: activeWorkspaceId,
}), }),
client.query(api.documents.listTrashedByWorkspace, { client.query(api.documents.listTrashedByWorkspace, {
userId: auth.userId,
workspaceId: activeWorkspaceId, workspaceId: activeWorkspaceId,
}), }),
]); ]);
@@ -96,7 +88,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
documents = docRows as unknown as DocumentRecord[]; documents = docRows as unknown as DocumentRecord[];
const mindmapRows = await client.query(api.mindmaps.listByWorkspace, { const mindmapRows = await client.query(api.mindmaps.listByWorkspace, {
userId: auth.userId,
workspaceId: activeWorkspaceId, workspaceId: activeWorkspaceId,
includeDeleted: true, includeDeleted: true,
}); });
@@ -178,10 +169,10 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
} }
return ( 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} />} {sidebarInitialData && <Sidebar initialData={sidebarInitialData} />}
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col h-full bg-white relative overflow-hidden">
<header className="flex h-10 items-center gap-4 border-b border-[#eeeeee] px-4"> <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 /> <MobileSidebarTrigger />
<Breadcrumb documents={documents} /> <Breadcrumb documents={documents} />
</header> </header>
@@ -193,58 +184,5 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
); );
} }
const supabase = await createSupabaseServerClient(); redirect("/login");
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>
);
} }
+8 -98
View File
@@ -1,116 +1,26 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); 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 () => { useEffect(() => {
setLoading(true); // 已移除 Supabase 登录,统一走 Convex Auth 页面。
setMessage(""); router.replace("/auth");
const supabaseBrowser = getSupabaseBrowserClient(); }, [router]);
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));
}, []);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 p-4"> <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"> <Card className="w-[400px] space-y-4 bg-white/90 shadow-md backdrop-blur">
<CardHeader> <CardHeader>
<CardTitle className="text-xl">Supabase </CardTitle> <CardTitle className="text-xl"></CardTitle>
<p className="text-sm text-zinc-500">test@163.com / 123456</p> <p className="text-sm text-zinc-500"> Convex Auth/auth</p>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="space-y-1"> <p className="text-sm text-zinc-700"> /auth</p>
<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>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { requireAuthContext } from "@/lib/auth/authContext";
import { import {
@@ -36,12 +35,7 @@ export async function POST(request: Request) {
const resolvedUserId = async () => { const resolvedUserId = async () => {
if (userId) return userId; if (userId) return userId;
const supabase = await createSupabaseRouteClient(); return null;
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) return null;
return session.user.id;
}; };
const finalUserId = await resolvedUserId(); const finalUserId = await resolvedUserId();
@@ -4,7 +4,6 @@ import { loadLocalAiConfig } from "@/lib/ai/localAiConfig";
import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/registry"; import { createToolRegistry, resolveAllowedToolIds } from "@/lib/ai-agent/tools/registry";
import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins"; import { builtinTools, builtinToolSets } from "@/lib/ai-agent/tools/builtins/registryBuiltins";
import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent"; import { runAiAgent } from "@/lib/ai-agent/runtime/runAgent";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb"; import { searchSearxng } from "@/lib/ai-agent/tools/builtins/searchWeb";
import { createMindmapServerTools, type MindmapSupabaseClient } from "@/lib/ai-agent/tools/builtins/mindmap/mindmapServerTools"; 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(); const { auth, client } = await getAuthedConvexClient();
return { userId: auth.userId, supabase: null as any, convexClient: client }; return { userId: auth.userId, supabase: null as any, convexClient: client };
} }
const supabase = await createSupabaseRouteClient(); return { userId: "", supabase: null as any, convexClient: null as any };
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 };
})(); })();
if (!userId) { if (!userId) {
return NextResponse.json({ error: "未登录" }, { status: 401 }); return NextResponse.json({ error: "未登录" }, { status: 401 });
@@ -1,18 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const supabase = await createSupabaseRouteClient(); // 说明:历史兼容接口(Supabase Auth callback)。当前项目迁移到 Convex 后,这里保持空实现。
const { event, session } = await request.json(); await request.json().catch(() => null);
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
await supabase.auth.setSession(session);
}
if (event === "SIGNED_OUT") {
await supabase.auth.signOut();
}
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { isDevAuthEnabled } from "@/lib/auth/devUser";
interface SignInRequest { interface SignInRequest {
email: string; email: string;
@@ -19,15 +20,14 @@ export async function POST(request: Request) {
// Convex 模式 // Convex 模式
if (isConvexEnabled()) { if (isConvexEnabled()) {
try { try {
const body = await request.json() as SignInRequest; await request.json().catch(() => null);
const { email, password, name, flow } = body;
// TODO: 调用 Convex Auth mutation 处理登录/注册 // 说明:当前项目默认使用开发用户鉴权(USE_CONVEX=1),无需走登录/注册
// 当前暂时返回未实现错误 if (isDevAuthEnabled()) {
return NextResponse.json( return NextResponse.json({ ok: true, mode: "dev_user" });
{ error: "Convex Auth API 路由暂未实现,请稍后实现" }, }
{ status: 501 }
); return NextResponse.json({ error: "Convex Auth 登录/注册尚未接入" }, { status: 501 });
} catch (error) { } catch (error) {
console.error("Convex Auth error:", error); console.error("Convex Auth error:", error);
return NextResponse.json( return NextResponse.json(
@@ -37,74 +37,5 @@ export async function POST(request: Request) {
} }
} }
// Supabase 模式(保留兼容) return NextResponse.json({ error: "仅支持 Convex 模式" }, { status: 501 });
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 }
);
}
} }
@@ -1,9 +1,7 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { getBlocksFromDocumentContent, findBlockInTree, withBlocksWrittenBack } from "@/lib/blocks"; import { getBlocksFromDocumentContent, findBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
@@ -26,16 +24,15 @@ export async function POST(request: Request) {
} }
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext(); const { client } = await getAuthedConvexClient();
const client = getConvexHttpClient();
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 }); if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
const sourceBlocks = getBlocksFromDocumentContent(source.content); const sourceBlocks = getBlocksFromDocumentContent(source.content);
const hit = findBlockInTree(sourceBlocks, blockId); const hit = findBlockInTree(sourceBlocks, blockId);
if (!hit) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 }); 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 }); if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
const targetBlocks = getBlocksFromDocumentContent(target.content); const targetBlocks = getBlocksFromDocumentContent(target.content);
@@ -53,11 +50,14 @@ export async function POST(request: Request) {
const nextBlocks = [...targetBlocks, referenceBlock]; const nextBlocks = [...targetBlocks, referenceBlock];
const payload = withBlocksWrittenBack(target.content, nextBlocks); 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({ ok: true });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -112,4 +112,5 @@ export async function POST(request: Request) {
if (error) return NextResponse.json({ error: error.message }, { status: 500 }); if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
*/
} }
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { getBlocksFromDocumentContent, findBlockInTree } from "@/lib/blocks"; import { getBlocksFromDocumentContent, findBlockInTree } from "@/lib/blocks";
@@ -16,9 +14,8 @@ export async function GET(request: Request) {
} }
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext(); const { client } = await getAuthedConvexClient();
const client = getConvexHttpClient(); const doc = await client.query(api.documents.getContent, { id: sourceDocumentId });
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 }); if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
const blocks = getBlocksFromDocumentContent(doc.content); const blocks = getBlocksFromDocumentContent(doc.content);
const hit = findBlockInTree(blocks, blockId); 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({ ok: true, block: hit.block });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -45,4 +45,5 @@ export async function GET(request: Request) {
if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 }); if (!hit) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
return NextResponse.json({ ok: true, block: hit.block }); return NextResponse.json({ ok: true, block: hit.block });
*/
} }
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { getBlocksFromDocumentContent, removeBlockSubtree, withBlocksWrittenBack } from "@/lib/blocks"; import { getBlocksFromDocumentContent, removeBlockSubtree, withBlocksWrittenBack } from "@/lib/blocks";
@@ -26,28 +24,30 @@ export async function POST(request: Request) {
} }
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext(); const { client } = await getAuthedConvexClient();
const client = getConvexHttpClient();
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 }); if (!source) return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
const sourceBlocks = getBlocksFromDocumentContent(source.content); const sourceBlocks = getBlocksFromDocumentContent(source.content);
const removedRes = removeBlockSubtree(sourceBlocks, blockId); const removedRes = removeBlockSubtree(sourceBlocks, blockId);
if (!removedRes.removed) return NextResponse.json({ error: "源块不存在或无权限" }, { status: 404 }); 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 }); if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
const targetBlocks = getBlocksFromDocumentContent(target.content); const targetBlocks = getBlocksFromDocumentContent(target.content);
const nextSourcePayload = withBlocksWrittenBack(source.content, removedRes.nextBlocks); const nextSourcePayload = withBlocksWrittenBack(source.content, removedRes.nextBlocks);
const nextTargetPayload = withBlocksWrittenBack(target.content, [...targetBlocks, removedRes.removed]); 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, { id: sourceDocumentId, content: nextSourcePayload });
await client.mutation(api.documents.updateContent, { userId: auth.userId, id: targetDocumentId, content: nextTargetPayload }); await client.mutation(api.documents.updateContent, { id: targetDocumentId, content: nextTargetPayload });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -96,4 +96,5 @@ export async function POST(request: Request) {
if (tgtErr) return NextResponse.json({ error: tgtErr.message }, { status: 500 }); if (tgtErr) return NextResponse.json({ error: tgtErr.message }, { status: 500 });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
*/
} }
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { getBlocksFromDocumentContent, replaceBlockInTree, withBlocksWrittenBack } from "@/lib/blocks"; import { getBlocksFromDocumentContent, replaceBlockInTree, withBlocksWrittenBack } from "@/lib/blocks";
@@ -20,9 +18,8 @@ export async function POST(request: Request) {
} }
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext(); const { client } = await getAuthedConvexClient();
const client = getConvexHttpClient(); const doc = await client.query(api.documents.getContent, { id: sourceDocumentId });
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 }); if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
const blocks = getBlocksFromDocumentContent(doc.content); const blocks = getBlocksFromDocumentContent(doc.content);
@@ -30,10 +27,13 @@ export async function POST(request: Request) {
if (!replaced.ok) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 }); if (!replaced.ok) return NextResponse.json({ error: "块不存在或无权限" }, { status: 404 });
const payload = withBlocksWrittenBack(doc.content, replaced.nextBlocks); 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({ ok: true });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -61,4 +61,5 @@ export async function POST(request: Request) {
if (error) return NextResponse.json({ error: error.message }, { status: 500 }); if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
*/
} }
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireAuthContext } from "@/lib/auth/authContext"; import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server"; import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -12,7 +12,7 @@ type Payload =
export async function POST(request: Request) { export async function POST(request: Request) {
const auth = await requireAuthContext(); const auth = await requireAuthContext();
const client = getConvexHttpClient(); const client = await getConvexAuthedHttpClient();
const body = (await request.json().catch(() => null)) as Payload | null; const body = (await request.json().catch(() => null)) as Payload | null;
if (!body) { if (!body) {
@@ -1,14 +1,14 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { requireAuthContext } from "@/lib/auth/authContext"; import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server"; import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function POST(request: Request) { export async function POST(request: Request) {
const auth = await requireAuthContext(); const auth = await requireAuthContext();
const client = getConvexHttpClient(); const client = await getConvexAuthedHttpClient();
const { ms }: { ms?: number } = await request.json().catch(() => ({})); const { ms }: { ms?: number } = await request.json().catch(() => ({}));
const id = randomUUID(); const id = randomUUID();
@@ -24,7 +24,7 @@ export async function POST(request: Request) {
export async function GET(request: Request) { export async function GET(request: Request) {
const auth = await requireAuthContext(); const auth = await requireAuthContext();
const client = getConvexHttpClient(); const client = await getConvexAuthedHttpClient();
const url = new URL(request.url); const url = new URL(request.url);
const id = url.searchParams.get("id") ?? ""; const id = url.searchParams.get("id") ?? "";
@@ -1,15 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; 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 { api } from "@/lib/convex/api";
import { requireAuthContext } from "@/lib/auth/authContext";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function GET(request: Request) { export async function GET(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext();
const url = new URL(request.url); const url = new URL(request.url);
const documentId = url.searchParams.get("documentId") ?? ""; const documentId = url.searchParams.get("documentId") ?? "";
@@ -17,9 +14,8 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
} }
const client = getConvexHttpClient(); const { client } = await getAuthedConvexClient();
const result = await client.query(api.documents.getContent, { const result = await client.query(api.documents.getContent, {
userId: auth.userId,
id: documentId, id: documentId,
}); });
@@ -30,36 +26,5 @@ export async function GET(request: Request) {
return NextResponse.json({ content: result.content ?? null }); return NextResponse.json({ content: result.content ?? null });
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
} }
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import path from "path"; import path from "path";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
@@ -163,8 +161,7 @@ function replaceAssetRefsInContent(content: Json | null, assetMap: Map<string, {
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext(); const { client } = await getAuthedConvexClient();
const client = getConvexHttpClient();
const payload = (await request.json()) as CopyTreePayload; const payload = (await request.json()) as CopyTreePayload;
if (!payload?.items?.length) { if (!payload?.items?.length) {
@@ -180,7 +177,7 @@ export async function POST(request: Request) {
let workspaceId: string | null = null; let workspaceId: string | null = null;
if (targetParentId) { 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) { if (!targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 }); 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 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) { if (!firstMeta) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 }); return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
} }
@@ -204,7 +201,6 @@ export async function POST(request: Request) {
const wid = workspaceId; const wid = workspaceId;
const allDocs = await client.query(api.documents.listAllForCopy, { const allDocs = await client.query(api.documents.listAllForCopy, {
userId: auth.userId,
workspaceId: wid, workspaceId: wid,
}); });
@@ -275,7 +271,6 @@ export async function POST(request: Request) {
titleSet.add(newTitle); titleSet.add(newTitle);
await client.mutation(api.documents.create, { await client.mutation(api.documents.create, {
userId: auth.userId,
id: newId, id: newId,
workspaceId: wid, workspaceId: wid,
parentId, parentId,
@@ -286,7 +281,6 @@ export async function POST(request: Request) {
await ensureDocumentScaffold(newId, newTitle); await ensureDocumentScaffold(newId, newTitle);
await client.mutation(api.mindmaps.copyByDocument, { await client.mutation(api.mindmaps.copyByDocument, {
userId: auth.userId,
sourceDocId: item.old.id, sourceDocId: item.old.id,
targetDocId: newId, targetDocId: newId,
}); });
@@ -298,6 +292,9 @@ export async function POST(request: Request) {
}); });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -543,4 +540,5 @@ export async function POST(request: Request) {
return NextResponse.json({ return NextResponse.json({
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })), items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
}); });
*/
} }
@@ -1,9 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
function makeId(): string { function makeId(): string {
@@ -22,8 +19,7 @@ export const dynamic = "force-dynamic";
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext(); const { auth, client } = await getAuthedConvexClient();
const client = getConvexHttpClient();
const { parentId, title, blocks }: CreateChildPayload = await request.json(); const { parentId, title, blocks }: CreateChildPayload = await request.json();
@@ -35,7 +31,7 @@ export async function POST(request: Request) {
let accessScope: "private" | "shared" | "public" = "private"; let accessScope: "private" | "shared" | "public" = "private";
if (parentId) { 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) { if (!parentDoc) {
return NextResponse.json({ error: "父页面不存在" }, { status: 404 }); return NextResponse.json({ error: "父页面不存在" }, { status: 404 });
} }
@@ -43,7 +39,6 @@ export async function POST(request: Request) {
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope; accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
} else { } else {
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, { const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
userId: auth.userId,
fallbackName: auth.name ?? auth.email ?? "我的空间", fallbackName: auth.name ?? auth.email ?? "我的空间",
workspaceIdIfCreate: makeId(), workspaceIdIfCreate: makeId(),
}); });
@@ -58,7 +53,6 @@ export async function POST(request: Request) {
const pageId = makeId(); const pageId = makeId();
const created = await client.mutation(api.documents.create, { const created = await client.mutation(api.documents.create, {
userId: auth.userId,
id: pageId, id: pageId,
workspaceId, workspaceId,
parentId: parentId ?? null, 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 supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -159,4 +156,5 @@ export async function POST(request: Request) {
pageId: data.id, pageId: data.id,
title: data.title ?? resolvedTitle, title: data.title ?? resolvedTitle,
}); });
*/
} }
@@ -1,16 +1,13 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
import type { Json } from "@/types/supabase"; import type { Json } from "@/types/supabase";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content"; import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import path from "path"; import path from "path";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import { getDocumentsBaseDir } from "@/lib/server/local-paths"; import { getDocumentsBaseDir } from "@/lib/server/local-paths";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
const documentsBaseDir = getDocumentsBaseDir(); const documentsBaseDir = getDocumentsBaseDir();
@@ -41,13 +38,11 @@ export async function POST(request: Request) {
} }
async function handleCreateRequestConvex(request: Request) { async function handleCreateRequestConvex(request: Request) {
const auth = await requireAuthContext(); const { auth, client } = await getAuthedConvexClient();
const client = getConvexHttpClient();
const { parentId }: { parentId?: string | null } = await request.json(); const { parentId }: { parentId?: string | null } = await request.json();
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, { const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
userId: auth.userId,
fallbackName: auth.email ?? auth.name ?? "我的空间", fallbackName: auth.email ?? auth.name ?? "我的空间",
workspaceIdIfCreate: randomUUID(), workspaceIdIfCreate: randomUUID(),
}); });
@@ -58,7 +53,6 @@ async function handleCreateRequestConvex(request: Request) {
if (parentId) { if (parentId) {
const parentDoc = await client.query(api.documents.getMeta, { const parentDoc = await client.query(api.documents.getMeta, {
userId: auth.userId,
id: parentId, id: parentId,
}); });
@@ -68,7 +62,7 @@ async function handleCreateRequestConvex(request: Request) {
workspaceId = parentDoc.workspace_id; workspaceId = parentDoc.workspace_id;
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope; 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; parentContent = (parentContentRes?.content as Json | null) ?? null;
} else { } else {
workspaceId = workspaceBootstrap.activeWorkspaceId || null; workspaceId = workspaceBootstrap.activeWorkspaceId || null;
@@ -80,7 +74,6 @@ async function handleCreateRequestConvex(request: Request) {
const id = randomUUID(); const id = randomUUID();
const data = await client.mutation(api.documents.create, { const data = await client.mutation(api.documents.create, {
userId: auth.userId,
id, id,
workspaceId, workspaceId,
parentId: parentId ?? null, parentId: parentId ?? null,
@@ -108,7 +101,6 @@ async function handleCreateRequestConvex(request: Request) {
const payload = composeContentWithBlocks(parentContent, nextBlocks); const payload = composeContentWithBlocks(parentContent, nextBlocks);
await client.mutation(api.documents.updateContent, { await client.mutation(api.documents.updateContent, {
userId: auth.userId,
id: parentId, id: parentId,
content: payload, content: payload,
}); });
@@ -118,6 +110,9 @@ async function handleCreateRequestConvex(request: Request) {
} }
async function handleCreateRequest(request: Request) { async function handleCreateRequest(request: Request) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -228,4 +223,5 @@ async function handleCreateRequest(request: Request) {
} }
return NextResponse.json(data); return NextResponse.json(data);
*/
} }
@@ -1,24 +1,24 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext();
const { documentId } = await request.json(); const { documentId } = await request.json();
if (!documentId) { if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
} }
const client = getConvexHttpClient(); const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.softDelete, { userId: auth.userId, id: documentId }); await client.mutation(api.documents.softDelete, { id: documentId });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -48,4 +48,5 @@ export async function POST(request: Request) {
} }
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
*/
} }
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import path from "path"; import path from "path";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
@@ -48,15 +46,14 @@ async function copyMindmapIfExists(sourceId: string, targetId: string) {
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext(); const { client } = await getAuthedConvexClient();
const client = getConvexHttpClient();
const { documentId }: DuplicatePayload = await request.json(); const { documentId }: DuplicatePayload = await request.json();
if (!documentId) { if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); 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) { if (!sourceDoc) {
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 }); return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
} }
@@ -70,7 +67,6 @@ export async function POST(request: Request) {
: `${Date.now()}_${Math.random().toString(16).slice(2)}`; : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const duplicated = await client.mutation(api.documents.duplicate, { const duplicated = await client.mutation(api.documents.duplicate, {
userId: auth.userId,
sourceId: documentId, sourceId: documentId,
newId, newId,
title: duplicatedTitle, title: duplicatedTitle,
@@ -78,7 +74,6 @@ export async function POST(request: Request) {
await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle); await ensureDocumentScaffold(duplicated.id, duplicated.title ?? duplicatedTitle);
await client.mutation(api.mindmaps.copyByDocument, { await client.mutation(api.mindmaps.copyByDocument, {
userId: auth.userId,
sourceDocId: documentId, sourceDocId: documentId,
targetDocId: duplicated.id, 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 supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -164,4 +162,5 @@ export async function POST(request: Request) {
await copyMindmapIfExists(sourceDoc.id, duplicated.id); await copyMindmapIfExists(sourceDoc.id, duplicated.id);
return NextResponse.json(duplicated); return NextResponse.json(duplicated);
*/
} }
@@ -1,6 +1,5 @@
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { Json } from "@/types/supabase"; import type { Json } from "@/types/supabase";
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content"; import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
@@ -22,12 +21,12 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 }); 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) { if (!sourceDoc) {
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 }); 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) { if (!targetContent) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 }); return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
} }
@@ -48,7 +47,6 @@ export async function POST(request: Request) {
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks); const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
await client.mutation(api.documents.updateContent, { await client.mutation(api.documents.updateContent, {
userId: auth.userId,
id: targetId, id: targetId,
content: payload, content: payload,
}); });
@@ -56,6 +54,9 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -119,4 +120,5 @@ export async function POST(request: Request) {
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
*/
} }
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -13,16 +11,18 @@ interface EmptyTrashPayload {
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext();
const { workspaceId }: EmptyTrashPayload = await request.json(); const { workspaceId }: EmptyTrashPayload = await request.json();
if (!workspaceId) { if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
} }
const client = getConvexHttpClient(); const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.emptyTrashByWorkspace, { userId: auth.userId, workspaceId }); await client.mutation(api.documents.emptyTrashByWorkspace, { workspaceId });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -65,4 +65,5 @@ export async function POST(request: Request) {
} }
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
*/
} }
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
interface MovePayload { interface MovePayload {
@@ -13,12 +11,10 @@ interface MovePayload {
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext();
const { documentId, parentId = null, position }: MovePayload = await request.json(); const { documentId, parentId = null, position }: MovePayload = await request.json();
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0; const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
const client = getConvexHttpClient(); const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.move, { await client.mutation(api.documents.move, {
userId: auth.userId,
id: documentId, id: documentId,
parentId, parentId,
sortOrder, sortOrder,
@@ -26,6 +22,9 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -52,4 +51,5 @@ export async function POST(request: Request) {
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
*/
} }
@@ -1,38 +1,23 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import type { PageOptionsState } from "@/types/page-options"; import type { PageOptionsState } from "@/types/page-options";
import type { Database } from "@/types/supabase";
type OptionsPayload = { type OptionsPayload = {
documentId: string; documentId: string;
options: Partial<PageOptionsState>; 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) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext();
const { documentId, options }: OptionsPayload = await request.json(); const { documentId, options }: OptionsPayload = await request.json();
if (!documentId || !options) { if (!documentId || !options) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 }); return NextResponse.json({ error: "缺少参数" }, { status: 400 });
} }
const client = getConvexHttpClient(); const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.updateOptions, { await client.mutation(api.documents.updateOptions, {
userId: auth.userId,
id: documentId, id: documentId,
options: { options: {
wideLayout: options.wideLayout, wideLayout: options.wideLayout,
@@ -48,43 +33,5 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
} }
@@ -1,24 +1,24 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext();
const { documentId } = await request.json(); const { documentId } = await request.json();
if (!documentId) { if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
} }
const client = getConvexHttpClient(); const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.purge, { userId: auth.userId, id: documentId }); await client.mutation(api.documents.purge, { id: documentId });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -45,4 +45,5 @@ export async function POST(request: Request) {
} }
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
*/
} }
@@ -1,24 +1,24 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext();
const { documentId } = await request.json(); const { documentId } = await request.json();
if (!documentId) { if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
} }
const client = getConvexHttpClient(); const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.restore, { userId: auth.userId, id: documentId }); await client.mutation(api.documents.restore, { id: documentId });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -50,4 +50,5 @@ export async function POST(request: Request) {
} }
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
*/
} }
@@ -1,9 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; 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 { api } from "@/lib/convex/api";
import { requireAuthContext } from "@/lib/auth/authContext";
interface SavePayload { interface SavePayload {
documentId: string; documentId: string;
@@ -12,37 +10,14 @@ interface SavePayload {
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext();
const { documentId, content }: SavePayload = await request.json(); const { documentId, content }: SavePayload = await request.json();
const client = getConvexHttpClient(); const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.updateContent, { await client.mutation(api.documents.updateContent, {
userId: auth.userId,
id: documentId, id: documentId,
content, content,
}); });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
} }
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import type { DocumentStats } from "@/types/page-options"; import type { DocumentStats } from "@/types/page-options";
@@ -13,15 +11,13 @@ interface StatsPayload {
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext();
const { documentId, stats }: StatsPayload = await request.json(); const { documentId, stats }: StatsPayload = await request.json();
if (!documentId || !stats) { if (!documentId || !stats) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 }); return NextResponse.json({ error: "缺少参数" }, { status: 400 });
} }
const client = getConvexHttpClient(); const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.updateStats, { await client.mutation(api.documents.updateStats, {
userId: auth.userId,
id: documentId, id: documentId,
wordCount: stats.wordCount, wordCount: stats.wordCount,
characterCount: stats.characterCount, characterCount: stats.characterCount,
@@ -31,33 +27,5 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
} }
@@ -1,9 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; 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 { api } from "@/lib/convex/api";
import { requireAuthContext } from "@/lib/auth/authContext";
interface RenamePayload { interface RenamePayload {
documentId: string; documentId: string;
@@ -12,37 +10,14 @@ interface RenamePayload {
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext();
const { documentId, title }: RenamePayload = await request.json(); const { documentId, title }: RenamePayload = await request.json();
const client = getConvexHttpClient(); const { client } = await getAuthedConvexClient();
await client.mutation(api.documents.updateTitle, { await client.mutation(api.documents.updateTitle, {
userId: auth.userId,
id: documentId, id: documentId,
title, title,
}); });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
} }
@@ -1,9 +1,8 @@
"use server"; "use server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server"; import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
export async function GET(request: Request) { export async function GET(request: Request) {
@@ -17,7 +16,7 @@ export async function GET(request: Request) {
// Convex 模式 // Convex 模式
if (isConvexEnabled()) { if (isConvexEnabled()) {
try { try {
const client = getConvexHttpClient(); const client = await getConvexAuthedHttpClient();
const result = await client.query(api.tables.getByGridKey, { gridKey }); const result = await client.query(api.tables.getByGridKey, { gridKey });
if (!result) { if (!result) {
@@ -36,6 +35,9 @@ export async function GET(request: Request) {
} }
// Supabase 模式 // Supabase 模式
return NextResponse.json({ code: 501, msg: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { data, error } = await supabase const { data, error } = await supabase
.from("document_tables") .from("document_tables")
@@ -56,4 +58,5 @@ export async function GET(request: Request) {
gridKey: data.grid_key, gridKey: data.grid_key,
}, },
}); });
*/
} }
@@ -2,10 +2,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createDefaultTableSnapshot } from "@/lib/online-table"; import { createDefaultTableSnapshot } from "@/lib/online-table";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { DocumentTableSnapshot, TableSchema } from "@/types/online-table"; import type { DocumentTableSnapshot, TableSchema } from "@/types/online-table";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server"; import { getConvexAuthedHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
const fallbackSchema: TableSchema = { const fallbackSchema: TableSchema = {
@@ -25,7 +24,7 @@ export async function POST(request: Request) {
// Convex 模式 // Convex 模式
if (isConvexEnabled()) { if (isConvexEnabled()) {
try { try {
const client = getConvexHttpClient(); const client = await getConvexAuthedHttpClient();
const tableData = await client.query(api.tables.getByGridKeyFull, { gridKey }); const tableData = await client.query(api.tables.getByGridKeyFull, { gridKey });
if (!tableData) { if (!tableData) {
@@ -54,6 +53,9 @@ export async function POST(request: Request) {
} }
// Supabase 模式 // Supabase 模式
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { data, error } = await supabase const { data, error } = await supabase
.from("document_tables") .from("document_tables")
@@ -80,4 +82,5 @@ export async function POST(request: Request) {
return new NextResponse(body, { return new NextResponse(body, {
headers: { "Content-Type": "text/plain; charset=utf-8" }, headers: { "Content-Type": "text/plain; charset=utf-8" },
}); });
*/
} }
@@ -3,12 +3,14 @@
import { Buffer } from "node:buffer"; import { Buffer } from "node:buffer";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
const BUCKET = "media"; const BUCKET = "media";
const DIRECTORY = "luckysheet"; const DIRECTORY = "luckysheet";
export async function POST(request: Request) { export async function POST(request: Request) {
return NextResponse.json({ code: 501, msg: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get("image"); const file = formData.get("image");
@@ -39,4 +41,5 @@ export async function POST(request: Request) {
msg: "ok", msg: "ok",
url: publicUrl, url: publicUrl,
}); });
*/
} }
@@ -1,10 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server"; import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -29,7 +28,7 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
} }
const client = getConvexHttpClient(); const client = await getConvexAuthedHttpClient();
const items = await client.query(api.mediaAssets.listByWorkspace, { const items = await client.query(api.mediaAssets.listByWorkspace, {
userId: auth.userId, userId: auth.userId,
workspaceId, workspaceId,
@@ -41,7 +40,9 @@ export async function GET(request: Request) {
return NextResponse.json({ items: (items ?? []) as MediaAsset[] }); return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
const supabase = null as any;
const { const {
data: { user }, data: { user },
error: authError, error: authError,
@@ -130,7 +131,7 @@ export async function POST(request: Request) {
updated_at: now, updated_at: now,
}; };
const client = getConvexHttpClient(); const client = await getConvexAuthedHttpClient();
await client.mutation(api.mediaAssets.create, { await client.mutation(api.mediaAssets.create, {
userId: auth.userId, userId: auth.userId,
asset: { asset: {
@@ -151,7 +152,9 @@ export async function POST(request: Request) {
return NextResponse.json({ asset }); return NextResponse.json({ asset });
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
const supabase = null as any;
const { const {
data: { user }, data: { user },
error: authError, error: authError,
@@ -1,12 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { makeUniqueFileName } from "@/lib/file-tree/naming"; import { makeUniqueFileName } from "@/lib/file-tree/naming";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server"; import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -84,7 +83,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 }); return NextResponse.json({ error: "缺少参数" }, { status: 400 });
} }
const client = getConvexHttpClient(); const client = await getConvexAuthedHttpClient();
const assets = (await client.query(api.mediaAssets.listByIds, { const assets = (await client.query(api.mediaAssets.listByIds, {
userId: auth.userId, userId: auth.userId,
ids: payload.assetIds, ids: payload.assetIds,
@@ -142,7 +141,6 @@ export async function POST(request: Request) {
} }
const targetDoc = await client.query(api.documents.getMeta, { const targetDoc = await client.query(api.documents.getMeta, {
userId: auth.userId,
id: payload.targetDocumentId, 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 supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -410,4 +411,5 @@ export async function POST(request: Request) {
const message = err instanceof Error ? err.message : "操作失败"; const message = err instanceof Error ? err.message : "操作失败";
return NextResponse.json({ error: message }, { status: 500 }); return NextResponse.json({ error: message }, { status: 500 });
} }
*/
} }
@@ -1,9 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server"; import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -45,7 +44,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
} }
const client = getConvexHttpClient(); const client = await getConvexAuthedHttpClient();
const res = await client.mutation(api.mediaAssets.emptyTrashByWorkspace, { const res = await client.mutation(api.mediaAssets.emptyTrashByWorkspace, {
userId: auth.userId, userId: auth.userId,
workspaceId, workspaceId,
@@ -55,6 +54,9 @@ export async function POST(request: Request) {
return NextResponse.json({ success: true, ...(res as any) }); return NextResponse.json({ success: true, ...(res as any) });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -114,4 +116,5 @@ export async function POST(request: Request) {
} }
return NextResponse.json({ success: true, updated: assetIds.length }); return NextResponse.json({ success: true, updated: assetIds.length });
*/
} }
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled"; 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 模式暂不支持 OCR" }, { status: 501 });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -51,4 +53,5 @@ export async function POST(request: Request) {
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
*/
} }
@@ -1,9 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server"; import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -45,7 +44,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 }); return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
} }
const client = getConvexHttpClient(); const client = await getConvexAuthedHttpClient();
const res = await client.mutation(api.mediaAssets.purgeById, { const res = await client.mutation(api.mediaAssets.purgeById, {
userId: auth.userId, userId: auth.userId,
id: assetId, id: assetId,
@@ -55,6 +54,9 @@ export async function POST(request: Request) {
return NextResponse.json({ success: true, ...(res as any) }); return NextResponse.json({ success: true, ...(res as any) });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -115,4 +117,5 @@ export async function POST(request: Request) {
} }
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
*/
} }
+7 -159
View File
@@ -1,74 +1,19 @@
import { NextResponse } from "next/server"; 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 { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api"; 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"; 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) { export async function GET(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
let auth; let auth;
let client;
try { try {
auth = await requireAuthContext(); const authed = await getAuthedConvexClient();
auth = authed.auth;
client = authed.client;
} catch (err) { } catch (err) {
if (err instanceof HttpError) { if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status }); return NextResponse.json({ error: "未登录" }, { status: err.status });
@@ -83,7 +28,6 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "缺少 assetId" }, { status: 400 }); return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
} }
const client = getConvexHttpClient();
const asset = await client.query(api.mediaAssets.getById, { userId: auth.userId, id: assetId }); const asset = await client.query(api.mediaAssets.getById, { userId: auth.userId, id: assetId });
if (!asset) { if (!asset) {
return NextResponse.json({ error: "资源不存在" }, { status: 404 }); return NextResponse.json({ error: "资源不存在" }, { status: 404 });
@@ -106,101 +50,5 @@ export async function GET(request: Request) {
}); });
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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,
},
});
} }
@@ -1,102 +1,11 @@
import { NextResponse } from "next/server"; 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 { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
export const dynamic = "force-dynamic"; 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) { export async function GET(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
try { try {
await requireAuthContext(); await requireAuthContext();
} catch (err) { } catch (err) {
@@ -111,105 +20,12 @@ export async function GET(request: Request) {
if (!fileUrl) { if (!fileUrl) {
return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 }); return NextResponse.json({ error: "缺少 fileUrl" }, { status: 400 });
} }
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
return NextResponse.json({ signedUrl: fileUrl }); return NextResponse.json({ signedUrl: fileUrl });
} }
// 说明:在 Cloudflare Tunnel 场景下,后端收到的 Host 可能是 localhost return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
// 但 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:不要强制 downloadContent-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() });
} }
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
import { extname } from "path"; import { extname } from "path";
import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin";
@@ -7,7 +6,7 @@ import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { HttpError, requireAuthContext } from "@/lib/auth/authContext"; import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { getConvexHttpClient } from "@/lib/convex/server"; import { getConvexAuthedHttpClient } from "@/lib/convex/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -50,7 +49,7 @@ export async function POST(request: Request) {
? crypto.randomUUID() ? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`; : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const assetType = resolveAssetType(file.type || ""); const assetType = resolveAssetType(file.type || "");
const client = getConvexHttpClient(); const client = await getConvexAuthedHttpClient();
// 1) 获取 Convex 的上传 URL(短时有效) // 1) 获取 Convex 的上传 URL(短时有效)
const uploadUrl = await client.mutation(api.mediaAssets.generateUploadUrl, { userId: auth.userId }); 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 supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -190,4 +192,5 @@ export async function POST(request: Request) {
console.error(error); console.error(error);
return NextResponse.json({ error: "上传失败" }, { status: 500 }); return NextResponse.json({ error: "上传失败" }, { status: 500 });
} }
*/
} }
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig"; import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
import { loadLocalAiConfig } from "@/lib/ai/localAiConfig"; import { loadLocalAiConfig } from "@/lib/ai/localAiConfig";
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat"; 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: "缺少 documentId/mindmapId/messages" }, { status: 400 });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -833,4 +835,5 @@ export async function POST(request: Request) {
}, },
{ status: 200 }, { status: 200 },
); );
*/
} }
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { detectLocalMindmapFiles } from "@/lib/server/mindmap-files"; import { detectLocalMindmapFiles } from "@/lib/server/mindmap-files";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
import path from "path"; import path from "path";
@@ -40,6 +39,9 @@ async function listTestPdfs(): Promise<AgentAssetItem[]> {
} }
export async function GET(request: Request) { export async function GET(request: Request) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -115,4 +117,5 @@ export async function GET(request: Request) {
documentId, documentId,
items, items,
}); });
*/
} }
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig"; import { loadOnlineAiConfig } from "@/lib/ai/onlineAiConfig";
import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat"; import { openAiCompatibleChat, tryExtractJsonObject } from "@/lib/ai/openaiCompatibleChat";
import { applyMindmapOps, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps"; 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: "缺少 documentId/mindmapId/targetUid" }, { status: 400 });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -308,4 +310,5 @@ export async function POST(request: Request) {
searxCount: searxResults.length, searxCount: searxResults.length,
}, },
}); });
*/
} }
@@ -1,101 +1,30 @@
import { NextResponse } from "next/server"; 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 { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const preferredBaseDir = getDocumentsBaseDir();
const legacyBaseDir = getLegacyMindmapsBaseDir();
interface EmptyTrashPayload { interface EmptyTrashPayload {
workspaceId?: string; 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) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { client } = await getAuthedConvexClient();
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({})); const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
if (!workspaceId) { if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
} }
try { try {
const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, { const result = await client.mutation(api.mindmaps.emptyTrashByWorkspace, { workspaceId });
userId: auth.userId,
workspaceId,
});
return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 }); return NextResponse.json({ ok: true, removed: result?.deletedCount ?? 0 });
} catch (error) { } catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 }); return NextResponse.json({ error: (error as Error).message }, { status: 400 });
} }
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
} }
@@ -1,7 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { applyMindmapOps, type MindmapOp } from "@/lib/mindmap/mindmapOps"; import { applyMindmapOps, type MindmapOp } from "@/lib/mindmap/mindmapOps";
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
@@ -24,7 +22,7 @@ export async function POST(
const { docId, mindmapId } = await params; const { docId, mindmapId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { client } = await getAuthedConvexClient();
const payload = (await request.json().catch(() => null)) as RequestPayload | null; const payload = (await request.json().catch(() => null)) as RequestPayload | null;
const ops = Array.isArray(payload?.ops) ? payload!.ops : []; const ops = Array.isArray(payload?.ops) ? payload!.ops : [];
@@ -32,21 +30,15 @@ export async function POST(
return NextResponse.json({ error: "缺少 ops" }, { status: 400 }); return NextResponse.json({ error: "缺少 ops" }, { status: 400 });
} }
if (ops.length > 80) { if (ops.length > 80) {
return NextResponse.json({ error: "ops 过多(最 80" }, { status: 400 }); return NextResponse.json({ error: "ops 过多(最 80" }, { status: 400 });
} }
try { try {
const current = await client.query(api.mindmaps.get, { const current = await client.query(api.mindmaps.get, { docId, mindmapId });
userId: auth.userId,
docId,
mindmapId,
});
const baseData = current?.data ?? defaultMindmapData; const baseData = current?.data ?? defaultMindmapData;
const { data: nextData, applied, errors } = applyMindmapOps(baseData, ops); const { data: nextData, applied, errors } = applyMindmapOps(baseData, ops);
await client.mutation(api.mindmaps.put, { await client.mutation(api.mindmaps.put, {
userId: auth.userId,
docId, docId,
mindmapId, mindmapId,
data: nextData, data: nextData,
@@ -69,54 +61,6 @@ export async function POST(
} }
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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,
},
});
} }
@@ -1,8 +1,4 @@
import { NextResponse } from "next/server"; 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 { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
@@ -12,104 +8,6 @@ const defaultMindmapData = {
children: [], 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( export async function GET(
_req: Request, _req: Request,
{ params }: { params: Promise<{ docId: string; mindmapId: string }> }, { params }: { params: Promise<{ docId: string; mindmapId: string }> },
@@ -117,51 +15,12 @@ export async function GET(
const { docId, mindmapId } = await params; const { docId, mindmapId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { client } = await getAuthedConvexClient();
const res = await client.query(api.mindmaps.get, { const res = await client.query(api.mindmaps.get, { docId, mindmapId });
userId: auth.userId,
docId,
mindmapId,
});
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" }); return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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" });
} }
export async function POST( export async function POST(
@@ -171,7 +30,7 @@ export async function POST(
const { docId, mindmapId } = await params; const { docId, mindmapId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { client } = await getAuthedConvexClient();
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as { const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
data?: unknown; data?: unknown;
createOnly?: boolean; createOnly?: boolean;
@@ -179,59 +38,18 @@ export async function POST(
try { try {
const result = await client.mutation(api.mindmaps.put, { const result = await client.mutation(api.mindmaps.put, {
userId: auth.userId,
docId, docId,
mindmapId, mindmapId,
data: data ?? defaultMindmapData, data: data ?? defaultMindmapData,
...(typeof createOnly === "boolean" ? { createOnly } : {}), ...(typeof createOnly === "boolean" ? { createOnly } : {}),
}); });
return NextResponse.json({ ok: true, ...(result ?? {}) }); return NextResponse.json(result ?? { ok: true });
} catch (error) { } catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 }); return NextResponse.json({ error: (error as Error).message }, { status: 400 });
} }
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
} }
export async function DELETE( export async function DELETE(
@@ -241,72 +59,16 @@ export async function DELETE(
const { docId, mindmapId } = await params; const { docId, mindmapId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { client } = await getAuthedConvexClient();
try { try {
const result = await client.mutation(api.mindmaps.softDelete, { const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
userId: auth.userId, return NextResponse.json(result ?? { ok: true });
docId,
mindmapId,
});
return NextResponse.json({ ok: true, ...(result ?? {}) });
} catch (error) { } catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 400 }); return NextResponse.json({ error: (error as Error).message }, { status: 400 });
} }
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
} }
export async function PATCH( export async function PATCH(
@@ -316,7 +78,7 @@ export async function PATCH(
const { docId, mindmapId } = await params; const { docId, mindmapId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { client } = await getAuthedConvexClient();
const { action } = (await request.json().catch(() => ({}))) as { action?: string }; const { action } = (await request.json().catch(() => ({}))) as { action?: string };
if (action !== "restore" && action !== "purge") { if (action !== "restore" && action !== "purge") {
return NextResponse.json({ error: "不支持的操作" }, { status: 400 }); return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
@@ -324,20 +86,11 @@ export async function PATCH(
try { try {
if (action === "purge") { if (action === "purge") {
const result = await client.mutation(api.mindmaps.purge, { const result = await client.mutation(api.mindmaps.purge, { docId, mindmapId });
userId: auth.userId, return NextResponse.json(result ?? { ok: true });
docId,
mindmapId,
});
return NextResponse.json({ ok: true, ...(result ?? {}) });
} }
const result = await client.mutation(api.mindmaps.restore, { docId, mindmapId });
const result = await client.mutation(api.mindmaps.restore, { return NextResponse.json(result ?? { ok: true });
userId: auth.userId,
docId,
mindmapId,
});
return NextResponse.json({ ok: true, ...(result ?? {}) });
} catch (error) { } catch (error) {
const msg = (error as Error).message ?? "操作失败"; const msg = (error as Error).message ?? "操作失败";
const status = msg.includes("未找到") ? 404 : 400; const status = msg.includes("未找到") ? 404 : 400;
@@ -345,68 +98,6 @@ export async function PATCH(
} }
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
} }
@@ -1,8 +1,4 @@
import { NextResponse } from "next/server"; 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 { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
@@ -12,189 +8,56 @@ const defaultMindmapData = {
children: [], 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( export async function GET(
_req: Request, _req: Request,
{ params }: { params: Promise<{ docId: string }> }, { params }: { params: Promise<{ docId: string }> },
) { ) {
const { docId: id } = await params; const { docId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { client } = await getAuthedConvexClient();
const mindmapId = `legacy-${id}`; const mindmapId = `legacy-${docId}`;
const res = await client.query(api.mindmaps.get, { const res = await client.query(api.mindmaps.get, { docId, mindmapId });
userId: auth.userId,
docId: id,
mindmapId,
});
return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" }); return NextResponse.json({ data: res?.data ?? defaultMindmapData, source: "convex" });
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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" });
} }
export async function POST( export async function POST(
request: Request, request: Request,
{ params }: { params: Promise<{ docId: string }> }, { params }: { params: Promise<{ docId: string }> },
) { ) {
const { docId: id } = await params; const { docId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { client } = await getAuthedConvexClient();
const mindmapId = `legacy-${id}`; const mindmapId = `legacy-${docId}`;
const { data } = await request.json(); const payload = (await request.json().catch(() => ({}))) as { data?: unknown };
const result = await client.mutation(api.mindmaps.put, { const result = await client.mutation(api.mindmaps.put, {
userId: auth.userId, docId,
docId: id,
mindmapId, mindmapId,
data: data ?? defaultMindmapData, data: payload.data ?? defaultMindmapData,
}); });
return NextResponse.json({ ok: true, ...(result ?? {}) }); return NextResponse.json(result ?? { ok: true });
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
} }
export async function DELETE( export async function DELETE(
_req: Request, _req: Request,
{ params }: { params: Promise<{ docId: string }> }, { params }: { params: Promise<{ docId: string }> },
) { ) {
const { docId: id } = await params; const { docId } = await params;
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { client } = await getAuthedConvexClient();
const mindmapId = `legacy-${id}`; const mindmapId = `legacy-${docId}`;
const result = await client.mutation(api.mindmaps.softDelete, { const result = await client.mutation(api.mindmaps.softDelete, { docId, mindmapId });
userId: auth.userId, return NextResponse.json(result ?? { ok: true });
docId: id,
mindmapId,
});
return NextResponse.json({ ok: true, ...(result ?? {}) });
} }
const supabase = await createSupabaseRouteClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
} }
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import supabaseAdmin from "@/lib/supabase/admin";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server"; import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
@@ -133,34 +132,5 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 1 }); return NextResponse.json({ error: 1 });
} }
} }
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 });
} }
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
@@ -28,6 +27,9 @@ export async function GET(request: Request) {
return NextResponse.json({ backlinks }); return NextResponse.json({ backlinks });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -73,4 +75,5 @@ export async function GET(request: Request) {
} }
return NextResponse.json({ backlinks: data ?? [] }); return NextResponse.json({ backlinks: data ?? [] });
*/
} }
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
@@ -43,6 +42,9 @@ export async function POST(request: Request) {
return NextResponse.json({ reference }); return NextResponse.json({ reference });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -92,4 +94,5 @@ export async function POST(request: Request) {
} }
return NextResponse.json({ reference: data }); return NextResponse.json({ reference: data });
*/
} }
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
@@ -34,211 +33,12 @@ const TIME_FIELD_COLUMN = {
created: "created_at", created: "created_at",
} as const; } 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) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (!isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
} }
const { auth, client } = await getAuthedConvexClient();
const payload = (await request.json()) as DocumentSearchRequest; const payload = (await request.json()) as DocumentSearchRequest;
const workspaceId = payload.workspaceId; const workspaceId = payload.workspaceId;
@@ -251,140 +51,82 @@ export async function POST(request: Request) {
...payload.filters, ...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 limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
const normalizedQuery = payload.query?.trim() ?? ""; const normalizedQuery = payload.query?.trim() ?? "";
const likePattern = filters.exact ? normalizedQuery : `%${escapeLike(normalizedQuery)}%`; const normalizedLower = normalizedQuery.toLowerCase();
const timeColumn = TIME_FIELD_COLUMN[filters.timeField ?? "updated"];
let builder = supabase const docs = await client.query(api.documents.listByWorkspace, { workspaceId });
.from("documents") const docMap = new Map(docs.map((d) => [d.id, d]));
.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);
if (filters.onlyCurrentPage && payload.documentId) { const recentRows = await client.query(api.recents.listByWorkspace, {
builder = builder.eq("id", payload.documentId); 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 timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange];
const customTo = filters.customRange?.to ? buildIsoBoundary(filters.customRange.to, true) : null; const timeField = TIME_FIELD_COLUMN[filters.timeField];
const boundaryIso =
typeof timeRangeMs === "number"
? new Date(Date.now() - timeRangeMs).toISOString()
: null;
if (customFrom) { const narrowed = [...docs]
builder = builder.gte(timeColumn, customFrom); .sort((a, b) => {
} const ta = a.updated_at ?? a.created_at ?? "";
if (customTo) { const tb = b.updated_at ?? b.created_at ?? "";
builder = builder.lte(timeColumn, customTo); return tb.localeCompare(ta);
} })
.filter((row) => {
if (!customFrom && !customTo) { if (filters.onlyCurrentPage && payload.documentId) {
const now = Date.now(); if (row.id !== payload.documentId) return false;
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}`);
} }
builder = builder.or(clauses.join(","));
}
}
const shouldSearchOcr = Boolean(filters.includeOcr && normalizedQuery); if (boundaryIso) {
const [{ data, error }, recentResults, ocrMatches] = await Promise.all([ const ts = (row as any)?.[timeField] ?? null;
builder, if (!ts || typeof ts !== "string") return false;
fetchRecentResults(supabase, session.user.id, workspaceId), if (ts < boundaryIso) return false;
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);
} }
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 = { const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({
results, id: row.id,
recent: recentResults, 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); return NextResponse.json(response);
} }
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
@@ -28,6 +27,9 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -72,4 +74,5 @@ export async function POST(request: Request) {
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
*/
} }
+6 -18
View File
@@ -1,19 +1,9 @@
import { NextResponse } from "next/server"; 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 type { SidebarInitialData } from "@/components/sidebar/types";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import {
detectLocalMindmapFiles,
detectLocalMindmapDocs,
detectLocalMindmapImageAssetIdsByMindmapId,
detectLocalTrashedMindmapAssets,
} from "@/lib/server/mindmap-files";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -72,20 +62,17 @@ function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
export async function GET(request: Request) { export async function GET(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext(); const { auth, client } = await getAuthedConvexClient();
const client = getConvexHttpClient();
const url = new URL(request.url); const url = new URL(request.url);
const workspaceIdParam = url.searchParams.get("workspaceId"); const workspaceIdParam = url.searchParams.get("workspaceId");
const bootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, { const bootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
userId: auth.userId,
fallbackName: auth.email ?? auth.name ?? "我的空间", fallbackName: auth.email ?? auth.name ?? "我的空间",
workspaceIdIfCreate: randomUUID(), workspaceIdIfCreate: randomUUID(),
}); });
const summaries = await client.query(api.workspaces.fetchWorkspaceSummaries, { const summaries = await client.query(api.workspaces.fetchWorkspaceSummaries, {
userId: auth.userId,
}); });
const workspaces = summaries.workspaces.length > 0 ? summaries.workspaces : bootstrap.workspaces; const workspaces = summaries.workspaces.length > 0 ? summaries.workspaces : bootstrap.workspaces;
@@ -98,17 +85,14 @@ export async function GET(request: Request) {
try { try {
const documents = await client.query(api.documents.listByWorkspace, { const documents = await client.query(api.documents.listByWorkspace, {
userId: auth.userId,
workspaceId: targetWorkspaceId, workspaceId: targetWorkspaceId,
}); });
const trashedDocuments = await client.query(api.documents.listTrashedByWorkspace, { const trashedDocuments = await client.query(api.documents.listTrashedByWorkspace, {
userId: auth.userId,
workspaceId: targetWorkspaceId, workspaceId: targetWorkspaceId,
}); });
const mindmapRows = await client.query(api.mindmaps.listByWorkspace, { const mindmapRows = await client.query(api.mindmaps.listByWorkspace, {
userId: auth.userId,
workspaceId: targetWorkspaceId, workspaceId: targetWorkspaceId,
includeDeleted: true, includeDeleted: true,
}); });
@@ -200,6 +184,9 @@ export async function GET(request: Request) {
} }
} }
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { const {
data: { session }, data: { session },
@@ -301,4 +288,5 @@ export async function GET(request: Request) {
{ status: 500 }, { status: 500 },
); );
} }
*/
} }
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { DocumentTableSnapshot, TableRowData } from "@/types/online-table"; import type { DocumentTableSnapshot, TableRowData } from "@/types/online-table";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
@@ -25,25 +24,12 @@ const extractTableId = async (context: RouteContext) => {
return (await context.params).tableId; return (await context.params).tableId;
}; };
const getAuthUser = async () => { export async function GET(_request: Request, context: RouteContext) {
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) {
const tableId = await extractTableId(context); const tableId = await extractTableId(context);
if (!tableId) { if (!tableId) {
return NextResponse.json({ error: "Missing tableId" }, { status: 400 }); return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
} }
// Convex 模式
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
try { try {
@@ -63,36 +49,7 @@ export async function GET(request: Request, context: RouteContext) {
} }
} }
// Supabase 模式 return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
}
} }
export async function PATCH(request: Request, context: RouteContext) { 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 }); return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
} }
// Convex 模式
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
let body: UpdateTableRequest;
try { 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, userId: auth.userId,
tableId, tableId,
title: body.title, title: body.title,
@@ -117,7 +79,6 @@ export async function PATCH(request: Request, context: RouteContext) {
rows: body.rows, rows: body.rows,
}); });
// 重新获取更新后的表格数据
const updated = await client.query(api.tables.get, { const updated = await client.query(api.tables.get, {
userId: auth.userId, userId: auth.userId,
tableId, tableId,
@@ -130,125 +91,15 @@ export async function PATCH(request: Request, context: RouteContext) {
} }
} }
// Supabase 模式 return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
}
} }
export async function DELETE(request: Request, context: RouteContext) { export async function DELETE(_request: Request, context: RouteContext) {
const tableId = await extractTableId(context); const tableId = await extractTableId(context);
if (!tableId) { if (!tableId) {
return NextResponse.json({ error: "Missing tableId" }, { status: 400 }); return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
} }
// Convex 模式
if (isConvexEnabled()) { if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient(); const { auth, client } = await getAuthedConvexClient();
try { try {
@@ -264,37 +115,6 @@ export async function DELETE(request: Request, context: RouteContext) {
} }
} }
// Supabase 模式 return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
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 });
}
} }
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { DocumentTableSnapshot, TableSchema } from "@/types/online-table"; import { DocumentTableSnapshot, TableSchema } from "@/types/online-table";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
@@ -27,7 +26,6 @@ export async function POST(request: Request) {
// 获取 document 所在的 workspace_id // 获取 document 所在的 workspace_id
const document = await client.query(api.documents.getMeta, { const document = await client.query(api.documents.getMeta, {
userId: auth.userId,
id: documentId, id: documentId,
}); });
@@ -54,6 +52,9 @@ export async function POST(request: Request) {
} }
// Supabase 模式 // Supabase 模式
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
/*
const supabase = await createSupabaseRouteClient(); const supabase = await createSupabaseRouteClient();
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
@@ -112,4 +113,5 @@ export async function POST(request: Request) {
console.error("API error:", error); console.error("API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }); return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
} }
*/
} }
@@ -1,75 +1,24 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
const auth = await requireAuthContext(); const { client } = await getAuthedConvexClient();
const payload = await request.json().catch(() => ({})); const payload = await request.json().catch(() => ({}));
const workspaceId = payload.workspaceId as string | undefined; const workspaceId = payload.workspaceId as string | undefined;
if (!workspaceId) { if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
} }
const client = getConvexHttpClient();
await client.mutation(api.workspaces.switchDefaultWorkspace, { await client.mutation(api.workspaces.switchDefaultWorkspace, {
userId: auth.userId,
workspaceId, workspaceId,
}); });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} }
return NextResponse.json({ error: "仅支持 Convex 模式" }, { status: 501 });
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 });
} }
+155
View File
@@ -43,6 +43,17 @@
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --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 { :root {
@@ -87,6 +98,45 @@ body,
min-height: 100%; 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 { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
@@ -510,4 +560,109 @@ body,
#luckysheet-rich-text-editor { #luckysheet-rich-text-editor {
z-index: 100 !important; 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;
}
} }
+5 -23
View File
@@ -1,10 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { SupabaseProvider } from "@/components/providers/supabase-provider";
import { QueryProvider } from "@/components/providers/query-provider"; import { QueryProvider } from "@/components/providers/query-provider";
import { ConvexClientProvider } from "@/components/providers/convex-provider"; import { ConvexClientProvider } from "@/components/providers/convex-provider";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server"; import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
@@ -16,7 +14,7 @@ const inter = Inter({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Wolai Clone", title: "Wolai Clone",
description: "Stage0 + Stage1 implementation powered by Supabase", description: "Convex 自部署模式",
}; };
export default async function RootLayout({ export default async function RootLayout({
@@ -27,16 +25,6 @@ export default async function RootLayout({
const isDesktop = process.env.MNOTE_DESKTOP === "1"; const isDesktop = process.env.MNOTE_DESKTOP === "1";
const useConvex = isConvexEnabled(); 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 runtimeConfig = { ...getMnoteRuntimeConfig(), isDesktop, useConvex };
const runtimeConfigJson = JSON.stringify(runtimeConfig).replace(/</g, "\\u003cc"); const runtimeConfigJson = JSON.stringify(runtimeConfig).replace(/</g, "\\u003cc");
@@ -52,17 +40,11 @@ export default async function RootLayout({
/> />
</head> </head>
<body className={`${inter.variable} antialiased`}> <body className={`${inter.variable} antialiased`}>
{useConvex ? ( <ConvexAuthNextjsServerProvider>
<ConvexAuthNextjsServerProvider> <ConvexClientProvider>
<ConvexClientProvider>
<QueryProvider>{children}</QueryProvider>
</ConvexClientProvider>
</ConvexAuthNextjsServerProvider>
) : (
<SupabaseProvider session={session}>
<QueryProvider>{children}</QueryProvider> <QueryProvider>{children}</QueryProvider>
</SupabaseProvider> </ConvexClientProvider>
)} </ConvexAuthNextjsServerProvider>
</body> </body>
</html> </html>
); );
+22 -71
View File
@@ -1,9 +1,6 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { ensureDefaultWorkspace, resolveActiveWorkspaceId } from "@/lib/workspaces";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { requireAuthContext } from "@/lib/auth/authContext"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
function makeId(): string { function makeId(): string {
@@ -13,80 +10,34 @@ function makeId(): string {
} }
export default async function Home() { export default async function Home() {
if (isConvexEnabled()) { if (!isConvexEnabled()) {
const auth = await requireAuthContext(); redirect("/auth");
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}`);
} }
const supabase = await createSupabaseServerClient(); const { auth, client } = await getAuthedConvexClient();
const session = (await supabase.auth.getSession()).data.session;
if (!session) { const { activeWorkspaceId } = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
redirect("/login"); fallbackName: auth.name ?? auth.email ?? "我的空间",
} workspaceIdIfCreate: makeId(),
});
await ensureDefaultWorkspace(supabase, session.user.id, session.user.email ?? "我的空间"); const docs = await client.query(api.documents.listByWorkspace, {
const workspaceId = await resolveActiveWorkspaceId(supabase, session.user.id); workspaceId: activeWorkspaceId,
});
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 firstDoc = [...docs].sort((a, b) => (a.created_at ?? "").localeCompare(b.created_at ?? ""))[0];
if (firstDoc?.id) { if (firstDoc?.id) {
redirect(`/documents/${firstDoc.id}`); redirect(`/documents/${firstDoc.id}`);
} }
const { data: createdDoc, error } = await supabase const docId = makeId();
.from("documents") await client.mutation(api.documents.create, {
.insert({ id: docId,
user_id: session.user.id, workspaceId: activeWorkspaceId,
workspace_id: workspaceId, parentId: null,
title: "新页面", title: "新页面",
content: {}, accessScope: "private",
}) content: [],
.select("id") });
.single(); redirect(`/documents/${docId}`);
if (error || !createdDoc) {
redirect("/login");
}
redirect(`/documents/${createdDoc.id}`);
} }
+12 -6
View File
@@ -31,16 +31,19 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
return ( return (
<div className="flex flex-1 items-center justify-between"> <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) => { {displayPath.map((node, index) => {
const isLast = index === displayPath.length - 1; const isLast = index === displayPath.length - 1;
return ( return (
<span key={node.id} className="flex items-center"> <span key={node.id} className="flex items-center">
{index > 0 && <ChevronRight className="mx-1 h-4 w-4 text-gray-400" />} {index > 0 && <ChevronRight className="mx-1 h-4 w-4 text-gray-400" />}
{isLast ? ( {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 || "无标题"} {node.title || "无标题"}
</Link> </Link>
)} )}
@@ -48,14 +51,17 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
); );
})} })}
</nav> </nav>
<div className="flex items-center gap-2 text-gray-500"> <div className="flex items-center gap-2 text-wolai-text-secondary">
<button type="button" className="wolai-hover rounded-full px-3 py-1 text-sm"> <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" /> <Star className="mr-1 inline h-4 w-4" />
</button> </button>
<button <button
type="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} onClick={toggleInspector}
aria-label={showInspector ? "隐藏页面选项" : "显示页面选项"} aria-label={showInspector ? "隐藏页面选项" : "显示页面选项"}
title={showInspector ? "隐藏页面选项" : "显示页面选项"} title={showInspector ? "隐藏页面选项" : "显示页面选项"}
@@ -354,15 +354,46 @@ const MediaBlockContent = ({ block, editor }: any) => {
); );
} }
if (assetType === "file") { 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 ( return (
<div className="flex items-center gap-3 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm"> <div
<span className="rounded-full bg-[#2563eb]/10 p-3 text-[#2563eb]"> role="button"
<Paperclip className="h-5 w-5" /> 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> </span>
<div className="flex-1"> <div className="flex flex-col justify-center gap-0.5 overflow-hidden">
<p className="text-sm font-medium text-gray-800">{displayFileName}</p> <p className="text-[14px] text-[#37352F] font-normal truncate">{displayFileName}</p>
{block.props.fileSize ? ( {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} ) : null}
</div> </div>
</div> </div>
@@ -1,11 +1,8 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react";
import { createReactBlockSpec } from "@blocknote/react"; import { createReactBlockSpec } from "@blocknote/react";
import { RiFileTextFill } from "react-icons/ri"; import { RiFileTextFill } from "react-icons/ri";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
const normalizeTitle = (value?: string | null) => { const normalizeTitle = (value?: string | null) => {
if (!value || !value.trim()) { if (!value || !value.trim()) {
@@ -16,65 +13,9 @@ const normalizeTitle = (value?: string | null) => {
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => { const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
const router = useRouter(); const router = useRouter();
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []); // 说明:Convex 迁移阶段,直接使用 block props 里的 title,不做实时订阅/拉取。
const fallbackTitle = normalizeTitle(title); // 页面引用的标题会由编辑器同步更新 block.props.title。
const [resolvedTitle, setResolvedTitle] = useState(fallbackTitle); const resolvedTitle = normalizeTitle(title);
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]);
const navigate = () => { const navigate = () => {
if (pageId) { if (pageId) {
@@ -93,13 +34,13 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
navigate(); 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" }} style={{ fontFamily: "Inter, system-ui, sans-serif" }}
> >
<RiFileTextFill className="text-xl" aria-hidden /> <RiFileTextFill className="w-4 h-4 text-[#9B9A97] group-hover:text-[#37352F] transition-colors" aria-hidden />
<span className="text-base font-medium leading-none">{resolvedTitle}</span> <span className="text-[15px] font-medium leading-normal">{resolvedTitle}</span>
<span className="ml-auto text-sm opacity-0 transition-opacity duration-150 group-hover:opacity-100"> <span className="text-[13px] text-[#9B9A97] opacity-0 group-hover:opacity-100 ml-2">
</span> </span>
</div> </div>
); );
@@ -115,8 +56,6 @@ export const pageReferenceBlock = createReactBlockSpec(
content: "none", content: "none",
}, },
() => ({ () => ({
render: ({ block }) => ( render: ({ block }) => <PageReferenceContent pageId={block.props.pageId} title={block.props.title} />,
<PageReferenceContent pageId={block.props.pageId} title={block.props.title} />
),
}), }),
)(); )();
@@ -307,9 +307,9 @@ export function DocumentContent({
return ( return (
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}> <ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
<div className="flex h-full overflow-hidden bg-white"> <div className="flex h-full overflow-hidden bg-wolai-bg">
<div className="flex h-full flex-1 flex-col overflow-hidden"> <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"> <div className="relative">
<input <input
value={pageTitle} value={pageTitle}
@@ -317,7 +317,7 @@ export function DocumentContent({
onBlur={handleTitleBlur} onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown} onKeyDown={handleTitleKeyDown}
placeholder="无标题" 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="页面标题" aria-label="页面标题"
disabled={options.protectEditing} disabled={options.protectEditing}
/> />
@@ -325,7 +325,7 @@ export function DocumentContent({
{options.protectEditing && ( {options.protectEditing && (
<p className="mt-1 text-sm text-[#b91c1c]"></p> <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>
<div className="flex-1 overflow-y-auto px-12 py-6"> <div className="flex-1 overflow-y-auto px-12 py-6">
{contentLoading ? ( {contentLoading ? (
@@ -14,8 +14,6 @@ import {
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader"; import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { extractRowsForPreview } from "@/components/online-table/utils"; import { extractRowsForPreview } from "@/components/online-table/utils";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
type LuckysheetSelection = type LuckysheetSelection =
| { | {
@@ -53,15 +51,6 @@ const VIEWER_CONTAINER_PREFIX = "headless-table-viewer-";
const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false, editable }) => { const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embed = false, editable }) => {
const containerId = useMemo(() => `${VIEWER_CONTAINER_PREFIX}${tableId}`, [tableId]); 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 containerRef = useRef<HTMLDivElement>(null);
const isLuckysheetReady = useLuckysheetLoader(); const isLuckysheetReady = useLuckysheetLoader();
const [table, setTable] = useState<DocumentTable | null>(null); const [table, setTable] = useState<DocumentTable | null>(null);
@@ -243,7 +232,8 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
useEffect(() => { useEffect(() => {
if (!tableId) return; if (!tableId) return;
if (!supabaseBrowser) return; // Supabase 已移除:此处不再做实时订阅
/* Supabase subscription removed
const channel = supabaseBrowser const channel = supabaseBrowser
.channel(`table-${tableId}-live`) .channel(`table-${tableId}-live`)
.on( .on(
@@ -271,7 +261,8 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
return () => { return () => {
supabaseBrowser.removeChannel(channel); supabaseBrowser.removeChannel(channel);
}; };
}, [tableId, supabaseBrowser]); */
}, [tableId]);
useEffect(() => { useEffect(() => {
if (!isLuckysheetReady || !table || !containerRef.current || !window.luckysheet) { 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.startIndex &&
index < dragOverRange.endIndex; index < dragOverRange.endIndex;
const baseClass = 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 = const activeClass =
row.kind === "index" row.kind === "index"
? "text-[#2563eb] font-medium" ? "text-[#2563eb]"
: row.kind === "doc" : row.kind === "doc"
? "text-[#2563eb]" ? "text-[#2563eb]"
: ""; : "";
@@ -135,9 +135,10 @@ export function FileTree({
className={cn( className={cn(
baseClass, baseClass,
active && activeClass, active && activeClass,
selected && "bg-[#e8f2ff] text-[#2563eb]", selected && "bg-wolai-bg-active text-[#2563eb]",
inDropFeedback && "bg-gray-200/70", inDropFeedback && "bg-gray-200/70",
)} )}
data-active={active}
style={{ paddingLeft }} style={{ paddingLeft }}
onClick={(event) => onRowClick(row, event)} onClick={(event) => onRowClick(row, event)}
onDoubleClick={(event) => onRowDoubleClick(row, event)} onDoubleClick={(event) => onRowDoubleClick(row, event)}
@@ -228,19 +229,19 @@ export function FileTree({
event.stopPropagation(); event.stopPropagation();
onToggleExpand(row.docId); 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 <ChevronRight
className={cn( 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", row.isExpanded && "rotate-90",
)} )}
/> />
</button> </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="min-w-0 flex-1 truncate text-left">{label}</span>
<button <button
type="button" type="button"
@@ -249,15 +250,15 @@ export function FileTree({
event.stopPropagation(); event.stopPropagation();
onCreateChild(row.docId); 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> </button>
</> </>
) : row.kind === "index" ? ( ) : row.kind === "index" ? (
<> <>
<span className="h-4 w-4" /> <span className="w-4 h-4 shrink-0" />
<FileText className="h-4 w-4 text-gray-500" /> <FileText className="w-4 h-4 text-wolai-text-secondary shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">{label}</span> <span className="min-w-0 flex-1 truncate text-left">{label}</span>
</> </>
) : row.kind === "asset-folder" ? ( ) : row.kind === "asset-folder" ? (
@@ -270,25 +271,25 @@ export function FileTree({
event.stopPropagation(); event.stopPropagation();
onToggleAssetFolderExpand?.(row.asset.id); 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 <ChevronRight
className={cn( 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", row.isExpanded && "rotate-90",
)} )}
/> />
</button> </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="min-w-0 flex-1 truncate text-left">{label}</span>
</> </>
) : ( ) : (
<> <>
<span className="h-4 w-4" /> <span className="w-4 h-4 shrink-0" />
<Paperclip className="h-4 w-4 text-gray-500" /> <Paperclip className="w-4 h-4 text-wolai-text-secondary shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">{label}</span> <span className="min-w-0 flex-1 truncate text-left">{label}</span>
</> </>
)} )}
@@ -198,36 +198,37 @@ function SortableTreeRow({
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={cn( className={cn(
"flex h-full items-center gap-1 border-b border-transparent px-2 text-sm text-gray-700 hover:bg-[#f5f7fb]", "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-[#e8f2ff] text-[#2563eb]", activeId === node.id && "bg-wolai-bg-active text-[#2563eb]",
isDragging && "opacity-60", isDragging && "opacity-60",
)} )}
data-active={activeId === node.id}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
> >
<button <button
type="button" type="button"
aria-label="拖拽排序" 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} {...listeners}
{...attributes} {...attributes}
> >
<GripVertical className="h-4 w-4" /> <GripVertical className="w-4 h-4" />
</button> </button>
<div <div
className="flex flex-1 items-center gap-2 rounded-md px-1 py-1" className="flex flex-1 items-center gap-2"
style={{ paddingLeft: depth * 14 }} style={{ paddingLeft: depth * 12 }}
> >
{hasChildren ? ( {hasChildren ? (
<button <button
type="button" type="button"
aria-label="展开或折叠" aria-label="展开或折叠"
onClick={onToggleExpand} 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> </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"> <Link href={`/documents/${node.id}`} className="flex-1 truncate text-left">
{node.title || "无标题"} {node.title || "无标题"}
@@ -237,9 +238,9 @@ function SortableTreeRow({
type="button" type="button"
aria-label="新建子页面" aria-label="新建子页面"
onClick={onCreateChild} 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>
<button <button
type="button" type="button"
@@ -248,9 +249,9 @@ function SortableTreeRow({
event.preventDefault(); event.preventDefault();
onContextMenu(event); 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> </button>
</div> </div>
); );
@@ -35,9 +35,9 @@ import { buildDocumentTree } from "@/lib/documents";
import { useSidebarStore } from "@/store/sidebar"; import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types"; import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
import { useSidebarData } from "@/hooks/use-sidebar-data"; import { useSidebarData } from "@/hooks/use-sidebar-data";
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
import { PrivateTree } from "@/components/sidebar/private-tree"; import { PrivateTree } from "@/components/sidebar/private-tree";
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree"; import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
import { getSupabaseBrowserClient } from "@/lib/supabase/client";
import { useSearchPaletteStore } from "@/store/search-palette"; import { useSearchPaletteStore } from "@/store/search-palette";
import { useEditorBridgeStore } from "@/store/editor-bridge"; import { useEditorBridgeStore } from "@/store/editor-bridge";
import { FileTree } from "@/components/sidebar/file-tree"; import { FileTree } from "@/components/sidebar/file-tree";
@@ -126,26 +126,56 @@ interface ContextMenuState {
y: number; y: number;
} }
// 主 Sidebar 组件:根据模式路由到不同的子组件
export function Sidebar({ initialData }: SidebarProps) { 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 } = const { open, setOpen, width, setWidth, collapsedSections, toggleSection, setSectionCollapsed, trashConfirm, setTrashConfirm } =
useSidebarStore(); 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 sectionsTrayOpen = useSidebarStore((state) => state.sectionsTrayOpen);
const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray); const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray);
const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen); const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen);
const viewMode = useSidebarStore((state) => state.viewMode); const viewMode = useSidebarStore((state) => state.viewMode);
const setViewMode = useSidebarStore((state) => state.setViewMode); const setViewMode = useSidebarStore((state) => state.setViewMode);
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch); const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
const sidebarQuery = useSidebarData(initialData);
const sidebarData = sidebarQuery.data ?? initialData; // 处理数据
const sidebarData = useMemo(() => {
return sidebarQuery.data ?? initialData;
}, [sidebarQuery, initialData]);
// isLoading 判断
const isLoading = sidebarQuery.isLoading;
const segments = useSelectedLayoutSegments(); const segments = useSelectedLayoutSegments();
const router = useRouter(); const router = useRouter();
const activeId = segments?.[1] ?? ""; const activeId = segments?.[1] ?? "";
@@ -253,45 +283,6 @@ export function Sidebar({ initialData }: SidebarProps) {
await sidebarQuery.refetch(); await sidebarQuery.refetch();
}, [sidebarQuery]); }, [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 sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]); const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.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) => { const handleEmbedPrompt = useCallback(async (node: DocumentNode) => {
openMoveEmbedPicker(node, "embed"); openMoveEmbedPicker(node, "embed");
return; }, [openMoveEmbedPicker]);
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("已在目标页面末尾插入引用块");
}, []);
const toggleExpand = useCallback((id: string) => { const toggleExpand = useCallback((id: string) => {
setExpanded((prev) => { setExpanded((prev) => {
@@ -1492,22 +1461,8 @@ export function Sidebar({ initialData }: SidebarProps) {
const handleMovePrompt = useCallback( const handleMovePrompt = useCallback(
async (node: DocumentNode) => { async (node: DocumentNode) => {
openMoveEmbedPicker(node, "move"); 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( const handleDelete = useCallback(
@@ -2035,8 +1990,8 @@ export function Sidebar({ initialData }: SidebarProps) {
return ( return (
<> <>
<aside className="hidden h-full min-w-0 overflow-hidden md:flex" style={{ width }}> <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-[#e9edf5] bg-white"> <div className="flex h-full min-w-0 flex-1 flex-col overflow-hidden border-r border-wolai-border bg-wolai-bg-sidebar">
{sidebarBody} {sidebarBody}
</div> </div>
<div className="w-1 cursor-col-resize bg-transparent" onMouseDown={handleResizeStart} /> <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; let resolved: ResolvedAttachment | null = null;
if (attachmentId) resolved = resolveAttachmentFromContext(args.ctx, attachmentId); if (attachmentId) resolved = resolveAttachmentFromContext(args.ctx, attachmentId);
if (!resolved && attachmentId) { // 说明:Convex 模式下不再从 Supabase 二次查询附件;优先使用前端传入的 attachments 或调用方显式传入 fileUrl。
const workspaceId = String((doc as any).workspace_id ?? "").trim(); if (!resolved && attachmentId && !fileUrlDirect) {
if (!workspaceId) throw new Error("缺少 workspace_id,无法解析附件"); throw new Error("未找到附件信息:请在调用工具时传入 fileUrl,或确保 attachments 包含该附件");
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,
};
}
} }
const finalUrl = (resolved?.fileUrl || fileUrlDirect || "").trim(); 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 { applyMindmapOps, ensureMindmapUids, type MindmapOp, type MindmapTreeNode, type NodeRef } from "@/lib/mindmap/mindmapOps";
import { readMindmapLocal, writeMindmapLocal } from "@/lib/mindmap/mindmapLocalStore"; 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; const fileName = typeof row.file_name === "string" ? row.file_name : undefined;
if (bucket && storagePath) { 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 失败"); if (error || !data?.signedUrl) throw new Error(error?.message ?? "生成签名 URL 失败");
return data.signedUrl;
} }
const parsed = fileUrl ? parseStoragePath(fileUrl) : null; const parsed = fileUrl ? parseStoragePath(fileUrl) : null;
if (!parsed) return fileUrl; 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 失败"); if (error || !data?.signedUrl) throw new Error(error?.message ?? "生成签名 URL 失败");
return data.signedUrl; return data.signedUrl;
}; };
+5 -3
View File
@@ -16,7 +16,9 @@ export function getDevUser(): AuthContext {
} }
export function isDevAuthEnabled(): boolean { export function isDevAuthEnabled(): boolean {
// 说明:目前只要启用了 USE_CONVEX,就默认启用固定用户鉴权(便于迁移与测试)。 // 说明:
return process.env.USE_CONVEX === "1"; // - 固定开发用户仅用于迁移/联调阶段的“免登录”模式。
// - 当启用 Convex Authmiddleware + /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";
} }
+2 -2
View File
@@ -1,10 +1,10 @@
import type { ConvexHttpClient } from "convex/browser"; import type { ConvexHttpClient } from "convex/browser";
import type { AuthContext } from "@/lib/auth/types"; import type { AuthContext } from "@/lib/auth/types";
import { requireAuthContext } from "@/lib/auth/authContext"; 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 }> { export async function getAuthedConvexClient(): Promise<{ auth: AuthContext; client: ConvexHttpClient }> {
const auth = await requireAuthContext(); const auth = await requireAuthContext();
const client = getConvexHttpClient(); const client = await getConvexAuthedHttpClient();
return { auth, client }; return { auth, client };
} }
+19
View File
@@ -1,4 +1,5 @@
import { ConvexHttpClient } from "convex/browser"; import { ConvexHttpClient } from "convex/browser";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
let cached: ConvexHttpClient | null = null; let cached: ConvexHttpClient | null = null;
@@ -21,3 +22,21 @@ export function getConvexHttpClient(): ConvexHttpClient {
cached = client; cached = client;
return 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", () => { describe("file-tree clipboard payload", () => {
it("可编码/解码", () => { 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); const text = encodeFileTreeClipboardPayload(payload);
expect(decodeFileTreeClipboardPayload(text)).toEqual(payload); expect(decodeFileTreeClipboardPayload(text)).toEqual(payload);
expect(decodeFileTreeClipboardPayload("not-a-payload")).toBeNull(); expect(decodeFileTreeClipboardPayload("not-a-payload")).toBeNull();
@@ -25,10 +25,10 @@ describe("computeFileTreeDeleteTargets", () => {
const docA = makeDoc("A", null); const docA = makeDoc("A", null);
const docB = makeDoc("B", "A"); const docB = makeDoc("B", "A");
const rows: FileTreeRow[] = [ const rows: FileTreeRow[] = [
{ rowId: "doc:A", kind: "doc", docId: "A", node: docA, depth: 0, hasChildren: true, isExpanded: true }, { rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: true, isExpanded: true },
{ rowId: "index:A", kind: "index", docId: "A", node: docA, depth: 1 }, { rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
{ rowId: "doc:B", kind: "doc", docId: "B", node: docB, depth: 1, hasChildren: false, isExpanded: false }, { rowId: "doc:B", kind: "doc", docId: "B", parentDocId: "A", node: docB, depth: 1, hasChildren: false, isExpanded: false },
{ rowId: "index:B", kind: "index", docId: "B", node: docB, depth: 2 }, { rowId: "index:B", kind: "index", docId: "B", parentDocId: "B", node: docB, depth: 2 },
]; ];
const parentById = buildParentById([ const parentById = buildParentById([
{ id: "A", parentId: null }, { id: "A", parentId: null },
@@ -43,8 +43,8 @@ describe("computeFileTreeDeleteTargets", () => {
it("选中 index 行等价于选中页面本身(去重)", () => { it("选中 index 行等价于选中页面本身(去重)", () => {
const docA = makeDoc("A", null); const docA = makeDoc("A", null);
const rows: FileTreeRow[] = [ const rows: FileTreeRow[] = [
{ rowId: "doc:A", kind: "doc", docId: "A", node: docA, depth: 0, hasChildren: false, isExpanded: false }, { rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: false, isExpanded: false },
{ rowId: "index:A", kind: "index", docId: "A", node: docA, depth: 1 }, { rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
]; ];
const parentById = buildParentById([{ id: "A", parentId: null }]); const parentById = buildParentById([{ id: "A", parentId: null }]);
const selectedRowIds = new Set(["doc:A", "index:A"]); const selectedRowIds = new Set(["doc:A", "index:A"]);
@@ -55,15 +55,16 @@ describe("computeFileTreeDeleteTargets", () => {
it("如果页面被删除,则跳过同页面下的附件删除(避免重复/无效操作)", () => { it("如果页面被删除,则跳过同页面下的附件删除(避免重复/无效操作)", () => {
const docA = makeDoc("A", null); const docA = makeDoc("A", null);
const rows: FileTreeRow[] = [ const rows: FileTreeRow[] = [
{ rowId: "doc:A", kind: "doc", docId: "A", node: docA, depth: 0, hasChildren: true, isExpanded: true }, { rowId: "doc:A", kind: "doc", docId: "A", parentDocId: null, node: docA, depth: 0, hasChildren: true, isExpanded: true },
{ rowId: "index:A", kind: "index", docId: "A", node: docA, depth: 1 }, { rowId: "index:A", kind: "index", docId: "A", parentDocId: "A", node: docA, depth: 1 },
{ {
rowId: "asset:1", rowId: "asset:1",
kind: "asset", kind: "asset",
docId: "A", docId: "A",
node: docA, parentDocId: "A",
asset: { asset: {
id: "1", id: "1",
workspace_id: "w1",
document_id: "A", document_id: "A",
asset_type: "file", asset_type: "file",
file_url: "https://example.com/1", file_url: "https://example.com/1",
+12 -4
View File
@@ -4,9 +4,18 @@ import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidD
describe("file-tree/dnd", () => { describe("file-tree/dnd", () => {
test("inferDropTargetDocId", () => { test("inferDropTargetDocId", () => {
const docRow = { kind: "doc", rowId: "doc:a", docId: "a", depth: 0, isExpanded: false, hasChildren: false, node: {} as any } as FileTreeRow; const docRow: FileTreeRow = {
const indexRow = { kind: "index", rowId: "index:a", docId: "a", depth: 1, node: {} as any } as FileTreeRow; kind: "doc",
const assetRow = { kind: "asset", rowId: "asset:x", docId: "a", depth: 1, asset: {} as any } as FileTreeRow; 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(docRow)).toBe("a");
expect(inferDropTargetDocId(indexRow)).toBe("a"); expect(inferDropTargetDocId(indexRow)).toBe("a");
expect(inferDropTargetDocId(assetRow)).toBe("a"); expect(inferDropTargetDocId(assetRow)).toBe("a");
@@ -35,4 +44,3 @@ describe("file-tree/dnd", () => {
expect(isInvalidDocDrop({ sourceDocIds: ["b"], targetParentId: "a", parentById })).toBe(false); expect(isInvalidDocDrop({ sourceDocIds: ["b"], targetParentId: "a", parentById })).toBe(false);
}); });
}); });
+4 -2
View File
@@ -1,11 +1,13 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { DocumentRecord, DocumentNode } from "@/lib/documents"; import type { DocumentRecord, DocumentNode } from "@/lib/documents";
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types"; import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
import type { Database } from "@/types/supabase"; import type { Database } from "@/types/supabase";
import type { MediaAsset } from "@/types/media"; import type { MediaAsset } from "@/types/media";
import { buildDocumentTree } from "@/lib/documents"; 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 = { export type SidebarTableRow = {
id: string; id: string;
-27
View File
@@ -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;
-29
View File
@@ -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;
}
-71
View File
@@ -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 默认用 “项目 refhostname 第一个片段)” 作为 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_URLHTTPS),从而触发 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,
);
};
+6 -5
View File
@@ -1,6 +1,6 @@
import type { SupabaseClient } from "@supabase/supabase-js"; type TypedClient = {
from: (table: string) => any;
type TypedClient = SupabaseClient<any>; };
interface WorkspaceMembershipRow { interface WorkspaceMembershipRow {
workspace_id: string; workspace_id: string;
@@ -101,8 +101,9 @@ export async function fetchWorkspaceSummaries(
throw new Error(`统计成员数失败:${countError.message}`); throw new Error(`统计成员数失败:${countError.message}`);
} }
(memberCounts ?? []).forEach((item) => { const countRows = (memberCounts ?? []) as Array<{ workspace_id: string }>;
const workspaceId = item.workspace_id as string; countRows.forEach((item) => {
const workspaceId = item.workspace_id;
memberCountMap[workspaceId] = (memberCountMap[workspaceId] ?? 0) + 1; memberCountMap[workspaceId] = (memberCountMap[workspaceId] ?? 0) + 1;
}); });
} }