dsh-long-plugins 1.3.9 → 1.3.10

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/lib/index.js CHANGED
@@ -503,36 +503,95 @@ async function docxHtml(buf) {
503
503
  }
504
504
  }
505
505
 
506
- /** Render the first sheet of a .xlsx as an HTML table. */
506
+ /** Excel 列号(A=1, AA=27)。 */
507
+ function colToNum(letters) {
508
+ let n = 0;
509
+ for (const ch of String(letters).toUpperCase()) n = n * 26 + (ch.charCodeAt(0) - 64);
510
+ return n;
511
+ }
512
+
513
+ /** 渲染 .xlsx 为 HTML 表格(多工作表 + 合并单元格 + 表头样式,bound 保持预览量)。 */
507
514
  async function xlsxHtml(buf) {
508
515
  try {
509
516
  const workbook = new ExcelJS.Workbook();
510
517
  await workbook.xlsx.load(buf);
511
- const sheet = workbook.worksheets[0];
512
- if (!sheet) return "<p>(空工作簿)</p>";
513
- const rows = [];
514
- const limit = 500; // cell cap to keep the preview bounded
515
- let rendered = 0;
516
- for (const row of sheet.eachRow({ includeEmpty: false })) {
517
- if (rendered >= limit) break;
518
- const cells = [];
519
- for (let col = 1; col <= Math.min(row.cellCount, 32); col += 1) {
520
- const cell = row.getCell(col);
521
- let value = cell.value;
522
- if (value !== null && typeof value === "object") {
523
- if (value.richText) value = value.richText.map((t) => t.text).join("");
524
- else if (value.text !== undefined) value = value.text;
525
- else if (value.result !== undefined) value = value.result;
526
- else value = "";
518
+ const sheets = workbook.worksheets.slice(0, 3);
519
+ if (sheets.length === 0) return "<p>(空工作簿)</p>";
520
+ let html = "";
521
+ for (const sheet of sheets) {
522
+ // 合并范围:topLeft -> {rowspan, colspan};并记录被覆盖的格子。
523
+ // exceljs 版本差异:mergedCells 可能是 getter;用 model.merges 兜底(格式 "A1:C1")。
524
+ const merges = (sheet.mergedCells && sheet.mergedCells.length ? sheet.mergedCells : (sheet.model && sheet.model.merges)) || [];
525
+ const merged = new Map();
526
+ const covered = new Set();
527
+ for (const range of merges) {
528
+ const m = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/.exec(String(range));
529
+ if (!m) continue;
530
+ const c1 = colToNum(m[1]), r1 = parseInt(m[2], 10), c2 = colToNum(m[3]), r2 = parseInt(m[4], 10);
531
+ merged.set(`${r1},${c1}`, { rowspan: r2 - r1 + 1, colspan: c2 - c1 + 1 });
532
+ for (let rr = r1; rr <= r2; rr++) for (let cc = c1; cc <= c2; cc++) if (!(rr === r1 && cc === c1)) covered.add(`${rr},${cc}`);
533
+ }
534
+ const rowLimit = 500, colLimit = 32;
535
+ const rows = [];
536
+ sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
537
+ if (rows.length >= rowLimit) return;
538
+ const rn = rowNumber;
539
+ const cells = [];
540
+ for (let col = 1; col <= Math.min(row.cellCount, colLimit); col += 1) {
541
+ if (covered.has(`${rn},${col}`)) continue;
542
+ let value = row.getCell(col).value;
543
+ if (value !== null && typeof value === "object") {
544
+ if (value.richText) value = value.richText.map((t) => t.text).join("");
545
+ else if (value.text !== undefined) value = value.text;
546
+ else if (value.result !== undefined) value = value.result;
547
+ else value = "";
548
+ }
549
+ const span = merged.get(`${rn},${col}`);
550
+ const attr = span ? (span.colspan > 1 ? ` colspan="${span.colspan}"` : "") + (span.rowspan > 1 ? ` rowspan="${span.rowspan}"` : "") : "";
551
+ cells.push(`<td${attr}>${escHtml(value ?? "")}</td>`);
527
552
  }
528
- cells.push(`<td>${escHtml(value ?? "")}</td>`);
553
+ rows.push(`<tr>${cells.join("")}</tr>`);
554
+ });
555
+ const header = rows.length > 0 ? `<thead>${rows[0].replace(/<td/g, "<th").replace(/<\/td>/g, "</th>")}</thead>` : "";
556
+ const body = rows.length > 1 ? `<tbody>${rows.slice(1).join("")}</tbody>` : "";
557
+ html += `<div class="sheet"><h3>${escHtml(sheet.name || "Sheet")}</h3><table>${header}${body}</table></div>`;
558
+ }
559
+ 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>";
560
+ return `${css}${html}`;
561
+ } catch {
562
+ return null;
563
+ }
564
+ }
565
+
566
+ /** 解析 CSV/TSV 文本为 HTML 表格(首行当作表头,bound)。 */
567
+ function csvHtml(text) {
568
+ try {
569
+ const limit = 500;
570
+ const rows = [];
571
+ const parse = (line) => {
572
+ const out = [];
573
+ let cur = "", inQ = false;
574
+ for (let i = 0; i < line.length; i += 1) {
575
+ const ch = line[i];
576
+ if (inQ) {
577
+ if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i += 1; } else inQ = false; }
578
+ else cur += ch;
579
+ } else if (ch === '"') { inQ = true; }
580
+ else if (ch === "," || ch === "\t") { out.push(cur); cur = ""; }
581
+ else cur += ch;
529
582
  }
530
- rows.push(`<tr>${cells.join("")}</tr>`);
531
- rendered += 1;
583
+ out.push(cur);
584
+ return out;
585
+ };
586
+ for (const line of String(text || "").split(/\r?\n/)) {
587
+ if (rows.length >= limit) break;
588
+ if (line.trim() === "") continue;
589
+ rows.push(parse(line));
532
590
  }
533
- const header = rows.length > 0 ? `<thead><tr>${rows[0]}</tr></thead>` : "";
534
- const body = rows.length > 1 ? `<tbody>${rows.slice(1).join("")}</tbody>` : "";
535
- const css = "<style>table{border-collapse:collapse;width:100%;font-size:12px}td{border:1px solid #d0d0d0;padding:4px 8px;white-space:pre-wrap;word-break:break-all}</style>";
591
+ if (rows.length === 0) return "<p>(空)</p>";
592
+ const header = `<thead><tr>${rows[0].map((c) => `<th>${escHtml(c)}</th>`).join("")}</tr></thead>`;
593
+ const body = rows.length > 1 ? `<tbody>${rows.slice(1).map((r) => `<tr>${r.map((c) => `<td>${escHtml(c)}</td>`).join("")}</tr>`).join("")}</tbody>` : "";
594
+ 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>";
536
595
  return `${css}<table>${header}${body}</table>`;
537
596
  } catch {
538
597
  return null;
@@ -603,6 +662,7 @@ async function officePreviewHtml(name, buffer) {
603
662
  if (ext === ".docx") return docxHtml(buffer);
604
663
  if (ext === ".xlsx") return xlsxHtml(buffer);
605
664
  if (ext === ".pptx") return pptxHtml(buffer);
665
+ if (ext === ".csv" || ext === ".tsv") return csvHtml(buffer.toString("utf8"));
606
666
  return null;
607
667
  }
608
668
 
@@ -819,9 +879,11 @@ export function createHandlers(options = {}) {
819
879
  const buffer = await readFile(full);
820
880
  const binary = buffer.subarray(0, 8192).includes(0);
821
881
  const truncated = buffer.length > PREVIEW_LIMIT;
822
- const officeHtml = binary && OFFICE_EXTS.has(extname(name).toLowerCase())
882
+ // Office 二进制(docx/xlsx/pptx)或 CSV/TSV 文本 → 表格 HTML 预览
883
+ const tableHtml = (binary && OFFICE_EXTS.has(extname(name).toLowerCase())) || /\.(csv|tsv)$/i.test(name)
823
884
  ? await officePreviewHtml(name, buffer)
824
885
  : undefined;
886
+ const officeHtml = tableHtml;
825
887
  // Markdown 返回内联样式后的完整 HTML 片段(含 MD_CSS),供前端 srcDoc 渲染真实效果,
826
888
  // 避免内嵌带独立头部的 workspace-preview 页面导致按钮重复、放大失效。
827
889
  const mdHtml = !binary && /\.(md|markdown)$/i.test(name)
@@ -1270,7 +1332,229 @@ window.addEventListener('resize',function(){ try{ if(fitMode) zoomFit(); else ap
1270
1332
  }
1271
1333
  };
1272
1334
 
1273
- return { root, maxFileBytes, totalMaxBytes, api, download, preview, workspaceList, workspaceFile, workspacePreview, workspaceBrowse, workspaceDelete, workspaceRename, workspaceSave, docxPreviewPage, docxPreviewAsset, pptxPreviewPage };
1335
+ // ---- xlsx 真实预览(浏览器端渲染,所见即所得)----
1336
+ // 返回一个自包含 HTML:引 SheetJS(xlsx.full.min.js) 解析 .xlsx,转换成 x-spreadsheet
1337
+ // 的 data 结构,用 x-spreadsheet 渲染成真实电子表格网格(行列表头/合并/冻结/缩放)。
1338
+ // 仅供参考;纯浏览器端渲染,不依赖 NAS 端转换,效果与 Excel/浏览器一致。
1339
+ const xlsxPreviewPage = async (req, res) => {
1340
+ try {
1341
+ requireTrusted(req);
1342
+ if (req.method !== "GET" && req.method !== "HEAD") {
1343
+ methodNotAllowed(res, ["GET", "HEAD"]);
1344
+ return;
1345
+ }
1346
+ const rel = (() => {
1347
+ try {
1348
+ return decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("path") || "");
1349
+ } catch {
1350
+ return "";
1351
+ }
1352
+ })();
1353
+ // 只允许 .xlsx(避免把别的文件喂给 SheetJS/x-spreadsheet)
1354
+ if (!/\.xlsx$/i.test(rel)) throw new HttpError(400, "only .xlsx supported");
1355
+ const full = safeResolve(workspaceRoot, rel);
1356
+ if (full === undefined) throw new HttpError(400, "invalid path");
1357
+ const info = await stat(full);
1358
+ if (!info.isFile()) throw new HttpError(400, "not a regular file");
1359
+ const name = rel.split("/").pop() || "file";
1360
+ const downloadHref = `workspace-file?path=${encodeURIComponent(rel)}&download=1`;
1361
+ const inlineHref = `workspace-file?path=${encodeURIComponent(rel)}&inline=1`;
1362
+ const assetBase = "/api/dsh-uploads/xlsx-preview-asset";
1363
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1364
+ res.end(`<!DOCTYPE html>
1365
+ <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
1366
+ <title>预览 - ${escapeHtml(name)}</title>
1367
+ <style>
1368
+ html,body{margin:0;height:100%;background:#1a2530;color:#e5e7eb;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
1369
+ .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}
1370
+ .bar strong{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#e5e7eb;font-size:13px}
1371
+ .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}
1372
+ .tabs{display:flex;gap:6px;padding:8px 14px;background:#141f2b;border-bottom:1px solid #2c3a47;overflow:auto}
1373
+ .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}
1374
+ .tabs button.on{background:#2563eb;color:#fff;border-color:#2563eb}
1375
+ // 静态图:整表一次性画到 canvas。拖动=移动/滚动一张画好的图,不重绘;放大用 CSS width/height 变化(与 ppt 预览一致)。
1376
+ #stage{position:relative;overflow:auto;height:calc(100vh - 48px);background:#e9ebee;padding:18px}
1377
+ #wrap{display:block;width:max-content;margin:0 auto;background:#fff;box-shadow:0 2px 14px rgba(0,0,0,.18)}
1378
+ canvas{display:block}
1379
+ #msg{color:#9ca3af;padding:24px;text-align:center;font-size:14px;font-family:inherit}
1380
+ </style>
1381
+ </head><body>
1382
+ <div class="bar">
1383
+ <strong>${escapeHtml(name)}</strong>
1384
+ <span style="display:flex;gap:4px;align-items:center">
1385
+ <button type="button" title="缩小" onclick="zoomBy(0.8)">−</button>
1386
+ <button type="button" title="放大" onclick="zoomBy(1.25)">+</button>
1387
+ <button type="button" title="适合宽度" onclick="zoomFit()">适合</button>
1388
+ </span>
1389
+ <a href="${downloadHref}" download>下载</a>
1390
+ <button type="button" onclick="closePreview()">✕ 关闭</button>
1391
+ </div>
1392
+ <div class="tabs" id="tabs"></div>
1393
+ <div id="stage"><div id="wrap"><canvas id="canvas"></canvas><div id="msg">加载中…</div></div></div>
1394
+ <script src="${assetBase}?f=xlsx.full.min.js"></script>
1395
+ <script>
1396
+ function closePreview(){
1397
+ try{ if(window.self!==window.top&&window.parent){ window.parent.postMessage({type:'dsh-close-preview'},location.origin); return; } }catch(e){ /* 跨域忽略 */ }
1398
+ window.close();
1399
+ }
1400
+ const stage=document.getElementById('stage');
1401
+ const canvas=document.getElementById('canvas');
1402
+ const wrap=document.getElementById('wrap');
1403
+ const msg=document.getElementById('msg');
1404
+ const tabsEl=document.getElementById('tabs');
1405
+ const dpr=Math.min(window.devicePixelRatio||1, 2);
1406
+ const HDR=26, IDXW=48, ROWH=26;
1407
+ const FONT='13px -apple-system,"PingFang SC","Microsoft YaHei",sans-serif';
1408
+ const FONTB='bold 13px -apple-system,"PingFang SC","Microsoft YaHei",sans-serif';
1409
+ var zoom=1, fitMode=true, natW=800, natH=600, wb=null, sheetIdx=0;
1410
+ function fmt(v){
1411
+ if(v===null||v===undefined) return '';
1412
+ if(v instanceof Date){ const p=(n)=>String(n).padStart(2,'0'); return v.getFullYear()+'-'+p(v.getMonth()+1)+'-'+p(v.getDate()); }
1413
+ return String(v);
1414
+ }
1415
+ 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; }
1416
+ function applyZoom(){
1417
+ canvas.style.width=Math.round(natW*zoom)+'px';
1418
+ canvas.style.height=Math.round(natH*zoom)+'px';
1419
+ }
1420
+ function zoomFit(){
1421
+ zoom=Math.min(1,((stage.clientWidth-36)||600)/natW); fitMode=true; applyZoom();
1422
+ }
1423
+ function zoom100(){ zoom=1; fitMode=false; applyZoom(); }
1424
+ function zoomBy(f){ zoom=Math.min(8,Math.max(0.05,zoom*f)); fitMode=false; applyZoom(); }
1425
+ function renderTabs(){
1426
+ if(!wb) return;
1427
+ tabsEl.innerHTML='';
1428
+ wb.SheetNames.forEach(function(nm,i){
1429
+ var b=document.createElement('button'); b.textContent=nm;
1430
+ if(i===sheetIdx) b.className='on';
1431
+ b.onclick=function(){ sheetIdx=i; renderSheet(); };
1432
+ tabsEl.appendChild(b);
1433
+ });
1434
+ }
1435
+ function renderSheet(){
1436
+ msg.style.display='';
1437
+ var ws=wb.Sheets[wb.SheetNames[sheetIdx]];
1438
+ var range=ws['!ref']?XLSX.utils.decode_range(ws['!ref']):{s:{r:0,c:0},e:{r:0,c:0}};
1439
+ var mc=document.createElement('canvas').getContext('2d'); mc.font=FONT;
1440
+ var texts={}, maxCols={};
1441
+ for(var r=range.s.r;r<=range.e.r;r++){
1442
+ for(var c=range.s.c;c<=range.e.c;c++){
1443
+ var cell=ws[XLSX.utils.encode_cell({r:r,c:c})];
1444
+ var t='';
1445
+ 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); }
1446
+ texts[r+'_'+c]=t;
1447
+ if(t){ var w=mc.measureText(t).width+18; if(!maxCols[c]||w>maxCols[c])maxCols[c]=w; }
1448
+ }
1449
+ }
1450
+ var colW={};
1451
+ for(var c=range.s.c;c<=range.e.c;c++){ colW[c]=Math.max(64,Math.min(380,maxCols[c]||120)); }
1452
+ var colX={}, acc=IDXW;
1453
+ for(var c=range.s.c;c<=range.e.c;c++){ colX[c]=acc; acc+=colW[c]; }
1454
+ var rowY={}, accy=HDR;
1455
+ for(var r=range.s.r;r<=range.e.r;r++){ rowY[r]=accy; accy+=ROWH; }
1456
+ // 合并单元格:记录左上角跨的格数,其余标记为 covered(不画独立边框/文字)
1457
+ var mergeMap={}, covered={};
1458
+ (ws['!merges']||[]).forEach(function(m){
1459
+ mergeMap[m.s.r+'_'+m.s.c]={rs:m.e.r-m.s.r+1, cs:m.e.c-m.s.c+1};
1460
+ 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; }
1461
+ });
1462
+ var totalW=acc, totalH=accy; natW=totalW; natH=totalH;
1463
+ var w=Math.round(totalW*dpr), h=Math.round(totalH*dpr);
1464
+ var maxDim=16000;
1465
+ 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); }
1466
+ canvas.width=w; canvas.height=h;
1467
+ var ctx=canvas.getContext('2d');
1468
+ ctx.setTransform(w/Math.max(1,totalW),0,0,h/Math.max(1,totalH),0,0);
1469
+ ctx.textBaseline='middle';
1470
+ ctx.fillStyle='#ffffff'; ctx.fillRect(0,0,totalW,totalH);
1471
+ // 左上角
1472
+ ctx.fillStyle='#e9ebee'; ctx.fillRect(0,0,IDXW,HDR);
1473
+ // 列标头
1474
+ ctx.font=FONTB; ctx.fillStyle='#374151';
1475
+ for(var c=range.s.c;c<=range.e.c;c++){
1476
+ ctx.fillStyle='#f3f4f7'; ctx.fillRect(colX[c],0,colW[c],HDR);
1477
+ ctx.strokeStyle='#dfe3ea'; ctx.beginPath(); ctx.moveTo(colX[c],0); ctx.lineTo(colX[c],HDR); ctx.stroke();
1478
+ ctx.beginPath(); ctx.moveTo(colX[c],HDR); ctx.lineTo(colX[c]+colW[c],HDR); ctx.stroke();
1479
+ var lx=colX[c]+colW[c]/2;
1480
+ ctx.fillStyle='#374151'; ctx.fillText(colLetter(c),lx,HDR/2+0.5);
1481
+ }
1482
+ // 行标头
1483
+ for(var r=range.s.r;r<=range.e.r;r++){
1484
+ ctx.fillStyle='#f3f4f7'; ctx.fillRect(0,rowY[r],IDXW,ROWH);
1485
+ ctx.strokeStyle='#dfe3ea'; ctx.beginPath(); ctx.moveTo(IDXW,rowY[r]); ctx.lineTo(IDXW,rowY[r]+ROWH); ctx.stroke();
1486
+ ctx.beginPath(); ctx.moveTo(0,rowY[r]+ROWH); ctx.lineTo(IDXW,rowY[r]+ROWH); ctx.stroke();
1487
+ ctx.fillStyle='#374151'; ctx.fillText(String(r+1),IDXW/2,rowY[r]+ROWH/2+0.5);
1488
+ }
1489
+ // 单元格
1490
+ ctx.font=FONT;
1491
+ for(var r=range.s.r;r<=range.e.r;r++){
1492
+ for(var c=range.s.c;c<=range.e.c;c++){
1493
+ var key=r+'_'+c;
1494
+ if(covered[key]) continue;
1495
+ var x=colX[c], y=rowY[r], ww=colW[c], hh=ROWH;
1496
+ var mk=mergeMap[key];
1497
+ if(mk){ for(var i=1;i<mk.cs;i++)ww+=colW[c+i]; for(var j=1;j<mk.rs;j++)hh+=ROWH; }
1498
+ ctx.fillStyle='#ffffff'; ctx.fillRect(x,y,ww,hh);
1499
+ ctx.strokeStyle='#e2e5ea'; ctx.strokeRect(x+0.5,y+0.5,ww-1,hh-1);
1500
+ var t=texts[key];
1501
+ if(t){
1502
+ var isNum=(ws[XLSX.utils.encode_cell({r:r,c:c})]||{}).t==='n';
1503
+ ctx.fillStyle='#1f2937';
1504
+ var xoff=isNum?(x+ww-6):(x+6);
1505
+ ctx.textAlign=isNum?'right':'left';
1506
+ ctx.fillText(t,xoff,y+hh/2+0.5);
1507
+ ctx.textAlign='left';
1508
+ }
1509
+ }
1510
+ }
1511
+ msg.style.display='none';
1512
+ renderTabs();
1513
+ zoomFit();
1514
+ }
1515
+ (async()=>{
1516
+ try{
1517
+ const r=await fetch(${JSON.stringify(inlineHref)},{cache:'no-store'});
1518
+ if(!r.ok) throw new Error('HTTP '+r.status);
1519
+ const buf=await r.arrayBuffer();
1520
+ wb=XLSX.read(new Uint8Array(buf),{type:'array',cellDates:true});
1521
+ renderSheet();
1522
+ }catch(e){ msg.style.display=''; msg.textContent='渲染失败:'+((e&&e.message)||e); }
1523
+ })();
1524
+ window.addEventListener('resize',function(){ try{ if(fitMode) zoomFit(); else applyZoom(); }catch(e){} });
1525
+ </script>
1526
+
1527
+ </body></html>`);
1528
+ } catch (error) {
1529
+ sendError(res, error, onError);
1530
+ }
1531
+ };
1532
+
1533
+ // serve SheetJS 前端库(从 PACKAGE_DIR/client/vendor 读)
1534
+ const xlsxPreviewAsset = async (req, res) => {
1535
+ try {
1536
+ requireTrusted(req);
1537
+ if (req.method !== "GET" && req.method !== "HEAD") {
1538
+ methodNotAllowed(res, ["GET", "HEAD"]);
1539
+ return;
1540
+ }
1541
+ const f = decodeURIComponent(new URL(req.url || "/", "http://dsh.internal").searchParams.get("f") || "");
1542
+ // 只允许 xlsx 预览页用到的 SheetJS 解析库(浏览器端把 .xlsx 转成静态表格图)。
1543
+ if (!["xlsx.full.min.js"].includes(f)) throw new HttpError(400, "unknown asset");
1544
+ const target = join(VENDOR_DIR, f);
1545
+ const buf = await readFile(target);
1546
+ res.writeHead(200, {
1547
+ "content-type": "text/javascript; charset=utf-8",
1548
+ "cache-control": "no-store",
1549
+ "x-content-type-options": "nosniff",
1550
+ });
1551
+ res.end(buf);
1552
+ } catch (error) {
1553
+ sendError(res, error, onError);
1554
+ }
1555
+ };
1556
+
1557
+ return { root, maxFileBytes, totalMaxBytes, api, download, preview, workspaceList, workspaceFile, workspacePreview, workspaceBrowse, workspaceDelete, workspaceRename, workspaceSave, docxPreviewPage, docxPreviewAsset, pptxPreviewPage, xlsxPreviewPage, xlsxPreviewAsset };
1274
1558
  }
1275
1559
 
1276
1560
  /** 人类可读文件大小。 */
@@ -1324,21 +1608,23 @@ function workspaceBrowseHtml(groups, ws = "", all = false) {
1324
1608
  const isPdf = /\.pdf$/i.test(f.name);
1325
1609
  const isDocx = /\.docx$/i.test(f.name);
1326
1610
  const isPptx = /\.pptx$/i.test(f.name);
1611
+ const isXlsx = /\.xlsx$/i.test(f.name);
1327
1612
  // PDF 直接嵌原始流(单层 iframe,浏览器原生查看器可滚动翻页);
1328
- // docx 走 docx-preview、pptx 走 PptxViewJS 真实渲染页(浏览器端解析,所见即所得);
1613
+ // docx 走 docx-preview、pptx 走 PptxViewJS、xlsx 走 x-spreadsheet 真实渲染页
1614
+ // (浏览器端解析,所见即所得);
1329
1615
  // 其它可预览类型走渲染页;不可预览 → 下载。
1330
1616
  const viewHref = previewable
1331
- ? (isPdf ? `workspace-file?path=${rel}&inline=1` : isDocx ? `docx-preview?path=${rel}` : isPptx ? `pptx-preview?path=${rel}` : `workspace-preview?path=${rel}&from=list`)
1617
+ ? (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`)
1332
1618
  : `workspace-file?path=${rel}&download=1`;
1333
1619
  return `<tr data-ts="${Math.floor(f.mtime)}" data-name="${escapeHtml(f.name)}" data-size="${f.size}">
1334
1620
  <td class="name"><a class="flink" href="${viewHref}" data-preview="${escapeHtml(viewHref)}" title="${escapeHtml(f.path)}">${escapeHtml(f.name)}</a></td>
1335
1621
  <td class="size">${humanSize(f.size)}</td>
1336
1622
  <td class="time">${humanTime(f.mtime)}</td>
1337
1623
  <td class="acts">
1338
- ${previewable ? `<a class="tag" href="${viewHref}" data-preview="${escapeHtml(viewHref)}">${ICON_EYE} 预览</a>` : `<a class="tag" href="${viewHref}">${ICON_DL} 下载</a>`}
1624
+ <button type="button" class="tag plain" data-copypath="${escapeHtml(f.path)}">复制路径</button>
1625
+ <button type="button" class="tag plain" data-rename="${escapeHtml(f.path)}">重命名</button>
1339
1626
  <a class="tag dl" href="workspace-file?path=${rel}&download=1">${ICON_DL} 下载</a>
1340
- <button type="button" class="tag" data-rename="${escapeHtml(f.path)}">重命名</button>
1341
- <button type="button" class="tag" data-copypath="${escapeHtml(f.path)}">复制路径</button>
1627
+ ${previewable ? `<a class="tag" href="${viewHref}" data-preview="${escapeHtml(viewHref)}">${ICON_EYE} 预览</a>` : ""}
1342
1628
  </td>
1343
1629
  </tr>`;
1344
1630
  }).join("");
@@ -1441,6 +1727,9 @@ function workspaceBrowseHtml(groups, ws = "", all = false) {
1441
1727
  .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); }
1442
1728
  .tag:hover { background:var(--lp-tag-hover); }
1443
1729
  .tag.dl { color:var(--lp-ok); }
1730
+ /* 重命名/复制路径:不设强调色,做白色/常规文字按钮 */
1731
+ .tag.plain { background:transparent; color:var(--lp-fg); border:1px solid var(--lp-border); }
1732
+ .tag.plain:hover { background:var(--lp-hover); }
1444
1733
  .empty { color:var(--lp-meta); text-align:center; padding:60px 0; }
1445
1734
  .ic { width:14px; height:14px; flex:none; }
1446
1735
  @media (max-width: 640px) {
@@ -2152,6 +2441,18 @@ export async function apply(ctx, config = {}) {
2152
2441
  handler: handlers.pptxPreviewPage,
2153
2442
  }), "dsh-long-plugins: pptx real-preview route");
2154
2443
 
2444
+ ctx.effect(() => ctx.webServer.register({
2445
+ kind: "exact",
2446
+ path: "/api/dsh-uploads/xlsx-preview",
2447
+ handler: handlers.xlsxPreviewPage,
2448
+ }), "dsh-long-plugins: xlsx real-preview route");
2449
+
2450
+ ctx.effect(() => ctx.webServer.register({
2451
+ kind: "exact",
2452
+ path: "/api/dsh-uploads/xlsx-preview-asset",
2453
+ handler: handlers.xlsxPreviewAsset,
2454
+ }), "dsh-long-plugins: xlsx-preview vendor assets");
2455
+
2155
2456
  ctx.effect(() => ctx.webServer.register({
2156
2457
  kind: "exact",
2157
2458
  path: "/api/dsh-uploads/workspace-browse",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-long-plugins",
3
- "version": "1.3.9",
3
+ "version": "1.3.10",
4
4
  "description": "Merged DSH web plugins: upload manager (drag-and-drop attach any file to the message), workspace output files, skill docs, account balance, Markdown-to-Word (md2docx), and polished file preview (Word & PowerPoint real preview with zoom/pan, rendered Markdown), plus an auto-repair install for DSH core patches (reverse-proxy WebSocket heartbeat, upgrade relink). | 一个插件整合 DSH Web 的常用增强:上传管理(拖放上传:拖任意文件到会话框直接附加)、工作区「输出文件」面板、技能文档浏览、账户余额显示、Markdown 转 Word(md2docx)、Word/PowerPoint 真实预览(可缩放、翻页);并自带修复安装,自动重连 DSH 核心补丁(反代 WebSocket 心跳、升级重连)。",
5
5
  "type": "module",
6
6
  "license": "MIT",