0.4.0 convex及界面修改
This commit is contained in:
+2
@@ -16,6 +16,7 @@ import type * as _utils_lightrag from "../_utils/lightrag.js";
|
||||
import type * as _utils_text from "../_utils/text.js";
|
||||
import type * as _utils_time from "../_utils/time.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as comments from "../comments.js";
|
||||
import type * as documentGroupShares from "../documentGroupShares.js";
|
||||
import type * as documentShares from "../documentShares.js";
|
||||
import type * as documentStars from "../documentStars.js";
|
||||
@@ -49,6 +50,7 @@ declare const fullApi: ApiFromModules<{
|
||||
"_utils/text": typeof _utils_text;
|
||||
"_utils/time": typeof _utils_time;
|
||||
auth: typeof auth;
|
||||
comments: typeof comments;
|
||||
documentGroupShares: typeof documentGroupShares;
|
||||
documentShares: typeof documentShares;
|
||||
documentStars: typeof documentStars;
|
||||
|
||||
@@ -3,10 +3,21 @@ type IngestTextArgs = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export async function lightragIngestText(args: IngestTextArgs): Promise<{ trackId: string | null }> {
|
||||
type IngestTextResult = {
|
||||
trackId: string | null;
|
||||
skipped?: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export function isLightRagEnabled(): boolean {
|
||||
return Boolean((process.env.LIGHTRAG_URL || "").trim());
|
||||
}
|
||||
|
||||
export async function lightragIngestText(args: IngestTextArgs): Promise<IngestTextResult> {
|
||||
const baseUrl = (process.env.LIGHTRAG_URL || "").trim().replace(/\/+$/, "");
|
||||
if (!baseUrl) {
|
||||
throw new Error("缺少环境变量:LIGHTRAG_URL");
|
||||
// 说明:LightRAG 未配置时不应让任务系统持续报错;直接降级为“跳过入库”。
|
||||
return { trackId: null, skipped: true, reason: "missing_env_LIGHTRAG_URL" };
|
||||
}
|
||||
|
||||
const fileSource = String(args.fileSource ?? "").trim();
|
||||
@@ -16,7 +27,7 @@ export async function lightragIngestText(args: IngestTextArgs): Promise<{ trackI
|
||||
|
||||
const text = String(args.text ?? "");
|
||||
if (!text.trim()) {
|
||||
return { trackId: null };
|
||||
return { trackId: null, skipped: true, reason: "empty_text" };
|
||||
}
|
||||
|
||||
const apiKey = (process.env.LIGHTRAG_API_KEY || "").trim();
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
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 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;
|
||||
}
|
||||
|
||||
type SharePermission = "read" | "edit";
|
||||
|
||||
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();
|
||||
if (!memberships.length) return null;
|
||||
const groupIds = new Set(memberships.map((m: any) => String(m.group_id)));
|
||||
|
||||
const direct = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", doc.id))
|
||||
.collect();
|
||||
for (const row of direct) {
|
||||
if (groupIds.has(String(row.group_id))) {
|
||||
return "read";
|
||||
}
|
||||
}
|
||||
|
||||
// 说明:群组共享的“包含子页面”逻辑在 documents.ts 内已实现;这里做一个最小的祖先扫描即可。
|
||||
let parentId: string | null = doc.parent_id ?? null;
|
||||
for (let depth = 0; depth < 60 && parentId; depth += 1) {
|
||||
const parentShares = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", parentId))
|
||||
.collect();
|
||||
for (const row of parentShares) {
|
||||
if (row.include_descendants && groupIds.has(String(row.group_id))) {
|
||||
return "read";
|
||||
}
|
||||
}
|
||||
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 requireCanViewDocument(ctx: any, doc: any, userId: string) {
|
||||
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
|
||||
if (doc.user_id === userId) return;
|
||||
if (doc.access_scope === "public") return;
|
||||
const perm =
|
||||
(await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
|
||||
if (!perm) {
|
||||
throw new Error("无权限");
|
||||
}
|
||||
}
|
||||
|
||||
async function requireCanModerateDocument(ctx: any, doc: any, userId: string) {
|
||||
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
|
||||
if (doc.user_id === userId) return;
|
||||
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 pickUserLite = (user: any) => {
|
||||
if (!user) return null;
|
||||
return {
|
||||
id: String(user._id),
|
||||
name: (user.name as string | undefined) ?? null,
|
||||
image: (user.image as string | undefined) ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export const listThreadsByDocument = query({
|
||||
args: {
|
||||
documentId: v.string(),
|
||||
includeResolved: v.optional(v.boolean()),
|
||||
},
|
||||
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 requireCanViewDocument(ctx, doc, userId);
|
||||
|
||||
const threads = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", args.documentId))
|
||||
.collect();
|
||||
|
||||
const includeResolved = Boolean(args.includeResolved);
|
||||
const filtered = threads.filter((t: any) => includeResolved || t.resolved_at == null);
|
||||
filtered.sort((a: any, b: any) => String(b.last_activity_at).localeCompare(String(a.last_activity_at)));
|
||||
|
||||
const userIds = new Set<string>();
|
||||
filtered.forEach((t: any) => {
|
||||
if (t.created_by) userIds.add(String(t.created_by));
|
||||
if (t.last_comment_by) userIds.add(String(t.last_comment_by));
|
||||
if (t.resolved_by) userIds.add(String(t.resolved_by));
|
||||
});
|
||||
|
||||
const userById = new Map<string, any>();
|
||||
for (const uid of userIds) {
|
||||
const u = await ctx.db.get(uid as any);
|
||||
if (u) userById.set(uid, u);
|
||||
}
|
||||
|
||||
return filtered.map((t: any) => ({
|
||||
id: t.id,
|
||||
workspaceId: t.workspace_id,
|
||||
documentId: t.document_id,
|
||||
blockId: t.block_id ?? null,
|
||||
resolvedAt: t.resolved_at ?? null,
|
||||
resolvedBy: pickUserLite(userById.get(String(t.resolved_by ?? ""))),
|
||||
commentCount: Number(t.comment_count ?? 0),
|
||||
lastActivityAt: t.last_activity_at,
|
||||
lastCommentPreview: t.last_comment_preview ?? null,
|
||||
lastCommentBy: pickUserLite(userById.get(String(t.last_comment_by ?? ""))),
|
||||
createdAt: t.created_at,
|
||||
createdBy: pickUserLite(userById.get(String(t.created_by ?? ""))),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const listMessagesByThread = query({
|
||||
args: { threadId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const thread = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_thread_id", (q: any) => q.eq("id", args.threadId))
|
||||
.first();
|
||||
if (!thread) throw new Error("评论线程不存在");
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", thread.document_id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
await requireCanViewDocument(ctx, doc, userId);
|
||||
|
||||
const messages = await ctx.db
|
||||
.query("comment_messages")
|
||||
.withIndex("by_thread", (q: any) => q.eq("thread_id", args.threadId))
|
||||
.collect();
|
||||
messages.sort((a: any, b: any) => String(a.created_at).localeCompare(String(b.created_at)));
|
||||
|
||||
const userIds = new Set<string>();
|
||||
messages.forEach((m: any) => {
|
||||
if (m.created_by) userIds.add(String(m.created_by));
|
||||
if (m.deleted_by) userIds.add(String(m.deleted_by));
|
||||
});
|
||||
const userById = new Map<string, any>();
|
||||
for (const uid of userIds) {
|
||||
const u = await ctx.db.get(uid as any);
|
||||
if (u) userById.set(uid, u);
|
||||
}
|
||||
|
||||
return messages.map((m: any) => ({
|
||||
id: m.id,
|
||||
threadId: m.thread_id,
|
||||
documentId: m.document_id,
|
||||
blockId: m.block_id ?? null,
|
||||
parentId: m.parent_id ?? null,
|
||||
body: m.deleted_at ? "" : m.body,
|
||||
deletedAt: m.deleted_at ?? null,
|
||||
createdAt: m.created_at,
|
||||
createdBy: pickUserLite(userById.get(String(m.created_by ?? ""))),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export const createThread = mutation({
|
||||
args: {
|
||||
id: v.string(),
|
||||
documentId: v.string(),
|
||||
workspaceId: v.string(),
|
||||
blockId: v.optional(v.union(v.string(), v.null())),
|
||||
messageId: v.string(),
|
||||
body: 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 requireCanViewDocument(ctx, doc, userId);
|
||||
if (String(doc.workspace_id) !== String(args.workspaceId)) {
|
||||
throw new Error("workspaceId 不匹配");
|
||||
}
|
||||
|
||||
const body = args.body.trim();
|
||||
if (!body) throw new Error("评论内容不能为空");
|
||||
if (body.length > 4000) throw new Error("评论内容过长(最大 4000 字符)");
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.insert("comment_threads", {
|
||||
id: args.id,
|
||||
workspace_id: args.workspaceId,
|
||||
document_id: args.documentId,
|
||||
block_id: args.blockId ?? null,
|
||||
resolved_at: null,
|
||||
resolved_by: null,
|
||||
comment_count: 1,
|
||||
last_activity_at: ts,
|
||||
last_comment_preview: body.slice(0, 160),
|
||||
last_comment_by: userId,
|
||||
created_by: userId,
|
||||
created_at: ts,
|
||||
});
|
||||
|
||||
await ctx.db.insert("comment_messages", {
|
||||
id: args.messageId,
|
||||
thread_id: args.id,
|
||||
workspace_id: args.workspaceId,
|
||||
document_id: args.documentId,
|
||||
block_id: args.blockId ?? null,
|
||||
parent_id: null,
|
||||
body,
|
||||
created_by: userId,
|
||||
created_at: ts,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const reply = mutation({
|
||||
args: {
|
||||
messageId: v.string(),
|
||||
threadId: v.string(),
|
||||
body: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const thread = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_thread_id", (q: any) => q.eq("id", args.threadId))
|
||||
.first();
|
||||
if (!thread) throw new Error("评论线程不存在");
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", thread.document_id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
await requireCanViewDocument(ctx, doc, userId);
|
||||
|
||||
const body = args.body.trim();
|
||||
if (!body) throw new Error("评论内容不能为空");
|
||||
if (body.length > 4000) throw new Error("评论内容过长(最大 4000 字符)");
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.insert("comment_messages", {
|
||||
id: args.messageId,
|
||||
thread_id: args.threadId,
|
||||
workspace_id: thread.workspace_id,
|
||||
document_id: thread.document_id,
|
||||
block_id: thread.block_id ?? null,
|
||||
parent_id: null,
|
||||
body,
|
||||
created_by: userId,
|
||||
created_at: ts,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
});
|
||||
|
||||
await ctx.db.patch(thread._id, {
|
||||
comment_count: Number(thread.comment_count ?? 0) + 1,
|
||||
last_activity_at: ts,
|
||||
last_comment_preview: body.slice(0, 160),
|
||||
last_comment_by: userId,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const setResolved = mutation({
|
||||
args: { threadId: v.string(), resolved: v.boolean() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const thread = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_thread_id", (q: any) => q.eq("id", args.threadId))
|
||||
.first();
|
||||
if (!thread) throw new Error("评论线程不存在");
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", thread.document_id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
|
||||
// 说明:解决/取消解决属于“管理”行为,要求可编辑权限,避免只读用户随意改状态。
|
||||
await requireCanModerateDocument(ctx, doc, userId);
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(thread._id, {
|
||||
resolved_at: args.resolved ? ts : null,
|
||||
resolved_by: args.resolved ? userId : null,
|
||||
last_activity_at: ts,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const editMessage = mutation({
|
||||
args: { messageId: v.string(), body: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const message = await ctx.db
|
||||
.query("comment_messages")
|
||||
.withIndex("by_message_id", (q: any) => q.eq("id", args.messageId))
|
||||
.first();
|
||||
if (!message) throw new Error("评论不存在");
|
||||
if (message.deleted_at != null) throw new Error("该评论已删除");
|
||||
if (String(message.created_by) !== String(userId)) throw new Error("无权限");
|
||||
|
||||
const thread = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_thread_id", (q: any) => q.eq("id", message.thread_id))
|
||||
.first();
|
||||
if (!thread) throw new Error("评论线程不存在");
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", message.document_id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
await requireCanViewDocument(ctx, doc, userId);
|
||||
|
||||
const body = args.body.trim();
|
||||
if (!body) throw new Error("评论内容不能为空");
|
||||
if (body.length > 4000) throw new Error("评论内容过长(最大 4000 字符)");
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(message._id, { body });
|
||||
|
||||
const messages = await ctx.db
|
||||
.query("comment_messages")
|
||||
.withIndex("by_thread", (q: any) => q.eq("thread_id", message.thread_id))
|
||||
.collect();
|
||||
messages.sort((a: any, b: any) => String(b.created_at).localeCompare(String(a.created_at)));
|
||||
const latestAlive = messages.find((m: any) => m.deleted_at == null) ?? null;
|
||||
|
||||
await ctx.db.patch(thread._id, {
|
||||
last_activity_at: ts,
|
||||
...(latestAlive && String(latestAlive.id) === String(message.id)
|
||||
? { last_comment_preview: body.slice(0, 160), last_comment_by: userId }
|
||||
: {}),
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteMessage = mutation({
|
||||
args: { messageId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const message = await ctx.db
|
||||
.query("comment_messages")
|
||||
.withIndex("by_message_id", (q: any) => q.eq("id", args.messageId))
|
||||
.first();
|
||||
if (!message) throw new Error("评论不存在");
|
||||
if (message.deleted_at != null) return { ok: true };
|
||||
|
||||
const thread = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_thread_id", (q: any) => q.eq("id", message.thread_id))
|
||||
.first();
|
||||
if (!thread) throw new Error("评论线程不存在");
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q: any) => q.eq("id", message.document_id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
|
||||
if (String(message.created_by) === String(userId)) {
|
||||
await requireCanViewDocument(ctx, doc, userId);
|
||||
} else {
|
||||
await requireCanModerateDocument(ctx, doc, userId);
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(message._id, { deleted_at: ts, deleted_by: userId });
|
||||
|
||||
const messages = await ctx.db
|
||||
.query("comment_messages")
|
||||
.withIndex("by_thread", (q: any) => q.eq("thread_id", message.thread_id))
|
||||
.collect();
|
||||
messages.sort((a: any, b: any) => String(b.created_at).localeCompare(String(a.created_at)));
|
||||
const latestAlive = messages.find((m: any) => m.deleted_at == null) ?? null;
|
||||
|
||||
await ctx.db.patch(thread._id, {
|
||||
last_activity_at: ts,
|
||||
last_comment_preview: latestAlive ? String(latestAlive.body ?? "").slice(0, 160) : null,
|
||||
last_comment_by: latestAlive ? String(latestAlive.created_by ?? "") : null,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cronJobs } from "convex/server";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
const crons = cronJobs();
|
||||
|
||||
// 每周定时清理(保留最近 7 天):authRefreshTokens / authSessions / jobs
|
||||
// 说明:使用 UTC 时间,避免本地时区/夏令时导致触发不稳定。
|
||||
crons.weekly(
|
||||
"cleanup_weekly_keep_last_7_days",
|
||||
{ dayOfWeek: "sunday", hourUTC: 3, minuteUTC: 0 },
|
||||
(internal as any).maintenance.cleanupWeekly,
|
||||
);
|
||||
|
||||
export default crons;
|
||||
|
||||
@@ -178,6 +178,154 @@ async function purgeDocumentShareRelations(ctx: any, workspaceId: string, docume
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeDocumentRelatedData(ctx: any, workspaceId: string, documentIds: string[]) {
|
||||
const uniqueDocIds = Array.from(new Set(documentIds.filter(Boolean)));
|
||||
if (uniqueDocIds.length === 0) return;
|
||||
|
||||
// 1) 思维导图
|
||||
for (const docId of uniqueDocIds) {
|
||||
const rows = await ctx.db
|
||||
.query("mindmaps")
|
||||
.withIndex("by_doc_mindmap", (q: any) => q.eq("document_id", docId))
|
||||
.collect();
|
||||
for (const r of rows) {
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 附件(含底层 storage 文件)
|
||||
const assets: any[] = [];
|
||||
for (const docId of uniqueDocIds) {
|
||||
const rows = await ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
|
||||
.collect();
|
||||
assets.push(...rows);
|
||||
}
|
||||
|
||||
if (assets.length) {
|
||||
const assetIdsToDelete = new Set<string>(assets.map((a) => String(a.id)));
|
||||
const byStorage = new Map<string, string[]>();
|
||||
for (const a of assets) {
|
||||
const sid = (a.storage_id as any) ?? null;
|
||||
if (!sid) continue;
|
||||
const list = byStorage.get(String(sid)) ?? [];
|
||||
list.push(String(a.id));
|
||||
byStorage.set(String(sid), list);
|
||||
}
|
||||
|
||||
for (const [sid] of byStorage.entries()) {
|
||||
const refs = await ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_storage_id", (q: any) => q.eq("storage_id", sid as any))
|
||||
.collect();
|
||||
// 说明:只要仍有“未清理”的引用(包括仍在垃圾桶但可恢复的记录),就不要删底层文件。
|
||||
const otherAlive = refs.some((r: any) => !assetIdsToDelete.has(String(r.id)) && !r.purged_at);
|
||||
if (!otherAlive) {
|
||||
try {
|
||||
await ctx.storage.delete(sid as any);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const a of assets) {
|
||||
await ctx.db.delete(a._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 在线表格(含行数据)
|
||||
for (const docId of uniqueDocIds) {
|
||||
const tables = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
|
||||
.collect();
|
||||
for (const table of tables) {
|
||||
const rows = await ctx.db
|
||||
.query("document_table_rows")
|
||||
.withIndex("by_table", (q: any) => q.eq("table_id", table.id))
|
||||
.collect();
|
||||
for (const r of rows) {
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
await ctx.db.delete(table._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 评论
|
||||
for (const docId of uniqueDocIds) {
|
||||
const msgs = await ctx.db
|
||||
.query("comment_messages")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
|
||||
.collect();
|
||||
for (const m of msgs) {
|
||||
await ctx.db.delete(m._id);
|
||||
}
|
||||
|
||||
const threads = await ctx.db
|
||||
.query("comment_threads")
|
||||
.withIndex("by_document", (q: any) => q.eq("document_id", docId))
|
||||
.collect();
|
||||
for (const t of threads) {
|
||||
await ctx.db.delete(t._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) 页面引用/反链(source/target 任一命中即删除)
|
||||
const deletedRefIds = new Set<string>();
|
||||
for (const docId of uniqueDocIds) {
|
||||
const bySource = await ctx.db
|
||||
.query("page_references")
|
||||
.withIndex("by_workspace_source", (q: any) => q.eq("workspace_id", workspaceId).eq("source_page_id", docId))
|
||||
.collect();
|
||||
for (const r of bySource) {
|
||||
const id = String(r._id);
|
||||
if (deletedRefIds.has(id)) continue;
|
||||
deletedRefIds.add(id);
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
|
||||
const byTarget = await ctx.db
|
||||
.query("page_references")
|
||||
.withIndex("by_workspace_target", (q: any) => q.eq("workspace_id", workspaceId).eq("target_page_id", docId))
|
||||
.collect();
|
||||
for (const r of byTarget) {
|
||||
const id = String(r._id);
|
||||
if (deletedRefIds.has(id)) continue;
|
||||
deletedRefIds.add(id);
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 6) 收藏(避免残留用户数据)
|
||||
for (const docId of uniqueDocIds) {
|
||||
const stars = await ctx.db
|
||||
.query("document_stars")
|
||||
.withIndex("by_workspace_document_user", (q: any) => q.eq("workspace_id", workspaceId).eq("document_id", docId))
|
||||
.collect();
|
||||
for (const s of stars) {
|
||||
await ctx.db.delete(s._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 7) 最近访问(避免“最近/历史”里出现幽灵页面)
|
||||
// 说明:user_recent_pages 当前缺少按 document_id 的索引,这里先全表扫描再过滤。
|
||||
// 若未来数据量变大,再考虑加 index("by_document", ["document_id"]) 或按 workspace/user 拆分索引。
|
||||
const recents = await ctx.db.query("user_recent_pages").collect();
|
||||
if (recents.length) {
|
||||
const docIdSet = new Set(uniqueDocIds);
|
||||
for (const r of recents) {
|
||||
if (!docIdSet.has(String((r as any).document_id ?? ""))) continue;
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:如果未来新增了其它 “document_id 外键表”,这里可以继续补充;
|
||||
// 当前先把最容易产生垃圾、且已出现历史堆积的表(media_assets/mindmaps/表格/评论/引用/收藏)清理掉。
|
||||
// 同时,避免误删“非 document_id 维度”的用户行为表(如 jobs 等),后续如确认需要可再补齐。
|
||||
}
|
||||
|
||||
export const getMeta = query({
|
||||
args: { id: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
@@ -228,9 +376,17 @@ export const getMeta = query({
|
||||
show_structure: doc.show_structure ?? null,
|
||||
protect_editing: doc.protect_editing ?? null,
|
||||
show_word_count: doc.show_word_count ?? null,
|
||||
collapse_backlinks: (doc as any).collapse_backlinks ?? null,
|
||||
page_font: (doc as any).page_font ?? null,
|
||||
layout_density: (doc as any).layout_density ?? null,
|
||||
hide_child_pages: (doc as any).hide_child_pages ?? null,
|
||||
show_block_ref_count: (doc as any).show_block_ref_count ?? null,
|
||||
embed_default_block_id: (doc as any).embed_default_block_id ?? null,
|
||||
word_count: doc.word_count ?? null,
|
||||
character_count: doc.character_count ?? null,
|
||||
block_count: doc.block_count ?? null,
|
||||
todo_total_count: (doc as any).todo_total_count ?? null,
|
||||
todo_done_count: (doc as any).todo_done_count ?? null,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -674,9 +830,17 @@ export const create = mutation({
|
||||
show_structure: false,
|
||||
protect_editing: false,
|
||||
show_word_count: true,
|
||||
collapse_backlinks: false,
|
||||
page_font: "default",
|
||||
layout_density: "normal",
|
||||
hide_child_pages: false,
|
||||
show_block_ref_count: false,
|
||||
embed_default_block_id: null,
|
||||
word_count: 0,
|
||||
character_count: 0,
|
||||
block_count: 0,
|
||||
todo_total_count: 0,
|
||||
todo_done_count: 0,
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
deleted_at: null,
|
||||
@@ -751,6 +915,32 @@ export const updateTitle = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const setTemplate = mutation({
|
||||
args: { id: v.string(), isTemplate: v.boolean() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
|
||||
const doc = await ctx.db
|
||||
.query("documents")
|
||||
.withIndex("by_document_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
if (!doc) throw new Error("页面不存在");
|
||||
if (doc.deleted_at != null) throw new Error("页面不存在");
|
||||
await requireWorkspaceMember(ctx, doc.workspace_id, userId);
|
||||
|
||||
if (doc.user_id !== userId) {
|
||||
if (doc.access_scope === "public") throw new Error("无权限");
|
||||
const perm =
|
||||
(await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId));
|
||||
if (perm !== "edit") throw new Error("无权限");
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(doc._id, { is_template: args.isTemplate, updated_at: ts });
|
||||
return { ok: true, updated_at: ts };
|
||||
},
|
||||
});
|
||||
|
||||
export const move = mutation({
|
||||
args: {
|
||||
id: v.string(),
|
||||
@@ -941,6 +1131,7 @@ export const purge = mutation({
|
||||
const subtree = collectSubtree(owned, doc.id);
|
||||
|
||||
// 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。
|
||||
await purgeDocumentRelatedData(ctx, doc.workspace_id, subtree.map((d) => d.id));
|
||||
for (const item of subtree) {
|
||||
await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id);
|
||||
await ctx.db.delete(item._id);
|
||||
@@ -971,6 +1162,7 @@ export const emptyTrashByWorkspace = mutation({
|
||||
.collect();
|
||||
|
||||
const toDelete = docs.filter((d) => d.user_id === userId && d.deleted_at != null);
|
||||
await purgeDocumentRelatedData(ctx, args.workspaceId, toDelete.map((d) => d.id));
|
||||
for (const d of toDelete) {
|
||||
await purgeDocumentShareRelations(ctx, args.workspaceId, d.id);
|
||||
await ctx.db.delete(d._id);
|
||||
@@ -991,6 +1183,12 @@ export const updateOptions = mutation({
|
||||
showStructure: v.optional(v.boolean()),
|
||||
protectEditing: v.optional(v.boolean()),
|
||||
showWordCount: v.optional(v.boolean()),
|
||||
collapseBacklinks: v.optional(v.boolean()),
|
||||
pageFont: v.optional(v.union(v.literal("default"), v.literal("song"), v.literal("kai"))),
|
||||
layoutDensity: v.optional(v.union(v.literal("compact"), v.literal("normal"), v.literal("spacious"))),
|
||||
hideChildPages: v.optional(v.boolean()),
|
||||
showBlockRefCount: v.optional(v.boolean()),
|
||||
embedDefaultBlockId: v.optional(v.union(v.string(), v.null())),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
@@ -1019,6 +1217,15 @@ export const updateOptions = mutation({
|
||||
if (typeof args.options.showStructure === "boolean") patch.show_structure = args.options.showStructure;
|
||||
if (typeof args.options.protectEditing === "boolean") patch.protect_editing = args.options.protectEditing;
|
||||
if (typeof args.options.showWordCount === "boolean") patch.show_word_count = args.options.showWordCount;
|
||||
if (typeof args.options.collapseBacklinks === "boolean") patch.collapse_backlinks = args.options.collapseBacklinks;
|
||||
if (typeof (args.options as any).pageFont === "string") patch.page_font = (args.options as any).pageFont;
|
||||
if (typeof (args.options as any).layoutDensity === "string") patch.layout_density = (args.options as any).layoutDensity;
|
||||
if (typeof (args.options as any).hideChildPages === "boolean") patch.hide_child_pages = (args.options as any).hideChildPages;
|
||||
if (typeof (args.options as any).showBlockRefCount === "boolean")
|
||||
patch.show_block_ref_count = (args.options as any).showBlockRefCount;
|
||||
if (typeof (args.options as any).embedDefaultBlockId === "string" || (args.options as any).embedDefaultBlockId === null) {
|
||||
patch.embed_default_block_id = (args.options as any).embedDefaultBlockId;
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new Error("缺少可更新的选项");
|
||||
@@ -1036,6 +1243,8 @@ export const updateStats = mutation({
|
||||
wordCount: v.number(),
|
||||
characterCount: v.number(),
|
||||
blockCount: v.number(),
|
||||
todoTotal: v.optional(v.number()),
|
||||
todoDone: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await requireUserId(ctx);
|
||||
@@ -1058,6 +1267,8 @@ export const updateStats = mutation({
|
||||
word_count: args.wordCount,
|
||||
character_count: args.characterCount,
|
||||
block_count: args.blockCount,
|
||||
todo_total_count: typeof args.todoTotal === "number" ? args.todoTotal : (doc as any).todo_total_count ?? 0,
|
||||
todo_done_count: typeof args.todoDone === "number" ? args.todoDone : (doc as any).todo_done_count ?? 0,
|
||||
updated_at: ts,
|
||||
});
|
||||
return { ok: true, updated_at: ts };
|
||||
@@ -1113,9 +1324,17 @@ export const duplicate = mutation({
|
||||
show_structure: source.show_structure ?? false,
|
||||
protect_editing: source.protect_editing ?? false,
|
||||
show_word_count: source.show_word_count ?? true,
|
||||
collapse_backlinks: (source as any).collapse_backlinks ?? false,
|
||||
page_font: (source as any).page_font ?? "default",
|
||||
layout_density: (source as any).layout_density ?? "normal",
|
||||
hide_child_pages: (source as any).hide_child_pages ?? false,
|
||||
show_block_ref_count: (source as any).show_block_ref_count ?? false,
|
||||
embed_default_block_id: (source as any).embed_default_block_id ?? null,
|
||||
word_count: source.word_count ?? 0,
|
||||
character_count: source.character_count ?? 0,
|
||||
block_count: source.block_count ?? 0,
|
||||
todo_total_count: (source as any).todo_total_count ?? 0,
|
||||
todo_done_count: (source as any).todo_done_count ?? 0,
|
||||
|
||||
created_at: ts,
|
||||
updated_at: ts,
|
||||
|
||||
@@ -132,11 +132,18 @@ export const run = internalAction({
|
||||
const title = meta.title ?? "无标题";
|
||||
const text = extractTextFromDocumentContent(contentRes?.content ?? null);
|
||||
const fileSource = `document:${documentId}`;
|
||||
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
const ingest = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
if (ingest.skipped) {
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "document", documentId, skipped: true, reason: ingest.reason ?? "skipped" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "document", documentId, trackId },
|
||||
result: { ok: true, kind: "document", documentId, trackId: ingest.trackId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -165,11 +172,18 @@ export const run = internalAction({
|
||||
const title = docMeta.title ?? "无标题";
|
||||
const text = extractTextFromMindmapData(mindmapRes.data ?? null);
|
||||
const fileSource = `mindmap:${docId}:${mindmapId}`;
|
||||
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
const ingest = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
if (ingest.skipped) {
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "mindmap", docId, mindmapId, skipped: true, reason: ingest.reason ?? "skipped" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "mindmap", docId, mindmapId, trackId },
|
||||
result: { ok: true, kind: "mindmap", docId, mindmapId, trackId: ingest.trackId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -191,11 +205,18 @@ export const run = internalAction({
|
||||
return;
|
||||
}
|
||||
const fileSource = `media:${assetId}`;
|
||||
const { trackId } = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
const ingest = await lightragIngestText({ fileSource, text: `# ${title}\n\n${text}` });
|
||||
if (ingest.skipped) {
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "media_asset", assetId, skipped: true, reason: ingest.reason ?? "skipped" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.runMutation(internal.jobs.finishSuccess, {
|
||||
id: args.id,
|
||||
result: { ok: true, kind: "media_asset", assetId, trackId },
|
||||
result: { ok: true, kind: "media_asset", assetId, trackId: ingest.trackId },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
async function cleanupByCreationTime(ctx: any, table: string, cutoffMs: number, maxDeletes: number, dryRun: boolean) {
|
||||
const batchSize = 200;
|
||||
let deleted = 0;
|
||||
|
||||
while (deleted < maxDeletes) {
|
||||
const take = Math.min(batchSize, maxDeletes - deleted);
|
||||
const rows = await ctx.db.query(table as any).order("asc").take(take);
|
||||
if (!rows.length) break;
|
||||
|
||||
let reachedNewer = false;
|
||||
for (const row of rows) {
|
||||
const created = typeof row._creationTime === "number" ? row._creationTime : 0;
|
||||
if (created >= cutoffMs) {
|
||||
reachedNewer = true;
|
||||
break;
|
||||
}
|
||||
if (!dryRun) {
|
||||
await ctx.db.delete(row._id);
|
||||
}
|
||||
deleted += 1;
|
||||
if (deleted >= maxDeletes) break;
|
||||
}
|
||||
|
||||
if (reachedNewer) break;
|
||||
}
|
||||
|
||||
// 说明:若本次达到上限,可能还有更多旧数据;由调用方决定是否继续调度。
|
||||
return deleted;
|
||||
}
|
||||
|
||||
export const cleanupWeekly = internalMutation({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
maxDeletes: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const dryRun = Boolean(args.dryRun);
|
||||
const maxDeletes = Math.max(100, Math.min(50_000, Math.floor(args.maxDeletes ?? 20_000)));
|
||||
|
||||
const cutoffMs = Date.now() - WEEK_MS;
|
||||
|
||||
// 说明:
|
||||
// - authRefreshTokens/authSessions:来自 @convex-dev/auth,按“只保留最近 7 天”的要求直接清理旧记录。
|
||||
// - jobs:异步任务表,保留最近 7 天即可(避免长期堆积)。
|
||||
const perTable = Math.max(100, Math.floor(maxDeletes / 3));
|
||||
|
||||
const deletedAuthRefreshTokens = await cleanupByCreationTime(
|
||||
ctx,
|
||||
"authRefreshTokens",
|
||||
cutoffMs,
|
||||
perTable,
|
||||
dryRun,
|
||||
);
|
||||
const deletedAuthSessions = await cleanupByCreationTime(ctx, "authSessions", cutoffMs, perTable, dryRun);
|
||||
const deletedJobs = await cleanupByCreationTime(ctx, "jobs", cutoffMs, maxDeletes - perTable * 2, dryRun);
|
||||
|
||||
// 若达到上限,兜底再跑一轮(避免一次删太多导致超时)
|
||||
const hitLimit =
|
||||
deletedAuthRefreshTokens >= perTable || deletedAuthSessions >= perTable || deletedJobs >= maxDeletes - perTable * 2;
|
||||
if (!dryRun && hitLimit) {
|
||||
await ctx.scheduler.runAfter(60_000, (internal as any).maintenance.cleanupWeekly, { dryRun: false, maxDeletes });
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
dryRun,
|
||||
cutoffMs,
|
||||
deleted: {
|
||||
authRefreshTokens: deletedAuthRefreshTokens,
|
||||
authSessions: deletedAuthSessions,
|
||||
jobs: deletedJobs,
|
||||
},
|
||||
rescheduled: !dryRun && hitLimit,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { internalMutation, mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { enqueueExtractMediaAssetTextJob, enqueueIngestMediaAssetJob } from "./_utils/ingestJobs";
|
||||
import type { MutationCtx, QueryCtx } from "./_generated/server";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) {
|
||||
const membership = await ctx.db
|
||||
@@ -15,6 +16,41 @@ async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string
|
||||
return membership;
|
||||
}
|
||||
|
||||
function resolveGraceSeconds(): number {
|
||||
const raw = process.env.DELETE_GRACE_SECONDS ?? process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? "600";
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 600;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function isExpiredForPurge(deletedAt: unknown, expiredDeletedAt: string): boolean {
|
||||
if (!deletedAt || typeof deletedAt !== "string") return false;
|
||||
// 说明:deleted_at 使用 ISO 字符串,按字典序比较即可满足时间先后。
|
||||
return deletedAt <= expiredDeletedAt;
|
||||
}
|
||||
|
||||
async function deleteMediaAssetRow(ctx: any, row: any, deletingIds: Set<string>) {
|
||||
const storageId = (row.storage_id as any) ?? null;
|
||||
if (storageId) {
|
||||
const refs = await ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_storage_id", (q: any) => q.eq("storage_id", storageId))
|
||||
.collect();
|
||||
// 说明:只要仍有“未清理”的引用(包括仍在垃圾桶但可恢复的记录),就不要删底层文件。
|
||||
const otherAlive = refs.some((r: any) => !deletingIds.has(String(r.id)) && !r.purged_at);
|
||||
if (!otherAlive) {
|
||||
try {
|
||||
await ctx.storage.delete(storageId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
await ctx.db.delete(row._id);
|
||||
}
|
||||
|
||||
function shouldExtractAttachmentText(args: {
|
||||
assetType?: string | null;
|
||||
mimeType?: string | null;
|
||||
@@ -340,6 +376,8 @@ export const patchById = mutation({
|
||||
if (!row) throw new Error("资源不存在");
|
||||
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
|
||||
|
||||
const prevDeletedAt = (row as any).deleted_at ?? null;
|
||||
|
||||
const next = { ...(args.patch as Record<string, unknown>), updated_at: nowIso() };
|
||||
await ctx.db.patch(row._id, next);
|
||||
|
||||
@@ -351,6 +389,21 @@ export const patchById = mutation({
|
||||
if (nextOcrStatus === "completed" && typeof nextOcrText === "string" && nextOcrText.trim()) {
|
||||
await enqueueIngestMediaAssetJob(ctx, { userId: args.userId, assetId: args.id, debounceMs: 1200 });
|
||||
}
|
||||
|
||||
// 说明:当附件进入垃圾桶时,自动调度“到期彻底删除”,避免长期堆积垃圾数据。
|
||||
const nextDeletedAt =
|
||||
patch.deleted_at === null
|
||||
? null
|
||||
: typeof patch.deleted_at === "string"
|
||||
? patch.deleted_at
|
||||
: ((row as any).deleted_at ?? null);
|
||||
if (prevDeletedAt == null && typeof nextDeletedAt === "string" && nextDeletedAt.trim()) {
|
||||
const graceMs = (resolveGraceSeconds() + 5) * 1000;
|
||||
await ctx.scheduler.runAfter(graceMs, internal.mediaAssets.purgeIfExpired, {
|
||||
id: args.id,
|
||||
deletedAt: nextDeletedAt,
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
},
|
||||
});
|
||||
@@ -463,34 +516,53 @@ export const purgeById = mutation({
|
||||
if (!row) throw new Error("未找到附件");
|
||||
await assertWorkspaceMember(ctx, args.userId, row.workspace_id);
|
||||
|
||||
if (row.purged_at) {
|
||||
return { ok: true, alreadyPurged: true };
|
||||
// 仅允许清理“已进入垃圾桶且超出宽限期”的附件;清空垃圾桶会传入一个很靠后的 expiredDeletedAt 以强制清理。
|
||||
if (!isExpiredForPurge((row as any).deleted_at, args.expiredDeletedAt)) {
|
||||
return { ok: false, reason: "not_expired" as const };
|
||||
}
|
||||
await deleteMediaAssetRow(ctx, row, new Set([String(row.id)]));
|
||||
return { ok: true, deleted: 1 };
|
||||
},
|
||||
});
|
||||
|
||||
export const purgeIfExpired = internalMutation({
|
||||
args: { id: v.string(), deletedAt: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const row = await ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_asset_id", (q) => q.eq("id", args.id))
|
||||
.first();
|
||||
|
||||
if (!row) {
|
||||
return { ok: true, deleted: false, reason: "not_found" as const };
|
||||
}
|
||||
|
||||
const storageId = (row.storage_id as any) ?? null;
|
||||
if (storageId) {
|
||||
const refs = await ctx.db
|
||||
.query("media_assets")
|
||||
.withIndex("by_storage_id", (q) => q.eq("storage_id", storageId))
|
||||
.collect();
|
||||
const otherAlive = refs.some((r) => r.id !== row.id && !r.purged_at);
|
||||
if (!otherAlive) {
|
||||
await ctx.storage.delete(storageId);
|
||||
if ((row as any).deleted_at == null) {
|
||||
return { ok: true, deleted: false, reason: "restored" as const };
|
||||
}
|
||||
|
||||
if ((row as any).deleted_at !== args.deletedAt) {
|
||||
// 删除时间不一致:说明已被恢复/重新删除过,旧调度作废
|
||||
return { ok: true, deleted: false, reason: "changed" as const };
|
||||
}
|
||||
|
||||
const graceSeconds = resolveGraceSeconds();
|
||||
const deletedTs = Date.parse(String((row as any).deleted_at));
|
||||
if (Number.isFinite(deletedTs)) {
|
||||
const elapsedMs = Date.now() - deletedTs;
|
||||
const graceMs = graceSeconds * 1000;
|
||||
if (elapsedMs < graceMs) {
|
||||
const remainingMs = Math.max(1000, graceMs - elapsedMs + 5000);
|
||||
await ctx.scheduler.runAfter(remainingMs, internal.mediaAssets.purgeIfExpired, {
|
||||
id: args.id,
|
||||
deletedAt: args.deletedAt,
|
||||
});
|
||||
return { ok: true, deleted: false, rescheduled: true, remainingMs };
|
||||
}
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(row._id, {
|
||||
deleted_at: args.expiredDeletedAt,
|
||||
deleted_by: args.userId,
|
||||
purged_at: ts,
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
storage_id: null,
|
||||
updated_at: ts,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
await deleteMediaAssetRow(ctx, row, new Set([String(row.id)]));
|
||||
return { ok: true, deleted: true };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -505,7 +577,13 @@ export const emptyTrashByWorkspace = mutation({
|
||||
.order("desc")
|
||||
.take(5000);
|
||||
|
||||
const targets = rows.filter((r) => Boolean(r.deleted_at) && !r.purged_at);
|
||||
const targets = rows.filter((r) => {
|
||||
// 说明:
|
||||
// - 清空垃圾桶是强制操作,route 会传一个很靠后的 expiredDeletedAt
|
||||
// - 这里也顺手清掉历史 purged_at 墓碑(避免长期堆积)
|
||||
if (r.purged_at) return true;
|
||||
return isExpiredForPurge((r as any).deleted_at, args.expiredDeletedAt);
|
||||
});
|
||||
if (targets.length === 0) {
|
||||
return { ok: true, updated: 0 };
|
||||
}
|
||||
@@ -525,8 +603,10 @@ export const emptyTrashByWorkspace = mutation({
|
||||
.query("media_assets")
|
||||
.withIndex("by_storage_id", (q) => q.eq("storage_id", sid as any))
|
||||
.collect();
|
||||
const alive = refs.some((r) => !r.purged_at && !r.deleted_at);
|
||||
if (!alive) {
|
||||
const deletingIds = new Set(byStorage.get(sid) ?? []);
|
||||
// 说明:只要仍有“未清理”的引用(包括仍在垃圾桶但可恢复的记录),就不要删底层文件。
|
||||
const otherAlive = refs.some((r) => !deletingIds.has(r.id) && !r.purged_at);
|
||||
if (!otherAlive) {
|
||||
try {
|
||||
await ctx.storage.delete(sid as any);
|
||||
} catch {
|
||||
@@ -535,19 +615,10 @@ export const emptyTrashByWorkspace = mutation({
|
||||
}
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
for (const r of targets) {
|
||||
await ctx.db.patch(r._id, {
|
||||
deleted_at: args.expiredDeletedAt,
|
||||
deleted_by: args.userId,
|
||||
purged_at: ts,
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
storage_id: null,
|
||||
updated_at: ts,
|
||||
});
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
|
||||
return { ok: true, updated: targets.length };
|
||||
return { ok: true, updated: targets.length, deleted: targets.length };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import { internalQuery, mutation, query } from "./_generated/server";
|
||||
import { internalMutation, internalQuery, mutation, query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { nowIso } from "./_utils/time";
|
||||
import { enqueueIngestMindmapJob } from "./_utils/ingestJobs";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
const defaultMindmapData = {
|
||||
data: { text: "中心主题" },
|
||||
children: [],
|
||||
};
|
||||
|
||||
function resolveGraceSeconds(): number {
|
||||
const raw = process.env.DELETE_GRACE_SECONDS ?? process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ?? "600";
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 600;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
async function requireUserId(ctx: any): Promise<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) {
|
||||
@@ -195,12 +205,71 @@ export const softDelete = mutation({
|
||||
return { ok: true, moved: 0 };
|
||||
}
|
||||
|
||||
if (existing.deleted_at != null) {
|
||||
// 已在垃圾桶:保持幂等,避免重复删除导致 updated_at 抖动/重复调度
|
||||
return { ok: true, moved: 0, deleted_at: existing.deleted_at };
|
||||
}
|
||||
|
||||
const ts = nowIso();
|
||||
await ctx.db.patch(existing._id, { deleted_at: ts, deleted_by: userId, updated_at: ts });
|
||||
|
||||
// 10 分钟宽限(可恢复);到期自动清理(彻底删除)。
|
||||
// 说明:这里用 scheduler.runAfter 做“按条目调度”,无需全局 cron。
|
||||
const graceMs = (resolveGraceSeconds() + 5) * 1000;
|
||||
await ctx.scheduler.runAfter(graceMs, internal.mindmaps.purgeIfExpired, {
|
||||
docId: args.docId,
|
||||
mindmapId,
|
||||
deletedAt: ts,
|
||||
});
|
||||
return { ok: true, moved: 1, deleted_at: ts };
|
||||
},
|
||||
});
|
||||
|
||||
export const purgeIfExpired = internalMutation({
|
||||
args: { docId: v.string(), mindmapId: v.string(), deletedAt: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const mindmapId = normalizeMindmapId(args.docId, args.mindmapId);
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("mindmaps")
|
||||
.withIndex("by_doc_mindmap", (q) => q.eq("document_id", args.docId).eq("mindmap_id", mindmapId))
|
||||
.first();
|
||||
|
||||
if (!existing) {
|
||||
return { ok: true, deleted: false, reason: "not_found" as const };
|
||||
}
|
||||
|
||||
if (existing.deleted_at == null) {
|
||||
return { ok: true, deleted: false, reason: "restored" as const };
|
||||
}
|
||||
|
||||
if (existing.deleted_at !== args.deletedAt) {
|
||||
// 删除时间不一致:说明已被恢复/重新删除过,旧调度作废
|
||||
return { ok: true, deleted: false, reason: "changed" as const };
|
||||
}
|
||||
|
||||
const graceSeconds = resolveGraceSeconds();
|
||||
const deletedTs = Date.parse(existing.deleted_at);
|
||||
if (Number.isFinite(deletedTs)) {
|
||||
const elapsedMs = Date.now() - deletedTs;
|
||||
const graceMs = graceSeconds * 1000;
|
||||
if (elapsedMs < graceMs) {
|
||||
// 兜底:调度延迟/时钟偏差时,重新排一次剩余时间
|
||||
const remainingMs = Math.max(1000, graceMs - elapsedMs + 5000);
|
||||
await ctx.scheduler.runAfter(remainingMs, internal.mindmaps.purgeIfExpired, {
|
||||
docId: args.docId,
|
||||
mindmapId,
|
||||
deletedAt: args.deletedAt,
|
||||
});
|
||||
return { ok: true, deleted: false, rescheduled: true, remainingMs };
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db.delete(existing._id);
|
||||
return { ok: true, deleted: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const restore = mutation({
|
||||
args: { docId: v.string(), mindmapId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
|
||||
@@ -52,11 +52,19 @@ export default defineSchema({
|
||||
show_structure: v.union(v.boolean(), v.null()),
|
||||
protect_editing: v.union(v.boolean(), v.null()),
|
||||
show_word_count: v.union(v.boolean(), v.null()),
|
||||
collapse_backlinks: v.optional(v.union(v.boolean(), v.null())),
|
||||
page_font: v.optional(v.union(v.literal("default"), v.literal("song"), v.literal("kai"), v.null())),
|
||||
layout_density: v.optional(v.union(v.literal("compact"), v.literal("normal"), v.literal("spacious"), v.null())),
|
||||
hide_child_pages: v.optional(v.union(v.boolean(), v.null())),
|
||||
show_block_ref_count: v.optional(v.union(v.boolean(), v.null())),
|
||||
embed_default_block_id: v.optional(v.union(v.string(), v.null())),
|
||||
|
||||
// 统计信息(由客户端编辑器计算后回写)
|
||||
word_count: v.union(v.number(), v.null()),
|
||||
character_count: v.union(v.number(), v.null()),
|
||||
block_count: v.union(v.number(), v.null()),
|
||||
todo_total_count: v.optional(v.union(v.number(), v.null())),
|
||||
todo_done_count: v.optional(v.union(v.number(), v.null())),
|
||||
|
||||
// 说明:当前文档内容结构还在演进,先用 any 承接(与 Supabase Json 一致的宽松形态)。
|
||||
content: v.any(),
|
||||
@@ -203,6 +211,10 @@ export default defineSchema({
|
||||
view_preferences: v.any(),
|
||||
snapshot: v.optional(v.any()), // DocumentTableSnapshot: { rows?, luckysheet? }
|
||||
is_archived: v.boolean(),
|
||||
// 垃圾桶字段:与附件/思维导图的删除语义保持一致(允许历史数据缺字段)
|
||||
deleted_at: v.optional(v.union(v.string(), v.null())),
|
||||
deleted_by: v.optional(v.union(v.string(), v.null())),
|
||||
purged_at: v.optional(v.union(v.string(), v.null())),
|
||||
last_synced_at: v.optional(v.string()),
|
||||
created_by: v.string(),
|
||||
updated_by: v.optional(v.string()),
|
||||
@@ -254,9 +266,50 @@ export default defineSchema({
|
||||
.index("by_created_by", ["created_by"])
|
||||
.index("by_document", ["document_id"])
|
||||
.index("by_doc_user", ["document_id", "shared_with_user_id"])
|
||||
.index("by_workspace", ["workspace_id"])
|
||||
.index("by_workspace_shared_with", ["workspace_id", "shared_with_user_id"])
|
||||
.index("by_workspace_created_by", ["workspace_id", "created_by"]),
|
||||
|
||||
// M9:评论(页面/块)
|
||||
comment_threads: defineTable({
|
||||
id: v.string(),
|
||||
workspace_id: v.string(),
|
||||
document_id: v.string(),
|
||||
block_id: v.union(v.string(), v.null()),
|
||||
resolved_at: v.union(v.string(), v.null()),
|
||||
resolved_by: v.union(v.string(), v.null()),
|
||||
comment_count: v.number(),
|
||||
last_activity_at: v.string(),
|
||||
last_comment_preview: v.union(v.string(), v.null()),
|
||||
last_comment_by: v.union(v.string(), v.null()),
|
||||
created_by: v.string(),
|
||||
created_at: v.string(),
|
||||
})
|
||||
.index("by_thread_id", ["id"])
|
||||
.index("by_document", ["document_id"])
|
||||
.index("by_document_block", ["document_id", "block_id"])
|
||||
.index("by_workspace", ["workspace_id"])
|
||||
.index("by_workspace_document_activity", ["workspace_id", "document_id", "last_activity_at"]),
|
||||
|
||||
comment_messages: defineTable({
|
||||
id: v.string(),
|
||||
thread_id: v.string(),
|
||||
workspace_id: v.string(),
|
||||
document_id: v.string(),
|
||||
block_id: v.union(v.string(), v.null()),
|
||||
parent_id: v.union(v.string(), v.null()),
|
||||
body: v.string(),
|
||||
created_by: v.string(),
|
||||
created_at: v.string(),
|
||||
deleted_at: v.union(v.string(), v.null()),
|
||||
deleted_by: v.union(v.string(), v.null()),
|
||||
})
|
||||
.index("by_message_id", ["id"])
|
||||
.index("by_thread", ["thread_id"])
|
||||
.index("by_document", ["document_id"])
|
||||
.index("by_document_block", ["document_id", "block_id"])
|
||||
.index("by_workspace", ["workspace_id"]),
|
||||
|
||||
groups: defineTable({
|
||||
id: v.string(),
|
||||
workspace_id: v.string(),
|
||||
|
||||
@@ -142,6 +142,9 @@ export const listByWorkspaceForSearch = query({
|
||||
document_id: t.document_id,
|
||||
title: t.title,
|
||||
is_archived: t.is_archived,
|
||||
deleted_at: t.deleted_at ?? null,
|
||||
deleted_by: t.deleted_by ?? null,
|
||||
purged_at: t.purged_at ?? null,
|
||||
created_at: t.created_at,
|
||||
updated_at: t.updated_at,
|
||||
}));
|
||||
@@ -173,6 +176,9 @@ export const create = mutation({
|
||||
view_preferences: {},
|
||||
snapshot: args.snapshot ?? null,
|
||||
is_archived: false,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
purged_at: null,
|
||||
last_synced_at: now,
|
||||
created_by: args.userId,
|
||||
updated_by: args.userId,
|
||||
@@ -279,9 +285,39 @@ export const remove = mutation({
|
||||
throw new Error("Table not found");
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
await ctx.db.patch(table._id, {
|
||||
is_archived: true,
|
||||
updated_at: nowIso(),
|
||||
deleted_at: now,
|
||||
deleted_by: args.userId,
|
||||
purged_at: null,
|
||||
updated_at: now,
|
||||
updated_by: args.userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation: 恢复表格(从垃圾桶恢复)
|
||||
export const restore = mutation({
|
||||
args: { userId: v.string(), tableId: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const table = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_table_id", (q) => q.eq("id", args.tableId))
|
||||
.first();
|
||||
if (!table) {
|
||||
throw new Error("Table not found");
|
||||
}
|
||||
|
||||
const now = nowIso();
|
||||
await ctx.db.patch(table._id, {
|
||||
is_archived: false,
|
||||
deleted_at: null,
|
||||
deleted_by: null,
|
||||
purged_at: null,
|
||||
updated_at: now,
|
||||
updated_by: args.userId,
|
||||
});
|
||||
|
||||
@@ -318,6 +354,45 @@ export const purge = mutation({
|
||||
},
|
||||
});
|
||||
|
||||
export const emptyTrashByWorkspace = mutation({
|
||||
args: { userId: v.string(), workspaceId: v.string(), expiredDeletedAt: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
await assertWorkspaceMember(ctx, args.userId, args.workspaceId);
|
||||
|
||||
const rows = await ctx.db
|
||||
.query("document_tables")
|
||||
.withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId))
|
||||
.order("desc")
|
||||
.take(5000);
|
||||
|
||||
// 仅清理“已进入垃圾桶且超出宽限期”的表格
|
||||
const targets = rows.filter((t) => {
|
||||
if (!t.is_archived) return false;
|
||||
const deletedAt = (t as any).deleted_at ?? null;
|
||||
if (!deletedAt || typeof deletedAt !== "string") return false;
|
||||
return deletedAt <= args.expiredDeletedAt;
|
||||
});
|
||||
|
||||
if (targets.length === 0) {
|
||||
return { ok: true, deleted: 0 };
|
||||
}
|
||||
|
||||
for (const table of targets) {
|
||||
const tableId = table.id;
|
||||
const tableRows = await ctx.db
|
||||
.query("document_table_rows")
|
||||
.withIndex("by_table", (q) => q.eq("table_id", tableId))
|
||||
.collect();
|
||||
for (const r of tableRows) {
|
||||
await ctx.db.delete(r._id);
|
||||
}
|
||||
await ctx.db.delete(table._id);
|
||||
}
|
||||
|
||||
return { ok: true, deleted: targets.length };
|
||||
},
|
||||
});
|
||||
|
||||
// Query: 获取表格行数据
|
||||
export const getRows = query({
|
||||
args: { tableId: v.string() },
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
import type { NextConfig } from "next";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
function loadEnvAll() {
|
||||
// 约定:全局仅使用仓库根目录的 .env.all 作为配置来源(生产优先)。
|
||||
// 说明:这里仅“补齐缺失项”,避免覆盖系统环境变量(方便部署/容器注入)。
|
||||
const envAllPath = path.resolve(__dirname, "..", ".env.all");
|
||||
if (!fs.existsSync(envAllPath)) return;
|
||||
const raw = fs.readFileSync(envAllPath, { encoding: "utf8" });
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const idx = trimmed.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
if (!key) continue;
|
||||
if (process.env[key] === undefined) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvAll();
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// 供桌面端打包使用(Electron 内置 Next server.js + 最小依赖)。
|
||||
@@ -24,6 +48,11 @@ const nextConfig: NextConfig = {
|
||||
source: "/_next/static/media/:path*",
|
||||
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
|
||||
},
|
||||
// Luckysheet 静态资源:文件位于 public/luckysheet,路径稳定(并且我们在 URL 上追加了版本参数),可长期缓存。
|
||||
{
|
||||
source: "/luckysheet/:path*",
|
||||
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
|
||||
},
|
||||
{
|
||||
source: "/onlyoffice/plugins/:path*",
|
||||
headers: [
|
||||
|
||||
@@ -35,12 +35,20 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
|
||||
showStructure: doc.show_structure ?? false,
|
||||
protectEditing: doc.protect_editing ?? false,
|
||||
showWordCount: doc.show_word_count ?? true,
|
||||
collapseBacklinks: (doc as any).collapse_backlinks ?? false,
|
||||
pageFont: (doc as any).page_font ?? "default",
|
||||
layoutDensity: (doc as any).layout_density ?? "normal",
|
||||
hideChildPages: (doc as any).hide_child_pages ?? false,
|
||||
showBlockRefCount: (doc as any).show_block_ref_count ?? false,
|
||||
embedDefaultBlockId: (doc as any).embed_default_block_id ?? null,
|
||||
};
|
||||
|
||||
const initialStats: DocumentStats = {
|
||||
wordCount: doc.word_count ?? 0,
|
||||
characterCount: doc.character_count ?? 0,
|
||||
blockCount: doc.block_count ?? 0,
|
||||
todoTotal: (doc as any).todo_total ?? (doc as any).todo_total_count ?? 0,
|
||||
todoDone: (doc as any).todo_done ?? (doc as any).todo_done_count ?? 0,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,6 @@ import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { Sidebar } from "@/components/sidebar/sidebar";
|
||||
import { Breadcrumb } from "@/components/breadcrumb";
|
||||
import { BottomToolbar } from "@/components/bottom-toolbar";
|
||||
import { MobileSidebarTrigger } from "@/components/mobile-sidebar-trigger";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
@@ -154,6 +153,70 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
};
|
||||
});
|
||||
|
||||
const tables = await client.query(api.tables.listByWorkspaceForSearch, {
|
||||
userId: auth.userId,
|
||||
workspaceId: activeWorkspaceId,
|
||||
includeArchived: true,
|
||||
limit: 3000,
|
||||
});
|
||||
|
||||
const tableAssets: MediaAsset[] = (tables ?? [])
|
||||
.filter((row) => !row.is_archived)
|
||||
.map((row) => {
|
||||
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
|
||||
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
|
||||
return {
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id ?? activeWorkspaceId,
|
||||
document_id: row.document_id,
|
||||
asset_type: "luckysheet",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: fileName,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: row.created_at ?? "",
|
||||
updated_at: row.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const trashedTableAssets: MediaAsset[] = (tables ?? [])
|
||||
.filter((row) => Boolean(row.is_archived))
|
||||
.map((row) => {
|
||||
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
|
||||
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
|
||||
return {
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id ?? activeWorkspaceId,
|
||||
document_id: row.document_id,
|
||||
asset_type: "luckysheet",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: fileName,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
deleted_at: row.deleted_at ?? row.updated_at ?? null,
|
||||
deleted_by: row.deleted_by ?? null,
|
||||
purged_at: row.purged_at ?? null,
|
||||
signed_url: null,
|
||||
created_at: row.created_at ?? "",
|
||||
updated_at: row.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
sidebarInitialData = {
|
||||
activeWorkspaceId,
|
||||
workspaces,
|
||||
@@ -161,10 +224,12 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
trashedDocuments: trashedDocs as unknown as SidebarInitialData["trashedDocuments"],
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets,
|
||||
trashedTableAssets,
|
||||
mediaAssets: [],
|
||||
mindmapDocs,
|
||||
mindmapAssets,
|
||||
mindmapAssetChildren,
|
||||
tableAssets,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,7 +242,6 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
<Breadcrumb documents={documents} />
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden bg-white">{children}</main>
|
||||
<BottomToolbar />
|
||||
<SearchPalette workspaceId={sidebarInitialData?.activeWorkspaceId ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,8 +34,15 @@ export async function POST(request: Request) {
|
||||
|
||||
const target = await client.query(api.documents.getContent, { id: targetDocumentId });
|
||||
if (!target) return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
const targetMeta = await client.query(api.documents.getMeta, { id: targetDocumentId });
|
||||
|
||||
const targetBlocks = getBlocksFromDocumentContent(target.content);
|
||||
const anchorId = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
|
||||
const anchorIndex =
|
||||
typeof anchorId === "string" && anchorId.trim()
|
||||
? targetBlocks.findIndex((b) => String((b as any)?.id ?? "") === anchorId)
|
||||
: -1;
|
||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
|
||||
const referenceBlock = {
|
||||
id: randomUUID(),
|
||||
type: "blockReference",
|
||||
@@ -48,7 +55,7 @@ export async function POST(request: Request) {
|
||||
children: [],
|
||||
};
|
||||
|
||||
const nextBlocks = [...targetBlocks, referenceBlock];
|
||||
const nextBlocks = [...targetBlocks.slice(0, insertIndex), referenceBlock, ...targetBlocks.slice(insertIndex)];
|
||||
const payload = withBlocksWrittenBack(target.content, nextBlocks);
|
||||
await client.mutation(api.documents.updateContent, { id: targetDocumentId, content: payload });
|
||||
|
||||
|
||||
@@ -30,10 +30,18 @@ export async function POST(request: Request) {
|
||||
if (!targetContent) {
|
||||
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
||||
}
|
||||
const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
|
||||
|
||||
const currentBlocks = extractBlocksFromContent(targetContent.content);
|
||||
const anchorId = (targetMeta as any)?.embed_default_block_id as string | null | undefined;
|
||||
const anchorIndex =
|
||||
typeof anchorId === "string" && anchorId.trim()
|
||||
? currentBlocks.findIndex((b) => typeof b === "object" && b !== null && (b as any).id === anchorId)
|
||||
: -1;
|
||||
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
|
||||
|
||||
const nextBlocks: Json[] = [
|
||||
...currentBlocks,
|
||||
...currentBlocks.slice(0, insertIndex),
|
||||
{
|
||||
id: randomUUID(),
|
||||
type: "pageReference",
|
||||
@@ -42,6 +50,7 @@ export async function POST(request: Request) {
|
||||
title: sourceDoc.title ?? "无标题",
|
||||
},
|
||||
},
|
||||
...currentBlocks.slice(insertIndex),
|
||||
];
|
||||
|
||||
const payload: Json = composeContentWithBlocks(targetContent.content, nextBlocks);
|
||||
|
||||
@@ -27,6 +27,12 @@ export async function POST(request: Request) {
|
||||
showStructure: options.showStructure,
|
||||
protectEditing: options.protectEditing,
|
||||
showWordCount: options.showWordCount,
|
||||
collapseBacklinks: options.collapseBacklinks,
|
||||
pageFont: options.pageFont,
|
||||
layoutDensity: options.layoutDensity,
|
||||
hideChildPages: options.hideChildPages,
|
||||
showBlockRefCount: options.showBlockRefCount,
|
||||
embedDefaultBlockId: typeof options.embedDefaultBlockId === "string" ? options.embedDefaultBlockId : null,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ export async function POST(request: Request) {
|
||||
wordCount: stats.wordCount,
|
||||
characterCount: stats.characterCount,
|
||||
blockCount: stats.blockCount,
|
||||
todoTotal: stats.todoTotal,
|
||||
todoDone: stats.todoDone,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
type TemplatePayload = {
|
||||
documentId: string;
|
||||
isTemplate: boolean;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
const { documentId, isTemplate }: TemplatePayload = await request.json();
|
||||
if (!documentId || typeof isTemplate !== "boolean") {
|
||||
return NextResponse.json({ error: "缺少参数" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { client } = await getAuthedConvexClient();
|
||||
await client.mutation(api.documents.setTemplate, { id: documentId, isTemplate });
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -37,7 +38,13 @@ export async function GET(request: Request) {
|
||||
limit: Number.isNaN(limit) ? 12 : limit,
|
||||
});
|
||||
|
||||
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
|
||||
const safeItems = (items ?? []).map((a: any) => ({
|
||||
...a,
|
||||
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
|
||||
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
|
||||
})) as MediaAsset[];
|
||||
|
||||
return NextResponse.json({ items: safeItems });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
@@ -149,7 +156,13 @@ export async function POST(request: Request) {
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ asset });
|
||||
const safeAsset = {
|
||||
...asset,
|
||||
file_url: maybeProxyForBrowserUrl(request, String(asset?.file_url ?? "")),
|
||||
thumbnail_url: maybeProxyForBrowserUrl(request, String(asset?.thumbnail_url ?? asset?.file_url ?? "")),
|
||||
} as MediaAsset;
|
||||
|
||||
return NextResponse.json({ asset: safeAsset });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
@@ -6,6 +6,7 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -200,8 +201,14 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ items: results });
|
||||
}
|
||||
const safeItems = (results ?? []).map((a: any) => ({
|
||||
...a,
|
||||
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
|
||||
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ items: safeItems });
|
||||
}
|
||||
default:
|
||||
return NextResponse.json({ error: "不支持的操作" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -34,9 +35,14 @@ export async function GET(request: Request) {
|
||||
limit: Number.isNaN(limit) ? 200 : limit,
|
||||
});
|
||||
|
||||
return NextResponse.json({ items: (items ?? []) as MediaAsset[] });
|
||||
const safeItems = (items ?? []).map((a: any) => ({
|
||||
...a,
|
||||
file_url: maybeProxyForBrowserUrl(request, String(a?.file_url ?? "")),
|
||||
thumbnail_url: maybeProxyForBrowserUrl(request, String(a?.thumbnail_url ?? a?.file_url ?? "")),
|
||||
})) as MediaAsset[];
|
||||
|
||||
return NextResponse.json({ items: safeItems });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -10,21 +10,10 @@ interface EmptyTrashPayload {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
function resolveGraceSeconds(): number {
|
||||
const raw =
|
||||
process.env.DELETE_GRACE_SECONDS ??
|
||||
process.env.NEXT_PUBLIC_DELETE_GRACE_SECONDS ??
|
||||
"600";
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 600;
|
||||
}
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function makeExpiredDeletedAt(): string {
|
||||
const graceSeconds = resolveGraceSeconds();
|
||||
return new Date(Date.now() - (graceSeconds + 5) * 1000).toISOString();
|
||||
function makeExpiredDeletedAtForForceEmpty(): string {
|
||||
// “清空附件垃圾桶”是一个显式的强制操作:直接清空所有垃圾桶内的附件,不受 10 分钟宽限期限制。
|
||||
// Convex 侧按 `deleted_at <= expiredDeletedAt` 做过滤,因此这里给一个很靠后的时间上界即可。
|
||||
return new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100).toISOString();
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
@@ -48,7 +37,7 @@ export async function POST(request: Request) {
|
||||
const res = await client.mutation(api.mediaAssets.emptyTrashByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
expiredDeletedAt: makeExpiredDeletedAt(),
|
||||
expiredDeletedAt: makeExpiredDeletedAtForForceEmpty(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, ...(res as any) });
|
||||
|
||||
@@ -3,44 +3,10 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { HttpError } from "@/lib/auth/authContext";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
return Buffer.from(input, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
const getRequestOrigin = (request: Request) => {
|
||||
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
|
||||
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
|
||||
return `${protocol}//${host}`;
|
||||
};
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const maybeProxyForBrowser = (request: Request, rawUrl: string) => {
|
||||
const input = String(rawUrl || "").trim();
|
||||
if (!input) return input;
|
||||
if (input.startsWith("/api/onlyoffice/proxy")) return input;
|
||||
try {
|
||||
const u = new URL(input);
|
||||
if (!isLocalHostname(u.hostname)) return input;
|
||||
const origin = getRequestOrigin(request);
|
||||
const proxy = new URL("/api/onlyoffice/proxy", origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
|
||||
return proxy.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
let auth;
|
||||
@@ -75,7 +41,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
signedUrl: maybeProxyForBrowser(request, signedUrl),
|
||||
signedUrl: maybeProxyForBrowserUrl(request, signedUrl),
|
||||
asset: {
|
||||
id: asset.id,
|
||||
file_name: asset.file_name,
|
||||
|
||||
@@ -1,44 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
return Buffer.from(input, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
const getRequestOrigin = (request: Request) => {
|
||||
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
|
||||
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
|
||||
return `${protocol}//${host}`;
|
||||
};
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "host.docker.internal";
|
||||
|
||||
const maybeProxyForBrowser = (request: Request, rawUrl: string) => {
|
||||
const input = String(rawUrl || "").trim();
|
||||
if (!input) return input;
|
||||
if (input.startsWith("/api/onlyoffice/proxy")) return input;
|
||||
try {
|
||||
const u = new URL(input);
|
||||
if (!isLocalHostname(u.hostname)) return input;
|
||||
const origin = getRequestOrigin(request);
|
||||
const proxy = new URL("/api/onlyoffice/proxy", origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
|
||||
return proxy.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (isConvexEnabled()) {
|
||||
try {
|
||||
@@ -58,7 +24,7 @@ export async function GET(request: Request) {
|
||||
|
||||
// 说明:Convex + MinIO 模式下,这个接口目前主要用于“外链文件”走 ONLYOFFICE 的场景。
|
||||
// 由于外链本身已经是可访问的 URL,这里只做最小透传。
|
||||
return NextResponse.json({ signedUrl: maybeProxyForBrowser(request, fileUrl) });
|
||||
return NextResponse.json({ signedUrl: maybeProxyForBrowserUrl(request, fileUrl) });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
|
||||
@@ -7,6 +7,7 @@ import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -89,9 +90,14 @@ export async function POST(request: Request) {
|
||||
});
|
||||
|
||||
const asset = created as unknown as MediaAsset;
|
||||
const safeAsset = {
|
||||
...(asset as any),
|
||||
file_url: maybeProxyForBrowserUrl(request, String((asset as any)?.file_url ?? "")),
|
||||
thumbnail_url: maybeProxyForBrowserUrl(request, String((asset as any)?.thumbnail_url ?? (asset as any)?.file_url ?? "")),
|
||||
} as MediaAsset;
|
||||
|
||||
return NextResponse.json({
|
||||
asset,
|
||||
asset: safeAsset,
|
||||
mindmapUrl: `asset:${asset.id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,6 +3,11 @@ import { detectLocalMindmapFiles } from "@/lib/server/mindmap-files";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
import { HttpError } from "@/lib/auth/authContext";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -39,7 +44,89 @@ async function listTestPdfs(): Promise<AgentAssetItem[]> {
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const documentId = String(searchParams.get("documentId") ?? "").trim();
|
||||
const q = String(searchParams.get("q") ?? "").trim().toLowerCase();
|
||||
const workspaceOnly = String(searchParams.get("workspaceOnly") ?? "").trim() === "1";
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: "缺少 documentId" }, { status: 400 });
|
||||
}
|
||||
|
||||
let auth: { userId: string };
|
||||
let client: any;
|
||||
try {
|
||||
const res = await getAuthedConvexClient();
|
||||
auth = res.auth;
|
||||
client = res.client;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
const doc = await client.query(api.documents.getMeta, { id: documentId });
|
||||
if (!doc) {
|
||||
return NextResponse.json({ error: "页面不存在" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (workspaceOnly) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
workspaceId: (doc as any).workspace_id ?? null,
|
||||
documentId,
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 说明:这里的附件列表主要用于 Mindmap AI Agent 选择引用素材。
|
||||
const mediaRows = (await client.query(api.mediaAssets.listByDocument, {
|
||||
userId: auth.userId,
|
||||
documentId,
|
||||
limit: 200,
|
||||
})) as unknown as MediaAsset[];
|
||||
|
||||
const mediaAssets: AgentAssetItem[] = (mediaRows ?? []).map((row) => ({
|
||||
kind: "media" as const,
|
||||
id: String((row as any).id ?? ""),
|
||||
title: String((row as any).file_name ?? (row as any).id ?? "附件"),
|
||||
fileUrl: maybeProxyForBrowserUrl(request, String((row as any).file_url ?? "")),
|
||||
mimeType: (row as any).mime_type ?? null,
|
||||
assetType: (row as any).asset_type ?? null,
|
||||
fileName: (row as any).file_name ?? null,
|
||||
}));
|
||||
|
||||
const localMindmaps = (await detectLocalMindmapFiles([documentId]))
|
||||
.filter((x) => x.documentId === documentId)
|
||||
.map((x) => ({
|
||||
kind: "local-mindmap" as const,
|
||||
id: `mindmap:${x.mindmapId}`,
|
||||
title: x.fileName,
|
||||
fileUrl: `/documents/${documentId}/${x.fileName}`,
|
||||
mimeType: "application/json",
|
||||
assetType: "mindmap",
|
||||
fileName: x.fileName,
|
||||
}));
|
||||
|
||||
const testPdfs = await listTestPdfs();
|
||||
|
||||
let items: AgentAssetItem[] = [...localMindmaps, ...mediaAssets, ...testPdfs];
|
||||
if (q) {
|
||||
items = items.filter((it) => {
|
||||
const hay = `${it.title} ${it.fileName ?? ""}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
workspaceId: (doc as any).workspace_id ?? null,
|
||||
documentId,
|
||||
items,
|
||||
});
|
||||
|
||||
/*
|
||||
const supabase = await createSupabaseRouteClient();
|
||||
|
||||
@@ -109,6 +109,13 @@ export async function GET(request: Request) {
|
||||
limit: 2000,
|
||||
});
|
||||
|
||||
const tables = await client.query(api.tables.listByWorkspaceForSearch, {
|
||||
userId: auth.userId,
|
||||
workspaceId: targetWorkspaceId,
|
||||
includeArchived: true,
|
||||
limit: 3000,
|
||||
});
|
||||
|
||||
const activeMindmaps = (mindmapRows ?? []).filter((r) => !r.deleted_at);
|
||||
const trashedMindmaps = (mindmapRows ?? []).filter((r) => !!r.deleted_at);
|
||||
|
||||
@@ -173,6 +180,63 @@ export async function GET(request: Request) {
|
||||
};
|
||||
});
|
||||
|
||||
const tableAssets: MediaAsset[] = (tables ?? [])
|
||||
.filter((row) => !row.is_archived)
|
||||
.map((row) => {
|
||||
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
|
||||
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
|
||||
return {
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id ?? targetWorkspaceId,
|
||||
document_id: row.document_id,
|
||||
asset_type: "luckysheet",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: fileName,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: row.created_at ?? "",
|
||||
updated_at: row.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const trashedTableAssets: MediaAsset[] = (tables ?? [])
|
||||
.filter((row) => Boolean(row.is_archived))
|
||||
.map((row) => {
|
||||
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
|
||||
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
|
||||
return {
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id ?? targetWorkspaceId,
|
||||
document_id: row.document_id,
|
||||
asset_type: "luckysheet",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: fileName,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
deleted_at: row.deleted_at ?? row.updated_at ?? null,
|
||||
deleted_by: row.deleted_by ?? null,
|
||||
purged_at: row.purged_at ?? null,
|
||||
signed_url: null,
|
||||
created_at: row.created_at ?? "",
|
||||
updated_at: row.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const payload: SidebarInitialData = {
|
||||
activeWorkspaceId: targetWorkspaceId,
|
||||
workspaces,
|
||||
@@ -180,10 +244,11 @@ export async function GET(request: Request) {
|
||||
trashedDocuments,
|
||||
trashedMediaAssets: (trashedMediaAssets ?? []) as MediaAsset[],
|
||||
trashedMindmapAssets,
|
||||
trashedTableAssets,
|
||||
mindmapDocs,
|
||||
mindmapAssets,
|
||||
mindmapAssetChildren,
|
||||
tableAssets: [],
|
||||
tableAssets,
|
||||
mediaAssets: (mediaAssets ?? []) as MediaAsset[],
|
||||
};
|
||||
|
||||
|
||||
@@ -103,7 +103,8 @@ export async function DELETE(_request: Request, context: RouteContext) {
|
||||
if (isConvexEnabled()) {
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
await client.mutation(api.tables.purge, {
|
||||
// 软删除:进入垃圾桶(可恢复)
|
||||
await client.mutation(api.tables.remove, {
|
||||
userId: auth.userId,
|
||||
tableId,
|
||||
});
|
||||
@@ -117,4 +118,3 @@ export async function DELETE(_request: Request, context: RouteContext) {
|
||||
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { HttpError, requireAuthContext } from "@/lib/auth/authContext";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { getConvexAuthedHttpClient } from "@/lib/convex/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface EmptyTrashPayload {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
function makeExpiredDeletedAtForForceEmpty(): string {
|
||||
// “清空附件垃圾桶”是一个显式的强制操作:直接清空所有垃圾桶内的表格,不受 10 分钟宽限期限制。
|
||||
// Convex 侧按 `deleted_at <= expiredDeletedAt` 做过滤,因此这里给一个很靠后的时间上界即可。
|
||||
return new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100).toISOString();
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
let auth;
|
||||
try {
|
||||
auth = await requireAuthContext();
|
||||
} catch (err) {
|
||||
if (err instanceof HttpError) {
|
||||
return NextResponse.json({ error: "未登录" }, { status: err.status });
|
||||
}
|
||||
return NextResponse.json({ error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workspaceId }: EmptyTrashPayload = await request.json().catch(() => ({}));
|
||||
if (!workspaceId) {
|
||||
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = await getConvexAuthedHttpClient();
|
||||
try {
|
||||
const res = await client.mutation(api.tables.emptyTrashByWorkspace, {
|
||||
userId: auth.userId,
|
||||
workspaceId,
|
||||
expiredDeletedAt: makeExpiredDeletedAtForForceEmpty(),
|
||||
});
|
||||
return NextResponse.json({ success: true, ...(res as any) });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "清空失败";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
const { tableId } = (await request.json().catch(() => ({}))) as { tableId?: string };
|
||||
if (!tableId) {
|
||||
return NextResponse.json({ error: "缺少 tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
await client.mutation(api.tables.purge, { userId: auth.userId, tableId });
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Convex API error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isConvexEnabled()) {
|
||||
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
||||
}
|
||||
|
||||
const { tableId } = (await request.json().catch(() => ({}))) as { tableId?: string };
|
||||
if (!tableId) {
|
||||
return NextResponse.json({ error: "缺少 tableId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { auth, client } = await getAuthedConvexClient();
|
||||
try {
|
||||
await client.mutation(api.tables.restore, { userId: auth.userId, tableId });
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Convex API error:", error);
|
||||
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { BooleanPageOptionKey, DocumentStats, PageFont, PageLayoutDensity, PageOptionsState } from "@/types/page-options";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { ImagePickerProvider } from "@/components/media/image-picker-context";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const defaultOptions: PageOptionsState = {
|
||||
wideLayout: false,
|
||||
smallText: false,
|
||||
showHeadingNumbers: true,
|
||||
showToc: false,
|
||||
showStructure: false,
|
||||
protectEditing: false,
|
||||
showWordCount: true,
|
||||
collapseBacklinks: false,
|
||||
pageFont: "default",
|
||||
layoutDensity: "normal",
|
||||
hideChildPages: false,
|
||||
showBlockRefCount: false,
|
||||
embedDefaultBlockId: null,
|
||||
};
|
||||
|
||||
const defaultStats: DocumentStats = {
|
||||
wordCount: 0,
|
||||
characterCount: 0,
|
||||
blockCount: 0,
|
||||
todoTotal: 0,
|
||||
todoDone: 0,
|
||||
};
|
||||
|
||||
export default function PageOptionsPlaygroundPage() {
|
||||
const editorBridge = useEditorBridgeStore((s) => s.bridge);
|
||||
const [options, setOptions] = useState<PageOptionsState>(defaultOptions);
|
||||
const [stats, setStats] = useState<DocumentStats>(defaultStats);
|
||||
const documentId = "dev-page-options";
|
||||
const workspaceId = "dev-workspace";
|
||||
|
||||
const initialContent = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "h1",
|
||||
type: "heading",
|
||||
props: { level: 1 },
|
||||
content: [{ type: "text", text: "标题一" }],
|
||||
},
|
||||
{
|
||||
id: "p1",
|
||||
type: "paragraph",
|
||||
props: {},
|
||||
content: [{ type: "text", text: "这是用于回归测试页面选项的示例段落。" }],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleOption = useCallback((key: BooleanPageOptionKey) => {
|
||||
setOptions((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
}, []);
|
||||
|
||||
const setPageFont = useCallback((font: PageFont) => setOptions((prev) => ({ ...prev, pageFont: font })), []);
|
||||
const setLayoutDensity = useCallback(
|
||||
(density: PageLayoutDensity) => setOptions((prev) => ({ ...prev, layoutDensity: density })),
|
||||
[],
|
||||
);
|
||||
|
||||
const pageRootClass = cn(
|
||||
"flex h-[calc(100vh-64px)] overflow-hidden bg-wolai-bg",
|
||||
options.pageFont === "song" && "wolai-page-font-song",
|
||||
options.pageFont === "kai" && "wolai-page-font-kai",
|
||||
options.layoutDensity === "compact" && "wolai-page-density-compact",
|
||||
options.layoutDensity === "spacious" && "wolai-page-density-spacious",
|
||||
options.smallText && "wolai-small-text",
|
||||
options.hideChildPages && "wolai-hide-child-pages",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-4 text-sm text-gray-500">
|
||||
Dev Playground:用于 Playwright 回归页面选项(不写入后端)
|
||||
</div>
|
||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||
<div className={pageRootClass}>
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
|
||||
<div className="text-3xl font-semibold text-wolai-text-primary">页面选项回归测试</div>
|
||||
<p className="mt-1 text-sm text-wolai-text-secondary">该页面不会保存任何更改。</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-12 py-6">
|
||||
<BlockNoteEditor
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
initialContent={initialContent as unknown}
|
||||
pageOptions={options}
|
||||
readOnly={false}
|
||||
onStatsChange={setStats}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageOptionsSidebar
|
||||
documentId={documentId}
|
||||
options={options}
|
||||
stats={stats}
|
||||
onToggle={toggleOption}
|
||||
onSetPageFont={setPageFont}
|
||||
onSetLayoutDensity={setLayoutDensity}
|
||||
onSetEmbedDefaultToCursor={() => window.alert("该页面为 Dev Playground,不写入后端")}
|
||||
onClearEmbedDefault={() => setOptions((prev) => ({ ...prev, embedDefaultBlockId: null }))}
|
||||
onExport={() => window.alert("该页面为 Dev Playground,不提供导出")}
|
||||
onOpenHistory={() => window.alert("该页面为 Dev Playground,不提供历史")}
|
||||
onOpenComments={() => window.alert("该页面为 Dev Playground,不提供评论")}
|
||||
onUndo={() => editorBridge?.undo?.()}
|
||||
onRedo={() => editorBridge?.redo?.()}
|
||||
onDeletePage={() => window.alert("该页面为 Dev Playground,不提供删除")}
|
||||
onOpenMoveEmbedPicker={() => window.alert("该页面为 Dev Playground,不提供移动/嵌入")}
|
||||
onCopyPageLink={() => window.alert("该页面为 Dev Playground,不提供复制链接")}
|
||||
onCopyPageReference={() => window.alert("该页面为 Dev Playground,不提供引用")}
|
||||
onAddToTemplates={() => window.alert("该页面为 Dev Playground,不提供模板")}
|
||||
/>
|
||||
</div>
|
||||
</ImagePickerProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -207,52 +207,72 @@ body {
|
||||
.wolai-editor .bn-block-group:hover .bn-drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] {
|
||||
.wolai-editor.wolai-heading-numbering {
|
||||
counter-reset: wolai-h1 wolai-h2 wolai-h3 wolai-h4 wolai-h5;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h1 {
|
||||
|
||||
/* 说明:BlockNote 的 heading 主要由 .bn-block-content[data-content-type=heading] 承载。
|
||||
为避免不同渲染结构导致“标题编号”失效,这里以块级容器为准实现编号。 */
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"]:not([data-level]),
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="1"] {
|
||||
counter-increment: wolai-h1;
|
||||
counter-reset: wolai-h2;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h1::before {
|
||||
content: counter(wolai-h1) ". ";
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"]:not([data-level])::before,
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="1"]::before {
|
||||
content: counter(wolai-h1) ". " !important;
|
||||
color: #94a3b8;
|
||||
margin-right: 8px;
|
||||
margin-right: 8px !important;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h2 {
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="2"] {
|
||||
counter-increment: wolai-h2;
|
||||
counter-reset: wolai-h3;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h2::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) ". ";
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="2"]::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) ". " !important;
|
||||
color: #94a3b8;
|
||||
margin-right: 6px;
|
||||
margin-right: 6px !important;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h3 {
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="3"] {
|
||||
counter-increment: wolai-h3;
|
||||
counter-reset: wolai-h4;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h3::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) ". ";
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="3"]::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) ". " !important;
|
||||
color: #cbd5f5;
|
||||
margin-right: 4px;
|
||||
margin-right: 4px !important;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h4 {
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="4"] {
|
||||
counter-increment: wolai-h4;
|
||||
counter-reset: wolai-h5;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h4::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) ". ";
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="4"]::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) ". " !important;
|
||||
color: #d0d7e7;
|
||||
margin-right: 4px;
|
||||
margin-right: 4px !important;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h5 {
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="5"] {
|
||||
counter-increment: wolai-h5;
|
||||
}
|
||||
.wolai-editor[data-heading-numbering="true"] h5::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) "." counter(wolai-h5) ". ";
|
||||
.wolai-editor.wolai-heading-numbering .bn-block-content[data-content-type="heading"][data-level="5"]::before {
|
||||
content: counter(wolai-h1) "." counter(wolai-h2) "." counter(wolai-h3) "." counter(wolai-h4) "." counter(wolai-h5) ". " !important;
|
||||
color: #d4d4d8;
|
||||
margin-right: 4px;
|
||||
margin-right: 4px !important;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.wolai-editor-show-structure .bn-block-outer {
|
||||
outline: 1px dashed #d4d4d8;
|
||||
@@ -587,6 +607,105 @@ body {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 页面选项:小字体(覆盖 BlockNote 的 .bn-default-styles 默认字号) */
|
||||
.wolai-small-text .wolai-editor.bn-default-styles,
|
||||
.wolai-small-text .wolai-editor .bn-default-styles {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* 自定义页面:字体 */
|
||||
.wolai-page-font-song {
|
||||
font-family: "宋体", SimSun, "Songti SC", serif;
|
||||
}
|
||||
|
||||
.wolai-page-font-song .wolai-editor.bn-default-styles,
|
||||
.wolai-page-font-song .wolai-editor .bn-default-styles {
|
||||
font-family: "宋体", SimSun, "Songti SC", serif;
|
||||
}
|
||||
|
||||
.wolai-page-font-kai {
|
||||
font-family: "楷体", "楷体_GB2312", SimKai, STKaiti, serif;
|
||||
}
|
||||
|
||||
.wolai-page-font-kai .wolai-editor.bn-default-styles,
|
||||
.wolai-page-font-kai .wolai-editor .bn-default-styles {
|
||||
font-family: "楷体", "楷体_GB2312", SimKai, STKaiti, serif;
|
||||
}
|
||||
|
||||
/* 自定义页面:布局密度(紧凑/默认/宽容) */
|
||||
.wolai-page-density-compact .wolai-editor .bn-block-content {
|
||||
line-height: 1.45;
|
||||
padding-top: 1px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.wolai-page-density-spacious .wolai-editor .bn-block-content {
|
||||
line-height: 1.75;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 页面选项:小字体(强制覆盖 BlockNote 默认字号) */
|
||||
.wolai-small-text .wolai-editor.bn-default-styles,
|
||||
.wolai-small-text .wolai-editor .bn-default-styles,
|
||||
.wolai-editor.wolai-small-text.bn-default-styles,
|
||||
.wolai-editor.wolai-small-text .bn-default-styles {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
/* 自定义页面:字体(强制覆盖 BlockNote 默认字体) */
|
||||
.wolai-page-font-song .wolai-editor.bn-default-styles,
|
||||
.wolai-page-font-song .wolai-editor .bn-default-styles,
|
||||
.wolai-editor.wolai-page-font-song.bn-default-styles,
|
||||
.wolai-editor.wolai-page-font-song .bn-default-styles {
|
||||
font-family: "宋体", SimSun, "Songti SC", serif !important;
|
||||
}
|
||||
|
||||
.wolai-page-font-kai .wolai-editor.bn-default-styles,
|
||||
.wolai-page-font-kai .wolai-editor .bn-default-styles,
|
||||
.wolai-editor.wolai-page-font-kai.bn-default-styles,
|
||||
.wolai-editor.wolai-page-font-kai .bn-default-styles {
|
||||
font-family: "楷体", "楷体_GB2312", SimKai, STKaiti, serif !important;
|
||||
}
|
||||
|
||||
/* 自定义页面:布局密度(强制覆盖 BlockNote 默认行高/间距) */
|
||||
.wolai-page-density-compact .wolai-editor .bn-block-content,
|
||||
.wolai-editor.wolai-page-density-compact .bn-block-content {
|
||||
line-height: 1.45 !important;
|
||||
padding-top: 1px !important;
|
||||
padding-bottom: 1px !important;
|
||||
}
|
||||
|
||||
.wolai-page-density-spacious .wolai-editor .bn-block-content,
|
||||
.wolai-editor.wolai-page-density-spacious .bn-block-content {
|
||||
line-height: 1.75 !important;
|
||||
padding-top: 6px !important;
|
||||
padding-bottom: 6px !important;
|
||||
}
|
||||
|
||||
.wolai-page-density-compact .wolai-editor h1 {
|
||||
margin-top: 1.2em;
|
||||
}
|
||||
|
||||
.wolai-page-density-spacious .wolai-editor h1 {
|
||||
margin-top: 1.8em;
|
||||
}
|
||||
|
||||
.wolai-page-density-compact .wolai-editor h2,
|
||||
.wolai-page-density-compact .wolai-editor h3 {
|
||||
margin-top: 1.1em;
|
||||
}
|
||||
|
||||
.wolai-page-density-spacious .wolai-editor h2,
|
||||
.wolai-page-density-spacious .wolai-editor h3 {
|
||||
margin-top: 1.6em;
|
||||
}
|
||||
|
||||
/* 自定义页面:隐藏子页面(仅隐藏通过 /ym 创建的子页面块) */
|
||||
.wolai-hide-child-pages [data-child-page="true"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 标题样式 */
|
||||
.wolai-editor h1 {
|
||||
font-size: 30px;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { QueryProvider } from "@/components/providers/query-provider";
|
||||
import { ConvexClientProvider } from "@/components/providers/convex-provider";
|
||||
import { AppPreferencesHydrator } from "@/components/providers/app-preferences-hydrator";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
|
||||
@@ -42,6 +43,7 @@ export default async function RootLayout({
|
||||
<body className={`${inter.variable} antialiased`}>
|
||||
<ConvexAuthNextjsServerProvider>
|
||||
<ConvexClientProvider>
|
||||
<AppPreferencesHydrator />
|
||||
<QueryProvider>{children}</QueryProvider>
|
||||
</ConvexClientProvider>
|
||||
</ConvexAuthNextjsServerProvider>
|
||||
|
||||
@@ -375,6 +375,12 @@ export default function OnlyOfficePage() {
|
||||
if (!uniq.includes(s)) uniq.push(s);
|
||||
};
|
||||
|
||||
const isPageLocal = (() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
const host = window.location.hostname;
|
||||
return host === "127.0.0.1" || host === "localhost";
|
||||
})();
|
||||
|
||||
// 说明:网页端优先走同源 /onlyoffice-server(Next 代理到 ONLYOFFICE_INTERNAL_URL),
|
||||
// 避免配置里误写成 https 自签证书域名导致浏览器报 ERR_CERT_AUTHORITY_INVALID。
|
||||
// 同时同源路径也更利于缓存与跨环境迁移(无需改域名/端口)。
|
||||
@@ -390,7 +396,10 @@ export default function OnlyOfficePage() {
|
||||
if (channel === "web") {
|
||||
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||
push(runtimeConfig.onlyofficeBaseUrl);
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
// 说明:Web 端通过公网访问时,不应回退到 localhost/127(会触发浏览器“本地网络”权限提示,且必然不可达)。
|
||||
if (isPageLocal) {
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
}
|
||||
return uniq;
|
||||
}
|
||||
if (channel === "desktop") {
|
||||
@@ -404,7 +413,9 @@ export default function OnlyOfficePage() {
|
||||
if (runtimeConfig.isDesktop) {
|
||||
push(runtimeConfig.onlyofficeBaseUrlWeb);
|
||||
} else {
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
if (isPageLocal) {
|
||||
push(runtimeConfig.onlyofficeBaseUrlDesktop);
|
||||
}
|
||||
}
|
||||
return uniq;
|
||||
}, [channel, runtimeConfig]);
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useSelectedLayoutSegments } from "next/navigation";
|
||||
import { ChevronRight, MoreHorizontal, Star } from "lucide-react";
|
||||
import { ChevronRight, MoreHorizontal, Sparkles, Star } from "lucide-react";
|
||||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import { findBreadcrumb } from "@/lib/documents";
|
||||
import { useBackendHealth } from "@/hooks/use-backend-health";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePageLayoutStore } from "@/store/page-layout";
|
||||
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
interface BreadcrumbProps {
|
||||
@@ -22,6 +25,9 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
const path = findBreadcrumb(documents, activeId);
|
||||
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
||||
const toggleInspector = usePageLayoutStore((state) => state.toggleInspector);
|
||||
const backendStatus = useBackendHealth();
|
||||
const documentAgentAvailable = useAiAgentUiStore((s) => s.documentAgentAvailable);
|
||||
const toggleDocumentAgentOpen = useAiAgentUiStore((s) => s.toggleDocumentAgentOpen);
|
||||
const isStarred = useQuery(
|
||||
api.documentStars.isStarred,
|
||||
activeId && isAuthenticated ? { documentId: activeId } : "skip",
|
||||
@@ -60,6 +66,35 @@ export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex items-center gap-2 text-wolai-text-secondary">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
backendStatus === "ok" ? "bg-green-500" : "bg-red-500",
|
||||
)}
|
||||
title={`后端连接:${
|
||||
backendStatus === "ok"
|
||||
? "正常"
|
||||
: backendStatus === "disabled"
|
||||
? "未配置"
|
||||
: backendStatus === "error"
|
||||
? "异常"
|
||||
: "检测中"
|
||||
}`}
|
||||
aria-label="后端连接状态"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full px-3 py-1 text-sm transition-colors",
|
||||
!documentAgentAvailable && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
onClick={() => toggleDocumentAgentOpen()}
|
||||
disabled={!documentAgentAvailable}
|
||||
title={documentAgentAvailable ? "打开页面 AI" : "仅在页面编辑区可用"}
|
||||
>
|
||||
<Sparkles className="mr-1 inline h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full px-3 py-1 text-sm transition-colors"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkle
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { useAiAgentUiStore } from "@/store/ai-agent-ui";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -154,10 +155,11 @@ export function DocumentAiAgentPanel({
|
||||
const open = useAiAgentUiStore((s) => s.documentAgentOpen);
|
||||
const setOpen = useAiAgentUiStore((s) => s.setDocumentAgentOpen);
|
||||
const setAvailable = useAiAgentUiStore((s) => s.setDocumentAgentAvailable);
|
||||
const flightMode = useAppPreferencesStore((s) => s.flightMode);
|
||||
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_SESSION_MESSAGES);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [networkOn, setNetworkOn] = useState(true);
|
||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||
const [toolAuto, setToolAuto] = useState(true);
|
||||
const [toolPickerOpen, setToolPickerOpen] = useState(false);
|
||||
const [selectedTools, setSelectedTools] = useState<ToolName[]>(DEFAULT_TOOLS);
|
||||
@@ -179,6 +181,12 @@ export function DocumentAiAgentPanel({
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const syncTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (flightMode) {
|
||||
setNetworkOn(false);
|
||||
}
|
||||
}, [flightMode]);
|
||||
|
||||
useEffect(() => {
|
||||
setAvailable(true);
|
||||
return () => {
|
||||
|
||||
@@ -29,7 +29,12 @@ import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import type { ReferenceTarget } from "@/types/search";
|
||||
import FullScreenTableEditor from "@/components/online-table/FullScreenTableEditor";
|
||||
import { clamp, MIN_BLOCK_LEVEL, MAX_BLOCK_LEVEL } from "@/lib/constants";
|
||||
import { ASSETS_CHANGED_EVENT, emitAssetsChanged } from "@/lib/events";
|
||||
import { ASSETS_CHANGED_EVENT, ASSETS_RESTORED_EVENT, emitAssetsChanged } from "@/lib/events";
|
||||
import { deleteOnlineTable } from "@/lib/online-table";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import { api } from "@/lib/convex/api";
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
documentId: string;
|
||||
@@ -39,6 +44,7 @@ interface BlockNoteEditorProps {
|
||||
readOnly?: boolean;
|
||||
onStatsChange?: (stats: DocumentStats) => void;
|
||||
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
|
||||
onCloseToc?: () => void;
|
||||
}
|
||||
|
||||
const extractInitialBlocks = (content: unknown): Json | undefined => {
|
||||
@@ -172,6 +178,7 @@ export function BlockNoteEditor({
|
||||
readOnly = false,
|
||||
onStatsChange,
|
||||
onSnapshot,
|
||||
onCloseToc,
|
||||
}: BlockNoteEditorProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
|
||||
@@ -180,6 +187,26 @@ export function BlockNoteEditor({
|
||||
|
||||
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
|
||||
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
|
||||
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
|
||||
const showStructure = useAppPreferencesStore((s) => s.showStructure);
|
||||
const openCommentsForBlock = useCommentsUiStore((s) => s.openForBlock);
|
||||
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const threads = useQuery(
|
||||
api.comments.listThreadsByDocument,
|
||||
isAuthenticated && documentId ? { documentId, includeResolved: false } : "skip",
|
||||
);
|
||||
|
||||
const unresolvedCommentCountByBlockId = useMemo(() => {
|
||||
const map: Record<string, number> = {};
|
||||
if (!Array.isArray(threads)) return map;
|
||||
for (const t of threads as any[]) {
|
||||
const bid = String(t?.blockId ?? "");
|
||||
if (!bid) continue;
|
||||
map[bid] = (map[bid] ?? 0) + 1;
|
||||
}
|
||||
return map;
|
||||
}, [threads]);
|
||||
|
||||
const normalizedInitialContent = useMemo(
|
||||
() => extractInitialBlocks(initialContent),
|
||||
@@ -202,6 +229,10 @@ export function BlockNoteEditor({
|
||||
{
|
||||
initialContent: normalizedInitialContent as never,
|
||||
schema: customBlockSchema,
|
||||
placeholders: {
|
||||
default: "输入'/'选择,按 空格 打开AI...",
|
||||
emptyDocument: "输入'/'选择,按 空格 打开AI...",
|
||||
},
|
||||
collaboration: collaboration
|
||||
? {
|
||||
provider: collaboration.provider,
|
||||
@@ -243,6 +274,11 @@ export function BlockNoteEditor({
|
||||
const debouncedSave = useDebouncedCallback(saveContent, 800);
|
||||
const previousAssetsRef = useRef<Set<string>>(new Set());
|
||||
const previousMindmapBlockIdsRef = useRef<Set<string>>(new Set());
|
||||
const previousOnlineTableIdsRef = useRef<Set<string>>(new Set());
|
||||
const onlineTableDeleteTimestampsRef = useRef<Map<string, number>>(new Map());
|
||||
const onlineTableRestoreRetryCountRef = useRef<Map<string, number>>(new Map());
|
||||
const onlineTableRestoreRetryTimerRef = useRef<Map<string, number>>(new Map());
|
||||
const onlineTableRestoringRef = useRef<Set<string>>(new Set());
|
||||
const clearMindmapAutosaveCache = useCallback((targetDocumentId: string, mindmapId?: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
@@ -290,6 +326,7 @@ export function BlockNoteEditor({
|
||||
const collectAssets = useCallback((blocks: Block<CustomBlockSchema>[]) => {
|
||||
const assetIds = new Set<string>();
|
||||
const mindmapBlockIds = new Set<string>();
|
||||
const onlineTableIds = new Set<string>();
|
||||
const walk = (target: Block<CustomBlockSchema>[]) => {
|
||||
target.forEach((b) => {
|
||||
if (b.type === "media") {
|
||||
@@ -299,13 +336,17 @@ export function BlockNoteEditor({
|
||||
if (b.type === "mindmap") {
|
||||
mindmapBlockIds.add(b.id);
|
||||
}
|
||||
if (b.type === "onlineTable") {
|
||||
const id = (b.props as { tableId?: string })?.tableId;
|
||||
if (id) onlineTableIds.add(id);
|
||||
}
|
||||
if (Array.isArray(b.children) && b.children.length > 0) {
|
||||
walk(b.children as Block<CustomBlockSchema>[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
walk(blocks);
|
||||
return { assetIds, mindmapBlockIds };
|
||||
return { assetIds, mindmapBlockIds, onlineTableIds };
|
||||
}, []);
|
||||
|
||||
const deleteAssets = useCallback(
|
||||
@@ -331,6 +372,15 @@ export function BlockNoteEditor({
|
||||
const deleteMindmapAssets = useCallback(
|
||||
async (mindmapIds: string[]) => {
|
||||
if (mindmapIds.length === 0) return;
|
||||
// 关键:当用户在编辑器里“直接删除 mindmap 块”(例如 Backspace/原生删除)时,
|
||||
// React 会先卸载 MindmapBlock;若此时未及时标记“删除中”,MindmapBlock 的卸载清理会
|
||||
// 把最后一次数据 POST 回 /api/mindmap/...,导致侧边栏的 mindmap 文件看起来没有被同步删除。
|
||||
// 因此这里必须在发起 DELETE 前先标记并清理 autosave,确保卸载清理跳过持久化写回。
|
||||
const unique = Array.from(new Set(mindmapIds)).filter((id) => typeof id === "string" && id);
|
||||
unique.forEach((mindmapId) => {
|
||||
markMindmapDeleting(documentId, mindmapId);
|
||||
clearMindmapAutosaveCache(documentId, mindmapId);
|
||||
});
|
||||
await Promise.all(
|
||||
mindmapIds.map(async (mindmapId) => {
|
||||
try {
|
||||
@@ -351,6 +401,115 @@ export function BlockNoteEditor({
|
||||
[clearMindmapAutosaveCache, documentId, markMindmapDeleting],
|
||||
);
|
||||
|
||||
const deleteOnlineTables = useCallback(
|
||||
async (tableIds: string[]) => {
|
||||
const unique = Array.from(new Set(tableIds)).filter((id) => typeof id === "string" && id);
|
||||
if (unique.length === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
unique.map(async (tableId) => {
|
||||
try {
|
||||
await deleteOnlineTable(tableId);
|
||||
// 通知侧边栏/其它视图:立即从文件树移除,并触发订阅更新
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
|
||||
emitAssetsChanged(documentId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("删除在线表格失败", tableId, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
[documentId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// 清理 restore 重试计时器,避免页面卸载后继续触发网络请求
|
||||
onlineTableRestoreRetryTimerRef.current.forEach((timerId) => {
|
||||
try {
|
||||
window.clearTimeout(timerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
onlineTableRestoreRetryTimerRef.current.clear();
|
||||
onlineTableRestoreRetryCountRef.current.clear();
|
||||
onlineTableRestoringRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const restoreOnlineTableIfNeeded = useCallback(
|
||||
async (tableId: string) => {
|
||||
const ts = onlineTableDeleteTimestampsRef.current.get(tableId);
|
||||
if (!ts) return;
|
||||
|
||||
// 仅对“最近删除”的表格做恢复(用于 Ctrl+Z / Undo),避免首次加载时误触发 restore
|
||||
if (Date.now() - ts > 10 * 60 * 1000) {
|
||||
onlineTableDeleteTimestampsRef.current.delete(tableId);
|
||||
onlineTableRestoreRetryCountRef.current.delete(tableId);
|
||||
const pendingTimer = onlineTableRestoreRetryTimerRef.current.get(tableId);
|
||||
if (pendingTimer) {
|
||||
try {
|
||||
window.clearTimeout(pendingTimer);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
onlineTableRestoreRetryTimerRef.current.delete(tableId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (onlineTableRestoringRef.current.has(tableId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
onlineTableRestoringRef.current.add(tableId);
|
||||
try {
|
||||
const resp = await fetch("/api/tables/restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tableId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const prev = onlineTableRestoreRetryCountRef.current.get(tableId) ?? 0;
|
||||
const next = prev + 1;
|
||||
onlineTableRestoreRetryCountRef.current.set(tableId, next);
|
||||
if (next <= 3 && typeof window !== "undefined" && !onlineTableRestoreRetryTimerRef.current.has(tableId)) {
|
||||
const timerId = window.setTimeout(() => {
|
||||
onlineTableRestoreRetryTimerRef.current.delete(tableId);
|
||||
void restoreOnlineTableIfNeeded(tableId);
|
||||
}, 600 * next);
|
||||
onlineTableRestoreRetryTimerRef.current.set(tableId, timerId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
|
||||
emitAssetsChanged(documentId);
|
||||
}
|
||||
onlineTableDeleteTimestampsRef.current.delete(tableId);
|
||||
onlineTableRestoreRetryCountRef.current.delete(tableId);
|
||||
} catch (error) {
|
||||
console.error("恢复在线表格失败", tableId, error);
|
||||
const prev = onlineTableRestoreRetryCountRef.current.get(tableId) ?? 0;
|
||||
const next = prev + 1;
|
||||
onlineTableRestoreRetryCountRef.current.set(tableId, next);
|
||||
if (next <= 3 && typeof window !== "undefined" && !onlineTableRestoreRetryTimerRef.current.has(tableId)) {
|
||||
const timerId = window.setTimeout(() => {
|
||||
onlineTableRestoreRetryTimerRef.current.delete(tableId);
|
||||
void restoreOnlineTableIfNeeded(tableId);
|
||||
}, 600 * next);
|
||||
onlineTableRestoreRetryTimerRef.current.set(tableId, timerId);
|
||||
}
|
||||
} finally {
|
||||
onlineTableRestoringRef.current.delete(tableId);
|
||||
}
|
||||
},
|
||||
[documentId],
|
||||
);
|
||||
|
||||
// 监听侧边栏删除事件,主动移除编辑区遗留块
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
@@ -419,6 +578,48 @@ export function BlockNoteEditor({
|
||||
return () => window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
}, [clearMindmapAutosaveCache, documentId, editor, markMindmapDeleting]);
|
||||
|
||||
// 监听在线表格删除事件(文件树/其它页面触发),主动移除编辑区的 onlineTable 块,并关闭全屏窗口
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
|
||||
const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
|
||||
if (!tableId) return;
|
||||
onlineTableDeleteTimestampsRef.current.set(tableId, Date.now());
|
||||
const blocks = editor?.topLevelBlocks as Block<CustomBlockSchema>[] | undefined;
|
||||
if (!blocks || blocks.length === 0) return;
|
||||
|
||||
const toRemove: string[] = [];
|
||||
const walk = (target: Block<CustomBlockSchema>[]) => {
|
||||
target.forEach((b) => {
|
||||
if (b.type === "onlineTable") {
|
||||
const id = (b.props as { tableId?: string })?.tableId;
|
||||
if (id && id === tableId) {
|
||||
toRemove.push(b.id);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(b.children) && b.children.length > 0) {
|
||||
walk(b.children as Block<CustomBlockSchema>[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
walk(blocks);
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
try {
|
||||
editor.removeBlocks(toRemove);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setFullScreenTableId((prev) => (prev === tableId ? null : prev));
|
||||
};
|
||||
window.addEventListener("online-table-deleted", handler as EventListener);
|
||||
return () => window.removeEventListener("online-table-deleted", handler as EventListener);
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return undefined;
|
||||
@@ -439,7 +640,7 @@ export function BlockNoteEditor({
|
||||
onSnapshot?.({ blocks: blocks as Json, stats });
|
||||
|
||||
// 检测块删除 -> 同步删除附件/思维导图并刷新侧边栏
|
||||
const { assetIds, mindmapBlockIds } = collectAssets(typedBlocks);
|
||||
const { assetIds, mindmapBlockIds, onlineTableIds } = collectAssets(typedBlocks);
|
||||
const prevAssets = previousAssetsRef.current;
|
||||
const removedAssets = [...prevAssets].filter((id) => !assetIds.has(id));
|
||||
if (removedAssets.length > 0) {
|
||||
@@ -452,6 +653,19 @@ export function BlockNoteEditor({
|
||||
void deleteMindmapAssets(removedMindmaps);
|
||||
}
|
||||
previousMindmapBlockIdsRef.current = mindmapBlockIds;
|
||||
|
||||
const prevTables = previousOnlineTableIdsRef.current;
|
||||
const removedTables = [...prevTables].filter((id) => !onlineTableIds.has(id));
|
||||
if (removedTables.length > 0) {
|
||||
removedTables.forEach((id) => onlineTableDeleteTimestampsRef.current.set(id, Date.now()));
|
||||
void deleteOnlineTables(removedTables);
|
||||
}
|
||||
|
||||
const addedTables = [...onlineTableIds].filter((id) => !prevTables.has(id));
|
||||
if (addedTables.length > 0) {
|
||||
addedTables.forEach((id) => void restoreOnlineTableIfNeeded(id));
|
||||
}
|
||||
previousOnlineTableIdsRef.current = onlineTableIds;
|
||||
};
|
||||
|
||||
runSync();
|
||||
@@ -462,7 +676,7 @@ export function BlockNoteEditor({
|
||||
disposed = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, editor, onSnapshot, onStatsChange]);
|
||||
}, [collectAssets, debouncedSave, deleteAssets, deleteMindmapAssets, deleteOnlineTables, editor, onSnapshot, onStatsChange, restoreOnlineTableIfNeeded]);
|
||||
|
||||
const jumpToHeading = useCallback((headingId: string) => {
|
||||
const target = document.querySelector<HTMLElement>(`[data-id="${headingId}"]`);
|
||||
@@ -478,7 +692,8 @@ export function BlockNoteEditor({
|
||||
|
||||
const blocknoteClass = cn(
|
||||
"wolai-editor min-h-full",
|
||||
pageOptions.showStructure && "wolai-editor-show-structure",
|
||||
(showStructure || pageOptions.showStructure) && "wolai-editor-show-structure",
|
||||
pageOptions.showHeadingNumbers && "wolai-heading-numbering",
|
||||
isFullScreenTableOpen && "pointer-events-none select-none",
|
||||
);
|
||||
|
||||
@@ -523,6 +738,8 @@ const generateBlockId = () => {
|
||||
const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats => {
|
||||
let characterCount = 0;
|
||||
let wordCount = 0;
|
||||
let todoTotal = 0;
|
||||
let todoDone = 0;
|
||||
const accumulate = (targetBlocks: Block<CustomBlockSchema>[]) => {
|
||||
targetBlocks.forEach((block) => {
|
||||
if (Array.isArray(block.content)) {
|
||||
@@ -543,6 +760,24 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 待办统计:
|
||||
// - advancedTodo:取消不计入总数;done 计为完成
|
||||
// - checkListItem(BlockNote 默认块):按 checked 统计
|
||||
if (block.type === "advancedTodo") {
|
||||
const status = String((block.props as any)?.status ?? "todo");
|
||||
if (status !== "cancelled") {
|
||||
todoTotal += 1;
|
||||
if (status === "done") {
|
||||
todoDone += 1;
|
||||
}
|
||||
}
|
||||
} else if (block.type === "checkListItem") {
|
||||
todoTotal += 1;
|
||||
if (Boolean((block.props as any)?.checked)) {
|
||||
todoDone += 1;
|
||||
}
|
||||
}
|
||||
if (block.children && block.children.length > 0) {
|
||||
accumulate(block.children as Block<CustomBlockSchema>[]);
|
||||
}
|
||||
@@ -553,6 +788,8 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
wordCount,
|
||||
characterCount,
|
||||
blockCount: blocks.length,
|
||||
todoTotal,
|
||||
todoDone,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -561,15 +798,32 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const topBlocks = editor.topLevelBlocks as any[];
|
||||
// 避免重复插入(例如:恢复事件重复触发/多端同时恢复)
|
||||
const exists = topBlocks.some((b) => {
|
||||
if (b.type !== "media") return false;
|
||||
const id = (b.props as { assetId?: string })?.assetId;
|
||||
return Boolean(id && asset.id && String(id) === String(asset.id));
|
||||
});
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
const fileUrl = asset.file_url ?? "";
|
||||
if (!fileUrl) {
|
||||
return;
|
||||
}
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const referenceBlock =
|
||||
cursor?.block ?? (editor.topLevelBlocks[editor.topLevelBlocks.length - 1] as Block<CustomBlockSchema> | undefined);
|
||||
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
|
||||
if (!referenceBlock) {
|
||||
return;
|
||||
try {
|
||||
// 兜底:部分情况下(例如删除掉最后一个块)topLevelBlocks 可能为空,先补一个段落作为插入锚点
|
||||
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
|
||||
const nextTopBlocks = editor.topLevelBlocks as any[];
|
||||
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!referenceBlock) return;
|
||||
}
|
||||
editor.insertBlocks(
|
||||
[
|
||||
@@ -595,6 +849,151 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
[documentId, editor],
|
||||
);
|
||||
|
||||
const insertMindmapBlock = useCallback(
|
||||
(args: { documentId: string; mindmapId: string }) => {
|
||||
if (!editor) return;
|
||||
// 仅允许插入到当前打开的页面
|
||||
if (!args.documentId || String(args.documentId) !== String(documentId)) return;
|
||||
const mindmapId = String(args.mindmapId ?? "").trim();
|
||||
if (!mindmapId) return;
|
||||
|
||||
const topBlocks = editor.topLevelBlocks as any[];
|
||||
const exists = topBlocks.some((b) => b.type === "mindmap" && String(b.id) === mindmapId);
|
||||
if (exists) return;
|
||||
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
|
||||
if (!referenceBlock) {
|
||||
try {
|
||||
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
|
||||
const nextTopBlocks = editor.topLevelBlocks as any[];
|
||||
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!referenceBlock) return;
|
||||
}
|
||||
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
id: mindmapId,
|
||||
type: "mindmap",
|
||||
props: { docId: documentId },
|
||||
content: [],
|
||||
} as any,
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
},
|
||||
[documentId, editor],
|
||||
);
|
||||
|
||||
const insertOnlineTableBlock = useCallback(
|
||||
(args: { documentId: string; tableId: string }) => {
|
||||
if (!editor) return;
|
||||
// 仅允许插入到当前打开的页面
|
||||
if (!args.documentId || String(args.documentId) !== String(documentId)) return;
|
||||
const tableId = String(args.tableId ?? "").trim();
|
||||
if (!tableId) return;
|
||||
|
||||
const topBlocks = editor.topLevelBlocks as any[];
|
||||
const exists = topBlocks.some((b) => {
|
||||
if (b.type !== "onlineTable") return false;
|
||||
const id = (b.props as { tableId?: string })?.tableId;
|
||||
return Boolean(id && String(id) === tableId);
|
||||
});
|
||||
if (exists) return;
|
||||
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
let referenceBlock: any = cursor?.block ?? topBlocks[topBlocks.length - 1];
|
||||
if (!referenceBlock) {
|
||||
try {
|
||||
editor.replaceBlocks(editor.topLevelBlocks, [{ type: "paragraph" } as any]);
|
||||
const nextTopBlocks = editor.topLevelBlocks as any[];
|
||||
referenceBlock = nextTopBlocks[nextTopBlocks.length - 1];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!referenceBlock) return;
|
||||
}
|
||||
|
||||
editor.insertBlocks(
|
||||
[
|
||||
{
|
||||
type: "onlineTable",
|
||||
props: { tableId, title: "未命名表格" },
|
||||
content: [],
|
||||
} as any,
|
||||
],
|
||||
referenceBlock,
|
||||
"after",
|
||||
);
|
||||
},
|
||||
[documentId, editor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return undefined;
|
||||
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent)?.detail as
|
||||
| { docId?: string; kind?: "media"; assetId?: string; asset?: MediaAsset }
|
||||
| { docId?: string; kind?: "mindmap"; mindmapId?: string }
|
||||
| { docId?: string; kind?: "table"; tableId?: string };
|
||||
if (!detail || !detail.docId) return;
|
||||
if (String(detail.docId) !== String(documentId)) return;
|
||||
|
||||
if ((detail as any).kind === "mindmap") {
|
||||
const mindmapId = String((detail as any).mindmapId ?? "").trim();
|
||||
if (!mindmapId) return;
|
||||
insertMindmapBlock({ documentId: detail.docId, mindmapId });
|
||||
return;
|
||||
}
|
||||
|
||||
if ((detail as any).kind === "table") {
|
||||
const tableId = String((detail as any).tableId ?? "").trim();
|
||||
if (!tableId) return;
|
||||
insertOnlineTableBlock({ documentId: detail.docId, tableId });
|
||||
return;
|
||||
}
|
||||
|
||||
if ((detail as any).kind === "media") {
|
||||
const assetId = String((detail as any).assetId ?? "").trim();
|
||||
const asset = ((detail as any).asset ?? null) as MediaAsset | null;
|
||||
if (!assetId) return;
|
||||
void (async () => {
|
||||
// 恢复列表里的 file_url 可能为空/不可用,优先用 sign 接口拿最新可访问链接
|
||||
let fileUrl = (asset?.file_url ?? "").trim();
|
||||
if (!fileUrl) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`);
|
||||
if (res.ok) {
|
||||
const payload = (await res.json().catch(() => null)) as any;
|
||||
fileUrl = String(payload?.signedUrl ?? "").trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (!fileUrl) return;
|
||||
|
||||
insertMediaAssetBlock({
|
||||
...(asset ?? ({} as MediaAsset)),
|
||||
id: assetId,
|
||||
document_id: documentId,
|
||||
file_url: fileUrl,
|
||||
thumbnail_url: (asset?.thumbnail_url ?? fileUrl) as any,
|
||||
} as MediaAsset);
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(ASSETS_RESTORED_EVENT, handler);
|
||||
return () => window.removeEventListener(ASSETS_RESTORED_EVENT, handler);
|
||||
}, [documentId, editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock]);
|
||||
|
||||
const uploadClipboardMedia = useCallback(
|
||||
async (file: File) => {
|
||||
if (!workspaceId) {
|
||||
@@ -628,12 +1027,34 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
return;
|
||||
}
|
||||
const bridge = {
|
||||
undo: () => {
|
||||
try {
|
||||
editor.focus();
|
||||
editor.undo();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
redo: () => {
|
||||
try {
|
||||
editor.focus();
|
||||
editor.redo();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
openTableFullScreen: (tableId: string) => {
|
||||
setFullScreenTableId(tableId);
|
||||
},
|
||||
insertMediaAsset: (asset: MediaAsset) => {
|
||||
insertMediaAssetBlock(asset);
|
||||
},
|
||||
insertMindmapAsset: (args: { documentId: string; mindmapId: string }) => {
|
||||
insertMindmapBlock(args);
|
||||
},
|
||||
insertOnlineTableAsset: (args: { documentId: string; tableId: string }) => {
|
||||
insertOnlineTableBlock(args);
|
||||
},
|
||||
insertInlineReference: (target: ReferenceTarget, aliasText?: string) => {
|
||||
editor.focus();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
@@ -683,15 +1104,60 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
}
|
||||
editor.replaceBlocks(editor.topLevelBlocks, nextBlocks as never);
|
||||
},
|
||||
getCursorBlockId: () => {
|
||||
try {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
return cursor?.block?.id ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
registerEditorBridge(bridge);
|
||||
return () => registerEditorBridge(null);
|
||||
}, [editor, insertMediaAssetBlock, registerEditorBridge]);
|
||||
}, [editor, insertMediaAssetBlock, insertMindmapBlock, insertOnlineTableBlock, registerEditorBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
if (!workspaceId) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const ctrlOrMeta = event.ctrlKey || event.metaKey;
|
||||
if (!ctrlOrMeta) return;
|
||||
if (!event.altKey) return;
|
||||
const key = String(event.key ?? "").toLowerCase();
|
||||
if (key !== "m") return;
|
||||
event.preventDefault();
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
const blockId = cursor?.block?.id ?? null;
|
||||
if (blockId) {
|
||||
openCommentsForBlock({ workspaceId, documentId, blockId });
|
||||
} else {
|
||||
openCommentsForPage({ workspaceId, documentId });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [documentId, editor, openCommentsForBlock, openCommentsForPage, workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return undefined;
|
||||
}
|
||||
// 说明:BlockNote 的 contenteditable 节点不直接暴露 spellcheck props。
|
||||
// 这里用 DOM 属性实现“全局选项:拼写检查”。
|
||||
try {
|
||||
const root = document.querySelector<HTMLElement>(".wolai-editor");
|
||||
if (root) {
|
||||
root.setAttribute("spellcheck", spellCheck ? "true" : "false");
|
||||
root.querySelectorAll<HTMLElement>("[contenteditable]").forEach((el) => {
|
||||
el.setAttribute("spellcheck", spellCheck ? "true" : "false");
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
const activeElement = event.target;
|
||||
if (!(activeElement instanceof HTMLElement) || !activeElement.closest(".wolai-editor")) {
|
||||
@@ -718,7 +1184,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
};
|
||||
window.addEventListener("paste", handlePaste);
|
||||
return () => window.removeEventListener("paste", handlePaste);
|
||||
}, [editor, insertMediaAssetBlock, uploadClipboardMedia]);
|
||||
}, [editor, insertMediaAssetBlock, spellCheck, uploadClipboardMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
@@ -777,16 +1243,20 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
editor={editor}
|
||||
theme="light"
|
||||
slashMenu={false}
|
||||
data-heading-numbering={pageOptions.showHeadingNumbers ? "true" : "false"}
|
||||
sideMenu={false}
|
||||
editable={!pageOptions.protectEditing && !readOnly}
|
||||
className={blocknoteClass}
|
||||
>
|
||||
{!isFullScreenTableOpen && (
|
||||
<SideMenuController
|
||||
sideMenu={(props: SideMenuProps<CustomBlockSchema>) => (
|
||||
<CustomSideMenu {...props} currentDocumentId={documentId} workspaceId={workspaceId} />
|
||||
<CustomSideMenu
|
||||
{...props}
|
||||
currentDocumentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
unresolvedCommentCountByBlockId={unresolvedCommentCountByBlockId}
|
||||
/>
|
||||
)}
|
||||
floatingOptions={{ placement: "left" }}
|
||||
/>
|
||||
)}
|
||||
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
|
||||
@@ -795,7 +1265,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
|
||||
{isSaving ? "保存中..." : "已保存"}
|
||||
</div>
|
||||
</div>
|
||||
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} />
|
||||
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
|
||||
</div>
|
||||
|
||||
<MoveEmbedPickerHost />
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { MediaKind } from "@/types/media";
|
||||
import { emitAssetsChanged } from "@/lib/events";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import { toBrowserAccessibleUrl } from "@/lib/url/browser-file-url";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -81,6 +82,12 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
const { openPicker } = useImagePicker();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const fileUrl = block.props.fileUrl as string;
|
||||
const browserFileUrl = useMemo(() => toBrowserAccessibleUrl(fileUrl) ?? fileUrl, [fileUrl]);
|
||||
const rawThumbUrl = (block.props.thumbnailUrl as string | undefined) || "";
|
||||
const browserThumbUrl = useMemo(
|
||||
() => (rawThumbUrl ? toBrowserAccessibleUrl(rawThumbUrl) ?? rawThumbUrl : ""),
|
||||
[rawThumbUrl],
|
||||
);
|
||||
const rawAssetType = (block.props.assetType as string) || "image";
|
||||
const assetType: MediaKind =
|
||||
rawAssetType === "video" || rawAssetType === "audio" || rawAssetType === "file"
|
||||
@@ -279,7 +286,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
const openWithOnlyOffice = async () => {
|
||||
if (!fileUrl) return;
|
||||
if (!officeBase) {
|
||||
window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
|
||||
window.alert("缺少 ONLYOFFICE 地址,请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
|
||||
return;
|
||||
}
|
||||
const currentDocReadOnly = useCurrentDocumentStore.getState().readOnly;
|
||||
@@ -400,10 +407,10 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
<video
|
||||
controls
|
||||
className="max-h-[420px] w-full rounded-2xl bg-black"
|
||||
poster={block.props.thumbnailUrl || undefined}
|
||||
poster={browserThumbUrl || undefined}
|
||||
style={resolvedWidth ? { width: `${resolvedWidth}px` } : undefined}
|
||||
>
|
||||
<source src={fileUrl} type={block.props.mimeType || "video/mp4"} />
|
||||
<source src={browserFileUrl} type={block.props.mimeType || "video/mp4"} />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
@@ -411,7 +418,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white/80 p-5">
|
||||
<audio controls className="w-full">
|
||||
<source src={fileUrl} type={block.props.mimeType || "audio/mpeg"} />
|
||||
<source src={browserFileUrl} type={block.props.mimeType || "audio/mpeg"} />
|
||||
</audio>
|
||||
<p className="mt-2 text-sm text-gray-500">{displayFileName}</p>
|
||||
</div>
|
||||
@@ -533,7 +540,13 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
);
|
||||
}
|
||||
const inlineStyle = resolvedWidth ? { width: `${resolvedWidth}px` } : undefined;
|
||||
return <img src={block.props.thumbnailUrl || fileUrl} alt={block.props.caption || typeLabel} style={inlineStyle} />;
|
||||
return (
|
||||
<img
|
||||
src={browserThumbUrl || browserFileUrl}
|
||||
alt={block.props.caption || typeLabel}
|
||||
style={inlineStyle}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const figure = (
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ChevronLeft, History, Network, Plus, Settings, Settings2, Send, Sparkle
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
|
||||
type AgentAssetItem = {
|
||||
kind: "media" | "local-mindmap" | "test-pdf";
|
||||
@@ -158,11 +159,12 @@ export function MindmapAiAgentPanel({
|
||||
activeNodes: unknown[];
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const flightMode = useAppPreferencesStore((s) => s.flightMode);
|
||||
const [messages, setMessages] = useState<AgentMessage[]>(() => DEFAULT_SESSION_MESSAGES);
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [networkOn, setNetworkOn] = useState(true);
|
||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||
const [toolAuto, setToolAuto] = useState(true);
|
||||
// 兼容旧 UI(已隐藏),避免影响已有交互与回滚风险
|
||||
const [toolPickerOpen, setToolPickerOpen] = useState(false);
|
||||
@@ -185,6 +187,12 @@ export function MindmapAiAgentPanel({
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const syncTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (flightMode) {
|
||||
setNetworkOn(false);
|
||||
}
|
||||
}, [flightMode]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const p = (window.localStorage.getItem("mindmap_ai_provider") || "").trim();
|
||||
|
||||
@@ -34,6 +34,9 @@ import { Input } from "@/components/ui/input";
|
||||
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import iconConfig from "./mindmapIconConfig";
|
||||
import { emitAssetsChanged } from "@/lib/events";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { useQuery } from "convex/react";
|
||||
|
||||
// 避免 SSR/Node 环境提前评估 simple-mind-map 内部依赖 document
|
||||
const loadIconModules = async () => {
|
||||
@@ -462,6 +465,11 @@ const MindmapBlockView = ({
|
||||
const mindmapReadyRef = useRef(false);
|
||||
const mindmapRef = useRef<MindMapInstance | null>(null);
|
||||
const hasLocalEditsRef = useRef(false);
|
||||
const lastLocalEditAtRef = useRef(0);
|
||||
const markLocalEdited = useCallback(() => {
|
||||
hasLocalEditsRef.current = true;
|
||||
lastLocalEditAtRef.current = Date.now();
|
||||
}, []);
|
||||
const applyingRemoteRef = useRef(false);
|
||||
const [canBack, setCanBack] = useState(false);
|
||||
const [canForward, setCanForward] = useState(false);
|
||||
@@ -513,7 +521,10 @@ const MindmapBlockView = ({
|
||||
if (!docId) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}`);
|
||||
// 说明:仅用于拿 workspaceId(上传图片需要),避免拉取整套 AI 资产列表导致打开页面变慢。
|
||||
const res = await fetch(
|
||||
`/api/mindmap-ai/assets?documentId=${encodeURIComponent(docId)}&workspaceOnly=1`,
|
||||
);
|
||||
const json: unknown = await res.json().catch(() => null);
|
||||
if (!res.ok) return;
|
||||
if (cancelled) return;
|
||||
@@ -530,6 +541,7 @@ const MindmapBlockView = ({
|
||||
|
||||
useEffect(() => {
|
||||
hasLocalEditsRef.current = false;
|
||||
lastLocalEditAtRef.current = 0;
|
||||
applyingRemoteRef.current = false;
|
||||
}, [docId]);
|
||||
|
||||
@@ -537,6 +549,15 @@ const MindmapBlockView = ({
|
||||
if (docId) return `${STORAGE_PREFIX}${docId}:${mindmapId}`;
|
||||
return `${STORAGE_PREFIX}${mindmapId}`;
|
||||
}, [docId, mindmapId]);
|
||||
|
||||
// 记录本端最近一次成功写入到后端的 updated_at,用于避免 Convex 订阅回放覆盖(清空历史/打断编辑)。
|
||||
const lastLocalSavedAtRef = useRef<string | null>(null);
|
||||
const lastAppliedRemoteUpdatedAtRef = useRef<string | null>(null);
|
||||
|
||||
const remoteMindmap = useQuery(
|
||||
api.mindmaps.get,
|
||||
isConvexEnabled() && docId ? { docId, mindmapId } : "skip",
|
||||
);
|
||||
const initialDataRef = useRef<unknown>(null);
|
||||
if (initialDataRef.current === null) {
|
||||
const cached = typeof window !== "undefined" ? window.localStorage.getItem(autosaveKey) : null;
|
||||
@@ -584,6 +605,83 @@ const MindmapBlockView = ({
|
||||
};
|
||||
}, [docId, mindmap, mindmapId]);
|
||||
|
||||
// Convex 实时订阅:当其它客户端更新/删除导图时,同步到当前实例。
|
||||
useEffect(() => {
|
||||
if (!remoteMindmap || typeof remoteMindmap !== "object") return;
|
||||
|
||||
const meta = (remoteMindmap as any).meta as Record<string, unknown> | undefined;
|
||||
const deletedAt = typeof meta?.deleted_at === "string" ? (meta.deleted_at as string) : null;
|
||||
const updatedAt = typeof meta?.updated_at === "string" ? (meta.updated_at as string) : null;
|
||||
|
||||
if (deletedAt) {
|
||||
if (!docId || deletingRef.current) return;
|
||||
deletingRef.current = true;
|
||||
try {
|
||||
window.localStorage.removeItem(autosaveKey);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 全屏页:退出到文档页
|
||||
if (effectiveFullscreen && typeof onExitFullscreen === "function") {
|
||||
window.alert("该思维导图已被删除(已移入垃圾桶),将返回页面。");
|
||||
onExitFullscreen();
|
||||
return;
|
||||
}
|
||||
|
||||
// 嵌入编辑器:复用编辑器监听链路移除块
|
||||
emitAssetsChanged(docId, undefined, undefined, false, [mindmapId]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updatedAt) return;
|
||||
if (lastAppliedRemoteUpdatedAtRef.current === updatedAt) return;
|
||||
// 如果这是本端刚刚保存产生的回放,跳过应用,避免清空历史/打断输入
|
||||
if (lastLocalSavedAtRef.current && lastLocalSavedAtRef.current === updatedAt) {
|
||||
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
|
||||
return;
|
||||
}
|
||||
// 本地仍有未同步编辑时,不覆盖
|
||||
if (hasLocalEditsRef.current || deletingRef.current) return;
|
||||
|
||||
const incoming = canonicalizeMindmapData((remoteMindmap as any).data ?? defaultMindmapData);
|
||||
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
|
||||
initialDataRef.current = incoming;
|
||||
try {
|
||||
window.localStorage.setItem(autosaveKey, JSON.stringify(incoming));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!effectiveFullscreen) {
|
||||
try {
|
||||
editor.updateBlock(block, { props: { ...block.props, data: incoming } });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (mindmap) {
|
||||
applyingRemoteRef.current = true;
|
||||
try {
|
||||
mindmap.setData(incoming);
|
||||
mindmap.command.clearHistory();
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
applyingRemoteRef.current = false;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
autosaveKey,
|
||||
block,
|
||||
docId,
|
||||
editor,
|
||||
effectiveFullscreen,
|
||||
mindmap,
|
||||
mindmapId,
|
||||
onExitFullscreen,
|
||||
remoteMindmap,
|
||||
]);
|
||||
|
||||
// 记录“最近一次指针交互是否发生在思维导图块内”,用于键盘快捷键作用域
|
||||
useEffect(() => {
|
||||
const onPointerDownCapture = (e: Event) => {
|
||||
@@ -956,7 +1054,7 @@ const MindmapBlockView = ({
|
||||
// 兜底:某些情况下 INSERT_NODE 不触发 data_change(例如被外层捕获键盘
|
||||
// 事件拦截导致内部 Keyboard 插件不走),这里主动做一次防抖保存,确保
|
||||
// 切换全屏/刷新后不会丢失。
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -974,7 +1072,7 @@ const MindmapBlockView = ({
|
||||
if (!node) return;
|
||||
inst.execCommand?.("SET_NODE_ACTIVE", node, true);
|
||||
inst.execCommand?.("INSERT_CHILD_NODE", false, [node]);
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -993,7 +1091,7 @@ const MindmapBlockView = ({
|
||||
const inst = ensureActiveBefore(mm);
|
||||
if (!inst) return;
|
||||
inst.execCommand?.("REMOVE_NODE");
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -1029,7 +1127,7 @@ const MindmapBlockView = ({
|
||||
const copyData = renderer.beingCopyData ?? null;
|
||||
if (copyData) {
|
||||
inst.execCommand?.("PASTE_NODE", copyData);
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -1039,7 +1137,7 @@ const MindmapBlockView = ({
|
||||
return;
|
||||
}
|
||||
renderer.paste?.();
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -1053,7 +1151,7 @@ const MindmapBlockView = ({
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDownCapture, true);
|
||||
};
|
||||
}, []);
|
||||
}, [markLocalEdited]);
|
||||
|
||||
// 兜底:在非全屏嵌入 BlockNote 时,Ctrl+V 可能仍然触发编辑器的 paste,导致思维导图块被替换成纯文本。
|
||||
// 这里在 capture 阶段拦截 paste:当“最近一次指针交互在思维导图块内”且当前不在节点文本编辑态时,
|
||||
@@ -1114,7 +1212,7 @@ const MindmapBlockView = ({
|
||||
}
|
||||
|
||||
// 兜底持久化:避免快速切换视图导致“看起来没保存”
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -1127,7 +1225,7 @@ const MindmapBlockView = ({
|
||||
return () => {
|
||||
window.removeEventListener("paste", onPasteCapture, true);
|
||||
};
|
||||
}, []);
|
||||
}, [markLocalEdited]);
|
||||
|
||||
// 兜底:Ctrl+C 可能被 BlockNote/ProseMirror 先行拦截,导致我们 keydown 捕获不到。
|
||||
// 这里直接在 copy 事件的 capture 阶段接管,确保“选中节点 -> Ctrl+C”一定能复制节点数据。
|
||||
@@ -1243,7 +1341,7 @@ const MindmapBlockView = ({
|
||||
// ignore
|
||||
}
|
||||
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot = inst.getData?.(true) ?? inst.getData?.() ?? null;
|
||||
if (snapshot) persistDataRef.current?.(snapshot);
|
||||
@@ -1256,7 +1354,7 @@ const MindmapBlockView = ({
|
||||
return () => {
|
||||
window.removeEventListener("beforeinput", onBeforeInputCapture, true);
|
||||
};
|
||||
}, []);
|
||||
}, [markLocalEdited]);
|
||||
|
||||
const persistData = useCallback(
|
||||
(data: unknown) => {
|
||||
@@ -1279,14 +1377,30 @@ const MindmapBlockView = ({
|
||||
editor.updateBlock(block, { props: { ...block.props, data: safe } });
|
||||
}
|
||||
if (docId) {
|
||||
// 同步到本地文件 + Supabase(弱依赖)
|
||||
// 同步到本地文件 + Convex(弱依赖)
|
||||
const requestStartedAt = Date.now();
|
||||
fetch(`/api/mindmap/${docId}/${mindmapId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ data: safe }),
|
||||
})
|
||||
.then((resp) => {
|
||||
.then(async (resp) => {
|
||||
if (resp.ok) {
|
||||
try {
|
||||
const payload = (await resp.json().catch(() => null)) as any;
|
||||
const updatedAt = payload && typeof payload.updated_at === "string" ? payload.updated_at : null;
|
||||
if (updatedAt) {
|
||||
lastLocalSavedAtRef.current = updatedAt;
|
||||
// 避免 Convex 订阅回放对本端“已应用的保存”重复 setData/clearHistory 导致闪烁
|
||||
lastAppliedRemoteUpdatedAtRef.current = updatedAt;
|
||||
// 仅当保存期间没有新增编辑时,才允许接收远端更新;否则会出现“插入节点后闪一下又没了”
|
||||
if (lastLocalEditAtRef.current <= requestStartedAt) {
|
||||
hasLocalEditsRef.current = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const fileName = `mindmap-${mindmapId}.json`;
|
||||
emitAssetsChanged(docId, {
|
||||
id: mindmapId,
|
||||
@@ -1699,7 +1813,7 @@ const MindmapBlockView = ({
|
||||
!deletingRef.current &&
|
||||
shouldPersistAfterCommand(cmd)
|
||||
) {
|
||||
hasLocalEditsRef.current = true;
|
||||
markLocalEdited();
|
||||
try {
|
||||
const snapshot =
|
||||
instance.getData?.(true) ?? instance.getData?.() ?? null;
|
||||
@@ -1845,13 +1959,13 @@ const MindmapBlockView = ({
|
||||
});
|
||||
instance.on?.("painter_start", () => setPainterMode(true));
|
||||
instance.on?.("painter_end", () => setPainterMode(false));
|
||||
instance.on?.("data_change", () => {
|
||||
if (!applyingRemoteRef.current) {
|
||||
hasLocalEditsRef.current = true;
|
||||
}
|
||||
// data_change 的参数在部分场景下可能不是“可直接序列化的完整数据”
|
||||
//(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。
|
||||
// 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。
|
||||
instance.on?.("data_change", () => {
|
||||
if (!applyingRemoteRef.current) {
|
||||
markLocalEdited();
|
||||
}
|
||||
// data_change 的参数在部分场景下可能不是“可直接序列化的完整数据”
|
||||
//(例如 children 非数组、text 缺失等),全屏切换重建时会触发 RichText 初始化崩溃。
|
||||
// 这里统一改为从实例读取快照,确保保存/恢复的数据结构稳定。
|
||||
const snapshot =
|
||||
instance.getData?.(true) ?? instance.getData?.() ?? null;
|
||||
if (snapshot) schedulePersist(snapshot);
|
||||
@@ -2688,7 +2802,7 @@ const MindmapBlockView = ({
|
||||
editor.removeBlocks([block.id]);
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm("确认删除当前思维导图?此操作会移除文件并清空侧边栏记录。");
|
||||
const confirmed = window.confirm("确认删除当前思维导图?将移入垃圾桶(10 分钟内可恢复),到期将自动清理。");
|
||||
if (!confirmed) return;
|
||||
deletingRef.current = true;
|
||||
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, { method: "DELETE" });
|
||||
|
||||
@@ -11,7 +11,15 @@ const normalizeTitle = (value?: string | null) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string }) => {
|
||||
const PageReferenceContent = ({
|
||||
pageId,
|
||||
title,
|
||||
asChildPage,
|
||||
}: {
|
||||
pageId: string;
|
||||
title: string;
|
||||
asChildPage: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
// 说明:Convex 迁移阶段,直接使用 block props 里的 title,不做实时订阅/拉取。
|
||||
// 页面引用的标题会由编辑器同步更新 block.props.title。
|
||||
@@ -25,6 +33,7 @@ const PageReferenceContent = ({ pageId, title }: { pageId: string; title: string
|
||||
|
||||
return (
|
||||
<div
|
||||
data-child-page={asChildPage ? "true" : "false"}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={navigate}
|
||||
@@ -52,10 +61,17 @@ export const pageReferenceBlock = createReactBlockSpec(
|
||||
propSchema: {
|
||||
pageId: { default: "" },
|
||||
title: { default: "未命名页面" },
|
||||
asChildPage: { default: false },
|
||||
},
|
||||
content: "none",
|
||||
},
|
||||
() => ({
|
||||
render: ({ block }) => <PageReferenceContent pageId={block.props.pageId} title={block.props.title} />,
|
||||
render: ({ block }) => (
|
||||
<PageReferenceContent
|
||||
pageId={block.props.pageId}
|
||||
title={block.props.title}
|
||||
asChildPage={Boolean((block.props as any).asChildPage)}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
)();
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||
import { MessageSquare, CornerDownRight, CheckCircle2, Circle, ExternalLink } from "lucide-react";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const makeId = (): string => {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
};
|
||||
|
||||
const jumpToBlock = (blockId: string) => {
|
||||
if (!blockId) return;
|
||||
const target = document.querySelector<HTMLElement>(`[data-id="${blockId}"]`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
} else {
|
||||
window.alert("未找到对应块(可能已被删除或未渲染)");
|
||||
}
|
||||
};
|
||||
|
||||
export function DocumentCommentsDrawer() {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const open = useCommentsUiStore((s) => s.open);
|
||||
const target = useCommentsUiStore((s) => s.target);
|
||||
const close = useCommentsUiStore((s) => s.close);
|
||||
|
||||
const documentId = target?.documentId ?? "";
|
||||
const workspaceId = target?.workspaceId ?? "";
|
||||
const focusBlockId = target?.blockId ?? null;
|
||||
|
||||
const [includeResolved, setIncludeResolved] = useState(false);
|
||||
const threads = useQuery(
|
||||
api.comments.listThreadsByDocument,
|
||||
open && isAuthenticated && documentId ? { documentId, includeResolved } : "skip",
|
||||
);
|
||||
const currentUser = useQuery(api.users.currentUser, open && isAuthenticated ? {} : "skip");
|
||||
|
||||
const createThread = useMutation(api.comments.createThread);
|
||||
const reply = useMutation(api.comments.reply);
|
||||
const setResolved = useMutation(api.comments.setResolved);
|
||||
const editMessage = useMutation(api.comments.editMessage);
|
||||
const deleteMessage = useMutation(api.comments.deleteMessage);
|
||||
|
||||
// 说明:messages 需要 threadId;在选择线程后再订阅。
|
||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
|
||||
const activeMessages = useQuery(
|
||||
api.comments.listMessagesByThread,
|
||||
open && isAuthenticated && activeThreadId ? { threadId: activeThreadId } : "skip",
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"page" | "block">("page");
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setActiveTab(focusBlockId ? "block" : "page");
|
||||
setActiveThreadId(null);
|
||||
setEditingMessageId(null);
|
||||
setEditingDraft("");
|
||||
}, [focusBlockId, open]);
|
||||
|
||||
const { pageThreads, blockThreads } = useMemo(() => {
|
||||
const list = Array.isArray(threads) ? threads : [];
|
||||
const pageThreads = list.filter((t: any) => !t.blockId);
|
||||
const blockThreads = list.filter((t: any) => Boolean(t.blockId));
|
||||
return { pageThreads, blockThreads };
|
||||
}, [threads]);
|
||||
|
||||
const blockThreadsForFocus = useMemo(() => {
|
||||
if (!focusBlockId) return blockThreads;
|
||||
return blockThreads.filter((t: any) => String(t.blockId) === String(focusBlockId));
|
||||
}, [blockThreads, focusBlockId]);
|
||||
|
||||
const activeThread = useMemo(() => {
|
||||
const list = Array.isArray(threads) ? threads : [];
|
||||
return list.find((t: any) => String(t.id) === String(activeThreadId)) ?? null;
|
||||
}, [activeThreadId, threads]);
|
||||
|
||||
const [draft, setDraft] = useState("");
|
||||
const [replyDraft, setReplyDraft] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||
const [editingDraft, setEditingDraft] = useState("");
|
||||
|
||||
const submitNewThread = async (blockId: string | null) => {
|
||||
if (!documentId || !workspaceId) return;
|
||||
const body = draft.trim();
|
||||
if (!body) {
|
||||
window.alert("请输入评论内容");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const threadId = makeId();
|
||||
const messageId = makeId();
|
||||
await createThread({
|
||||
id: threadId,
|
||||
documentId,
|
||||
workspaceId,
|
||||
blockId,
|
||||
messageId,
|
||||
body,
|
||||
});
|
||||
setDraft("");
|
||||
setActiveThreadId(threadId);
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : "创建评论失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitReply = async () => {
|
||||
if (!activeThreadId) return;
|
||||
const body = replyDraft.trim();
|
||||
if (!body) {
|
||||
window.alert("请输入回复内容");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await reply({ threadId: activeThreadId, messageId: makeId(), body });
|
||||
setReplyDraft("");
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : "回复失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!editingMessageId) return;
|
||||
const body = editingDraft.trim();
|
||||
if (!body) {
|
||||
window.alert("请输入评论内容");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await editMessage({ messageId: editingMessageId, body });
|
||||
setEditingMessageId(null);
|
||||
setEditingDraft("");
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : "编辑失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitDelete = async (messageId: string) => {
|
||||
const ok = window.confirm("确定删除这条评论吗?");
|
||||
if (!ok) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await deleteMessage({ messageId });
|
||||
if (editingMessageId === messageId) {
|
||||
setEditingMessageId(null);
|
||||
setEditingDraft("");
|
||||
}
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleResolved = async () => {
|
||||
if (!activeThreadId || !activeThread) return;
|
||||
const next = !activeThread.resolvedAt;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await setResolved({ threadId: activeThreadId, resolved: next });
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : "更新状态失败");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const ThreadList = ({ list }: { list: any[] }) => {
|
||||
if (!list.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-[#e2e8f0] px-4 py-10 text-center text-sm text-gray-500">
|
||||
暂无评论
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{list.map((t) => {
|
||||
const isActive = String(t.id) === String(activeThreadId);
|
||||
const resolved = Boolean(t.resolvedAt);
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors",
|
||||
isActive ? "border-[#c7d2fe] bg-[#eef2ff]" : "border-[#e2e8f0] bg-white hover:bg-gray-50",
|
||||
)}
|
||||
onClick={() => setActiveThreadId(t.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
{resolved ? (
|
||||
<span className="inline-flex items-center gap-1 text-green-700">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
已解决
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-gray-500">
|
||||
<Circle className="h-4 w-4" />
|
||||
进行中
|
||||
</span>
|
||||
)}
|
||||
<span>·</span>
|
||||
<span>{t.commentCount} 条</span>
|
||||
{t.blockId ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
块评论
|
||||
<ExternalLink
|
||||
className="h-3.5 w-3.5"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
jumpToBlock(String(t.blockId));
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 truncate font-medium text-gray-900">
|
||||
{t.lastCommentPreview || "(无预览)"}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{t.lastCommentBy?.name || "匿名"} · {new Date(t.lastActivityAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadDetail = () => {
|
||||
if (!activeThreadId || !activeThread) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-[#e2e8f0] px-4 py-10 text-center text-sm text-gray-500">
|
||||
选择一条评论后查看详情
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const list = Array.isArray(activeMessages) ? activeMessages : [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between rounded-lg border border-[#e2e8f0] bg-white px-4 py-3">
|
||||
<div className="text-sm font-medium text-gray-800">
|
||||
{activeThread.blockId ? "块评论" : "页面评论"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeThread.blockId ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => jumpToBlock(String(activeThread.blockId))}
|
||||
>
|
||||
定位到块
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" size="sm" variant="outline" onClick={toggleResolved} disabled={submitting}>
|
||||
{activeThread.resolvedAt ? "取消解决" : "标记解决"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[38vh] space-y-2 overflow-y-auto rounded-lg border border-[#eef2ff] bg-[#fbfbff] p-3">
|
||||
{list.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-gray-500">加载中...</div>
|
||||
) : (
|
||||
list.map((m: any) => (
|
||||
<div key={m.id} className="rounded-md border border-[#e2e8f0] bg-white p-3 text-sm">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>{m.createdBy?.name || "匿名"}</span>
|
||||
<span>{new Date(m.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className={cn("mt-2 whitespace-pre-wrap text-gray-800", m.deletedAt && "text-gray-400")}>
|
||||
{m.deletedAt ? (
|
||||
"该评论已删除"
|
||||
) : editingMessageId === String(m.id) ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={editingDraft}
|
||||
onChange={(e) => setEditingDraft(e.target.value)}
|
||||
className="min-h-[90px]"
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" onClick={submitEdit} disabled={submitting}>
|
||||
保存
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingMessageId(null);
|
||||
setEditingDraft("");
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
m.body
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!m.deletedAt && editingMessageId !== String(m.id) ? (
|
||||
<div className="mt-2 flex gap-2 text-xs">
|
||||
{currentUser && String((currentUser as any)?._id ?? "") === String(m.createdBy?.id ?? "") ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setEditingMessageId(String(m.id));
|
||||
setEditingDraft(String(m.body ?? ""));
|
||||
}}
|
||||
disabled={submitting}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => submitDelete(String(m.id))}
|
||||
disabled={submitting}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-[#e2e8f0] bg-white p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<CornerDownRight className="h-4 w-4" />
|
||||
回复
|
||||
</div>
|
||||
<Textarea
|
||||
value={replyDraft}
|
||||
onChange={(e) => setReplyDraft(e.target.value)}
|
||||
placeholder="输入回复内容..."
|
||||
className="mt-2 min-h-20"
|
||||
/>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button type="button" onClick={submitReply} disabled={submitting}>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) close();
|
||||
}}
|
||||
>
|
||||
<DrawerContent className="max-h-[92vh]">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
评论
|
||||
</DrawerTitle>
|
||||
<DrawerDescription>支持页面评论与块评论(先实现核心闭环)。</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
|
||||
{!isAuthenticated ? (
|
||||
<div className="px-4 pb-6 text-sm text-gray-500">未登录,无法查看评论。</div>
|
||||
) : !documentId ? (
|
||||
<div className="px-4 pb-6 text-sm text-gray-500">缺少 documentId。</div>
|
||||
) : (
|
||||
<div className="grid gap-4 px-4 pb-6 lg:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-semibold text-gray-800">线程列表</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIncludeResolved((prev) => !prev)}
|
||||
>
|
||||
{includeResolved ? "隐藏已解决" : "显示已解决"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="page" className="flex-1">
|
||||
页面评论({pageThreads.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="block" className="flex-1">
|
||||
块评论({blockThreads.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="page" className="mt-3 space-y-3">
|
||||
<div className="rounded-lg border border-[#e2e8f0] bg-white p-3">
|
||||
<div className="text-xs font-medium text-gray-600">新建页面评论</div>
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="输入评论内容..."
|
||||
className="mt-2 min-h-20"
|
||||
/>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button type="button" onClick={() => submitNewThread(null)} disabled={submitting}>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ThreadList list={pageThreads} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="block" className="mt-3 space-y-3">
|
||||
<div className="rounded-lg border border-[#e2e8f0] bg-white p-3">
|
||||
<div className="text-xs font-medium text-gray-600">新建块评论</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Input
|
||||
value={focusBlockId ?? ""}
|
||||
readOnly
|
||||
placeholder="从块菜单进入后会自动带上 blockId"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!focusBlockId}
|
||||
onClick={() => (focusBlockId ? jumpToBlock(focusBlockId) : null)}
|
||||
>
|
||||
定位
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={focusBlockId ? "对该块发表评论..." : "请从块菜单进入以指定 blockId"}
|
||||
className="mt-2 min-h-20"
|
||||
disabled={!focusBlockId}
|
||||
/>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => submitNewThread(focusBlockId)}
|
||||
disabled={submitting || !focusBlockId}
|
||||
>
|
||||
发送
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ThreadList list={blockThreadsForFocus} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-semibold text-gray-800">线程详情</div>
|
||||
<ThreadDetail />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
|
||||
import type { BooleanPageOptionKey, DocumentStats, PageOptionsState, PageFont, PageLayoutDensity } from "@/types/page-options";
|
||||
import { PageOptionsSidebar } from "@/components/editor/page-options-sidebar";
|
||||
import { PageBacklinksPanel } from "@/components/editor/page-backlinks-panel";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
@@ -10,12 +10,17 @@ import { usePageLayoutStore } from "@/store/page-layout";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import type { Json } from "@/types/supabase";
|
||||
import { DocumentHistoryDrawer } from "@/components/editor/document-history-drawer";
|
||||
import { DocumentCommentsDrawer } from "@/components/editor/document-comments-drawer";
|
||||
import type { DocumentSnapshot } from "@/types/document";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { ImagePickerProvider } from "@/components/media/image-picker-context";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { DocumentAiAgentPanel } from "./DocumentAiAgentPanel";
|
||||
import { CONTENT_LOADING_DELAY_MS } from "@/lib/constants";
|
||||
import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BlockNoteEditor = dynamic(
|
||||
() => import("@/components/editor/blocknote-editor").then((mod) => mod.BlockNoteEditor),
|
||||
@@ -49,8 +54,14 @@ const defaultOptions: PageOptionsState = {
|
||||
showStructure: false,
|
||||
protectEditing: false,
|
||||
showWordCount: true,
|
||||
collapseBacklinks: false,
|
||||
pageFont: "default",
|
||||
layoutDensity: "normal",
|
||||
hideChildPages: false,
|
||||
showBlockRefCount: false,
|
||||
embedDefaultBlockId: null,
|
||||
};
|
||||
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0 };
|
||||
const defaultStats: DocumentStats = { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 };
|
||||
|
||||
export function DocumentContent({
|
||||
documentId,
|
||||
@@ -74,6 +85,9 @@ export function DocumentContent({
|
||||
const editorBridge = useEditorBridgeStore((state) => state.bridge);
|
||||
const router = useRouter();
|
||||
const showInspector = usePageLayoutStore((state) => state.showInspector);
|
||||
const openCommentsForPage = useCommentsUiStore((s) => s.openForPage);
|
||||
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
|
||||
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
||||
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
|
||||
const [content, setContent] = useState<unknown>(initialContent);
|
||||
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
|
||||
@@ -314,15 +328,221 @@ export function DocumentContent({
|
||||
[documentId, readOnly],
|
||||
);
|
||||
|
||||
const toggleOption = (key: keyof PageOptionsState) => {
|
||||
const toggleOption = useCallback(
|
||||
(key: BooleanPageOptionKey) => {
|
||||
if (readOnly) return;
|
||||
setOptions((prev) => {
|
||||
const nextValue = !prev[key];
|
||||
const next = { ...prev, [key]: nextValue };
|
||||
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[persistOptions, readOnly],
|
||||
);
|
||||
|
||||
const setOptionPatch = useCallback(
|
||||
(patch: Partial<PageOptionsState>) => {
|
||||
if (readOnly) return;
|
||||
setOptions((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
void persistOptions(patch);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[persistOptions, readOnly],
|
||||
);
|
||||
|
||||
const closeToc = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
setOptions((prev) => {
|
||||
const nextValue = !prev[key];
|
||||
const next = { ...prev, [key]: nextValue };
|
||||
void persistOptions({ [key]: nextValue } as Partial<PageOptionsState>);
|
||||
if (!prev.showToc) return prev;
|
||||
const next = { ...prev, showToc: false };
|
||||
void persistOptions({ showToc: false });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
}, [persistOptions, readOnly]);
|
||||
|
||||
const handleSetPageFont = useCallback(
|
||||
(font: PageFont) => {
|
||||
setOptionPatch({ pageFont: font });
|
||||
},
|
||||
[setOptionPatch],
|
||||
);
|
||||
|
||||
const handleSetLayoutDensity = useCallback(
|
||||
(density: PageLayoutDensity) => {
|
||||
setOptionPatch({ layoutDensity: density });
|
||||
},
|
||||
[setOptionPatch],
|
||||
);
|
||||
|
||||
const handleSetEmbedDefaultToCursor = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
const blockId = editorBridge?.getCursorBlockId?.() ?? null;
|
||||
if (!blockId) {
|
||||
window.alert("未找到当前光标所在块,请先在编辑器中点击任意块");
|
||||
return;
|
||||
}
|
||||
setOptionPatch({ embedDefaultBlockId: blockId });
|
||||
window.alert("已设置“嵌入默认位置”");
|
||||
}, [editorBridge, readOnly, setOptionPatch]);
|
||||
|
||||
const handleClearEmbedDefault = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
setOptionPatch({ embedDefaultBlockId: null });
|
||||
window.alert("已清除“嵌入默认位置”");
|
||||
}, [readOnly, setOptionPatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const root = pageRootRef.current;
|
||||
if (root) {
|
||||
const target = event.target;
|
||||
if (target && target instanceof Node && !root.contains(target)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const ctrlOrMeta = event.ctrlKey || event.metaKey;
|
||||
if (!ctrlOrMeta) return;
|
||||
if (!event.shiftKey) return;
|
||||
const key = String(event.key ?? "").toLowerCase();
|
||||
if (key === "l") {
|
||||
event.preventDefault();
|
||||
toggleOption("showToc");
|
||||
} else if (key === "c") {
|
||||
event.preventDefault();
|
||||
toggleOption("protectEditing");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [toggleOption]);
|
||||
|
||||
const buildDocumentUrl = useCallback((id: string): string => {
|
||||
if (typeof window === "undefined" || !window.location) {
|
||||
return `/documents/${id}`;
|
||||
}
|
||||
return `${window.location.origin}/documents/${id}`;
|
||||
}, []);
|
||||
|
||||
const copyText = useCallback(async (text: string, successMessage: string) => {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
window.alert(successMessage);
|
||||
return;
|
||||
} catch {
|
||||
// ignore and fallback
|
||||
}
|
||||
}
|
||||
window.prompt("复制失败,请手动复制内容", text);
|
||||
}, []);
|
||||
|
||||
const handleCopyPageLink = useCallback(
|
||||
async (includeTitle: boolean) => {
|
||||
const url = buildDocumentUrl(documentId);
|
||||
if (includeTitle) {
|
||||
const text = `${pageTitle || "无标题"}\n${url}`;
|
||||
await copyText(text, "标题 + 链接已复制");
|
||||
return;
|
||||
}
|
||||
await copyText(url, "页面链接已复制");
|
||||
},
|
||||
[buildDocumentUrl, copyText, documentId, pageTitle],
|
||||
);
|
||||
|
||||
const handleCopyPageReference = useCallback(
|
||||
async (mode: "inline" | "embed") => {
|
||||
const template = mode === "inline" ? `((${documentId}))` : `{{${documentId}}}`;
|
||||
await copyText(template, mode === "inline" ? "行内引用已复制" : "嵌入引用已复制");
|
||||
},
|
||||
[copyText, documentId],
|
||||
);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
editorBridge?.undo?.();
|
||||
}, [editorBridge]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
editorBridge?.redo?.();
|
||||
}, [editorBridge]);
|
||||
|
||||
const handleDeletePage = useCallback(async () => {
|
||||
if (readOnly) return;
|
||||
const ok = window.confirm("确定删除该页面吗?删除后会进入垃圾桶。");
|
||||
if (!ok) return;
|
||||
const resp = await fetch("/api/documents/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除失败");
|
||||
return;
|
||||
}
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}, [documentId, readOnly, router]);
|
||||
|
||||
const handleOpenMoveEmbed = useCallback(() => {
|
||||
if (readOnly) return;
|
||||
openMoveEmbedPicker({
|
||||
workspaceId,
|
||||
defaultMode: "move",
|
||||
modes: ["move", "embed"],
|
||||
allowRoot: true,
|
||||
excludeIds: [documentId],
|
||||
onPick: async (mode, targetId) => {
|
||||
if (mode === "move") {
|
||||
const resp = await fetch("/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, parentId: targetId ?? null, position: 999999 }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "移动失败");
|
||||
return;
|
||||
}
|
||||
window.alert("移动成功");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch("/api/documents/embed", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: documentId, targetId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "嵌入失败");
|
||||
return;
|
||||
}
|
||||
window.alert("已嵌入到目标页面");
|
||||
},
|
||||
});
|
||||
}, [documentId, openMoveEmbedPicker, readOnly, router, workspaceId]);
|
||||
|
||||
const handleAddToTemplates = useCallback(async () => {
|
||||
if (readOnly) return;
|
||||
const ok = window.confirm("将该页面添加为模板?");
|
||||
if (!ok) return;
|
||||
const resp = await fetch("/api/documents/template", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId, isTemplate: true }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "设置模板失败");
|
||||
return;
|
||||
}
|
||||
window.alert("已添加为模板");
|
||||
router.refresh();
|
||||
}, [documentId, readOnly, router]);
|
||||
|
||||
const formattedUpdatedAt = useMemo(() => {
|
||||
if (!updatedAt) return "";
|
||||
@@ -349,6 +569,16 @@ export function DocumentContent({
|
||||
URL.revokeObjectURL(url);
|
||||
}, [disableDownload, history, title]);
|
||||
|
||||
const pageRootClass = cn(
|
||||
"flex h-full overflow-hidden bg-wolai-bg",
|
||||
options.pageFont === "song" && "wolai-page-font-song",
|
||||
options.pageFont === "kai" && "wolai-page-font-kai",
|
||||
options.layoutDensity === "compact" && "wolai-page-density-compact",
|
||||
options.layoutDensity === "spacious" && "wolai-page-density-spacious",
|
||||
options.smallText && "wolai-small-text",
|
||||
options.hideChildPages && "wolai-hide-child-pages",
|
||||
);
|
||||
|
||||
const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => {
|
||||
latestBlocksRef.current = payload.blocks;
|
||||
setHistory((prev) => {
|
||||
@@ -398,7 +628,7 @@ export function DocumentContent({
|
||||
|
||||
return (
|
||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||
<div className="flex h-full overflow-hidden bg-wolai-bg" ref={pageRootRef}>
|
||||
<div className={pageRootClass} ref={pageRootRef}>
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
|
||||
<div className="relative">
|
||||
@@ -411,6 +641,7 @@ export function DocumentContent({
|
||||
className="w-full border-none bg-transparent text-3xl font-semibold text-wolai-text-primary outline-none focus:ring-0"
|
||||
aria-label="页面标题"
|
||||
disabled={options.protectEditing || readOnly}
|
||||
spellCheck={spellCheck}
|
||||
/>
|
||||
</div>
|
||||
{readOnly ? (
|
||||
@@ -453,9 +684,15 @@ export function DocumentContent({
|
||||
readOnly={readOnly}
|
||||
onStatsChange={handleStatsChange}
|
||||
onSnapshot={handleSnapshot}
|
||||
onCloseToc={closeToc}
|
||||
/>
|
||||
)}
|
||||
<PageBacklinksPanel className="mt-10" workspaceId={workspaceId} documentId={documentId} />
|
||||
<PageBacklinksPanel
|
||||
className="mt-10"
|
||||
workspaceId={workspaceId}
|
||||
documentId={documentId}
|
||||
defaultCollapsed={options.collapseBacklinks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showInspector && (
|
||||
@@ -464,8 +701,20 @@ export function DocumentContent({
|
||||
options={options}
|
||||
stats={stats}
|
||||
onToggle={toggleOption}
|
||||
onSetPageFont={handleSetPageFont}
|
||||
onSetLayoutDensity={handleSetLayoutDensity}
|
||||
onSetEmbedDefaultToCursor={handleSetEmbedDefaultToCursor}
|
||||
onClearEmbedDefault={handleClearEmbedDefault}
|
||||
onExport={handleExport}
|
||||
onOpenHistory={() => setHistoryOpen(true)}
|
||||
onOpenComments={() => openCommentsForPage({ workspaceId, documentId })}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onDeletePage={handleDeletePage}
|
||||
onOpenMoveEmbedPicker={handleOpenMoveEmbed}
|
||||
onCopyPageLink={handleCopyPageLink}
|
||||
onCopyPageReference={handleCopyPageReference}
|
||||
onAddToTemplates={handleAddToTemplates}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -475,6 +724,7 @@ export function DocumentContent({
|
||||
history={history}
|
||||
onRestore={handleRestoreSnapshot}
|
||||
/>
|
||||
<DocumentCommentsDrawer />
|
||||
<DocumentAiAgentPanel documentId={documentId} getLatestBlocks={() => latestBlocksRef.current} />
|
||||
</ImagePickerProvider>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
export interface TocEntry {
|
||||
id: string;
|
||||
@@ -13,19 +25,54 @@ interface DocumentTocProps {
|
||||
entries: TocEntry[];
|
||||
visible: boolean;
|
||||
onJump: (id: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function DocumentToc({ entries, visible, onJump }: DocumentTocProps) {
|
||||
export function DocumentToc({ entries, visible, onJump, onClose }: DocumentTocProps) {
|
||||
const [maxLevel, setMaxLevel] = useState<number>(4);
|
||||
const [showFullTitle, setShowFullTitle] = useState(false);
|
||||
|
||||
const filteredEntries = useMemo(() => entries.filter((entry) => entry.level <= maxLevel), [entries, maxLevel]);
|
||||
|
||||
if (!visible || entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute right-0 top-0 z-10 hidden lg:block">
|
||||
<div className="pointer-events-auto mt-2 w-48 max-h-[65vh] overflow-y-auto rounded-2xl border border-[#e4e4e7] bg-white/90 p-3 text-xs text-gray-600 shadow-lg backdrop-blur">
|
||||
<div className="mb-2 text-[11px] font-semibold text-gray-400">标题目录</div>
|
||||
<div className="pointer-events-auto mt-2 w-[min(320px,22vw)] min-w-40 max-w-80 max-h-[65vh] overflow-y-auto rounded-2xl border border-[#e4e4e7] bg-white/90 p-3 text-xs text-gray-600 shadow-lg backdrop-blur">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-[11px] font-semibold text-gray-400">标题目录</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-md p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="标题目录菜单"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={String(maxLevel)} onValueChange={(v) => setMaxLevel(Number(v) || 4)}>
|
||||
<DropdownMenuRadioItem value="1">展开到 H1</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="2">展开到 H2</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="3">展开到 H3</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="4">展开到 H4</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem checked={showFullTitle} onCheckedChange={(v) => setShowFullTitle(Boolean(v))}>
|
||||
显示完整标题
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => onClose?.()} disabled={!onClose}>
|
||||
关闭
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{entries.map((entry) => (
|
||||
{filteredEntries.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -33,6 +80,7 @@ export function DocumentToc({ entries, visible, onJump }: DocumentTocProps) {
|
||||
"w-full rounded-md px-2 py-1 text-left text-[11px] text-gray-500 transition-colors hover:bg-[#eef2ff] hover:text-[#2563eb]",
|
||||
entry.level > 1 && "pl-4",
|
||||
entry.level > 2 && "pl-6",
|
||||
showFullTitle ? "whitespace-normal" : "truncate",
|
||||
)}
|
||||
onClick={() => onJump(entry.id)}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
||||
import type { Block, PartialBlock } from "@blocknote/core";
|
||||
import {
|
||||
BlockColorsItem,
|
||||
@@ -18,6 +18,7 @@ import type { CustomBlockSchema } from "../schema";
|
||||
import { deleteOnlineTable } from "@/lib/online-table";
|
||||
import { emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
||||
import { useMoveEmbedPickerStore } from "@/store/move-embed-picker";
|
||||
import { useCommentsUiStore } from "@/store/comments-ui";
|
||||
|
||||
type InlineNode = { text?: unknown };
|
||||
type TableMenuBlock = Parameters<
|
||||
@@ -55,11 +56,57 @@ const extractText = (block: Block<CustomBlockSchema>) => {
|
||||
return "未命名页面";
|
||||
};
|
||||
|
||||
const clearMindmapAutosaveCache = (targetDocumentId: string, mindmapId?: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const prefix = "wolai-mindmap-autosave-";
|
||||
const targetPrefix = `${prefix}${targetDocumentId}`;
|
||||
const directKey = mindmapId ? `${targetPrefix}:${mindmapId}` : targetPrefix;
|
||||
const keys: string[] = [];
|
||||
for (let i = 0; i < window.localStorage.length; i += 1) {
|
||||
const k = window.localStorage.key(i);
|
||||
if (!k) continue;
|
||||
if (mindmapId) {
|
||||
if (k === directKey) keys.push(k);
|
||||
} else if (k === targetPrefix || k.startsWith(`${targetPrefix}:`)) {
|
||||
keys.push(k);
|
||||
}
|
||||
}
|
||||
keys.forEach((k) => window.localStorage.removeItem(k));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const markMindmapDeleting = (targetDocumentId: string, mindmapId?: string) => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const w = window as unknown as {
|
||||
__wolaiMindmapDeletingKeys?: Set<string>;
|
||||
};
|
||||
if (!w.__wolaiMindmapDeletingKeys) {
|
||||
w.__wolaiMindmapDeletingKeys = new Set<string>();
|
||||
}
|
||||
const key = mindmapId ? `${targetDocumentId}:${mindmapId}` : targetDocumentId;
|
||||
w.__wolaiMindmapDeletingKeys.add(key);
|
||||
window.setTimeout(() => {
|
||||
try {
|
||||
w.__wolaiMindmapDeletingKeys?.delete(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 8000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomDragProps) => {
|
||||
const Components = useComponentsContext()!;
|
||||
const editor = useBlockNoteEditor<CustomBlockSchema>();
|
||||
const router = useRouter();
|
||||
const openPicker = useMoveEmbedPickerStore((s) => s.openPicker);
|
||||
const openCommentsForBlock = useCommentsUiStore((s) => s.openForBlock);
|
||||
|
||||
const duplicateBlock = useCallback(() => {
|
||||
const blockWithoutId: DraftBlock = { ...block };
|
||||
@@ -93,8 +140,13 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
|
||||
if (block.type === "onlineTable") {
|
||||
const tableId = block.props.tableId as string | undefined;
|
||||
if (tableId) {
|
||||
void deleteOnlineTable(tableId)
|
||||
.catch((error) => console.error("删除在线表格失败", error));
|
||||
try {
|
||||
await deleteOnlineTable(tableId);
|
||||
} catch (error) {
|
||||
console.error("删除在线表格失败", error);
|
||||
window.alert("删除在线表格失败,请稍后重试");
|
||||
return;
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("online-table-deleted", { detail: { tableId } }));
|
||||
emitAssetsChanged(currentDocumentId);
|
||||
@@ -122,6 +174,9 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
|
||||
return;
|
||||
}
|
||||
if (block.type === "mindmap") {
|
||||
// 关键:必须先标记“删除中”,避免 MindmapBlock 卸载清理把数据 POST 回去导致“删除后复活”。
|
||||
markMindmapDeleting(currentDocumentId, block.id);
|
||||
clearMindmapAutosaveCache(currentDocumentId, block.id);
|
||||
const resp = await fetch(`/api/mindmap/${currentDocumentId}/${block.id}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
@@ -162,7 +217,7 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
|
||||
[
|
||||
{
|
||||
type: "pageReference",
|
||||
props: { pageId, title },
|
||||
props: { pageId, title, asChildPage: true },
|
||||
} as PartialBlock<CustomBlockSchema>,
|
||||
],
|
||||
);
|
||||
@@ -365,7 +420,13 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
|
||||
|
||||
<Components.Generic.Menu.Item
|
||||
className="bn-menu-item"
|
||||
onClick={() => window.alert("评论功能暂未开放")}
|
||||
onClick={() => {
|
||||
if (!workspaceId) {
|
||||
window.alert("缺少 workspaceId,无法打开评论");
|
||||
return;
|
||||
}
|
||||
openCommentsForBlock({ workspaceId, documentId: currentDocumentId, blockId: block.id });
|
||||
}}
|
||||
>
|
||||
评论
|
||||
</Components.Generic.Menu.Item>
|
||||
@@ -418,14 +479,27 @@ const CustomDragHandleMenu = ({ block, currentDocumentId, workspaceId }: CustomD
|
||||
type CustomSideMenuProps = SideMenuProps<CustomBlockSchema> & {
|
||||
currentDocumentId: string;
|
||||
workspaceId: string | null;
|
||||
unresolvedCommentCountByBlockId?: Record<string, number>;
|
||||
};
|
||||
|
||||
const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
|
||||
const Components = useComponentsContext()!;
|
||||
const { editor, block, blockDragStart, blockDragEnd, freezeMenu, unfreezeMenu, currentDocumentId, workspaceId } = props;
|
||||
const [activeLine, setActiveLine] = useState<null | "top" | "bottom">(null);
|
||||
const {
|
||||
editor,
|
||||
block,
|
||||
blockDragStart,
|
||||
blockDragEnd,
|
||||
freezeMenu,
|
||||
unfreezeMenu,
|
||||
currentDocumentId,
|
||||
workspaceId,
|
||||
unresolvedCommentCountByBlockId,
|
||||
} = props;
|
||||
const [insertHovered, setInsertHovered] = useState<null | "top" | "bottom">(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const [activeCursorBlockId, setActiveCursorBlockId] = useState<string | null>(null);
|
||||
const hoverAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const menuFrozenRef = useRef(false);
|
||||
|
||||
// 说明:Wolai 的附件(file)手柄是在行中线左侧居中显示。
|
||||
@@ -433,8 +507,19 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
|
||||
const isFileAttachmentRow = block.type === "media" && (block as any)?.props?.assetType === "file";
|
||||
const rowHeightPx = isFileAttachmentRow ? 26 : 24;
|
||||
const hoverPadPx = 14;
|
||||
const lineOffsetPx = 6;
|
||||
const lineGapPx = 5;
|
||||
const lineGapPx = 3;
|
||||
const insertBtnSizePx = 16;
|
||||
const handleBtnSizePx = 22;
|
||||
const unresolvedCount = unresolvedCommentCountByBlockId?.[block.id] ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const cursor = editor.getTextCursorPosition();
|
||||
setActiveCursorBlockId(cursor?.block?.id ?? null);
|
||||
};
|
||||
update();
|
||||
return editor.onSelectionChange(update);
|
||||
}, [editor]);
|
||||
|
||||
const setFrozen = useCallback(
|
||||
(next: boolean) => {
|
||||
@@ -449,6 +534,32 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
|
||||
[freezeMenu, unfreezeMenu],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 说明:Win+Shift+S 截图会触发窗口失焦/可见性变化;若用右键取消,
|
||||
// 有些环境下不会触发正常的 mouseleave,导致手柄状态卡死(看起来像“消失”)。
|
||||
// 这里在失焦/隐藏时强制解除冻结并重置 hover 状态。
|
||||
const reset = () => {
|
||||
setHovering(false);
|
||||
setMenuOpen(false);
|
||||
setInsertHovered(null);
|
||||
setFrozen(false);
|
||||
};
|
||||
|
||||
const onBlur = () => reset();
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
reset();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("blur", onBlur);
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener("blur", onBlur);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [setFrozen]);
|
||||
|
||||
const insertParagraph = useCallback(
|
||||
(position: "before" | "after") => {
|
||||
const inserted = editor.insertBlocks([{ type: "paragraph" } as any], block as any, position as any)?.[0];
|
||||
@@ -465,6 +576,47 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const paragraphPlainText = useMemo(() => {
|
||||
if (block.type !== "paragraph") return null;
|
||||
const content = Array.isArray(block.content) ? (block.content as any[]) : [];
|
||||
const text = content
|
||||
.map((node) =>
|
||||
node && typeof (node as { text?: unknown }).text === "string" ? (node as { text: string }).text : ""
|
||||
)
|
||||
.join("");
|
||||
return text;
|
||||
}, [block]);
|
||||
|
||||
const isEmptyParagraph = block.type === "paragraph" && (paragraphPlainText ?? "").trim().length === 0;
|
||||
const showEmptyPlus = isEmptyParagraph && (activeCursorBlockId === block.id || hovering || menuOpen);
|
||||
|
||||
const openSlashMenuFromEmptyPlus = (e: ReactMouseEvent) => {
|
||||
stop(e);
|
||||
if (!isEmptyParagraph) return;
|
||||
try {
|
||||
editor.setTextCursorPosition(block as any, "start");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
editor.focus();
|
||||
// 说明:按 Wolai 手感,点击“+”等同于在空行输入 “/” 打开斜杠菜单。
|
||||
// deleteTriggerCharacter=true 会把 “/” 写入编辑器,并在选择条目后由插件清理掉。
|
||||
editor.openSuggestionMenu("/", { deleteTriggerCharacter: true, ignoreQueryLength: true });
|
||||
};
|
||||
|
||||
const forceShowInsertButtons = block.type === "mindmap" || block.type === "onlineTable";
|
||||
// 说明:思维导图/在线表格等嵌入块内部可能接管鼠标事件,导致 hover 状态不稳定。
|
||||
// 对这些块直接常驻显示插入控件,避免“看不到横杠/加号”。
|
||||
const showInsertButtons = !showEmptyPlus && (forceShowInsertButtons || hovering || menuOpen);
|
||||
const handleCenterY = hoverPadPx + rowHeightPx / 2;
|
||||
// 说明:插入按钮的位置必须“跟着手柄走”,不能依赖容器上下边界。
|
||||
// 否则对于思维导图/在线表格等高块,容器可能被撑高,导致按钮跑到块底部。
|
||||
// 说明:插入按钮不能与六点手柄发生重叠,否则 hover/click 会被手柄拦截(表现为“看得见但点不到/hover 没反应”)。
|
||||
// 这里用“手柄按钮尺寸 + 插入按钮尺寸 + 间距”计算中心距,确保永不重叠。
|
||||
const insertDistPx = handleBtnSizePx / 2 + insertBtnSizePx / 2 + lineGapPx;
|
||||
const insertBeforeTopPx = handleCenterY - insertDistPx - insertBtnSizePx / 2;
|
||||
const insertAfterTopPx = handleCenterY + insertDistPx - insertBtnSizePx / 2;
|
||||
|
||||
return (
|
||||
<Components.Generic.Menu.Root
|
||||
onOpenChange={(open: boolean) => {
|
||||
@@ -474,70 +626,122 @@ const WolaiDragHandleWithInsert = (props: CustomSideMenuProps) => {
|
||||
position={"left"}
|
||||
>
|
||||
<div
|
||||
ref={hoverAreaRef}
|
||||
data-testid="wolai-handle-area"
|
||||
className="relative w-7 overflow-visible"
|
||||
style={{ height: rowHeightPx + hoverPadPx * 2, marginTop: -hoverPadPx }}
|
||||
onMouseEnter={() => {
|
||||
onPointerEnter={() => {
|
||||
setHovering(true);
|
||||
setFrozen(menuOpen || true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
onPointerLeave={() => {
|
||||
setHovering(false);
|
||||
setActiveLine(null);
|
||||
setInsertHovered(null);
|
||||
setFrozen(menuOpen || false);
|
||||
}}
|
||||
>
|
||||
{activeLine !== "bottom" && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-insert-before"
|
||||
title="在上方插入块"
|
||||
className={`absolute left-1/2 z-30 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${hovering ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ top: hoverPadPx - lineOffsetPx - lineGapPx }}
|
||||
onMouseEnter={() => setActiveLine("top")}
|
||||
onMouseDown={stop}
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
insertParagraph("before");
|
||||
}}
|
||||
>
|
||||
{activeLine === "top" ? <Plus className="h-3.5 w-3.5" /> : <span className="block h-px w-3 bg-[#CFCFCF]" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-insert-before"
|
||||
title="在上方插入块"
|
||||
className={`absolute left-1/2 z-[2147483647] flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${showInsertButtons ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ top: insertBeforeTopPx }}
|
||||
onPointerEnter={() => setInsertHovered("top")}
|
||||
onPointerLeave={() => {
|
||||
queueMicrotask(() => {
|
||||
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
|
||||
if (!stillHovering) setInsertHovered(null);
|
||||
});
|
||||
}}
|
||||
onMouseDown={stop}
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
insertParagraph("before");
|
||||
}}
|
||||
>
|
||||
{insertHovered === "top"
|
||||
? <Plus className="h-3.5 w-3.5" />
|
||||
: <span className="block h-px w-3 bg-[#CFCFCF]" />}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||
className="absolute left-1/2 z-[2147483647] -translate-x-1/2 -translate-y-1/2"
|
||||
style={{ top: hoverPadPx + rowHeightPx / 2 }}
|
||||
onMouseEnter={() => setActiveLine(null)}
|
||||
>
|
||||
<Components.Generic.Menu.Trigger>
|
||||
{showEmptyPlus ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-empty-plus"
|
||||
aria-label="打开斜杠命令"
|
||||
className="flex h-[22px] w-[22px] items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]"
|
||||
onMouseDown={stop}
|
||||
onClick={openSlashMenuFromEmptyPlus}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{!showEmptyPlus ? (
|
||||
<Components.Generic.Menu.Trigger>
|
||||
<Components.SideMenu.Button
|
||||
label="块操作"
|
||||
draggable={true}
|
||||
onDragStart={(e) => blockDragStart(e, block)}
|
||||
onDragEnd={blockDragEnd}
|
||||
className={"bn-button bn-drag-handle"}
|
||||
icon={<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />}
|
||||
onPointerEnter={() => {
|
||||
setHovering(true);
|
||||
setFrozen(menuOpen || true);
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
// 说明:从手柄移到插入按钮时,也会触发手柄的 pointerleave;
|
||||
// 这里用 :hover 兜底,避免插入按钮一闪而过。
|
||||
queueMicrotask(() => {
|
||||
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
|
||||
if (!stillHovering && !menuOpen) {
|
||||
setHovering(false);
|
||||
setInsertHovered(null);
|
||||
setFrozen(false);
|
||||
}
|
||||
});
|
||||
}}
|
||||
className={`bn-button transition-opacity duration-200 ${showInsertButtons || hovering || menuOpen ? "opacity-100" : "opacity-0"}`}
|
||||
icon={
|
||||
<span className="relative inline-flex">
|
||||
<FourDotHandleIcon className="h-5 w-5" data-test="dragHandle" />
|
||||
{unresolvedCount > 0 ? (
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-[#2563eb] px-1 text-[10px] font-semibold leading-[14px] text-white">
|
||||
{unresolvedCount > 9 ? "9+" : unresolvedCount}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
</Components.Generic.Menu.Trigger>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activeLine !== "top" && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-insert-after"
|
||||
title="在下方插入块"
|
||||
className={`absolute left-1/2 z-30 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${hovering ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ bottom: hoverPadPx - lineOffsetPx - lineGapPx }}
|
||||
onMouseEnter={() => setActiveLine("bottom")}
|
||||
onMouseDown={stop}
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
insertParagraph("after");
|
||||
}}
|
||||
>
|
||||
{activeLine === "bottom" ? <Plus className="h-3.5 w-3.5" /> : <span className="block h-px w-3 bg-[#CFCFCF]" />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
data-testid="wolai-insert-after"
|
||||
title="在下方插入块"
|
||||
className={`absolute left-1/2 z-[2147483647] flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F] ${showInsertButtons ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
|
||||
style={{ top: insertAfterTopPx }}
|
||||
onPointerEnter={() => setInsertHovered("bottom")}
|
||||
onPointerLeave={() => {
|
||||
queueMicrotask(() => {
|
||||
const stillHovering = !!hoverAreaRef.current?.matches?.(":hover");
|
||||
if (!stillHovering) setInsertHovered(null);
|
||||
});
|
||||
}}
|
||||
onMouseDown={stop}
|
||||
onClick={(e) => {
|
||||
stop(e);
|
||||
insertParagraph("after");
|
||||
}}
|
||||
>
|
||||
{insertHovered === "bottom"
|
||||
? <Plus className="h-3.5 w-3.5" />
|
||||
: <span className="block h-px w-3 bg-[#CFCFCF]" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CustomDragHandleMenu block={block} currentDocumentId={currentDocumentId} workspaceId={workspaceId} />
|
||||
|
||||
@@ -262,7 +262,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
const { pageId, title } = await response.json();
|
||||
insertOrUpdateBlockForSlashMenuCompat(editor, {
|
||||
type: "pageReference",
|
||||
props: { pageId, title },
|
||||
props: { pageId, title, asChildPage: true },
|
||||
content: [],
|
||||
});
|
||||
router.refresh();
|
||||
@@ -373,6 +373,7 @@ export const CustomSlashMenu = ({ editor, currentDocumentId }: Props) => {
|
||||
const handleMediaPick = (mediaType: MediaKind) => {
|
||||
openPicker({
|
||||
mediaType,
|
||||
multiple: true,
|
||||
onSelect: (selection) => {
|
||||
insertMediaSelection({
|
||||
...selection,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useBacklinks } from "@/hooks/use-backlinks";
|
||||
import type { BacklinkRecord } from "@/types/references";
|
||||
@@ -10,6 +10,7 @@ interface PageBacklinksPanelProps {
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
className?: string;
|
||||
defaultCollapsed?: boolean;
|
||||
}
|
||||
|
||||
const formatRelative = (value: string) => {
|
||||
@@ -37,13 +38,16 @@ const BacklinkItem = ({ record }: { record: BacklinkRecord }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
export function PageBacklinksPanel({ workspaceId, documentId, className }: PageBacklinksPanelProps) {
|
||||
export function PageBacklinksPanel({ workspaceId, documentId, className, defaultCollapsed }: PageBacklinksPanelProps) {
|
||||
const { data, isLoading, error, refetch, isFetching } = useBacklinks({
|
||||
workspaceId,
|
||||
documentId,
|
||||
});
|
||||
|
||||
const records = useMemo(() => data ?? [], [data]);
|
||||
// 说明:collapsed 需要可交互;这里用一个轻量的局部状态,但默认值来自 props(用于“自定义页面:折叠引用列表”)。
|
||||
const [isCollapsed, setIsCollapsed] = useState(Boolean(defaultCollapsed));
|
||||
useEffect(() => setIsCollapsed(Boolean(defaultCollapsed)), [defaultCollapsed]);
|
||||
// 避免页面切换时“先出现加载态、随后又消失”的闪烁:
|
||||
// 当尚未拿到任何记录且正在加载时,直接不渲染面板。
|
||||
if (!error && records.length === 0 && (isLoading || isFetching)) {
|
||||
@@ -65,12 +69,29 @@ export function PageBacklinksPanel({ workspaceId, documentId, className }: PageB
|
||||
{isFetching ? "刷新中..." : "刷新"}
|
||||
</Button>
|
||||
</div>
|
||||
{records.length > 0 && (
|
||||
<div className="mb-4 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>共 {records.length} 条</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setIsCollapsed((prev) => !prev)}
|
||||
className="h-7 px-2"
|
||||
>
|
||||
{isCollapsed ? "展开" : "折叠"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="py-6 text-center text-sm text-gray-500">加载引用中...</div>
|
||||
) : error ? (
|
||||
<div className="py-6 text-center text-sm text-red-500">{(error as Error).message}</div>
|
||||
) : records.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : isCollapsed ? (
|
||||
<div className="rounded-xl border border-dashed border-[#e2e8f0] bg-white/60 px-4 py-4 text-center text-xs text-gray-500">
|
||||
已折叠。点击“展开”查看。
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{records.map((record) => (
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ComponentType } from "react";
|
||||
import { BookOpenCheck, Focus, ListOrdered, ListTree, Maximize2, ShieldCheck, Type } from "lucide-react";
|
||||
import { BookOpenCheck, Focus, ListOrdered, ListTree, Maximize2, ShieldCheck, Type, MessageSquare } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DocumentStats, PageOptionsState } from "@/types/page-options";
|
||||
import type { BooleanPageOptionKey, DocumentStats, PageFont, PageLayoutDensity, PageOptionsState } from "@/types/page-options";
|
||||
import { DocumentTaskPanel } from "@/components/document-task-panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppPreferencesStore, type ThemeMode } from "@/store/app-preferences";
|
||||
|
||||
type TabId = "page" | "custom" | "global";
|
||||
|
||||
@@ -16,7 +17,7 @@ const TABS: Array<{ id: TabId; label: string }> = [
|
||||
];
|
||||
|
||||
const OPTION_META: Record<
|
||||
keyof PageOptionsState,
|
||||
BooleanPageOptionKey,
|
||||
{ label: string; description: string; icon: ComponentType<{ className?: string }> }
|
||||
> = {
|
||||
wideLayout: {
|
||||
@@ -39,11 +40,6 @@ const OPTION_META: Record<
|
||||
description: "在右侧展示目录导航",
|
||||
icon: ListTree,
|
||||
},
|
||||
showStructure: {
|
||||
label: "块结构线框",
|
||||
description: "显示块级元素的结构边界",
|
||||
icon: Focus,
|
||||
},
|
||||
protectEditing: {
|
||||
label: "编辑保护",
|
||||
description: "保护内容避免误触修改",
|
||||
@@ -54,19 +50,53 @@ const OPTION_META: Record<
|
||||
description: "实时展示字数和块统计",
|
||||
icon: BookOpenCheck,
|
||||
},
|
||||
collapseBacklinks: {
|
||||
label: "折叠反向引用",
|
||||
description: "默认折叠页面底部的反向引用列表",
|
||||
icon: Focus,
|
||||
},
|
||||
hideChildPages: {
|
||||
label: "隐藏子页面",
|
||||
description: "隐藏通过 /ym 创建的子页面块(不会删除内容)",
|
||||
icon: Focus,
|
||||
},
|
||||
showBlockRefCount: {
|
||||
label: "显示块引用数字",
|
||||
description: "显示块被引用次数(当前为占位,后续补齐)",
|
||||
icon: Focus,
|
||||
},
|
||||
};
|
||||
|
||||
const CUSTOM_LAYOUT_OPTIONS: (keyof PageOptionsState)[] = ["wideLayout", "smallText"];
|
||||
const CUSTOM_STRUCTURE_OPTIONS: (keyof PageOptionsState)[] = ["showHeadingNumbers", "showToc"];
|
||||
const GLOBAL_OPTIONS: (keyof PageOptionsState)[] = ["showStructure", "protectEditing", "showWordCount"];
|
||||
const PAGE_OPTIONS: BooleanPageOptionKey[] = [
|
||||
"wideLayout",
|
||||
"smallText",
|
||||
"showHeadingNumbers",
|
||||
"showToc",
|
||||
"protectEditing",
|
||||
"showWordCount",
|
||||
];
|
||||
|
||||
const CUSTOM_PAGE_OPTIONS: BooleanPageOptionKey[] = ["collapseBacklinks", "hideChildPages", "showBlockRefCount"];
|
||||
|
||||
interface PageOptionsSidebarProps {
|
||||
documentId: string;
|
||||
options: PageOptionsState;
|
||||
stats?: DocumentStats;
|
||||
onToggle: (key: keyof PageOptionsState) => void;
|
||||
onToggle: (key: BooleanPageOptionKey) => void;
|
||||
onSetPageFont?: (font: PageFont) => void;
|
||||
onSetLayoutDensity?: (density: PageLayoutDensity) => void;
|
||||
onSetEmbedDefaultToCursor?: () => void;
|
||||
onClearEmbedDefault?: () => void;
|
||||
onExport: () => void;
|
||||
onOpenHistory: () => void;
|
||||
onOpenComments?: () => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onDeletePage?: () => void;
|
||||
onOpenMoveEmbedPicker?: () => void;
|
||||
onCopyPageLink?: (includeTitle: boolean) => void;
|
||||
onCopyPageReference?: (mode: "inline" | "embed") => void;
|
||||
onAddToTemplates?: () => void;
|
||||
}
|
||||
|
||||
export function PageOptionsSidebar({
|
||||
@@ -74,10 +104,30 @@ export function PageOptionsSidebar({
|
||||
options,
|
||||
stats,
|
||||
onToggle,
|
||||
onSetPageFont,
|
||||
onSetLayoutDensity,
|
||||
onSetEmbedDefaultToCursor,
|
||||
onClearEmbedDefault,
|
||||
onExport,
|
||||
onOpenHistory,
|
||||
onOpenComments,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onDeletePage,
|
||||
onOpenMoveEmbedPicker,
|
||||
onCopyPageLink,
|
||||
onCopyPageReference,
|
||||
onAddToTemplates,
|
||||
}: PageOptionsSidebarProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>("page");
|
||||
const theme = useAppPreferencesStore((s) => s.theme);
|
||||
const showStructure = useAppPreferencesStore((s) => s.showStructure);
|
||||
const spellCheck = useAppPreferencesStore((s) => s.spellCheck);
|
||||
const flightMode = useAppPreferencesStore((s) => s.flightMode);
|
||||
const setTheme = useAppPreferencesStore((s) => s.setTheme);
|
||||
const setShowStructure = useAppPreferencesStore((s) => s.setShowStructure);
|
||||
const setSpellCheck = useAppPreferencesStore((s) => s.setSpellCheck);
|
||||
const setFlightMode = useAppPreferencesStore((s) => s.setFlightMode);
|
||||
|
||||
return (
|
||||
<aside className="flex h-full w-80 shrink-0 flex-col border-l border-[#f0f0f0] bg-white/95">
|
||||
@@ -107,8 +157,13 @@ export function PageOptionsSidebar({
|
||||
<StatsCell label="字符" value={stats.characterCount} />
|
||||
<StatsCell label="块数" value={stats.blockCount} />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-center text-xs text-gray-500">
|
||||
<StatsCell label="待办总数" value={stats.todoTotal} />
|
||||
<StatsCell label="已完成" value={stats.todoDone} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<OptionToggleGroup title="页面选项" optionKeys={PAGE_OPTIONS} options={options} onToggle={onToggle} />
|
||||
<section className="rounded-2xl border border-[#eef1f6] p-4 text-sm text-gray-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-gray-800">页面操作</span>
|
||||
@@ -119,34 +174,197 @@ export function PageOptionsSidebar({
|
||||
<Button type="button" variant="outline" size="sm" onClick={onOpenHistory}>
|
||||
历史
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onOpenComments}
|
||||
disabled={!onOpenComments}
|
||||
title={onOpenComments ? "打开评论" : "评论功能未启用"}
|
||||
>
|
||||
<MessageSquare className="mr-1 h-4 w-4" />
|
||||
评论
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-400">导出最新快照或打开历史版本面板。</p>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[#eef1f6] p-4 text-sm text-gray-600">
|
||||
<div className="text-sm font-semibold text-gray-800">快捷操作</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={onUndo} disabled={!onUndo}>
|
||||
撤回
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onRedo} disabled={!onRedo}>
|
||||
重做
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onOpenMoveEmbedPicker}
|
||||
disabled={!onOpenMoveEmbedPicker}
|
||||
>
|
||||
移动/嵌入到...
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onAddToTemplates} disabled={!onAddToTemplates}>
|
||||
添加为模板
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => onCopyPageLink?.(false)} disabled={!onCopyPageLink}>
|
||||
复制页面链接
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => onCopyPageLink?.(true)} disabled={!onCopyPageLink}>
|
||||
复制链接(带标题)
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => onCopyPageReference?.("inline")} disabled={!onCopyPageReference}>
|
||||
行内页面引用
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => onCopyPageReference?.("embed")} disabled={!onCopyPageReference}>
|
||||
嵌入页面引用
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="destructive" onClick={onDeletePage} disabled={!onDeletePage}>
|
||||
删除页面
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
提示:标题目录快捷键为 <span className="font-mono">Ctrl/Cmd + Shift + L</span>。
|
||||
</p>
|
||||
</section>
|
||||
<DocumentTaskPanel documentId={documentId} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "custom" && (
|
||||
<div className="space-y-5">
|
||||
<OptionToggleGroup
|
||||
title="布局与排版"
|
||||
optionKeys={CUSTOM_LAYOUT_OPTIONS}
|
||||
options={options}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
<OptionToggleGroup
|
||||
title="结构与目录"
|
||||
optionKeys={CUSTOM_STRUCTURE_OPTIONS}
|
||||
options={options}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
<section className="rounded-2xl border border-[#eef1f6] p-4">
|
||||
<div className="text-sm font-semibold text-gray-800">字体</div>
|
||||
<p className="mt-1 text-xs text-gray-400">仅对当前页面生效。</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{([
|
||||
{ id: "default", label: "默认" },
|
||||
{ id: "song", label: "宋体" },
|
||||
{ id: "kai", label: "楷体" },
|
||||
] as Array<{ id: PageFont; label: string }>).map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={options.pageFont === item.id ? "default" : "outline"}
|
||||
onClick={() => onSetPageFont?.(item.id)}
|
||||
disabled={!onSetPageFont}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[#eef1f6] p-4">
|
||||
<div className="text-sm font-semibold text-gray-800">布局</div>
|
||||
<p className="mt-1 text-xs text-gray-400">控制段落行高与标题间距。</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{([
|
||||
{ id: "compact", label: "紧凑" },
|
||||
{ id: "normal", label: "默认" },
|
||||
{ id: "spacious", label: "宽容" },
|
||||
] as Array<{ id: PageLayoutDensity; label: string }>).map((item) => (
|
||||
<Button
|
||||
key={item.id}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={options.layoutDensity === item.id ? "default" : "outline"}
|
||||
onClick={() => onSetLayoutDensity?.(item.id)}
|
||||
disabled={!onSetLayoutDensity}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OptionToggleGroup title="反向链接" optionKeys={CUSTOM_PAGE_OPTIONS} options={options} onToggle={onToggle} />
|
||||
|
||||
<section className="rounded-2xl border border-[#eef1f6] p-4">
|
||||
<div className="text-sm font-semibold text-gray-800">嵌入默认位置</div>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
当其它页面/块“嵌入到...”当前页面时,默认插入到指定块之后。
|
||||
</p>
|
||||
<div className="mt-3 rounded-xl bg-[#f9fafc] px-3 py-2 text-xs text-gray-600">
|
||||
当前:{options.embedDefaultBlockId ? options.embedDefaultBlockId : "未设置"}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onSetEmbedDefaultToCursor}
|
||||
disabled={!onSetEmbedDefaultToCursor}
|
||||
>
|
||||
使用当前光标块
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onClearEmbedDefault}
|
||||
disabled={!onClearEmbedDefault || !options.embedDefaultBlockId}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[#eef1f6] p-4 text-xs text-gray-500">
|
||||
<div className="text-sm font-semibold text-gray-800">更多自定义项(待补齐)</div>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-4">
|
||||
<li>显示块引用数字:需要补齐“块被引用统计”数据源</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "global" && (
|
||||
<div className="space-y-5">
|
||||
<OptionToggleGroup title="全局偏好" optionKeys={GLOBAL_OPTIONS} options={options} onToggle={onToggle} />
|
||||
<section className="rounded-2xl border border-dashed border-[#e3e3e3] p-4 text-xs text-gray-400">
|
||||
更多全局配置(如 Good Night 模式、导出默认行为等)将在后续版本开放。
|
||||
<section className="rounded-2xl border border-[#eef1f6] p-4">
|
||||
<div className="text-sm font-semibold text-gray-800">辅助提示</div>
|
||||
<PreferenceRow
|
||||
title="显示块结构"
|
||||
description="显示块级元素的结构边界(虚线框)。快捷键:Ctrl/Cmd + Shift + U。"
|
||||
enabled={showStructure}
|
||||
onToggle={() => setShowStructure(!showStructure)}
|
||||
/>
|
||||
<PreferenceRow
|
||||
title="拼写检查"
|
||||
description="控制编辑器的浏览器拼写检查(spellcheck)。"
|
||||
enabled={spellCheck}
|
||||
onToggle={() => setSpellCheck(!spellCheck)}
|
||||
/>
|
||||
</section>
|
||||
<section className="rounded-2xl border border-[#eef1f6] p-4">
|
||||
<div className="text-sm font-semibold text-gray-800">风格</div>
|
||||
<p className="mt-1 text-xs text-gray-400">Good Night(深色主题)与跟随系统。快捷键:Ctrl/Cmd + Alt/Opt + G。</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
{(["system", "light", "dark"] as ThemeMode[]).map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={theme === mode ? "default" : "outline"}
|
||||
onClick={() => setTheme(mode)}
|
||||
>
|
||||
{mode === "system" ? "跟随系统" : mode === "dark" ? "深色" : "浅色"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-[#eef1f6] p-4">
|
||||
<div className="text-sm font-semibold text-gray-800">其它</div>
|
||||
<PreferenceRow
|
||||
title="飞行模式"
|
||||
description="开启后默认关闭 AI 面板的“联网”开关(避免误触外部网络)。"
|
||||
enabled={flightMode}
|
||||
onToggle={() => setFlightMode(!flightMode)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
@@ -162,9 +380,9 @@ function OptionToggleGroup({
|
||||
onToggle,
|
||||
}: {
|
||||
title: string;
|
||||
optionKeys: (keyof PageOptionsState)[];
|
||||
optionKeys: BooleanPageOptionKey[];
|
||||
options: PageOptionsState;
|
||||
onToggle: (key: keyof PageOptionsState) => void;
|
||||
onToggle: (key: BooleanPageOptionKey) => void;
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
@@ -183,9 +401,9 @@ function OptionToggle({
|
||||
options,
|
||||
onToggle,
|
||||
}: {
|
||||
optionKey: keyof PageOptionsState;
|
||||
optionKey: BooleanPageOptionKey;
|
||||
options: PageOptionsState;
|
||||
onToggle: (key: keyof PageOptionsState) => void;
|
||||
onToggle: (key: BooleanPageOptionKey) => void;
|
||||
}) {
|
||||
const meta = OPTION_META[optionKey];
|
||||
const Icon = meta.icon;
|
||||
@@ -226,3 +444,27 @@ function StatsCell({ label, value }: { label: string; value: number }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreferenceRow({
|
||||
title,
|
||||
description,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-3 flex items-start justify-between gap-3 rounded-xl bg-[#f9fafc] px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900">{title}</div>
|
||||
<div className="text-xs text-gray-400">{description}</div>
|
||||
</div>
|
||||
<Button type="button" size="sm" variant={enabled ? "default" : "outline"} onClick={onToggle}>
|
||||
{enabled ? "已开启" : "已关闭"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useEffect, act } from "react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { MediaSelection } from "@/types/media";
|
||||
import { ImagePickerProvider, useImagePicker } from "./image-picker-context";
|
||||
|
||||
// React 18+:需要显式开启 act 环境标记,避免警告。
|
||||
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let lastDialogProps: null | { open: boolean; onSelect: (selection: MediaSelection) => void } = null;
|
||||
|
||||
vi.mock("@/components/media/image-picker-dialog", () => ({
|
||||
ImagePickerDialog: (props: { open: boolean; onSelect: (selection: MediaSelection) => void }) => {
|
||||
lastDialogProps = props;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
function Harness(props: { multiple: boolean; onSelect: (selection: MediaSelection) => void }) {
|
||||
const { openPicker } = useImagePicker();
|
||||
useEffect(() => {
|
||||
openPicker({
|
||||
mediaType: "file",
|
||||
multiple: props.multiple,
|
||||
onSelect: props.onSelect,
|
||||
});
|
||||
}, [openPicker, props.multiple, props.onSelect]);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("ImagePickerProvider", () => {
|
||||
test("single 模式:选择后自动关闭", () => {
|
||||
lastDialogProps = null;
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const onSelect = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ImagePickerProvider documentId="doc" workspaceId="ws">
|
||||
<Harness multiple={false} onSelect={onSelect} />
|
||||
</ImagePickerProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(lastDialogProps?.open).toBe(true);
|
||||
|
||||
act(() => {
|
||||
lastDialogProps?.onSelect({ assetId: "a", fileUrl: "https://example.com/a" });
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(lastDialogProps?.open).toBe(false);
|
||||
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
test("multiple 模式:可连续选择,不会因第一次选择而关闭", () => {
|
||||
lastDialogProps = null;
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const onSelect = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<ImagePickerProvider documentId="doc" workspaceId="ws">
|
||||
<Harness multiple onSelect={onSelect} />
|
||||
</ImagePickerProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(lastDialogProps?.open).toBe(true);
|
||||
|
||||
act(() => {
|
||||
lastDialogProps?.onSelect({ assetId: "a", fileUrl: "https://example.com/a" });
|
||||
});
|
||||
act(() => {
|
||||
lastDialogProps?.onSelect({ assetId: "b", fileUrl: "https://example.com/b" });
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(2);
|
||||
expect(lastDialogProps?.open).toBe(true);
|
||||
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,17 @@ interface PickerState {
|
||||
open: boolean;
|
||||
defaultTab: PickerTab;
|
||||
mediaType: MediaKind;
|
||||
multiple: boolean;
|
||||
onSelect?: (selection: MediaSelection) => void;
|
||||
}
|
||||
|
||||
interface ImagePickerContextValue {
|
||||
openPicker: (options: { onSelect: (selection: MediaSelection) => void; defaultTab?: PickerTab; mediaType?: MediaKind }) => void;
|
||||
openPicker: (options: {
|
||||
onSelect: (selection: MediaSelection) => void;
|
||||
defaultTab?: PickerTab;
|
||||
mediaType?: MediaKind;
|
||||
multiple?: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const ImagePickerContext = createContext<ImagePickerContextValue | undefined>(undefined);
|
||||
@@ -28,16 +34,18 @@ export function ImagePickerProvider({ documentId, workspaceId, children }: Image
|
||||
open: false,
|
||||
defaultTab: "upload",
|
||||
mediaType: "image",
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const contextValue = useMemo<ImagePickerContextValue>(
|
||||
() => ({
|
||||
openPicker: ({ onSelect, defaultTab = "upload", mediaType = "image" }) => {
|
||||
openPicker: ({ onSelect, defaultTab = "upload", mediaType = "image", multiple = false }) => {
|
||||
setState({
|
||||
open: true,
|
||||
onSelect,
|
||||
defaultTab,
|
||||
mediaType,
|
||||
multiple,
|
||||
});
|
||||
},
|
||||
}),
|
||||
@@ -50,7 +58,9 @@ export function ImagePickerProvider({ documentId, workspaceId, children }: Image
|
||||
|
||||
const handleSelect = (selection: MediaSelection) => {
|
||||
state.onSelect?.(selection);
|
||||
setState((prev) => ({ ...prev, open: false }));
|
||||
if (!state.multiple) {
|
||||
setState((prev) => ({ ...prev, open: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -60,6 +70,7 @@ export function ImagePickerProvider({ documentId, workspaceId, children }: Image
|
||||
open={state.open}
|
||||
defaultTab={state.defaultTab}
|
||||
mediaType={state.mediaType}
|
||||
multiple={state.multiple}
|
||||
documentId={documentId}
|
||||
workspaceId={workspaceId}
|
||||
onClose={handleClose}
|
||||
|
||||
@@ -16,6 +16,7 @@ interface ImagePickerDialogProps {
|
||||
open: boolean;
|
||||
defaultTab: PickerTab;
|
||||
mediaType: MediaKind;
|
||||
multiple?: boolean;
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
onClose: () => void;
|
||||
@@ -92,6 +93,7 @@ export function ImagePickerDialog({
|
||||
open,
|
||||
defaultTab,
|
||||
mediaType,
|
||||
multiple = false,
|
||||
documentId,
|
||||
workspaceId,
|
||||
onClose,
|
||||
@@ -141,41 +143,51 @@ export function ImagePickerDialog({
|
||||
}
|
||||
}, [open, tab, fetchRecent]);
|
||||
|
||||
const handleUpload = useCallback(
|
||||
const uploadOne = useCallback(
|
||||
async (file: File) => {
|
||||
if (!workspaceId || !documentId) {
|
||||
setError("缺少必要参数");
|
||||
return;
|
||||
throw new Error("缺少必要参数");
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("workspaceId", workspaceId);
|
||||
form.append("documentId", documentId);
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "上传失败");
|
||||
}
|
||||
const payload = (await response.json()) as { asset: MediaAsset };
|
||||
if (!payload.asset?.file_url) {
|
||||
throw new Error("返回数据缺少文件地址");
|
||||
}
|
||||
emitAssetsChanged(documentId, payload.asset);
|
||||
return {
|
||||
assetId: payload.asset.id,
|
||||
fileUrl: payload.asset.file_url,
|
||||
thumbnailUrl: payload.asset.thumbnail_url,
|
||||
assetType: payload.asset.asset_type,
|
||||
fileName: payload.asset.file_name,
|
||||
fileSize: payload.asset.file_size,
|
||||
mimeType: payload.asset.mime_type,
|
||||
} satisfies MediaSelection;
|
||||
},
|
||||
[documentId, workspaceId],
|
||||
);
|
||||
|
||||
const handleUploadMany = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("workspaceId", workspaceId);
|
||||
form.append("documentId", documentId);
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.error ?? "上传失败");
|
||||
for (const file of files) {
|
||||
const selection = await uploadOne(file);
|
||||
onSelect(selection);
|
||||
}
|
||||
const payload = (await response.json()) as { asset: MediaAsset };
|
||||
if (!payload.asset?.file_url) {
|
||||
throw new Error("返回数据缺少文件地址");
|
||||
}
|
||||
emitAssetsChanged(documentId, payload.asset);
|
||||
onSelect({
|
||||
assetId: payload.asset.id,
|
||||
fileUrl: payload.asset.file_url,
|
||||
thumbnailUrl: payload.asset.thumbnail_url,
|
||||
assetType: payload.asset.asset_type,
|
||||
fileName: payload.asset.file_name,
|
||||
fileSize: payload.asset.file_size,
|
||||
mimeType: payload.asset.mime_type,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
@@ -183,15 +195,15 @@ export function ImagePickerDialog({
|
||||
setUploading(false);
|
||||
}
|
||||
},
|
||||
[documentId, onClose, onSelect, workspaceId],
|
||||
[onClose, onSelect, uploadOne],
|
||||
);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length === 0) return;
|
||||
void handleUpload(acceptedFiles[0]);
|
||||
void handleUploadMany(multiple ? acceptedFiles : [acceptedFiles[0]]);
|
||||
},
|
||||
[handleUpload],
|
||||
[handleUploadMany, multiple],
|
||||
);
|
||||
|
||||
const acceptConfig = MEDIA_ACCEPTS[mediaType];
|
||||
@@ -199,7 +211,7 @@ export function ImagePickerDialog({
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: acceptConfig ?? undefined,
|
||||
maxFiles: 1,
|
||||
multiple,
|
||||
});
|
||||
|
||||
const handleLinkSubmit = async () => {
|
||||
@@ -320,7 +332,9 @@ export function ImagePickerDialog({
|
||||
const label = MEDIA_TITLES[mediaType].replace("选择", "");
|
||||
switch (tab) {
|
||||
case "upload":
|
||||
return `支持拖拽或点击上传${label},单个文件不超过 200MB`;
|
||||
return multiple
|
||||
? `支持拖拽或点击上传${label}(可多选),单个文件不超过 200MB`
|
||||
: `支持拖拽或点击上传${label},单个文件不超过 200MB`;
|
||||
case "recent":
|
||||
return `最近 12 个${label},支持一键选取`;
|
||||
case "link":
|
||||
@@ -330,7 +344,7 @@ export function ImagePickerDialog({
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}, [mediaType, tab]);
|
||||
}, [mediaType, multiple, tab]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import type { DocumentTable } from "@/types/online-table";
|
||||
import { deleteOnlineTable, getDocumentTable, saveOnlineTable } from "@/lib/online-table";
|
||||
import { Loader2, Table as TableIcon, Maximize2, Trash2, RotateCw } from "lucide-react";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
|
||||
interface CompactTablePreviewProps {
|
||||
tableId: string;
|
||||
@@ -13,16 +16,45 @@ interface CompactTablePreviewProps {
|
||||
}
|
||||
|
||||
const useTableData = (tableId: string) => {
|
||||
const convexEnabled = isConvexEnabled();
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const currentUser = useQuery(api.users.currentUser, convexEnabled && isAuthenticated ? {} : "skip");
|
||||
const userId =
|
||||
currentUser && typeof currentUser === "object" && typeof (currentUser as any)._id === "string"
|
||||
? String((currentUser as any)._id)
|
||||
: "";
|
||||
const tableFromConvex = useQuery(
|
||||
api.tables.get,
|
||||
convexEnabled && userId && tableId ? { userId, tableId } : "skip",
|
||||
);
|
||||
|
||||
const [table, setTable] = useState<DocumentTable | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [version, setVersion] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
setVersion((prev) => prev + 1);
|
||||
}, []);
|
||||
if (!convexEnabled) {
|
||||
setIsLoading(true);
|
||||
setVersion((prev) => prev + 1);
|
||||
}
|
||||
}, [convexEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (convexEnabled) {
|
||||
if (tableFromConvex === undefined) {
|
||||
setIsLoading(true);
|
||||
return;
|
||||
}
|
||||
if (tableFromConvex === null) {
|
||||
setTable(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
setTable({ ...(tableFromConvex as unknown as DocumentTable), title: (tableFromConvex as any)?.title || "未命名表格" });
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let canceled = false;
|
||||
getDocumentTable(tableId)
|
||||
.then((data) => {
|
||||
@@ -44,7 +76,7 @@ const useTableData = (tableId: string) => {
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [tableId, version]);
|
||||
}, [convexEnabled, tableFromConvex, tableId, version]);
|
||||
|
||||
return { table, isLoading, refresh };
|
||||
};
|
||||
@@ -103,7 +135,7 @@ const CompactTablePreviewInner: React.FC<CompactTablePreviewProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleDeleteTable = useCallback(async () => {
|
||||
const confirmed = window.confirm("删除表格将同步移除 Supabase 记录,确认继续?");
|
||||
const confirmed = window.confirm("删除表格将同步移除在线表格记录,确认继续?");
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
await deleteOnlineTable(tableId);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Loader2, Table, X, Zap } from "lucide-react";
|
||||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||
import type { DocumentTable, TableRowData } from "@/types/online-table";
|
||||
import {
|
||||
DEFAULT_TABLE_COLUMNS,
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
createDefaultTableSnapshot,
|
||||
saveOnlineTable,
|
||||
} from "@/lib/online-table";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
|
||||
import { extractRowsForPreview } from "@/components/online-table/utils";
|
||||
@@ -75,6 +78,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [lastSyncedAt, setLastSyncedAt] = useState<number | null>(null);
|
||||
const persistSnapshotRef = useRef<((reason: "auto" | "close") => Promise<void>) | null>(null);
|
||||
const lastLocalPersistAtRef = useRef<number>(0);
|
||||
const lastPointerDownInGridRef = useRef(false);
|
||||
const savingHintTimerRef = useRef<number | null>(null);
|
||||
const [showSavingHint, setShowSavingHint] = useState(false);
|
||||
@@ -82,6 +86,19 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
const [isSavingTitle, setIsSavingTitle] = useState(false);
|
||||
|
||||
const convexEnabled = isConvexEnabled();
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const currentUser = useQuery(api.users.currentUser, convexEnabled && isAuthenticated ? {} : "skip");
|
||||
const userId =
|
||||
currentUser && typeof currentUser === "object" && typeof (currentUser as any)._id === "string"
|
||||
? String((currentUser as any)._id)
|
||||
: "";
|
||||
const tableFromConvex = useQuery(
|
||||
api.tables.get,
|
||||
convexEnabled && userId && tableId ? { userId, tableId } : "skip",
|
||||
);
|
||||
const updateTable = useMutation(api.tables.update);
|
||||
|
||||
const startSavingHint = useCallback(() => {
|
||||
if (savingHintTimerRef.current !== null) return;
|
||||
savingHintTimerRef.current = window.setTimeout(() => {
|
||||
@@ -129,16 +146,36 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTable(tableId);
|
||||
hasInitializedRef.current = false;
|
||||
lastTableIdRef.current = tableId;
|
||||
}, [fetchTable, tableId]);
|
||||
}, [tableId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tableData) {
|
||||
if (convexEnabled) {
|
||||
if (tableFromConvex === undefined) {
|
||||
setIsTableLoading(true);
|
||||
setTableError(null);
|
||||
return;
|
||||
}
|
||||
if (tableFromConvex === null) {
|
||||
setIsTableLoading(false);
|
||||
setTableError("无法加载表格数据,请稍后重试。");
|
||||
setTableData(null);
|
||||
return;
|
||||
}
|
||||
// 避免“本地保存 -> 订阅回推 -> 立刻重建 luckysheet”导致的闪烁/选区丢失
|
||||
if (Date.now() - lastLocalPersistAtRef.current < 1500) {
|
||||
setIsTableLoading(false);
|
||||
return;
|
||||
}
|
||||
setTableError(null);
|
||||
setTableData(tableFromConvex as unknown as DocumentTable);
|
||||
setIsTableLoading(false);
|
||||
return;
|
||||
}
|
||||
}, [tableData]);
|
||||
|
||||
fetchTable(tableId);
|
||||
}, [convexEnabled, fetchTable, tableFromConvex, tableId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tableData?.title !== undefined) {
|
||||
@@ -185,11 +222,22 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
rows,
|
||||
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : luckysheetSheets,
|
||||
};
|
||||
await saveOnlineTable(tableId, {
|
||||
snapshot,
|
||||
rows,
|
||||
schema: tableData.schema,
|
||||
});
|
||||
lastLocalPersistAtRef.current = Date.now();
|
||||
if (convexEnabled && userId) {
|
||||
await updateTable({
|
||||
userId,
|
||||
tableId,
|
||||
snapshot,
|
||||
rows,
|
||||
schema: tableData.schema,
|
||||
});
|
||||
} else {
|
||||
await saveOnlineTable(tableId, {
|
||||
snapshot,
|
||||
rows,
|
||||
schema: tableData.schema,
|
||||
});
|
||||
}
|
||||
setHasPendingChanges(false);
|
||||
setLastSyncedAt(Date.now());
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
|
||||
@@ -200,7 +248,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
stopSavingHint();
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [luckysheetSheets, startSavingHint, stopSavingHint, tableData, tableId]);
|
||||
}, [convexEnabled, luckysheetSheets, startSavingHint, stopSavingHint, tableData, tableId, updateTable, userId]);
|
||||
|
||||
const debouncedPersist = useDebouncedCallback(() => {
|
||||
void persistSnapshot("auto");
|
||||
@@ -443,6 +491,37 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
|
||||
const gridKey = tableData?.grid_key ?? tableId;
|
||||
const loadUrl = gridKey ? `/api/luckysheet/load?gridKey=${gridKey}` : "";
|
||||
let canceled = false;
|
||||
let resizeRafId: number | null = null;
|
||||
const resizeTimerIds: number[] = [];
|
||||
let unlockTimerId: number | null = null;
|
||||
|
||||
const safeResize = () => {
|
||||
if (canceled) return;
|
||||
const container = document.getElementById(LUCKY_SHEET_CONTAINER_ID);
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
if (rect.width < 10 || rect.height < 10) return;
|
||||
const instance = window.luckysheet as any;
|
||||
if (!instance || typeof instance.resize !== "function") return;
|
||||
try {
|
||||
instance.resize();
|
||||
} catch {
|
||||
// 忽略:Luckysheet 内部可能处于 destroy/create 过程中
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleResize = () => {
|
||||
if (canceled) return;
|
||||
resizeRafId = window.requestAnimationFrame(() => {
|
||||
safeResize();
|
||||
});
|
||||
// 多次兜底:避免首次打开时样式/布局尚未完全稳定
|
||||
resizeTimerIds.push(window.setTimeout(safeResize, 80));
|
||||
resizeTimerIds.push(window.setTimeout(safeResize, 240));
|
||||
resizeTimerIds.push(window.setTimeout(safeResize, 800));
|
||||
};
|
||||
|
||||
const options = {
|
||||
container: LUCKY_SHEET_CONTAINER_ID,
|
||||
title: tableData.title ?? tableId,
|
||||
@@ -476,7 +555,10 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
imageUrlHandle: (url: string) => url,
|
||||
hook: {
|
||||
workbookCreateAfter: () => {
|
||||
if (canceled) return;
|
||||
setIsTableLoading(false);
|
||||
// 仅在 Luckysheet 完成 DOM 构建后再调用 resize,避免触发其内部空引用(offsetHeight of null)
|
||||
scheduleResize();
|
||||
},
|
||||
updated: () => {
|
||||
if (isApplyingSnapshotRef.current) return;
|
||||
@@ -515,31 +597,21 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
return;
|
||||
}
|
||||
|
||||
// 首次加载时,资源/样式可能刚完成注入,强制触发一次 resize 让 Luckysheet 重新计算布局(避免工具栏/公式栏不显示)
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
(window.luckysheet as any)?.resize?.();
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
(window.luckysheet as any)?.resize?.();
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}, 80);
|
||||
});
|
||||
|
||||
// 等待首帧渲染完成再开放 updated 事件
|
||||
setTimeout(() => {
|
||||
unlockTimerId = window.setTimeout(() => {
|
||||
isApplyingSnapshotRef.current = false;
|
||||
}, 0);
|
||||
|
||||
const containerEl = containerRef.current;
|
||||
return () => {
|
||||
canceled = true;
|
||||
if (resizeRafId !== null) {
|
||||
cancelAnimationFrame(resizeRafId);
|
||||
}
|
||||
resizeTimerIds.forEach((id) => clearTimeout(id));
|
||||
if (unlockTimerId !== null) {
|
||||
clearTimeout(unlockTimerId);
|
||||
}
|
||||
if (window.luckysheet) {
|
||||
window.luckysheet?.destroy?.(LUCKY_SHEET_CONTAINER_ID);
|
||||
}
|
||||
@@ -570,8 +642,13 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
}
|
||||
setIsSavingTitle(true);
|
||||
try {
|
||||
const updated = await saveOnlineTable(tableId, { title: nextTitle });
|
||||
const finalTitle = updated.title ?? nextTitle;
|
||||
let finalTitle = nextTitle;
|
||||
if (convexEnabled && userId) {
|
||||
await updateTable({ userId, tableId, title: nextTitle });
|
||||
} else {
|
||||
const updated = await saveOnlineTable(tableId, { title: nextTitle });
|
||||
finalTitle = updated.title ?? nextTitle;
|
||||
}
|
||||
setTableData((prev) => (prev ? { ...prev, title: finalTitle } : prev));
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
|
||||
} catch (error) {
|
||||
@@ -581,7 +658,7 @@ const FullScreenTableEditor: React.FC<FullScreenTableEditorProps> = ({ tableId,
|
||||
setIsRenaming(false);
|
||||
setIsSavingTitle(false);
|
||||
}
|
||||
}, [renameValue, tableData, tableId]);
|
||||
}, [convexEnabled, renameValue, tableData, tableId, updateTable, userId]);
|
||||
|
||||
const statusText = saveError
|
||||
? saveError
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Loader2, RotateCw } from "lucide-react";
|
||||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||
import type { DocumentTable } from "@/types/online-table";
|
||||
import {
|
||||
DEFAULT_TABLE_COLUMNS,
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
getDocumentTable,
|
||||
saveOnlineTable,
|
||||
} from "@/lib/online-table";
|
||||
import { api } from "@/lib/convex/api";
|
||||
import { isConvexEnabled } from "@/lib/convex/enabled";
|
||||
import { useLuckysheetLoader } from "@/components/online-table/useLuckysheetLoader";
|
||||
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
|
||||
import { extractRowsForPreview } from "@/components/online-table/utils";
|
||||
@@ -61,16 +64,24 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [showSavingHint, setShowSavingHint] = useState(false);
|
||||
const savingHintTimerRef = useRef<number | null>(null);
|
||||
const lastRemoteSyncedAtRef = useRef<string | null>(null);
|
||||
const lastSnapshotHashRef = useRef<string | null>(null);
|
||||
const lastLocalPersistAtRef = useRef<number>(0);
|
||||
|
||||
const computeSnapshotHash = useCallback((snapshot: unknown) => {
|
||||
try {
|
||||
return JSON.stringify(snapshot ?? {});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
const convexEnabled = isConvexEnabled();
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const currentUser = useQuery(api.users.currentUser, convexEnabled && isAuthenticated ? {} : "skip");
|
||||
const userId =
|
||||
currentUser && typeof currentUser === "object" && typeof (currentUser as any)._id === "string"
|
||||
? String((currentUser as any)._id)
|
||||
: "";
|
||||
|
||||
const shouldFetchTable = Boolean(convexEnabled && userId && tableId);
|
||||
const tableFromConvex = useQuery(
|
||||
api.tables.get,
|
||||
shouldFetchTable ? { userId, tableId } : "skip",
|
||||
);
|
||||
const updateTable = useMutation(api.tables.update);
|
||||
|
||||
const allowInlineEdit = editable ?? embed;
|
||||
|
||||
const startSavingHint = useCallback(() => {
|
||||
if (savingHintTimerRef.current !== null) return;
|
||||
@@ -92,13 +103,37 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (convexEnabled) {
|
||||
// Convex 模式下走 useQuery 实时订阅,这里仅维护与旧逻辑兼容的 loading/error 状态
|
||||
if (tableFromConvex === undefined) {
|
||||
// still loading
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}
|
||||
if (!canceled) {
|
||||
if (tableFromConvex === null) {
|
||||
setTable(null);
|
||||
setError("无法加载表格数据");
|
||||
} else {
|
||||
// 可编辑内嵌视图:避免每次自动保存后立即重建 UI(会闪烁)。
|
||||
// 我们在本组件触发保存后的短时间内,忽略来自订阅的回写更新。
|
||||
if (allowInlineEdit && Date.now() - lastLocalPersistAtRef.current < 1500) {
|
||||
// ignore
|
||||
} else {
|
||||
setTable(tableFromConvex as unknown as DocumentTable);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}
|
||||
|
||||
getDocumentTable(tableId)
|
||||
.then((data) => {
|
||||
if (!canceled) {
|
||||
lastSnapshotHashRef.current = computeSnapshotHash(data.snapshot);
|
||||
if ((data as { last_synced_at?: string }).last_synced_at) {
|
||||
lastRemoteSyncedAtRef.current = (data as { last_synced_at?: string }).last_synced_at ?? null;
|
||||
}
|
||||
setTable(data);
|
||||
}
|
||||
})
|
||||
@@ -118,10 +153,9 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [computeSnapshotHash, reloadVersion, tableId]);
|
||||
}, [allowInlineEdit, convexEnabled, reloadVersion, tableFromConvex, tableId]);
|
||||
|
||||
|
||||
const allowInlineEdit = editable ?? embed;
|
||||
const focusLuckysheetEditor = useCallback(() => {
|
||||
const applyFocus = () => {
|
||||
const editor = document.getElementById("luckysheet-rich-text-editor");
|
||||
@@ -203,12 +237,22 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
rows,
|
||||
luckysheet: Array.isArray(luckysheetData) ? luckysheetData : table.snapshot?.luckysheet ?? [],
|
||||
};
|
||||
lastSnapshotHashRef.current = computeSnapshotHash(snapshot);
|
||||
await saveOnlineTable(tableId, {
|
||||
snapshot,
|
||||
rows,
|
||||
schema: table.schema,
|
||||
});
|
||||
lastLocalPersistAtRef.current = Date.now();
|
||||
if (convexEnabled && userId) {
|
||||
await updateTable({
|
||||
userId,
|
||||
tableId,
|
||||
snapshot,
|
||||
rows,
|
||||
schema: table.schema,
|
||||
});
|
||||
} else {
|
||||
await saveOnlineTable(tableId, {
|
||||
snapshot,
|
||||
rows,
|
||||
schema: table.schema,
|
||||
});
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent("online-table-saved", { detail: { tableId } }));
|
||||
} catch (err) {
|
||||
console.error("内嵌表格保存失败", err);
|
||||
@@ -217,7 +261,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
stopSavingHint();
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [allowInlineEdit, computeSnapshotHash, startSavingHint, stopSavingHint, table, tableId]);
|
||||
}, [allowInlineEdit, convexEnabled, startSavingHint, stopSavingHint, table, tableId, updateTable, userId]);
|
||||
|
||||
const debouncedPersist = useDebouncedCallback(() => {
|
||||
void persistSnapshot();
|
||||
@@ -232,36 +276,7 @@ const HeadlessTableViewer: React.FC<HeadlessTableViewerProps> = ({ tableId, embe
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableId) return;
|
||||
// Supabase 已移除:此处不再做实时订阅
|
||||
/* Supabase subscription removed
|
||||
const channel = supabaseBrowser
|
||||
.channel(`table-${tableId}-live`)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "UPDATE", schema: "public", table: "document_tables", filter: `id=eq.${tableId}` },
|
||||
(payload) => {
|
||||
const next = payload.new as DocumentTable | null;
|
||||
if (!next) return;
|
||||
const nextSynced = (next as { last_synced_at?: string }).last_synced_at ?? null;
|
||||
if (nextSynced && lastRemoteSyncedAtRef.current && nextSynced <= lastRemoteSyncedAtRef.current) {
|
||||
return;
|
||||
}
|
||||
lastRemoteSyncedAtRef.current = nextSynced;
|
||||
const nextHash = computeSnapshotHash(next.snapshot);
|
||||
const currentHash = lastSnapshotHashRef.current;
|
||||
if (nextHash && currentHash && nextHash === currentHash) {
|
||||
return; // 相同快照无需重建,避免闪烁
|
||||
}
|
||||
lastSnapshotHashRef.current = nextHash;
|
||||
setTable(next);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabaseBrowser.removeChannel(channel);
|
||||
};
|
||||
*/
|
||||
// Supabase 已移除:实时订阅由 Convex useQuery 承担(见上方 tableFromConvex)
|
||||
}, [tableId]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { clamp } from "@/lib/constants";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
|
||||
type AgentMessage = { role: "user" | "assistant"; content: string };
|
||||
|
||||
@@ -83,6 +84,7 @@ export function OnlyOfficeAiAgentPanel({
|
||||
}: {
|
||||
openFile: { id: string; title: string; fileUrl: string; mimeType?: string | null };
|
||||
}) {
|
||||
const flightMode = useAppPreferencesStore((s) => s.flightMode);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [page, setPage] = useState<PanelPage>("chat");
|
||||
|
||||
@@ -91,7 +93,7 @@ export function OnlyOfficeAiAgentPanel({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [toolLogs, setToolLogs] = useState<ToolLog[]>([]);
|
||||
|
||||
const [networkOn, setNetworkOn] = useState(true);
|
||||
const [networkOn, setNetworkOn] = useState(() => !flightMode);
|
||||
const [toolAuto, setToolAuto] = useState(true);
|
||||
const [aiProvider, setAiProvider] = useState<"online" | "local">("online");
|
||||
const [aiModel, setAiModel] = useState<string>("");
|
||||
@@ -117,6 +119,12 @@ export function OnlyOfficeAiAgentPanel({
|
||||
[openFile.fileUrl, openFile.id, openFile.mimeType, openFile.title],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (flightMode) {
|
||||
setNetworkOn(false);
|
||||
}
|
||||
}, [flightMode]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stepsRaw = window.localStorage.getItem("onlyoffice_ai_max_steps") || "";
|
||||
@@ -606,4 +614,3 @@ export function OnlyOfficeAiAgentPanel({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAppPreferencesStore } from "@/store/app-preferences";
|
||||
|
||||
const applyTheme = (theme: "system" | "light" | "dark") => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const prefersDark =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const shouldDark = theme === "dark" || (theme === "system" && prefersDark);
|
||||
root.classList.toggle("dark", shouldDark);
|
||||
};
|
||||
|
||||
export function AppPreferencesHydrator() {
|
||||
const hydrated = useAppPreferencesStore((s) => s.hydrated);
|
||||
const hydrate = useAppPreferencesStore((s) => s.hydrate);
|
||||
const theme = useAppPreferencesStore((s) => s.theme);
|
||||
const setTheme = useAppPreferencesStore((s) => s.setTheme);
|
||||
const setShowStructure = useAppPreferencesStore((s) => s.setShowStructure);
|
||||
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = () => {
|
||||
const current = useAppPreferencesStore.getState().theme;
|
||||
if (current === "system") {
|
||||
applyTheme("system");
|
||||
}
|
||||
};
|
||||
mq.addEventListener?.("change", onChange);
|
||||
return () => mq.removeEventListener?.("change", onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const isEditableTarget = (target: EventTarget | null) => {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
const tag = String(el.tagName ?? "").toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return true;
|
||||
return Boolean(el.isContentEditable);
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (isEditableTarget(event.target)) {
|
||||
// 保留 Wolai 的快捷键体验,但不打断输入法/编辑器输入。
|
||||
return;
|
||||
}
|
||||
const ctrlOrMeta = event.ctrlKey || event.metaKey;
|
||||
const key = String(event.key ?? "").toLowerCase();
|
||||
|
||||
// Wolai:ctrl/cmd + shift + U 显示/隐藏块结构虚线框
|
||||
if (ctrlOrMeta && event.shiftKey && key === "u") {
|
||||
event.preventDefault();
|
||||
const { showStructure } = useAppPreferencesStore.getState();
|
||||
setShowStructure(!showStructure);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wolai:ctrl/cmd + alt/opt + G 打开/关闭 Good Night 模式
|
||||
if (ctrlOrMeta && event.altKey && key === "g") {
|
||||
event.preventDefault();
|
||||
const current = useAppPreferencesStore.getState().theme;
|
||||
setTheme(current === "dark" ? "light" : "dark");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [setShowStructure, setTheme]);
|
||||
|
||||
// hydrated 仅用于触发一次 render,避免被 tree-shaking 误删
|
||||
if (!hydrated) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function DocumentShareDialog({
|
||||
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` 后重试。");
|
||||
setError("共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all` 后重试。");
|
||||
} else {
|
||||
setError(msg);
|
||||
}
|
||||
@@ -213,11 +213,11 @@ export function DocumentShareDialog({
|
||||
await loadShares();
|
||||
await onChanged?.();
|
||||
setError(
|
||||
"已共享,但“禁止下载/禁止复制”未生效(当前 Convex 后端未更新)。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后再设置限制。",
|
||||
"已共享,但“禁止下载/禁止复制”未生效(当前 Convex 后端未更新)。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all` 后再设置限制。",
|
||||
);
|
||||
} catch {
|
||||
setError(
|
||||
"共享功能已升级(新增“禁止下载/禁止复制”),但当前 Convex 后端未更新。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后刷新页面再试。",
|
||||
"共享功能已升级(新增“禁止下载/禁止复制”),但当前 Convex 后端未更新。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all` 后刷新页面再试。",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -277,11 +277,11 @@ export function DocumentShareDialog({
|
||||
await loadGroupShares();
|
||||
await onChanged?.();
|
||||
setError(
|
||||
"已公开,但“禁止下载/禁止复制”未生效(当前 Convex 后端未更新)。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后再设置限制。",
|
||||
"已公开,但“禁止下载/禁止复制”未生效(当前 Convex 后端未更新)。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all` 后再设置限制。",
|
||||
);
|
||||
} catch {
|
||||
setError(
|
||||
"共享功能已升级(新增“禁止下载/禁止复制”),但当前 Convex 后端未更新。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后刷新页面再试。",
|
||||
"共享功能已升级(新增“禁止下载/禁止复制”),但当前 Convex 后端未更新。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all` 后刷新页面再试。",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -111,7 +111,9 @@ export function FileTree({
|
||||
{rows.map((row, index) => {
|
||||
const label = getFileTreeRowLabel(row);
|
||||
const selected = selectedRowIds.has(row.rowId);
|
||||
const active = row.kind !== "asset" && row.docId === activeId;
|
||||
// 说明:只有“页面本身(doc/index.md)”才需要 active 高亮。
|
||||
// 否则当你打开某个页面时,它下面的思维导图文件夹/附件会出现“灰色假选中”的视觉误导。
|
||||
const active = (row.kind === "doc" || row.kind === "index") && row.docId === activeId;
|
||||
const draggable =
|
||||
row.kind === "doc" || (row.kind === "asset" && isRealFileAsset(row.asset));
|
||||
const inDropFeedback =
|
||||
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
} from "@/lib/file-tree/clipboard";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
||||
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
||||
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitAssetsRestored, emitDocumentsChanged } from "@/lib/events";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { DocumentShareDialog } from "@/components/sharing/document-share-dialog";
|
||||
import { api } from "@/lib/convex/api";
|
||||
@@ -239,6 +239,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
|
||||
// 删除在线表格后,Convex 订阅刷新存在极短延迟;这里做短暂“乐观隐藏”,避免文件树闪回。
|
||||
const hiddenTableIdsRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
setTree(() => {
|
||||
@@ -257,7 +259,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}, [sidebarData.mindmapAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
setTableAssets(sidebarData.tableAssets ?? []);
|
||||
const hidden = hiddenTableIdsRef.current;
|
||||
const now = Date.now();
|
||||
hidden.forEach((ts, id) => {
|
||||
if (now - ts > 15000) {
|
||||
hidden.delete(id);
|
||||
}
|
||||
});
|
||||
setTableAssets((sidebarData.tableAssets ?? []).filter((item) => !hidden.has(item.id)));
|
||||
}, [sidebarData.tableAssets]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -265,8 +274,23 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}, [activeId, setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const onSaved = () => void sidebarQuery.refetch();
|
||||
const onDeleted = () => void sidebarQuery.refetch();
|
||||
const onSaved = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
|
||||
const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
|
||||
if (tableId) {
|
||||
hiddenTableIdsRef.current.delete(tableId);
|
||||
}
|
||||
void sidebarQuery.refetch();
|
||||
};
|
||||
const onDeleted = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ tableId?: string }>).detail;
|
||||
const tableId = typeof detail?.tableId === "string" ? detail.tableId : "";
|
||||
if (tableId) {
|
||||
hiddenTableIdsRef.current.set(tableId, Date.now());
|
||||
setTableAssets((prev) => prev.filter((item) => item.id !== tableId));
|
||||
}
|
||||
void sidebarQuery.refetch();
|
||||
};
|
||||
window.addEventListener("online-table-saved", onSaved as EventListener);
|
||||
window.addEventListener("online-table-deleted", onDeleted as EventListener);
|
||||
return () => {
|
||||
@@ -288,7 +312,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const msg = e?.message ?? "加载共享摘要失败";
|
||||
if (String(msg).includes("Could not find public function for 'documentShares:listMyShareRoots'")) {
|
||||
setShareSummaryError(
|
||||
"共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local`。",
|
||||
"共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all`。",
|
||||
);
|
||||
} else {
|
||||
setShareSummaryError(msg);
|
||||
@@ -328,7 +352,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
} 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`。");
|
||||
setGroupPublicError("群组公开功能后端未部署/未更新:请执行 `npx convex dev --env-file ../.env.all` 或 `npx convex deploy --env-file ../.env.all`。");
|
||||
} else {
|
||||
setGroupPublicError(msg);
|
||||
}
|
||||
@@ -463,13 +487,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const assets = [
|
||||
...(sidebarData.trashedMediaAssets ?? []),
|
||||
...(sidebarData.trashedMindmapAssets ?? []),
|
||||
...(sidebarData.trashedTableAssets ?? []),
|
||||
];
|
||||
const keyword = trashSearch.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return assets;
|
||||
}
|
||||
return assets.filter((item) => (item.file_name ?? "未命名附件").toLowerCase().includes(keyword));
|
||||
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, trashSearch]);
|
||||
}, [sidebarData.trashedMediaAssets, sidebarData.trashedMindmapAssets, sidebarData.trashedTableAssets, trashSearch]);
|
||||
|
||||
const mindmapChildrenSnapshot = useMemo(() => {
|
||||
const mapping = sidebarData.mindmapAssetChildren ?? {};
|
||||
@@ -703,7 +728,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
if (officeFileType) {
|
||||
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
|
||||
if (!officeBase) {
|
||||
window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
|
||||
window.alert("缺少 ONLYOFFICE 地址,请在 .env.all 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL");
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
@@ -1142,15 +1167,19 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
);
|
||||
|
||||
const handleDeleteAssets = useCallback(
|
||||
async (assetIds: string[], assetHint?: MediaAsset) => {
|
||||
async (assetIds: string[], assetHint?: MediaAsset | MediaAsset[]) => {
|
||||
const uniqueAssetIds = Array.from(new Set(assetIds));
|
||||
const assets = uniqueAssetIds
|
||||
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
|
||||
.filter(Boolean) as MediaAsset[];
|
||||
|
||||
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
|
||||
assets.unshift(assetHint);
|
||||
}
|
||||
const hints = Array.isArray(assetHint) ? assetHint : assetHint ? [assetHint] : [];
|
||||
hints.forEach((hint) => {
|
||||
if (!hint) return;
|
||||
if (!assets.find((item) => item.id === hint.id)) {
|
||||
assets.unshift(hint);
|
||||
}
|
||||
});
|
||||
|
||||
const mindmapAssetsToDelete = assets.filter((item) => item.asset_type === "mindmap");
|
||||
const tableAssetsToDelete = assets.filter((item) => item.asset_type === "luckysheet");
|
||||
@@ -1231,16 +1260,32 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}
|
||||
|
||||
const docText = docIds.length > 0 ? `${docIds.length} 个页面(删除到垃圾桶)` : "";
|
||||
const selectedAssets = assetIds
|
||||
.map((id) => mediaAssets.find((item) => item.id === id) ?? mindmapAssets.find((item) => item.id === id) ?? tableAssets.find((item) => item.id === id))
|
||||
.filter(Boolean) as MediaAsset[];
|
||||
const mindmapCount = selectedAssets.filter((item) => item.asset_type === "mindmap").length;
|
||||
const tableCount = selectedAssets.filter((item) => item.asset_type === "luckysheet").length;
|
||||
const fileCount = selectedAssets.filter((item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet").length;
|
||||
|
||||
// 说明:文件树里可能展示“思维导图子文件”等动态资源(不一定在 mindmapAssets 列表里)。
|
||||
// 为避免出现“看起来选中了,但删除不生效”,这里优先从可见行里拿到选中资源的完整元数据。
|
||||
const isAssetRow = (
|
||||
row: FileTreeRow,
|
||||
): row is Extract<FileTreeRow, { kind: "asset" | "asset-folder" }> =>
|
||||
row.kind === "asset" || row.kind === "asset-folder";
|
||||
|
||||
const selectedAssetHints = Array.from(
|
||||
new Map(
|
||||
fileTreeRows
|
||||
.filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId))
|
||||
.filter(isAssetRow)
|
||||
.map((row) => [row.asset.id, row.asset] as const),
|
||||
).values(),
|
||||
);
|
||||
|
||||
const mindmapCount = selectedAssetHints.filter((item) => item.asset_type === "mindmap").length;
|
||||
const tableCount = selectedAssetHints.filter((item) => item.asset_type === "luckysheet").length;
|
||||
const fileCount = selectedAssetHints.filter(
|
||||
(item) => item.asset_type !== "mindmap" && item.asset_type !== "luckysheet",
|
||||
).length;
|
||||
const unknownCount = Math.max(0, assetIds.length - mindmapCount - tableCount - fileCount);
|
||||
const assetTextParts: string[] = [];
|
||||
if (fileCount > 0) assetTextParts.push(`${fileCount} 个附件(删除,10 分钟内可撤销)`);
|
||||
if (mindmapCount > 0) assetTextParts.push(`${mindmapCount} 个思维导图(删除)`);
|
||||
if (mindmapCount > 0) assetTextParts.push(`${mindmapCount} 个思维导图(移入垃圾桶,10 分钟内可恢复)`);
|
||||
if (tableCount > 0) assetTextParts.push(`${tableCount} 个在线表格(删除)`);
|
||||
if (unknownCount > 0) assetTextParts.push(`${unknownCount} 个对象(删除)`);
|
||||
const assetText = assetTextParts.length > 0 ? assetTextParts.join(" + ") : "";
|
||||
@@ -1273,7 +1318,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}
|
||||
|
||||
if (assetIds.length > 0) {
|
||||
await handleDeleteAssets(assetIds);
|
||||
await handleDeleteAssets(assetIds, selectedAssetHints);
|
||||
}
|
||||
|
||||
await refreshTree();
|
||||
@@ -1842,6 +1887,10 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
if (!confirmTrashAction("确认恢复该附件吗?")) {
|
||||
return;
|
||||
}
|
||||
const assetHint =
|
||||
(filteredTrashedMediaAssets ?? []).find((a) => a.id === assetId) ??
|
||||
(sidebarData.trashedMediaAssets ?? []).find((a) => a.id === assetId) ??
|
||||
null;
|
||||
const response = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -1853,8 +1902,84 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
// 重要:删除附件时会同步移除主编辑区块;恢复后向编辑器广播“恢复事件”,由编辑器决定是否插入。
|
||||
let assetForInsert = assetHint as any;
|
||||
if (assetHint?.document_id && String(assetHint.document_id) === String(activeId)) {
|
||||
const fallback = (assetHint.signed_url ?? assetHint.file_url ?? "").trim();
|
||||
let fileUrl = fallback;
|
||||
if (!fileUrl) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`);
|
||||
if (res.ok) {
|
||||
const payload = (await res.json().catch(() => null)) as any;
|
||||
fileUrl = String(payload?.signedUrl ?? "").trim();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (fileUrl) {
|
||||
assetForInsert = {
|
||||
...(assetHint as any),
|
||||
id: assetId,
|
||||
file_url: fileUrl,
|
||||
thumbnail_url: (assetHint.thumbnail_url ?? fileUrl) as any,
|
||||
} as any;
|
||||
editorBridge?.insertMediaAsset?.(assetForInsert);
|
||||
}
|
||||
}
|
||||
if (assetHint?.document_id) {
|
||||
emitAssetsRestored({ docId: String(assetHint.document_id), kind: "media", assetId, asset: assetForInsert });
|
||||
}
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
[
|
||||
activeId,
|
||||
confirmTrashAction,
|
||||
editorBridge,
|
||||
filteredTrashedMediaAssets,
|
||||
refreshTree,
|
||||
sidebarData.trashedMediaAssets,
|
||||
sidebarQuery,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRestoreTableFromTrash = useCallback(
|
||||
async (tableId: string) => {
|
||||
if (!confirmTrashAction("确认恢复该在线表格吗?")) {
|
||||
return;
|
||||
}
|
||||
const tableHint =
|
||||
(filteredTrashedMediaAssets ?? []).find((a) => a.id === tableId && a.asset_type === "luckysheet") ??
|
||||
(sidebarData.trashedTableAssets ?? []).find((a) => a.id === tableId) ??
|
||||
null;
|
||||
const response = await fetch("/api/tables/restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tableId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "恢复在线表格失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
// 重要:删除在线表格会同步移除主编辑区块;恢复后若目标页面正打开,则把表格重新插入主编辑区。
|
||||
if (tableHint?.document_id && String(tableHint.document_id) === String(activeId)) {
|
||||
editorBridge?.insertOnlineTableAsset?.({ documentId: String(tableHint.document_id), tableId });
|
||||
}
|
||||
if (tableHint?.document_id) {
|
||||
emitAssetsRestored({ docId: String(tableHint.document_id), kind: "table", tableId });
|
||||
}
|
||||
},
|
||||
[
|
||||
activeId,
|
||||
confirmTrashAction,
|
||||
editorBridge,
|
||||
filteredTrashedMediaAssets,
|
||||
refreshTree,
|
||||
sidebarData.trashedTableAssets,
|
||||
sidebarQuery,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePurgeMediaAssetFromTrash = useCallback(
|
||||
@@ -1877,6 +2002,26 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handlePurgeTableFromTrash = useCallback(
|
||||
async (tableId: string) => {
|
||||
if (!confirmTrashAction("彻底删除后将无法恢复,是否继续?")) {
|
||||
return;
|
||||
}
|
||||
const response = await fetch("/api/tables/purge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tableId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "彻底删除在线表格失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handleEmptyMediaTrash = useCallback(async () => {
|
||||
if (!sidebarData.activeWorkspaceId) {
|
||||
window.alert("暂无可清空的工作空间");
|
||||
@@ -1887,7 +2032,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}
|
||||
setEmptyingTrash(true);
|
||||
try {
|
||||
const [mediaResp, mindmapResp] = await Promise.all([
|
||||
const [mediaResp, mindmapResp, tableResp] = await Promise.all([
|
||||
fetch("/api/media/empty-trash", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -1898,6 +2043,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
|
||||
}),
|
||||
fetch("/api/tables/empty-trash", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspaceId: sidebarData.activeWorkspaceId }),
|
||||
}),
|
||||
]);
|
||||
if (!mediaResp.ok) {
|
||||
const payload = await mediaResp.json().catch(() => ({}));
|
||||
@@ -1909,6 +2059,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
window.alert(payload?.error ?? "清空思维导图垃圾桶失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
if (!tableResp.ok) {
|
||||
const payload = await tableResp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "清空在线表格垃圾桶失败,请稍后再试");
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
} finally {
|
||||
setEmptyingTrash(false);
|
||||
@@ -1931,8 +2086,15 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
return;
|
||||
}
|
||||
await Promise.all([sidebarQuery.refetch(), refreshTree()]);
|
||||
// 重要:删除思维导图会同步移除主编辑区块;恢复后若目标页面正打开,则把导图重新插入主编辑区。
|
||||
if (documentId && String(documentId) === String(activeId)) {
|
||||
editorBridge?.insertMindmapAsset?.({ documentId, mindmapId });
|
||||
}
|
||||
if (documentId) {
|
||||
emitAssetsRestored({ docId: String(documentId), kind: "mindmap", mindmapId });
|
||||
}
|
||||
},
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
[activeId, confirmTrashAction, editorBridge, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handlePurgeMindmapFromTrash = useCallback(
|
||||
@@ -2387,7 +2549,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
<span className="text-xs text-gray-400">
|
||||
{sidebarData.trashedDocuments.length +
|
||||
(sidebarData.trashedMediaAssets?.length ?? 0) +
|
||||
(sidebarData.trashedMindmapAssets?.length ?? 0)}{" "}
|
||||
(sidebarData.trashedMindmapAssets?.length ?? 0) +
|
||||
(sidebarData.trashedTableAssets?.length ?? 0)}{" "}
|
||||
条
|
||||
</span>
|
||||
</button>
|
||||
@@ -2509,7 +2672,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
/>
|
||||
)}
|
||||
<Drawer open={trashOpen} onOpenChange={setTrashOpen}>
|
||||
<DrawerContent className="max-h-[90vh]">
|
||||
<DrawerContent className="max-h-[90vh] overflow-hidden">
|
||||
<div className="flex max-h-[90vh] flex-col">
|
||||
<DrawerHeader className="text-left">
|
||||
<DrawerTitle>垃圾桶</DrawerTitle>
|
||||
{trashTab === "documents" ? (
|
||||
@@ -2520,6 +2684,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
</p>
|
||||
)}
|
||||
</DrawerHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-4 px-4 pb-6">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -2543,7 +2708,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
onClick={() => setTrashTab("assets")}
|
||||
>
|
||||
附件 (
|
||||
{(sidebarData.trashedMediaAssets?.length ?? 0) + (sidebarData.trashedMindmapAssets?.length ?? 0)}
|
||||
{(sidebarData.trashedMediaAssets?.length ?? 0) +
|
||||
(sidebarData.trashedMindmapAssets?.length ?? 0) +
|
||||
(sidebarData.trashedTableAssets?.length ?? 0)}
|
||||
)
|
||||
</button>
|
||||
</div>
|
||||
@@ -2624,7 +2791,7 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
删除时间:{item.deleted_at ? new Date(item.deleted_at).toLocaleString() : "-"}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
类型:{item.mime_type ?? item.asset_type ?? "unknown"}
|
||||
类型:{item.asset_type === "mindmap" ? "思维导图" : (item.mime_type ?? item.asset_type ?? "unknown")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -2634,7 +2801,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
onClick={() =>
|
||||
void (item.asset_type === "mindmap"
|
||||
? handleRestoreMindmapFromTrash(item.document_id, item.id)
|
||||
: handleRestoreMediaAssetFromTrash(item.id))
|
||||
: item.asset_type === "luckysheet"
|
||||
? handleRestoreTableFromTrash(item.id)
|
||||
: handleRestoreMediaAssetFromTrash(item.id))
|
||||
}
|
||||
>
|
||||
恢复
|
||||
@@ -2645,7 +2814,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
onClick={() =>
|
||||
void (item.asset_type === "mindmap"
|
||||
? handlePurgeMindmapFromTrash(item.document_id, item.id)
|
||||
: handlePurgeMediaAssetFromTrash(item.id))
|
||||
: item.asset_type === "luckysheet"
|
||||
? handlePurgeTableFromTrash(item.id)
|
||||
: handlePurgeMediaAssetFromTrash(item.id))
|
||||
}
|
||||
>
|
||||
彻底删除
|
||||
@@ -2656,6 +2827,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</>
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface SidebarInitialData {
|
||||
trashedDocuments: TrashRecord[];
|
||||
trashedMediaAssets?: MediaAsset[];
|
||||
trashedMindmapAssets?: MediaAsset[];
|
||||
trashedTableAssets?: MediaAsset[];
|
||||
/**
|
||||
* 在线表格(Luckysheet)在文件树中的“虚拟文件”列表。
|
||||
* 仅用于文件树展示与操作(单击跳转 index / 双击全屏打开 / 同步删除)。
|
||||
|
||||
@@ -57,6 +57,11 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
shouldFetchAuthed ? { userId, workspaceId, limit: 2000 } : "skip",
|
||||
);
|
||||
|
||||
const tables = useQuery(
|
||||
api.tables.listByWorkspaceForSearch,
|
||||
shouldFetchAuthed ? { userId, workspaceId, includeArchived: true, limit: 3000 } : "skip",
|
||||
);
|
||||
|
||||
const workspacesResult = useQuery(
|
||||
api.workspaces.fetchWorkspaceSummaries,
|
||||
shouldFetch ? {} : "skip",
|
||||
@@ -77,6 +82,7 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
mindmaps === undefined ||
|
||||
mediaAssets === undefined ||
|
||||
trashedMediaAssets === undefined ||
|
||||
tables === undefined ||
|
||||
workspacesResult === undefined) {
|
||||
return null;
|
||||
}
|
||||
@@ -137,6 +143,63 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
};
|
||||
});
|
||||
|
||||
const tableAssets: MediaAsset[] = (tables ?? [])
|
||||
.filter((row) => !row.is_archived)
|
||||
.map((row) => {
|
||||
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
|
||||
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
|
||||
return {
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id ?? workspaceId,
|
||||
document_id: row.document_id,
|
||||
asset_type: "luckysheet",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: fileName,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: row.created_at ?? "",
|
||||
updated_at: row.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
const trashedTableAssets: MediaAsset[] = (tables ?? [])
|
||||
.filter((row) => Boolean(row.is_archived))
|
||||
.map((row) => {
|
||||
const base = String(row.title ?? "未命名表格").trim() || "未命名表格";
|
||||
const fileName = base.toLowerCase().endsWith(".luckysheet") ? base : `${base}.luckysheet`;
|
||||
return {
|
||||
id: row.id,
|
||||
workspace_id: row.workspace_id ?? workspaceId,
|
||||
document_id: row.document_id,
|
||||
asset_type: "luckysheet",
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: fileName,
|
||||
file_size: null,
|
||||
mime_type: "application/json",
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
deleted_at: row.deleted_at ?? row.updated_at ?? null,
|
||||
deleted_by: row.deleted_by ?? null,
|
||||
purged_at: row.purged_at ?? null,
|
||||
signed_url: null,
|
||||
created_at: row.created_at ?? "",
|
||||
updated_at: row.updated_at ?? "",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
activeWorkspaceId: workspacesResult.activeWorkspaceId || workspaceId,
|
||||
workspaces: workspacesResult.workspaces,
|
||||
@@ -144,10 +207,11 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
trashedDocuments,
|
||||
trashedMediaAssets: ((trashedMediaAssets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
|
||||
trashedMindmapAssets,
|
||||
trashedTableAssets,
|
||||
mindmapDocs,
|
||||
mindmapAssets,
|
||||
mindmapAssetChildren: {},
|
||||
tableAssets: [],
|
||||
tableAssets,
|
||||
mediaAssets: ((mediaAssets ?? []) as MediaAsset[]).map(normalizeAssetUrls),
|
||||
};
|
||||
}, [
|
||||
@@ -157,6 +221,7 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
mindmaps,
|
||||
mediaAssets,
|
||||
trashedMediaAssets,
|
||||
tables,
|
||||
workspacesResult,
|
||||
workspaceId,
|
||||
]);
|
||||
@@ -170,6 +235,7 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
mindmaps === undefined ||
|
||||
mediaAssets === undefined ||
|
||||
trashedMediaAssets === undefined ||
|
||||
tables === undefined ||
|
||||
workspacesResult === undefined
|
||||
);
|
||||
const error = null;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { convexAuthNextjsToken } from "@convex-dev/auth/nextjs/server";
|
||||
import { isDevAuthEnabled } from "@/lib/auth/devUser";
|
||||
|
||||
let cached: ConvexHttpClient | null = null;
|
||||
|
||||
@@ -31,6 +32,16 @@ export async function getConvexAuthedHttpClient(): Promise<ConvexHttpClient> {
|
||||
|
||||
const token = await convexAuthNextjsToken();
|
||||
if (!token) {
|
||||
// 说明:开发用户模式(MNOTE_DEV_AUTH=1)用于迁移/联调阶段的“免登录”体验。
|
||||
// 此时浏览器侧可能没有 Convex Auth cookies,但服务端仍需要能访问 Convex。
|
||||
// 若配置了自托管 Admin Key,则允许在开发用户模式下回退到 Admin Auth(仅本地/联调使用)。
|
||||
const adminKey = process.env.CONVEX_SELF_HOSTED_ADMIN_KEY;
|
||||
if (adminKey && isDevAuthEnabled()) {
|
||||
const client = new ConvexHttpClient(url);
|
||||
(client as any).setAdminAuth(adminKey);
|
||||
return client;
|
||||
}
|
||||
|
||||
throw new Error("未登录");
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
export const ASSETS_CHANGED_EVENT = "wolai:assets-changed";
|
||||
export const DOCUMENTS_CHANGED_EVENT = "wolai:documents-changed";
|
||||
export const ASSETS_RESTORED_EVENT = "wolai:assets-restored";
|
||||
|
||||
type AssetsChangedPayload = {
|
||||
docId?: string;
|
||||
@@ -12,6 +13,11 @@ type AssetsChangedPayload = {
|
||||
mindmapAssetIds?: string[];
|
||||
};
|
||||
|
||||
type AssetsRestoredPayload =
|
||||
| { docId: string; kind: "media"; assetId: string; asset?: unknown }
|
||||
| { docId: string; kind: "mindmap"; mindmapId: string }
|
||||
| { docId: string; kind: "table"; tableId: string };
|
||||
|
||||
export function emitAssetsChanged(
|
||||
docId?: string,
|
||||
asset?: unknown,
|
||||
@@ -24,6 +30,11 @@ export function emitAssetsChanged(
|
||||
window.dispatchEvent(new CustomEvent(ASSETS_CHANGED_EVENT, { detail }));
|
||||
}
|
||||
|
||||
export function emitAssetsRestored(payload: AssetsRestoredPayload) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(ASSETS_RESTORED_EVENT, { detail: payload }));
|
||||
}
|
||||
|
||||
export function emitDocumentsChanged(docId?: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent(DOCUMENTS_CHANGED_EVENT, { detail: { docId } }));
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { maybeProxyForBrowserUrl } from "@/lib/url/proxyForBrowser";
|
||||
|
||||
const buildReq = () =>
|
||||
new Request("https://example.com/api/test", {
|
||||
headers: {
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "app.example.com",
|
||||
},
|
||||
});
|
||||
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
return Buffer.from(input, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
describe("maybeProxyForBrowserUrl", () => {
|
||||
it("对 localhost URL 生成 proxy URL", () => {
|
||||
const req = buildReq();
|
||||
const raw = "http://localhost:3210/api/storage/xxx";
|
||||
const out = maybeProxyForBrowserUrl(req, raw);
|
||||
const expected = `https://app.example.com/api/onlyoffice/proxy?u=${base64UrlEncodeUtf8(raw)}`;
|
||||
expect(out).toBe(expected);
|
||||
});
|
||||
|
||||
it("对与 Convex Origin host 匹配的 URL 生成 proxy URL", () => {
|
||||
process.env.CONVEX_SELF_HOSTED_URL = "http://backend:3210";
|
||||
const req = buildReq();
|
||||
const raw = "http://backend:3210/api/storage/yyy";
|
||||
const out = maybeProxyForBrowserUrl(req, raw);
|
||||
const expected = `https://app.example.com/api/onlyoffice/proxy?u=${base64UrlEncodeUtf8(raw)}`;
|
||||
expect(out).toBe(expected);
|
||||
});
|
||||
|
||||
it("对外部 URL 不做处理", () => {
|
||||
process.env.CONVEX_SELF_HOSTED_URL = "http://backend:3210";
|
||||
const req = buildReq();
|
||||
const raw = "https://cdn.example.com/image.png";
|
||||
const out = maybeProxyForBrowserUrl(req, raw);
|
||||
expect(out).toBe(raw);
|
||||
});
|
||||
|
||||
it("已经是 /api/onlyoffice/proxy 的 URL 不重复包裹", () => {
|
||||
const req = buildReq();
|
||||
const raw = "/api/onlyoffice/proxy?u=abc";
|
||||
const out = maybeProxyForBrowserUrl(req, raw);
|
||||
expect(out).toBe(raw);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const base64UrlEncodeUtf8 = (input: string) => {
|
||||
return Buffer.from(input, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
};
|
||||
|
||||
const getRequestOrigin = (request: Request) => {
|
||||
const xfProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||
const xfHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
|
||||
const host = xfHost || request.headers.get("host") || new URL(request.url).host;
|
||||
const protocol = (xfProto || new URL(request.url).protocol.replace(/:$/, "")) + ":";
|
||||
return `${protocol}//${host}`;
|
||||
};
|
||||
|
||||
const isLocalHostname = (hostname: string) =>
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "localhost" ||
|
||||
hostname === "host.docker.internal" ||
|
||||
hostname === "0.0.0.0";
|
||||
|
||||
const getConvexOriginHost = () => {
|
||||
const raw = (process.env.CONVEX_SELF_HOSTED_URL ?? process.env.NEXT_PUBLIC_CONVEX_URL ?? "").trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
return u.hostname;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将“浏览器不可达”的内部回源 URL 包一层 `/api/onlyoffice/proxy?u=...`,避免前端直接请求内网地址。
|
||||
* 说明:该函数只做代理 URL 的生成;真正的 SSRF 防护在 `/api/onlyoffice/proxy` 内完成。
|
||||
*/
|
||||
export const maybeProxyForBrowserUrl = (request: Request, rawUrl: string) => {
|
||||
const input = String(rawUrl || "").trim();
|
||||
if (!input) return input;
|
||||
if (input.startsWith("/api/onlyoffice/proxy")) return input;
|
||||
|
||||
try {
|
||||
const u = new URL(input);
|
||||
const convexHost = getConvexOriginHost();
|
||||
const shouldProxy = isLocalHostname(u.hostname) || (convexHost ? u.hostname === convexHost : false);
|
||||
if (!shouldProxy) return input;
|
||||
|
||||
const origin = getRequestOrigin(request);
|
||||
const proxy = new URL("/api/onlyoffice/proxy", origin);
|
||||
proxy.searchParams.set("u", base64UrlEncodeUtf8(input));
|
||||
return proxy.toString();
|
||||
} catch {
|
||||
return input;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { convexAuthNextjsMiddleware, createRouteMatcher, nextjsMiddlewareRedirect } from "@convex-dev/auth/nextjs/server";
|
||||
import { isDevAuthEnabled } from "@/lib/auth/devUser";
|
||||
|
||||
// 说明:
|
||||
// - 这里启用 Convex Auth 的 Next.js 中间件,负责:
|
||||
@@ -10,6 +11,9 @@ import { convexAuthNextjsMiddleware, createRouteMatcher, nextjsMiddlewareRedirec
|
||||
const isPublicRoute = createRouteMatcher([
|
||||
"/auth",
|
||||
"/login",
|
||||
// 说明:仅用于本地/联调的页面选项回归入口(Playwright 会用它验证页面选项是否真正生效)。
|
||||
// 该路由不写入后端数据,放行可避免 E2E 因鉴权/数据初始化问题被阻塞。
|
||||
"/dev/page-options-playground",
|
||||
"/api/auth(.*)",
|
||||
// 说明:ONLYOFFICE 文档服务器(容器/远端)拉取文件与回调保存不携带用户态,必须放行。
|
||||
"/api/onlyoffice/proxy(.*)",
|
||||
@@ -31,6 +35,10 @@ export default convexAuthNextjsMiddleware(async (request, ctx) => {
|
||||
// 注意:middleware 运行在 Edge/Node 环境中,读取到的是运行期环境变量。
|
||||
if (process.env.NEXT_PUBLIC_USE_CONVEX !== "1") return;
|
||||
|
||||
// 开发用户模式下允许“免登录”访问(主要用于迁移/联调与 E2E 回归)。
|
||||
// 注意:生产环境请勿开启 MNOTE_DEV_AUTH。
|
||||
if (isDevAuthEnabled()) return;
|
||||
|
||||
const authed = await ctx.convexAuth.isAuthenticated();
|
||||
if (!authed) {
|
||||
return nextjsMiddlewareRedirect(request, "/auth");
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
export type ThemeMode = "system" | "light" | "dark";
|
||||
|
||||
type PersistedPreferences = {
|
||||
theme: ThemeMode;
|
||||
showStructure: boolean;
|
||||
spellCheck: boolean;
|
||||
flightMode: boolean;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "mnote:preferences:v1";
|
||||
|
||||
const loadPersisted = (): PersistedPreferences | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<PersistedPreferences>;
|
||||
const theme = parsed.theme === "light" || parsed.theme === "dark" || parsed.theme === "system" ? parsed.theme : "system";
|
||||
return {
|
||||
theme,
|
||||
showStructure: typeof parsed.showStructure === "boolean" ? parsed.showStructure : false,
|
||||
spellCheck: typeof parsed.spellCheck === "boolean" ? parsed.spellCheck : false,
|
||||
flightMode: typeof parsed.flightMode === "boolean" ? parsed.flightMode : false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const persist = (value: PersistedPreferences) => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
interface AppPreferencesState extends PersistedPreferences {
|
||||
hydrated: boolean;
|
||||
hydrate: () => void;
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
setShowStructure: (showStructure: boolean) => void;
|
||||
setSpellCheck: (spellCheck: boolean) => void;
|
||||
setFlightMode: (flightMode: boolean) => void;
|
||||
}
|
||||
|
||||
export const useAppPreferencesStore = create<AppPreferencesState>((set, get) => ({
|
||||
hydrated: false,
|
||||
theme: "system",
|
||||
showStructure: false,
|
||||
spellCheck: false,
|
||||
flightMode: false,
|
||||
hydrate: () => {
|
||||
const current = get();
|
||||
if (current.hydrated) return;
|
||||
const persisted = loadPersisted();
|
||||
if (persisted) {
|
||||
set({ ...persisted, hydrated: true });
|
||||
} else {
|
||||
set({ hydrated: true });
|
||||
}
|
||||
},
|
||||
setTheme: (theme) =>
|
||||
set((state) => {
|
||||
const next = { ...state, theme };
|
||||
persist({
|
||||
theme: next.theme,
|
||||
showStructure: next.showStructure,
|
||||
spellCheck: next.spellCheck,
|
||||
flightMode: next.flightMode,
|
||||
});
|
||||
return next;
|
||||
}),
|
||||
setShowStructure: (showStructure) =>
|
||||
set((state) => {
|
||||
const next = { ...state, showStructure };
|
||||
persist({
|
||||
theme: next.theme,
|
||||
showStructure: next.showStructure,
|
||||
spellCheck: next.spellCheck,
|
||||
flightMode: next.flightMode,
|
||||
});
|
||||
return next;
|
||||
}),
|
||||
setSpellCheck: (spellCheck) =>
|
||||
set((state) => {
|
||||
const next = { ...state, spellCheck };
|
||||
persist({
|
||||
theme: next.theme,
|
||||
showStructure: next.showStructure,
|
||||
spellCheck: next.spellCheck,
|
||||
flightMode: next.flightMode,
|
||||
});
|
||||
return next;
|
||||
}),
|
||||
setFlightMode: (flightMode) =>
|
||||
set((state) => {
|
||||
const next = { ...state, flightMode };
|
||||
persist({
|
||||
theme: next.theme,
|
||||
showStructure: next.showStructure,
|
||||
spellCheck: next.spellCheck,
|
||||
flightMode: next.flightMode,
|
||||
});
|
||||
return next;
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
export type CommentTarget = {
|
||||
workspaceId: string;
|
||||
documentId: string;
|
||||
blockId: string | null;
|
||||
};
|
||||
|
||||
interface CommentsUiState {
|
||||
open: boolean;
|
||||
target: CommentTarget | null;
|
||||
openForPage: (args: { workspaceId: string; documentId: string }) => void;
|
||||
openForBlock: (args: { workspaceId: string; documentId: string; blockId: string }) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useCommentsUiStore = create<CommentsUiState>((set) => ({
|
||||
open: false,
|
||||
target: null,
|
||||
openForPage: ({ workspaceId, documentId }) =>
|
||||
set({
|
||||
open: true,
|
||||
target: { workspaceId, documentId, blockId: null },
|
||||
}),
|
||||
openForBlock: ({ workspaceId, documentId, blockId }) =>
|
||||
set({
|
||||
open: true,
|
||||
target: { workspaceId, documentId, blockId },
|
||||
}),
|
||||
close: () => set({ open: false }),
|
||||
}));
|
||||
|
||||
@@ -12,11 +12,24 @@ export interface EditorReferenceBridgeResult {
|
||||
export interface EditorReferenceBridge {
|
||||
insertInlineReference: (target: ReferenceTarget, alias?: string) => EditorReferenceBridgeResult;
|
||||
insertEmbedReference: (target: ReferenceTarget) => EditorReferenceBridgeResult;
|
||||
undo?: () => void;
|
||||
redo?: () => void;
|
||||
getCursorBlockId?: () => string | null;
|
||||
/**
|
||||
* 向当前打开的文档插入一个“附件/媒体”块。
|
||||
* 主要用于侧边栏文件树拖拽上传后,把文件显示到主编辑区。
|
||||
*/
|
||||
insertMediaAsset?: (asset: MediaAsset) => void;
|
||||
/**
|
||||
* 向当前打开的文档插入一个“思维导图”块(使用 mindmapId 作为 block.id)。
|
||||
* 主要用于侧边栏垃圾桶“恢复思维导图”后,把导图重新显示到主编辑区。
|
||||
*/
|
||||
insertMindmapAsset?: (args: { documentId: string; mindmapId: string }) => void;
|
||||
/**
|
||||
* 向当前打开的文档插入一个“在线表格(luckysheet)”块。
|
||||
* 主要用于侧边栏垃圾桶“恢复表格”后,把表格重新显示到主编辑区。
|
||||
*/
|
||||
insertOnlineTableAsset?: (args: { documentId: string; tableId: string }) => void;
|
||||
replaceWithSnapshot: (blocks: Json) => void;
|
||||
openTableFullScreen?: (tableId: string) => void;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
export type PageFont = "default" | "song" | "kai";
|
||||
|
||||
export type PageLayoutDensity = "compact" | "normal" | "spacious";
|
||||
|
||||
export type BooleanPageOptionKey =
|
||||
| "wideLayout"
|
||||
| "smallText"
|
||||
| "showHeadingNumbers"
|
||||
| "showToc"
|
||||
| "protectEditing"
|
||||
| "showWordCount"
|
||||
| "collapseBacklinks"
|
||||
| "hideChildPages"
|
||||
| "showBlockRefCount";
|
||||
|
||||
export interface PageOptionsState {
|
||||
wideLayout: boolean;
|
||||
smallText: boolean;
|
||||
@@ -6,10 +21,18 @@ export interface PageOptionsState {
|
||||
showStructure: boolean;
|
||||
protectEditing: boolean;
|
||||
showWordCount: boolean;
|
||||
collapseBacklinks: boolean;
|
||||
pageFont: PageFont;
|
||||
layoutDensity: PageLayoutDensity;
|
||||
hideChildPages: boolean;
|
||||
showBlockRefCount: boolean;
|
||||
embedDefaultBlockId: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentStats {
|
||||
wordCount: number;
|
||||
characterCount: number;
|
||||
blockCount: number;
|
||||
todoTotal: number;
|
||||
todoDone: number;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -13,14 +17,18 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"types": ["vitest/globals"],
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -29,7 +37,10 @@
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
"**/*.mts",
|
||||
".next/dev/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(dirname(fileURLToPath(import.meta.url)), "./src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
|
||||
Reference in New Issue
Block a user