0.3.5 共享功能修复

This commit is contained in:
liaibo
2026-01-24 12:32:51 +08:00
parent 25923f308c
commit 3c3f407f4b
44 changed files with 3754 additions and 420 deletions
@@ -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>
+55 -14
View File
@@ -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,13 +104,11 @@ export default function AuthPage() {
if (result.signingIn) {
if (flow === "signUp") {
try {
await setMyUsername({ username });
} catch (e: any) {
// 说明:注册已成功,但用户名可能重复;此时保持登录状态,转入“设置用户名”步骤继续处理。
setMessage({ type: "error", text: e?.message ?? "用户名设置失败,请重试" });
return;
}
// 说明:注册已成功,但此刻登录态可能尚未同步到 Convex functions。
// 这里先进入“待写入用户名”状态,等 currentUser 可用后再写,避免出现“未登录”的竞态。
setMessage({ type: "info", text: "注册成功,正在保存用户名..." });
setPendingUsernameToSave(username.trim());
return;
}
setMessage({ type: "success", text: flow === "signIn" ? "登录成功!" : "注册成功!" });
setTimeout(() => router.push("/"), 500);
@@ -89,9 +124,9 @@ export default function AuthPage() {
setMessage({ type: "error", text: "操作失败,请重试" });
} catch (error: any) {
setMessage({ type: "error", text: error.message || "操作失败,请重试" });
setMessage({ type: "error", text: getUserFacingErrorMessage(error, "操作失败,请重试") });
}
}, [router, setMyUsername, signIn, username]);
}, [router, signIn, username]);
const handleSubmit = useCallback(async (event: React.FormEvent<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,100 +34,389 @@ const TIME_FIELD_COLUMN = {
created: "created_at",
} as const;
export async function POST(request: Request) {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
type RangeIso = { fromIso: string | null; toIso: string | null };
const { auth, client } = await getAuthedConvexClient();
const payload = (await request.json()) as DocumentSearchRequest;
const workspaceId = payload.workspaceId;
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const filters: DocumentSearchFilters = {
...DEFAULT_FILTERS,
...payload.filters,
};
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
const normalizedQuery = payload.query?.trim() ?? "";
const normalizedLower = normalizedQuery.toLowerCase();
const docs = await client.query(api.documents.listByWorkspace, { workspaceId });
const docMap = new Map(docs.map((d) => [d.id, d]));
const recentRows = await client.query(api.recents.listByWorkspace, {
userId: auth.userId,
workspaceId,
limit: 10,
});
const recent: DocumentSearchResult[] = recentRows
.map((r) => docMap.get(r.document_id))
.filter((row): row is (typeof docs)[number] => Boolean(row))
.map((row) => ({
id: row.id,
title: row.title ?? "无标题",
snippet: "",
updatedAt: row.updated_at ?? null,
createdAt: row.created_at ?? null,
matchField: "recent",
hasOcr: false,
publicPath: `/documents/${row.id}`,
score: 0,
}));
if (!normalizedQuery) {
const response: DocumentSearchResponse = { results: [], recent };
return NextResponse.json(response);
}
const timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange];
const timeField = TIME_FIELD_COLUMN[filters.timeField];
const boundaryIso =
typeof timeRangeMs === "number"
? new Date(Date.now() - timeRangeMs).toISOString()
: null;
const narrowed = [...docs]
.sort((a, b) => {
const ta = a.updated_at ?? a.created_at ?? "";
const tb = b.updated_at ?? b.created_at ?? "";
return tb.localeCompare(ta);
})
.filter((row) => {
if (filters.onlyCurrentPage && payload.documentId) {
if (row.id !== payload.documentId) return false;
}
if (boundaryIso) {
const ts = (row as any)?.[timeField] ?? null;
if (!ts || typeof ts !== "string") return false;
if (ts < boundaryIso) return false;
}
const title = (row.title ?? "无标题").toLowerCase();
if (filters.exact) {
return title === normalizedLower;
}
return title.includes(normalizedLower);
});
const results: DocumentSearchResult[] = narrowed.slice(0, limit).map((row) => ({
id: row.id,
title: row.title ?? "无标题",
snippet: buildSnippet(row.title ?? "", normalizedQuery),
updatedAt: row.updated_at ?? null,
createdAt: row.created_at ?? null,
matchField: "title",
hasOcr: false,
publicPath: `/documents/${row.id}`,
score: 2,
}));
const response: DocumentSearchResponse = { results, recent };
return NextResponse.json(response);
function parseDateToIso(value: string | undefined): string | null {
const raw = String(value ?? "").trim();
if (!raw) return null;
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return null;
return d.toISOString();
}
function resolveCustomRangeIso(filters: DocumentSearchFilters): RangeIso {
const fromIso = parseDateToIso(filters.customRange?.from);
const toIso = parseDateToIso(filters.customRange?.to);
return { fromIso, toIso };
}
type MindmapNode = { data?: { text?: unknown }; children?: unknown[] };
function extractTextFromMindmapData(data: unknown, maxChars = 60000): string {
const out: string[] = [];
const pushText = (value: unknown) => {
const s = typeof value === "string" ? value : null;
if (!s) return;
const trimmed = s.replace(/\s+/g, " ").trim();
if (!trimmed) return;
out.push(trimmed);
};
const walk = (node: unknown) => {
if (out.join("\n").length >= maxChars) return;
if (!node || typeof node !== "object") return;
const n = node as MindmapNode;
pushText(n.data?.text);
if (Array.isArray(n.children)) {
for (const c of n.children) {
walk(c);
if (out.join("\n").length >= maxChars) return;
}
}
};
walk(data);
const joined = out.join("\n").trim();
return joined.length > maxChars ? `${joined.slice(0, maxChars)}` : joined;
}
export async function POST(request: Request) {
try {
if (!isConvexEnabled()) {
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
}
const { auth, client } = await getAuthedConvexClient();
const payload = (await request.json()) as DocumentSearchRequest;
const workspaceId = payload.workspaceId;
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const filters: DocumentSearchFilters = {
...DEFAULT_FILTERS,
...payload.filters,
};
const limit = Math.min(payload.limit ?? 30, MAX_LIMIT);
const normalizedQuery = payload.query?.trim() ?? "";
const normalizedLower = normalizedQuery.toLowerCase();
const docs = await client.query(api.documents.listSearchDataByWorkspace, { workspaceId });
const docMap = new Map(docs.map((d) => [d.id, d]));
const recentRows = await client.query(api.recents.listByWorkspace, {
userId: auth.userId,
workspaceId,
limit: 10,
});
const recent: DocumentSearchResult[] = recentRows
.map((r) => docMap.get(r.document_id))
.filter((row): row is (typeof docs)[number] => Boolean(row))
.map((row) => ({
id: row.id,
title: row.title ?? "无标题",
snippet: "",
updatedAt: row.updated_at ?? null,
createdAt: row.created_at ?? null,
matchField: "recent",
hasOcr: false,
publicPath: `/documents/${row.id}`,
score: 0,
}));
if (!normalizedQuery) {
const response: DocumentSearchResponse = { results: [], recent };
return NextResponse.json(response);
}
const timeRangeMs = TIME_RANGE_TO_MS[filters.timeRange];
const timeField = TIME_FIELD_COLUMN[filters.timeField];
const boundaryIso =
typeof timeRangeMs === "number"
? new Date(Date.now() - timeRangeMs).toISOString()
: null;
const { fromIso, toIso } = resolveCustomRangeIso(filters);
const eligibleDocs = [...docs]
.sort((a, b) => {
const ta = a.updated_at ?? a.created_at ?? "";
const tb = b.updated_at ?? b.created_at ?? "";
return tb.localeCompare(ta);
})
.filter((row) => {
if (filters.onlyCurrentPage && payload.documentId) {
if (row.id !== payload.documentId) return false;
}
if (boundaryIso) {
const ts = (row as any)?.[timeField] ?? null;
if (!ts || typeof ts !== "string") return false;
if (ts < boundaryIso) return false;
}
if (fromIso || toIso) {
const ts = (row as any)?.[timeField] ?? null;
if (!ts || typeof ts !== "string") return false;
if (fromIso && ts < fromIso) return false;
if (toIso && ts > toIso) return false;
}
return true;
});
const eligibleDocIds = new Set(eligibleDocs.map((d) => d.id));
type MatchInfo = {
score: number;
matchField: "title" | "content";
snippet: string;
hasOcr: boolean;
};
const matches = new Map<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: match.snippet,
updatedAt: row.updated_at ?? null,
createdAt: row.created_at ?? null,
matchField: match.matchField,
hasOcr: match.hasOcr,
publicPath: `/documents/${row.id}`,
score: match.score,
});
}
results.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const ta = a.updatedAt ?? a.createdAt ?? "";
const tb = b.updatedAt ?? b.createdAt ?? "";
const byTime = tb.localeCompare(ta);
if (byTime) return byTime;
return String(a.title ?? "").localeCompare(String(b.title ?? ""));
});
const limitedResults = results.slice(0, limit);
const response: DocumentSearchResponse = { results: limitedResults, recent };
return NextResponse.json(response);
} catch (err) {
if (err instanceof HttpError) {
return NextResponse.json({ error: err.message }, { status: err.status });
}
const message = err instanceof Error ? err.message : "搜索失败,请稍后再试";
console.error("[search/documents] failed:", err);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+24 -3
View File
@@ -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,10 +69,47 @@ window.__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
return u;
}
}
var origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
return origOpen.call(this, method, rewrite(url), async, user, password);
};
try {
var origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
return origOpen.call(this, method, rewrite(url), async, user, password);
};
} catch (e) {}
try {
var origFetch = window.fetch;
if (typeof origFetch === 'function') {
window.fetch = function (input, init) {
try {
if (typeof input === 'string') return origFetch.call(this, rewrite(input), init);
if (input && typeof input === 'object' && typeof input.url === 'string') {
return origFetch.call(this, new Request(rewrite(input.url), input), init);
}
} catch (e) {}
return origFetch.call(this, input, init);
};
}
} catch (e) {}
try {
var origWinOpen = window.open;
if (typeof origWinOpen === 'function') {
window.open = function (url, target, features) {
try {
if (typeof url === 'string') url = rewrite(url);
} catch (e) {}
return origWinOpen.call(this, url, target, features);
};
}
} catch (e) {}
try {
var origAssign = location.assign && location.assign.bind(location);
if (origAssign) location.assign = function (url) { return origAssign(rewrite(url)); };
var origReplace = location.replace && location.replace.bind(location);
if (origReplace) location.replace = function (url) { return origReplace(rewrite(url)); };
} catch (e) {}
} catch (e) {}
})();
</script>
@@ -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,20 +196,87 @@ const setupOnlyOfficeInternalRequestRewrite = (baseUrl: string, onlyofficeBaseUr
return input;
}
};
const origOpen = (win as any).XMLHttpRequest?.prototype?.open;
if (typeof origOpen !== "function") return;
(win as any).XMLHttpRequest.prototype.open = function openPatched(
method: string,
url: string,
async?: boolean,
user?: string | null,
password?: string | null,
) {
const nextUrl = typeof url === "string" ? rewriteUrl(url) : url;
return origOpen.call(this, method, nextUrl, async, user as any, password as any);
};
// 说明:修复 “下载为 xlsx/docx” 等场景:这类下载常通过 window.open/location 跳转,
// 如果 URL 指向 127.0.0.1:8081,会导致浏览器无法下载(因为 8081 只在 Docker 内部/代理后可用)。
try {
const origWinOpen = (win as any).open;
if (typeof origWinOpen === "function") {
(win as any).open = function openPatched(
url?: string | URL,
target?: string,
features?: string,
) {
const nextUrl =
typeof url === "string"
? rewriteUrl(url)
: url instanceof URL
? new URL(rewriteUrl(url.toString()))
: url;
return origWinOpen.call(this, nextUrl as any, target as any, features as any);
};
}
} catch {
// ignore
}
try {
const loc = (win as any).location as Location | undefined;
if (loc && typeof loc.assign === "function") {
const origAssign = loc.assign.bind(loc);
loc.assign = ((url: string) => origAssign(rewriteUrl(url))) as any;
}
if (loc && typeof loc.replace === "function") {
const origReplace = loc.replace.bind(loc);
loc.replace = ((url: string) => origReplace(rewriteUrl(url))) as any;
}
} catch {
// ignore
}
try {
const origFetch = (win as any).fetch;
if (typeof origFetch === "function") {
(win as any).fetch = function fetchPatched(input: any, init?: any) {
try {
if (typeof input === "string") {
return origFetch.call(this, rewriteUrl(input), init);
}
if (input instanceof URL) {
return origFetch.call(this, new URL(rewriteUrl(input.toString())), init);
}
if (input && typeof input === "object" && typeof input.url === "string") {
const next = new Request(rewriteUrl(input.url), input);
return origFetch.call(this, next, init);
}
} catch {
// ignore
}
return origFetch.call(this, input, init);
};
}
} catch {
// ignore
}
try {
const origOpen = (win as any).XMLHttpRequest?.prototype?.open;
if (typeof origOpen === "function") {
(win as any).XMLHttpRequest.prototype.open = function openPatched(
method: string,
url: string,
async?: boolean,
user?: string | null,
password?: string | null,
) {
const nextUrl = typeof url === "string" ? rewriteUrl(url) : url;
return origOpen.call(this, method, nextUrl, async, user as any, password as any);
};
}
} catch {
// ignore
}
(win as any).__MNOTE_ONLYOFFICE_XHR_REWRITE__ = true;
} catch {
// ignore
@@ -279,10 +349,22 @@ export default function OnlyOfficePage() {
const fileType = (params.get("fileType") ?? "docx").toLowerCase();
const mode = (params.get("mode") ?? "edit") as EditorMode;
const assetId = params.get("assetId") ?? "";
const documentId = params.get("documentId") ?? "";
const initialUserId = params.get("userId") ?? "";
const channel = (params.get("channel") ?? "").trim().toLowerCase();
const convex = useConvex();
const [error, setError] = useState<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,
+7 -3
View File
@@ -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,
},
{
key: "download",
label: `下载${typeLabel}`,
icon: <Download className="h-4 w-4" />,
onClick: () => {
void downloadAsset();
},
},
!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,28 +398,52 @@ 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) => (
<div
key={g.id}
className={`flex items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-gray-50 ${selectedGroupId === g.id ? "bg-blue-50" : ""}`}
>
<button
type="button"
className="min-w-0 flex-1 truncate text-left"
onClick={() => setSelectedGroupId(g.id)}
(() => {
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" : ""}`}
>
{g.name}
</button>
<Button
variant="outline"
className="h-8"
disabled={loading}
onClick={() => void handleRemoveGroup(g.id)}
>
</Button>
</div>
))
<button
type="button"
className="min-w-0 flex-1 truncate text-left"
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"
disabled={loading}
onClick={() => void handleRemoveGroup(g.id)}
>
</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, {
+208 -148
View File
@@ -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) {
@@ -978,7 +1001,17 @@ function SidebarContent({ initialData, sidebarQuery }: SidebarContentProps) {
await copyText(path, "存储路径已复制");
}, []);
const handleDownloadAsset = useCallback(async (asset: MediaAsset) => {
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,51 +1985,30 @@ 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"
onClick={(event) => {
event.preventDefault();
setWorkspaceMenuOpen((prev) => !prev);
}}
>
</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) => (
<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"
onClick={() => void handleSignOut()}
disabled={signingOut}
>
<LogOut className="h-4 w-4" />
<span>{signingOut ? "正在退出..." : "退出登录"}</span>
</button>
</div>
</div>
)}
</div>
<button
type="button"
className="rounded-md border border-[#e1e1e1] px-2 py-1 text-xs text-gray-400"
onClick={(event) => {
event.preventDefault();
window.alert("工作空间切换功能暂时停用(正在修复中)。");
}}
>
</button>
</div>
<div className="mt-2 text-xs text-gray-400">
/
</div>
<div className="mt-2">
<button
type="button"
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}
>
<LogOut className="h-4 w-4" />
<span>{signingOut ? "正在退出..." : "退出登录"}</span>
</button>
</div>
</div>
@@ -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}
/>
) : null}
<GroupManagerDialog
open={groupManagerOpen}
onOpenChange={setGroupManagerOpen}
workspaceId={sidebarData.activeWorkspaceId || ""}
/>
<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");
});
});
+23
View File
@@ -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 });
}
},
}));