@spzxg/cli-ui 0.3.1 → 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 +101 -18
- 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 +126 -29
- package/src/core/blocks.ts +21 -2
- package/src/core/client.ts +270 -41
- package/src/core/config.ts +28 -11
- package/src/core/conversation.ts +35 -12
- package/src/core/provider.tsx +27 -6
- package/src/core/store.ts +354 -69
- package/src/core/textDecode.ts +24 -0
- package/src/core/types.ts +28 -6
- package/src/elements.tsx +113 -9
- 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 +64 -26
- 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-C0F64Ln7.js +0 -37922
- package/dist/cli-ui-index--RVPCGIc.js +0 -13516
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// 工作区「下载整个文件夹」:逐级列目录、逐个读文件(经中枢),在浏览器里打成 ZIP 下载。
|
|
2
|
+
// 有上限:文件数、总大小(读进内存再打包);超了直接报出来,让用户进到更小的目录或单个下载,不会把页面撑爆。
|
|
3
|
+
// 弱网 / 节点忙:每个请求失败了按退避重试几次;某个文件一直读不到(或读的时候被任务删了)就跳过,
|
|
4
|
+
// 照样把其余的打包,失败的清单放进压缩包里的 FAILED_LIST_NAME,并告诉调用方。取消会中止正在传的请求。
|
|
5
|
+
import { CenterError, type BoardBackend } from "../core/client.js";
|
|
6
|
+
import { fmtSize } from "./fileKind.js";
|
|
7
|
+
import { saveBlob } from "./fileOps.js";
|
|
8
|
+
import { buildZip, type ZipEntry } from "./zip.js";
|
|
9
|
+
|
|
10
|
+
export const FOLDER_MAX_FILES = 2000;
|
|
11
|
+
export const FOLDER_MAX_BYTES = 300 * 1024 * 1024;
|
|
12
|
+
/** 同时读几个文件:中枢 / 节点不被一个人的下载占满,浏览器连接也留给别的请求。 */
|
|
13
|
+
const PARALLEL = 3;
|
|
14
|
+
/** 每个请求最多试几次,两次之间等多久(毫秒;带 ±30% 浮动)。 */
|
|
15
|
+
const ATTEMPTS = 3;
|
|
16
|
+
const BACKOFF_MS = [1_000, 3_000];
|
|
17
|
+
export const FAILED_LIST_NAME = "_下载失败的文件.txt";
|
|
18
|
+
|
|
19
|
+
export type FolderProgress = { phase: "listing" | "reading" | "zipping"; files: number; total: number; bytes: number; totalBytes?: number; failed?: number; retrying?: string };
|
|
20
|
+
export type FolderResult = { files: number; failed: Array<{ path: string; reason: string }> };
|
|
21
|
+
export type FolderZip = FolderResult & { blob: Blob; name: string };
|
|
22
|
+
export type ListOptions = { signal?: AbortSignal; backoff?: number[]; maxFiles?: number; maxBytes?: number };
|
|
23
|
+
|
|
24
|
+
const abortError = () => new DOMException("已取消", "AbortError");
|
|
25
|
+
const isAbort = (e: unknown) => e instanceof DOMException && e.name === "AbortError";
|
|
26
|
+
|
|
27
|
+
/** 值得再试的失败:网络(超时、断线)、限流、中枢 / 节点临时出错。404、403 之类再试也一样。 */
|
|
28
|
+
export function isTransient(error: unknown): boolean {
|
|
29
|
+
if (!(error instanceof CenterError)) return !isAbort(error);
|
|
30
|
+
return error.code === "NETWORK" || error.status === 429 || error.status === 408 || error.status >= 500 || error.code === "node_unreachable";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sleep(ms: number, signal?: AbortSignal) {
|
|
34
|
+
return new Promise<void>((resolve, reject) => {
|
|
35
|
+
if (signal?.aborted) return reject(abortError());
|
|
36
|
+
const t = setTimeout(() => { signal?.removeEventListener("abort", stop); resolve(); }, ms);
|
|
37
|
+
const stop = () => { clearTimeout(t); reject(abortError()); };
|
|
38
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 失败了按退避再试(只试临时性的失败)。onRetry 告诉界面「正在重试」。 */
|
|
43
|
+
export async function withRetry<T>(fn: () => Promise<T>, signal?: AbortSignal, onRetry?: (attempt: number, error: unknown) => void, backoff = BACKOFF_MS): Promise<T> {
|
|
44
|
+
for (let attempt = 1; ; attempt++) {
|
|
45
|
+
if (signal?.aborted) throw abortError();
|
|
46
|
+
try {
|
|
47
|
+
return await fn();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (isAbort(error) || signal?.aborted) throw abortError();
|
|
50
|
+
if (attempt >= ATTEMPTS || !isTransient(error)) throw error;
|
|
51
|
+
onRetry?.(attempt, error);
|
|
52
|
+
const base = backoff[Math.min(attempt - 1, backoff.length - 1)] ?? 0;
|
|
53
|
+
await sleep(Math.round(base * (0.7 + Math.random() * 0.6)), signal);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 列出 dir 下所有文件(递归)。超过上限(缺省 FOLDER_MAX_FILES 个、FOLDER_MAX_BYTES)抛错。 */
|
|
59
|
+
export async function listFolder(backend: BoardBackend, jobId: string, dir: string, opts: ListOptions = {}): Promise<Array<{ path: string; size: number; mtime: number }>> {
|
|
60
|
+
const { signal, backoff, maxFiles = FOLDER_MAX_FILES, maxBytes = FOLDER_MAX_BYTES } = opts;
|
|
61
|
+
const out: Array<{ path: string; size: number; mtime: number }> = [];
|
|
62
|
+
let bytes = 0;
|
|
63
|
+
const queue = [dir || "."];
|
|
64
|
+
while (queue.length) {
|
|
65
|
+
if (signal?.aborted) throw abortError();
|
|
66
|
+
const cur = queue.shift()!;
|
|
67
|
+
let res;
|
|
68
|
+
try {
|
|
69
|
+
res = await withRetry(() => backend.stat(jobId, cur), signal, undefined, backoff);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// 子目录在列的时候被任务删了:跳过它;要下载的目录本身没了照常报错。
|
|
72
|
+
if (cur !== (dir || ".") && error instanceof CenterError && error.status === 404) continue;
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
if (res.type !== "dir") continue;
|
|
76
|
+
if (res.truncated) throw new Error(`「${cur === "." ? "工作区" : cur}」里文件太多,节点只列出了一部分;请进到更小的目录再下载`);
|
|
77
|
+
for (const e of res.entries) {
|
|
78
|
+
if (e.type === "dir") { queue.push(e.path); continue; }
|
|
79
|
+
out.push({ path: e.path, size: e.size, mtime: e.mtime });
|
|
80
|
+
bytes += e.size;
|
|
81
|
+
if (out.length > maxFiles) throw new Error(`文件超过 ${maxFiles} 个,请进到更小的目录再下载`);
|
|
82
|
+
if (bytes > maxBytes) throw new Error(`总大小超过 ${fmtSize(maxBytes)},请进到更小的目录或单个下载`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type FolderOptions = { onProgress?: (p: FolderProgress) => void; signal?: AbortSignal; zipName?: string; backoff?: number[] };
|
|
89
|
+
|
|
90
|
+
/** 打包下载 dir("" = 整个工作区)到本地。返回打了几个文件、哪些没读到。 */
|
|
91
|
+
export async function downloadFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderResult> {
|
|
92
|
+
const { blob, name, files, failed } = await zipFolder(backend, jobId, dir, opts);
|
|
93
|
+
saveBlob(blob, name);
|
|
94
|
+
return { files, failed };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 把 dir("" = 整个工作区)打成 ZIP,返回 Blob(不下载;业务方可以自己上传到服务器)。 */
|
|
98
|
+
export async function zipFolder(backend: BoardBackend, jobId: string, dir: string, opts: FolderOptions = {}): Promise<FolderZip> {
|
|
99
|
+
const { onProgress, signal } = opts;
|
|
100
|
+
onProgress?.({ phase: "listing", files: 0, total: 0, bytes: 0 });
|
|
101
|
+
const files = await listFolder(backend, jobId, dir, { signal, backoff: opts.backoff });
|
|
102
|
+
if (!files.length) throw new Error("这个目录里没有文件");
|
|
103
|
+
const totalBytes = files.reduce((n, f) => n + f.size, 0);
|
|
104
|
+
const base = dir ? `${dir.replace(/\/+$/, "")}/` : "";
|
|
105
|
+
const entries: Array<ZipEntry | null> = new Array(files.length).fill(null);
|
|
106
|
+
const failed: FolderResult["failed"] = [];
|
|
107
|
+
// 每个文件已收到的字节(含正在传的),进度条按它走:一个大文件慢慢传时也看得到在动。
|
|
108
|
+
const got = new Array<number>(files.length).fill(0);
|
|
109
|
+
let done = 0;
|
|
110
|
+
let next = 0;
|
|
111
|
+
let retrying: string | undefined;
|
|
112
|
+
let lastReport = 0;
|
|
113
|
+
const report = (force = false) => {
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
if (!force && now - lastReport < 200) return;
|
|
116
|
+
lastReport = now;
|
|
117
|
+
onProgress?.({ phase: "reading", files: done, total: files.length, bytes: got.reduce((a, b) => a + b, 0), totalBytes, failed: failed.length, retrying });
|
|
118
|
+
};
|
|
119
|
+
const worker = async () => {
|
|
120
|
+
while (next < files.length) {
|
|
121
|
+
const i = next++;
|
|
122
|
+
if (signal?.aborted) throw abortError();
|
|
123
|
+
const f = files[i];
|
|
124
|
+
try {
|
|
125
|
+
const buffer = await withRetry(
|
|
126
|
+
() => { got[i] = 0; return backend.readBuffer(jobId, f.path, { signal, onBytes: (n) => { got[i] = n; report(); } }); },
|
|
127
|
+
signal,
|
|
128
|
+
() => { retrying = f.path; report(true); },
|
|
129
|
+
opts.backoff,
|
|
130
|
+
);
|
|
131
|
+
const data = new Uint8Array(buffer);
|
|
132
|
+
got[i] = data.length;
|
|
133
|
+
// 任务在写、文件比列的时候大了很多:总量还是要守住上限。
|
|
134
|
+
if (got.reduce((a, b) => a + b, 0) > FOLDER_MAX_BYTES * 1.1) throw new Error(`读到的总大小超过 ${fmtSize(FOLDER_MAX_BYTES)}(文件还在变大),请进到更小的目录或单个下载`);
|
|
135
|
+
// ZIP 里的路径相对下载的目录(文件夹名本身作为 ZIP 文件名)。
|
|
136
|
+
entries[i] = { path: f.path.startsWith(base) ? f.path.slice(base.length) : f.path, data, mtime: f.mtime };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (isAbort(error) || signal?.aborted) throw abortError();
|
|
139
|
+
if (!(error instanceof CenterError)) throw error;
|
|
140
|
+
got[i] = 0;
|
|
141
|
+
failed.push({ path: f.path, reason: error.message });
|
|
142
|
+
}
|
|
143
|
+
if (retrying === f.path) retrying = undefined;
|
|
144
|
+
done++;
|
|
145
|
+
report(true);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
// 列完马上报「读取 0 / N」:网络慢时第一个文件要好一会儿才有字节,不能一直停在「正在列出」。
|
|
149
|
+
report(true);
|
|
150
|
+
await Promise.all(Array.from({ length: Math.min(PARALLEL, files.length) }, worker));
|
|
151
|
+
const ok = entries.filter((e): e is ZipEntry => !!e);
|
|
152
|
+
if (!ok.length) throw new Error(`${failed.length} 个文件都没读到:${failed[0]?.reason ?? "未知原因"}`);
|
|
153
|
+
onProgress?.({ phase: "zipping", files: done, total: files.length, bytes: got.reduce((a, b) => a + b, 0), totalBytes, failed: failed.length });
|
|
154
|
+
if (failed.length) {
|
|
155
|
+
const list = ["下面这些文件没有下载下来(其余文件都在压缩包里):", "", ...failed.map((f) => `${f.path}\t${f.reason}`), ""].join("\n");
|
|
156
|
+
ok.push({ path: FAILED_LIST_NAME, data: new TextEncoder().encode(list) as Uint8Array<ArrayBuffer> });
|
|
157
|
+
}
|
|
158
|
+
const blob = buildZip(ok);
|
|
159
|
+
const name = opts.zipName ?? `${(dir.split("/").filter(Boolean).pop() || "工作区")}.zip`;
|
|
160
|
+
return { blob, name, files: ok.length - (failed.length ? 1 : 0), failed };
|
|
161
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 给业务方的工作区接口:不显示看板也能拿到任务产出的文件。
|
|
3
|
+
*
|
|
4
|
+
* const ws = store.workspace("job_xxx"); // 不传 = 当前选中的对话;Web Component:el.workspace()
|
|
5
|
+
* await ws.listAll(); // 工作区里所有文件(递归)
|
|
6
|
+
* await ws.readText("reports/报告.md"); // 读成文字
|
|
7
|
+
* await ws.readFile("reports/图.png"); // 读成 Blob(可以自己上传)
|
|
8
|
+
* await ws.zip("reports"); // 打成 ZIP 的 Blob
|
|
9
|
+
* await ws.download("reports/报告.md"); // 下载到用户本地;ws.downloadFolder("reports") 打包下载
|
|
10
|
+
* ws.openFile(); // 看板上正在看的文件(没有看板 / 没打开文件时为 null)
|
|
11
|
+
*
|
|
12
|
+
* 数据都经中枢读执行节点(和看板一样),弱网时按网络错误(CenterError,code = "NETWORK")失败,可以重试。
|
|
13
|
+
*/
|
|
14
|
+
import type { BoardBackend } from "../core/client.js";
|
|
15
|
+
import type { BoardInfo, FileEntry } from "../core/types.js";
|
|
16
|
+
import { directFileUrl, downloadFile, mimeOf } from "./fileOps.js";
|
|
17
|
+
import { fmtSize } from "./fileKind.js";
|
|
18
|
+
import { downloadFolder, listFolder, zipFolder, type FolderOptions, type FolderResult, type FolderZip } from "./folderDownload.js";
|
|
19
|
+
|
|
20
|
+
/** readText 最多读这么大(更大的用 readFile)。 */
|
|
21
|
+
export const WORKSPACE_TEXT_MAX = 20 * 1024 * 1024;
|
|
22
|
+
/** readFile 最多读这么大进内存(更大的用 fileUrl / download,浏览器流式下载)。 */
|
|
23
|
+
export const WORKSPACE_FILE_MAX = 500 * 1024 * 1024;
|
|
24
|
+
/** listAll 最多列这么多个文件。 */
|
|
25
|
+
export const WORKSPACE_LIST_MAX = 10_000;
|
|
26
|
+
|
|
27
|
+
/** 看板现在的样子(看板组件报告给仓库;没有看板时没有)。 */
|
|
28
|
+
export interface BoardView {
|
|
29
|
+
/** 当前页签:files / changes / browser、"file:<路径>"(打开的文件)或自己加的页签 id。 */
|
|
30
|
+
tab: string;
|
|
31
|
+
/** 文件列表所在目录("" = 工作区根目录)。 */
|
|
32
|
+
dir: string;
|
|
33
|
+
/** 正在看的文件(当前页签是文件时),否则 null。 */
|
|
34
|
+
file: string | null;
|
|
35
|
+
/** 开着的文件页签。 */
|
|
36
|
+
files: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CliWorkspace {
|
|
40
|
+
/** 这个工作区属于哪个对话(对话键)。 */
|
|
41
|
+
readonly jobId: string;
|
|
42
|
+
/** 看板概况:派发到节点没有、节点是否在线、有没有浏览器。 */
|
|
43
|
+
info(): Promise<BoardInfo>;
|
|
44
|
+
/** 列一个目录(不递归)。 */
|
|
45
|
+
list(dir?: string): Promise<FileEntry[]>;
|
|
46
|
+
/** 列 dir 下所有文件(递归,只含文件)。 */
|
|
47
|
+
listAll(dir?: string, opts?: { signal?: AbortSignal }): Promise<FileEntry[]>;
|
|
48
|
+
/** 读成文字(GBK 导出的文件会自动转码)。 */
|
|
49
|
+
readText(path: string): Promise<string>;
|
|
50
|
+
/** 读成 Blob。onProgress 报已收到的字节数;超过 maxBytes(缺省 500 MB)报错,改用 fileUrl / download。 */
|
|
51
|
+
readFile(path: string, opts?: { signal?: AbortSignal; onProgress?: (bytes: number) => void; maxBytes?: number }): Promise<Blob>;
|
|
52
|
+
/** 浏览器能直接打开 / 下载的地址(没有同源代理时是 30 分钟有效的短期链接);接入方式不支持时为 null,改用 readFile。 */
|
|
53
|
+
fileUrl(path: string, opts?: { download?: boolean }): Promise<string | null>;
|
|
54
|
+
/** 下载一个文件到用户本地。 */
|
|
55
|
+
download(path: string): Promise<void>;
|
|
56
|
+
/** 把目录打包成 ZIP 下载到用户本地("" = 整个工作区)。 */
|
|
57
|
+
downloadFolder(dir?: string, opts?: FolderOptions): Promise<FolderResult>;
|
|
58
|
+
/** 把目录打成 ZIP,返回 Blob,不下载。 */
|
|
59
|
+
zip(dir?: string, opts?: FolderOptions): Promise<FolderZip>;
|
|
60
|
+
/** 看板上正在看的文件;没有看板或没打开文件时为 null。 */
|
|
61
|
+
openFile(): string | null;
|
|
62
|
+
/** 看板上开着的所有文件页签。 */
|
|
63
|
+
openFiles(): string[];
|
|
64
|
+
/** 在看板里打开这个文件(没有看板接时返回 false)。 */
|
|
65
|
+
open(path: string): boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createWorkspace(deps: {
|
|
69
|
+
backend: BoardBackend | undefined;
|
|
70
|
+
key: string;
|
|
71
|
+
view: () => BoardView | undefined;
|
|
72
|
+
open: (path: string) => boolean;
|
|
73
|
+
}): CliWorkspace {
|
|
74
|
+
const { key } = deps;
|
|
75
|
+
const backend = () => {
|
|
76
|
+
if (!deps.backend) throw new Error("当前后端不支持读取工作区文件(需要任务中心的看板接口)");
|
|
77
|
+
return deps.backend;
|
|
78
|
+
};
|
|
79
|
+
const clean = (path: string) => path.replace(/^\.\//, "").replace(/\/+$/, "");
|
|
80
|
+
return {
|
|
81
|
+
jobId: key,
|
|
82
|
+
info: () => backend().info(key),
|
|
83
|
+
async list(dir = "") {
|
|
84
|
+
const res = await backend().stat(key, clean(dir) || ".");
|
|
85
|
+
if (res.type !== "dir") throw new Error(`「${dir}」不是目录`);
|
|
86
|
+
return res.entries;
|
|
87
|
+
},
|
|
88
|
+
async listAll(dir = "", opts = {}) {
|
|
89
|
+
const files = await listFolder(backend(), key, clean(dir), { signal: opts.signal, maxFiles: WORKSPACE_LIST_MAX, maxBytes: Infinity });
|
|
90
|
+
return files.map((f) => ({ name: f.path.split("/").pop() || f.path, path: f.path, type: "file" as const, size: f.size, mtime: f.mtime }));
|
|
91
|
+
},
|
|
92
|
+
async readText(path) {
|
|
93
|
+
const r = await backend().readText(key, clean(path), WORKSPACE_TEXT_MAX);
|
|
94
|
+
if (r.truncated) throw new Error(`文件较大(超过 ${WORKSPACE_TEXT_MAX / 1024 / 1024} MB),请用 readFile 读成 Blob`);
|
|
95
|
+
return r.text;
|
|
96
|
+
},
|
|
97
|
+
async readFile(path, opts = {}) {
|
|
98
|
+
const p = clean(path);
|
|
99
|
+
// 太大的读到一半就停:整个读进内存会把页面撑爆。
|
|
100
|
+
const ctl = new AbortController();
|
|
101
|
+
const stop = () => ctl.abort();
|
|
102
|
+
opts.signal?.addEventListener("abort", stop, { once: true });
|
|
103
|
+
let tooBig = false;
|
|
104
|
+
const max = opts.maxBytes ?? WORKSPACE_FILE_MAX;
|
|
105
|
+
try {
|
|
106
|
+
const buffer = await backend().readBuffer(key, p, {
|
|
107
|
+
signal: ctl.signal,
|
|
108
|
+
onBytes: (n) => { if (n > max) { tooBig = true; ctl.abort(); } else opts.onProgress?.(n); },
|
|
109
|
+
});
|
|
110
|
+
return new Blob([buffer], { type: mimeOf(p) });
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (tooBig) throw new Error(`文件超过 ${fmtSize(max)},请用 fileUrl / download(浏览器流式下载,不占内存)`);
|
|
113
|
+
throw error;
|
|
114
|
+
} finally {
|
|
115
|
+
opts.signal?.removeEventListener("abort", stop);
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
async fileUrl(path, opts = {}) {
|
|
119
|
+
const url = await directFileUrl(backend(), key, clean(path), opts.download);
|
|
120
|
+
return url && typeof location !== "undefined" ? new URL(url, location.href).href : url;
|
|
121
|
+
},
|
|
122
|
+
download: (path) => downloadFile(backend(), key, clean(path)),
|
|
123
|
+
downloadFolder: (dir = "", opts) => downloadFolder(backend(), key, clean(dir), opts),
|
|
124
|
+
zip: (dir = "", opts) => zipFolder(backend(), key, clean(dir), opts),
|
|
125
|
+
openFile: () => deps.view()?.file ?? null,
|
|
126
|
+
openFiles: () => [...(deps.view()?.files ?? [])],
|
|
127
|
+
open: (path) => deps.open(clean(path)),
|
|
128
|
+
};
|
|
129
|
+
}
|
package/src/board/zip.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// 在浏览器里把几个文件打成 ZIP(只存储、不压缩):工作区「下载整个文件夹」用。
|
|
2
|
+
// 中枢 / 节点没有打包接口,文件逐个经中枢读回来,在这里拼成一个 ZIP 交给浏览器下载。
|
|
3
|
+
// 不压缩:报告、图片、表格多半已经压缩过,存储模式快、内存只多一份目录表;文件名按 UTF-8 标记(中文名不乱码)。
|
|
4
|
+
// 不支持 ZIP64:调用方限制了总大小(远小于 4 GB)和文件数。
|
|
5
|
+
|
|
6
|
+
export type ZipEntry = { path: string; data: Uint8Array<ArrayBuffer>; mtime?: number };
|
|
7
|
+
|
|
8
|
+
const CRC_TABLE = (() => {
|
|
9
|
+
const t = new Uint32Array(256);
|
|
10
|
+
for (let n = 0; n < 256; n++) {
|
|
11
|
+
let c = n;
|
|
12
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
13
|
+
t[n] = c >>> 0;
|
|
14
|
+
}
|
|
15
|
+
return t;
|
|
16
|
+
})();
|
|
17
|
+
|
|
18
|
+
export function crc32(data: Uint8Array): number {
|
|
19
|
+
let c = 0xffffffff;
|
|
20
|
+
for (let i = 0; i < data.length; i++) c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
|
|
21
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** DOS 时间 / 日期(ZIP 头里用)。 */
|
|
25
|
+
function dosTime(ms: number | undefined): { time: number; date: number } {
|
|
26
|
+
const d = new Date(ms && ms > 0 ? ms : Date.now());
|
|
27
|
+
const year = Math.max(1980, d.getFullYear());
|
|
28
|
+
return {
|
|
29
|
+
time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2),
|
|
30
|
+
date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildZip(entries: ZipEntry[]): Blob {
|
|
35
|
+
const enc = new TextEncoder();
|
|
36
|
+
const parts: BlobPart[] = [];
|
|
37
|
+
const central: Uint8Array<ArrayBuffer>[] = [];
|
|
38
|
+
let offset = 0;
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
const name = enc.encode(e.path.replace(/^\/+/, ""));
|
|
41
|
+
const crc = crc32(e.data);
|
|
42
|
+
const { time, date } = dosTime(e.mtime);
|
|
43
|
+
const local = new Uint8Array(30 + name.length);
|
|
44
|
+
const lv = new DataView(local.buffer);
|
|
45
|
+
lv.setUint32(0, 0x04034b50, true);
|
|
46
|
+
lv.setUint16(4, 20, true);
|
|
47
|
+
lv.setUint16(6, 0x0800, true); // 文件名是 UTF-8
|
|
48
|
+
lv.setUint16(8, 0, true); // 存储
|
|
49
|
+
lv.setUint16(10, time, true);
|
|
50
|
+
lv.setUint16(12, date, true);
|
|
51
|
+
lv.setUint32(14, crc, true);
|
|
52
|
+
lv.setUint32(18, e.data.length, true);
|
|
53
|
+
lv.setUint32(22, e.data.length, true);
|
|
54
|
+
lv.setUint16(26, name.length, true);
|
|
55
|
+
local.set(name, 30);
|
|
56
|
+
parts.push(local, e.data);
|
|
57
|
+
|
|
58
|
+
const head = new Uint8Array(46 + name.length);
|
|
59
|
+
const cv = new DataView(head.buffer);
|
|
60
|
+
cv.setUint32(0, 0x02014b50, true);
|
|
61
|
+
cv.setUint16(4, 20, true);
|
|
62
|
+
cv.setUint16(6, 20, true);
|
|
63
|
+
cv.setUint16(8, 0x0800, true);
|
|
64
|
+
cv.setUint16(10, 0, true);
|
|
65
|
+
cv.setUint16(12, time, true);
|
|
66
|
+
cv.setUint16(14, date, true);
|
|
67
|
+
cv.setUint32(16, crc, true);
|
|
68
|
+
cv.setUint32(20, e.data.length, true);
|
|
69
|
+
cv.setUint32(24, e.data.length, true);
|
|
70
|
+
cv.setUint16(28, name.length, true);
|
|
71
|
+
cv.setUint32(42, offset, true);
|
|
72
|
+
head.set(name, 46);
|
|
73
|
+
central.push(head);
|
|
74
|
+
offset += local.length + e.data.length;
|
|
75
|
+
}
|
|
76
|
+
const centralSize = central.reduce((n, c) => n + c.length, 0);
|
|
77
|
+
const end = new Uint8Array(22);
|
|
78
|
+
const ev = new DataView(end.buffer);
|
|
79
|
+
ev.setUint32(0, 0x06054b50, true);
|
|
80
|
+
ev.setUint16(8, entries.length, true);
|
|
81
|
+
ev.setUint16(10, entries.length, true);
|
|
82
|
+
ev.setUint32(12, centralSize, true);
|
|
83
|
+
ev.setUint32(16, offset, true);
|
|
84
|
+
return new Blob([...parts, ...central, end], { type: "application/zip" });
|
|
85
|
+
}
|