feat: 收口文档桥接与 OnlyOffice/Sidebar 回归

- 为 documents.save/meta/content、blocks.patch 与 sidebar.dataset.list 补齐 Rust 协议映射、共享契约与桥接执行器

- 对齐 BlockNote、Mindmap、OnlyOffice 的保存/路由元信息,并补真实浏览器回归脚本与 OnlyOffice 部署基线

- 忽略 Rust 本地构建产物与 Harness 调试状态文件,避免临时产物进入仓库历史
This commit is contained in:
lix-2026
2026-04-15 03:06:29 +08:00
parent 84a8454fa9
commit b33ffb99e7
51 changed files with 3260 additions and 379 deletions
@@ -35,16 +35,23 @@ import { useAppPreferencesStore } from "@/store/app-preferences";
import { useCommentsUiStore } from "@/store/comments-ui";
import { useConvexAuth, useQuery } from "convex/react";
import { api } from "@/lib/convex/api";
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
interface BlockNoteEditorProps {
documentId: string;
workspaceId: string;
initialContent: unknown;
initialRevision?: number | null;
initialConflictDetectionKey?: string | null;
pageOptions: PageOptionsState;
readOnly?: boolean;
onStatsChange?: (stats: DocumentStats) => void;
onSnapshot?: (payload: { blocks: Json; stats: DocumentStats }) => void;
onCloseToc?: () => void;
onPersistedMetaChange?: (payload: {
revision: number | null;
conflictDetectionKey: string | null;
}) => void;
}
const extractInitialBlocks = (content: unknown): Json | undefined => {
@@ -174,16 +181,22 @@ export function BlockNoteEditor({
documentId,
workspaceId,
initialContent,
initialRevision = null,
initialConflictDetectionKey = null,
pageOptions,
readOnly = false,
onStatsChange,
onSnapshot,
onCloseToc,
onPersistedMetaChange,
}: BlockNoteEditorProps) {
const [isSaving, setIsSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [tocEntries, setTocEntries] = useState<TocEntry[]>([]);
const [fullScreenTableId, setFullScreenTableId] = useState<string | null>(null);
const isFullScreenTableOpen = fullScreenTableId !== null;
const revisionRef = useRef<number | null>(initialRevision);
const conflictDetectionKeyRef = useRef<string | null>(initialConflictDetectionKey);
const openReferencePalette = useSearchPaletteStore((state) => state.openReference);
const registerEditorBridge = useEditorBridgeStore((state) => state.registerBridge);
@@ -255,20 +268,76 @@ export function BlockNoteEditor({
[collaboration],
);
useEffect(() => {
revisionRef.current = initialRevision;
}, [initialRevision]);
useEffect(() => {
conflictDetectionKeyRef.current = initialConflictDetectionKey;
}, [initialConflictDetectionKey]);
const saveContent = useCallback(
async (content: Json) => {
setIsSaving(true);
try {
await fetch("/api/documents/save", {
setSaveError(null);
const blockCount = Array.isArray(content) ? content.length : null;
const response = await fetch("/api/documents/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ documentId, workspaceId, content }),
body: JSON.stringify(
buildDocumentSavePayload({
documentId,
workspaceId,
revision: revisionRef.current,
content,
conflictDetectionKey: conflictDetectionKeyRef.current,
snapshotCapturedAt: new Date().toISOString(),
blockCount,
}),
),
});
if (!response.ok) {
let message = "保存失败";
try {
const payload = await response.json();
if (payload && typeof payload === "object" && typeof payload.error === "string") {
message = payload.error;
}
} catch {
// ignore
}
setSaveError(message);
throw new Error(message);
}
const payload = await response.json() as {
revision?: number | null;
conflictDetectionKey?: string | null;
};
const nextRevision =
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: revisionRef.current;
const nextConflictDetectionKey =
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: conflictDetectionKeyRef.current;
revisionRef.current = nextRevision ?? null;
conflictDetectionKeyRef.current = nextConflictDetectionKey ?? null;
onPersistedMetaChange?.({
revision: revisionRef.current,
conflictDetectionKey: conflictDetectionKeyRef.current,
});
setSaveError(null);
} catch (error) {
if (error instanceof Error) {
setSaveError(error.message);
}
} finally {
setIsSaving(false);
}
},
[documentId, workspaceId],
[documentId, onPersistedMetaChange, workspaceId],
);
const debouncedSave = useDebouncedCallback(saveContent, 800);
@@ -1262,7 +1331,7 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
<CustomSlashMenu editor={editor} currentDocumentId={documentId} />
</BlockNoteView>
<div className="pointer-events-none absolute right-4 top-3 text-xs text-gray-400">
{isSaving ? "保存中..." : "已保存"}
{isSaving ? "保存中..." : saveError ? saveError : "已保存"}
</div>
</div>
<DocumentToc entries={tocEntries} visible={pageOptions.showToc} onJump={jumpToHeading} onClose={onCloseToc} />
@@ -1279,4 +1348,4 @@ const computeDocumentStats = (blocks: Block<CustomBlockSchema>[]): DocumentStats
)}
</>
);
}
}
@@ -102,10 +102,21 @@ type MindMapInstance = {
setLayout: (layout: string) => void;
};
type MindMapData = {
data: Record<string, unknown>;
children?: unknown[];
};
type MindMapData = {
data: Record<string, unknown>;
children?: unknown[];
};
type MindmapRouteMeta = {
requestId?: string;
traceId?: string;
workspaceId?: string | null;
documentId?: string;
pageId?: string;
mindmapId?: string;
attachmentId?: string;
updatedAt?: string | null;
};
export const defaultMindmapData = {
data: { text: "中心主题" },
@@ -505,15 +516,35 @@ const MindmapBlockView = ({
return instance?.renderer?.root ?? instance?.renderer?.renderTree?._node ?? null;
}, [mindmap]);
const docId = useMemo(
() =>
block.props.docId ||
(typeof window !== "undefined"
? window.location.pathname.split("/").pop() ?? ""
: ""),
[block.props.docId],
);
const mindmapId = block.id;
const docId = useMemo(
() =>
block.props.docId ||
(typeof window !== "undefined"
? window.location.pathname.split("/").pop() ?? ""
: ""),
[block.props.docId],
);
const mindmapId = block.id;
const pageId = docId;
const attachmentId = mindmapId;
const [requestMeta, setRequestMeta] = useState<{ requestId: string; traceId: string } | null>(null);
const syncMindmapRouteMeta = useCallback((meta: unknown) => {
if (!isRecord(meta)) return;
const requestId = typeof meta.requestId === "string" ? meta.requestId.trim() : "";
const traceId = typeof meta.traceId === "string" ? meta.traceId.trim() : "";
if (requestId && traceId) {
setRequestMeta((prev) =>
prev?.requestId === requestId && prev?.traceId === traceId
? prev
: { requestId, traceId },
);
}
const nextWorkspaceId = typeof meta.workspaceId === "string" ? meta.workspaceId.trim() : "";
if (nextWorkspaceId) {
setWorkspaceId((prev) => (prev === nextWorkspaceId ? prev : nextWorkspaceId));
}
}, []);
// 获取 workspaceId(用于上传图片)
useEffect(() => {
@@ -581,12 +612,13 @@ const MindmapBlockView = ({
// 多导图:按 docId + mindmapId 拉取
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`);
if (!resp.ok) return;
const payload = await resp.json().catch(() => null);
const data = payload?.data;
if (!data || cancelled) return;
// 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置”
if (hasLocalEditsRef.current) return;
initialDataRef.current = canonicalizeMindmapData(data);
const payload = await resp.json().catch(() => null);
const data = payload?.data;
if (!data || cancelled) return;
syncMindmapRouteMeta(payload?.meta);
// 若用户已在本地做过编辑,则不再用远端数据覆盖,避免“插入节点后又被重置”
if (hasLocalEditsRef.current) return;
initialDataRef.current = canonicalizeMindmapData(data);
if (mindmap) {
applyingRemoteRef.current = true;
try {
@@ -1388,7 +1420,13 @@ const MindmapBlockView = ({
if (resp.ok) {
try {
const payload = (await resp.json().catch(() => null)) as any;
const updatedAt = payload && typeof payload.updated_at === "string" ? payload.updated_at : null;
syncMindmapRouteMeta(payload?.meta);
const updatedAt =
payload && typeof payload.updated_at === "string"
? payload.updated_at
: payload?.meta && typeof payload.meta.updatedAt === "string"
? payload.meta.updatedAt
: null;
if (updatedAt) {
lastLocalSavedAtRef.current = updatedAt;
// 避免 Convex 订阅回放对本端“已应用的保存”重复 setData/clearHistory 导致闪烁
@@ -1465,12 +1503,15 @@ const MindmapBlockView = ({
);
(async () => {
try {
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data, createOnly: true }),
});
const resp = await fetch(`/api/mindmap/${docId}/${mindmapId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data, createOnly: true }),
});
if (!resp.ok) {
} else {
const payload = (await resp.json().catch(() => null)) as { meta?: MindmapRouteMeta } | null;
syncMindmapRouteMeta(payload?.meta);
}
} catch {} finally {
// 即便远端失败,也通知侧边栏刷新,保证本地文件树及时更新
@@ -3155,6 +3196,13 @@ const MindmapBlockView = ({
<div
ref={wrapperRef}
data-testid="mindmap-fullscreen"
data-page-id={pageId || undefined}
data-document-id={docId || undefined}
data-attachment-id={attachmentId}
data-mindmap-id={mindmapId}
data-workspace-id={workspaceId || undefined}
data-request-id={requestMeta?.requestId}
data-trace-id={requestMeta?.traceId}
tabIndex={0}
className="fixed inset-0 z-[9999] overflow-hidden bg-white"
>
@@ -3166,9 +3214,15 @@ const MindmapBlockView = ({
ref={containerRef}
className="h-full w-full"
data-testid="mindmap-canvas"
data-mindmap-id={mindmapId}
contentEditable={false}
/>
data-page-id={pageId || undefined}
data-document-id={docId || undefined}
data-attachment-id={attachmentId}
data-mindmap-id={mindmapId}
data-workspace-id={workspaceId || undefined}
data-request-id={requestMeta?.requestId}
data-trace-id={requestMeta?.traceId}
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
@@ -3212,12 +3266,19 @@ const MindmapBlockView = ({
}
return (
<div
ref={wrapperRef}
data-testid="mindmap-embed"
tabIndex={0}
className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm"
>
<div
ref={wrapperRef}
data-testid="mindmap-embed"
data-page-id={pageId || undefined}
data-document-id={docId || undefined}
data-attachment-id={attachmentId}
data-mindmap-id={mindmapId}
data-workspace-id={workspaceId || undefined}
data-request-id={requestMeta?.requestId}
data-trace-id={requestMeta?.traceId}
tabIndex={0}
className="not-prose my-4 w-full overflow-hidden rounded-xl border border-gray-200 bg-[#f8fafc] shadow-sm"
>
<div className="flex flex-col gap-3 border-b border-gray-100 bg-white px-4 py-3">
<div className="w-full">
<MindmapToolbar {...toolbarProps} />
@@ -3254,13 +3315,19 @@ const MindmapBlockView = ({
enterLocalFullscreen();
}}
>
<div
ref={containerRef}
className="h-full w-full"
data-testid="mindmap-canvas"
data-mindmap-id={mindmapId}
contentEditable={false}
/>
<div
ref={containerRef}
className="h-full w-full"
data-testid="mindmap-canvas"
data-page-id={pageId || undefined}
data-document-id={docId || undefined}
data-attachment-id={attachmentId}
data-mindmap-id={mindmapId}
data-workspace-id={workspaceId || undefined}
data-request-id={requestMeta?.requestId}
data-trace-id={requestMeta?.traceId}
contentEditable={false}
/>
<MindmapSidebarTrigger
activeSidebar={activeSidebar}
@@ -38,6 +38,8 @@ export interface DocumentContentProps {
title: string | null;
updatedAt: string | null;
initialContent: unknown;
initialContentRevision?: number | null;
initialConflictDetectionKey?: string | null;
initialOptions: PageOptionsState;
initialStats: DocumentStats | null;
openTableId?: string | null;
@@ -69,6 +71,8 @@ export function DocumentContent({
title,
updatedAt,
initialContent,
initialContentRevision = null,
initialConflictDetectionKey = null,
initialOptions,
initialStats,
openTableId,
@@ -90,6 +94,8 @@ export function DocumentContent({
const openMoveEmbedPicker = useMoveEmbedPickerStore((s) => s.openPicker);
const [pageTitle, setPageTitle] = useState(title ?? "无标题");
const [content, setContent] = useState<unknown>(initialContent);
const [contentRevision, setContentRevision] = useState<number | null>(initialContentRevision);
const [conflictDetectionKey, setConflictDetectionKey] = useState<string | null>(initialConflictDetectionKey);
const [contentLoading, setContentLoading] = useState(() => initialContent == null);
const [contentError, setContentError] = useState<string | null>(null);
const [contentReloadKey, setContentReloadKey] = useState(0);
@@ -204,6 +210,14 @@ export function DocumentContent({
useEffect(() => {
setStats(initialStats ?? defaultStats);
}, [initialStats]);
useEffect(() => {
setContentRevision(initialContentRevision);
}, [initialContentRevision]);
useEffect(() => {
setConflictDetectionKey(initialConflictDetectionKey);
}, [initialConflictDetectionKey]);
useEffect(() => {
@@ -244,9 +258,23 @@ export function DocumentContent({
const payload = await response.json().catch(() => ({}));
throw new Error(payload?.error ?? "加载页面内容失败");
}
const payload = (await response.json()) as { content?: unknown };
const payload = (await response.json()) as {
content?: unknown;
revision?: number | null;
conflictDetectionKey?: string | null;
};
if (canceled) return;
setContent(payload.content ?? null);
setContentRevision(
typeof payload.revision === "number" && Number.isInteger(payload.revision)
? payload.revision
: 0,
);
setConflictDetectionKey(
typeof payload.conflictDetectionKey === "string" && payload.conflictDetectionKey.trim()
? payload.conflictDetectionKey
: `${documentId}:0`,
);
} catch (error) {
if (canceled) return;
if ((error as { name?: string })?.name === "AbortError") return;
@@ -683,11 +711,17 @@ export function DocumentContent({
documentId={documentId}
workspaceId={workspaceId}
initialContent={content}
initialRevision={contentRevision}
initialConflictDetectionKey={conflictDetectionKey}
pageOptions={options}
readOnly={readOnly}
onStatsChange={handleStatsChange}
onSnapshot={handleSnapshot}
onCloseToc={closeToc}
onPersistedMetaChange={({ revision, conflictDetectionKey: nextConflictDetectionKey }) => {
setContentRevision(revision);
setConflictDetectionKey(nextConflictDetectionKey);
}}
/>
)}
<PageBacklinksPanel