diff --git a/desktop-electron/desktop-next/src/app/api/onlyoffice/callback/route.ts b/desktop-electron/desktop-next/src/app/api/onlyoffice/callback/route.ts new file mode 100644 index 00000000..7b180fd6 --- /dev/null +++ b/desktop-electron/desktop-next/src/app/api/onlyoffice/callback/route.ts @@ -0,0 +1,153 @@ +import { NextResponse } from "next/server"; +import supabaseAdmin from "@/lib/supabase/admin"; + +export const dynamic = "force-dynamic"; + +const ONLYOFFICE_CALLBACK_SECRET = String(process.env.ONLYOFFICE_CALLBACK_SECRET || "").trim(); + +type OnlyOfficeCallbackBody = { + status?: number; + url?: string; + key?: string; +}; + +const normalizeSecret = (raw: string) => { + const trimmed = String(raw || "").trim(); + if (!trimmed) return ""; + // 兼容用户把 .env 的值写成 "xxx" / 'xxx' + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) || + (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +}; + +const isUuid = (value: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); + +const resolveAssetObjectLocation = async (params: { + bucket?: string | null; + storagePath?: string | null; + workspaceId?: string | null; + fileName?: string | null; +}) => { + const { bucket, storagePath, workspaceId, fileName } = params; + const ws = (workspaceId ?? "").trim(); + const fn = (fileName ?? "").trim(); + + if (!ws || !isUuid(ws) || !fn) return null; + + try { + const storage = supabaseAdmin.schema("storage"); + + const b = (bucket ?? "").trim(); + const p = (storagePath ?? "").trim(); + if (b && p) { + const { data: exact, error: exactError } = await storage + .from("objects") + .select("bucket_id,name") + .eq("bucket_id", b) + .eq("name", p) + .limit(1); + if (!exactError && exact && exact.length > 0) { + return { bucket: b, path: p }; + } + } + + const pattern = `${ws}/%/${fn}`; + const { data: candidates, error: candError } = await storage + .from("objects") + .select("bucket_id,name,created_at") + .like("name", pattern) + .order("created_at", { ascending: false }) + .limit(20); + + if (candError || !candidates || candidates.length === 0) return null; + + const exactCandidate = + candidates.find((o) => String(o?.name || "").endsWith(`/${fn}`)) ?? candidates[0]; + if (!exactCandidate?.bucket_id || !exactCandidate?.name) return null; + return { bucket: String(exactCandidate.bucket_id), path: String(exactCandidate.name) }; + } catch { + return null; + } +}; + +export async function POST(request: Request) { + const { searchParams } = new URL(request.url); + const assetId = searchParams.get("assetId") || ""; + + // 说明:ONLYOFFICE 回调由文档服务器触发,不一定携带用户态;这里提供一个可选的共享密钥校验。 + // 若未配置 ONLYOFFICE_CALLBACK_SECRET,则保持兼容不校验。 + if (ONLYOFFICE_CALLBACK_SECRET) { + const got = normalizeSecret(searchParams.get("token") || ""); + const expected = normalizeSecret(ONLYOFFICE_CALLBACK_SECRET); + if (!got || got !== expected) { + return NextResponse.json({ error: 1 }); + } + } + + const body = (await request.json().catch(() => null)) as OnlyOfficeCallbackBody | null; + if (!body) { + return NextResponse.json({ error: 0 }); + } + + const status = Number(body.status ?? -1); + // 说明:仅在文档需要保存时处理(2=ready for saving;6=force save)。 + if (status !== 2 && status !== 6) { + return NextResponse.json({ error: 0 }); + } + + if (!assetId) { + return NextResponse.json({ error: 1 }); + } + if (!body.url) { + return NextResponse.json({ error: 1 }); + } + + try { + const { data: asset, error: assetError } = await supabaseAdmin + .from("media_assets") + .select("*") + .eq("id", assetId) + .single(); + if (assetError || !asset) { + return NextResponse.json({ error: 1 }); + } + + const loc = await resolveAssetObjectLocation({ + bucket: (asset.bucket as string | null) ?? null, + storagePath: (asset.storage_path as string | null) ?? null, + workspaceId: (asset.workspace_id as string | null) ?? null, + fileName: (asset.file_name as string | null) ?? null, + }); + if (!loc) { + return NextResponse.json({ error: 1 }); + } + + const upstream = await fetch(body.url, { method: "GET", redirect: "follow" }); + if (!upstream.ok) { + return NextResponse.json({ error: 1 }); + } + const buf = Buffer.from(await upstream.arrayBuffer()); + + const contentType = String(asset.mime_type || "application/octet-stream"); + const { error: uploadError } = await supabaseAdmin.storage.from(loc.bucket).upload(loc.path, buf, { + contentType, + upsert: true, + }); + if (uploadError) { + return NextResponse.json({ error: 1 }); + } + + // 说明:桌面端(Supabase)模式下,文件 URL 通常是短期 signedUrl; + // 覆盖同一 storage_path 的对象即可让后续刷新/下载拿到最新文件。 + return NextResponse.json({ error: 0 }); + } catch (error) { + console.error("[onlyoffice/callback] supabase writeback failed:", error); + return NextResponse.json({ error: 1 }); + } +} + diff --git a/desktop-electron/desktop-next/src/app/onlyoffice/OnlyOfficeClientPage.tsx b/desktop-electron/desktop-next/src/app/onlyoffice/OnlyOfficeClientPage.tsx index 93d096ec..5f65e5a0 100644 --- a/desktop-electron/desktop-next/src/app/onlyoffice/OnlyOfficeClientPage.tsx +++ b/desktop-electron/desktop-next/src/app/onlyoffice/OnlyOfficeClientPage.tsx @@ -110,7 +110,7 @@ export default function OnlyOfficePage() { } catch { return base; } - }, [fileUrl, storageHostOverride]); + }, [fileUrl, proxyOrigin, runtimeConfig.supabaseUrl, storageHostOverride]); useEffect(() => { if (!baseUrl) { @@ -144,6 +144,16 @@ export default function OnlyOfficePage() { editorConfig: { mode: mode === "view" ? "view" : "edit", lang: "zh-CN", + // 说明:ONLYOFFICE 文档服务器会通过 callbackUrl 回传保存事件; + // 桌面端(standalone)这里必须配置,否则“看似已保存”但不会写回原文件。 + callbackUrl: assetId + ? (() => { + const base = proxyOrigin || window.location.origin; + const cb = new URL("/api/onlyoffice/callback", base); + cb.searchParams.set("assetId", assetId); + return cb.toString(); + })() + : undefined, customization: { feedback: { visible: false }, }, @@ -192,7 +202,7 @@ export default function OnlyOfficePage() { .catch((err: Error) => { setError(err.message); }); - }, [baseUrl, fileName, fileType, mode, resolvedFileUrl, targetDocType]); + }, [assetId, baseUrl, fileName, fileType, mode, proxyOrigin, resolvedFileUrl, targetDocType]); if (error) { return ( diff --git a/pw_auth_login_failed.png b/pw_auth_login_failed.png new file mode 100644 index 00000000..c10c9d42 Binary files /dev/null and b/pw_auth_login_failed.png differ diff --git a/pw_onlyoffice_loaded_run1.png b/pw_onlyoffice_loaded_run1.png new file mode 100644 index 00000000..34aa2fbd Binary files /dev/null and b/pw_onlyoffice_loaded_run1.png differ diff --git a/pw_onlyoffice_loaded_run2.png b/pw_onlyoffice_loaded_run2.png new file mode 100644 index 00000000..34aa2fbd Binary files /dev/null and b/pw_onlyoffice_loaded_run2.png differ diff --git a/wolai-frontend/convex/_generated/api.d.ts b/wolai-frontend/convex/_generated/api.d.ts index 5816b004..dc8664dd 100644 --- a/wolai-frontend/convex/_generated/api.d.ts +++ b/wolai-frontend/convex/_generated/api.d.ts @@ -8,6 +8,7 @@ * @module */ +import type * as _utils_attachmentExtract from "../_utils/attachmentExtract.js"; import type * as _utils_documentTree from "../_utils/documentTree.js"; import type * as _utils_id from "../_utils/id.js"; import type * as _utils_ingestJobs from "../_utils/ingestJobs.js"; @@ -19,6 +20,7 @@ import type * as documentGroupShares from "../documentGroupShares.js"; import type * as documentShares from "../documentShares.js"; import type * as documentStars from "../documentStars.js"; import type * as documents from "../documents.js"; +import type * as groupInvitations from "../groupInvitations.js"; import type * as groupMembers from "../groupMembers.js"; import type * as groups from "../groups.js"; import type * as http from "../http.js"; @@ -39,6 +41,7 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + "_utils/attachmentExtract": typeof _utils_attachmentExtract; "_utils/documentTree": typeof _utils_documentTree; "_utils/id": typeof _utils_id; "_utils/ingestJobs": typeof _utils_ingestJobs; @@ -50,6 +53,7 @@ declare const fullApi: ApiFromModules<{ documentShares: typeof documentShares; documentStars: typeof documentStars; documents: typeof documents; + groupInvitations: typeof groupInvitations; groupMembers: typeof groupMembers; groups: typeof groups; http: typeof http; diff --git a/wolai-frontend/convex/_utils/attachmentExtract.ts b/wolai-frontend/convex/_utils/attachmentExtract.ts new file mode 100644 index 00000000..bafe62e2 --- /dev/null +++ b/wolai-frontend/convex/_utils/attachmentExtract.ts @@ -0,0 +1,250 @@ +import JSZip from "jszip"; + +const MAX_TEXT_CHARS = 120_000; + +function clampText(input: string, maxChars = MAX_TEXT_CHARS): string { + const trimmed = String(input || "").replace(/\s+\n/g, "\n").trim(); + if (!trimmed) return ""; + if (trimmed.length <= maxChars) return trimmed; + return `${trimmed.slice(0, maxChars)}…`; +} + +function decodeXmlEntities(input: string): string { + return input + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => { + const code = Number.parseInt(hex, 16); + if (!Number.isFinite(code)) return ""; + return String.fromCodePoint(code); + }) + .replace(/&#(\d+);/g, (_, dec) => { + const code = Number.parseInt(dec, 10); + if (!Number.isFinite(code)) return ""; + return String.fromCodePoint(code); + }); +} + +function getExtension(fileName: string | null | undefined): string { + const raw = String(fileName ?? "").trim().toLowerCase(); + const idx = raw.lastIndexOf("."); + if (idx === -1) return ""; + return raw.slice(idx + 1).replace(/[^a-z0-9]+/g, ""); +} + +function detectKind(args: { mimeType?: string | null; fileName?: string | null }): "pdf" | "docx" | "pptx" | "xlsx" | null { + const mime = String(args.mimeType ?? "").toLowerCase().trim(); + if (mime === "application/pdf") return "pdf"; + if (mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") return "docx"; + if (mime === "application/vnd.openxmlformats-officedocument.presentationml.presentation") return "pptx"; + if (mime === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") return "xlsx"; + + const ext = getExtension(args.fileName ?? null); + if (ext === "pdf") return "pdf"; + if (ext === "docx") return "docx"; + if (ext === "pptx") return "pptx"; + if (ext === "xlsx") return "xlsx"; + return null; +} + +function extractTextFromXmlByTag(xml: string, tagName: string): string { + const out: string[] = []; + const re = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, "g"); + let m: RegExpExecArray | null; + while ((m = re.exec(xml))) { + const raw = m[1] ?? ""; + const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim(); + if (clean) out.push(clean); + } + return out.join(" ").trim(); +} + +function extractDocxText(documentXml: string): string { + const paras = documentXml.split(/]/g); + const out: string[] = []; + for (const p of paras) { + const line: string[] = []; + const re = /]*>([\s\S]*?)<\/w:t>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(p))) { + const raw = m[1] ?? ""; + const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim(); + if (clean) line.push(clean); + } + const joined = line.join("").trim(); + if (joined) out.push(joined); + } + return out.join("\n").trim(); +} + +function extractPptxText(slideXmls: string[]): string { + const out: string[] = []; + for (const xml of slideXmls) { + const line = extractTextFromXmlByTag(xml, "a:t"); + if (line) out.push(line); + } + return out.join("\n\n").trim(); +} + +function extractXlsxText(args: { sharedStringsXml: string | null; sheetXmls: string[] }): string { + const sharedStrings: string[] = []; + if (args.sharedStringsXml) { + const re = /]*>([\s\S]*?)<\/t>/g; + let m: RegExpExecArray | null; + while ((m = re.exec(args.sharedStringsXml))) { + const raw = m[1] ?? ""; + const clean = decodeXmlEntities(raw).replace(/\s+/g, " ").trim(); + if (clean) sharedStrings.push(clean); + } + } + + const out: string[] = []; + for (const xml of args.sheetXmls) { + const rows = xml.split(/]/g); + for (const r of rows) { + const cells: string[] = []; + + // t="s" => sharedStrings index;t="inlineStr" => ...;默认 => number + const cellRe = /]*?(?:t="([^"]+)")?[^>]*>([\s\S]*?)<\/c>/g; + let cm: RegExpExecArray | null; + while ((cm = cellRe.exec(r))) { + const t = (cm[1] ?? "").trim(); + const body = cm[2] ?? ""; + if (t === "inlineStr") { + const inline = extractTextFromXmlByTag(body, "t"); + if (inline) cells.push(inline); + continue; + } + const vMatch = /([\s\S]*?)<\/v>/.exec(body); + if (!vMatch) continue; + const rawV = decodeXmlEntities(String(vMatch[1] ?? "")).trim(); + if (!rawV) continue; + if (t === "s") { + const idx = Number.parseInt(rawV, 10); + const s = Number.isFinite(idx) ? (sharedStrings[idx] ?? "") : ""; + if (s) cells.push(s); + } else { + cells.push(rawV); + } + } + + const line = cells.join(" ").replace(/\s+/g, " ").trim(); + if (line) out.push(line); + } + } + return out.join("\n").trim(); +} + +export type AttachmentExtractOk = { + ok: true; + strategy: string; + text: string; + meta?: Record; +}; +export type AttachmentExtractResult = + | AttachmentExtractOk + | { ok: false; strategy: string; reason: string; meta?: Record }; + +export async function extractTextFromAttachment(args: { + mimeType: string | null; + fileName: string | null; + bytes: ArrayBuffer; +}): Promise { + const kind = detectKind({ mimeType: args.mimeType, fileName: args.fileName }); + if (!kind) { + return { ok: false, strategy: "unsupported", reason: "暂不支持该附件类型" }; + } + + if (kind === "pdf") { + try { + const mod = await import("pdfjs-dist/legacy/build/pdf.mjs"); + const data = new Uint8Array(args.bytes); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const loadingTask = (mod as any).getDocument({ data }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pdf = await (loadingTask as any).promise; + const out: string[] = []; + const pages = Number(pdf?.numPages ?? 0) || 0; + for (let i = 1; i <= pages; i += 1) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const page = await (pdf as any).getPage(i); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const content = await (page as any).getTextContent(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const items = Array.isArray((content as any)?.items) ? (content as any).items : []; + const line = items + .map((it: any) => (typeof it?.str === "string" ? it.str : "")) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + if (line) out.push(line); + } + const text = clampText(out.join("\n\n")); + if (!text) { + return { ok: false, strategy: "pdfjs", reason: "未提取到可用文本", meta: { pages } }; + } + return { ok: true, strategy: "pdfjs", text, meta: { pages } }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, strategy: "pdfjs", reason: message }; + } + } + + try { + const zip = await JSZip.loadAsync(args.bytes); + if (kind === "docx") { + const file = zip.file("word/document.xml"); + const xml = file ? await file.async("string") : ""; + const text = clampText(extractDocxText(xml)); + if (!text) return { ok: false, strategy: "docx.xml", reason: "未提取到可用文本" }; + return { ok: true, strategy: "docx.xml", text }; + } + + if (kind === "pptx") { + const slideFiles = Object.keys(zip.files) + .filter((p) => /^ppt\/slides\/slide\d+\.xml$/i.test(p)) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + const slideXmls: string[] = []; + for (const p of slideFiles) { + const f = zip.file(p); + if (!f) continue; + // eslint-disable-next-line no-await-in-loop + slideXmls.push(await f.async("string")); + } + const text = clampText(extractPptxText(slideXmls)); + if (!text) return { ok: false, strategy: "pptx.xml", reason: "未提取到可用文本" }; + return { ok: true, strategy: "pptx.xml", text, meta: { slides: slideXmls.length } }; + } + + if (kind === "xlsx") { + const sharedStringsXml = await (async () => { + const f = zip.file("xl/sharedStrings.xml"); + if (!f) return null; + return await f.async("string"); + })(); + + const sheetFiles = Object.keys(zip.files) + .filter((p) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(p)) + .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); + const sheetXmls: string[] = []; + for (const p of sheetFiles) { + const f = zip.file(p); + if (!f) continue; + // eslint-disable-next-line no-await-in-loop + sheetXmls.push(await f.async("string")); + } + const text = clampText(extractXlsxText({ sharedStringsXml, sheetXmls })); + if (!text) return { ok: false, strategy: "xlsx.xml", reason: "未提取到可用文本" }; + return { ok: true, strategy: "xlsx.xml", text, meta: { sheets: sheetXmls.length } }; + } + + return { ok: false, strategy: "unsupported", reason: "暂不支持该附件类型" }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { ok: false, strategy: "zip", reason: message }; + } +} + diff --git a/wolai-frontend/convex/_utils/ingestJobs.ts b/wolai-frontend/convex/_utils/ingestJobs.ts index da1afc0d..a8ac9920 100644 --- a/wolai-frontend/convex/_utils/ingestJobs.ts +++ b/wolai-frontend/convex/_utils/ingestJobs.ts @@ -79,6 +79,10 @@ export function ingestMediaAssetJobId(assetId: string): string { return `ingest:media:${assetId}`; } +export function extractMediaAssetTextJobId(assetId: string): string { + return `extract:media:${assetId}`; +} + export async function enqueueIngestDocumentJob( ctx: MutationCtx, args: { userId: string; documentId: string; debounceMs?: number }, @@ -123,3 +127,18 @@ export async function enqueueIngestMediaAssetJob( }); return { id }; } + +export async function enqueueExtractMediaAssetTextJob( + ctx: MutationCtx, + args: { userId: string; assetId: string; debounceMs?: number }, +): Promise<{ id: string }> { + const id = extractMediaAssetTextJobId(args.assetId); + await upsertJob(ctx, { + id, + userId: args.userId, + type: "extract.media_asset_text", + payload: { assetId: args.assetId }, + debounceMs: args.debounceMs, + }); + return { id }; +} diff --git a/wolai-frontend/convex/documentGroupShares.ts b/wolai-frontend/convex/documentGroupShares.ts index 4e9cfa65..52d3ec9f 100644 --- a/wolai-frontend/convex/documentGroupShares.ts +++ b/wolai-frontend/convex/documentGroupShares.ts @@ -30,6 +30,15 @@ async function requireGroup(ctx: any, groupId: string) { return group; } +async function requireGroupMember(ctx: any, groupId: string, userId: string) { + const membership = await ctx.db + .query("group_members") + .withIndex("by_group_user", (q: any) => q.eq("group_id", groupId).eq("user_id", userId)) + .first(); + if (!membership) throw new Error("无权限(你不在该群组内)"); + return membership; +} + export const listByDocument = query({ args: { documentId: v.string() }, handler: async (ctx, args) => { @@ -63,6 +72,8 @@ export const listByDocument = query({ groupId: share.group_id, groupName: group?.name ?? null, includeDescendants: share.include_descendants, + disableDownload: Boolean((share as any).disable_download), + disableCopy: Boolean((share as any).disable_copy), editableUserIds, updatedAt: share.updated_at, }); @@ -78,15 +89,20 @@ export const upsert = mutation({ groupId: v.string(), includeDescendants: v.optional(v.boolean()), editableUserIds: v.array(v.string()), + disableDownload: v.optional(v.boolean()), + disableCopy: v.optional(v.boolean()), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); const doc = await requireOwnedDocument(ctx, args.documentId, userId); const group = await requireGroup(ctx, args.groupId); if (group.workspace_id !== doc.workspace_id) throw new Error("群组不属于当前工作空间"); + await requireGroupMember(ctx, args.groupId, userId); const ts = nowIso(); const includeDescendants = Boolean(args.includeDescendants); + const disableDownload = Boolean(args.disableDownload); + const disableCopy = Boolean(args.disableCopy); const existingShare = await ctx.db .query("document_group_shares") @@ -96,6 +112,8 @@ export const upsert = mutation({ if (existingShare) { await ctx.db.patch(existingShare._id, { include_descendants: includeDescendants, + disable_download: disableDownload, + disable_copy: disableCopy, updated_at: ts, }); } else { @@ -104,6 +122,8 @@ export const upsert = mutation({ group_id: args.groupId, document_id: doc.id, include_descendants: includeDescendants, + disable_download: disableDownload, + disable_copy: disableCopy, created_by: userId, created_at: ts, updated_at: ts, diff --git a/wolai-frontend/convex/documentShares.ts b/wolai-frontend/convex/documentShares.ts index 76d84a41..fac1c6c5 100644 --- a/wolai-frontend/convex/documentShares.ts +++ b/wolai-frontend/convex/documentShares.ts @@ -62,6 +62,8 @@ export const listByDocument = query({ username: user?.name ?? null, permission: share.permission, includeDescendants: share.include_descendants, + disableDownload: Boolean((share as any).disable_download), + disableCopy: Boolean((share as any).disable_copy), createdAt: share.created_at, updatedAt: share.updated_at, }); @@ -77,6 +79,8 @@ export const upsert = mutation({ username: v.string(), permission, includeDescendants: v.optional(v.boolean()), + disableDownload: v.optional(v.boolean()), + disableCopy: v.optional(v.boolean()), }, handler: async (ctx, args) => { const userId = await requireUserId(ctx); @@ -86,6 +90,8 @@ export const upsert = mutation({ const ts = nowIso(); const includeDescendants = Boolean(args.includeDescendants); + const disableDownload = Boolean(args.disableDownload); + const disableCopy = Boolean(args.disableCopy); // 确保对方成为 workspace member(否则无法看到该 workspace 的数据) const existingMember = await ctx.db @@ -113,6 +119,8 @@ export const upsert = mutation({ await ctx.db.patch(existingShare._id, { permission: args.permission, include_descendants: includeDescendants, + disable_download: disableDownload, + disable_copy: disableCopy, updated_at: ts, }); } else { @@ -122,6 +130,8 @@ export const upsert = mutation({ shared_with_user_id: sharedWithUserId, permission: args.permission, include_descendants: includeDescendants, + disable_download: disableDownload, + disable_copy: disableCopy, created_by: userId, created_at: ts, updated_at: ts, @@ -210,3 +220,137 @@ export const listShareRootsByWorkspace = query({ }; }, }); + +export const listMyShareRoots = query({ + args: {}, + handler: async (ctx) => { + const userId = await requireUserId(ctx); + + const incomingRows = await ctx.db + .query("document_shares") + .withIndex("by_shared_with_user", (q: any) => q.eq("shared_with_user_id", userId)) + .collect(); + + // 说明:只统计“我共享出去的”(自己是 created_by);用于顶部共享面板展示摘要。 + const outgoingRows = await ctx.db + .query("document_shares") + .withIndex("by_created_by", (q: any) => q.eq("created_by", userId)) + .collect(); + + // 说明:去重/合并:同一页面共享给多个用户时,只展示一条,并累计 sharedWithCount。 + const outgoingByKey = new Map< + string, + { + workspaceId: string; + documentId: string; + includeDescendants: boolean; + sharedWithCount: number; + updatedAt: string; + } + >(); + + for (const row of outgoingRows) { + const key = `${row.workspace_id}:${row.document_id}`; + const existing = outgoingByKey.get(key); + if (!existing) { + outgoingByKey.set(key, { + workspaceId: row.workspace_id, + documentId: row.document_id, + includeDescendants: Boolean(row.include_descendants), + sharedWithCount: 1, + updatedAt: String(row.updated_at ?? ""), + }); + } else { + existing.includeDescendants = existing.includeDescendants || Boolean(row.include_descendants); + existing.sharedWithCount += 1; + const u = String(row.updated_at ?? ""); + if (u && u.localeCompare(existing.updatedAt) > 0) { + existing.updatedAt = u; + } + } + } + + const workspaceNameCache = new Map(); + const getWorkspaceName = async (workspaceId: string): Promise => { + const cached = workspaceNameCache.get(workspaceId); + if (workspaceNameCache.has(workspaceId)) return cached ?? null; + const ws = await ctx.db + .query("workspaces") + .withIndex("by_workspace_id", (q: any) => q.eq("id", workspaceId)) + .first(); + const name = ws?.name ?? null; + workspaceNameCache.set(workspaceId, name); + return name; + }; + + const documentCache = new Map< + string, + { exists: boolean; deleted: boolean; title: string | null } + >(); + const getDocumentInfo = async ( + documentId: string, + ): Promise<{ exists: boolean; deleted: boolean; title: string | null }> => { + const cached = documentCache.get(documentId); + if (documentCache.has(documentId)) { + return cached ?? { exists: false, deleted: true, title: null }; + } + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q: any) => q.eq("id", documentId)) + .first(); + const info = doc + ? { exists: true, deleted: doc.deleted_at != null, title: (doc.title ?? null) as string | null } + : { exists: false, deleted: true, title: null }; + documentCache.set(documentId, info); + return info; + }; + + const incoming = []; + for (const row of incomingRows) { + // 说明:若对方仅写入了 share 但没把我加入 workspace,则这里会被文档/页面接口挡住; + // 为避免面板出现“打不开的幽灵分享”,这里要求我确实是 workspace member。 + const member = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", row.workspace_id).eq("user_id", userId)) + .first(); + if (!member) continue; + + const docInfo = await getDocumentInfo(row.document_id); + if (!docInfo.exists) continue; + if (docInfo.deleted) continue; + + incoming.push({ + workspaceId: row.workspace_id, + workspaceName: await getWorkspaceName(row.workspace_id), + documentId: row.document_id, + documentTitle: docInfo.title, + permission: row.permission, + includeDescendants: row.include_descendants, + createdBy: row.created_by, + updatedAt: row.updated_at, + }); + } + + const outgoing = []; + for (const v of outgoingByKey.values()) { + const docInfo = await getDocumentInfo(v.documentId); + if (!docInfo.exists) continue; + if (docInfo.deleted) continue; + + outgoing.push({ + workspaceId: v.workspaceId, + workspaceName: await getWorkspaceName(v.workspaceId), + documentId: v.documentId, + documentTitle: docInfo.title, + includeDescendants: v.includeDescendants, + sharedWithCount: v.sharedWithCount, + updatedAt: v.updatedAt, + }); + } + + incoming.sort((a: any, b: any) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""))); + outgoing.sort((a: any, b: any) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""))); + + return { incoming, outgoing }; + }, +}); diff --git a/wolai-frontend/convex/documentStars.ts b/wolai-frontend/convex/documentStars.ts index 8014f85d..043311d4 100644 --- a/wolai-frontend/convex/documentStars.ts +++ b/wolai-frontend/convex/documentStars.ts @@ -103,7 +103,11 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): export const isStarred = query({ args: { documentId: v.string() }, handler: async (ctx, args) => { - const userId = await requireUserId(ctx); + // 退出登录/未登录时,客户端仍可能会发起 isStarred 查询(例如 Breadcrumb 仍在渲染)。 + // 这里不要抛错,直接返回 false,避免把“未登录”作为运行时错误冒泡到前端。 + const authUserId = await getAuthUserId(ctx); + if (authUserId === null) return false; + const userId = String(authUserId); const doc = await ctx.db .query("documents") @@ -182,4 +186,3 @@ export const toggle = mutation({ return { ok: true, starred: true }; }, }); - diff --git a/wolai-frontend/convex/documents.ts b/wolai-frontend/convex/documents.ts index 54209b5e..baf61159 100644 --- a/wolai-frontend/convex/documents.ts +++ b/wolai-frontend/convex/documents.ts @@ -4,6 +4,7 @@ import { getAuthUserId } from "@convex-dev/auth/server"; import { nowIso } from "./_utils/time"; import { collectSubtree } from "./_utils/documentTree"; import { enqueueIngestDocumentJob } from "./_utils/ingestJobs"; +import { extractTextFromDocumentContent } from "./_utils/text"; const accessScope = v.union(v.literal("private"), v.literal("shared"), v.literal("public")); @@ -17,6 +18,8 @@ async function requireUserId(ctx: any): Promise { type SharePermission = "read" | "edit"; +type SharePolicy = { permission: SharePermission; disableDownload: boolean; disableCopy: boolean }; + async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) { const member = await ctx.db .query("workspace_members") @@ -28,13 +31,17 @@ async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: str return member; } -async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise { +async function resolveSharePolicy(ctx: any, doc: any, userId: string): Promise { const direct = await ctx.db .query("document_shares") .withIndex("by_doc_user", (q: any) => q.eq("document_id", doc.id).eq("shared_with_user_id", userId)) .first(); if (direct) { - return direct.permission as SharePermission; + return { + permission: direct.permission as SharePermission, + disableDownload: Boolean((direct as any).disable_download), + disableCopy: Boolean((direct as any).disable_copy), + }; } let parentId: string | null = doc.parent_id ?? null; @@ -44,7 +51,11 @@ async function resolveSharePermission(ctx: any, doc: any, userId: string): Promi .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; + return { + permission: parentShare.permission as SharePermission, + disableDownload: Boolean((parentShare as any).disable_download), + disableCopy: Boolean((parentShare as any).disable_copy), + }; } const parent = await ctx.db @@ -57,7 +68,12 @@ async function resolveSharePermission(ctx: any, doc: any, userId: string): Promi return null; } -async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise { +async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise { + const policy = await resolveSharePolicy(ctx, doc, userId); + return policy?.permission ?? null; +} + +async function resolveGroupSharePolicy(ctx: any, doc: any, userId: string): Promise { const memberships = await ctx.db .query("group_members") .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", userId)) @@ -77,6 +93,8 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): }; let best: SharePermission | null = null; + let disableDownload = false; + let disableCopy = false; const checkDocId = async (documentId: string, requireIncludeDesc: boolean) => { const shares = await ctx.db @@ -89,23 +107,25 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): if (!groupIds.has(gid)) continue; if (requireIncludeDesc && !s.include_descendants) continue; + disableDownload = disableDownload || Boolean((s as any).disable_download); + disableCopy = disableCopy || Boolean((s as any).disable_copy); + const perm = await permissionFromGroup(documentId, gid); if (perm === "edit") { best = "edit"; - return true; + } else { + best = best ?? "read"; } - best = best ?? "read"; } - return false; }; // 当前页面是否被群组公开 - if (await checkDocId(doc.id, false)) return "edit"; + await checkDocId(doc.id, false); // 沿父链查找“包含子页面”的群组公开 let parentId: string | null = doc.parent_id ?? null; for (let depth = 0; depth < 60 && parentId; depth += 1) { - if (await checkDocId(parentId, true)) return "edit"; + await checkDocId(parentId, true); const parent = await ctx.db .query("documents") @@ -114,7 +134,48 @@ async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): parentId = parent?.parent_id ?? null; } - return best; + if (!best) return null; + return { permission: best, disableDownload, disableCopy }; +} + +async function resolveGroupSharePermission(ctx: any, doc: any, userId: string): Promise { + const policy = await resolveGroupSharePolicy(ctx, doc, userId); + return policy?.permission ?? null; +} + +async function purgeDocumentShareRelations(ctx: any, workspaceId: string, documentId: string) { + // 说明:页面被“永久删除/清空回收站”后,需要级联清理共享关系; + // 否则被分享者可能在“共享页面/公共页面”里看到幽灵条目(点开 404 / 无标题)。 + const directShares = await ctx.db + .query("document_shares") + .withIndex("by_document", (q: any) => q.eq("document_id", documentId)) + .collect(); + for (const row of directShares) { + await ctx.db.delete(row._id); + } + + const groupShares = await ctx.db + .query("document_group_shares") + .withIndex("by_document", (q: any) => q.eq("document_id", documentId)) + .collect(); + + const touchedGroupIds = new Set(); + for (const row of groupShares) { + touchedGroupIds.add(String(row.group_id)); + await ctx.db.delete(row._id); + } + + // 删除该文档在相关群组下的“用户可编辑覆盖权限” + for (const groupId of touchedGroupIds) { + const perms = await ctx.db + .query("document_group_user_permissions") + .withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", workspaceId).eq("group_id", groupId)) + .collect(); + for (const p of perms) { + if (String(p.document_id) !== documentId) continue; + await ctx.db.delete(p._id); + } + } } export const getMeta = query({ @@ -135,14 +196,18 @@ export const getMeta = query({ } let canEdit = false; + let disableDownload = false; + let disableCopy = false; if (doc.user_id === userId) { canEdit = true; } else if (doc.access_scope === "public") { canEdit = false; } else { - const perm = (await resolveSharePermission(ctx, doc, userId)) ?? (await resolveGroupSharePermission(ctx, doc, userId)); - if (!perm) return null; - canEdit = perm === "edit"; + const policy = (await resolveSharePolicy(ctx, doc, userId)) ?? (await resolveGroupSharePolicy(ctx, doc, userId)); + if (!policy) return null; + canEdit = policy.permission === "edit"; + disableDownload = policy.disableDownload; + disableCopy = policy.disableCopy; } return { id: doc.id, @@ -150,6 +215,8 @@ export const getMeta = query({ workspace_id: doc.workspace_id, access_scope: doc.access_scope, can_edit: canEdit, + disable_download: disableDownload, + disable_copy: disableCopy, title: doc.title ?? null, parent_id: doc.parent_id ?? null, created_at: doc.created_at, @@ -168,6 +235,37 @@ export const getMeta = query({ }, }); +export const getPermissionForUser = query({ + args: { userId: v.string(), id: v.string() }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("documents") + .withIndex("by_document_id", (q) => q.eq("id", args.id)) + .first(); + if (!doc) return null; + if (doc.deleted_at != null) return null; + + // workspace 权限 + await requireWorkspaceMember(ctx, doc.workspace_id, args.userId); + + if (doc.user_id === args.userId) { + return { permission: "edit" as const, disableDownload: false, disableCopy: false }; + } + if (doc.access_scope === "public") { + return { permission: "read" as const, disableDownload: false, disableCopy: false }; + } + + const policy = (await resolveSharePolicy(ctx, doc, args.userId)) ?? (await resolveGroupSharePolicy(ctx, doc, args.userId)); + if (!policy) return null; + + return { + permission: policy.permission === "edit" ? ("edit" as const) : ("read" as const), + disableDownload: policy.disableDownload, + disableCopy: policy.disableCopy, + }; + }, +}); + export const getMetaForIngest = internalQuery({ args: { userId: v.string(), id: v.string() }, handler: async (ctx, args) => { @@ -379,6 +477,134 @@ export const listByWorkspace = query({ }, }); +export const listSearchDataByWorkspace = query({ + args: { workspaceId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + + // workspace 权限 + try { + await requireWorkspaceMember(ctx, args.workspaceId, userId); + } catch { + return []; + } + + const docs = await ctx.db + .query("documents") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .collect(); + + const alive = docs.filter((d) => d.deleted_at == null); + + const shares = await ctx.db + .query("document_shares") + .withIndex("by_workspace_shared_with", (q: any) => + q.eq("workspace_id", args.workspaceId).eq("shared_with_user_id", userId), + ) + .collect(); + + const directShares = new Map< + string, + { permission: SharePermission; includeDescendants: boolean } + >(); + for (const s of shares) { + directShares.set(s.document_id, { + permission: s.permission as SharePermission, + includeDescendants: Boolean(s.include_descendants), + }); + } + + const groupMemberships = await ctx.db + .query("group_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) + .collect(); + const groupIds = new Set(groupMemberships.map((m: any) => String(m.group_id))); + + const directGroupShares = new Map(); + if (groupIds.size > 0) { + for (const gid of groupIds) { + const rows = await ctx.db + .query("document_group_shares") + .withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", gid)) + .collect(); + for (const r of rows) { + const existing = directGroupShares.get(r.document_id); + if (!existing) { + directGroupShares.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) }); + } else { + existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants); + } + } + } + } + + const parentById = new Map(); + for (const d of alive) { + parentById.set(d.id, d.parent_id ?? null); + } + + const shareAccessCache = new Map(); + const canAccessByShare = (docId: string): boolean => { + const cached = shareAccessCache.get(docId); + if (typeof cached === "boolean") return cached; + if (directShares.has(docId)) { + shareAccessCache.set(docId, true); + return true; + } + let parentId = parentById.get(docId) ?? null; + for (let depth = 0; depth < 60 && parentId; depth += 1) { + const parentShare = directShares.get(parentId); + if (parentShare && parentShare.includeDescendants) { + shareAccessCache.set(docId, true); + return true; + } + parentId = parentById.get(parentId) ?? null; + } + shareAccessCache.set(docId, false); + return false; + }; + + const groupAccessCache = new Map(); + const canAccessByGroupShare = (docId: string): boolean => { + const cached = groupAccessCache.get(docId); + if (typeof cached === "boolean") return cached; + if (directGroupShares.has(docId)) { + groupAccessCache.set(docId, true); + return true; + } + let parentId = parentById.get(docId) ?? null; + for (let depth = 0; depth < 60 && parentId; depth += 1) { + const parentShare = directGroupShares.get(parentId); + if (parentShare && parentShare.includeDescendants) { + groupAccessCache.set(docId, true); + return true; + } + parentId = parentById.get(parentId) ?? null; + } + groupAccessCache.set(docId, false); + return false; + }; + + return alive + .filter((d) => { + if (d.user_id === userId) return true; + if (d.access_scope === "public") return true; + return canAccessByShare(d.id) || canAccessByGroupShare(d.id); + }) + .map((d) => ({ + id: d.id, + workspace_id: d.workspace_id, + title: d.title ?? null, + created_at: d.created_at ?? null, + updated_at: d.updated_at ?? null, + raw_text: + typeof d.raw_text === "string" && d.raw_text.trim() + ? d.raw_text + : extractTextFromDocumentContent(d.content ?? null), + })); + }, +}); + export const listTrashedByWorkspace = query({ args: { workspaceId: v.string() }, handler: async (ctx, args) => { @@ -426,6 +652,7 @@ export const create = mutation({ const title = (args.title ?? "无标题") || "无标题"; const content = typeof args.content === "undefined" ? [] : args.content; + const rawText = extractTextFromDocumentContent(content); await ctx.db.insert("documents", { id: args.id, @@ -434,6 +661,7 @@ export const create = mutation({ parent_id: args.parentId, title, content, + raw_text: rawText, access_scope: args.accessScope, sort_order: sortOrder, is_starred: false, @@ -489,7 +717,8 @@ export const updateContent = mutation({ if (perm !== "edit") throw new Error("无权限"); } const ts = nowIso(); - await ctx.db.patch(doc._id, { content: args.content, updated_at: ts }); + const rawText = extractTextFromDocumentContent(args.content); + await ctx.db.patch(doc._id, { content: args.content, raw_text: rawText, updated_at: ts }); // 说明:在 Convex 模式下,把"自动入库/LightRAG 触发"迁到 Convex jobs/actions。 // 采用 debounce,避免频繁保存时触发过多任务。 @@ -641,6 +870,9 @@ export const softDelete = mutation({ for (const item of subtree) { if (item.deleted_at != null) continue; await ctx.db.patch(item._id, { deleted_at: ts, deleted_by: userId, updated_at: ts }); + // 说明:为了避免被分享者仍看到已删除页面(点开 404),软删除时也同步移除共享关系。 + // 如需保留共享关系用于恢复后自动生效,可改为仅在 purge/emptyTrash 时清理。 + await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id); moved += 1; } @@ -710,6 +942,7 @@ export const purge = mutation({ // 删除顺序对当前数据模型无强制要求,这里简单逐个删除即可。 for (const item of subtree) { + await purgeDocumentShareRelations(ctx, doc.workspace_id, item.id); await ctx.db.delete(item._id); } @@ -739,6 +972,7 @@ export const emptyTrashByWorkspace = mutation({ const toDelete = docs.filter((d) => d.user_id === userId && d.deleted_at != null); for (const d of toDelete) { + await purgeDocumentShareRelations(ctx, args.workspaceId, d.id); await ctx.db.delete(d._id); } @@ -857,6 +1091,7 @@ export const duplicate = mutation({ const ts = nowIso(); const baseTitle = (source.title ?? "无标题").trim() ? (source.title ?? "无标题").trim() : "无标题"; const title = (args.title ?? `${baseTitle} 副本`) || `${baseTitle} 副本`; + const rawText = extractTextFromDocumentContent(source.content ?? []); await ctx.db.insert("documents", { id: args.newId, @@ -865,6 +1100,7 @@ export const duplicate = mutation({ parent_id: source.parent_id, title, content: source.content ?? [], + raw_text: rawText, access_scope: source.access_scope, sort_order: sortOrder, is_starred: false, diff --git a/wolai-frontend/convex/groupInvitations.ts b/wolai-frontend/convex/groupInvitations.ts new file mode 100644 index 00000000..f83cfe46 --- /dev/null +++ b/wolai-frontend/convex/groupInvitations.ts @@ -0,0 +1,241 @@ +import { mutation, query } from "./_generated/server"; +import { v } from "convex/values"; +import { getAuthUserId } from "@convex-dev/auth/server"; +import { nowIso } from "./_utils/time"; +import type { Id } from "./_generated/dataModel"; + +async function requireUserId(ctx: any): Promise { + const userId = await getAuthUserId(ctx); + if (userId === null) throw new Error("未登录"); + return String(userId); +} + +async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) { + const member = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId)) + .first(); + if (!member) throw new Error("无权限"); + return member; +} + +async function requireGroup(ctx: any, groupId: string) { + const group = await ctx.db + .query("groups") + .withIndex("by_group_id", (q: any) => q.eq("id", groupId)) + .first(); + if (!group) throw new Error("群组不存在"); + return group; +} + +async function requireGroupOwner(ctx: any, groupId: string, userId: string) { + const membership = await ctx.db + .query("group_members") + .withIndex("by_group_user", (q: any) => q.eq("group_id", groupId).eq("user_id", userId)) + .first(); + if (!membership || membership.role !== "owner") throw new Error("无权限"); + return membership; +} + +function normalizeUsername(raw: string): string { + const username = String(raw ?? "").trim(); + if (!username) throw new Error("用户名不能为空"); + if (username.length < 2 || username.length > 32) throw new Error("用户名长度需为 2-32 个字符"); + if (/\s/.test(username)) throw new Error("用户名不能包含空格"); + return username; +} + +async function lookupUserIdByUsername(ctx: any, rawUsername: string): Promise { + const username = normalizeUsername(rawUsername); + const user = await ctx.db + .query("users") + .filter((q: any) => q.eq(q.field("name"), username)) + .first(); + if (!user) throw new Error("未找到该用户"); + return String(user._id); +} + +export const listMineByWorkspace = query({ + args: { workspaceId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + + const invites = await ctx.db + .query("group_invitations") + .withIndex("by_workspace_invited_user", (q: any) => + q.eq("workspace_id", args.workspaceId).eq("invited_user_id", userId), + ) + .collect(); + + // 最新的排前面 + invites.sort((a: any, b: any) => String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? ""))); + + const result = []; + for (const inv of invites) { + const group = await ctx.db + .query("groups") + .withIndex("by_group_id", (q: any) => q.eq("id", inv.group_id)) + .first(); + const inviter = await ctx.db.get(inv.invited_by as Id<"users">); + result.push({ + groupId: inv.group_id, + groupName: group?.name ?? null, + invitedByUserId: inv.invited_by, + invitedByUsername: inviter?.name ?? null, + createdAt: inv.created_at, + updatedAt: inv.updated_at, + }); + } + + return result; + }, +}); + +export const listMine = query({ + args: {}, + handler: async (ctx) => { + const userId = await requireUserId(ctx); + + const invites = await ctx.db + .query("group_invitations") + .withIndex("by_invited_user", (q: any) => q.eq("invited_user_id", userId)) + .collect(); + + invites.sort((a: any, b: any) => String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? ""))); + + const result = []; + for (const inv of invites) { + const group = await ctx.db + .query("groups") + .withIndex("by_group_id", (q: any) => q.eq("id", inv.group_id)) + .first(); + const workspace = await ctx.db + .query("workspaces") + .withIndex("by_workspace_id", (q: any) => q.eq("id", inv.workspace_id)) + .first(); + const inviter = await ctx.db.get(inv.invited_by as Id<"users">); + result.push({ + workspaceId: inv.workspace_id, + workspaceName: workspace?.name ?? null, + groupId: inv.group_id, + groupName: group?.name ?? null, + invitedByUserId: inv.invited_by, + invitedByUsername: inviter?.name ?? null, + createdAt: inv.created_at, + updatedAt: inv.updated_at, + }); + } + + return result; + }, +}); + +export const inviteByUsername = mutation({ + args: { groupId: v.string(), username: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const group = await requireGroup(ctx, args.groupId); + + await requireWorkspaceMember(ctx, group.workspace_id, userId); + await requireGroupOwner(ctx, args.groupId, userId); + + const targetUserId = await lookupUserIdByUsername(ctx, args.username); + if (targetUserId === userId) throw new Error("不能邀请自己"); + + const existingMember = await ctx.db + .query("group_members") + .withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", targetUserId)) + .first(); + if (existingMember) { + return { ok: true }; + } + + const ts = nowIso(); + const existingInvite = await ctx.db + .query("group_invitations") + .withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("invited_user_id", targetUserId)) + .first(); + + if (existingInvite) { + await ctx.db.patch(existingInvite._id, { invited_by: userId, updated_at: ts }); + return { ok: true }; + } + + await ctx.db.insert("group_invitations", { + workspace_id: group.workspace_id, + group_id: args.groupId, + invited_user_id: targetUserId, + invited_by: userId, + created_at: ts, + updated_at: ts, + }); + + return { ok: true }; + }, +}); + +export const accept = mutation({ + args: { groupId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const group = await requireGroup(ctx, args.groupId); + + await requireWorkspaceMember(ctx, group.workspace_id, userId).catch(async () => { + // 说明:允许被邀请者尚未加入 workspace,接受邀请时自动加入。 + await ctx.db.insert("workspace_members", { + workspace_id: group.workspace_id, + user_id: userId, + role: "member", + is_default: false, + created_at: nowIso(), + }); + }); + + const invite = await ctx.db + .query("group_invitations") + .withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("invited_user_id", userId)) + .first(); + if (!invite) { + return { ok: true }; + } + + const existingMember = await ctx.db + .query("group_members") + .withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", userId)) + .first(); + + const ts = nowIso(); + if (!existingMember) { + await ctx.db.insert("group_members", { + workspace_id: group.workspace_id, + group_id: args.groupId, + user_id: userId, + role: "member", + created_at: ts, + }); + } + + await ctx.db.delete(invite._id); + return { ok: true }; + }, +}); + +export const decline = mutation({ + args: { groupId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + const group = await requireGroup(ctx, args.groupId); + await requireWorkspaceMember(ctx, group.workspace_id, userId).catch(() => { + // 即使没进 workspace,也可以拒绝邀请(删除邀请记录)。 + }); + + const invite = await ctx.db + .query("group_invitations") + .withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("invited_user_id", userId)) + .first(); + if (invite) { + await ctx.db.delete(invite._id); + } + return { ok: true }; + }, +}); diff --git a/wolai-frontend/convex/groupMembers.ts b/wolai-frontend/convex/groupMembers.ts index 3d4edf2f..2fdb821c 100644 --- a/wolai-frontend/convex/groupMembers.ts +++ b/wolai-frontend/convex/groupMembers.ts @@ -90,46 +90,7 @@ export const listByGroup = query({ export const inviteByUsername = mutation({ args: { groupId: v.string(), username: v.string() }, handler: async (ctx, args) => { - const userId = await requireUserId(ctx); - const group = await requireGroup(ctx, args.groupId); - await requireWorkspaceMember(ctx, group.workspace_id, userId); - await requireGroupOwner(ctx, args.groupId, userId); - - const targetUserId = await lookupUserIdByUsername(ctx, args.username); - if (targetUserId === userId) throw new Error("不能邀请自己"); - - // 确保对方为 workspace member - const existingWorkspaceMember = await ctx.db - .query("workspace_members") - .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", group.workspace_id).eq("user_id", targetUserId)) - .first(); - if (!existingWorkspaceMember) { - await ctx.db.insert("workspace_members", { - workspace_id: group.workspace_id, - user_id: targetUserId, - role: "member", - is_default: false, - created_at: nowIso(), - }); - } - - const existing = await ctx.db - .query("group_members") - .withIndex("by_group_user", (q: any) => q.eq("group_id", args.groupId).eq("user_id", targetUserId)) - .first(); - if (existing) { - return { ok: true }; - } - - await ctx.db.insert("group_members", { - workspace_id: group.workspace_id, - group_id: args.groupId, - user_id: targetUserId, - role: "member", - created_at: nowIso(), - }); - - return { ok: true }; + throw new Error("接口已废弃:请改用 groupInvitations.inviteByUsername(支持对方接受/拒绝)。"); }, }); @@ -152,4 +113,3 @@ export const removeMember = mutation({ return { ok: true }; }, }); - diff --git a/wolai-frontend/convex/groups.ts b/wolai-frontend/convex/groups.ts index c5f83146..b5da6004 100644 --- a/wolai-frontend/convex/groups.ts +++ b/wolai-frontend/convex/groups.ts @@ -2,6 +2,7 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { getAuthUserId } from "@convex-dev/auth/server"; import { nowIso } from "./_utils/time"; +import type { Id } from "./_generated/dataModel"; async function requireUserId(ctx: any): Promise { const userId = await getAuthUserId(ctx); @@ -48,6 +49,49 @@ export const listByWorkspace = query({ }, }); +export const listMineByWorkspace = query({ + args: { workspaceId: v.string() }, + handler: async (ctx, args) => { + const userId = await requireUserId(ctx); + await requireWorkspaceMember(ctx, args.workspaceId, userId); + + const memberships = await ctx.db + .query("group_members") + .withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", args.workspaceId).eq("user_id", userId)) + .collect(); + + const groupsById = new Map(); + for (const m of memberships) { + const group = await ctx.db + .query("groups") + .withIndex("by_group_id", (q: any) => q.eq("id", m.group_id)) + .first(); + if (group) { + groupsById.set(String(group.id), { + id: group.id, + workspace_id: group.workspace_id, + name: group.name, + created_by: group.created_by, + created_at: group.created_at, + updated_at: group.updated_at, + my_role: m.role, + }); + } + } + + const rows = Array.from(groupsById.values()); + rows.sort((a: any, b: any) => String(a.created_at ?? "").localeCompare(String(b.created_at ?? ""))); + + // 额外:带上当前用户用户名,前端可用 + const me = await ctx.db.get(userId as Id<"users">); + + return { + me: { userId, username: me?.name ?? null }, + groups: rows, + }; + }, +}); + export const create = mutation({ args: { id: v.string(), @@ -159,4 +203,3 @@ export const remove = mutation({ return { ok: true }; }, }); - diff --git a/wolai-frontend/convex/jobs.ts b/wolai-frontend/convex/jobs.ts index eaea24f8..9f93d448 100644 --- a/wolai-frontend/convex/jobs.ts +++ b/wolai-frontend/convex/jobs.ts @@ -5,6 +5,7 @@ import { api, internal } from "./_generated/api"; import { lightragIngestText } from "./_utils/lightrag"; import { extractTextFromDocumentContent, extractTextFromMindmapData } from "./_utils/text"; import { enqueueIngestDocumentJob, enqueueIngestMediaAssetJob, enqueueIngestMindmapJob } from "./_utils/ingestJobs"; +import { extractTextFromAttachment } from "./_utils/attachmentExtract"; export const get = query({ args: { userId: v.string(), id: v.string() }, @@ -199,6 +200,105 @@ export const run = internalAction({ return; } + if (job.type === "extract.media_asset_text") { + const assetId = String(job.payload?.assetId ?? "").trim(); + if (!assetId) throw new Error("缺少 assetId"); + + const asset = await ctx.runQuery(api.mediaAssets.getById, { userId: job.user_id, id: assetId }); + if (!asset) throw new Error("资源不存在或无权限"); + + // 说明:只处理 file 类型的常见附件(pdf/docx/pptx/xlsx)。 + if (String(asset.asset_type ?? "") !== "file") { + await ctx.runMutation(api.mediaAssets.patchById, { + userId: job.user_id, + id: assetId, + patch: { + ocr_status: "skipped", + ocr_payload: { reason: "non_file_asset" }, + ocr_strategy: "attachment_extract", + }, + }); + await ctx.runMutation(internal.jobs.finishSuccess, { + id: args.id, + result: { ok: true, kind: "extract_media_asset_text", assetId, skipped: true, reason: "non_file_asset" }, + }); + return; + } + + const fileSize = typeof asset.file_size === "number" ? asset.file_size : null; + const maxBytes = 25 * 1024 * 1024; + if (typeof fileSize === "number" && fileSize > maxBytes) { + await ctx.runMutation(api.mediaAssets.patchById, { + userId: job.user_id, + id: assetId, + patch: { + ocr_status: "failed", + ocr_payload: { error: `文件过大(${fileSize} bytes),暂不解析`, maxBytes }, + ocr_strategy: "attachment_extract", + }, + }); + await ctx.runMutation(internal.jobs.finishFailure, { + id: args.id, + error: "文件过大,暂不解析", + }); + return; + } + + // 说明:Convex Files 的 getUrl 可能过期,先刷新并获取当前可用链接。 + const refreshed = await ctx.runMutation(api.mediaAssets.refreshUrl, { userId: job.user_id, id: assetId }); + const url = String((refreshed as any)?.signedUrl ?? asset.file_url ?? "").trim(); + if (!url) throw new Error("缺少可用文件链接"); + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`下载附件失败:${res.status}`); + } + const bytes = await res.arrayBuffer(); + + const extracted = await extractTextFromAttachment({ + mimeType: (asset as any).mime_type ?? null, + fileName: (asset as any).file_name ?? null, + bytes, + }); + + if (!extracted.ok) { + await ctx.runMutation(api.mediaAssets.patchById, { + userId: job.user_id, + id: assetId, + patch: { + ocr_status: "failed", + ocr_payload: { error: extracted.reason, meta: extracted.meta ?? null }, + ocr_strategy: extracted.strategy, + }, + }); + await ctx.runMutation(internal.jobs.finishFailure, { id: args.id, error: extracted.reason }); + return; + } + + await ctx.runMutation(api.mediaAssets.patchById, { + userId: job.user_id, + id: assetId, + patch: { + ocr_text: extracted.text, + ocr_status: "completed", + ocr_payload: extracted.meta ?? null, + ocr_strategy: extracted.strategy, + }, + }); + + await ctx.runMutation(internal.jobs.finishSuccess, { + id: args.id, + result: { + ok: true, + kind: "extract_media_asset_text", + assetId, + strategy: extracted.strategy, + chars: extracted.text.length, + }, + }); + return; + } + throw new Error(`未知任务类型:${job.type}`); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/wolai-frontend/convex/mediaAssets.ts b/wolai-frontend/convex/mediaAssets.ts index 63aecf73..a7e15f2a 100644 --- a/wolai-frontend/convex/mediaAssets.ts +++ b/wolai-frontend/convex/mediaAssets.ts @@ -1,7 +1,7 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { nowIso } from "./_utils/time"; -import { enqueueIngestMediaAssetJob } from "./_utils/ingestJobs"; +import { enqueueExtractMediaAssetTextJob, enqueueIngestMediaAssetJob } from "./_utils/ingestJobs"; import type { MutationCtx, QueryCtx } from "./_generated/server"; async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) { @@ -15,6 +15,26 @@ async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string return membership; } +function shouldExtractAttachmentText(args: { + assetType?: string | null; + mimeType?: string | null; + fileName?: string | null; +}): boolean { + if (String(args.assetType ?? "") !== "file") return false; + const mime = String(args.mimeType ?? "").toLowerCase().trim(); + if (mime === "application/pdf") return true; + if (mime === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") return true; + if (mime === "application/vnd.openxmlformats-officedocument.presentationml.presentation") return true; + if (mime === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") return true; + + const name = String(args.fileName ?? "").toLowerCase().trim(); + if (name.endsWith(".pdf")) return true; + if (name.endsWith(".docx")) return true; + if (name.endsWith(".pptx")) return true; + if (name.endsWith(".xlsx")) return true; + return false; +} + export const getById = query({ args: { userId: v.string(), id: v.string() }, handler: async (ctx, args) => { @@ -76,6 +96,44 @@ export const listByWorkspace = query({ }, }); +export const listSearchDataByWorkspace = query({ + args: { + userId: v.string(), + workspaceId: v.string(), + includeDeleted: v.optional(v.boolean()), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + await assertWorkspaceMember(ctx, args.userId, args.workspaceId); + + const includeDeleted = Boolean(args.includeDeleted); + const limit = Math.max(1, Math.min(5000, Math.floor(args.limit ?? 5000))); + + const rows = await ctx.db + .query("media_assets") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .order("desc") + .take(limit * 2); + + return rows + .filter((r) => (includeDeleted ? true : !r.deleted_at)) + .filter((r) => !r.purged_at) + .slice(0, limit) + .map((r) => ({ + id: r.id, + workspace_id: r.workspace_id, + document_id: r.document_id, + asset_type: r.asset_type, + file_name: r.file_name ?? null, + mime_type: r.mime_type ?? null, + ocr_text: r.ocr_text ?? null, + ocr_status: r.ocr_status ?? null, + created_at: r.created_at ?? null, + updated_at: r.updated_at ?? null, + })); + }, +}); + export const listByDocument = query({ args: { userId: v.string(), @@ -162,7 +220,13 @@ export const create = mutation({ ...args.asset, storage_id: args.asset.storage_id ?? null, ocr_text: null, - ocr_status: null, + ocr_status: shouldExtractAttachmentText({ + assetType: args.asset.asset_type, + mimeType: args.asset.mime_type, + fileName: args.asset.file_name, + }) + ? "queued" + : null, deleted_at: null, deleted_by: null, purged_at: null, @@ -171,6 +235,16 @@ export const create = mutation({ updated_at: ts, }); + if ( + shouldExtractAttachmentText({ + assetType: args.asset.asset_type, + mimeType: args.asset.mime_type, + fileName: args.asset.file_name, + }) + ) { + await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: args.asset.id, debounceMs: 800 }); + } + return args.asset; }, }); @@ -222,7 +296,13 @@ export const createWithStorage = mutation({ mime_type: args.asset.mime_type, ocr_text: null, - ocr_status: null, + ocr_status: shouldExtractAttachmentText({ + assetType: args.asset.asset_type, + mimeType: args.asset.mime_type, + fileName: args.asset.file_name, + }) + ? "queued" + : null, deleted_at: null, deleted_by: null, purged_at: null, @@ -232,6 +312,16 @@ export const createWithStorage = mutation({ }; await ctx.db.insert("media_assets", row); + + if ( + shouldExtractAttachmentText({ + assetType: args.asset.asset_type, + mimeType: args.asset.mime_type, + fileName: args.asset.file_name, + }) + ) { + await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: args.asset.id, debounceMs: 800 }); + } return row; }, }); @@ -312,13 +402,57 @@ export const replaceStorageFromUpload = mutation({ thumbnail_url: url, bucket: null, storage_path: null, + ocr_text: null, + ocr_status: shouldExtractAttachmentText({ + assetType: row.asset_type, + mimeType: row.mime_type ?? null, + fileName: row.file_name ?? null, + }) + ? "queued" + : row.ocr_status ?? null, updated_at: nowIso(), }); + if ( + shouldExtractAttachmentText({ + assetType: row.asset_type, + mimeType: row.mime_type ?? null, + fileName: row.file_name ?? null, + }) + ) { + await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: args.id, debounceMs: 800 }); + } + return { ok: true, fileUrl: url }; }, }); +export const enqueueExtractText = mutation({ + args: { userId: v.string(), id: 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) throw new Error("资源不存在"); + await assertWorkspaceMember(ctx, args.userId, row.workspace_id); + + if ( + !shouldExtractAttachmentText({ + assetType: row.asset_type, + mimeType: row.mime_type ?? null, + fileName: row.file_name ?? null, + }) + ) { + return { ok: false, reason: "unsupported" as const }; + } + + await ctx.db.patch(row._id, { ocr_status: "queued", updated_at: nowIso() }); + await enqueueExtractMediaAssetTextJob(ctx, { userId: args.userId, assetId: args.id, debounceMs: 0 }); + return { ok: true }; + }, +}); + export const purgeById = mutation({ args: { userId: v.string(), id: v.string(), expiredDeletedAt: v.string() }, handler: async (ctx, args) => { diff --git a/wolai-frontend/convex/schema.ts b/wolai-frontend/convex/schema.ts index 3262e01b..c5308c24 100644 --- a/wolai-frontend/convex/schema.ts +++ b/wolai-frontend/convex/schema.ts @@ -244,10 +244,14 @@ export default defineSchema({ shared_with_user_id: v.string(), permission: v.union(v.literal("read"), v.literal("edit")), include_descendants: v.boolean(), + disable_download: v.optional(v.boolean()), + disable_copy: v.optional(v.boolean()), created_by: v.string(), created_at: v.string(), updated_at: v.string(), }) + .index("by_shared_with_user", ["shared_with_user_id"]) + .index("by_created_by", ["created_by"]) .index("by_document", ["document_id"]) .index("by_doc_user", ["document_id", "shared_with_user_id"]) .index("by_workspace_shared_with", ["workspace_id", "shared_with_user_id"]) @@ -275,11 +279,27 @@ export default defineSchema({ .index("by_group_user", ["group_id", "user_id"]) .index("by_workspace_user", ["workspace_id", "user_id"]), + group_invitations: defineTable({ + workspace_id: v.string(), + group_id: v.string(), + invited_user_id: v.string(), + invited_by: v.string(), + created_at: v.string(), + updated_at: v.string(), + }) + .index("by_invited_user", ["invited_user_id"]) + .index("by_group_user", ["group_id", "invited_user_id"]) + .index("by_workspace_invited_user", ["workspace_id", "invited_user_id"]) + .index("by_workspace_group", ["workspace_id", "group_id"]) + .index("by_workspace_invited_by", ["workspace_id", "invited_by"]), + document_group_shares: defineTable({ workspace_id: v.string(), group_id: v.string(), document_id: v.string(), include_descendants: v.boolean(), + disable_download: v.optional(v.boolean()), + disable_copy: v.optional(v.boolean()), created_by: v.string(), created_at: v.string(), updated_at: v.string(), diff --git a/wolai-frontend/convex/tables.ts b/wolai-frontend/convex/tables.ts index 230311fd..75b8717e 100644 --- a/wolai-frontend/convex/tables.ts +++ b/wolai-frontend/convex/tables.ts @@ -2,6 +2,18 @@ import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; import { nowIso } from "./_utils/time"; import { generateId } from "./_utils/id"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; + +async function assertWorkspaceMember(ctx: QueryCtx | MutationCtx, userId: string, workspaceId: string) { + const membership = await ctx.db + .query("workspace_members") + .withIndex("by_workspace_user", (q) => q.eq("workspace_id", workspaceId).eq("user_id", userId)) + .first(); + if (!membership) { + throw new Error("无权访问该工作空间"); + } + return membership; +} // Query: 获取单个表格 export const get = query({ @@ -102,6 +114,40 @@ export const listByDocument = query({ }, }); +export const listByWorkspaceForSearch = query({ + args: { + userId: v.string(), + workspaceId: v.string(), + includeArchived: v.optional(v.boolean()), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + await assertWorkspaceMember(ctx, args.userId, args.workspaceId); + + const includeArchived = Boolean(args.includeArchived); + const limit = Math.max(1, Math.min(3000, Math.floor(args.limit ?? 3000))); + + const rows = await ctx.db + .query("document_tables") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .order("desc") + .take(limit * 2); + + return rows + .filter((t) => (includeArchived ? true : !t.is_archived)) + .slice(0, limit) + .map((t) => ({ + id: t.id, + workspace_id: t.workspace_id, + document_id: t.document_id, + title: t.title, + is_archived: t.is_archived, + created_at: t.created_at, + updated_at: t.updated_at, + })); + }, +}); + // Mutation: 创建表格 export const create = mutation({ args: { @@ -287,3 +333,36 @@ export const getRows = query({ .map((r) => r.row_data); }, }); + +export const listRowsByWorkspaceForSearch = query({ + args: { + userId: v.string(), + workspaceId: v.string(), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + await assertWorkspaceMember(ctx, args.userId, args.workspaceId); + + const limit = Math.max(1, Math.min(8000, Math.floor(args.limit ?? 8000))); + + const rows = await ctx.db + .query("document_table_rows") + .withIndex("by_workspace", (q) => q.eq("workspace_id", args.workspaceId)) + .order("desc") + .take(limit * 2); + + return rows + .filter((r) => !r.is_deleted) + .slice(0, limit) + .map((r) => ({ + id: r.id, + workspace_id: r.workspace_id, + document_id: r.document_id, + table_id: r.table_id, + row_index: r.row_index, + row_hash: r.row_hash ?? null, + created_at: r.created_at ?? null, + updated_at: r.updated_at ?? null, + })); + }, +}); diff --git a/wolai-frontend/package.json b/wolai-frontend/package.json index 2bd7dfe5..ae91a0af 100644 --- a/wolai-frontend/package.json +++ b/wolai-frontend/package.json @@ -5,7 +5,8 @@ "scripts": { "dev": "node scripts/dev-server.js", "build": "next build", - "start": "next start", + "start": "node scripts/prod-server.js", + "start:next": "next start", "lint": "eslint", "test": "vitest" }, @@ -45,6 +46,7 @@ "jszip": "3.10.1", "lucide-react": "^0.554.0", "next": "16.0.3", + "pdfjs-dist": "^4.10.38", "react": "19.2.0", "react-dom": "19.2.0", "react-dropzone": "^14.3.8", diff --git a/wolai-frontend/pnpm-lock.yaml b/wolai-frontend/pnpm-lock.yaml index e89cb808..900e42a6 100644 --- a/wolai-frontend/pnpm-lock.yaml +++ b/wolai-frontend/pnpm-lock.yaml @@ -113,6 +113,9 @@ importers: next: specifier: 16.0.3 version: 16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + pdfjs-dist: + specifier: ^4.10.38 + version: 4.10.38 react: specifier: 19.2.0 version: 19.2.0 @@ -1317,6 +1320,76 @@ packages: peerDependencies: react: '>=16.8.0' + '@napi-rs/canvas-android-arm64@0.1.88': + resolution: {integrity: sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.88': + resolution: {integrity: sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.88': + resolution: {integrity: sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.88': + resolution: {integrity: sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.88': + resolution: {integrity: sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.88': + resolution: {integrity: sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.88': + resolution: {integrity: sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.88': + resolution: {integrity: sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.88': + resolution: {integrity: sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.88': + resolution: {integrity: sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.88': + resolution: {integrity: sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.88': + resolution: {integrity: sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -4347,6 +4420,10 @@ packages: pdf-lib@1.17.1: resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + pdfjs-dist@4.10.38: + resolution: {integrity: sha512-/Y3fcFrXEAsMjJXeL9J8+ZG9U01LbuWaYypvDW2ycW1jL269L3js3DVBjDJ0Up9Np1uqDXsDrRihHANhZOlwdQ==} + engines: {node: '>=20'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -6412,6 +6489,54 @@ snapshots: dependencies: react: 19.2.0 + '@napi-rs/canvas-android-arm64@0.1.88': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.88': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.88': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.88': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.88': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.88': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.88': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.88': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.88': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.88': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.88': + optional: true + + '@napi-rs/canvas@0.1.88': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.88 + '@napi-rs/canvas-darwin-arm64': 0.1.88 + '@napi-rs/canvas-darwin-x64': 0.1.88 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.88 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.88 + '@napi-rs/canvas-linux-arm64-musl': 0.1.88 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.88 + '@napi-rs/canvas-linux-x64-gnu': 0.1.88 + '@napi-rs/canvas-linux-x64-musl': 0.1.88 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.88 + '@napi-rs/canvas-win32-x64-msvc': 0.1.88 + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.7.1 @@ -9891,6 +10016,10 @@ snapshots: pako: 1.0.11 tslib: 1.14.1 + pdfjs-dist@4.10.38: + optionalDependencies: + '@napi-rs/canvas': 0.1.88 + picocolors@1.1.1: {} picomatch@2.3.1: {} diff --git a/wolai-frontend/scripts/prod-server.js b/wolai-frontend/scripts/prod-server.js new file mode 100644 index 00000000..c3bf2e6e --- /dev/null +++ b/wolai-frontend/scripts/prod-server.js @@ -0,0 +1,330 @@ +#!/usr/bin/env node +/** + * 自定义 Next 生产 server: + * - 解决 ONLYOFFICE 在 `/onlyoffice-server/*` 下的 WebSocket Upgrade 需求(socket.io / coauthoring)。 + * - 解决 Convex 在 HTTPS(frp/nginx)场景下浏览器不能连接 ws:// 的问题:通过同源 `/convex/*` 反代到本机 Convex。 + * + * 用法: + * - pnpm build + * - pnpm start (默认会执行本脚本) + * + * 依赖环境变量: + * - ONLYOFFICE_INTERNAL_URL:默认 http://127.0.0.1:8081 + * - CONVEX_INTERNAL_URL:默认 http://127.0.0.1:3210 + */ + +const http = require("http"); +const net = require("net"); +const path = require("path"); +const next = require("next"); +const { parse: parseUrl } = require("url"); + +const ONLYOFFICE_PREFIX = "/onlyoffice-server"; +const CONVEX_PREFIX = "/convex"; + +function readArgValue(flag) { + const idx = process.argv.findIndex((x) => x === flag); + if (idx === -1) return null; + const v = process.argv[idx + 1]; + if (!v || v.startsWith("-")) return null; + return v; +} + +function resolvePort() { + const fromArg = readArgValue("-p") || readArgValue("--port"); + const raw = fromArg || process.env.PORT || "3000"; + const n = Number(raw); + return Number.isFinite(n) ? Math.max(1, Math.min(65535, Math.floor(n))) : 3000; +} + +function resolveHostname() { + return readArgValue("-H") || readArgValue("--hostname") || process.env.HOSTNAME || "0.0.0.0"; +} + +function isOnlyOfficePath(urlString) { + try { + const u = new URL(urlString, "http://localhost"); + return u.pathname === ONLYOFFICE_PREFIX || u.pathname.startsWith(`${ONLYOFFICE_PREFIX}/`); + } catch { + return false; + } +} + +function isConvexPath(urlString) { + try { + const u = new URL(urlString, "http://localhost"); + return u.pathname === CONVEX_PREFIX || u.pathname.startsWith(`${CONVEX_PREFIX}/`); + } catch { + return false; + } +} + +function buildUpstreamRequestHead(req, targetUrl, prefix) { + const incoming = new URL(req.url || "/", "http://localhost"); + const rawPath = incoming.pathname || "/"; + const stripped = rawPath === prefix ? "/" : rawPath.slice(prefix.length) || "/"; + + const basePath = String(targetUrl.pathname || "/").replace(/\/+$/, "") || ""; + const upstreamPath = `${basePath}${stripped}`.replace(/\/{2,}/g, "/") + (incoming.search || ""); + + const lines = []; + lines.push(`${req.method || "GET"} ${upstreamPath} HTTP/1.1`); + + const headers = req.headers || {}; + const headerValue = (name) => { + const v = headers[name]; + if (!v) return ""; + return Array.isArray(v) ? v[0] : String(v); + }; + + const originLike = headerValue("origin") || headerValue("referer") || ""; + const originUrl = (() => { + try { + if (!originLike) return null; + return new URL(originLike); + } catch { + return null; + } + })(); + + const forwardedHostRaw = + headerValue("x-forwarded-host") || (originUrl ? originUrl.host : "") || headerValue("host") || ""; + const forwardedProto = + (headerValue("x-forwarded-proto") || "").split(",")[0].trim() || + (originUrl ? originUrl.protocol.replace(":", "") : "") || + "http"; + const forwardedPort = (() => { + const fromHeader = (headerValue("x-forwarded-port") || "").split(",")[0].trim(); + if (fromHeader) return fromHeader; + const hostHasPort = forwardedHostRaw.includes(":") ? forwardedHostRaw.split(":").pop() : ""; + if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort; + return forwardedProto === "https" ? "443" : "80"; + })(); + const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost"; + + for (const [k, v] of Object.entries(headers)) { + if (!v) continue; + const key = String(k); + if (key.toLowerCase() === "host") continue; + if (Array.isArray(v)) { + lines.push(`${key}: ${v.join(", ")}`); + } else { + lines.push(`${key}: ${String(v)}`); + } + } + + lines.push(`x-forwarded-host: ${forwardedHost}`); + lines.push(`x-forwarded-proto: ${forwardedProto}`); + lines.push(`x-forwarded-port: ${forwardedPort}`); + lines.push(`x-forwarded-prefix: ${prefix}`); + lines.push(`Host: ${targetUrl.host}`); + lines.push(""); + lines.push(""); + return lines.join("\r\n"); +} + +function proxyOnlyOfficeUpgrade(req, socket, head) { + const target = new URL((process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, "") + "/"); + const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80); + + const upstream = net.connect({ host: target.hostname, port }, () => { + try { + const reqHead = buildUpstreamRequestHead(req, target, ONLYOFFICE_PREFIX); + upstream.write(reqHead); + if (head && head.length > 0) upstream.write(head); + socket.pipe(upstream); + upstream.pipe(socket); + } catch { + try { + socket.destroy(); + } catch {} + try { + upstream.destroy(); + } catch {} + } + }); + + const onError = (err) => { + try { + const msg = err && err.message ? String(err.message) : String(err || ""); + console.log("[prod-server][onlyoffice-ws] proxy error", msg); + } catch {} + try { + socket.destroy(); + } catch {} + try { + upstream.destroy(); + } catch {} + }; + + upstream.on("error", onError); + socket.on("error", onError); +} + +function resolveConvexInternalUrl() { + const raw = (process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210").trim(); + try { + return new URL(raw.replace(/\/+$/, "") + "/"); + } catch { + return new URL("http://127.0.0.1:3210/"); + } +} + +function proxyConvexUpgrade(req, socket, head) { + const target = resolveConvexInternalUrl(); + const port = Number(target.port) || (target.protocol === "https:" ? 443 : 80); + + const upstream = net.connect({ host: target.hostname, port }, () => { + try { + const reqHead = buildUpstreamRequestHead(req, target, CONVEX_PREFIX); + upstream.write(reqHead); + if (head && head.length > 0) upstream.write(head); + socket.pipe(upstream); + upstream.pipe(socket); + } catch { + try { + socket.destroy(); + } catch {} + try { + upstream.destroy(); + } catch {} + } + }); + + const onError = (err) => { + try { + const msg = err && err.message ? String(err.message) : String(err || ""); + console.log("[prod-server][convex-ws] proxy error", msg); + } catch {} + try { + socket.destroy(); + } catch {} + try { + upstream.destroy(); + } catch {} + }; + + upstream.on("error", onError); + socket.on("error", onError); +} + +function proxyConvexHttp(req, res) { + const target = resolveConvexInternalUrl(); + const incoming = new URL(req.url || "/", "http://localhost"); + const rawPath = incoming.pathname || "/"; + const stripped = rawPath === CONVEX_PREFIX ? "/" : rawPath.slice(CONVEX_PREFIX.length) || "/"; + const upstreamPath = stripped + (incoming.search || ""); + + const isHttps = target.protocol === "https:"; + const mod = isHttps ? require("https") : require("http"); + + const headers = { ...(req.headers || {}) }; + headers.host = target.host; + + const forwardedHostRaw = String(headers["x-forwarded-host"] || req.headers.host || ""); + const forwardedHost = forwardedHostRaw.split(",")[0].trim() || "localhost"; + const forwardedProto = + String(headers["x-forwarded-proto"] || "").split(",")[0].trim() || + (String(req.headers.origin || "").startsWith("https") ? "https" : "http"); + const forwardedPortRaw = String(headers["x-forwarded-port"] || "").split(",")[0].trim(); + const forwardedPort = (() => { + if (forwardedPortRaw) return forwardedPortRaw; + const hostHasPort = forwardedHost.includes(":") ? forwardedHost.split(":").pop() : ""; + if (hostHasPort && /^\d+$/.test(hostHasPort)) return hostHasPort; + return forwardedProto === "https" ? "443" : "80"; + })(); + + headers["x-forwarded-host"] = forwardedHost; + headers["x-forwarded-proto"] = forwardedProto; + headers["x-forwarded-port"] = forwardedPort; + headers["x-forwarded-prefix"] = CONVEX_PREFIX; + + const upstreamReq = mod.request( + { + protocol: target.protocol, + hostname: target.hostname, + port: target.port || (isHttps ? 443 : 80), + method: req.method, + path: upstreamPath, + headers, + }, + (upstreamRes) => { + res.writeHead(upstreamRes.statusCode || 502, upstreamRes.headers || {}); + upstreamRes.pipe(res); + }, + ); + + upstreamReq.on("error", (err) => { + try { + console.log("[prod-server][convex-http] proxy error", err && err.message ? err.message : String(err || "")); + } catch {} + try { + res.statusCode = 502; + res.end("Bad Gateway"); + } catch {} + }); + + req.pipe(upstreamReq); +} + +async function main() { + const port = resolvePort(); + const hostname = resolveHostname(); + const dev = false; + + const app = next({ dev, dir: path.join(__dirname, "..") }); + const handle = app.getRequestHandler(); + + await app.prepare(); + const handleUpgrade = typeof app.getUpgradeHandler === "function" ? app.getUpgradeHandler() : null; + + const server = http.createServer((req, res) => { + try { + res.setHeader("x-mnote-prod-server", "1"); + res.setHeader("x-mnote-onlyoffice-ws-proxy", "1"); + res.setHeader("x-mnote-convex-ws-proxy", "1"); + } catch { + // ignore + } + + if (isConvexPath(req.url || "/")) { + proxyConvexHttp(req, res); + return; + } + + const parsed = parseUrl(req.url || "/", true); + handle(req, res, parsed); + }); + + server.on("upgrade", (req, socket, head) => { + if (isConvexPath(req.url || "/")) { + proxyConvexUpgrade(req, socket, head); + return; + } + if (isOnlyOfficePath(req.url || "/")) { + proxyOnlyOfficeUpgrade(req, socket, head); + return; + } + if (handleUpgrade) { + handleUpgrade(req, socket, head); + return; + } + try { + socket.destroy(); + } catch { + // ignore + } + }); + + server.listen(port, hostname, () => { + console.log( + `[prod-server] ready http://${hostname}:${port} (ONLYOFFICE ws via ${ONLYOFFICE_PREFIX} -> ${process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081"}; Convex ws via ${CONVEX_PREFIX} -> ${process.env.CONVEX_INTERNAL_URL || "http://127.0.0.1:3210"})`, + ); + }); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.stack : String(err)); + process.exit(1); +}); + diff --git a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx index 9367c285..f5c0272d 100644 --- a/wolai-frontend/src/app/(app)/documents/[id]/page.tsx +++ b/wolai-frontend/src/app/(app)/documents/[id]/page.tsx @@ -24,6 +24,8 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag notFound(); } const readOnly = (doc as any).can_edit === false; + const disableDownload = Boolean((doc as any).disable_download); + const disableCopy = Boolean((doc as any).disable_copy); const initialOptions: PageOptionsState = { wideLayout: doc.wide_layout ?? false, @@ -54,6 +56,8 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag initialStats={initialStats} openTableId={openTableId} readOnly={readOnly} + disableDownload={disableDownload} + disableCopy={disableCopy} /> diff --git a/wolai-frontend/src/app/(auth)/auth/page.tsx b/wolai-frontend/src/app/(auth)/auth/page.tsx index 34ac4440..bc937e68 100644 --- a/wolai-frontend/src/app/(auth)/auth/page.tsx +++ b/wolai-frontend/src/app/(auth)/auth/page.tsx @@ -6,6 +6,7 @@ import { useState, useCallback, useEffect } from "react"; import { useRouter } from "next/navigation"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { api } from "@/lib/convex/api"; +import { getUserFacingErrorMessage } from "@/lib/auth/errors"; type AuthStep = "signIn" | "signUp"; @@ -43,11 +44,47 @@ export default function AuthPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [username, setUsername] = useState(""); + const [pendingUsernameToSave, setPendingUsernameToSave] = useState(null); + const [isSavingUsername, setIsSavingUsername] = useState(false); const [message, setMessage] = useState<{ type: "success" | "error" | "info"; text: string; } | null>(null); + // 注册完成后,等待登录态同步完成再写入用户名,避免出现“未登录”的竞态 + useEffect(() => { + if (!pendingUsernameToSave) return; + if (!isAuthenticated) return; + if (currentUser === undefined || currentUser === null) return; + if (currentUser.name) { + setPendingUsernameToSave(null); + return; + } + + let cancelled = false; + setIsSavingUsername(true); + + (async () => { + try { + await setMyUsername({ username: pendingUsernameToSave }); + if (cancelled) return; + setPendingUsernameToSave(null); + setMessage({ type: "success", text: "注册成功!" }); + router.replace("/"); + } catch (err: unknown) { + if (cancelled) return; + setPendingUsernameToSave(null); + setMessage({ type: "error", text: getUserFacingErrorMessage(err, "用户名设置失败,请重试") }); + } finally { + if (!cancelled) setIsSavingUsername(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [currentUser, isAuthenticated, pendingUsernameToSave, router, setMyUsername]); + // 执行登录的核心逻辑 const performSignIn = useCallback(async (email: string, password: string, flow: AuthStep) => { setMessage(null); @@ -67,13 +104,11 @@ export default function AuthPage() { if (result.signingIn) { if (flow === "signUp") { - try { - await setMyUsername({ username }); - } catch (e: any) { - // 说明:注册已成功,但用户名可能重复;此时保持登录状态,转入“设置用户名”步骤继续处理。 - setMessage({ type: "error", text: e?.message ?? "用户名设置失败,请重试" }); - return; - } + // 说明:注册已成功,但此刻登录态可能尚未同步到 Convex functions。 + // 这里先进入“待写入用户名”状态,等 currentUser 可用后再写,避免出现“未登录”的竞态。 + setMessage({ type: "info", text: "注册成功,正在保存用户名..." }); + setPendingUsernameToSave(username.trim()); + return; } setMessage({ type: "success", text: flow === "signIn" ? "登录成功!" : "注册成功!" }); setTimeout(() => router.push("/"), 500); @@ -89,9 +124,9 @@ export default function AuthPage() { setMessage({ type: "error", text: "操作失败,请重试" }); } catch (error: any) { - setMessage({ type: "error", text: error.message || "操作失败,请重试" }); + setMessage({ type: "error", text: getUserFacingErrorMessage(error, "操作失败,请重试") }); } - }, [router, setMyUsername, signIn, username]); + }, [router, signIn, username]); const handleSubmit = useCallback(async (event: React.FormEvent) => { event.preventDefault(); @@ -134,12 +169,12 @@ export default function AuthPage() { } if (isAuthenticated) { - if (currentUser === undefined) { + if (currentUser === undefined || currentUser === null) { return (
-

加载中...

+

正在同步登录状态...

); @@ -148,11 +183,15 @@ export default function AuthPage() { const saveUsername = async () => { setMessage(null); try { - await setMyUsername({ username }); + if (isSavingUsername) return; + setIsSavingUsername(true); + await setMyUsername({ username: username.trim() }); setMessage({ type: "success", text: "用户名已保存!" }); router.replace("/"); } catch (error: any) { - setMessage({ type: "error", text: error.message || "保存失败,请重试" }); + setMessage({ type: "error", text: getUserFacingErrorMessage(error, "保存失败,请重试") }); + } finally { + setIsSavingUsername(false); } }; @@ -185,6 +224,7 @@ export default function AuthPage() { required value={username} onChange={(e) => setUsername(e.target.value)} + disabled={isSavingUsername} className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" placeholder="用户名(2-32 位,不含空格)" /> @@ -192,9 +232,10 @@ export default function AuthPage() { diff --git a/wolai-frontend/src/app/api/media/sign/route.ts b/wolai-frontend/src/app/api/media/sign/route.ts index ca2d3cc8..1557a336 100644 --- a/wolai-frontend/src/app/api/media/sign/route.ts +++ b/wolai-frontend/src/app/api/media/sign/route.ts @@ -81,6 +81,8 @@ export async function GET(request: Request) { file_name: asset.file_name, mime_type: asset.mime_type, file_size: asset.file_size, + storage_id: (asset as any)?.storage_id ?? null, + updated_at: (asset as any)?.updated_at ?? null, }, }); } diff --git a/wolai-frontend/src/app/api/onlyoffice/callback/route.ts b/wolai-frontend/src/app/api/onlyoffice/callback/route.ts index 627785f8..db642329 100644 --- a/wolai-frontend/src/app/api/onlyoffice/callback/route.ts +++ b/wolai-frontend/src/app/api/onlyoffice/callback/route.ts @@ -94,6 +94,19 @@ export async function POST(request: Request) { return NextResponse.json({ error: 1 }); } + // 只读权限:不允许通过 ONLYOFFICE 回调写回 + try { + const perm = await client.query(api.documents.getPermissionForUser, { + userId, + id: String((asset as any).document_id || ""), + }); + if (!perm || (perm as any).permission !== "edit") { + return NextResponse.json({ error: 1 }); + } + } catch { + return NextResponse.json({ error: 1 }); + } + const downloadUrl = tryRewriteOnlyOfficeDownloadUrl(body.url); const upstream = await fetch(downloadUrl, { method: "GET", redirect: "follow" }); if (!upstream.ok) { diff --git a/wolai-frontend/src/app/api/onlyoffice/forcesave/route.ts b/wolai-frontend/src/app/api/onlyoffice/forcesave/route.ts new file mode 100644 index 00000000..33026e73 --- /dev/null +++ b/wolai-frontend/src/app/api/onlyoffice/forcesave/route.ts @@ -0,0 +1,143 @@ +import { NextResponse } from "next/server"; +import crypto from "crypto"; +import { api } from "@/lib/convex/api"; +import { HttpError } from "@/lib/auth/authContext"; +import { getAuthedConvexClient } from "@/lib/convex/route"; + +export const dynamic = "force-dynamic"; + +const ONLYOFFICE_INTERNAL_URL = (process.env.ONLYOFFICE_INTERNAL_URL || "http://127.0.0.1:8081").replace(/\/+$/, ""); + +const base64Url = (input: Buffer | string) => + Buffer.from(input) + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + +const signHs256 = (payload: unknown, secret: string) => { + const header = { alg: "HS256", typ: "JWT" }; + const headerPart = base64Url(JSON.stringify(header)); + const payloadPart = base64Url(JSON.stringify(payload)); + const signingInput = `${headerPart}.${payloadPart}`; + const signature = crypto.createHmac("sha256", secret).update(signingInput).digest(); + return `${signingInput}.${base64Url(signature)}`; +}; + +const normalizeSecret = (raw: string) => { + const trimmed = String(raw || "").trim(); + if (!trimmed) return ""; + // 兼容用户把 .env 的值写成 "xxx" / 'xxx' + if ( + (trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length >= 2) || + (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +}; + +export async function POST(request: Request) { + let auth; + let client; + try { + const authed = await getAuthedConvexClient(); + auth = authed.auth; + client = authed.client; + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: "未登录" }, { status: err.status }); + } + return NextResponse.json({ error: "未登录" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const assetId = String(searchParams.get("assetId") || "").trim(); + const key = String(searchParams.get("key") || "").trim(); + if (!assetId) return NextResponse.json({ error: "缺少 assetId" }, { status: 400 }); + if (!key) return NextResponse.json({ error: "缺少 key" }, { status: 400 }); + + const asset = await client.query(api.mediaAssets.getById, { userId: auth.userId, id: assetId }); + if (!asset) { + return NextResponse.json({ error: "资源不存在" }, { status: 404 }); + } + + // 只读权限:不允许触发 forcesave(避免只读用户间接写回存储) + const docId = String((asset as any).document_id || "").trim(); + if (!docId) { + return NextResponse.json({ error: "缺少 documentId" }, { status: 400 }); + } + const doc = await client.query(api.documents.getMeta, { id: docId }); + if (!doc || (doc as any).can_edit === false) { + return NextResponse.json({ error: "无权修改(只读共享的文件)" }, { status: 403 }); + } + + const payload = { c: "forcesave", key, userdata: `asset:${assetId}` }; + const secret = normalizeSecret(process.env.ONLYOFFICE_JWT_SECRET || ""); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tryJson = async (res: Response) => (await res.json().catch(() => null)) as any; + + try { + // 优先按文档推荐:使用 /command + token + if (secret) { + const token = signHs256(payload, secret); + const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + const j = await tryJson(r); + if (r.ok && Number(j?.error ?? 0) === 0) { + return NextResponse.json({ ok: true, via: "command", result: j }); + } + + // 兜底:部分环境可能暴露 /forcesave 直连接口 + const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const j2 = await tryJson(r2); + if (r2.ok && Number(j2?.error ?? 0) === 0) { + return NextResponse.json({ ok: true, via: "forcesave", result: j2 }); + } + + return NextResponse.json( + { error: "触发 forcesave 失败", detail: { command: j, forcesave: j2 } }, + { status: 502 }, + ); + } + + // JWT 未启用:尝试 /forcesave 直连 + const r = await fetch(`${ONLYOFFICE_INTERNAL_URL}/forcesave`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const j = await tryJson(r); + if (r.ok && Number(j?.error ?? 0) === 0) { + return NextResponse.json({ ok: true, via: "forcesave", result: j }); + } + + // 最后兜底:部分部署可能仍接受不带 token 的 /command(不保证) + const r2 = await fetch(`${ONLYOFFICE_INTERNAL_URL}/command`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const j2 = await tryJson(r2); + if (r2.ok && Number(j2?.error ?? 0) === 0) { + return NextResponse.json({ ok: true, via: "command_no_token", result: j2 }); + } + + return NextResponse.json( + { error: "触发 forcesave 失败", detail: { forcesave: j, command: j2 } }, + { status: 502 }, + ); + } catch (error) { + console.error("[onlyoffice/forcesave] failed:", error); + return NextResponse.json({ error: "触发 forcesave 失败" }, { status: 502 }); + } +} + diff --git a/wolai-frontend/src/app/api/search/documents/route.ts b/wolai-frontend/src/app/api/search/documents/route.ts index 69b056fd..ebfd5c65 100644 --- a/wolai-frontend/src/app/api/search/documents/route.ts +++ b/wolai-frontend/src/app/api/search/documents/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { isConvexEnabled } from "@/lib/convex/enabled"; import { getAuthedConvexClient } from "@/lib/convex/route"; import { api } from "@/lib/convex/api"; +import { HttpError } from "@/lib/auth/authContext"; import type { DocumentSearchFilters, DocumentSearchRequest, @@ -33,100 +34,389 @@ const TIME_FIELD_COLUMN = { created: "created_at", } as const; -export async function POST(request: Request) { - if (!isConvexEnabled()) { - return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); - } +type RangeIso = { fromIso: string | null; toIso: string | null }; - const { auth, client } = await getAuthedConvexClient(); - const payload = (await request.json()) as DocumentSearchRequest; - const workspaceId = payload.workspaceId; - - if (!workspaceId) { - return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); - } - - const filters: DocumentSearchFilters = { - ...DEFAULT_FILTERS, - ...payload.filters, - }; - - const limit = Math.min(payload.limit ?? 30, MAX_LIMIT); - const normalizedQuery = payload.query?.trim() ?? ""; - const normalizedLower = normalizedQuery.toLowerCase(); - - const docs = await client.query(api.documents.listByWorkspace, { workspaceId }); - const docMap = new Map(docs.map((d) => [d.id, d])); - - const recentRows = await client.query(api.recents.listByWorkspace, { - userId: auth.userId, - workspaceId, - limit: 10, - }); - const recent: DocumentSearchResult[] = recentRows - .map((r) => docMap.get(r.document_id)) - .filter((row): row is (typeof docs)[number] => Boolean(row)) - .map((row) => ({ - id: row.id, - title: row.title ?? "无标题", - snippet: "", - updatedAt: row.updated_at ?? null, - createdAt: row.created_at ?? null, - matchField: "recent", - hasOcr: false, - publicPath: `/documents/${row.id}`, - score: 0, - })); - - if (!normalizedQuery) { - const response: DocumentSearchResponse = { results: [], recent }; - return NextResponse.json(response); - } - - const timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange]; - const timeField = TIME_FIELD_COLUMN[filters.timeField]; - const boundaryIso = - typeof timeRangeMs === "number" - ? new Date(Date.now() - timeRangeMs).toISOString() - : null; - - const narrowed = [...docs] - .sort((a, b) => { - const ta = a.updated_at ?? a.created_at ?? ""; - const tb = b.updated_at ?? b.created_at ?? ""; - return tb.localeCompare(ta); - }) - .filter((row) => { - if (filters.onlyCurrentPage && payload.documentId) { - if (row.id !== payload.documentId) return false; - } - - if (boundaryIso) { - const ts = (row as any)?.[timeField] ?? null; - if (!ts || typeof ts !== "string") return false; - if (ts < boundaryIso) return false; - } - - const title = (row.title ?? "无标题").toLowerCase(); - if (filters.exact) { - return title === normalizedLower; - } - return title.includes(normalizedLower); - }); - - const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({ - id: row.id, - title: row.title ?? "无标题", - snippet: buildSnippet(row.title ?? "", normalizedQuery), - updatedAt: row.updated_at ?? null, - createdAt: row.created_at ?? null, - matchField: "title", - hasOcr: false, - publicPath: `/documents/${row.id}`, - score: 2, - })); - - const response: DocumentSearchResponse = { results, recent }; - return NextResponse.json(response); +function parseDateToIso(value: string | undefined): string | null { + const raw = String(value ?? "").trim(); + if (!raw) return null; + const d = new Date(raw); + if (Number.isNaN(d.getTime())) return null; + return d.toISOString(); } +function resolveCustomRangeIso(filters: DocumentSearchFilters): RangeIso { + const fromIso = parseDateToIso(filters.customRange?.from); + const toIso = parseDateToIso(filters.customRange?.to); + return { fromIso, toIso }; +} + +type MindmapNode = { data?: { text?: unknown }; children?: unknown[] }; + +function extractTextFromMindmapData(data: unknown, maxChars = 60000): string { + const out: string[] = []; + const pushText = (value: unknown) => { + const s = typeof value === "string" ? value : null; + if (!s) return; + const trimmed = s.replace(/\s+/g, " ").trim(); + if (!trimmed) return; + out.push(trimmed); + }; + + const walk = (node: unknown) => { + if (out.join("\n").length >= maxChars) return; + if (!node || typeof node !== "object") return; + const n = node as MindmapNode; + pushText(n.data?.text); + if (Array.isArray(n.children)) { + for (const c of n.children) { + walk(c); + if (out.join("\n").length >= maxChars) return; + } + } + }; + + walk(data); + const joined = out.join("\n").trim(); + return joined.length > maxChars ? `${joined.slice(0, maxChars)}…` : joined; +} + +export async function POST(request: Request) { + try { + if (!isConvexEnabled()) { + return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); + } + + const { auth, client } = await getAuthedConvexClient(); + const payload = (await request.json()) as DocumentSearchRequest; + const workspaceId = payload.workspaceId; + + if (!workspaceId) { + return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 }); + } + + const filters: DocumentSearchFilters = { + ...DEFAULT_FILTERS, + ...payload.filters, + }; + + const limit = Math.min(payload.limit ?? 30, MAX_LIMIT); + const normalizedQuery = payload.query?.trim() ?? ""; + const normalizedLower = normalizedQuery.toLowerCase(); + + const docs = await client.query(api.documents.listSearchDataByWorkspace, { workspaceId }); + const docMap = new Map(docs.map((d) => [d.id, d])); + + const recentRows = await client.query(api.recents.listByWorkspace, { + userId: auth.userId, + workspaceId, + limit: 10, + }); + const recent: DocumentSearchResult[] = recentRows + .map((r) => docMap.get(r.document_id)) + .filter((row): row is (typeof docs)[number] => Boolean(row)) + .map((row) => ({ + id: row.id, + title: row.title ?? "无标题", + snippet: "", + updatedAt: row.updated_at ?? null, + createdAt: row.created_at ?? null, + matchField: "recent", + hasOcr: false, + publicPath: `/documents/${row.id}`, + score: 0, + })); + + if (!normalizedQuery) { + const response: DocumentSearchResponse = { results: [], recent }; + return NextResponse.json(response); + } + + const timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange]; + const timeField = TIME_FIELD_COLUMN[filters.timeField]; + const boundaryIso = + typeof timeRangeMs === "number" + ? new Date(Date.now() - timeRangeMs).toISOString() + : null; + const { fromIso, toIso } = resolveCustomRangeIso(filters); + + const eligibleDocs = [...docs] + .sort((a, b) => { + const ta = a.updated_at ?? a.created_at ?? ""; + const tb = b.updated_at ?? b.created_at ?? ""; + return tb.localeCompare(ta); + }) + .filter((row) => { + if (filters.onlyCurrentPage && payload.documentId) { + if (row.id !== payload.documentId) return false; + } + + if (boundaryIso) { + const ts = (row as any)?.[timeField] ?? null; + if (!ts || typeof ts !== "string") return false; + if (ts < boundaryIso) return false; + } + + if (fromIso || toIso) { + const ts = (row as any)?.[timeField] ?? null; + if (!ts || typeof ts !== "string") return false; + if (fromIso && ts < fromIso) return false; + if (toIso && ts > toIso) return false; + } + + return true; + }); + + const eligibleDocIds = new Set(eligibleDocs.map((d) => d.id)); + + type MatchInfo = { + score: number; + matchField: "title" | "content"; + snippet: string; + hasOcr: boolean; + }; + + const matches = new Map(); + + const upsertMatch = (docId: string, patch: Partial) => { + const prev = matches.get(docId); + if (!prev) { + const score = typeof patch.score === "number" ? patch.score : 0; + const matchField = patch.matchField ?? "content"; + const snippet = patch.snippet ?? "暂无正文内容"; + const hasOcr = Boolean(patch.hasOcr); + matches.set(docId, { score, matchField, snippet, hasOcr }); + return; + } + + const next: MatchInfo = { + score: typeof patch.score === "number" ? Math.max(prev.score, patch.score) : prev.score, + matchField: patch.matchField ?? prev.matchField, + snippet: patch.snippet ?? prev.snippet, + hasOcr: prev.hasOcr || Boolean(patch.hasOcr), + }; + + // 若现有是标题匹配,不用较低分覆盖文案。 + if (prev.matchField === "title" && next.matchField !== "title") { + next.matchField = "title"; + next.snippet = prev.snippet; + } + + // 若新分数更高,则用新片段(除标题外) + if (typeof patch.score === "number" && patch.score > prev.score && prev.matchField !== "title") { + next.snippet = patch.snippet ?? next.snippet; + next.matchField = patch.matchField ?? next.matchField; + } + + matches.set(docId, next); + }; + + for (const row of eligibleDocs) { + const title = (row.title ?? "无标题").trim() || "无标题"; + const titleLower = title.toLowerCase(); + const hitTitle = filters.exact ? titleLower === normalizedLower : titleLower.includes(normalizedLower); + if (hitTitle) { + upsertMatch(row.id, { + score: 3, + matchField: "title", + snippet: buildSnippet(title, normalizedQuery), + }); + } + + if (filters.titleOnly) continue; + + const rawText = String(row.raw_text ?? "").trim(); + if (rawText) { + const hitContent = rawText.toLowerCase().includes(normalizedLower); + if (hitContent) { + upsertMatch(row.id, { + score: 2, + matchField: "content", + snippet: buildSnippet(rawText, normalizedQuery), + }); + } + } + } + + if (!filters.titleOnly) { + // 思维导图(默认纳入“正文”搜索) + const mindmaps = await client + .query(api.mindmaps.listByWorkspace, { workspaceId, includeDeleted: false }) + .catch(() => []); + for (const m of mindmaps) { + const docId = String(m.document_id ?? "").trim(); + if (!docId || !eligibleDocIds.has(docId)) continue; + if (!docMap.has(docId)) continue; + + const text = extractTextFromMindmapData(m.data ?? null); + if (!text) continue; + if (!text.toLowerCase().includes(normalizedLower)) continue; + + upsertMatch(docId, { + score: 1.6, + matchField: "content", + snippet: buildSnippet(`思维导图:${text}`, normalizedQuery), + }); + } + + // Luckysheet 在线表格(标题 + 行内容) + const [tables, tableRows] = await Promise.all([ + client + .query(api.tables.listByWorkspaceForSearch, { + userId: auth.userId, + workspaceId, + includeArchived: false, + limit: 3000, + }) + .catch(() => []), + client + .query(api.tables.listRowsByWorkspaceForSearch, { + userId: auth.userId, + workspaceId, + limit: 8000, + }) + .catch(() => []), + ]); + + const tableTitleById = new Map(); + for (const t of tables) { + if (!eligibleDocIds.has(t.document_id)) continue; + tableTitleById.set(t.id, String(t.title ?? "").trim() || "未命名表格"); + + const hitTableTitle = String(t.title ?? "").toLowerCase().includes(normalizedLower); + if (hitTableTitle && docMap.has(t.document_id)) { + upsertMatch(t.document_id, { + score: 1.5, + matchField: "content", + snippet: buildSnippet(`表格:${t.title}`, normalizedQuery), + }); + } + } + + for (const r of tableRows) { + const docId = String(r.document_id ?? "").trim(); + if (!docId || !eligibleDocIds.has(docId)) continue; + if (!docMap.has(docId)) continue; + const rowHash = String(r.row_hash ?? "").trim(); + if (!rowHash) continue; + if (!rowHash.toLowerCase().includes(normalizedLower)) continue; + + const tableTitle = tableTitleById.get(String(r.table_id ?? "").trim()) ?? "未命名表格"; + upsertMatch(docId, { + score: 1.4, + matchField: "content", + snippet: buildSnippet(`表格:${tableTitle}\n${rowHash}`, normalizedQuery), + }); + } + + // 附件/图片:默认搜索文件名;勾选“搜索附件内容”后再纳入 OCR/解析文本。 + const assets = await client + .query(api.mediaAssets.listSearchDataByWorkspace, { + userId: auth.userId, + workspaceId, + includeDeleted: false, + limit: 5000, + }) + .catch(() => []); + + const toEnqueueExtract: { id: string; mime: string | null; name: string | null; status: string | null; type: string | null }[] = []; + + for (const a of assets) { + const docId = String(a.document_id ?? "").trim(); + if (!docId || !eligibleDocIds.has(docId)) continue; + if (!docMap.has(docId)) continue; + + const fileName = String(a.file_name ?? "").trim(); + if (fileName && fileName.toLowerCase().includes(normalizedLower)) { + upsertMatch(docId, { + score: 1.2, + matchField: "content", + snippet: buildSnippet(`附件:${fileName}`, normalizedQuery), + }); + } + + if (!filters.includeOcr) continue; + + // 说明:勾选“搜索附件内容”时,若附件尚未生成 ocr_text,则后台排队解析(避免用户长期搜不到)。 + const ocrTextValue = String(a.ocr_text ?? "").trim(); + if (!ocrTextValue) { + const assetType = String((a as any).asset_type ?? "").trim() || null; + const mimeType = String((a as any).mime_type ?? "").toLowerCase().trim() || null; + const ocrStatus = String((a as any).ocr_status ?? "").trim() || null; + const nameLower = String(a.file_name ?? "").toLowerCase().trim() || null; + + const supported = + assetType === "file" && + (mimeType === "application/pdf" || + mimeType === "application/vnd.openxmlformats-officedocument.wordprocessingml.document" || + mimeType === "application/vnd.openxmlformats-officedocument.presentationml.presentation" || + mimeType === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" || + (nameLower ? [".pdf", ".docx", ".pptx", ".xlsx"].some((ext) => nameLower.endsWith(ext)) : false)); + + const busy = ocrStatus === "queued" || ocrStatus === "running"; + if (supported && !busy) { + toEnqueueExtract.push({ id: a.id, mime: mimeType, name: a.file_name ?? null, status: ocrStatus, type: assetType }); + } + continue; + } + + if (!ocrTextValue.toLowerCase().includes(normalizedLower)) continue; + + upsertMatch(docId, { + score: 1.7, + matchField: "content", + snippet: buildSnippet(`附件:${fileName || a.id}\n${ocrTextValue}`, normalizedQuery), + hasOcr: true, + }); + } + + if (toEnqueueExtract.length > 0) { + // 说明:限制每次搜索触发的解析数量,避免请求变慢;剩余附件可继续通过下一次搜索逐步排队。 + await Promise.all( + toEnqueueExtract + .slice(0, 3) + .map((item) => + client.mutation(api.mediaAssets.enqueueExtractText, { userId: auth.userId, id: item.id }).catch(() => null), + ), + ); + } + } + + const results: DocumentSearchResult[] = []; + for (const [docId, match] of matches.entries()) { + const row = docMap.get(docId); + if (!row) continue; + results.push({ + id: row.id, + title: row.title ?? "无标题", + snippet: match.snippet, + updatedAt: row.updated_at ?? null, + createdAt: row.created_at ?? null, + matchField: match.matchField, + hasOcr: match.hasOcr, + publicPath: `/documents/${row.id}`, + score: match.score, + }); + } + + results.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + const ta = a.updatedAt ?? a.createdAt ?? ""; + const tb = b.updatedAt ?? b.createdAt ?? ""; + const byTime = tb.localeCompare(ta); + if (byTime) return byTime; + return String(a.title ?? "").localeCompare(String(b.title ?? "")); + }); + + const limitedResults = results.slice(0, limit); + + const response: DocumentSearchResponse = { results: limitedResults, recent }; + return NextResponse.json(response); + } catch (err) { + if (err instanceof HttpError) { + return NextResponse.json({ error: err.message }, { status: err.status }); + } + const message = err instanceof Error ? err.message : "搜索失败,请稍后再试"; + console.error("[search/documents] failed:", err); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/wolai-frontend/src/app/cache/[...path]/route.ts b/wolai-frontend/src/app/cache/[...path]/route.ts index bdf7e707..201ba701 100644 --- a/wolai-frontend/src/app/cache/[...path]/route.ts +++ b/wolai-frontend/src/app/cache/[...path]/route.ts @@ -37,9 +37,20 @@ const pickCacheControl = (pathParts: string[], contentType: string, method: stri const p = `/${(pathParts ?? []).join("/")}`.toLowerCase(); // 说明:/cache 主要是 ONLYOFFICE 运行期二进制缓存,适合短缓存提升性能,但不宜过长。 if (p.includes("editor.bin") || p.endsWith(".bin")) { - return "public, max-age=3600, stale-while-revalidate=600"; + return "public, max-age=604800, stale-while-revalidate=86400, immutable"; } - return "public, max-age=300, stale-while-revalidate=300"; + return "public, max-age=86400, stale-while-revalidate=3600"; +}; + +const buildStableEtag = (pathParts: string[], search: string) => { + const p = `/${(pathParts ?? []).join("/")}`; + const s = String(search || ""); + return `W/"mnote-oo-cache:${p}${s}"`; +}; + +const isStrongCache = (cc: string) => { + const v = String(cc || ""); + return v.includes("max-age=604800"); }; const proxyCache = async (request: NextRequest, pathParts: string[]) => { @@ -73,11 +84,21 @@ const proxyCache = async (request: NextRequest, pathParts: string[]) => { stripHopByHopHeaders(outHeaders); outHeaders.delete("content-encoding"); outHeaders.delete("content-length"); - outHeaders.set("cache-control", pickCacheControl(pathParts, upstream.headers.get("content-type") || "", request.method)); + const cacheControl = pickCacheControl(pathParts, upstream.headers.get("content-type") || "", request.method); + outHeaders.set("cache-control", cacheControl); outHeaders.delete("pragma"); outHeaders.delete("expires"); outHeaders.delete("set-cookie"); + if (isStrongCache(cacheControl)) { + const etag = buildStableEtag(pathParts, incomingUrl.search); + outHeaders.set("etag", etag); + const inm = String(request.headers.get("if-none-match") || "").trim(); + if (inm && (inm === etag || inm.split(",").map((s) => s.trim()).includes(etag))) { + return new NextResponse(null, { status: 304, headers: outHeaders }); + } + } + return new NextResponse(upstream.body, { status: upstream.status, headers: outHeaders, diff --git a/wolai-frontend/src/app/onlyoffice-server/[...path]/route.ts b/wolai-frontend/src/app/onlyoffice-server/[...path]/route.ts index 1248a6fa..8f536a6b 100644 --- a/wolai-frontend/src/app/onlyoffice-server/[...path]/route.ts +++ b/wolai-frontend/src/app/onlyoffice-server/[...path]/route.ts @@ -38,7 +38,8 @@ const XHR_REWRITE_SNIPPET = ` // 说明:ONLYOFFICE 在被反向代理(/onlyoffice-server)时,运行期仍可能发起指向内部端口 // http://127.0.0.1:8081/cache/... 的绝对请求(来自 ONLYOFFICE 内部逻辑)。 // 这会导致浏览器从 origin(3000) 跨域请求 8081 并触发 CORS 拦截。 -// 这里在 ONLYOFFICE 页面(含其 iframe)内统一重写 XHR:把内部 8081 的请求改写回同源 /onlyoffice-server/*。 +// 这里在 ONLYOFFICE 页面(含其 iframe)内统一重写 XHR/fetch/window.open/location: +// 把内部 8081 的请求改写回同源 /onlyoffice-server/*。 window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true; (function () { try { @@ -68,10 +69,47 @@ window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true; return u; } } - var origOpen = XMLHttpRequest.prototype.open; - XMLHttpRequest.prototype.open = function (method, url, async, user, password) { - return origOpen.call(this, method, rewrite(url), async, user, password); - }; + + try { + var origOpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function (method, url, async, user, password) { + return origOpen.call(this, method, rewrite(url), async, user, password); + }; + } catch (e) {} + + try { + var origFetch = window.fetch; + if (typeof origFetch === 'function') { + window.fetch = function (input, init) { + try { + if (typeof input === 'string') return origFetch.call(this, rewrite(input), init); + if (input && typeof input === 'object' && typeof input.url === 'string') { + return origFetch.call(this, new Request(rewrite(input.url), input), init); + } + } catch (e) {} + return origFetch.call(this, input, init); + }; + } + } catch (e) {} + + try { + var origWinOpen = window.open; + if (typeof origWinOpen === 'function') { + window.open = function (url, target, features) { + try { + if (typeof url === 'string') url = rewrite(url); + } catch (e) {} + return origWinOpen.call(this, url, target, features); + }; + } + } catch (e) {} + + try { + var origAssign = location.assign && location.assign.bind(location); + if (origAssign) location.assign = function (url) { return origAssign(rewrite(url)); }; + var origReplace = location.replace && location.replace.bind(location); + if (origReplace) location.replace = function (url) { return origReplace(rewrite(url)); }; + } catch (e) {} } catch (e) {} })(); @@ -155,6 +193,21 @@ const pickCacheControl = (pathParts: string[], contentType: string, method: stri return "public, max-age=300, stale-while-revalidate=300"; }; +const buildStableEtag = (pathParts: string[], search: string) => { + // 说明:ONLYOFFICE 的静态资源路径通常包含版本号/哈希(例如 /9.2.1-/sdkjs/...)。 + // 在 frp/隧道场景下,如果浏览器端请求带 `cache-control: no-cache`,会强制走 revalidate; + // 但只要我们提供稳定 ETag,就可以快速返回 304,避免重复下载几十 MB 的资源。 + const p = `/${(pathParts ?? []).join("/")}`; + const s = String(search || ""); + return `W/"mnote-oo:${p}${s}"`; +}; + +const isStaticCacheControl = (cc: string) => { + const v = String(cc || ""); + // 与 pickCacheControl 的“静态资源”分支对齐 + return v.includes("max-age=604800"); +}; + const proxy = async (request: NextRequest, pathParts: string[]) => { const incomingUrl = new URL(request.url); const target = new URL(`${ONLYOFFICE_INTERNAL_URL}/${pathParts.map(encodeURIComponent).join("/")}`); @@ -244,10 +297,23 @@ const proxy = async (request: NextRequest, pathParts: string[]) => { outHeaders.delete("content-length"); const contentType = upstream.headers.get("content-type") || ""; - outHeaders.set("cache-control", pickCacheControl(pathParts, contentType, request.method)); + const cacheControl = pickCacheControl(pathParts, contentType, request.method); + outHeaders.set("cache-control", cacheControl); outHeaders.delete("pragma"); outHeaders.delete("expires"); outHeaders.delete("set-cookie"); + + // 说明:为静态资源提供稳定 ETag,并支持 if-none-match → 304,提升重复打开速度。 + // 注意:仅对我们判定为“强缓存静态资源”的请求启用,避免影响协同接口/动态响应。 + if (isStaticCacheControl(cacheControl)) { + const etag = buildStableEtag(pathParts, incomingUrl.search); + outHeaders.set("etag", etag); + const inm = String(request.headers.get("if-none-match") || "").trim(); + if (inm && (inm === etag || inm.split(",").map((s) => s.trim()).includes(etag))) { + return new NextResponse(null, { status: 304, headers: outHeaders }); + } + } + if (contentType.includes("text/html")) { const html = await upstream.text(); const injected = injectDisableServiceWorker(html); diff --git a/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx b/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx index e21a46fe..50513fd9 100644 --- a/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx +++ b/wolai-frontend/src/app/onlyoffice/OnlyOfficeClientPage.tsx @@ -5,6 +5,8 @@ import { useSearchParams } from "next/navigation"; import { OnlyOfficeAiAgentPanel } from "@/components/onlyoffice/OnlyOfficeAiAgentPanel"; import { rewriteToPublicOrigin } from "@/lib/url/rewritePublicOrigin"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; +import { useConvex } from "convex/react"; +import { api } from "@/lib/convex/api"; type EditorMode = "view" | "edit"; @@ -136,7 +138,8 @@ const base64UrlEncodeUtf8 = (input: string) => { const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUrlDesktop?: string | null) => { // 说明:当我们用 `/onlyoffice-server` 反代 ONLYOFFICE 时,编辑器运行期仍可能发起指向 // `http://127.0.0.1:8081/cache/...` 的绝对请求(来自 ONLYOFFICE 内部),导致浏览器跨域被 CORS 拦截。 - // 这里在 ONLYOFFICE 页面内对 XHR 做一次 URL 重写:把 “内部 8081” 的请求改写回同源 `/onlyoffice-server/*`。 + // 这里在 ONLYOFFICE 页面内对 XHR/fetch/window.open/location.assign 等做 URL 重写: + // 把 “内部 8081” 的请求改写回同源 `/onlyoffice-server/*`。 // 注意:ONLYOFFICE 会创建多个 iframe(同源但不同 realm),因此这里也会周期性给新出现的 iframe 打补丁。 if (typeof window === "undefined") return; if (!baseUrl) return; @@ -193,20 +196,87 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr return input; } }; - const origOpen = (win as any).XMLHttpRequest?.prototype?.open; - if (typeof origOpen !== "function") return; - - (win as any).XMLHttpRequest.prototype.open = function openPatched( - method: string, - url: string, - async?: boolean, - user?: string | null, - password?: string | null, - ) { - const nextUrl = typeof url === "string" ? rewriteUrl(url) : url; - - return origOpen.call(this, method, nextUrl, async, user as any, password as any); - }; + + // 说明:修复 “下载为 xlsx/docx” 等场景:这类下载常通过 window.open/location 跳转, + // 如果 URL 指向 127.0.0.1:8081,会导致浏览器无法下载(因为 8081 只在 Docker 内部/代理后可用)。 + try { + const origWinOpen = (win as any).open; + if (typeof origWinOpen === "function") { + (win as any).open = function openPatched( + url?: string | URL, + target?: string, + features?: string, + ) { + const nextUrl = + typeof url === "string" + ? rewriteUrl(url) + : url instanceof URL + ? new URL(rewriteUrl(url.toString())) + : url; + return origWinOpen.call(this, nextUrl as any, target as any, features as any); + }; + } + } catch { + // ignore + } + + try { + const loc = (win as any).location as Location | undefined; + if (loc && typeof loc.assign === "function") { + const origAssign = loc.assign.bind(loc); + loc.assign = ((url: string) => origAssign(rewriteUrl(url))) as any; + } + if (loc && typeof loc.replace === "function") { + const origReplace = loc.replace.bind(loc); + loc.replace = ((url: string) => origReplace(rewriteUrl(url))) as any; + } + } catch { + // ignore + } + + try { + const origFetch = (win as any).fetch; + if (typeof origFetch === "function") { + (win as any).fetch = function fetchPatched(input: any, init?: any) { + try { + if (typeof input === "string") { + return origFetch.call(this, rewriteUrl(input), init); + } + if (input instanceof URL) { + return origFetch.call(this, new URL(rewriteUrl(input.toString())), init); + } + if (input && typeof input === "object" && typeof input.url === "string") { + const next = new Request(rewriteUrl(input.url), input); + return origFetch.call(this, next, init); + } + } catch { + // ignore + } + return origFetch.call(this, input, init); + }; + } + } catch { + // ignore + } + + try { + const origOpen = (win as any).XMLHttpRequest?.prototype?.open; + if (typeof origOpen === "function") { + (win as any).XMLHttpRequest.prototype.open = function openPatched( + method: string, + url: string, + async?: boolean, + user?: string | null, + password?: string | null, + ) { + const nextUrl = typeof url === "string" ? rewriteUrl(url) : url; + return origOpen.call(this, method, nextUrl, async, user as any, password as any); + }; + } + } catch { + // ignore + } + (win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true; } catch { // ignore @@ -279,10 +349,22 @@ export default function OnlyOfficePage() { const fileType = (params.get("fileType") ?? "docx").toLowerCase(); const mode = (params.get("mode") ?? "edit") as EditorMode; const assetId = params.get("assetId") ?? ""; + const documentId = params.get("documentId") ?? ""; const initialUserId = params.get("userId") ?? ""; const channel = (params.get("channel") ?? "").trim().toLowerCase(); + const convex = useConvex(); const [error, setError] = useState(null); const [authedUserId, setAuthedUserId] = useState(initialUserId); + const [resolvedMode, setResolvedMode] = useState(mode === "view" ? "view" : null); + const [resolvedDisableDownload, setResolvedDisableDownload] = useState(false); + const [resolvedDisableCopy, setResolvedDisableCopy] = useState(false); + const [assetSignedUrl, setAssetSignedUrl] = useState(""); + const [assetStorageId, setAssetStorageId] = useState(""); + const [forceSaveState, setForceSaveState] = useState<{ + busy: boolean; + message: string | null; + ok: boolean | null; + }>({ busy: false, message: null, ok: null }); const runtimeConfig = useMemo(() => getMnoteRuntimeConfig(), []); const baseUrlCandidates = useMemo(() => { const uniq: string[] = []; @@ -332,6 +414,33 @@ export default function OnlyOfficePage() { const proxyOrigin = runtimeConfig.onlyofficeProxyOrigin; const callbackOrigin = runtimeConfig.onlyofficeCallbackOrigin; + useEffect(() => { + // 说明:为了让 document.key 在同一附件的多次打开之间保持稳定(提升缓存命中与加载速度), + // 这里在有 assetId 时拉取一次附件元信息(storage_id)。 + if (!assetId) return; + let canceled = false; + fetch(`/api/media/sign?assetId=${encodeURIComponent(assetId)}`, { method: "GET" }) + .then(async (r) => { + if (!r.ok) return null; + return (await r.json().catch(() => null)) as + | { signedUrl?: string; asset?: { storage_id?: string | null } | null } + | null; + }) + .then((payload) => { + if (!payload || canceled) return; + const signed = String(payload.signedUrl || "").trim(); + const sid = String(payload.asset?.storage_id || "").trim(); + if (signed) setAssetSignedUrl(signed); + if (sid) setAssetStorageId(sid); + }) + .catch(() => { + // ignore + }); + return () => { + canceled = true; + }; + }, [assetId]); + useEffect(() => { // 说明:ONLYOFFICE 回调由文档服务器触发,不携带用户 Cookie。 // 为了让 /api/onlyoffice/callback 能以真实用户身份写回存储, @@ -357,6 +466,52 @@ export default function OnlyOfficePage() { }; }, [authedUserId]); + useEffect(() => { + // 只读权限:强制以 view 模式打开(避免分享页面只读但 ONLYOFFICE 仍可编辑)。 + if (!documentId) { + setResolvedMode(mode === "view" ? "view" : mode); + setResolvedDisableDownload(false); + setResolvedDisableCopy(false); + return; + } + if (!authedUserId) return; + + let canceled = false; + void (async () => { + try { + const perm = await convex.query(api.documents.getPermissionForUser, { + userId: authedUserId, + id: documentId, + }); + if (canceled) return; + const disableDownload = Boolean((perm as any)?.disableDownload); + const disableCopy = Boolean((perm as any)?.disableCopy); + setResolvedDisableDownload(disableDownload); + setResolvedDisableCopy(disableCopy); + + if (mode === "view") { + setResolvedMode("view"); + return; + } + + if (!perm || (perm as any).permission !== "edit") { + setResolvedMode("view"); + } else { + setResolvedMode("edit"); + } + } catch { + if (canceled) return; + setResolvedMode(mode === "view" ? "view" : mode); + setResolvedDisableDownload(false); + setResolvedDisableCopy(false); + } + })(); + + return () => { + canceled = true; + }; + }, [authedUserId, convex, documentId, mode]); + useEffect(() => { // 说明:在部分 ONLYOFFICE 版本/环境下,编辑器内部会触发 DOMException(NotFoundError: removeChild), // 该异常会被 Next.js 捕获并显示“客户端异常”大红屏,但实际文档仍可继续使用。 @@ -470,8 +625,22 @@ export default function OnlyOfficePage() { }, []); const targetDocType = useMemo(() => docTypeFromExt(fileType), [fileType]); + const effectiveFileUrl = assetSignedUrl || fileUrl; + const docKey = useMemo(() => { + // 说明:ONLYOFFICE 的 document.key 会参与其内部缓存/分片路由(部分版本会把它拼进请求参数), + // 如果 key 每次打开都变化,会导致浏览器缓存命中率极低。 + // - 有 assetId:优先用 assetId + storage_id(保存写回后 storage_id 会变化,自然失效) + // - 无 assetId:退回到现有 hash 逻辑 + if (assetId) { + const sid = String(assetStorageId || "").trim(); + // 说明:ONLYOFFICE 的 document.key 允许字符集为 [0-9a-zA-Z_.=-],不包含 ":" 等字符; + // 否则可能报错(例如 errorCode=-23)。这里用 hash 生成安全 key,同时在 storage_id 变化时自动失效。 + return sid ? `${assetId}_${hashKey(sid)}` : assetId; + } + return hashKey(`${effectiveFileUrl}-${fileName}`); + }, [assetId, assetStorageId, effectiveFileUrl, fileName]); const resolvedFileUrl = useMemo(() => { - if (!fileUrl) return ""; + if (!effectiveFileUrl) return ""; // 说明:OnlyOffice 的 document.url 由“文档服务器”拉取(不是浏览器)。 // 如果我们已经配置了专用回源(storageHostOverride),就不要再把 URL 改写成公网, // 否则会把 http://host.docker.internal:18000 错误改成 https://host.docker.internal:18000, @@ -483,7 +652,7 @@ export default function OnlyOfficePage() { // 这类 URL 不应套用 Supabase 的 rewriteToPublicOrigin,否则会被误改写到 supabaseInternalUrl(例如 18000), // 进而导致 ONLYOFFICE 报 “下载失败(-4)”。 try { - const u = new URL(fileUrl); + const u = new URL(effectiveFileUrl); return u.pathname.startsWith("/api/storage/"); } catch { return false; @@ -492,8 +661,8 @@ export default function OnlyOfficePage() { let base = storageHostOverride || runtimeConfig.useConvex || isConvexStorageUrl - ? fileUrl - : rewriteToPublicOrigin(fileUrl, runtimeConfig.supabaseUrl); + ? effectiveFileUrl + : rewriteToPublicOrigin(effectiveFileUrl, runtimeConfig.supabaseUrl); try { // 关键兜底:即使外部传进来的 fileUrl 是 Supabase signedUrl(含 token=...),也要避免 token 参数 // 出现在 document.url 上,否则 ONLYOFFICE 会把它当作 JWT 去解析并报 @@ -548,7 +717,7 @@ export default function OnlyOfficePage() { } catch { return base; } - }, [fileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl]); + }, [effectiveFileUrl, proxyOrigin, storageHostOverride, runtimeConfig.supabaseUrl, runtimeConfig.useConvex]); useEffect(() => { try { @@ -557,24 +726,43 @@ export default function OnlyOfficePage() { baseUrl, proxyOrigin, callbackOrigin, - fileUrlInput: fileUrl, + fileUrlInput: effectiveFileUrl, resolvedFileUrl, fileName, fileType, mode, assetId, + docKey, }; } catch { // ignore } - }, [assetId, authedUserId, baseUrl, callbackOrigin, fileName, fileType, fileUrl, mode, proxyOrigin, resolvedFileUrl]); + }, [ + assetId, + authedUserId, + baseUrl, + callbackOrigin, + docKey, + effectiveFileUrl, + fileName, + fileType, + resolvedMode, + proxyOrigin, + resolvedFileUrl, + ]); useEffect(() => { + // 说明:如果是附件(assetId)编辑模式,则必须拿到真实 userId 才能让 + // /api/onlyoffice/callback 通过工作空间成员校验并把文件写回存储。 + // 否则会出现:编辑器里看似“已保存”,但下载/再次打开仍是旧文件。 + if (mode !== "view" && assetId && !authedUserId) { + return; + } if (!baseUrl) { setError("缺少 NEXT_PUBLIC_ONLYOFFICE_BASE_URL 配置,无法加载编辑器。"); return; } - if (!fileUrl) { + if (!effectiveFileUrl) { setError("缺少 fileUrl 参数。"); return; } @@ -588,7 +776,7 @@ export default function OnlyOfficePage() { const pageHost = window.location.hostname; const isPageLocal = pageHost === "127.0.0.1" || pageHost === "localhost"; const isPageRemote = !isPageLocal; - const u = new URL(fileUrl); + const u = new URL(effectiveFileUrl); const isFileLocal = u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "host.docker.internal"; if (isPageRemote && isFileLocal && !proxyOrigin) { setError( @@ -600,6 +788,9 @@ export default function OnlyOfficePage() { // ignore } + if (!resolvedMode) return; + if (resolvedMode !== "view" && assetId && !authedUserId) return; + const scriptUrl = `${baseUrl.replace(/\/$/, "")}/web-apps/apps/api/documents/api.js`; loadScript(scriptUrl) .then(async () => { @@ -617,7 +808,8 @@ export default function OnlyOfficePage() { title: fileName, url: resolvedFileUrl, // 说明:key 用于 ONLYOFFICE 内部区分文档实例;应随 URL/文件名变化,避免缓存/冲突。 - key: hashKey(`${resolvedFileUrl}-${fileName}`), + // 这里改为“对同一附件尽量稳定”的 key,以提升远端场景的缓存命中与加载速度。 + key: docKey, }, documentType: targetDocType, events: { @@ -655,6 +847,9 @@ export default function OnlyOfficePage() { })(), customization: { feedback: { visible: false }, + // 说明:启用 forcesave 后,用户点击 ONLYOFFICE 的“保存”会触发 status=6 回调, + // 从而立即把最新版本写回主存储(无需等待关闭文档触发 status=2)。 + forcesave: true, }, plugins: { autostart: [MNOTE_AGENT_PLUGIN_GUID], @@ -665,6 +860,24 @@ export default function OnlyOfficePage() { // 兜底:有些版本不会触发 onDocumentReady/onAppReady,这里用轮询判断“编辑器 DOM 已出现” // 来设置 ready flag,保证远程 E2E 判定稳定。 + // 只读权限兜底:即使外部传了 mode=edit,也强制改为 view,且禁用编辑写回。 + try { + config.document = config.document || {}; + config.document.permissions = { + ...(config.document.permissions || {}), + edit: resolvedMode !== "view", + download: !resolvedDisableDownload, + print: !resolvedDisableDownload, + copy: !resolvedDisableCopy, + }; + config.editorConfig = config.editorConfig || {}; + config.editorConfig.mode = resolvedMode === "view" ? "view" : "edit"; + config.editorConfig.customization = config.editorConfig.customization || {}; + config.editorConfig.customization.forcesave = resolvedMode !== "view"; + } catch { + // ignore + } + (window as any).__MNOTE_ONLYOFFICE_READY__ = false; const readyDeadline = Date.now() + 120_000; const timer = window.setInterval(() => { @@ -725,7 +938,58 @@ export default function OnlyOfficePage() { } setError(err.message); }); - }, [baseUrl, baseUrlCandidates.length, baseUrlIndex, fileName, fileType, fileUrl, mode, resolvedFileUrl, targetDocType]); + }, [ + assetId, + authedUserId, + baseUrl, + baseUrlCandidates.length, + baseUrlIndex, + callbackOrigin, + docKey, + effectiveFileUrl, + fileName, + fileType, + mode, + proxyOrigin, + resolvedFileUrl, + targetDocType, + runtimeConfig.onlyofficeBaseUrlDesktop, + ]); + + const canForceSave = resolvedMode === "edit" && Boolean(assetId) && Boolean(docKey); + const triggerForceSave = async () => { + if (!canForceSave) return; + if (forceSaveState.busy) return; + setForceSaveState({ busy: true, message: "正在触发同步保存…", ok: null }); + try { + const url = new URL("/api/onlyoffice/forcesave", window.location.origin); + url.searchParams.set("assetId", assetId); + url.searchParams.set("key", docKey); + const r = await fetch(url.toString(), { method: "POST" }); + if (!r.ok) { + const payload = (await r.json().catch(() => null)) as { error?: string } | null; + throw new Error(payload?.error ?? "触发失败"); + } + setForceSaveState({ busy: false, message: "已触发同步保存:请稍等 1~3 秒后再下载/刷新。", ok: true }); + window.setTimeout(() => { + setForceSaveState((s) => (s.ok ? { ...s, message: null } : s)); + }, 4000); + } catch (e) { + setForceSaveState({ + busy: false, + message: `同步保存失败:${(e as Error).message}`, + ok: false, + }); + } + }; + + if (!error && mode !== "view" && assetId && !authedUserId) { + return ( +
+ 正在获取用户身份,用于写回保存… +
+ ); + } if (error) { return ( @@ -739,9 +1003,27 @@ export default function OnlyOfficePage() { return (
+ {canForceSave && ( +
+ + {forceSaveState.message && ( +
+ {forceSaveState.message} +
+ )} +
+ )} (); const paramId = typeof params?.id === "string" ? params.id : ""; @@ -21,7 +22,10 @@ export function Breadcrumb({ documents }: BreadcrumbProps) { const path = findBreadcrumb(documents, activeId); const showInspector = usePageLayoutStore((state) => state.showInspector); const toggleInspector = usePageLayoutStore((state) => state.toggleInspector); - const isStarred = useQuery(api.documentStars.isStarred, activeId ? { documentId: activeId } : "skip"); + const isStarred = useQuery( + api.documentStars.isStarred, + activeId && isAuthenticated ? { documentId: activeId } : "skip", + ); const toggleStar = useMutation(api.documentStars.toggle); // 新建页面后,侧边栏数据可能还没同步到 layout(SSR)注入的 documents,导致 findBreadcrumb 暂时为空。 @@ -60,7 +64,7 @@ export function Breadcrumb({ documents }: BreadcrumbProps) { type="button" className="hover:bg-wolai-bg-hover hover:text-wolai-text-primary rounded-full px-3 py-1 text-sm transition-colors" onClick={() => void toggleStar({ documentId: activeId })} - disabled={!activeId} + disabled={!activeId || !isAuthenticated} > { window.alert("缺少 ONLYOFFICE 地址,请在 .env.local 配置 NEXT_PUBLIC_ONLYOFFICE_BASE_URL"); return; } + const currentDocReadOnly = useCurrentDocumentStore.getState().readOnly; try { const assetId = await resolveAssetId(); const res = assetId @@ -299,16 +301,33 @@ const MediaBlockContent = ({ block, editor }: any) => { target.searchParams.set("fileUrl", signedUrl); target.searchParams.set("fileName", displayFileName); target.searchParams.set("fileType", extension || "docx"); + const docId = resolveDocumentId(); + if (docId) { + target.searchParams.set("documentId", docId); + } if (assetId) { target.searchParams.set("assetId", assetId); } + target.searchParams.set("mode", currentDocReadOnly ? "view" : "edit"); window.open(target.toString(), "_blank", "noopener,noreferrer"); } catch (error) { window.alert((error as Error).message); } }; + const currentDocumentId = useCurrentDocumentStore((state) => state.documentId); + const currentDisableDownload = useCurrentDocumentStore((state) => state.disableDownload); + const resolvedDocIdForRestriction = resolveDocumentId(); + const downloadDisabled = + Boolean(currentDisableDownload) && + Boolean(resolvedDocIdForRestriction) && + String(currentDocumentId ?? "") === String(resolvedDocIdForRestriction); + const downloadAsset = async () => { + if (downloadDisabled) { + window.alert("该页面已禁止下载"); + return; + } const url = await resolveLatestFileUrl(); if (!url) return; const anchor = document.createElement("a"); @@ -449,8 +468,9 @@ const MediaBlockContent = ({ block, editor }: any) => { type="button" data-testid="wolai-media-file-download" className="inline-flex h-6 w-6 items-center justify-center rounded-[4px] text-[#9B9A97] hover:bg-[#EDEDED] hover:text-[#37352F]" - aria-label="下载" - title="下载" + aria-label={downloadDisabled ? "已禁止下载" : "下载"} + title={downloadDisabled ? "已禁止下载" : "下载"} + disabled={downloadDisabled} onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); @@ -497,11 +517,12 @@ const MediaBlockContent = ({ block, editor }: any) => { {isOfficeDoc && void openWithOnlyOffice()}>使用 ONLYOFFICE 打开} { void downloadAsset(); }} > - 下载到本地 + {downloadDisabled ? "已禁止下载" : "下载到本地"} 删除 @@ -570,14 +591,16 @@ const MediaBlockContent = ({ block, editor }: any) => { icon: , onClick: handleLink, }, - { - key: "download", - label: `下载${typeLabel}`, - icon: , - onClick: () => { - void downloadAsset(); - }, - }, + !downloadDisabled + ? { + key: "download", + label: `下载${typeLabel}`, + icon: , + onClick: () => { + void downloadAsset(); + }, + } + : null, { key: "delete", label: `删除${typeLabel}`, @@ -657,11 +680,12 @@ const MediaBlockContent = ({ block, editor }: any) => { 查看原文件 { void downloadAsset(); }} > - 下载到本地 + {downloadDisabled ? "已禁止下载" : "下载到本地"} {canTriggerOcr && ( <> diff --git a/wolai-frontend/src/components/editor/document-content.tsx b/wolai-frontend/src/components/editor/document-content.tsx index 8180a26d..82f64c33 100644 --- a/wolai-frontend/src/components/editor/document-content.tsx +++ b/wolai-frontend/src/components/editor/document-content.tsx @@ -1,12 +1,13 @@ "use client"; import dynamic from "next/dynamic"; -import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; import type { DocumentStats, PageOptionsState } 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"; 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 type { DocumentSnapshot } from "@/types/document"; @@ -36,6 +37,8 @@ export interface DocumentContentProps { initialStats: DocumentStats | null; openTableId?: string | null; readOnly?: boolean; + disableDownload?: boolean; + disableCopy?: boolean; } const defaultOptions: PageOptionsState = { @@ -59,7 +62,11 @@ export function DocumentContent({ initialStats, openTableId, readOnly = false, + disableDownload = false, + disableCopy = false, }: DocumentContentProps) { + const setCurrentDocument = useCurrentDocumentStore((state) => state.setCurrent); + const clearIfMatch = useCurrentDocumentStore((state) => state.clearIfMatch); const [options, setOptions] = useState(initialOptions ?? defaultOptions); const [stats, setStats] = useState(initialStats ?? defaultStats); const [history, setHistory] = useState([]); @@ -76,6 +83,78 @@ export function DocumentContent({ const contentLoadingTimerRef = useRef | null>(null); const pendingOpenTableRef = useRef(null); const latestBlocksRef = useRef(null); + const pageRootRef = useRef(null); + const lastCopyBlockedAtRef = useRef(0); + + useEffect(() => { + setCurrentDocument(documentId, readOnly, disableDownload, disableCopy); + return () => { + clearIfMatch(documentId); + }; + }, [clearIfMatch, disableCopy, disableDownload, documentId, readOnly, setCurrentDocument]); + + useEffect(() => { + if (!disableCopy) return; + + const isEventInsidePage = () => { + const root = pageRootRef.current; + if (!root) return false; + const selection = typeof window !== "undefined" ? window.getSelection() : null; + const anchor = selection?.anchorNode ?? null; + const focus = selection?.focusNode ?? null; + const anchorEl = + anchor && "nodeType" in anchor && anchor.nodeType === Node.TEXT_NODE + ? anchor.parentElement + : (anchor as any as Element | null); + const focusEl = + focus && "nodeType" in focus && focus.nodeType === Node.TEXT_NODE + ? focus.parentElement + : (focus as any as Element | null); + return Boolean((anchorEl && root.contains(anchorEl)) || (focusEl && root.contains(focusEl))); + }; + + const notifyBlocked = () => { + const now = Date.now(); + if (now - lastCopyBlockedAtRef.current < 1200) return; + lastCopyBlockedAtRef.current = now; + window.alert("该页面已禁止复制"); + }; + + const onCopy = (event: ClipboardEvent) => { + if (!isEventInsidePage()) return; + event.preventDefault(); + event.stopPropagation(); + notifyBlocked(); + }; + + const onCut = (event: ClipboardEvent) => { + if (!isEventInsidePage()) return; + event.preventDefault(); + event.stopPropagation(); + notifyBlocked(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (!isEventInsidePage()) return; + const key = String(event.key ?? "").toLowerCase(); + const ctrlOrMeta = event.ctrlKey || event.metaKey; + if (!ctrlOrMeta) return; + if (key === "c" || key === "x" || key === "insert") { + event.preventDefault(); + event.stopPropagation(); + notifyBlocked(); + } + }; + + document.addEventListener("copy", onCopy, true); + document.addEventListener("cut", onCut, true); + document.addEventListener("keydown", onKeyDown, true); + return () => { + document.removeEventListener("copy", onCopy, true); + document.removeEventListener("cut", onCut, true); + document.removeEventListener("keydown", onKeyDown, true); + }; + }, [disableCopy]); useEffect(() => { const tableId = (openTableId ?? "").trim(); @@ -208,7 +287,7 @@ export function DocumentContent({ void persistTitle(pageTitle); }; - const handleTitleKeyDown = (event: KeyboardEvent) => { + const handleTitleKeyDown = (event: ReactKeyboardEvent) => { if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); @@ -251,6 +330,10 @@ export function DocumentContent({ }, [updatedAt]); const handleExport = useCallback(() => { + if (disableDownload) { + window.alert("该页面已禁止下载"); + return; + } const latest = history[0]; if (!latest) { window.alert("暂无可导出的内容"); @@ -264,7 +347,7 @@ export function DocumentContent({ anchor.download = `${title ?? "未命名页面"}-${new Date(latest.timestamp).toISOString()}.json`; anchor.click(); URL.revokeObjectURL(url); - }, [history, title]); + }, [disableDownload, history, title]); const handleSnapshot = useCallback((payload: { blocks: Json; stats: DocumentStats }) => { latestBlocksRef.current = payload.blocks; @@ -315,7 +398,7 @@ export function DocumentContent({ return ( -
+
diff --git a/wolai-frontend/src/components/groups/group-manager-dialog.tsx b/wolai-frontend/src/components/groups/group-manager-dialog.tsx index abc5a8a0..f5d1f401 100644 --- a/wolai-frontend/src/components/groups/group-manager-dialog.tsx +++ b/wolai-frontend/src/components/groups/group-manager-dialog.tsx @@ -13,6 +13,7 @@ type GroupRow = { name: string; created_by: string; created_at: string; + my_role: "owner" | "member"; }; type MemberRow = { @@ -32,12 +33,42 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan const convex = useConvex(); const createGroup = useMutation(api.groups.create); const removeGroup = useMutation(api.groups.remove); - const inviteByUsername = useMutation(api.groupMembers.inviteByUsername); + const inviteByUsername = useMutation(api.groupInvitations.inviteByUsername); const removeMember = useMutation(api.groupMembers.removeMember); + const listMyInvitations = useCallback(async () => { + const resp = await convex.query(api.groupInvitations.listMine, {}); + const rows = Array.isArray(resp) ? (resp as any[]) : []; + return rows.map((r) => ({ + workspaceId: String(r.workspaceId), + workspaceName: r.workspaceName ? String(r.workspaceName) : null, + groupId: String(r.groupId), + groupName: r.groupName ? String(r.groupName) : null, + invitedByUserId: String(r.invitedByUserId ?? ""), + invitedByUsername: r.invitedByUsername ? String(r.invitedByUsername) : null, + createdAt: String(r.createdAt ?? ""), + updatedAt: String(r.updatedAt ?? ""), + })); + }, [convex]); + const acceptInvite = useMutation(api.groupInvitations.accept); + const declineInvite = useMutation(api.groupInvitations.decline); const [groups, setGroups] = useState([]); const [selectedGroupId, setSelectedGroupId] = useState(""); const [members, setMembers] = useState([]); + const [workspaces, setWorkspaces] = useState>([]); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(workspaceId); + const [invitations, setInvitations] = useState< + Array<{ + workspaceId: string; + workspaceName: string | null; + groupId: string; + groupName: string | null; + invitedByUserId: string; + invitedByUsername: string | null; + createdAt: string; + updatedAt: string; + }> + >([]); const [newGroupName, setNewGroupName] = useState(""); const [inviteUsername, setInviteUsername] = useState(""); @@ -50,18 +81,24 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan ); const loadGroups = useCallback(async () => { - if (!workspaceId) return; - const resp = await convex.query(api.groups.listByWorkspace, { workspaceId }); - const rows = Array.isArray(resp) ? (resp as any[]) : []; + if (!selectedWorkspaceId) return; + const resp = await convex.query(api.groups.listMineByWorkspace, { workspaceId: selectedWorkspaceId }); + const rows = Array.isArray((resp as any)?.groups) ? ((resp as any).groups as any[]) : []; setGroups( rows.map((g) => ({ id: String(g.id), name: String(g.name ?? ""), created_by: String(g.created_by ?? ""), created_at: String(g.created_at ?? ""), + my_role: g.my_role === "owner" ? "owner" : "member", })), ); - }, [convex, workspaceId]); + }, [convex, selectedWorkspaceId]); + + const loadInvitations = useCallback(async () => { + const rows = await listMyInvitations(); + setInvitations(rows); + }, [listMyInvitations]); const loadMembers = useCallback(async (groupId: string) => { if (!groupId) { @@ -86,14 +123,54 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan setLoading(true); void (async () => { try { - await loadGroups(); + const wsResp = await convex.query(api.workspaces.fetchWorkspaceSummaries, {}); + const wsRows = Array.isArray((wsResp as any)?.workspaces) ? ((wsResp as any).workspaces as any[]) : []; + const normalized = wsRows.map((w) => ({ + id: String(w.id), + name: String(w.name ?? ""), + type: String(w.type ?? ""), + })); + setWorkspaces(normalized); + + const desired = + workspaceId && normalized.some((w) => w.id === workspaceId) + ? workspaceId + : typeof (wsResp as any)?.activeWorkspaceId === "string" && (wsResp as any).activeWorkspaceId + ? String((wsResp as any).activeWorkspaceId) + : normalized[0]?.id ?? workspaceId; + setSelectedWorkspaceId(desired); + + await Promise.all([loadInvitations()]); } catch (e: any) { setError(e?.message ?? "加载群组失败"); } finally { setLoading(false); } })(); - }, [loadGroups, open]); + }, [convex, loadInvitations, open, workspaceId]); + + useEffect(() => { + if (!open) return; + if (!selectedWorkspaceId) { + setGroups([]); + setSelectedGroupId(""); + setMembers([]); + return; + } + setError(null); + setLoading(true); + void (async () => { + try { + await loadGroups(); + setSelectedGroupId(""); + setMembers([]); + } catch (e: any) { + setError(e?.message ?? "加载群组失败"); + } finally { + setLoading(false); + } + })(); + }, [loadGroups, open, selectedWorkspaceId]); useEffect(() => { if (!open) return; @@ -113,9 +190,13 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan setError("请输入群组名称"); return; } + if (!selectedWorkspaceId) { + setError("请先选择一个工作空间"); + return; + } setLoading(true); try { - await createGroup({ id: uuidv4(), workspaceId, name }); + await createGroup({ id: uuidv4(), workspaceId: selectedWorkspaceId, name }); setNewGroupName(""); await loadGroups(); } catch (e: any) { @@ -149,6 +230,10 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan setError("请先选择一个群组"); return; } + if (selectedGroup?.my_role !== "owner") { + setError("只有群主可以邀请成员"); + return; + } setError(null); const username = inviteUsername.trim(); if (!username) { @@ -159,7 +244,7 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan try { await inviteByUsername({ groupId: selectedGroupId, username }); setInviteUsername(""); - await loadMembers(selectedGroupId); + await loadInvitations(); } catch (e: any) { setError(e?.message ?? "邀请失败"); } finally { @@ -167,6 +252,34 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan } }; + const handleAcceptInvite = async (inv: { groupId: string; workspaceId: string }) => { + setError(null); + setLoading(true); + try { + await acceptInvite({ groupId: inv.groupId }); + await loadInvitations(); + setSelectedWorkspaceId(inv.workspaceId); + window.alert("已接受邀请:已加入群组与工作空间。共享页面会出现在左侧「共享页面」面板。"); + } catch (e: any) { + setError(e?.message ?? "接受邀请失败"); + } finally { + setLoading(false); + } + }; + + const handleDeclineInvite = async (groupId: string) => { + setError(null); + setLoading(true); + try { + await declineInvite({ groupId }); + await loadInvitations(); + } catch (e: any) { + setError(e?.message ?? "拒绝邀请失败"); + } finally { + setLoading(false); + } + }; + const handleRemoveMember = async (userId: string) => { if (!selectedGroupId) return; if (!window.confirm("确认移除该成员吗?")) return; @@ -184,7 +297,7 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan return ( - + 群组管理 @@ -193,6 +306,78 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
{error}
) : null} +
+
工作空间
+
+
+ +
+
+ 说明:工作空间切换功能暂时停用;这里的选择仅影响“群组管理”的展示与操作范围。 +
+
+
+ +
+
+ 收到的邀请({invitations.length}) +
+
+ {invitations.length === 0 ? ( +
暂无邀请
+ ) : ( +
+ {invitations.map((inv) => ( +
+
+
+ 群组:{inv.groupName ?? inv.groupId} + + 工作空间:{inv.workspaceName ?? inv.workspaceId} + +
+
+ 邀请人:{inv.invitedByUsername ?? inv.invitedByUserId} +
+
+
+ + +
+
+ ))} +
+ )} +
+
+
群组
@@ -213,28 +398,52 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan {groups.length === 0 ? (
暂无群组
) : ( - groups.map((g) => ( -
- - -
- )) + + {g.my_role === "owner" ? ( + + ) : null} +
+ ); + + return ( +
+
我创建的
+ {owned.length === 0 ? ( +
暂无
+ ) : ( + owned.map(renderGroupRow) + )} +
我加入的
+ {joined.length === 0 ? ( +
暂无
+ ) : ( + joined.map(renderGroupRow) + )} +
+ ); + })() )}
@@ -250,12 +459,15 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan value={inviteUsername} onChange={(e) => setInviteUsername(e.target.value)} placeholder="输入用户名邀请" - disabled={loading} + disabled={loading || !selectedGroupId || selectedGroup?.my_role !== "owner"} />
+ {selectedGroupId && selectedGroup?.my_role !== "owner" ? ( +
提示:只有群主可以邀请成员
+ ) : null}
{!selectedGroupId ? ( @@ -301,4 +513,3 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan ); } - diff --git a/wolai-frontend/src/components/providers/convex-provider.tsx b/wolai-frontend/src/components/providers/convex-provider.tsx index 1b31818b..d85c8e4c 100644 --- a/wolai-frontend/src/components/providers/convex-provider.tsx +++ b/wolai-frontend/src/components/providers/convex-provider.tsx @@ -54,9 +54,13 @@ export function ConvexClientProvider({ children }: ConvexClientProviderProps) { } // 未配置 env:使用当前主机名,端口固定 3210,并跟随当前页面协议(http/https)。 + // https 场景下浏览器禁止 ws://,因此默认走同源反代 `/convex`(需要 server 支持 Upgrade 透传)。 + if (browserProtocol === "https:") { + return normalize(`${window.location.origin}${convexProxyPath}`); + } + const hostname = window.location.hostname; - const protocol = "http:"; - return normalize(`${protocol}//${hostname}:3210`); + return normalize(`http://${hostname}:3210`); }; const convexUrl = getConvexUrl(); diff --git a/wolai-frontend/src/components/search/search-palette.tsx b/wolai-frontend/src/components/search/search-palette.tsx index d45d74bc..83cb9fad 100644 --- a/wolai-frontend/src/components/search/search-palette.tsx +++ b/wolai-frontend/src/components/search/search-palette.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { DragEvent as ReactDragEvent } from "react"; import { useRouter, useSelectedLayoutSegments } from "next/navigation"; import { CalendarClock, ChevronsUpDown, Copy, Layers, Search } from "lucide-react"; -import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -249,9 +249,12 @@ export function SearchPalette({ workspaceId }: SearchPaletteProps) { return ( !next && close()}> - + {dialogTitle} -
+ + 输入关键词在当前工作区搜索页面标题、正文、思维导图、表格内容与附件文件名;可勾选“搜索附件内容”以纳入附件 OCR/解析文本。 + +
@@ -266,7 +269,7 @@ export function SearchPalette({ workspaceId }: SearchPaletteProps) { } setQuery(nextValue); }} - placeholder={mode === "search" ? "搜索页面标题、正文或 OCR 内容..." : "选择要引用的页面"} + placeholder={mode === "search" ? "搜索页面标题、正文或附件文件名..." : "选择要引用的页面"} className="h-11 w-full rounded-xl border border-[#e2e8f0] bg-white pl-9 text-sm" />
@@ -321,7 +324,7 @@ export function SearchPalette({ workspaceId }: SearchPaletteProps) { onClick={() => toggleFilter("onlyCurrentPage")} /> toggleFilter("includeOcr")} /> @@ -569,7 +572,7 @@ function ResultRow({ return ( {content} - + diff --git a/wolai-frontend/src/components/sharing/document-share-dialog.tsx b/wolai-frontend/src/components/sharing/document-share-dialog.tsx index 31368586..913eadc1 100644 --- a/wolai-frontend/src/components/sharing/document-share-dialog.tsx +++ b/wolai-frontend/src/components/sharing/document-share-dialog.tsx @@ -32,6 +32,8 @@ export function DocumentShareDialog({ const [username, setUsername] = useState(""); const [permission, setPermission] = useState("read"); const [includeDescendants, setIncludeDescendants] = useState(false); + const [disableDownload, setDisableDownload] = useState(false); + const [disableCopy, setDisableCopy] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [shares, setShares] = useState(null); @@ -40,6 +42,8 @@ export function DocumentShareDialog({ const [groupShares, setGroupShares] = useState(null); const [selectedGroupId, setSelectedGroupId] = useState(""); const [groupIncludeDescendants, setGroupIncludeDescendants] = useState(false); + const [groupDisableDownload, setGroupDisableDownload] = useState(false); + const [groupDisableCopy, setGroupDisableCopy] = useState(false); const [groupMembers, setGroupMembers] = useState>( [], ); @@ -121,6 +125,8 @@ export function DocumentShareDialog({ setUsername(""); setPermission("read"); setIncludeDescendants(false); + setDisableDownload(false); + setDisableCopy(false); setSubmitting(false); setError(null); setShares(null); @@ -129,6 +135,8 @@ export function DocumentShareDialog({ setGroupShares(null); setSelectedGroupId(""); setGroupIncludeDescendants(false); + setGroupDisableDownload(false); + setGroupDisableCopy(false); setGroupMembers([]); setGroupEditableUserIds(new Set()); return; @@ -150,10 +158,14 @@ export function DocumentShareDialog({ const existing = rows.find((r: any) => String(r.groupId) === String(selectedGroupId)); if (existing) { setGroupIncludeDescendants(Boolean(existing.includeDescendants)); + setGroupDisableDownload(Boolean(existing.disableDownload)); + setGroupDisableCopy(Boolean(existing.disableCopy)); const editable = new Set(Array.isArray(existing.editableUserIds) ? existing.editableUserIds.map(String) : []); setGroupEditableUserIds(editable); } else { setGroupIncludeDescendants(false); + setGroupDisableDownload(false); + setGroupDisableCopy(false); setGroupEditableUserIds(new Set()); } }, [groupShares, open, selectedGroupId]); @@ -177,12 +189,40 @@ export function DocumentShareDialog({ username: u, permission, includeDescendants: allowIncludeDescendants ? includeDescendants : false, + disableDownload, + disableCopy, }); setUsername(""); await loadShares(); await onChanged?.(); } catch (e: any) { - setError(e?.message ?? "共享失败,请重试"); + const msg = e?.message ?? "共享失败,请重试"; + if ( + String(msg).includes("ArgumentValidationError") && + (String(msg).includes("disableCopy") || String(msg).includes("disableDownload")) + ) { + // 兼容旧后端:先用旧参数重试,避免用户完全无法共享。 + try { + await upsertShare({ + documentId, + username: u, + permission, + includeDescendants: allowIncludeDescendants ? includeDescendants : false, + } as any); + setUsername(""); + await loadShares(); + await onChanged?.(); + setError( + "已共享,但“禁止下载/禁止复制”未生效(当前 Convex 后端未更新)。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后再设置限制。", + ); + } catch { + setError( + "共享功能已升级(新增“禁止下载/禁止复制”),但当前 Convex 后端未更新。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后刷新页面再试。", + ); + } + } else { + setError(msg); + } } finally { setSubmitting(false); } @@ -216,11 +256,37 @@ export function DocumentShareDialog({ groupId, includeDescendants: allowIncludeDescendants ? groupIncludeDescendants : false, editableUserIds: Array.from(groupEditableUserIds), + disableDownload: groupDisableDownload, + disableCopy: groupDisableCopy, }); await loadGroupShares(); await onChanged?.(); } catch (e: any) { - setError(e?.message ?? "公开失败,请重试"); + const msg = e?.message ?? "公开失败,请重试"; + if ( + String(msg).includes("ArgumentValidationError") && + (String(msg).includes("disableCopy") || String(msg).includes("disableDownload")) + ) { + try { + await upsertGroupShare({ + documentId, + groupId, + includeDescendants: allowIncludeDescendants ? groupIncludeDescendants : false, + editableUserIds: Array.from(groupEditableUserIds), + } as any); + await loadGroupShares(); + await onChanged?.(); + setError( + "已公开,但“禁止下载/禁止复制”未生效(当前 Convex 后端未更新)。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后再设置限制。", + ); + } catch { + setError( + "共享功能已升级(新增“禁止下载/禁止复制”),但当前 Convex 后端未更新。请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local` 后刷新页面再试。", + ); + } + } else { + setError(msg); + } } finally { setSubmitting(false); } @@ -294,6 +360,24 @@ export function DocumentShareDialog({ 包含子页面(共享文件夹) )} + +
@@ -325,6 +409,24 @@ export function DocumentShareDialog({ 包含子页面(公开文件夹) )} + + @@ -396,6 +498,14 @@ export function DocumentShareDialog({ {r.includeDescendants ? "包含子页面" : "仅当前页面"} {" · "} 可编辑:{Array.isArray(r.editableUserIds) ? r.editableUserIds.length : 0} 人 + {(r.disableDownload || r.disableCopy) ? ( + <> + {" · "} + {r.disableDownload ? "禁止下载" : ""} + {r.disableDownload && r.disableCopy ? " / " : ""} + {r.disableCopy ? "禁止复制" : ""} + + ) : null}
- {workspaceMenuOpen && ( -
- {sidebarData.workspaces.map((workspace) => ( - - ))} -
- -
-
- )} -
+ +
+
+ 提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。 +
+
+
@@ -2067,6 +2065,70 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { }; if (topPanel === "shared") { + const groupByWorkspace = ( + rows: Array<{ + workspaceId: string; + workspaceName: string | null; + documentId: string; + documentTitle: string | null; + includeDescendants: boolean; + permission?: "read" | "edit"; + sharedWithCount?: number; + }>, + ) => { + const map = new Map(); + for (const r of rows) { + const existing = map.get(r.workspaceId); + if (!existing) { + map.set(r.workspaceId, { workspaceName: r.workspaceName ?? null, rows: [r] }); + } else { + existing.rows.push(r); + } + } + return Array.from(map.entries()).map(([workspaceId, v]) => ({ + workspaceId, + workspaceName: v.workspaceName, + rows: v.rows, + })); + }; + + const renderShareRows = (rows: Array) => { + if (!rows || rows.length === 0) { + return
暂无内容
; + } + const groups = groupByWorkspace(rows); + return ( +
+ {groups.map((g) => ( +
+
+ {g.workspaceName ?? g.workspaceId} +
+
+ {g.rows.map((r) => ( + + {r.documentTitle || "无标题"} + {typeof r.permission === "string" ? ( + + {r.permission === "edit" ? "可编辑" : "只读"} + + ) : null} + {typeof r.sharedWithCount === "number" ? ( + {r.sharedWithCount} 人 + ) : null} + + ))} +
+
+ ))} +
+ ); + }; + return (
{shareSummaryError ? ( @@ -2077,14 +2139,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
共享给我的({shareSummary?.incoming?.length ?? 0})
- {renderList(sharedNodes)} + {renderShareRows(shareSummary?.incoming ?? [])}
我共享出去的({shareSummary?.outgoing?.length ?? 0})
- {renderList(outgoingSharedRootNodes)} + {renderShareRows(shareSummary?.outgoing ?? [])}
); @@ -2334,13 +2396,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) { }} /> )} - {sidebarData.activeWorkspaceId ? ( - - ) : null} + { diff --git a/wolai-frontend/src/lib/auth/errors.test.ts b/wolai-frontend/src/lib/auth/errors.test.ts new file mode 100644 index 00000000..6afaf5db --- /dev/null +++ b/wolai-frontend/src/lib/auth/errors.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { getUserFacingErrorMessage } from "./errors"; + +describe("getUserFacingErrorMessage", () => { + it("在 message 很短时直接返回", () => { + expect(getUserFacingErrorMessage(new Error("未登录"), "fallback")).toBe("未登录"); + }); + + it("能从 Convex 的长错误中提取 Uncaught Error 之后的消息", () => { + const err = new Error( + "[CONVEX M(users:setMyUsername)] [Request ID: xxx] Server Error Uncaught Error: 未登录 at handler (./convex/users.ts:41:10) Called by client", + ); + expect(getUserFacingErrorMessage(err, "fallback")).toBe("未登录"); + }); + + it("能从包含 Error: 的错误中提取关键信息", () => { + const err = new Error("Server Error Error: 用户名已被占用 at handler (./convex/users.ts:48:7)"); + expect(getUserFacingErrorMessage(err, "fallback")).toBe("用户名已被占用"); + }); + + it("字符串错误也能直接展示", () => { + expect(getUserFacingErrorMessage(" 保存失败 ", "fallback")).toBe("保存失败"); + }); + + it("无法解析时返回 fallback", () => { + expect(getUserFacingErrorMessage({ foo: "bar" }, "fallback")).toBe("fallback"); + }); +}); diff --git a/wolai-frontend/src/lib/auth/errors.ts b/wolai-frontend/src/lib/auth/errors.ts new file mode 100644 index 00000000..d5e86f54 --- /dev/null +++ b/wolai-frontend/src/lib/auth/errors.ts @@ -0,0 +1,23 @@ +/** + * 将各种错误对象(尤其是 Convex Client 抛出的长错误)转换为更适合展示给用户的短消息。 + */ +export function getUserFacingErrorMessage(err: unknown, fallback: string): string { + if (!err) return fallback; + if (typeof err === "string") return err.trim() || fallback; + + const record = typeof err === "object" && err !== null ? (err as Record) : null; + const raw = typeof record?.message === "string" ? String(record.message) : ""; + const oneLine = raw.split("\n")[0]?.trim() ?? ""; + + // Convex 有时会把服务端堆栈拼进 message 里,形如: + // "... Server Error Uncaught Error: 未登录 at handler (...)",这里尽量只取“未登录”。 + const uncaught = raw.match(/Uncaught Error:\s*([^\n]+?)(?:\s+at\s|$)/i); + if (uncaught?.[1]) return uncaught[1].trim(); + + const plainError = raw.match(/Error:\s*([^\n]+?)(?:\s+at\s|$)/i); + if (plainError?.[1]) return plainError[1].trim(); + + if (oneLine) return oneLine; + + return fallback; +} diff --git a/wolai-frontend/src/store/current-document.ts b/wolai-frontend/src/store/current-document.ts new file mode 100644 index 00000000..b0895a8c --- /dev/null +++ b/wolai-frontend/src/store/current-document.ts @@ -0,0 +1,27 @@ +"use client"; + +import { create } from "zustand"; + +interface CurrentDocumentState { + documentId: string | null; + readOnly: boolean; + disableDownload: boolean; + disableCopy: boolean; + setCurrent: (documentId: string, readOnly: boolean, disableDownload: boolean, disableCopy: boolean) => void; + clearIfMatch: (documentId: string) => void; +} + +export const useCurrentDocumentStore = create((set, get) => ({ + documentId: null, + readOnly: false, + disableDownload: false, + disableCopy: false, + setCurrent: (documentId, readOnly, disableDownload, disableCopy) => + set({ documentId, readOnly, disableDownload, disableCopy }), + clearIfMatch: (documentId) => { + const state = get(); + if (state.documentId === documentId) { + set({ documentId: null, readOnly: false, disableDownload: false, disableCopy: false }); + } + }, +}));