@numa-tech/numa 1.18.0 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -10
- package/THIRD_PARTY_NOTICES.md +50 -0
- package/dist/oss/commands.d.ts +2 -1
- package/dist/oss/commands.js +44 -23
- package/dist/oss/commands.js.map +1 -1
- package/dist/oss/file-operations.d.ts +57 -0
- package/dist/oss/file-operations.js +269 -0
- package/dist/oss/file-operations.js.map +1 -0
- package/dist/oss/local-picker.d.ts +25 -0
- package/dist/oss/local-picker.js +251 -0
- package/dist/oss/local-picker.js.map +1 -0
- package/dist/oss/transfer-paths.d.ts +16 -0
- package/dist/oss/transfer-paths.js +131 -0
- package/dist/oss/transfer-paths.js.map +1 -0
- package/dist/oss/transfer-queue.d.ts +62 -0
- package/dist/oss/transfer-queue.js +349 -0
- package/dist/oss/transfer-queue.js.map +1 -0
- package/dist/oss/transfer-types.d.ts +54 -0
- package/dist/oss/transfer-types.js +2 -0
- package/dist/oss/transfer-types.js.map +1 -0
- package/dist/oss/transfer-worker.d.ts +3 -0
- package/dist/oss/transfer-worker.js +415 -0
- package/dist/oss/transfer-worker.js.map +1 -0
- package/dist/oss/transfers.d.ts +23 -0
- package/dist/oss/transfers.js +111 -0
- package/dist/oss/transfers.js.map +1 -1
- package/dist/oss/web-icons.d.ts +3 -0
- package/dist/oss/web-icons.js +4 -0
- package/dist/oss/web-icons.js.map +1 -0
- package/dist/oss/web-page.js +510 -65
- package/dist/oss/web-page.js.map +1 -1
- package/dist/oss/web-service.d.ts +45 -0
- package/dist/oss/web-service.js +107 -0
- package/dist/oss/web-service.js.map +1 -0
- package/dist/oss/web.d.ts +16 -0
- package/dist/oss/web.js +320 -79
- package/dist/oss/web.js.map +1 -1
- package/package.json +5 -5
package/dist/oss/web-page.js
CHANGED
|
@@ -1,24 +1,81 @@
|
|
|
1
|
+
import { OSS_REFRESH_ICON, OSS_COPY_ICON } from "./web-icons.js";
|
|
1
2
|
/** Self-contained local assets: no CDN, remote fonts, analytics or credential-bearing markup. */
|
|
2
3
|
export const OSS_WEB_HTML = String.raw `<!doctype html>
|
|
3
4
|
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
4
5
|
<title>Numa · OSS 文件</title><link rel="icon" href="data:,"><link rel="stylesheet" href="/app.css"><script src="/app.js" defer></script></head>
|
|
5
|
-
<body><header class="topbar"><a class="brand" href="/">numa<span> / OSS</span></a><span class="local"><i></i
|
|
6
|
-
<div class="authorization"><div class="grant-field"><label for="grant">当前授权</label><select id="grant" aria-label="选择 OSS Grant"><option>正在读取授权…</option></select><button id="grants-refresh" class="quiet icon-button" title="刷新授权列表" aria-label="刷新授权列表"
|
|
6
|
+
<body><header class="topbar"><a class="brand" href="/">numa<span> / OSS</span></a><span class="local"><i></i><span id="service-status">本机连接</span></span><span class="topnote">文件工作区</span><button id="tasks-toggle" aria-controls="tasks-panel" aria-expanded="false">传输任务 <span id="tasks-count">0</span></button><button id="service-stop" title="暂停任务并停止本机 CLI 服务">停止服务</button></header>
|
|
7
|
+
<div class="authorization"><div class="grant-field"><label for="grant">当前授权</label><select id="grant" aria-label="选择 OSS Grant"><option>正在读取授权…</option></select><button id="grants-refresh" class="quiet icon-button" title="刷新授权列表" aria-label="刷新授权列表">${OSS_REFRESH_ICON}</button></div>
|
|
7
8
|
<div class="session-controls"><div class="expiry"><span>STS 剩余时间</span><strong id="countdown" aria-live="off">--:--</strong></div><label class="check"><input id="auto-renew" type="checkbox" checked>自动续期</label><button id="renew">立即续期</button></div></div>
|
|
8
9
|
<div id="notice" class="notice" role="status" hidden></div>
|
|
9
|
-
<div class="workspace"><aside class="sidebar"><div class="eyebrow">存储位置</div><button id="root" class="root-button"><span class="folder-icon"></span><span id="bucket">正在连接</span></button><div class="scope-label">授权目录</div><div id="scope" class="scope">—</div><div class="sidebar-bottom"><span id="permission">正在读取权限</span><p>文件操作仅限当前授权范围。<br>关闭 CLI 将断开本机连接。</p></div></aside>
|
|
10
|
-
<main class="files"><div class="page-heading"><div><div class="eyebrow">OBJECT STORAGE</div><h1>文件</h1></div><
|
|
11
|
-
<div class="location"><button id="up" class="quiet" title="返回上级目录" aria-label="返回上级目录">↑</button><nav id="breadcrumbs" aria-label="文件路径"></nav><button id="refresh" class="quiet"
|
|
10
|
+
<div id="workspace" class="workspace"><aside class="sidebar"><div class="sidebar-heading"><h2 class="eyebrow">存储位置</h2><button id="sidebar-toggle" class="quiet panel-toggle" aria-label="折叠存储位置" title="折叠存储位置" aria-expanded="true" aria-controls="sidebar-content">‹</button></div><div id="sidebar-content" class="sidebar-content"><button id="root" class="root-button"><span class="folder-icon"></span><span id="bucket">正在连接</span></button><div class="scope-label">授权目录</div><div id="scope" class="scope">—</div><div class="sidebar-bottom"><span id="permission">正在读取权限</span><p>文件操作仅限当前授权范围。<br>关闭 CLI 将断开本机连接。</p></div></div></aside>
|
|
11
|
+
<main class="files"><div class="page-heading"><div><div class="eyebrow">OBJECT STORAGE</div><h1>文件</h1></div><div class="upload-actions"><button id="mkdir" disabled>创建目录…</button><button id="upload-folder" disabled>上传文件夹</button><button id="upload" class="primary" disabled>+ 上传文件</button></div></div>
|
|
12
|
+
<div class="location"><button id="up" class="quiet" title="返回上级目录" aria-label="返回上级目录">↑</button><nav id="breadcrumbs" aria-label="文件路径"></nav><button id="refresh" class="quiet icon-button" title="刷新文件列表" aria-label="刷新文件列表">${OSS_REFRESH_ICON}</button></div>
|
|
12
13
|
<div class="toolbar"><input id="search" type="search" placeholder="筛选已加载的文件与目录…" aria-label="筛选已加载的文件与目录"><span id="selection-count"></span><button id="download-selected" disabled>下载所选</button><button id="delete-selected" class="danger" disabled>删除所选</button></div>
|
|
13
|
-
<div class="table-wrap"><table><thead><tr><th class="check-cell"><input id="select-all" type="checkbox" aria-label="
|
|
14
|
+
<div class="table-wrap"><table><thead><tr><th class="check-cell"><input id="select-all" type="checkbox" aria-label="选择已显示的文件与目录"></th><th>名称</th><th class="size-cell">大小</th><th class="date-cell">修改时间</th></tr></thead><tbody id="rows"></tbody></table><div id="empty" class="empty">正在连接当前授权…</div></div>
|
|
14
15
|
<footer class="list-footer"><span id="list-summary">—</span><button id="more" hidden>加载更多</button></footer>
|
|
15
|
-
|
|
16
|
-
</main><aside class="preview-panel"><div class="preview-heading"><h2>文件预览</h2><button id="preview-close" class="quiet" aria-label="
|
|
17
|
-
<dialog id="delete-dialog"><h2
|
|
16
|
+
|
|
17
|
+
</main><aside class="preview-panel"><div class="preview-heading"><h2>文件预览</h2><button id="preview-close" class="quiet panel-toggle" aria-label="折叠文件预览" title="折叠文件预览" aria-expanded="true" aria-controls="preview-content">›</button></div><div id="preview-content" class="preview-content"><div id="preview" class="preview-body"><div class="preview-empty">选择一个文件<br><small>查看内容与文件信息</small></div></div><div class="preview-actions"><button id="download" disabled>下载文件</button><button id="delete" class="danger" disabled>删除文件</button></div></div></aside></div>
|
|
18
|
+
<dialog id="delete-dialog"><h2>删除所选项目?</h2><p id="delete-count"></p><p>文件夹包含全部子文件。确认后分页扫描并删除,不预先统计总数;已删除内容不能在此页面撤销。执行期间请勿向目标目录写入新文件。</p><ul id="delete-list"></ul><p id="delete-target" class="scope"></p><div class="dialog-actions"><button id="delete-cancel">取消</button><button id="delete-confirm" class="destructive">确认删除</button></div></dialog>
|
|
19
|
+
<section id="tasks-panel" class="tasks-panel" aria-labelledby="tasks-heading" hidden><div class="tasks-heading"><h2 id="tasks-heading">传输任务</h2><button id="tasks-close" class="quiet" aria-label="关闭传输任务">×</button></div><p class="tasks-note">由本机 CLI 持续传输,关闭浏览器后仍会继续。CLI 重启后,未完成任务会暂停,需手动恢复。</p><p id="tasks-connection" role="status" hidden></p><div id="tasks-list" class="tasks-list"></div><p id="tasks-empty" class="tasks-note">暂无任务。选择文件或文件夹开始传输。</p></section>
|
|
20
|
+
<dialog id="stop-dialog"><h2>停止本机服务?</h2><p>上传下载任务将暂停并保存断点;删除、复制、移动等文件操作将停止,已完成部分保留且不会自动续做。随后关闭 CLI 服务。所有已打开的控制台都会断开连接。重新运行 numa oss web 后可继续任务。</p><div class="dialog-actions"><button id="stop-cancel">继续运行</button><button id="stop-confirm" class="destructive">停止服务</button></div></dialog>
|
|
21
|
+
<div id="permission-tip" class="permission-tip" role="tooltip" hidden></div>
|
|
22
|
+
<div id="file-menu" class="file-menu" role="menu" aria-label="文件操作" hidden></div>
|
|
23
|
+
<dialog id="operation-dialog"><h2 id="operation-title"></h2><p id="operation-source"></p><section id="destination-picker" hidden><p id="destination-hint">点选目标目录,也可以在下方直接输入路径。</p><div id="destination-tree" class="destination-tree" role="tree" aria-label="目标目录"></div></section><label for="operation-value" id="operation-label"></label><input id="operation-value" type="text" autocomplete="off"><p id="operation-help"></p><p id="operation-error" role="alert"></p><div class="dialog-actions"><button id="operation-cancel">取消</button><button id="operation-confirm" class="primary">确认</button></div></dialog>
|
|
24
|
+
<section id="operations-panel" class="operations-panel" hidden aria-label="文件操作进度"><div id="operations-list"></div><p>关闭浏览器后继续执行。停止服务会中断文件操作;已完成部分保留,重启后不会自动续做。</p></section>
|
|
25
|
+
<template id="copy-icon">${OSS_COPY_ICON}</template>
|
|
18
26
|
</body></html>`;
|
|
19
27
|
export const OSS_WEB_CSS = String.raw `
|
|
28
|
+
.file-menu{position:fixed;z-index:20;min-width:175px;padding:5px;background:white;border:1px solid #d4ded6;border-radius:8px;box-shadow:0 8px 28px #20372e26}.file-menu button{display:block;width:100%;border:0;text-align:left;padding:9px 12px}.file-menu button:focus{background:#eff5f1}.file-menu button[aria-disabled=true]{color:#99a49e}.permission-tip{position:fixed;z-index:30;max-width:min(330px,calc(100vw - 16px));padding:10px 12px;background:#263c31;color:#fff;border-radius:6px;box-shadow:0 6px 24px #20372e26;font-size:12px;line-height:1.7;overflow-wrap:anywhere}
|
|
29
|
+
#operation-dialog{max-height:90vh;overflow:auto;width:min(520px,90vw)}
|
|
30
|
+
#destination-picker{margin-top:16px}#destination-hint{font-size:12px;margin-bottom:8px}.destination-tree{max-height:260px;overflow:auto;border:1px solid #dbe3de;border-radius:6px;padding:6px;background:#fafcfb}.destination-children{padding-left:18px}.destination-line{display:flex;align-items:center;gap:4px;min-height:34px;border-radius:4px}.destination-line button{border:0;background:transparent;padding:6px;color:#344b41}.destination-toggle{width:28px;flex-shrink:0}.destination-name{display:flex;align-items:center;gap:8px;flex:1;min-width:0;text-align:left;white-space:normal;overflow-wrap:anywhere}.destination-name .folder-icon{flex-shrink:0}.destination-item[aria-selected=true]>.destination-line{background:#dfede3}.destination-item:focus{outline:none}.destination-item:focus-visible>.destination-line{outline:2px solid #16805a;outline-offset:-2px}.destination-status{font-size:11px;color:#7b8d81;margin-left:32px}.destination-more{font-size:12px;margin:4px 0 4px 32px}.destination-editor{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 6px 32px}.destination-editor input{width:100%;min-width:0;padding:7px;border:1px solid #cbd9cf;border-radius:4px;background:white;color:#23322e}.destination-editor button{font-size:12px;padding:6px 9px}
|
|
31
|
+
#operation-dialog label{display:block;font-size:12px;margin:16px 0 8px}#operation-value{width:100%;border:1px solid #cbd9cf;padding:10px;border-radius:5px;color:#23322e}#operation-source{overflow-wrap:anywhere}#operation-help{margin-top:10px}#operation-error{color:#a7473d}
|
|
32
|
+
.operations-panel{flex-shrink:0;background:#f0f6f2;border-top:1px solid #cbd9cf;max-height:24vh;overflow:auto;padding:10px 26px;font-size:12px}.operations-panel>p{font-size:11px;color:#74867b;margin-top:5px}.operation-row{display:flex;align-items:center;gap:12px;padding:5px 0}.operation-row span{flex:1;overflow-wrap:anywhere}.operation-row button{font-size:12px;padding:5px 10px}.upload-actions{flex-wrap:wrap;justify-content:flex-end}
|
|
33
|
+
|
|
20
34
|
.icon-button{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;min-width:34px;min-height:34px}
|
|
21
35
|
:root{font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;color:#23322e;background:#f5f7f5;font-size:14px;font-synthesis:none}*{box-sizing:border-box}body{margin:0}button,input,select{font:inherit}button,select{cursor:pointer}button{border:1px solid #d8e0db;background:white;color:#344b41;border-radius:6px;padding:8px 12px;white-space:nowrap}button:hover:not(:disabled){background:#eff5f1;border-color:#a5bcae}button:disabled{opacity:.4;cursor:not-allowed}input:focus-visible,button:focus-visible,select:focus-visible{outline:2px solid #16805a;outline-offset:2px}input[type=checkbox]{accent-color:#197854;width:15px;height:15px;cursor:pointer}input[type=search]{border:1px solid #dbe3de;border-radius:6px;padding:9px 12px;min-width:160px;background:#fafcfb;color:#23322e}select{border:0;background:transparent;color:#263d31;max-width:100%;padding:6px 24px 6px 0;font-weight:550}h1,h2,p{margin:0}h1{font-size:27px;letter-spacing:-.7px;font-weight:600}h2{font-size:15px;font-weight:600}.topbar{height:62px;padding:0 26px;background:#20372e;color:#ecf4ed;display:flex;align-items:center;gap:20px}.brand{font-size:24px;letter-spacing:-1px;font-weight:650;color:inherit;text-decoration:none}.brand span{font-size:16px;font-weight:400;letter-spacing:.5px;color:#a9c0b0;margin-left:12px}.local{font-size:11px;color:#cee0d5;display:flex;align-items:center;gap:6px}.local i{height:6px;width:6px;background:#8edaac;border-radius:50%}.topnote{margin-left:auto;color:#aac0b2;font-size:12px;letter-spacing:2px}.authorization{display:flex;align-items:center;justify-content:space-between;gap:24px;background:#fff;border-bottom:1px solid #dbe3de;padding:16px 26px}.grant-field{min-width:0;display:flex;align-items:center;gap:12px;flex:1}.grant-field label{font-size:12px;color:#7a8c80;white-space:nowrap}.session-controls{display:flex;align-items:center;gap:18px;flex-shrink:0}.expiry{display:flex;align-items:center;gap:10px;font-size:11px;color:#718076}.expiry strong{font-family:ui-monospace,SFMono-Regular,monospace;color:#2b674c;font-size:20px;font-weight:500;min-width:62px}.expiry strong.warning{color:#b45309}.expiry strong.expired{color:#bf403d}.check{display:flex;align-items:center;gap:5px;font-size:12px;white-space:nowrap}.quiet{background:transparent;border-color:transparent;color:#718577;padding:6px 8px}.primary{color:white;background:#227b54;border-color:#227b54}.primary:hover:not(:disabled){background:#196140;color:white}.danger{color:#9f4b40}.destructive{color:white;background:#a7473d;border-color:#a7473d}.notice{margin:0;padding:10px 26px;background:#fff4df;border-bottom:1px solid #ead9b8;color:#845422;font-size:13px;line-height:1.6}.notice.success{background:#e9f6ec;color:#266342;border-color:#c9e3d0}.workspace{display:grid;grid-template-columns:205px minmax(420px,1fr) 310px;min-height:calc(100vh - 137px)}.sidebar{border-right:1px solid #dbe3de;padding:30px 16px;display:flex;flex-direction:column;background:#f0f4f0;min-width:0}.eyebrow{font-size:10px;letter-spacing:1.7px;color:#89988d;font-weight:600}.sidebar .eyebrow{padding-left:10px}.root-button{display:flex;align-items:center;gap:10px;margin-top:18px;white-space:normal;text-align:left;overflow-wrap:anywhere;background:#e1ebe2;border:0;color:#24573d;font-size:13px}.folder-icon{display:inline-block;width:16px;height:11px;background:#9fbba1;border-radius:2px;position:relative;flex-shrink:0;margin-top:3px}.folder-icon:before{content:"";position:absolute;left:0;top:-3px;width:7px;height:4px;border-radius:2px 2px 0 0;background:inherit}.scope-label{font-size:11px;color:#86978a;margin:23px 10px 9px}.scope{font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;color:#637c6a;overflow-wrap:anywhere;line-height:1.7}.sidebar>.scope{padding:0 10px}.sidebar-bottom{margin-top:auto;padding:40px 10px 0;color:#607e68;font-size:12px}.sidebar-bottom p{margin-top:12px;line-height:1.8;color:#8b9b8e;font-size:11px}.files{background:white;min-width:0;padding:30px 28px 0;display:flex;flex-direction:column}.page-heading{display:flex;align-items:center;justify-content:space-between;margin-bottom:24px}.page-heading .eyebrow{margin-bottom:8px}.location{display:flex;align-items:center;gap:6px;border-top:1px solid #e8ede9;border-bottom:1px solid #e8ede9;padding:9px 0;min-width:0}.location nav{display:flex;align-items:center;flex-wrap:wrap;flex:1;gap:2px;min-width:0}.location nav button{padding:4px 7px;border:0;font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis}.location nav span{color:#c1ccc4}.toolbar{display:flex;gap:8px;align-items:center;margin:18px 0}.toolbar input[type=search]{flex:1;min-width:0}.toolbar button{font-size:12px;padding:7px 9px}#selection-count{font-size:11px;color:#819286;white-space:nowrap}.table-wrap{overflow-x:auto;min-height:290px}table{width:100%;border-collapse:collapse;table-layout:fixed}th{text-align:left;font-size:11px;color:#8a978e;font-weight:500;border-bottom:1px solid #e3e9e4;padding:11px 9px}td{padding:12px 9px;border-bottom:1px solid #f0f3f0;font-size:12px;color:#79897e}.check-cell{width:30px;padding-left:0}.size-cell{width:82px;text-align:right;font-variant-numeric:tabular-nums}.date-cell{width:145px;text-align:right}tr:hover td{background:#f7faf7}tr.active td{background:#edf5ee}td .entry{border:0;background:transparent;padding:0;display:flex;align-items:center;gap:10px;max-width:100%;color:#354e3e;font-size:13px;text-align:left}td .entry span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-icon{display:inline-block;width:13px;height:16px;border:1px solid #a8b8ad;background:#f5f8f5;border-radius:2px;flex-shrink:0}.empty{text-align:center;padding:65px 20px;color:#8b9b8f;line-height:1.9}.list-footer{border-top:1px solid #e9eee9;display:flex;align-items:center;justify-content:space-between;color:#92a095;font-size:11px;padding:13px 0;margin-top:auto;min-height:48px}.preview-panel{border-left:1px solid #dbe3de;display:flex;flex-direction:column;padding:29px 20px 20px;background:#f8faf7;min-width:0}.preview-heading{display:flex;justify-content:space-between;align-items:center}.preview-heading h2{font-size:13px;color:#61776a}.preview-body{padding-top:25px;flex:1;min-width:0}.preview-empty{text-align:center;color:#859889;padding:80px 0;line-height:2}.preview-empty small{color:#a0aca2;font-size:11px}.preview-body h3{font-size:15px;line-height:1.7;overflow-wrap:anywhere;font-weight:500;margin:0 0 18px}.preview-body dl{font-size:11px;display:grid;grid-template-columns:60px minmax(0,1fr);gap:10px;margin:20px 0;color:#91a094}.preview-body dd{margin:0;color:#607968;overflow-wrap:anywhere}.preview-body pre{font:11px/1.8 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;overflow-wrap:anywhere;max-height:50vh;overflow:auto;background:#edf2ec;padding:12px;border-radius:5px;color:#48654e}.preview-body img{max-width:100%;max-height:40vh;object-fit:contain;border-radius:4px}.preview-body p{font-size:12px;line-height:1.8;color:#8c9a8f}.preview-actions{display:flex;gap:8px;padding-top:20px}.preview-actions button{flex:1;font-size:12px}.transfer{border-top:1px solid #dce8df;padding:14px 0;font-size:12px}.transfer>div{display:flex;align-items:center;justify-content:space-between}.transfer progress{width:100%;height:5px;accent-color:#227b54;margin:10px 0}.transfer span{font-size:11px;color:#7b9082}dialog{border:1px solid #d4ded6;border-radius:10px;padding:26px;max-width:min(480px,90vw);color:#344c3c;box-shadow:0 20px 80px #182c2530}dialog::backdrop{background:#1c322747}dialog h2{font-size:19px;margin-bottom:15px}dialog p{font-size:13px;line-height:1.8;color:#879489}dialog ul{max-height:180px;overflow:auto;padding-left:20px;font-size:12px;line-height:1.9;overflow-wrap:anywhere}.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:24px}[hidden]{display:none!important}@media(min-width:1500px){.workspace{grid-template-columns:235px minmax(500px,1fr) 370px}.files{padding-left:38px;padding-right:38px}}@media(max-width:1100px){.workspace{grid-template-columns:160px minmax(360px,1fr) 260px}.files{padding:24px 18px 0}.authorization{flex-wrap:wrap;gap:10px}.grant-field{flex-basis:100%}.session-controls{margin-left:auto}.date-cell{width:110px}.toolbar{flex-wrap:wrap}.toolbar input[type=search]{flex-basis:100%}}@media(max-width:820px){.workspace{grid-template-columns:1fr}.sidebar{display:none}.preview-panel{border-left:0;border-top:1px solid #dbe3de}.preview-empty{padding:20px}.files{min-height:60vh}.authorization{padding:14px 18px}.grant-field{gap:7px;flex-wrap:wrap}.grant-field select{flex:1;min-width:180px}.session-controls{width:100%;justify-content:space-between;gap:8px}.expiry{gap:6px}.topbar{padding:0 18px}.topnote{display:none}.date-cell{width:110px}.preview-body pre{max-height:280px}.preview-actions{max-width:360px}}
|
|
36
|
+
/* Independent scrolling keeps file actions within the current viewport. */
|
|
37
|
+
body{height:100vh;height:100dvh;display:flex;flex-direction:column;overflow:hidden}
|
|
38
|
+
.topbar,.authorization,.notice{flex-shrink:0}.notice{max-height:20vh;overflow:auto}
|
|
39
|
+
.workspace{flex:1;min-height:0;overflow:hidden}.sidebar{min-height:0;overflow:auto}
|
|
40
|
+
.files{min-height:0;overflow:hidden}.files>:not(.table-wrap){flex-shrink:0}
|
|
41
|
+
.table-wrap{flex:1;min-height:0;overflow:auto;overscroll-behavior:contain}
|
|
42
|
+
.table-wrap thead{position:sticky;top:0;background:#fff;z-index:1}
|
|
43
|
+
.list-footer{margin-top:0;flex-shrink:0}.preview-panel{min-height:0;overflow:hidden}
|
|
44
|
+
.preview-heading,.preview-actions{flex-shrink:0}.preview-body{min-height:0;overflow:auto;overscroll-behavior:contain;padding-right:6px}
|
|
45
|
+
.preview-actions{border-top:1px solid #dbe3de;margin-top:16px;padding-top:16px;background:#f8faf7}
|
|
46
|
+
.preview-body h4{font-size:12px;font-weight:600;margin:24px 0 12px;color:#496553}
|
|
47
|
+
.preview-body dl{grid-template-columns:78px minmax(0,1fr)}.preview-body dt{overflow-wrap:anywhere}
|
|
48
|
+
.preview-body .metadata-list{grid-template-columns:minmax(0,1fr);gap:5px}.metadata-list dd{padding-bottom:9px}
|
|
49
|
+
.copy-field{display:flex;align-items:flex-start;gap:6px;min-width:0}.copy-field>span{min-width:0;overflow-wrap:anywhere}
|
|
50
|
+
.copy-button{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;padding:2px;width:24px;height:24px;margin-top:-3px}
|
|
51
|
+
.copy-button svg{width:15px;height:15px}.copy-button.copied{color:#18764e;background:#e1f2e7}
|
|
52
|
+
.copy-field a{color:#197854;text-underline-offset:3px;line-height:1.7}.copy-manual{width:100%;font:inherit}
|
|
53
|
+
.clipboard-fallback{position:fixed;left:0;top:0;opacity:0;pointer-events:none}
|
|
54
|
+
.icon-button{color:#526f5e}.icon-button svg{display:block}.is-loading svg{animation:oss-refresh-spin .8s linear infinite}
|
|
55
|
+
@keyframes oss-refresh-spin{to{transform:rotate(360deg)}}
|
|
56
|
+
@media(prefers-reduced-motion:reduce){.is-loading svg{animation:none}.is-loading{opacity:.55}}
|
|
57
|
+
@media(max-width:820px){.workspace{display:flex;flex-direction:column;overflow:auto}.files{flex:none;height:58dvh;min-height:330px}.preview-panel{flex:none;height:45dvh;min-height:270px}.preview-body pre{max-height:none}}
|
|
58
|
+
|
|
59
|
+
.topbar{height:auto;min-height:62px;flex-wrap:wrap;gap:12px;padding-top:10px;padding-bottom:10px}#service-stop{font-size:12px;background:transparent;border-color:#82958a;color:#edf5ef}.upload-actions{display:flex;gap:8px}.tasks-panel{position:fixed;right:20px;bottom:20px;top:155px;width:min(480px,calc(100vw - 32px));z-index:5;background:#fff;border:1px solid #cbd9cf;border-radius:10px;box-shadow:0 12px 50px #20372e30;display:flex;flex-direction:column;overflow:hidden}.tasks-heading{display:flex;align-items:center;justify-content:space-between;padding:15px 18px;border-bottom:1px solid #e4ebe5}.tasks-note{font-size:12px;line-height:1.8;color:#718577;padding:12px 18px}.tasks-list{overflow:auto;overscroll-behavior:contain;flex:1;min-height:0}.task-card{padding:15px 18px;border-top:1px solid #e8eee9}.task-card h3{font-size:13px;margin:0 0 7px;overflow-wrap:anywhere;font-weight:550}.task-meta,.task-path,.task-stats{font-size:11px;color:#738a7b;line-height:1.7;overflow-wrap:anywhere}.task-path{font-family:ui-monospace,SFMono-Regular,monospace}.task-card progress{width:100%;height:6px;accent-color:#227b54;margin:10px 0 6px}.task-actions{display:flex;gap:7px;margin-top:10px}.task-actions button{font-size:12px;padding:5px 10px}.task-error,#tasks-connection{font-size:12px;color:#9f4b40;line-height:1.7;overflow-wrap:anywhere}.task-error{margin-top:8px}#tasks-connection{padding:0 18px 10px}#tasks-toggle{font-size:12px;background:#2f4b3e;border-color:#607969;color:#edf5ef}#tasks-count{font-variant-numeric:tabular-nums;margin-left:5px}@media(max-width:820px){.tasks-panel{top:140px;right:16px;bottom:16px}.upload-actions{gap:5px}.upload-actions button{font-size:12px;padding:7px 9px}}
|
|
60
|
+
|
|
61
|
+
/* Collapsed rails keep both expand buttons visible and give space back to the file list. */
|
|
62
|
+
.workspace{--sidebar-width:205px;--preview-width:310px;grid-template-columns:var(--sidebar-width) minmax(0,1fr) var(--preview-width)}
|
|
63
|
+
.sidebar-heading{display:flex;align-items:center;justify-content:space-between;gap:6px;flex-shrink:0}.sidebar-heading .eyebrow{letter-spacing:1px;font-size:11px}
|
|
64
|
+
.panel-toggle{width:28px;height:28px;padding:0;flex-shrink:0;font-size:24px;line-height:1}
|
|
65
|
+
.sidebar-content{display:flex;flex-direction:column;flex:1;min-height:0;overflow:auto}.sidebar-content>.scope{padding:0 10px}.sidebar-content .root-button{flex-shrink:0}
|
|
66
|
+
.preview-content{display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden}
|
|
67
|
+
@media(min-width:1500px){.workspace{--sidebar-width:235px;--preview-width:370px}}
|
|
68
|
+
@media(min-width:821px) and (max-width:1100px){.workspace{--sidebar-width:160px;--preview-width:260px}}
|
|
69
|
+
.workspace[data-sidebar-collapsed=true]{--sidebar-width:44px}.workspace[data-preview-collapsed=true]{--preview-width:44px}
|
|
70
|
+
.workspace[data-sidebar-collapsed=true] .sidebar,.workspace[data-preview-collapsed=true] .preview-panel{padding:20px 7px;overflow:hidden}
|
|
71
|
+
.workspace[data-sidebar-collapsed=true] .sidebar-heading,.workspace[data-preview-collapsed=true] .preview-heading{flex-direction:column-reverse;gap:16px}
|
|
72
|
+
.workspace[data-sidebar-collapsed=true] .sidebar-heading h2,.workspace[data-preview-collapsed=true] .preview-heading h2{writing-mode:vertical-rl;letter-spacing:2px;font-size:11px;padding:0;font-weight:500}
|
|
73
|
+
@media(max-width:820px){
|
|
74
|
+
.sidebar{display:flex;flex:none;padding:12px 18px;max-height:38dvh;border-right:0;border-bottom:1px solid #dbe3de}.sidebar-content{flex:auto}.sidebar-bottom{padding-top:16px}.sidebar-heading .eyebrow{padding-left:0}
|
|
75
|
+
.workspace[data-sidebar-collapsed=true] .sidebar,.workspace[data-preview-collapsed=true] .preview-panel{height:auto;min-height:0;max-height:none;padding:8px 18px}
|
|
76
|
+
.workspace[data-sidebar-collapsed=true] .sidebar-heading,.workspace[data-preview-collapsed=true] .preview-heading{flex-direction:row;gap:8px}
|
|
77
|
+
.workspace[data-sidebar-collapsed=true] .sidebar-heading h2,.workspace[data-preview-collapsed=true] .preview-heading h2{writing-mode:horizontal-tb;letter-spacing:1px}
|
|
78
|
+
}
|
|
22
79
|
`;
|
|
23
80
|
export const OSS_WEB_JS = String.raw `(() => {
|
|
24
81
|
'use strict';
|
|
@@ -28,9 +85,31 @@ const supplied = fragment.get('token');
|
|
|
28
85
|
let token = supplied || sessionStorage.getItem('numa-oss-web-token');
|
|
29
86
|
if (supplied) { sessionStorage.setItem('numa-oss-web-token', supplied); history.replaceState(null, '', location.pathname); }
|
|
30
87
|
const state = { id: 0, epoch: 0, dir: '', root: '', rows: [], marker: '', selected: new Set(), file: null,
|
|
31
|
-
session: null, offset: 0, ready: false, loading: false, mutating: false, controller: null, previewController: null,
|
|
88
|
+
stopping: false, stopped: false, session: null, offset: 0, ready: false, loading: false, mutating: false, controller: null, previewController: null, renewalBusy: false, grantsLoading: false };
|
|
32
89
|
const node = (tag, text, className) => { const el = document.createElement(tag); if (text !== undefined) el.textContent = text; if (className) el.className = className; return el; };
|
|
33
90
|
function notice(text, success = false) { $('notice').textContent = text; $('notice').className = 'notice' + (success ? ' success' : ''); $('notice').hidden = !text; }
|
|
91
|
+
function setBusy(id, busy) {
|
|
92
|
+
const button = $(id); button.classList.toggle('is-loading', busy); button.setAttribute('aria-busy', String(busy));
|
|
93
|
+
}
|
|
94
|
+
function copyButton(value, label) {
|
|
95
|
+
const button = node('button', undefined, 'quiet copy-button'); button.title = '复制' + label; button.setAttribute('aria-label', button.title);
|
|
96
|
+
button.append($('copy-icon').content.cloneNode(true));
|
|
97
|
+
button.onclick = async () => {
|
|
98
|
+
try {
|
|
99
|
+
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(value);
|
|
100
|
+
else {
|
|
101
|
+
const field = node('textarea', undefined, 'clipboard-fallback'); field.value = value; document.body.append(field); field.select();
|
|
102
|
+
try { if (!document.execCommand('copy')) throw new Error('copy unavailable'); } finally { field.remove(); button.focus(); }
|
|
103
|
+
}
|
|
104
|
+
button.classList.add('copied'); button.title = '已复制'; notice(label + '已复制。', true);
|
|
105
|
+
setTimeout(() => { button.classList.remove('copied'); button.title = '复制' + label; }, 1600);
|
|
106
|
+
} catch {
|
|
107
|
+
const field = node('input', undefined, 'copy-manual'); field.value = value; field.readOnly = true;
|
|
108
|
+
button.parentElement.append(field); field.select(); notice('浏览器未允许复制,请复制已选中的内容。');
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
return button;
|
|
112
|
+
}
|
|
34
113
|
function fail(error) { if (error.name !== 'AbortError') notice(error.message || '操作失败,请重试。'); }
|
|
35
114
|
function url(path, params = {}, id = state.id) { return '/api/' + path + '?' + new URLSearchParams({ grantId: String(id), ...params }); }
|
|
36
115
|
async function api(path, params = {}, options = {}, id = state.id) {
|
|
@@ -45,15 +124,20 @@ function bytes(value) { if (!Number.isFinite(value)) return '—'; if (value < 1
|
|
|
45
124
|
function date(value) { if (!value) return '—'; const d = new Date(value); return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString('zh-CN', { hour12: false }); }
|
|
46
125
|
function remaining() { return state.session ? Math.max(0, Math.ceil((Date.parse(state.session.expiration) - Date.now() - state.offset) / 1000)) : 0; }
|
|
47
126
|
function controls() {
|
|
48
|
-
const active = state.ready && remaining() > 0 && !state.mutating;
|
|
127
|
+
const active = state.ready && remaining() > 0 && !state.mutating && !state.stopping;
|
|
49
128
|
$('upload').disabled = !active || !state.session?.write;
|
|
129
|
+
$('upload-folder').disabled = $('upload').disabled;
|
|
130
|
+
$('mkdir').disabled = !active || !state.session?.write || !state.session?.list;
|
|
50
131
|
$('download').disabled = !active || !state.file || !state.session?.read;
|
|
51
132
|
$('delete').disabled = !active || !state.file || !state.session?.delete;
|
|
52
133
|
$('download-selected').disabled = !active || !state.selected.size || !state.session?.read;
|
|
53
134
|
$('delete-selected').disabled = !active || !state.selected.size || !state.session?.delete;
|
|
54
|
-
$('grant').disabled = state.mutating;
|
|
55
|
-
$('
|
|
56
|
-
$('
|
|
135
|
+
$('grant').disabled = state.mutating || state.stopping;
|
|
136
|
+
$('grants-refresh').disabled = state.mutating || state.grantsLoading || state.stopping;
|
|
137
|
+
$('refresh').disabled = state.mutating || state.loading || !state.session || state.stopping;
|
|
138
|
+
$('renew').disabled = !state.id || state.renewalBusy || state.stopping;
|
|
139
|
+
$('auto-renew').disabled = !state.session || state.renewalBusy || state.stopping;
|
|
140
|
+
$('service-stop').disabled = !token || state.stopping;
|
|
57
141
|
$('selection-count').textContent = state.selected.size ? '已选 ' + state.selected.size + ' 项' : '';
|
|
58
142
|
}
|
|
59
143
|
function tick() {
|
|
@@ -81,9 +165,9 @@ function visible() { const query = $('search').value.toLocaleLowerCase(); return
|
|
|
81
165
|
function renderRows() {
|
|
82
166
|
const body = $('rows'); body.replaceChildren(); const rows = visible();
|
|
83
167
|
for (const row of rows) {
|
|
84
|
-
const tr = node('tr'); if (state.file?.key === row.key) tr.className = 'active';
|
|
168
|
+
const tr = node('tr'); tr.oncontextmenu = event => openFileMenu(event, row); tr.tabIndex = 0; tr.onkeydown = event => { if (event.key === 'ContextMenu' || event.shiftKey && event.key === 'F10') openFileMenu(event, row, tr); }; if (state.file?.key === row.key) tr.className = 'active';
|
|
85
169
|
const checkCell = node('td', undefined, 'check-cell');
|
|
86
|
-
|
|
170
|
+
{ const check = document.createElement('input'); check.type = 'checkbox'; check.checked = state.selected.has(row.key); check.setAttribute('aria-label', '选择 ' + row.key); check.onchange = () => { if (check.checked) state.selected.add(row.key); else state.selected.delete(row.key); updateSelection(); controls(); }; checkCell.append(check); }
|
|
87
171
|
tr.append(checkCell);
|
|
88
172
|
const name = node('td'); const button = node('button', undefined, 'entry'); button.title = row.key;
|
|
89
173
|
button.append(node('span', undefined, row.directory ? 'folder-icon' : 'file-icon'), node('span', row.key.slice(state.dir.length) || row.key));
|
|
@@ -94,27 +178,31 @@ function renderRows() {
|
|
|
94
178
|
$('empty').hidden = rows.length > 0; $('empty').textContent = state.loading ? '正在读取文件…' : $('search').value ? '已加载列表中没有匹配项' : '此目录暂无文件';
|
|
95
179
|
$('list-summary').textContent = '已加载 ' + state.rows.length + ' 项' + ($('search').value ? ' · 显示 ' + rows.length + ' 项' : '') + (state.marker ? ' · 还有更多文件' : '');
|
|
96
180
|
$('more').hidden = !state.marker; $('more').disabled = state.loading;
|
|
97
|
-
|
|
181
|
+
updateSelection(); controls();
|
|
98
182
|
}
|
|
183
|
+
function updateSelection() { const rows = visible(), count = rows.filter(row => state.selected.has(row.key)).length; $('select-all').checked = rows.length > 0 && count === rows.length; $('select-all').indeterminate = count > 0 && count < rows.length; }
|
|
99
184
|
function clearPreview() { state.previewController?.abort(); state.file = null; $('preview').replaceChildren(node('div', '选择文件以查看内容与信息', 'preview-empty')); controls(); }
|
|
100
185
|
async function navigate(dir, append = false) {
|
|
186
|
+
closeFileMenu();
|
|
187
|
+
if (state.mutating || state.stopping) return;
|
|
101
188
|
if (state.session && remaining() <= 0) { notice('STS 已到期,请点击立即续期后继续。'); return; }
|
|
102
189
|
const epoch = state.epoch, id = state.id; state.controller?.abort(); state.controller = new AbortController();
|
|
103
190
|
const controller = state.controller;
|
|
104
191
|
if (!append) { state.dir = dir; state.rows = []; state.selected.clear(); state.marker = ''; clearPreview(); }
|
|
105
|
-
state.loading = true; renderRows(); breadcrumbs();
|
|
192
|
+
state.loading = true; setBusy('refresh', true); renderRows(); breadcrumbs();
|
|
106
193
|
try {
|
|
107
194
|
const data = await api('list', { prefix: dir, ...(append ? { marker: state.marker } : {}) }, { signal: controller.signal }, id);
|
|
108
195
|
if (epoch !== state.epoch || controller !== state.controller) return;
|
|
109
196
|
setSession(data.session);
|
|
110
|
-
const entries = data.listing.directories.map(key => ({ key, directory: true })).concat(data.listing.objects.filter(r => r.key !== dir).map(r => ({ ...r, directory:
|
|
197
|
+
const entries = data.listing.directories.map(key => ({ key, directory: true })).concat(data.listing.objects.filter(r => r.key !== dir).map(r => ({ ...r, directory: r.key.endsWith('/') })));
|
|
111
198
|
const merged = new Map((append ? state.rows : []).concat(entries).map(r => [r.key, r]));
|
|
112
199
|
state.rows = [...merged.values()].sort((a,b) => Number(b.directory) - Number(a.directory) || a.key.localeCompare(b.key));
|
|
113
200
|
state.marker = data.listing.truncated ? data.listing.nextMarker || '' : '';
|
|
114
201
|
} catch (error) { if (epoch === state.epoch && controller === state.controller) { fail(error); if (error.code?.includes('session') || error.code?.includes('refresh')) state.ready = false; } }
|
|
115
|
-
finally { if (epoch === state.epoch && controller === state.controller) { state.loading = false; renderRows(); breadcrumbs(); } }
|
|
202
|
+
finally { if (epoch === state.epoch && controller === state.controller) { state.loading = false; setBusy('refresh', false); renderRows(); breadcrumbs(); } }
|
|
116
203
|
}
|
|
117
204
|
async function preview(row) {
|
|
205
|
+
setPanelCollapsed('preview',false);
|
|
118
206
|
clearPreview(); state.file = row; renderRows();
|
|
119
207
|
const epoch = state.epoch, id = state.id; state.previewController = new AbortController(); const controller = state.previewController;
|
|
120
208
|
$('preview').replaceChildren(node('h3', row.key.split('/').pop()), node('p', '正在读取预览…'));
|
|
@@ -123,21 +211,39 @@ async function preview(row) {
|
|
|
123
211
|
if (epoch !== state.epoch || controller !== state.previewController) return;
|
|
124
212
|
const box = $('preview'); box.replaceChildren(node('h3', row.key.split('/').pop()));
|
|
125
213
|
if (data.kind === 'text') box.append(node('pre', data.text));
|
|
126
|
-
else if (data.kind === 'image') { const img = document.createElement('img'); img.src = data.data; img.alt = row.key; box.append(img); }
|
|
214
|
+
else if (data.kind === 'image') { const img = document.createElement('img'); img.src = data.data; img.alt = row.key; img.onerror = () => img.replaceWith(node('p', '图片无法预览,请下载后查看。')); box.append(img); }
|
|
127
215
|
else box.append(node('p', '此文件暂不支持在线预览,请下载后查看。支持文本与不超过 1 MiB 的常见图片。'));
|
|
128
216
|
if (data.truncated && data.kind === 'text') box.append(node('p', '仅显示前 256 KiB;下载可查看完整内容。'));
|
|
129
|
-
const dl = node('dl');
|
|
130
|
-
for (const [label, value] of [['大小', bytes(
|
|
131
|
-
|
|
217
|
+
const meta = data.metadata, dl = node('dl');
|
|
218
|
+
for (const [label, value] of [['大小', bytes(meta.contentLength)], ['类型', meta.contentType || '—'], ['修改时间', date(meta.lastModified)]]) dl.append(node('dt', label), node('dd', value));
|
|
219
|
+
const path = node('dd', undefined, 'copy-field'); path.append(copyButton(meta.key, '对象路径'), node('span', meta.key));
|
|
220
|
+
dl.append(node('dt', '对象路径'), path);
|
|
221
|
+
if (data.publicUrl) {
|
|
222
|
+
const address = node('dd', undefined, 'copy-field'), link = node('a', '打开链接');
|
|
223
|
+
link.href = data.publicUrl; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.title = '打开文件公网地址';
|
|
224
|
+
address.append(copyButton(data.publicUrl, '公网地址'), link); dl.append(node('dt', '公网地址'), address);
|
|
225
|
+
}
|
|
226
|
+
box.append(dl, node('p', '公网地址不含临时签名;私有对象需授权访问。'));
|
|
227
|
+
const standard = node('dl', undefined, 'metadata-list');
|
|
228
|
+
for (const [label, key] of [['ETag','etag'],['存储类型','storageClass'],['对象类型','objectType'],['版本 ID','versionId'],['CRC64','crc64'],['Cache-Control','cacheControl'],['Content-Disposition','contentDisposition'],['Content-Encoding','contentEncoding'],['Expires','expires'],['服务端加密','serverSideEncryption']]) {
|
|
229
|
+
if (meta[key] !== undefined) standard.append(node('dt', label), node('dd', String(meta[key])));
|
|
230
|
+
}
|
|
231
|
+
box.append(node('h4', 'OSS 系统元数据'), standard.children.length ? standard : node('p', '暂无其他系统元数据'));
|
|
232
|
+
const custom = node('dl', undefined, 'metadata-list');
|
|
233
|
+
for (const [key, value] of Object.entries(meta.metadata || {}).sort(([a], [b]) => a.localeCompare(b))) custom.append(node('dt', key.startsWith('x-oss-meta-') ? key : 'x-oss-meta-' + key), node('dd', value));
|
|
234
|
+
box.append(node('h4', 'OSS 自定义元数据'), custom.children.length ? custom : node('p', '暂无自定义元数据'));
|
|
132
235
|
} catch (error) { if (epoch === state.epoch && controller === state.previewController && error.name !== 'AbortError') { $('preview').replaceChildren(node('h3', row.key.split('/').pop()), node('p', error.message)); } }
|
|
133
236
|
}
|
|
134
237
|
async function selectGrant(id) {
|
|
238
|
+
state.loading = false; setBusy('refresh', false);
|
|
135
239
|
state.epoch++; const epoch = state.epoch; state.id = Number(id); state.ready = false; state.session = null; state.rows = []; state.selected.clear(); state.marker = ''; state.dir = ''; state.root = '';
|
|
136
240
|
state.controller?.abort(); $('search').value = ''; clearPreview(); notice(''); tick(); renderRows(); $('scope').textContent = '正在连接…'; $('bucket').textContent = '正在连接…';
|
|
137
241
|
try { const data = await api('session', {}, {}, state.id); if (epoch !== state.epoch) return; setSession(data.session); await navigate(state.root); }
|
|
138
242
|
catch (error) { if (epoch === state.epoch) { fail(error); $('empty').textContent = '连接失败,请检查授权并重试。'; } }
|
|
139
243
|
}
|
|
140
244
|
async function refreshGrants(initial = false) {
|
|
245
|
+
if (state.grantsLoading) return;
|
|
246
|
+
state.grantsLoading = true; setBusy('grants-refresh', true); controls();
|
|
141
247
|
try {
|
|
142
248
|
const data = await api('grants'); const select = $('grant'); const previous = state.id;
|
|
143
249
|
select.replaceChildren(); for (const grant of data.grants) { const option = node('option', (grant.policyCode || 'Grant') + ' · ' + grant.bucketName + '/' + grant.objectPrefix + ' (#' + grant.grantId + ')'); option.value = String(grant.grantId); select.append(option); }
|
|
@@ -145,61 +251,380 @@ async function refreshGrants(initial = false) {
|
|
|
145
251
|
const id = data.grants.some(g => g.grantId === previous) ? previous : data.grants.some(g => g.grantId === data.selectedGrantId) ? data.selectedGrantId : data.grants[0].grantId;
|
|
146
252
|
select.value = String(id); if (initial || id !== previous) await selectGrant(id);
|
|
147
253
|
} catch (error) { fail(error); }
|
|
254
|
+
finally { state.grantsLoading = false; setBusy('grants-refresh', false); controls(); }
|
|
148
255
|
}
|
|
149
256
|
async function renew(manual = false) {
|
|
150
|
-
if (!state.id || state.renewalBusy) return;
|
|
257
|
+
if (!state.id || state.renewalBusy || state.stopping) return;
|
|
151
258
|
const epoch = state.epoch, id = state.id; state.renewalBusy = true; controls();
|
|
152
259
|
if (manual) $('renew').textContent = '续期中…';
|
|
153
260
|
try { const data = await api(manual ? 'renew' : 'session', {}, manual ? { method: 'POST' } : {}, id); if (epoch === state.epoch) { setSession(data.session); if (manual) notice('STS 已续期,可继续使用。', true); } }
|
|
154
261
|
catch (error) { if (epoch === state.epoch) { state.ready = false; fail(error); } }
|
|
155
262
|
finally { state.renewalBusy = false; $('renew').textContent = '立即续期'; tick(); }
|
|
156
263
|
}
|
|
264
|
+
async function chooseTransfer(path, body) {
|
|
265
|
+
if (state.mutating || !state.ready || remaining() <= 0) return;
|
|
266
|
+
const id = state.id; state.mutating = true; controls();
|
|
267
|
+
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 5 * 60 * 1000);
|
|
268
|
+
notice('请在系统对话框中选择' + (path === 'transfers/download' ? '保存目录' : '上传内容') + '…');
|
|
269
|
+
try {
|
|
270
|
+
const data = await api(path, {}, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: controller.signal }, id);
|
|
271
|
+
if (data.cancelled) { notice('已取消选择。'); return; }
|
|
272
|
+
showTasks(true); notice('已加入本机传输队列。可在传输任务中查看进度。', true); await pollTransfers();
|
|
273
|
+
} catch (error) { if (error.name === 'AbortError') notice('选择或任务准备超时。请先查看传输任务,确认是否已加入队列。'); else fail(error); }
|
|
274
|
+
finally { clearTimeout(timeout); state.mutating = false; controls(); }
|
|
275
|
+
}
|
|
157
276
|
async function download(keys) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
277
|
+
if (!state.session?.read || !keys.length) return;
|
|
278
|
+
const entries = keys.map(key => ({ key, directory: !!state.rows.find(row => row.key === key)?.directory }));
|
|
279
|
+
await chooseTransfer('transfers/download', { entries });
|
|
280
|
+
}
|
|
281
|
+
const taskCards = new Map(), taskStatuses = new Map(), taskBusy = new Set();
|
|
282
|
+
let pollingTransfers = false, pendingUploadNotice = false;
|
|
283
|
+
const taskLabels = { queued:'排队中', running:'传输中', pausing:'正在暂停', paused:'已暂停', cancelling:'正在取消', cancelled:'已取消', completed:'已完成', failed:'失败' };
|
|
284
|
+
function showTasks(show) { $('tasks-panel').hidden = !show; $('tasks-toggle').setAttribute('aria-expanded', String(show)); }
|
|
285
|
+
function setText(element, value) { if (element.textContent !== value) element.textContent = value; }
|
|
286
|
+
async function taskAction(id, action) {
|
|
287
|
+
if (taskBusy.has(id) || state.stopping) return;
|
|
288
|
+
taskBusy.add(id); const card = taskCards.get(id); if (card) for (const button of card.actions.values()) button.disabled = true;
|
|
289
|
+
try { await api('transfers/' + encodeURIComponent(id) + '/' + action, {}, { method:'POST' }); }
|
|
290
|
+
catch (error) { fail(error); }
|
|
291
|
+
finally { taskBusy.delete(id); await pollTransfers(); }
|
|
292
|
+
}
|
|
293
|
+
function renderTasks(jobs) {
|
|
294
|
+
const present = new Set(jobs.map(job => job.id));
|
|
295
|
+
for (const [id, card] of taskCards) if (!present.has(id)) { card.root.remove(); taskCards.delete(id); taskStatuses.delete(id); }
|
|
296
|
+
for (const job of jobs) {
|
|
297
|
+
let card = taskCards.get(job.id);
|
|
298
|
+
if (!card) {
|
|
299
|
+
const root = node('article', undefined, 'task-card'), title = node('h3'), meta = node('p', undefined, 'task-meta'), path = node('p', undefined, 'task-path'), progress = node('progress'), stats = node('p', undefined, 'task-stats'), error = node('p', undefined, 'task-error'), buttons = node('div', undefined, 'task-actions');
|
|
300
|
+
progress.max = 100; progress.setAttribute('aria-label', '文件传输进度');
|
|
301
|
+
const actions = new Map();
|
|
302
|
+
for (const [action, label] of [['pause','暂停'],['resume','恢复'],['cancel','取消']]) { const button = node('button', label); button.onclick = () => taskAction(job.id, action); actions.set(action, button); buttons.append(button); }
|
|
303
|
+
root.append(title, meta, path, progress, stats, error, buttons); $('tasks-list').append(root);
|
|
304
|
+
card = { root, title, meta, path, progress, stats, error, actions }; taskCards.set(job.id, card);
|
|
305
|
+
}
|
|
306
|
+
const prior = taskStatuses.get(job.id);
|
|
307
|
+
if (prior && prior !== 'completed' && job.status === 'completed' && job.direction === 'upload' && Number(job.grantId) === state.id) pendingUploadNotice = true;
|
|
308
|
+
taskStatuses.set(job.id, job.status);
|
|
309
|
+
setText(card.title, job.key); setText(card.meta, (job.direction === 'upload' ? '上传' : '下载') + ' · Grant #' + job.grantId + ' · ' + (taskLabels[job.status] || '状态未知'));
|
|
310
|
+
setText(card.path, job.localPath); card.path.title = job.localPath;
|
|
311
|
+
card.progress.value = job.size > 0 ? Math.min(100, job.transferred / job.size * 100) : job.status === 'completed' ? 100 : 0;
|
|
312
|
+
card.progress.setAttribute('aria-valuetext', bytes(job.transferred) + ' / ' + bytes(job.size));
|
|
313
|
+
setText(card.stats, bytes(job.transferred) + ' / ' + bytes(job.size) + (job.status === 'running' ? ' · ' + bytes(Math.max(0, Number(job.bytesPerSecond) || 0)) + '/s' : ''));
|
|
314
|
+
setText(card.error, job.error || ''); card.error.hidden = !job.error;
|
|
315
|
+
const available = { pause: ['queued','running'].includes(job.status), resume: ['paused','failed'].includes(job.status), cancel: ['queued','running','paused','failed'].includes(job.status) };
|
|
316
|
+
for (const [action, button] of card.actions) { button.hidden = !available[action]; button.disabled = taskBusy.has(job.id); }
|
|
162
317
|
}
|
|
318
|
+
$('tasks-empty').hidden = jobs.length > 0;
|
|
319
|
+
setText($('tasks-count'), String(jobs.length));
|
|
320
|
+
$('tasks-toggle').title = jobs.filter(job => ['queued','running','pausing','cancelling'].includes(job.status)).length + ' 个进行中的任务';
|
|
321
|
+
if (pendingUploadNotice && !state.mutating && !state.loading) { pendingUploadNotice = false; notice('当前授权有上传任务已完成。点击刷新文件列表查看新文件;当前预览已保留。', true); }
|
|
322
|
+
}
|
|
323
|
+
async function pollTransfers() {
|
|
324
|
+
if (!token || pollingTransfers || state.stopping) return;
|
|
325
|
+
pollingTransfers = true;
|
|
326
|
+
try { const data = await api('transfers'); renderTasks(data.jobs || []); $('tasks-connection').hidden = true; }
|
|
327
|
+
catch (error) { setText($('tasks-connection'), error.message || '暂时无法读取任务状态。'); $('tasks-connection').hidden = false; }
|
|
328
|
+
finally { pollingTransfers = false; }
|
|
163
329
|
}
|
|
164
330
|
let deletion = null;
|
|
165
331
|
function askDelete(keys) {
|
|
166
|
-
if (!keys.length
|
|
167
|
-
|
|
332
|
+
if (!keys.length || state.mutating || state.stopping || !state.session?.delete || remaining() <= 0) return;
|
|
333
|
+
const entries = keys.map(key => ({ key, directory: key.endsWith('/') }));
|
|
334
|
+
deletion = { entries, id: state.id };
|
|
335
|
+
const folders = entries.filter(entry => entry.directory).length;
|
|
336
|
+
$('delete-count').textContent = '已选择 ' + entries.length + ' 项:' + (entries.length - folders) + ' 个文件、' + folders + ' 个文件夹。';
|
|
337
|
+
$('delete-list').replaceChildren(...keys.slice(0,100).map(key => node('li', key)));
|
|
338
|
+
if (keys.length > 100) $('delete-list').append(node('li', '另有 ' + (keys.length - 100) + ' 项…'));
|
|
168
339
|
$('delete-target').textContent = 'Grant #' + state.id + ' · ' + state.session.bucket;
|
|
169
340
|
$('delete-dialog').showModal();
|
|
170
341
|
}
|
|
342
|
+
async function submitOperation(input, id) {
|
|
343
|
+
const data = await api('operations', {}, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Numa-Confirm': input.kind }, body: JSON.stringify(input) }, id);
|
|
344
|
+
await pollOperations();
|
|
345
|
+
notice('操作已交给 CLI 执行,进度显示在页面底部。', true);
|
|
346
|
+
return data;
|
|
347
|
+
}
|
|
171
348
|
async function deleteFiles() {
|
|
172
|
-
const target = deletion; if (!target
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
349
|
+
const target = deletion; if (!target || state.mutating || state.stopping) return;
|
|
350
|
+
deletion = null; $('delete-dialog').close(); state.mutating = true; controls();
|
|
351
|
+
try { await submitOperation({kind:'delete',entries:target.entries}, target.id); }
|
|
352
|
+
catch (error) { fail(error); }
|
|
353
|
+
finally { state.mutating = false; controls(); }
|
|
354
|
+
}
|
|
355
|
+
let menuOwner = null, permissionTipOwner = null, permissionTipTimer = null;
|
|
356
|
+
function hidePermissionTip() { clearTimeout(permissionTipTimer); permissionTipTimer = null; $('permission-tip').hidden = true; permissionTipOwner?.removeAttribute('aria-describedby'); permissionTipOwner = null; }
|
|
357
|
+
function showPermissionTip(button, missing) {
|
|
358
|
+
hidePermissionTip(); button.focus(); permissionTipOwner = button;
|
|
359
|
+
const tip = $('permission-tip'); tip.textContent = '当前授权缺少' + missing.join('、') + ',无法执行此操作。请联系管理员调整 Grant 权限。';
|
|
360
|
+
button.setAttribute('aria-describedby','permission-tip'); tip.hidden = false;
|
|
361
|
+
permissionTipTimer = setTimeout(hidePermissionTip,3000);
|
|
362
|
+
const bounds = button.getBoundingClientRect();
|
|
363
|
+
tip.style.left = Math.max(8,Math.min(bounds.left,window.innerWidth - tip.offsetWidth - 8)) + 'px';
|
|
364
|
+
tip.style.top = Math.max(8,Math.min(bounds.bottom + 6,window.innerHeight - tip.offsetHeight - 8)) + 'px';
|
|
365
|
+
}
|
|
366
|
+
function closeFileMenu(restore = false) { hidePermissionTip(); $('file-menu').hidden = true; if (restore) menuOwner?.focus(); }
|
|
367
|
+
function openFileMenu(event, row, anchor) {
|
|
368
|
+
event.preventDefault(); closeFileMenu();
|
|
369
|
+
if (!state.ready || state.stopping || state.mutating || remaining() <= 0) return;
|
|
370
|
+
const menu = $('file-menu'); document.body.append(menu,$('permission-tip')); menu.replaceChildren(); menuOwner = anchor || event.currentTarget;
|
|
371
|
+
const permissions = {read:'读取权限(oss:GetObject)',write:'写入权限(oss:PutObject)',delete:'删除权限(oss:DeleteObject)',list:'列举权限(oss:ListObjects)'};
|
|
372
|
+
const actions = [['delete',row.directory ? '删除文件夹…' : '删除文件…',['delete']],['copy','拷贝到…',['read','write']],['move','移动到…',['read','write','delete']],['rename','重命名…',['read','write','delete']]];
|
|
373
|
+
for (const [kind,label,required] of actions) {
|
|
374
|
+
const missing = [...required,...(row.directory ? ['list'] : [])].filter(permission => !state.session[permission]).map(permission => permissions[permission]);
|
|
375
|
+
const button = node('button',label,kind === 'delete' ? 'danger' : ''); button.setAttribute('role','menuitem'); button.setAttribute('aria-disabled',String(missing.length > 0));
|
|
376
|
+
button.onblur = hidePermissionTip;
|
|
377
|
+
button.onclick = () => { if (missing.length) { showPermissionTip(button,missing); return; } closeFileMenu(); if (kind === 'delete') askDelete([row.key]); else askOperation(kind,row); }; menu.append(button);
|
|
378
|
+
}
|
|
379
|
+
menu.hidden = false;
|
|
380
|
+
const bounds = anchor?.getBoundingClientRect();
|
|
381
|
+
menu.style.left = Math.max(8, Math.min(bounds ? bounds.left : event.clientX, window.innerWidth - menu.offsetWidth - 8)) + 'px';
|
|
382
|
+
menu.style.top = Math.max(8, Math.min(bounds ? bounds.bottom : event.clientY, window.innerHeight - menu.offsetHeight - 8)) + 'px';
|
|
383
|
+
[...menu.children].find(button => !button.disabled)?.focus();
|
|
384
|
+
}
|
|
385
|
+
let destinationTree = null;
|
|
386
|
+
function clearDestinationTree() {
|
|
387
|
+
closeFileMenu();
|
|
388
|
+
destinationTree?.controller.abort(); destinationTree = null;
|
|
389
|
+
$('destination-tree').replaceChildren(); $('destination-picker').hidden = true;
|
|
390
|
+
}
|
|
391
|
+
function syncDestinationSelection() {
|
|
392
|
+
const tree = destinationTree; if (!tree) return;
|
|
393
|
+
const relative = $('operation-value').value.trim().replace(/\/+$/,'');
|
|
394
|
+
const selected = tree.target.root + relative + (relative ? '/' : '');
|
|
395
|
+
for (const branch of tree.nodes.values()) branch.item.setAttribute('aria-selected', String(branch.path === selected && !branch.choose.disabled));
|
|
396
|
+
}
|
|
397
|
+
function addDestinationBranch(tree, path, parent) {
|
|
398
|
+
if (tree.nodes.has(path)) return tree.nodes.get(path);
|
|
399
|
+
const item = node('div',undefined,'destination-item'), line = node('div',undefined,'destination-line');
|
|
400
|
+
item.setAttribute('role','treeitem'); item.setAttribute('aria-expanded','false'); item.setAttribute('aria-selected','false'); item.tabIndex = parent ? -1 : 0;
|
|
401
|
+
const toggle = node('button','›','destination-toggle'), choose = node('button',undefined,'destination-name');
|
|
402
|
+
toggle.tabIndex = -1; choose.tabIndex = -1;
|
|
403
|
+
const label = parent ? path.slice(parent.path.length).replace(/\/$/,'') : (state.session?.bucket || '授权根目录') + ' / ' + tree.target.root;
|
|
404
|
+
item.setAttribute('aria-label',label); toggle.setAttribute('aria-label','展开 ' + label);
|
|
405
|
+
choose.append(node('span',undefined,'folder-icon'),node('span',label)); choose.title = path || '授权根目录';
|
|
406
|
+
const children = node('div',undefined,'destination-children'); children.setAttribute('role','group'); children.hidden = true;
|
|
407
|
+
const status = node('div',undefined,'destination-status'), more = node('button','加载更多目录','destination-more'); more.hidden = true; status.setAttribute('role','status');
|
|
408
|
+
const blocked = tree.target.row.directory && path.startsWith(tree.target.row.key);
|
|
409
|
+
choose.disabled = blocked; toggle.disabled = blocked; item.setAttribute('aria-disabled',String(blocked));
|
|
410
|
+
if (blocked) choose.title = '不能选择源文件夹或它的子目录';
|
|
411
|
+
const editor = node('div',undefined,'destination-editor'); editor.hidden = true;
|
|
412
|
+
const nameInput = node('input'); nameInput.type = 'text'; nameInput.placeholder = '输入子目录名称'; nameInput.setAttribute('aria-label','子目录名称'); nameInput.maxLength = 255;
|
|
413
|
+
const save = node('button','创建并打开','primary'), cancel = node('button','取消'); editor.append(nameInput,save,cancel);
|
|
414
|
+
const branch = {path,item,toggle,choose,children,status,more,parent,editor,nameInput,save,cancel,expanded:false,loaded:false,loading:false,marker:''};
|
|
415
|
+
cancel.onclick = () => { if (state.mutating) return; editor.hidden = true; status.textContent = branch.loaded && !branch.children.children.length && !branch.marker ? '暂无子目录' : ''; item.focus(); };
|
|
416
|
+
save.onclick = () => createDestinationDirectory(tree,branch);
|
|
417
|
+
editor.onkeydown = event => { event.stopPropagation(); if (event.key === 'Enter') { event.preventDefault(); save.onclick(); } else if (event.key === 'Escape') { event.preventDefault(); cancel.onclick(); } };
|
|
418
|
+
tree.nodes.set(path,branch); line.append(toggle,choose); item.append(line,children,status,more,editor); (parent ? parent.children : tree.box).append(item);
|
|
419
|
+
const focus = () => { for (const other of tree.nodes.values()) other.item.tabIndex = other === branch ? 0 : -1; item.focus(); };
|
|
420
|
+
choose.onclick = () => { if (blocked || tree !== destinationTree || state.mutating) return; $('operation-value').value = path.slice(tree.target.root.length); syncDestinationSelection(); focus(); };
|
|
421
|
+
toggle.onclick = async () => {
|
|
422
|
+
if (blocked || tree !== destinationTree) return;
|
|
423
|
+
branch.expanded = !branch.expanded; children.hidden = !branch.expanded; item.setAttribute('aria-expanded',String(branch.expanded));
|
|
424
|
+
toggle.textContent = branch.expanded ? '⌄' : '›'; toggle.setAttribute('aria-label',(branch.expanded ? '收起 ' : '展开 ') + label);
|
|
425
|
+
more.hidden = !branch.expanded || branch.loading || branch.loaded && !branch.marker;
|
|
426
|
+
status.hidden = !branch.expanded; editor.hidden = true; focus();
|
|
427
|
+
if (branch.expanded && !branch.loaded) await loadDestinationBranch(tree,branch);
|
|
428
|
+
};
|
|
429
|
+
more.onclick = () => loadDestinationBranch(tree,branch);
|
|
430
|
+
item.oncontextmenu = event => openDestinationMenu(event,tree,branch);
|
|
431
|
+
item.onkeydown = event => {
|
|
432
|
+
if (event.target !== item) return;
|
|
433
|
+
if (event.key === 'ContextMenu' || event.shiftKey && event.key === 'F10') { openDestinationMenu(event,tree,branch,item); return; }
|
|
434
|
+
if (!['ArrowRight','ArrowLeft','ArrowDown','ArrowUp','Home','End','Enter',' '].includes(event.key)) return;
|
|
435
|
+
event.preventDefault(); event.stopPropagation();
|
|
436
|
+
if (event.key === 'Enter' || event.key === ' ') { choose.onclick(); return; }
|
|
437
|
+
if (event.key === 'ArrowRight') { if (!branch.expanded) toggle.onclick(); else children.children[0]?.focus(); return; }
|
|
438
|
+
if (event.key === 'ArrowLeft') { if (branch.expanded) toggle.onclick(); else parent?.item.focus(); return; }
|
|
439
|
+
const visible = [...tree.box.querySelectorAll('[role="treeitem"]')].filter(row => row.getClientRects().length);
|
|
440
|
+
const index = visible.indexOf(item), next = event.key === 'Home' ? 0 : event.key === 'End' ? visible.length - 1 : index + (event.key === 'ArrowDown' ? 1 : -1);
|
|
441
|
+
visible[Math.max(0,Math.min(visible.length - 1,next))]?.focus();
|
|
442
|
+
};
|
|
443
|
+
return branch;
|
|
444
|
+
}
|
|
445
|
+
async function loadDestinationBranch(tree, branch) {
|
|
446
|
+
if (tree !== destinationTree || branch.loading || tree.controller.signal.aborted) return;
|
|
447
|
+
branch.loading = true; branch.status.textContent = '正在读取目录…'; branch.more.hidden = true;
|
|
448
|
+
try {
|
|
449
|
+
const data = await api('list',{prefix:branch.path,...(branch.marker ? {marker:branch.marker} : {})},{signal:tree.controller.signal},tree.target.id);
|
|
450
|
+
if (tree !== destinationTree || tree.controller.signal.aborted) return;
|
|
451
|
+
const listing = data.listing;
|
|
452
|
+
if (listing.truncated && (!listing.nextMarker || listing.nextMarker === branch.marker)) throw new Error('目录分页异常,请重新打开选择器。');
|
|
453
|
+
const paths = new Set([...(listing.directories || []),...(listing.objects || []).filter(row => row.key.endsWith('/')).map(row => row.key)]);
|
|
454
|
+
for (const path of [...paths].sort((a,b) => a.localeCompare(b))) {
|
|
455
|
+
if (path.startsWith(branch.path) && /^[^/]+\/$/.test(path.slice(branch.path.length))) addDestinationBranch(tree,path,branch);
|
|
456
|
+
}
|
|
457
|
+
branch.loaded = true; branch.marker = listing.truncated ? listing.nextMarker : '';
|
|
458
|
+
branch.status.textContent = branch.children.children.length ? '' : branch.marker ? '本页暂无目录,可继续加载。' : '暂无子目录';
|
|
459
|
+
branch.more.textContent = '加载更多目录'; branch.more.hidden = !branch.expanded || !branch.marker;
|
|
460
|
+
syncDestinationSelection();
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (tree !== destinationTree || error.name === 'AbortError') return;
|
|
463
|
+
branch.status.textContent = error.message || '目录读取失败,可继续手动输入。'; branch.more.textContent = '重试'; branch.more.hidden = !branch.expanded;
|
|
464
|
+
} finally { branch.loading = false; }
|
|
465
|
+
}
|
|
466
|
+
function openDestinationMenu(event, tree, branch, anchor) {
|
|
467
|
+
event.preventDefault(); event.stopPropagation(); closeFileMenu();
|
|
468
|
+
if (tree !== destinationTree || state.mutating || state.stopping || branch.choose.disabled) return;
|
|
469
|
+
const menu = $('file-menu'); menu.replaceChildren(); menuOwner = branch.item;
|
|
470
|
+
// A modal dialog makes body-level menus inert; keep the menu and tooltip in its top layer.
|
|
471
|
+
$('operation-dialog').append(menu,$('permission-tip'));
|
|
472
|
+
const create = node('button','创建子目录…'); create.setAttribute('role','menuitem'); create.setAttribute('aria-disabled',String(!tree.writable));
|
|
473
|
+
create.onblur = hidePermissionTip;
|
|
474
|
+
create.onclick = () => {
|
|
475
|
+
if (tree !== destinationTree || state.mutating || state.stopping) return;
|
|
476
|
+
if (!tree.writable) { showPermissionTip(create,['写入权限(oss:PutObject)']); return; }
|
|
477
|
+
closeFileMenu();
|
|
478
|
+
for (const other of tree.nodes.values()) other.editor.hidden = true;
|
|
479
|
+
branch.expanded = true; branch.children.hidden = false; branch.item.setAttribute('aria-expanded','true'); branch.toggle.textContent = '⌄'; branch.toggle.setAttribute('aria-label','收起目录');
|
|
480
|
+
branch.status.hidden = false; branch.status.textContent = ''; branch.more.hidden = !branch.marker;
|
|
481
|
+
branch.editor.hidden = false; branch.nameInput.value = ''; branch.nameInput.focus();
|
|
482
|
+
};
|
|
483
|
+
menu.append(create); menu.hidden = false;
|
|
484
|
+
const bounds = anchor?.getBoundingClientRect();
|
|
485
|
+
menu.style.left = Math.max(8,Math.min(bounds ? bounds.left : event.clientX,window.innerWidth - menu.offsetWidth - 8)) + 'px';
|
|
486
|
+
menu.style.top = Math.max(8,Math.min(bounds ? bounds.bottom : event.clientY,window.innerHeight - menu.offsetHeight - 8)) + 'px';
|
|
487
|
+
create.focus();
|
|
488
|
+
}
|
|
489
|
+
async function createDestinationDirectory(tree, branch) {
|
|
490
|
+
if (tree !== destinationTree || !tree.writable || state.mutating || state.stopping || branch.choose.disabled) return;
|
|
491
|
+
const name = branch.nameInput.value.trim();
|
|
492
|
+
if (!name || name === '.' || name === '..' || /[\\/\u0000-\u001f\u007f]/.test(name)) { branch.status.textContent = '请输入有效的目录名,不包含斜杠。'; return; }
|
|
493
|
+
const destination = branch.path + name + '/';
|
|
494
|
+
if (destination.length > 1024) { branch.status.textContent = '目录路径过长,请缩短名称。'; return; }
|
|
495
|
+
state.mutating = true; controls(); $('operation-confirm').disabled = true;
|
|
496
|
+
branch.save.disabled = true; branch.cancel.disabled = true; branch.nameInput.disabled = true; branch.status.textContent = '正在创建目录…';
|
|
191
497
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
498
|
+
const data = await api('operations',{}, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind:'mkdir',destination}),signal:tree.controller.signal},tree.target.id);
|
|
499
|
+
const deadline = Date.now() + 60000;
|
|
500
|
+
for (;;) {
|
|
501
|
+
const result = await api('operations',{}, {signal:tree.controller.signal},tree.target.id);
|
|
502
|
+
const job = result.operations?.find(job => job.id === data.operation.id);
|
|
503
|
+
if (job?.status === 'completed') break;
|
|
504
|
+
if (!job || ['failed','stopped'].includes(job.status)) throw new Error(job?.error || '目录创建未完成,请检查任务状态。');
|
|
505
|
+
if (Date.now() >= deadline) throw new Error('目录仍在创建,请稍后刷新目录核对结果。');
|
|
506
|
+
await new Promise(resolve => setTimeout(resolve,300));
|
|
198
507
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
508
|
+
if (tree !== destinationTree) return;
|
|
509
|
+
branch.editor.hidden = true; branch.status.textContent = '';
|
|
510
|
+
if (!branch.loaded) await loadDestinationBranch(tree,branch);
|
|
511
|
+
if (tree !== destinationTree) return;
|
|
512
|
+
const child = addDestinationBranch(tree,destination,branch);
|
|
513
|
+
$('operation-value').value = destination.slice(tree.target.root.length); syncDestinationSelection();
|
|
514
|
+
if (!child.expanded) await child.toggle.onclick();
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (tree === destinationTree && error.name !== 'AbortError') branch.status.textContent = error.message || '目录创建失败,请核对后重试。';
|
|
517
|
+
} finally {
|
|
518
|
+
state.mutating = false; controls(); $('operation-confirm').disabled = false;
|
|
519
|
+
branch.save.disabled = false; branch.cancel.disabled = false; branch.nameInput.disabled = false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function showDestinationTree(target) {
|
|
523
|
+
clearDestinationTree(); $('destination-picker').hidden = false;
|
|
524
|
+
$('destination-hint').textContent = state.session?.list ? '点选目标目录,也可以直接输入路径;右键目录可创建子目录。' : '当前授权无法列举目录,请在下方直接输入目标路径。';
|
|
525
|
+
if (!state.session?.list) { $('destination-tree').hidden = true; return; }
|
|
526
|
+
const tree = {target,writable:!!state.session?.write,controller:new AbortController(),nodes:new Map(),box:$('destination-tree')}; destinationTree = tree; tree.box.hidden = false;
|
|
527
|
+
const root = addDestinationBranch(tree,target.root,null);
|
|
528
|
+
root.expanded = true; root.children.hidden = false; root.item.setAttribute('aria-expanded','true'); root.toggle.textContent = '⌄'; root.toggle.setAttribute('aria-label','收起授权根目录');
|
|
529
|
+
syncDestinationSelection(); void loadDestinationBranch(tree,root);
|
|
530
|
+
}
|
|
531
|
+
let operationInput = null;
|
|
532
|
+
function askOperation(kind, row) {
|
|
533
|
+
if (state.stopping || state.mutating || remaining() <= 0) return;
|
|
534
|
+
clearDestinationTree();
|
|
535
|
+
operationInput = {kind,row,id:state.id,dir:state.dir,root:state.root};
|
|
536
|
+
const name = row?.key.replace(/\/$/,'').split('/').pop();
|
|
537
|
+
$('operation-title').textContent = {mkdir:'创建目录',copy:'拷贝到…',move:'移动到…',rename:'重命名'}[kind];
|
|
538
|
+
$('operation-source').textContent = 'Grant #' + state.id + ' · ' + state.session.bucket + ' / ' + (row?.key || state.dir);
|
|
539
|
+
const named = kind === 'mkdir' || kind === 'rename';
|
|
540
|
+
$('operation-label').textContent = named ? '名称' : '目标目录(相对于当前授权根目录)';
|
|
541
|
+
$('operation-value').value = kind === 'rename' ? name : named ? '' : state.dir.slice(state.root.length);
|
|
542
|
+
$('operation-help').textContent = named ? '输入单个名称,不包含斜杠;已有同名目标会停止操作。' : '例如 archive/2026/,留空表示授权根目录。保留原文件或文件夹名称。文件夹包含所有子文件;已有同名目标会停止操作。';
|
|
543
|
+
if (kind === 'move' || kind === 'rename') $('operation-help').textContent += ' 复制成功后删除源对象;部分完成不会回滚,执行期间请勿修改源文件。';
|
|
544
|
+
if (!named) showDestinationTree(operationInput);
|
|
545
|
+
$('operation-error').textContent = ''; $('operation-confirm').disabled = false; $('operation-dialog').showModal(); $('operation-value').focus();
|
|
546
|
+
}
|
|
547
|
+
async function confirmOperation() {
|
|
548
|
+
const target = operationInput; if (!target || state.mutating || state.stopping) return;
|
|
549
|
+
const value = $('operation-value').value.trim(), named = target.kind === 'mkdir' || target.kind === 'rename';
|
|
550
|
+
if (named && (!value || /[\\/]/.test(value) || value === '.' || value === '..') || !named && (value.startsWith('/') || value.split('/').some(part => part === '.' || part === '..'))) { $('operation-error').textContent = '请输入有效的名称或授权范围内的相对目录。'; return; }
|
|
551
|
+
const destination = target.kind === 'mkdir' ? target.dir + value + '/' : target.kind === 'rename' ? value : target.root + value.replace(/\/+$/,'') + (value ? '/' : '');
|
|
552
|
+
const input = {kind:target.kind,destination,...(target.row ? {entries:[{key:target.row.key,directory:target.row.directory}]} : {})};
|
|
553
|
+
state.mutating = true; controls(); $('operation-confirm').disabled = true;
|
|
554
|
+
try { await submitOperation(input,target.id); operationInput = null; clearDestinationTree(); $('operation-dialog').close(); }
|
|
555
|
+
catch (error) { $('operation-error').textContent = error.message; }
|
|
556
|
+
finally { state.mutating = false; controls(); $('operation-confirm').disabled = false; }
|
|
202
557
|
}
|
|
558
|
+
let pollingOperations = false;
|
|
559
|
+
const operationCards = new Map();
|
|
560
|
+
async function pollOperations() {
|
|
561
|
+
if (!token || pollingOperations || state.stopping) return;
|
|
562
|
+
pollingOperations = true;
|
|
563
|
+
try {
|
|
564
|
+
const data = await api('operations');
|
|
565
|
+
const jobs = data.operations || []; const ids = new Set(jobs.map(job => job.id));
|
|
566
|
+
for (const [id,card] of operationCards) if (!ids.has(id)) { card.row.remove(); operationCards.delete(id); }
|
|
567
|
+
for (const job of jobs) {
|
|
568
|
+
let card = operationCards.get(job.id);
|
|
569
|
+
if (!card) {
|
|
570
|
+
const row = node('div',undefined,'operation-row'), text = node('span'), button = node('button','停止'); row.append(text,button); $('operations-list').append(row);
|
|
571
|
+
button.onclick = async () => { button.disabled = true; try { await api('operations/' + encodeURIComponent(job.id) + '/stop',{}, {method:'POST'}); } catch (error) { fail(error); } finally { button.disabled = false; await pollOperations(); } };
|
|
572
|
+
card = {row,text,button}; operationCards.set(job.id,card);
|
|
573
|
+
}
|
|
574
|
+
const live = ['running','stopping'].includes(job.status);
|
|
575
|
+
const label = {delete:'删除',copy:'复制',move:'移动',rename:'重命名',mkdir:'创建目录'}[job.kind];
|
|
576
|
+
card.text.textContent = 'Grant #' + job.grantId + ' · ' + label + ' · ' + ({running:'进行中',stopping:'正在停止',completed:'已完成',stopped:'已停止',failed:'失败'}[job.status]) + ' · 已扫描 ' + job.scanned + ' / 已确认完成 ' + job.done + ' / 失败或未确认 ' + job.failed + (job.copied ? ' · 已复制 ' + job.copied : '') + (job.error ? ' · ' + job.error : '');
|
|
577
|
+
card.text.title = job.currentKey || ''; card.button.hidden = !live; card.button.disabled = job.status === 'stopping';
|
|
578
|
+
if (card.status && card.status !== job.status && !live && job.grantId === state.id) notice('文件操作已结束。点击刷新查看最新列表;已完成的操作不会回滚。', job.status === 'completed');
|
|
579
|
+
card.status = job.status;
|
|
580
|
+
}
|
|
581
|
+
$('operations-panel').hidden = !jobs.length;
|
|
582
|
+
} catch (error) { if (operationCards.size) notice(error.message); }
|
|
583
|
+
finally { pollingOperations = false; }
|
|
584
|
+
}
|
|
585
|
+
$('mkdir').onclick = () => askOperation('mkdir');
|
|
586
|
+
$('operation-cancel').onclick = () => { if (!state.mutating) { operationInput = null; clearDestinationTree(); $('operation-dialog').close(); } };
|
|
587
|
+
$('operation-dialog').oncancel = event => { if (state.mutating) { event.preventDefault(); return; } operationInput = null; clearDestinationTree(); };
|
|
588
|
+
$('operation-value').oninput = syncDestinationSelection;
|
|
589
|
+
$('operation-confirm').onclick = confirmOperation;
|
|
590
|
+
$('operation-value').onkeydown = event => { if (event.key === 'Enter') { event.preventDefault(); confirmOperation(); } };
|
|
591
|
+
document.addEventListener('click', event => { if (!$('file-menu').contains(event.target)) closeFileMenu(); });
|
|
592
|
+
document.addEventListener('keydown', event => {
|
|
593
|
+
const menu = $('file-menu'); if (menu.hidden) return;
|
|
594
|
+
if (event.key === 'Escape' || event.key === 'Tab') { if (event.key === 'Escape') event.preventDefault(); closeFileMenu(true); return; }
|
|
595
|
+
if (['ArrowDown','ArrowUp','Home','End'].includes(event.key)) {
|
|
596
|
+
event.preventDefault(); const buttons = [...menu.children].filter(button => !button.disabled), index = buttons.indexOf(document.activeElement);
|
|
597
|
+
const next = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (index + (event.key === 'ArrowDown' ? 1 : buttons.length - 1)) % buttons.length;
|
|
598
|
+
buttons[next]?.focus();
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
document.addEventListener('scroll', () => closeFileMenu(), true);
|
|
602
|
+
setInterval(pollOperations, 1000);
|
|
603
|
+
let pollingService = false;
|
|
604
|
+
async function pollService() {
|
|
605
|
+
if (!token || pollingService || state.stopped) return;
|
|
606
|
+
pollingService = true;
|
|
607
|
+
try {
|
|
608
|
+
const data = await api('service', {}, { signal: AbortSignal.timeout(3000) });
|
|
609
|
+
const info = data.service;
|
|
610
|
+
$('service-status').textContent = 'PID ' + info.pid + ' · ' + (info.stopping ? '正在停止' : info.tasks.running || info.activeOperations ? '传输 ' + info.tasks.running + ' · 文件操作 ' + (info.activeOperations || 0) : '空闲');
|
|
611
|
+
$('service-status').title = '服务启动于 ' + date(info.startedAt) + ' · 排队 ' + info.tasks.queued + ' · 暂停 ' + info.tasks.paused;
|
|
612
|
+
if (info.stopping) { state.stopping = true; controls(); }
|
|
613
|
+
} catch {
|
|
614
|
+
$('service-status').textContent = state.stopping ? '本机服务已断开' : '本机连接不可用';
|
|
615
|
+
if (state.stopping) { state.stopped = true; notice('本机服务已断开。重新运行 numa oss web 可打开服务并恢复任务。', true); }
|
|
616
|
+
} finally { pollingService = false; }
|
|
617
|
+
}
|
|
618
|
+
async function stopService() {
|
|
619
|
+
if (state.stopping) return;
|
|
620
|
+
$('stop-dialog').close(); state.stopping = true; controls();
|
|
621
|
+
notice('正在请求停止服务,传输任务将暂停并保存断点…');
|
|
622
|
+
try { await api('shutdown', {}, { method: 'POST', headers: { 'X-Numa-Confirm': 'stop-service' } }); }
|
|
623
|
+
catch (error) { state.stopping = false; controls(); fail(error); }
|
|
624
|
+
}
|
|
625
|
+
$('service-stop').onclick = () => { if (token && !state.stopping) $('stop-dialog').showModal(); };
|
|
626
|
+
$('stop-cancel').onclick = () => $('stop-dialog').close();
|
|
627
|
+
$('stop-confirm').onclick = stopService;
|
|
203
628
|
$('grant').onchange = () => selectGrant($('grant').value);
|
|
204
629
|
$('grants-refresh').onclick = () => { if (!state.mutating) refreshGrants(); };
|
|
205
630
|
$('renew').onclick = () => renew(true);
|
|
@@ -214,20 +639,40 @@ $('up').onclick = () => { const parts = state.dir.slice(state.root.length).split
|
|
|
214
639
|
$('refresh').onclick = () => { notice(''); navigate(state.dir); };
|
|
215
640
|
$('search').oninput = renderRows;
|
|
216
641
|
$('more').onclick = () => navigate(state.dir, true);
|
|
217
|
-
$('select-all').onchange = () => { for (const row of visible()
|
|
218
|
-
|
|
642
|
+
$('select-all').onchange = () => { for (const row of visible()) { if ($('select-all').checked) state.selected.add(row.key); else state.selected.delete(row.key); } renderRows(); };
|
|
643
|
+
const collapsedPanels = {sidebar:false,preview:false};
|
|
644
|
+
function setPanelCollapsed(panel, collapsed, persist = true) {
|
|
645
|
+
collapsedPanels[panel] = collapsed;
|
|
646
|
+
const name = panel === 'sidebar' ? '存储位置' : '文件预览';
|
|
647
|
+
const button = $(panel === 'sidebar' ? 'sidebar-toggle' : 'preview-close');
|
|
648
|
+
$(panel + '-content').hidden = collapsed;
|
|
649
|
+
$('workspace').setAttribute('data-' + panel + '-collapsed',String(collapsed));
|
|
650
|
+
button.setAttribute('aria-expanded',String(!collapsed)); button.setAttribute('aria-label',(collapsed ? '展开' : '折叠') + name); button.title = (collapsed ? '展开' : '折叠') + name;
|
|
651
|
+
button.textContent = panel === 'sidebar' ? (collapsed ? '›' : '‹') : (collapsed ? '‹' : '›');
|
|
652
|
+
if (persist) { try { sessionStorage.setItem('numa-oss-panel-' + panel,String(collapsed)); } catch {} }
|
|
653
|
+
}
|
|
654
|
+
for (const panel of ['sidebar','preview']) {
|
|
655
|
+
let collapsed = false;
|
|
656
|
+
try { collapsed = sessionStorage.getItem('numa-oss-panel-' + panel) === 'true'; } catch {}
|
|
657
|
+
setPanelCollapsed(panel,collapsed,false);
|
|
658
|
+
}
|
|
659
|
+
$('sidebar-toggle').onclick = () => setPanelCollapsed('sidebar',!collapsedPanels.sidebar);
|
|
660
|
+
$('preview-close').onclick = () => setPanelCollapsed('preview',!collapsedPanels.preview);
|
|
219
661
|
$('download').onclick = () => { if (state.file) download([state.file.key]); };
|
|
220
662
|
$('download-selected').onclick = () => download([...state.selected]);
|
|
221
663
|
$('delete').onclick = () => { if (state.file) askDelete([state.file.key]); };
|
|
222
664
|
$('delete-selected').onclick = () => askDelete([...state.selected]);
|
|
223
665
|
$('delete-cancel').onclick = () => { deletion = null; $('delete-dialog').close(); };
|
|
224
666
|
$('delete-confirm').onclick = deleteFiles;
|
|
225
|
-
$('upload').onclick = () =>
|
|
226
|
-
$('
|
|
227
|
-
$('
|
|
667
|
+
$('upload').onclick = () => { if (state.session?.write) chooseTransfer('transfers/upload', { prefix: state.dir, folder: false }); };
|
|
668
|
+
$('upload-folder').onclick = () => { if (state.session?.write) chooseTransfer('transfers/upload', { prefix: state.dir, folder: true }); };
|
|
669
|
+
$('tasks-toggle').onclick = () => showTasks($('tasks-panel').hidden);
|
|
670
|
+
$('tasks-close').onclick = () => { showTasks(false); $('tasks-toggle').focus(); };
|
|
671
|
+
setInterval(pollService, 3000);
|
|
672
|
+
setInterval(pollTransfers, 1000);
|
|
228
673
|
setInterval(tick, 1000);
|
|
229
674
|
setInterval(() => { if (state.id && !state.loading) renew(false); }, 15000);
|
|
230
675
|
if (!token) { notice('请通过 numa oss web 启动,并使用 CLI 显示的完整链接打开页面。'); $('empty').textContent = '等待本机会话'; }
|
|
231
|
-
else refreshGrants(true);
|
|
676
|
+
else { refreshGrants(true); pollTransfers(); pollOperations(); pollService(); }
|
|
232
677
|
})();`;
|
|
233
678
|
//# sourceMappingURL=web-page.js.map
|