From 25923f308cd6842102179eb03c1f63b3d14df932 Mon Sep 17 00:00:00 2001 From: liaibo Date: Thu, 22 Jan 2026 18:53:20 +0800 Subject: [PATCH] =?UTF-8?q?0.3.4=20=E5=B0=8F=E5=9B=BE=E6=A0=87=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E5=A2=9E=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- wolai-frontend/convex/_generated/api.d.ts | 12 + wolai-frontend/convex/_generated/api.js | 2 +- .../convex/_generated/dataModel.d.ts | 2 +- wolai-frontend/convex/_generated/server.d.ts | 2 +- wolai-frontend/convex/_generated/server.js | 2 +- wolai-frontend/convex/_utils/documentTree.ts | 44 ++ wolai-frontend/convex/documentGroupShares.ts | 260 ++++++++++ wolai-frontend/convex/documentShares.ts | 212 ++++++++ wolai-frontend/convex/documentStars.ts | 185 +++++++ wolai-frontend/convex/documents.ts | 349 ++++++++++++- wolai-frontend/convex/groupMembers.ts | 155 ++++++ wolai-frontend/convex/groups.ts | 162 ++++++ wolai-frontend/convex/schema.ts | 75 +++ wolai-frontend/convex/users.ts | 37 ++ .../src/app/(app)/documents/[id]/page.tsx | 2 + .../src/app/(app)/wolai-import/page.tsx | 20 +- wolai-frontend/src/app/(auth)/auth/page.tsx | 126 ++++- wolai-frontend/src/components/breadcrumb.tsx | 11 +- .../components/editor/blocknote-editor.tsx | 4 +- .../components/editor/document-content.tsx | 20 +- .../groups/group-manager-dialog.tsx | 304 ++++++++++++ .../sharing/document-share-dialog.tsx | 469 ++++++++++++++++++ .../src/components/sidebar/sidebar.tsx | 405 ++++++++++++--- wolai-frontend/src/lib/document-tree.test.ts | 34 ++ wolai-frontend/src/store/sidebar.ts | 28 +- 25 files changed, 2781 insertions(+), 141 deletions(-) create mode 100644 wolai-frontend/convex/_utils/documentTree.ts create mode 100644 wolai-frontend/convex/documentGroupShares.ts create mode 100644 wolai-frontend/convex/documentShares.ts create mode 100644 wolai-frontend/convex/documentStars.ts create mode 100644 wolai-frontend/convex/groupMembers.ts create mode 100644 wolai-frontend/convex/groups.ts create mode 100644 wolai-frontend/src/components/groups/group-manager-dialog.tsx create mode 100644 wolai-frontend/src/components/sharing/document-share-dialog.tsx create mode 100644 wolai-frontend/src/lib/document-tree.test.ts diff --git a/wolai-frontend/convex/_generated/api.d.ts b/wolai-frontend/convex/_generated/api.d.ts index 5b0defcc..5816b004 100644 --- a/wolai-frontend/convex/_generated/api.d.ts +++ b/wolai-frontend/convex/_generated/api.d.ts @@ -8,13 +8,19 @@ * @module */ +import type * as _utils_documentTree from "../_utils/documentTree.js"; 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 documentGroupShares from "../documentGroupShares.js"; +import type * as documentShares from "../documentShares.js"; +import type * as documentStars from "../documentStars.js"; import type * as documents from "../documents.js"; +import type * as groupMembers from "../groupMembers.js"; +import type * as groups from "../groups.js"; import type * as http from "../http.js"; import type * as jobs from "../jobs.js"; import type * as mediaAssets from "../mediaAssets.js"; @@ -33,13 +39,19 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + "_utils/documentTree": typeof _utils_documentTree; "_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; + documentGroupShares: typeof documentGroupShares; + documentShares: typeof documentShares; + documentStars: typeof documentStars; documents: typeof documents; + groupMembers: typeof groupMembers; + groups: typeof groups; http: typeof http; jobs: typeof jobs; mediaAssets: typeof mediaAssets; diff --git a/wolai-frontend/convex/_generated/api.js b/wolai-frontend/convex/_generated/api.js index 9124e129..44bf9858 100644 --- a/wolai-frontend/convex/_generated/api.js +++ b/wolai-frontend/convex/_generated/api.js @@ -1,4 +1,4 @@ - +/* eslint-disable */ /** * Generated `api` utility. * diff --git a/wolai-frontend/convex/_generated/dataModel.d.ts b/wolai-frontend/convex/_generated/dataModel.d.ts index f29136b0..f97fd194 100644 --- a/wolai-frontend/convex/_generated/dataModel.d.ts +++ b/wolai-frontend/convex/_generated/dataModel.d.ts @@ -1,4 +1,4 @@ - +/* eslint-disable */ /** * Generated data model types. * diff --git a/wolai-frontend/convex/_generated/server.d.ts b/wolai-frontend/convex/_generated/server.d.ts index a6e81e75..bec05e68 100644 --- a/wolai-frontend/convex/_generated/server.d.ts +++ b/wolai-frontend/convex/_generated/server.d.ts @@ -1,4 +1,4 @@ - +/* eslint-disable */ /** * Generated utilities for implementing server-side Convex query and mutation functions. * diff --git a/wolai-frontend/convex/_generated/server.js b/wolai-frontend/convex/_generated/server.js index 8df38674..bf3d25ad 100644 --- a/wolai-frontend/convex/_generated/server.js +++ b/wolai-frontend/convex/_generated/server.js @@ -1,4 +1,4 @@ - +/* eslint-disable */ /** * Generated utilities for implementing server-side Convex query and mutation functions. * diff --git a/wolai-frontend/convex/_utils/documentTree.ts b/wolai-frontend/convex/_utils/documentTree.ts new file mode 100644 index 00000000..c4e14f74 --- /dev/null +++ b/wolai-frontend/convex/_utils/documentTree.ts @@ -0,0 +1,44 @@ +export type ParentLinkedRow = { + id: string; + parent_id?: string | null; +}; + +/** + * 收集以 rootId 为根的整棵子树(包含根节点本身)。 + * + * 说明: + * - 仅依赖 (id, parent_id) 字段,便于在 Convex 与前端共用同一套遍历逻辑。 + * - 会做去重与环检测,避免异常数据导致死循环。 + */ +export function collectSubtree(rows: readonly T[], rootId: string): T[] { + const byParentId = new Map(); + for (const row of rows) { + const parentId = (row.parent_id ?? null) as string | null; + const bucket = byParentId.get(parentId); + if (bucket) bucket.push(row); + else byParentId.set(parentId, [row]); + } + + const root = rows.find((r) => r.id === rootId); + if (!root) return []; + + const visited = new Set(); + const stack: T[] = [root]; + const result: T[] = []; + + while (stack.length > 0) { + const current = stack.pop()!; + if (visited.has(current.id)) continue; + visited.add(current.id); + result.push(current); + + const children = byParentId.get(current.id) ?? []; + // 倒序入栈,保持更接近“原列表顺序”的遍历结果(不影响正确性) + for (let i = children.length - 1; i >= 0; i -= 1) { + stack.push(children[i]!); + } + } + + return result; +} + diff --git a/wolai-frontend/convex/documentGroupShares.ts b/wolai-frontend/convex/documentGroupShares.ts new file mode 100644 index 00000000..4e9cfa65 --- /dev/null +++ b/wolai-frontend/convex/documentGroupShares.ts @@ -0,0 +1,260 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { getAuthUserId } from "@convex-dev/auth/server"; +import { nowIso } from "./_utils/time"; + +type Permission = "read" | "edit"; + +async function requireUserId(ctx: any): Promise { + const userId = await getAuthUserId(ctx); + if (userId === null) throw new Error("未登录"); + return String(userId); +} + +async function requireOwnedDocument(ctx: any, documentId: string, userId: string) { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q: any) => q.eq("id", documentId)) + .first(); + if (!doc) throw new Error("页面不存在"); + if (doc.user_id !== userId) throw new Error("无权限"); + return doc; +} + +async function requireGroup(ctx: any, groupId: string) { + const group = await ctx.db + .query("groups") + .withIndex("by_group_id", (q: any) => q.eq("id", groupId)) + .first(); + if (!group) throw new Error("群组不存在"); + return group; +} + +export const listByDocument = query({ + args: { documentId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const doc = await requireOwnedDocument(ctx, args.documentId, userId); + + const shares = await ctx.db + .query("document_group_shares") + .withIndex("by_document", (q: any) => q.eq("document_id", doc.id)) + .collect(); + + const result = []; + for (const share of shares) { + const group = await ctx.db + .query("groups") + .withIndex("by_group_id", (q: any) => q.eq("id", share.group_id)) + .first(); + + const perms = await ctx.db + .query("document_group_user_permissions") + .withIndex("by_workspace_group", (q: any) => + q.eq("workspace_id", doc.workspace_id).eq("group_id", share.group_id), + ) + .collect(); + + const editableUserIds = perms + .filter((p: any) => p.document_id === doc.id && p.permission === "edit") + .map((p: any) => String(p.user_id)); + + result.push({ + groupId: share.group_id, + groupName: group?.name ?? null, + includeDescendants: share.include_descendants, + editableUserIds, + updatedAt: share.updated_at, + }); + } + + return result; + }, +}); + +export const upsert = mutation({ + args: { + documentId: v.string(), + groupId: v.string(), + includeDescendants: v.optional(v.boolean()), + editableUserIds: v.array(v.string()), + }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const doc = await requireOwnedDocument(ctx, args.documentId, userId); + const group = await requireGroup(ctx, args.groupId); + if (group.workspace_id !== doc.workspace_id) throw new Error("群组不属于当前工作空间"); + + const ts = nowIso(); + const includeDescendants = Boolean(args.includeDescendants); + + const existingShare = await ctx.db + .query("document_group_shares") + .withIndex("by_doc_group", (q: any) => q.eq("document_id", doc.id).eq("group_id", args.groupId)) + .first(); + + if (existingShare) { + await ctx.db.patch(existingShare._id, { + include_descendants: includeDescendants, + updated_at: ts, + }); + } else { + await ctx.db.insert("document_group_shares", { + workspace_id: doc.workspace_id, + group_id: args.groupId, + document_id: doc.id, + include_descendants: includeDescendants, + created_by: userId, + created_at: ts, + updated_at: ts, + }); + } + + // 更新成员权限:默认只读,仅对可编辑用户写入 override(edit)。 + const existingPerms = await ctx.db + .query("document_group_user_permissions") + .withIndex("by_workspace_group", (q: any) => + q.eq("workspace_id", doc.workspace_id).eq("group_id", args.groupId), + ) + .collect(); + + const targetSet = new Set(args.editableUserIds.map(String)); + for (const p of existingPerms) { + if (p.document_id !== doc.id) continue; + const uid = String(p.user_id); + if (!targetSet.has(uid)) { + await ctx.db.delete(p._id); + } + } + + for (const uid of targetSet) { + const existing = existingPerms.find( + (p: any) => p.document_id === doc.id && String(p.user_id) === uid, + ); + if (existing) { + if (existing.permission !== "edit") { + await ctx.db.patch(existing._id, { permission: "edit" as Permission, updated_at: ts }); + } + } else { + await ctx.db.insert("document_group_user_permissions", { + workspace_id: doc.workspace_id, + group_id: args.groupId, + document_id: doc.id, + user_id: uid, + permission: "edit" as Permission, + created_at: ts, + updated_at: ts, + }); + } + } + + await ctx.db.patch(doc._id, { updated_at: ts }); + + return { ok: true }; + }, +}); + +export const remove = mutation({ + args: { documentId: v.string(), groupId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const doc = await requireOwnedDocument(ctx, args.documentId, userId); + + const existingShare = await ctx.db + .query("document_group_shares") + .withIndex("by_doc_group", (q: any) => q.eq("document_id", doc.id).eq("group_id", args.groupId)) + .first(); + if (existingShare) { + await ctx.db.delete(existingShare._id); + } + + const perms = await ctx.db + .query("document_group_user_permissions") + .withIndex("by_workspace_group", (q: any) => + q.eq("workspace_id", doc.workspace_id).eq("group_id", args.groupId), + ) + .collect(); + + for (const p of perms) { + if (p.document_id !== doc.id) continue; + await ctx.db.delete(p._id); + } + + await ctx.db.patch(doc._id, { updated_at: nowIso() }); + return { ok: true }; + }, +}); + +export const listPublicByWorkspace = query({ + args: { workspaceId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + + const member = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) + .first(); + if (!member) throw new Error("无权限"); + + const myGroups = await ctx.db + .query("group_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) + .collect(); + + const myGroupIds = new Set(myGroups.map((g: any) => String(g.group_id))); + + const myCreatedShares = await ctx.db + .query("document_group_shares") + .withIndex("by_workspace_created_by", (q: any) => q.eq("workspace_id", args.workspaceId).eq("created_by", userId)) + .collect(); + + const visibleGroupIds = new Set(myGroupIds); + myCreatedShares.forEach((s: any) => visibleGroupIds.add(String(s.group_id))); + + const groups = await ctx.db + .query("groups") + .withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId)) + .collect(); + + const groupNameById = new Map(); + for (const g of groups) { + if (visibleGroupIds.has(String(g.id))) { + groupNameById.set(String(g.id), String(g.name ?? "")); + } + } + + const sharesByGroup = []; + for (const groupId of visibleGroupIds) { + const rows = await ctx.db + .query("document_group_shares") + .withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", groupId)) + .collect(); + + if (rows.length === 0) continue; + + const docMap = new Map(); + for (const r of rows) { + const existing = docMap.get(r.document_id); + if (!existing) { + docMap.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) }); + } else { + existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants); + } + } + + sharesByGroup.push({ + groupId, + groupName: groupNameById.get(groupId) ?? null, + documents: Array.from(docMap.entries()).map(([documentId, v]) => ({ + documentId, + includeDescendants: v.includeDescendants, + })), + }); + } + + // 按群组名称排序,便于折叠展示 + sharesByGroup.sort((a: any, b: any) => String(a.groupName ?? "").localeCompare(String(b.groupName ?? ""))); + + return sharesByGroup; + }, +}); diff --git a/wolai-frontend/convex/documentShares.ts b/wolai-frontend/convex/documentShares.ts new file mode 100644 index 00000000..76d84a41 --- /dev/null +++ b/wolai-frontend/convex/documentShares.ts @@ -0,0 +1,212 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { getAuthUserId } from "@convex-dev/auth/server"; +import { nowIso } from "./_utils/time"; +import type { Id } from "./_generated/dataModel"; + +const permission = v.union(v.literal("read"), v.literal("edit")); + +async function requireUserId(ctx: any): Promise { + const userId = await getAuthUserId(ctx); + if (userId === null) throw new Error("未登录"); + return String(userId); +} + +function normalizeUsername(raw: string): string { + const username = String(raw ?? "").trim(); + if (!username) throw new Error("用户名不能为空"); + if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符"); + if (/\s/.test(username)) throw new Error("用户名不能包含空格"); + return username; +} + +async function requireOwnedDocument(ctx: any, documentId: string, userId: string) { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q: any) => q.eq("id", documentId)) + .first(); + if (!doc) throw new Error("页面不存在"); + if (doc.user_id !== userId) throw new Error("无权限"); + return doc; +} + +async function resolveUserIdByUsername(ctx: any, rawUsername: string): Promise<{ userId: string; username: string }> { + const username = normalizeUsername(rawUsername); + + // 说明:用户名存放在 Convex Auth 的 users.name;当前没有索引,这里用 filter 做扫描。 + const user = await ctx.db + .query("users") + .filter((q: any) => q.eq(q.field("name"), username)) + .first(); + if (!user) throw new Error("未找到该用户(对方可能未注册或未设置用户名)"); + + return { userId: String(user._id), username: String(user.name ?? "") || username }; +} + +export const listByDocument = query({ + args: { documentId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const doc = await requireOwnedDocument(ctx, args.documentId, userId); + + const shares = await ctx.db + .query("document_shares") + .withIndex("by_document", (q: any) => q.eq("document_id", doc.id)) + .collect(); + + const result = []; + for (const share of shares) { + const user = await ctx.db.get(share.shared_with_user_id as Id<"users">); + result.push({ + userId: share.shared_with_user_id, + username: user?.name ?? null, + permission: share.permission, + includeDescendants: share.include_descendants, + createdAt: share.created_at, + updatedAt: share.updated_at, + }); + } + + return result; + }, +}); + +export const upsert = mutation({ + args: { + documentId: v.string(), + username: v.string(), + permission, + includeDescendants: v.optional(v.boolean()), + }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const doc = await requireOwnedDocument(ctx, args.documentId, userId); + const { userId: sharedWithUserId } = await resolveUserIdByUsername(ctx, args.username); + if (sharedWithUserId === userId) throw new Error("不能共享给自己"); + + const ts = nowIso(); + const includeDescendants = Boolean(args.includeDescendants); + + // 确保对方成为 workspace member(否则无法看到该 workspace 的数据) + const existingMember = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", sharedWithUserId)) + .first(); + if (!existingMember) { + await ctx.db.insert("workspace_members", { + workspace_id: doc.workspace_id, + user_id: sharedWithUserId, + role: "member", + is_default: false, + created_at: ts, + }); + } + + const existingShare = await ctx.db + .query("document_shares") + .withIndex("by_doc_user", (q: any) => + q.eq("document_id", doc.id).eq("shared_with_user_id", sharedWithUserId), + ) + .first(); + + if (existingShare) { + await ctx.db.patch(existingShare._id, { + permission: args.permission, + include_descendants: includeDescendants, + updated_at: ts, + }); + } else { + await ctx.db.insert("document_shares", { + workspace_id: doc.workspace_id, + document_id: doc.id, + shared_with_user_id: sharedWithUserId, + permission: args.permission, + include_descendants: includeDescendants, + created_by: userId, + created_at: ts, + updated_at: ts, + }); + } + + // 仅更新时间戳:共享不改变页面自身的 access_scope(避免把自己的页面从“私密”分区挪走) + await ctx.db.patch(doc._id, { updated_at: ts }); + + return { ok: true }; + }, +}); + +export const remove = mutation({ + args: { documentId: v.string(), sharedWithUserId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const doc = await requireOwnedDocument(ctx, args.documentId, userId); + + const existingShare = await ctx.db + .query("document_shares") + .withIndex("by_doc_user", (q: any) => + q.eq("document_id", doc.id).eq("shared_with_user_id", args.sharedWithUserId), + ) + .first(); + + if (existingShare) { + await ctx.db.delete(existingShare._id); + } + + await ctx.db.patch(doc._id, { updated_at: nowIso() }); + + return { ok: true }; + }, +}); + +export const listShareRootsByWorkspace = query({ + args: { workspaceId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + + // 仅允许查看自己所在 workspace 的共享摘要 + const member = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) + .first(); + if (!member) throw new Error("无权限"); + + const incoming = await ctx.db + .query("document_shares") + .withIndex("by_workspace_shared_with", (q: any) => q.eq("workspace_id", args.workspaceId).eq("shared_with_user_id", userId)) + .collect(); + + const outgoing = await ctx.db + .query("document_shares") + .withIndex("by_workspace_created_by", (q: any) => q.eq("workspace_id", args.workspaceId).eq("created_by", userId)) + .collect(); + + const outgoingByDoc = new Map(); + for (const row of outgoing) { + const existing = outgoingByDoc.get(row.document_id); + if (!existing) { + outgoingByDoc.set(row.document_id, { + includeDescendants: Boolean(row.include_descendants), + sharedWithCount: 1, + }); + } else { + existing.includeDescendants = existing.includeDescendants || Boolean(row.include_descendants); + existing.sharedWithCount += 1; + } + } + + return { + incoming: incoming.map((r) => ({ + documentId: r.document_id, + permission: r.permission, + includeDescendants: r.include_descendants, + createdBy: r.created_by, + updatedAt: r.updated_at, + })), + outgoing: Array.from(outgoingByDoc.entries()).map(([documentId, v]) => ({ + documentId, + includeDescendants: v.includeDescendants, + sharedWithCount: v.sharedWithCount, + })), + }; + }, +}); diff --git a/wolai-frontend/convex/documentStars.ts b/wolai-frontend/convex/documentStars.ts new file mode 100644 index 00000000..8014f85d --- /dev/null +++ b/wolai-frontend/convex/documentStars.ts @@ -0,0 +1,185 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { getAuthUserId } from "@convex-dev/auth/server"; +import { nowIso } from "./_utils/time"; + +type SharePermission = "read" | "edit"; + +async function requireUserId(ctx: any): Promise { + const userId = await getAuthUserId(ctx); + if (userId === null) throw new Error("未登录"); + return String(userId); +} + +async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) { + const member = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId)) + .first(); + if (!member) throw new Error("无权限"); + return member; +} + +async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise { + const direct = await ctx.db + .query("document_shares") + .withIndex("by_doc_user", (q: any) => q.eq("document_id", doc.id).eq("shared_with_user_id", userId)) + .first(); + if (direct) return direct.permission as SharePermission; + + let parentId: string | null = doc.parent_id ?? null; + for (let depth = 0; depth < 60 && parentId; depth += 1) { + const parentShare = await ctx.db + .query("document_shares") + .withIndex("by_doc_user", (q: any) => q.eq("document_id", parentId).eq("shared_with_user_id", userId)) + .first(); + if (parentShare && parentShare.include_descendants) return parentShare.permission as SharePermission; + + const parent = await ctx.db + .query("documents") + .withIndex("by_document_id", (q: any) => q.eq("id", parentId)) + .first(); + parentId = parent?.parent_id ?? null; + } + return null; +} + +async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise { + const memberships = await ctx.db + .query("group_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", userId)) + .collect(); + const groupIds = new Set(memberships.map((m: any) => String(m.group_id))); + if (groupIds.size === 0) return null; + + const permissionFromGroup = async (documentId: string, groupId: string): Promise => { + const override = await ctx.db + .query("document_group_user_permissions") + .withIndex("by_doc_group_user", (q: any) => + q.eq("document_id", documentId).eq("group_id", groupId).eq("user_id", userId), + ) + .first(); + return override?.permission === "edit" ? "edit" : "read"; + }; + + let best: SharePermission | null = null; + const checkDocId = async (documentId: string, requireIncludeDesc: boolean) => { + const shares = await ctx.db + .query("document_group_shares") + .withIndex("by_document", (q: any) => q.eq("document_id", documentId)) + .collect(); + + for (const s of shares) { + const gid = String(s.group_id); + if (!groupIds.has(gid)) continue; + if (requireIncludeDesc && !s.include_descendants) continue; + + const perm = await permissionFromGroup(documentId, gid); + if (perm === "edit") { + best = "edit"; + return true; + } + best = best ?? "read"; + } + return false; + }; + + if (await checkDocId(doc.id, false)) return "edit"; + + let parentId: string | null = doc.parent_id ?? null; + for (let depth = 0; depth < 60 && parentId; depth += 1) { + if (await checkDocId(parentId, true)) return "edit"; + + const parent = await ctx.db + .query("documents") + .withIndex("by_document_id", (q: any) => q.eq("id", parentId)) + .first(); + parentId = parent?.parent_id ?? null; + } + + return best; +} + +export const isStarred = query({ + args: { documentId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q: any) => q.eq("id", args.documentId)) + .first(); + if (!doc) return false; + if (doc.deleted_at != null) return false; + + try { + await requireWorkspaceMember(ctx, doc.workspace_id, userId); + } catch { + return false; + } + + const star = await ctx.db + .query("document_stars") + .withIndex("by_workspace_document_user", (q: any) => + q.eq("workspace_id", doc.workspace_id).eq("document_id", doc.id).eq("user_id", userId), + ) + .first(); + return Boolean(star); + }, +}); + +export const toggle = mutation({ + args: { documentId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q: any) => q.eq("id", args.documentId)) + .first(); + if (!doc) throw new Error("页面不存在"); + if (doc.deleted_at != null) throw new Error("页面不存在"); + + await requireWorkspaceMember(ctx, doc.workspace_id, userId); + + // 权限:需要能访问该页面(自己、全员公开、个人共享、群组公开) + if (doc.user_id !== userId && doc.access_scope !== "public") { + const sharePerm = await resolveSharePermission(ctx, doc, userId); + const groupPerm = await resolveGroupSharePermission(ctx, doc, userId); + if (!sharePerm && !groupPerm) throw new Error("无权限"); + } + + const existing = await ctx.db + .query("document_stars") + .withIndex("by_workspace_document_user", (q: any) => + q.eq("workspace_id", doc.workspace_id).eq("document_id", doc.id).eq("user_id", userId), + ) + .first(); + + const ts = nowIso(); + + if (existing) { + await ctx.db.delete(existing._id); + // 兼容旧字段:仅当自己是 owner 时同步 documents.is_starred + if (doc.user_id === userId) { + await ctx.db.patch(doc._id, { is_starred: false, updated_at: ts }); + } + return { ok: true, starred: false }; + } + + await ctx.db.insert("document_stars", { + workspace_id: doc.workspace_id, + document_id: doc.id, + user_id: userId, + created_at: ts, + updated_at: ts, + }); + + if (doc.user_id === userId) { + await ctx.db.patch(doc._id, { is_starred: true, updated_at: ts }); + } + + return { ok: true, starred: true }; + }, +}); + diff --git a/wolai-frontend/convex/documents.ts b/wolai-frontend/convex/documents.ts index 42719910..54209b5e 100644 --- a/wolai-frontend/convex/documents.ts +++ b/wolai-frontend/convex/documents.ts @@ -2,6 +2,7 @@ import { internalQuery, mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { getAuthUserId } from "@convex-dev/auth/server"; import { nowIso } from "./_utils/time"; +import { collectSubtree } from "./_utils/documentTree"; import { enqueueIngestDocumentJob } from "./_utils/ingestJobs"; const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public")); @@ -14,6 +15,108 @@ async function requireUserId(ctx: any): Promise { return userId; } +type SharePermission = "read" | "edit"; + +async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) { + const member = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId)) + .first(); + if (!member) { + throw new Error("无权限"); + } + return member; +} + +async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise { + const direct = await ctx.db + .query("document_shares") + .withIndex("by_doc_user", (q: any) => q.eq("document_id", doc.id).eq("shared_with_user_id", userId)) + .first(); + if (direct) { + return direct.permission as SharePermission; + } + + let parentId: string | null = doc.parent_id ?? null; + for (let depth = 0; depth < 60 && parentId; depth += 1) { + const parentShare = await ctx.db + .query("document_shares") + .withIndex("by_doc_user", (q: any) => q.eq("document_id", parentId).eq("shared_with_user_id", userId)) + .first(); + if (parentShare && parentShare.include_descendants) { + return parentShare.permission as SharePermission; + } + + const parent = await ctx.db + .query("documents") + .withIndex("by_document_id", (q: any) => q.eq("id", parentId)) + .first(); + parentId = parent?.parent_id ?? null; + } + + return null; +} + +async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise { + const memberships = await ctx.db + .query("group_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", userId)) + .collect(); + + const groupIds = new Set(memberships.map((m: any) => String(m.group_id))); + if (groupIds.size === 0) return null; + + const permissionFromGroup = async (documentId: string, groupId: string): Promise => { + const override = await ctx.db + .query("document_group_user_permissions") + .withIndex("by_doc_group_user", (q: any) => + q.eq("document_id", documentId).eq("group_id", groupId).eq("user_id", userId), + ) + .first(); + return override?.permission === "edit" ? "edit" : "read"; + }; + + let best: SharePermission | null = null; + + const checkDocId = async (documentId: string, requireIncludeDesc: boolean) => { + const shares = await ctx.db + .query("document_group_shares") + .withIndex("by_document", (q: any) => q.eq("document_id", documentId)) + .collect(); + + for (const s of shares) { + const gid = String(s.group_id); + if (!groupIds.has(gid)) continue; + if (requireIncludeDesc && !s.include_descendants) continue; + + const perm = await permissionFromGroup(documentId, gid); + if (perm === "edit") { + best = "edit"; + return true; + } + best = best ?? "read"; + } + return false; + }; + + // 当前页面是否被群组公开 + if (await checkDocId(doc.id, false)) return "edit"; + + // 沿父链查找“包含子页面”的群组公开 + let parentId: string | null = doc.parent_id ?? null; + for (let depth = 0; depth < 60 && parentId; depth += 1) { + if (await checkDocId(parentId, true)) return "edit"; + + const parent = await ctx.db + .query("documents") + .withIndex("by_document_id", (q: any) => q.eq("id", parentId)) + .first(); + parentId = parent?.parent_id ?? null; + } + + return best; +} + export const getMeta = query({ args: { id: v.string() }, handler: async (ctx, args) => { @@ -24,12 +127,29 @@ export const getMeta = query({ .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) return null; - if (doc.user_id !== userId) return null; + if (doc.deleted_at != null) return null; + try { + await requireWorkspaceMember(ctx, doc.workspace_id, userId); + } catch { + return null; + } + + let canEdit = false; + if (doc.user_id === userId) { + canEdit = true; + } else if (doc.access_scope === "public") { + canEdit = false; + } else { + const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); + if (!perm) return null; + canEdit = perm === "edit"; + } return { id: doc.id, user_id: doc.user_id, workspace_id: doc.workspace_id, access_scope: doc.access_scope, + can_edit: canEdit, title: doc.title ?? null, parent_id: doc.parent_id ?? null, created_at: doc.created_at, @@ -90,7 +210,21 @@ export const getContent = query({ .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) return null; - if (doc.user_id !== userId) return null; + if (doc.deleted_at != null) return null; + try { + await requireWorkspaceMember(ctx, doc.workspace_id, userId); + } catch { + return null; + } + + if (doc.user_id === userId) { + return { content: doc.content ?? null }; + } + if (doc.access_scope === "public") { + return { content: doc.content ?? null }; + } + const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); + if (!perm) return null; return { content: doc.content ?? null }; }, }); @@ -113,23 +247,131 @@ export const listByWorkspace = query({ handler: async (ctx, args) => { const userId = await requireUserId(ctx); + // workspace 权限 + try { + await requireWorkspaceMember(ctx, args.workspaceId, userId); + } catch { + return []; + } + const docs = await ctx.db .query("documents") .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) .collect(); - // 说明:阶段 4 先不做垃圾桶(deleted_at != null),因此这里直接过滤。 - return docs - .filter((d) => d.user_id === userId) - .filter((d) => d.deleted_at == null) + // 说明:这里仅用于侧边栏数据(不返回垃圾桶 deleted_at != null)。 + const alive = docs.filter((d) => d.deleted_at == null); + + const shares = await ctx.db + .query("document_shares") + .withIndex("by_workspace_shared_with", (q: any) => + q.eq("workspace_id", args.workspaceId).eq("shared_with_user_id", userId), + ) + .collect(); + + const directShares = new Map< + string, + { permission: SharePermission; includeDescendants: boolean } + >(); + for (const s of shares) { + directShares.set(s.document_id, { + permission: s.permission as SharePermission, + includeDescendants: Boolean(s.include_descendants), + }); + } + + const groupMemberships = await ctx.db + .query("group_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) + .collect(); + const groupIds = new Set(groupMemberships.map((m: any) => String(m.group_id))); + + const directGroupShares = new Map(); + if (groupIds.size > 0) { + for (const gid of groupIds) { + const rows = await ctx.db + .query("document_group_shares") + .withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", gid)) + .collect(); + for (const r of rows) { + const existing = directGroupShares.get(r.document_id); + if (!existing) { + directGroupShares.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) }); + } else { + existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants); + } + } + } + } + + const parentById = new Map(); + for (const d of alive) { + parentById.set(d.id, d.parent_id ?? null); + } + + const shareAccessCache = new Map(); + const canAccessByShare = (docId: string): boolean => { + const cached = shareAccessCache.get(docId); + if (typeof cached === "boolean") return cached; + if (directShares.has(docId)) { + shareAccessCache.set(docId, true); + return true; + } + let parentId = parentById.get(docId) ?? null; + for (let depth = 0; depth < 60 && parentId; depth += 1) { + const parentShare = directShares.get(parentId); + if (parentShare && parentShare.includeDescendants) { + shareAccessCache.set(docId, true); + return true; + } + parentId = parentById.get(parentId) ?? null; + } + shareAccessCache.set(docId, false); + return false; + }; + + const groupAccessCache = new Map(); + const canAccessByGroupShare = (docId: string): boolean => { + const cached = groupAccessCache.get(docId); + if (typeof cached === "boolean") return cached; + if (directGroupShares.has(docId)) { + groupAccessCache.set(docId, true); + return true; + } + let parentId = parentById.get(docId) ?? null; + for (let depth = 0; depth < 60 && parentId; depth += 1) { + const parentShare = directGroupShares.get(parentId); + if (parentShare && parentShare.includeDescendants) { + groupAccessCache.set(docId, true); + return true; + } + parentId = parentById.get(parentId) ?? null; + } + groupAccessCache.set(docId, false); + return false; + }; + + const stars = await ctx.db + .query("document_stars") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) + .collect(); + const starredDocIds = new Set(stars.map((s: any) => String(s.document_id))); + + return alive + .filter((d) => { + if (d.user_id === userId) return true; + if (d.access_scope === "public") return true; + return canAccessByShare(d.id) || canAccessByGroupShare(d.id); + }) .map((d) => ({ - access_scope: d.access_scope, + access_scope: + d.user_id === userId ? d.access_scope : d.access_scope === "public" ? "public" : "shared", id: d.id, workspace_id: d.workspace_id, title: d.title ?? "无标题", parent_id: d.parent_id ?? null, sort_order: d.sort_order ?? null, - is_starred: d.is_starred ?? null, + is_starred: Boolean(starredDocIds.has(d.id) || d.is_starred), is_template: d.is_template ?? false, created_at: d.created_at, updated_at: d.updated_at ?? null, @@ -238,13 +480,20 @@ export const updateContent = mutation({ .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) throw new Error("页面不存在"); - if (doc.user_id !== userId) throw new Error("无权限"); + if (doc.deleted_at != null) throw new Error("页面不存在"); + await requireWorkspaceMember(ctx, doc.workspace_id, userId); + + if (doc.user_id !== userId) { + if (doc.access_scope === "public") throw new Error("无权限"); + const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); + if (perm !== "edit") 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, documentId: args.id, debounceMs: 1500 }); + await enqueueIngestDocumentJob(ctx, { userId: doc.user_id, documentId: args.id, debounceMs: 1500 }); return { ok: true, updated_at: ts }; }, }); @@ -259,7 +508,14 @@ export const updateTitle = mutation({ .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) throw new Error("页面不存在"); - if (doc.user_id !== userId) throw new Error("无权限"); + if (doc.deleted_at != null) throw new Error("页面不存在"); + await requireWorkspaceMember(ctx, doc.workspace_id, userId); + + if (doc.user_id !== userId) { + if (doc.access_scope === "public") throw new Error("无权限"); + const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); + if (perm !== "edit") throw new Error("无权限"); + } const ts = nowIso(); await ctx.db.patch(doc._id, { title: args.title, updated_at: ts }); return { ok: true, updated_at: ts }; @@ -372,8 +628,23 @@ export const softDelete = mutation({ if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); const ts = nowIso(); - await ctx.db.patch(doc._id, { deleted_at: ts, deleted_by: userId, updated_at: ts }); - return { ok: true }; + + // 级联软删除:删除父节点时,必须同步删除子节点,否则子节点会因为父节点缺失而“跑到根目录”。 + const allInWorkspace = await ctx.db + .query("documents") + .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) + .collect(); + const owned = allInWorkspace.filter((d) => d.user_id === userId); + const subtree = collectSubtree(owned, doc.id); + + let moved = 0; + for (const item of subtree) { + if (item.deleted_at != null) continue; + await ctx.db.patch(item._id, { deleted_at: ts, deleted_by: userId, updated_at: ts }); + moved += 1; + } + + return { ok: true, moved, deleted_at: ts }; }, }); @@ -389,6 +660,7 @@ export const restore = mutation({ if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); const ts = nowIso(); + const restoreDeletedAt = doc.deleted_at; await ctx.db.patch(doc._id, { deleted_at: null, deleted_by: null, @@ -396,6 +668,22 @@ export const restore = mutation({ access_scope: "private", updated_at: ts, }); + + // 级联恢复:仅恢复“随本次父节点删除而进入垃圾桶”的子节点,避免把之前单独删除的子页面一并恢复。 + if (restoreDeletedAt != null) { + const allInWorkspace = await ctx.db + .query("documents") + .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) + .collect(); + const owned = allInWorkspace.filter((d) => d.user_id === userId); + const subtree = collectSubtree(owned, doc.id); + + for (const item of subtree) { + if (item.id === doc.id) continue; + if (item.deleted_at !== restoreDeletedAt) continue; + await ctx.db.patch(item._id, { deleted_at: null, deleted_by: null, updated_at: ts }); + } + } return { ok: true }; }, }); @@ -411,8 +699,21 @@ export const purge = mutation({ .first(); if (!doc) throw new Error("页面不存在"); if (doc.user_id !== userId) throw new Error("无权限"); - await ctx.db.delete(doc._id); - return { ok: true }; + + // 级联彻底删除:避免父节点被删后,子节点变成孤儿数据。 + const allInWorkspace = await ctx.db + .query("documents") + .withIndex("by_workspace", (q) => q.eq("workspace_id", doc.workspace_id)) + .collect(); + const owned = allInWorkspace.filter((d) => d.user_id === userId); + const subtree = collectSubtree(owned, doc.id); + + // 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。 + for (const item of subtree) { + await ctx.db.delete(item._id); + } + + return { ok: true, deletedCount: subtree.length }; }, }); @@ -466,7 +767,14 @@ export const updateOptions = mutation({ .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) throw new Error("页面不存在"); - if (doc.user_id !== userId) throw new Error("无权限"); + if (doc.deleted_at != null) throw new Error("页面不存在"); + await requireWorkspaceMember(ctx, doc.workspace_id, userId); + + if (doc.user_id !== userId) { + if (doc.access_scope === "public") throw new Error("无权限"); + const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); + if (perm !== "edit") throw new Error("无权限"); + } const patch: Record = {}; if (typeof args.options.wideLayout === "boolean") patch.wide_layout = args.options.wideLayout; @@ -503,7 +811,14 @@ export const updateStats = mutation({ .withIndex("by_document_id", (q) => q.eq("id", args.id)) .first(); if (!doc) throw new Error("页面不存在"); - if (doc.user_id !== userId) throw new Error("无权限"); + if (doc.deleted_at != null) throw new Error("页面不存在"); + await requireWorkspaceMember(ctx, doc.workspace_id, userId); + + if (doc.user_id !== userId) { + if (doc.access_scope === "public") throw new Error("无权限"); + const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); + if (perm !== "edit") throw new Error("无权限"); + } const ts = nowIso(); await ctx.db.patch(doc._id, { word_count: args.wordCount, diff --git a/wolai-frontend/convex/groupMembers.ts b/wolai-frontend/convex/groupMembers.ts new file mode 100644 index 00000000..3d4edf2f --- /dev/null +++ b/wolai-frontend/convex/groupMembers.ts @@ -0,0 +1,155 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { getAuthUserId } from "@convex-dev/auth/server"; +import { nowIso } from "./_utils/time"; +import type { Id } from "./_generated/dataModel"; + +async function requireUserId(ctx: any): Promise { + const userId = await getAuthUserId(ctx); + if (userId === null) throw new Error("未登录"); + return String(userId); +} + +async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) { + const member = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId)) + .first(); + if (!member) throw new Error("无权限"); + return member; +} + +function normalizeUsername(raw: string): string { + const username = String(raw ?? "").trim(); + if (!username) throw new Error("用户名不能为空"); + if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符"); + if (/\s/.test(username)) throw new Error("用户名不能包含空格"); + return username; +} + +async function lookupUserIdByUsername(ctx: any, rawUsername: string): Promise { + const username = normalizeUsername(rawUsername); + const user = await ctx.db + .query("users") + .filter((q: any) => q.eq(q.field("name"), username)) + .first(); + if (!user) throw new Error("未找到该用户"); + return String(user._id); +} + +async function requireGroup(ctx: any, groupId: string) { + const group = await ctx.db + .query("groups") + .withIndex("by_group_id", (q: any) => q.eq("id", groupId)) + .first(); + if (!group) throw new Error("群组不存在"); + return group; +} + +async function requireGroupOwner(ctx: any, groupId: string, userId: string) { + const membership = await ctx.db + .query("group_members") + .withIndex("by_group_user", (q: any) => q.eq("group_id", groupId).eq("user_id", userId)) + .first(); + if (!membership || membership.role !== "owner") throw new Error("无权限"); + return membership; +} + +export const listByGroup = query({ + args: { groupId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const group = await requireGroup(ctx, args.groupId); + await requireWorkspaceMember(ctx, group.workspace_id, userId); + + const membership = await ctx.db + .query("group_members") + .withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", userId)) + .first(); + if (!membership) throw new Error("无权限"); + + const members = await ctx.db + .query("group_members") + .withIndex("by_group", (q: any) => q.eq("group_id", args.groupId)) + .collect(); + + const result = []; + for (const m of members) { + const user = await ctx.db.get(m.user_id as Id<"users">); + result.push({ + userId: m.user_id, + username: user?.name ?? null, + role: m.role, + createdAt: m.created_at, + }); + } + return result; + }, +}); + +export const inviteByUsername = mutation({ + args: { groupId: v.string(), username: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const group = await requireGroup(ctx, args.groupId); + await requireWorkspaceMember(ctx, group.workspace_id, userId); + await requireGroupOwner(ctx, args.groupId, userId); + + const targetUserId = await lookupUserIdByUsername(ctx, args.username); + if (targetUserId === userId) throw new Error("不能邀请自己"); + + // 确保对方为 workspace member + const existingWorkspaceMember = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", group.workspace_id).eq("user_id", targetUserId)) + .first(); + if (!existingWorkspaceMember) { + await ctx.db.insert("workspace_members", { + workspace_id: group.workspace_id, + user_id: targetUserId, + role: "member", + is_default: false, + created_at: nowIso(), + }); + } + + const existing = await ctx.db + .query("group_members") + .withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", targetUserId)) + .first(); + if (existing) { + return { ok: true }; + } + + await ctx.db.insert("group_members", { + workspace_id: group.workspace_id, + group_id: args.groupId, + user_id: targetUserId, + role: "member", + created_at: nowIso(), + }); + + return { ok: true }; + }, +}); + +export const removeMember = mutation({ + args: { groupId: v.string(), userId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const group = await requireGroup(ctx, args.groupId); + await requireWorkspaceMember(ctx, group.workspace_id, userId); + await requireGroupOwner(ctx, args.groupId, userId); + + const existing = await ctx.db + .query("group_members") + .withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", args.userId)) + .first(); + if (!existing) return { ok: true }; + if (existing.role === "owner") throw new Error("不能移除群主"); + + await ctx.db.delete(existing._id); + return { ok: true }; + }, +}); + diff --git a/wolai-frontend/convex/groups.ts b/wolai-frontend/convex/groups.ts new file mode 100644 index 00000000..c5f83146 --- /dev/null +++ b/wolai-frontend/convex/groups.ts @@ -0,0 +1,162 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { getAuthUserId } from "@convex-dev/auth/server"; +import { nowIso } from "./_utils/time"; + +async function requireUserId(ctx: any): Promise { + const userId = await getAuthUserId(ctx); + if (userId === null) throw new Error("未登录"); + return String(userId); +} + +async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) { + const member = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId)) + .first(); + if (!member) throw new Error("无权限"); + return member; +} + +function normalizeGroupName(raw: string): string { + const name = String(raw ?? "").trim(); + if (!name) throw new Error("群组名称不能为空"); + if (name.length > 50) throw new Error("群组名称最多 50 个字符"); + return name; +} + +export const listByWorkspace = query({ + args: { workspaceId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + await requireWorkspaceMember(ctx, args.workspaceId, userId); + + const groups = await ctx.db + .query("groups") + .withIndex("by_workspace", (q: any) => q.eq("workspace_id", args.workspaceId)) + .collect(); + + groups.sort((a: any, b: any) => (a.created_at ?? "").localeCompare(b.created_at ?? "")); + return groups.map((g: any) => ({ + id: g.id, + workspace_id: g.workspace_id, + name: g.name, + created_by: g.created_by, + created_at: g.created_at, + updated_at: g.updated_at, + })); + }, +}); + +export const create = mutation({ + args: { + id: v.string(), + workspaceId: v.string(), + name: v.string(), + }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + await requireWorkspaceMember(ctx, args.workspaceId, userId); + + const name = normalizeGroupName(args.name); + const ts = nowIso(); + + const existing = await ctx.db + .query("groups") + .withIndex("by_group_id", (q: any) => q.eq("id", args.id)) + .first(); + if (existing) throw new Error("群组已存在"); + + await ctx.db.insert("groups", { + id: args.id, + workspace_id: args.workspaceId, + name, + created_by: userId, + created_at: ts, + updated_at: ts, + }); + + await ctx.db.insert("group_members", { + workspace_id: args.workspaceId, + group_id: args.id, + user_id: userId, + role: "owner", + created_at: ts, + }); + + return { ok: true, id: args.id }; + }, +}); + +export const rename = mutation({ + args: { groupId: v.string(), name: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const group = await ctx.db + .query("groups") + .withIndex("by_group_id", (q: any) => q.eq("id", args.groupId)) + .first(); + if (!group) throw new Error("群组不存在"); + + await requireWorkspaceMember(ctx, group.workspace_id, userId); + + const membership = await ctx.db + .query("group_members") + .withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", userId)) + .first(); + if (!membership || membership.role !== "owner") throw new Error("无权限"); + + const ts = nowIso(); + await ctx.db.patch(group._id, { name: normalizeGroupName(args.name), updated_at: ts }); + return { ok: true }; + }, +}); + +export const remove = mutation({ + args: { groupId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const group = await ctx.db + .query("groups") + .withIndex("by_group_id", (q: any) => q.eq("id", args.groupId)) + .first(); + if (!group) return { ok: true }; + + await requireWorkspaceMember(ctx, group.workspace_id, userId); + + const membership = await ctx.db + .query("group_members") + .withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", userId)) + .first(); + if (!membership || membership.role !== "owner") throw new Error("无权限"); + + const members = await ctx.db + .query("group_members") + .withIndex("by_group", (q: any) => q.eq("group_id", args.groupId)) + .collect(); + + const shares = await ctx.db + .query("document_group_shares") + .withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", group.workspace_id).eq("group_id", args.groupId)) + .collect(); + + const perms = await ctx.db + .query("document_group_user_permissions") + .withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", group.workspace_id).eq("group_id", args.groupId)) + .collect(); + + for (const m of members) { + await ctx.db.delete(m._id); + } + for (const s of shares) { + await ctx.db.delete(s._id); + } + for (const p of perms) { + await ctx.db.delete(p._id); + } + await ctx.db.delete(group._id); + + return { ok: true }; + }, +}); + diff --git a/wolai-frontend/convex/schema.ts b/wolai-frontend/convex/schema.ts index 68596d26..3262e01b 100644 --- a/wolai-frontend/convex/schema.ts +++ b/wolai-frontend/convex/schema.ts @@ -237,4 +237,79 @@ export default defineSchema({ searchField: "row_hash", filterFields: ["table_id", "is_deleted"], }), + + document_shares: defineTable({ + workspace_id: v.string(), + document_id: v.string(), + shared_with_user_id: v.string(), + permission: v.union(v.literal("read"), v.literal("edit")), + include_descendants: v.boolean(), + created_by: v.string(), + created_at: v.string(), + updated_at: v.string(), + }) + .index("by_document", ["document_id"]) + .index("by_doc_user", ["document_id", "shared_with_user_id"]) + .index("by_workspace_shared_with", ["workspace_id", "shared_with_user_id"]) + .index("by_workspace_created_by", ["workspace_id", "created_by"]), + + groups: defineTable({ + id: v.string(), + workspace_id: v.string(), + name: v.string(), + created_by: v.string(), + created_at: v.string(), + updated_at: v.string(), + }) + .index("by_group_id", ["id"]) + .index("by_workspace", ["workspace_id"]), + + group_members: defineTable({ + workspace_id: v.string(), + group_id: v.string(), + user_id: v.string(), + role: v.union(v.literal("owner"), v.literal("member")), + created_at: v.string(), + }) + .index("by_group", ["group_id"]) + .index("by_group_user", ["group_id", "user_id"]) + .index("by_workspace_user", ["workspace_id", "user_id"]), + + document_group_shares: defineTable({ + workspace_id: v.string(), + group_id: v.string(), + document_id: v.string(), + include_descendants: v.boolean(), + created_by: v.string(), + created_at: v.string(), + updated_at: v.string(), + }) + .index("by_document", ["document_id"]) + .index("by_doc_group", ["document_id", "group_id"]) + .index("by_workspace_group", ["workspace_id", "group_id"]) + .index("by_workspace_created_by", ["workspace_id", "created_by"]), + + document_group_user_permissions: defineTable({ + workspace_id: v.string(), + group_id: v.string(), + document_id: v.string(), + user_id: v.string(), + permission: v.union(v.literal("read"), v.literal("edit")), + created_at: v.string(), + updated_at: v.string(), + }) + .index("by_doc_group_user", ["document_id", "group_id", "user_id"]) + .index("by_workspace_user", ["workspace_id", "user_id"]) + .index("by_workspace_group", ["workspace_id", "group_id"]), + + document_stars: defineTable({ + workspace_id: v.string(), + document_id: v.string(), + user_id: v.string(), + created_at: v.string(), + updated_at: v.string(), + }) + .index("by_workspace_user", ["workspace_id", "user_id"]) + .index("by_user_document", ["user_id", "document_id"]) + .index("by_workspace_document_user", ["workspace_id", "document_id", "user_id"]), }); diff --git a/wolai-frontend/convex/users.ts b/wolai-frontend/convex/users.ts index e48ae110..a9f4855d 100644 --- a/wolai-frontend/convex/users.ts +++ b/wolai-frontend/convex/users.ts @@ -1,4 +1,6 @@ import { query } from "./_generated/server"; +import { mutation } from "./_generated/server"; +import { v } from "convex/values"; import { getAuthUserId } from "@convex-dev/auth/server"; /** @@ -15,3 +17,38 @@ export const currentUser = query({ return await ctx.db.get(userId); }, }); + +function normalizeUsername(raw: string): string { + const username = String(raw ?? "").trim(); + if (!username) throw new Error("用户名不能为空"); + if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符"); + if (/\s/.test(username)) throw new Error("用户名不能包含空格"); + return username; +} + +/** + * 设置当前用户的唯一用户名(写入 users.name) + */ +export const setMyUsername = mutation({ + args: { username: v.string() }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + if (userId === null) throw new Error("未登录"); + + const username = normalizeUsername(args.username); + + // 说明:authTables 的 users 表目前没有为 name 建索引,这里用 filter 做一次全表扫描。 + // 用户量较小的桌面场景可接受;后续如需优化可增加独立 username 索引表。 + const existing = await ctx.db + .query("users") + .filter((q) => q.eq(q.field("name"), username)) + .first(); + + if (existing && String(existing._id) !== String(userId)) { + throw new Error("用户名已被占用"); + } + + await ctx.db.patch(userId, { name: username }); + return { ok: true, username }; + }, +}); diff --git a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx index 2a8c8ae6..9367c285 100644 --- a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx +++ b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx @@ -23,6 +23,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag if (!doc) { notFound(); } + const readOnly = (doc as any).can_edit === false; const initialOptions: PageOptionsState = { wideLayout: doc.wide_layout ?? false, @@ -52,6 +53,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag initialOptions={initialOptions} initialStats={initialStats} openTableId={openTableId} + readOnly={readOnly} /> diff --git a/wolai-frontend/src/app/(app)/wolai-import/page.tsx b/wolai-frontend/src/app/(app)/wolai-import/page.tsx index 54963120..1b21f86c 100644 --- a/wolai-frontend/src/app/(app)/wolai-import/page.tsx +++ b/wolai-frontend/src/app/(app)/wolai-import/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -12,6 +12,11 @@ export default function WolaiImportPage() { const searchParams = useSearchParams(); const parentId = useMemo(() => searchParams.get("parentId") ?? "", [searchParams]); + // 说明:该页面是工具页,避免开发环境下偶发的 hydration mismatch(SSR 与客户端首屏不一致)。 + // 首屏统一渲染一个稳定的占位内容,挂载后再渲染真实表单。 + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + const [file, setFile] = useState(null); const [zipPath, setZipPath] = useState(""); const [rootMdPath, setRootMdPath] = useState(""); @@ -78,6 +83,19 @@ export default function WolaiImportPage() { } }; + if (!mounted) { + return ( +
+ + + 导入 Wolai(Markdown ZIP) + + 加载中... + +
+ ); + } + return (
diff --git a/wolai-frontend/src/app/(auth)/auth/page.tsx b/wolai-frontend/src/app/(auth)/auth/page.tsx index ff917d1b..34ac4440 100644 --- a/wolai-frontend/src/app/(auth)/auth/page.tsx +++ b/wolai-frontend/src/app/(auth)/auth/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { useConvexAuth } from "convex/react"; +import { useConvexAuth, useMutation, useQuery } 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"; +import { api } from "@/lib/convex/api"; type AuthStep = "signIn" | "signUp"; @@ -24,18 +25,24 @@ const TEST_CREDENTIALS = { export default function AuthPage() { const { isLoading, isAuthenticated } = useConvexAuth(); const { signIn } = useAuthActions(); + const setMyUsername = useMutation(api.users.setMyUsername); const router = useRouter(); + const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip"); + useEffect(() => { - if (!isLoading && isAuthenticated) { + if (isLoading) return; + if (!isAuthenticated) return; + if (currentUser === undefined) return; + if (currentUser && currentUser.name) { router.replace("/"); } - }, [isAuthenticated, isLoading, router]); + }, [currentUser, isAuthenticated, isLoading, router]); const [flow, setFlow] = useState("signIn"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [name, setName] = useState(""); + const [username, setUsername] = useState(""); const [message, setMessage] = useState<{ type: "success" | "error" | "info"; text: string; @@ -46,17 +53,28 @@ export default function AuthPage() { setMessage(null); try { + if (flow === "signUp" && !username.trim()) { + setMessage({ type: "error", text: "用户名不能为空" }); + return; + } + 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) { + if (flow === "signUp") { + try { + await setMyUsername({ username }); + } catch (e: any) { + // 说明:注册已成功,但用户名可能重复;此时保持登录状态,转入“设置用户名”步骤继续处理。 + setMessage({ type: "error", text: e?.message ?? "用户名设置失败,请重试" }); + return; + } + } setMessage({ type: "success", text: flow === "signIn" ? "登录成功!" : "注册成功!" }); setTimeout(() => router.push("/"), 500); return; @@ -73,7 +91,7 @@ export default function AuthPage() { } catch (error: any) { setMessage({ type: "error", text: error.message || "操作失败,请重试" }); } - }, [name, signIn, router]); + }, [router, setMyUsername, signIn, username]); const handleSubmit = useCallback(async (event: React.FormEvent) => { event.preventDefault(); @@ -104,7 +122,7 @@ export default function AuthPage() { ); } - if (isAuthenticated) { + if (isAuthenticated && currentUser && currentUser.name) { return (
@@ -115,6 +133,75 @@ export default function AuthPage() { ); } + if (isAuthenticated) { + if (currentUser === undefined) { + return ( +
+
+
+

加载中...

+
+
+ ); + } + + const saveUsername = async () => { + setMessage(null); + try { + await setMyUsername({ username }); + setMessage({ type: "success", text: "用户名已保存!" }); + router.replace("/"); + } catch (error: any) { + setMessage({ type: "error", text: error.message || "保存失败,请重试" }); + } + }; + + return ( +
+
+
+

设置用户名

+

用于分享功能的用户查找(全局唯一)

+
+ + {message && ( +
+

{message.text}

+
+ )} + +
+
+ + setUsername(e.target.value)} + className="appearance-none rounded-md 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 sm:text-sm" + placeholder="用户名(2-32 位,不含空格)" + /> +
+ +
+
+
+ ); + } + return (
@@ -155,18 +242,19 @@ export default function AuthPage() {
{flow === "signUp" && (
- + 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="姓名(可选)" - /> -
+ autoComplete="username" + required + value={username} + onChange={(e) => setUsername(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="用户名(2-32 位,不含空格)" + /> +
)}
diff --git a/wolai-frontend/src/components/breadcrumb.tsx b/wolai-frontend/src/components/breadcrumb.tsx index b266192b..1f3728ac 100644 --- a/wolai-frontend/src/components/breadcrumb.tsx +++ b/wolai-frontend/src/components/breadcrumb.tsx @@ -3,9 +3,11 @@ import Link from "next/link"; import { useParams, useSelectedLayoutSegments } from "next/navigation"; import { ChevronRight, MoreHorizontal, Star } from "lucide-react"; +import { useMutation, useQuery } from "convex/react"; import type { DocumentRecord } from "@/lib/documents"; import { findBreadcrumb } from "@/lib/documents"; import { usePageLayoutStore } from "@/store/page-layout"; +import { api } from "@/lib/convex/api"; interface BreadcrumbProps { documents: DocumentRecord[]; @@ -19,6 +21,8 @@ export function Breadcrumb({ documents }: BreadcrumbProps) { const path = findBreadcrumb(documents, activeId); const showInspector = usePageLayoutStore((state) => state.showInspector); const toggleInspector = usePageLayoutStore((state) => state.toggleInspector); + const isStarred = useQuery(api.documentStars.isStarred, activeId ? { documentId: activeId } : "skip"); + const toggleStar = useMutation(api.documentStars.toggle); // 新建页面后,侧边栏数据可能还没同步到 layout(SSR)注入的 documents,导致 findBreadcrumb 暂时为空。 // 这里用 activeId 兜底,避免右上角“收藏/更多”按钮消失。 @@ -55,8 +59,13 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
- {options.protectEditing && ( + {readOnly ? ( +

该页面为只读共享,无法编辑。

+ ) : options.protectEditing ? (

当前页面已开启编辑保护,关闭后方可修改内容。

- )} + ) : null}

最近更新:{formattedUpdatedAt}

@@ -358,6 +367,7 @@ export function DocumentContent({ workspaceId={workspaceId} initialContent={content} pageOptions={options} + readOnly={readOnly} onStatsChange={handleStatsChange} onSnapshot={handleSnapshot} /> diff --git a/wolai-frontend/src/components/groups/group-manager-dialog.tsx b/wolai-frontend/src/components/groups/group-manager-dialog.tsx new file mode 100644 index 00000000..abc5a8a0 --- /dev/null +++ b/wolai-frontend/src/components/groups/group-manager-dialog.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useConvex, useMutation } from "convex/react"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/convex/api"; +import { v4 as uuidv4 } from "uuid"; + +type GroupRow = { + id: string; + name: string; + created_by: string; + created_at: string; +}; + +type MemberRow = { + userId: string; + username: string | null; + role: "owner" | "member"; + createdAt: string; +}; + +export interface GroupManagerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string; +} + +export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupManagerDialogProps) { + const convex = useConvex(); + const createGroup = useMutation(api.groups.create); + const removeGroup = useMutation(api.groups.remove); + const inviteByUsername = useMutation(api.groupMembers.inviteByUsername); + const removeMember = useMutation(api.groupMembers.removeMember); + + const [groups, setGroups] = useState([]); + const [selectedGroupId, setSelectedGroupId] = useState(""); + const [members, setMembers] = useState([]); + + const [newGroupName, setNewGroupName] = useState(""); + const [inviteUsername, setInviteUsername] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const selectedGroup = useMemo( + () => groups.find((g) => g.id === selectedGroupId) ?? null, + [groups, selectedGroupId], + ); + + const loadGroups = useCallback(async () => { + if (!workspaceId) return; + const resp = await convex.query(api.groups.listByWorkspace, { workspaceId }); + const rows = Array.isArray(resp) ? (resp as any[]) : []; + setGroups( + rows.map((g) => ({ + id: String(g.id), + name: String(g.name ?? ""), + created_by: String(g.created_by ?? ""), + created_at: String(g.created_at ?? ""), + })), + ); + }, [convex, workspaceId]); + + const loadMembers = useCallback(async (groupId: string) => { + if (!groupId) { + setMembers([]); + return; + } + const resp = await convex.query(api.groupMembers.listByGroup, { groupId }); + const rows = Array.isArray(resp) ? (resp as any[]) : []; + setMembers( + rows.map((m) => ({ + userId: String(m.userId), + username: m.username ? String(m.username) : null, + role: m.role === "owner" ? "owner" : "member", + createdAt: String(m.createdAt ?? ""), + })), + ); + }, [convex]); + + useEffect(() => { + if (!open) return; + setError(null); + setLoading(true); + void (async () => { + try { + await loadGroups(); + } catch (e: any) { + setError(e?.message ?? "加载群组失败"); + } finally { + setLoading(false); + } + })(); + }, [loadGroups, open]); + + useEffect(() => { + if (!open) return; + void (async () => { + try { + await loadMembers(selectedGroupId); + } catch (e: any) { + setError(e?.message ?? "加载群组成员失败"); + } + })(); + }, [loadMembers, open, selectedGroupId]); + + const handleCreateGroup = async () => { + setError(null); + const name = newGroupName.trim(); + if (!name) { + setError("请输入群组名称"); + return; + } + setLoading(true); + try { + await createGroup({ id: uuidv4(), workspaceId, name }); + setNewGroupName(""); + await loadGroups(); + } catch (e: any) { + setError(e?.message ?? "创建群组失败"); + } finally { + setLoading(false); + } + }; + + const handleRemoveGroup = async (groupId: string) => { + if (!groupId) return; + if (!window.confirm("确认删除该群组吗?")) return; + setError(null); + setLoading(true); + try { + await removeGroup({ groupId }); + if (selectedGroupId === groupId) { + setSelectedGroupId(""); + setMembers([]); + } + await loadGroups(); + } catch (e: any) { + setError(e?.message ?? "删除群组失败"); + } finally { + setLoading(false); + } + }; + + const handleInvite = async () => { + if (!selectedGroupId) { + setError("请先选择一个群组"); + return; + } + setError(null); + const username = inviteUsername.trim(); + if (!username) { + setError("请输入用户名"); + return; + } + setLoading(true); + try { + await inviteByUsername({ groupId: selectedGroupId, username }); + setInviteUsername(""); + await loadMembers(selectedGroupId); + } catch (e: any) { + setError(e?.message ?? "邀请失败"); + } finally { + setLoading(false); + } + }; + + const handleRemoveMember = async (userId: string) => { + if (!selectedGroupId) return; + if (!window.confirm("确认移除该成员吗?")) return; + setError(null); + setLoading(true); + try { + await removeMember({ groupId: selectedGroupId, userId }); + await loadMembers(selectedGroupId); + } catch (e: any) { + setError(e?.message ?? "移除成员失败"); + } finally { + setLoading(false); + } + }; + + return ( + + + + 群组管理 + + + {error ? ( +
{error}
+ ) : null} + +
+
+
群组
+
+
+ setNewGroupName(e.target.value)} + placeholder="新群组名称" + disabled={loading} + /> + +
+ +
+ {groups.length === 0 ? ( +
暂无群组
+ ) : ( + groups.map((g) => ( +
+ + +
+ )) + )} +
+
+
+ +
+
+ 成员{selectedGroup ? `:${selectedGroup.name}` : ""} +
+
+
+ setInviteUsername(e.target.value)} + placeholder="输入用户名邀请" + disabled={loading} + /> + +
+ +
+ {!selectedGroupId ? ( +
请先选择一个群组
+ ) : members.length === 0 ? ( +
暂无成员
+ ) : ( + members.map((m) => ( +
+
+ {m.username ?? m.userId} + + {m.role === "owner" ? "群主" : "成员"} + +
+ {m.role !== "owner" ? ( + + ) : null} +
+ )) + )} +
+
+
+
+ + + + +
+
+ ); +} + diff --git a/wolai-frontend/src/components/sharing/document-share-dialog.tsx b/wolai-frontend/src/components/sharing/document-share-dialog.tsx new file mode 100644 index 00000000..31368586 --- /dev/null +++ b/wolai-frontend/src/components/sharing/document-share-dialog.tsx @@ -0,0 +1,469 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useConvex, useMutation } from "convex/react"; +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { api } from "@/lib/convex/api"; + +type SharePermission = "read" | "edit"; + +export interface DocumentShareDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + documentId: string; + documentTitle?: string | null; + workspaceId: string; + allowIncludeDescendants?: boolean; + onChanged?: () => void | Promise; +} + +export function DocumentShareDialog({ + open, + onOpenChange, + documentId, + documentTitle, + workspaceId, + allowIncludeDescendants = false, + onChanged, +}: DocumentShareDialogProps) { + const convex = useConvex(); + const [username, setUsername] = useState(""); + const [permission, setPermission] = useState("read"); + const [includeDescendants, setIncludeDescendants] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [shares, setShares] = useState(null); + const [sharesLoading, setSharesLoading] = useState(false); + const [groups, setGroups] = useState>([]); + const [groupShares, setGroupShares] = useState(null); + const [selectedGroupId, setSelectedGroupId] = useState(""); + const [groupIncludeDescendants, setGroupIncludeDescendants] = useState(false); + const [groupMembers, setGroupMembers] = useState>( + [], + ); + const [groupEditableUserIds, setGroupEditableUserIds] = useState>(() => new Set()); + + const upsertShare = useMutation(api.documentShares.upsert); + const removeShare = useMutation(api.documentShares.remove); + const upsertGroupShare = useMutation(api.documentGroupShares.upsert); + const removeGroupShare = useMutation(api.documentGroupShares.remove); + + const loadShares = useCallback(async () => { + if (!open) return; + if (!documentId) { + setShares([]); + return; + } + + setSharesLoading(true); + try { + const resp = await convex.query(api.documentShares.listByDocument, { documentId }); + setShares(Array.isArray(resp) ? resp : []); + } catch (e: any) { + const msg = e?.message ?? "加载共享者失败,请重试"; + // 说明:这类报错通常是 Convex functions 没有部署到当前 backend(尤其是自托管场景)。 + if (String(msg).includes("Could not find public function for 'documentShares:listByDocument'")) { + setError("共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后重试。"); + } else { + setError(msg); + } + setShares([]); + } finally { + setSharesLoading(false); + } + }, [convex, documentId, open]); + + const loadGroups = useCallback(async () => { + if (!open) return; + if (!workspaceId) { + setGroups([]); + return; + } + const resp = await convex.query(api.groups.listByWorkspace, { workspaceId }); + const rows = Array.isArray(resp) ? (resp as any[]) : []; + setGroups(rows.map((g) => ({ id: String(g.id), name: String(g.name ?? "") }))); + }, [convex, open, workspaceId]); + + const loadGroupShares = useCallback(async () => { + if (!open) return; + if (!documentId) { + setGroupShares([]); + return; + } + const resp = await convex.query(api.documentGroupShares.listByDocument, { documentId }); + setGroupShares(Array.isArray(resp) ? resp : []); + }, [convex, documentId, open]); + + const loadGroupMembers = useCallback( + async (groupId: string) => { + if (!open) return; + if (!groupId) { + setGroupMembers([]); + return; + } + const resp = await convex.query(api.groupMembers.listByGroup, { groupId }); + const rows = Array.isArray(resp) ? (resp as any[]) : []; + setGroupMembers( + rows.map((m) => ({ + userId: String(m.userId), + username: m.username ? String(m.username) : null, + role: String(m.role ?? ""), + })), + ); + }, + [convex, open], + ); + + useEffect(() => { + if (!open) { + setUsername(""); + setPermission("read"); + setIncludeDescendants(false); + setSubmitting(false); + setError(null); + setShares(null); + setSharesLoading(false); + setGroups([]); + setGroupShares(null); + setSelectedGroupId(""); + setGroupIncludeDescendants(false); + setGroupMembers([]); + setGroupEditableUserIds(new Set()); + return; + } + void loadShares(); + void loadGroups(); + void loadGroupShares(); + }, [loadGroups, loadGroupShares, loadShares, open]); + + useEffect(() => { + if (!open) return; + void loadGroupMembers(selectedGroupId); + }, [loadGroupMembers, open, selectedGroupId]); + + useEffect(() => { + if (!open) return; + if (!selectedGroupId) return; + const rows = Array.isArray(groupShares) ? groupShares : []; + const existing = rows.find((r: any) => String(r.groupId) === String(selectedGroupId)); + if (existing) { + setGroupIncludeDescendants(Boolean(existing.includeDescendants)); + const editable = new Set(Array.isArray(existing.editableUserIds) ? existing.editableUserIds.map(String) : []); + setGroupEditableUserIds(editable); + } else { + setGroupIncludeDescendants(false); + setGroupEditableUserIds(new Set()); + } + }, [groupShares, open, selectedGroupId]); + + const shareRows = useMemo(() => { + if (!Array.isArray(shares)) return []; + return shares; + }, [shares]); + + const handleSubmit = async () => { + setError(null); + const u = username.trim(); + if (!u) { + setError("请输入对方用户名"); + return; + } + setSubmitting(true); + try { + await upsertShare({ + documentId, + username: u, + permission, + includeDescendants: allowIncludeDescendants ? includeDescendants : false, + }); + setUsername(""); + await loadShares(); + await onChanged?.(); + } catch (e: any) { + setError(e?.message ?? "共享失败,请重试"); + } finally { + setSubmitting(false); + } + }; + + const handleRemove = async (sharedWithUserId: string) => { + setError(null); + setSubmitting(true); + try { + await removeShare({ documentId, sharedWithUserId }); + await loadShares(); + await onChanged?.(); + } catch (e: any) { + setError(e?.message ?? "移除失败,请重试"); + } finally { + setSubmitting(false); + } + }; + + const handleUpsertGroupShare = async () => { + setError(null); + const groupId = selectedGroupId; + if (!groupId) { + setError("请选择群组"); + return; + } + setSubmitting(true); + try { + await upsertGroupShare({ + documentId, + groupId, + includeDescendants: allowIncludeDescendants ? groupIncludeDescendants : false, + editableUserIds: Array.from(groupEditableUserIds), + }); + await loadGroupShares(); + await onChanged?.(); + } catch (e: any) { + setError(e?.message ?? "公开失败,请重试"); + } finally { + setSubmitting(false); + } + }; + + const handleRemoveGroupShare = async (groupId: string) => { + setError(null); + setSubmitting(true); + try { + await removeGroupShare({ documentId, groupId }); + await loadGroupShares(); + await onChanged?.(); + } catch (e: any) { + setError(e?.message ?? "取消公开失败,请重试"); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + 共享:{documentTitle ?? "无标题"} + + +
+
+
输入用户名进行共享
+
+ setUsername(e.target.value)} + placeholder="对方用户名" + disabled={submitting} + /> + +
+ +
+ + + {allowIncludeDescendants && ( + + )} +
+
+ +
+
公开到群组(群组内所有人可见)
+
+
+ + {allowIncludeDescendants && ( + + )} + +
+ +
+
+ 群组成员权限(默认只读,可单独设为可编辑) +
+
+ {!selectedGroupId ? ( +
请选择群组
+ ) : groupMembers.length === 0 ? ( +
暂无成员
+ ) : ( +
+ {groupMembers.map((m) => { + const checked = groupEditableUserIds.has(m.userId); + return ( + + ); + })} +
+ )} +
+
+ +
+
+ 已公开到群组 +
+
+ {groupShares === null ? ( +
加载中...
+ ) : (groupShares ?? []).length === 0 ? ( +
暂无公开群组
+ ) : ( +
+ {(groupShares ?? []).map((r: any) => ( +
+
+
{r.groupName ?? r.groupId}
+
+ {r.includeDescendants ? "包含子页面" : "仅当前页面"} + {" · "} + 可编辑:{Array.isArray(r.editableUserIds) ? r.editableUserIds.length : 0} 人 +
+
+ +
+ ))} +
+ )} +
+
+
+
+ + {error &&
{error}
} + +
+
+ 已共享给 +
+
+ {sharesLoading && shares === null ? ( +
加载中...
+ ) : shareRows.length === 0 ? ( +
暂无共享者
+ ) : ( +
+ {shareRows.map((row: any) => ( +
+
+
+ {row.username ?? row.userId} +
+
+ 权限:{row.permission === "edit" ? "可编辑" : "只读"} + {row.includeDescendants ? " · 包含子页面" : ""} +
+
+ +
+ ))} +
+ )} +
+
+
+ + + + +
+
+ ); +} diff --git a/wolai-frontend/src/components/sidebar/sidebar.tsx b/wolai-frontend/src/components/sidebar/sidebar.tsx index b8065a88..7b9e7c3f 100644 --- a/wolai-frontend/src/components/sidebar/sidebar.tsx +++ b/wolai-frontend/src/components/sidebar/sidebar.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import Link from "next/link"; import { useRouter, useSelectedLayoutSegments } from "next/navigation"; import { useAuthActions } from "@convex-dev/auth/react"; +import { useConvex } from "convex/react"; import { ArrowRightLeft, ArrowUpRight, @@ -36,7 +37,6 @@ import type { DocumentNode } from "@/lib/documents"; import { buildDocumentTree } from "@/lib/documents"; import { useSidebarStore } from "@/store/sidebar"; import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types"; -import { useSidebarData } from "@/hooks/use-sidebar-data"; import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data"; import { PrivateTree } from "@/components/sidebar/private-tree"; import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree"; @@ -61,6 +61,9 @@ import type { MediaAsset } from "@/types/media"; import { AssetContextMenu } from "@/components/sidebar/asset-context-menu"; import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; +import { DocumentShareDialog } from "@/components/sharing/document-share-dialog"; +import { api } from "@/lib/convex/api"; +import { GroupManagerDialog } from "@/components/groups/group-manager-dialog"; const TOP_BUTTONS = [ { id: "search", icon: SearchIcon, label: "搜索" }, @@ -128,15 +131,8 @@ interface ContextMenuState { y: number; } -// 主 Sidebar 组件:根据模式路由到不同的子组件 export function Sidebar({ initialData }: SidebarProps) { - const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []); - - // 根据模式渲染不同的子组件,确保 Supabase 模式完全不调用 Convex hooks - if (useConvex) { - return ; - } - return ; + return ; } // Convex 模式专用组件 - 只调用 Convex hooks @@ -145,12 +141,6 @@ function SidebarConvex({ initialData }: SidebarProps) { return ; } -// Supabase 模式专用组件 - 只调用 Supabase hooks -function SidebarSupabase({ initialData }: SidebarProps) { - const supabaseData = useSidebarData(initialData); - return ; -} - // 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑 interface SidebarContentProps { initialData: SidebarInitialData; @@ -162,11 +152,9 @@ interface SidebarContentProps { } function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { - const { open, setOpen, width, setWidth, collapsedSections, toggleSection, setSectionCollapsed, trashConfirm, setTrashConfirm } = + const convex = useConvex(); + const { open, setOpen, width, setWidth, collapsedSections, toggleSection, trashConfirm, setTrashConfirm } = useSidebarStore(); - const sectionsTrayOpen = useSidebarStore((state) => state.sectionsTrayOpen); - const toggleSectionsTray = useSidebarStore((state) => state.toggleSectionsTray); - const setSectionsTrayOpen = useSidebarStore((state) => state.setSectionsTrayOpen); const viewMode = useSidebarStore((state) => state.viewMode); const setViewMode = useSidebarStore((state) => state.setViewMode); const openSearchPalette = useSearchPaletteStore((state) => state.openSearch); @@ -188,6 +176,39 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { const [filter, setFilter] = useState(""); const [expanded, setExpanded] = useState>(() => collectNodeIds(tree)); const [contextMenu, setContextMenu] = useState(null); + const [topPanel, setTopPanel] = useState<"starred" | "public" | "shared" | "templates" | null>(null); + const [shareSummary, setShareSummary] = useState<{ + incoming: Array<{ + documentId: string; + permission: "read" | "edit"; + includeDescendants: boolean; + createdBy: string; + updatedAt: string; + }>; + outgoing: Array<{ + documentId: string; + includeDescendants: boolean; + sharedWithCount: number; + }>; + } | null>(null); + const [shareSummaryError, setShareSummaryError] = useState(null); + const [groupPublicSummary, setGroupPublicSummary] = useState< + Array<{ + groupId: string; + groupName: string | null; + documents: Array<{ documentId: string; includeDescendants: boolean }>; + }> + >([]); + const [groupPublicError, setGroupPublicError] = useState(null); + const [openPublicGroups, setOpenPublicGroups] = useState>(() => new Set()); + const [shareDialogOpen, setShareDialogOpen] = useState(false); + const [shareTarget, setShareTarget] = useState<{ + id: string; + title: string | null; + workspaceId: string; + allowIncludeDescendants: boolean; + } | null>(null); + const [groupManagerOpen, setGroupManagerOpen] = useState(false); const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false); const [signingOut, setSigningOut] = useState(false); const [trashOpen, setTrashOpen] = useState(false); @@ -287,12 +308,122 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { await sidebarQuery.refetch(); }, [sidebarQuery]); + const refreshShareSummary = useCallback(async () => { + const workspaceId = sidebarData.activeWorkspaceId; + if (!workspaceId) { + setShareSummary(null); + setShareSummaryError(null); + return; + } + + try { + const resp = await convex.query(api.documentShares.listShareRootsByWorkspace, { workspaceId }); + setShareSummary(resp as any); + setShareSummaryError(null); + } catch (e: any) { + const msg = e?.message ?? "加载共享摘要失败"; + if (String(msg).includes("Could not find public function for 'documentShares:listShareRootsByWorkspace'")) { + setShareSummaryError("共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local`。"); + } else { + setShareSummaryError(msg); + } + setShareSummary(null); + } + }, [convex, sidebarData.activeWorkspaceId]); + + useEffect(() => { + void refreshShareSummary(); + }, [refreshShareSummary]); + + const refreshGroupPublicSummary = useCallback(async () => { + const workspaceId = sidebarData.activeWorkspaceId; + if (!workspaceId) { + setGroupPublicSummary([]); + setGroupPublicError(null); + return; + } + + try { + const resp = await convex.query(api.documentGroupShares.listPublicByWorkspace, { workspaceId }); + const rows = Array.isArray(resp) ? (resp as any[]) : []; + setGroupPublicSummary( + rows.map((r) => ({ + groupId: String(r.groupId), + groupName: r.groupName ? String(r.groupName) : null, + documents: Array.isArray(r.documents) + ? r.documents.map((d: any) => ({ + documentId: String(d.documentId), + includeDescendants: Boolean(d.includeDescendants), + })) + : [], + })), + ); + setGroupPublicError(null); + } catch (e: any) { + const msg = e?.message ?? "加载群组公开摘要失败"; + if (String(msg).includes("Could not find public function for 'documentGroupShares:listPublicByWorkspace'")) { + setGroupPublicError("群组公开功能后端未部署/未更新:请执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local`。"); + } else { + setGroupPublicError(msg); + } + setGroupPublicSummary([]); + } + }, [convex, sidebarData.activeWorkspaceId]); + + useEffect(() => { + void refreshGroupPublicSummary(); + }, [refreshGroupPublicSummary]); + const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]); const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]); const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.nodes ?? [], [sections]); const sharedNodes = useMemo(() => sections.find((section) => section.id === "shared")?.nodes ?? [], [sections]); const templateNodes = useMemo(() => sections.find((section) => section.id === "templates")?.nodes ?? [], [sections]); const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]); + + const nodeById = useMemo(() => { + const map = new Map(); + const walk = (nodes: DocumentNode[]) => { + nodes.forEach((node) => { + map.set(node.id, node); + if (node.children.length > 0) { + walk(node.children); + } + }); + }; + walk(tree); + return map; + }, [tree]); + + const outgoingSharedRootNodes = useMemo(() => { + const ids = new Set((shareSummary?.outgoing ?? []).map((o) => o.documentId)); + const nodes: DocumentNode[] = []; + for (const id of ids) { + const node = nodeById.get(id); + if (node) nodes.push(node); + } + // 说明:同一个页面被共享给多个用户时,只展示一份。 + const uniq = new Map(); + nodes.forEach((n) => uniq.set(n.id, n)); + return Array.from(uniq.values()); + }, [nodeById, shareSummary?.outgoing]); + + const publicGroupNodesByGroupId = useMemo(() => { + const map = new Map(); + for (const g of groupPublicSummary) { + const nodes: DocumentNode[] = []; + const uniq = new Map(); + for (const d of g.documents ?? []) { + const node = nodeById.get(d.documentId); + if (node) { + uniq.set(node.id, node); + } + } + uniq.forEach((v) => nodes.push(v)); + map.set(g.groupId, nodes); + } + return map; + }, [groupPublicSummary, nodeById]); const filteredPrivateTree = useMemo( () => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree), [filter, privateTree], @@ -1763,6 +1894,16 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { }); }, []); + const openShareDialog = useCallback((node: DocumentNode) => { + setShareTarget({ + id: node.id, + title: node.title ?? null, + workspaceId: node.workspace_id, + allowIncludeDescendants: (node.children?.length ?? 0) > 0, + }); + setShareDialogOpen(true); + }, []); + const handleTopButtonClick = useCallback( (buttonId: (typeof TOP_BUTTONS)[number]["id"]) => { if (buttonId === "search") { @@ -1776,13 +1917,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { buttonId === "templates" ) { setViewMode("section"); - setSectionsTrayOpen(true); - setSectionCollapsed(buttonId, false); + setTopPanel((prev) => (prev === buttonId ? null : buttonId)); + return; + } + if (buttonId === "members") { + setGroupManagerOpen(true); return; } window.alert("该功能即将上线,敬请期待"); }, - [openSearchPalette, setSectionCollapsed, setSectionsTrayOpen, setViewMode], + [ + openSearchPalette, + setViewMode, + ], ); useEffect(() => { @@ -1883,6 +2030,132 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { ))}
+ {topPanel ? ( +
+
+ {SECTION_ICONS[topPanel]} + {topPanel === "starred" + ? "星标置顶" + : topPanel === "public" + ? "公共页面" + : topPanel === "shared" + ? "共享页面" + : "模板中心"} + 再次点击图标可收起 +
+
+ {(() => { + const renderList = (nodes: DocumentNode[]) => { + const flat = flattenDocumentTree(nodes, collectNodeIds(nodes)); + if (flat.length === 0) { + return
暂无内容
; + } + return ( +
+ {flat.map(({ node, depth }) => ( + + {node.title || "无标题"} + + ))} +
+ ); + }; + + if (topPanel === "shared") { + return ( +
+ {shareSummaryError ? ( +
{shareSummaryError}
+ ) : null} + +
+
+ 共享给我的({shareSummary?.incoming?.length ?? 0}) +
+ {renderList(sharedNodes)} +
+ +
+
+ 我共享出去的({shareSummary?.outgoing?.length ?? 0}) +
+ {renderList(outgoingSharedRootNodes)} +
+
+ ); + } + + if (topPanel === "public") { + const toggleGroup = (groupId: string) => { + setOpenPublicGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) next.delete(groupId); + else next.add(groupId); + return next; + }); + }; + + return ( +
+ {groupPublicError ? ( +
{groupPublicError}
+ ) : null} + +
+
全员公开
+ {renderList(publicNodes)} +
+ +
+
群组公开
+ {groupPublicSummary.length === 0 ? ( +
暂无群组公开页面
+ ) : ( +
+ {groupPublicSummary.map((g) => { + const isOpen = openPublicGroups.has(g.groupId); + const nodes = publicGroupNodesByGroupId.get(g.groupId) ?? []; + return ( +
+ + {isOpen ?
{renderList(nodes)}
: null} +
+ ); + })} +
+ )} +
+
+ ); + } + + const nodes = topPanel === "starred" ? starredNodes : templateNodes; + return renderList(nodes); + })()} +
+
+ ) : null} +
@@ -1924,56 +2197,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { {viewMode === "section" ? ( <> - - - {sectionsTrayOpen ? ( - <> - toggleSection("starred")} - /> - toggleSection("public")} - /> - toggleSection("shared")} - /> - toggleSection("templates")} - /> - - ) : null} -
+