@spzxg/cli-ui 0.4.0 → 0.6.0
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/README.md +78 -10
- package/dist/cli-ui-embed-CXdmrwo1.js +40896 -0
- package/dist/cli-ui-index-BZppwn-v.js +13528 -0
- package/dist/cli-ui.css +1 -1
- package/dist/cli-ui.js +1 -1
- package/package.json +2 -1
- package/src/board/BrowserPane.tsx +396 -96
- package/src/board/ChangesPane.tsx +13 -4
- package/src/board/CliBoard.tsx +219 -37
- package/src/board/FileViewer.tsx +244 -63
- package/src/board/FilesPane.tsx +51 -4
- package/src/board/blocks.tsx +3 -2
- package/src/board/fileOps.ts +55 -0
- package/src/board/folderDownload.ts +161 -0
- package/src/board/workspace.ts +129 -0
- package/src/board/zip.ts +85 -0
- package/src/chat/CliChat.tsx +114 -21
- package/src/core/blocks.ts +21 -2
- package/src/core/client.ts +258 -41
- package/src/core/conversation.ts +17 -7
- package/src/core/store.ts +340 -71
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +21 -6
- package/src/elements.tsx +106 -11
- package/src/index.ts +7 -2
- package/src/layout/CliSplit.tsx +53 -0
- package/src/layout/split.ts +214 -0
- package/src/msglist/CliMsgList.tsx +50 -22
- package/src/styles.css +101 -0
- package/src/ui/ErrorBoundary.tsx +38 -0
- package/src/ui/TextBlock.tsx +1 -1
- package/src/ui/transcriptModel.ts +66 -9
- package/dist/cli-ui-embed-D7xtcvGG.js +0 -37967
- package/dist/cli-ui-index-O0krpZBg.js +0 -13516
package/src/board/FileViewer.tsx
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
// 文件预览:Markdown / 代码 / 文本 / CSV / 图片 / PDF / HTML(沙箱)/ Word,其余给下载。
|
|
2
2
|
// 看板和对话里的弹窗共用。文件经 center 转发读取(浏览器不直连执行节点)。
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
//
|
|
4
|
+
// 远端、实时、超大文件:
|
|
5
|
+
// · 图片 / PDF / HTML / 下载一律用浏览器能直接打开的地址(同源代理时就是文件接口;没有代理注入令牌时用中枢发的短期链接),
|
|
6
|
+
// 流式加载、支持断点,不把文件读进浏览器内存。中枢太旧发不了短期链接时才先 fetch 成 blob,这时超大的文件不预览、只给下载。
|
|
7
|
+
// · 文本只读开头 512 KB(Range),超过的可以切到「看末尾」(日志);GBK 导出的文件按 GB18030 解。
|
|
8
|
+
// · 文件在变(任务还在写):看板递增 version 时重读;读的时候保留滚动位置,原本在最底下的继续贴着底(像 tail -f)。
|
|
9
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
10
|
+
import { fileUrlFromBase, type BoardBackend } from "../core/client.js";
|
|
5
11
|
import { Icon } from "../ui/icons.js";
|
|
6
12
|
import { Markdown } from "../ui/Markdown.js";
|
|
7
13
|
import { FileLinkContext, FileOpenContext } from "../ui/fileLinks.js";
|
|
8
14
|
import { codeLang, fmtSize, parseCsv, previewKind, resolveRelative, type PreviewKind } from "./fileKind.js";
|
|
9
15
|
import { relTime } from "../ui/format.js";
|
|
16
|
+
import { downloadFile, mimeOf } from "./fileOps.js";
|
|
17
|
+
|
|
18
|
+
export { downloadFile };
|
|
10
19
|
|
|
11
20
|
/** 看板 / 预览里给文件加的自定义操作(例如「在对话里引用」「发到工作协同」)。 */
|
|
12
21
|
export interface FileAction {
|
|
@@ -19,54 +28,76 @@ export interface FileAction {
|
|
|
19
28
|
|
|
20
29
|
const TEXT_MAX = 512 * 1024;
|
|
21
30
|
const HIGHLIGHT_MAX = 120 * 1024;
|
|
31
|
+
/** Markdown 超过这么多按纯文本显示(渲染几百 KB 的 Markdown 会让页面卡住)。 */
|
|
32
|
+
const MARKDOWN_RENDER_MAX = 300 * 1024;
|
|
33
|
+
/** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的不预览、不在浏览器里拼下载。 */
|
|
34
|
+
const BLOB_PREVIEW_MAX = 50 * 1024 * 1024;
|
|
35
|
+
/** Word 在浏览器里转换,太大的只给下载。 */
|
|
36
|
+
const DOCX_PREVIEW_MAX = 30 * 1024 * 1024;
|
|
37
|
+
/** 短期链接 30 分钟有效:每 5 分钟问一次(客户端缓存着,快到期才真的换),长时间开着的预览不会失效。 */
|
|
38
|
+
const LINK_RECHECK_MS = 5 * 60_000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 浏览器能直接打开的文件地址。directLinks(同源代理)时马上有;否则等中枢发的短期链接(pending = 还在换);
|
|
42
|
+
* 中枢发不了(太旧)时 url 为 null、blob = true,调用方退回先 fetch 再显示。
|
|
43
|
+
*/
|
|
44
|
+
function useFileLinks(backend: BoardBackend, jobId: string): { url: ((path: string, download?: boolean) => string) | null; pending: boolean; blob: boolean } {
|
|
45
|
+
const [linkBase, setLinkBase] = useState<string | null | undefined>(undefined);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (backend.directLinks || !backend.fileLinkBase) return;
|
|
48
|
+
let alive = true;
|
|
49
|
+
const check = () => backend.fileLinkBase!(jobId).then((b) => { if (alive) setLinkBase(b); }, () => { if (alive) setLinkBase((old) => old ?? null); });
|
|
50
|
+
void check();
|
|
51
|
+
const timer = setInterval(check, LINK_RECHECK_MS);
|
|
52
|
+
return () => { alive = false; clearInterval(timer); };
|
|
53
|
+
}, [backend, jobId]);
|
|
54
|
+
return useMemo(() => {
|
|
55
|
+
if (backend.directLinks) return { url: (path: string, download?: boolean) => backend.fileUrl(jobId, path, { download }), pending: false, blob: false };
|
|
56
|
+
if (!backend.fileLinkBase || linkBase === null) return { url: null, pending: false, blob: true };
|
|
57
|
+
if (linkBase === undefined) return { url: null, pending: true, blob: false };
|
|
58
|
+
return { url: (path: string, download?: boolean) => fileUrlFromBase(linkBase, path, download), pending: false, blob: false };
|
|
59
|
+
}, [backend, jobId, linkBase]);
|
|
60
|
+
}
|
|
22
61
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
62
|
+
const withVersion = (url: string, version: number, attempt = 0) => {
|
|
63
|
+
const q = [version ? `v=${version}` : "", attempt ? `r=${attempt}` : ""].filter(Boolean).join("&");
|
|
64
|
+
return q ? `${url}${url.includes("?") ? "&" : "?"}${q}` : url;
|
|
26
65
|
};
|
|
66
|
+
/** 读了这么久还没好:提示「网络较慢」,不让人以为卡死了。 */
|
|
67
|
+
const SLOW_HINT_MS = 8_000;
|
|
68
|
+
function useSlow(loading: boolean) {
|
|
69
|
+
const [slow, setSlow] = useState(false);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
setSlow(false);
|
|
72
|
+
if (!loading) return;
|
|
73
|
+
const t = setTimeout(() => setSlow(true), SLOW_HINT_MS);
|
|
74
|
+
return () => clearTimeout(t);
|
|
75
|
+
}, [loading]);
|
|
76
|
+
return slow;
|
|
77
|
+
}
|
|
27
78
|
|
|
28
|
-
/**
|
|
29
|
-
function useFileSrc(backend: BoardBackend, jobId: string, path: string, enabled: boolean, version: number) {
|
|
79
|
+
/** 图片 / PDF / HTML 的地址:能直接用就用;只能 fetch 时先读成 blob(离开时释放),太大的不读。 */
|
|
80
|
+
function useFileSrc(backend: BoardBackend, jobId: string, path: string, enabled: boolean, version: number, size: number | undefined, links: ReturnType<typeof useFileLinks>, attempt = 0) {
|
|
30
81
|
const [state, setState] = useState<{ url: string | null; error: string | null }>({ url: null, error: null });
|
|
82
|
+
const direct = enabled && links.url ? withVersion(links.url(path), version, attempt) : null;
|
|
31
83
|
useEffect(() => {
|
|
32
|
-
if (!enabled) return;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
setState({ url: version ? `${url}${url.includes("?") ? "&" : "?"}v=${version}` : url, error: null });
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
84
|
+
if (!enabled || !links.blob) return;
|
|
85
|
+
setState({ url: null, error: null });
|
|
86
|
+
if (size !== undefined && size > BLOB_PREVIEW_MAX) { setState({ url: null, error: `文件较大(${fmtSize(size)}),请下载后查看` }); return; }
|
|
38
87
|
let alive = true;
|
|
39
88
|
let objectUrl: string | null = null;
|
|
40
89
|
backend.readBuffer(jobId, path).then((buffer) => {
|
|
41
90
|
if (!alive) return;
|
|
42
|
-
|
|
43
|
-
objectUrl = URL.createObjectURL(new Blob([buffer], { type: MIME[ext] ?? "application/octet-stream" }));
|
|
91
|
+
objectUrl = URL.createObjectURL(new Blob([buffer], { type: mimeOf(path) }));
|
|
44
92
|
setState({ url: objectUrl, error: null });
|
|
45
93
|
}, (error: unknown) => { if (alive) setState({ url: null, error: error instanceof Error ? error.message : String(error) }); });
|
|
46
94
|
return () => { alive = false; if (objectUrl) URL.revokeObjectURL(objectUrl); };
|
|
47
|
-
}, [backend, jobId, path, enabled, version]);
|
|
48
|
-
return state;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** 下载:能直接用地址就用(不占内存),否则 fetch 成 blob 再存。 */
|
|
52
|
-
export async function downloadFile(backend: BoardBackend, jobId: string, path: string) {
|
|
53
|
-
const name = path.split("/").pop() || "file";
|
|
54
|
-
const a = document.createElement("a");
|
|
55
|
-
a.download = name;
|
|
56
|
-
let objectUrl: string | null = null;
|
|
57
|
-
if (backend.directLinks) a.href = backend.fileUrl(jobId, path, { download: true });
|
|
58
|
-
else {
|
|
59
|
-
objectUrl = URL.createObjectURL(new Blob([await backend.readBuffer(jobId, path)]));
|
|
60
|
-
a.href = objectUrl;
|
|
61
|
-
}
|
|
62
|
-
document.body.appendChild(a);
|
|
63
|
-
a.click();
|
|
64
|
-
a.remove();
|
|
65
|
-
if (objectUrl) setTimeout(() => URL.revokeObjectURL(objectUrl!), 30_000);
|
|
95
|
+
}, [backend, jobId, path, enabled, version, links.blob, size, attempt]);
|
|
96
|
+
return direct ? { url: direct, error: null } : state;
|
|
66
97
|
}
|
|
67
98
|
|
|
68
99
|
type Loaded =
|
|
69
|
-
| { kind: "text"; text: string; truncated: boolean; size: number }
|
|
100
|
+
| { kind: "text"; text: string; truncated: boolean; size: number; tail: boolean; encoding?: string }
|
|
70
101
|
| { kind: "docx"; html: string }
|
|
71
102
|
| { kind: "none" };
|
|
72
103
|
|
|
@@ -87,9 +118,14 @@ export interface FileViewerProps {
|
|
|
87
118
|
actions?: FileAction[];
|
|
88
119
|
/** 隐藏头部(宿主自己做)。 */
|
|
89
120
|
bare?: boolean;
|
|
121
|
+
/** 给了就支持「选中文字 → 引用到对话」:在预览里选中一段文字时浮出按钮(Markdown / 代码 / 文本 / 表格)。 */
|
|
122
|
+
onQuoteText?: (text: string) => void;
|
|
90
123
|
}
|
|
91
124
|
|
|
92
|
-
|
|
125
|
+
/** 选中的文字超过这么多只带开头(引用是给智能体指位置的,不是搬运全文)。 */
|
|
126
|
+
const QUOTE_SELECTION_MAX = 3_000;
|
|
127
|
+
|
|
128
|
+
export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onOpenPath, onClose, lead, actions = [], bare, onQuoteText }: FileViewerProps) {
|
|
93
129
|
const name = path.split("/").pop() || path;
|
|
94
130
|
const kind: PreviewKind = previewKind(path);
|
|
95
131
|
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
|
@@ -97,14 +133,75 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
97
133
|
const [copied, setCopied] = useState(false);
|
|
98
134
|
const [busy, setBusy] = useState(false);
|
|
99
135
|
const [mode, setMode] = useState<"preview" | "source">("preview");
|
|
136
|
+
// 超大文本:看开头还是看末尾(日志看末尾)。
|
|
137
|
+
const [tail, setTail] = useState(false);
|
|
138
|
+
// 放大:盖住页面看(看板窄的时候看报告、表格、图片)。Esc 还原。
|
|
139
|
+
const [big, setBig] = useState(false);
|
|
140
|
+
// 点「重试」:重新读(图片 / PDF 换一个地址参数,绕开浏览器缓存的失败)。
|
|
141
|
+
const [attempt, setAttempt] = useState(0);
|
|
142
|
+
const retry = () => { setError(null); setLoaded(null); setAttempt((n) => n + 1); };
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (!big) return;
|
|
145
|
+
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setBig(false); };
|
|
146
|
+
const prev = document.body.style.overflow;
|
|
147
|
+
document.body.style.overflow = "hidden";
|
|
148
|
+
window.addEventListener("keydown", onKey);
|
|
149
|
+
return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; };
|
|
150
|
+
}, [big]);
|
|
100
151
|
const needsSrc = kind === "image" || kind === "pdf" || kind === "html";
|
|
101
|
-
const
|
|
152
|
+
const links = useFileLinks(backend, jobId);
|
|
153
|
+
const knownSize = size ?? (loaded?.kind === "text" ? loaded.size : undefined);
|
|
154
|
+
const src = useFileSrc(backend, jobId, path, needsSrc, version, knownSize, links, attempt);
|
|
102
155
|
|
|
156
|
+
// 重读时保留滚动位置;原本贴着底(或在看末尾)的继续贴着底——任务在往文件里写时像 tail -f。
|
|
157
|
+
const bodyRef = useRef<HTMLDivElement | null>(null);
|
|
158
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
159
|
+
const stick = useRef(false);
|
|
160
|
+
// 选中文字 → 浮出「引用到对话」。按钮放在选区上方;滚动、点别处、选区清空时收起。
|
|
161
|
+
const [sel, setSel] = useState<{ text: string; x: number; y: number } | null>(null);
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!onQuoteText) return;
|
|
164
|
+
const read = () => {
|
|
165
|
+
const s = window.getSelection();
|
|
166
|
+
const body = bodyRef.current;
|
|
167
|
+
const root = rootRef.current;
|
|
168
|
+
if (!s || s.isCollapsed || !s.rangeCount || !body || !root || !body.contains(s.anchorNode) || !body.contains(s.focusNode)) { setSel(null); return; }
|
|
169
|
+
const text = s.toString().trim();
|
|
170
|
+
if (!text) { setSel(null); return; }
|
|
171
|
+
const box = root.getBoundingClientRect();
|
|
172
|
+
// 取不到选区位置的环境(老浏览器、测试环境):按钮放在预览顶部中间。
|
|
173
|
+
const range = s.getRangeAt(0);
|
|
174
|
+
const r = typeof range.getBoundingClientRect === "function" ? range.getBoundingClientRect() : { left: box.left + box.width / 2, width: 0, top: box.top + 48 };
|
|
175
|
+
setSel({ text, x: Math.min(Math.max(8, r.left - box.left + r.width / 2), box.width - 8), y: Math.max(40, r.top - box.top) });
|
|
176
|
+
};
|
|
177
|
+
const onUp = () => setTimeout(read, 0);
|
|
178
|
+
const onChange = () => { const s = window.getSelection(); if (!s || s.isCollapsed) setSel(null); };
|
|
179
|
+
const body = bodyRef.current;
|
|
180
|
+
document.addEventListener("mouseup", onUp);
|
|
181
|
+
document.addEventListener("keyup", onUp);
|
|
182
|
+
document.addEventListener("selectionchange", onChange);
|
|
183
|
+
const onScroll = () => setSel(null);
|
|
184
|
+
body?.addEventListener("scroll", onScroll, { passive: true });
|
|
185
|
+
return () => {
|
|
186
|
+
document.removeEventListener("mouseup", onUp);
|
|
187
|
+
document.removeEventListener("keyup", onUp);
|
|
188
|
+
document.removeEventListener("selectionchange", onChange);
|
|
189
|
+
body?.removeEventListener("scroll", onScroll);
|
|
190
|
+
};
|
|
191
|
+
}, [onQuoteText, loaded]);
|
|
192
|
+
const quoteSelection = () => {
|
|
193
|
+
if (!sel || !onQuoteText) return;
|
|
194
|
+
const text = sel.text.length > QUOTE_SELECTION_MAX ? `${sel.text.slice(0, QUOTE_SELECTION_MAX)}…` : sel.text;
|
|
195
|
+
onQuoteText(text);
|
|
196
|
+
window.getSelection()?.removeAllRanges();
|
|
197
|
+
setSel(null);
|
|
198
|
+
};
|
|
103
199
|
useEffect(() => {
|
|
104
200
|
let alive = true;
|
|
105
201
|
setError(null);
|
|
106
202
|
if (needsSrc || kind === "binary") { setLoaded({ kind: "none" }); return; }
|
|
107
203
|
if (kind === "docx") {
|
|
204
|
+
if (size !== undefined && size > DOCX_PREVIEW_MAX) { setError(`文件较大(${fmtSize(size)}),请下载后查看`); return; }
|
|
108
205
|
backend.readBuffer(jobId, path).then(async (buffer) => {
|
|
109
206
|
const mammoth = await import("mammoth");
|
|
110
207
|
const convert = (mammoth as unknown as { convertToHtml: typeof import("mammoth").convertToHtml }).convertToHtml
|
|
@@ -116,39 +213,59 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
116
213
|
if (alive) setLoaded({ kind: "docx", html: out.value });
|
|
117
214
|
}).catch((e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); });
|
|
118
215
|
} else {
|
|
119
|
-
backend.readText(jobId, path, TEXT_MAX).then(
|
|
120
|
-
(r) => {
|
|
216
|
+
backend.readText(jobId, path, TEXT_MAX, { tail }).then(
|
|
217
|
+
(r) => {
|
|
218
|
+
if (!alive) return;
|
|
219
|
+
const box = bodyRef.current;
|
|
220
|
+
stick.current = tail || (!!box && box.scrollHeight > box.clientHeight && box.scrollHeight - box.scrollTop - box.clientHeight < 40);
|
|
221
|
+
setLoaded({ kind: "text", text: r.text, truncated: r.truncated, size: r.size, tail: !!r.tail, encoding: r.encoding });
|
|
222
|
+
},
|
|
121
223
|
(e: unknown) => { if (alive) setError(e instanceof Error ? e.message : String(e)); },
|
|
122
224
|
);
|
|
123
225
|
}
|
|
124
226
|
return () => { alive = false; };
|
|
125
|
-
}, [backend, jobId, path, kind, needsSrc, version]);
|
|
227
|
+
}, [backend, jobId, path, kind, needsSrc, version, tail, attempt]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
228
|
+
useLayoutEffect(() => {
|
|
229
|
+
const box = bodyRef.current;
|
|
230
|
+
if (box && stick.current && loaded?.kind === "text") box.scrollTop = box.scrollHeight;
|
|
231
|
+
}, [loaded]);
|
|
126
232
|
|
|
127
233
|
const fileInfo = useCallback(() => ({
|
|
128
|
-
path, name, url: backend.fileUrl(jobId, path), downloadUrl: backend.fileUrl(jobId, path, { download: true }),
|
|
129
|
-
}), [backend, jobId, path, name]);
|
|
234
|
+
path, name, url: links.url?.(path) ?? backend.fileUrl(jobId, path), downloadUrl: links.url?.(path, true) ?? backend.fileUrl(jobId, path, { download: true }),
|
|
235
|
+
}), [backend, jobId, path, name, links]);
|
|
130
236
|
|
|
131
237
|
const download = async () => {
|
|
132
238
|
setBusy(true);
|
|
133
|
-
try { await downloadFile(backend, jobId, path); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); }
|
|
134
|
-
};
|
|
135
|
-
const openNew = () => {
|
|
136
|
-
if (backend.directLinks) window.open(backend.fileUrl(jobId, path), "_blank", "noopener");
|
|
137
|
-
else if (src.url) window.open(src.url, "_blank", "noopener");
|
|
239
|
+
try { await downloadFile(backend, jobId, path, knownSize); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); }
|
|
138
240
|
};
|
|
241
|
+
const openUrl = links.url ? links.url(path) : src.url;
|
|
242
|
+
const openNew = () => { if (openUrl) window.open(openUrl, "_blank", "noopener"); };
|
|
139
243
|
const copyPath = () => {
|
|
140
244
|
void navigator.clipboard?.writeText(path).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1200); }, () => undefined);
|
|
141
245
|
};
|
|
142
246
|
|
|
143
247
|
// Markdown 里的相对链接 / 图片:相对这个文件所在目录。
|
|
144
|
-
const mdResolve = useMemo(() => (href: string) =>
|
|
248
|
+
const mdResolve = useMemo(() => (href: string) => links.url?.(resolveRelative(path, href)) ?? null, [links, path]);
|
|
145
249
|
const mdOpen = useMemo(() => (onOpenPath ? (href: string) => { onOpenPath(resolveRelative(path, href)); return true; } : null), [onOpenPath, path]);
|
|
146
250
|
|
|
147
251
|
const canSource = kind === "markdown" || kind === "html" || kind === "csv";
|
|
148
|
-
const shownSize =
|
|
252
|
+
const shownSize = knownSize;
|
|
253
|
+
const text = loaded?.kind === "text" ? loaded : null;
|
|
254
|
+
// 太大的 Markdown 按纯文本显示(渲染会卡住页面)。
|
|
255
|
+
const mdTooBig = !!text && kind === "markdown" && text.text.length > MARKDOWN_RENDER_MAX;
|
|
256
|
+
const loading = !error && !src.error && (needsSrc ? !src.url : !loaded);
|
|
257
|
+
const slow = useSlow(loading);
|
|
258
|
+
const slowNote = slow ? <div className="cli-fv-slow">网络较慢或执行节点忙,仍在读取{shownSize ? `(${fmtSize(shownSize)})` : ""}…</div> : null;
|
|
149
259
|
|
|
150
260
|
return (
|
|
151
|
-
|
|
261
|
+
<>
|
|
262
|
+
{big && <div className="cli-ui cli-fv-backdrop" onClick={() => setBig(false)} />}
|
|
263
|
+
<div className={`cli-fv ${big ? "cli-fv-big" : ""}`} ref={rootRef}>
|
|
264
|
+
{sel && (
|
|
265
|
+
<button className="cli-fv-selq" style={{ left: sel.x, top: sel.y }} onMouseDown={(e) => e.preventDefault()} onClick={quoteSelection} title="把选中的这段带进下一条消息">
|
|
266
|
+
{Icon.board()} 引用到对话{sel.text.length > 1 ? `(${sel.text.length > QUOTE_SELECTION_MAX ? `前 ${QUOTE_SELECTION_MAX}` : sel.text.length} 字)` : ""}
|
|
267
|
+
</button>
|
|
268
|
+
)}
|
|
152
269
|
{!bare && (
|
|
153
270
|
<div className="cli-fv-h">
|
|
154
271
|
{lead}
|
|
@@ -157,6 +274,7 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
157
274
|
<span className="cli-fv-meta">
|
|
158
275
|
{shownSize !== undefined && fmtSize(shownSize)}
|
|
159
276
|
{mtime && mtime > 0 ? ` · ${relTime(mtime)}更新` : ""}
|
|
277
|
+
{text?.encoding && text.encoding !== "utf-8" ? ` · ${text.encoding.toUpperCase()}` : ""}
|
|
160
278
|
</span>
|
|
161
279
|
<span className="cli-fv-sp" />
|
|
162
280
|
{canSource && (
|
|
@@ -168,14 +286,16 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
168
286
|
<button key={a.label} className="cli-fv-btn" title={a.title} onClick={() => a.onClick(fileInfo(), jobId)}>{a.label}</button>
|
|
169
287
|
))}
|
|
170
288
|
<button className="cli-fv-icon" onClick={copyPath} title={copied ? "已复制" : "复制路径"}>{copied ? Icon.check() : Icon.copy()}</button>
|
|
171
|
-
<button className="cli-fv-icon" onClick={
|
|
289
|
+
<button className="cli-fv-icon cli-fv-zoom" onClick={() => setBig((b) => !b)} title={big ? "还原(Esc)" : "放大查看"} aria-pressed={big}>{big ? "⤡" : "⤢"}</button>
|
|
290
|
+
<button className="cli-fv-icon" onClick={openNew} title="在新窗口打开" disabled={!openUrl}>{Icon.external()}</button>
|
|
172
291
|
<button className="cli-fv-icon" onClick={() => void download()} title="下载" disabled={busy}>{Icon.download()}</button>
|
|
173
292
|
{onClose && <button className="cli-fv-icon" onClick={onClose} title="关闭">{Icon.x()}</button>}
|
|
174
293
|
</div>
|
|
175
294
|
)}
|
|
176
|
-
<div className={`cli-fv-b k-${kind}`}>
|
|
295
|
+
<div className={`cli-fv-b k-${kind}`} ref={bodyRef}>
|
|
177
296
|
{error || src.error ? (
|
|
178
297
|
<div className="cli-fv-note err">读取失败:{error ?? src.error}
|
|
298
|
+
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={retry}>重试</button>
|
|
179
299
|
<button className="btn btn-line btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()}>{Icon.download()} 下载</button>
|
|
180
300
|
</div>
|
|
181
301
|
) : kind === "binary" ? (
|
|
@@ -183,36 +303,97 @@ export function FileViewer({ backend, jobId, path, size, mtime, version = 0, onO
|
|
|
183
303
|
<button className="btn btn-accent btn-sm" style={{ marginLeft: 8 }} onClick={() => void download()} disabled={busy}>{Icon.download()} 下载</button>
|
|
184
304
|
</div>
|
|
185
305
|
) : needsSrc ? (
|
|
186
|
-
!src.url ? <div className="cli-fv-note"
|
|
187
|
-
: kind === "image" ? <
|
|
306
|
+
!src.url ? <div className="cli-fv-note">加载中…{slowNote}</div>
|
|
307
|
+
: kind === "image" ? <ImageView key={src.url} src={src.url} name={name} onRetry={retry} />
|
|
188
308
|
: kind === "pdf" ? <iframe className="cli-fv-frame" src={src.url} title={name} />
|
|
189
309
|
: mode === "source" ? <SourceView backend={backend} jobId={jobId} path={path} version={version} />
|
|
190
310
|
// HTML:节点按沙箱 CSP 返回;这里再加 iframe 沙箱(不给同源),页面脚本碰不到宿主。
|
|
191
311
|
: <iframe className="cli-fv-frame" src={src.url} title={name} sandbox="allow-scripts allow-popups allow-forms allow-modals" />
|
|
192
312
|
) : !loaded ? (
|
|
193
|
-
<div className="cli-fv-note"
|
|
313
|
+
<div className="cli-fv-note">加载中…{slowNote}</div>
|
|
194
314
|
) : loaded.kind === "docx" ? (
|
|
195
315
|
<iframe className="cli-fv-frame doc" title={name} sandbox="" srcDoc={docxPage(loaded.html)} />
|
|
196
|
-
) :
|
|
316
|
+
) : text ? (
|
|
197
317
|
<>
|
|
198
|
-
{
|
|
199
|
-
|
|
318
|
+
{(text.truncated || tail) && (
|
|
319
|
+
<div className="cli-fv-trunc">
|
|
320
|
+
{tail && !text.tail ? "执行节点不支持只读末尾,下面仍是开头;" : ""}
|
|
321
|
+
文件较大({fmtSize(text.size)}),只显示{text.tail ? "最后" : "前"} {fmtSize(TEXT_MAX)};完整内容请下载。
|
|
322
|
+
<button className="cli-fv-btn" onClick={() => setTail(!tail)}>{tail ? "看开头" : "看末尾"}</button>
|
|
323
|
+
</div>
|
|
324
|
+
)}
|
|
325
|
+
{mdTooBig && <div className="cli-fv-trunc">Markdown 较大,按纯文本显示。</div>}
|
|
326
|
+
{kind === "markdown" && mode === "preview" && !mdTooBig ? (
|
|
200
327
|
<FileLinkContext.Provider value={mdResolve}>
|
|
201
328
|
<FileOpenContext.Provider value={mdOpen}>
|
|
202
|
-
<div className="cli-fv-md"><Markdown>{
|
|
329
|
+
<div className="cli-fv-md"><Markdown>{text.text}</Markdown></div>
|
|
203
330
|
</FileOpenContext.Provider>
|
|
204
331
|
</FileLinkContext.Provider>
|
|
205
332
|
) : kind === "csv" && mode === "preview" ? (
|
|
206
|
-
<CsvTable text={
|
|
207
|
-
) : kind === "code" &&
|
|
208
|
-
<div className="cli-fv-code"><Markdown>{fenced(
|
|
333
|
+
<CsvTable text={text.text} />
|
|
334
|
+
) : kind === "code" && text.text.length <= HIGHLIGHT_MAX ? (
|
|
335
|
+
<div className="cli-fv-code"><Markdown>{fenced(text.text, codeLang(path))}</Markdown></div>
|
|
209
336
|
) : (
|
|
210
|
-
<pre className="cli-fv-pre">{
|
|
337
|
+
<pre className="cli-fv-pre">{text.text}</pre>
|
|
211
338
|
)}
|
|
212
339
|
</>
|
|
213
340
|
) : null}
|
|
214
341
|
</div>
|
|
215
342
|
</div>
|
|
343
|
+
</>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 图片:缺省适应宽度;可以放大缩小、看原始大小,Ctrl / ⌘ + 滚轮也能缩放。 */
|
|
348
|
+
function ImageView({ src, name, onRetry }: { src: string; name: string; onRetry?: () => void }) {
|
|
349
|
+
const [scale, setScale] = useState<number | null>(null); // null = 适应
|
|
350
|
+
const [natural, setNatural] = useState<{ w: number; h: number } | null>(null);
|
|
351
|
+
// 加载失败(网络断了、链接过期、文件坏了 / 不是浏览器认识的图片):说清楚,给重试。
|
|
352
|
+
const [failed, setFailed] = useState(false);
|
|
353
|
+
const loadingImg = !natural && !failed;
|
|
354
|
+
const slow = useSlow(loadingImg);
|
|
355
|
+
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
356
|
+
const fitScale = () => {
|
|
357
|
+
const box = boxRef.current;
|
|
358
|
+
if (!box || !natural) return 1;
|
|
359
|
+
return Math.min(1, (box.clientWidth - 32) / natural.w);
|
|
360
|
+
};
|
|
361
|
+
const zoom = (factor: number) => setScale((s) => Math.min(8, Math.max(0.1, (s ?? fitScale()) * factor)));
|
|
362
|
+
useEffect(() => {
|
|
363
|
+
const box = boxRef.current;
|
|
364
|
+
if (!box) return;
|
|
365
|
+
const onWheel = (e: WheelEvent) => {
|
|
366
|
+
if (!e.ctrlKey && !e.metaKey) return;
|
|
367
|
+
e.preventDefault();
|
|
368
|
+
zoom(e.deltaY < 0 ? 1.1 : 1 / 1.1);
|
|
369
|
+
};
|
|
370
|
+
box.addEventListener("wheel", onWheel, { passive: false });
|
|
371
|
+
return () => box.removeEventListener("wheel", onWheel);
|
|
372
|
+
}); // eslint-disable-line react-hooks/exhaustive-deps
|
|
373
|
+
const style = scale === null || !natural ? undefined : { width: Math.round(natural.w * scale), maxWidth: "none" };
|
|
374
|
+
return (
|
|
375
|
+
<div className="cli-fv-imgwrap">
|
|
376
|
+
<div className="cli-fv-imgbar">
|
|
377
|
+
<button className="cli-fv-btn" onClick={() => zoom(1 / 1.25)} title="缩小">−</button>
|
|
378
|
+
<span className="cli-fv-imgpct">{scale === null ? "适应" : `${Math.round(scale * 100)}%`}</span>
|
|
379
|
+
<button className="cli-fv-btn" onClick={() => zoom(1.25)} title="放大">+</button>
|
|
380
|
+
<button className="cli-fv-btn" onClick={() => setScale(null)} disabled={scale === null}>适应</button>
|
|
381
|
+
<button className="cli-fv-btn" onClick={() => setScale(1)} disabled={scale === 1}>原始大小</button>
|
|
382
|
+
{natural && <span className="cli-fv-imgpct">{natural.w} × {natural.h}</span>}
|
|
383
|
+
</div>
|
|
384
|
+
<div className={`cli-fv-img ${scale === null ? "" : "zoomed"}`} ref={boxRef}>
|
|
385
|
+
{failed ? (
|
|
386
|
+
<div className="cli-fv-img-err">图片没有加载出来(网络中断、链接过期,或文件损坏 / 格式不支持)。
|
|
387
|
+
{onRetry && <button className="btn btn-line btn-sm" onClick={onRetry}>重试</button>}
|
|
388
|
+
</div>
|
|
389
|
+
) : (
|
|
390
|
+
<>
|
|
391
|
+
<img src={src} alt={name} style={style} onLoad={(e) => setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })} onError={() => setFailed(true)} />
|
|
392
|
+
{slow && <div className="cli-fv-slow">图片较大或网络较慢,仍在加载…</div>}
|
|
393
|
+
</>
|
|
394
|
+
)}
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
216
397
|
);
|
|
217
398
|
}
|
|
218
399
|
|
package/src/board/FilesPane.tsx
CHANGED
|
@@ -5,8 +5,9 @@ import { CenterError, type BoardBackend } from "../core/client.js";
|
|
|
5
5
|
import type { FileEntry } from "../core/types.js";
|
|
6
6
|
import { Icon } from "../ui/icons.js";
|
|
7
7
|
import { relTime } from "../ui/format.js";
|
|
8
|
-
import { downloadFile } from "./
|
|
8
|
+
import { downloadFile } from "./fileOps.js";
|
|
9
9
|
import { fmtSize } from "./fileKind.js";
|
|
10
|
+
import { downloadFolder, FAILED_LIST_NAME, type FolderProgress } from "./folderDownload.js";
|
|
10
11
|
|
|
11
12
|
export interface FilesPaneProps {
|
|
12
13
|
backend: BoardBackend;
|
|
@@ -22,6 +23,7 @@ export interface FilesPaneProps {
|
|
|
22
23
|
export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onListed }: FilesPaneProps) {
|
|
23
24
|
const [entries, setEntries] = useState<FileEntry[] | null>(null);
|
|
24
25
|
const [truncated, setTruncated] = useState(false);
|
|
26
|
+
const [total, setTotal] = useState<number | undefined>(undefined);
|
|
25
27
|
const [error, setError] = useState<string | null>(null);
|
|
26
28
|
const [loading, setLoading] = useState(false);
|
|
27
29
|
// 这个目录上一次看到的 mtime:用来标「新」。第一次打开目录不标。
|
|
@@ -31,6 +33,27 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
|
|
|
31
33
|
onListedRef.current = onListed;
|
|
32
34
|
const onDirRef = useRef(onDir);
|
|
33
35
|
onDirRef.current = onDir;
|
|
36
|
+
// 打包下载文件夹:同一时间只打一个,可以取消。
|
|
37
|
+
const [zip, setZip] = useState<{ dir: string; progress: FolderProgress; abort: AbortController } | null>(null);
|
|
38
|
+
// 下载的结果 / 失败(和「同步失败」分开:下载出错不代表列表过期)。
|
|
39
|
+
const [notice, setNotice] = useState<{ text: string; err: boolean } | null>(null);
|
|
40
|
+
const zipDir = (target: string) => {
|
|
41
|
+
if (zip) return;
|
|
42
|
+
const abort = new AbortController();
|
|
43
|
+
setNotice(null);
|
|
44
|
+
setZip({ dir: target, progress: { phase: "listing", files: 0, total: 0, bytes: 0 }, abort });
|
|
45
|
+
downloadFolder(backend, jobId, target, { signal: abort.signal, onProgress: (progress) => setZip((z) => (z && z.abort === abort ? { ...z, progress } : z)) })
|
|
46
|
+
.then(({ files, failed }) => {
|
|
47
|
+
if (failed.length) setNotice({ err: true, text: `已打包 ${files} 个文件;${failed.length} 个没读到(${failed[0].reason}${failed.length > 1 ? " 等" : ""}),清单在压缩包里的 ${FAILED_LIST_NAME}` });
|
|
48
|
+
})
|
|
49
|
+
.catch((err: unknown) => { if (!(err instanceof DOMException && err.name === "AbortError")) setNotice({ err: true, text: `打包下载失败:${err instanceof Error ? err.message : String(err)}` }); })
|
|
50
|
+
.finally(() => setZip((z) => (z && z.abort === abort ? null : z)));
|
|
51
|
+
};
|
|
52
|
+
const downloadOne = (path: string, size: number) => {
|
|
53
|
+
setNotice(null);
|
|
54
|
+
void downloadFile(backend, jobId, path, size).catch((err: unknown) => setNotice({ err: true, text: `下载失败:${err instanceof Error ? err.message : String(err)}` }));
|
|
55
|
+
};
|
|
56
|
+
useEffect(() => () => zip?.abort.abort(), [jobId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
34
57
|
|
|
35
58
|
useEffect(() => {
|
|
36
59
|
let alive = true;
|
|
@@ -49,6 +72,7 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
|
|
|
49
72
|
seen.current.mtimes = new Map(res.entries.map((e) => [e.path, e.mtime]));
|
|
50
73
|
setEntries(res.entries);
|
|
51
74
|
setTruncated(res.truncated);
|
|
75
|
+
setTotal(res.total);
|
|
52
76
|
onListedRef.current?.(dir, res.entries);
|
|
53
77
|
}, (e: unknown) => {
|
|
54
78
|
if (!alive) return;
|
|
@@ -73,7 +97,28 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
|
|
|
73
97
|
))}
|
|
74
98
|
<span className="cli-fv-sp" />
|
|
75
99
|
{loading && entries && <span className="cli-files-sync" title="正在同步">同步中…</span>}
|
|
100
|
+
{entries && entries.length > 0 && !zip && (
|
|
101
|
+
<button className="cli-fv-icon" title={`把「${dir || "工作区"}」打包成 ZIP 下载`} onClick={() => zipDir(dir)}>{Icon.download()}</button>
|
|
102
|
+
)}
|
|
76
103
|
</div>
|
|
104
|
+
{zip && (
|
|
105
|
+
<div className="cli-fv-trunc cli-zip-bar">
|
|
106
|
+
{zip.progress.phase === "listing" ? `正在列出「${zip.dir || "工作区"}」里的文件…`
|
|
107
|
+
: zip.progress.phase === "reading" ? `正在读取 ${zip.progress.files} / ${zip.progress.total} 个文件(${fmtSize(zip.progress.bytes)}${zip.progress.totalBytes ? ` / ${fmtSize(zip.progress.totalBytes)}` : ""})`
|
|
108
|
+
+ (zip.progress.retrying ? ` · 网络不稳,正在重试 ${zip.progress.retrying.split("/").pop()}` : "")
|
|
109
|
+
+ (zip.progress.failed ? ` · ${zip.progress.failed} 个没读到` : "") + "…"
|
|
110
|
+
: "正在打包…"}
|
|
111
|
+
{zip.progress.phase === "reading" && zip.progress.totalBytes ? (
|
|
112
|
+
<span className="cli-zip-meter"><span style={{ width: `${Math.min(100, (zip.progress.bytes / Math.max(1, zip.progress.totalBytes)) * 100)}%` }} /></span>
|
|
113
|
+
) : null}
|
|
114
|
+
<button className="cli-fv-btn" onClick={() => zip.abort.abort()}>取消</button>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
{notice && (
|
|
118
|
+
<div className={`cli-fv-trunc ${notice.err ? "cli-files-err" : ""}`}>{notice.text}
|
|
119
|
+
<button className="cli-fv-btn" onClick={() => setNotice(null)}>知道了</button>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
77
122
|
{error && entries && <div className="cli-fv-trunc">同步失败:{error}(下面是上次读到的列表,有进展时会自动再试,也可以点右上角 ↻)</div>}
|
|
78
123
|
{error && !entries ? (
|
|
79
124
|
<div className="cli-fv-note err">{error}<br /><span style={{ color: "var(--ink-3)" }}>有进展时会自动再试,也可以点右上角 ↻ 重试。</span></div>
|
|
@@ -96,12 +141,14 @@ export function FilesPane({ backend, jobId, dir, onDir, onOpen, refreshKey, onLi
|
|
|
96
141
|
{fresh.has(e.path) && <span className="cli-frow-new">有更新</span>}
|
|
97
142
|
<span className="cli-frow-meta">{e.type === "file" ? `${fmtSize(e.size)} · ` : ""}{relTime(e.mtime)}</span>
|
|
98
143
|
</button>
|
|
99
|
-
{e.type === "file"
|
|
100
|
-
<button className="cli-frow-dl" title="下载" onClick={() =>
|
|
144
|
+
{e.type === "file" ? (
|
|
145
|
+
<button className="cli-frow-dl" title="下载" onClick={() => downloadOne(e.path, e.size)}>{Icon.download()}</button>
|
|
146
|
+
) : (
|
|
147
|
+
<button className="cli-frow-dl" title="打包成 ZIP 下载" disabled={!!zip} onClick={() => zipDir(e.path)}>{Icon.download()}</button>
|
|
101
148
|
)}
|
|
102
149
|
</div>
|
|
103
150
|
))}
|
|
104
|
-
{truncated && <div className="cli-fv-trunc"
|
|
151
|
+
{truncated && <div className="cli-fv-trunc">文件太多{total ? `(共 ${total.toLocaleString()} 个)` : ""},只列出前 {entries.length.toLocaleString()} 个。</div>}
|
|
105
152
|
</div>
|
|
106
153
|
)}
|
|
107
154
|
</div>
|
package/src/board/blocks.tsx
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// (baseUrl / token / line / headers)建一个。不给 jobId 时跟 Provider 的选中走。
|
|
8
8
|
// 它们只依赖中枢的看板接口(/api/jobs/:id/files、/browser…),不依赖 cli-chat / cli-board。
|
|
9
9
|
import { useState, type CSSProperties } from "react";
|
|
10
|
+
import { CliErrorBoundary } from "../ui/ErrorBoundary.js";
|
|
10
11
|
import type { CliEvent, CliStore, CliStoreOptions } from "../core/store.js";
|
|
11
12
|
import { useCliState, useCliStore, useCliStoreContext, useComponentEvents, withOwnProvider } from "../core/provider.js";
|
|
12
13
|
import { FileViewer, type FileAction } from "./FileViewer.js";
|
|
@@ -39,7 +40,7 @@ export interface CliFilePreviewProps extends BlockProps {
|
|
|
39
40
|
|
|
40
41
|
export function CliFilePreview(props: CliFilePreviewProps) {
|
|
41
42
|
const ctx = useCliStoreContext();
|
|
42
|
-
return withOwnProvider(props, <FilePreviewInner {...props} inProvider={!!ctx} />, !!ctx)
|
|
43
|
+
return <CliErrorBoundary component="cli-file-preview" theme={props.theme}>{withOwnProvider(props, <FilePreviewInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
function FilePreviewInner({ jobId: jobIdProp, path, onOpenPath, onClose, actions, version, bare, theme = "light", className, style, onEvent, inProvider }: CliFilePreviewProps & { inProvider: boolean }) {
|
|
@@ -67,7 +68,7 @@ export interface CliBrowserViewProps extends BlockProps {
|
|
|
67
68
|
|
|
68
69
|
export function CliBrowserView(props: CliBrowserViewProps) {
|
|
69
70
|
const ctx = useCliStoreContext();
|
|
70
|
-
return withOwnProvider(props, <BrowserViewInner {...props} inProvider={!!ctx} />, !!ctx)
|
|
71
|
+
return <CliErrorBoundary component="cli-browser" theme={props.theme}>{withOwnProvider(props, <BrowserViewInner {...props} inProvider={!!ctx} />, !!ctx)}</CliErrorBoundary>;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
function BrowserViewInner({ jobId: jobIdProp, readOnly, theme = "light", className, style, onEvent, inProvider }: CliBrowserViewProps & { inProvider: boolean }) {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// 工作区文件的基础操作(不依赖界面):可直接打开的地址、下载到本地、读成 Blob。
|
|
2
|
+
// 看板、文件预览和给业务方的工作区接口(workspace.ts)共用。
|
|
3
|
+
import { fileUrlFromBase, type BoardBackend } from "../core/client.js";
|
|
4
|
+
import { extOf, fmtSize } from "./fileKind.js";
|
|
5
|
+
|
|
6
|
+
/** 只能先 fetch 成 blob 时(中枢太旧):超过这么大的不在浏览器里拼下载。 */
|
|
7
|
+
const BLOB_DOWNLOAD_MAX = 300 * 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export const MIME: Record<string, string> = {
|
|
10
|
+
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
|
|
11
|
+
bmp: "image/bmp", ico: "image/x-icon", avif: "image/avif", pdf: "application/pdf", html: "text/html", htm: "text/html",
|
|
12
|
+
md: "text/markdown", txt: "text/plain", csv: "text/csv", json: "application/json", zip: "application/zip",
|
|
13
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
14
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
15
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
16
|
+
};
|
|
17
|
+
export const mimeOf = (path: string) => MIME[extOf(path)] ?? "application/octet-stream";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 浏览器能直接打开的地址(不需要请求头):同源代理时就是文件接口;没有代理注入令牌时用中枢发的短期链接。
|
|
21
|
+
* 中枢太旧发不了短期链接时返回 null(调用方改用读成 Blob)。
|
|
22
|
+
*/
|
|
23
|
+
export async function directFileUrl(backend: BoardBackend, jobId: string, path: string, download = false): Promise<string | null> {
|
|
24
|
+
if (backend.directLinks) return backend.fileUrl(jobId, path, { download });
|
|
25
|
+
const linkBase = backend.fileLinkBase ? await backend.fileLinkBase(jobId) : null;
|
|
26
|
+
return linkBase ? fileUrlFromBase(linkBase, path, download) : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 把 Blob 存到本地(浏览器下载)。 */
|
|
30
|
+
export function saveBlob(blob: Blob, name: string) {
|
|
31
|
+
const url = URL.createObjectURL(blob);
|
|
32
|
+
clickDownload(url, name);
|
|
33
|
+
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clickDownload(href: string, name: string) {
|
|
37
|
+
const a = document.createElement("a");
|
|
38
|
+
a.href = href;
|
|
39
|
+
a.download = name;
|
|
40
|
+
document.body.appendChild(a);
|
|
41
|
+
a.click();
|
|
42
|
+
a.remove();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 下载:用浏览器能直接打开的地址(流式、可断点续传,不占内存);同源代理以外的接入向中枢换短期链接。
|
|
47
|
+
* 只有中枢太旧时才 fetch 成 blob 再存(太大的拒绝,免得撑爆浏览器内存)。
|
|
48
|
+
*/
|
|
49
|
+
export async function downloadFile(backend: BoardBackend, jobId: string, path: string, size?: number) {
|
|
50
|
+
const name = path.split("/").pop() || "file";
|
|
51
|
+
const url = await directFileUrl(backend, jobId, path, true);
|
|
52
|
+
if (url) { clickDownload(url, name); return; }
|
|
53
|
+
if (size !== undefined && size > BLOB_DOWNLOAD_MAX) throw new Error(`文件较大(${fmtSize(size)}),当前接入方式不能在浏览器里下载,请升级任务中心`);
|
|
54
|
+
saveBlob(new Blob([await backend.readBuffer(jobId, path)]), name);
|
|
55
|
+
}
|