@xgspz/cli-ui 0.0.0-stage → 0.6.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +156 -0
- package/README.md +512 -2
- package/dist/cli-ui-embed-D-3DvyTN.js +50279 -0
- package/dist/cli-ui-index-C34qFb4u.js +11209 -0
- package/dist/cli-ui-jszip.min-BObcE2nn.js +26 -0
- package/dist/cli-ui-jszip.min-DojbZPiE.js +2324 -0
- package/dist/cli-ui-xlsx-JVLtx0qR.js +93 -0
- package/dist/cli-ui.css +32 -0
- package/dist/cli-ui.js +17 -0
- package/package.json +60 -4
- package/src/board/BrowserPane.tsx +969 -0
- package/src/board/ChangesPane.tsx +185 -0
- package/src/board/CliBoard.tsx +484 -0
- package/src/board/FileViewer.tsx +721 -0
- package/src/board/FilesPane.tsx +262 -0
- package/src/board/HtmlReadingPreview.tsx +52 -0
- package/src/board/MarkdownPreview.tsx +134 -0
- package/src/board/PanelControls.tsx +40 -0
- package/src/board/blocks.tsx +97 -0
- package/src/board/context.ts +65 -0
- package/src/board/diffLines.ts +46 -0
- package/src/board/fileKind.ts +250 -0
- package/src/board/fileOps.ts +85 -0
- package/src/board/fileScope.ts +32 -0
- package/src/board/fileSync.ts +351 -0
- package/src/board/fileSyncCache.ts +250 -0
- package/src/board/fileSyncProcessor.ts +72 -0
- package/src/board/fileSyncQueue.ts +56 -0
- package/src/board/fileSyncTransport.ts +85 -0
- package/src/board/fileWatch.ts +211 -0
- package/src/board/folderDownload.ts +211 -0
- package/src/board/frameQuote.ts +52 -0
- package/src/board/htmlReading.ts +148 -0
- package/src/board/panels.ts +33 -0
- package/src/board/textLoad.ts +76 -0
- package/src/board/useBoardUpload.tsx +51 -0
- package/src/board/useBoardVisibility.ts +35 -0
- package/src/board/workspace.ts +200 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +932 -0
- package/src/chat/Composer.tsx +239 -0
- package/src/chat/GroupChat.tsx +113 -0
- package/src/chat/RunControls.tsx +196 -0
- package/src/chat/configuration.ts +15 -0
- package/src/chat/draft.ts +49 -0
- package/src/chat/export.ts +34 -0
- package/src/chat/queueEdit.ts +27 -0
- package/src/chat/useExecutionConfig.ts +93 -0
- package/src/components.tsx +13 -0
- package/src/core/blocks.ts +205 -0
- package/src/core/callbacks.ts +8 -0
- package/src/core/client.ts +822 -0
- package/src/core/config.ts +252 -0
- package/src/core/conversation.ts +577 -0
- package/src/core/documentUpload.ts +196 -0
- package/src/core/execution.ts +24 -0
- package/src/core/id.ts +15 -0
- package/src/core/identity.ts +21 -0
- package/src/core/provider.tsx +141 -0
- package/src/core/sha256.ts +21 -0
- package/src/core/sse.ts +51 -0
- package/src/core/store.ts +1103 -0
- package/src/core/subagents.ts +78 -0
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +505 -0
- package/src/core/xlsx.ts +142 -0
- package/src/elements.tsx +376 -0
- package/src/embed.ts +56 -0
- package/src/index.ts +56 -0
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +251 -0
- package/src/msglist/CliMsgList.tsx +422 -0
- package/src/operator/branchFlow.ts +35 -0
- package/src/operator/index.ts +9 -0
- package/src/operator/liveStream.ts +1 -0
- package/src/operator/node/AgentConnect.tsx +619 -0
- package/src/operator/node/AppearancePanel.tsx +35 -0
- package/src/operator/node/BranchHistory.tsx +67 -0
- package/src/operator/node/BranchNavigator.tsx +36 -0
- package/src/operator/node/BrowserPane.tsx +128 -0
- package/src/operator/node/BrowserPopup.tsx +22 -0
- package/src/operator/node/Chat.tsx +538 -0
- package/src/operator/node/CommandPalette.tsx +213 -0
- package/src/operator/node/Composer.tsx +316 -0
- package/src/operator/node/InsightsView.tsx +831 -0
- package/src/operator/node/Layout.tsx +192 -0
- package/src/operator/node/Markdown.tsx +61 -0
- package/src/operator/node/Modal.tsx +179 -0
- package/src/operator/node/NeedsYouMenu.tsx +54 -0
- package/src/operator/node/OnboardingWizard.tsx +248 -0
- package/src/operator/node/Orchestrator.tsx +644 -0
- package/src/operator/node/ProjectLanding.tsx +86 -0
- package/src/operator/node/ProjectsColumn.tsx +137 -0
- package/src/operator/node/ProviderAuth.tsx +559 -0
- package/src/operator/node/RunControls.tsx +2 -0
- package/src/operator/node/RunSwitch.tsx +113 -0
- package/src/operator/node/Services.tsx +245 -0
- package/src/operator/node/SessionRail.tsx +299 -0
- package/src/operator/node/SessionView.tsx +292 -0
- package/src/operator/node/SettingsView.tsx +690 -0
- package/src/operator/node/TaskBoard.tsx +352 -0
- package/src/operator/node/TaskChanges.tsx +526 -0
- package/src/operator/node/TaskHero.tsx +50 -0
- package/src/operator/node/TasksColumn.tsx +200 -0
- package/src/operator/node/Terminal.tsx +143 -0
- package/src/operator/node/Transcript.tsx +485 -0
- package/src/operator/node/ViewFile.tsx +79 -0
- package/src/operator/node/Welcome.tsx +101 -0
- package/src/operator/node/WorktreePrune.tsx +333 -0
- package/src/operator/node/agents.ts +58 -0
- package/src/operator/node/api.ts +57 -0
- package/src/operator/node/boardBackend.ts +63 -0
- package/src/operator/node/boardEvents.ts +40 -0
- package/src/operator/node/boardInteraction.ts +15 -0
- package/src/operator/node/boardState.ts +74 -0
- package/src/operator/node/boardWorkspace.ts +15 -0
- package/src/operator/node/boundedPoll.ts +47 -0
- package/src/operator/node/branchFlow.ts +1 -0
- package/src/operator/node/composerDraft.ts +78 -0
- package/src/operator/node/fileLinks.ts +81 -0
- package/src/operator/node/format.ts +292 -0
- package/src/operator/node/github.tsx +232 -0
- package/src/operator/node/icons.tsx +88 -0
- package/src/operator/node/liveStream.ts +323 -0
- package/src/operator/node/modals.tsx +766 -0
- package/src/operator/node/navHistory.ts +70 -0
- package/src/operator/node/persist.ts +26 -0
- package/src/operator/node/protocol/agents/optIn.ts +9 -0
- package/src/operator/node/protocol/agents/types.ts +394 -0
- package/src/operator/node/protocol/approvalFailure.ts +77 -0
- package/src/operator/node/protocol/authFailure.ts +66 -0
- package/src/operator/node/protocol/customModels.ts +23 -0
- package/src/operator/node/protocol/events.ts +13 -0
- package/src/operator/node/protocol/features.ts +70 -0
- package/src/operator/node/protocol/internalUsage.ts +1 -0
- package/src/operator/node/protocol/promptLimits.ts +52 -0
- package/src/operator/node/protocol/storageRetention.ts +13 -0
- package/src/operator/node/protocol/types.ts +335 -0
- package/src/operator/node/protocol/usageLimit.ts +72 -0
- package/src/operator/node/shared.tsx +89 -0
- package/src/operator/node/transcriptModel.ts +129 -0
- package/src/operator/node/types.ts +305 -0
- package/src/operator/node/useGlobalEvents.ts +97 -0
- package/src/operator/node/useOrchestrator.ts +697 -0
- package/src/operator/node/usePrefs.ts +114 -0
- package/src/operator/node/useRecaps.ts +102 -0
- package/src/operator/node/useTaskStream.ts +286 -0
- package/src/operator.base.css +1949 -0
- package/src/operator.css +1949 -0
- package/src/styles.css +579 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/Markdown.tsx +60 -0
- package/src/ui/Popover.tsx +95 -0
- package/src/ui/QueuedMessages.tsx +110 -0
- package/src/ui/ReferenceCard.tsx +28 -0
- package/src/ui/SubagentChat.tsx +233 -0
- package/src/ui/TextBlock.tsx +51 -0
- package/src/ui/Transcript.tsx +435 -0
- package/src/ui/UploadProgress.tsx +46 -0
- package/src/ui/WorkspaceDrawer.tsx +64 -0
- package/src/ui/WorkspaceTabs.tsx +60 -0
- package/src/ui/fileLinks.ts +52 -0
- package/src/ui/format.ts +103 -0
- package/src/ui/icons.tsx +92 -0
- package/src/ui/reference.ts +40 -0
- package/src/ui/shared.tsx +38 -0
- package/src/ui/transcriptModel.ts +151 -0
- package/src/ui/useChatScrollbar.ts +29 -0
- package/src/ui/useComposerAutosize.ts +54 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
// 文件预览:Markdown / 代码 / 文本 / CSV / 图片 / PDF / HTML(沙箱)/ Word,其余给下载。
|
|
2
|
+
// 看板和对话里的弹窗共用。文件经 center 转发读取(浏览器不直连执行节点)。
|
|
3
|
+
//
|
|
4
|
+
// 远端、实时、超大文件:
|
|
5
|
+
// · HTML 使用经授权的不可变修订与持久块缓存重组;图片 / PDF / 显式下载保留直接文件地址。
|
|
6
|
+
// · Markdown 按需增加已加载范围并窗口渲染;其他文本可切到末尾。GBK 导出按 GB18030 解。
|
|
7
|
+
// · 文件在变(任务还在写):看板递增 version 时重读;读的时候保留滚动位置,原本在最底下的继续贴着底(像 tail -f)。
|
|
8
|
+
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
|
9
|
+
import { createPortal } from "react-dom";
|
|
10
|
+
import { fileUrlFromBase, type BoardBackend } from "../core/client";
|
|
11
|
+
import { Icon } from "../ui/icons";
|
|
12
|
+
import { Popover } from "../ui/Popover";
|
|
13
|
+
import { Markdown } from "../ui/Markdown";
|
|
14
|
+
import { MarkdownPreview, BoundedTextPreview } from "./MarkdownPreview";
|
|
15
|
+
import { HtmlReadingPreview } from "./HtmlReadingPreview";
|
|
16
|
+
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks";
|
|
17
|
+
import { codeLang, fmtSize, parseCsv, previewKind, resolveRelative, type PreviewKind } from "./fileKind";
|
|
18
|
+
import { loadText, refreshText, type LoadedText } from "./textLoad";
|
|
19
|
+
import { decodeText } from "../core/textDecode";
|
|
20
|
+
import { relTime } from "../ui/format";
|
|
21
|
+
import { downloadFile, mimeOf, readBoundedBuffer, FileReadLimitError } from "./fileOps";
|
|
22
|
+
import { bridgeDoc, frameToken, type FrameSelMessage } from "./frameQuote";
|
|
23
|
+
import { syncFile } from "./fileSync";
|
|
24
|
+
import { retainFileScope } from "./fileScope";
|
|
25
|
+
import { isTaskUploadPath } from "../core/documentUpload";
|
|
26
|
+
|
|
27
|
+
export { downloadFile };
|
|
28
|
+
|
|
29
|
+
/** 看板 / 预览里给文件加的自定义操作(例如「在对话里引用」「发到工作协同」)。 */
|
|
30
|
+
export interface FileAction {
|
|
31
|
+
label: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
/** 只对某些文件显示。 */
|
|
34
|
+
when?: (file: { path: string; name: string }) => boolean;
|
|
35
|
+
onClick: (file: { path: string; name: string; url: string; downloadUrl: string }, jobId: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 原样预览保留完整排版;分段阅读按需加载,限制单次创建的 DOM。
|
|
39
|
+
/** 文本(代码、日志、Markdown、CSV、HTML 源文件)一次最多读这么多;更大的看开头 / 末尾。 */
|
|
40
|
+
export const TEXT_MAX = 32 * 1024 * 1024;
|
|
41
|
+
/** HTML 预览(能选中引用)最多读这么多进来;更大的直接按地址打开(能看,不能选中引用)。 */
|
|
42
|
+
export const HTML_QUOTE_MAX = 100 * 1024 * 1024;
|
|
43
|
+
/** 代码高亮到这么大(约 1 MB/秒);更大的按纯文本显示(照样能选中引用)。 */
|
|
44
|
+
const HIGHLIGHT_MAX = 512 * 1024;
|
|
45
|
+
const MARKDOWN_STEP = 512 * 1024;
|
|
46
|
+
const nextTextLimit = (limit: number) => Math.min(HTML_QUOTE_MAX, limit + Math.min(limit, 8 * 1024 * 1024));
|
|
47
|
+
/** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的图片 / PDF 不预览、不在浏览器里拼下载。 */
|
|
48
|
+
const BLOB_PREVIEW_MAX = 100 * 1024 * 1024;
|
|
49
|
+
/** Word 在浏览器里转换,太大的只给下载。 */
|
|
50
|
+
const DOCX_PREVIEW_MAX = 50 * 1024 * 1024;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 浏览器能直接打开的文件地址。directLinks(同源代理)时马上有;否则等中枢发的短期链接(pending = 还在换);
|
|
54
|
+
* 中枢发不了(太旧)时 url 为 null、blob = true,调用方退回先 fetch 再显示。
|
|
55
|
+
*/
|
|
56
|
+
function useFileLinks(backend: BoardBackend, jobId: string, path: string, version: number, attempt: number, reloadKey: number | string | undefined, useBlocks = false): { url: ((path: string, download?: boolean) => string) | null; pending: boolean; blob: boolean } {
|
|
57
|
+
const [linkBase, setLinkBase] = useState<string | null | undefined>(undefined);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (useBlocks || backend.directLinks || !backend.fileLinkBase) return;
|
|
60
|
+
let alive = true, pending = false;
|
|
61
|
+
const check = () => {
|
|
62
|
+
if (!alive || pending) return;
|
|
63
|
+
pending = true;
|
|
64
|
+
void Promise.resolve().then(() => alive ? backend.fileLinkBase!(jobId) : null).then(
|
|
65
|
+
base => { if (alive) setLinkBase(old => base ?? old ?? null); },
|
|
66
|
+
() => { if (alive) setLinkBase(old => old ?? null); },
|
|
67
|
+
).finally(() => { pending = false; });
|
|
68
|
+
};
|
|
69
|
+
const wake = () => { if (document.visibilityState === "visible") check(); };
|
|
70
|
+
void check();
|
|
71
|
+
document.addEventListener("visibilitychange", wake); window.addEventListener("online", wake);
|
|
72
|
+
return () => { alive = false; document.removeEventListener("visibilitychange", wake); window.removeEventListener("online", wake); };
|
|
73
|
+
}, [backend, jobId, path, version, attempt, reloadKey, useBlocks]);
|
|
74
|
+
return useMemo(() => {
|
|
75
|
+
if (useBlocks) return { url: null, pending: false, blob: true };
|
|
76
|
+
if (backend.directLinks) return { url: (path: string, download?: boolean) => backend.fileUrl(jobId, path, { download }), pending: false, blob: false };
|
|
77
|
+
if (!backend.fileLinkBase || linkBase === null) return { url: null, pending: false, blob: true };
|
|
78
|
+
if (linkBase === undefined) return { url: null, pending: true, blob: false };
|
|
79
|
+
return { url: (path: string, download?: boolean) => fileUrlFromBase(linkBase, path, download), pending: false, blob: false };
|
|
80
|
+
}, [backend, jobId, linkBase, useBlocks]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const withVersion = (url: string, version: number, attempt = 0) => {
|
|
84
|
+
const q = [version ? `v=${version}` : "", attempt ? `r=${attempt}` : ""].filter(Boolean).join("&");
|
|
85
|
+
return q ? `${url}${url.includes("?") ? "&" : "?"}${q}` : url;
|
|
86
|
+
};
|
|
87
|
+
/** 读了这么久还没好:提示「网络较慢」,不让人以为卡死了。 */
|
|
88
|
+
const SLOW_HINT_MS = 8_000;
|
|
89
|
+
function useSlow(loading: boolean) {
|
|
90
|
+
const [slow, setSlow] = useState(false);
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
setSlow(false);
|
|
93
|
+
if (!loading) return;
|
|
94
|
+
const t = setTimeout(() => setSlow(true), SLOW_HINT_MS);
|
|
95
|
+
return () => clearTimeout(t);
|
|
96
|
+
}, [loading]);
|
|
97
|
+
return slow;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 图片 / PDF / HTML 的地址:能直接用就用;只能 fetch 时先读成 blob(离开时释放),太大的不读。 */
|
|
101
|
+
function useFileSrc(backend: BoardBackend, jobId: string, path: string, enabled: boolean, version: number, size: number | undefined, links: ReturnType<typeof useFileLinks>, attempt = 0) {
|
|
102
|
+
const [state, setState] = useState<{ url: string | null; error: string | null }>({ url: null, error: null });
|
|
103
|
+
const direct = enabled && links.url ? withVersion(links.url(path), version, attempt) : null;
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!enabled || !links.blob) return;
|
|
106
|
+
setState({ url: null, error: null });
|
|
107
|
+
if (size !== undefined && size > BLOB_PREVIEW_MAX) { setState({ url: null, error: `文件较大(${fmtSize(size)}),请下载后查看` }); return; }
|
|
108
|
+
let alive = true;
|
|
109
|
+
const ctl = new AbortController();
|
|
110
|
+
let objectUrl: string | null = null;
|
|
111
|
+
readBoundedBuffer(backend, jobId, path, BLOB_PREVIEW_MAX, {signal:ctl.signal}).then((buffer) => {
|
|
112
|
+
if (!alive) return;
|
|
113
|
+
objectUrl = URL.createObjectURL(new Blob([buffer], { type: mimeOf(path) }));
|
|
114
|
+
setState({ url: objectUrl, error: null });
|
|
115
|
+
}, (error: unknown) => { if (alive) setState({ url: null, error: error instanceof Error ? error.message : String(error) }); });
|
|
116
|
+
return () => { alive = false; ctl.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
|
117
|
+
}, [backend, jobId, path, enabled, version, links.blob, size, attempt]);
|
|
118
|
+
return direct ? { url: direct, error: null } : state;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type Loaded =
|
|
122
|
+
| ({ kind: "text" } & LoadedText)
|
|
123
|
+
| { kind: "docx"; html: string }
|
|
124
|
+
| { kind: "none" };
|
|
125
|
+
|
|
126
|
+
export interface FileViewerProps {
|
|
127
|
+
backend: BoardBackend;
|
|
128
|
+
jobId: string;
|
|
129
|
+
path: string;
|
|
130
|
+
/** 服务端保留的上传原名;实际访问仍使用 path。 */
|
|
131
|
+
displayName?: string;
|
|
132
|
+
/** 文件信息(列表里已有的话省一次请求)。 */
|
|
133
|
+
size?: number;
|
|
134
|
+
mtime?: number;
|
|
135
|
+
/** 变了就重新读(看板在文件更新时递增)。 */
|
|
136
|
+
version?: number;
|
|
137
|
+
/** 明确的文件变更事件使内容重新读取,不能只按末尾重叠判断为追加。 */
|
|
138
|
+
reloadKey?: number | string;
|
|
139
|
+
/** 预览里点了别的本地文件(Markdown 里的链接)。 */
|
|
140
|
+
onOpenPath?: (path: string) => void;
|
|
141
|
+
onClose?: () => void;
|
|
142
|
+
/** 头部左侧额外内容(看板放「返回」按钮)。 */
|
|
143
|
+
lead?: ReactNode;
|
|
144
|
+
onBack?: () => void;
|
|
145
|
+
actions?: FileAction[];
|
|
146
|
+
/** 隐藏头部(宿主自己做)。 */
|
|
147
|
+
bare?: boolean;
|
|
148
|
+
/** 看板页签栏提供操作容器,避免重复标题行。 */
|
|
149
|
+
toolbarHost?: HTMLElement | null;
|
|
150
|
+
/** 给了就支持「选中文字 → 引用到对话」:在预览里选中一段文字时浮出按钮(Markdown / 代码 / 文本 / 表格 / HTML / Word,及各自的源文件)。 */
|
|
151
|
+
onQuoteText?: (text: string, context?: { revision: string }) => void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 选中的文字超过这么多只带开头(引用是给智能体指位置的,不是搬运全文)。 */
|
|
155
|
+
const QUOTE_SELECTION_MAX = 3_000;
|
|
156
|
+
|
|
157
|
+
const viewerBackendIds=new WeakMap<BoardBackend,number>();let nextViewerBackendId=1;
|
|
158
|
+
export function FileViewer(props:FileViewerProps) {
|
|
159
|
+
let id=viewerBackendIds.get(props.backend);if(id===undefined){id=nextViewerBackendId++;viewerBackendIds.set(props.backend,id);}
|
|
160
|
+
return <FileViewerInner key={JSON.stringify([id,props.jobId,props.path])} {...props}/>;
|
|
161
|
+
}
|
|
162
|
+
function FileViewerInner({ backend, jobId, path, displayName, size, mtime, version = 0, reloadKey, onOpenPath, onClose, lead, onBack, actions = [], bare, toolbarHost, onQuoteText }: FileViewerProps) {
|
|
163
|
+
const name = displayName || path.split("/").pop() || path;
|
|
164
|
+
const kind: PreviewKind = previewKind(path);
|
|
165
|
+
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
|
166
|
+
const [error, setError] = useState<string | null>(null);
|
|
167
|
+
const [downloadError, setDownloadError] = useState<string | null>(null);
|
|
168
|
+
const [copied, setCopied] = useState(false);
|
|
169
|
+
const [copying, setCopying] = useState(false);
|
|
170
|
+
const [copyError, setCopyError] = useState<string | null>(null);
|
|
171
|
+
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
172
|
+
const [actionsOpen, setActionsOpen] = useState(false);
|
|
173
|
+
const moreRef = useRef<HTMLButtonElement>(null);
|
|
174
|
+
const [busy, setBusy] = useState(false);
|
|
175
|
+
const [mode, setMode] = useState<"preview" | "source">("preview");
|
|
176
|
+
const [htmlPresentation, setHtmlPresentation] = useState<"reading" | "original">("original");
|
|
177
|
+
const htmlReading = kind === "html" && mode === "preview" && htmlPresentation === "reading";
|
|
178
|
+
// 超大文本:看开头还是看末尾(日志看末尾)。
|
|
179
|
+
const [tail, setTail] = useState(false);
|
|
180
|
+
const progressiveText = !!backend.fileSync && (kind === "text" || kind === "code");
|
|
181
|
+
const [textLimit, setTextLimit] = useState(kind === "html" || backend.fileSync && (kind === "markdown" || progressiveText) ? MARKDOWN_STEP : TEXT_MAX);
|
|
182
|
+
const [syncing, setSyncing] = useState(false);
|
|
183
|
+
const [parsing, setParsing] = useState(false);
|
|
184
|
+
const [cancelled, setCancelled] = useState(false);
|
|
185
|
+
const sourceBusy = useCallback((value: boolean) => { setSyncing(value); if (value) setCancelled(false); }, []);
|
|
186
|
+
const readingBusy = useCallback((value: boolean) => { setParsing(value); if (value) setCancelled(false); }, []);
|
|
187
|
+
const revisionRef = useRef<string | undefined>(undefined);
|
|
188
|
+
const fileReadAbort = useRef<AbortController | null>(null);
|
|
189
|
+
const readingAbort = useRef<AbortController | null>(null);
|
|
190
|
+
const cancelRead = () => { fileReadAbort.current?.abort(); readingAbort.current?.abort(); setSyncing(false); setParsing(false); setCancelled(true); };
|
|
191
|
+
useEffect(() => retainFileScope(backend, jobId, { paths: [path], dirs: [], onDenied: message => {
|
|
192
|
+
fileReadAbort.current?.abort(); readingAbort.current?.abort(); revisionRef.current = undefined;
|
|
193
|
+
setLoaded(null); setHtmlDoc(null); setSyncing(false); setParsing(false); setError(message || "文件访问受限,请检查任务文件目录或访问权限");
|
|
194
|
+
} }), [backend, jobId, path]);
|
|
195
|
+
// 放大:盖住页面看(看板窄的时候看报告、表格、图片)。Esc 还原。
|
|
196
|
+
const [big, setBig] = useState(false);
|
|
197
|
+
const previousBig = useRef(big);
|
|
198
|
+
useLayoutEffect(() => {
|
|
199
|
+
if (previousBig.current !== big && toolbarHost !== undefined) moreRef.current?.focus({ preventScroll: true });
|
|
200
|
+
previousBig.current = big;
|
|
201
|
+
}, [big, toolbarHost]);
|
|
202
|
+
// 点「重试」:重新读(图片 / PDF 换一个地址参数,绕开浏览器缓存的失败)。
|
|
203
|
+
const [attempt, setAttempt] = useState(0);
|
|
204
|
+
useEffect(() => () => { if (copiedTimer.current !== null) clearTimeout(copiedTimer.current); }, []);
|
|
205
|
+
const retry = () => { setError(null); setLoaded(null); setAttempt((n) => n + 1); };
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!big) return;
|
|
208
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !e.defaultPrevented && !actionsOpen) setBig(false); };
|
|
209
|
+
const prev = document.body.style.overflow;
|
|
210
|
+
document.body.style.overflow = "hidden";
|
|
211
|
+
window.addEventListener("keydown", onKey);
|
|
212
|
+
return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
|
|
213
|
+
}, [big, actionsOpen]);
|
|
214
|
+
const needsSrc = kind === "image" || kind === "pdf" || kind === "html" && !htmlReading;
|
|
215
|
+
// 这个预览的口令:iframe 里的选区桥报上来的消息只认它(见 frameQuote.ts)。
|
|
216
|
+
const token = useMemo(frameToken, []);
|
|
217
|
+
const links = useFileLinks(backend, jobId, path, version, attempt, reloadKey, !!backend.fileSync && isTaskUploadPath(path));
|
|
218
|
+
const knownSize = size ?? (loaded?.kind === "text" ? loaded.size : undefined);
|
|
219
|
+
// 读大文件时的进度(已收到的字节数)。
|
|
220
|
+
const [progress, setProgress] = useState(0);
|
|
221
|
+
// HTML 默认原样预览,完整重组后插选区桥;加载或排版较慢时可切分段阅读。
|
|
222
|
+
// 新同步协议失败时保留旧完整修订并提示重试;只有自定义旧后端保留原来的地址预览。
|
|
223
|
+
// 文件地址在读到的那一刻定下来:短期链接定时换新时不重载页面。
|
|
224
|
+
const linksRef = useRef(links);
|
|
225
|
+
linksRef.current = links;
|
|
226
|
+
const htmlTooBig = size !== undefined && size > HTML_QUOTE_MAX;
|
|
227
|
+
const htmlBridge = (!!backend.fileSync || !!onQuoteText) && kind === "html" && mode === "preview" && !htmlReading && !links.pending && !htmlTooBig;
|
|
228
|
+
const [htmlDoc, setHtmlDoc] = useState<{ srcDoc: string; token: string; revision?: string } | "fallback" | "toobig" | null>(null);
|
|
229
|
+
useEffect(() => { if (htmlReading || mode === "source") setHtmlDoc(null); }, [htmlReading, mode]);
|
|
230
|
+
const [frameState, setFrameState] = useState<"rendering" | "slow" | "ready">("rendering");
|
|
231
|
+
// 走 srcdoc 时不用再按地址取(中枢太旧时那是又整个读一遍)。
|
|
232
|
+
const bridged = htmlBridge && htmlDoc !== "fallback" && htmlDoc !== "toobig";
|
|
233
|
+
const src = useFileSrc(backend, jobId, path, needsSrc && !bridged && !(kind === "html" && mode === "source"), version, knownSize, links, attempt);
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (!htmlBridge) return;
|
|
236
|
+
const ctl = new AbortController();
|
|
237
|
+
fileReadAbort.current = ctl;
|
|
238
|
+
setError(null); setCancelled(false); setSyncing(true); setProgress(0);
|
|
239
|
+
const loading = backend.fileSync
|
|
240
|
+
? (async () => {
|
|
241
|
+
const transport = backend.fileSync!, options = { signal: ctl.signal, preview: true, fresh: attempt > 0, onProgress: (progress: { loadedBytes: number }) => setProgress(progress.loadedBytes) };
|
|
242
|
+
const result = await syncFile(transport, jobId, path, options);
|
|
243
|
+
return { text: result.text ?? decodeText(result.bytes).text, revision: result.revision, size: result.size };
|
|
244
|
+
})()
|
|
245
|
+
: readBoundedBuffer(backend, jobId, path, HTML_QUOTE_MAX, { signal: ctl.signal, onBytes: setProgress }).then(buffer => ({ text: decodeText(new Uint8Array(buffer)).text, revision: undefined }));
|
|
246
|
+
loading.then(result => {
|
|
247
|
+
if (ctl.signal.aborted) return;
|
|
248
|
+
const url = linksRef.current.url?.(path);
|
|
249
|
+
const base = url ? new URL(url, window.location.href).href : undefined;
|
|
250
|
+
revisionRef.current = result.revision;
|
|
251
|
+
setHtmlDoc(previous => {
|
|
252
|
+
if (result.revision && previous && typeof previous === "object" && previous.revision === result.revision) return previous;
|
|
253
|
+
const bridgeToken = frameToken();
|
|
254
|
+
return { srcDoc: bridgeDoc(result.text, { token: bridgeToken, base }), token: bridgeToken, revision: result.revision };
|
|
255
|
+
});
|
|
256
|
+
}, (error: unknown) => {
|
|
257
|
+
if (ctl.signal.aborted) return;
|
|
258
|
+
if (backend.fileSync) {
|
|
259
|
+
if ([401, 403, 404].includes((error as { status?: number }).status ?? 0)) { setHtmlDoc(null); revisionRef.current = undefined; }
|
|
260
|
+
setError(error instanceof Error ? error.message : String(error));
|
|
261
|
+
} else if (error instanceof FileReadLimitError) setHtmlDoc("toobig");
|
|
262
|
+
else setHtmlDoc("fallback");
|
|
263
|
+
}).finally(() => { if (!ctl.signal.aborted) setSyncing(false); });
|
|
264
|
+
return () => ctl.abort();
|
|
265
|
+
}, [htmlBridge, backend, jobId, path, version, reloadKey, attempt, htmlPresentation]);
|
|
266
|
+
const activeToken = htmlDoc && typeof htmlDoc === "object" ? htmlDoc.token : token;
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!bridged || !htmlDoc || typeof htmlDoc !== "object") return;
|
|
269
|
+
setFrameState("rendering");
|
|
270
|
+
const timer = setTimeout(() => setFrameState(state => state === "ready" ? state : "slow"), 12_000);
|
|
271
|
+
return () => clearTimeout(timer);
|
|
272
|
+
}, [bridged, htmlDoc]);
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
// 重读时保留滚动位置;原本贴着底(或在看末尾)的继续贴着底——任务在往文件里写时像 tail -f。
|
|
276
|
+
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
277
|
+
const loadedRef = useRef(loaded);
|
|
278
|
+
loadedRef.current = loaded;
|
|
279
|
+
const textKey = useRef("");
|
|
280
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
281
|
+
const selectionButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
282
|
+
const stick = useRef(false);
|
|
283
|
+
// 选中文字 → 浮出「引用到对话」。按钮放在选区上方;滚动、点别处、选区清空时收起。
|
|
284
|
+
// frame = 选区在 HTML / Word 预览的 iframe 里(由插进去的小脚本报上来,见 frameQuote.ts)。
|
|
285
|
+
const [sel, setSel] = useState<{ text: string; x: number; y: number; frame?: boolean; revision?: string } | null>(null);
|
|
286
|
+
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
|
287
|
+
const frameScroll = useRef({ top: 0, left: 0 });
|
|
288
|
+
useEffect(() => { setSel(null); }, [htmlDoc, loaded, mode]);
|
|
289
|
+
const sourceRevision = useCallback((revision: string | undefined) => { revisionRef.current = revision; setSel(null); }, []);
|
|
290
|
+
useEffect(() => {
|
|
291
|
+
const onMessage = (e: MessageEvent) => {
|
|
292
|
+
const frame = frameRef.current;
|
|
293
|
+
const root = rootRef.current;
|
|
294
|
+
const data = e.data as FrameSelMessage | null;
|
|
295
|
+
if (!frame || !root || e.source !== frame.contentWindow || !data || data.cliUiSel !== activeToken) return;
|
|
296
|
+
if (data.ready) { setFrameState("ready"); return; }
|
|
297
|
+
if (data.scroll) frameScroll.current = { top: Math.max(0, data.top ?? 0), left: Math.max(0, data.left ?? 0) };
|
|
298
|
+
if (!onQuoteText) return;
|
|
299
|
+
const text = data.scroll ? "" : (data.text ?? "").trim();
|
|
300
|
+
if (!text) { setSel((cur) => (cur?.frame ? null : cur)); return; }
|
|
301
|
+
const box = root.getBoundingClientRect();
|
|
302
|
+
const fr = frame.getBoundingClientRect();
|
|
303
|
+
const r = data.rect ?? { left: fr.width / 2, top: 48, width: 0 };
|
|
304
|
+
setSel({ text, frame: true, revision: revisionRef.current, x: Math.min(Math.max(8, fr.left - box.left + r.left + r.width / 2), box.width - 8), y: Math.max(40, fr.top - box.top + r.top) });
|
|
305
|
+
};
|
|
306
|
+
window.addEventListener("message", onMessage);
|
|
307
|
+
return () => window.removeEventListener("message", onMessage);
|
|
308
|
+
}, [onQuoteText, activeToken]);
|
|
309
|
+
useEffect(() => {
|
|
310
|
+
if (!onQuoteText) return;
|
|
311
|
+
const read = () => {
|
|
312
|
+
const s = window.getSelection();
|
|
313
|
+
const body = bodyRef.current;
|
|
314
|
+
const root = rootRef.current;
|
|
315
|
+
// 宿主页面上没有选区:iframe 里的选区(另一个文档)不在这里,交给上面的消息处理,不清掉。
|
|
316
|
+
if (!s || s.isCollapsed || !s.rangeCount) { setSel((cur) => (cur?.frame ? cur : null)); return; }
|
|
317
|
+
if (!body || !root || !body.contains(s.anchorNode) || !body.contains(s.focusNode)) { setSel(null); return; }
|
|
318
|
+
const text = s.toString().trim();
|
|
319
|
+
if (!text) { setSel(null); return; }
|
|
320
|
+
const box = root.getBoundingClientRect();
|
|
321
|
+
// 取不到选区位置的环境(老浏览器、测试环境):按钮放在预览顶部中间。
|
|
322
|
+
const range = s.getRangeAt(0);
|
|
323
|
+
const r = typeof range.getBoundingClientRect === "function" ? range.getBoundingClientRect() : { left: box.left + box.width / 2, width: 0, top: box.top + 48 };
|
|
324
|
+
setSel({ text, revision: revisionRef.current, x: Math.min(Math.max(8, r.left - box.left + r.width / 2), box.width - 8), y: Math.max(40, r.top - box.top) });
|
|
325
|
+
};
|
|
326
|
+
let selectionTimer: ReturnType<typeof setTimeout> | undefined;
|
|
327
|
+
const onUp = () => {
|
|
328
|
+
if (selectionTimer !== undefined) clearTimeout(selectionTimer);
|
|
329
|
+
selectionTimer = setTimeout(() => { selectionTimer = undefined; read(); }, 0);
|
|
330
|
+
};
|
|
331
|
+
// Native touch selection/handle changes need not generate mouseup or keyup.
|
|
332
|
+
const onChange = onUp;
|
|
333
|
+
const body = bodyRef.current;
|
|
334
|
+
document.addEventListener("mouseup", onUp);
|
|
335
|
+
document.addEventListener("keyup", onUp);
|
|
336
|
+
document.addEventListener("selectionchange", onChange);
|
|
337
|
+
const onScroll = () => setSel(null);
|
|
338
|
+
// 选区在 iframe 里时,点宿主页面别处(不是这个按钮)就收起。
|
|
339
|
+
const onDown = (e: MouseEvent) => { if (!(e.target instanceof Element && e.target.closest(".cli-fv-selq"))) setSel((cur) => (cur?.frame ? null : cur)); };
|
|
340
|
+
body?.addEventListener("scroll", onScroll, { passive: true });
|
|
341
|
+
document.addEventListener("mousedown", onDown);
|
|
342
|
+
return () => {
|
|
343
|
+
if (selectionTimer !== undefined) clearTimeout(selectionTimer);
|
|
344
|
+
document.removeEventListener("mousedown", onDown);
|
|
345
|
+
document.removeEventListener("mouseup", onUp);
|
|
346
|
+
document.removeEventListener("keyup", onUp);
|
|
347
|
+
document.removeEventListener("selectionchange", onChange);
|
|
348
|
+
body?.removeEventListener("scroll", onScroll);
|
|
349
|
+
};
|
|
350
|
+
}, [onQuoteText, loaded]);
|
|
351
|
+
useLayoutEffect(() => {
|
|
352
|
+
const root = rootRef.current, button = selectionButtonRef.current;
|
|
353
|
+
if (!sel || !root || !button) return;
|
|
354
|
+
const place = () => {
|
|
355
|
+
const bounds = root.getBoundingClientRect(), size = button.getBoundingClientRect();
|
|
356
|
+
if (bounds.width <= 0) return;
|
|
357
|
+
const half = size.width / 2;
|
|
358
|
+
const headerHeight = root.querySelector(".cli-fv-h")?.getBoundingClientRect().height ?? 0;
|
|
359
|
+
const minimumTop = Math.max(8, headerHeight) + size.height + 6;
|
|
360
|
+
button.style.left = `${Math.max(half + 8, Math.min(sel.x, bounds.width - half - 8))}px`;
|
|
361
|
+
button.style.top = `${Math.max(minimumTop, Math.min(sel.y, bounds.height - 8))}px`;
|
|
362
|
+
};
|
|
363
|
+
place();
|
|
364
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(place);
|
|
365
|
+
observer?.observe(root); observer?.observe(button);
|
|
366
|
+
return () => observer?.disconnect();
|
|
367
|
+
}, [sel]);
|
|
368
|
+
const quoteSelection = () => {
|
|
369
|
+
if (!sel || !onQuoteText) return;
|
|
370
|
+
const text = sel.text.length > QUOTE_SELECTION_MAX ? `${sel.text.slice(0, QUOTE_SELECTION_MAX)}…` : sel.text;
|
|
371
|
+
if (sel.revision) onQuoteText(text, { revision: sel.revision }); else onQuoteText(text);
|
|
372
|
+
if (sel.frame) frameRef.current?.contentWindow?.postMessage({ cliUiClear: activeToken }, "*");
|
|
373
|
+
else window.getSelection()?.removeAllRanges();
|
|
374
|
+
setSel(null);
|
|
375
|
+
};
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
let alive = true;
|
|
378
|
+
const ctl=new AbortController();
|
|
379
|
+
setError(null); setCancelled(false);
|
|
380
|
+
if (needsSrc || kind === "binary") { setLoaded({ kind: "none" }); return; }
|
|
381
|
+
fileReadAbort.current = ctl;
|
|
382
|
+
if (kind === "docx") {
|
|
383
|
+
if (size !== undefined && size > DOCX_PREVIEW_MAX) { setError(`文件较大(${fmtSize(size)}),请下载后查看`); return; }
|
|
384
|
+
setProgress(0);
|
|
385
|
+
readBoundedBuffer(backend, jobId, path, DOCX_PREVIEW_MAX, { signal:ctl.signal,onBytes: (n) => { if (alive) setProgress(n); } }).then(async (buffer) => {
|
|
386
|
+
if(!alive)return;
|
|
387
|
+
const mammoth = await import("mammoth");
|
|
388
|
+
const convert = (mammoth as unknown as { convertToHtml: typeof import("mammoth").convertToHtml }).convertToHtml
|
|
389
|
+
?? (mammoth as unknown as { default: typeof import("mammoth") }).default.convertToHtml;
|
|
390
|
+
if(!alive)return;
|
|
391
|
+
const input: Record<string, unknown> = { arrayBuffer: buffer };
|
|
392
|
+
const B = (globalThis as { Buffer?: { from(b: ArrayBuffer): unknown } }).Buffer;
|
|
393
|
+
if (B) input.buffer = B.from(buffer);
|
|
394
|
+
const out = await convert(input as unknown as Parameters<typeof convert>[0]);
|
|
395
|
+
if (alive) setLoaded({ kind: "docx", html: out.value });
|
|
396
|
+
}).catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
|
|
397
|
+
} else {
|
|
398
|
+
// A refresh verifies the immutable revision and reuses matching blocks.
|
|
399
|
+
const key = `${path}|${tail}|${attempt}|${reloadKey??""}`;
|
|
400
|
+
const prev = loadedRef.current?.kind === "text" && textKey.current === key ? loadedRef.current : null;
|
|
401
|
+
textKey.current = key;
|
|
402
|
+
if (!prev) setProgress(0);
|
|
403
|
+
setSyncing(true);
|
|
404
|
+
const max = htmlReading || backend.fileSync && (kind === "markdown" || progressiveText) ? textLimit : TEXT_MAX;
|
|
405
|
+
const full = () => loadText(backend, jobId, path, { max, tail, preview: true, fresh: attempt > 0, signal: ctl.signal, onBytes: (n) => { if (alive && !prev) setProgress(n); } });
|
|
406
|
+
(prev ? refreshText(backend, jobId, path, prev, max, { signal: ctl.signal, preview: true, fresh: attempt > 0 }).then((r) => r ?? full()) : full()).then(
|
|
407
|
+
(r) => {
|
|
408
|
+
if (!alive || ctl.signal.aborted) return;
|
|
409
|
+
const box = bodyRef.current;
|
|
410
|
+
stick.current = tail || (!!box && box.scrollHeight > box.clientHeight && box.scrollHeight - box.scrollTop - box.clientHeight < 40);
|
|
411
|
+
revisionRef.current = r.revision;
|
|
412
|
+
setLoaded({ kind: "text", ...r });
|
|
413
|
+
},
|
|
414
|
+
(e: unknown) => { if (alive && !ctl.signal.aborted) {
|
|
415
|
+
if ([401, 403, 404].includes((e as { status?: number }).status ?? 0)) { setLoaded(null); revisionRef.current = undefined; }
|
|
416
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
417
|
+
} },
|
|
418
|
+
).finally(() => { if (alive) setSyncing(false); });
|
|
419
|
+
}
|
|
420
|
+
return () => { alive = false; ctl.abort(); };
|
|
421
|
+
}, [backend, jobId, path, kind, needsSrc, version, reloadKey, tail, attempt, textLimit, htmlReading]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
422
|
+
useLayoutEffect(() => {
|
|
423
|
+
const box = bodyRef.current;
|
|
424
|
+
if (box && stick.current && loaded?.kind === "text") box.scrollTop = box.scrollHeight;
|
|
425
|
+
}, [loaded]);
|
|
426
|
+
|
|
427
|
+
const fileInfo = useCallback(() => ({
|
|
428
|
+
path, name, url: links.url?.(path) ?? backend.fileUrl(jobId, path), downloadUrl: links.url?.(path, true) ?? backend.fileUrl(jobId, path, { download: true }),
|
|
429
|
+
}), [backend, jobId, path, name, links]);
|
|
430
|
+
|
|
431
|
+
const download = async () => {
|
|
432
|
+
if (busy) return;
|
|
433
|
+
setBusy(true); setDownloadError(null);
|
|
434
|
+
try { await downloadFile(backend, jobId, path, knownSize); } catch (e) { setDownloadError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); }
|
|
435
|
+
};
|
|
436
|
+
const openUrl = links.url ? links.url(path) : src.url;
|
|
437
|
+
const canOpenExternal = !!openUrl && !(backend.fileSync && isTaskUploadPath(path));
|
|
438
|
+
const openNew = () => { if (canOpenExternal && openUrl) window.open(openUrl, "_blank", "noopener"); };
|
|
439
|
+
const copyPath = async () => {
|
|
440
|
+
if (copying) return;
|
|
441
|
+
setCopying(true); setCopyError(null); setCopied(false);
|
|
442
|
+
try {
|
|
443
|
+
if (!navigator.clipboard?.writeText) throw new Error("当前浏览器不支持自动复制,可从文件操作菜单选择路径手动复制");
|
|
444
|
+
await navigator.clipboard.writeText(path);
|
|
445
|
+
setCopied(true);
|
|
446
|
+
if (copiedTimer.current !== null) clearTimeout(copiedTimer.current);
|
|
447
|
+
copiedTimer.current = setTimeout(() => setCopied(false), 1200);
|
|
448
|
+
} catch (error) { setCopyError(`复制路径失败:${error instanceof Error ? error.message : "请重试"}`); }
|
|
449
|
+
finally { setCopying(false); }
|
|
450
|
+
};
|
|
451
|
+
const performAction = (action: () => void) => {
|
|
452
|
+
setActionsOpen(false);
|
|
453
|
+
moreRef.current?.focus({ preventScroll: true });
|
|
454
|
+
action();
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// Markdown 里的相对链接 / 图片:相对这个文件所在目录。
|
|
458
|
+
const mdResolve = useMemo(() => (href: string) => links.url?.(resolveRelative(path, href)) ?? null, [links, path]);
|
|
459
|
+
const mdOpen = useMemo(() => (onOpenPath ? (href: string) => { onOpenPath(resolveRelative(path, href)); return true; } : null), [onOpenPath, path]);
|
|
460
|
+
|
|
461
|
+
const canSource = kind === "markdown" || kind === "html" || kind === "csv";
|
|
462
|
+
const visibleActions = actions.filter(action => !action.when || action.when({ path, name }));
|
|
463
|
+
const shownSize = knownSize;
|
|
464
|
+
const text = loaded?.kind === "text" ? loaded : null;
|
|
465
|
+
const loading = !error && !src.error && (needsSrc ? (bridged ? htmlDoc === null : !src.url) : !loaded);
|
|
466
|
+
const slow = useSlow(loading);
|
|
467
|
+
// 大文件显示读到哪了;很久没读完提示网络慢。
|
|
468
|
+
const progressText = progress > 0 ? `已读取 ${fmtSize(progress)}${shownSize ? ` / ${fmtSize(shownSize)}` : ""}` : "";
|
|
469
|
+
const slowNote = progressText || slow
|
|
470
|
+
? <div className="cli-fv-slow">{slow ? `网络较慢或执行节点忙,仍在读取${progressText ? `(${progressText})` : shownSize ? `(${fmtSize(shownSize)})` : ""}…` : progressText}</div>
|
|
471
|
+
: null;
|
|
472
|
+
|
|
473
|
+
const embedded = toolbarHost !== undefined && !big;
|
|
474
|
+
const toolbar = <>
|
|
475
|
+
{(copying || copied || busy) && <span className="cli-fv-status" role="status">{copying ? "正在复制路径…" : copied ? "路径已复制" : "正在下载…"}</span>}
|
|
476
|
+
<div className="cli-fv-actions">
|
|
477
|
+
{embedded && <button className="cli-fv-icon" title={syncing || parsing ? "正在同步当前文件" : "刷新当前文件"} aria-label="刷新当前文件" aria-busy={syncing || parsing} disabled={syncing || parsing} onClick={() => setAttempt(value => value + 1)}>↻</button>}
|
|
478
|
+
<button ref={moreRef} className="cli-fv-icon cli-fv-more" title="更多文件操作" aria-label="更多文件操作" aria-haspopup="dialog" aria-expanded={actionsOpen} onClick={() => setActionsOpen(open => !open)}>{Icon.dots()}</button>
|
|
479
|
+
{actionsOpen && <Popover className="cli-fv-menu" ariaLabel="文件操作" returnFocusRef={moreRef} onClose={() => setActionsOpen(false)}>
|
|
480
|
+
<div className="cli-fv-menu-head"><strong>文件操作</strong><span title={path}>{path}</span></div>
|
|
481
|
+
<button className="pop-item" disabled={syncing || parsing} onClick={() => performAction(() => setAttempt(value => value + 1))}>刷新内容</button>
|
|
482
|
+
{onBack && <button className="pop-item" title="返回文件列表" onClick={() => performAction(onBack)}>返回文件列表</button>}
|
|
483
|
+
{visibleActions.length > 0 && <div className="cli-fv-menu-custom">{visibleActions.map(action => (
|
|
484
|
+
<button key={action.label} className="pop-item" title={action.title} onClick={() => performAction(() => action.onClick(fileInfo(), jobId))}>{action.label}</button>
|
|
485
|
+
))}</div>}
|
|
486
|
+
{canSource && <button className="pop-item" onClick={() => performAction(() => setMode(mode === "preview" ? "source" : "preview"))} title={mode === "preview" ? "查看源文件" : "查看预览"}>{Icon.doc()} {mode === "preview" ? "源文件" : "预览"}</button>}
|
|
487
|
+
{kind === "html" && <button className="pop-item" onClick={() => performAction(() => { setMode("preview"); setHtmlPresentation(htmlReading ? "original" : "reading"); })}>{htmlReading ? "原样预览" : "分段阅读"}</button>}
|
|
488
|
+
<button className="pop-item" disabled={copying} onClick={() => performAction(() => void copyPath())} title={copied ? "已复制" : "复制路径"}>{copied ? Icon.check() : Icon.copy()} {copying ? "复制中…" : copied ? "路径已复制" : "复制路径"}</button>
|
|
489
|
+
<button className="pop-item" onClick={() => performAction(() => setBig((b) => !b))} title={big ? "还原(Esc)" : "放大查看"} aria-pressed={big}>{big ? "⤡" : "⤢"} {big ? "还原大小" : "放大查看"}</button>
|
|
490
|
+
<button className="pop-item" onClick={() => performAction(openNew)} title={openUrl && !canOpenExternal ? "此附件可在看板预览或下载" : "在新窗口打开"} disabled={!canOpenExternal}>{Icon.external()} 在新窗口打开</button>
|
|
491
|
+
<button className="pop-item" onClick={() => performAction(() => void download())} title="下载" disabled={busy}>{Icon.download()} {busy ? "下载中…" : "下载"}</button>
|
|
492
|
+
</Popover>}
|
|
493
|
+
</div>
|
|
494
|
+
</>;
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
<>
|
|
498
|
+
{big && <div className="cli-ui cli-fv-backdrop" onClick={() => setBig(false)} />}
|
|
499
|
+
<div className={`cli-fv ${big ? "cli-fv-big" : ""}`} ref={rootRef} role={big ? "dialog" : undefined} aria-modal={big || undefined} aria-label={big ? name : undefined}>
|
|
500
|
+
{sel && (
|
|
501
|
+
<button ref={selectionButtonRef} className="cli-fv-selq" style={{ left: sel.x, top: sel.y }} onPointerDown={event => event.preventDefault()} onMouseDown={(e) => e.preventDefault()} onClick={quoteSelection} title={`把选中的${sel.text.length > QUOTE_SELECTION_MAX ? `前 ${QUOTE_SELECTION_MAX}` : sel.text.length}字带进下一条消息`}>
|
|
502
|
+
{Icon.board()} 引用到对话
|
|
503
|
+
</button>
|
|
504
|
+
)}
|
|
505
|
+
{!bare && embedded && toolbarHost && createPortal(toolbar, toolbarHost)}
|
|
506
|
+
{!bare && !embedded && (
|
|
507
|
+
<div className="cli-fv-h">
|
|
508
|
+
{lead}
|
|
509
|
+
<span className="cli-fv-ic">{Icon.doc()}</span>
|
|
510
|
+
<span className="cli-fv-name" title={path}>{name}</span>
|
|
511
|
+
<span className="cli-fv-meta">
|
|
512
|
+
{shownSize !== undefined && fmtSize(shownSize)}
|
|
513
|
+
{mtime && mtime > 0 ? ` · ${relTime(mtime)}更新` : ""}
|
|
514
|
+
{text?.encoding && text.encoding !== "utf-8" ? ` · ${text.encoding.toUpperCase()}` : ""}
|
|
515
|
+
</span>
|
|
516
|
+
<span className="cli-fv-sp" />
|
|
517
|
+
{toolbar}
|
|
518
|
+
{onClose && <button className="cli-fv-icon" onClick={onClose} title="关闭">{Icon.x()}</button>}
|
|
519
|
+
</div>
|
|
520
|
+
)}
|
|
521
|
+
{copyError && <div className="cli-fv-trunc" role="alert">{copyError}
|
|
522
|
+
<button className="cli-fv-btn" disabled={copying} onClick={() => void copyPath()}>重试复制</button>
|
|
523
|
+
<button className="cli-fv-btn" onClick={() => setCopyError(null)} aria-label="关闭复制错误">关闭</button>
|
|
524
|
+
</div>}
|
|
525
|
+
{downloadError && <div className="cli-fv-trunc" role="alert">下载失败:{downloadError}
|
|
526
|
+
<button className="cli-fv-btn" disabled={busy} onClick={() => void download()}>重试下载</button>
|
|
527
|
+
<button className="cli-fv-btn" onClick={() => setDownloadError(null)} aria-label="关闭下载错误">关闭</button>
|
|
528
|
+
</div>}
|
|
529
|
+
{error && (loaded?.kind === "text" || (htmlDoc && typeof htmlDoc === "object")) && <div className="cli-fv-trunc" role="alert">同步失败:{error}。保留上次完整内容。<button className="cli-fv-btn" onClick={() => setAttempt(value => value + 1)}>重试</button></div>}
|
|
530
|
+
{(syncing || parsing) && <div className="cli-fv-sync" role="status">{!syncing && parsing ? "正在整理正文…" : loaded?.kind === "text" || (htmlDoc && typeof htmlDoc === "object") ? "正在核对更新…" : "正在读取文件…"}<button className="cli-fv-btn" onClick={cancelRead}>暂停加载</button>{kind === "html" && mode === "preview" && !htmlReading && <button className="cli-fv-btn" onClick={() => setHtmlPresentation("reading")}>分段阅读</button>}</div>}
|
|
531
|
+
{cancelled && <div className="cli-fv-trunc" role="status">已暂停加载,已读取的内容仍然保留。<button className="cli-fv-btn" onClick={() => setAttempt(value => value + 1)}>继续加载</button></div>}
|
|
532
|
+
{bridged && htmlDoc && typeof htmlDoc === "object" && frameState !== "ready" && <div className="cli-fv-trunc cli-fv-render" role="status">{frameState === "slow" ? "原样预览排版较慢,可切换分段阅读。" : "正在显示页面…"}<button className="cli-fv-btn" onClick={() => setHtmlPresentation("reading")}>分段阅读</button></div>}
|
|
533
|
+
<div className={`cli-fv-b k-${kind}${kind === "html" && (htmlReading || mode === "source") ? " cli-fv-flow" : ""}`} ref={bodyRef} onScroll={event => {
|
|
534
|
+
const el = event.currentTarget;
|
|
535
|
+
if (kind === "markdown" && backend.fileSync && loaded?.kind === "text" && loaded.truncated && !syncing && !error && el.scrollHeight - el.scrollTop - el.clientHeight < 500) setTextLimit(limit => nextTextLimit(limit));
|
|
536
|
+
}}>
|
|
537
|
+
{(error && loaded?.kind !== "text" && !(htmlDoc && typeof htmlDoc === "object")) || src.error ? (
|
|
538
|
+
<div className="cli-fv-note err">读取失败:{error ?? src.error}
|
|
539
|
+
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={retry}>重试</button>
|
|
540
|
+
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
|
|
541
|
+
</div>
|
|
542
|
+
) : kind === "binary" ? (
|
|
543
|
+
<div className="cli-fv-note">这种文件不能在这里预览。
|
|
544
|
+
<button className="btn btn-accent btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
|
|
545
|
+
</div>
|
|
546
|
+
) : needsSrc ? (
|
|
547
|
+
kind === "html" && mode === "source" ? <SourceView backend={backend} jobId={jobId} path={path} version={`${version}:${reloadKey ?? ""}:${attempt}`} fresh={attempt > 0} onBusyChange={sourceBusy} onRevision={sourceRevision} abortRef={fileReadAbort} scrollRef={bodyRef} />
|
|
548
|
+
: bridged && htmlDoc === null ? (cancelled ? null : <div className="cli-fv-note">加载中…{slowNote}</div>)
|
|
549
|
+
// 可以选中引用:srcdoc + 选区桥。沙箱同样不给同源,页面自己的脚本被插入的 CSP 挡住,只有桥脚本能跑。
|
|
550
|
+
: bridged && htmlDoc ? <iframe ref={frameRef} className="cli-fv-frame" srcDoc={htmlDoc.srcDoc} title={name} onLoad={() => frameRef.current?.contentWindow?.postMessage({ cliUiRestore: htmlDoc.token, ...frameScroll.current }, "*")} sandbox="allow-scripts allow-popups allow-forms allow-modals" />
|
|
551
|
+
: !src.url ? <div className="cli-fv-note">加载中…{slowNote}</div>
|
|
552
|
+
: kind === "image" ? <ImageView key={src.url} src={src.url} name={name} onRetry={retry} />
|
|
553
|
+
: kind === "pdf" ? <iframe className="cli-fv-frame" src={src.url} title={name} />
|
|
554
|
+
: mode === "source" ? <SourceView backend={backend} jobId={jobId} path={path} version={`${version}:${reloadKey ?? ""}:${attempt}`} fresh={attempt > 0} onBusyChange={sourceBusy} onRevision={sourceRevision} abortRef={fileReadAbort} scrollRef={bodyRef} />
|
|
555
|
+
// HTML:节点按沙箱 CSP 返回;这里再加 iframe 沙箱(不给同源),页面脚本碰不到宿主。
|
|
556
|
+
: <>
|
|
557
|
+
{onQuoteText && (htmlTooBig || htmlDoc === "toobig") && <div className="cli-fv-trunc">文件超过 {fmtSize(HTML_QUOTE_MAX)},直接打开显示,不能选中文字引用(可以用「引用到对话」引用整个文件)。</div>}
|
|
558
|
+
<iframe className="cli-fv-frame" src={src.url} title={name} sandbox="allow-scripts allow-popups allow-forms allow-modals" />
|
|
559
|
+
</>
|
|
560
|
+
) : !loaded ? (
|
|
561
|
+
cancelled ? null : <div className="cli-fv-note">加载中…{slowNote}</div>
|
|
562
|
+
) : loaded.kind === "docx" ? (
|
|
563
|
+
// Word:转出来的 HTML 放进沙箱。能选中引用时插上选区桥(只放行桥脚本,文档里不会有别的脚本能跑)。
|
|
564
|
+
onQuoteText
|
|
565
|
+
? <iframe ref={frameRef} className="cli-fv-frame doc" title={name} sandbox="allow-scripts" srcDoc={bridgeDoc(docxPage(loaded.html), { token })} />
|
|
566
|
+
: <iframe className="cli-fv-frame doc" title={name} sandbox="" srcDoc={docxPage(loaded.html)} />
|
|
567
|
+
) : text ? (
|
|
568
|
+
<>
|
|
569
|
+
{(text.truncated || tail) && !htmlReading && !(kind === "markdown" && backend.fileSync) && (
|
|
570
|
+
<div className="cli-fv-trunc">
|
|
571
|
+
{tail && !text.tail ? "执行节点不支持只读末尾,下面仍是开头;" : ""}
|
|
572
|
+
文件较大({fmtSize(text.size)}),只显示{text.tail ? "最后" : "前"} {fmtSize(progressiveText ? text.end - text.start : TEXT_MAX)};完整内容请下载。
|
|
573
|
+
{progressiveText && text.truncated && textLimit < TEXT_MAX && <button className="cli-fv-btn" disabled={syncing || !!error} onClick={() => setTextLimit(limit => Math.min(TEXT_MAX, nextTextLimit(limit)))}>{syncing ? "读取中…" : "继续读取"}</button>}
|
|
574
|
+
<button className="cli-fv-btn" onClick={() => { if (progressiveText) setTextLimit(MARKDOWN_STEP); setTail(!tail); }}>{tail ? "看开头" : "看末尾"}</button>
|
|
575
|
+
</div>
|
|
576
|
+
)}
|
|
577
|
+
{htmlReading ? <HtmlReadingPreview text={text.text} complete={!text.truncated} version={`${version}:${reloadKey ?? ""}:${attempt}:${text.revision ?? ""}`} onBusyChange={readingBusy} abortRef={readingAbort} scrollRef={bodyRef}
|
|
578
|
+
onSource={() => setMode("source")} onOriginal={() => setHtmlPresentation("original")} onMore={!syncing && !cancelled && text.truncated && textLimit < HTML_QUOTE_MAX ? () => setTextLimit(limit => nextTextLimit(limit)) : undefined} /> : kind === "markdown" && mode === "preview" ? (
|
|
579
|
+
<FileLinkContext.Provider value={mdResolve}>
|
|
580
|
+
<FileOpenContext.Provider value={mdOpen}>
|
|
581
|
+
<div className="cli-fv-md"><MarkdownPreview text={text.text} scrollRef={bodyRef} /></div>
|
|
582
|
+
{text.truncated && backend.fileSync && <div className="cli-fv-trunc"><button className="cli-fv-btn" disabled={syncing} onClick={() => setTextLimit(limit => nextTextLimit(limit))}>{syncing ? "读取中…" : "继续读取"}</button>已读取 {fmtSize(text.end)} / {fmtSize(text.size)}</div>}
|
|
583
|
+
</FileOpenContext.Provider>
|
|
584
|
+
</FileLinkContext.Provider>
|
|
585
|
+
) : kind === "csv" && mode === "preview" ? (
|
|
586
|
+
<CsvTable text={text.text} />
|
|
587
|
+
) : kind === "code" && text.text.length <= HIGHLIGHT_MAX ? (
|
|
588
|
+
<div className="cli-fv-code"><Markdown>{fenced(text.text, codeLang(path))}</Markdown></div>
|
|
589
|
+
) : (
|
|
590
|
+
// 追加的内容各是一个文字节点:文件往后长的时候前面的不用重新排版。
|
|
591
|
+
<pre className="cli-fv-pre">{text.parts.map((p, i) => <Fragment key={i}>{p}</Fragment>)}</pre>
|
|
592
|
+
)}
|
|
593
|
+
</>
|
|
594
|
+
) : null}
|
|
595
|
+
</div>
|
|
596
|
+
</div>
|
|
597
|
+
</>
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** 图片:缺省适应宽度;可以放大缩小、看原始大小,Ctrl / ⌘ + 滚轮也能缩放。 */
|
|
602
|
+
function ImageView({ src, name, onRetry }: { src: string; name: string; onRetry?: () => void }) {
|
|
603
|
+
const [scale, setScale] = useState<number | null>(null); // null = 适应
|
|
604
|
+
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
|
605
|
+
// 加载失败(网络断了、链接过期、文件坏了 / 不是浏览器认识的图片):说清楚,给重试。
|
|
606
|
+
const [failed, setFailed] = useState(false);
|
|
607
|
+
const loadingImg = !natural && !failed;
|
|
608
|
+
const slow = useSlow(loadingImg);
|
|
609
|
+
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
610
|
+
const fitScale = () => {
|
|
611
|
+
const box = boxRef.current;
|
|
612
|
+
if (!box || !natural) return 1;
|
|
613
|
+
return Math.min(1, (box.clientWidth - 32) / natural.w);
|
|
614
|
+
};
|
|
615
|
+
const zoom = (factor: number) => setScale((s) => Math.min(8, Math.max(0.1, (s ?? fitScale()) * factor)));
|
|
616
|
+
useEffect(() => {
|
|
617
|
+
const box = boxRef.current;
|
|
618
|
+
if (!box) return;
|
|
619
|
+
const onWheel = (e: WheelEvent) => {
|
|
620
|
+
if (!e.ctrlKey && !e.metaKey) return;
|
|
621
|
+
e.preventDefault();
|
|
622
|
+
zoom(e.deltaY < 0 ? 1.1 : 1 / 1.1);
|
|
623
|
+
};
|
|
624
|
+
box.addEventListener("wheel", onWheel, { passive: false });
|
|
625
|
+
return () => box.removeEventListener("wheel", onWheel);
|
|
626
|
+
}); // eslint-disable-line react-hooks/exhaustive-deps
|
|
627
|
+
const style = scale === null || !natural ? undefined : { width: Math.round(natural.w * scale), maxWidth: "none" };
|
|
628
|
+
return (
|
|
629
|
+
<div className="cli-fv-imgwrap">
|
|
630
|
+
<div className="cli-fv-imgbar">
|
|
631
|
+
<button className="cli-fv-btn" onClick={() => zoom(1 / 1.25)} title="缩小">−</button>
|
|
632
|
+
<span className="cli-fv-imgpct">{scale === null ? "适应" : `${Math.round(scale * 100)}%`}</span>
|
|
633
|
+
<button className="cli-fv-btn" onClick={() => zoom(1.25)} title="放大">+</button>
|
|
634
|
+
<button className="cli-fv-btn" onClick={() => setScale(null)} disabled={scale === null}>适应</button>
|
|
635
|
+
<button className="cli-fv-btn" onClick={() => setScale(1)} disabled={scale === 1}>原始大小</button>
|
|
636
|
+
{natural && <span className="cli-fv-imgpct">{natural.w} × {natural.h}</span>}
|
|
637
|
+
</div>
|
|
638
|
+
<div className={`cli-fv-img ${scale === null ? "" : "zoomed"}`} ref={boxRef}>
|
|
639
|
+
{failed ? (
|
|
640
|
+
<div className="cli-fv-img-err">图片没有加载出来(网络中断、链接过期,或文件损坏 / 格式不支持)。
|
|
641
|
+
{onRetry && <button className="btn btn-line btn-sm" onClick={onRetry}>重试</button>}
|
|
642
|
+
</div>
|
|
643
|
+
) : (
|
|
644
|
+
<>
|
|
645
|
+
<img src={src} alt={name} style={style} onLoad={(e) => setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })} onError={() => setFailed(true)} />
|
|
646
|
+
{slow && <div className="cli-fv-slow">图片较大或网络较慢,仍在加载…</div>}
|
|
647
|
+
</>
|
|
648
|
+
)}
|
|
649
|
+
</div>
|
|
650
|
+
</div>
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function SourceView({ backend, jobId, path, version, fresh, onBusyChange, onRevision, abortRef, scrollRef }: { backend: BoardBackend; jobId: string; path: string; version: string; fresh?: boolean; onBusyChange: (busy: boolean) => void; onRevision: (revision: string | undefined) => void; abortRef: RefObject<AbortController | null>; scrollRef: RefObject<HTMLDivElement | null> }) {
|
|
655
|
+
const [state, setState] = useState<LoadedText | null>(null);
|
|
656
|
+
const [error, setError] = useState<string | null>(null);
|
|
657
|
+
const [limit, setLimit] = useState(backend.fileSync ? MARKDOWN_STEP : TEXT_MAX);
|
|
658
|
+
const [busy, setBusy] = useState(false);
|
|
659
|
+
useEffect(() => {
|
|
660
|
+
const ctl = new AbortController(); abortRef.current = ctl;
|
|
661
|
+
const cancelled = () => { setBusy(false); onBusyChange(false); };
|
|
662
|
+
ctl.signal.addEventListener("abort", cancelled, { once: true });
|
|
663
|
+
setBusy(true); onBusyChange(true); setError(null);
|
|
664
|
+
loadText(backend, jobId, path, { max: limit, signal: ctl.signal, preview: true, fresh }).then(result => {
|
|
665
|
+
if (ctl.signal.aborted) return;
|
|
666
|
+
setState(result); onRevision(result.revision);
|
|
667
|
+
}, (error: unknown) => {
|
|
668
|
+
if (ctl.signal.aborted) return;
|
|
669
|
+
if ([401, 403, 404].includes((error as { status?: number }).status ?? 0)) { setState(null); onRevision(undefined); }
|
|
670
|
+
setError(error instanceof Error ? error.message : String(error));
|
|
671
|
+
}).finally(() => { if (!ctl.signal.aborted) { setBusy(false); onBusyChange(false); } });
|
|
672
|
+
return () => { ctl.signal.removeEventListener("abort", cancelled); ctl.abort(); onBusyChange(false); };
|
|
673
|
+
}, [backend, jobId, path, version, limit, fresh, onBusyChange, onRevision, abortRef]);
|
|
674
|
+
return <>
|
|
675
|
+
{error && <div className="cli-fv-trunc" role="alert">读取失败:{error}</div>}
|
|
676
|
+
{!state && !error && busy && <div className="cli-fv-note">加载中…</div>}
|
|
677
|
+
{state?.truncated && <div className="cli-fv-trunc">已读取 {fmtSize(limit)} / {fmtSize(state.size ?? 0)}
|
|
678
|
+
{backend.fileSync && limit < HTML_QUOTE_MAX && <button className="cli-fv-btn" disabled={busy} onClick={() => setLimit(value => nextTextLimit(value))}>{busy ? '读取中…' : '继续读取'}</button>}
|
|
679
|
+
</div>}
|
|
680
|
+
{state && <BoundedTextPreview text={state.text} scrollRef={scrollRef} />}
|
|
681
|
+
</>;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** 表格先显示这么多行,「再显示」每次多这么多(几十万行一次画出来页面会卡住)。 */
|
|
685
|
+
const CSV_PAGE = 2_000;
|
|
686
|
+
|
|
687
|
+
function CsvTable({ text }: { text: string }) {
|
|
688
|
+
const [limit, setLimit] = useState(CSV_PAGE);
|
|
689
|
+
const { rows, more } = useMemo(() => parseCsv(text, limit + 1), [text, limit]);
|
|
690
|
+
if (!rows.length) return <div className="cli-fv-note">空文件。</div>;
|
|
691
|
+
const [head, ...body] = rows;
|
|
692
|
+
return (
|
|
693
|
+
<div className="cli-fv-csv">
|
|
694
|
+
<table>
|
|
695
|
+
<thead><tr>{head.map((c, i) => <th key={i}>{c}</th>)}</tr></thead>
|
|
696
|
+
<tbody>{body.map((r, i) => <tr key={i}>{r.map((c, j) => <td key={j}>{c}</td>)}</tr>)}</tbody>
|
|
697
|
+
</table>
|
|
698
|
+
{more && (
|
|
699
|
+
<div className="cli-fv-trunc">已显示前 {body.length.toLocaleString()} 行。
|
|
700
|
+
<button className="cli-fv-btn" onClick={() => setLimit((n) => n + CSV_PAGE * 5)}>再显示 {(CSV_PAGE * 5).toLocaleString()} 行</button>
|
|
701
|
+
(也可以切到「源文件」看全部文字)
|
|
702
|
+
</div>
|
|
703
|
+
)}
|
|
704
|
+
</div>
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
function fenced(text: string, lang: string) {
|
|
709
|
+
const runs = text.match(/`{3,}/g) ?? [];
|
|
710
|
+
const fence = "`".repeat(Math.max(3, ...runs.map((r) => r.length + 1)));
|
|
711
|
+
return `${fence}${lang}\n${text}\n${fence}`;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Word 转出来的 HTML 放进无脚本的沙箱 iframe,配一份阅读样式。
|
|
715
|
+
function docxPage(body: string) {
|
|
716
|
+
return `<!doctype html><meta charset="utf-8"><style>
|
|
717
|
+
body{font:14px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#1f2328;max-width:820px;margin:0 auto;padding:24px 28px;}
|
|
718
|
+
h1,h2,h3{line-height:1.35}table{border-collapse:collapse}td,th{border:1px solid #d0d7de;padding:4px 8px}img{max-width:100%}
|
|
719
|
+
</style>${body}`;
|
|
720
|
+
}
|
|
721
|
+
|