0.3.5 共享功能修复
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
+4
@@ -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;
|
||||
|
||||
@@ -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(/<w:p[\s>]/g);
|
||||
const out: string[] = [];
|
||||
for (const p of paras) {
|
||||
const line: string[] = [];
|
||||
const re = /<w:t[^>]*>([\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 = /<t[^>]*>([\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(/<row[\s>]/g);
|
||||
for (const r of rows) {
|
||||
const cells: string[] = [];
|
||||
|
||||
// t="s" => sharedStrings index;t="inlineStr" => <is><t>...;默认 => <v>number
|
||||
const cellRe = /<c\b[^>]*?(?: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 = /<v>([\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<string, unknown>;
|
||||
};
|
||||
export type AttachmentExtractResult =
|
||||
| AttachmentExtractOk
|
||||
| { ok: false; strategy: string; reason: string; meta?: Record<string, unknown> };
|
||||
|
||||
export async function extractTextFromAttachment(args: {
|
||||
mimeType: string | null;
|
||||
fileName: string | null;
|
||||
bytes: ArrayBuffer;
|
||||
}): Promise<AttachmentExtractResult> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string | null>();
|
||||
const getWorkspaceName = async (workspaceId: string): Promise<string | null> => {
|
||||
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 };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
|
||||
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<SharePermission | null> {
|
||||
async function resolveSharePolicy(ctx: any, doc: any, userId: string): Promise<SharePolicy | null> {
|
||||
const direct = await ctx.db
|
||||
.query("document_shares")
|
||||
.withIndex("by_doc_user", (q: any) => q.eq("document_id", doc.id).eq("shared_with_user_id", userId))
|
||||
.first();
|
||||
if (direct) {
|
||||
return direct.permission as SharePermission;
|
||||
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<SharePermission | null> {
|
||||
async function resolveSharePermission(ctx: any, doc: any, userId: string): Promise<SharePermission | null> {
|
||||
const policy = await resolveSharePolicy(ctx, doc, userId);
|
||||
return policy?.permission ?? null;
|
||||
}
|
||||
|
||||
async function resolveGroupSharePolicy(ctx: any, doc: any, userId: string): Promise<SharePolicy | null> {
|
||||
const memberships = await ctx.db
|
||||
.query("group_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", doc.workspace_id).eq("user_id", userId))
|
||||
@@ -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";
|
||||
}
|
||||
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<SharePermission | null> {
|
||||
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<string>();
|
||||
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<string>(groupMemberships.map((m: any) => String(m.group_id)));
|
||||
|
||||
const directGroupShares = new Map<string, { includeDescendants: boolean }>();
|
||||
if (groupIds.size > 0) {
|
||||
for (const gid of groupIds) {
|
||||
const rows = await ctx.db
|
||||
.query("document_group_shares")
|
||||
.withIndex("by_workspace_group", (q: any) => q.eq("workspace_id", args.workspaceId).eq("group_id", gid))
|
||||
.collect();
|
||||
for (const r of rows) {
|
||||
const existing = directGroupShares.get(r.document_id);
|
||||
if (!existing) {
|
||||
directGroupShares.set(r.document_id, { includeDescendants: Boolean(r.include_descendants) });
|
||||
} else {
|
||||
existing.includeDescendants = existing.includeDescendants || Boolean(r.include_descendants);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parentById = new Map<string, string | null>();
|
||||
for (const d of alive) {
|
||||
parentById.set(d.id, d.parent_id ?? null);
|
||||
}
|
||||
|
||||
const shareAccessCache = new Map<string, boolean>();
|
||||
const canAccessByShare = (docId: string): boolean => {
|
||||
const cached = shareAccessCache.get(docId);
|
||||
if (typeof cached === "boolean") return cached;
|
||||
if (directShares.has(docId)) {
|
||||
shareAccessCache.set(docId, true);
|
||||
return true;
|
||||
}
|
||||
let parentId = parentById.get(docId) ?? null;
|
||||
for (let depth = 0; depth < 60 && parentId; depth += 1) {
|
||||
const parentShare = directShares.get(parentId);
|
||||
if (parentShare && parentShare.includeDescendants) {
|
||||
shareAccessCache.set(docId, true);
|
||||
return true;
|
||||
}
|
||||
parentId = parentById.get(parentId) ?? null;
|
||||
}
|
||||
shareAccessCache.set(docId, false);
|
||||
return false;
|
||||
};
|
||||
|
||||
const groupAccessCache = new Map<string, boolean>();
|
||||
const canAccessByGroupShare = (docId: string): boolean => {
|
||||
const cached = groupAccessCache.get(docId);
|
||||
if (typeof cached === "boolean") return cached;
|
||||
if (directGroupShares.has(docId)) {
|
||||
groupAccessCache.set(docId, true);
|
||||
return true;
|
||||
}
|
||||
let parentId = parentById.get(docId) ?? null;
|
||||
for (let depth = 0; depth < 60 && parentId; depth += 1) {
|
||||
const parentShare = directGroupShares.get(parentId);
|
||||
if (parentShare && parentShare.includeDescendants) {
|
||||
groupAccessCache.set(docId, true);
|
||||
return true;
|
||||
}
|
||||
parentId = parentById.get(parentId) ?? null;
|
||||
}
|
||||
groupAccessCache.set(docId, false);
|
||||
return false;
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<string> {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (userId === null) throw new Error("未登录");
|
||||
return String(userId);
|
||||
}
|
||||
|
||||
async function requireWorkspaceMember(ctx: any, workspaceId: string, userId: string) {
|
||||
const member = await ctx.db
|
||||
.query("workspace_members")
|
||||
.withIndex("by_workspace_user", (q: any) => q.eq("workspace_id", workspaceId).eq("user_id", userId))
|
||||
.first();
|
||||
if (!member) throw new Error("无权限");
|
||||
return member;
|
||||
}
|
||||
|
||||
async function 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<string> {
|
||||
const username = normalizeUsername(rawUsername);
|
||||
const user = await ctx.db
|
||||
.query("users")
|
||||
.filter((q: any) => q.eq(q.field("name"), username))
|
||||
.first();
|
||||
if (!user) throw new Error("未找到该用户");
|
||||
return String(user._id);
|
||||
}
|
||||
|
||||
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 };
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
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<string, any>();
|
||||
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 };
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+129
@@ -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: {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string | null>(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,14 +104,12 @@ export default function AuthPage() {
|
||||
|
||||
if (result.signingIn) {
|
||||
if (flow === "signUp") {
|
||||
try {
|
||||
await setMyUsername({ username });
|
||||
} catch (e: any) {
|
||||
// 说明:注册已成功,但用户名可能重复;此时保持登录状态,转入“设置用户名”步骤继续处理。
|
||||
setMessage({ type: "error", text: e?.message ?? "用户名设置失败,请重试" });
|
||||
// 说明:注册已成功,但此刻登录态可能尚未同步到 Convex functions。
|
||||
// 这里先进入“待写入用户名”状态,等 currentUser 可用后再写,避免出现“未登录”的竞态。
|
||||
setMessage({ type: "info", text: "注册成功,正在保存用户名..." });
|
||||
setPendingUsernameToSave(username.trim());
|
||||
return;
|
||||
}
|
||||
}
|
||||
setMessage({ type: "success", text: flow === "signIn" ? "登录成功!" : "注册成功!" });
|
||||
setTimeout(() => router.push("/"), 500);
|
||||
return;
|
||||
@@ -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<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -134,12 +169,12 @@ export default function AuthPage() {
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
if (currentUser === undefined) {
|
||||
if (currentUser === undefined || currentUser === null) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">加载中...</p>
|
||||
<p className="text-gray-600">正在同步登录状态...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -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() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveUsername()}
|
||||
disabled={isSavingUsername}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
保存并继续
|
||||
{isSavingUsername ? "保存中..." : "保存并继续"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +34,54 @@ const TIME_FIELD_COLUMN = {
|
||||
created: "created_at",
|
||||
} as const;
|
||||
|
||||
type RangeIso = { fromIso: string | null; toIso: string | null };
|
||||
|
||||
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 });
|
||||
}
|
||||
@@ -55,7 +103,7 @@ export async function POST(request: Request) {
|
||||
const normalizedQuery = payload.query?.trim() ?? "";
|
||||
const normalizedLower = normalizedQuery.toLowerCase();
|
||||
|
||||
const docs = await client.query(api.documents.listByWorkspace, { workspaceId });
|
||||
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, {
|
||||
@@ -89,8 +137,9 @@ export async function POST(request: Request) {
|
||||
typeof timeRangeMs === "number"
|
||||
? new Date(Date.now() - timeRangeMs).toISOString()
|
||||
: null;
|
||||
const { fromIso, toIso } = resolveCustomRangeIso(filters);
|
||||
|
||||
const narrowed = [...docs]
|
||||
const eligibleDocs = [...docs]
|
||||
.sort((a, b) => {
|
||||
const ta = a.updated_at ?? a.created_at ?? "";
|
||||
const tb = b.updated_at ?? b.created_at ?? "";
|
||||
@@ -107,26 +156,267 @@ export async function POST(request: Request) {
|
||||
if (ts < boundaryIso) return false;
|
||||
}
|
||||
|
||||
const title = (row.title ?? "无标题").toLowerCase();
|
||||
if (filters.exact) {
|
||||
return title === normalizedLower;
|
||||
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 title.includes(normalizedLower);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({
|
||||
const eligibleDocIds = new Set(eligibleDocs.map((d) => d.id));
|
||||
|
||||
type MatchInfo = {
|
||||
score: number;
|
||||
matchField: "title" | "content";
|
||||
snippet: string;
|
||||
hasOcr: boolean;
|
||||
};
|
||||
|
||||
const matches = new Map<string, MatchInfo>();
|
||||
|
||||
const upsertMatch = (docId: string, patch: Partial<MatchInfo>) => {
|
||||
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<string, string>();
|
||||
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: buildSnippet(row.title ?? "", normalizedQuery),
|
||||
snippet: match.snippet,
|
||||
updatedAt: row.updated_at ?? null,
|
||||
createdAt: row.created_at ?? null,
|
||||
matchField: "title",
|
||||
hasOcr: false,
|
||||
matchField: match.matchField,
|
||||
hasOcr: match.hasOcr,
|
||||
publicPath: `/documents/${row.id}`,
|
||||
score: 2,
|
||||
}));
|
||||
score: match.score,
|
||||
});
|
||||
}
|
||||
|
||||
const response: DocumentSearchResponse = { results, recent };
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-3
@@ -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,
|
||||
|
||||
@@ -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,11 +69,48 @@ window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
|
||||
return u;
|
||||
}
|
||||
}
|
||||
|
||||
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) {}
|
||||
})();
|
||||
</script>
|
||||
`.trim();
|
||||
@@ -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-<hash>/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);
|
||||
|
||||
@@ -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,9 +196,72 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
|
||||
return input;
|
||||
}
|
||||
};
|
||||
const origOpen = (win as any).XMLHttpRequest?.prototype?.open;
|
||||
if (typeof origOpen !== "function") return;
|
||||
|
||||
// 说明:修复 “下载为 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,
|
||||
@@ -204,9 +270,13 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
|
||||
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<string | null>(null);
|
||||
const [authedUserId, setAuthedUserId] = useState<string>(initialUserId);
|
||||
const [resolvedMode, setResolvedMode] = useState<EditorMode | null>(mode === "view" ? "view" : null);
|
||||
const [resolvedDisableDownload, setResolvedDisableDownload] = useState(false);
|
||||
const [resolvedDisableCopy, setResolvedDisableCopy] = useState(false);
|
||||
const [assetSignedUrl, setAssetSignedUrl] = useState<string>("");
|
||||
const [assetStorageId, setAssetStorageId] = useState<string>("");
|
||||
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 (
|
||||
<div className="flex h-screen items-center justify-center bg-slate-50 text-sm text-gray-600">
|
||||
正在获取用户身份,用于写回保存…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -739,9 +1003,27 @@ export default function OnlyOfficePage() {
|
||||
return (
|
||||
<div className="relative h-screen w-screen bg-slate-50">
|
||||
<div id="onlyoffice-frame" className="h-full w-full" />
|
||||
{canForceSave && (
|
||||
<div className="pointer-events-none absolute right-3 top-3 z-50 flex flex-col items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto rounded-md bg-white/90 px-3 py-2 text-xs text-gray-700 shadow-sm ring-1 ring-gray-200 hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={forceSaveState.busy}
|
||||
onClick={() => void triggerForceSave()}
|
||||
title="不依赖 ONLYOFFICE 内置保存按钮,直接触发 forcesave 写回主存储"
|
||||
>
|
||||
{forceSaveState.busy ? "同步保存中…" : "同步保存"}
|
||||
</button>
|
||||
{forceSaveState.message && (
|
||||
<div className="pointer-events-none max-w-[320px] rounded-md bg-black/70 px-3 py-2 text-xs text-white">
|
||||
{forceSaveState.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<OnlyOfficeAiAgentPanel
|
||||
openFile={{
|
||||
id: assetId || `onlyoffice_${hashKey(`${resolvedFileUrl}-${fileName}`)}`,
|
||||
id: assetId || `onlyoffice_${docKey || hashKey(`${resolvedFileUrl}-${fileName}`)}`,
|
||||
title: fileName,
|
||||
fileUrl: resolvedFileUrl,
|
||||
mimeType: null,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useParams, useSelectedLayoutSegments } from "next/navigation";
|
||||
import { ChevronRight, MoreHorizontal, Star } from "lucide-react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { useConvexAuth, useMutation, useQuery } from "convex/react";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import { findBreadcrumb } from "@/lib/documents";
|
||||
import { usePageLayoutStore } from "@/store/page-layout";
|
||||
@@ -14,6 +14,7 @@ interface BreadcrumbProps {
|
||||
}
|
||||
|
||||
export function Breadcrumb({ documents }: BreadcrumbProps) {
|
||||
const { isAuthenticated } = useConvexAuth();
|
||||
const segments = useSelectedLayoutSegments();
|
||||
const params = useParams<{ id?: string }>();
|
||||
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}
|
||||
>
|
||||
<Star
|
||||
className={`mr-1 inline h-4 w-4 ${isStarred ? "text-[#f5a623]" : ""}`}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useImagePicker } from "@/components/media/image-picker-context";
|
||||
import type { MediaKind } from "@/types/media";
|
||||
import { emitAssetsChanged } from "@/lib/events";
|
||||
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -281,6 +282,7 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
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) => {
|
||||
</DropdownMenuItem>
|
||||
{isOfficeDoc && <DropdownMenuItem onClick={() => void openWithOnlyOffice()}>使用 ONLYOFFICE 打开</DropdownMenuItem>}
|
||||
<DropdownMenuItem
|
||||
disabled={downloadDisabled}
|
||||
onClick={() => {
|
||||
void downloadAsset();
|
||||
}}
|
||||
>
|
||||
下载到本地
|
||||
{downloadDisabled ? "已禁止下载" : "下载到本地"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleDeleteAsset}>删除</DropdownMenuItem>
|
||||
@@ -570,14 +591,16 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
icon: <LinkIcon className="h-4 w-4" />,
|
||||
onClick: handleLink,
|
||||
},
|
||||
{
|
||||
!downloadDisabled
|
||||
? {
|
||||
key: "download",
|
||||
label: `下载${typeLabel}`,
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
void downloadAsset();
|
||||
},
|
||||
},
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "delete",
|
||||
label: `删除${typeLabel}`,
|
||||
@@ -657,11 +680,12 @@ const MediaBlockContent = ({ block, editor }: any) => {
|
||||
查看原文件
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={downloadDisabled}
|
||||
onClick={() => {
|
||||
void downloadAsset();
|
||||
}}
|
||||
>
|
||||
下载到本地
|
||||
{downloadDisabled ? "已禁止下载" : "下载到本地"}
|
||||
</DropdownMenuItem>
|
||||
{canTriggerOcr && (
|
||||
<>
|
||||
|
||||
@@ -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<PageOptionsState>(initialOptions ?? defaultOptions);
|
||||
const [stats, setStats] = useState<DocumentStats>(initialStats ?? defaultStats);
|
||||
const [history, setHistory] = useState<DocumentSnapshot[]>([]);
|
||||
@@ -76,6 +83,78 @@ export function DocumentContent({
|
||||
const contentLoadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingOpenTableRef = useRef<string | null>(null);
|
||||
const latestBlocksRef = useRef<Json | null>(null);
|
||||
const pageRootRef = useRef<HTMLDivElement>(null);
|
||||
const lastCopyBlockedAtRef = useRef<number>(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<HTMLInputElement>) => {
|
||||
const handleTitleKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<ImagePickerProvider documentId={documentId} workspaceId={workspaceId}>
|
||||
<div className="flex h-full overflow-hidden bg-wolai-bg">
|
||||
<div className="flex h-full overflow-hidden bg-wolai-bg" ref={pageRootRef}>
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<div className="border-b border-wolai-border px-12 pb-6 pt-8">
|
||||
<div className="relative">
|
||||
|
||||
@@ -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<GroupRow[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>("");
|
||||
const [members, setMembers] = useState<MemberRow[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<Array<{ id: string; name: string; type: string }>>([]);
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogContent className="sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>群组管理</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -193,6 +306,78 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
<div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-md border border-gray-200">
|
||||
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">工作空间</div>
|
||||
<div className="p-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<select
|
||||
className="h-10 min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700"
|
||||
value={selectedWorkspaceId}
|
||||
onChange={(e) => setSelectedWorkspaceId(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{workspaces.length === 0 ? (
|
||||
<option value={selectedWorkspaceId || ""}>暂无工作空间</option>
|
||||
) : (
|
||||
workspaces.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}({w.type === "team" ? "团队" : "个人"})
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
说明:工作空间切换功能暂时停用;这里的选择仅影响“群组管理”的展示与操作范围。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-gray-200">
|
||||
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">
|
||||
收到的邀请({invitations.length})
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{invitations.length === 0 ? (
|
||||
<div className="text-sm text-gray-400">暂无邀请</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{invitations.map((inv) => (
|
||||
<div
|
||||
key={`${inv.workspaceId}:${inv.groupId}`}
|
||||
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate">
|
||||
群组:{inv.groupName ?? inv.groupId}
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
工作空间:{inv.workspaceName ?? inv.workspaceId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-gray-400">
|
||||
邀请人:{inv.invitedByUsername ?? inv.invitedByUserId}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button className="h-8" disabled={loading} onClick={() => void handleAcceptInvite({ groupId: inv.groupId, workspaceId: inv.workspaceId })}>
|
||||
接受
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
disabled={loading}
|
||||
onClick={() => void handleDeclineInvite(inv.groupId)}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="rounded-md border border-gray-200">
|
||||
<div className="border-b border-gray-200 px-3 py-2 text-sm font-medium text-gray-700">群组</div>
|
||||
@@ -213,7 +398,10 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
{groups.length === 0 ? (
|
||||
<div className="px-3 py-3 text-sm text-gray-400">暂无群组</div>
|
||||
) : (
|
||||
groups.map((g) => (
|
||||
(() => {
|
||||
const owned = groups.filter((g) => g.my_role === "owner");
|
||||
const joined = groups.filter((g) => g.my_role !== "owner");
|
||||
const renderGroupRow = (g: GroupRow) => (
|
||||
<div
|
||||
key={g.id}
|
||||
className={`flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-gray-50 ${selectedGroupId === g.id ? "bg-blue-50" : ""}`}
|
||||
@@ -224,7 +412,9 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
onClick={() => setSelectedGroupId(g.id)}
|
||||
>
|
||||
{g.name}
|
||||
<span className="ml-2 text-xs text-gray-400">{g.my_role === "owner" ? "群主" : "成员"}</span>
|
||||
</button>
|
||||
{g.my_role === "owner" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8"
|
||||
@@ -233,8 +423,27 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="py-1">
|
||||
<div className="px-3 py-2 text-xs font-medium text-gray-500">我创建的</div>
|
||||
{owned.length === 0 ? (
|
||||
<div className="px-3 pb-2 text-sm text-gray-400">暂无</div>
|
||||
) : (
|
||||
owned.map(renderGroupRow)
|
||||
)}
|
||||
<div className="px-3 py-2 text-xs font-medium text-gray-500">我加入的</div>
|
||||
{joined.length === 0 ? (
|
||||
<div className="px-3 pb-2 text-sm text-gray-400">暂无</div>
|
||||
) : (
|
||||
joined.map(renderGroupRow)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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"}
|
||||
/>
|
||||
<Button onClick={() => void handleInvite()} disabled={loading}>
|
||||
邀请
|
||||
</Button>
|
||||
</div>
|
||||
{selectedGroupId && selectedGroup?.my_role !== "owner" ? (
|
||||
<div className="text-xs text-gray-400">提示:只有群主可以邀请成员</div>
|
||||
) : null}
|
||||
|
||||
<div className="max-h-60 overflow-auto rounded-md border border-gray-200">
|
||||
{!selectedGroupId ? (
|
||||
@@ -301,4 +513,3 @@ export function GroupManagerDialog({ open, onOpenChange, workspaceId }: GroupMan
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && close()}>
|
||||
<DialogContent className="max-h-[90vh] w-full max-w-3xl overflow-hidden border-none bg-white/95 p-0 shadow-xl">
|
||||
<DialogContent className="max-h-[92vh] w-[min(1000px,96vw)] !max-w-[min(1000px,96vw)] sm:!max-w-[min(1000px,96vw)] overflow-hidden border-none bg-white/95 p-0 shadow-xl">
|
||||
<DialogTitle className="sr-only">{dialogTitle}</DialogTitle>
|
||||
<div className="flex h-[520px] flex-col">
|
||||
<DialogDescription className="sr-only">
|
||||
输入关键词在当前工作区搜索页面标题、正文、思维导图、表格内容与附件文件名;可勾选“搜索附件内容”以纳入附件 OCR/解析文本。
|
||||
</DialogDescription>
|
||||
<div className="flex h-[min(78vh,720px)] min-h-[560px] flex-col">
|
||||
<div className="border-b border-[#eef2ff] p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1">
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
@@ -321,7 +324,7 @@ export function SearchPalette({ workspaceId }: SearchPaletteProps) {
|
||||
onClick={() => toggleFilter("onlyCurrentPage")}
|
||||
/>
|
||||
<FilterToggle
|
||||
label="图片 OCR"
|
||||
label="搜索附件内容"
|
||||
active={filters.includeOcr}
|
||||
onClick={() => toggleFilter("includeOcr")}
|
||||
/>
|
||||
@@ -569,7 +572,7 @@ function ResultRow({
|
||||
return (
|
||||
<HoverCard openDelay={250}>
|
||||
<HoverCardTrigger asChild>{content}</HoverCardTrigger>
|
||||
<HoverCardContent align="start">
|
||||
<HoverCardContent align="start" className="w-[min(720px,90vw)] max-w-[720px]">
|
||||
<PageHoverCard result={result} onPreview={onOpen} />
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
@@ -32,6 +32,8 @@ export function DocumentShareDialog({
|
||||
const [username, setUsername] = useState("");
|
||||
const [permission, setPermission] = useState<SharePermission>("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<string | null>(null);
|
||||
const [shares, setShares] = useState<any[] | null>(null);
|
||||
@@ -40,6 +42,8 @@ export function DocumentShareDialog({
|
||||
const [groupShares, setGroupShares] = useState<any[] | null>(null);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState("");
|
||||
const [groupIncludeDescendants, setGroupIncludeDescendants] = useState(false);
|
||||
const [groupDisableDownload, setGroupDisableDownload] = useState(false);
|
||||
const [groupDisableCopy, setGroupDisableCopy] = useState(false);
|
||||
const [groupMembers, setGroupMembers] = useState<Array<{ userId: string; username: string | null; role: string }>>(
|
||||
[],
|
||||
);
|
||||
@@ -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<string>(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({
|
||||
包含子页面(共享文件夹)
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disableDownload}
|
||||
onChange={(e) => setDisableDownload(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
禁止下载
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disableCopy}
|
||||
onChange={(e) => setDisableCopy(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
禁止复制
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -325,6 +409,24 @@ export function DocumentShareDialog({
|
||||
包含子页面(公开文件夹)
|
||||
</label>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupDisableDownload}
|
||||
onChange={(e) => setGroupDisableDownload(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
禁止下载
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={groupDisableCopy}
|
||||
onChange={(e) => setGroupDisableCopy(e.target.checked)}
|
||||
disabled={submitting}
|
||||
/>
|
||||
禁止复制
|
||||
</label>
|
||||
<Button onClick={() => void handleUpsertGroupShare()} disabled={submitting || !selectedGroupId}>
|
||||
{submitting ? "处理中..." : "公开/更新"}
|
||||
</Button>
|
||||
@@ -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}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -440,6 +550,14 @@ export function DocumentShareDialog({
|
||||
<div className="text-xs text-gray-500">
|
||||
权限:{row.permission === "edit" ? "可编辑" : "只读"}
|
||||
{row.includeDescendants ? " · 包含子页面" : ""}
|
||||
{(row.disableDownload || row.disableCopy) ? (
|
||||
<>
|
||||
{" · "}
|
||||
{row.disableDownload ? "禁止下载" : ""}
|
||||
{row.disableDownload && row.disableCopy ? " / " : ""}
|
||||
{row.disableCopy ? "禁止复制" : ""}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -44,7 +44,17 @@ export function PrivateTree({
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
|
||||
const flatNodes = useMemo(() => flattenDocumentTree(nodes, expanded), [nodes, expanded]);
|
||||
const flatNodes = useMemo(() => {
|
||||
const flattened = flattenDocumentTree(nodes, expanded);
|
||||
const seen = new Set<string>();
|
||||
const deduped: typeof flattened = [];
|
||||
for (const item of flattened) {
|
||||
if (seen.has(item.node.id)) continue;
|
||||
seen.add(item.node.id);
|
||||
deduped.push(item);
|
||||
}
|
||||
return deduped;
|
||||
}, [nodes, expanded]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
||||
@@ -42,6 +42,7 @@ import { PrivateTree } from "@/components/sidebar/private-tree";
|
||||
import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar-tree";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { useEditorBridgeStore } from "@/store/editor-bridge";
|
||||
import { useCurrentDocumentStore } from "@/store/current-document";
|
||||
import { FileTree } from "@/components/sidebar/file-tree";
|
||||
import { buildVisibleRows } from "@/lib/file-tree/rows";
|
||||
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
|
||||
@@ -179,16 +180,23 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
const [topPanel, setTopPanel] = useState<"starred" | "public" | "shared" | "templates" | null>(null);
|
||||
const [shareSummary, setShareSummary] = useState<{
|
||||
incoming: Array<{
|
||||
workspaceId: string;
|
||||
workspaceName: string | null;
|
||||
documentId: string;
|
||||
documentTitle: string | null;
|
||||
permission: "read" | "edit";
|
||||
includeDescendants: boolean;
|
||||
createdBy: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
outgoing: Array<{
|
||||
workspaceId: string;
|
||||
workspaceName: string | null;
|
||||
documentId: string;
|
||||
documentTitle: string | null;
|
||||
includeDescendants: boolean;
|
||||
sharedWithCount: number;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
} | null>(null);
|
||||
const [shareSummaryError, setShareSummaryError] = useState<string | null>(null);
|
||||
@@ -209,7 +217,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
allowIncludeDescendants: boolean;
|
||||
} | null>(null);
|
||||
const [groupManagerOpen, setGroupManagerOpen] = useState(false);
|
||||
const [workspaceMenuOpen, setWorkspaceMenuOpen] = useState(false);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [trashOpen, setTrashOpen] = useState(false);
|
||||
const [trashSearch, setTrashSearch] = useState("");
|
||||
@@ -230,8 +237,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
focusedRowId: null,
|
||||
}));
|
||||
|
||||
const workspaceMenuRef = useRef<HTMLDivElement>(null);
|
||||
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
|
||||
const creatingDocumentUnderParentRef = useRef<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
setTree(() => {
|
||||
@@ -261,38 +268,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
setOpen(false);
|
||||
}, [activeId, setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const custom = event as CustomEvent<{ docId?: string; asset?: MediaAsset }>;
|
||||
const asset = custom.detail?.asset as MediaAsset | undefined;
|
||||
if (asset?.id) {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
setMindmapAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
} else if (asset.asset_type === "luckysheet") {
|
||||
setTableAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
} else {
|
||||
setMediaAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
}
|
||||
}
|
||||
void sidebarQuery.refetch();
|
||||
};
|
||||
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
window.addEventListener(DOCUMENTS_CHANGED_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
window.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
|
||||
};
|
||||
}, [sidebarQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
const onSaved = () => void sidebarQuery.refetch();
|
||||
const onDeleted = () => void sidebarQuery.refetch();
|
||||
@@ -309,27 +284,22 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}, [sidebarQuery]);
|
||||
|
||||
const refreshShareSummary = useCallback(async () => {
|
||||
const workspaceId = sidebarData.activeWorkspaceId;
|
||||
if (!workspaceId) {
|
||||
setShareSummary(null);
|
||||
setShareSummaryError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await convex.query(api.documentShares.listShareRootsByWorkspace, { workspaceId });
|
||||
const resp = await convex.query(api.documentShares.listMyShareRoots, {});
|
||||
setShareSummary(resp as any);
|
||||
setShareSummaryError(null);
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? "加载共享摘要失败";
|
||||
if (String(msg).includes("Could not find public function for 'documentShares:listShareRootsByWorkspace'")) {
|
||||
setShareSummaryError("共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local`。");
|
||||
if (String(msg).includes("Could not find public function for 'documentShares:listMyShareRoots'")) {
|
||||
setShareSummaryError(
|
||||
"共享功能后端未部署/未更新:请在 `wolai-frontend` 目录执行 `npx convex dev --env-file .env.local` 或 `npx convex deploy --env-file .env.local`。",
|
||||
);
|
||||
} else {
|
||||
setShareSummaryError(msg);
|
||||
}
|
||||
setShareSummary(null);
|
||||
}
|
||||
}, [convex, sidebarData.activeWorkspaceId]);
|
||||
}, [convex]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshShareSummary();
|
||||
@@ -374,6 +344,70 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
void refreshGroupPublicSummary();
|
||||
}, [refreshGroupPublicSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const custom = event as CustomEvent<{ docId?: string; asset?: MediaAsset }>;
|
||||
const asset = custom.detail?.asset as MediaAsset | undefined;
|
||||
if (asset?.id) {
|
||||
if (asset.asset_type === "mindmap") {
|
||||
setMindmapAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
} else if (asset.asset_type === "luckysheet") {
|
||||
setTableAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
} else {
|
||||
setMediaAssets((prev) => {
|
||||
if (prev.find((item) => item.id === asset.id)) return prev;
|
||||
return [asset, ...prev];
|
||||
});
|
||||
}
|
||||
}
|
||||
void sidebarQuery.refetch();
|
||||
// 同步刷新共享/公共摘要,避免跨页面操作后出现“幽灵共享条目”(点开 404 / 无标题)。
|
||||
void refreshShareSummary();
|
||||
void refreshGroupPublicSummary();
|
||||
};
|
||||
window.addEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
window.addEventListener(DOCUMENTS_CHANGED_EVENT, handler);
|
||||
return () => {
|
||||
window.removeEventListener(ASSETS_CHANGED_EVENT, handler);
|
||||
window.removeEventListener(DOCUMENTS_CHANGED_EVENT, handler);
|
||||
};
|
||||
}, [sidebarQuery, refreshShareSummary, refreshGroupPublicSummary]);
|
||||
|
||||
useEffect(() => {
|
||||
// 说明:shareSummary/groupPublicSummary 目前走的是一次性 query + 本地 state,
|
||||
// 为了让 A 侧删除/清空回收站后,B 侧能自动消失(而不是保留 404 幽灵项),这里做轻量轮询刷新。
|
||||
if (topPanel !== "shared" && topPanel !== "public") {
|
||||
return;
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
if (topPanel === "shared") void refreshShareSummary();
|
||||
if (topPanel === "public") void refreshGroupPublicSummary();
|
||||
};
|
||||
|
||||
refresh();
|
||||
const intervalId = window.setInterval(refresh, 2500);
|
||||
|
||||
const onFocus = () => {
|
||||
if (document.visibilityState && document.visibilityState !== "visible") return;
|
||||
refresh();
|
||||
};
|
||||
|
||||
window.addEventListener("focus", onFocus);
|
||||
document.addEventListener("visibilitychange", onFocus);
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
window.removeEventListener("focus", onFocus);
|
||||
document.removeEventListener("visibilitychange", onFocus);
|
||||
};
|
||||
}, [topPanel, refreshShareSummary, refreshGroupPublicSummary]);
|
||||
|
||||
const sections = useMemo(() => buildSidebarSectionsFromTree(tree), [tree]);
|
||||
const starredNodes = useMemo(() => sections.find((section) => section.id === "starred")?.nodes ?? [], [sections]);
|
||||
const publicNodes = useMemo(() => sections.find((section) => section.id === "public")?.nodes ?? [], [sections]);
|
||||
@@ -395,19 +429,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
return map;
|
||||
}, [tree]);
|
||||
|
||||
const outgoingSharedRootNodes = useMemo(() => {
|
||||
const ids = new Set((shareSummary?.outgoing ?? []).map((o) => o.documentId));
|
||||
const nodes: DocumentNode[] = [];
|
||||
for (const id of ids) {
|
||||
const node = nodeById.get(id);
|
||||
if (node) nodes.push(node);
|
||||
}
|
||||
// 说明:同一个页面被共享给多个用户时,只展示一份。
|
||||
const uniq = new Map<string, DocumentNode>();
|
||||
nodes.forEach((n) => uniq.set(n.id, n));
|
||||
return Array.from(uniq.values());
|
||||
}, [nodeById, shareSummary?.outgoing]);
|
||||
|
||||
const publicGroupNodesByGroupId = useMemo(() => {
|
||||
const map = new Map<string, DocumentNode[]>();
|
||||
for (const g of groupPublicSummary) {
|
||||
@@ -698,6 +719,8 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
target.searchParams.set("fileName", asset.file_name ?? `未命名.${officeFileType}`);
|
||||
target.searchParams.set("fileType", officeFileType);
|
||||
target.searchParams.set("assetId", asset.id);
|
||||
target.searchParams.set("documentId", asset.document_id);
|
||||
target.searchParams.set("mode", "edit");
|
||||
window.open(target.toString(), "_blank", "noopener,noreferrer");
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
@@ -979,6 +1002,16 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}, []);
|
||||
|
||||
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
|
||||
const current = useCurrentDocumentStore.getState();
|
||||
if (
|
||||
current.disableDownload &&
|
||||
current.documentId &&
|
||||
asset.document_id &&
|
||||
String(asset.document_id) === String(current.documentId)
|
||||
) {
|
||||
window.alert("该页面已禁止下载");
|
||||
return;
|
||||
}
|
||||
if (asset.asset_type === "mindmap") {
|
||||
const resp = await fetch(`/api/mindmap/${asset.document_id}/${asset.id}`);
|
||||
if (!resp.ok) {
|
||||
@@ -1284,6 +1317,12 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (parentId: string | null) => {
|
||||
const creatingKey = parentId ?? "__root__";
|
||||
if (creatingDocumentUnderParentRef.current.has(creatingKey)) {
|
||||
return;
|
||||
}
|
||||
creatingDocumentUnderParentRef.current.add(creatingKey);
|
||||
try {
|
||||
const response = await fetch("/api/documents/create", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -1306,7 +1345,18 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
children: [],
|
||||
};
|
||||
|
||||
setTree((prev) => insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode));
|
||||
setTree((prev) => {
|
||||
// 防止同一节点被插入多次(会导致重复 key / 列表出现“同一个页面两条记录”)
|
||||
const exists = (nodes: DocumentNode[]): boolean => {
|
||||
for (const node of nodes) {
|
||||
if (node.id === nextNode.id) return true;
|
||||
if (node.children.length > 0 && exists(node.children)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (exists(prev)) return prev;
|
||||
return insertNode(prev, parentId, Number.MAX_SAFE_INTEGER, nextNode);
|
||||
});
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (parentId) {
|
||||
@@ -1320,6 +1370,9 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
|
||||
await refreshTree();
|
||||
router.push(`/documents/${nextNode.id}`);
|
||||
} finally {
|
||||
creatingDocumentUnderParentRef.current.delete(creatingKey);
|
||||
}
|
||||
},
|
||||
[refreshTree, router],
|
||||
);
|
||||
@@ -1847,23 +1900,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
[confirmTrashAction, refreshTree, sidebarQuery],
|
||||
);
|
||||
|
||||
const handleWorkspaceSwitch = useCallback(
|
||||
async (workspaceId: string) => {
|
||||
if (workspaceId === sidebarData.activeWorkspaceId) {
|
||||
setWorkspaceMenuOpen(false);
|
||||
return;
|
||||
}
|
||||
await fetch("/api/workspaces/switch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
});
|
||||
setWorkspaceMenuOpen(false);
|
||||
await sidebarQuery.refetch();
|
||||
},
|
||||
[sidebarData.activeWorkspaceId, sidebarQuery],
|
||||
);
|
||||
|
||||
const handleSignOut = useCallback(async () => {
|
||||
if (signingOut) {
|
||||
return;
|
||||
@@ -1874,7 +1910,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
setSigningOut(true);
|
||||
try {
|
||||
await signOut();
|
||||
setWorkspaceMenuOpen(false);
|
||||
router.replace("/auth");
|
||||
router.refresh();
|
||||
} catch (error: any) {
|
||||
@@ -1941,22 +1976,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
return () => window.removeEventListener("click", closeMenu);
|
||||
}, [contextMenu]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceMenuOpen) {
|
||||
return;
|
||||
}
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
workspaceMenuRef.current &&
|
||||
!workspaceMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setWorkspaceMenuOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("click", handleClickOutside);
|
||||
return () => window.removeEventListener("click", handleClickOutside);
|
||||
}, [workspaceMenuOpen]);
|
||||
|
||||
const sidebarBody = (
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
<div className="border-b border-[#f1f1f1] p-3">
|
||||
@@ -1966,41 +1985,24 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
<div className="text-base font-semibold text-gray-900">{activeWorkspace?.name ?? "我的空间"}</div>
|
||||
<div className="text-xs text-gray-500">{activeWorkspace?.type === "team" ? "团队空间" : "个人空间"}</div>
|
||||
</div>
|
||||
<div className="relative" ref={workspaceMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-[#e1e1e1] px-2 py-1 text-xs text-gray-600 hover:bg-gray-50"
|
||||
className="rounded-md border border-[#e1e1e1] px-2 py-1 text-xs text-gray-400"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setWorkspaceMenuOpen((prev) => !prev);
|
||||
window.alert("工作空间切换功能暂时停用(正在修复中)。");
|
||||
}}
|
||||
>
|
||||
切换
|
||||
切换(暂停)
|
||||
</button>
|
||||
{workspaceMenuOpen && (
|
||||
<div className="absolute right-0 z-20 mt-2 w-56 rounded-md border border-[#eaeaea] bg-white shadow-lg">
|
||||
{sidebarData.workspaces.map((workspace) => (
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
提示:当前工作空间切换暂时停用;共享/群组相关内容会在「共享页面」与「成员」里跨工作空间展示。
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
key={workspace.id}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between px-3 py-2 text-left text-sm hover:bg-gray-50",
|
||||
workspace.id === activeWorkspace?.id && "bg-[#f5f7fb]",
|
||||
)}
|
||||
onClick={() => void handleWorkspaceSwitch(workspace.id)}
|
||||
>
|
||||
<span>{workspace.name}</span>
|
||||
{workspace.id === activeWorkspace?.id ? (
|
||||
<span className="text-xs text-[#2563eb]">当前</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">{workspace.memberCount} 人</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t border-[#f1f1f1] p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1 text-xs text-red-600 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={() => void handleSignOut()}
|
||||
disabled={signingOut}
|
||||
>
|
||||
@@ -2009,10 +2011,6 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 border-b border-[#f1f1f1] p-3">
|
||||
{TOP_BUTTONS.map((button) => (
|
||||
@@ -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<string, { workspaceName: string | null; rows: typeof rows }>();
|
||||
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<any>) => {
|
||||
if (!rows || rows.length === 0) {
|
||||
return <div className="px-2 py-2 text-xs text-gray-400">暂无内容</div>;
|
||||
}
|
||||
const groups = groupByWorkspace(rows);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{groups.map((g) => (
|
||||
<div key={g.workspaceId}>
|
||||
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
|
||||
{g.workspaceName ?? g.workspaceId}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{g.rows.map((r) => (
|
||||
<Link
|
||||
key={`${r.workspaceId}:${r.documentId}`}
|
||||
href={`/documents/${r.documentId}`}
|
||||
className="block truncate rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f5f7fb] hover:text-[#2563eb]"
|
||||
>
|
||||
{r.documentTitle || "无标题"}
|
||||
{typeof r.permission === "string" ? (
|
||||
<span className="ml-2 text-xs text-gray-400">
|
||||
{r.permission === "edit" ? "可编辑" : "只读"}
|
||||
</span>
|
||||
) : null}
|
||||
{typeof r.sharedWithCount === "number" ? (
|
||||
<span className="ml-2 text-xs text-gray-400">{r.sharedWithCount} 人</span>
|
||||
) : null}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{shareSummaryError ? (
|
||||
@@ -2077,14 +2139,14 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
|
||||
共享给我的({shareSummary?.incoming?.length ?? 0})
|
||||
</div>
|
||||
{renderList(sharedNodes)}
|
||||
{renderShareRows(shareSummary?.incoming ?? [])}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#f1f1f1] pt-2">
|
||||
<div className="px-2 pb-1 text-xs font-medium text-gray-500">
|
||||
我共享出去的({shareSummary?.outgoing?.length ?? 0})
|
||||
</div>
|
||||
{renderList(outgoingSharedRootNodes)}
|
||||
{renderShareRows(shareSummary?.outgoing ?? [])}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2334,13 +2396,11 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{sidebarData.activeWorkspaceId ? (
|
||||
<GroupManagerDialog
|
||||
open={groupManagerOpen}
|
||||
onOpenChange={setGroupManagerOpen}
|
||||
workspaceId={sidebarData.activeWorkspaceId}
|
||||
workspaceId={sidebarData.activeWorkspaceId || ""}
|
||||
/>
|
||||
) : null}
|
||||
<MoveEmbedPickerDialog
|
||||
open={moveEmbedOpen}
|
||||
onOpenChange={setMoveEmbedOpen}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function useConvexSidebarData(workspaceId: string): {
|
||||
|
||||
const workspacesResult = useQuery(
|
||||
api.workspaces.fetchWorkspaceSummaries,
|
||||
shouldFetch ? undefined : "skip"
|
||||
shouldFetch ? {} : "skip",
|
||||
);
|
||||
|
||||
const normalizeAssetUrls = (asset: MediaAsset): MediaAsset => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) : 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;
|
||||
}
|
||||
@@ -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<CurrentDocumentState>((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 });
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user