0.3.4 小图标功能增加
This commit is contained in:
+12
@@ -8,13 +8,19 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type * as _utils_documentTree from "../_utils/documentTree.js";
|
||||||
import type * as _utils_id from "../_utils/id.js";
|
import type * as _utils_id from "../_utils/id.js";
|
||||||
import type * as _utils_ingestJobs from "../_utils/ingestJobs.js";
|
import type * as _utils_ingestJobs from "../_utils/ingestJobs.js";
|
||||||
import type * as _utils_lightrag from "../_utils/lightrag.js";
|
import type * as _utils_lightrag from "../_utils/lightrag.js";
|
||||||
import type * as _utils_text from "../_utils/text.js";
|
import type * as _utils_text from "../_utils/text.js";
|
||||||
import type * as _utils_time from "../_utils/time.js";
|
import type * as _utils_time from "../_utils/time.js";
|
||||||
import type * as auth from "../auth.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 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 http from "../http.js";
|
||||||
import type * as jobs from "../jobs.js";
|
import type * as jobs from "../jobs.js";
|
||||||
import type * as mediaAssets from "../mediaAssets.js";
|
import type * as mediaAssets from "../mediaAssets.js";
|
||||||
@@ -33,13 +39,19 @@ import type {
|
|||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
|
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
|
"_utils/documentTree": typeof _utils_documentTree;
|
||||||
"_utils/id": typeof _utils_id;
|
"_utils/id": typeof _utils_id;
|
||||||
"_utils/ingestJobs": typeof _utils_ingestJobs;
|
"_utils/ingestJobs": typeof _utils_ingestJobs;
|
||||||
"_utils/lightrag": typeof _utils_lightrag;
|
"_utils/lightrag": typeof _utils_lightrag;
|
||||||
"_utils/text": typeof _utils_text;
|
"_utils/text": typeof _utils_text;
|
||||||
"_utils/time": typeof _utils_time;
|
"_utils/time": typeof _utils_time;
|
||||||
auth: typeof auth;
|
auth: typeof auth;
|
||||||
|
documentGroupShares: typeof documentGroupShares;
|
||||||
|
documentShares: typeof documentShares;
|
||||||
|
documentStars: typeof documentStars;
|
||||||
documents: typeof documents;
|
documents: typeof documents;
|
||||||
|
groupMembers: typeof groupMembers;
|
||||||
|
groups: typeof groups;
|
||||||
http: typeof http;
|
http: typeof http;
|
||||||
jobs: typeof jobs;
|
jobs: typeof jobs;
|
||||||
mediaAssets: typeof mediaAssets;
|
mediaAssets: typeof mediaAssets;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
/* eslint-disable */
|
||||||
/**
|
/**
|
||||||
* Generated `api` utility.
|
* Generated `api` utility.
|
||||||
*
|
*
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
|
/* eslint-disable */
|
||||||
/**
|
/**
|
||||||
* Generated data model types.
|
* Generated data model types.
|
||||||
*
|
*
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
|
/* eslint-disable */
|
||||||
/**
|
/**
|
||||||
* Generated utilities for implementing server-side Convex query and mutation functions.
|
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
/* eslint-disable */
|
||||||
/**
|
/**
|
||||||
* Generated utilities for implementing server-side Convex query and mutation functions.
|
* Generated utilities for implementing server-side Convex query and mutation functions.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export type ParentLinkedRow = {
|
||||||
|
id: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收集以 rootId 为根的整棵子树(包含根节点本身)。
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - 仅依赖 (id, parent_id) 字段,便于在 Convex 与前端共用同一套遍历逻辑。
|
||||||
|
* - 会做去重与环检测,避免异常数据导致死循环。
|
||||||
|
*/
|
||||||
|
export function collectSubtree<T extends ParentLinkedRow>(rows: readonly T[], rootId: string): T[] {
|
||||||
|
const byParentId = new Map<string | null, T[]>();
|
||||||
|
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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<string> {
|
||||||
|
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<string>(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<string>(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<string, string>();
|
||||||
|
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<string, { includeDescendants: boolean }>();
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<string> {
|
||||||
|
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<string, { includeDescendants: boolean; sharedWithCount: number }>();
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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<string> {
|
||||||
|
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<SharePermission | null> {
|
||||||
|
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<SharePermission | null> {
|
||||||
|
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<string>(memberships.map((m: any) => String(m.group_id)));
|
||||||
|
if (groupIds.size === 0) return null;
|
||||||
|
|
||||||
|
const permissionFromGroup = async (documentId: string, groupId: string): Promise<SharePermission> => {
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ import { internalQuery, mutation, query } from "./_generated/server";
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||||
import { nowIso } from "./_utils/time";
|
import { nowIso } from "./_utils/time";
|
||||||
|
import { collectSubtree } from "./_utils/documentTree";
|
||||||
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
|
import { enqueueIngestDocumentJob } from "./_utils/ingestJobs";
|
||||||
|
|
||||||
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
|
const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public"));
|
||||||
@@ -14,6 +15,108 @@ async function requireUserId(ctx: any): Promise<string> {
|
|||||||
return userId;
|
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<SharePermission | null> {
|
||||||
|
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<SharePermission | null> {
|
||||||
|
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<string>(memberships.map((m: any) => String(m.group_id)));
|
||||||
|
if (groupIds.size === 0) return null;
|
||||||
|
|
||||||
|
const permissionFromGroup = async (documentId: string, groupId: string): Promise<SharePermission> => {
|
||||||
|
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({
|
export const getMeta = query({
|
||||||
args: { id: v.string() },
|
args: { id: v.string() },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -24,12 +127,29 @@ export const getMeta = query({
|
|||||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
.first();
|
.first();
|
||||||
if (!doc) return null;
|
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 {
|
return {
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
user_id: doc.user_id,
|
user_id: doc.user_id,
|
||||||
workspace_id: doc.workspace_id,
|
workspace_id: doc.workspace_id,
|
||||||
access_scope: doc.access_scope,
|
access_scope: doc.access_scope,
|
||||||
|
can_edit: canEdit,
|
||||||
title: doc.title ?? null,
|
title: doc.title ?? null,
|
||||||
parent_id: doc.parent_id ?? null,
|
parent_id: doc.parent_id ?? null,
|
||||||
created_at: doc.created_at,
|
created_at: doc.created_at,
|
||||||
@@ -90,7 +210,21 @@ export const getContent = query({
|
|||||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
.first();
|
.first();
|
||||||
if (!doc) return null;
|
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 };
|
return { content: doc.content ?? null };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -113,23 +247,131 @@ export const listByWorkspace = query({
|
|||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const userId = await requireUserId(ctx);
|
const userId = await requireUserId(ctx);
|
||||||
|
|
||||||
|
// workspace 权限
|
||||||
|
try {
|
||||||
|
await requireWorkspaceMember(ctx, args.workspaceId, userId);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const docs = await ctx.db
|
const docs = await ctx.db
|
||||||
.query("documents")
|
.query("documents")
|
||||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// 说明:阶段 4 先不做垃圾桶(deleted_at != null),因此这里直接过滤。
|
// 说明:这里仅用于侧边栏数据(不返回垃圾桶 deleted_at != null)。
|
||||||
return docs
|
const alive = docs.filter((d) => d.deleted_at == null);
|
||||||
.filter((d) => d.user_id === userId)
|
|
||||||
.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<string>(groupMemberships.map((m: any) => String(m.group_id)));
|
||||||
|
|
||||||
|
const directGroupShares = new Map<string, { includeDescendants: boolean }>();
|
||||||
|
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<string, string | null>();
|
||||||
|
for (const d of alive) {
|
||||||
|
parentById.set(d.id, d.parent_id ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shareAccessCache = new Map<string, boolean>();
|
||||||
|
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<string, boolean>();
|
||||||
|
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<string>(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) => ({
|
.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,
|
id: d.id,
|
||||||
workspace_id: d.workspace_id,
|
workspace_id: d.workspace_id,
|
||||||
title: d.title ?? "无标题",
|
title: d.title ?? "无标题",
|
||||||
parent_id: d.parent_id ?? null,
|
parent_id: d.parent_id ?? null,
|
||||||
sort_order: d.sort_order ?? 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,
|
is_template: d.is_template ?? false,
|
||||||
created_at: d.created_at,
|
created_at: d.created_at,
|
||||||
updated_at: d.updated_at ?? null,
|
updated_at: d.updated_at ?? null,
|
||||||
@@ -238,13 +480,20 @@ export const updateContent = mutation({
|
|||||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
.first();
|
.first();
|
||||||
if (!doc) throw new Error("页面不存在");
|
if (!doc) throw new Error("页面不存在");
|
||||||
if (doc.user_id !== 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();
|
const ts = nowIso();
|
||||||
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
|
await ctx.db.patch(doc._id, { content: args.content, updated_at: ts });
|
||||||
|
|
||||||
// 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。
|
// 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。
|
||||||
// 采用 debounce,避免频繁保存时触发过多任务。
|
// 采用 debounce,避免频繁保存时触发过多任务。
|
||||||
await enqueueIngestDocumentJob(ctx, { userId, documentId: args.id, debounceMs: 1500 });
|
await enqueueIngestDocumentJob(ctx, { userId: doc.user_id, documentId: args.id, debounceMs: 1500 });
|
||||||
return { ok: true, updated_at: ts };
|
return { ok: true, updated_at: ts };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -259,7 +508,14 @@ export const updateTitle = mutation({
|
|||||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
.first();
|
.first();
|
||||||
if (!doc) throw new Error("页面不存在");
|
if (!doc) throw new Error("页面不存在");
|
||||||
if (doc.user_id !== 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();
|
const ts = nowIso();
|
||||||
await ctx.db.patch(doc._id, { title: args.title, updated_at: ts });
|
await ctx.db.patch(doc._id, { title: args.title, updated_at: ts });
|
||||||
return { ok: true, updated_at: ts };
|
return { ok: true, updated_at: ts };
|
||||||
@@ -372,8 +628,23 @@ export const softDelete = mutation({
|
|||||||
if (!doc) throw new Error("页面不存在");
|
if (!doc) throw new Error("页面不存在");
|
||||||
if (doc.user_id !== userId) throw new Error("无权限");
|
if (doc.user_id !== userId) throw new Error("无权限");
|
||||||
const ts = nowIso();
|
const ts = nowIso();
|
||||||
await ctx.db.patch(doc._id, { deleted_at: ts, deleted_by: 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) throw new Error("页面不存在");
|
||||||
if (doc.user_id !== userId) throw new Error("无权限");
|
if (doc.user_id !== userId) throw new Error("无权限");
|
||||||
const ts = nowIso();
|
const ts = nowIso();
|
||||||
|
const restoreDeletedAt = doc.deleted_at;
|
||||||
await ctx.db.patch(doc._id, {
|
await ctx.db.patch(doc._id, {
|
||||||
deleted_at: null,
|
deleted_at: null,
|
||||||
deleted_by: null,
|
deleted_by: null,
|
||||||
@@ -396,6 +668,22 @@ export const restore = mutation({
|
|||||||
access_scope: "private",
|
access_scope: "private",
|
||||||
updated_at: ts,
|
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 };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -411,8 +699,21 @@ export const purge = mutation({
|
|||||||
.first();
|
.first();
|
||||||
if (!doc) throw new Error("页面不存在");
|
if (!doc) throw new Error("页面不存在");
|
||||||
if (doc.user_id !== userId) 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))
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
.first();
|
.first();
|
||||||
if (!doc) throw new Error("页面不存在");
|
if (!doc) throw new Error("页面不存在");
|
||||||
if (doc.user_id !== 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<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
if (typeof args.options.wideLayout === "boolean") patch.wide_layout = args.options.wideLayout;
|
if (typeof args.options.wideLayout === "boolean") patch.wide_layout = args.options.wideLayout;
|
||||||
@@ -503,7 +811,14 @@ export const updateStats = mutation({
|
|||||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||||
.first();
|
.first();
|
||||||
if (!doc) throw new Error("页面不存在");
|
if (!doc) throw new Error("页面不存在");
|
||||||
if (doc.user_id !== 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();
|
const ts = nowIso();
|
||||||
await ctx.db.patch(doc._id, {
|
await ctx.db.patch(doc._id, {
|
||||||
word_count: args.wordCount,
|
word_count: args.wordCount,
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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<string> {
|
||||||
|
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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -237,4 +237,79 @@ export default defineSchema({
|
|||||||
searchField: "row_hash",
|
searchField: "row_hash",
|
||||||
filterFields: ["table_id", "is_deleted"],
|
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"]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { query } from "./_generated/server";
|
import { query } from "./_generated/server";
|
||||||
|
import { mutation } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,3 +17,38 @@ export const currentUser = query({
|
|||||||
return await ctx.db.get(userId);
|
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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
|||||||
if (!doc) {
|
if (!doc) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
const readOnly = (doc as any).can_edit === false;
|
||||||
|
|
||||||
const initialOptions: PageOptionsState = {
|
const initialOptions: PageOptionsState = {
|
||||||
wideLayout: doc.wide_layout ?? false,
|
wideLayout: doc.wide_layout ?? false,
|
||||||
@@ -52,6 +53,7 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
|||||||
initialOptions={initialOptions}
|
initialOptions={initialOptions}
|
||||||
initialStats={initialStats}
|
initialStats={initialStats}
|
||||||
openTableId={openTableId}
|
openTableId={openTableId}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -12,6 +12,11 @@ export default function WolaiImportPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const parentId = useMemo(() => searchParams.get("parentId") ?? "", [searchParams]);
|
const parentId = useMemo(() => searchParams.get("parentId") ?? "", [searchParams]);
|
||||||
|
|
||||||
|
// 说明:该页面是工具页,避免开发环境下偶发的 hydration mismatch(SSR 与客户端首屏不一致)。
|
||||||
|
// 首屏统一渲染一个稳定的占位内容,挂载后再渲染真实表单。
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [zipPath, setZipPath] = useState("");
|
const [zipPath, setZipPath] = useState("");
|
||||||
const [rootMdPath, setRootMdPath] = useState("");
|
const [rootMdPath, setRootMdPath] = useState("");
|
||||||
@@ -78,6 +83,19 @@ export default function WolaiImportPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-2xl p-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>导入 Wolai(Markdown ZIP)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground">加载中...</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-2xl p-6">
|
<div className="mx-auto w-full max-w-2xl p-6">
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useConvexAuth } from "convex/react";
|
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||||
import { useAuthActions } from "@convex-dev/auth/react";
|
import { useAuthActions } from "@convex-dev/auth/react";
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
type AuthStep = "signIn" | "signUp";
|
type AuthStep = "signIn" | "signUp";
|
||||||
|
|
||||||
@@ -24,18 +25,24 @@ const TEST_CREDENTIALS = {
|
|||||||
export default function AuthPage() {
|
export default function AuthPage() {
|
||||||
const { isLoading, isAuthenticated } = useConvexAuth();
|
const { isLoading, isAuthenticated } = useConvexAuth();
|
||||||
const { signIn } = useAuthActions();
|
const { signIn } = useAuthActions();
|
||||||
|
const setMyUsername = useMutation(api.users.setMyUsername);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const currentUser = useQuery(api.users.currentUser, isAuthenticated ? {} : "skip");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && isAuthenticated) {
|
if (isLoading) return;
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
if (currentUser === undefined) return;
|
||||||
|
if (currentUser && currentUser.name) {
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, isLoading, router]);
|
}, [currentUser, isAuthenticated, isLoading, router]);
|
||||||
|
|
||||||
const [flow, setFlow] = useState<AuthStep>("signIn");
|
const [flow, setFlow] = useState<AuthStep>("signIn");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [name, setName] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [message, setMessage] = useState<{
|
const [message, setMessage] = useState<{
|
||||||
type: "success" | "error" | "info";
|
type: "success" | "error" | "info";
|
||||||
text: string;
|
text: string;
|
||||||
@@ -46,17 +53,28 @@ export default function AuthPage() {
|
|||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (flow === "signUp" && !username.trim()) {
|
||||||
|
setMessage({ type: "error", text: "用户名不能为空" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("email", email);
|
formData.append("email", email);
|
||||||
formData.append("password", password);
|
formData.append("password", password);
|
||||||
if (flow === "signUp") {
|
|
||||||
formData.append("name", name);
|
|
||||||
}
|
|
||||||
formData.append("flow", flow);
|
formData.append("flow", flow);
|
||||||
|
|
||||||
const result = await signIn("password", formData);
|
const result = await signIn("password", formData);
|
||||||
|
|
||||||
if (result.signingIn) {
|
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" ? "登录成功!" : "注册成功!" });
|
setMessage({ type: "success", text: flow === "signIn" ? "登录成功!" : "注册成功!" });
|
||||||
setTimeout(() => router.push("/"), 500);
|
setTimeout(() => router.push("/"), 500);
|
||||||
return;
|
return;
|
||||||
@@ -73,7 +91,7 @@ export default function AuthPage() {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setMessage({ type: "error", text: error.message || "操作失败,请重试" });
|
setMessage({ type: "error", text: error.message || "操作失败,请重试" });
|
||||||
}
|
}
|
||||||
}, [name, signIn, router]);
|
}, [router, setMyUsername, signIn, username]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
|
const handleSubmit = useCallback(async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -104,7 +122,7 @@ export default function AuthPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated && currentUser && currentUser.name) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -115,6 +133,75 @@ export default function AuthPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
if (currentUser === undefined) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">设置用户名</h2>
|
||||||
|
<p className="mt-2 text-center text-sm text-gray-600">用于分享功能的用户查找(全局唯一)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`rounded-md p-4 ${
|
||||||
|
message.type === "success" ? "bg-green-50 text-green-800" :
|
||||||
|
message.type === "error" ? "bg-red-50 text-red-800" :
|
||||||
|
"bg-blue-50 text-blue-800"
|
||||||
|
}`}>
|
||||||
|
<p className="text-sm">{message.text}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="sr-only">用户名</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => 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 位,不含空格)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void saveUsername()}
|
||||||
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
保存并继续
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-md w-full space-y-8">
|
<div className="max-w-md w-full space-y-8">
|
||||||
@@ -155,18 +242,19 @@ export default function AuthPage() {
|
|||||||
</div>
|
</div>
|
||||||
{flow === "signUp" && (
|
{flow === "signUp" && (
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="sr-only">姓名</label>
|
<label htmlFor="username" className="sr-only">用户名</label>
|
||||||
<input
|
<input
|
||||||
id="name"
|
id="username"
|
||||||
name="name"
|
name="username"
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="name"
|
autoComplete="username"
|
||||||
value={name}
|
required
|
||||||
onChange={(e) => setName(e.target.value)}
|
value={username}
|
||||||
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"
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="姓名(可选)"
|
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 位,不含空格)"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className="sr-only">密码</label>
|
<label htmlFor="password" className="sr-only">密码</label>
|
||||||
|
|||||||
@@ -3,9 +3,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, useSelectedLayoutSegments } from "next/navigation";
|
import { useParams, useSelectedLayoutSegments } from "next/navigation";
|
||||||
import { ChevronRight, MoreHorizontal, Star } from "lucide-react";
|
import { ChevronRight, MoreHorizontal, Star } from "lucide-react";
|
||||||
|
import { useMutation, useQuery } from "convex/react";
|
||||||
import type { DocumentRecord } from "@/lib/documents";
|
import type { DocumentRecord } from "@/lib/documents";
|
||||||
import { findBreadcrumb } from "@/lib/documents";
|
import { findBreadcrumb } from "@/lib/documents";
|
||||||
import { usePageLayoutStore } from "@/store/page-layout";
|
import { usePageLayoutStore } from "@/store/page-layout";
|
||||||
|
import { api } from "@/lib/convex/api";
|
||||||
|
|
||||||
interface BreadcrumbProps {
|
interface BreadcrumbProps {
|
||||||
documents: DocumentRecord[];
|
documents: DocumentRecord[];
|
||||||
@@ -19,6 +21,8 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
|||||||
const path = findBreadcrumb(documents, activeId);
|
const path = findBreadcrumb(documents, activeId);
|
||||||
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
||||||
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
|
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 暂时为空。
|
// 新建页面后,侧边栏数据可能还没同步到 layout(SSR)注入的 documents,导致 findBreadcrumb 暂时为空。
|
||||||
// 这里用 activeId 兜底,避免右上角“收藏/更多”按钮消失。
|
// 这里用 activeId 兜底,避免右上角“收藏/更多”按钮消失。
|
||||||
@@ -55,8 +59,13 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full px-3 py-1 text-sm transition-colors"
|
className="hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full px-3 py-1 text-sm transition-colors"
|
||||||
|
onClick={() => void toggleStar({ documentId: activeId })}
|
||||||
|
disabled={!activeId}
|
||||||
>
|
>
|
||||||
<Star className="mr-1 inline h-4 w-4" />
|
<Star
|
||||||
|
className={`mr-1 inline h-4 w-4 ${isStarred ? "text-[#f5a623]" : ""}`}
|
||||||
|
fill={isStarred ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
收藏
|
收藏
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface BlockNoteEditorProps {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
initialContent: unknown;
|
initialContent: unknown;
|
||||||
pageOptions: PageOptionsState;
|
pageOptions: PageOptionsState;
|
||||||
|
readOnly?: boolean;
|
||||||
onStatsChange?: (stats: DocumentStats) => void;
|
onStatsChange?: (stats: DocumentStats) => void;
|
||||||
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
|
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
|
||||||
}
|
}
|
||||||
@@ -168,6 +169,7 @@ export function BlockNoteEditor({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
initialContent,
|
initialContent,
|
||||||
pageOptions,
|
pageOptions,
|
||||||
|
readOnly = false,
|
||||||
onStatsChange,
|
onStatsChange,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
}: BlockNoteEditorProps) {
|
}: BlockNoteEditorProps) {
|
||||||
@@ -776,7 +778,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
|||||||
theme="light"
|
theme="light"
|
||||||
slashMenu={false}
|
slashMenu={false}
|
||||||
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
|
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
|
||||||
editable={!pageOptions.protectEditing}
|
editable={!pageOptions.protectEditing && !readOnly}
|
||||||
className={blocknoteClass}
|
className={blocknoteClass}
|
||||||
>
|
>
|
||||||
{!isFullScreenTableOpen && (
|
{!isFullScreenTableOpen && (
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export interface DocumentContentProps {
|
|||||||
initialOptions: PageOptionsState;
|
initialOptions: PageOptionsState;
|
||||||
initialStats: DocumentStats | null;
|
initialStats: DocumentStats | null;
|
||||||
openTableId?: string | null;
|
openTableId?: string | null;
|
||||||
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultOptions: PageOptionsState = {
|
const defaultOptions: PageOptionsState = {
|
||||||
@@ -57,6 +58,7 @@ export function DocumentContent({
|
|||||||
initialOptions,
|
initialOptions,
|
||||||
initialStats,
|
initialStats,
|
||||||
openTableId,
|
openTableId,
|
||||||
|
readOnly = false,
|
||||||
}: DocumentContentProps) {
|
}: DocumentContentProps) {
|
||||||
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
|
const [options, setOptions] = useState<PageOptionsState>(initialOptions ?? defaultOptions);
|
||||||
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
|
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
|
||||||
@@ -179,6 +181,7 @@ export function DocumentContent({
|
|||||||
|
|
||||||
const persistTitle = useCallback(
|
const persistTitle = useCallback(
|
||||||
async (nextTitle: string) => {
|
async (nextTitle: string) => {
|
||||||
|
if (readOnly) return;
|
||||||
const payload = nextTitle.trim() || "无标题";
|
const payload = nextTitle.trim() || "无标题";
|
||||||
await fetch("/api/documents/title", {
|
await fetch("/api/documents/title", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -186,7 +189,7 @@ export function DocumentContent({
|
|||||||
body: JSON.stringify({ documentId, title: payload }),
|
body: JSON.stringify({ documentId, title: payload }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[documentId],
|
[documentId, readOnly],
|
||||||
);
|
);
|
||||||
|
|
||||||
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
|
const debouncedPersistTitle = useDebouncedCallback((value: string) => {
|
||||||
@@ -194,12 +197,14 @@ export function DocumentContent({
|
|||||||
}, 600);
|
}, 600);
|
||||||
|
|
||||||
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
const handleTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (readOnly) return;
|
||||||
const value = event.target.value;
|
const value = event.target.value;
|
||||||
setPageTitle(value);
|
setPageTitle(value);
|
||||||
debouncedPersistTitle(value);
|
debouncedPersistTitle(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTitleBlur = () => {
|
const handleTitleBlur = () => {
|
||||||
|
if (readOnly) return;
|
||||||
void persistTitle(pageTitle);
|
void persistTitle(pageTitle);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,6 +217,7 @@ export function DocumentContent({
|
|||||||
|
|
||||||
const persistOptions = useCallback(
|
const persistOptions = useCallback(
|
||||||
async (patch: Partial<PageOptionsState>) => {
|
async (patch: Partial<PageOptionsState>) => {
|
||||||
|
if (readOnly) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/documents/options", {
|
const response = await fetch("/api/documents/options", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -226,10 +232,11 @@ export function DocumentContent({
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[documentId],
|
[documentId, readOnly],
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleOption = (key: keyof PageOptionsState) => {
|
const toggleOption = (key: keyof PageOptionsState) => {
|
||||||
|
if (readOnly) return;
|
||||||
setOptions((prev) => {
|
setOptions((prev) => {
|
||||||
const nextValue = !prev[key];
|
const nextValue = !prev[key];
|
||||||
const next = { ...prev, [key]: nextValue };
|
const next = { ...prev, [key]: nextValue };
|
||||||
@@ -320,12 +327,14 @@ export function DocumentContent({
|
|||||||
placeholder="无标题"
|
placeholder="无标题"
|
||||||
className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0"
|
className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0"
|
||||||
aria-label="页面标题"
|
aria-label="页面标题"
|
||||||
disabled={options.protectEditing}
|
disabled={options.protectEditing || readOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{options.protectEditing && (
|
{readOnly ? (
|
||||||
|
<p className="mt-1 text-sm text-gray-500">该页面为只读共享,无法编辑。</p>
|
||||||
|
) : options.protectEditing ? (
|
||||||
<p className="mt-1 text-sm text-[#b91c1c]">当前页面已开启编辑保护,关闭后方可修改内容。</p>
|
<p className="mt-1 text-sm text-[#b91c1c]">当前页面已开启编辑保护,关闭后方可修改内容。</p>
|
||||||
)}
|
) : null}
|
||||||
<p className="text-sm text-wolai-text-secondary">最近更新:{formattedUpdatedAt}</p>
|
<p className="text-sm text-wolai-text-secondary">最近更新:{formattedUpdatedAt}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto px-12 py-6">
|
<div className="flex-1 overflow-y-auto px-12 py-6">
|
||||||
@@ -358,6 +367,7 @@ export function DocumentContent({
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
initialContent={content}
|
initialContent={content}
|
||||||
pageOptions={options}
|
pageOptions={options}
|
||||||
|
readOnly={readOnly}
|
||||||
onStatsChange={handleStatsChange}
|
onStatsChange={handleStatsChange}
|
||||||
onSnapshot={handleSnapshot}
|
onSnapshot={handleSnapshot}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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<GroupRow[]>([]);
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<string>("");
|
||||||
|
const [members, setMembers] = useState<MemberRow[]>([]);
|
||||||
|
|
||||||
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
|
const [inviteUsername, setInviteUsername] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>群组管理</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-md border border-gray-200">
|
||||||
|
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">群组</div>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={newGroupName}
|
||||||
|
onChange={(e) => setNewGroupName(e.target.value)}
|
||||||
|
placeholder="新群组名称"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => void handleCreateGroup()} disabled={loading}>
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-60 overflow-auto rounded-md border border-gray-200">
|
||||||
|
{groups.length === 0 ? (
|
||||||
|
<div className="px-3 py-3 text-sm text-gray-400">暂无群组</div>
|
||||||
|
) : (
|
||||||
|
groups.map((g) => (
|
||||||
|
<div
|
||||||
|
key={g.id}
|
||||||
|
className={`flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-gray-50 ${selectedGroupId === g.id ? "bg-blue-50" : ""}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 flex-1 truncate text-left"
|
||||||
|
onClick={() => setSelectedGroupId(g.id)}
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => void handleRemoveGroup(g.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-gray-200">
|
||||||
|
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">
|
||||||
|
成员{selectedGroup ? `:${selectedGroup.name}` : ""}
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={inviteUsername}
|
||||||
|
onChange={(e) => setInviteUsername(e.target.value)}
|
||||||
|
placeholder="输入用户名邀请"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => void handleInvite()} disabled={loading}>
|
||||||
|
邀请
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-60 overflow-auto rounded-md border border-gray-200">
|
||||||
|
{!selectedGroupId ? (
|
||||||
|
<div className="px-3 py-3 text-sm text-gray-400">请先选择一个群组</div>
|
||||||
|
) : members.length === 0 ? (
|
||||||
|
<div className="px-3 py-3 text-sm text-gray-400">暂无成员</div>
|
||||||
|
) : (
|
||||||
|
members.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.userId}
|
||||||
|
className="flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1 truncate">
|
||||||
|
{m.username ?? m.userId}
|
||||||
|
<span className="ml-2 text-xs text-gray-400">
|
||||||
|
{m.role === "owner" ? "群主" : "成员"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{m.role !== "owner" ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => void handleRemoveMember(m.userId)}
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentShareDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
documentId,
|
||||||
|
documentTitle,
|
||||||
|
workspaceId,
|
||||||
|
allowIncludeDescendants = false,
|
||||||
|
onChanged,
|
||||||
|
}: DocumentShareDialogProps) {
|
||||||
|
const convex = useConvex();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [permission, setPermission] = useState<SharePermission>("read");
|
||||||
|
const [includeDescendants, setIncludeDescendants] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [shares, setShares] = useState<any[] | null>(null);
|
||||||
|
const [sharesLoading, setSharesLoading] = useState(false);
|
||||||
|
const [groups, setGroups] = useState<Array<{ id: string; name: string }>>([]);
|
||||||
|
const [groupShares, setGroupShares] = useState<any[] | null>(null);
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState("");
|
||||||
|
const [groupIncludeDescendants, setGroupIncludeDescendants] = useState(false);
|
||||||
|
const [groupMembers, setGroupMembers] = useState<Array<{ userId: string; username: string | null; role: string }>>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [groupEditableUserIds, setGroupEditableUserIds] = useState<Set<string>>(() => 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<string>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>共享:{documentTitle ?? "无标题"}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm text-gray-600">输入用户名进行共享</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder="对方用户名"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => void handleSubmit()} disabled={submitting}>
|
||||||
|
{submitting ? "处理中..." : "共享/更新"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-700">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="permission"
|
||||||
|
checked={permission === "read"}
|
||||||
|
onChange={() => setPermission("read")}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
只读
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="permission"
|
||||||
|
checked={permission === "edit"}
|
||||||
|
onChange={() => setPermission("edit")}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
可编辑
|
||||||
|
</label>
|
||||||
|
{allowIncludeDescendants && (
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={includeDescendants}
|
||||||
|
onChange={(e) => setIncludeDescendants(e.target.checked)}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
包含子页面(共享文件夹)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-gray-200 p-3">
|
||||||
|
<div className="text-sm font-medium text-gray-700">公开到群组(群组内所有人可见)</div>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-gray-200 bg-white px-2 text-sm"
|
||||||
|
value={selectedGroupId}
|
||||||
|
onChange={(e) => setSelectedGroupId(e.target.value)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
<option value="">请选择群组</option>
|
||||||
|
{groups.map((g) => (
|
||||||
|
<option key={g.id} value={g.id}>
|
||||||
|
{g.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{allowIncludeDescendants && (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={groupIncludeDescendants}
|
||||||
|
onChange={(e) => setGroupIncludeDescendants(e.target.checked)}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
包含子页面(公开文件夹)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<Button onClick={() => void handleUpsertGroupShare()} disabled={submitting || !selectedGroupId}>
|
||||||
|
{submitting ? "处理中..." : "公开/更新"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-gray-200">
|
||||||
|
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">
|
||||||
|
群组成员权限(默认只读,可单独设为可编辑)
|
||||||
|
</div>
|
||||||
|
<div className="max-h-44 overflow-auto p-2">
|
||||||
|
{!selectedGroupId ? (
|
||||||
|
<div className="px-2 py-2 text-sm text-gray-400">请选择群组</div>
|
||||||
|
) : groupMembers.length === 0 ? (
|
||||||
|
<div className="px-2 py-2 text-sm text-gray-400">暂无成员</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{groupMembers.map((m) => {
|
||||||
|
const checked = groupEditableUserIds.has(m.userId);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={m.userId}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-gray-900">
|
||||||
|
{m.username ?? m.userId}
|
||||||
|
<span className="ml-2 text-xs text-gray-400">{m.role === "owner" ? "群主" : "成员"}</span>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2 text-xs text-gray-600">
|
||||||
|
<span>{checked ? "可编辑" : "只读"}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = new Set(groupEditableUserIds);
|
||||||
|
if (e.target.checked) next.add(m.userId);
|
||||||
|
else next.delete(m.userId);
|
||||||
|
setGroupEditableUserIds(next);
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-gray-200">
|
||||||
|
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">
|
||||||
|
已公开到群组
|
||||||
|
</div>
|
||||||
|
<div className="max-h-44 overflow-auto p-2">
|
||||||
|
{groupShares === null ? (
|
||||||
|
<div className="px-2 py-2 text-sm text-gray-400">加载中...</div>
|
||||||
|
) : (groupShares ?? []).length === 0 ? (
|
||||||
|
<div className="px-2 py-2 text-sm text-gray-400">暂无公开群组</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{(groupShares ?? []).map((r: any) => (
|
||||||
|
<div
|
||||||
|
key={String(r.groupId)}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-gray-900">{r.groupName ?? r.groupId}</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{r.includeDescendants ? "包含子页面" : "仅当前页面"}
|
||||||
|
{" · "}
|
||||||
|
可编辑:{Array.isArray(r.editableUserIds) ? r.editableUserIds.length : 0} 人
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() => void handleRemoveGroupShare(String(r.groupId))}
|
||||||
|
>
|
||||||
|
取消公开
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</div>}
|
||||||
|
|
||||||
|
<div className="rounded-md border border-gray-200">
|
||||||
|
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">
|
||||||
|
已共享给
|
||||||
|
</div>
|
||||||
|
<div className="max-h-60 overflow-auto p-2">
|
||||||
|
{sharesLoading && shares === null ? (
|
||||||
|
<div className="px-2 py-2 text-sm text-gray-400">加载中...</div>
|
||||||
|
) : shareRows.length === 0 ? (
|
||||||
|
<div className="px-2 py-2 text-sm text-gray-400">暂无共享者</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{shareRows.map((row: any) => (
|
||||||
|
<div
|
||||||
|
key={row.userId}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md px-2 py-2 text-sm hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-gray-900">
|
||||||
|
{row.username ?? row.userId}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
权限:{row.permission === "edit" ? "可编辑" : "只读"}
|
||||||
|
{row.includeDescendants ? " · 包含子页面" : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() => void handleRemove(String(row.userId))}
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
import { useRouter, useSelectedLayoutSegments } from "next/navigation";
|
||||||
import { useAuthActions } from "@convex-dev/auth/react";
|
import { useAuthActions } from "@convex-dev/auth/react";
|
||||||
|
import { useConvex } from "convex/react";
|
||||||
import {
|
import {
|
||||||
ArrowRightLeft,
|
ArrowRightLeft,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
@@ -36,7 +37,6 @@ import type { DocumentNode } from "@/lib/documents";
|
|||||||
import { buildDocumentTree } from "@/lib/documents";
|
import { buildDocumentTree } from "@/lib/documents";
|
||||||
import { useSidebarStore } from "@/store/sidebar";
|
import { useSidebarStore } from "@/store/sidebar";
|
||||||
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
|
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
|
||||||
import { useSidebarData } from "@/hooks/use-sidebar-data";
|
|
||||||
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
|
import { useConvexSidebarData } from "@/hooks/use-convex-sidebar-data";
|
||||||
import { PrivateTree } from "@/components/sidebar/private-tree";
|
import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||||
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
|
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||||
@@ -61,6 +61,9 @@ import type { MediaAsset } from "@/types/media";
|
|||||||
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
||||||
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
||||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
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 = [
|
const TOP_BUTTONS = [
|
||||||
{ id: "search", icon: SearchIcon, label: "搜索" },
|
{ id: "search", icon: SearchIcon, label: "搜索" },
|
||||||
@@ -128,15 +131,8 @@ interface ContextMenuState {
|
|||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主 Sidebar 组件:根据模式路由到不同的子组件
|
|
||||||
export function Sidebar({ initialData }: SidebarProps) {
|
export function Sidebar({ initialData }: SidebarProps) {
|
||||||
const useConvex = useMemo(() => Boolean(getMnoteRuntimeConfig().useConvex), []);
|
return <SidebarConvex initialData={initialData} />;
|
||||||
|
|
||||||
// 根据模式渲染不同的子组件,确保 Supabase 模式完全不调用 Convex hooks
|
|
||||||
if (useConvex) {
|
|
||||||
return <SidebarConvex initialData={initialData} />;
|
|
||||||
}
|
|
||||||
return <SidebarSupabase initialData={initialData} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convex 模式专用组件 - 只调用 Convex hooks
|
// Convex 模式专用组件 - 只调用 Convex hooks
|
||||||
@@ -145,12 +141,6 @@ function SidebarConvex({ initialData }: SidebarProps) {
|
|||||||
return <SidebarContent initialData={initialData} sidebarQuery={convexData} />;
|
return <SidebarContent initialData={initialData} sidebarQuery={convexData} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supabase 模式专用组件 - 只调用 Supabase hooks
|
|
||||||
function SidebarSupabase({ initialData }: SidebarProps) {
|
|
||||||
const supabaseData = useSidebarData(initialData);
|
|
||||||
return <SidebarContent initialData={initialData} sidebarQuery={supabaseData} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
|
// 共享的 UI 内容组件 - 包含所有现有的 Sidebar 逻辑
|
||||||
interface SidebarContentProps {
|
interface SidebarContentProps {
|
||||||
initialData: SidebarInitialData;
|
initialData: SidebarInitialData;
|
||||||
@@ -162,11 +152,9 @@ interface SidebarContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SidebarContent({ initialData, sidebarQuery }: 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();
|
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 viewMode = useSidebarStore((state) => state.viewMode);
|
||||||
const setViewMode = useSidebarStore((state) => state.setViewMode);
|
const setViewMode = useSidebarStore((state) => state.setViewMode);
|
||||||
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
|
const openSearchPalette = useSearchPaletteStore((state) => state.openSearch);
|
||||||
@@ -188,6 +176,39 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
|
const [expanded, setExpanded] = useState<Set<string>>(() => collectNodeIds(tree));
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(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<string | null>(null);
|
||||||
|
const [groupPublicSummary, setGroupPublicSummary] = useState<
|
||||||
|
Array<{
|
||||||
|
groupId: string;
|
||||||
|
groupName: string | null;
|
||||||
|
documents: Array<{ documentId: string; includeDescendants: boolean }>;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
|
const [groupPublicError, setGroupPublicError] = useState<string | null>(null);
|
||||||
|
const [openPublicGroups, setOpenPublicGroups] = useState<Set<string>>(() => 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 [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
|
||||||
const [signingOut, setSigningOut] = useState(false);
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
const [trashOpen, setTrashOpen] = useState(false);
|
const [trashOpen, setTrashOpen] = useState(false);
|
||||||
@@ -287,12 +308,122 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
await sidebarQuery.refetch();
|
await sidebarQuery.refetch();
|
||||||
}, [sidebarQuery]);
|
}, [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 sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
|
||||||
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
|
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
|
||||||
const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.nodes ?? [], [sections]);
|
const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.nodes ?? [], [sections]);
|
||||||
const sharedNodes = useMemo(() => sections.find((section) => section.id === "shared")?.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 templateNodes = useMemo(() => sections.find((section) => section.id === "templates")?.nodes ?? [], [sections]);
|
||||||
const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]);
|
const privateTree = useMemo(() => sections.find((section) => section.id === "private")?.nodes ?? [], [sections]);
|
||||||
|
|
||||||
|
const nodeById = useMemo(() => {
|
||||||
|
const map = new Map<string, DocumentNode>();
|
||||||
|
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<string, DocumentNode>();
|
||||||
|
nodes.forEach((n) => uniq.set(n.id, n));
|
||||||
|
return Array.from(uniq.values());
|
||||||
|
}, [nodeById, shareSummary?.outgoing]);
|
||||||
|
|
||||||
|
const publicGroupNodesByGroupId = useMemo(() => {
|
||||||
|
const map = new Map<string, DocumentNode[]>();
|
||||||
|
for (const g of groupPublicSummary) {
|
||||||
|
const nodes: DocumentNode[] = [];
|
||||||
|
const uniq = new Map<string, DocumentNode>();
|
||||||
|
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(
|
const filteredPrivateTree = useMemo(
|
||||||
() => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree),
|
() => (filter ? filterTree(privateTree, filter.toLowerCase()) : privateTree),
|
||||||
[filter, 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(
|
const handleTopButtonClick = useCallback(
|
||||||
(buttonId: (typeof TOP_BUTTONS)[number]["id"]) => {
|
(buttonId: (typeof TOP_BUTTONS)[number]["id"]) => {
|
||||||
if (buttonId === "search") {
|
if (buttonId === "search") {
|
||||||
@@ -1776,13 +1917,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
buttonId === "templates"
|
buttonId === "templates"
|
||||||
) {
|
) {
|
||||||
setViewMode("section");
|
setViewMode("section");
|
||||||
setSectionsTrayOpen(true);
|
setTopPanel((prev) => (prev === buttonId ? null : buttonId));
|
||||||
setSectionCollapsed(buttonId, false);
|
return;
|
||||||
|
}
|
||||||
|
if (buttonId === "members") {
|
||||||
|
setGroupManagerOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.alert("该功能即将上线,敬请期待");
|
window.alert("该功能即将上线,敬请期待");
|
||||||
},
|
},
|
||||||
[openSearchPalette, setSectionCollapsed, setSectionsTrayOpen, setViewMode],
|
[
|
||||||
|
openSearchPalette,
|
||||||
|
setViewMode,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1883,6 +2030,132 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{topPanel ? (
|
||||||
|
<div className="border-b border-[#f1f1f1] bg-white px-3 pb-3">
|
||||||
|
<div className="flex items-center gap-2 px-1 py-2 text-sm font-medium text-gray-600">
|
||||||
|
{SECTION_ICONS[topPanel]}
|
||||||
|
{topPanel === "starred"
|
||||||
|
? "星标置顶"
|
||||||
|
: topPanel === "public"
|
||||||
|
? "公共页面"
|
||||||
|
: topPanel === "shared"
|
||||||
|
? "共享页面"
|
||||||
|
: "模板中心"}
|
||||||
|
<span className="ml-auto text-xs text-gray-400">再次点击图标可收起</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-52 overflow-auto rounded-md border border-[#eff2f6] bg-white px-2 py-2">
|
||||||
|
{(() => {
|
||||||
|
const renderList = (nodes: DocumentNode[]) => {
|
||||||
|
const flat = flattenDocumentTree(nodes, collectNodeIds(nodes));
|
||||||
|
if (flat.length === 0) {
|
||||||
|
return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{flat.map(({ node, depth }) => (
|
||||||
|
<Link
|
||||||
|
key={node.id}
|
||||||
|
href={`/documents/${node.id}`}
|
||||||
|
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
|
||||||
|
style={{ paddingLeft: 8 + depth * 12 }}
|
||||||
|
>
|
||||||
|
{node.title || "无标题"}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (topPanel === "shared") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{shareSummaryError ? (
|
||||||
|
<div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">{shareSummaryError}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
|
||||||
|
共享给我的({shareSummary?.incoming?.length ?? 0})
|
||||||
|
</div>
|
||||||
|
{renderList(sharedNodes)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#f1f1f1] pt-2">
|
||||||
|
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
|
||||||
|
我共享出去的({shareSummary?.outgoing?.length ?? 0})
|
||||||
|
</div>
|
||||||
|
{renderList(outgoingSharedRootNodes)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{groupPublicError ? (
|
||||||
|
<div className="rounded-md bg-red-50 px-3 py-2 text-xs text-red-700">{groupPublicError}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="px-2 pb-1 text-xs font-medium text-gray-500">全员公开</div>
|
||||||
|
{renderList(publicNodes)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#f1f1f1] pt-2">
|
||||||
|
<div className="px-2 pb-1 text-xs font-medium text-gray-500">群组公开</div>
|
||||||
|
{groupPublicSummary.length === 0 ? (
|
||||||
|
<div className="px-2 py-2 text-xs text-gray-400">暂无群组公开页面</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{groupPublicSummary.map((g) => {
|
||||||
|
const isOpen = openPublicGroups.has(g.groupId);
|
||||||
|
const nodes = publicGroupNodesByGroupId.get(g.groupId) ?? [];
|
||||||
|
return (
|
||||||
|
<div key={g.groupId} className="rounded-md border border-[#eff2f6]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center justify-between px-2 py-2 text-sm text-gray-600 hover:bg-gray-50"
|
||||||
|
onClick={() => toggleGroup(g.groupId)}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 text-gray-400 transition-transform",
|
||||||
|
isOpen && "rotate-90",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{g.groupName ?? "未命名群组"}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">{g.documents.length}</span>
|
||||||
|
</button>
|
||||||
|
{isOpen ? <div className="px-1 pb-2">{renderList(nodes)}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodes = topPanel === "starred" ? starredNodes : templateNodes;
|
||||||
|
return renderList(nodes);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
<div className="flex h-full min-w-0 flex-col overflow-y-auto overflow-x-hidden">
|
<div className="flex h-full min-w-0 flex-col overflow-y-auto overflow-x-hidden">
|
||||||
<div className="border-b border-[#f1f1f1] p-3">
|
<div className="border-b border-[#f1f1f1] p-3">
|
||||||
@@ -1924,56 +2197,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
|
|
||||||
{viewMode === "section" ? (
|
{viewMode === "section" ? (
|
||||||
<>
|
<>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex w-full items-center justify-between border-b border-[#f1f1f1] px-4 py-3 text-sm font-medium text-gray-600 hover:bg-gray-50"
|
|
||||||
onClick={toggleSectionsTray}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Star className="h-4 w-4 text-[#f5a623]" />
|
|
||||||
星标 / 公共 / 共享 / 模板
|
|
||||||
</span>
|
|
||||||
<ChevronRight
|
|
||||||
className={cn(
|
|
||||||
"h-4 w-4 text-gray-400 transition-transform",
|
|
||||||
sectionsTrayOpen && "rotate-90",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{sectionsTrayOpen ? (
|
|
||||||
<>
|
|
||||||
<SectionList
|
|
||||||
label="星标置顶"
|
|
||||||
icon={SECTION_ICONS.starred}
|
|
||||||
nodes={starredNodes}
|
|
||||||
collapsed={collapsedSections.starred}
|
|
||||||
onToggle={() => toggleSection("starred")}
|
|
||||||
/>
|
|
||||||
<SectionList
|
|
||||||
label="公共页面"
|
|
||||||
icon={SECTION_ICONS.public}
|
|
||||||
nodes={publicNodes}
|
|
||||||
collapsed={collapsedSections.public}
|
|
||||||
onToggle={() => toggleSection("public")}
|
|
||||||
/>
|
|
||||||
<SectionList
|
|
||||||
label="共享页面"
|
|
||||||
icon={SECTION_ICONS.shared}
|
|
||||||
nodes={sharedNodes}
|
|
||||||
collapsed={collapsedSections.shared}
|
|
||||||
onToggle={() => toggleSection("shared")}
|
|
||||||
/>
|
|
||||||
<SectionList
|
|
||||||
label="模板中心"
|
|
||||||
icon={SECTION_ICONS.templates}
|
|
||||||
nodes={templateNodes}
|
|
||||||
collapsed={collapsedSections.templates}
|
|
||||||
onToggle={() => toggleSection("templates")}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2078,6 +2301,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
contextMenu={contextMenu}
|
contextMenu={contextMenu}
|
||||||
onClose={() => setContextMenu(null)}
|
onClose={() => setContextMenu(null)}
|
||||||
onOpenRight={(node) => handleOpenDocument(node.id, "sidebar")}
|
onOpenRight={(node) => handleOpenDocument(node.id, "sidebar")}
|
||||||
|
onShare={openShareDialog}
|
||||||
onMove={handleMovePrompt}
|
onMove={handleMovePrompt}
|
||||||
onEmbed={handleEmbedPrompt}
|
onEmbed={handleEmbedPrompt}
|
||||||
onCopyLink={handleCopyLink}
|
onCopyLink={handleCopyLink}
|
||||||
@@ -2090,6 +2314,33 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
|||||||
onDelete={() => void handleDeleteFileTreeSelection()}
|
onDelete={() => void handleDeleteFileTreeSelection()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{shareTarget && (
|
||||||
|
<DocumentShareDialog
|
||||||
|
open={shareDialogOpen}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
setShareDialogOpen(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
setShareTarget(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
documentId={shareTarget.id}
|
||||||
|
documentTitle={shareTarget.title}
|
||||||
|
workspaceId={shareTarget.workspaceId}
|
||||||
|
allowIncludeDescendants={shareTarget.allowIncludeDescendants}
|
||||||
|
onChanged={async () => {
|
||||||
|
await refreshTree();
|
||||||
|
await refreshShareSummary();
|
||||||
|
await refreshGroupPublicSummary();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{sidebarData.activeWorkspaceId ? (
|
||||||
|
<GroupManagerDialog
|
||||||
|
open={groupManagerOpen}
|
||||||
|
onOpenChange={setGroupManagerOpen}
|
||||||
|
workspaceId={sidebarData.activeWorkspaceId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<MoveEmbedPickerDialog
|
<MoveEmbedPickerDialog
|
||||||
open={moveEmbedOpen}
|
open={moveEmbedOpen}
|
||||||
onOpenChange={setMoveEmbedOpen}
|
onOpenChange={setMoveEmbedOpen}
|
||||||
@@ -2343,6 +2594,7 @@ interface ContextMenuProps {
|
|||||||
contextMenu: ContextMenuState;
|
contextMenu: ContextMenuState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onOpenRight: (node: DocumentNode) => void;
|
onOpenRight: (node: DocumentNode) => void;
|
||||||
|
onShare: (node: DocumentNode) => void;
|
||||||
onMove: (node: DocumentNode) => void;
|
onMove: (node: DocumentNode) => void;
|
||||||
onEmbed: (node: DocumentNode) => void;
|
onEmbed: (node: DocumentNode) => void;
|
||||||
onCopyLink: (node: DocumentNode, withTitle?: boolean) => void;
|
onCopyLink: (node: DocumentNode, withTitle?: boolean) => void;
|
||||||
@@ -2359,6 +2611,7 @@ function ContextMenu({
|
|||||||
contextMenu,
|
contextMenu,
|
||||||
onClose,
|
onClose,
|
||||||
onOpenRight,
|
onOpenRight,
|
||||||
|
onShare,
|
||||||
onMove,
|
onMove,
|
||||||
onEmbed,
|
onEmbed,
|
||||||
onCopyLink,
|
onCopyLink,
|
||||||
@@ -2417,6 +2670,14 @@ function ContextMenu({
|
|||||||
<span>在右侧边栏打开</span>
|
<span>在右侧边栏打开</span>
|
||||||
<span className="ml-auto text-[11px] text-gray-400">Alt + O</span>
|
<span className="ml-auto text-[11px] text-gray-400">Alt + O</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={buttonClass}
|
||||||
|
onClick={() => handleAction(() => onShare(node))}
|
||||||
|
>
|
||||||
|
<Share2 className="h-4 w-4 text-gray-500" />
|
||||||
|
<span>共享...</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={buttonClass}
|
className={buttonClass}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { collectSubtree } from "../../convex/_utils/documentTree";
|
||||||
|
|
||||||
|
describe("collectSubtree", () => {
|
||||||
|
it("会收集根节点及其所有后代", () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: "A", parent_id: null },
|
||||||
|
{ id: "B", parent_id: "A" },
|
||||||
|
{ id: "C", parent_id: "B" },
|
||||||
|
{ id: "D", parent_id: "A" },
|
||||||
|
{ id: "E", parent_id: null },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const subtree = collectSubtree(rows, "A");
|
||||||
|
expect(new Set(subtree.map((r) => r.id))).toEqual(new Set(["A", "B", "C", "D"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("根节点不存在时返回空数组", () => {
|
||||||
|
const rows = [{ id: "A", parent_id: null }] as const;
|
||||||
|
expect(collectSubtree(rows, "missing")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("存在环时不会死循环", () => {
|
||||||
|
const rows = [
|
||||||
|
{ id: "A", parent_id: "B" },
|
||||||
|
{ id: "B", parent_id: "A" },
|
||||||
|
{ id: "C", parent_id: null },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const subtree = collectSubtree(rows, "A");
|
||||||
|
expect(new Set(subtree.map((r) => r.id))).toEqual(new Set(["A", "B"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -5,14 +5,11 @@ import type { SidebarSectionId } from "@/components/sidebar/types";
|
|||||||
interface SidebarState {
|
interface SidebarState {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
width: number;
|
width: number;
|
||||||
sectionsTrayOpen: boolean;
|
|
||||||
collapsedSections: Record<SidebarSectionId, boolean>;
|
collapsedSections: Record<SidebarSectionId, boolean>;
|
||||||
trashConfirm: boolean;
|
trashConfirm: boolean;
|
||||||
viewMode: "section" | "filesystem";
|
viewMode: "section" | "filesystem";
|
||||||
setOpen: (open: boolean) => void;
|
setOpen: (open: boolean) => void;
|
||||||
setWidth: (width: number) => void;
|
setWidth: (width: number) => void;
|
||||||
setSectionsTrayOpen: (open: boolean) => void;
|
|
||||||
toggleSectionsTray: () => void;
|
|
||||||
toggleSection: (section: SidebarSectionId) => void;
|
toggleSection: (section: SidebarSectionId) => void;
|
||||||
setSectionCollapsed: (section: SidebarSectionId, collapsed: boolean) => void;
|
setSectionCollapsed: (section: SidebarSectionId, collapsed: boolean) => void;
|
||||||
setTrashConfirm: (value: boolean) => void;
|
setTrashConfirm: (value: boolean) => void;
|
||||||
@@ -21,11 +18,11 @@ interface SidebarState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sectionDefaults: Record<SidebarSectionId, boolean> = {
|
const sectionDefaults: Record<SidebarSectionId, boolean> = {
|
||||||
starred: false,
|
starred: true,
|
||||||
public: false,
|
public: true,
|
||||||
shared: false,
|
shared: true,
|
||||||
private: false,
|
private: false,
|
||||||
templates: false,
|
templates: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSidebarStore = create<SidebarState>()(
|
export const useSidebarStore = create<SidebarState>()(
|
||||||
@@ -33,14 +30,11 @@ export const useSidebarStore = create<SidebarState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
open: false,
|
open: false,
|
||||||
width: 280,
|
width: 280,
|
||||||
sectionsTrayOpen: false,
|
|
||||||
collapsedSections: { ...sectionDefaults },
|
collapsedSections: { ...sectionDefaults },
|
||||||
trashConfirm: true,
|
trashConfirm: true,
|
||||||
viewMode: "section",
|
viewMode: "section",
|
||||||
setOpen: (open) => set({ open }),
|
setOpen: (open) => set({ open }),
|
||||||
setWidth: (width) => set({ width }),
|
setWidth: (width) => set({ width }),
|
||||||
setSectionsTrayOpen: (open) => set({ sectionsTrayOpen: open }),
|
|
||||||
toggleSectionsTray: () => set((state) => ({ sectionsTrayOpen: !state.sectionsTrayOpen })),
|
|
||||||
toggleSection: (section) =>
|
toggleSection: (section) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
collapsedSections: {
|
collapsedSections: {
|
||||||
@@ -61,39 +55,31 @@ export const useSidebarStore = create<SidebarState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: "sidebar-ui",
|
name: "sidebar-ui",
|
||||||
version: 2,
|
version: 4,
|
||||||
migrate: (persistedState, version) => {
|
migrate: (persistedState, version) => {
|
||||||
const state = persistedState as
|
const state = persistedState as
|
||||||
| Partial<
|
| Partial<
|
||||||
Pick<
|
Pick<
|
||||||
SidebarState,
|
SidebarState,
|
||||||
"width" | "collapsedSections" | "trashConfirm" | "viewMode" | "sectionsTrayOpen"
|
"width" | "collapsedSections" | "trashConfirm" | "viewMode"
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!state) return state;
|
if (!state) return state;
|
||||||
if (version >= 2) return state;
|
if (version >= 4) return state;
|
||||||
|
|
||||||
const nextCollapsedSections = {
|
const nextCollapsedSections = {
|
||||||
...sectionDefaults,
|
...sectionDefaults,
|
||||||
...(state.collapsedSections ?? {}),
|
...(state.collapsedSections ?? {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 迁移到「分区入口可折叠」后,让分区默认展开,折叠由入口开关控制。
|
|
||||||
nextCollapsedSections.starred = false;
|
|
||||||
nextCollapsedSections.public = false;
|
|
||||||
nextCollapsedSections.shared = false;
|
|
||||||
nextCollapsedSections.templates = false;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
sectionsTrayOpen: false,
|
|
||||||
collapsedSections: nextCollapsedSections,
|
collapsedSections: nextCollapsedSections,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
partialize: (state) => ({
|
partialize: (state) => ({
|
||||||
width: state.width,
|
width: state.width,
|
||||||
sectionsTrayOpen: state.sectionsTrayOpen,
|
|
||||||
collapsedSections: state.collapsedSections,
|
collapsedSections: state.collapsedSections,
|
||||||
trashConfirm: state.trashConfirm,
|
trashConfirm: state.trashConfirm,
|
||||||
viewMode: state.viewMode,
|
viewMode: state.viewMode,
|
||||||
|
|||||||
Reference in New Issue
Block a user