feat: 收口 Rust Web 3000 主链
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user