dsh-long-plugins 2.6.2 → 3.0.2
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/LICENSE +0 -0
- package/README.md +0 -0
- package/THIRD_PARTY_NOTICES.md +0 -0
- package/client/client.js +440 -980
- package/client/client.js.bak-v264 +3291 -0
- package/client/vendor/chart.umd.min.js +0 -0
- package/client/vendor/docx-preview.min.js +0 -0
- package/client/vendor/jszip.min.js +0 -0
- package/client/vendor/pptxviewjs.min.js +0 -0
- package/client/vendor/xlsx.full.min.js +0 -0
- package/cordis.patch.yml +0 -0
- package/dsh.plugin.json +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.bak-v264 +2836 -0
- package/package.json +2 -3
- package/skill/dsh-common-plugins-install/SKILL.md +0 -0
- package/skill/dsh-long-plugins-install/SKILL.md +0 -0
- package/skill/dsh-upgrade/SKILL.md +0 -0
- package/skill/dsh-web-start-panel-install/SKILL.md +0 -0
- package/skill/dsh-web-win-service-install/SKILL.md +0 -0
- package/patches/dsh-client-connection-heartbeat.sh +0 -112
|
@@ -0,0 +1,2836 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-long-plugins host entry.
|
|
3
|
+
*
|
|
4
|
+
* Merged single plugin bundling the former separate plugins:
|
|
5
|
+
*
|
|
6
|
+
* - dsh-file-uploads — upload manager + workspace "输出文件" section
|
|
7
|
+
* (`/api/dsh-uploads/*`)
|
|
8
|
+
* - dsh-skill-docs — settings "技能文档" section (`/dsh-skill-docs/*`)
|
|
9
|
+
* - dsh-token-usage — DeepSeek account balance (`/dsh-token-usage/balance`)
|
|
10
|
+
*
|
|
11
|
+
* All routes are loopback / same-origin gated via `isTrustedUploadRequest`
|
|
12
|
+
* (which honours `config.trustedHosts`); file paths are resolved inside
|
|
13
|
+
* their configured root so traversal is impossible.
|
|
14
|
+
*/
|
|
15
|
+
import { createReadStream, readFileSync } from "node:fs";
|
|
16
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
17
|
+
import { link, lstat, mkdir, open, readdir, rename, stat, unlink } from "node:fs/promises";
|
|
18
|
+
import { homedir } from "node:os";
|
|
19
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { randomUUID } from "node:crypto";
|
|
22
|
+
import { inflateRawSync, inflateSync } from "node:zlib";
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
25
|
+
import mammoth from "mammoth";
|
|
26
|
+
import ExcelJS from "exceljs";
|
|
27
|
+
|
|
28
|
+
// 插件根下的 client/vendor 目录(存放 docx-preview / jszip 前端库,供 docx 预览端点读取)。
|
|
29
|
+
// 用 module 级定位,让 createHandlers / 各 handler 都能访问(不依赖 apply 内的 PACKAGE_DIR)。
|
|
30
|
+
const VENDOR_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "client", "vendor");
|
|
31
|
+
// 插件当前版本(读 package.json),用于「插件升级时」的配置迁移(如默认关闭回合导航/RA-Span)
|
|
32
|
+
const PLUGIN_VERSION = (() => { try { return JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version || ""; } catch { return ""; } })();
|
|
33
|
+
|
|
34
|
+
export const name = "dsh-long-plugins";
|
|
35
|
+
|
|
36
|
+
/** Server services required: the web route carrier and the credential seam. */
|
|
37
|
+
export const inject = ["webServer", "credentials", "sessions", "sessionPersistence", "tools"];
|
|
38
|
+
|
|
39
|
+
export const API_PATH = "/api/dsh-uploads";
|
|
40
|
+
export const DOWNLOAD_PATH = "/api/dsh-uploads/download";
|
|
41
|
+
export const PREVIEW_PATH = "/api/dsh-uploads/preview";
|
|
42
|
+
export const DEFAULT_MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
43
|
+
export const DEFAULT_TOTAL_MAX_BYTES = 1024 * 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
/** DeepSeek account balance endpoint. */
|
|
46
|
+
const BALANCE_URL = "https://api.deepseek.com/user/balance";
|
|
47
|
+
|
|
48
|
+
/** Largest preview body we will inline (256 KiB); larger files preview as metadata only. */
|
|
49
|
+
const PREVIEW_LIMIT = 256 * 1024;
|
|
50
|
+
|
|
51
|
+
/** Skills root: default $HOME/skills, overridable via config.skillsRoot. */
|
|
52
|
+
const DEFAULT_SKILLS_ROOT = resolve(process.env.HOME ?? process.env.DSH_HOME ?? "", "skills");
|
|
53
|
+
|
|
54
|
+
class HttpError extends Error {
|
|
55
|
+
constructor(status, message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.status = status;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveUploadRoot(env = process.env) {
|
|
62
|
+
const dshHome = env.DSH_HOME?.trim() || join(homedir(), ".dsh");
|
|
63
|
+
return resolve(env.DSH_UPLOAD_DIR?.trim() || join(dshHome, "uploads"));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function positiveInteger(value, fallback) {
|
|
67
|
+
const configured = Number(value);
|
|
68
|
+
return Number.isSafeInteger(configured) && configured > 0 ? configured : fallback;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveMaxFileBytes(env = process.env) {
|
|
72
|
+
return positiveInteger(env.DSH_UPLOAD_MAX_BYTES, DEFAULT_MAX_FILE_BYTES);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function resolveTotalMaxBytes(env = process.env) {
|
|
76
|
+
return positiveInteger(env.DSH_UPLOAD_TOTAL_MAX_BYTES, DEFAULT_TOTAL_MAX_BYTES);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function sanitizeUploadName(value) {
|
|
80
|
+
const decoded = String(value || "").normalize("NFC");
|
|
81
|
+
let safe = basename(decoded)
|
|
82
|
+
.replace(/[\\/\u0000-\u001f\u007f]/g, "_")
|
|
83
|
+
.replace(/^\.+/, "")
|
|
84
|
+
.trim();
|
|
85
|
+
|
|
86
|
+
if (!safe) safe = "upload.bin";
|
|
87
|
+
if (safe.startsWith(".upload-")) safe = `file-${safe}`;
|
|
88
|
+
|
|
89
|
+
if (safe.length > 180) {
|
|
90
|
+
const extension = extname(safe).slice(0, 24);
|
|
91
|
+
const stem = safe.slice(0, Math.max(1, 180 - extension.length));
|
|
92
|
+
safe = `${stem}${extension}`;
|
|
93
|
+
}
|
|
94
|
+
return safe;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isSafeStoredName(value) {
|
|
98
|
+
return typeof value === "string"
|
|
99
|
+
&& value.length > 0
|
|
100
|
+
&& value.length <= 180
|
|
101
|
+
&& value === basename(value)
|
|
102
|
+
&& value !== "."
|
|
103
|
+
&& value !== ".."
|
|
104
|
+
&& !value.startsWith(".upload-")
|
|
105
|
+
&& !/[\\/\u0000-\u001f\u007f]/.test(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function requestUrl(req) {
|
|
109
|
+
return new URL(req.url || "/", "http://dsh.internal");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function queryName(req) {
|
|
113
|
+
const value = requestUrl(req).searchParams.get("name");
|
|
114
|
+
if (!isSafeStoredName(value)) throw new HttpError(400, "invalid file name");
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function uploadHeaderName(req) {
|
|
119
|
+
const value = header(req.headers, "x-file-name");
|
|
120
|
+
if (value === undefined || value.length === 0) {
|
|
121
|
+
throw new HttpError(400, "x-file-name header is required");
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
return sanitizeUploadName(decodeURIComponent(value));
|
|
125
|
+
} catch {
|
|
126
|
+
throw new HttpError(400, "x-file-name must be URI encoded UTF-8");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function header(headers, key) {
|
|
131
|
+
const value = headers[key];
|
|
132
|
+
return typeof value === "string" ? value : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseAuthority(authority) {
|
|
136
|
+
try {
|
|
137
|
+
return new URL(`http://${authority}`);
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function canonicalAuthority(entry, entryUrl) {
|
|
144
|
+
const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port;
|
|
145
|
+
return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function assertTrustedAuthority(entry) {
|
|
149
|
+
const entryUrl = parseAuthority(entry);
|
|
150
|
+
if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return;
|
|
151
|
+
throw new Error(`dsh-long-plugins: trusted host ${JSON.stringify(entry)} is not a bare host[:port] authority`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isLoopbackHostname(hostname) {
|
|
155
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
156
|
+
const parts = hostname.split(".");
|
|
157
|
+
return parts.length === 4
|
|
158
|
+
&& parts[0] === "127"
|
|
159
|
+
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
163
|
+
return trustedHosts.some((entry) => {
|
|
164
|
+
const entryUrl = parseAuthority(entry);
|
|
165
|
+
if (entryUrl === undefined) return false;
|
|
166
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
|
167
|
+
? entryUrl.hostname === hostUrl.hostname
|
|
168
|
+
: entryUrl.host === hostUrl.host;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Loopback / same-origin gate for every route (browser requests must match
|
|
174
|
+
* Origin to Host; Origin-less calls only from loopback; `trustedHosts` from
|
|
175
|
+
* the profile patch extend loopback to trusted reverse-proxy authorities).
|
|
176
|
+
*/
|
|
177
|
+
export function isTrustedUploadRequest(req, trustedHosts = []) {
|
|
178
|
+
const host = header(req.headers, "host");
|
|
179
|
+
if (host === undefined) return false;
|
|
180
|
+
const hostUrl = parseAuthority(host);
|
|
181
|
+
if (hostUrl === undefined) return false;
|
|
182
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
|
|
183
|
+
if (header(req.headers, "sec-fetch-site") === "cross-site") return false;
|
|
184
|
+
|
|
185
|
+
const origin = header(req.headers, "origin");
|
|
186
|
+
if (origin === undefined) return true;
|
|
187
|
+
try {
|
|
188
|
+
return new URL(origin).host === hostUrl.host;
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Read a JSON request body (bounded; 1 MiB to fit large edited documents). */
|
|
195
|
+
function readJsonBody(request, maxBytes = 1024 * 1024) {
|
|
196
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
197
|
+
let size = 0;
|
|
198
|
+
let tooBig = false;
|
|
199
|
+
const chunks = [];
|
|
200
|
+
request.on("data", (chunk) => {
|
|
201
|
+
if (tooBig) return;
|
|
202
|
+
size += chunk.length;
|
|
203
|
+
if (size > maxBytes) {
|
|
204
|
+
tooBig = true;
|
|
205
|
+
chunks.length = 0;
|
|
206
|
+
rejectPromise(new HttpError(413, "body too large"));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
chunks.push(chunk);
|
|
210
|
+
});
|
|
211
|
+
request.on("end", () => {
|
|
212
|
+
if (tooBig) return;
|
|
213
|
+
try {
|
|
214
|
+
resolvePromise(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
215
|
+
} catch (error) {
|
|
216
|
+
rejectPromise(error);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
request.on("error", rejectPromise);
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function sendJson(res, status, value) {
|
|
224
|
+
const body = JSON.stringify(value);
|
|
225
|
+
res.writeHead(status, {
|
|
226
|
+
"content-type": "application/json; charset=utf-8",
|
|
227
|
+
"content-length": Buffer.byteLength(body),
|
|
228
|
+
"cache-control": "no-store",
|
|
229
|
+
});
|
|
230
|
+
res.end(body);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function sendError(res, error, onError) {
|
|
234
|
+
if (error instanceof HttpError) {
|
|
235
|
+
sendJson(res, error.status, { error: error.message });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
onError?.(error);
|
|
239
|
+
sendJson(res, 500, { error: "internal server error" });
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function methodNotAllowed(res, methods) {
|
|
243
|
+
res.writeHead(405, { allow: methods.join(", "), "content-length": 0 });
|
|
244
|
+
res.end();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function writeRequestToFile(req, target, maxBytes, limitStatus, limitMessage) {
|
|
248
|
+
const declared = Number(header(req.headers, "content-length"));
|
|
249
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
250
|
+
req.resume();
|
|
251
|
+
throw new HttpError(limitStatus, limitMessage);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const handle = await open(target, "wx", 0o600);
|
|
255
|
+
let bytes = 0;
|
|
256
|
+
try {
|
|
257
|
+
for await (const chunk of req) {
|
|
258
|
+
bytes += chunk.length;
|
|
259
|
+
if (bytes > maxBytes) {
|
|
260
|
+
req.resume();
|
|
261
|
+
throw new HttpError(limitStatus, limitMessage);
|
|
262
|
+
}
|
|
263
|
+
await handle.write(chunk);
|
|
264
|
+
}
|
|
265
|
+
await handle.sync();
|
|
266
|
+
return bytes;
|
|
267
|
+
} finally {
|
|
268
|
+
await handle.close();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function numberedName(name, index) {
|
|
273
|
+
if (index === 0) return name;
|
|
274
|
+
const extension = extname(name).slice(0, 24);
|
|
275
|
+
const stem = name.slice(0, name.length - extname(name).length);
|
|
276
|
+
const suffix = ` (${index})`;
|
|
277
|
+
const maxStemLength = Math.max(1, 180 - extension.length - suffix.length);
|
|
278
|
+
return `${stem.slice(0, maxStemLength)}${suffix}${extension}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function publishUnique(tempPath, root, requestedName) {
|
|
282
|
+
for (let index = 0; index < 10_000; index += 1) {
|
|
283
|
+
const name = numberedName(requestedName, index);
|
|
284
|
+
const target = join(root, name);
|
|
285
|
+
try {
|
|
286
|
+
await link(tempPath, target);
|
|
287
|
+
await unlink(tempPath);
|
|
288
|
+
return { name, path: target };
|
|
289
|
+
} catch (error) {
|
|
290
|
+
if (error?.code === "EEXIST") continue;
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
throw new HttpError(409, "too many files share this name");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function fileRecord(root, name, info) {
|
|
298
|
+
return {
|
|
299
|
+
name,
|
|
300
|
+
path: join(root, name),
|
|
301
|
+
size: info.size,
|
|
302
|
+
modifiedAt: info.mtime.toISOString(),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function listUploadedFiles(root) {
|
|
307
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
308
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
309
|
+
const files = [];
|
|
310
|
+
for (const entry of entries) {
|
|
311
|
+
if (!entry.isFile() || entry.name.startsWith(".upload-")) continue;
|
|
312
|
+
try {
|
|
313
|
+
const info = await stat(join(root, entry.name));
|
|
314
|
+
files.push(fileRecord(root, entry.name, info));
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (error?.code !== "ENOENT") throw error;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
files.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt) || a.name.localeCompare(b.name));
|
|
320
|
+
return files;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function resolveWorkspaceRoot(env = process.env) {
|
|
324
|
+
return dirname(resolveUploadRoot(env));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function workspaceExcludedName(env = process.env) {
|
|
328
|
+
return basename(resolveUploadRoot(env)) || "upload";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Resolve a user-supplied relative path safely inside the root. */
|
|
332
|
+
function safeResolve(root, rel) {
|
|
333
|
+
if (typeof rel !== "string" || rel.length === 0 || rel.includes("\0")) return undefined;
|
|
334
|
+
const full = resolve(root, rel);
|
|
335
|
+
if (full !== root && !full.startsWith(root + sep)) return undefined;
|
|
336
|
+
return full;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** 输出文件白名单:只显示文档类 + 图片(压缩包/代码/音视频/脚本一律隐藏) */
|
|
340
|
+
const DOCUMENT_EXTS = new Set([
|
|
341
|
+
// 文本/文档
|
|
342
|
+
"md", "markdown", "txt", "log", "rtf",
|
|
343
|
+
// Office 文档
|
|
344
|
+
"doc", "docx", "pdf",
|
|
345
|
+
// 表格
|
|
346
|
+
"xls", "xlsx", "csv", "tsv",
|
|
347
|
+
// 演示
|
|
348
|
+
"ppt", "pptx",
|
|
349
|
+
// 数据/配置
|
|
350
|
+
"json", "yml", "yaml", "xml",
|
|
351
|
+
// 网页
|
|
352
|
+
"html", "htm",
|
|
353
|
+
// 图片
|
|
354
|
+
"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "tiff", "heic",
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
/** 默认隐藏的「非会话产出」文件(部署/插件/日志等混入工作区的杂项),
|
|
358
|
+
* 浏览页与输出文件面板都隐藏;可用 config.excludedWorkspaceNames 追加。 */
|
|
359
|
+
const WORKSPACE_EXCLUDED_NAMES = new Set([
|
|
360
|
+
"index.html", "index.htm", "serve.log", "docker-compose.yml", "docker-compose.yaml",
|
|
361
|
+
"compose.yml", "compose.yaml", "package.json", "package-lock.json", "pnpm-lock.yaml",
|
|
362
|
+
"yarn.lock", "bun.lockb", "Dockerfile", ".dockerignore", ".gitignore", ".npmrc",
|
|
363
|
+
"server.mjs", "server.js", "start.sh", "start-at-boot.sh",
|
|
364
|
+
]);
|
|
365
|
+
const WORKSPACE_EXCLUDED_SUFFIXES = [".log", ".lock"];
|
|
366
|
+
|
|
367
|
+
/** Whether a file name should be hidden from workspace listings (case-insensitive). */
|
|
368
|
+
function isExcludedWorkspaceName(name, extra = []) {
|
|
369
|
+
const lower = String(name).toLowerCase();
|
|
370
|
+
if (WORKSPACE_EXCLUDED_NAMES.has(lower)) return true;
|
|
371
|
+
for (const s of WORKSPACE_EXCLUDED_SUFFIXES) if (lower.endsWith(s)) return true;
|
|
372
|
+
for (const n of extra) if (lower === String(n).toLowerCase()) return true;
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Collect subdirectories with their files, grouped by folder. */
|
|
377
|
+
async function collectGroups(root, excluded, hiddenNames = []) {
|
|
378
|
+
let entries;
|
|
379
|
+
try {
|
|
380
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (error && typeof error === "object" && error.code === "ENOENT") return [];
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
const groups = [];
|
|
386
|
+
for (const entry of entries) {
|
|
387
|
+
if (!entry.isDirectory() || entry.name === excluded) continue;
|
|
388
|
+
// 排除点开头(.dsh/.outputdir)与下划线开头(_sd_extract 等中间产物)的文件夹及其下层所有内容
|
|
389
|
+
if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
|
|
390
|
+
const files = [];
|
|
391
|
+
const walk = async (dir) => {
|
|
392
|
+
let sub;
|
|
393
|
+
try {
|
|
394
|
+
sub = await readdir(dir, { withFileTypes: true });
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error && typeof error === "object" && error.code === "ENOENT") return;
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
sub.sort((a, b) => a.name.localeCompare(b.name));
|
|
400
|
+
for (const item of sub) {
|
|
401
|
+
const abs = join(dir, item.name);
|
|
402
|
+
// 嵌套里的点/下划线开头条目(夹/文件)也一并排除
|
|
403
|
+
if (item.name.startsWith(".") || item.name.startsWith("_")) continue;
|
|
404
|
+
if (item.isDirectory()) {
|
|
405
|
+
await walk(abs);
|
|
406
|
+
} else if (item.isFile()) {
|
|
407
|
+
// 隐藏明确的「非会话产出」文件(部署/插件/日志等),再走文档类白名单
|
|
408
|
+
if (isExcludedWorkspaceName(item.name, hiddenNames)) continue;
|
|
409
|
+
// 白名单:只显示文档类 + 图片文件,脚本/压缩包/代码/音视频一律隐藏
|
|
410
|
+
const ext = item.name.slice(item.name.lastIndexOf(".") + 1).toLowerCase();
|
|
411
|
+
if (!DOCUMENT_EXTS.has(ext)) continue;
|
|
412
|
+
try {
|
|
413
|
+
const info = await stat(abs);
|
|
414
|
+
files.push({
|
|
415
|
+
path: relative(root, abs).split(sep).join("/"),
|
|
416
|
+
name: item.name,
|
|
417
|
+
size: info.size,
|
|
418
|
+
mtime: info.mtimeMs,
|
|
419
|
+
});
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (error && typeof error === "object" && error.code !== "ENOENT") throw error;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
await walk(join(root, entry.name));
|
|
427
|
+
files.sort((a, b) => (b.mtime - a.mtime) || a.path.localeCompare(b.path));
|
|
428
|
+
groups.push({ folder: entry.name, files });
|
|
429
|
+
}
|
|
430
|
+
groups.sort((a, b) => a.folder.localeCompare(b.folder));
|
|
431
|
+
return groups;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** 扫描 workspace 根下的子目录:凡有 .dsh/skills 就收集其技能文件,按工作区名分组;无文件则该组 files 为空。 */
|
|
435
|
+
async function collectWorkspaceSkills(baseRoot) {
|
|
436
|
+
let entries;
|
|
437
|
+
try {
|
|
438
|
+
entries = await readdir(baseRoot, { withFileTypes: true });
|
|
439
|
+
} catch {
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
const groups = [];
|
|
443
|
+
for (const e of entries) {
|
|
444
|
+
if (!e.isDirectory() || e.name.startsWith(".")) continue;
|
|
445
|
+
const skillsDir = resolve(baseRoot, e.name, ".dsh", "skills");
|
|
446
|
+
try {
|
|
447
|
+
const st = await stat(skillsDir);
|
|
448
|
+
if (!st.isDirectory()) continue;
|
|
449
|
+
} catch { continue; }
|
|
450
|
+
const files = [];
|
|
451
|
+
const walk = async (dir) => {
|
|
452
|
+
let sub;
|
|
453
|
+
try { sub = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
454
|
+
sub.sort((a, b) => a.name.localeCompare(b.name));
|
|
455
|
+
for (const item of sub) {
|
|
456
|
+
if (item.name.startsWith(".")) continue;
|
|
457
|
+
const abs = join(dir, item.name);
|
|
458
|
+
try {
|
|
459
|
+
if (item.isDirectory()) await walk(abs);
|
|
460
|
+
else if (item.isFile()) {
|
|
461
|
+
const info = await stat(abs);
|
|
462
|
+
files.push({ path: relative(skillsDir, abs).split(sep).join("/"), name: item.name, size: info.size, mtime: info.mtimeMs });
|
|
463
|
+
}
|
|
464
|
+
} catch {}
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
await walk(skillsDir);
|
|
468
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
469
|
+
groups.push({ folder: e.name, files });
|
|
470
|
+
}
|
|
471
|
+
groups.sort((a, b) => a.folder.localeCompare(b.folder));
|
|
472
|
+
return groups;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export async function sweepUploadTemps(root) {
|
|
476
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
477
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
478
|
+
let removed = 0;
|
|
479
|
+
for (const entry of entries) {
|
|
480
|
+
if (!entry.name.startsWith(".upload-")) continue;
|
|
481
|
+
try {
|
|
482
|
+
await unlink(join(root, entry.name));
|
|
483
|
+
removed += 1;
|
|
484
|
+
} catch (error) {
|
|
485
|
+
if (error?.code !== "ENOENT" && error?.code !== "EISDIR" && error?.code !== "EPERM") throw error;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return removed;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function requireRegularFile(root, name) {
|
|
492
|
+
const target = join(root, name);
|
|
493
|
+
let info;
|
|
494
|
+
try {
|
|
495
|
+
info = await lstat(target);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (error?.code === "ENOENT") throw new HttpError(404, "file not found");
|
|
498
|
+
throw error;
|
|
499
|
+
}
|
|
500
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
501
|
+
return { target, info };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function asciiDownloadName(name) {
|
|
505
|
+
const value = name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
|
506
|
+
return value || "download";
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function contentDisposition(name) {
|
|
510
|
+
return `attachment; filename="${asciiDownloadName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Content type for preview by extension. */
|
|
514
|
+
function contentType(name) {
|
|
515
|
+
const extension = extname(name).toLowerCase();
|
|
516
|
+
return ({
|
|
517
|
+
".txt": "text/plain; charset=utf-8",
|
|
518
|
+
".log": "text/plain; charset=utf-8",
|
|
519
|
+
".md": "text/markdown; charset=utf-8",
|
|
520
|
+
".json": "application/json; charset=utf-8",
|
|
521
|
+
".yml": "text/yaml; charset=utf-8",
|
|
522
|
+
".yaml": "text/yaml; charset=utf-8",
|
|
523
|
+
".js": "text/javascript; charset=utf-8",
|
|
524
|
+
".html": "text/html; charset=utf-8",
|
|
525
|
+
".css": "text/css; charset=utf-8",
|
|
526
|
+
".pdf": "application/pdf",
|
|
527
|
+
".png": "image/png",
|
|
528
|
+
".jpg": "image/jpeg",
|
|
529
|
+
".jpeg": "image/jpeg",
|
|
530
|
+
".gif": "image/gif",
|
|
531
|
+
".webp": "image/webp",
|
|
532
|
+
".zip": "application/zip",
|
|
533
|
+
})[extension] || "application/octet-stream";
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const OFFICE_EXTS = new Set([".docx", ".xlsx", ".pptx", ".doc", ".xls", ".ppt"]);
|
|
537
|
+
|
|
538
|
+
/** Escape HTML special characters (attribute/body safe). */
|
|
539
|
+
function escHtml(value) {
|
|
540
|
+
return String(value)
|
|
541
|
+
.replace(/&/g, "&")
|
|
542
|
+
.replace(/</g, "<")
|
|
543
|
+
.replace(/>/g, ">")
|
|
544
|
+
.replace(/"/g, """);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Render a .docx as HTML (keeps paragraphs, headings, bold, lists, tables). */
|
|
548
|
+
async function docxHtml(buf) {
|
|
549
|
+
try {
|
|
550
|
+
const result = await mammoth.convertToHtml({ buffer: buf });
|
|
551
|
+
return result.value || "<p>(空文档)</p>";
|
|
552
|
+
} catch {
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Excel 列号(A=1, AA=27)。 */
|
|
558
|
+
function colToNum(letters) {
|
|
559
|
+
let n = 0;
|
|
560
|
+
for (const ch of String(letters).toUpperCase()) n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
561
|
+
return n;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** 渲染 .xlsx 为 HTML 表格(多工作表 + 合并单元格 + 表头样式,bound 保持预览量)。 */
|
|
565
|
+
async function xlsxHtml(buf) {
|
|
566
|
+
try {
|
|
567
|
+
const workbook = new ExcelJS.Workbook();
|
|
568
|
+
await workbook.xlsx.load(buf);
|
|
569
|
+
const sheets = workbook.worksheets.slice(0, 3);
|
|
570
|
+
if (sheets.length === 0) return "<p>(空工作簿)</p>";
|
|
571
|
+
let html = "";
|
|
572
|
+
for (const sheet of sheets) {
|
|
573
|
+
// 合并范围:topLeft -> {rowspan, colspan};并记录被覆盖的格子。
|
|
574
|
+
// exceljs 版本差异:mergedCells 可能是 getter;用 model.merges 兜底(格式 "A1:C1")。
|
|
575
|
+
const merges = (sheet.mergedCells && sheet.mergedCells.length ? sheet.mergedCells : (sheet.model && sheet.model.merges)) || [];
|
|
576
|
+
const merged = new Map();
|
|
577
|
+
const covered = new Set();
|
|
578
|
+
for (const range of merges) {
|
|
579
|
+
const m = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/.exec(String(range));
|
|
580
|
+
if (!m) continue;
|
|
581
|
+
const c1 = colToNum(m[1]), r1 = parseInt(m[2], 10), c2 = colToNum(m[3]), r2 = parseInt(m[4], 10);
|
|
582
|
+
merged.set(`${r1},${c1}`, { rowspan: r2 - r1 + 1, colspan: c2 - c1 + 1 });
|
|
583
|
+
for (let rr = r1; rr <= r2; rr++) for (let cc = c1; cc <= c2; cc++) if (!(rr === r1 && cc === c1)) covered.add(`${rr},${cc}`);
|
|
584
|
+
}
|
|
585
|
+
const rowLimit = 500, colLimit = 32;
|
|
586
|
+
const rows = [];
|
|
587
|
+
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
|
|
588
|
+
if (rows.length >= rowLimit) return;
|
|
589
|
+
const rn = rowNumber;
|
|
590
|
+
const cells = [];
|
|
591
|
+
for (let col = 1; col <= Math.min(row.cellCount, colLimit); col += 1) {
|
|
592
|
+
if (covered.has(`${rn},${col}`)) continue;
|
|
593
|
+
let value = row.getCell(col).value;
|
|
594
|
+
if (value !== null && typeof value === "object") {
|
|
595
|
+
if (value.richText) value = value.richText.map((t) => t.text).join("");
|
|
596
|
+
else if (value.text !== undefined) value = value.text;
|
|
597
|
+
else if (value.result !== undefined) value = value.result;
|
|
598
|
+
else value = "";
|
|
599
|
+
}
|
|
600
|
+
const span = merged.get(`${rn},${col}`);
|
|
601
|
+
const attr = span ? (span.colspan > 1 ? ` colspan="${span.colspan}"` : "") + (span.rowspan > 1 ? ` rowspan="${span.rowspan}"` : "") : "";
|
|
602
|
+
cells.push(`<td${attr}>${escHtml(value ?? "")}</td>`);
|
|
603
|
+
}
|
|
604
|
+
rows.push(`<tr>${cells.join("")}</tr>`);
|
|
605
|
+
});
|
|
606
|
+
const header = rows.length > 0 ? `<thead>${rows[0].replace(/<td/g, "<th").replace(/<\/td>/g, "</th>")}</thead>` : "";
|
|
607
|
+
const body = rows.length > 1 ? `<tbody>${rows.slice(1).join("")}</tbody>` : "";
|
|
608
|
+
html += `<div class="sheet"><h3>${escHtml(sheet.name || "Sheet")}</h3><table>${header}${body}</table></div>`;
|
|
609
|
+
}
|
|
610
|
+
const css = "<style>table{border-collapse:collapse;width:100%;font-size:12px;margin-bottom:14px}th,td{border:1px solid #cfd5dd;padding:4px 8px;white-space:pre-wrap;word-break:break-all;vertical-align:top}thead th{background:#f0f3f8;font-weight:600;text-align:left}.sheet h3{font-size:14px;margin:8px 0 4px;color:#1f3a5f}</style>";
|
|
611
|
+
return `${css}${html}`;
|
|
612
|
+
} catch {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** 解析 CSV/TSV 文本为 HTML 表格(首行当作表头,bound)。 */
|
|
618
|
+
function csvHtml(text) {
|
|
619
|
+
try {
|
|
620
|
+
const limit = 500;
|
|
621
|
+
const rows = [];
|
|
622
|
+
const parse = (line) => {
|
|
623
|
+
const out = [];
|
|
624
|
+
let cur = "", inQ = false;
|
|
625
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
626
|
+
const ch = line[i];
|
|
627
|
+
if (inQ) {
|
|
628
|
+
if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i += 1; } else inQ = false; }
|
|
629
|
+
else cur += ch;
|
|
630
|
+
} else if (ch === '"') { inQ = true; }
|
|
631
|
+
else if (ch === "," || ch === "\t") { out.push(cur); cur = ""; }
|
|
632
|
+
else cur += ch;
|
|
633
|
+
}
|
|
634
|
+
out.push(cur);
|
|
635
|
+
return out;
|
|
636
|
+
};
|
|
637
|
+
for (const line of String(text || "").split(/\r?\n/)) {
|
|
638
|
+
if (rows.length >= limit) break;
|
|
639
|
+
if (line.trim() === "") continue;
|
|
640
|
+
rows.push(parse(line));
|
|
641
|
+
}
|
|
642
|
+
if (rows.length === 0) return "<p>(空)</p>";
|
|
643
|
+
const header = `<thead><tr>${rows[0].map((c) => `<th>${escHtml(c)}</th>`).join("")}</tr></thead>`;
|
|
644
|
+
const body = rows.length > 1 ? `<tbody>${rows.slice(1).map((r) => `<tr>${r.map((c) => `<td>${escHtml(c)}</td>`).join("")}</tr>`).join("")}</tbody>` : "";
|
|
645
|
+
const css = "<style>table{border-collapse:collapse;width:100%;font-size:12px}th,td{border:1px solid #cfd5dd;padding:4px 8px;white-space:pre-wrap;word-break:break-all;vertical-align:top}thead th{background:#f0f3f8;font-weight:600;text-align:left}</style>";
|
|
646
|
+
return `${css}<table>${header}${body}</table>`;
|
|
647
|
+
} catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** List the entries of a minimal ZIP container (local-file-header walk). */
|
|
653
|
+
function zipEntries(buf) {
|
|
654
|
+
const entries = new Map();
|
|
655
|
+
let off = 0;
|
|
656
|
+
while (off + 30 <= buf.length) {
|
|
657
|
+
if (buf.readUInt32LE(off) !== 0x04034b50) break;
|
|
658
|
+
const method = buf.readUInt16LE(off + 8);
|
|
659
|
+
const compSize = buf.readUInt32LE(off + 18);
|
|
660
|
+
const nameLen = buf.readUInt16LE(off + 26);
|
|
661
|
+
const extraLen = buf.readUInt16LE(off + 28);
|
|
662
|
+
const name = buf.subarray(off + 30, off + 30 + nameLen).toString("utf8");
|
|
663
|
+
const dataStart = off + 30 + nameLen + extraLen;
|
|
664
|
+
entries.set(name, { method, data: buf.subarray(dataStart, dataStart + compSize) });
|
|
665
|
+
off = dataStart + compSize;
|
|
666
|
+
}
|
|
667
|
+
return entries;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function inflateEntry(entry) {
|
|
671
|
+
if (!entry) return null;
|
|
672
|
+
try {
|
|
673
|
+
return entry.method === 0 ? entry.data : inflateRawSync(entry.data);
|
|
674
|
+
} catch {
|
|
675
|
+
try {
|
|
676
|
+
return inflateSync(entry.data);
|
|
677
|
+
} catch {
|
|
678
|
+
return null;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** Join the text of every `<w:t>` (docx) or `<a:t>` (pptx) run. */
|
|
684
|
+
function taggedText(xml, tag) {
|
|
685
|
+
const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "g");
|
|
686
|
+
return [...xml.matchAll(re)].map((m) => m[1]).join("").replace(/\s+/g, " ").trim();
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Render .pptx as simple HTML (one section per slide with its text runs). */
|
|
690
|
+
function pptxHtml(buf) {
|
|
691
|
+
const parts = [];
|
|
692
|
+
const entries = zipEntries(buf);
|
|
693
|
+
const names = [...entries.keys()].filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n)).sort();
|
|
694
|
+
for (const name of names) {
|
|
695
|
+
const xml = inflateEntry(entries.get(name))?.toString("utf8");
|
|
696
|
+
if (xml === undefined) continue;
|
|
697
|
+
const texts = [...xml.matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map((m) => m[1]).join(" ").trim();
|
|
698
|
+
if (texts !== "") parts.push(`<div style="margin:0 0 14px;padding:10px 12px;border:1px solid #e0e0e0;border-radius:6px;background:#fafafa"><div style="font-size:11px;color:#999;margin-bottom:4px">${escHtml(name.replace(/^ppt\/slides\/|\.xml$/g, ""))}</div><div>${escHtml(texts)}</div></div>`);
|
|
699
|
+
}
|
|
700
|
+
return parts.length > 0 ? parts.join("") : null;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Render a binary Office document to HTML for layout-preserving preview.
|
|
705
|
+
* Returns `null` when the file is not a supported Office format or the
|
|
706
|
+
* conversion fails.
|
|
707
|
+
* @param name - file name (extension decides the format).
|
|
708
|
+
* @param buffer - raw file bytes.
|
|
709
|
+
* @returns HTML string or null.
|
|
710
|
+
*/
|
|
711
|
+
async function officePreviewHtml(name, buffer) {
|
|
712
|
+
const ext = extname(name).toLowerCase();
|
|
713
|
+
if (ext === ".docx") return docxHtml(buffer);
|
|
714
|
+
if (ext === ".xlsx") return xlsxHtml(buffer);
|
|
715
|
+
if (ext === ".pptx") return pptxHtml(buffer);
|
|
716
|
+
if (ext === ".csv" || ext === ".tsv") return csvHtml(buffer.toString("utf8"));
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** 仅保留毛玻璃配置允许字段并 clamp,避免写入任意/超大内容。背景图只收 data:image/*;base64 且限 2MB。 */
|
|
721
|
+
export function createHandlers(options = {}) {
|
|
722
|
+
const root = resolve(options.root || resolveUploadRoot());
|
|
723
|
+
const maxFileBytes = positiveInteger(options.maxFileBytes, resolveMaxFileBytes());
|
|
724
|
+
const totalMaxBytes = positiveInteger(options.totalMaxBytes, resolveTotalMaxBytes());
|
|
725
|
+
const trustedHosts = Array.isArray(options.trustedHosts) ? [...options.trustedHosts] : [];
|
|
726
|
+
const hiddenNames = Array.isArray(options.excludedWorkspaceNames) ? [...options.excludedWorkspaceNames] : [];
|
|
727
|
+
const onError = typeof options.onError === "function" ? options.onError : undefined;
|
|
728
|
+
for (const entry of trustedHosts) assertTrustedAuthority(entry);
|
|
729
|
+
|
|
730
|
+
let mutationTail = Promise.resolve();
|
|
731
|
+
const enqueueMutation = (operation) => {
|
|
732
|
+
const current = mutationTail.then(operation, operation);
|
|
733
|
+
mutationTail = current.catch(() => {});
|
|
734
|
+
return current;
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
const requireTrusted = (req) => {
|
|
738
|
+
if (!isTrustedUploadRequest(req, trustedHosts)) throw new HttpError(403, "forbidden");
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
const api = async (req, res) => {
|
|
742
|
+
try {
|
|
743
|
+
requireTrusted(req);
|
|
744
|
+
|
|
745
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
746
|
+
const files = await listUploadedFiles(root);
|
|
747
|
+
const usedBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
748
|
+
if (req.method === "HEAD") {
|
|
749
|
+
res.writeHead(200, { "cache-control": "no-store", "content-length": 0 });
|
|
750
|
+
res.end();
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
sendJson(res, 200, { root, maxFileBytes, totalMaxBytes, usedBytes, files });
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (req.method === "POST") {
|
|
758
|
+
await enqueueMutation(async () => {
|
|
759
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
760
|
+
const requestedName = uploadHeaderName(req);
|
|
761
|
+
const declared = Number(header(req.headers, "content-length"));
|
|
762
|
+
if (Number.isFinite(declared) && declared > maxFileBytes) {
|
|
763
|
+
req.resume();
|
|
764
|
+
throw new HttpError(413, `file exceeds ${maxFileBytes} bytes`);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const files = await listUploadedFiles(root);
|
|
768
|
+
const usedBytes = files.reduce((sum, file) => sum + file.size, 0);
|
|
769
|
+
const remainingBytes = totalMaxBytes - usedBytes;
|
|
770
|
+
if (remainingBytes <= 0) {
|
|
771
|
+
req.resume();
|
|
772
|
+
throw new HttpError(507, "upload storage quota exceeded");
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const allowedBytes = Math.min(maxFileBytes, remainingBytes);
|
|
776
|
+
const quotaLimited = allowedBytes < maxFileBytes;
|
|
777
|
+
const tempPath = join(root, `.upload-${randomUUID()}.tmp`);
|
|
778
|
+
try {
|
|
779
|
+
const size = await writeRequestToFile(
|
|
780
|
+
req,
|
|
781
|
+
tempPath,
|
|
782
|
+
allowedBytes,
|
|
783
|
+
quotaLimited ? 507 : 413,
|
|
784
|
+
quotaLimited ? "upload storage quota exceeded" : `file exceeds ${maxFileBytes} bytes`,
|
|
785
|
+
);
|
|
786
|
+
const published = await publishUnique(tempPath, root, requestedName);
|
|
787
|
+
const info = await stat(published.path);
|
|
788
|
+
sendJson(res, 201, {
|
|
789
|
+
root,
|
|
790
|
+
file: fileRecord(root, published.name, { size, mtime: info.mtime }),
|
|
791
|
+
});
|
|
792
|
+
} finally {
|
|
793
|
+
await unlink(tempPath).catch(() => {});
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (req.method === "DELETE") {
|
|
800
|
+
await enqueueMutation(async () => {
|
|
801
|
+
const fileName = queryName(req);
|
|
802
|
+
const { target } = await requireRegularFile(root, fileName);
|
|
803
|
+
await unlink(target);
|
|
804
|
+
sendJson(res, 200, { deleted: fileName });
|
|
805
|
+
});
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
methodNotAllowed(res, ["GET", "HEAD", "POST", "DELETE"]);
|
|
810
|
+
} catch (error) {
|
|
811
|
+
sendError(res, error, onError);
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
const serveFile = async (req, res, disposition) => {
|
|
816
|
+
try {
|
|
817
|
+
requireTrusted(req);
|
|
818
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
819
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const fileName = queryName(req);
|
|
823
|
+
const { target, info } = await requireRegularFile(root, fileName);
|
|
824
|
+
const dispositionValue = disposition === "inline"
|
|
825
|
+
? `inline; filename="${asciiDownloadName(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`
|
|
826
|
+
: contentDisposition(fileName);
|
|
827
|
+
// Office 文档 inline 预览:返回渲染好的 HTML(保留原布局)
|
|
828
|
+
if (disposition === "inline" && OFFICE_EXTS.has(extname(fileName).toLowerCase())) {
|
|
829
|
+
const buffer = await readFile(target);
|
|
830
|
+
const officeHtml = await officePreviewHtml(fileName, buffer);
|
|
831
|
+
sendJson(res, 200, {
|
|
832
|
+
ok: true,
|
|
833
|
+
name: fileName,
|
|
834
|
+
size: info.size,
|
|
835
|
+
mtime: info.mtimeMs,
|
|
836
|
+
binary: true,
|
|
837
|
+
truncated: officeHtml !== null && officeHtml.length > PREVIEW_LIMIT,
|
|
838
|
+
contentType: contentType(fileName),
|
|
839
|
+
content: undefined,
|
|
840
|
+
officeHtml: officeHtml ?? undefined,
|
|
841
|
+
});
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
// Markdown inline 预览:返回渲染后的 HTML 页面(真实效果,而非源码文本)。
|
|
845
|
+
if (disposition === "inline" && /\.(md|markdown)$/i.test(fileName)) {
|
|
846
|
+
const buffer = await readFile(target);
|
|
847
|
+
const body = `<article class="md">${markdownToHtml(buffer.toString("utf8"))}</article>`;
|
|
848
|
+
const downloadHref = `${DOWNLOAD_PATH}?name=${encodeURIComponent(fileName)}&download=1`;
|
|
849
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
850
|
+
res.end(previewPageHtml(fileName, fileName, info.size, downloadHref, body, `${PREVIEW_PATH}?name=${encodeURIComponent(fileName)}&inline=1`));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
res.writeHead(200, {
|
|
854
|
+
"content-type": contentType(fileName),
|
|
855
|
+
"content-length": info.size,
|
|
856
|
+
"content-disposition": dispositionValue,
|
|
857
|
+
"cache-control": "private, no-store",
|
|
858
|
+
"x-content-type-options": "nosniff",
|
|
859
|
+
});
|
|
860
|
+
if (req.method === "HEAD") {
|
|
861
|
+
res.end();
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const stream = createReadStream(target);
|
|
865
|
+
stream.on("error", (error) => res.destroy(error));
|
|
866
|
+
stream.pipe(res);
|
|
867
|
+
} catch (error) {
|
|
868
|
+
sendError(res, error, onError);
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
const download = (req, res) => serveFile(req, res, "attachment");
|
|
873
|
+
const preview = (req, res) => serveFile(req, res, "inline");
|
|
874
|
+
|
|
875
|
+
const workspaceRoot = resolveWorkspaceRoot();
|
|
876
|
+
const workspaceExcluded = workspaceExcludedName();
|
|
877
|
+
|
|
878
|
+
const workspaceList = async (req, res) => {
|
|
879
|
+
try {
|
|
880
|
+
requireTrusted(req);
|
|
881
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
882
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const groups = await collectGroups(workspaceRoot, workspaceExcluded, hiddenNames);
|
|
886
|
+
sendJson(res, 200, { ok: true, root: workspaceRoot, groups });
|
|
887
|
+
} catch (error) {
|
|
888
|
+
sendError(res, error, onError);
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
const workspaceFile = async (req, res) => {
|
|
893
|
+
try {
|
|
894
|
+
requireTrusted(req);
|
|
895
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
896
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
const rel = (() => {
|
|
900
|
+
try {
|
|
901
|
+
return decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || "");
|
|
902
|
+
} catch {
|
|
903
|
+
return "";
|
|
904
|
+
}
|
|
905
|
+
})();
|
|
906
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
907
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
908
|
+
const info = await stat(full);
|
|
909
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
910
|
+
const download = new URL(req.url || "/", "http://dsh.internal").searchParams.get("download") === "1";
|
|
911
|
+
const inline = new URL(req.url || "/", "http://dsh.internal").searchParams.get("inline") === "1";
|
|
912
|
+
const name = rel.split("/").pop() || "file";
|
|
913
|
+
if (download || inline) {
|
|
914
|
+
// inline=1: 流式返回原始文件(浏览器内嵌渲染,如 PDF 查看器),
|
|
915
|
+
// download=1: attachment 下载。两者都跳过 JSON 包装。
|
|
916
|
+
const disposition = inline
|
|
917
|
+
? `inline; filename="${asciiDownloadName(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`
|
|
918
|
+
: contentDisposition(name);
|
|
919
|
+
res.writeHead(200, {
|
|
920
|
+
"content-type": inline ? contentType(name) : "application/octet-stream",
|
|
921
|
+
"content-disposition": disposition,
|
|
922
|
+
"content-length": String(info.size),
|
|
923
|
+
"cache-control": "private, no-store",
|
|
924
|
+
"x-content-type-options": "nosniff",
|
|
925
|
+
});
|
|
926
|
+
const stream = createReadStream(full);
|
|
927
|
+
stream.on("error", (error) => res.destroy(error));
|
|
928
|
+
stream.pipe(res);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
const buffer = await readFile(full);
|
|
932
|
+
const binary = buffer.subarray(0, 8192).includes(0);
|
|
933
|
+
const truncated = buffer.length > PREVIEW_LIMIT;
|
|
934
|
+
// Office 二进制(docx/xlsx/pptx)或 CSV/TSV 文本 → 表格 HTML 预览
|
|
935
|
+
const tableHtml = (binary && OFFICE_EXTS.has(extname(name).toLowerCase())) || /\.(csv|tsv)$/i.test(name)
|
|
936
|
+
? await officePreviewHtml(name, buffer)
|
|
937
|
+
: undefined;
|
|
938
|
+
const officeHtml = tableHtml;
|
|
939
|
+
// Markdown 返回内联样式后的完整 HTML 片段(含 MD_CSS),供前端 srcDoc 渲染真实效果,
|
|
940
|
+
// 避免内嵌带独立头部的 workspace-preview 页面导致按钮重复、放大失效。
|
|
941
|
+
const mdHtml = !binary && /\.(md|markdown)$/i.test(name)
|
|
942
|
+
? `<!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${MD_CSS}</style></head><body><article class="md">${markdownToHtml(buffer.toString("utf8"))}</article></body></html>`
|
|
943
|
+
: undefined;
|
|
944
|
+
sendJson(res, 200, {
|
|
945
|
+
ok: true,
|
|
946
|
+
path: rel,
|
|
947
|
+
name,
|
|
948
|
+
size: info.size,
|
|
949
|
+
mtime: info.mtimeMs,
|
|
950
|
+
binary,
|
|
951
|
+
truncated,
|
|
952
|
+
contentType: contentType(extname(name)),
|
|
953
|
+
content: binary ? undefined : buffer.subarray(0, PREVIEW_LIMIT).toString("utf8"),
|
|
954
|
+
officeHtml: officeHtml ?? undefined,
|
|
955
|
+
mdHtml: mdHtml ?? undefined,
|
|
956
|
+
});
|
|
957
|
+
} catch (error) {
|
|
958
|
+
sendError(res, error, onError);
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
const workspaceDelete = async (req, res) => {
|
|
963
|
+
try {
|
|
964
|
+
requireTrusted(req);
|
|
965
|
+
if (req.method !== "POST") {
|
|
966
|
+
methodNotAllowed(res, ["POST"]);
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
const body = await readJsonBody(req);
|
|
970
|
+
const rel = typeof body === "object" && body !== null ? body.path : undefined;
|
|
971
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
972
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
973
|
+
const info = await stat(full);
|
|
974
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
975
|
+
await unlink(full);
|
|
976
|
+
sendJson(res, 200, { ok: true, deleted: rel });
|
|
977
|
+
} catch (error) {
|
|
978
|
+
sendError(res, error, onError);
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
// 重命名工作区文件:只改文件名(限定同目录,不跨目录移动),低风险。
|
|
983
|
+
const workspaceRename = async (req, res) => {
|
|
984
|
+
try {
|
|
985
|
+
requireTrusted(req);
|
|
986
|
+
if (req.method !== "POST") {
|
|
987
|
+
methodNotAllowed(res, ["POST"]);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const body = await readJsonBody(req);
|
|
991
|
+
const rel = typeof body === "object" && body !== null ? body.path : undefined;
|
|
992
|
+
const newName = typeof body === "object" && body !== null ? body.newName : undefined;
|
|
993
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
994
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
995
|
+
const info = await stat(full);
|
|
996
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
997
|
+
// 新文件名必须是安全的纯文件名(无分隔符/路径穿越/隐藏/特殊)
|
|
998
|
+
if (!isSafeStoredName(newName)) throw new HttpError(400, "invalid name");
|
|
999
|
+
const newRel = join(dirname(rel), newName);
|
|
1000
|
+
const newFull = safeResolve(workspaceRoot, newRel);
|
|
1001
|
+
if (newFull === undefined || newFull === full) throw new HttpError(400, "invalid target");
|
|
1002
|
+
// 同目录纯改名:rename 原子且安全(不跨目录移动)
|
|
1003
|
+
await rename(full, newFull);
|
|
1004
|
+
sendJson(res, 200, { ok: true, path: newRel });
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
sendError(res, error, onError);
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
const workspaceSave = async (req, res) => {
|
|
1011
|
+
try {
|
|
1012
|
+
requireTrusted(req);
|
|
1013
|
+
if (req.method !== "POST") {
|
|
1014
|
+
methodNotAllowed(res, ["POST"]);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const body = await readJsonBody(req);
|
|
1018
|
+
const rel = typeof body === "object" && body !== null ? body.path : undefined;
|
|
1019
|
+
const content = typeof body === "object" && body !== null ? body.content : undefined;
|
|
1020
|
+
if (typeof content !== "string") throw new HttpError(400, "content required");
|
|
1021
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
1022
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
1023
|
+
const info = await stat(full);
|
|
1024
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
1025
|
+
await writeFile(full, content, "utf8");
|
|
1026
|
+
sendJson(res, 200, { ok: true, path: rel });
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
sendError(res, error, onError);
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
const workspacePreview = async (req, res) => {
|
|
1033
|
+
try {
|
|
1034
|
+
requireTrusted(req);
|
|
1035
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1036
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
const rel = (() => {
|
|
1040
|
+
try {
|
|
1041
|
+
return decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || "");
|
|
1042
|
+
} catch {
|
|
1043
|
+
return "";
|
|
1044
|
+
}
|
|
1045
|
+
})();
|
|
1046
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
1047
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
1048
|
+
const info = await stat(full);
|
|
1049
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
1050
|
+
const name = rel.split("/").pop() || "file";
|
|
1051
|
+
const ext = extname(name).toLowerCase();
|
|
1052
|
+
const buffer = await readFile(full);
|
|
1053
|
+
const size = info.size;
|
|
1054
|
+
const downloadHref = `workspace-file?path=${encodeURIComponent(rel)}&download=1`;
|
|
1055
|
+
let body;
|
|
1056
|
+
let editable = false;
|
|
1057
|
+
let rawText = "";
|
|
1058
|
+
if (OFFICE_EXTS.has(ext)) {
|
|
1059
|
+
const html = await officePreviewHtml(name, buffer);
|
|
1060
|
+
body = html ? `<div class="office">${html}</div>` : `<p class="unsupported">该 Office 文档无法渲染,请下载查看。</p>`;
|
|
1061
|
+
} else if (/\.(png|jpe?g|gif|webp|bmp|ico)$/i.test(name)) {
|
|
1062
|
+
body = `<img class="image" src="data:${contentType(ext) || "image/png"};base64,${buffer.toString("base64")}" alt="${escapeHtml(name)}">`;
|
|
1063
|
+
} else if (ext === ".svg") {
|
|
1064
|
+
body = `<img class="image" src="data:image/svg+xml;base64,${buffer.toString("base64")}" alt="${escapeHtml(name)}">`;
|
|
1065
|
+
} else if (ext === ".pdf") {
|
|
1066
|
+
// 直接指向原始文件 inline 流(浏览器 PDF 查看器原生渲染),
|
|
1067
|
+
// 避免 base64 data URI 在 iframe 内被 Chrome 拒绝。
|
|
1068
|
+
body = `<iframe class="pdf" src="workspace-file?path=${encodeURIComponent(rel)}&inline=1"></iframe>`;
|
|
1069
|
+
} else if (/\.(txt|log)$/i.test(name)) {
|
|
1070
|
+
editable = true; rawText = buffer.toString("utf8");
|
|
1071
|
+
body = `<pre class="text">${escapeHtml(rawText)}</pre>`;
|
|
1072
|
+
} else if (/\.(md|markdown)$/i.test(name)) {
|
|
1073
|
+
// Markdown → 直接渲染成 HTML(真实效果),失败回退源码文本。
|
|
1074
|
+
editable = true; rawText = buffer.toString("utf8");
|
|
1075
|
+
body = `<article class="md">${markdownToHtml(rawText)}</article>`;
|
|
1076
|
+
} else if (/\.(html?|xhtml)$/i.test(name)) {
|
|
1077
|
+
// HTML/HTM → 直接渲染成实际效果(iframe + srcdoc, 保留其样式/脚本)
|
|
1078
|
+
editable = true; rawText = buffer.toString("utf8");
|
|
1079
|
+
body = `<iframe class="html" srcDoc="${escapeHtml(rawText)}"></iframe>`;
|
|
1080
|
+
} else if (/\.(json|ya?ml|py|js|mjs|cjs|ts|sh|css|html?|xml|csv|ini|conf|env|toml|sql|rs|go|c|h|cpp|java|kt|swift|rb|php|vue|jsx|tsx)$/i.test(name)) {
|
|
1081
|
+
editable = true; rawText = buffer.toString("utf8");
|
|
1082
|
+
body = `<pre class="text">${escapeHtml(rawText)}</pre>`;
|
|
1083
|
+
} else {
|
|
1084
|
+
body = `<p class="unsupported">该文件类型暂不支持预览,请点击右上角「下载」。</p>`;
|
|
1085
|
+
}
|
|
1086
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1087
|
+
res.end(previewPageHtml(name, rel, size, downloadHref, body, `workspace-file?path=${encodeURIComponent(rel)}&inline=1`, editable, rawText));
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
sendError(res, error, onError);
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
const workspaceBrowse = async (req, res) => {
|
|
1094
|
+
try {
|
|
1095
|
+
requireTrusted(req);
|
|
1096
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1097
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
const params = new URL(req.url || "/", "http://dsh.internal").searchParams;
|
|
1101
|
+
const ws = params.get("ws") || "";
|
|
1102
|
+
const all = params.get("all") === "1";
|
|
1103
|
+
const groups = await collectGroups(workspaceRoot, workspaceExcluded, hiddenNames);
|
|
1104
|
+
const view = all ? groups : (ws ? groups.filter((g) => g.folder === ws) : groups);
|
|
1105
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1106
|
+
res.end(workspaceBrowseHtml(view, ws, all));
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
sendError(res, error, onError);
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
// ---- docx-preview 真实预览(浏览器端渲染)----
|
|
1113
|
+
// 返回一个自包含 HTML:引 jszip + docx-preview,再 fetch workspace-file?inline=1
|
|
1114
|
+
// 拿 .docx 原始字节,用 docx-preview 真实渲染(所见即所得)。仅供 .docx 预览。
|
|
1115
|
+
const docxPreviewPage = async (req, res) => {
|
|
1116
|
+
try {
|
|
1117
|
+
requireTrusted(req);
|
|
1118
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1119
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
const rel = (() => {
|
|
1123
|
+
try {
|
|
1124
|
+
return decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || "");
|
|
1125
|
+
} catch {
|
|
1126
|
+
return "";
|
|
1127
|
+
}
|
|
1128
|
+
})();
|
|
1129
|
+
// 只允许 .docx(避免把别的文件喂给 docx-preview)
|
|
1130
|
+
if (!/\.docx$/i.test(rel)) throw new HttpError(400, "only .docx supported");
|
|
1131
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
1132
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
1133
|
+
const info = await stat(full);
|
|
1134
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
1135
|
+
const name = rel.split("/").pop() || "file";
|
|
1136
|
+
const downloadHref = `workspace-file?path=${encodeURIComponent(rel)}&download=1`;
|
|
1137
|
+
const inlineHref = `workspace-file?path=${encodeURIComponent(rel)}&inline=1`;
|
|
1138
|
+
const assetBase = "/api/dsh-uploads/docx-preview-asset";
|
|
1139
|
+
// 用 encodeURIComponent 但保留正斜杠,保证 query 里合法
|
|
1140
|
+
const q = encodeURIComponent(rel).replace(/%2F/g, "/");
|
|
1141
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1142
|
+
res.end(`<!DOCTYPE html>
|
|
1143
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1144
|
+
<title>预览 - ${escapeHtml(name)}</title>
|
|
1145
|
+
<style>
|
|
1146
|
+
body{margin:0;background:#313b48;color:#1f2937}
|
|
1147
|
+
#container{padding:16px 0;overflow:auto}
|
|
1148
|
+
/* docx-preview 分页:每页一张"纸",页间留间隙(breakPages 模式下 .docx-wrapper 内每个 .docx 是一页) */
|
|
1149
|
+
#container .docx-wrapper{background:transparent;padding:0;margin:0}
|
|
1150
|
+
#container .docx-wrapper > .docx,
|
|
1151
|
+
#container .docx-wrapper > section.docx,
|
|
1152
|
+
#container .docx-wrapper section.docx{background:#fff;box-shadow:0 2px 12px rgba(0,0,0,.28);max-width:900px;margin:0 0 24px;padding:56px 64px;box-sizing:border-box}
|
|
1153
|
+
#container .docx-wrapper > .docx{min-height:1100px}
|
|
1154
|
+
#container img{max-width:100%}
|
|
1155
|
+
#container ._dsh-docx-stage{width:max-content !important;display:block !important}
|
|
1156
|
+
#container ._dsh-docx-stage section.docx,
|
|
1157
|
+
#container ._dsh-docx-stage .docx,
|
|
1158
|
+
#container > .docx{margin:0 0 24px !important}
|
|
1159
|
+
</style>
|
|
1160
|
+
</head><body>
|
|
1161
|
+
<div style="position:sticky;top:0;z-index:20;display:flex;gap:6px;padding:8px 12px;align-items:center;flex-wrap:wrap;background:#1a2530">
|
|
1162
|
+
<strong style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e5e7eb;font-size:13px">${escapeHtml(name)}</strong>
|
|
1163
|
+
<span style="display:flex;gap:4px;align-items:center;color:#9ca3af;font-size:12px">
|
|
1164
|
+
<button type="button" title="缩小" onclick="zoomBy(0.8)" style="padding:4px 9px;border:1px solid #2c3a47;border-radius:7px;color:#e5e7eb;background:transparent;font-size:12px;cursor:pointer">−</button>
|
|
1165
|
+
<button type="button" title="放大" onclick="zoomBy(1.25)" style="padding:4px 9px;border:1px solid #2c3a47;border-radius:7px;color:#e5e7eb;background:transparent;font-size:12px;cursor:pointer">+</button>
|
|
1166
|
+
<button type="button" title="适合宽度" onclick="zoomFit()" style="padding:4px 9px;border:1px solid #2c3a47;border-radius:7px;color:#e5e7eb;background:transparent;font-size:12px;cursor:pointer">适合</button>
|
|
1167
|
+
</span>
|
|
1168
|
+
<a class="btn" href="${downloadHref}" download style="padding:5px 12px;border:1px solid #2c3a47;border-radius:8px;color:#e5e7eb;text-decoration:none;font-size:13px">下载</a>
|
|
1169
|
+
<button type="button" onclick="closePreview()" style="padding:5px 12px;border:1px solid #2c3a47;border-radius:8px;color:#e5e7eb;background:transparent;text-decoration:none;font-size:13px;cursor:pointer">✕ 关闭</button>
|
|
1170
|
+
</div>
|
|
1171
|
+
<div id="container"><div style="padding:40px;text-align:center;color:#999">加载中…</div></div>
|
|
1172
|
+
<script src="${assetBase}?f=jszip.min.js"></script>
|
|
1173
|
+
<script src="${assetBase}?f=docx-preview.min.js"></script>
|
|
1174
|
+
<script>
|
|
1175
|
+
// 「✕ 关闭」:预览页自带的关闭按钮(与 md 预览页一致的"两层关闭"行为)。
|
|
1176
|
+
// 在切换式弹窗(父窗口)里 → 通知父窗口回到文件列表;独立标签页 → 尝试关窗。
|
|
1177
|
+
function closePreview() {
|
|
1178
|
+
try {
|
|
1179
|
+
if (window.self !== window.top && window.parent) {
|
|
1180
|
+
window.parent.postMessage({ type: 'dsh-close-preview' }, location.origin);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
} catch (e) { /* 跨域忽略 */ }
|
|
1184
|
+
window.close();
|
|
1185
|
+
}
|
|
1186
|
+
const c=document.getElementById('container');
|
|
1187
|
+
// docx 按纸张固定宽度渲染,手机上屏窄。这里用 transform:scale(统一缩放,不破坏内部绝对定位
|
|
1188
|
+
// 排版,不会像 CSS zoom 那样叠字)。默认「适合宽度」整页完整显示,可放大读数。
|
|
1189
|
+
var zoom=1, fitMode=true;
|
|
1190
|
+
function dw(){
|
|
1191
|
+
// 文档页可能在 .docx-wrapper / .docx-wrap / 或直接 #container 里。取承载页面的元素;
|
|
1192
|
+
// 若直接在 #container,就包一层,避免缩放影响容器自身。
|
|
1193
|
+
var host=c.querySelector('.docx-wrapper, .docx-wrap');
|
|
1194
|
+
if(!host){ var p=c.querySelector('section.docx, .docx'); host=p? p.parentElement : c; }
|
|
1195
|
+
if(host && host===c){
|
|
1196
|
+
var wrap=document.createElement('div'); wrap.className='_dsh-docx-stage';
|
|
1197
|
+
while(c.firstChild) wrap.appendChild(c.firstChild);
|
|
1198
|
+
c.appendChild(wrap); host=wrap;
|
|
1199
|
+
}
|
|
1200
|
+
if(host){ host.style.width='max-content'; host.style.margin='0 auto'; }
|
|
1201
|
+
return host || c;
|
|
1202
|
+
}
|
|
1203
|
+
function applyZoom(){
|
|
1204
|
+
var w=dw(); if(!w) return;
|
|
1205
|
+
w.style.transformOrigin='top left';
|
|
1206
|
+
w.style.transform='scale('+zoom+')';
|
|
1207
|
+
}
|
|
1208
|
+
function zoomFit(){
|
|
1209
|
+
var page=c.querySelector('section.docx, .docx'); if(!page) return;
|
|
1210
|
+
var natW=page.offsetWidth||1; var target=c.clientWidth||1;
|
|
1211
|
+
zoom=Math.min(1, target/natW); fitMode=true; applyZoom();
|
|
1212
|
+
}
|
|
1213
|
+
function zoom100(){ zoom=1; fitMode=false; applyZoom(); }
|
|
1214
|
+
function zoomBy(f){ zoom=Math.min(8, Math.max(0.05, zoom*f)); fitMode=false; applyZoom(); }
|
|
1215
|
+
(async()=>{
|
|
1216
|
+
try{
|
|
1217
|
+
const r=await fetch(${JSON.stringify(inlineHref)}, {cache:'no-store'});
|
|
1218
|
+
if(!r.ok) throw new Error('HTTP '+r.status);
|
|
1219
|
+
const buf=await r.arrayBuffer();
|
|
1220
|
+
c.innerHTML='';
|
|
1221
|
+
// styleContainer 传 null:docx-preview 用自带样式。分页靠下方 .docx-wrapper > .docx 的纸张 CSS 控制。
|
|
1222
|
+
await docx.renderAsync(buf, c, null, {inWrapper:true, breakPages:true, ignoreLastRenderedPageBreak:false, experimental:true, className:'docx', useBase64URL:false});
|
|
1223
|
+
zoomFit(); // 默认「适合宽度」:整页等比缩放(行距/字号比例按 word 原样),可再放大读数
|
|
1224
|
+
}catch(e){ c.innerHTML='<p style="color:#dc2626;padding:20px">渲染失败:'+(e&&e.message||e)+'</p>'; }
|
|
1225
|
+
})();
|
|
1226
|
+
window.addEventListener('resize', function(){ try{ if(fitMode) zoomFit(); else applyZoom(); }catch(e){} });
|
|
1227
|
+
</script>
|
|
1228
|
+
</body></html>`);
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
sendError(res, error, onError);
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
|
|
1234
|
+
// serve jszip / docx-preview 库文件(从 PACKAGE_DIR/client/vendor 读)
|
|
1235
|
+
const docxPreviewAsset = async (req, res) => {
|
|
1236
|
+
try {
|
|
1237
|
+
requireTrusted(req);
|
|
1238
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1239
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
const f = decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("f") || "");
|
|
1243
|
+
// 允许的 vendor 前端库(docx-preview 与 pptx-preview 共用这一端点)
|
|
1244
|
+
if (!["jszip.min.js", "docx-preview.min.js", "pptxviewjs.min.js", "chart.umd.min.js"].includes(f)) throw new HttpError(400, "unknown asset");
|
|
1245
|
+
const target = join(VENDOR_DIR, f);
|
|
1246
|
+
const buf = await readFile(target);
|
|
1247
|
+
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
|
|
1248
|
+
res.end(buf);
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
sendError(res, error, onError);
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
|
|
1254
|
+
// ---- PptxViewJS 真实预览(浏览器端渲染,所见即所得)----
|
|
1255
|
+
// 返回一个自包含 HTML:引 jszip + chart.js + pptxviewjs,再 fetch workspace-file?inline=1
|
|
1256
|
+
// 拿 .pptx 原始字节,用 PptxViewJS 在 Canvas 上渲染每页幻灯片(可翻页)。仅供 .pptx 预览。
|
|
1257
|
+
const pptxPreviewPage = async (req, res) => {
|
|
1258
|
+
try {
|
|
1259
|
+
requireTrusted(req);
|
|
1260
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1261
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
const rel = (() => {
|
|
1265
|
+
try {
|
|
1266
|
+
return decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || "");
|
|
1267
|
+
} catch {
|
|
1268
|
+
return "";
|
|
1269
|
+
}
|
|
1270
|
+
})();
|
|
1271
|
+
// 只允许 .pptx(避免把别的文件喂给 PptxViewJS)
|
|
1272
|
+
if (!/\.pptx$/i.test(rel)) throw new HttpError(400, "only .pptx supported");
|
|
1273
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
1274
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
1275
|
+
const info = await stat(full);
|
|
1276
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
1277
|
+
const name = rel.split("/").pop() || "file";
|
|
1278
|
+
const downloadHref = `workspace-file?path=${encodeURIComponent(rel)}&download=1`;
|
|
1279
|
+
const inlineHref = `workspace-file?path=${encodeURIComponent(rel)}&inline=1`;
|
|
1280
|
+
const assetBase = "/api/dsh-uploads/docx-preview-asset";
|
|
1281
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1282
|
+
res.end(`<!DOCTYPE html>
|
|
1283
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1284
|
+
<title>预览 - ${escapeHtml(name)}</title>
|
|
1285
|
+
<style>
|
|
1286
|
+
body{margin:0;background:#1a2530;color:#e5e7eb;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
|
|
1287
|
+
.bar{position:sticky;top:0;z-index:20;display:flex;gap:8px;padding:10px 14px;align-items:center;background:#1a2530;border-bottom:1px solid #2c3a47;flex-wrap:wrap}
|
|
1288
|
+
.bar strong{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e5e7eb;font-size:13px}
|
|
1289
|
+
.bar button,.bar a{padding:6px 12px;border:1px solid #2c3a47;border-radius:8px;background:transparent;color:#e5e7eb;text-decoration:none;font-size:13px;cursor:pointer;white-space:nowrap}
|
|
1290
|
+
.bar button:disabled{opacity:.45;cursor:default}
|
|
1291
|
+
#stage{display:block;overflow:auto;padding:20px 16px 44px;min-height:calc(100vh - 60px)}
|
|
1292
|
+
#stage canvas{display:block;margin:0 auto;background:#fff;border-radius:8px;box-shadow:0 4px 22px rgba(0,0,0,.45)}
|
|
1293
|
+
#status{font-size:12px;color:#9ca3af;min-width:70px;text-align:center}
|
|
1294
|
+
#msg{color:#9ca3af}
|
|
1295
|
+
/* 电脑预览器式左右翻页箭头:浮在幻灯片两侧 */
|
|
1296
|
+
.pv-arrow{position:fixed;top:50%;transform:translateY(-50%);z-index:30;width:48px;height:48px;border-radius:50%;border:1px solid rgba(255,255,255,.28);background:rgba(18,28,42,.55);color:#fff;font-size:26px;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer;user-select:none}
|
|
1297
|
+
.pv-arrow:hover{background:rgba(45,66,95,.85)}
|
|
1298
|
+
.pv-arrow:disabled{opacity:.28;cursor:default}
|
|
1299
|
+
.pv-prev{left:10px}
|
|
1300
|
+
.pv-next{right:10px}
|
|
1301
|
+
@media (max-width:767px){ .pv-arrow{width:40px;height:40px;font-size:22px} }
|
|
1302
|
+
</style>
|
|
1303
|
+
</head><body>
|
|
1304
|
+
<div class="bar">
|
|
1305
|
+
<strong>${escapeHtml(name)}</strong>
|
|
1306
|
+
<span style="display:flex;gap:4px;align-items:center">
|
|
1307
|
+
<button type="button" title="缩小" onclick="zoomBy(0.8)">−</button>
|
|
1308
|
+
<button type="button" title="放大" onclick="zoomBy(1.25)">+</button>
|
|
1309
|
+
<button type="button" title="适合宽度" onclick="zoomFit()">适合</button>
|
|
1310
|
+
</span>
|
|
1311
|
+
<button type="button" id="prev" disabled>‹ 上一页</button>
|
|
1312
|
+
<span id="status">— / —</span>
|
|
1313
|
+
<button type="button" id="next" disabled>下一页 ›</button>
|
|
1314
|
+
<a href="${downloadHref}" download>下载</a>
|
|
1315
|
+
<button type="button" onclick="closePreview()">✕ 关闭</button>
|
|
1316
|
+
</div>
|
|
1317
|
+
<div id="stage"><div id="msg">加载中…</div><canvas id="canvas" style="display:none"></canvas></div>
|
|
1318
|
+
<button type="button" class="pv-arrow pv-prev" id="prevOverlay" title="上一页" disabled>‹</button>
|
|
1319
|
+
<button type="button" class="pv-arrow pv-next" id="nextOverlay" title="下一页" disabled>›</button>
|
|
1320
|
+
<script src="${assetBase}?f=jszip.min.js"></script>
|
|
1321
|
+
<script src="${assetBase}?f=chart.umd.min.js"></script>
|
|
1322
|
+
<script src="${assetBase}?f=pptxviewjs.min.js"></script>
|
|
1323
|
+
<script>
|
|
1324
|
+
// 「✕ 关闭」:预览页自带的关闭按钮(与 md/docx 预览一致的"两层关闭"行为)。
|
|
1325
|
+
function closePreview(){
|
|
1326
|
+
try{ if(window.self!==window.top&&window.parent){ window.parent.postMessage({type:'dsh-close-preview'},location.origin); return; } }catch(e){ /* 跨域忽略 */ }
|
|
1327
|
+
window.close();
|
|
1328
|
+
}
|
|
1329
|
+
const canvas=document.getElementById('canvas');
|
|
1330
|
+
const stage=document.getElementById('stage');
|
|
1331
|
+
const msg=document.getElementById('msg');
|
|
1332
|
+
const prevBtn=document.getElementById('prev');
|
|
1333
|
+
const nextBtn=document.getElementById('next');
|
|
1334
|
+
const prevOverlay=document.getElementById('prevOverlay');
|
|
1335
|
+
const nextOverlay=document.getElementById('nextOverlay');
|
|
1336
|
+
const status=document.getElementById('status');
|
|
1337
|
+
let viewer=null,total=0;
|
|
1338
|
+
var zoom=1, fitMode=true;
|
|
1339
|
+
// 幻灯片缩放:控制 canvas 的 CSS 显示尺寸等比放大/缩小(transform 对 canvas 容易叠字/错位,
|
|
1340
|
+
// 用 CSS width/height 更稳)。默认「适合宽度」看整页,可放大读数。
|
|
1341
|
+
function applyZoom(){
|
|
1342
|
+
var nw=canvas.width||1280, nh=canvas.height||720;
|
|
1343
|
+
canvas.style.width=Math.round(nw*zoom)+'px';
|
|
1344
|
+
canvas.style.height=Math.round(nh*zoom)+'px';
|
|
1345
|
+
}
|
|
1346
|
+
function zoomFit(){
|
|
1347
|
+
var nw=canvas.width||1280;
|
|
1348
|
+
// 用「内容区宽度」(clientWidth - 左右 padding),否则 canvas 会多出 padding 宽度、造成横向溢出,
|
|
1349
|
+
// 拖到手势里「有溢出=已放大」的误判,导致滑动翻页失效。
|
|
1350
|
+
var cs=getComputedStyle(stage); var pl=parseFloat(cs.paddingLeft)||0, pr=parseFloat(cs.paddingRight)||0;
|
|
1351
|
+
var t=(stage.clientWidth-pl-pr)||1;
|
|
1352
|
+
zoom=Math.min(1, t/nw); fitMode=true; applyZoom();
|
|
1353
|
+
}
|
|
1354
|
+
function zoom100(){ zoom=1; fitMode=false; applyZoom(); }
|
|
1355
|
+
function zoomBy(f){ zoom=Math.min(2,Math.max(0.05,zoom*f)); fitMode=false; applyZoom(); }
|
|
1356
|
+
function update(){
|
|
1357
|
+
if(!viewer)return;
|
|
1358
|
+
const cur=viewer.getCurrentSlideIndex();
|
|
1359
|
+
status.textContent='第 '+(cur+1)+' / '+total+' 页';
|
|
1360
|
+
prevBtn.disabled=cur<=0; prevOverlay.disabled=cur<=0;
|
|
1361
|
+
nextBtn.disabled=cur>=total-1; nextOverlay.disabled=cur>=total-1;
|
|
1362
|
+
}
|
|
1363
|
+
(async()=>{
|
|
1364
|
+
try{
|
|
1365
|
+
const r=await fetch(${JSON.stringify(inlineHref)},{cache:'no-store'});
|
|
1366
|
+
if(!r.ok) throw new Error('HTTP '+r.status);
|
|
1367
|
+
const blob=await r.blob();
|
|
1368
|
+
msg.style.display='none';
|
|
1369
|
+
canvas.style.display='block';
|
|
1370
|
+
// 给一个确定的初始尺寸,确保库能正常渲染;显示由 CSS 缩放(分辨率取高些,放大时更清晰)
|
|
1371
|
+
canvas.width=1920; canvas.height=1080;
|
|
1372
|
+
viewer=new window.PptxViewJS.PPTXViewer({canvas});
|
|
1373
|
+
await viewer.loadFile(new File([blob],${JSON.stringify(name)},{type:'application/vnd.openxmlformats-officedocument.presentationml.presentation'}));
|
|
1374
|
+
await viewer.render();
|
|
1375
|
+
total=viewer.getSlideCount();
|
|
1376
|
+
zoomFit();
|
|
1377
|
+
update();
|
|
1378
|
+
}catch(e){
|
|
1379
|
+
msg.style.display='';
|
|
1380
|
+
msg.textContent='渲染失败:'+((e&&e.message)||e);
|
|
1381
|
+
console.error(e);
|
|
1382
|
+
}
|
|
1383
|
+
})();
|
|
1384
|
+
prevBtn.addEventListener('click',async()=>{if(viewer){await viewer.previousSlide();update();}});
|
|
1385
|
+
nextBtn.addEventListener('click',async()=>{if(viewer){await viewer.nextSlide();update();}});
|
|
1386
|
+
prevOverlay.addEventListener('click',async()=>{if(viewer){await viewer.previousSlide();update();}});
|
|
1387
|
+
nextOverlay.addEventListener('click',async()=>{if(viewer){await viewer.nextSlide();update();}});
|
|
1388
|
+
window.addEventListener('resize',function(){ try{ if(fitMode) zoomFit(); else applyZoom(); }catch(e){} });
|
|
1389
|
+
</script>
|
|
1390
|
+
</body></html>`);
|
|
1391
|
+
} catch (error) {
|
|
1392
|
+
sendError(res, error, onError);
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
|
|
1396
|
+
// ---- xlsx 真实预览(浏览器端渲染,所见即所得)----
|
|
1397
|
+
// 返回一个自包含 HTML:引 SheetJS(xlsx.full.min.js) 解析 .xlsx,转换成 x-spreadsheet
|
|
1398
|
+
// 的 data 结构,用 x-spreadsheet 渲染成真实电子表格网格(行列表头/合并/冻结/缩放)。
|
|
1399
|
+
// 仅供参考;纯浏览器端渲染,不依赖 NAS 端转换,效果与 Excel/浏览器一致。
|
|
1400
|
+
const xlsxPreviewPage = async (req, res) => {
|
|
1401
|
+
try {
|
|
1402
|
+
requireTrusted(req);
|
|
1403
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1404
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
const rel = (() => {
|
|
1408
|
+
try {
|
|
1409
|
+
return decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || "");
|
|
1410
|
+
} catch {
|
|
1411
|
+
return "";
|
|
1412
|
+
}
|
|
1413
|
+
})();
|
|
1414
|
+
// 只允许 .xlsx(避免把别的文件喂给 SheetJS/x-spreadsheet)
|
|
1415
|
+
if (!/\.xlsx$/i.test(rel)) throw new HttpError(400, "only .xlsx supported");
|
|
1416
|
+
const full = safeResolve(workspaceRoot, rel);
|
|
1417
|
+
if (full === undefined) throw new HttpError(400, "invalid path");
|
|
1418
|
+
const info = await stat(full);
|
|
1419
|
+
if (!info.isFile()) throw new HttpError(400, "not a regular file");
|
|
1420
|
+
const name = rel.split("/").pop() || "file";
|
|
1421
|
+
const downloadHref = `workspace-file?path=${encodeURIComponent(rel)}&download=1`;
|
|
1422
|
+
const inlineHref = `workspace-file?path=${encodeURIComponent(rel)}&inline=1`;
|
|
1423
|
+
const assetBase = "/api/dsh-uploads/xlsx-preview-asset";
|
|
1424
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1425
|
+
res.end(`<!DOCTYPE html>
|
|
1426
|
+
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1427
|
+
<title>预览 - ${escapeHtml(name)}</title>
|
|
1428
|
+
<style>
|
|
1429
|
+
html,body{margin:0;height:100%;background:#1a2530;color:#e5e7eb;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
|
|
1430
|
+
.bar{position:sticky;top:0;z-index:20;display:flex;gap:8px;padding:10px 14px;align-items:center;background:#1a2530;border-bottom:1px solid #2c3a47;flex-wrap:wrap}
|
|
1431
|
+
.bar strong{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e5e7eb;font-size:13px}
|
|
1432
|
+
.bar button,.bar a{padding:6px 12px;border:1px solid #2c3a47;border-radius:8px;background:transparent;color:#e5e7eb;text-decoration:none;font-size:13px;cursor:pointer;white-space:nowrap}
|
|
1433
|
+
.tabs{display:flex;gap:6px;padding:8px 14px;background:#141f2b;border-bottom:1px solid #2c3a47;overflow:auto}
|
|
1434
|
+
.tabs button{padding:4px 12px;border:1px solid #2c3a47;border-radius:7px;background:transparent;color:#9ca3af;font-size:12px;cursor:pointer;white-space:nowrap;flex:none}
|
|
1435
|
+
.tabs button.on{background:#2563eb;color:#fff;border-color:#2563eb}
|
|
1436
|
+
// 静态图:整表一次性画到 canvas。拖动=移动/滚动一张画好的图,不重绘;放大用 CSS width/height 变化(与 ppt 预览一致)。
|
|
1437
|
+
#stage{position:relative;overflow:auto;height:calc(100vh - 48px);background:#e9ebee;padding:18px}
|
|
1438
|
+
#wrap{display:block;width:max-content;margin:0 auto;background:#fff;box-shadow:0 2px 14px rgba(0,0,0,.18)}
|
|
1439
|
+
canvas{display:block}
|
|
1440
|
+
#msg{color:#9ca3af;padding:24px;text-align:center;font-size:14px;font-family:inherit}
|
|
1441
|
+
</style>
|
|
1442
|
+
</head><body>
|
|
1443
|
+
<div class="bar">
|
|
1444
|
+
<strong>${escapeHtml(name)}</strong>
|
|
1445
|
+
<span style="display:flex;gap:4px;align-items:center">
|
|
1446
|
+
<button type="button" title="缩小" onclick="zoomBy(0.8)">−</button>
|
|
1447
|
+
<button type="button" title="放大" onclick="zoomBy(1.25)">+</button>
|
|
1448
|
+
<button type="button" title="适合宽度" onclick="zoomFit()">适合</button>
|
|
1449
|
+
</span>
|
|
1450
|
+
<a href="${downloadHref}" download>下载</a>
|
|
1451
|
+
<button type="button" onclick="closePreview()">✕ 关闭</button>
|
|
1452
|
+
</div>
|
|
1453
|
+
<div class="tabs" id="tabs"></div>
|
|
1454
|
+
<div id="stage"><div id="wrap"><canvas id="canvas"></canvas><div id="msg">加载中…</div></div></div>
|
|
1455
|
+
<script src="${assetBase}?f=xlsx.full.min.js"></script>
|
|
1456
|
+
<script>
|
|
1457
|
+
function closePreview(){
|
|
1458
|
+
try{ if(window.self!==window.top&&window.parent){ window.parent.postMessage({type:'dsh-close-preview'},location.origin); return; } }catch(e){ /* 跨域忽略 */ }
|
|
1459
|
+
window.close();
|
|
1460
|
+
}
|
|
1461
|
+
const stage=document.getElementById('stage');
|
|
1462
|
+
const canvas=document.getElementById('canvas');
|
|
1463
|
+
const wrap=document.getElementById('wrap');
|
|
1464
|
+
const msg=document.getElementById('msg');
|
|
1465
|
+
const tabsEl=document.getElementById('tabs');
|
|
1466
|
+
const dpr=Math.min(window.devicePixelRatio||1, 2);
|
|
1467
|
+
const HDR=26, IDXW=48, ROWH=26;
|
|
1468
|
+
const FONT='13px -apple-system,"PingFang SC","Microsoft YaHei",sans-serif';
|
|
1469
|
+
const FONTB='bold 13px -apple-system,"PingFang SC","Microsoft YaHei",sans-serif';
|
|
1470
|
+
var zoom=1, fitMode=true, natW=800, natH=600, wb=null, sheetIdx=0;
|
|
1471
|
+
function fmt(v){
|
|
1472
|
+
if(v===null||v===undefined) return '';
|
|
1473
|
+
if(v instanceof Date){ const p=(n)=>String(n).padStart(2,'0'); return v.getFullYear()+'-'+p(v.getMonth()+1)+'-'+p(v.getDate()); }
|
|
1474
|
+
return String(v);
|
|
1475
|
+
}
|
|
1476
|
+
function colLetter(c){ c+=1; var s=''; while(c>0){ var m=(c-1)%26; s=String.fromCharCode(65+m)+s; c=(c-m-1)/26; } return s; }
|
|
1477
|
+
function applyZoom(){
|
|
1478
|
+
canvas.style.width=Math.round(natW*zoom)+'px';
|
|
1479
|
+
canvas.style.height=Math.round(natH*zoom)+'px';
|
|
1480
|
+
}
|
|
1481
|
+
function zoomFit(){
|
|
1482
|
+
zoom=Math.min(1,((stage.clientWidth-36)||600)/natW); fitMode=true; applyZoom();
|
|
1483
|
+
}
|
|
1484
|
+
function zoom100(){ zoom=1; fitMode=false; applyZoom(); }
|
|
1485
|
+
function zoomBy(f){ zoom=Math.min(8,Math.max(0.05,zoom*f)); fitMode=false; applyZoom(); }
|
|
1486
|
+
function renderTabs(){
|
|
1487
|
+
if(!wb) return;
|
|
1488
|
+
tabsEl.innerHTML='';
|
|
1489
|
+
wb.SheetNames.forEach(function(nm,i){
|
|
1490
|
+
var b=document.createElement('button'); b.textContent=nm;
|
|
1491
|
+
if(i===sheetIdx) b.className='on';
|
|
1492
|
+
b.onclick=function(){ sheetIdx=i; renderSheet(); };
|
|
1493
|
+
tabsEl.appendChild(b);
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
function renderSheet(){
|
|
1497
|
+
msg.style.display='';
|
|
1498
|
+
var ws=wb.Sheets[wb.SheetNames[sheetIdx]];
|
|
1499
|
+
var range=ws['!ref']?XLSX.utils.decode_range(ws['!ref']):{s:{r:0,c:0},e:{r:0,c:0}};
|
|
1500
|
+
var mc=document.createElement('canvas').getContext('2d'); mc.font=FONT;
|
|
1501
|
+
var texts={}, maxCols={};
|
|
1502
|
+
for(var r=range.s.r;r<=range.e.r;r++){
|
|
1503
|
+
for(var c=range.s.c;c<=range.e.c;c++){
|
|
1504
|
+
var cell=ws[XLSX.utils.encode_cell({r:r,c:c})];
|
|
1505
|
+
var t='';
|
|
1506
|
+
if(cell){ if(cell.t==='n')t=String(cell.v); else if(cell.t==='b')t=cell.v?'TRUE':'FALSE'; else t=fmt(cell.v); }
|
|
1507
|
+
texts[r+'_'+c]=t;
|
|
1508
|
+
if(t){ var w=mc.measureText(t).width+18; if(!maxCols[c]||w>maxCols[c])maxCols[c]=w; }
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
var colW={};
|
|
1512
|
+
for(var c=range.s.c;c<=range.e.c;c++){ colW[c]=Math.max(64,Math.min(380,maxCols[c]||120)); }
|
|
1513
|
+
var colX={}, acc=IDXW;
|
|
1514
|
+
for(var c=range.s.c;c<=range.e.c;c++){ colX[c]=acc; acc+=colW[c]; }
|
|
1515
|
+
var rowY={}, accy=HDR;
|
|
1516
|
+
for(var r=range.s.r;r<=range.e.r;r++){ rowY[r]=accy; accy+=ROWH; }
|
|
1517
|
+
// 合并单元格:记录左上角跨的格数,其余标记为 covered(不画独立边框/文字)
|
|
1518
|
+
var mergeMap={}, covered={};
|
|
1519
|
+
(ws['!merges']||[]).forEach(function(m){
|
|
1520
|
+
mergeMap[m.s.r+'_'+m.s.c]={rs:m.e.r-m.s.r+1, cs:m.e.c-m.s.c+1};
|
|
1521
|
+
for(var rr=m.s.r;rr<=m.e.r;rr++)for(var cc=m.s.c;cc<=m.e.c;cc++){ if(rr===m.s.r&&cc===m.s.c)continue; covered[rr+'_'+cc]=1; }
|
|
1522
|
+
});
|
|
1523
|
+
var totalW=acc, totalH=accy; natW=totalW; natH=totalH;
|
|
1524
|
+
var w=Math.round(totalW*dpr), h=Math.round(totalH*dpr);
|
|
1525
|
+
var maxDim=16000;
|
|
1526
|
+
if(w>maxDim||h>maxDim){ var s=Math.min(maxDim/Math.max(1,w),maxDim/Math.max(1,h)); w=Math.round(totalW*dpr*s); h=Math.round(totalH*dpr*s); }
|
|
1527
|
+
canvas.width=w; canvas.height=h;
|
|
1528
|
+
var ctx=canvas.getContext('2d');
|
|
1529
|
+
ctx.setTransform(w/Math.max(1,totalW),0,0,h/Math.max(1,totalH),0,0);
|
|
1530
|
+
ctx.textBaseline='middle';
|
|
1531
|
+
ctx.fillStyle='#ffffff'; ctx.fillRect(0,0,totalW,totalH);
|
|
1532
|
+
// 左上角
|
|
1533
|
+
ctx.fillStyle='#e9ebee'; ctx.fillRect(0,0,IDXW,HDR);
|
|
1534
|
+
// 列标头
|
|
1535
|
+
ctx.font=FONTB; ctx.fillStyle='#374151';
|
|
1536
|
+
for(var c=range.s.c;c<=range.e.c;c++){
|
|
1537
|
+
ctx.fillStyle='#f3f4f7'; ctx.fillRect(colX[c],0,colW[c],HDR);
|
|
1538
|
+
ctx.strokeStyle='#dfe3ea'; ctx.beginPath(); ctx.moveTo(colX[c],0); ctx.lineTo(colX[c],HDR); ctx.stroke();
|
|
1539
|
+
ctx.beginPath(); ctx.moveTo(colX[c],HDR); ctx.lineTo(colX[c]+colW[c],HDR); ctx.stroke();
|
|
1540
|
+
var lx=colX[c]+colW[c]/2;
|
|
1541
|
+
ctx.fillStyle='#374151'; ctx.fillText(colLetter(c),lx,HDR/2+0.5);
|
|
1542
|
+
}
|
|
1543
|
+
// 行标头
|
|
1544
|
+
for(var r=range.s.r;r<=range.e.r;r++){
|
|
1545
|
+
ctx.fillStyle='#f3f4f7'; ctx.fillRect(0,rowY[r],IDXW,ROWH);
|
|
1546
|
+
ctx.strokeStyle='#dfe3ea'; ctx.beginPath(); ctx.moveTo(IDXW,rowY[r]); ctx.lineTo(IDXW,rowY[r]+ROWH); ctx.stroke();
|
|
1547
|
+
ctx.beginPath(); ctx.moveTo(0,rowY[r]+ROWH); ctx.lineTo(IDXW,rowY[r]+ROWH); ctx.stroke();
|
|
1548
|
+
ctx.fillStyle='#374151'; ctx.fillText(String(r+1),IDXW/2,rowY[r]+ROWH/2+0.5);
|
|
1549
|
+
}
|
|
1550
|
+
// 单元格
|
|
1551
|
+
ctx.font=FONT;
|
|
1552
|
+
for(var r=range.s.r;r<=range.e.r;r++){
|
|
1553
|
+
for(var c=range.s.c;c<=range.e.c;c++){
|
|
1554
|
+
var key=r+'_'+c;
|
|
1555
|
+
if(covered[key]) continue;
|
|
1556
|
+
var x=colX[c], y=rowY[r], ww=colW[c], hh=ROWH;
|
|
1557
|
+
var mk=mergeMap[key];
|
|
1558
|
+
if(mk){ for(var i=1;i<mk.cs;i++)ww+=colW[c+i]; for(var j=1;j<mk.rs;j++)hh+=ROWH; }
|
|
1559
|
+
ctx.fillStyle='#ffffff'; ctx.fillRect(x,y,ww,hh);
|
|
1560
|
+
ctx.strokeStyle='#e2e5ea'; ctx.strokeRect(x+0.5,y+0.5,ww-1,hh-1);
|
|
1561
|
+
var t=texts[key];
|
|
1562
|
+
if(t){
|
|
1563
|
+
var isNum=(ws[XLSX.utils.encode_cell({r:r,c:c})]||{}).t==='n';
|
|
1564
|
+
ctx.fillStyle='#1f2937';
|
|
1565
|
+
var xoff=isNum?(x+ww-6):(x+6);
|
|
1566
|
+
ctx.textAlign=isNum?'right':'left';
|
|
1567
|
+
ctx.fillText(t,xoff,y+hh/2+0.5);
|
|
1568
|
+
ctx.textAlign='left';
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
msg.style.display='none';
|
|
1573
|
+
renderTabs();
|
|
1574
|
+
zoomFit();
|
|
1575
|
+
}
|
|
1576
|
+
(async()=>{
|
|
1577
|
+
try{
|
|
1578
|
+
const r=await fetch(${JSON.stringify(inlineHref)},{cache:'no-store'});
|
|
1579
|
+
if(!r.ok) throw new Error('HTTP '+r.status);
|
|
1580
|
+
const buf=await r.arrayBuffer();
|
|
1581
|
+
wb=XLSX.read(new Uint8Array(buf),{type:'array',cellDates:true});
|
|
1582
|
+
renderSheet();
|
|
1583
|
+
}catch(e){ msg.style.display=''; msg.textContent='渲染失败:'+((e&&e.message)||e); }
|
|
1584
|
+
})();
|
|
1585
|
+
window.addEventListener('resize',function(){ try{ if(fitMode) zoomFit(); else applyZoom(); }catch(e){} });
|
|
1586
|
+
</script>
|
|
1587
|
+
|
|
1588
|
+
</body></html>`);
|
|
1589
|
+
} catch (error) {
|
|
1590
|
+
sendError(res, error, onError);
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
|
|
1594
|
+
// serve SheetJS 前端库(从 PACKAGE_DIR/client/vendor 读)
|
|
1595
|
+
const xlsxPreviewAsset = async (req, res) => {
|
|
1596
|
+
try {
|
|
1597
|
+
requireTrusted(req);
|
|
1598
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1599
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
const f = decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("f") || "");
|
|
1603
|
+
// 只允许 xlsx 预览页用到的 SheetJS 解析库(浏览器端把 .xlsx 转成静态表格图)。
|
|
1604
|
+
if (!["xlsx.full.min.js"].includes(f)) throw new HttpError(400, "unknown asset");
|
|
1605
|
+
const target = join(VENDOR_DIR, f);
|
|
1606
|
+
const buf = await readFile(target);
|
|
1607
|
+
res.writeHead(200, {
|
|
1608
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
1609
|
+
"cache-control": "no-store",
|
|
1610
|
+
"x-content-type-options": "nosniff",
|
|
1611
|
+
});
|
|
1612
|
+
res.end(buf);
|
|
1613
|
+
} catch (error) {
|
|
1614
|
+
sendError(res, error, onError);
|
|
1615
|
+
}
|
|
1616
|
+
};
|
|
1617
|
+
|
|
1618
|
+
// 补丁状态:只读检测 DSH 核心各补丁的「已打 / 未打 / 原生无需」,供 dsh-long 设置区显示
|
|
1619
|
+
const patchStatus = async (req, res) => {
|
|
1620
|
+
try {
|
|
1621
|
+
requireTrusted(req);
|
|
1622
|
+
// 定位 DSH 核心 dsh-client-connection 目录(探测常见路径)
|
|
1623
|
+
let DIR = "";
|
|
1624
|
+
const candidates = [
|
|
1625
|
+
"/volume1/npm/global/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-client-connection/lib",
|
|
1626
|
+
"/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-client-connection/lib",
|
|
1627
|
+
"/volume1/dsh/.dsh/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-client-connection/lib",
|
|
1628
|
+
"/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-client-connection/lib",
|
|
1629
|
+
];
|
|
1630
|
+
for (const c of candidates) { try { await stat(join(c, "index.js")); DIR = c; break; } catch (_) {} }
|
|
1631
|
+
if (!DIR) { sendJson(res, 200, { ok: true, patches: null, error: "未找到 DSH 核心 dsh-client-connection 目录" }); return; }
|
|
1632
|
+
const IDX = join(DIR, "index.js");
|
|
1633
|
+
const CLI = join(DIR, "client.js");
|
|
1634
|
+
const idx = await readFile(IDX, "utf8");
|
|
1635
|
+
let cli = "";
|
|
1636
|
+
try { cli = await readFile(CLI, "utf8"); } catch (_) {}
|
|
1637
|
+
const mark1 = "PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, trustedHosts)";
|
|
1638
|
+
const mark2 = "isLoopback: pageLocation === void 0 || isLoopbackHostname(pageLocation.hostname) || [";
|
|
1639
|
+
const mark3 = "WEBSOCKET_HEARTBEAT_MS";
|
|
1640
|
+
const patches = {
|
|
1641
|
+
trustedHosts: idx.includes(mark1) ? "已打" : "未打",
|
|
1642
|
+
loopback: cli.includes(mark2) ? "已打" : "未打",
|
|
1643
|
+
heartbeat: idx.includes(mark3) ? "已打" : (idx.includes("socket.ping()") ? "原生无需" : "未打"),
|
|
1644
|
+
};
|
|
1645
|
+
sendJson(res, 200, { ok: true, patches, dir: DIR });
|
|
1646
|
+
} catch (error) {
|
|
1647
|
+
sendError(res, error, onError);
|
|
1648
|
+
}
|
|
1649
|
+
};
|
|
1650
|
+
|
|
1651
|
+
// 各模块开关(dsh-long 设置区):读写 ~/.dsh-long-plugins/modules.json(独立于 dsh-span 的 RA-Span 配置)。
|
|
1652
|
+
const MODULES_DEFAULTS = { uploadAttach: true, uploadDragDrop: true, uploadPaste: true, uploadPreview: true, skillDocs: true, balance: true, mobile: true, workspace: true, turnRuler: false, sessionCost: false };
|
|
1653
|
+
const modulesFile = join(homedir(), ".dsh-long-plugins", "modules.json");
|
|
1654
|
+
async function readModulesJSON() {
|
|
1655
|
+
try {
|
|
1656
|
+
const raw = JSON.parse(await readFile(modulesFile, "utf8"));
|
|
1657
|
+
return { ...MODULES_DEFAULTS, ...(raw && typeof raw === "object" && raw.modules ? raw.modules : {}) };
|
|
1658
|
+
} catch { return { ...MODULES_DEFAULTS }; }
|
|
1659
|
+
}
|
|
1660
|
+
const modulesConfig = async (req, res) => {
|
|
1661
|
+
try {
|
|
1662
|
+
requireTrusted(req);
|
|
1663
|
+
if (req.method === "GET" || req.method === "HEAD") {
|
|
1664
|
+
const modules = await readModulesJSON();
|
|
1665
|
+
sendJson(res, 200, { ok: true, cfg: { modules } });
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
if (req.method === "POST") {
|
|
1669
|
+
const body = await readJsonBody(req, 1024 * 1024);
|
|
1670
|
+
const current = await readModulesJSON();
|
|
1671
|
+
const next = { ...current };
|
|
1672
|
+
if (body && typeof body.modules === "object" && body.modules !== null) {
|
|
1673
|
+
for (const k of Object.keys(body.modules)) next[k] = body.modules[k] !== false;
|
|
1674
|
+
}
|
|
1675
|
+
await mkdir(join(homedir(), ".dsh-long-plugins"), { recursive: true, mode: 0o700 });
|
|
1676
|
+
await writeFile(modulesFile, JSON.stringify({ modules: next }, null, 2), "utf8");
|
|
1677
|
+
sendJson(res, 200, { ok: true, saved: true });
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
methodNotAllowed(res, ["GET", "HEAD", "POST"]);
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
sendError(res, error, onError);
|
|
1683
|
+
}
|
|
1684
|
+
};
|
|
1685
|
+
|
|
1686
|
+
return { root, maxFileBytes, totalMaxBytes, api, download, preview, workspaceList, workspaceFile, workspacePreview, workspaceBrowse, workspaceDelete, workspaceRename, workspaceSave, docxPreviewPage, docxPreviewAsset, pptxPreviewPage, xlsxPreviewPage, xlsxPreviewAsset, patchStatus, modulesConfig };
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/** 人类可读文件大小。 */
|
|
1690
|
+
function humanSize(bytes) {
|
|
1691
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "-";
|
|
1692
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1693
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1694
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
1695
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** 人类可读相对时间。 */
|
|
1699
|
+
function humanTime(ms) {
|
|
1700
|
+
if (!Number.isFinite(ms)) return "-";
|
|
1701
|
+
const diff = Date.now() - ms;
|
|
1702
|
+
if (diff < 60 * 1000) return "刚刚";
|
|
1703
|
+
if (diff < 60 * 60 * 1000) return `${Math.floor(diff / 60000)} 分钟前`;
|
|
1704
|
+
if (diff < 24 * 60 * 60 * 1000) return `${Math.floor(diff / 3600000)} 小时前`;
|
|
1705
|
+
if (diff < 7 * 24 * 60 * 60 * 1000) return `${Math.floor(diff / 86400000)} 天前`;
|
|
1706
|
+
const d = new Date(ms);
|
|
1707
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1708
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
/** 扩展名是否可内嵌预览(与前端 INLINE_PREVIEW_EXTS + 图片 + Office 一致)。 */
|
|
1712
|
+
const INLINE_PREVIEW_EXTS = new Set([
|
|
1713
|
+
".pdf", ".txt", ".md", ".markdown", ".json", ".yml", ".yaml", ".xml", ".html", ".htm",
|
|
1714
|
+
".csv", ".tsv", ".log", ".ini", ".conf", ".env", ".toml", ".rtf",
|
|
1715
|
+
".py", ".js", ".mjs", ".cjs", ".ts", ".sh", ".css", ".sql", ".rs", ".go", ".c", ".h", ".cpp",
|
|
1716
|
+
".java", ".kt", ".swift", ".rb", ".php", ".vue", ".jsx", ".tsx",
|
|
1717
|
+
]);
|
|
1718
|
+
const INLINE_PREVIEW_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".svg"]);
|
|
1719
|
+
function isInlinePreviewableName(name) {
|
|
1720
|
+
const ext = extname(name).toLowerCase();
|
|
1721
|
+
return OFFICE_EXTS.has(ext) || INLINE_PREVIEW_EXTS.has(ext) || INLINE_PREVIEW_IMAGE_EXTS.has(ext);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/** 工作区文件浏览页面。顶部并列切换:📁工作区文件 / 📂总文件;ws=所在工作区,all=1 显示全部。 */
|
|
1725
|
+
function workspaceBrowseHtml(groups, ws = "", all = false) {
|
|
1726
|
+
const total = groups.reduce((sum, g) => sum + g.files.length, 0);
|
|
1727
|
+
const enc = encodeURIComponent(ws);
|
|
1728
|
+
const wsHref = ws ? `workspace-browse?ws=${enc}` : "";
|
|
1729
|
+
const allHref = ws ? `workspace-browse?ws=${enc}&all=1` : "workspace-browse";
|
|
1730
|
+
const refreshHref = all ? allHref : (wsHref || "workspace-browse");
|
|
1731
|
+
const metaText = all ? `全部工作区 · ${total} 个文件` : (ws ? `${escapeHtml(ws)} · ${total} 个文件` : `全部工作区 · ${total} 个文件`);
|
|
1732
|
+
const section = (group) => {
|
|
1733
|
+
if (group.files.length === 0) return "";
|
|
1734
|
+
const rows = group.files.map((f) => {
|
|
1735
|
+
const rel = encodeURIComponent(f.path);
|
|
1736
|
+
const previewable = isInlinePreviewableName(f.name);
|
|
1737
|
+
const isPdf = /\.pdf$/i.test(f.name);
|
|
1738
|
+
const isDocx = /\.docx$/i.test(f.name);
|
|
1739
|
+
const isPptx = /\.pptx$/i.test(f.name);
|
|
1740
|
+
const isXlsx = /\.xlsx$/i.test(f.name);
|
|
1741
|
+
// PDF 直接嵌原始流(单层 iframe,浏览器原生查看器可滚动翻页);
|
|
1742
|
+
// docx 走 docx-preview、pptx 走 PptxViewJS、xlsx 走 x-spreadsheet 真实渲染页
|
|
1743
|
+
// (浏览器端解析,所见即所得);
|
|
1744
|
+
// 其它可预览类型走渲染页;不可预览 → 下载。
|
|
1745
|
+
const viewHref = previewable
|
|
1746
|
+
? (isPdf ? `workspace-file?path=${rel}&inline=1` : isDocx ? `docx-preview?path=${rel}` : isPptx ? `pptx-preview?path=${rel}` : isXlsx ? `xlsx-preview?path=${rel}` : `workspace-preview?path=${rel}&from=list`)
|
|
1747
|
+
: `workspace-file?path=${rel}&download=1`;
|
|
1748
|
+
return `<tr data-ts="${Math.floor(f.mtime)}" data-name="${escapeHtml(f.name)}" data-size="${f.size}">
|
|
1749
|
+
<td class="name"><a class="flink" href="${viewHref}" data-preview="${escapeHtml(viewHref)}" title="${escapeHtml(f.path)}">${escapeHtml(f.name)}</a></td>
|
|
1750
|
+
<td class="size">${humanSize(f.size)}</td>
|
|
1751
|
+
<td class="time">${humanTime(f.mtime)}</td>
|
|
1752
|
+
<td class="acts">
|
|
1753
|
+
<button type="button" class="tag plain" data-copypath="${escapeHtml(f.path)}">复制路径</button>
|
|
1754
|
+
<button type="button" class="tag plain" data-rename="${escapeHtml(f.path)}">重命名</button>
|
|
1755
|
+
<a class="tag dl" href="workspace-file?path=${rel}&download=1">${ICON_DL} 下载</a>
|
|
1756
|
+
${previewable ? `<a class="tag" href="${viewHref}" data-preview="${escapeHtml(viewHref)}">${ICON_EYE} 预览</a>` : ""}
|
|
1757
|
+
</td>
|
|
1758
|
+
</tr>`;
|
|
1759
|
+
}).join("");
|
|
1760
|
+
return `<div class="group"><h2 class="gtoggle">${escapeHtml(group.folder)} <span class="cnt">${group.files.length}</span></h2>
|
|
1761
|
+
<table><thead><tr><th>文件</th><th>大小</th><th>修改</th><th>操作</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
1762
|
+
};
|
|
1763
|
+
return `<!DOCTYPE html>
|
|
1764
|
+
<html lang="zh-CN">
|
|
1765
|
+
<head>
|
|
1766
|
+
<meta charset="utf-8">
|
|
1767
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1768
|
+
<title>工作区文件</title>
|
|
1769
|
+
<style>
|
|
1770
|
+
* { box-sizing: border-box; }
|
|
1771
|
+
:root {
|
|
1772
|
+
--lp-bg:#0f1720; --lp-fg:#e5e7eb; --lp-bar-bg:#1a2530; --lp-border:#2c3a47;
|
|
1773
|
+
--lp-meta:#9ca3af; --lp-hover:#273449; --lp-btn-bg:#374151; --lp-btn-fg:#e5e7eb;
|
|
1774
|
+
--lp-btn-hover:#4b5563; --lp-h2:#93c5fd; --lp-accent:#93c5fd; --lp-ok:#86efac;
|
|
1775
|
+
--lp-table-bg:#111a24; --lp-table-border:#263241; --lp-cell-border:#1c2836;
|
|
1776
|
+
--lp-th-bg:#16222f; --lp-row-hover:#17232f; --lp-flink:#e5e7eb;
|
|
1777
|
+
--lp-tag-bg:#1e293b; --lp-tag-hover:#273449; --lp-seg-hover:#273449;
|
|
1778
|
+
}
|
|
1779
|
+
@media (prefers-color-scheme: light) {
|
|
1780
|
+
:root {
|
|
1781
|
+
--lp-bg:#ffffff; --lp-fg:#1f2937; --lp-bar-bg:#f3f4f6; --lp-border:#e5e7eb;
|
|
1782
|
+
--lp-meta:#6b7280; --lp-hover:#e5e7eb; --lp-btn-bg:#e5e7eb; --lp-btn-fg:#374151;
|
|
1783
|
+
--lp-btn-hover:#d1d5db; --lp-h2:#1d4ed8; --lp-accent:#1d4ed8; --lp-ok:#15803d;
|
|
1784
|
+
--lp-table-bg:#ffffff; --lp-table-border:#e5e7eb; --lp-cell-border:#f3f4f6;
|
|
1785
|
+
--lp-th-bg:#f9fafb; --lp-row-hover:#f3f4f6; --lp-flink:#1f2937;
|
|
1786
|
+
--lp-tag-bg:#eff6ff; --lp-tag-hover:#dbeafe; --lp-seg-hover:#e5e7eb;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
body { margin:0; font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif; background:var(--lp-bg); color:var(--lp-fg); }
|
|
1790
|
+
.bar { position:sticky; top:0; display:flex; align-items:center; gap:14px; padding:12px 20px; background:var(--lp-bar-bg); border-bottom:1px solid var(--lp-border); z-index:5; flex-wrap:wrap; }
|
|
1791
|
+
.seg { display:flex; border:1px solid var(--lp-border); border-radius:10px; overflow:hidden; flex:none; }
|
|
1792
|
+
.seg a { display:inline-flex; align-items:center; gap:6px; padding:8px 18px; font-size:14px; text-decoration:none; color:var(--lp-meta); background:transparent; }
|
|
1793
|
+
.seg a + a { border-left:1px solid var(--lp-border); }
|
|
1794
|
+
.seg a.on { background:#2563eb; color:#fff; }
|
|
1795
|
+
.seg a:hover:not(.on) { background:var(--lp-seg-hover); color:var(--lp-fg); }
|
|
1796
|
+
.bar .meta { color:var(--lp-meta); font-size:13px; }
|
|
1797
|
+
.bar .spacer { flex:1; }
|
|
1798
|
+
.btn { display:inline-flex; align-items:center; gap:6px; background:var(--lp-btn-bg); color:var(--lp-btn-fg); text-decoration:none; border-radius:8px; padding:7px 14px; font-size:13px; flex:none; }
|
|
1799
|
+
.btn:hover { background:var(--lp-btn-hover); }
|
|
1800
|
+
.wrap { max-width:980px; margin:0 auto; padding:20px; }
|
|
1801
|
+
.radial { position:fixed; right:22px; bottom:22px; width:0; height:0; z-index:60; }
|
|
1802
|
+
.radial-center { position:absolute; left:0; top:0; transform:translate(-50%,-50%); width:52px; height:52px; border-radius:50%; border:1px solid var(--lp-border); background:var(--lp-bar-bg); color:var(--lp-fg); font-size:20px; line-height:1; cursor:pointer; display:flex; align-items:center; justify-content:center; box-shadow:var(--lp-shadow-lv2, 0 6px 18px rgba(0,0,0,.35)); }
|
|
1803
|
+
.radial-center:hover { border-color:var(--lp-accent); }
|
|
1804
|
+
.radial-item { position:absolute; left:0; top:0; transform:translate(-50%,-50%); display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1px; width:52px; height:52px; border-radius:50%; border:1px solid var(--lp-border); background:var(--lp-bar-bg); color:var(--lp-fg); padding:0; font:inherit; cursor:pointer; opacity:0; pointer-events:none; transition:transform .18s ease, opacity .18s ease; }
|
|
1805
|
+
.radial-item b { font-size:15px; line-height:1; font-variant-numeric:tabular-nums; }
|
|
1806
|
+
.radial-item span { font-size:10px; color:var(--lp-meta); }
|
|
1807
|
+
.radial-item.on { border-color:var(--lp-accent); }
|
|
1808
|
+
.radial-item.on b { color:var(--lp-accent); }
|
|
1809
|
+
.radial.expanded .radial-item { opacity:1; pointer-events:auto; }
|
|
1810
|
+
.radial.expanded .ri-today { transform:translate(-50%,-50%) translate(0,-78px); }
|
|
1811
|
+
.radial.expanded .ri-week { transform:translate(-50%,-50%) translate(-56px,-56px); }
|
|
1812
|
+
.radial.expanded .ri-all { transform:translate(-50%,-50%) translate(-78px,0); }
|
|
1813
|
+
@media (max-width: 640px) { .radial { right:14px; bottom:14px; } .radial-center, .radial-item { width:46px; height:46px; } }
|
|
1814
|
+
.group { margin-bottom:26px; }
|
|
1815
|
+
.group h2 { font-size:15px; margin:0 0 8px; color:var(--lp-h2); }
|
|
1816
|
+
.cnt { color:var(--lp-meta); font-size:12px; font-weight:400; }
|
|
1817
|
+
.gtoggle { cursor:pointer; user-select:none; display:inline-flex; align-items:center; gap:8px; }
|
|
1818
|
+
.gtoggle:before { content:'▾'; font-size:11px; color:var(--lp-meta); transition:transform .15s; }
|
|
1819
|
+
.group.collapsed .gtoggle:before { content:'▸'; }
|
|
1820
|
+
.group.collapsed table { display:none; }
|
|
1821
|
+
.group.filtered-empty { display:none; }
|
|
1822
|
+
.daybox { display:inline-flex; align-items:center; gap:8px; flex:none; } .daylabel { color:var(--lp-meta); font-size:13px; }
|
|
1823
|
+
.dfilter { position:relative; display:inline-flex; align-items:center; gap:6px; min-width:118px; border:1px solid var(--lp-border); border-radius:8px; background:var(--lp-btn-bg); color:var(--lp-fg); padding:5px 8px; font-size:13px; cursor:pointer; }
|
|
1824
|
+
.dfilter:hover { border-color:var(--lp-accent); }
|
|
1825
|
+
.dfilter.has-value { border-color:var(--lp-accent); }
|
|
1826
|
+
.dfilter-icon { font-size:13px; flex:none; }
|
|
1827
|
+
.dfilter-text { white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
1828
|
+
.dfilter-clear { position:relative; z-index:2; background:transparent; border:none; color:var(--lp-meta); font-size:12px; line-height:1; cursor:pointer; padding:0 2px; }
|
|
1829
|
+
.dfilter-clear:hover { color:var(--lp-fg); }
|
|
1830
|
+
.dfilter-native { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:pointer; pointer-events:auto; z-index:1; }
|
|
1831
|
+
.fsearch-wrap { position:relative; display:inline-flex; align-items:center; flex:none; }
|
|
1832
|
+
.fsearch-btn { width:30px; height:30px; border:1px solid var(--lp-border); border-radius:8px; background:var(--lp-btn-bg); color:var(--lp-fg); font-size:14px; line-height:1; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; }
|
|
1833
|
+
.fsearch-btn:hover { background:var(--lp-btn-hover); }
|
|
1834
|
+
.fsearch-box { position:fixed; z-index:60; display:flex; align-items:center; gap:6px; background:var(--lp-bar-bg); border:1px solid var(--lp-border); border-radius:10px; padding:6px; box-shadow:var(--lp-shadow-lv3, 0 10px 30px rgba(0,0,0,.35)); max-width:calc(100vw - 16px); }
|
|
1835
|
+
.fsearch { background:var(--lp-btn-bg); color:var(--lp-fg); border:1px solid var(--lp-border); border-radius:8px; padding:5px 10px; font-size:13px; min-width:160px; }
|
|
1836
|
+
.fsearch::placeholder { color:var(--lp-meta); }
|
|
1837
|
+
.fsearch:focus { outline:none; border-color:var(--lp-accent); }
|
|
1838
|
+
.fsearch-clear { background:transparent; border:none; color:var(--lp-meta); font-size:13px; line-height:1; cursor:pointer; padding:0 3px; }
|
|
1839
|
+
.fsearch-clear:hover { color:var(--lp-fg); }
|
|
1840
|
+
.wsb-toast { position:fixed; left:50%; bottom:32px; transform:translateX(-50%) translateY(12px); z-index:2000; max-width:min(90vw,520px); padding:10px 16px; border-radius:10px; background:var(--lp-bar-bg); border:1px solid var(--lp-border); color:var(--lp-fg); font-size:13px; line-height:20px; opacity:0; pointer-events:none; transition:opacity .2s ease,transform .2s ease; box-shadow:0 10px 30px rgba(0,0,0,.35); }
|
|
1841
|
+
.wsb-toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
|
|
1842
|
+
.wsb-toast.error { border-color:#e25050; color:#ffb4b4; }
|
|
1843
|
+
.btn2 { display:inline-flex; align-items:center; background:var(--lp-btn-bg); color:var(--lp-btn-fg); border:1px solid var(--lp-border); border-radius:8px; padding:6px 11px; font-size:12px; cursor:pointer; }
|
|
1844
|
+
.btn2:hover { background:var(--lp-btn-hover); }
|
|
1845
|
+
.daybtn.active { background:#2563eb; color:#fff; }
|
|
1846
|
+
table { width:100%; border-collapse:collapse; background:var(--lp-table-bg); border:1px solid var(--lp-table-border); border-radius:10px; overflow:hidden; }
|
|
1847
|
+
th, td { text-align:left; padding:9px 14px; font-size:13px; border-bottom:1px solid var(--lp-cell-border); }
|
|
1848
|
+
th { background:var(--lp-th-bg); color:var(--lp-meta); font-weight:500; }
|
|
1849
|
+
tr:last-child td { border-bottom:none; }
|
|
1850
|
+
tr:hover td { background:var(--lp-row-hover); }
|
|
1851
|
+
.name { max-width:380px; }
|
|
1852
|
+
.flink { color:var(--lp-flink); text-decoration:none; word-break:break-all; }
|
|
1853
|
+
.flink:hover { color:var(--lp-accent); text-decoration:underline; }
|
|
1854
|
+
.size, .time { color:var(--lp-meta); white-space:nowrap; }
|
|
1855
|
+
.acts { white-space:nowrap; }
|
|
1856
|
+
.tag { display:inline-flex; align-items:center; gap:4px; text-decoration:none; font-size:12px; padding:4px 10px; border-radius:6px; margin-right:6px; background:var(--lp-tag-bg); color:var(--lp-accent); }
|
|
1857
|
+
.tag:hover { background:var(--lp-tag-hover); }
|
|
1858
|
+
.tag.dl { color:var(--lp-ok); }
|
|
1859
|
+
/* 重命名/复制路径:不设强调色,做白色/常规文字按钮 */
|
|
1860
|
+
.tag.plain { background:transparent; color:var(--lp-fg); border:1px solid var(--lp-border); }
|
|
1861
|
+
.tag.plain:hover { background:var(--lp-hover); }
|
|
1862
|
+
.empty { color:var(--lp-meta); text-align:center; padding:60px 0; }
|
|
1863
|
+
.ic { width:14px; height:14px; flex:none; }
|
|
1864
|
+
@media (max-width: 640px) {
|
|
1865
|
+
.bar { padding:10px 12px; gap:10px; }
|
|
1866
|
+
.seg a { padding:7px 14px; font-size:13px; }
|
|
1867
|
+
.group { margin-bottom:14px; }
|
|
1868
|
+
table, thead, tbody, tr, th, td { display:block; }
|
|
1869
|
+
thead { display:none; }
|
|
1870
|
+
tbody { display:block; }
|
|
1871
|
+
tr { background:var(--lp-table-bg); border:1px solid var(--lp-table-border); border-radius:10px; padding:10px 12px; margin-bottom:8px; }
|
|
1872
|
+
td { border:none; padding:2px 0; }
|
|
1873
|
+
.name { max-width:none; }
|
|
1874
|
+
.flink { font-size:14px; }
|
|
1875
|
+
.size, .time { display:inline-block; margin-right:12px; }
|
|
1876
|
+
.acts { margin-top:6px; }
|
|
1877
|
+
}
|
|
1878
|
+
</style>
|
|
1879
|
+
</head>
|
|
1880
|
+
<body>
|
|
1881
|
+
<div class="bar">
|
|
1882
|
+
<div class="seg">
|
|
1883
|
+
<a class="${!all && ws ? "on" : ""}" href="${wsHref || "#"}" ${wsHref ? "" : 'aria-disabled="true" title="从会话窗口的「📂 文件」进入可回到当前工作区"'}>${ICON_FOLDER} 工作区文件</a>
|
|
1884
|
+
<a class="${all ? "on" : ""}" href="${allHref}">${ICON_FOLDER_OPEN} 总文件</a>
|
|
1885
|
+
</div>
|
|
1886
|
+
<span class="meta">${metaText}</span>
|
|
1887
|
+
<span class="daybox">
|
|
1888
|
+
<label class="daylabel" for="dayFilter">日期</label>
|
|
1889
|
+
<span class="dfilter" id="dfilter">
|
|
1890
|
+
<span class="dfilter-icon">📅</span>
|
|
1891
|
+
<span class="dfilter-text" id="dfilterText">选择日期</span>
|
|
1892
|
+
<button type="button" class="dfilter-clear" id="dfilterClear" title="清除" aria-label="清除日期" style="display:none">✕</button>
|
|
1893
|
+
<input type="date" id="dayFilter" class="dfilter-native" aria-label="按日期筛选">
|
|
1894
|
+
</span>
|
|
1895
|
+
<button type="button" class="btn2 daybtn" id="dayToday">今天</button>
|
|
1896
|
+
<span class="fsearch-wrap" id="fsearchWrap">
|
|
1897
|
+
<button type="button" class="fsearch-btn" id="fsearchBtn" title="搜索文件名" aria-label="搜索文件名">🔍</button>
|
|
1898
|
+
<span class="fsearch-box" id="fsearchBox" style="display:none">
|
|
1899
|
+
<input type="text" class="fsearch" id="fsearch" placeholder="搜索文件名…" aria-label="搜索文件名">
|
|
1900
|
+
<button type="button" class="fsearch-clear" id="fsearchClear" title="清除" aria-label="清除搜索">✕</button>
|
|
1901
|
+
</span>
|
|
1902
|
+
</span>
|
|
1903
|
+
</span>
|
|
1904
|
+
<span class="spacer"></span>
|
|
1905
|
+
<a class="btn" href="${refreshHref}">⟳ 刷新</a>
|
|
1906
|
+
</div>
|
|
1907
|
+
<div class="wrap">
|
|
1908
|
+
${groups.map(section).join("") || `<p class="empty">${ws && !all ? `工作区「${escapeHtml(ws)}」还没有文件` : "工作区还没有文件"}</p>`}
|
|
1909
|
+
<p class="empty" id="filterEmpty" style="display:none">没有匹配的文件</p>
|
|
1910
|
+
</div>
|
|
1911
|
+
<div class="radial" id="radial">
|
|
1912
|
+
<button type="button" class="radial-center" id="radialBtn" title="工作区概览" aria-label="工作区概览">📊</button>
|
|
1913
|
+
<button type="button" class="radial-item ri-today" id="radialToday" title="只看今天生成的文件"><b id="dashTodayN">–</b><span>今日</span></button>
|
|
1914
|
+
<button type="button" class="radial-item ri-week" id="radialWeek" title="只看最近7天生成的文件"><b id="dashWeekN">–</b><span>本周</span></button>
|
|
1915
|
+
<button type="button" class="radial-item ri-all" id="radialAll" title="显示全部文件"><b id="dashTotalN">–</b><span>全部</span></button>
|
|
1916
|
+
</div>
|
|
1917
|
+
<script>
|
|
1918
|
+
// 预览链接点击 → 通知父窗口(wsOverlay)打开,父窗口用 embed/iframe 渲染,
|
|
1919
|
+
// 避免在嵌套 iframe 内直接导航导致 PDF 无法滚动。
|
|
1920
|
+
document.addEventListener('click', function (e) {
|
|
1921
|
+
var el = e.target && e.target.closest ? e.target.closest('[data-preview]') : null;
|
|
1922
|
+
if (!el) return;
|
|
1923
|
+
// el.href:浏览器解码 HTML 实体(& → &)并绝对化,消除歧义
|
|
1924
|
+
var abs = el.href;
|
|
1925
|
+
if (!abs) return;
|
|
1926
|
+
e.preventDefault();
|
|
1927
|
+
e.stopPropagation();
|
|
1928
|
+
var name = (el.getAttribute('title') || el.textContent || '').trim().split('/').pop();
|
|
1929
|
+
try {
|
|
1930
|
+
if (window.parent !== window) {
|
|
1931
|
+
window.parent.postMessage({ type: 'dsh-open-preview', url: abs, title: name }, location.origin);
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
} catch (err) { /* 跨域忽略 */ }
|
|
1935
|
+
location.href = abs;
|
|
1936
|
+
});
|
|
1937
|
+
// 按文件夹可折叠 + 按日期筛选(前端 JS;mtime 以浏览器本地时区算日期)
|
|
1938
|
+
(function () {
|
|
1939
|
+
var groups = Array.prototype.slice.call(document.querySelectorAll('.group'));
|
|
1940
|
+
// 1) 文件夹头点击 → 折叠/展开
|
|
1941
|
+
groups.forEach(function (g) {
|
|
1942
|
+
var h = g.querySelector('.gtoggle');
|
|
1943
|
+
if (h) h.addEventListener('click', function () { g.classList.toggle('collapsed'); });
|
|
1944
|
+
});
|
|
1945
|
+
// 2) 日期筛选
|
|
1946
|
+
var dayInput = document.getElementById('dayFilter');
|
|
1947
|
+
var dayToday = document.getElementById('dayToday');
|
|
1948
|
+
var dfilter = document.getElementById('dfilter');
|
|
1949
|
+
var dfilterText = document.getElementById('dfilterText');
|
|
1950
|
+
var dfilterClear = document.getElementById('dfilterClear');
|
|
1951
|
+
var fsearch = document.getElementById('fsearch');
|
|
1952
|
+
var filterEmpty = document.getElementById('filterEmpty');
|
|
1953
|
+
// 预览后返回不丢检索:用 sessionStorage 记住检索词与检索框开/关状态。
|
|
1954
|
+
var savedSearch = '';
|
|
1955
|
+
var savedOpen = false;
|
|
1956
|
+
try { savedSearch = sessionStorage.getItem('wsb-search') || ''; } catch (e) {}
|
|
1957
|
+
try { savedOpen = sessionStorage.getItem('wsb-search-open') === '1'; } catch (e) {}
|
|
1958
|
+
if (fsearch) fsearch.value = savedSearch;
|
|
1959
|
+
var metaEl = document.querySelector('.bar .meta');
|
|
1960
|
+
var origMeta = metaEl ? metaEl.textContent : '';
|
|
1961
|
+
function pad(v) { return v < 10 ? '0' + v : v; }
|
|
1962
|
+
function fmtDate(ts) {
|
|
1963
|
+
var d = new Date(ts);
|
|
1964
|
+
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
|
|
1965
|
+
}
|
|
1966
|
+
function matchName(tr) {
|
|
1967
|
+
var q = (fsearch && fsearch.value || '').trim().toLowerCase();
|
|
1968
|
+
if (!q) return true;
|
|
1969
|
+
var hay = String(tr.dataset.name || '').toLowerCase();
|
|
1970
|
+
return q.split(/\s+/).filter(Boolean).every(function (tok) { return hay.indexOf(tok) !== -1; });
|
|
1971
|
+
}
|
|
1972
|
+
// 预计算每行本地日期
|
|
1973
|
+
Array.prototype.forEach.call(document.querySelectorAll('tr[data-ts]'), function (tr) {
|
|
1974
|
+
tr.dataset.date = fmtDate(Number(tr.dataset.ts));
|
|
1975
|
+
});
|
|
1976
|
+
var todayStr = fmtDate(Date.now());
|
|
1977
|
+
var weekOn = false; // 近7天范围筛选
|
|
1978
|
+
function apply() {
|
|
1979
|
+
var sel = dayInput.value;
|
|
1980
|
+
var nowTs = Date.now();
|
|
1981
|
+
var visTotal = 0;
|
|
1982
|
+
groups.forEach(function (g) {
|
|
1983
|
+
var vis = 0;
|
|
1984
|
+
Array.prototype.forEach.call(g.querySelectorAll('tr[data-date]'), function (tr) {
|
|
1985
|
+
var dayOk = !sel || tr.dataset.date === sel;
|
|
1986
|
+
var weekOk = !weekOn || (nowTs - Number(tr.dataset.ts)) <= 7 * 86400000;
|
|
1987
|
+
var ok = dayOk && weekOk && matchName(tr);
|
|
1988
|
+
tr.style.display = ok ? '' : 'none';
|
|
1989
|
+
if (ok) vis++;
|
|
1990
|
+
});
|
|
1991
|
+
var cnt = g.querySelector('.cnt');
|
|
1992
|
+
if (cnt) cnt.textContent = vis;
|
|
1993
|
+
if (vis === 0) g.classList.add('filtered-empty'); else g.classList.remove('filtered-empty');
|
|
1994
|
+
visTotal += vis;
|
|
1995
|
+
});
|
|
1996
|
+
var searching = fsearch && fsearch.value.trim();
|
|
1997
|
+
var label = sel ? ('筛选 ' + sel) : (weekOn ? '近7日' : '');
|
|
1998
|
+
if (metaEl) metaEl.textContent = (label || searching) ? ((label || '全部') + (searching ? ' · ' + searching : '') + ' · ' + visTotal + ' 个文件') : origMeta;
|
|
1999
|
+
if (dfilterText) dfilterText.textContent = sel || '选择日期';
|
|
2000
|
+
if (dfilterClear) dfilterClear.style.display = sel ? 'inline-block' : 'none';
|
|
2001
|
+
if (dfilter) dfilter.classList.toggle('has-value', !!sel);
|
|
2002
|
+
if (dayToday) dayToday.classList.toggle('active', sel === todayStr);
|
|
2003
|
+
if (filterEmpty) filterEmpty.style.display = ((label || searching) && visTotal === 0) ? 'block' : 'none';
|
|
2004
|
+
}
|
|
2005
|
+
// 打开日期选择器:整个控件点击 → showPicker(兜底 focus);点 ✕ 清除(阻止冒泡)。
|
|
2006
|
+
if (dfilter) dfilter.addEventListener('click', function () {
|
|
2007
|
+
var el = dayInput;
|
|
2008
|
+
if (!el) return;
|
|
2009
|
+
if (typeof el.showPicker === 'function') { try { el.showPicker(); return; } catch (e) {} }
|
|
2010
|
+
try { el.focus(); } catch (e) {}
|
|
2011
|
+
});
|
|
2012
|
+
if (dayInput) dayInput.addEventListener('input', apply);
|
|
2013
|
+
if (dayInput) dayInput.addEventListener('change', apply);
|
|
2014
|
+
if (fsearch) fsearch.addEventListener('input', function () { try { sessionStorage.setItem('wsb-search', fsearch.value); } catch (e) {} apply(); });
|
|
2015
|
+
// 🔍 搜索弹窗:点击图标开/关,点✕清除,点击外部关闭。
|
|
2016
|
+
var fsearchBtn = document.getElementById('fsearchBtn');
|
|
2017
|
+
var fsearchBox = document.getElementById('fsearchBox');
|
|
2018
|
+
var fsearchClear = document.getElementById('fsearchClear');
|
|
2019
|
+
var fsearchWrap = document.getElementById('fsearchWrap');
|
|
2020
|
+
var fsearchOpen = false;
|
|
2021
|
+
function setFsearchOpen(open) {
|
|
2022
|
+
fsearchOpen = open;
|
|
2023
|
+
try { sessionStorage.setItem('wsb-search-open', open ? '1' : '0'); } catch (e) {}
|
|
2024
|
+
if (fsearchBox) {
|
|
2025
|
+
fsearchBox.style.display = open ? 'flex' : 'none';
|
|
2026
|
+
if (open && fsearchBtn) {
|
|
2027
|
+
var r = fsearchBtn.getBoundingClientRect();
|
|
2028
|
+
fsearchBox.style.top = (r.bottom + 8) + 'px';
|
|
2029
|
+
fsearchBox.style.left = Math.max(8, Math.min(r.left, (window.innerWidth || 0) - 280)) + 'px';
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
if (open && fsearch) { try { fsearch.focus(); } catch (e) {} }
|
|
2033
|
+
}
|
|
2034
|
+
if (fsearchBtn) fsearchBtn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); setFsearchOpen(!fsearchOpen); });
|
|
2035
|
+
if (fsearchClear) fsearchClear.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); });
|
|
2036
|
+
if (fsearchClear) fsearchClear.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); fsearch.value = ''; try { sessionStorage.removeItem('wsb-search'); sessionStorage.removeItem('wsb-search-open'); } catch (e2) {} apply(); setFsearchOpen(false); });
|
|
2037
|
+
if (dayToday) dayToday.addEventListener('click', function () { dayInput.value = todayStr; apply(); });
|
|
2038
|
+
if (dfilterClear) dfilterClear.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); dayInput.value = ''; apply(); });
|
|
2039
|
+
if (savedOpen) setFsearchOpen(true);
|
|
2040
|
+
apply();
|
|
2041
|
+
|
|
2042
|
+
// 重命名 / 复制路径
|
|
2043
|
+
function showMsg(text, kind) {
|
|
2044
|
+
var t = document.querySelector('.wsb-toast');
|
|
2045
|
+
if (!t) {
|
|
2046
|
+
t = document.createElement('div');
|
|
2047
|
+
t.className = 'wsb-toast';
|
|
2048
|
+
document.body.appendChild(t);
|
|
2049
|
+
}
|
|
2050
|
+
t.textContent = text;
|
|
2051
|
+
t.className = 'wsb-toast show' + (kind ? ' ' + kind : '');
|
|
2052
|
+
clearTimeout(t._t);
|
|
2053
|
+
t._t = setTimeout(function () { t.className = 'wsb-toast'; }, 2600);
|
|
2054
|
+
}
|
|
2055
|
+
document.addEventListener('click', function (e) {
|
|
2056
|
+
var t = e.target && e.target.closest ? e.target.closest('[data-rename]') : null;
|
|
2057
|
+
var c = e.target && e.target.closest ? e.target.closest('[data-copypath]') : null;
|
|
2058
|
+
if (t) {
|
|
2059
|
+
e.preventDefault(); e.stopPropagation();
|
|
2060
|
+
var rel = t.getAttribute('data-rename');
|
|
2061
|
+
var name = rel.split(/[\\/]/).pop() || rel;
|
|
2062
|
+
var nn = window.prompt('重命名文件(仅改名,不跨目录)', name);
|
|
2063
|
+
if (nn == null || String(nn).trim() === '' || String(nn).trim() === name) return;
|
|
2064
|
+
fetch('/api/dsh-uploads/workspace-file/rename', {
|
|
2065
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
2066
|
+
body: JSON.stringify({ path: rel, newName: String(nn).trim() })
|
|
2067
|
+
}).then(function (r) { return r.json(); }).then(function (d) {
|
|
2068
|
+
if (d && d.ok) { location.reload(); }
|
|
2069
|
+
else { showMsg('重命名失败:' + ((d && d.error) || '未知'), 'error'); }
|
|
2070
|
+
}).catch(function (err) { showMsg('重命名失败:' + err, 'error'); });
|
|
2071
|
+
} else if (c) {
|
|
2072
|
+
e.preventDefault(); e.stopPropagation();
|
|
2073
|
+
var rel2 = c.getAttribute('data-copypath');
|
|
2074
|
+
try {
|
|
2075
|
+
navigator.clipboard.writeText(rel2).then(function () { showMsg('已复制路径:' + rel2); }, function () { showMsg('复制失败', 'error'); });
|
|
2076
|
+
} catch (e2) { showMsg('复制失败', 'error'); }
|
|
2077
|
+
}
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
// 工作区概览(仪表盘):从各文件行的 mtime/size 统计 今日/7日/全部/总大小。
|
|
2081
|
+
(function () {
|
|
2082
|
+
var rows = Array.prototype.slice.call(document.querySelectorAll('tr[data-ts]'));
|
|
2083
|
+
var now = Date.now();
|
|
2084
|
+
var today = 0, week = 0, size = 0;
|
|
2085
|
+
rows.forEach(function (tr) {
|
|
2086
|
+
var ts = Number(tr.dataset.ts) || 0;
|
|
2087
|
+
if (tr.dataset.date === todayStr) today++;
|
|
2088
|
+
if (now - ts <= 7 * 86400000) week++;
|
|
2089
|
+
size += Number(tr.dataset.size) || 0;
|
|
2090
|
+
});
|
|
2091
|
+
function setTxt(id, v) { var el = document.getElementById(id); if (el) el.textContent = v; }
|
|
2092
|
+
setTxt('dashTodayN', today);
|
|
2093
|
+
setTxt('dashWeekN', week);
|
|
2094
|
+
setTxt('dashTotalN', rows.length);
|
|
2095
|
+
var radial = document.getElementById('radial');
|
|
2096
|
+
var radialBtn = document.getElementById('radialBtn');
|
|
2097
|
+
var radToday = document.getElementById('radialToday');
|
|
2098
|
+
var radWeek = document.getElementById('radialWeek');
|
|
2099
|
+
var radAll = document.getElementById('radialAll');
|
|
2100
|
+
if (radialBtn) radialBtn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); if (radial) radial.classList.toggle('expanded'); });
|
|
2101
|
+
function syncDashActive() {
|
|
2102
|
+
var sel = dayInput.value;
|
|
2103
|
+
if (radToday) radToday.classList.toggle('on', !weekOn && sel === todayStr);
|
|
2104
|
+
if (radWeek) radWeek.classList.toggle('on', weekOn);
|
|
2105
|
+
if (radAll) radAll.classList.toggle('on', !weekOn && !sel);
|
|
2106
|
+
}
|
|
2107
|
+
if (radToday) radToday.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); weekOn = false; dayInput.value = todayStr; apply(); syncDashActive(); });
|
|
2108
|
+
if (radWeek) radWeek.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); weekOn = true; dayInput.value = ''; apply(); syncDashActive(); });
|
|
2109
|
+
if (radAll) radAll.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); weekOn = false; dayInput.value = ''; apply(); syncDashActive(); });
|
|
2110
|
+
syncDashActive();
|
|
2111
|
+
})();
|
|
2112
|
+
})();
|
|
2113
|
+
</script>
|
|
2114
|
+
</body>
|
|
2115
|
+
</html>`;
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
/** 内联 SVG 图标(眼睛/下载/文件夹,比 emoji 干净)。 */
|
|
2119
|
+
const ICON_EYE = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
|
2120
|
+
const ICON_DL = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
|
2121
|
+
const ICON_FOLDER = '<svg class="ic" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/></svg>';
|
|
2122
|
+
const ICON_FOLDER_OPEN = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
|
|
2123
|
+
const ICON_X = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
|
2124
|
+
/** 独立可访问的 SVG 图标文件(供聊天 markdown 图片内联,颜色固定、尺寸 14px)。 */
|
|
2125
|
+
const ICON_FILES = {
|
|
2126
|
+
"eye.svg": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#93c5fd" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
|
|
2127
|
+
"download.svg": '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#86efac" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>',
|
|
2128
|
+
};
|
|
2129
|
+
|
|
2130
|
+
/** 渲染后 Markdown 的通用样式(供预览页与 mdHtml srcDoc 共用,跟随明暗主题)。 */
|
|
2131
|
+
const MD_CSS = `
|
|
2132
|
+
:root { --lp-bg:#0f1720; --lp-fg:#e5e7eb; --lp-text-bg:#0b1219; --lp-border:#2c3a47; --lp-bar-bg:#1a2530; --lp-meta:#9ca3af; }
|
|
2133
|
+
@media (prefers-color-scheme: light) { :root { --lp-bg:#ffffff; --lp-fg:#1f2937; --lp-text-bg:#f9fafb; --lp-border:#e5e7eb; --lp-bar-bg:#f3f4f6; --lp-meta:#6b7280; } }
|
|
2134
|
+
body { margin:0; background:var(--lp-bg); color:var(--lp-fg); }
|
|
2135
|
+
.md { line-height:1.8; font-size:14px; word-break:break-word; padding:24px 32px 48px; max-width:820px; margin:0 auto; }
|
|
2136
|
+
.md h1,.md h2,.md h3,.md h4,.md h5,.md h6 { line-height:1.45; margin:1.7em 0 .8em; }
|
|
2137
|
+
.md h1 { font-size:1.5em; } .md h2 { font-size:1.3em; } .md h3 { font-size:1.15em; }
|
|
2138
|
+
.md h1:first-child,.md h2:first-child,.md h3:first-child { margin-top:.6em; }
|
|
2139
|
+
.md p { margin:1em 0; }
|
|
2140
|
+
.md a { color:#60a5fa; text-decoration:none; } .md a:hover { text-decoration:underline; }
|
|
2141
|
+
.md code { background:var(--lp-text-bg); border:1px solid var(--lp-border); border-radius:4px; padding:1px 5px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:.9em; }
|
|
2142
|
+
.md pre.md-code { background:var(--lp-text-bg); border:1px solid var(--lp-border); border-radius:8px; padding:12px; overflow:auto; }
|
|
2143
|
+
.md pre.md-code code { background:none; border:none; padding:0; font-size:12px; line-height:1.6; }
|
|
2144
|
+
.md blockquote { margin:.8em 0; padding:.2em 1em; border-left:3px solid var(--lp-border); color:var(--lp-meta); }
|
|
2145
|
+
.md ul,.md ol { margin:.6em 0; padding-left:1.6em; } .md li { margin:.2em 0; }
|
|
2146
|
+
.md table { border-collapse:collapse; margin:.8em 0; max-width:100%; display:block; overflow:auto; }
|
|
2147
|
+
.md th,.md td { border:1px solid var(--lp-border); padding:6px 10px; font-size:13px; }
|
|
2148
|
+
.md th { background:var(--lp-bar-bg); font-weight:600; }
|
|
2149
|
+
.md img { max-width:100%; border-radius:8px; }
|
|
2150
|
+
.md hr { border:none; border-top:1px solid var(--lp-border); margin:1.5em 0; }
|
|
2151
|
+
.md del { color:var(--lp-meta); }
|
|
2152
|
+
`;
|
|
2153
|
+
|
|
2154
|
+
/** HTML 转义。 */
|
|
2155
|
+
function escapeHtml(value) {
|
|
2156
|
+
return String(value)
|
|
2157
|
+
.replace(/&/g, "&")
|
|
2158
|
+
.replace(/</g, "<")
|
|
2159
|
+
.replace(/>/g, ">")
|
|
2160
|
+
.replace(/"/g, """)
|
|
2161
|
+
.replace(/'/g, "'");
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
/**
|
|
2165
|
+
* 最小 Markdown → HTML 渲染器(零依赖,供 md/markdown 预览用)。
|
|
2166
|
+
* 覆盖常见语法:标题、加粗/斜体、行内代码、代码块、有序/无序列表、
|
|
2167
|
+
* 表格、引用、分隔线、链接、图片、段落。仅用于预览,不做完整 GFM。
|
|
2168
|
+
* 任何解析失败都回退到源码文本(escape 后 <pre>),不会抛错。
|
|
2169
|
+
*/
|
|
2170
|
+
function markdownToHtml(md) {
|
|
2171
|
+
const esc = escapeHtml;
|
|
2172
|
+
let text = String(md).replace(/\r\n?/g, "\n");
|
|
2173
|
+
|
|
2174
|
+
// 1) 代码块(多行 ``` 或 缩进 4 空格)——整体抽取,避免内部内容被后续规则误处理
|
|
2175
|
+
const codeBlocks = [];
|
|
2176
|
+
text = text.replace(/```[^\n]*\n([\s\S]*?)```/g, (_m, code) => {
|
|
2177
|
+
codeBlocks.push(code.replace(/^\n/, ""));
|
|
2178
|
+
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
|
2179
|
+
});
|
|
2180
|
+
// 缩进代码块(连续 >=4 空格行)
|
|
2181
|
+
text = text.replace(/(?:^|\n)((?: {4}[^\n]*\n?)+)/g, (_m, block) => {
|
|
2182
|
+
codeBlocks.push(block.split("\n").filter((l) => l.trim()).map((l) => l.replace(/^ {4}/, "")).join("\n"));
|
|
2183
|
+
return `\n\u0000CODE${codeBlocks.length - 1}\u0000`;
|
|
2184
|
+
});
|
|
2185
|
+
|
|
2186
|
+
// 2) 行内元素处理函数(在分段后应用)
|
|
2187
|
+
const inline = (s) =>
|
|
2188
|
+
s
|
|
2189
|
+
.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_m, alt, src, title) => {
|
|
2190
|
+
const t = title ? ` title="${esc(title)}"` : "";
|
|
2191
|
+
return `<img src="${esc(src)}" alt="${esc(alt)}"${t}>`;
|
|
2192
|
+
})
|
|
2193
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_m, label, href, title) => {
|
|
2194
|
+
const t = title ? ` title="${esc(title)}"` : "";
|
|
2195
|
+
return `<a href="${esc(href)}" target="_blank" rel="noopener noreferrer"${t}>${esc(label)}</a>`;
|
|
2196
|
+
})
|
|
2197
|
+
.replace(/`([^`]+)`/g, (_m, code) => `<code>${esc(code)}</code>`)
|
|
2198
|
+
.replace(/\*\*([^*]+)\*\*/g, (_m, b) => `<strong>${esc(b)}</strong>`)
|
|
2199
|
+
.replace(/__([^_]+)__/g, (_m, b) => `<strong>${esc(b)}</strong>`)
|
|
2200
|
+
.replace(/(^|[^*])\*([^*]+)\*/g, (_m, pre, i) => `${pre}<em>${esc(i)}</em>`)
|
|
2201
|
+
.replace(/(^|[^_])_([^_]+)_/g, (_m, pre, i) => `${pre}<em>${esc(i)}</em>`)
|
|
2202
|
+
.replace(/~~([^~]+)~~/g, (_m, d) => `<del>${esc(d)}</del>`);
|
|
2203
|
+
// 注意:链接/图片的 URL 不转义内部空格,且 href 用 esc 防注入。
|
|
2204
|
+
|
|
2205
|
+
// 3) 按块处理(保留代码块哨兵与表格)
|
|
2206
|
+
const lines = text.split("\n");
|
|
2207
|
+
const out = [];
|
|
2208
|
+
let para = []; // 累积段落行
|
|
2209
|
+
let list = null; // { ordered, items:[{indent,text}] }
|
|
2210
|
+
let table = null;
|
|
2211
|
+
|
|
2212
|
+
const flushPara = () => {
|
|
2213
|
+
if (para.length) {
|
|
2214
|
+
const content = inline(para.join("\n"));
|
|
2215
|
+
out.push(`<p>${content}</p>`);
|
|
2216
|
+
para = [];
|
|
2217
|
+
}
|
|
2218
|
+
};
|
|
2219
|
+
const flushList = () => {
|
|
2220
|
+
if (!list) return;
|
|
2221
|
+
const tag = list.ordered ? "ol" : "ul";
|
|
2222
|
+
const items = list.items.map((it) => `<li>${inline(it.text)}</li>`).join("");
|
|
2223
|
+
out.push(`<${tag}>${items}</${tag}>`);
|
|
2224
|
+
list = null;
|
|
2225
|
+
};
|
|
2226
|
+
const flushTable = () => {
|
|
2227
|
+
if (!table) return;
|
|
2228
|
+
const thead = table.header.map((c) => `<th>${inline(c)}</th>`).join("");
|
|
2229
|
+
const rows = table.rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`).join("");
|
|
2230
|
+
out.push(`<table><thead><tr>${thead}</tr></thead><tbody>${rows}</tbody></table>`);
|
|
2231
|
+
table = null;
|
|
2232
|
+
};
|
|
2233
|
+
|
|
2234
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2235
|
+
const line = lines[i];
|
|
2236
|
+
const trimmed = line.trim();
|
|
2237
|
+
|
|
2238
|
+
// 代码块哨兵
|
|
2239
|
+
const codeMatch = trimmed.match(/^\u0000CODE(\d+)\u0000$/);
|
|
2240
|
+
if (codeMatch) {
|
|
2241
|
+
flushPara(); flushList(); flushTable();
|
|
2242
|
+
out.push(`<pre class="md-code"><code>${esc(codeBlocks[Number(codeMatch[1])])}</code></pre>`);
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// 空行
|
|
2247
|
+
if (!trimmed) { flushPara(); flushList(); flushTable(); continue; }
|
|
2248
|
+
|
|
2249
|
+
// 分隔线
|
|
2250
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flushPara(); flushList(); flushTable(); out.push("<hr>"); continue; }
|
|
2251
|
+
|
|
2252
|
+
// 标题
|
|
2253
|
+
const h = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
|
2254
|
+
if (h) {
|
|
2255
|
+
flushPara(); flushList(); flushTable();
|
|
2256
|
+
const n = h[1].length;
|
|
2257
|
+
out.push(`<h${n}>${inline(h[2])}</h${n}>`);
|
|
2258
|
+
continue;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
// 引用(连续 > 行)
|
|
2262
|
+
if (/^>\s?/.test(trimmed)) {
|
|
2263
|
+
flushPara(); flushList(); flushTable();
|
|
2264
|
+
const quote = [];
|
|
2265
|
+
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
|
2266
|
+
quote.push(inline(lines[i].trim().replace(/^>\s?/, "")));
|
|
2267
|
+
i++;
|
|
2268
|
+
}
|
|
2269
|
+
i--;
|
|
2270
|
+
out.push(`<blockquote>${quote.join("<br>")}</blockquote>`);
|
|
2271
|
+
continue;
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// 表格(含分隔行 |---|)
|
|
2275
|
+
if (trimmed.startsWith("|") && trimmed.endsWith("|") && i + 1 < lines.length && /^\|?[\s:|-]+\|?$/.test(lines[i + 1].trim())) {
|
|
2276
|
+
flushPara(); flushList();
|
|
2277
|
+
const header = trimmed.slice(1, -1).split("|").map((c) => c.trim());
|
|
2278
|
+
const alignRow = lines[i + 1].trim().slice(1, -1).split("|");
|
|
2279
|
+
// 只解析表头对齐(简化),行内容保持结构
|
|
2280
|
+
const rows = [];
|
|
2281
|
+
i += 2;
|
|
2282
|
+
while (i < lines.length && lines[i].trim().startsWith("|") && lines[i].trim().endsWith("|")) {
|
|
2283
|
+
rows.push(lines[i].trim().slice(1, -1).split("|").map((c) => c.trim()));
|
|
2284
|
+
i++;
|
|
2285
|
+
}
|
|
2286
|
+
i--;
|
|
2287
|
+
table = { header, rows };
|
|
2288
|
+
flushTable();
|
|
2289
|
+
continue;
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// 有序列表
|
|
2293
|
+
const ol = trimmed.match(/^(\d+)\.\s+(.*)$/);
|
|
2294
|
+
if (ol) {
|
|
2295
|
+
flushPara();
|
|
2296
|
+
if (!list || !list.ordered) { flushList(); list = { ordered: true, items: [] }; }
|
|
2297
|
+
list.items.push({ text: ol[2] });
|
|
2298
|
+
continue;
|
|
2299
|
+
}
|
|
2300
|
+
// 无序列表 - / * / +
|
|
2301
|
+
const ul = trimmed.match(/^[-*+]\s+(.*)$/);
|
|
2302
|
+
if (ul) {
|
|
2303
|
+
flushPara();
|
|
2304
|
+
if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] }; }
|
|
2305
|
+
list.items.push({ text: ul[1] });
|
|
2306
|
+
continue;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// 其他 → 段落累积(多行用 <br> 连接)
|
|
2310
|
+
flushList();
|
|
2311
|
+
para.push(trimmed);
|
|
2312
|
+
}
|
|
2313
|
+
flushPara(); flushList(); flushTable();
|
|
2314
|
+
|
|
2315
|
+
return out.join("\n");
|
|
2316
|
+
}
|
|
2317
|
+
|
|
2318
|
+
|
|
2319
|
+
/** 工作区文件预览页面骨架(自包含,无外部依赖)。 */
|
|
2320
|
+
function previewPageHtml(name, rel, size, downloadHref, body, inlineHref = "", editable = false, rawText = "") {
|
|
2321
|
+
return `<!DOCTYPE html>
|
|
2322
|
+
<html lang="zh-CN">
|
|
2323
|
+
<head>
|
|
2324
|
+
<meta charset="utf-8">
|
|
2325
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2326
|
+
<title>预览 - ${escapeHtml(name)}</title>
|
|
2327
|
+
<style>
|
|
2328
|
+
* { box-sizing: border-box; }
|
|
2329
|
+
:root {
|
|
2330
|
+
--lp-bg:#0f1720; --lp-fg:#e5e7eb; --lp-bar-bg:#1a2530; --lp-border:#2c3a47;
|
|
2331
|
+
--lp-meta:#9ca3af; --lp-text-bg:#0b1219; --lp-hover:#2c3a47; --lp-btn2-fg:#e5e7eb;
|
|
2332
|
+
}
|
|
2333
|
+
@media (prefers-color-scheme: light) {
|
|
2334
|
+
:root {
|
|
2335
|
+
--lp-bg:#ffffff; --lp-fg:#1f2937; --lp-bar-bg:#f3f4f6; --lp-border:#e5e7eb;
|
|
2336
|
+
--lp-meta:#6b7280; --lp-text-bg:#f9fafb; --lp-hover:#e5e7eb; --lp-btn2-fg:#374151;
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
body { margin:0; font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif; background:var(--lp-bg); color:var(--lp-fg); }
|
|
2340
|
+
html, body { height:100%; }
|
|
2341
|
+
.bar { position:sticky; top:0; display:flex; align-items:center; gap:12px; padding:10px 16px; background:var(--lp-bar-bg); border-bottom:1px solid var(--lp-border); }
|
|
2342
|
+
.bar .name { font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
2343
|
+
.bar .meta { color:var(--lp-meta); font-size:12px; }
|
|
2344
|
+
.bar .spacer { flex:1; }
|
|
2345
|
+
.btn, .btn2 { display:inline-flex; align-items:center; gap:6px; height:32px; padding:0 14px; border-radius:8px; font-size:13px; line-height:1; }
|
|
2346
|
+
.btn { background:#2563eb; color:#fff; text-decoration:none; }
|
|
2347
|
+
.btn:hover { background:#1d4ed8; }
|
|
2348
|
+
.btn2 { background:transparent; color:var(--lp-btn2-fg); border:1px solid var(--lp-border); cursor:pointer; }
|
|
2349
|
+
.btn2:hover { background:var(--lp-hover); }
|
|
2350
|
+
.btn .ic, .btn2 .ic { width:14px; height:14px; }
|
|
2351
|
+
.hint { position:fixed; left:50%; bottom:24px; transform:translateX(-50%); background:#7c2d12; color:#fdba74; border-radius:8px; padding:10px 18px; font-size:13px; display:none; z-index:9; }
|
|
2352
|
+
.content { padding:20px; max-width:960px; margin:0 auto; }
|
|
2353
|
+
.text { background:var(--lp-text-bg); border:1px solid var(--lp-border); border-radius:10px; padding:16px; overflow:auto; font-size:13px; line-height:1.7; white-space:pre-wrap; word-break:break-word; }
|
|
2354
|
+
.editor { display:none; width:100%; min-height:60vh; background:var(--lp-text-bg); color:var(--lp-fg); border:1px solid var(--lp-border); border-radius:10px; padding:12px 14px; font:13px/1.7 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; resize:vertical; white-space:pre; word-break:normal; }
|
|
2355
|
+
body.maximized .editor { min-height:calc(100vh - 53px); }
|
|
2356
|
+
.md { line-height:1.8; font-size:14px; word-break:break-word; }
|
|
2357
|
+
.md h1,.md h2,.md h3,.md h4,.md h5,.md h6 { line-height:1.45; margin:1.7em 0 .8em; }
|
|
2358
|
+
.md h1 { font-size:1.5em; }
|
|
2359
|
+
.md h2 { font-size:1.3em; }
|
|
2360
|
+
.md h3 { font-size:1.15em; }
|
|
2361
|
+
.md h1:first-child,.md h2:first-child,.md h3:first-child { margin-top:.6em; }
|
|
2362
|
+
.md p { margin:1em 0; }
|
|
2363
|
+
.md a { color:#60a5fa; text-decoration:none; }
|
|
2364
|
+
.md a:hover { text-decoration:underline; }
|
|
2365
|
+
.md code { background:var(--lp-text-bg); border:1px solid var(--lp-border); border-radius:4px; padding:1px 5px; font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; font-size:.9em; }
|
|
2366
|
+
.md pre.md-code { background:var(--lp-text-bg); border:1px solid var(--lp-border); border-radius:8px; padding:12px; overflow:auto; }
|
|
2367
|
+
.md pre.md-code code { background:none; border:none; padding:0; font-size:12px; line-height:1.6; }
|
|
2368
|
+
.md blockquote { margin:.8em 0; padding:.2em 1em; border-left:3px solid var(--lp-border); color:var(--lp-meta); }
|
|
2369
|
+
.md ul,.md ol { margin:.6em 0; padding-left:1.6em; }
|
|
2370
|
+
.md li { margin:.2em 0; }
|
|
2371
|
+
.md table { border-collapse:collapse; margin:.8em 0; max-width:100%; display:block; overflow:auto; }
|
|
2372
|
+
.md th,.md td { border:1px solid var(--lp-border); padding:6px 10px; font-size:13px; }
|
|
2373
|
+
.md th { background:var(--lp-bar-bg); font-weight:600; }
|
|
2374
|
+
.md img { max-width:100%; border-radius:8px; }
|
|
2375
|
+
.md hr { border:none; border-top:1px solid var(--lp-border); margin:1.5em 0; }
|
|
2376
|
+
.md del { color:var(--lp-meta); }
|
|
2377
|
+
body.maximized .md { max-width:none; }
|
|
2378
|
+
.image { max-width:100%; border-radius:8px; }
|
|
2379
|
+
.pdf { display:block; width:100%; height:86vh; border:1px solid var(--lp-border); border-radius:8px; background:#fff; }
|
|
2380
|
+
.html { display:block; width:100%; height:86vh; border:1px solid var(--lp-border); border-radius:8px; background:#fff; }
|
|
2381
|
+
.office { background:#fff; color:#111; border-radius:8px; padding:16px; overflow:auto; }
|
|
2382
|
+
.unsupported { color:#f59e0b; text-align:center; padding:40px 0; }
|
|
2383
|
+
/* 放大模式:顶栏常驻(固定悬浮),内容占满视口、PDF 全高 */
|
|
2384
|
+
body.maximized .bar { position:fixed; top:0; left:0; right:0; z-index:20; }
|
|
2385
|
+
body.maximized .content { max-width:none; padding:0; margin:0; height:100vh; padding-top:53px; }
|
|
2386
|
+
body.maximized .md { padding:24px 32px 48px; max-width:820px; margin:0 auto; }
|
|
2387
|
+
body.maximized .pdf { height:calc(100vh - 53px); border:none; border-radius:0; }
|
|
2388
|
+
body.maximized .html { height:calc(100vh - 53px); border:none; border-radius:0; }
|
|
2389
|
+
body.maximized .text { height:calc(100vh - 53px); border:none; border-radius:0; overflow:auto; }
|
|
2390
|
+
body.maximized .office { height:calc(100vh - 53px); overflow:auto; }
|
|
2391
|
+
body.maximized .image { max-width:100vw; max-height:100vh; object-fit:contain; }
|
|
2392
|
+
@media (max-width: 767px) {
|
|
2393
|
+
.bar { flex-wrap:wrap; gap:8px; padding:8px 10px; }
|
|
2394
|
+
.bar .name { font-size:13px; }
|
|
2395
|
+
.btn, .btn2 { padding:4px 12px; font-size:12px; border-radius:7px; }
|
|
2396
|
+
}
|
|
2397
|
+
</style>
|
|
2398
|
+
</head>
|
|
2399
|
+
<body>
|
|
2400
|
+
<div class="bar">
|
|
2401
|
+
<span class="name">${escapeHtml(name)}</span>
|
|
2402
|
+
<span class="meta">${escapeHtml(rel)} · ${(size / 1024).toFixed(1)} KB</span>
|
|
2403
|
+
<span class="spacer"></span>
|
|
2404
|
+
${editable ? `<button class="btn2" type="button" id="editBtn" onclick="editMode()">${ICON_FOLDER} 编辑</button>
|
|
2405
|
+
<button class="btn" type="button" id="saveBtn" onclick="saveFile()" style="display:none">${ICON_DL} 保存</button>` : ""}
|
|
2406
|
+
${inlineHref ? `<a class="btn" href="${escapeHtml(inlineHref)}" target="_blank" rel="noopener noreferrer">${ICON_EYE} 打开</a>` : ""}
|
|
2407
|
+
<a class="btn" href="${escapeHtml(downloadHref)}" download>${ICON_DL} 下载</a>
|
|
2408
|
+
<button class="btn2" type="button" id="maxBtn" onclick="toggleMax()">${ICON_FOLDER} 放大</button>
|
|
2409
|
+
<button class="btn2" type="button" onclick="closePreview()">${ICON_X} 关闭</button>
|
|
2410
|
+
</div>
|
|
2411
|
+
<div class="content">${body}</div>
|
|
2412
|
+
${editable ? `<textarea id="editor" class="editor">${escapeHtml(rawText)}</textarea>` : ""}
|
|
2413
|
+
<div class="hint" id="closeHint">浏览器不允许脚本直接关闭此标签页,请手动关闭本标签页(或按 Ctrl+W / ⌘+W)。</div>
|
|
2414
|
+
<script>
|
|
2415
|
+
var SAVE_PATH = ${JSON.stringify("/api/dsh-uploads/workspace-file/save")};
|
|
2416
|
+
var SAVE_REL = ${JSON.stringify(rel)};
|
|
2417
|
+
function editMode() {
|
|
2418
|
+
var editing = document.getElementById('editor').style.display === 'none';
|
|
2419
|
+
document.querySelector('.content').style.display = editing ? 'none' : '';
|
|
2420
|
+
document.getElementById('editor').style.display = editing ? 'block' : 'none';
|
|
2421
|
+
document.getElementById('saveBtn').style.display = editing ? '' : 'none';
|
|
2422
|
+
document.getElementById('editBtn').textContent = editing ? '取消' : '编辑';
|
|
2423
|
+
if (editing) { var ed = document.getElementById('editor'); ed.focus(); }
|
|
2424
|
+
}
|
|
2425
|
+
function saveFile() {
|
|
2426
|
+
var content = document.getElementById('editor').value;
|
|
2427
|
+
var btn = document.getElementById('saveBtn');
|
|
2428
|
+
btn.disabled = true; btn.textContent = '保存中…';
|
|
2429
|
+
fetch(SAVE_PATH, { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path: SAVE_REL, content: content }) })
|
|
2430
|
+
.then(function (r) { return r.json().catch(function(){ return {}; }).then(function(b){ return { ok: r.ok, b: b }; }); })
|
|
2431
|
+
.then(function (o) { btn.textContent = o.ok ? '已保存' : ('失败: ' + (o.b && o.b.error || '')); btn.disabled = false; setTimeout(function(){ btn.textContent = '保存'; }, 1400); })
|
|
2432
|
+
.catch(function () { btn.textContent = '网络错误'; btn.disabled = false; setTimeout(function(){ btn.textContent = '保存'; }, 1500); });
|
|
2433
|
+
}
|
|
2434
|
+
function toggleMax() {
|
|
2435
|
+
var max = document.body.classList.toggle('maximized');
|
|
2436
|
+
document.getElementById('maxBtn').textContent = max ? '还原' : '放大';
|
|
2437
|
+
}
|
|
2438
|
+
function closePreview() {
|
|
2439
|
+
// 从文件列表页进入(URL 带 from=list)→ 返回列表页,只关掉当前文件
|
|
2440
|
+
var fromList = /[?&]from=list(&|$)/.test(location.search);
|
|
2441
|
+
try {
|
|
2442
|
+
if (window.self !== window.top && window.parent) {
|
|
2443
|
+
// 在 iframe(会话内联预览)里:
|
|
2444
|
+
// 从列表页进入 → 返回列表;直接打开 → 通知父窗口关闭整个面板
|
|
2445
|
+
if (fromList) { if (window.history.length > 1) { window.history.back(); return; } }
|
|
2446
|
+
window.parent.postMessage({ type: 'dsh-close-preview' }, location.origin);
|
|
2447
|
+
return;
|
|
2448
|
+
}
|
|
2449
|
+
} catch (e) { /* 跨域忽略 */ }
|
|
2450
|
+
if (fromList && window.history.length > 1) { window.history.back(); return; }
|
|
2451
|
+
window.close();
|
|
2452
|
+
setTimeout(function () { document.getElementById('closeHint').style.display = 'block'; }, 300);
|
|
2453
|
+
}
|
|
2454
|
+
</script>
|
|
2455
|
+
</body>
|
|
2456
|
+
</html>`;
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
/**
|
|
2460
|
+
* Mount every route once the profile composes the webServer and credentials
|
|
2461
|
+
* services.
|
|
2462
|
+
* @param ctx - host plugin context.
|
|
2463
|
+
* @param config - optional profile override (trustedHosts, skillsRoot).
|
|
2464
|
+
*/
|
|
2465
|
+
export async function apply(ctx, config = {}) {
|
|
2466
|
+
const trustedHosts = Array.isArray(config.trustedHosts) ? [...config.trustedHosts] : [];
|
|
2467
|
+
const skillsRoot = resolve(config.skillsRoot ?? DEFAULT_SKILLS_ROOT);
|
|
2468
|
+
const workspaceRoot = resolveWorkspaceRoot(); // 供 skill-docs 路由"工作区技能"使用
|
|
2469
|
+
const onError = (error) => ctx.logger.error(error instanceof Error ? error : new Error(String(error)));
|
|
2470
|
+
|
|
2471
|
+
const handlers = createHandlers({ trustedHosts, onError, excludedWorkspaceNames: config.excludedWorkspaceNames });
|
|
2472
|
+
|
|
2473
|
+
|
|
2474
|
+
await sweepUploadTemps(handlers.root);
|
|
2475
|
+
|
|
2476
|
+
const requireTrusted = (req) => {
|
|
2477
|
+
if (!isTrustedUploadRequest(req, trustedHosts)) throw new HttpError(403, "forbidden");
|
|
2478
|
+
};
|
|
2479
|
+
|
|
2480
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2481
|
+
kind: "exact",
|
|
2482
|
+
path: API_PATH,
|
|
2483
|
+
handler: handlers.api,
|
|
2484
|
+
}), "dsh-long-plugins: upload/list/delete route");
|
|
2485
|
+
|
|
2486
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2487
|
+
kind: "exact",
|
|
2488
|
+
path: DOWNLOAD_PATH,
|
|
2489
|
+
handler: handlers.download,
|
|
2490
|
+
}), "dsh-long-plugins: download route");
|
|
2491
|
+
|
|
2492
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2493
|
+
kind: "exact",
|
|
2494
|
+
path: PREVIEW_PATH,
|
|
2495
|
+
handler: handlers.preview,
|
|
2496
|
+
}), "dsh-long-plugins: preview route");
|
|
2497
|
+
|
|
2498
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2499
|
+
kind: "exact",
|
|
2500
|
+
path: "/api/dsh-uploads/workspace",
|
|
2501
|
+
handler: handlers.workspaceList,
|
|
2502
|
+
}), "dsh-long-plugins: workspace list route");
|
|
2503
|
+
|
|
2504
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2505
|
+
kind: "exact",
|
|
2506
|
+
path: "/api/dsh-uploads/workspace-file",
|
|
2507
|
+
handler: handlers.workspaceFile,
|
|
2508
|
+
}), "dsh-long-plugins: workspace file route");
|
|
2509
|
+
|
|
2510
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2511
|
+
kind: "exact",
|
|
2512
|
+
path: "/api/dsh-uploads/workspace-preview",
|
|
2513
|
+
handler: handlers.workspacePreview,
|
|
2514
|
+
}), "dsh-long-plugins: workspace preview route");
|
|
2515
|
+
|
|
2516
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2517
|
+
kind: "exact",
|
|
2518
|
+
path: "/api/dsh-uploads/docx-preview",
|
|
2519
|
+
handler: handlers.docxPreviewPage,
|
|
2520
|
+
}), "dsh-long-plugins: docx real-preview route");
|
|
2521
|
+
|
|
2522
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2523
|
+
kind: "exact",
|
|
2524
|
+
path: "/api/dsh-uploads/docx-preview-asset",
|
|
2525
|
+
handler: handlers.docxPreviewAsset,
|
|
2526
|
+
}), "dsh-long-plugins: docx-preview vendor assets");
|
|
2527
|
+
|
|
2528
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2529
|
+
kind: "exact",
|
|
2530
|
+
path: "/api/dsh-uploads/pptx-preview",
|
|
2531
|
+
handler: handlers.pptxPreviewPage,
|
|
2532
|
+
}), "dsh-long-plugins: pptx real-preview route");
|
|
2533
|
+
|
|
2534
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2535
|
+
kind: "exact",
|
|
2536
|
+
path: "/api/dsh-uploads/xlsx-preview",
|
|
2537
|
+
handler: handlers.xlsxPreviewPage,
|
|
2538
|
+
}), "dsh-long-plugins: xlsx real-preview route");
|
|
2539
|
+
|
|
2540
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2541
|
+
kind: "exact",
|
|
2542
|
+
path: "/api/dsh-uploads/xlsx-preview-asset",
|
|
2543
|
+
handler: handlers.xlsxPreviewAsset,
|
|
2544
|
+
}), "dsh-long-plugins: xlsx-preview vendor assets");
|
|
2545
|
+
|
|
2546
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2547
|
+
path: "/api/dsh-uploads/patch-status",
|
|
2548
|
+
handler: handlers.patchStatus,
|
|
2549
|
+
}), "dsh-long-plugins: patch status route");
|
|
2550
|
+
|
|
2551
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2552
|
+
kind: "exact",
|
|
2553
|
+
path: "/api/dsh-uploads/modules-config",
|
|
2554
|
+
handler: handlers.modulesConfig,
|
|
2555
|
+
}), "dsh-long-plugins: modules config route");
|
|
2556
|
+
|
|
2557
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2558
|
+
kind: "exact",
|
|
2559
|
+
path: "/api/dsh-uploads/workspace-browse",
|
|
2560
|
+
handler: handlers.workspaceBrowse,
|
|
2561
|
+
}), "dsh-long-plugins: workspace browse route");
|
|
2562
|
+
|
|
2563
|
+
// 聊天 markdown 内联用的 SVG 图标文件(eye/download)
|
|
2564
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2565
|
+
kind: "prefix",
|
|
2566
|
+
path: "/api/dsh-uploads/icons",
|
|
2567
|
+
handler: (req, res) => {
|
|
2568
|
+
try {
|
|
2569
|
+
requireTrusted(req);
|
|
2570
|
+
const name = new URL(req.url || "/", "http://dsh.internal").pathname.split("/").pop() || "";
|
|
2571
|
+
const svg = ICON_FILES[name];
|
|
2572
|
+
if (svg === undefined) {
|
|
2573
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
2574
|
+
res.end("not found");
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
res.writeHead(200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" });
|
|
2578
|
+
res.end(svg);
|
|
2579
|
+
} catch (error) {
|
|
2580
|
+
sendError(res, error, onError);
|
|
2581
|
+
}
|
|
2582
|
+
},
|
|
2583
|
+
}), "dsh-long-plugins: chat icon files route");
|
|
2584
|
+
|
|
2585
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2586
|
+
kind: "exact",
|
|
2587
|
+
path: "/api/dsh-uploads/workspace-file/delete",
|
|
2588
|
+
handler: handlers.workspaceDelete,
|
|
2589
|
+
}), "dsh-long-plugins: workspace delete route");
|
|
2590
|
+
|
|
2591
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2592
|
+
kind: "exact",
|
|
2593
|
+
path: "/api/dsh-uploads/workspace-file/save",
|
|
2594
|
+
handler: handlers.workspaceSave,
|
|
2595
|
+
}), "dsh-long-plugins: workspace save route");
|
|
2596
|
+
|
|
2597
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2598
|
+
kind: "exact",
|
|
2599
|
+
path: "/api/dsh-uploads/workspace-file/rename",
|
|
2600
|
+
handler: handlers.workspaceRename,
|
|
2601
|
+
}), "dsh-long-plugins: workspace rename route");
|
|
2602
|
+
|
|
2603
|
+
// ---- 技能文档 (skill docs) routes ----
|
|
2604
|
+
const skillDocsList = async (req, res) => {
|
|
2605
|
+
const rootParam = (() => { try { return new URL(req.url || "/", "http://dsh.internal").searchParams.get("root") || "global"; } catch { return "global"; } })();
|
|
2606
|
+
const wsParam = (() => { try { return new URL(req.url || "/", "http://dsh.internal").searchParams.get("ws") || ""; } catch { return ""; } })();
|
|
2607
|
+
if (rootParam === "workspace") {
|
|
2608
|
+
// 扫描 workspace 根下的子目录:凡有 .dsh/skills 就识别,按工作区名分组;无文件则该组 files 为空
|
|
2609
|
+
const groups = await collectWorkspaceSkills(workspaceRoot);
|
|
2610
|
+
sendJson(res, 200, { ok: true, root: workspaceRoot, groups });
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
const groups = await collectGroups(skillsRoot, "__dsh_none__");
|
|
2614
|
+
sendJson(res, 200, { ok: true, root: skillsRoot, groups });
|
|
2615
|
+
};
|
|
2616
|
+
|
|
2617
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2618
|
+
kind: "exact",
|
|
2619
|
+
path: "/dsh-skill-docs/skill-docs",
|
|
2620
|
+
handler: async (req, res) => {
|
|
2621
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2622
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
try {
|
|
2626
|
+
requireTrusted(req);
|
|
2627
|
+
await skillDocsList(req, res);
|
|
2628
|
+
} catch (error) {
|
|
2629
|
+
sendError(res, error, onError);
|
|
2630
|
+
}
|
|
2631
|
+
},
|
|
2632
|
+
}), "dsh-long-plugins: skill-docs list route");
|
|
2633
|
+
|
|
2634
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2635
|
+
kind: "exact",
|
|
2636
|
+
path: "/dsh-skill-docs/skill-doc",
|
|
2637
|
+
handler: async (req, res) => {
|
|
2638
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2639
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
let rel;
|
|
2643
|
+
try {
|
|
2644
|
+
rel = decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || "");
|
|
2645
|
+
} catch {
|
|
2646
|
+
rel = "";
|
|
2647
|
+
}
|
|
2648
|
+
try {
|
|
2649
|
+
requireTrusted(req);
|
|
2650
|
+
const rootParam = (() => { try { return new URL(req.url || "/", "http://dsh.internal").searchParams.get("root") || "global"; } catch { return "global"; } })();
|
|
2651
|
+
const wsParam = (() => { try { return new URL(req.url || "/", "http://dsh.internal").searchParams.get("ws") || ""; } catch { return ""; } })();
|
|
2652
|
+
const root = rootParam === "workspace" ? resolve(workspaceRoot, wsParam, ".dsh", "skills") : skillsRoot;
|
|
2653
|
+
const full = safeResolve(root, rel);
|
|
2654
|
+
if (full === undefined) throw new HttpError(400, "bad path");
|
|
2655
|
+
const info = await stat(full);
|
|
2656
|
+
if (!info.isFile()) throw new HttpError(400, "not a file");
|
|
2657
|
+
const download = new URL(req.url || "/", "http://dsh.internal").searchParams.get("download") === "1";
|
|
2658
|
+
const name = rel.split("/").pop() || "file";
|
|
2659
|
+
if (download) {
|
|
2660
|
+
res.writeHead(200, {
|
|
2661
|
+
"content-type": "application/octet-stream",
|
|
2662
|
+
"content-disposition": contentDisposition(name),
|
|
2663
|
+
"content-length": String(info.size),
|
|
2664
|
+
"cache-control": "no-store",
|
|
2665
|
+
});
|
|
2666
|
+
const stream = createReadStream(full);
|
|
2667
|
+
stream.on("error", (error) => res.destroy(error));
|
|
2668
|
+
stream.pipe(res);
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
const buffer = await readFile(full);
|
|
2672
|
+
const binary = buffer.subarray(0, 8192).includes(0);
|
|
2673
|
+
const truncated = buffer.length > PREVIEW_LIMIT;
|
|
2674
|
+
sendJson(res, 200, {
|
|
2675
|
+
ok: true,
|
|
2676
|
+
path: rel,
|
|
2677
|
+
name,
|
|
2678
|
+
size: info.size,
|
|
2679
|
+
mtime: info.mtimeMs,
|
|
2680
|
+
binary,
|
|
2681
|
+
truncated,
|
|
2682
|
+
contentType: contentType(name),
|
|
2683
|
+
content: binary || truncated ? undefined : buffer.subarray(0, PREVIEW_LIMIT).toString("utf8"),
|
|
2684
|
+
});
|
|
2685
|
+
} catch (error) {
|
|
2686
|
+
const code = error && typeof error === "object" && error.code;
|
|
2687
|
+
sendError(res, error, onError);
|
|
2688
|
+
}
|
|
2689
|
+
},
|
|
2690
|
+
}), "dsh-long-plugins: skill-doc route");
|
|
2691
|
+
|
|
2692
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2693
|
+
kind: "exact",
|
|
2694
|
+
path: "/dsh-skill-docs/skill-doc/save",
|
|
2695
|
+
handler: async (req, res) => {
|
|
2696
|
+
if (req.method !== "POST") {
|
|
2697
|
+
methodNotAllowed(res, ["POST"]);
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
try {
|
|
2701
|
+
requireTrusted(req);
|
|
2702
|
+
const body = await readJsonBody(req);
|
|
2703
|
+
const rel = typeof body === "object" && body !== null ? body.path : undefined;
|
|
2704
|
+
const content = typeof body === "object" && body !== null ? body.content : undefined;
|
|
2705
|
+
const rootParam = typeof body === "object" && body !== null && body.root === "workspace" ? "workspace" : "global";
|
|
2706
|
+
const wsParam = typeof body === "object" && body !== null && typeof body.ws === "string" ? body.ws : "";
|
|
2707
|
+
const root = rootParam === "workspace" ? resolve(workspaceRoot, wsParam, ".dsh", "skills") : skillsRoot;
|
|
2708
|
+
if (typeof content !== "string") throw new HttpError(400, "content required");
|
|
2709
|
+
const full = safeResolve(root, rel);
|
|
2710
|
+
if (full === undefined) throw new HttpError(400, "bad path");
|
|
2711
|
+
const info = await stat(full);
|
|
2712
|
+
if (!info.isFile()) throw new HttpError(400, "not a file");
|
|
2713
|
+
await writeFile(full, content, "utf8");
|
|
2714
|
+
sendJson(res, 200, { ok: true, path: rel });
|
|
2715
|
+
} catch (error) {
|
|
2716
|
+
sendError(res, error, onError);
|
|
2717
|
+
}
|
|
2718
|
+
},
|
|
2719
|
+
}), "dsh-long-plugins: skill-doc save route");
|
|
2720
|
+
|
|
2721
|
+
// ---- 余额 (account balance) route ----
|
|
2722
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2723
|
+
kind: "exact",
|
|
2724
|
+
path: "/dsh-token-usage/balance",
|
|
2725
|
+
handler: async (req, res) => {
|
|
2726
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2727
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
try {
|
|
2731
|
+
requireTrusted(req);
|
|
2732
|
+
const resolved = await ctx.credentials.resolve("DEEPSEEK_API_KEY");
|
|
2733
|
+
if (resolved === undefined) {
|
|
2734
|
+
sendJson(res, 503, { ok: false, error: "no-api-key" });
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
const upstream = await fetch(BALANCE_URL, {
|
|
2738
|
+
headers: {
|
|
2739
|
+
Authorization: `Bearer ${resolved.value}`,
|
|
2740
|
+
Accept: "application/json",
|
|
2741
|
+
},
|
|
2742
|
+
signal: AbortSignal.timeout(10000),
|
|
2743
|
+
});
|
|
2744
|
+
const text = await upstream.text();
|
|
2745
|
+
res.writeHead(upstream.status, {
|
|
2746
|
+
"content-type": "application/json; charset=utf-8",
|
|
2747
|
+
"cache-control": "no-store",
|
|
2748
|
+
});
|
|
2749
|
+
res.end(text);
|
|
2750
|
+
} catch (error) {
|
|
2751
|
+
sendJson(res, 502, { ok: false, error: String(error instanceof Error ? error.message : error) });
|
|
2752
|
+
}
|
|
2753
|
+
},
|
|
2754
|
+
}), "dsh-long-plugins: balance route");
|
|
2755
|
+
|
|
2756
|
+
// ---- 本会话消费 (session spend) route ----
|
|
2757
|
+
// Reads the live session's event log and prices every assistant/message
|
|
2758
|
+
// usage sample against DeepSeek V4-Flash official pricing (effective
|
|
2759
|
+
// 2026-08-17, peak/off-peak split). Peak hours are Beijing time
|
|
2760
|
+
// 09:00-12:00 and 14:00-18:00; off-peak is everything else.
|
|
2761
|
+
const SESSION_PRICE_PEAK = { input: 3, cache: 0.1, output: 9 }; // ¥ per 1M tokens
|
|
2762
|
+
const SESSION_PRICE_OFFPEAK = { input: 1.5, cache: 0.05, output: 4.5 };
|
|
2763
|
+
ctx.effect(() => ctx.webServer.register({
|
|
2764
|
+
kind: "exact",
|
|
2765
|
+
path: "/dsh-token-usage/session-cost",
|
|
2766
|
+
handler: async (req, res) => {
|
|
2767
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2768
|
+
methodNotAllowed(res, ["GET", "HEAD"]);
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
try {
|
|
2772
|
+
requireTrusted(req);
|
|
2773
|
+
const url = new URL(req.url ?? "", "http://localhost");
|
|
2774
|
+
const sessionId = url.searchParams.get("session");
|
|
2775
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
2776
|
+
sendJson(res, 400, { ok: false, error: "session required" });
|
|
2777
|
+
return;
|
|
2778
|
+
}
|
|
2779
|
+
const session = ctx.sessions?.get(sessionId);
|
|
2780
|
+
let events;
|
|
2781
|
+
if (session !== undefined && typeof session.snapshotEvents === "function") {
|
|
2782
|
+
// DSH 0.1.2-alpha.5 Session exposes snapshotEvents() (the the .events
|
|
2783
|
+
// property is not part of the live Session surface). The legacy
|
|
2784
|
+
// .events array is kept as a fallback for older DSH builds.
|
|
2785
|
+
events = session.snapshotEvents();
|
|
2786
|
+
} else if (session !== undefined && Array.isArray(session.events)) {
|
|
2787
|
+
events = session.events;
|
|
2788
|
+
} else if (ctx.sessionPersistence !== undefined) {
|
|
2789
|
+
// Cold (historical) session: restore through the durable persistence
|
|
2790
|
+
// backend. inspect() prefers the live store and falls back to disk.
|
|
2791
|
+
// It throws for a session whose live turn is still open; treat that
|
|
2792
|
+
// as "no usable events" instead of a 500 so the chip degrades to
|
|
2793
|
+
// balance-only rather than erroring.
|
|
2794
|
+
try {
|
|
2795
|
+
const inspected = await ctx.sessionPersistence.inspect(sessionId);
|
|
2796
|
+
events = inspected && Array.isArray(inspected.events) ? inspected.events : undefined;
|
|
2797
|
+
} catch {
|
|
2798
|
+
events = undefined;
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
if (events === undefined) {
|
|
2802
|
+
sendJson(res, 404, { ok: false, error: "session not found" });
|
|
2803
|
+
return;
|
|
2804
|
+
}
|
|
2805
|
+
let input = 0, output = 0, cacheRead = 0;
|
|
2806
|
+
let peakCny = 0, offPeakCny = 0;
|
|
2807
|
+
for (const ev of events) {
|
|
2808
|
+
if (ev.type !== "assistant/message") continue;
|
|
2809
|
+
const usage = ev.data && ev.data.usage;
|
|
2810
|
+
if (usage == null || typeof usage !== "object") continue;
|
|
2811
|
+
const inT = Number(usage.inputTokens) || 0;
|
|
2812
|
+
const outT = Number(usage.outputTokens) || 0;
|
|
2813
|
+
const cR = Number(usage.cacheReadTokens) || 0;
|
|
2814
|
+
if (inT + outT + cR <= 0) continue;
|
|
2815
|
+
input += inT; output += outT; cacheRead += cR;
|
|
2816
|
+
// Beijing-time peak check (server local time is CST on this host,
|
|
2817
|
+
// but compute against UTC+8 explicitly to be safe).
|
|
2818
|
+
const d = new Date(ev.time);
|
|
2819
|
+
const bjHour = (d.getUTCHours() + 8) % 24;
|
|
2820
|
+
const isPeak = (bjHour >= 9 && bjHour < 12) || (bjHour >= 14 && bjHour < 18);
|
|
2821
|
+
const price = isPeak ? SESSION_PRICE_PEAK : SESSION_PRICE_OFFPEAK;
|
|
2822
|
+
const cny = (inT * price.input + cR * price.cache + outT * price.output) / 1e6;
|
|
2823
|
+
if (isPeak) peakCny += cny; else offPeakCny += cny;
|
|
2824
|
+
}
|
|
2825
|
+
sendJson(res, 200, {
|
|
2826
|
+
ok: true,
|
|
2827
|
+
sessionId,
|
|
2828
|
+
tokens: { input, output, cacheRead },
|
|
2829
|
+
cny: { peak: Number(peakCny.toFixed(4)), offPeak: Number(offPeakCny.toFixed(4)), total: Number((peakCny + offPeakCny).toFixed(4)) },
|
|
2830
|
+
});
|
|
2831
|
+
} catch (error) {
|
|
2832
|
+
sendError(res, error, onError);
|
|
2833
|
+
}
|
|
2834
|
+
},
|
|
2835
|
+
}), "dsh-long-plugins: session-cost route");
|
|
2836
|
+
}
|