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);
},
});