@numa-tech/numa 1.19.0 → 1.20.1

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.
Files changed (120) hide show
  1. package/README.md +57 -3
  2. package/dist/ai-registry/bridge.js +3 -2
  3. package/dist/ai-registry/bridge.js.map +1 -1
  4. package/dist/ai-registry/client.d.ts +8 -1
  5. package/dist/ai-registry/client.js +11 -1
  6. package/dist/ai-registry/client.js.map +1 -1
  7. package/dist/ai-registry/commands.d.ts +3 -0
  8. package/dist/ai-registry/commands.js +48 -36
  9. package/dist/ai-registry/commands.js.map +1 -1
  10. package/dist/ai-registry/mcp-management.d.ts +2 -0
  11. package/dist/ai-registry/mcp-management.js +74 -0
  12. package/dist/ai-registry/mcp-management.js.map +1 -0
  13. package/dist/ai-registry/mcp-runtime-commands.d.ts +15 -0
  14. package/dist/ai-registry/mcp-runtime-commands.js +101 -0
  15. package/dist/ai-registry/mcp-runtime-commands.js.map +1 -0
  16. package/dist/ai-registry/mcp-runtime.d.ts +37 -0
  17. package/dist/ai-registry/mcp-runtime.js +134 -0
  18. package/dist/ai-registry/mcp-runtime.js.map +1 -0
  19. package/dist/ai-registry/mcp-stdio.d.ts +2 -0
  20. package/dist/ai-registry/mcp-stdio.js +18 -0
  21. package/dist/ai-registry/mcp-stdio.js.map +1 -0
  22. package/dist/authorization/application-client.d.ts +1271 -0
  23. package/dist/authorization/application-client.js +391 -0
  24. package/dist/authorization/application-client.js.map +1 -0
  25. package/dist/authorization/application-commands.d.ts +15 -0
  26. package/dist/authorization/application-commands.js +240 -0
  27. package/dist/authorization/application-commands.js.map +1 -0
  28. package/dist/authorization/application-schemas.d.ts +731 -0
  29. package/dist/authorization/application-schemas.js +521 -0
  30. package/dist/authorization/application-schemas.js.map +1 -0
  31. package/dist/authorization/{client.d.ts → bootstrap-client.d.ts} +5 -26
  32. package/dist/authorization/{client.js → bootstrap-client.js} +4 -27
  33. package/dist/authorization/bootstrap-client.js.map +1 -0
  34. package/dist/authorization/commands.d.ts +6 -21
  35. package/dist/authorization/commands.js +8 -130
  36. package/dist/authorization/commands.js.map +1 -1
  37. package/dist/authorization/enrollment-client.d.ts +40 -0
  38. package/dist/authorization/enrollment-client.js +56 -0
  39. package/dist/authorization/enrollment-client.js.map +1 -0
  40. package/dist/authorization/enrollment-commands.d.ts +5 -0
  41. package/dist/authorization/enrollment-commands.js +30 -0
  42. package/dist/authorization/enrollment-commands.js.map +1 -0
  43. package/dist/authorization/enrollment.d.ts +2 -2
  44. package/dist/authorization/enrollment.js +2 -2
  45. package/dist/authorization/enrollment.js.map +1 -1
  46. package/dist/authorization/schemas.d.ts +0 -90
  47. package/dist/authorization/schemas.js +0 -48
  48. package/dist/authorization/schemas.js.map +1 -1
  49. package/dist/backend.d.ts +1 -0
  50. package/dist/backend.js +4 -1
  51. package/dist/backend.js.map +1 -1
  52. package/dist/business-issuer.d.ts +4 -0
  53. package/dist/business-issuer.js +30 -0
  54. package/dist/business-issuer.js.map +1 -0
  55. package/dist/cli.js +61 -30
  56. package/dist/cli.js.map +1 -1
  57. package/dist/cloud/bootstrap.js +1 -1
  58. package/dist/cloud/bootstrap.js.map +1 -1
  59. package/dist/command-catalog.js +11 -36
  60. package/dist/command-catalog.js.map +1 -1
  61. package/dist/config.d.ts +1 -1
  62. package/dist/config.js +15 -6
  63. package/dist/config.js.map +1 -1
  64. package/dist/login-configuration.d.ts +8 -0
  65. package/dist/login-configuration.js +17 -0
  66. package/dist/login-configuration.js.map +1 -0
  67. package/dist/login-recovery.d.ts +2 -0
  68. package/dist/login-recovery.js +15 -0
  69. package/dist/login-recovery.js.map +1 -0
  70. package/dist/mcp/commands.js +12 -2
  71. package/dist/mcp/commands.js.map +1 -1
  72. package/dist/mcp-gateway/commands.d.ts +2 -0
  73. package/dist/mcp-gateway/commands.js +11 -0
  74. package/dist/mcp-gateway/commands.js.map +1 -1
  75. package/dist/oss/commands.d.ts +2 -1
  76. package/dist/oss/commands.js +55 -37
  77. package/dist/oss/commands.js.map +1 -1
  78. package/dist/oss/file-operations.d.ts +57 -0
  79. package/dist/oss/file-operations.js +269 -0
  80. package/dist/oss/file-operations.js.map +1 -0
  81. package/dist/oss/local-picker.d.ts +2 -0
  82. package/dist/oss/local-picker.js +6 -0
  83. package/dist/oss/local-picker.js.map +1 -1
  84. package/dist/oss/tools.js +1 -1
  85. package/dist/oss/tools.js.map +1 -1
  86. package/dist/oss/transfer-queue.js +1 -1
  87. package/dist/oss/transfer-queue.js.map +1 -1
  88. package/dist/oss/transfers.d.ts +7 -0
  89. package/dist/oss/transfers.js +87 -0
  90. package/dist/oss/transfers.js.map +1 -1
  91. package/dist/oss/web-page.js +365 -30
  92. package/dist/oss/web-page.js.map +1 -1
  93. package/dist/oss/web-service.d.ts +45 -0
  94. package/dist/oss/web-service.js +107 -0
  95. package/dist/oss/web-service.js.map +1 -0
  96. package/dist/oss/web.d.ts +10 -0
  97. package/dist/oss/web.js +117 -15
  98. package/dist/oss/web.js.map +1 -1
  99. package/dist/platform-profile.d.ts +22 -2
  100. package/dist/platform-profile.js +67 -6
  101. package/dist/platform-profile.js.map +1 -1
  102. package/docs/application-default-groups.md +73 -0
  103. package/examples/cloud-bootstrap/README.md +1 -1
  104. package/examples/mcp/README.md +108 -0
  105. package/examples/mcp/management-smoke.py +154 -0
  106. package/examples/mcp/runtime-smoke.mjs +187 -0
  107. package/examples/mcp/server.json +17 -0
  108. package/examples/oss/README.md +78 -0
  109. package/examples/oss/__pycache__/roundtrip.cpython-314.pyc +0 -0
  110. package/examples/oss/__pycache__/test_roundtrip.cpython-314.pyc +0 -0
  111. package/examples/oss/roundtrip.py +84 -0
  112. package/examples/oss/test_roundtrip.py +66 -0
  113. package/package.json +6 -5
  114. package/dist/authorization/client.js.map +0 -1
  115. package/dist/authorization/group-client.d.ts +0 -169
  116. package/dist/authorization/group-client.js +0 -129
  117. package/dist/authorization/group-client.js.map +0 -1
  118. package/dist/authorization/group-commands.d.ts +0 -25
  119. package/dist/authorization/group-commands.js +0 -155
  120. package/dist/authorization/group-commands.js.map +0 -1
@@ -3,23 +3,34 @@ import { OSS_REFRESH_ICON, OSS_COPY_ICON } from "./web-icons.js";
3
3
  export const OSS_WEB_HTML = String.raw `<!doctype html>
4
4
  <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
5
  <title>Numa · OSS 文件</title><link rel="icon" href="data:,"><link rel="stylesheet" href="/app.css"><script src="/app.js" defer></script></head>
6
- <body><header class="topbar"><a class="brand" href="/">numa<span> / OSS</span></a><span class="local"><i></i> 本机连接</span><span class="topnote">文件工作区</span><button id="tasks-toggle" aria-controls="tasks-panel" aria-expanded="false">传输任务 <span id="tasks-count">0</span></button></header>
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
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>
8
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>
9
9
  <div id="notice" class="notice" role="status" hidden></div>
10
- <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>
11
- <main class="files"><div class="page-heading"><div><div class="eyebrow">OBJECT STORAGE</div><h1>文件</h1></div><div class="upload-actions"><button id="upload-folder" disabled>上传文件夹</button><button id="upload" class="primary" disabled>+ 上传文件</button></div></div>
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
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>
13
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>
14
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>
15
15
  <footer class="list-footer"><span id="list-summary">—</span><button id="more" hidden>加载更多</button></footer>
16
16
 
17
- </main><aside class="preview-panel"><div class="preview-heading"><h2>文件预览</h2><button id="preview-close" class="quiet" aria-label="关闭预览">×</button></div><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></aside></div>
18
- <dialog id="delete-dialog"><h2>删除所选文件?</h2><p>此操作会删除远程 OSS 对象,不能在此页面撤销。</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>
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
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>
20
25
  <template id="copy-icon">${OSS_COPY_ICON}</template>
21
26
  </body></html>`;
22
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
+
23
34
  .icon-button{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;min-width:34px;min-height:34px}
24
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}}
25
36
  /* Independent scrolling keeps file actions within the current viewport. */
@@ -45,7 +56,26 @@ body{height:100vh;height:100dvh;display:flex;flex-direction:column;overflow:hidd
45
56
  @media(prefers-reduced-motion:reduce){.is-loading svg{animation:none}.is-loading{opacity:.55}}
46
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}}
47
58
 
48
- .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}}
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
+ }
49
79
  `;
50
80
  export const OSS_WEB_JS = String.raw `(() => {
51
81
  'use strict';
@@ -55,7 +85,7 @@ const supplied = fragment.get('token');
55
85
  let token = supplied || sessionStorage.getItem('numa-oss-web-token');
56
86
  if (supplied) { sessionStorage.setItem('numa-oss-web-token', supplied); history.replaceState(null, '', location.pathname); }
57
87
  const state = { id: 0, epoch: 0, dir: '', root: '', rows: [], marker: '', selected: new Set(), file: null,
58
- session: null, offset: 0, ready: false, loading: false, mutating: false, controller: null, previewController: null, renewalBusy: false, grantsLoading: false };
88
+ stopping: false, stopped: false, session: null, offset: 0, ready: false, loading: false, mutating: false, controller: null, previewController: null, renewalBusy: false, grantsLoading: false };
59
89
  const node = (tag, text, className) => { const el = document.createElement(tag); if (text !== undefined) el.textContent = text; if (className) el.className = className; return el; };
60
90
  function notice(text, success = false) { $('notice').textContent = text; $('notice').className = 'notice' + (success ? ' success' : ''); $('notice').hidden = !text; }
61
91
  function setBusy(id, busy) {
@@ -94,18 +124,20 @@ function bytes(value) { if (!Number.isFinite(value)) return '—'; if (value < 1
94
124
  function date(value) { if (!value) return '—'; const d = new Date(value); return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString('zh-CN', { hour12: false }); }
95
125
  function remaining() { return state.session ? Math.max(0, Math.ceil((Date.parse(state.session.expiration) - Date.now() - state.offset) / 1000)) : 0; }
96
126
  function controls() {
97
- const active = state.ready && remaining() > 0 && !state.mutating;
127
+ const active = state.ready && remaining() > 0 && !state.mutating && !state.stopping;
98
128
  $('upload').disabled = !active || !state.session?.write;
99
129
  $('upload-folder').disabled = $('upload').disabled;
130
+ $('mkdir').disabled = !active || !state.session?.write || !state.session?.list;
100
131
  $('download').disabled = !active || !state.file || !state.session?.read;
101
132
  $('delete').disabled = !active || !state.file || !state.session?.delete;
102
133
  $('download-selected').disabled = !active || !state.selected.size || !state.session?.read;
103
- $('delete-selected').disabled = !active || !state.selected.size || !state.session?.delete || state.rows.some(row => row.directory && state.selected.has(row.key));
104
- $('grant').disabled = state.mutating;
105
- $('grants-refresh').disabled = state.mutating || state.grantsLoading;
106
- $('refresh').disabled = state.mutating || state.loading || !state.session;
107
- $('renew').disabled = !state.id || state.renewalBusy;
108
- $('auto-renew').disabled = !state.session || state.renewalBusy;
134
+ $('delete-selected').disabled = !active || !state.selected.size || !state.session?.delete;
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;
109
141
  $('selection-count').textContent = state.selected.size ? '已选 ' + state.selected.size + ' 项' : '';
110
142
  }
111
143
  function tick() {
@@ -133,7 +165,7 @@ function visible() { const query = $('search').value.toLocaleLowerCase(); return
133
165
  function renderRows() {
134
166
  const body = $('rows'); body.replaceChildren(); const rows = visible();
135
167
  for (const row of rows) {
136
- 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';
137
169
  const checkCell = node('td', undefined, 'check-cell');
138
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); }
139
171
  tr.append(checkCell);
@@ -151,7 +183,8 @@ function renderRows() {
151
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; }
152
184
  function clearPreview() { state.previewController?.abort(); state.file = null; $('preview').replaceChildren(node('div', '选择文件以查看内容与信息', 'preview-empty')); controls(); }
153
185
  async function navigate(dir, append = false) {
154
- if (state.mutating) return;
186
+ closeFileMenu();
187
+ if (state.mutating || state.stopping) return;
155
188
  if (state.session && remaining() <= 0) { notice('STS 已到期,请点击立即续期后继续。'); return; }
156
189
  const epoch = state.epoch, id = state.id; state.controller?.abort(); state.controller = new AbortController();
157
190
  const controller = state.controller;
@@ -161,7 +194,7 @@ async function navigate(dir, append = false) {
161
194
  const data = await api('list', { prefix: dir, ...(append ? { marker: state.marker } : {}) }, { signal: controller.signal }, id);
162
195
  if (epoch !== state.epoch || controller !== state.controller) return;
163
196
  setSession(data.session);
164
- const entries = data.listing.directories.map(key => ({ key, directory: true })).concat(data.listing.objects.filter(r => r.key !== dir).map(r => ({ ...r, directory: false })));
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('/') })));
165
198
  const merged = new Map((append ? state.rows : []).concat(entries).map(r => [r.key, r]));
166
199
  state.rows = [...merged.values()].sort((a,b) => Number(b.directory) - Number(a.directory) || a.key.localeCompare(b.key));
167
200
  state.marker = data.listing.truncated ? data.listing.nextMarker || '' : '';
@@ -169,6 +202,7 @@ async function navigate(dir, append = false) {
169
202
  finally { if (epoch === state.epoch && controller === state.controller) { state.loading = false; setBusy('refresh', false); renderRows(); breadcrumbs(); } }
170
203
  }
171
204
  async function preview(row) {
205
+ setPanelCollapsed('preview',false);
172
206
  clearPreview(); state.file = row; renderRows();
173
207
  const epoch = state.epoch, id = state.id; state.previewController = new AbortController(); const controller = state.previewController;
174
208
  $('preview').replaceChildren(node('h3', row.key.split('/').pop()), node('p', '正在读取预览…'));
@@ -220,7 +254,7 @@ async function refreshGrants(initial = false) {
220
254
  finally { state.grantsLoading = false; setBusy('grants-refresh', false); controls(); }
221
255
  }
222
256
  async function renew(manual = false) {
223
- if (!state.id || state.renewalBusy) return;
257
+ if (!state.id || state.renewalBusy || state.stopping) return;
224
258
  const epoch = state.epoch, id = state.id; state.renewalBusy = true; controls();
225
259
  if (manual) $('renew').textContent = '续期中…';
226
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); } }
@@ -250,7 +284,7 @@ const taskLabels = { queued:'排队中', running:'传输中', pausing:'正在暂
250
284
  function showTasks(show) { $('tasks-panel').hidden = !show; $('tasks-toggle').setAttribute('aria-expanded', String(show)); }
251
285
  function setText(element, value) { if (element.textContent !== value) element.textContent = value; }
252
286
  async function taskAction(id, action) {
253
- if (taskBusy.has(id)) return;
287
+ if (taskBusy.has(id) || state.stopping) return;
254
288
  taskBusy.add(id); const card = taskCards.get(id); if (card) for (const button of card.actions.values()) button.disabled = true;
255
289
  try { await api('transfers/' + encodeURIComponent(id) + '/' + action, {}, { method:'POST' }); }
256
290
  catch (error) { fail(error); }
@@ -287,7 +321,7 @@ function renderTasks(jobs) {
287
321
  if (pendingUploadNotice && !state.mutating && !state.loading) { pendingUploadNotice = false; notice('当前授权有上传任务已完成。点击刷新文件列表查看新文件;当前预览已保留。', true); }
288
322
  }
289
323
  async function pollTransfers() {
290
- if (!token || pollingTransfers) return;
324
+ if (!token || pollingTransfers || state.stopping) return;
291
325
  pollingTransfers = true;
292
326
  try { const data = await api('transfers'); renderTasks(data.jobs || []); $('tasks-connection').hidden = true; }
293
327
  catch (error) { setText($('tasks-connection'), error.message || '暂时无法读取任务状态。'); $('tasks-connection').hidden = false; }
@@ -295,19 +329,302 @@ async function pollTransfers() {
295
329
  }
296
330
  let deletion = null;
297
331
  function askDelete(keys) {
298
- if (!keys.length || state.mutating || !state.session?.delete || remaining() <= 0) return;
299
- if (keys.some(key => state.rows.find(row => row.key === key)?.directory)) { notice('目录仅支持下载。删除前请取消选择目录。'); return; }
300
- deletion = { keys, id: state.id, dir: state.dir, epoch: state.epoch };
301
- $('delete-list').replaceChildren(...keys.map(key => node('li', key)));
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) + ' 项…'));
302
339
  $('delete-target').textContent = 'Grant #' + state.id + ' · ' + state.session.bucket;
303
340
  $('delete-dialog').showModal();
304
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
+ }
305
348
  async function deleteFiles() {
306
- const target = deletion; if (!target) return; deletion = null; $('delete-dialog').close(); state.mutating = true; controls(); let done = 0;
307
- try { for (const key of target.keys) { await api('object', { key }, { method: 'DELETE', headers: { 'X-Numa-Confirm': 'delete' } }, target.id); done++; } notice('已删除 ' + done + ' 个文件。', true); }
308
- catch (error) { notice('已删除 ' + done + ' 个文件,后续操作已停止。' + error.message); }
309
- finally { state.mutating = false; controls(); if (state.epoch === target.epoch) await navigate(target.dir); }
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 = '正在创建目录…';
497
+ try {
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));
507
+ }
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; }
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; }
310
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;
311
628
  $('grant').onchange = () => selectGrant($('grant').value);
312
629
  $('grants-refresh').onclick = () => { if (!state.mutating) refreshGrants(); };
313
630
  $('renew').onclick = () => renew(true);
@@ -323,7 +640,24 @@ $('refresh').onclick = () => { notice(''); navigate(state.dir); };
323
640
  $('search').oninput = renderRows;
324
641
  $('more').onclick = () => navigate(state.dir, true);
325
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(); };
326
- $('preview-close').onclick = () => { clearPreview(); 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);
327
661
  $('download').onclick = () => { if (state.file) download([state.file.key]); };
328
662
  $('download-selected').onclick = () => download([...state.selected]);
329
663
  $('delete').onclick = () => { if (state.file) askDelete([state.file.key]); };
@@ -334,10 +668,11 @@ $('upload').onclick = () => { if (state.session?.write) chooseTransfer('transfer
334
668
  $('upload-folder').onclick = () => { if (state.session?.write) chooseTransfer('transfers/upload', { prefix: state.dir, folder: true }); };
335
669
  $('tasks-toggle').onclick = () => showTasks($('tasks-panel').hidden);
336
670
  $('tasks-close').onclick = () => { showTasks(false); $('tasks-toggle').focus(); };
671
+ setInterval(pollService, 3000);
337
672
  setInterval(pollTransfers, 1000);
338
673
  setInterval(tick, 1000);
339
674
  setInterval(() => { if (state.id && !state.loading) renew(false); }, 15000);
340
675
  if (!token) { notice('请通过 numa oss web 启动,并使用 CLI 显示的完整链接打开页面。'); $('empty').textContent = '等待本机会话'; }
341
- else { refreshGrants(true); pollTransfers(); }
676
+ else { refreshGrants(true); pollTransfers(); pollOperations(); pollService(); }
342
677
  })();`;
343
678
  //# sourceMappingURL=web-page.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"web-page.js","sourceRoot":"","sources":["../../src/oss/web-page.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEjE,iGAAiG;AACjG,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC,GAAG,CAAA;;;;4PAIsN,gBAAgB;;;;;mOAKzC,gBAAgB;;;;;;;;2BAQxN,aAAa;eACzB,CAAC;AAEhB,MAAM,CAAC,MAAM,WAAW,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BpC,CAAC;AAEF,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MAoS9B,CAAC"}
1
+ {"version":3,"file":"web-page.js","sourceRoot":"","sources":["../../src/oss/web-page.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAEjE,iGAAiG;AACjG,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC,GAAG,CAAA;;;;4PAIsN,gBAAgB;;;;;mOAKzC,gBAAgB;;;;;;;;;;;;;2BAaxN,aAAa;eACzB,CAAC;AAEhB,MAAM,CAAC,MAAM,WAAW,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoDpC,CAAC;AAEF,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;MAqlB9B,CAAC"}