0.3 增加登录模块

This commit is contained in:
liaibo
2026-01-18 05:13:53 +08:00
parent df2e9f5339
commit 90a38b48cf
86 changed files with 2041 additions and 148 deletions
+16
View File
@@ -8,14 +8,22 @@
* @module
*/
import type * as _utils_id from "../_utils/id.js";
import type * as _utils_ingestJobs from "../_utils/ingestJobs.js";
import type * as _utils_lightrag from "../_utils/lightrag.js";
import type * as _utils_text from "../_utils/text.js";
import type * as _utils_time from "../_utils/time.js";
import type * as auth from "../auth.js";
import type * as documents from "../documents.js";
import type * as http from "../http.js";
import type * as jobs from "../jobs.js";
import type * as mediaAssets from "../mediaAssets.js";
import type * as mindmaps from "../mindmaps.js";
import type * as ping from "../ping.js";
import type * as recents from "../recents.js";
import type * as references from "../references.js";
import type * as tables from "../tables.js";
import type * as users from "../users.js";
import type * as workspaces from "../workspaces.js";
import type {
@@ -25,14 +33,22 @@ import type {
} from "convex/server";
declare const fullApi: ApiFromModules<{
"_utils/id": typeof _utils_id;
"_utils/ingestJobs": typeof _utils_ingestJobs;
"_utils/lightrag": typeof _utils_lightrag;
"_utils/text": typeof _utils_text;
"_utils/time": typeof _utils_time;
auth: typeof auth;
documents: typeof documents;
http: typeof http;
jobs: typeof jobs;
mediaAssets: typeof mediaAssets;
mindmaps: typeof mindmaps;
ping: typeof ping;
recents: typeof recents;
references: typeof references;
tables: typeof tables;
users: typeof users;
workspaces: typeof workspaces;
}>;
+7
View File
@@ -0,0 +1,7 @@
// 简单的 ID 生成器,用于生成类似 UUID 的字符串
export function generateId(): string {
// 使用时间戳 + 随机数生成唯一 ID
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 15);
return `${timestamp}${random}`;
}
+125
View File
@@ -0,0 +1,125 @@
import { internal } from "../_generated/api";
import { nowIso } from "./time";
import type { MutationCtx } from "../_generated/server";
type JobStatus = "queued" | "running" | "succeeded" | "failed";
async function upsertJob(
ctx: MutationCtx,
args: {
id: string;
userId: string;
type: string;
payload: unknown;
debounceMs?: number;
},
): Promise<{ id: string; status: JobStatus }> {
const debounceMs = Math.max(0, Math.floor(args.debounceMs ?? 1200));
const ts = nowIso();
const existing = await ctx.db
.query("jobs")
.withIndex("by_job_id", (q) => q.eq("id", args.id))
.first();
if (existing) {
if (existing.user_id !== args.userId) {
// 说明:避免不同用户复用同一 job id 导致“互相覆盖”。
return { id: args.id, status: existing.status as JobStatus };
}
const status = String(existing.status) as JobStatus;
if (status === "running" || status === "queued") {
await ctx.db.patch(existing._id, { updated_at: ts });
// 说明:start 内部会检查状态,重复 schedule 不会造成重复执行。
await ctx.scheduler.runAfter(debounceMs, internal.jobs.start, { id: args.id });
return { id: args.id, status };
}
await ctx.db.patch(existing._id, {
type: args.type,
status: "queued",
payload: args.payload,
result: null,
error: null,
updated_at: ts,
started_at: null,
finished_at: null,
});
await ctx.scheduler.runAfter(debounceMs, internal.jobs.start, { id: args.id });
return { id: args.id, status: "queued" };
}
await ctx.db.insert("jobs", {
id: args.id,
user_id: args.userId,
type: args.type,
status: "queued",
payload: args.payload,
result: null,
error: null,
created_at: ts,
updated_at: ts,
started_at: null,
finished_at: null,
});
await ctx.scheduler.runAfter(debounceMs, internal.jobs.start, { id: args.id });
return { id: args.id, status: "queued" };
}
export function ingestDocumentJobId(documentId: string): string {
return `ingest:document:${documentId}`;
}
export function ingestMindmapJobId(docId: string, mindmapId: string): string {
return `ingest:mindmap:${docId}:${mindmapId}`;
}
export function ingestMediaAssetJobId(assetId: string): string {
return `ingest:media:${assetId}`;
}
export async function enqueueIngestDocumentJob(
ctx: MutationCtx,
args: { userId: string; documentId: string; debounceMs?: number },
): Promise<{ id: string }> {
const id = ingestDocumentJobId(args.documentId);
await upsertJob(ctx, {
id,
userId: args.userId,
type: "ingest.rag_index_document",
payload: { documentId: args.documentId },
debounceMs: args.debounceMs,
});
return { id };
}
export async function enqueueIngestMindmapJob(
ctx: MutationCtx,
args: { userId: string; docId: string; mindmapId: string; debounceMs?: number },
): Promise<{ id: string }> {
const id = ingestMindmapJobId(args.docId, args.mindmapId);
await upsertJob(ctx, {
id,
userId: args.userId,
type: "ingest.rag_index_mindmap",
payload: { docId: args.docId, mindmapId: args.mindmapId },
debounceMs: args.debounceMs,
});
return { id };
}
export async function enqueueIngestMediaAssetJob(
ctx: MutationCtx,
args: { userId: string; assetId: string; debounceMs?: number },
): Promise<{ id: string }> {
const id = ingestMediaAssetJobId(args.assetId);
await upsertJob(ctx, {
id,
userId: args.userId,
type: "ingest.rag_index_media_asset",
payload: { assetId: args.assetId },
debounceMs: args.debounceMs,
});
return { id };
}
+47
View File
@@ -0,0 +1,47 @@
type IngestTextArgs = {
fileSource: string;
text: string;
};
export async function lightragIngestText(args: IngestTextArgs): Promise<{ trackId: string | null }> {
const baseUrl = (process.env.LIGHTRAG_URL || "").trim().replace(/\/+$/, "");
if (!baseUrl) {
throw new Error("缺少环境变量:LIGHTRAG_URL");
}
const fileSource = String(args.fileSource ?? "").trim();
if (!fileSource) {
throw new Error("缺少 fileSource");
}
const text = String(args.text ?? "");
if (!text.trim()) {
return { trackId: null };
}
const apiKey = (process.env.LIGHTRAG_API_KEY || "").trim();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (apiKey) {
headers["X-API-Key"] = apiKey;
}
// 说明:LightRAG(>=1.4.x) 使用 /documents/text 作为“文本入库”接口。
const resp = await fetch(`${baseUrl}/documents/text`, {
method: "POST",
headers,
body: JSON.stringify({ text, file_source: fileSource }),
});
if (!resp.ok) {
const raw = await resp.text().catch(() => "");
throw new Error(`LightRAG 入库失败:HTTP ${resp.status} ${raw.slice(0, 500)}`);
}
const data = (await resp.json().catch(() => null)) as unknown;
const trackId = (() => {
if (!data || typeof data !== "object") return null;
const v = (data as Record<string, unknown>).track_id;
return typeof v === "string" ? v : null;
})();
return { trackId };
}
+101
View File
@@ -0,0 +1,101 @@
function pushText(out: string[], value: unknown) {
const s = typeof value === "string" ? value : null;
if (!s) return;
const trimmed = s.replace(/\s+/g, " ").trim();
if (!trimmed) return;
out.push(trimmed);
}
export function extractTextFromDocumentContent(content: unknown, maxChars = 60000): string {
const out: string[] = [];
const readPropText = (value: unknown) => {
if (!value || typeof value !== "object") return null;
const t = (value as Record<string, unknown>).text;
return typeof t === "string" ? t : null;
};
const walk = (node: unknown) => {
if (out.join("\n").length >= maxChars) return;
if (node == null) return;
if (typeof node === "string") {
pushText(out, node);
return;
}
if (typeof node === "number" || typeof node === "boolean") return;
if (Array.isArray(node)) {
for (const item of node) {
walk(item);
if (out.join("\n").length >= maxChars) return;
}
return;
}
if (typeof node === "object") {
const obj = node as Record<string, unknown>;
// 兼容常见 BlockNote 结构:
// - block.props.text
// - block.content: [{ text: "..." }, ...]
// - block.children: [...]
pushText(out, obj.text);
pushText(out, readPropText(obj.props));
const maybeContent = obj.content;
if (Array.isArray(maybeContent)) {
for (const seg of maybeContent) {
if (typeof seg === "string") pushText(out, seg);
else if (seg && typeof seg === "object") pushText(out, readPropText(seg));
if (out.join("\n").length >= maxChars) return;
}
}
const children = obj.children;
if (Array.isArray(children)) {
for (const c of children) {
walk(c);
if (out.join("\n").length >= maxChars) return;
}
}
// 兜底:遍历其它字段(避免遗漏 title/caption 等)
for (const [k, v] of Object.entries(obj)) {
if (k === "children" || k === "content" || k === "props" || k === "text") continue;
if (typeof v === "string") pushText(out, v);
else if (Array.isArray(v) || (v && typeof v === "object")) walk(v);
if (out.join("\n").length >= maxChars) return;
}
}
};
walk(content);
const joined = out.join("\n").trim();
return joined.length > maxChars ? `${joined.slice(0, maxChars)}` : joined;
}
type MindmapNode = { data?: { text?: unknown }; children?: unknown[] };
export function extractTextFromMindmapData(data: unknown, maxChars = 60000): string {
const out: string[] = [];
const walk = (node: unknown) => {
if (out.join("\n").length >= maxChars) return;
if (!node || typeof node !== "object") return;
const n = node as MindmapNode;
pushText(out, n.data?.text);
if (Array.isArray(n.children)) {
for (const c of n.children) {
walk(c);
if (out.join("\n").length >= maxChars) return;
}
}
};
walk(data);
const joined = out.join("\n").trim();
return joined.length > maxChars ? `${joined.slice(0, maxChars)}` : joined;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Convex Auth 配置文件
*
* 说明:
* - 该文件由 @convex-dev/auth 约定读取,用于声明 “Convex” 这一内置 provider 的基本信息。
* - domain 必须与站点对外访问的 Origin 一致(本地默认 http://127.0.0.1:3000)。
*/
export default {
providers: [
{
domain: process.env.CONVEX_SITE_URL,
applicationID: "convex",
},
],
};
+32
View File
@@ -0,0 +1,32 @@
import { Password } from "@convex-dev/auth/providers/Password";
import { convexAuth } from "@convex-dev/auth/server";
/**
* Convex Auth 配置
*
* 配置密码认证方式,支持用户通过邮箱和密码注册/登录。
*/
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
providers: [
Password({
/**
* 密码要求验证
*/
validatePasswordRequirements: (password: string) => {
if (password.length < 8) {
throw new Error("密码至少需要 8 个字符");
}
},
/**
* 用户资料配置
*/
profile(params, _ctx) {
const email = typeof params.email === "string" ? params.email : String(params.email ?? "");
const name = typeof params.name === "string" ? params.name : "";
// 说明:Convex 的 Value 类型不允许 undefined;这里统一给 name 一个字符串占位。
return { email, name: name.trim() ? name : "" };
},
}),
],
});
+5
View File
@@ -1,6 +1,7 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
@@ -175,6 +176,10 @@ export const updateContent = mutation({
if (doc.user_id !== args.userId) throw new Error("无权限");
const ts = nowIso();
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
// 说明:在 Convex 模式下,把“自动入库/LightRAG 触发”迁到 Convex jobs/actions。
// 采用 debounce,避免频繁保存时触发过多任务。
await enqueueIngestDocumentJob(ctx, { userId: args.userId, documentId: args.id, debounceMs: 1500 });
return { ok: true, updated_at: ts };
},
});
+13
View File
@@ -0,0 +1,13 @@
import { httpRouter } from "convex/server";
import { auth } from "./auth";
// 说明:
// - Convex Auth 需要通过 HTTP Routes 提供回调/授权等端点(即使你只用密码登录,也建议保持该配置)。
// - 该文件是 Convex 的约定入口(convex/http.ts)。
const http = httpRouter();
auth.addHttpRoutes(http);
export default http;
+116 -1
View File
@@ -1,7 +1,10 @@
import { internalAction, internalMutation, internalQuery, mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import { internal } from "./_generated/api";
import { api, internal } from "./_generated/api";
import { lightragIngestText } from "./_utils/lightrag";
import { extractTextFromDocumentContent, extractTextFromMindmapData } from "./_utils/text";
import { enqueueIngestDocumentJob, enqueueIngestMediaAssetJob, enqueueIngestMindmapJob } from "./_utils/ingestJobs";
export const get = query({
args: { userId: v.string(), id: v.string() },
@@ -52,6 +55,35 @@ export const enqueueDemo = mutation({
},
});
export const enqueueRagIndexDocument = mutation({
args: { userId: v.string(), documentId: v.string() },
handler: async (ctx, args) => {
const { id } = await enqueueIngestDocumentJob(ctx, { userId: args.userId, documentId: args.documentId, debounceMs: 0 });
return { ok: true, id };
},
});
export const enqueueRagIndexMindmap = mutation({
args: { userId: v.string(), docId: v.string(), mindmapId: v.string() },
handler: async (ctx, args) => {
const { id } = await enqueueIngestMindmapJob(ctx, {
userId: args.userId,
docId: args.docId,
mindmapId: args.mindmapId,
debounceMs: 0,
});
return { ok: true, id };
},
});
export const enqueueRagIndexMediaAsset = mutation({
args: { userId: v.string(), assetId: v.string() },
handler: async (ctx, args) => {
const { id } = await enqueueIngestMediaAssetJob(ctx, { userId: args.userId, assetId: args.assetId, debounceMs: 0 });
return { ok: true, id };
},
});
export const start = internalMutation({
args: { id: v.string() },
handler: async (ctx, args) => {
@@ -85,6 +117,88 @@ export const run = internalAction({
return;
}
if (job.type === "ingest.rag_index_document") {
const documentId = String(job.payload?.documentId ?? "").trim();
if (!documentId) throw new Error("缺少 documentId");
const [meta, contentRes] = await Promise.all([
ctx.runQuery(api.documents.getMeta, { userId: job.user_id, id: documentId }),
ctx.runQuery(api.documents.getContent, { userId: job.user_id, id: documentId }),
]);
if (!meta) throw new Error("页面不存在或无权限");
const title = meta.title ?? "无标题";
const text = extractTextFromDocumentContent(contentRes?.content ?? null);
const fileSource = `document:${documentId}`;
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, kind: "document", documentId, trackId },
});
return;
}
if (job.type === "ingest.rag_index_mindmap") {
const docId = String(job.payload?.docId ?? "").trim();
const mindmapId = String(job.payload?.mindmapId ?? "").trim();
if (!docId) throw new Error("缺少 docId");
if (!mindmapId) throw new Error("缺少 mindmapId");
const [docMeta, mindmapRes] = await Promise.all([
ctx.runQuery(api.documents.getMeta, { userId: job.user_id, id: docId }),
ctx.runQuery(api.mindmaps.get, { userId: job.user_id, docId, mindmapId }),
]);
if (!docMeta) throw new Error("页面不存在或无权限");
if (!mindmapRes?.ok) throw new Error("导图读取失败");
if (!mindmapRes.meta?.exists) {
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, kind: "mindmap", docId, mindmapId, skipped: true, reason: "mindmap_not_exists" },
});
return;
}
const title = docMeta.title ?? "无标题";
const text = extractTextFromMindmapData(mindmapRes.data ?? null);
const fileSource = `mindmap:${docId}:${mindmapId}`;
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, kind: "mindmap", docId, mindmapId, trackId },
});
return;
}
if (job.type === "ingest.rag_index_media_asset") {
const assetId = String(job.payload?.assetId ?? "").trim();
if (!assetId) throw new Error("缺少 assetId");
const asset = await ctx.runQuery(api.mediaAssets.getById, { userId: job.user_id, id: assetId });
if (!asset) throw new Error("资源不存在或无权限");
const title = asset.file_name ?? asset.id;
const text = String(asset.ocr_text ?? "").trim();
if (!text) {
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, kind: "media_asset", assetId, skipped: true, reason: "empty_ocr_text" },
});
return;
}
const fileSource = `media:${assetId}`;
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
await ctx.runMutation(internal.jobs.finishSuccess, {
id: args.id,
result: { ok: true, kind: "media_asset", assetId, trackId },
});
return;
}
throw new Error(`未知任务类型:${job.type}`);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -103,6 +217,7 @@ export const _getInternal = internalQuery({
if (!job) return null;
return {
id: job.id,
user_id: job.user_id,
type: job.type,
status: job.status,
payload: job.payload,
+10
View File
@@ -1,6 +1,7 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import { enqueueIngestMediaAssetJob } from "./_utils/ingestJobs";
import type { MutationCtx, QueryCtx } from "./_generated/server";
async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) {
@@ -251,6 +252,15 @@ export const patchById = mutation({
const next = { ...(args.patch as Record<string, unknown>), updated_at: nowIso() };
await ctx.db.patch(row._id, next);
// 说明:当 OCR 写回完成时,自动触发 LightRAG 入库任务(Convex jobs/actions)。
const patch = args.patch as Record<string, unknown>;
const prev = row as Record<string, unknown>;
const nextOcrStatus = typeof patch.ocr_status === "string" ? patch.ocr_status : prev.ocr_status;
const nextOcrText = typeof patch.ocr_text === "string" ? patch.ocr_text : prev.ocr_text;
if (nextOcrStatus === "completed" && typeof nextOcrText === "string" && nextOcrText.trim()) {
await enqueueIngestMediaAssetJob(ctx, { userId: args.userId, assetId: args.id, debounceMs: 1200 });
}
return { ok: true };
},
});
+3 -1
View File
@@ -1,6 +1,7 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import { enqueueIngestMindmapJob } from "./_utils/ingestJobs";
const defaultMindmapData = {
data: { text: "中心主题" },
@@ -101,6 +102,7 @@ export const put = mutation({
deleted_at: null,
deleted_by: null,
});
await enqueueIngestMindmapJob(ctx, { userId: args.userId, docId: args.docId, mindmapId, debounceMs: 1500 });
return { ok: true, created: false, skipped: false, updated_at: ts };
}
@@ -117,6 +119,7 @@ export const put = mutation({
deleted_by: null,
});
await enqueueIngestMindmapJob(ctx, { userId: args.userId, docId: args.docId, mindmapId, debounceMs: 1500 });
return { ok: true, created: true, skipped: false, updated_at: ts };
},
});
@@ -291,4 +294,3 @@ export const emptyTrashByWorkspace = mutation({
return { ok: true, deletedCount: toDelete.length };
},
});
+51 -1
View File
@@ -1,11 +1,15 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
import { authTables } from "@convex-dev/auth/server";
// 说明:
// - 这里先按兼容现有 Next API 返回结构的思路设计字段:id/workspace_id/user_id 等命名保持与 Supabase 一致。
// - 这里先按"兼容现有 Next API 返回结构"的思路设计字段:id/workspace_id/user_id 等命名保持与 Supabase 一致。
// - Convex 自带的 _id 仍然存在,但我们暂时不把它暴露给上层业务,便于逐步迁移与回退。
// - M8: 集成 Convex AuthauthTables 提供用户认证所需的数据表
export default defineSchema({
// Convex Auth 表(用户、账户、会话等)
...authTables,
workspaces: defineTable({
id: v.string(),
name: v.string(),
@@ -187,4 +191,50 @@ export default defineSchema({
.index("by_workspace_target", ["workspace_id", "target_page_id"])
.index("by_workspace_source", ["workspace_id", "source_page_id"])
.index("by_unique", ["workspace_id", "source_page_id", "source_block_id", "target_page_id", "display_mode"]),
// M5:在线表格(替代 Supabase document_tables
document_tables: defineTable({
id: v.string(),
workspace_id: v.string(),
document_id: v.string(),
grid_key: v.string(),
title: v.string(),
schema: v.any(), // TableSchema: { columns, frozenRowCount, frozenColCount }
view_preferences: v.any(),
snapshot: v.optional(v.any()), // DocumentTableSnapshot: { rows?, luckysheet? }
is_archived: v.boolean(),
last_synced_at: v.optional(v.string()),
created_by: v.string(),
updated_by: v.optional(v.string()),
created_at: v.string(),
updated_at: v.string(),
})
.index("by_table_id", ["id"])
.index("by_grid_key", ["grid_key"])
.index("by_document", ["document_id"])
.index("by_workspace", ["workspace_id"])
.index("by_workspace_archived", ["workspace_id", "is_archived"]),
// M5:在线表格行数据(替代 Supabase document_table_rows
document_table_rows: defineTable({
id: v.string(),
workspace_id: v.string(),
document_id: v.string(),
table_id: v.string(),
row_index: v.number(),
row_data: v.any(),
row_hash: v.optional(v.string()),
is_deleted: v.boolean(),
updated_by: v.optional(v.string()),
created_at: v.string(),
updated_at: v.string(),
})
.index("by_row_id", ["id"])
.index("by_table", ["table_id"])
.index("by_table_row", ["table_id", "row_index"])
.index("by_workspace", ["workspace_id"])
.searchIndex("by_table_row_full", {
searchField: "row_hash",
filterFields: ["table_id", "is_deleted"],
}),
});
+289
View File
@@ -0,0 +1,289 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { nowIso } from "./_utils/time";
import { generateId } from "./_utils/id";
// Query: 获取单个表格
export const get = query({
args: { userId: v.string(), tableId: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
.first();
if (!table) return null;
if (table.workspace_id) {
// 验证用户是否在工作区中(简化版:假设 userId 有效)
// TODO: 添加 workspace_members 验证
}
return {
id: table.id,
workspace_id: table.workspace_id,
document_id: table.document_id,
grid_key: table.grid_key,
title: table.title,
schema: table.schema,
view_preferences: table.view_preferences,
snapshot: table.snapshot ?? null,
is_archived: table.is_archived,
last_synced_at: table.last_synced_at ?? null,
created_by: table.created_by,
updated_by: table.updated_by ?? null,
created_at: table.created_at,
updated_at: table.updated_at,
};
},
});
// Query: 通过 gridKey 获取表格(用于 Luckysheet get-workerbook
export const getByGridKey = query({
args: { gridKey: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey))
.first();
if (!table) return null;
// 返回 Luckysheet 需要的格式
return {
title: table.title,
gridKey: table.grid_key,
lang: "zh",
};
},
});
// Query: 通过 gridKey 获取完整表格数据(用于 Luckysheet load
export const getByGridKeyFull = query({
args: { gridKey: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_grid_key", (q) => q.eq("grid_key", args.gridKey))
.first();
if (!table) return null;
// 返回完整的表格数据,包括 schema 和 snapshot
return {
id: table.id,
grid_key: table.grid_key,
title: table.title,
schema: table.schema,
snapshot: table.snapshot ?? null,
};
},
});
// Query: 列出文档的所有表格
export const listByDocument = query({
args: { userId: v.string(), documentId: v.string() },
handler: async (ctx, args) => {
const tables = await ctx.db
.query("document_tables")
.withIndex("by_document", (q) => q.eq("document_id", args.documentId))
.collect();
return tables
.filter((t) => !t.is_archived)
.map((t) => ({
id: t.id,
document_id: t.document_id,
grid_key: t.grid_key,
title: t.title,
schema: t.schema,
view_preferences: t.view_preferences,
snapshot: t.snapshot ?? null,
is_archived: t.is_archived,
last_synced_at: t.last_synced_at ?? null,
created_at: t.created_at,
updated_at: t.updated_at,
}));
},
});
// Mutation: 创建表格
export const create = mutation({
args: {
userId: v.string(),
workspaceId: v.string(),
documentId: v.string(),
title: v.optional(v.string()),
schema: v.optional(v.any()),
snapshot: v.optional(v.any()),
},
handler: async (ctx, args) => {
const now = nowIso();
const tableId = generateId();
const gridKey = generateId(); // 用于 WebSocket 协同标识
const table = {
id: tableId,
workspace_id: args.workspaceId,
document_id: args.documentId,
grid_key: gridKey,
title: args.title ?? "未命名表格",
schema: args.schema ?? {},
view_preferences: {},
snapshot: args.snapshot ?? null,
is_archived: false,
last_synced_at: now,
created_by: args.userId,
updated_by: args.userId,
created_at: now,
updated_at: now,
};
await ctx.db.insert("document_tables", table);
return {
id: table.id,
grid_key: table.grid_key,
title: table.title,
schema: table.schema,
snapshot: table.snapshot,
};
},
});
// Mutation: 更新表格
export const update = mutation({
args: {
userId: v.string(),
tableId: v.string(),
title: v.optional(v.string()),
schema: v.optional(v.any()),
view_preferences: v.optional(v.any()),
snapshot: v.optional(v.any()),
rows: v.optional(v.any()),
},
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
.first();
if (!table) {
throw new Error("Table not found");
}
const now = nowIso();
const updates: any = {
updated_at: now,
updated_by: args.userId,
last_synced_at: now,
};
if (args.title !== undefined) updates.title = args.title;
if (args.schema !== undefined) updates.schema = args.schema;
if (args.view_preferences !== undefined) updates.view_preferences = args.view_preferences;
if (args.snapshot !== undefined) updates.snapshot = args.snapshot;
await ctx.db.patch(table._id, updates);
// 如果提供了 rows,更新行数据
if (args.rows && Array.isArray(args.rows)) {
// 先删除旧行
const existingRows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
.collect();
for (const row of existingRows) {
await ctx.db.delete(row._id);
}
// 插入新行
for (const [index, rowData] of args.rows.entries()) {
await ctx.db.insert("document_table_rows", {
id: generateId(),
workspace_id: table.workspace_id,
document_id: table.document_id,
table_id: args.tableId,
row_index: index,
row_data: rowData,
row_hash: JSON.stringify(rowData),
is_deleted: false,
updated_by: args.userId,
created_at: now,
updated_at: now,
});
}
}
return {
id: table.id,
title: updates.title ?? table.title,
schema: updates.schema ?? table.schema,
view_preferences: updates.view_preferences ?? table.view_preferences,
snapshot: updates.snapshot ?? table.snapshot,
updated_at: now,
};
},
});
// Mutation: 删除表格(软删除)
export const remove = mutation({
args: { userId: v.string(), tableId: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
.first();
if (!table) {
throw new Error("Table not found");
}
await ctx.db.patch(table._id, {
is_archived: true,
updated_at: nowIso(),
updated_by: args.userId,
});
return { success: true };
},
});
// Mutation: 永久删除表格
export const purge = mutation({
args: { userId: v.string(), tableId: v.string() },
handler: async (ctx, args) => {
const table = await ctx.db
.query("document_tables")
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
.first();
if (!table) {
throw new Error("Table not found");
}
// 删除关联的行
const rows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
.collect();
for (const row of rows) {
await ctx.db.delete(row._id);
}
// 删除表格
await ctx.db.delete(table._id);
return { success: true };
},
});
// Query: 获取表格行数据
export const getRows = query({
args: { tableId: v.string() },
handler: async (ctx, args) => {
const rows = await ctx.db
.query("document_table_rows")
.withIndex("by_table", (q) => q.eq("table_id", args.tableId))
.collect();
return rows
.filter((r) => !r.is_deleted)
.sort((a, b) => a.row_index - b.row_index)
.map((r) => r.row_data);
},
});
+17
View File
@@ -0,0 +1,17 @@
import { query } from "./_generated/server";
import { getAuthUserId } from "@convex-dev/auth/server";
/**
* 获取当前登录用户
*/
export const currentUser = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (userId === null) {
return null;
}
return await ctx.db.get(userId);
},
});
+3
View File
@@ -10,9 +10,11 @@
"test": "vitest"
},
"dependencies": {
"@auth/core": "0.37.0",
"@blocknote/core": "^0.42.0",
"@blocknote/mantine": "0.42.0",
"@blocknote/react": "^0.42.0",
"@convex-dev/auth": "^0.0.90",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -65,6 +67,7 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.3",
"jose": "^6.1.3",
"jsdom": "^27.2.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
+163
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@auth/core':
specifier: 0.37.0
version: 0.37.0
'@blocknote/core':
specifier: ^0.42.0
version: 0.42.0(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)
@@ -17,6 +20,9 @@ importers:
'@blocknote/react':
specifier: ^0.42.0
version: 0.42.0(@floating-ui/dom@1.7.4)(@hocuspocus/provider@2.15.3(y-protocols@1.0.6(yjs@13.6.27))(yjs@13.6.27))(@tiptap/extensions@3.10.7(@tiptap/core@3.10.7(@tiptap/pm@3.10.7))(@tiptap/pm@3.10.7))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.5))(@types/react@19.2.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@convex-dev/auth':
specifier: ^0.0.90
version: 0.0.90(@auth/core@0.37.0)(convex@1.31.4(react@19.2.0))(react@19.2.0)
'@dnd-kit/core':
specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -168,6 +174,9 @@ importers:
eslint-config-next:
specifier: 16.0.3
version: 16.0.3(@typescript-eslint/parser@8.47.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3)
jose:
specifier: ^6.1.3
version: 6.1.3
jsdom:
specifier: ^27.2.0
version: 27.2.0
@@ -202,6 +211,20 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@auth/core@0.37.0':
resolution: {integrity: sha512-LybAgfFC5dta3Mu3al0UbnzMGVBpZRqLMvvXupQOfETtPNlL7rXgTO13EVRTCdvPqMQrVYjODUDvgVfQM1M3Qg==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^6.8.0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -382,6 +405,17 @@ packages:
cpu: [x64]
os: [win32]
'@convex-dev/auth@0.0.90':
resolution: {integrity: sha512-aqw88EB042HvnaF4wcf/f/wTocmT2Bus2VDQRuV79cM0+8kORM0ICK/ByZ6XsHgQ9qr6TmidNbXm6QAgndrdpQ==}
hasBin: true
peerDependencies:
'@auth/core': ^0.37.0
convex: ^1.17.0
react: ^18.2.0 || ^19.0.0-0
peerDependenciesMeta:
react:
optional: true
'@cspotcode/source-map-support@0.8.1':
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
@@ -1356,6 +1390,21 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@oslojs/asn1@1.0.0':
resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
'@oslojs/binary@1.0.0':
resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==}
'@oslojs/crypto@1.0.1':
resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==}
'@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@pdf-lib/standard-fonts@1.0.0':
resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
@@ -2191,6 +2240,9 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
@@ -2748,6 +2800,10 @@ packages:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
engines: {node: '>= 0.6'}
cookie@0.7.1:
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
engines: {node: '>= 0.6'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
@@ -3611,6 +3667,10 @@ packages:
resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==}
engines: {node: '>= 0.4'}
is-network-error@1.3.0:
resolution: {integrity: sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==}
engines: {node: '>=16'}
is-number-object@1.1.1:
resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==}
engines: {node: '>= 0.4'}
@@ -3685,6 +3745,12 @@ packages:
jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -3731,6 +3797,10 @@ packages:
jszip@3.10.1:
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
jwt-decode@4.0.0:
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
engines: {node: '>=18'}
katex@0.16.27:
resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==}
hasBin: true
@@ -3868,6 +3938,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucia@3.2.2:
resolution: {integrity: sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA==}
deprecated: This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate.
lucide-react@0.554.0:
resolution: {integrity: sha512-St+z29uthEJVx0Is7ellNkgTEhaeSoA42I7JjOCBCrc5X6LYMGSv0P/2uS5HDLTExP5tpiqRD2PyUEOS6s9UXA==}
peerDependencies:
@@ -4170,6 +4244,9 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
oauth4webapi@3.8.3:
resolution: {integrity: sha512-pQ5BsX3QRTgnt5HxgHwgunIRaDXBdkT23tf8dfzmtTIL2LTpdmxgbpbBm0VgFWAIDlezQvQCTgnVIUmHupXHxw==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -4293,6 +4370,14 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
preact-render-to-string@5.2.3:
resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==}
peerDependencies:
preact: '>=10'
preact@10.11.3:
resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -4302,6 +4387,9 @@ packages:
engines: {node: '>=14'}
hasBin: true
pretty-format@3.8.0:
resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==}
printable-characters@1.0.42:
resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==}
@@ -4647,6 +4735,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
@@ -5402,6 +5493,16 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
'@auth/core@0.37.0':
dependencies:
'@panva/hkdf': 1.2.1
'@types/cookie': 0.6.0
cookie: 0.7.1
jose: 5.10.0
oauth4webapi: 3.8.3
preact: 10.11.3
preact-render-to-string: 5.2.3(preact@10.11.3)
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -5665,6 +5766,23 @@ snapshots:
'@cloudflare/workerd-windows-64@1.20251210.0':
optional: true
'@convex-dev/auth@0.0.90(@auth/core@0.37.0)(convex@1.31.4(react@19.2.0))(react@19.2.0)':
dependencies:
'@auth/core': 0.37.0
'@oslojs/crypto': 1.0.1
'@oslojs/encoding': 1.1.0
convex: 1.31.4(react@19.2.0)
cookie: 1.1.1
is-network-error: 1.3.0
jose: 5.10.0
jwt-decode: 4.0.0
lucia: 3.2.2
oauth4webapi: 3.8.3
path-to-regexp: 6.3.0
server-only: 0.0.1
optionalDependencies:
react: 19.2.0
'@cspotcode/source-map-support@0.8.1':
dependencies:
'@jridgewell/trace-mapping': 0.3.9
@@ -6345,6 +6463,21 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@oslojs/asn1@1.0.0':
dependencies:
'@oslojs/binary': 1.0.0
'@oslojs/binary@1.0.0': {}
'@oslojs/crypto@1.0.1':
dependencies:
'@oslojs/asn1': 1.0.0
'@oslojs/binary': 1.0.0
'@oslojs/encoding@1.1.0': {}
'@panva/hkdf@1.2.1': {}
'@pdf-lib/standard-fonts@1.0.0':
dependencies:
pako: 1.0.11
@@ -7124,6 +7257,8 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/cookie@0.6.0': {}
'@types/d3-color@3.1.3': {}
'@types/d3-drag@3.0.7':
@@ -7704,6 +7839,8 @@ snapshots:
cookie@0.5.0: {}
cookie@0.7.1: {}
cookie@0.7.2: {}
cookie@1.1.1: {}
@@ -8804,6 +8941,8 @@ snapshots:
is-negative-zero@2.0.3: {}
is-network-error@1.3.0: {}
is-number-object@1.1.1:
dependencies:
call-bound: 1.0.4
@@ -8875,6 +9014,10 @@ snapshots:
jose@4.15.9: {}
jose@5.10.0: {}
jose@6.1.3: {}
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -8936,6 +9079,8 @@ snapshots:
readable-stream: 2.3.8
setimmediate: 1.0.5
jwt-decode@4.0.0: {}
katex@0.16.27:
dependencies:
commander: 8.3.0
@@ -9044,6 +9189,11 @@ snapshots:
dependencies:
yallist: 3.1.1
lucia@3.2.2:
dependencies:
'@oslojs/crypto': 1.0.1
'@oslojs/encoding': 1.1.0
lucide-react@0.554.0(react@19.2.0):
dependencies:
react: 19.2.0
@@ -9626,6 +9776,8 @@ snapshots:
normalize-path@3.0.0: {}
oauth4webapi@3.8.3: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -9759,10 +9911,19 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
preact-render-to-string@5.2.3(preact@10.11.3):
dependencies:
preact: 10.11.3
pretty-format: 3.8.0
preact@10.11.3: {}
prelude-ls@1.2.1: {}
prettier@3.8.0: {}
pretty-format@3.8.0: {}
printable-characters@1.0.42: {}
process-nextick-args@2.0.1: {}
@@ -10189,6 +10350,8 @@ snapshots:
semver@7.7.3: {}
server-only@0.0.1: {}
set-cookie-parser@2.7.2: {}
set-function-length@1.2.2:
+79
View File
@@ -0,0 +1,79 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
function setConvexEnv(name, value) {
// 说明:使用 stdin 传值,避免以 `-----BEGIN ...` 开头的内容被 CLI 误当作参数/选项。
execSync(`npx convex env set ${name}`, {
input: String(value ?? ""),
stdio: ['pipe', 'inherit', 'inherit'],
cwd: process.cwd(),
});
}
const privateKey = `-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCPcrWDwjh74nu1
gfYo9ywVIRMedfPwbNiDjHnovzHAIeToc1+Lw3ju/io9OME3KLWzIibUHX39LilK
sSQKigJ3+i7J13IquJD++ozY9C8Dp/P34txVY+ECSQRdIBbETLJxYBWXOx6Ysw/d
/CUuDOdEw2IhNvN97sTh5jUZe6f/H6mfCpJ3X5SZrqrIUTTL6r4Lj9ZS3PUX5ivU
6sd2rXzpDsUQgrNgmvGN3w9nAHudxAUv/zOGa4802Z+z14LCBIX6v6PIK79YQ4s9
/5hH73MesA6vgzWShJcyVy3guyde+duhpECo1q3d6dIZeqrazQwqAW3vcpmEFJdX
jkpociDfAgMBAAECggEAAYtRE+mH1SGThlkvTrKWeWXBQG8xoJFzZTsiZtSEExbq
UWxIh4cjqqL2znDpd5ALILIJ6/ejTxHrpN+yTSC+NQ9u6IJWusoA2ZXV5VH/nZD1
yeHZ0FuCZRVnJB9/zz4qH5lSsi2TPz6SOagIuG2wIafeyw+94EmtOedSBAO2Q8NN
3jHoINBCyRu6hU3ml0h7daoIhUw9ONI7MZUlYvuV7Ti3yf+czzpqwYx3gZA39sa8
qUbJPFk6ts+CVjqSAdYVSfU3TC1Us1usOM3+04mACp3vc6ZUKxnB4Dsk/sSHj71n
EcZ3fOR7EgjmXf6wBiq24T2+0UzoHW2yDaiAowr6iQKBgQDDs2eJTopFejt47EMm
zY//e8fyUDKx07CWwUZKP78IMTZcp8BwHmKTSY+VbQLvVYnNxADkckYapokjU3a7
GMOXxvKVLqfgU/oUBIkRFDRo+nasFCd5cPpnYjQ6lGalBkDghVlyvq7kKF/MPtIK
dLqsV3tZDXoHX2mjTga94ygTZwKBgQC7pa+eZdWiz275mpYql9Gs6N3G3HhcpRxb
oWYWekCtZQ9gtecpvA9e0tW3ShoF19ksWYGpM4vxAOXul5Ei9IPVVNHc7RvGF5CO
0Zryx3qaVk7E6XQc3BQVVAenT2fNiv3+fkCMeAvcZBZOGiPPziCCT3wU3bd3tsn8
EsMiAPvTyQKBgEs0iWhBr29NrsckfBXQTzMN/WOIIEMoJ6d3dKyZ3K6oQszOhmxP
sPALB8uTjdotk/xoAzPHGlupfe/+ZhU2Sgvsn1JnEIprmyHQMGBI1G83OR2dzSGl
IgVSvuF4IA3w3kOp2xr2Xj09qrrRtWPhQc9y+urY+/kTWIQyOvMD9WWnAoGADdUN
2BBLqj++P3oMvcEJPMTBrGoOGU42g+6m1ttWLzH26zsdei8ZtvS1ulglCO87XBCR
BUb+dtqJGIhls3zwxuYEvlNgK78K8ewzjtfzirL4BX3sCECU3mmeUtAAp98qD/uA
iJpEzY83MbStlSDtto1jaSpa3uFDjGhZqAUIizkCgYABf/HSMwWsnvRgo0JMmDru
3L/xScJiFVdFV9vNiSckjUYhx7lgFqEjsDEN1mZiRIa2UhLOqpo5S/cIXfMkeEPh
9Rusf9STwNwOOyhFRrHCxqhTH6Ivnj6oZSM/UrS8GvCVMPWs1z8k45FWKI/IRXQq
AT5PRKihrWbg63/rXaSCxw==
-----END PRIVATE KEY-----`;
const jwks = '{"keys":[{"use":"sig","kty":"RSA","n":"j3K1g8I4e-J7tYH2KPcsFSETHnXz8GzYg4x56L8xwCHk6HNfi8N47v4qPTjBNyi1syIm1B19_S4pSrEkCooCd_ouyddyKriQ_vqM2PQvA6fz9-LcVWPhAkkEXSAWxEyycWAVlzsemLMP3fwlLgznRMNiITbzfe7E4eY1GXun_x-pnwqSd1-Uma6qyFE0y-q-C4_WUtz1F-Yr1OrHdq186Q7FEIKzYJrxjd8PZwB7ncQFL_8zhmuPNNmfs9eCwgSF-r-jyCu_WEOLPf-YR-9zHrAOr4M1koSXMlct4LsnXvnboaRAqNat3enSGXqq2s0MKgFt73KZhBSXV45KaHIg3w","e":"AQAB"}]}';
// 使用临时目录(跨平台兼容)
const tmpDir = process.env.TMPDIR || process.env.TEMP || '/tmp';
const keyFile = path.join(tmpDir, 'convex_jwt_key.txt');
const jwksFile = path.join(tmpDir, 'convex_jwks.txt');
// 写入临时文件
fs.writeFileSync(keyFile, privateKey);
fs.writeFileSync(jwksFile, jwks);
console.log('Setting JWT_PRIVATE_KEY from file...');
const keyContent = fs.readFileSync(keyFile, 'utf8');
try {
setConvexEnv("JWT_PRIVATE_KEY", keyContent);
console.log('✓ JWT_PRIVATE_KEY set');
} catch (e) {
console.error('✗ Failed to set JWT_PRIVATE_KEY:', e.message);
}
console.log('Setting JWKS...');
try {
setConvexEnv("JWKS", jwks);
console.log('✓ JWKS set');
} catch (e) {
console.error('✗ Failed to set JWKS:', e.message);
}
// 清理临时文件
try {
fs.unlinkSync(keyFile);
fs.unlinkSync(jwksFile);
} catch (e) {
// 忽略清理错误
}
console.log('Done! You can now test registration.');
+13
View File
@@ -0,0 +1,13 @@
import { exportJWK, exportPKCS8, generateKeyPair } from "jose";
const keys = await generateKeyPair("RS256", { extractable: true });
const privateKey = await exportPKCS8(keys.privateKey);
const publicKey = await exportJWK(keys.publicKey);
const jwks = JSON.stringify({ keys: [{ use: "sig", ...publicKey }] });
process.stdout.write(
`JWT_PRIVATE_KEY="${privateKey.trimEnd().replace(/\n/g, " ")}"`,
);
process.stdout.write("\n");
process.stdout.write(`JWKS=${jwks}`);
process.stdout.write("\n");
@@ -0,0 +1,60 @@
/**
* 注册测试账号脚本
*
* 用途:快速注册测试账号,方便开发和测试
* 运行:node scripts/register-test-user.js
*/
const TEST_CREDENTIALS = {
email: "test@example.com",
password: "Test123456",
name: "测试用户",
};
async function registerTestUser() {
const baseUrl = "http://localhost:3000";
console.log("正在注册测试账号...");
console.log(`邮箱: ${TEST_CREDENTIALS.email}`);
console.log(`密码: ${TEST_CREDENTIALS.password}`);
try {
const response = await fetch(`${baseUrl}/api/auth/signin`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: TEST_CREDENTIALS.email,
password: TEST_CREDENTIALS.password,
name: TEST_CREDENTIALS.name,
flow: "signUp",
}),
});
const result = await response.json();
if (response.ok) {
console.log("✓ 测试账号注册成功!");
console.log(`\n您现在可以使用以下凭据登录:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(`\n或在登录页面点击"测试账号快速登录"按钮。`);
} else if (response.status === 501) {
console.log("ℹ API 路由暂未实现,请通过浏览器手动注册:");
console.log(` 1. 访问 http://localhost:3000/auth`);
console.log(` 2. 点击"还没有账户?立即注册"`);
console.log(` 3. 填写:`);
console.log(` 邮箱: ${TEST_CREDENTIALS.email}`);
console.log(` 密码: ${TEST_CREDENTIALS.password}`);
console.log(` 姓名: ${TEST_CREDENTIALS.name}`);
} else {
console.error("✗ 注册失败:", result.error || result.message);
}
} catch (error) {
console.error("✗ 请求失败:", error.message);
console.log("\n请确保开发服务器正在运行 (pnpm dev)");
}
}
registerTestUser();
+57
View File
@@ -0,0 +1,57 @@
const { execSync } = require('child_process');
function setConvexEnv(name, value) {
// 说明:使用 stdin 传值,避免以 `-----BEGIN ...` 开头的内容被 CLI 误当作参数/选项。
execSync(`npx convex env set ${name}`, {
input: String(value ?? ""),
stdio: ['pipe', 'inherit', 'inherit'],
cwd: process.cwd(),
});
}
const privateKey = `-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCPcrWDwjh74nu1
gfYo9ywVIRMedfPwbNiDjHnovzHAIeToc1+Lw3ju/io9OME3KLWzIibUHX39LilK
sSQKigJ3+i7J13IquJD++ozY9C8Dp/P34txVY+ECSQRdIBbETLJxYBWXOx6Ysw/d
/CUuDOdEw2IhNvN97sTh5jUZe6f/H6mfCpJ3X5SZrqrIUTTL6r4Lj9ZS3PUX5ivU
6sd2rXzpDsUQgrNgmvGN3w9nAHudxAUv/zOGa4802Z+z14LCBIX6v6PIK79YQ4s9
/5hH73MesA6vgzWShJcyVy3guyde+duhpECo1q3d6dIZeqrazQwqAW3vcpmEFJdX
jkpociDfAgMBAAECggEAAYtRE+mH1SGThlkvTrKWeWXBQG8xoJFzZTsiZtSEExbq
UWxIh4cjqqL2znDpd5ALILIJ6/ejTxHrpN+yTSC+NQ9u6IJWusoA2ZXV5VH/nZD1
yeHZ0FuCZRVnJB9/zz4qH5lSsi2TPz6SOagIuG2wIafeyw+94EmtOedSBAO2Q8NN
3jHoINBCyRu6hU3ml0h7daoIhUw9ONI7MZUlYvuV7Ti3yf+czzpqwYx3gZA39sa8
qUbJPFk6ts+CVjqSAdYVSfU3TC1Us1usOM3+04mACp3vc6ZUKxnB4Dsk/sSHj71n
EcZ3fOR7EgjmXf6wBiq24T2+0UzoHW2yDaiAowr6iQKBgQDDs2eJTopFejt47EMm
zY//e8fyUDKx07CWwUZKP78IMTZcp8BwHmKTSY+VbQLvVYnNxADkckYapokjU3a7
GMOXxvKVLqfgU/oUBIkRFDRo+nasFCd5cPpnYjQ6lGalBkDghVlyvq7kKF/MPtIK
dLqsV3tZDXoHX2mjTga94ygTZwKBgQC7pa+eZdWiz275mpYql9Gs6N3G3HhcpRxb
oWYWekCtZQ9gtecpvA9e0tW3ShoF19ksWYGpM4vxAOXul5Ei9IPVVNHc7RvGF5CO
0Zryx3qaVk7E6XQc3BQVVAenT2fNiv3+fkCMeAvcZBZOGiPPziCCT3wU3bd3tsn8
EsMiAPvTyQKBgEs0iWhBr29NrsckfBXQTzMN/WOIIEMoJ6d3dKyZ3K6oQszOhmxP
sPALB8uTjdotk/xoAzPHGlupfe/+ZhU2Sgvsn1JnEIprmyHQMGBI1G83OR2dzSGl
IgVSvuF4IA3w3kOp2xr2Xj09qrrRtWPhQc9y+urY+/kTWIQyOvMD9WWnAoGADdUN
2BBLqj++P3oMvcEJPMTBrGoOGU42g+6m1ttWLzH26zsdei8ZtvS1ulglCO87XBCR
BUb+dtqJGIhls3zwxuYEvlNgK78K8ewzjtfzirL4BX3sCECU3mmeUtAAp98qD/uA
iJpEzY83MbStlSDtto1jaSpa3uFDjGhZqAUIizkCgYABf/HSMwWsnvRgo0JMmDru
3L/xScJiFVdFV9vNiSckjUYhx7lgFqEjsDEN1mZiRIa2UhLOqpo5S/cIXfMkeEPh
9Rusf9STwNwOOyhFRrHCxqhTH6Ivnj6oZSM/UrS8GvCVMPWs1z8k45FWKI/IRXQq
AT5PRKihrWbg63/rXaSCxw==
-----END PRIVATE KEY-----`;
const jwks = '{"keys":[{"use":"sig","kty":"RSA","n":"j3K1g8I4e-J7tYH2KPcsFSETHnXz8GzYg4x56L8xwCHk6HNfi8N47v4qPTjBNyi1syIm1B19_S4pSrEkCooCd_ouyddyKriQ_vqM2PQvA6fz9-LcVWPhAkkEXSAWxEyycWAVlzsemLMP3fwlLgznRMNiITbzfe7E4eY1GXun_x-pnwqSd1-Uma6qyFE0y-q-C4_WUtz1F-Yr1OrHdq186Q7FEIKzYJrxjd8PZwB7ncQFL_8zhmuPNNmfs9eCwgSF-r-jyCu_WEOLPf-YR-9zHrAOr4M1koSXMlct4LsnXvnboaRAqNat3enSGXqq2s0MKgFt73KZhBSXV45KaHIg3w","e":"AQAB"}]}';
console.log('Setting JWT_PRIVATE_KEY...');
try {
setConvexEnv("JWT_PRIVATE_KEY", privateKey);
} catch (e) {
console.error('Failed to set JWT_PRIVATE_KEY:', e.message);
}
console.log('Setting JWKS...');
try {
setConvexEnv("JWKS", jwks);
} catch (e) {
console.error('Failed to set JWKS:', e.message);
}
console.log('Done!');
@@ -20,7 +20,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
const openTableId = typeof openTableIdRaw === "string" ? openTableIdRaw : null;
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const doc = await client.query(api.documents.getMeta, { userId: auth.userId, id });
if (!doc) {
+1 -1
View File
@@ -66,7 +66,7 @@ function makeId(): string {
export default async function AppLayout({ children }: { children: ReactNode }) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const ensured = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
+229
View File
@@ -0,0 +1,229 @@
"use client";
import { useConvexAuth } from "convex/react";
import { useAuthActions } from "@convex-dev/auth/react";
import { useState, useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { isConvexEnabled } from "@/lib/convex/enabled";
type AuthStep = "signIn" | "signUp";
// 测试账号凭据常量
const TEST_CREDENTIALS = {
email: "test@example.com",
password: "Test123456",
} as const;
/**
* Convex Auth 登录/注册页面
*
* 支持功能:
* - 邮箱密码登录
* - 邮箱密码注册
*/
export default function AuthPage() {
const { isLoading, isAuthenticated } = useConvexAuth();
const { signIn } = useAuthActions();
const router = useRouter();
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.replace("/");
}
}, [isAuthenticated, isLoading, router]);
const [flow, setFlow] = useState<AuthStep>("signIn");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [message, setMessage] = useState<{
type: "success" | "error" | "info";
text: string;
} | null>(null);
// 执行登录的核心逻辑
const performSignIn = useCallback(async (email: string, password: string, flow: AuthStep) => {
setMessage(null);
try {
const formData = new FormData();
formData.append("email", email);
formData.append("password", password);
if (flow === "signUp") {
formData.append("name", name);
}
formData.append("flow", flow);
const result = await signIn("password", formData);
if (result.signingIn) {
setMessage({ type: "success", text: flow === "signIn" ? "登录成功!" : "注册成功!" });
setTimeout(() => router.push("/"), 500);
return;
}
// 说明:部分认证方式会返回 redirect(例如 OAuth);密码模式一般不会走到这里。
if (result.redirect) {
setMessage({ type: "info", text: "正在跳转..." });
router.push(result.redirect.toString());
return;
}
setMessage({ type: "error", text: "操作失败,请重试" });
} catch (error: any) {
setMessage({ type: "error", text: error.message || "操作失败,请重试" });
}
}, [name, signIn, router]);
const handleSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
await performSignIn(email, password, flow);
}, [email, password, flow, performSignIn]);
// 检查是否启用了 Convex
const isConvex = isConvexEnabled();
if (!isConvex) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-4"></h1>
<p className="text-gray-600"> Convex </p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
);
}
if (isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
{flow === "signIn" ? "登录账户" : "创建账户"}
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
使 Convex Auth
</p>
</div>
{message && (
<div className={`rounded-md p-4 ${
message.type === "success" ? "bg-green-50 text-green-800" :
message.type === "error" ? "bg-red-50 text-red-800" :
"bg-blue-50 text-blue-800"
}`}>
<p className="text-sm">{message.text}</p>
</div>
)}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only"></label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="邮箱地址"
/>
</div>
{flow === "signUp" && (
<div>
<label htmlFor="name" className="sr-only"></label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
value={name}
onChange={(e) => setName(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="姓名(可选)"
/>
</div>
)}
<div>
<label htmlFor="password" className="sr-only"></label>
<input
id="password"
name="password"
type="password"
autoComplete={flow === "signIn" ? "current-password" : "new-password"}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="密码(至少 8 位)"
/>
</div>
</div>
<div>
<button
type="submit"
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
{flow === "signIn" ? "登录" : "注册"}
</button>
</div>
{flow === "signIn" && (
<div>
<button
type="button"
onClick={() => {
setEmail(TEST_CREDENTIALS.email);
setPassword(TEST_CREDENTIALS.password);
// 直接调用登录逻辑
performSignIn(TEST_CREDENTIALS.email, TEST_CREDENTIALS.password, "signIn");
}}
className="group relative w-full flex justify-center py-2 px-4 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
</button>
</div>
)}
<div className="text-center">
<button
type="button"
onClick={() => {
setFlow(flow === "signIn" ? "signUp" : "signIn");
setMessage(null);
}}
className="text-blue-600 hover:text-blue-500 text-sm"
>
{flow === "signIn" ? "还没有账户?立即注册" : "已有账户?去登录"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -28,13 +28,11 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "缺少 requestId/callId" }, { status: 400 });
}
const userId = (() => {
if (isConvexEnabled()) {
const auth = requireAuthContext();
return auth.userId;
}
return null;
})();
let userId: string | null = null;
if (isConvexEnabled()) {
const auth = await requireAuthContext();
userId = auth.userId;
}
const resolvedUserId = async () => {
if (userId) return userId;
@@ -77,7 +77,7 @@ export async function POST(request: Request) {
const convexOn = isConvexEnabled();
const { userId, supabase, convexClient } = await (async () => {
if (convexOn) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
return { userId: auth.userId, supabase: null as any, convexClient: client };
}
const supabase = await createSupabaseRouteClient();
@@ -0,0 +1,110 @@
import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
interface SignInRequest {
email: string;
password: string;
name?: string;
flow: "signIn" | "signUp";
}
/**
* POST /api/auth/signin
*
* 处理登录/注册请求
* - 在 Convex 模式下调用 Convex mutation
* - 在 Supabase 模式下调用 Supabase Auth
*/
export async function POST(request: Request) {
// Convex 模式
if (isConvexEnabled()) {
try {
const body = await request.json() as SignInRequest;
const { email, password, name, flow } = body;
// TODO: 调用 Convex Auth mutation 处理登录/注册
// 当前暂时返回未实现错误
return NextResponse.json(
{ error: "Convex Auth API 路由暂未实现,请稍后实现" },
{ status: 501 }
);
} catch (error) {
console.error("Convex Auth error:", error);
return NextResponse.json(
{ error: "服务器错误" },
{ status: 500 }
);
}
}
// Supabase 模式(保留兼容)
try {
const { createSupabaseRouteClient } = await import("@/lib/supabase/server");
const supabase = await createSupabaseRouteClient();
const body = await request.json() as SignInRequest;
const { email, password, name, flow } = body;
if (flow === "signUp") {
// 注册
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
name: name ?? "",
},
},
});
if (error) {
return NextResponse.json(
{ error: error.message },
{ status: 400 }
);
}
// 注册成功后自动登录
const { error: signInError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (signInError) {
return NextResponse.json(
{ error: signInError.message },
{ status: 401 }
);
}
return NextResponse.json(
{ message: "注册并登录成功" },
{ status: 200 }
);
} else {
// 登录
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
return NextResponse.json(
{ error: error.message },
{ status: 401 }
);
}
return NextResponse.json(
{ message: "登录成功" },
{ status: 200 }
);
}
} catch (error) {
console.error("Supabase Auth error:", error);
return NextResponse.json(
{ error: "服务器错误" },
{ status: 500 }
);
}
}
@@ -26,7 +26,7 @@ export async function POST(request: Request) {
}
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
@@ -113,4 +113,3 @@ export async function POST(request: Request) {
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ ok: true });
}
@@ -16,7 +16,7 @@ export async function GET(request: Request) {
}
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
@@ -46,4 +46,3 @@ export async function GET(request: Request) {
return NextResponse.json({ ok: true, block: hit.block });
}
@@ -26,7 +26,7 @@ export async function POST(request: Request) {
}
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const source = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
@@ -97,4 +97,3 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true });
}
@@ -20,7 +20,7 @@ export async function POST(request: Request) {
}
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const doc = await client.query(api.documents.getContent, { userId: auth.userId, id: sourceDocumentId });
if (!doc) return NextResponse.json({ error: "页面不存在或无权限" }, { status: 404 });
@@ -62,4 +62,3 @@ export async function POST(request: Request) {
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
type Payload =
| { type: "document"; documentId: string }
| { type: "mindmap"; docId: string; mindmapId: string }
| { type: "media"; assetId: string };
export async function POST(request: Request) {
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const body = (await request.json().catch(() => null)) as Payload | null;
if (!body) {
return NextResponse.json({ error: "缺少请求体" }, { status: 400 });
}
if (body.type === "document") {
const documentId = String(body.documentId ?? "").trim();
if (!documentId) return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
const result = await client.mutation(api.jobs.enqueueRagIndexDocument, { userId: auth.userId, documentId });
return NextResponse.json(result);
}
if (body.type === "mindmap") {
const docId = String(body.docId ?? "").trim();
const mindmapId = String(body.mindmapId ?? "").trim();
if (!docId) return NextResponse.json({ error: "缺少 docId" }, { status: 400 });
if (!mindmapId) return NextResponse.json({ error: "缺少 mindmapId" }, { status: 400 });
const result = await client.mutation(api.jobs.enqueueRagIndexMindmap, { userId: auth.userId, docId, mindmapId });
return NextResponse.json(result);
}
if (body.type === "media") {
const assetId = String(body.assetId ?? "").trim();
if (!assetId) return NextResponse.json({ error: "缺少 assetId" }, { status: 400 });
const result = await client.mutation(api.jobs.enqueueRagIndexMediaAsset, { userId: auth.userId, assetId });
return NextResponse.json(result);
}
return NextResponse.json({ error: "不支持的 type" }, { status: 400 });
}
@@ -7,7 +7,7 @@ import { api } from "@/lib/convex/api";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const { ms }: { ms?: number } = await request.json().catch(() => ({}));
@@ -23,7 +23,7 @@ export async function POST(request: Request) {
}
export async function GET(request: Request) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const url = new URL(request.url);
@@ -39,4 +39,3 @@ export async function GET(request: Request) {
return NextResponse.json(job);
}
@@ -6,7 +6,7 @@ export const dynamic = "force-dynamic";
export async function GET() {
try {
const auth = requireAuthContext();
const auth = await requireAuthContext();
return NextResponse.json({ ok: true, auth }, { status: 200 });
} catch (err) {
const status = err instanceof HttpError ? err.status : 500;
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
export async function GET(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const url = new URL(request.url);
const documentId = url.searchParams.get("documentId") ?? "";
@@ -163,7 +163,7 @@ function replaceAssetRefsInContent(content: Json | null, assetMap: Map<string, {
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const payload = (await request.json()) as CopyTreePayload;
@@ -22,7 +22,7 @@ export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const { parentId, title, blocks }: CreateChildPayload = await request.json();
@@ -41,7 +41,7 @@ export async function POST(request: Request) {
}
async function handleCreateRequestConvex(request: Request) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const { parentId }: { parentId?: string | null } = await request.json();
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
@@ -48,7 +48,7 @@ async function copyMindmapIfExists(sourceId: string, targetId: string) {
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const { documentId }: DuplicatePayload = await request.json();
@@ -14,7 +14,7 @@ interface EmbedPayload {
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const { sourceId, targetId }: EmbedPayload = await request.json();
@@ -13,7 +13,7 @@ interface EmptyTrashPayload {
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const { workspaceId }: EmptyTrashPayload = await request.json();
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
@@ -13,7 +13,7 @@ interface MovePayload {
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const { documentId, parentId = null, position }: MovePayload = await request.json();
const sortOrder = Number.isFinite(position) ? Math.floor(position) : 0;
const client = getConvexHttpClient();
@@ -24,7 +24,7 @@ const COLUMN_MAP: Record<keyof PageOptionsState, keyof Database["public"]["Table
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const { documentId, options }: OptionsPayload = await request.json();
if (!documentId || !options) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const { documentId } = await request.json();
if (!documentId) {
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
@@ -12,7 +12,7 @@ interface SavePayload {
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const { documentId, content }: SavePayload = await request.json();
const client = getConvexHttpClient();
await client.mutation(api.documents.updateContent, {
@@ -13,7 +13,7 @@ interface StatsPayload {
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const { documentId, stats }: StatsPayload = await request.json();
if (!documentId || !stats) {
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
@@ -12,7 +12,7 @@ interface RenamePayload {
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const { documentId, title }: RenamePayload = await request.json();
const client = getConvexHttpClient();
await client.mutation(api.documents.updateTitle, {
@@ -2,6 +2,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
export async function GET(request: Request) {
const url = new URL(request.url);
@@ -11,6 +14,28 @@ export async function GET(request: Request) {
return NextResponse.json({ code: 400, msg: "gridKey 参数缺失" }, { status: 400 });
}
// Convex 模式
if (isConvexEnabled()) {
try {
const client = getConvexHttpClient();
const result = await client.query(api.tables.getByGridKey, { gridKey });
if (!result) {
return NextResponse.json({ code: 404, msg: "未查询到相关数据" }, { status: 404 });
}
return NextResponse.json({
code: 200,
msg: "ok",
data: result,
});
} catch (error) {
console.error("Convex API error:", error);
return NextResponse.json({ code: 500, msg: "服务器错误" }, { status: 500 });
}
}
// Supabase 模式
const supabase = await createSupabaseRouteClient();
const { data, error } = await supabase
.from("document_tables")
@@ -4,6 +4,9 @@ import { NextResponse } from "next/server";
import { createDefaultTableSnapshot } from "@/lib/online-table";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { DocumentTableSnapshot, TableSchema } from "@/types/online-table";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getConvexHttpClient } from "@/lib/convex/server";
import { api } from "@/lib/convex/api";
const fallbackSchema: TableSchema = {
columns: [],
@@ -19,6 +22,38 @@ export async function POST(request: Request) {
return NextResponse.json({ code: 400, msg: "gridKey 参数缺失" }, { status: 400 });
}
// Convex 模式
if (isConvexEnabled()) {
try {
const client = getConvexHttpClient();
const tableData = await client.query(api.tables.getByGridKeyFull, { gridKey });
if (!tableData) {
return NextResponse.json({ code: 404, msg: "未查询到相关数据" }, { status: 404 });
}
const snapshot = (tableData.snapshot as DocumentTableSnapshot | null) ?? null;
const schema = (tableData.schema as TableSchema | null) ?? fallbackSchema;
let payload: unknown[] = [];
if (snapshot?.luckysheet && Array.isArray(snapshot.luckysheet)) {
payload = snapshot.luckysheet;
} else {
const defaultSnapshot = createDefaultTableSnapshot(schema);
payload = defaultSnapshot.luckysheet ?? [];
}
const body = JSON.stringify(payload);
return new NextResponse(body, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
} catch (error) {
console.error("Convex API error:", error);
return NextResponse.json({ code: 500, msg: "服务器错误" }, { status: 500 });
}
}
// Supabase 模式
const supabase = await createSupabaseRouteClient();
const { data, error } = await supabase
.from("document_tables")
@@ -12,7 +12,7 @@ export async function GET(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
auth = await requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
@@ -85,7 +85,7 @@ export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
auth = await requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
@@ -71,7 +71,7 @@ export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
auth = await requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
@@ -32,7 +32,7 @@ export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
auth = await requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
@@ -32,7 +32,7 @@ export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
auth = await requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
@@ -68,7 +68,7 @@ export async function GET(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
auth = await requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
@@ -98,7 +98,7 @@ export async function GET(request: Request) {
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
try {
requireAuthContext();
await requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
@@ -24,7 +24,7 @@ export async function POST(request: Request) {
if (isConvexEnabled()) {
let auth;
try {
auth = requireAuthContext();
auth = await requireAuthContext();
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: "未登录" }, { status: err.status });
@@ -33,7 +33,7 @@ async function purgeTrashFolder(folder: string): Promise<number> {
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
@@ -24,7 +24,7 @@ export async function POST(
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const payload = (await request.json().catch(() => null)) as RequestPayload | null;
const ops = Array.isArray(payload?.ops) ? payload!.ops : [];
@@ -117,7 +117,7 @@ export async function GET(
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const res = await client.query(api.mindmaps.get, {
userId: auth.userId,
docId,
@@ -171,7 +171,7 @@ export async function POST(
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const { data, createOnly } = (await request.json().catch(() => ({ data: null }))) as {
data?: unknown;
createOnly?: boolean;
@@ -241,7 +241,7 @@ export async function DELETE(
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
try {
const result = await client.mutation(api.mindmaps.softDelete, {
userId: auth.userId,
@@ -316,7 +316,7 @@ export async function PATCH(
const { docId, mindmapId } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const { action } = (await request.json().catch(() => ({}))) as { action?: string };
if (action !== "restore" && action !== "purge") {
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
@@ -45,7 +45,7 @@ export async function GET(
const { docId: id } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${id}`;
const res = await client.query(api.mindmaps.get, {
userId: auth.userId,
@@ -106,7 +106,7 @@ export async function POST(
const { docId: id } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${id}`;
const { data } = await request.json();
const result = await client.mutation(api.mindmaps.put, {
@@ -161,7 +161,7 @@ export async function DELETE(
const { docId: id } = await params;
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const mindmapId = `legacy-${id}`;
const result = await client.mutation(api.mindmaps.softDelete, {
userId: auth.userId,
@@ -6,7 +6,7 @@ import { api } from "@/lib/convex/api";
export async function GET(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const { searchParams } = new URL(request.url);
const workspaceId = searchParams.get("workspaceId");
const pageId = searchParams.get("pageId");
@@ -20,7 +20,7 @@ const isValidDisplayMode = (mode: string): mode is DisplayMode => mode === "inli
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const body = (await request.json()) as RecordReferencePayload;
if (!body?.workspaceId || !body?.sourcePageId || !body?.targetPageId) {
return NextResponse.json({ error: "缺少必要参数" }, { status: 400 });
@@ -148,7 +148,7 @@ const fetchOcrMatches = async (
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const payload = (await request.json()) as DocumentSearchRequest;
const workspaceId = payload.workspaceId;
@@ -11,7 +11,7 @@ interface RecentPayload {
export async function POST(request: Request) {
if (isConvexEnabled()) {
const { auth, client } = getAuthedConvexClient();
const { auth, client } = await getAuthedConvexClient();
const { workspaceId, documentId }: RecentPayload = await request.json();
if (!workspaceId || !documentId) {
+1 -1
View File
@@ -72,7 +72,7 @@ function extractMindmapImageAssetIdsFromData(input: unknown): string[] {
export async function GET(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const url = new URL(request.url);
@@ -1,6 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import type { DocumentTableSnapshot, TableRowData } from "@/types/online-table";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
interface RouteContext {
params: Promise<{
@@ -35,19 +38,38 @@ const getAuthUser = async () => {
};
export async function GET(request: Request, context: RouteContext) {
const tableId = await extractTableId(context);
if (!tableId) {
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
}
// Convex 模式
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
try {
const table = await client.query(api.tables.get, {
userId: auth.userId,
tableId,
});
if (!table) {
return NextResponse.json({ error: "Table not found" }, { status: 404 });
}
return NextResponse.json(table, { status: 200 });
} catch (error) {
console.error("Convex API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// Supabase 模式
const { supabase, user } = await getAuthUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 强制 await params,以解决 Next.js 16/Turbopack 错误。
const tableId = await extractTableId(context);
if (!tableId) {
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
}
try {
// 假设 RLS 确保了用户只能访问其有权限的表格
const { data: tableData, error } = await supabase
@@ -74,17 +96,47 @@ export async function GET(request: Request, context: RouteContext) {
}
export async function PATCH(request: Request, context: RouteContext) {
const tableId = await extractTableId(context);
if (!tableId) {
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
}
// Convex 模式
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
try {
const body = await request.json() as UpdateTableRequest;
const result = await client.mutation(api.tables.update, {
userId: auth.userId,
tableId,
title: body.title,
schema: body.schema,
view_preferences: body.viewPreferences,
snapshot: body.snapshot,
rows: body.rows,
});
// 重新获取更新后的表格数据
const updated = await client.query(api.tables.get, {
userId: auth.userId,
tableId,
});
return NextResponse.json(updated, { status: 200 });
} catch (error) {
console.error("Convex API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// Supabase 模式
const { supabase, user } = await getAuthUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const tableId = await extractTableId(context);
if (!tableId) {
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
}
let body: UpdateTableRequest | null = null;
try {
body = await request.json() as UpdateTableRequest;
@@ -191,17 +243,34 @@ export async function PATCH(request: Request, context: RouteContext) {
}
export async function DELETE(request: Request, context: RouteContext) {
const tableId = await extractTableId(context);
if (!tableId) {
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
}
// Convex 模式
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
try {
await client.mutation(api.tables.purge, {
userId: auth.userId,
tableId,
});
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("Convex API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// Supabase 模式
const { supabase, user } = await getAuthUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const tableId = await extractTableId(context);
if (!tableId) {
return NextResponse.json({ error: "Missing tableId" }, { status: 400 });
}
try {
const { error: deleteRowsError } = await supabase
.from("document_table_rows")
@@ -1,6 +1,9 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import { DocumentTableSnapshot, TableSchema } from "@/types/online-table";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { api } from "@/lib/convex/api";
// 定义请求体类型
interface CreateTableRequestBody {
@@ -11,6 +14,46 @@ interface CreateTableRequestBody {
}
export async function POST(request: Request) {
// Convex 模式
if (isConvexEnabled()) {
const { auth, client } = await getAuthedConvexClient();
try {
const { documentId, title, schema, snapshot } = await request.json() as CreateTableRequestBody;
if (!documentId || !title || !schema) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
// 获取 document 所在的 workspace_id
const document = await client.query(api.documents.getMeta, {
userId: auth.userId,
id: documentId,
});
if (!document) {
return NextResponse.json({ error: "Document not found" }, { status: 404 });
}
// 创建表格
const result = await client.mutation(api.tables.create, {
userId: auth.userId,
workspaceId: document.workspace_id,
documentId,
title,
schema,
snapshot,
});
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error("Convex API error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
// Supabase 模式
const supabase = await createSupabaseRouteClient();
const { data: { user } } = await supabase.auth.getUser();
@@ -9,7 +9,7 @@ export const dynamic = "force-dynamic";
export async function POST(request: Request) {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const payload = await request.json().catch(() => ({}));
const workspaceId = payload.workspaceId as string | undefined;
if (!workspaceId) {
+7 -1
View File
@@ -3,9 +3,11 @@ import { Inter } from "next/font/google";
import "./globals.css";
import { SupabaseProvider } from "@/components/providers/supabase-provider";
import { QueryProvider } from "@/components/providers/query-provider";
import { ConvexClientProvider } from "@/components/providers/convex-provider";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
const inter = Inter({
subsets: ["latin"],
@@ -51,7 +53,11 @@ export default async function RootLayout({
</head>
<body className={`${inter.variable} antialiased`}>
{useConvex ? (
<QueryProvider>{children}</QueryProvider>
<ConvexAuthNextjsServerProvider>
<ConvexClientProvider>
<QueryProvider>{children}</QueryProvider>
</ConvexClientProvider>
</ConvexAuthNextjsServerProvider>
) : (
<SupabaseProvider session={session}>
<QueryProvider>{children}</QueryProvider>
+1 -1
View File
@@ -14,7 +14,7 @@ function makeId(): string {
export default async function Home() {
if (isConvexEnabled()) {
const auth = requireAuthContext();
const auth = await requireAuthContext();
const client = getConvexHttpClient();
const { activeWorkspaceId } = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
@@ -0,0 +1,41 @@
"use client";
import { ReactNode, useState } from "react";
import { ConvexAuthNextjsProvider } from "@convex-dev/auth/nextjs";
import { ConvexReactClient } from "convex/react";
interface ConvexClientProviderProps {
children: ReactNode;
}
/**
* Convex 客户端 Provider(包含 Auth
*/
export function ConvexClientProvider({ children }: ConvexClientProviderProps) {
const [convex] = useState(() => {
// 动态获取 Convex URL,默认优先使用环境变量。
// 说明:浏览器侧优先用当前 hostname(端口固定 3210),避免通过非 localhost/127 访问前端时出现跨域/白名单不匹配。
const getConvexUrl = () => {
// 默认使用环境变量
const envUrl = process.env.NEXT_PUBLIC_CONVEX_URL;
if (typeof window === "undefined") {
return envUrl || "http://127.0.0.1:3210";
}
// 在浏览器中,使用当前主机名,但端口改为 3210
const hostname = window.location.hostname;
return `http://${hostname}:3210`;
};
const convexUrl = getConvexUrl();
if (!convexUrl) {
throw new Error("NEXT_PUBLIC_CONVEX_URL is not defined");
}
return new ConvexReactClient(convexUrl);
});
return (
<ConvexAuthNextjsProvider client={convex}>
{children}
</ConvexAuthNextjsProvider>
);
}
+71 -5
View File
@@ -1,5 +1,9 @@
import type { AuthContext } from "@/lib/auth/types";
import { getDevUser, isDevAuthEnabled } from "@/lib/auth/devUser";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
import { fetchQuery } from "convex/nextjs";
import { api } from "@/lib/convex/api";
export class HttpError extends Error {
status: number;
@@ -11,17 +15,79 @@ export class HttpError extends Error {
}
}
export function getAuthContext(): AuthContext {
if (isDevAuthEnabled()) return getDevUser();
// 说明:后续接入真实鉴权时,在这里替换为 Supabase/Convex Auth 的校验逻辑。
/**
* 获取认证上下文
*
* 优先级:
* 1. Convex Auth(如果启用)- 服务端:从 Convex Auth cookies 获取并查询当前用户;客户端:从 hooks 获取
* 2. 开发用户(如果启用)
* 3. 抛出错误(未配置)
*/
export async function getAuthContext(): Promise<AuthContext> {
// 1. 检查是否启用 Convex Auth
if (isConvexEnabled()) {
// 说明:开发模式下允许使用固定用户快速跑通链路(例如迁移测试)。
if (isDevAuthEnabled()) {
return getDevUser();
}
const token = await convexAuthNextjsToken();
if (!token) {
throw new Error("未登录,请先登录");
}
// 说明:从 Convex Auth token 获取当前用户(users 表由 authTables 提供)。
// 这里不强依赖具体字段结构,避免升级 authTables 后类型不兼容。
const user = (await fetchQuery(api.users.currentUser, {}, { token })) as unknown;
if (!user || typeof user !== "object") {
throw new Error("未登录,请先登录");
}
const record = user as Record<string, unknown>;
const id = record["_id"];
const email = record["email"];
const name = record["name"];
if (typeof id !== "string" || !id) {
throw new Error("未登录,请先登录");
}
return {
userId: id,
email: typeof email === "string" ? email : undefined,
name: typeof name === "string" ? name : undefined,
};
}
// 2. 检查是否启用开发用户模式
if (isDevAuthEnabled()) {
return getDevUser();
}
// 3. 未配置认证
throw new Error("Auth is not configured");
}
export function requireAuthContext(): AuthContext {
/**
* 要求已认证的上下文
*
* 如果未认证,抛出 401 错误
*/
export async function requireAuthContext(): Promise<AuthContext> {
try {
return getAuthContext();
return await getAuthContext();
} catch (err) {
const message = err instanceof Error ? err.message : "Unauthorized";
throw new HttpError(401, message);
}
}
/**
* 获取当前用户 ID
*
* 用于服务端组件和 API 路由
*/
export async function getCurrentUserId(): Promise<string> {
const auth = await requireAuthContext();
return auth.userId;
}
+1 -1
View File
@@ -1,4 +1,4 @@
export function isConvexEnabled(): boolean {
return process.env.USE_CONVEX === "1";
return process.env.NEXT_PUBLIC_USE_CONVEX === "1";
}
+2 -3
View File
@@ -3,9 +3,8 @@ import type { AuthContext } from "@/lib/auth/types";
import { requireAuthContext } from "@/lib/auth/authContext";
import { getConvexHttpClient } from "@/lib/convex/server";
export function getAuthedConvexClient(): { auth: AuthContext; client: ConvexHttpClient } {
const auth = requireAuthContext();
export async function getAuthedConvexClient(): Promise<{ auth: AuthContext; client: ConvexHttpClient }> {
const auth = await requireAuthContext();
const client = getConvexHttpClient();
return { auth, client };
}
+3 -2
View File
@@ -13,10 +13,11 @@ export function getConvexHttpClient(): ConvexHttpClient {
const client = new ConvexHttpClient(url);
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY;
if (adminKey) {
client.setAdminAuth(adminKey);
// 说明:ConvexHttpClient 的类型声明可能未暴露 setAdminAuth(但运行期存在)。
// 这里用最小侵入方式兼容自托管管理密钥。
(client as any).setAdminAuth(adminKey);
}
cached = client;
return client;
}
+37
View File
@@ -0,0 +1,37 @@
import { convexAuthNextjsMiddleware, createRouteMatcher, nextjsMiddlewareRedirect } from "@convex-dev/auth/nextjs/server";
// 说明:
// - 这里启用 Convex Auth 的 Next.js 中间件,负责:
// 1) 代理 /api/auth 到 Convex(用于 signIn / signOut 等动作)
// 2) 刷新/同步 auth cookies
// 3) 对需要登录的页面做统一拦截
// - 默认将未登录用户重定向到 /auth(Convex Auth 登录页)。
const isPublicRoute = createRouteMatcher([
"/auth",
"/login",
"/api/auth(.*)",
"/api/health(.*)",
"/_next(.*)",
"/favicon.ico",
]);
export default convexAuthNextjsMiddleware(async (request, ctx) => {
// 公共路由不做拦截(但 middleware 仍会处理 token 刷新/代理等)。
if (isPublicRoute(request)) return;
// 只在启用 Convex 模式时做鉴权拦截;否则走 Supabase 逻辑(页面内部自行处理)。
// 注意:middleware 运行在 Edge/Node 环境中,读取到的是运行期环境变量。
if (process.env.NEXT_PUBLIC_USE_CONVEX !== "1") return;
const authed = await ctx.convexAuth.isAuthenticated();
if (!authed) {
return nextjsMiddlewareRedirect(request, "/auth");
}
});
export const config = {
// 说明:排除静态资源,避免无意义的中间件开销。
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};