feat: 收口 Rust Web 3000 主链

This commit is contained in:
lix-2026
2026-05-11 13:16:34 +08:00
parent 7f3f7d4e2f
commit 17c003976b
61 changed files with 2090 additions and 2256 deletions
@@ -59,7 +59,7 @@ export const readAiPanelPrefs = (
const parsedSteps = Number(stepsRaw);
const provider: AiProvider =
providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama" || providerRaw === "codex"
providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama"
? providerRaw
: defaults.provider;
@@ -0,0 +1,85 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { DocumentReadView } from "./document-read-view";
import type { PageOptionsState } from "@/types/page-options";
function buildOptions(overrides: Partial<PageOptionsState> = {}): PageOptionsState {
return {
wideLayout: false,
smallText: false,
showHeadingNumbers: false,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: false,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
...overrides,
};
}
describe("DocumentReadView media attachments", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal("location", new URL("https://mnote.example.com/documents/doc_1"));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
vi.unstubAllGlobals();
container.remove();
});
it("renders Office media as a Wolai-like attachment row that opens OnlyOffice in a new window", () => {
act(() => {
root.render(
<DocumentReadView
documentId="doc_1"
options={buildOptions()}
content={{
blocks: [
{
id: "block_asset_1",
type: "media",
props: {
assetType: "file",
assetId: "asset_ppt_1",
documentId: "doc_1",
fileName: "2023自我介绍PPT_李爱波0831.pptx",
fileUrl: "https://storage.example.com/demo.pptx?token=abc",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
fileSize: 12_110_520,
},
},
],
}}
/>,
);
});
const row = container.querySelector<HTMLAnchorElement>('[data-testid="mnote-office-attachment-row"]');
expect(row).not.toBeNull();
expect(row?.target).toBe("_blank");
expect(row?.rel).toContain("noreferrer");
expect(row?.textContent).toContain("2023自我介绍PPT_李爱波0831.pptx");
expect(row?.textContent).toContain("11.55 MB");
expect(row?.getAttribute("href")).toContain("/onlyoffice?");
expect(row?.getAttribute("href")).toContain("fileType=pptx");
expect(row?.getAttribute("href")).toContain("assetId=asset_ppt_1");
expect(row?.getAttribute("href")).toContain("documentId=doc_1");
expect(row?.querySelector('[aria-label="预览"]')).not.toBeNull();
});
});
@@ -2,8 +2,13 @@
import Link from "next/link";
import type { CSSProperties, ReactNode } from "react";
import { Eye } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TocEntry } from "@/components/editor/document-toc";
import {
buildOnlyOfficeAssetOpenUrl,
inferOnlyOfficeFileType,
} from "@/lib/onlyoffice/client-session";
import {
clampHeadingLevel,
extractPageBlocks,
@@ -225,7 +230,51 @@ const renderChildren = (
);
};
const renderMediaBlock = (block: PageSubtreeBlock) => {
const formatFileSize = (size: unknown): string => {
const bytes = typeof size === "number" ? size : Number(size);
if (!Number.isFinite(bytes) || bytes <= 0) return "";
if (bytes < 1024) return `${Math.round(bytes)} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(2)} ${units[unitIndex]}`;
};
const getOfficeAttachmentTone = (fileType: string | null) => {
switch (fileType) {
case "ppt":
case "pptx":
case "odp":
return {
badge: "P",
badgeClassName: "bg-[#f97316] text-white",
};
case "xls":
case "xlsx":
case "ods":
case "csv":
return {
badge: "X",
badgeClassName: "bg-[#16a34a] text-white",
};
case "pdf":
return {
badge: "PDF",
badgeClassName: "bg-[#dc2626] text-white",
};
default:
return {
badge: "W",
badgeClassName: "bg-[#2563eb] text-white",
};
}
};
const renderMediaBlock = (block: PageSubtreeBlock, currentDocumentId: string) => {
const props = block.props ?? {};
const assetType = String(props.assetType ?? "image");
const fileUrl = typeof props.fileUrl === "string" ? props.fileUrl : "";
@@ -234,6 +283,11 @@ const renderMediaBlock = (block: PageSubtreeBlock) => {
const fileName =
typeof props.fileName === "string" && props.fileName.trim() ? props.fileName : "未命名资源";
const caption = typeof props.caption === "string" ? props.caption.trim() : "";
const mimeType = typeof props.mimeType === "string" ? props.mimeType : "";
const assetId = typeof props.assetId === "string" ? props.assetId : "";
const documentId = typeof props.documentId === "string" && props.documentId.trim() ? props.documentId : currentDocumentId;
const fileType = inferOnlyOfficeFileType(fileName, mimeType);
const sizeLabel = formatFileSize(props.fileSize ?? props.size ?? props.file_size);
if (!fileUrl) {
return (
@@ -283,6 +337,41 @@ const renderMediaBlock = (block: PageSubtreeBlock) => {
);
}
if (fileType) {
const tone = getOfficeAttachmentTone(fileType);
const officeHref = buildOnlyOfficeAssetOpenUrl({
origin: typeof window !== "undefined" ? window.location.origin : "http://localhost",
fileUrl,
fileName,
fileType,
assetId,
documentId,
mode: "edit",
});
return (
<a
href={officeHref}
target="_blank"
rel="noopener noreferrer"
data-testid="mnote-office-attachment-row"
className="inline-flex max-w-full items-center gap-2 rounded px-1 py-0.5 text-[#27272a] transition hover:bg-[#f8fafc]"
>
<span
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[4px] text-[10px] font-semibold leading-none",
tone.badgeClassName,
)}
aria-hidden="true"
>
{tone.badge}
</span>
<span className="min-w-0 truncate text-[15px] leading-6">{caption || fileName}</span>
<Eye className="h-4 w-4 shrink-0 text-[#a1a1aa]" aria-label="预览" />
{sizeLabel ? <span className="shrink-0 text-[12px] text-[#a1a1aa]">{sizeLabel}</span> : null}
</a>
);
}
return (
<a
href={fileUrl}
@@ -465,7 +554,7 @@ const renderBlock = (
case "media":
return (
<div key={key} className="space-y-2">
{renderMediaBlock(block)}
{renderMediaBlock(block, documentId)}
{children}
</div>
);
@@ -7,6 +7,7 @@ import {
pageAggregateClientStateReducer,
selectPageAggregateClientAiSnapshot,
selectPageAggregateClientPageSubtree,
selectPageAggregateClientTitleState,
} from "@/components/editor/page-aggregate-client-state";
import type { PageOptionsState } from "@/types/page-options";
@@ -103,11 +104,13 @@ describe("page-aggregate-client-state", () => {
const state = createPageAggregateClientState(page);
expect(state.serverPageTitle).toBe("页面标题");
expect(state.persistedPageTitle).toBeNull();
expect(state.draftPageTitle).toBeNull();
expect(state.options).toEqual(page.layout.pageOptions);
expect(state.content).toBe(page.body.content);
expect(state.serverContentSnapshot).toBe(page.body.content);
expect(state.serverPageSubtreeSnapshot).toBe(page.tree.pageSubtree);
expect(state.serverPageSubtreeTitle).toBe("页面标题");
expect(state.contentRevision).toBe(3);
expect(state.conflictDetectionKey).toBe("doc_1:3");
});
@@ -135,12 +138,14 @@ describe("page-aggregate-client-state", () => {
page: reloadedPage,
});
expect(next.serverPageTitle).toBe("刷新后的标题");
expect(next.persistedPageTitle).toBeNull();
expect(next.draftPageTitle).toBeNull();
expect(next.options.wideLayout).toBe(true);
expect(next.options.showToc).toBe(true);
expect(next.content).toBe(reloadedContent);
expect(next.serverContentSnapshot).toBe(reloadedContent);
expect(next.serverPageSubtreeSnapshot).toBe(reloadedPage.tree.pageSubtree);
expect(next.serverPageSubtreeTitle).toBe("刷新后的标题");
expect(next.contentRevision).toBe(9);
expect(next.conflictDetectionKey).toBe("doc_1:9");
});
@@ -173,24 +178,89 @@ describe("page-aggregate-client-state", () => {
expect(next.conflictDetectionKey).toBe("doc_1:10");
});
it("标题 committed/draft 应收口到同一份 page aggregate client state", () => {
const page = createPageAggregate();
const initialState = createPageAggregateClientState(page);
expect(
selectPageAggregateClientTitleState(initialState, {
liveSidebarTitle: "树标题",
}),
).toEqual({
displayTitle: "树标题",
committedTitle: "树标题",
hasDraft: false,
});
const draftState = pageAggregateClientStateReducer(initialState, {
type: "set_draft_page_title",
title: " 新标题 ",
});
expect(
selectPageAggregateClientTitleState(draftState, {
liveSidebarTitle: "树标题",
}),
).toEqual({
displayTitle: " 新标题 ",
committedTitle: "树标题",
hasDraft: true,
});
const persistedState = pageAggregateClientStateReducer(draftState, {
type: "commit_persisted_page_title",
title: "新标题",
});
expect(
selectPageAggregateClientTitleState(persistedState, {
liveSidebarTitle: "树标题",
}),
).toEqual({
displayTitle: "新标题",
committedTitle: "新标题",
hasDraft: false,
});
});
it("本地标题或正文与服务端快照不一致时,不应继续复用旧 pageSubtree", () => {
const page = createPageAggregate();
const initialState = createPageAggregateClientState(page);
expect(selectPageAggregateClientPageSubtree(initialState, "页面标题")).toBe(page.tree.pageSubtree);
expect(
selectPageAggregateClientPageSubtree(initialState, {
liveSidebarTitle: "页面标题",
}),
).toBe(page.tree.pageSubtree);
const localContentState = pageAggregateClientStateReducer(initialState, {
type: "apply_local_content_snapshot",
content: [{ id: "block_local", type: "paragraph", content: [] }],
});
expect(selectPageAggregateClientPageSubtree(localContentState, "页面标题")).toBeNull();
expect(
selectPageAggregateClientPageSubtree(localContentState, {
liveSidebarTitle: "页面标题",
}),
).toBeNull();
const retitledState = pageAggregateClientStateReducer(initialState, {
type: "update_server_page_subtree_title",
const draftTitleState = pageAggregateClientStateReducer(initialState, {
type: "set_draft_page_title",
title: "草稿标题",
});
expect(
selectPageAggregateClientPageSubtree(draftTitleState, {
liveSidebarTitle: "页面标题",
}),
).toBeNull();
const persistedTitleState = pageAggregateClientStateReducer(initialState, {
type: "commit_persisted_page_title",
title: "持久化后的标题",
});
expect(selectPageAggregateClientPageSubtree(retitledState, "页面标题")).toBeNull();
expect(selectPageAggregateClientPageSubtree(retitledState, "持久化后的标题")).toBe(page.tree.pageSubtree);
const retitledSubtree = selectPageAggregateClientPageSubtree(persistedTitleState, {
liveSidebarTitle: "页面标题",
});
expect(retitledSubtree).not.toBeNull();
expect(retitledSubtree?.rootNode.metadata.title).toBe("持久化后的标题");
expect(retitledSubtree?.outline).toBe(page.tree.pageSubtree?.outline);
});
it("页面设置 patch 应只合并局部字段,不重建整份页面状态", () => {
@@ -223,7 +293,7 @@ describe("page-aggregate-client-state", () => {
const snapshot = selectPageAggregateClientAiSnapshot(createPageAggregateClientState(page), {
workspaceId: "ws_1",
pageTitle: "页面标题",
liveSidebarTitle: "页面标题",
});
expect(snapshot).toEqual({
@@ -5,11 +5,13 @@ import type { PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase";
export type PageAggregateClientState = {
serverPageTitle: string;
persistedPageTitle: string | null;
draftPageTitle: string | null;
options: PageOptionsState;
content: unknown;
serverContentSnapshot: unknown;
serverPageSubtreeSnapshot: PageSubtreeProjection | null;
serverPageSubtreeTitle: string;
contentRevision: number | null;
conflictDetectionKey: string | null;
};
@@ -19,6 +21,14 @@ export type PageAggregateClientStateAction =
type: "hydrate_from_page";
page: PageAggregateProjection;
}
| {
type: "set_draft_page_title";
title: string;
}
| {
type: "commit_persisted_page_title";
title: string;
}
| {
type: "patch_page_options";
patch: Partial<PageOptionsState>;
@@ -30,10 +40,6 @@ export type PageAggregateClientStateAction =
| {
type: "apply_persisted_body_meta";
meta: PageBodyPersistedMeta;
}
| {
type: "update_server_page_subtree_title";
title: string;
};
function normalizePageTitle(title: string | null | undefined): string {
@@ -41,23 +47,37 @@ function normalizePageTitle(title: string | null | undefined): string {
return normalized || "无标题";
}
function resolveServerPageSubtreeTitle(page: PageAggregateProjection): string {
const subtreeTitle = page.tree.pageSubtree?.rootNode.metadata.title;
if (typeof subtreeTitle === "string" && subtreeTitle.trim()) {
return subtreeTitle.trim();
function withResolvedPageSubtreeTitle(
pageSubtree: PageSubtreeProjection,
title: string,
): PageSubtreeProjection {
const normalizedTitle = normalizePageTitle(title);
if (normalizePageTitle(pageSubtree.rootNode.metadata.title) === normalizedTitle) {
return pageSubtree;
}
return normalizePageTitle(page.head.title);
return {
...pageSubtree,
rootNode: {
...pageSubtree.rootNode,
metadata: {
...pageSubtree.rootNode.metadata,
title: normalizedTitle,
},
},
};
}
export function createPageAggregateClientState(
page: PageAggregateProjection,
): PageAggregateClientState {
return {
serverPageTitle: normalizePageTitle(page.head.title),
persistedPageTitle: null,
draftPageTitle: null,
options: page.layout.pageOptions,
content: page.body.content,
serverContentSnapshot: page.body.content,
serverPageSubtreeSnapshot: page.tree.pageSubtree,
serverPageSubtreeTitle: resolveServerPageSubtreeTitle(page),
contentRevision: page.body.revision,
conflictDetectionKey: page.body.conflictDetectionKey,
};
@@ -70,6 +90,19 @@ export function pageAggregateClientStateReducer(
switch (action.type) {
case "hydrate_from_page":
return createPageAggregateClientState(action.page);
case "set_draft_page_title":
return {
...state,
draftPageTitle: action.title,
};
case "commit_persisted_page_title": {
const normalizedTitle = normalizePageTitle(action.title);
return {
...state,
persistedPageTitle: normalizedTitle,
draftPageTitle: normalizedTitle,
};
}
case "patch_page_options":
return {
...state,
@@ -89,26 +122,49 @@ export function pageAggregateClientStateReducer(
contentRevision: action.meta.revision,
conflictDetectionKey: action.meta.conflictDetectionKey,
};
case "update_server_page_subtree_title":
return {
...state,
serverPageSubtreeTitle: normalizePageTitle(action.title),
};
default:
return state;
}
}
export function selectPageAggregateClientTitleState(
state: PageAggregateClientState,
input: {
liveSidebarTitle: string | null;
},
): {
displayTitle: string;
committedTitle: string;
hasDraft: boolean;
} {
const liveCommittedTitle = normalizePageTitle(input.liveSidebarTitle ?? state.serverPageTitle);
const committedTitle =
state.persistedPageTitle != null &&
normalizePageTitle(state.persistedPageTitle) !== liveCommittedTitle
? normalizePageTitle(state.persistedPageTitle)
: liveCommittedTitle;
const hasDraft =
state.draftPageTitle != null &&
normalizePageTitle(state.draftPageTitle) !== committedTitle;
return {
displayTitle: hasDraft ? state.draftPageTitle ?? committedTitle : committedTitle,
committedTitle,
hasDraft,
};
}
export function selectPageAggregateClientPageSubtree(
state: PageAggregateClientState,
pageTitle: string,
input: {
liveSidebarTitle: string | null;
},
): PageSubtreeProjection | null {
const hasServerPageSubtree = Boolean(state.serverPageSubtreeSnapshot);
const titleUnchanged = normalizePageTitle(pageTitle) === state.serverPageSubtreeTitle;
const titleState = selectPageAggregateClientTitleState(state, input);
const contentUnchanged = state.content === state.serverContentSnapshot;
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) {
return state.serverPageSubtreeSnapshot;
if (state.serverPageSubtreeSnapshot && !titleState.hasDraft && contentUnchanged) {
return withResolvedPageSubtreeTitle(state.serverPageSubtreeSnapshot, titleState.committedTitle);
}
return null;
}
@@ -117,7 +173,7 @@ export function selectPageAggregateClientAiSnapshot(
state: PageAggregateClientState,
input: {
workspaceId: string | null;
pageTitle: string;
liveSidebarTitle: string | null;
},
): {
blocks: Json | null;
@@ -133,7 +189,9 @@ export function selectPageAggregateClientAiSnapshot(
return {
blocks,
pageSubtree: selectPageAggregateClientPageSubtree(state, input.pageTitle),
pageSubtree: selectPageAggregateClientPageSubtree(state, {
liveSidebarTitle: input.liveSidebarTitle,
}),
persistedMeta: {
workspaceId: input.workspaceId,
revision: state.contentRevision,
@@ -35,6 +35,10 @@ import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import {
buildOnlyOfficeAssetOpenUrl,
inferOnlyOfficeFileType,
} from "@/lib/onlyoffice/client-session";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
@@ -141,21 +145,6 @@ const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNod
templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
};
const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | null) => {
const name = (fileName ?? "").trim().toLowerCase();
const mt = (mimeType ?? "").trim().toLowerCase();
const ext = name.includes(".") ? name.split(".").pop() : null;
if (ext && ["doc", "docx", "odt", "rtf"].includes(ext)) return ext;
if (ext && ["ppt", "pptx", "odp"].includes(ext)) return ext;
if (ext && ["xls", "xlsx", "ods", "csv"].includes(ext)) return ext;
if (ext && ["pdf"].includes(ext)) return ext;
if (mt.includes("wordprocessingml")) return "docx";
if (mt.includes("presentationml")) return "pptx";
if (mt.includes("spreadsheetml")) return "xlsx";
if (mt.includes("pdf")) return "pdf";
return null;
};
interface SidebarProps {
initialData: SidebarInitialData;
sidebarData?: SidebarInitialData;
@@ -898,7 +887,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return;
}
const officeFileType = officeFileTypeFromAsset(asset.file_name ?? null, asset.mime_type ?? null);
const officeFileType = inferOnlyOfficeFileType(asset.file_name ?? null, asset.mime_type ?? null);
if (officeFileType) {
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
if (!officeBase) {
@@ -913,14 +902,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
throw new Error(payload?.error ?? "生成签名链接失败");
}
const { signedUrl } = (await res.json()) as { signedUrl: string };
const target = new URL("/onlyoffice", window.location.origin);
target.searchParams.set("fileUrl", signedUrl);
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");
const target = buildOnlyOfficeAssetOpenUrl({
origin: window.location.origin,
fileUrl: signedUrl,
fileName: asset.file_name ?? `未命名.${officeFileType}`,
fileType: officeFileType,
assetId: asset.id,
documentId: asset.document_id,
mode: "edit",
});
window.open(target, "_blank", "noopener,noreferrer");
setOpen(false);
} catch (error) {
window.alert((error as Error).message);
@@ -37,7 +37,7 @@ function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
function Harness(props: {
initialData: SidebarInitialData;
sidebarQueryData: SidebarInitialData;
sidebarQueryData: SidebarInitialData | null;
treeStreamData: SidebarInitialData | null;
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
@@ -174,4 +174,45 @@ describe("usePreferredSidebarSnapshot", () => {
}),
});
});
it("fallback 且 query 不可用时应回到 initial 快照,而不是继续复用旧 stream 数据", async () => {
const initialData = buildSidebarData([buildDocument({ title: "初始标题" })]);
const staleTreeStream = buildSidebarData([
buildDocument({
title: "旧 stream 标题",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={initialData}
treeStreamData={staleTreeStream}
treeStreamStatus="live"
onState={onState}
/>,
);
});
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={null}
treeStreamData={staleTreeStream}
treeStreamStatus="fallback"
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "initial",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "初始标题" })],
}),
});
});
});
@@ -40,7 +40,7 @@ export function usePreferredSidebarSnapshot(input: {
? input.treeStreamData
: source === "query" && input.sidebarQueryData
? input.sidebarQueryData
: input.treeStreamData ?? input.sidebarQueryData ?? input.initialData;
: input.initialData;
const syncKey =
source === "tree_stream"
? treeStreamSyncKey ?? querySyncKey ?? initialSyncKey