dsh-mcloud 0.0.0-stage → 0.2.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.
package/lib/client.js ADDED
@@ -0,0 +1,3694 @@
1
+ /**
2
+ * dsh-mcloud — Client half.
3
+ *
4
+ * 会话窗口顶部 View 标签条里的一个新标签「移动云盘」,选中后是一整条面板:
5
+ * 1. 授权:粘贴 yun.139.com 的 Authorization(Basic 后面的部分);ud_id 可选
6
+ * 2. 状态:账号、授权有效期、容量进度条、剩余空间
7
+ * 3. 文件:面包屑浏览、新建文件夹、重命名、删除、下载直链
8
+ *
9
+ * 只走 Host 的 /api/dsh-mcloud/* 路由;自己既不去读 Cookie,也不去碰磁盘。
10
+ * Host 契约见插件 README:status / auth / auth/clear / list / mkdir / mkdir-path / rename /
11
+ * move / remove / link / upload-body,所有 POST 都带 x-dsh-mcloud: 1。
12
+ *
13
+ * 之后又长出来三块(一样只走 Host):
14
+ * 4. 右键菜单:行上右键 → 打开 / 下载 / 重命名 / 删除;列表空白处右键 → 新建 / 刷新 / 上传
15
+ * 5. 下载:下载目录(localStorage 持久化,默认用 status.defaultDownloadDir)、
16
+ * 「下载到本地」(POST /download)、「下载整个文件夹」(POST /download-folder)
17
+ * 6. 上传(POST /upload)与传输面板(GET /transfers 轮询 + POST /transfer/cancel)——
18
+ * Host 的这三个 POST 立刻回 jobId,成败要等任务自己变成 state:'error',别把它吞掉。
19
+ * 7. 选文件用**浏览器自己的** <input type="file"> —— 不再让 Host spawn 系统对话框
20
+ * (那条路在 DSH 宿主里起不来,还抢焦点 / 卡 z-order):
21
+ * 「上传文件」→ multiple 多选,选完逐个 XMLHttpRequest POST /upload-body 上传;
22
+ * 「上传文件夹」→ webkitdirectory 选择,先按 webkitRelativePath 逐层
23
+ * POST /mkdir-path 把远端目录建出来,再把每个文件塞进它自己那一层;
24
+ * 拖拽区也复用同一条 /upload-body(原始字节),这样浏览器才拿得到
25
+ * 「浏览器→Host」这一段的上行进度(fetch 拿不到)。
26
+ * 取消文件对话框时浏览器不发任何事件,靠 window 的 focus 兜底恢复按钮。
27
+ */
28
+ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__ModuleLoader__.load === 'function') {
29
+ window.__ModuleLoader__.load({
30
+ id: 'dsh-mcloud',
31
+ factory: function (require) {
32
+ var React = require('react')
33
+ var h = React.createElement
34
+ /** 有 useLayoutEffect 就用它量菜单尺寸(免得菜单先闪一下再翻边)。 */
35
+ var useLayout = typeof React.useLayoutEffect === 'function' ? React.useLayoutEffect : React.useEffect
36
+
37
+ var NS = 'dsh-mcloud'
38
+ var BASE = '/api/dsh-mcloud'
39
+
40
+ // ---------------------------------------------------------------- 日志
41
+ function logWarn(scope, failure) {
42
+ if (typeof console === 'undefined' || typeof console.warn !== 'function') return
43
+ console.warn('[dsh-mcloud] ' + scope, failure)
44
+ }
45
+ function logError(scope, failure) {
46
+ if (typeof console === 'undefined' || typeof console.error !== 'function') return
47
+ console.error('[dsh-mcloud] ' + scope, failure)
48
+ }
49
+
50
+ // ---------------------------------------------------------------- 文案
51
+ var ZH = {
52
+ nav: '移动云盘',
53
+ title: '移动云盘',
54
+ subtitle: '中国移动云盘(139)的文件浏览与授权',
55
+ refresh: '刷新',
56
+ refreshFailed: '读取状态失败:{detail}',
57
+ account: '账号',
58
+ expires: '授权有效期',
59
+ expired: '已过期',
60
+ quota: '容量',
61
+ used: '已用',
62
+ free: '剩余空间',
63
+ total: '总容量',
64
+ unauthorized: '还没有授权',
65
+ notAuthorized: '还没有登录,请先登录。',
66
+ authorization: '授权',
67
+ quotaUnavailable: '容量:暂时不可用',
68
+ save: '保存并验证',
69
+ saving: '验证中…',
70
+ authOk: '授权成功',
71
+ // 登录页:账号密码 / 手动授权 两个标签
72
+ loginTitle: '登录移动云盘',
73
+ tabLogin: '账号密码登录',
74
+ tabScan: '扫码登录',
75
+ scanIntro: '点击下面的按钮,会弹出一个浏览器窗口,用手机扫码登录即可。',
76
+ scanStart: '开始扫码登录',
77
+ scanRetry: '重新扫码',
78
+ scanLaunching: '正在启动浏览器…',
79
+ scanWaiting: '请在弹出的浏览器里扫码登录',
80
+ scanDone: '登录成功',
81
+ scanCancelled: '已取消',
82
+ scanFailed: '扫码登录失败',
83
+ scanDoneHint: '登录成功!凭据已保存,之后会自动续期,不需要反复扫码。',
84
+ scanBrowserHint: '浏览器窗口可能开在后台,找不到就看任务栏。',
85
+ scanSecurityNote: '扫码登录不经过密码,插件只读取登录后的凭据;凭据加密保存在本机。',
86
+ loginAccount: '手机号 / 邮箱',
87
+ loginAccountPlaceholder: '输入手机号或邮箱',
88
+ loginPassword: '密码',
89
+ loginPasswordPlaceholder: '输入密码',
90
+ rememberPassword: '记住密码',
91
+ rememberPasswordHint: '勾选后密码会加密保存在本机;不勾选则只在登录时使用一次',
92
+ loginSubmit: '登录',
93
+ loginBusy: '登录中…',
94
+ loginOk: '登录成功',
95
+ loginSavedHint: '已保存密码,可直接登录',
96
+ showPassword: '显示密码',
97
+ hidePassword: '隐藏密码',
98
+ loginSecurityNote: '仅用于换取访问令牌;密码不会发送到除运营商外的任何地方',
99
+ loginEmpty: '请输入手机号/邮箱和密码',
100
+ reauthorize: '重新授权',
101
+ clearAuth: '清除授权',
102
+ clearConfirm: '退出登录并清除本机保存的凭据?之后需要重新登录。',
103
+ root: '/',
104
+ up: '返回上级',
105
+ newFolder: '新建文件夹',
106
+ folderName: '文件夹名称',
107
+ create: '创建',
108
+ cancel: '取消',
109
+ rename: '重命名',
110
+ renamePrompt: '输入新名称',
111
+ remove: '删除',
112
+ removeConfirm: '确定删除「{name}」?此操作不可恢复。',
113
+ downloadLink: '下载直链',
114
+ more: '加载更多',
115
+ loading: '加载中…',
116
+ empty: '这个文件夹是空的',
117
+ listFailed: '读取目录失败:{detail}',
118
+ actFailed: '操作失败:{detail}',
119
+ openFailed: '打开直链失败:{detail}',
120
+ host: '主机',
121
+ credentialPath: '凭证文件',
122
+ footerHint: '所有请求都经由 Host 的 /api/dsh-mcloud 转发。',
123
+ error: '出错了',
124
+ // 右键菜单
125
+ menuOpen: '打开',
126
+ menuDownloadFolder: '下载整个文件夹',
127
+ menuDownloadLocal: '下载到本地',
128
+ menuDownloadLink: '打开下载直链',
129
+ menuRename: '重命名',
130
+ menuRemove: '删除',
131
+ menuNewFolder: '新建文件夹',
132
+ menuRefresh: '刷新',
133
+ menuUpload: '上传',
134
+ menuUploadFile: '上传文件',
135
+ menuUploadFolder: '上传文件夹',
136
+ menuDragUpload: '拖动上传',
137
+ menuMove: '转移到…',
138
+ menuCopyPath: '复制路径',
139
+ pathCopied: '已复制:{path}',
140
+ copyFailed: '复制失败,路径是:{path}',
141
+ moveTitle: '转移文件',
142
+ moveSource: '把「{name}」转移到:',
143
+ moveHere: '转移到「{name}」',
144
+ moveNoFolders: '这个文件夹里没有子文件夹',
145
+ moveSelf: '不能转移到自己所在的位置',
146
+ moveInside: '不能转移到自己里面',
147
+ menuTypePath: '直接填路径',
148
+ // 下载目录 / 上传
149
+ downloadDir: '下载目录',
150
+ downloadDirHint: '「下载到本地」的文件会存到这个目录;路径是运行 DSH 那台机器上的路径。',
151
+ upload: '上传',
152
+ uploadPath: '本机文件绝对路径,例如 D:\\tmp\\a.zip',
153
+ uploadPathHint: '这个路径指向运行 DSH 的那台机器,不是浏览器所在的机器。',
154
+ uploadToCurrent: '上传到当前目录',
155
+ // 账号显示名(本机自定义,移动云盘没有昵称接口)
156
+ displayName: '显示名',
157
+ displayNamePlaceholder: '留空 = 显示脱敏手机号',
158
+ displayNameHint: '移动云盘没有查昵称的接口,这里填什么就显示什么(只存在本机浏览器里)。',
159
+ displayNameSave: '保存',
160
+ displayNameClear: '清除',
161
+ // 选择本机文件 / 拖拽上传
162
+ pickFile: '选择文件…',
163
+ pickFolder: '选择文件夹…',
164
+ pickWaiting: '等待选择…',
165
+ pickCancelled: '已取消选择',
166
+ pickHint: '会弹出系统文件对话框;上传直接从磁盘读取,大文件不会先复制一份。',
167
+ uploadFolderStarted: '正在上传文件夹:{name}',
168
+ uploadFolderDone: '文件夹上传完成:{name}({count} 个文件)',
169
+ uploadFolderFailed: '文件夹上传失败:{detail}',
170
+ noFilesSelected: '没有选择任何文件',
171
+ dropZone: '把文件拖到这里上传',
172
+ dropActive: '松开鼠标即上传',
173
+ dropHint: '拖进来的文件会传到当前打开的这个目录。',
174
+ dropFolderHint: '文件夹请点上面的「选择文件夹…」',
175
+ orTypePath: '或直接填路径',
176
+ dragUploadOpen: '拖动上传…',
177
+ uploadPanelTitle: '上传到「{name}」',
178
+ uploadPanelHere: '上传到当前目录',
179
+ uploadPanelHint: '把文件拖进下面的区域就开始上传;文件夹请用「选择文件夹…」',
180
+ close: '关闭',
181
+ urlUploading: '上传中 {name} · {percent}%',
182
+ urlUploadDone: '已上传 {name}',
183
+ urlUploadFailed: '上传失败:{detail}',
184
+ // 传输
185
+ transfers: '传输',
186
+ transferCancel: '取消',
187
+ transferCanceled: '已请求取消',
188
+ transferRunning: '进行中',
189
+ transferDone: '已完成',
190
+ transferFailed: '失败',
191
+ progressUnknown: '进行中',
192
+ noTransfers: '还没有传输任务。',
193
+ filesUnit: '个文件',
194
+ confirmDownloadFolder: '「{name}」里的每个文件都会单独下载一遍,文件多或很大时会比较慢。确定要下载整个文件夹吗?',
195
+ downloadStarted: '已开始下载:{name}',
196
+ uploadStarted: '已开始上传:{name}',
197
+ phasePrepare: '准备',
198
+ phaseHash: '校验哈希',
199
+ phaseCreate: '创建任务',
200
+ phaseUpload: '上传中',
201
+ phaseScan: '扫描目录',
202
+ phaseDownload: '下载中',
203
+ phaseComplete: '已完成',
204
+ }
205
+ var EN = {
206
+ nav: 'Mobile Cloud',
207
+ title: 'China Mobile Cloud Drive',
208
+ subtitle: 'Browse and authorize China Mobile Cloud Drive (yun.139.com)',
209
+ refresh: 'Refresh',
210
+ refreshFailed: 'Could not read status: {detail}',
211
+ account: 'Account',
212
+ expires: 'Authorized until',
213
+ expired: 'Expired',
214
+ quota: 'Storage',
215
+ used: 'Used',
216
+ free: 'Free space',
217
+ total: 'Total',
218
+ unauthorized: 'Not authorized yet',
219
+ notAuthorized: 'Not authorized yet — paste the Authorization value above.',
220
+ authorization: 'Authorization',
221
+ quotaUnavailable: 'Storage: temporarily unavailable',
222
+ save: 'Save and verify',
223
+ saving: 'Verifying…',
224
+ authOk: 'Authorization saved',
225
+ // Sign-in page: password / manual authorization tabs
226
+ loginTitle: 'Sign in to Mobile Cloud',
227
+ tabLogin: 'Password sign-in',
228
+ tabScan: 'Scan to sign in',
229
+ scanIntro: 'Click the button below. A browser window opens — scan the QR code with your phone to sign in.',
230
+ scanStart: 'Start scan login',
231
+ scanRetry: 'Scan again',
232
+ scanLaunching: 'Launching browser…',
233
+ scanWaiting: 'Scan the QR code in the browser window',
234
+ scanDone: 'Signed in',
235
+ scanCancelled: 'Cancelled',
236
+ scanFailed: 'Scan login failed',
237
+ scanDoneHint: 'Signed in. The credential is saved and will refresh automatically — no need to scan again.',
238
+ scanBrowserHint: 'The browser window may open in the background; check your taskbar.',
239
+ scanSecurityNote: 'Scan login never involves your password. The plugin only reads the credential after you sign in, and stores it encrypted locally.',
240
+ loginAccount: 'Phone / email',
241
+ loginAccountPlaceholder: 'Phone or email',
242
+ loginPassword: 'Password',
243
+ loginPasswordPlaceholder: 'Password',
244
+ rememberPassword: 'Remember password',
245
+ rememberPasswordHint: 'If checked the password is encrypted and stored locally; otherwise it is used once and discarded',
246
+ loginSubmit: 'Sign in',
247
+ loginBusy: 'Signing in…',
248
+ loginOk: 'Signed in',
249
+ loginSavedHint: 'Password saved, you can sign in directly',
250
+ showPassword: 'Show password',
251
+ hidePassword: 'Hide password',
252
+ loginSecurityNote: 'Used only to obtain an access token; the password is never sent anywhere but the carrier',
253
+ loginEmpty: 'Enter your phone/email and password',
254
+ reauthorize: 'Re-authorize',
255
+ clearAuth: 'Clear authorization',
256
+ clearConfirm: 'Clear the stored Mobile Cloud authorization? You will need to paste the Authorization value again.',
257
+ root: '/',
258
+ up: 'Up one level',
259
+ newFolder: 'New folder',
260
+ folderName: 'Folder name',
261
+ create: 'Create',
262
+ cancel: 'Cancel',
263
+ rename: 'Rename',
264
+ renamePrompt: 'Enter a new name',
265
+ remove: 'Delete',
266
+ removeConfirm: 'Delete "{name}"? This cannot be undone.',
267
+ downloadLink: 'Get link',
268
+ more: 'Load more',
269
+ loading: 'Loading…',
270
+ empty: 'This folder is empty',
271
+ listFailed: 'Could not read this folder: {detail}',
272
+ actFailed: 'Action failed: {detail}',
273
+ openFailed: 'Could not open the link: {detail}',
274
+ host: 'Host',
275
+ credentialPath: 'Credential file',
276
+ footerHint: 'Every request goes through the host route /api/dsh-mcloud.',
277
+ error: 'Something went wrong',
278
+ // Context menu
279
+ menuOpen: 'Open',
280
+ menuDownloadFolder: 'Download whole folder',
281
+ menuDownloadLocal: 'Download to this machine',
282
+ menuDownloadLink: 'Open download link',
283
+ menuRename: 'Rename',
284
+ menuRemove: 'Delete',
285
+ menuNewFolder: 'New folder',
286
+ menuRefresh: 'Refresh',
287
+ menuUpload: 'Upload',
288
+ menuUploadFile: 'Upload files',
289
+ menuUploadFolder: 'Upload a folder',
290
+ menuDragUpload: 'Drag and drop upload',
291
+ menuMove: 'Move to…',
292
+ menuCopyPath: 'Copy path',
293
+ pathCopied: 'Copied: {path}',
294
+ copyFailed: 'Copy failed, the path is: {path}',
295
+ moveTitle: 'Move',
296
+ moveSource: 'Move "{name}" to:',
297
+ moveHere: 'Move to "{name}"',
298
+ moveNoFolders: 'No subfolders here',
299
+ moveSelf: 'Cannot move to the same location',
300
+ moveInside: 'Cannot move into itself',
301
+ menuTypePath: 'Type a path instead',
302
+ // Download dir / upload
303
+ downloadDir: 'Download folder',
304
+ downloadDirHint: 'Files downloaded to this machine land in this folder; the path is on the machine running DSH.',
305
+ upload: 'Upload',
306
+ uploadPath: 'Absolute path on this machine, e.g. D:\\tmp\\a.zip',
307
+ uploadPathHint: 'The path is on the machine running DSH, not the machine with the browser.',
308
+ uploadToCurrent: 'Upload to current folder',
309
+ // Account display name (local only — the drive has no nickname API)
310
+ displayName: 'Display name',
311
+ displayNamePlaceholder: 'Blank = show the masked phone number',
312
+ displayNameHint: 'The drive has no API for reading your nickname, so whatever you type here is shown as-is (it stays in this browser only).',
313
+ displayNameSave: 'Save',
314
+ displayNameClear: 'Clear',
315
+ // Picking local files / drag and drop
316
+ pickFile: 'Choose files…',
317
+ pickFolder: 'Choose folder…',
318
+ pickWaiting: 'Waiting for selection…',
319
+ pickCancelled: 'Selection cancelled',
320
+ pickHint: 'Opens the native file dialog; uploads are read straight from disk, so a large file is not copied first.',
321
+ uploadFolderStarted: 'Uploading folder: {name}',
322
+ uploadFolderDone: 'Folder uploaded: {name} ({count} files)',
323
+ uploadFolderFailed: 'Folder upload failed: {detail}',
324
+ noFilesSelected: 'No files selected',
325
+ dropZone: 'Drop files here to upload',
326
+ dropActive: 'Release to upload',
327
+ dropHint: 'Anything you drop goes to the folder that is open right now.',
328
+ dropFolderHint: 'For a folder, use “Choose folder…” above.',
329
+ orTypePath: 'Or type a path',
330
+ dragUploadOpen: 'Drag and drop…',
331
+ uploadPanelTitle: 'Upload to "{name}"',
332
+ uploadPanelHere: 'Upload to the current folder',
333
+ uploadPanelHint: 'Drop files into the area below to start uploading; for a folder, use “Choose folder…”.',
334
+ close: 'Close',
335
+ urlUploading: 'Uploading {name} · {percent}%',
336
+ urlUploadDone: 'Uploaded {name}',
337
+ urlUploadFailed: 'Upload failed: {detail}',
338
+ // Transfers
339
+ transfers: 'Transfers',
340
+ transferCancel: 'Cancel',
341
+ transferCanceled: 'Cancellation requested',
342
+ transferRunning: 'Running',
343
+ transferDone: 'Done',
344
+ transferFailed: 'Failed',
345
+ progressUnknown: 'in progress',
346
+ noTransfers: 'No transfers yet.',
347
+ filesUnit: 'files',
348
+ confirmDownloadFolder: 'Every file inside "{name}" is downloaded one by one, so a large or deep folder can be slow. Download the whole folder?',
349
+ downloadStarted: 'Download started: {name}',
350
+ uploadStarted: 'Upload started: {name}',
351
+ phasePrepare: 'preparing',
352
+ phaseHash: 'hashing',
353
+ phaseCreate: 'creating',
354
+ phaseUpload: 'uploading',
355
+ phaseScan: 'scanning',
356
+ phaseDownload: 'downloading',
357
+ phaseComplete: 'complete',
358
+ }
359
+
360
+ /**
361
+ * 字典取词。查不到就原样返回 key,方便看出哪个词条漏了。
362
+ * 等价于 fallback `function (key) { return ZH[key] || key }`,另外支持 {name} 占位。
363
+ */
364
+ function translate(dict) {
365
+ return function (key, params) {
366
+ var template = dict[key] !== undefined ? dict[key] : key
367
+ if (!params) return template
368
+ return template.replace(/\{(\w+)\}/g, function (whole, name) {
369
+ return name in params ? String(params[name]) : whole
370
+ })
371
+ }
372
+ }
373
+
374
+ // ---------------------------------------------------------------- 样式
375
+ // 中性灰:优先用 Theme token,拿不到就退回在浅色/深色底上都能看的中性灰。
376
+ var GREY_1 = 'var(--dsw-alias-label-primary,#1f2329)'
377
+ var GREY_2 = 'var(--dsw-alias-label-secondary,#8a8f99)'
378
+ var LINE = 'var(--dsw-alias-border-l2,rgba(127,127,127,.34))'
379
+ var LAYER_1 = 'var(--dsw-alias-bg-layer-1,rgba(127,127,127,.06))'
380
+ var LAYER_2 = 'var(--dsw-alias-bg-layer-2,rgba(127,127,127,.12))'
381
+ var DANGER = 'var(--dsw-alias-state-error-primary,#d9534f)'
382
+
383
+ var CSS_TEXT = [
384
+ '.mc-root{box-sizing:border-box;height:100%;min-height:0;overflow-y:auto;display:flex;flex-direction:column;align-items:center;padding:24px clamp(16px,3vw,40px) 40px;gap:16px;color:' + GREY_1 + '}',
385
+ '.mc-root>*{width:100%;max-width:960px}',
386
+ '.mc-stack{display:flex;flex-direction:column;gap:16px}',
387
+ '.mc-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;flex-wrap:wrap}',
388
+ '.mc-title{font-size:20px;font-weight:600;line-height:28px;margin:0}',
389
+ '.mc-subtitle{margin:4px 0 0;font-size:12px;line-height:18px;color:' + GREY_2 + '}',
390
+ '.mc-headActions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
391
+ '.mc-notice{font-size:12px;line-height:18px;color:' + GREY_2 + '}',
392
+ '.mc-btn{appearance:none;font:inherit;font-size:13px;line-height:18px;cursor:pointer;display:inline-flex;align-items:center;gap:6px;border:0.5px solid ' + LINE + ';border-radius:8px;padding:6px 12px;background:transparent;color:' + GREY_1 + ';transition:background-color .14s ease,border-color .14s ease}',
393
+ '.mc-btn:hover:not(:disabled){background:' + LAYER_2 + '}',
394
+ '.mc-btn:disabled{opacity:.45;cursor:default}',
395
+ '.mc-btnPrimary{border-color:transparent;background:' + LAYER_2 + ';font-weight:600}',
396
+ '.mc-btnDanger{color:' + DANGER + ';border-color:' + DANGER + '}',
397
+ '.mc-btnSmall{padding:3px 9px;font-size:12px;line-height:16px;border-radius:6px}',
398
+ '.mc-card{border:0.5px solid ' + LINE + ';border-radius:12px;background:' + LAYER_1 + ';padding:16px 18px;display:flex;flex-direction:column;gap:12px}',
399
+ '.mc-cardHead{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}',
400
+ '.mc-cardTitle{font-size:14px;font-weight:600;line-height:22px;margin:0}',
401
+ '.mc-cardActions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
402
+ '.mc-hint{margin:0;font-size:12px;line-height:19px;color:' + GREY_2 + ';white-space:pre-wrap;word-break:break-word}',
403
+ '.mc-field{display:flex;flex-direction:column;gap:5px}',
404
+ '.mc-label{font-size:12px;line-height:17px;color:' + GREY_2 + '}',
405
+ '.mc-input{box-sizing:border-box;width:100%;font:inherit;font-size:13px;line-height:18px;color:' + GREY_1 + ';background:transparent;border:0.5px solid ' + LINE + ';border-radius:8px;padding:7px 10px}',
406
+ '.mc-input::placeholder{color:' + GREY_2 + '}',
407
+ '.mc-inlineForm{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
408
+ '.mc-inlineForm .mc-input{flex:1 1 220px;max-width:340px}',
409
+ '.mc-banner{border:0.5px solid ' + LINE + ';border-radius:10px;padding:9px 12px;font-size:12px;line-height:18px;word-break:break-word;background:' + LAYER_2 + '}',
410
+ '.mc-bannerErr{border-color:' + DANGER + ';color:' + DANGER + '}',
411
+ '.mc-bannerOk{border-color:' + LINE + ';color:' + GREY_2 + '}',
412
+ '.mc-kv{display:flex;align-items:baseline;gap:10px;font-size:12px;line-height:19px}',
413
+ '.mc-kvLabel{flex:none;min-width:76px;color:' + GREY_2 + '}',
414
+ '.mc-kvValue{min-width:0;word-break:break-all}',
415
+ // 账号行:显示名 / 脱敏手机号 + 小铅笔
416
+ '.mc-acctLine{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}',
417
+ '.mc-acctText{min-width:0;word-break:break-all}',
418
+ '.mc-acctSub{display:block;margin-top:2px;font-size:11px;line-height:16px;color:' + GREY_2 + '}',
419
+ '.mc-acctForm{display:flex;flex-direction:column;gap:8px;margin-top:2px}',
420
+ '.mc-acctWarn{display:block;margin-top:2px;font-size:11px;line-height:16px;color:' + DANGER + '}',
421
+ '.mc-barWrap{display:flex;flex-direction:column;gap:6px}',
422
+ '.mc-barText{font-size:12px;line-height:18px;color:' + GREY_2 + ';font-variant-numeric:tabular-nums}',
423
+ '.mc-bar{position:relative;height:8px;border-radius:999px;overflow:hidden;background:' + LAYER_2 + '}',
424
+ '.mc-barFill{height:100%;border-radius:999px;background:' + GREY_2 + ';transition:width .2s ease}',
425
+ '.mc-crumbs{display:inline-flex;align-items:center;gap:4px;flex-wrap:wrap;min-width:0;max-width:100%;box-sizing:border-box;padding:0;border:0;background:transparent}',
426
+ '.mc-crumb{appearance:none;display:inline-flex;align-items:center;box-sizing:border-box;border:0.5px solid ' + LINE + ';background:' + LAYER_1 + ';font:inherit;font-size:13px;font-weight:600;line-height:20px;color:' + GREY_1 + ';cursor:pointer;padding:3px 9px;border-radius:8px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
427
+ '.mc-crumb:hover{background:' + LAYER_2 + '}',
428
+ '.mc-crumbSep{font-size:13px;line-height:20px;font-weight:600;color:' + GREY_2 + ';user-select:none}',
429
+ '.mc-rows{display:flex;flex-direction:column}',
430
+ '.mc-row{display:flex;align-items:center;gap:10px;padding:7px 4px;border-top:0.5px solid ' + LINE + ';font-size:13px;line-height:19px}',
431
+ '.mc-row:first-child{border-top:0}',
432
+ '.mc-rowIcon{flex:none;width:18px;text-align:center}',
433
+ '.mc-rowName{min-width:0;flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
434
+ '.mc-rowNameBtn{appearance:none;border:0;background:transparent;font:inherit;color:inherit;text-align:left;padding:0;cursor:pointer;text-decoration:underline;text-decoration-color:' + LINE + '}',
435
+ '.mc-rowMeta{flex:none;font-size:12px;color:' + GREY_2 + ';font-variant-numeric:tabular-nums}',
436
+ '.mc-rowSize{min-width:72px;text-align:right}',
437
+ '.mc-rowTime{min-width:118px;text-align:right}',
438
+ '.mc-rowActions{flex:none;display:flex;align-items:center;gap:6px}',
439
+ '.mc-loading,.mc-empty{padding:14px 0;font-size:12px;line-height:18px;color:' + GREY_2 + ';text-align:center}',
440
+ // 列表区域要有最小高度,「空白处右键」才点得到。
441
+ '.mc-listArea{display:flex;flex-direction:column;min-height:104px}',
442
+ // 浮层菜单:fixed 定在光标处,颜色在浅色/深色底下都要看得清。
443
+ '.mc-menu{position:fixed;z-index:80;box-sizing:border-box;min-width:176px;width:auto;max-width:min(280px,90vw);padding:4px;border:0.5px solid ' + LINE + ';border-radius:10px;background:var(--dsw-alias-bg-layer-3,#ffffff);box-shadow:0 10px 28px rgba(0,0,0,.22)}',
444
+ '.mc-root>.mc-menu{width:auto;max-width:min(280px,90vw)}',
445
+ '.mc-menuHead{padding:5px 10px 6px;font-size:11px;line-height:16px;color:' + GREY_2 + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
446
+ '.mc-menuItem{display:flex;box-sizing:border-box;width:100%;appearance:none;border:0;background:transparent;font:inherit;font-size:13px;line-height:18px;color:' + GREY_1 + ';text-align:left;padding:7px 10px;border-radius:7px;cursor:pointer}',
447
+ '.mc-menuItem:hover{background:' + LAYER_2 + '}',
448
+ '.mc-menuItem:focus-visible{background:' + LAYER_2 + ';outline:none}',
449
+ '.mc-menuItemDanger{color:' + DANGER + '}',
450
+ '.mc-menuSep{height:0;margin:4px 6px;border:0;border-top:0.5px solid ' + LINE + '}',
451
+ // 传输任务
452
+ '.mc-job{display:flex;flex-direction:column;gap:6px;padding:9px 0;border-top:0.5px solid ' + LINE + '}',
453
+ '.mc-job:first-child{border-top:0;padding-top:2px}',
454
+ '.mc-jobHead{display:flex;align-items:center;gap:8px}',
455
+ '.mc-jobIcon{flex:none;width:18px;text-align:center}',
456
+ '.mc-jobLabel{min-width:0;flex:1 1 auto;font-size:13px;line-height:19px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
457
+ '.mc-jobState{flex:none;font-size:12px;line-height:18px;color:' + GREY_2 + '}',
458
+ '.mc-jobErr{font-size:12px;line-height:18px;color:' + DANGER + ';word-break:break-word}',
459
+ '.mc-jobMeta{font-size:11px;line-height:16px;color:' + GREY_2 + ';word-break:break-all}',
460
+ '.mc-barIndet .mc-barFill{width:34%;animation:mc-indet 1.15s ease-in-out infinite}',
461
+ '@keyframes mc-indet{0%{margin-left:-34%}100%{margin-left:100%}}',
462
+ // 上传卡:选择按钮 / 拖动上传按钮 / 「或直接填路径」折叠区;拖拽区本体在上传面板里
463
+ '.mc-pickRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
464
+ '.mc-sep{border:0;border-top:0.5px solid ' + LINE + ';height:0;margin:2px 0}',
465
+ // 拖拽区:上传面板里那一块(卡片里不再有);虚线 + 拖过时的高亮。
466
+ '.mc-drop{box-sizing:border-box;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;min-height:96px;padding:16px 14px;border:1.5px dashed ' + LINE + ';border-radius:12px;background:transparent;text-align:center}',
467
+ '.mc-dropActive{border-color:' + GREY_2 + ';background:' + LAYER_2 + '}',
468
+ '.mc-dropText{font-size:13px;line-height:19px;color:' + GREY_2 + '}',
469
+ '.mc-dropActive .mc-dropText{color:' + GREY_1 + '}',
470
+ '.mc-dropHint{font-size:11px;line-height:16px;color:' + GREY_2 + '}',
471
+ // 上传面板:盖住整屏的浮层 + 居中面板。菜单的 z-index 是 80,这里必须更高。
472
+ '.mc-overlay{position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;padding:16px;background:rgba(0,0,0,.42)}',
473
+ '.mc-panel{box-sizing:border-box;display:flex;flex-direction:column;gap:12px;width:min(520px,100%);max-height:calc(100vh - 32px);overflow-y:auto;padding:18px 20px;border:0.5px solid ' + LINE + ';border-radius:14px;background:var(--dsw-alias-bg-layer-3,#ffffff);color:' + GREY_1 + ';box-shadow:0 18px 48px rgba(0,0,0,.30)}',
474
+ '.mc-panelHead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}',
475
+ '.mc-panelTitle{min-width:0;font-size:15px;font-weight:600;line-height:22px;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
476
+ '.mc-panelClose{flex:none;width:28px;height:28px;padding:0;justify-content:center}',
477
+ '.mc-panelFoot{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
478
+ // 面板里的拖拽区是主角:比卡片里那一版更高。
479
+ '.mc-panel .mc-drop{min-height:150px}',
480
+ '.mc-scanBox{display:flex;flex-direction:column;align-items:center;gap:10px;padding:22px 16px;border:0.5px dashed ' + LINE + ';border-radius:12px;background:' + LAYER_1 + '}',
481
+ '.mc-scanSpinner{font-size:26px;line-height:1;color:' + GREY_2 + ';animation:mc-spin 1.4s linear infinite}',
482
+ '@keyframes mc-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}',
483
+ '.mc-scanText{font-size:13px;line-height:19px;color:' + GREY_1 + ';text-align:center}',
484
+ '.mc-securityNote{margin:8px 0 0;font-size:11px;line-height:16px;color:' + GREY_2 + '}',
485
+ '.mc-moveBar{display:flex;align-items:center;gap:8px;min-width:0}',
486
+ '.mc-movePath{min-width:0;flex:1;font-size:12px;line-height:18px;color:' + GREY_2 + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
487
+ '.mc-moveList{display:flex;flex-direction:column;gap:2px;max-height:280px;overflow-y:auto;padding:2px}',
488
+ '.mc-moveRow{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;box-sizing:border-box;padding:8px 10px;border:0;border-radius:8px;background:transparent;color:inherit;font-size:13px;text-align:left;cursor:pointer}',
489
+ '.mc-moveRow:hover{background:' + LAYER_2 + '}',
490
+ '.mc-moveRowDisabled{opacity:.45;cursor:not-allowed}',
491
+ '.mc-moveTag{flex:none;font-size:11px;color:' + DANGER + '}',
492
+ '.mc-linkBtn{appearance:none;border:0;background:transparent;font:inherit;font-size:12px;line-height:18px;color:' + GREY_2 + ';cursor:pointer;padding:2px 4px;margin-left:-4px;border-radius:6px;align-self:flex-start;text-align:left}',
493
+ '.mc-linkBtn:hover{background:' + LAYER_2 + ';color:' + GREY_1 + '}',
494
+ // 浏览器 → Host 的逐文件上行进度
495
+ '.mc-urlLines{display:flex;flex-direction:column;gap:7px}',
496
+ '.mc-urlLine{display:flex;flex-direction:column;gap:4px}',
497
+ '.mc-urlLineHead{display:flex;align-items:baseline;gap:8px;font-size:12px;line-height:17px;color:' + GREY_2 + '}',
498
+ '.mc-urlLineName{min-width:0;flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
499
+ '.mc-urlLinePct{flex:none;font-variant-numeric:tabular-nums}',
500
+ '.mc-urlLineErr{color:' + DANGER + '}',
501
+ '@media (prefers-color-scheme: dark){.mc-menu{background:var(--dsw-alias-bg-layer-3,#23262b)}.mc-panel{background:var(--dsw-alias-bg-layer-3,#23262b)}.mc-crumb{background:var(--dsw-alias-bg-layer-2,#1e2126)}}',
502
+ '.mc-footer{font-size:11px;line-height:17px;color:' + GREY_2 + ';text-align:center}',
503
+ // 登录页:标签栏 + 账号密码表单(只追加,不改上面任何一条)
504
+ '.mc-tabs{display:flex;align-items:center;gap:4px;border-bottom:0.5px solid ' + LINE + ';margin:-2px 0 2px}',
505
+ '.mc-tab{appearance:none;font:inherit;font-size:13px;line-height:19px;cursor:pointer;border:0;border-bottom:2px solid transparent;background:transparent;color:' + GREY_2 + ';padding:8px 12px;margin-bottom:-1px;border-radius:8px 8px 0 0;transition:color .14s ease,background-color .14s ease,border-color .14s ease}',
506
+ '.mc-tab:hover{background:' + LAYER_2 + ';color:' + GREY_1 + '}',
507
+ '.mc-tabActive{color:' + GREY_1 + ';font-weight:600;border-bottom-color:' + GREY_1 + '}',
508
+ '.mc-tabActive:hover{background:' + LAYER_2 + ';color:' + GREY_1 + '}',
509
+ '.mc-tabPanel{display:flex;flex-direction:column;gap:12px}',
510
+ '.mc-pwWrap{position:relative;display:flex;align-items:center}',
511
+ '.mc-pwWrap .mc-input{padding-right:40px}',
512
+ '.mc-pwToggle{position:absolute;right:4px;top:50%;transform:translateY(-50%);appearance:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:6px;background:transparent;color:' + GREY_2 + ';font-size:14px;line-height:1;cursor:pointer}',
513
+ '.mc-pwToggle:hover{background:' + LAYER_2 + ';color:' + GREY_1 + '}',
514
+ '.mc-checkRow{display:flex;align-items:flex-start;gap:8px;font-size:12px;line-height:18px;color:' + GREY_1 + ';cursor:pointer}',
515
+ '.mc-checkRow input{margin:2px 0 0;flex:none;accent-color:' + GREY_2 + '}',
516
+ '.mc-checkText{min-width:0}',
517
+ '.mc-formNote{font-size:11px;line-height:17px;color:' + GREY_2 + ';margin:0;word-break:break-word}',
518
+ '.mc-submitRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
519
+ '.mc-submit{justify-content:center;min-width:132px}',
520
+ ].join('')
521
+ var CSS_ID = 'dsh-mcloud/panel.css'
522
+ function ensureStyle() {
523
+ try {
524
+ if (typeof document === 'undefined' || !document.head) return
525
+ if (document.getElementById(CSS_ID) !== null) return
526
+ if (document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_ID) + ']') !== null) return
527
+ var tag = document.createElement('style')
528
+ tag.id = CSS_ID
529
+ tag.dataset.plugin = 'dsh-mcloud'
530
+ tag.dataset.pluginCss = CSS_ID
531
+ tag.textContent = CSS_TEXT
532
+ document.head.appendChild(tag)
533
+ } catch (failure) {
534
+ logWarn('style injection failed', failure)
535
+ }
536
+ }
537
+
538
+ // ---------------------------------------------------------------- 格式化
539
+ /** B / KB / MB / GB / TB,1 位小数。 */
540
+ function formatBytes(value) {
541
+ var n = Number(value)
542
+ if (!isFinite(n) || n <= 0) return '0 B'
543
+ var units = ['B', 'KB', 'MB', 'GB', 'TB']
544
+ var index = 0
545
+ var size = n
546
+ while (size >= 1024 && index < units.length - 1) {
547
+ size = size / 1024
548
+ index += 1
549
+ }
550
+ if (index === 0) return Math.round(size) + ' B'
551
+ return size.toFixed(1) + ' ' + units[index]
552
+ }
553
+
554
+ function pad2(value) {
555
+ return String(value).padStart(2, '0')
556
+ }
557
+
558
+ /** 本地时间 YYYY-MM-DD HH:mm;认不出来就原样显示。 */
559
+ function formatDateTime(value) {
560
+ if (value === null || value === undefined || value === '') return '—'
561
+ var date = new Date(value)
562
+ if (isNaN(date.getTime())) return String(value)
563
+ return (
564
+ date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate()) +
565
+ ' ' + pad2(date.getHours()) + ':' + pad2(date.getMinutes())
566
+ )
567
+ }
568
+
569
+ function messageOf(failure) {
570
+ if (!failure) return 'unknown error'
571
+ if (failure.message) return String(failure.message)
572
+ return String(failure)
573
+ }
574
+
575
+ // ---------------------------------------------------------------- 本机路径
576
+ /**
577
+ * 目录 + 名字 → 本机路径。
578
+ *
579
+ * 目录里出现 `\`(或者像 `D:` 这样的盘符)就按 Windows 拼 `\`,否则按 POSIX 拼 `/`;
580
+ * 先剪掉尾部分隔符,免得拼出 `D:\tmp\\a.zip`。目录为空就只回名字,
581
+ * 由 Host 自己决定相对路径落在哪。
582
+ */
583
+ function joinPath(dir, name) {
584
+ var base = dir === null || dir === undefined ? '' : String(dir).trim()
585
+ base = base.replace(/[\\/]+$/, '')
586
+ if (base === '') return String(name)
587
+ var windows = base.indexOf('\\') >= 0 || /^[A-Za-z]:/.test(base)
588
+ return base + (windows ? '\\' : '/') + String(name)
589
+ }
590
+
591
+ /** 路径最后一段(`D:\tmp\a.zip` → `a.zip`),只用来给提示起个好听的名字。 */
592
+ function baseName(value) {
593
+ var text = String(value === null || value === undefined ? '' : value)
594
+ var cut = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\'))
595
+ return cut >= 0 ? text.slice(cut + 1) : text
596
+ }
597
+
598
+ var DOWNLOAD_DIR_KEY = 'dsh-mcloud/downloadDir'
599
+
600
+ /**
601
+ * 账号显示名(可选):存 localStorage,只在浏览器里生效。
602
+ * 只认字符串;拿不到就回空串 —— 调用方回退到脱敏手机号。
603
+ */
604
+ var DISPLAY_NAME_KEY = 'dsh-mcloud/displayName'
605
+
606
+ function normalizeDisplayName(value) {
607
+ return typeof value === 'string' ? value.trim() : ''
608
+ }
609
+
610
+ function readDisplayName() {
611
+ try {
612
+ if (typeof window === 'undefined' || !window.localStorage) return ''
613
+ return normalizeDisplayName(window.localStorage.getItem(DISPLAY_NAME_KEY))
614
+ } catch (failure) {
615
+ logWarn('read display name failed', failure)
616
+ return ''
617
+ }
618
+ }
619
+
620
+ function writeDisplayName(value) {
621
+ var next = normalizeDisplayName(value)
622
+ try {
623
+ if (typeof window === 'undefined' || !window.localStorage) return next
624
+ if (next === '') window.localStorage.removeItem(DISPLAY_NAME_KEY)
625
+ else window.localStorage.setItem(DISPLAY_NAME_KEY, next)
626
+ } catch (failure) {
627
+ logWarn('write display name failed', failure)
628
+ }
629
+ return next
630
+ }
631
+
632
+ /** 下载目录存在 localStorage 里;拿不到(隐私模式/没 window)就给空串。 */
633
+ function readDownloadDir() {
634
+ try {
635
+ if (typeof window === 'undefined' || !window.localStorage) return ''
636
+ var stored = window.localStorage.getItem(DOWNLOAD_DIR_KEY)
637
+ return typeof stored === 'string' ? stored : ''
638
+ } catch (failure) {
639
+ logWarn('read download dir failed', failure)
640
+ return ''
641
+ }
642
+ }
643
+
644
+ function writeDownloadDir(value) {
645
+ try {
646
+ if (typeof window === 'undefined' || !window.localStorage) return
647
+ window.localStorage.setItem(DOWNLOAD_DIR_KEY, String(value))
648
+ } catch (failure) {
649
+ logWarn('write download dir failed', failure)
650
+ }
651
+ }
652
+
653
+ // ---------------------------------------------------------------- 传输
654
+ /** 有任务在跑时 1s 拉一次 /transfers;没有就停表,绝不留一个空转的 interval。 */
655
+ var TRANSFER_POLL_MS = 1000
656
+ /** 面板上最多列几个任务(新的在前)。 */
657
+ var TRANSFER_VISIBLE = 8
658
+ /** 拖拽上传:那条「浏览器 → Host」进度行成功后还留几秒再消失。 */
659
+ var URL_LINE_LINGER_MS = 4000
660
+ /** 拖拽上传时最多同时显示几条进度行。 */
661
+ var URL_LINE_VISIBLE = 8
662
+
663
+ var PHASE_KEYS = {
664
+ prepare: 'phasePrepare',
665
+ hash: 'phaseHash',
666
+ create: 'phaseCreate',
667
+ upload: 'phaseUpload',
668
+ scan: 'phaseScan',
669
+ download: 'phaseDownload',
670
+ complete: 'phaseComplete',
671
+ }
672
+
673
+ var JOB_ICONS = { upload: '⬆', download: '⬇', 'download-folder': '🗂', 'upload-folder': '🗂' }
674
+
675
+ // ---------------------------------------------------------------- HTTP
676
+ /** 非 2xx 与 { ok:false, error } 都转成 Error 抛出去;响应体不是 JSON 也当失败。 */
677
+ function readJsonResponse(res) {
678
+ return res.text().then(function (text) {
679
+ var doc = null
680
+ if (text) {
681
+ try {
682
+ doc = JSON.parse(text)
683
+ } catch (parseFailure) {
684
+ doc = null
685
+ }
686
+ }
687
+ if (!res.ok || !doc || doc.ok === false) {
688
+ throw new Error((doc && doc.error) || ('HTTP ' + res.status))
689
+ }
690
+ return doc
691
+ })
692
+ }
693
+
694
+ /**
695
+ * 统一出口:GET 不带自定义头,POST 一律 content-type + x-dsh-mcloud。
696
+ * 非 2xx 与 { ok:false, error } 都转成 Error 抛出去。
697
+ */
698
+ function requestJson(method, path, body) {
699
+ var init = { method: method, credentials: 'same-origin', cache: 'no-store' }
700
+ if (method === 'POST') {
701
+ init.headers = { 'content-type': 'application/json', 'x-dsh-mcloud': '1' }
702
+ init.body = JSON.stringify(body || {})
703
+ }
704
+ return fetch(BASE + path, init).then(readJsonResponse)
705
+ }
706
+
707
+ var api = {
708
+ status: function () {
709
+ return requestJson('GET', '/status')
710
+ },
711
+ auth: function (authorization, userDomainId) {
712
+ return requestJson('POST', '/auth', { authorization: authorization, userDomainId: userDomainId })
713
+ },
714
+ /** 账号密码登录:密码只在这一跳里出现,Host 回 { ok, accountMasked, quota, quotaError, rememberPassword }。 */
715
+ login: function (account, password, rememberPassword) {
716
+ return requestJson('POST', '/login', {
717
+ account: account,
718
+ password: password,
719
+ rememberPassword: rememberPassword === true,
720
+ })
721
+ },
722
+ /** 本机是否存过密码(密码本身永远不回传,只回脱敏账号)。 */
723
+ loginSaved: function () {
724
+ return requestJson('GET', '/login/saved')
725
+ },
726
+ /** 扫码登录:开始 / 查进度 / 取消。 */
727
+ scanStart: function () {
728
+ return requestJson('POST', '/scan/start', {})
729
+ },
730
+ scanStatus: function () {
731
+ return requestJson('GET', '/scan/status')
732
+ },
733
+ scanCancel: function () {
734
+ return requestJson('POST', '/scan/cancel', {})
735
+ },
736
+ clearAuth: function () {
737
+ return requestJson('POST', '/auth/clear', {})
738
+ },
739
+ list: function (parentFileId, cursor) {
740
+ return requestJson(
741
+ 'GET',
742
+ '/list?parentFileId=' + encodeURIComponent(parentFileId || '/') +
743
+ '&cursor=' + encodeURIComponent(cursor || '')
744
+ )
745
+ },
746
+ mkdir: function (parentFileId, name) {
747
+ return requestJson('POST', '/mkdir', { parentFileId: parentFileId, name: name })
748
+ },
749
+ /**
750
+ * 「上传文件夹」专用:按相对路径把远端目录逐层建出来(Host 端会复用已存在的同名目录),
751
+ * 回 { ok:true, fileId } —— fileId 是 path 最末一层那个目录的 id。
752
+ */
753
+ mkdirPath: function (parentFileId, path) {
754
+ return requestJson('POST', '/mkdir-path', { parentFileId: parentFileId, path: path })
755
+ },
756
+ rename: function (fileId, name) {
757
+ return requestJson('POST', '/rename', { fileId: fileId, name: name })
758
+ },
759
+ move: function (fileIds, toParentFileId) {
760
+ return requestJson('POST', '/move', { fileIds: fileIds, toParentFileId: toParentFileId })
761
+ },
762
+ remove: function (fileIds) {
763
+ return requestJson('POST', '/remove', { fileIds: fileIds })
764
+ },
765
+ link: function (fileId) {
766
+ return requestJson('POST', '/link', { fileId: fileId })
767
+ },
768
+ /**
769
+ * 下面三个传输接口都是「200 + jobId」的异步任务:失败(含没授权)也会先回 200,
770
+ * 之后才在任务里变成 state:'error' —— 所以真正的错误提示在传输面板上,不在 catch 里。
771
+ */
772
+ upload: function (localPath, parentFileId, name) {
773
+ var body = { localPath: localPath, parentFileId: parentFileId }
774
+ if (name) body.name = name
775
+ return requestJson('POST', '/upload', body)
776
+ },
777
+ /** 整个本机文件夹交给 Host 扫描上传(POST /upload-folder → jobId)。 */
778
+ uploadFolder: function (localPath, parentFileId) {
779
+ return requestJson('POST', '/upload-folder', { localPath: localPath, parentFileId: parentFileId })
780
+ },
781
+ /**
782
+ * 拖拽上传:body 是文件原始字节,不是 JSON —— 所以只能用 XMLHttpRequest
783
+ * (fetch 拿不到「浏览器 → Host」这一段的上行进度)。
784
+ * 成功 = HTTP 200 + { ok:true, jobId, name };失败按 { ok:false, error } 抛出去。
785
+ */
786
+ uploadBody: function (file, parentFileId, onProgress) {
787
+ return new Promise(function (resolve, reject) {
788
+ var name = file && file.name ? String(file.name) : 'upload.bin'
789
+ var url = BASE + '/upload-body?name=' + encodeURIComponent(name) +
790
+ '&parentFileId=' + encodeURIComponent(parentFileId || '/')
791
+ try {
792
+ var xhr = new XMLHttpRequest()
793
+ xhr.open('POST', url)
794
+ xhr.setRequestHeader('content-type', 'application/octet-stream')
795
+ xhr.setRequestHeader('x-dsh-mcloud', '1')
796
+ if (xhr.upload && typeof onProgress === 'function') {
797
+ xhr.upload.onprogress = function (event) {
798
+ if (!event || !event.lengthComputable) return
799
+ var total = Number(event.total) || 0
800
+ if (total <= 0) return
801
+ var percent = Math.min(100, Math.max(0, ((Number(event.loaded) || 0) / total) * 100))
802
+ try {
803
+ onProgress(percent)
804
+ } catch (failure) {
805
+ logWarn('upload progress handler failed', failure)
806
+ }
807
+ }
808
+ }
809
+ xhr.onload = function () {
810
+ var doc = null
811
+ try {
812
+ doc = JSON.parse(xhr.responseText)
813
+ } catch (parseFailure) {
814
+ doc = null
815
+ }
816
+ if (xhr.status !== 200 || !doc || doc.ok === false) {
817
+ reject(new Error((doc && doc.error) || ('HTTP ' + xhr.status)))
818
+ return
819
+ }
820
+ resolve(doc)
821
+ }
822
+ xhr.onerror = function () {
823
+ reject(new Error('network error'))
824
+ }
825
+ xhr.onabort = function () {
826
+ reject(new Error('upload aborted'))
827
+ }
828
+ xhr.send(file)
829
+ } catch (failure) {
830
+ reject(failure instanceof Error ? failure : new Error(messageOf(failure)))
831
+ }
832
+ })
833
+ },
834
+ download: function (fileId, localPath) {
835
+ return requestJson('POST', '/download', { fileId: fileId, localPath: localPath })
836
+ },
837
+ downloadFolder: function (folderId, localPath) {
838
+ return requestJson('POST', '/download-folder', { folderId: folderId, localPath: localPath })
839
+ },
840
+ cancelTransfer: function (jobId) {
841
+ return requestJson('POST', '/transfer/cancel', { jobId: jobId })
842
+ },
843
+ transfers: function () {
844
+ return requestJson('GET', '/transfers')
845
+ },
846
+ transfer: function (jobId) {
847
+ return requestJson('GET', '/transfers?jobId=' + encodeURIComponent(jobId))
848
+ },
849
+ }
850
+
851
+ // ---------------------------------------------------------------- 小组件
852
+ function KeyValue(props) {
853
+ return h('div', { className: 'mc-kv' },
854
+ h('span', { className: 'mc-kvLabel' }, props.label),
855
+ h('span', { className: 'mc-kvValue' }, props.value)
856
+ )
857
+ }
858
+
859
+ // ---------------------------------------------------------------- 面板
860
+ function createPanel(t) {
861
+ return function MobileCloudPanel() {
862
+ // 状态卡
863
+ var statusState = React.useState(null)
864
+ var status = statusState[0]
865
+ var setStatus = statusState[1]
866
+ var statusErrorState = React.useState('')
867
+ var statusError = statusErrorState[0]
868
+ var setStatusError = statusErrorState[1]
869
+ var statusLoadingState = React.useState(true)
870
+ var statusLoading = statusLoadingState[0]
871
+ var setStatusLoading = statusLoadingState[1]
872
+ // 授权表单
873
+ var authValueState = React.useState('')
874
+ var authValue = authValueState[0]
875
+ var setAuthValue = authValueState[1]
876
+ var udValueState = React.useState('')
877
+ var udValue = udValueState[0]
878
+ var setUdValue = udValueState[1]
879
+ var authBusyState = React.useState(false)
880
+ var authBusy = authBusyState[0]
881
+ var setAuthBusy = authBusyState[1]
882
+ var authErrorState = React.useState('')
883
+ var authError = authErrorState[0]
884
+ var setAuthError = authErrorState[1]
885
+ var authNoticeState = React.useState('')
886
+ var authNotice = authNoticeState[0]
887
+ var setAuthNotice = authNoticeState[1]
888
+ var reauthOpenState = React.useState(false)
889
+ var reauthOpen = reauthOpenState[0]
890
+ var setReauthOpen = reauthOpenState[1]
891
+ // 登录页:'scan' = 扫码登录,'login' = 账号密码
892
+ var authTabState = React.useState('scan')
893
+ var authTab = authTabState[0]
894
+ var setAuthTab = authTabState[1]
895
+ var loginAccountState = React.useState('')
896
+ var loginAccount = loginAccountState[0]
897
+ var setLoginAccount = loginAccountState[1]
898
+ var loginPasswordState = React.useState('')
899
+ var loginPassword = loginPasswordState[0]
900
+ var setLoginPassword = loginPasswordState[1]
901
+ var rememberPasswordState = React.useState(false)
902
+ var rememberPassword = rememberPasswordState[0] // 默认不勾:密码只在登录时用一次
903
+ var setRememberPassword = rememberPasswordState[1]
904
+ var loginBusyState = React.useState(false)
905
+ var loginBusy = loginBusyState[0]
906
+ var setLoginBusy = loginBusyState[1]
907
+ var loginErrorState = React.useState('')
908
+ var loginError = loginErrorState[0]
909
+ var setLoginError = loginErrorState[1]
910
+ var loginNoticeState = React.useState('')
911
+ var loginNotice = loginNoticeState[0]
912
+ var setLoginNotice = loginNoticeState[1]
913
+ var showPasswordState = React.useState(false)
914
+ var showPassword = showPasswordState[0]
915
+ var setShowPassword = showPasswordState[1]
916
+ var savedHintState = React.useState('')
917
+ var savedHint = savedHintState[0]
918
+ var setSavedHint = savedHintState[1]
919
+
920
+ // ------------------------------------------------------ 扫码登录状态
921
+ // idle = 没开始;launching/waiting = 进行中;done = 成功;error/cancelled = 结束
922
+ var scanStateState = React.useState({ state: 'idle', phase: 'idle', message: '' })
923
+ var scanState = scanStateState[0]
924
+ var setScanState = scanStateState[1]
925
+ var scanTimerRef = React.useRef(null)
926
+
927
+ /** 停止轮询扫码进度。 */
928
+ function stopScanPolling() {
929
+ if (scanTimerRef.current !== null) {
930
+ try {
931
+ clearInterval(scanTimerRef.current)
932
+ } catch (failure) {
933
+ /* 已经清过 */
934
+ }
935
+ scanTimerRef.current = null
936
+ }
937
+ }
938
+
939
+ /** 轮询 /scan/status,直到结束态。 */
940
+ function startScanPolling() {
941
+ stopScanPolling()
942
+ var tick = function () {
943
+ api.scanStatus()
944
+ .then(function (doc) {
945
+ if (!doc || doc.ok !== true) return
946
+ setScanState({
947
+ state: doc.state || 'idle',
948
+ phase: doc.phase || 'idle',
949
+ message: doc.message || '',
950
+ })
951
+ if (doc.state === 'done') {
952
+ stopScanPolling()
953
+ // 扫码头像成功后刷新整个面板:状态、容量、文件列表
954
+ loadStatus().then(function () { reload() })
955
+ } else if (doc.state === 'error' || doc.state === 'cancelled') {
956
+ stopScanPolling()
957
+ }
958
+ })
959
+ .catch(function (failure) {
960
+ logWarn('scan status failed', failure)
961
+ })
962
+ }
963
+ tick()
964
+ scanTimerRef.current = setInterval(tick, 1200)
965
+ }
966
+
967
+ /** 开始扫码登录。 */
968
+ function beginScan() {
969
+ setLoginError('')
970
+ setLoginNotice('')
971
+ setScanState({ state: 'launching', phase: 'launching', message: '' })
972
+ api.scanStart()
973
+ .then(function (doc) {
974
+ if (doc && doc.ok === false) {
975
+ setScanState({ state: 'error', phase: 'error', message: doc.error || '' })
976
+ setLoginError(doc.error || '')
977
+ return
978
+ }
979
+ // 服务端已经开跑,开始轮询
980
+ startScanPolling()
981
+ })
982
+ .catch(function (failure) {
983
+ const detail = messageOf(failure)
984
+ setScanState({ state: 'error', phase: 'error', message: detail })
985
+ setLoginError(t('actFailed', { detail: detail }))
986
+ logWarn('scan start failed', failure)
987
+ })
988
+ }
989
+
990
+ /** 取消扫码。 */
991
+ function cancelScan() {
992
+ api.scanCancel()
993
+ .then(function () { setScanState({ state: 'cancelled', phase: 'cancelled', message: '' }) })
994
+ .catch(function () { /* 忽略 */ })
995
+ stopScanPolling()
996
+ }
997
+
998
+ // 面板卸载时别留下定时器
999
+ React.useEffect(function () {
1000
+ return function () { stopScanPolling() }
1001
+ }, [])
1002
+ // 文件浏览
1003
+ var stackState = React.useState([])
1004
+ var stack = stackState[0]
1005
+ var setStack = stackState[1]
1006
+ var itemsState = React.useState([])
1007
+ var items = itemsState[0]
1008
+ var setItems = itemsState[1]
1009
+ var cursorState = React.useState(null)
1010
+ var nextCursor = cursorState[0]
1011
+ var setNextCursor = cursorState[1]
1012
+ var listLoadingState = React.useState(false)
1013
+ var listLoading = listLoadingState[0]
1014
+ var setListLoading = listLoadingState[1]
1015
+ var listErrorState = React.useState('')
1016
+ var listError = listErrorState[0]
1017
+ var setListError = listErrorState[1]
1018
+ var busyState = React.useState(false)
1019
+ var busy = busyState[0]
1020
+ var setBusy = busyState[1]
1021
+ var folderOpenState = React.useState(false)
1022
+ var folderOpen = folderOpenState[0]
1023
+ var setFolderOpen = folderOpenState[1]
1024
+ var folderNameState = React.useState('')
1025
+ var folderName = folderNameState[0]
1026
+ var setFolderName = folderNameState[1]
1027
+ var linkBusyState = React.useState('')
1028
+ var linkBusy = linkBusyState[0]
1029
+ var setLinkBusy = linkBusyState[1]
1030
+ // 下载目录 / 上传
1031
+ var downloadDirState = React.useState('')
1032
+ var downloadDir = downloadDirState[0]
1033
+ var setDownloadDir = downloadDirState[1]
1034
+ var uploadPathState = React.useState('')
1035
+ var uploadPath = uploadPathState[0]
1036
+ var setUploadPath = uploadPathState[1]
1037
+ // 账号显示名(可选,存 localStorage)
1038
+ var displayNameState = React.useState('')
1039
+ var displayName = displayNameState[0]
1040
+ var setDisplayName = displayNameState[1]
1041
+ var displayNameEditState = React.useState(false)
1042
+ var displayNameEdit = displayNameEditState[0]
1043
+ var setDisplayNameEdit = displayNameEditState[1]
1044
+ var displayNameDraftState = React.useState('')
1045
+ var displayNameDraft = displayNameDraftState[0]
1046
+ var setDisplayNameDraft = displayNameDraftState[1]
1047
+ var displayNameErrorState = React.useState('')
1048
+ var displayNameError = displayNameErrorState[0]
1049
+ var setDisplayNameError = displayNameErrorState[1]
1050
+ // 选择本机文件 / 文件夹(POST /pick)与拖拽上传
1051
+ var pickingState = React.useState(false)
1052
+ var picking = pickingState[0] // 系统对话框还开着,等用户作答
1053
+ var setPicking = pickingState[1]
1054
+ var pathOpenState = React.useState(false)
1055
+ var pathOpen = pathOpenState[0] // 「或直接填路径」折叠区
1056
+ var setPathOpen = pathOpenState[1]
1057
+ var dragOverState = React.useState(false)
1058
+ var dragOver = dragOverState[0]
1059
+ var setDragOver = dragOverState[1]
1060
+ var urlItemsState = React.useState([])
1061
+ var urlItems = urlItemsState[0] // 拖拽上传的逐文件上行进度
1062
+ var setUrlItems = urlItemsState[1]
1063
+ var dropNoteState = React.useState('')
1064
+ var dropNote = dropNoteState[0] // 拖到文件夹时的一句提示
1065
+ var setDropNote = dropNoteState[1]
1066
+ var jobBusyState = React.useState('')
1067
+ var jobBusy = jobBusyState[0] // 正在起任务的按钮:'' / item.id / 'upload'
1068
+ var setJobBusy = jobBusyState[1]
1069
+ var noticeState = React.useState('')
1070
+ var notice = noticeState[0] // 文件区的一行成功提示
1071
+ var setNotice = noticeState[1]
1072
+ // 传输面板
1073
+ var jobsState = React.useState([])
1074
+ var jobs = jobsState[0]
1075
+ var setJobs = jobsState[1]
1076
+ var cancelBusyState = React.useState('')
1077
+ var cancelBusy = cancelBusyState[0]
1078
+ var setCancelBusy = cancelBusyState[1]
1079
+ // 右键菜单:{ x, y, item|null, placed },同一时刻最多一个
1080
+ var menuState = React.useState(null)
1081
+ var menu = menuState[0]
1082
+ var setMenu = menuState[1]
1083
+ // 上传面板:uploadOpen = 面板开着;uploadTarget = 打开那一刻钉住的目标目录 id/name。
1084
+ var uploadOpenState = React.useState(false)
1085
+ var uploadOpen = uploadOpenState[0]
1086
+ var setUploadOpen = uploadOpenState[1]
1087
+ var uploadTargetState = React.useState(null)
1088
+ var moveOpenState = React.useState(false)
1089
+ var moveSourceState = React.useState(null)
1090
+ var moveStackState = React.useState([])
1091
+ var moveItemsState = React.useState([])
1092
+ var moveCursorState = React.useState(null)
1093
+ var moveLoadingState = React.useState(false)
1094
+ var moveBusyState = React.useState(false)
1095
+ var moveErrorState = React.useState('')
1096
+ var moveNoteState = React.useState('')
1097
+ var moveOpen = moveOpenState[0]; var setMoveOpen = moveOpenState[1]
1098
+ var moveSource = moveSourceState[0]; var setMoveSource = moveSourceState[1]
1099
+ var moveStack = moveStackState[0]; var setMoveStack = moveStackState[1]
1100
+ var moveItems = moveItemsState[0]; var setMoveItems = moveItemsState[1]
1101
+ var moveCursor = moveCursorState[0]; var setMoveCursor = moveCursorState[1]
1102
+ var moveLoading = moveLoadingState[0]; var setMoveLoading = moveLoadingState[1]
1103
+ var moveBusy = moveBusyState[0]; var setMoveBusy = moveBusyState[1]
1104
+ var moveError = moveErrorState[0]; var setMoveError = moveErrorState[1]
1105
+ var moveNote = moveNoteState[0]; var setMoveNote = moveNoteState[1]
1106
+ var uploadTarget = uploadTargetState[0]
1107
+ var setUploadTarget = uploadTargetState[1]
1108
+
1109
+ // 请求序号:快速切目录时把旧响应丢掉。
1110
+ var seqRef = React.useRef(0)
1111
+ var pollTimerRef = React.useRef(null)
1112
+ var pollingRef = React.useRef(false)
1113
+ var fetchRef = React.useRef(null)
1114
+ var jobsRef = React.useRef([])
1115
+ var downloadDirReadyRef = React.useRef(false)
1116
+ var displayNameReadyRef = React.useRef(false)
1117
+ var uploadInputRef = React.useRef(null)
1118
+ var menuRef = React.useRef(null)
1119
+ var dragDepthRef = React.useRef(0)
1120
+ /** 上传面板的根节点,给 window 捕获阶段的拖放护栏判断「事件在不在面板里」。 */
1121
+ var dragGuardRef = React.useRef(null) // dragenter/leave 成对计数,免得划过子元素就闪
1122
+ var urlSeqRef = React.useRef(0)
1123
+ var urlTimersRef = React.useRef([])
1124
+ var focusUploadRef = React.useRef(false)
1125
+ // 上传面板:投放时用的「钉住的目录」(投放回调里读 ref,拿到的永远是最新一版)。
1126
+ var uploadTargetRef = React.useRef(null)
1127
+ /** GET /login/saved 只问一次:之后再挂载(重新授权等)不重复打。 */
1128
+ var loginSavedRef = React.useRef(false)
1129
+
1130
+ var authorized = !!(status && status.authorized)
1131
+ var parentId = stack.length > 0 ? stack[stack.length - 1].id : '/'
1132
+ /** 脱敏手机号:Host 新版本才会给;没有就当没有(绝不回退成完整号码)。 */
1133
+ var accountMasked = status && typeof status.accountMasked === 'string' ? status.accountMasked.trim() : ''
1134
+ var hasAccount = accountMasked !== ''
1135
+ /** 显示名非空就盖住脱敏号码。 */
1136
+ var accountLabel = displayName !== '' ? displayName : accountMasked
1137
+
1138
+ function loadStatus() {
1139
+ setStatusLoading(true)
1140
+ return api.status()
1141
+ .then(function (doc) {
1142
+ setStatus(doc)
1143
+ setStatusError('')
1144
+ setStatusLoading(false)
1145
+ return doc
1146
+ })
1147
+ .catch(function (failure) {
1148
+ setStatusLoading(false)
1149
+ setStatusError(t('refreshFailed', { detail: messageOf(failure) }))
1150
+ logWarn('status failed', failure)
1151
+ return null
1152
+ })
1153
+ }
1154
+
1155
+ /**
1156
+ * 目录缓存:点文件夹「慢」的主因是每次进出都重新拉一遍。
1157
+ * 命中缓存就**立刻**渲染,同时后台再拉一次校准(stale-while-revalidate)。
1158
+ * 写操作 / 手动刷新会清掉全部缓存。只缓存第一页(翻页数据不缓存)。
1159
+ */
1160
+ var listingCacheRef = React.useRef({})
1161
+
1162
+ function cacheKeyOf(id, cursor) {
1163
+ return String(id || '/') + '|' + String(cursor || '')
1164
+ }
1165
+
1166
+ /** 客户端目录缓存的有效期:比 Host 端(60s)宽松一点,因为这里只负责「秒开」。 */
1167
+ var LISTING_CACHE_TTL_MS = 5 * 60 * 1000
1168
+
1169
+ function readListingCache(id, cursor) {
1170
+ if (cursor) return null // 只缓存首屏
1171
+ var bucket = listingCacheRef.current
1172
+ var hit = bucket[cacheKeyOf(id, cursor)]
1173
+ if (!hit) return null
1174
+ // 5 分钟内的缓存可以秒开;再久就别用了,数据可能变
1175
+ if (Date.now() - hit.at > LISTING_CACHE_TTL_MS) {
1176
+ delete bucket[cacheKeyOf(id, cursor)]
1177
+ return null
1178
+ }
1179
+ return hit.value
1180
+ }
1181
+
1182
+ function writeListingCache(id, cursor, value) {
1183
+ if (cursor) return
1184
+ listingCacheRef.current[cacheKeyOf(id, cursor)] = { at: Date.now(), value: value }
1185
+ }
1186
+
1187
+ function clearListingCache() {
1188
+ listingCacheRef.current = {}
1189
+ }
1190
+
1191
+ function fetchListing(cursor, append) {
1192
+ var seq = seqRef.current + 1
1193
+ seqRef.current = seq
1194
+ var isFirstPage = !cursor
1195
+ var cached = isFirstPage ? readListingCache(parentId, '') : null
1196
+
1197
+ // 命中缓存:先秒开,再后台校准。用户看到的就是「立刻响应」。
1198
+ if (isFirstPage) {
1199
+ if (cached) {
1200
+ setItems(cached.items)
1201
+ setNextCursor(cached.nextCursor || null)
1202
+ setListLoading(false)
1203
+ setListError('')
1204
+ } else {
1205
+ setListLoading(true)
1206
+ setListError('')
1207
+ }
1208
+ } else {
1209
+ setListLoading(true)
1210
+ setListError('')
1211
+ }
1212
+
1213
+ return api.list(parentId, cursor || '')
1214
+ .then(function (doc) {
1215
+ if (seqRef.current !== seq) return
1216
+ var list = Array.isArray(doc.items) ? doc.items : []
1217
+ setItems(function (previous) { return append ? previous.concat(list) : list })
1218
+ setNextCursor(doc.nextCursor || null)
1219
+ setListLoading(false)
1220
+ if (isFirstPage) {
1221
+ writeListingCache(parentId, '', { items: list, nextCursor: doc.nextCursor || null })
1222
+ }
1223
+ })
1224
+ .catch(function (failure) {
1225
+ if (seqRef.current !== seq) return
1226
+ setListLoading(false)
1227
+ // 有缓存就保持旧内容,只在上面挂一条轻提示,别把整页清空。
1228
+ if (cached && isFirstPage) {
1229
+ setListError(t('refreshFailed', { detail: messageOf(failure) }))
1230
+ return
1231
+ }
1232
+ setListError(t('listFailed', { detail: messageOf(failure) }))
1233
+ if (!append) {
1234
+ setItems([])
1235
+ setNextCursor(null)
1236
+ }
1237
+ logWarn('list failed', failure)
1238
+ })
1239
+ }
1240
+
1241
+ function reload() {
1242
+ // 手动刷新:先清缓存,再强制拉一次
1243
+ clearListingCache()
1244
+ return fetchListing('', false)
1245
+ }
1246
+
1247
+ React.useEffect(function () {
1248
+ loadStatus()
1249
+ }, [])
1250
+
1251
+ // 本机存过密码就预填账号 + 勾上「记住密码」;密码本身永远拿不回来(Host 也不回传)。
1252
+ // 只问一次;失败就当没存过,绝不影响登录页本身。
1253
+ React.useEffect(function () {
1254
+ if (loginSavedRef.current) return
1255
+ loginSavedRef.current = true
1256
+ api.loginSaved()
1257
+ .then(function (doc) {
1258
+ if (!doc || doc.hasSaved !== true) return
1259
+ var account = typeof doc.account === 'string' ? doc.account : ''
1260
+ if (account !== '') setLoginAccount(account)
1261
+ setRememberPassword(true)
1262
+ setSavedHint(t('loginSavedHint'))
1263
+ })
1264
+ .catch(function (failure) {
1265
+ logWarn('login/saved failed', failure)
1266
+ })
1267
+ }, [])
1268
+
1269
+ React.useEffect(function () {
1270
+ if (!authorized) {
1271
+ seqRef.current += 1
1272
+ setItems([])
1273
+ setNextCursor(null)
1274
+ setListLoading(false)
1275
+ return
1276
+ }
1277
+ fetchListing('', false)
1278
+ }, [parentId, authorized])
1279
+
1280
+ // ------------------------------------------------------ 传输 / 菜单副作用
1281
+ // interval 里调的是 ref,所以每次渲染后都把它指向最新一版 fetchTransfers。
1282
+ React.useEffect(function () {
1283
+ fetchRef.current = fetchTransfers
1284
+ })
1285
+
1286
+ // 上传面板的目标目录同样过一遍 ref:投放回调里读得又稳又新。
1287
+ React.useEffect(function () {
1288
+ uploadTargetRef.current = uploadOpen ? uploadTarget : null
1289
+ }, [uploadOpen, uploadTarget])
1290
+
1291
+ // 卸载时一定要停表:不在有任务时才轮询,也绝不漏一个野 interval / 定时器。
1292
+ React.useEffect(function () {
1293
+ return function () {
1294
+ stopTransferPolling()
1295
+ clearUrlTimers()
1296
+ }
1297
+ }, [])
1298
+
1299
+ // 折叠区刚被「上传」菜单项展开:等它真挂上去了再聚焦 + 滚进来。
1300
+ React.useEffect(function () {
1301
+ if (!pathOpen || !focusUploadRef.current) return
1302
+ focusUploadRef.current = false
1303
+ focusUploadInput()
1304
+ }, [pathOpen])
1305
+
1306
+ // 挂载(或重新授权)时先拉一次任务列表;有任务在跑它自己会开表。
1307
+ React.useEffect(function () {
1308
+ if (!authorized) {
1309
+ jobsRef.current = []
1310
+ setJobs([])
1311
+ stopTransferPolling()
1312
+ return
1313
+ }
1314
+ fetchTransfers()
1315
+ }, [authorized])
1316
+
1317
+ // 下载目录:localStorage 优先,其次用 Host 给的默认值;用户改过就不再覆盖。
1318
+ React.useEffect(function () {
1319
+ if (downloadDirReadyRef.current || downloadDir !== '') return
1320
+ var stored = readDownloadDir()
1321
+ var fallback = status && typeof status.defaultDownloadDir === 'string' ? status.defaultDownloadDir : ''
1322
+ var initial = stored || fallback
1323
+ if (!initial) return
1324
+ downloadDirReadyRef.current = true
1325
+ setDownloadDir(initial)
1326
+ }, [status, downloadDir])
1327
+
1328
+ // 显示名:只读一次本机存的值(拿不到就保持空 = 显示脱敏号码)。
1329
+ React.useEffect(function () {
1330
+ if (displayNameReadyRef.current) return
1331
+ displayNameReadyRef.current = true
1332
+ var stored = readDisplayName()
1333
+ if (stored !== '') setDisplayName(stored)
1334
+ }, [])
1335
+
1336
+ // 菜单刚打开:量一次尺寸,会溢出视口就翻到光标左/上方。
1337
+ useLayout(function () {
1338
+ if (!menu || menu.placed) return
1339
+ var left = menu.x
1340
+ var top = menu.y
1341
+ var node = menuRef.current
1342
+ var rect = node && typeof node.getBoundingClientRect === 'function' ? node.getBoundingClientRect() : null
1343
+ if (rect) {
1344
+ var width = Number(rect.width) || 0
1345
+ var height = Number(rect.height) || 0
1346
+ var vw = typeof window !== 'undefined' ? Number(window.innerWidth) || 0 : 0
1347
+ var vh = typeof window !== 'undefined' ? Number(window.innerHeight) || 0 : 0
1348
+ if (vw > 0 && width > 0 && left + width > vw - 8) left = Math.max(8, menu.x - width)
1349
+ if (vh > 0 && height > 0 && top + height > vh - 8) top = Math.max(8, menu.y - height)
1350
+ }
1351
+ // placed 无论如何都要落下:量不到尺寸时也得把菜单亮出来,不能一直藏着。
1352
+ setMenu(function (previous) {
1353
+ if (!previous || previous.placed) return previous
1354
+ return { x: left, y: top, item: previous.item, placed: true }
1355
+ })
1356
+ }, [menu])
1357
+
1358
+ // ------------------------------------------------------ 拖放护栏
1359
+ /**
1360
+ * 上传面板开着时,在 window 的**捕获阶段**接管面板内的拖放事件。
1361
+ *
1362
+ * 为什么非得在捕获阶段:DSH 的会话输入框也在监听拖放(丢文件进去会变成附件),
1363
+ * 而它很可能挂在 document 的捕获阶段 —— 那比我们面板里的冒泡处理更早。
1364
+ * 只有 window 捕获阶段能抢在它前面。
1365
+ *
1366
+ * 但这里**截断之后必须自己把事件转交给面板**,否则事件永远到不了拖拽区
1367
+ * (捕获阶段在 window 上 stopPropagation,目标元素就收不到了 ——
1368
+ * 上一版就是这么把上传搞坏的)。
1369
+ *
1370
+ * 所以:window 捕获 → 拦下 → 按事件类型调用面板对应的处理函数。
1371
+ */
1372
+ React.useEffect(function () {
1373
+ if (!uploadOpen) return undefined
1374
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return undefined
1375
+
1376
+ function insidePanel(target) {
1377
+ try {
1378
+ var panelNode = dragGuardRef.current
1379
+ return Boolean(panelNode && target && typeof panelNode.contains === 'function' && panelNode.contains(target))
1380
+ } catch (failure) {
1381
+ return false
1382
+ }
1383
+ }
1384
+
1385
+ function guard(event) {
1386
+ if (!insidePanel(event ? event.target : null)) return
1387
+ // 抢在宿主前面:既允许 drop,又阻止它继续被 document/window 上的监听拿走
1388
+ if (typeof event.preventDefault === 'function') event.preventDefault()
1389
+ if (typeof event.stopPropagation === 'function') event.stopPropagation()
1390
+ if (typeof event.stopImmediatePropagation === 'function') event.stopImmediatePropagation()
1391
+
1392
+ // 事件已经被我们截断,目标元素收不到了 —— 在这里手动转交给面板。
1393
+ var type = event ? event.type : ''
1394
+ try {
1395
+ if (type === 'dragenter') onDragEnter(event)
1396
+ else if (type === 'dragover') onDragOver(event)
1397
+ else if (type === 'dragleave') onDragLeave(event)
1398
+ else if (type === 'drop') onPanelDrop(event)
1399
+ } catch (failure) {
1400
+ logWarn('drag guard dispatch failed', failure)
1401
+ }
1402
+ }
1403
+
1404
+ var EVENTS = ['dragenter', 'dragover', 'dragleave', 'drop']
1405
+ for (var i = 0; i < EVENTS.length; i += 1) {
1406
+ window.addEventListener(EVENTS[i], guard, true)
1407
+ }
1408
+ return function () {
1409
+ for (var j = 0; j < EVENTS.length; j += 1) {
1410
+ try {
1411
+ window.removeEventListener(EVENTS[j], guard, true)
1412
+ } catch (failure) {
1413
+ /* 卸不掉也无妨 */
1414
+ }
1415
+ }
1416
+ }
1417
+ }, [uploadOpen])
1418
+
1419
+
1420
+ React.useEffect(function () {
1421
+ if (!menu) return undefined
1422
+ function close() { setMenu(null) }
1423
+ function onPointer(event) {
1424
+ var node = menuRef.current
1425
+ var target = event ? event.target : null
1426
+ if (node && target && (node === target ||
1427
+ (typeof node.contains === 'function' && node.contains(target)))) return
1428
+ close()
1429
+ }
1430
+ function onKey(event) {
1431
+ if (event && event.key === 'Escape') close()
1432
+ }
1433
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
1434
+ document.addEventListener('mousedown', onPointer, true)
1435
+ document.addEventListener('contextmenu', onPointer, true)
1436
+ document.addEventListener('keydown', onKey, true)
1437
+ }
1438
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
1439
+ window.addEventListener('scroll', close, true)
1440
+ window.addEventListener('resize', close, true)
1441
+ window.addEventListener('blur', close, true)
1442
+ }
1443
+ return function () {
1444
+ if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
1445
+ document.removeEventListener('mousedown', onPointer, true)
1446
+ document.removeEventListener('contextmenu', onPointer, true)
1447
+ document.removeEventListener('keydown', onKey, true)
1448
+ }
1449
+ if (typeof window !== 'undefined' && typeof window.removeEventListener === 'function') {
1450
+ window.removeEventListener('scroll', close, true)
1451
+ window.removeEventListener('resize', close, true)
1452
+ window.removeEventListener('blur', close, true)
1453
+ }
1454
+ }
1455
+ }, [menu])
1456
+
1457
+ // 上传面板开着的时候:Escape 关掉它(面板和菜单不会同时开着)。
1458
+ React.useEffect(function () {
1459
+ if (!uploadOpen) return undefined
1460
+ function onKey(event) {
1461
+ if (event && event.key === 'Escape') closeUploadPanel()
1462
+ }
1463
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
1464
+ document.addEventListener('keydown', onKey, true)
1465
+ }
1466
+ return function () {
1467
+ if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
1468
+ document.removeEventListener('keydown', onKey, true)
1469
+ }
1470
+ }
1471
+ }, [uploadOpen])
1472
+
1473
+ // ------------------------------------------------------ 动作
1474
+ /** 成功就重拉列表 + 状态;失败只显示 banner,绝不往外抛。 */
1475
+ function runMutation(factory) {
1476
+ setBusy(true)
1477
+ setListError('')
1478
+ return factory()
1479
+ .then(function () {
1480
+ setBusy(false)
1481
+ loadStatus()
1482
+ return reload()
1483
+ })
1484
+ .catch(function (failure) {
1485
+ setBusy(false)
1486
+ setListError(t('actFailed', { detail: messageOf(failure) }))
1487
+ logWarn('mutation failed', failure)
1488
+ return null
1489
+ })
1490
+ }
1491
+
1492
+ /**
1493
+ * 账号密码登录。
1494
+ *
1495
+ * 客户端先校验:账号或密码为空就**不发请求**(Host 那边也会拒,但没必要多跑一趟)。
1496
+ * 失败时账号必须留着 —— 用户重输密码就行,别逼他再打一遍手机号。
1497
+ * 成功时密码一律清空;只有没勾「记住密码」才连账号一起清掉(勾了的话
1498
+ * Host 已经把它存下来了,下次进来要预填)。
1499
+ */
1500
+ function submitLogin() {
1501
+ if (loginBusy) return
1502
+ var account = loginAccount.trim()
1503
+ var password = loginPassword
1504
+ if (!account || !password) {
1505
+ setLoginError(t('loginEmpty'))
1506
+ setLoginNotice('')
1507
+ return
1508
+ }
1509
+ var remember = rememberPassword === true
1510
+ setLoginBusy(true)
1511
+ setLoginError('')
1512
+ setLoginNotice('')
1513
+ api.login(account, password, remember)
1514
+ .then(function (doc) {
1515
+ setLoginBusy(false)
1516
+ setLoginPassword('')
1517
+ if (!remember) setLoginAccount('')
1518
+ setLoginNotice(t('loginOk') + (doc && doc.accountMasked ? ' · ' + doc.accountMasked : ''))
1519
+ if (remember) setSavedHint(t('loginSavedHint'))
1520
+ // /login 已经回传 accountMasked / quota:先乐观更新,不等第二次 /status。
1521
+ setStatus(function (previous) {
1522
+ var base = previous && typeof previous === 'object' ? previous : {}
1523
+ return {
1524
+ ok: true,
1525
+ authorized: true,
1526
+ accountMasked: (doc && doc.accountMasked) || base.accountMasked || '',
1527
+ expiresAt: typeof base.expiresAt === 'number' ? base.expiresAt : null,
1528
+ credentialPath: base.credentialPath || '',
1529
+ quota: (doc && doc.quota) || base.quota || null,
1530
+ quotaError: (doc && doc.quotaError) || '',
1531
+ host: base.host || null,
1532
+ userDomainId: base.userDomainId || null,
1533
+ }
1534
+ })
1535
+ setStack([])
1536
+ loadStatus().then(function () { reload() })
1537
+ })
1538
+ .catch(function (failure) {
1539
+ setLoginBusy(false)
1540
+ // 原样显示 Host 的 error(例如「账号或密码不正确」),不翻译、不改写。
1541
+ setLoginError(messageOf(failure))
1542
+ logWarn('login failed', failure)
1543
+ })
1544
+ }
1545
+
1546
+ function saveAuth() {
1547
+ var authorization = authValue.trim()
1548
+ var userDomainId = udValue.trim()
1549
+ if (!authorization) {
1550
+ setAuthError(t('notAuthorized'))
1551
+ return
1552
+ }
1553
+ setAuthBusy(true)
1554
+ setAuthError('')
1555
+ setAuthNotice('')
1556
+ api.auth(authorization, userDomainId)
1557
+ .then(function (doc) {
1558
+ setAuthBusy(false)
1559
+ setAuthValue('')
1560
+ setReauthOpen(false)
1561
+ setAuthNotice(t('authOk') + (doc && doc.accountMasked ? ' · ' + doc.accountMasked : ''))
1562
+ // /auth 已经回传 accountMasked / quota:先乐观更新,不等第二次 /status。
1563
+ setStatus(function (previous) {
1564
+ var base = previous && typeof previous === 'object' ? previous : {}
1565
+ return {
1566
+ ok: true,
1567
+ authorized: true,
1568
+ accountMasked: (doc && doc.accountMasked) || base.accountMasked || '',
1569
+ expiresAt: typeof base.expiresAt === 'number' ? base.expiresAt : null,
1570
+ credentialPath: base.credentialPath || '',
1571
+ quota: (doc && doc.quota) || base.quota || null,
1572
+ host: base.host || null,
1573
+ userDomainId: userDomainId || base.userDomainId || null,
1574
+ }
1575
+ })
1576
+ setStack([])
1577
+ loadStatus().then(function () { reload() })
1578
+ })
1579
+ .catch(function (failure) {
1580
+ setAuthBusy(false)
1581
+ setAuthError(messageOf(failure))
1582
+ logWarn('auth failed', failure)
1583
+ })
1584
+ }
1585
+
1586
+ function clearAuth() {
1587
+ if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
1588
+ if (!window.confirm(t('clearConfirm'))) return
1589
+ }
1590
+ setAuthBusy(true)
1591
+ setAuthError('')
1592
+ api.clearAuth()
1593
+ .then(function () {
1594
+ setAuthBusy(false)
1595
+ setAuthNotice('')
1596
+ setReauthOpen(false)
1597
+ setAuthValue('')
1598
+ setUdValue('')
1599
+ setStack([])
1600
+ setItems([])
1601
+ setNextCursor(null)
1602
+ return loadStatus()
1603
+ })
1604
+ .catch(function (failure) {
1605
+ setAuthBusy(false)
1606
+ setAuthError(messageOf(failure))
1607
+ logWarn('clear auth failed', failure)
1608
+ })
1609
+ }
1610
+
1611
+ function openReauth() {
1612
+ setReauthOpen(true)
1613
+ setAuthValue('')
1614
+ setUdValue((status && status.userDomainId) || '')
1615
+ setAuthError('')
1616
+ setAuthNotice('')
1617
+ }
1618
+
1619
+ function enterFolder(item) {
1620
+ setStack(stack.concat([{ id: item.id, name: item.name }]))
1621
+ }
1622
+
1623
+ function goCrumb(index) {
1624
+ setStack(stack.slice(0, index + 1))
1625
+ }
1626
+
1627
+ function goRoot() {
1628
+ setStack([])
1629
+ }
1630
+
1631
+ function goUp() {
1632
+ setStack(stack.slice(0, stack.length - 1))
1633
+ }
1634
+
1635
+ function createFolder() {
1636
+ var name = folderName.trim()
1637
+ if (!name) return
1638
+ runMutation(function () {
1639
+ return api.mkdir(parentId, name).then(function (doc) {
1640
+ setFolderName('')
1641
+ setFolderOpen(false)
1642
+ return doc
1643
+ })
1644
+ })
1645
+ }
1646
+
1647
+ function renameEntry(item) {
1648
+ if (typeof window === 'undefined' || typeof window.prompt !== 'function') return
1649
+ var next = window.prompt(t('renamePrompt'), item.name)
1650
+ if (next === null) return
1651
+ next = String(next).trim()
1652
+ if (!next || next === item.name) return
1653
+ runMutation(function () {
1654
+ return api.rename(item.id, next)
1655
+ })
1656
+ }
1657
+
1658
+ // ------------------------------------------------------ 复制路径
1659
+ /** 当前所在目录的完整路径,例如 "/windows/实验"。 */
1660
+ function currentDirPath() {
1661
+ return '/' + stack.map(function (s) { return s.name }).join('/')
1662
+ }
1663
+
1664
+ /**
1665
+ * 把一个项的完整路径拼出来(基于当前目录栈 + 名字)。
1666
+ * 注意:这是**展示用的路径**,不是网盘的 fileId;移动云盘按 id 定位,
1667
+ * 所以这个路径只适合给人看 / 粘到笔记里。
1668
+ */
1669
+ function itemFullPath(item) {
1670
+ if (!item) return currentDirPath()
1671
+ var base = currentDirPath()
1672
+ return (base === '/' ? '' : base) + '/' + String(item.name || '')
1673
+ }
1674
+
1675
+ /** 优先用剪贴板 API;不支持时退回 textarea + execCommand。 */
1676
+ function copyTextToClipboard(text) {
1677
+ return new Promise(function (resolve, reject) {
1678
+ if (typeof navigator !== 'undefined' && navigator.clipboard &&
1679
+ typeof navigator.clipboard.writeText === 'function') {
1680
+ navigator.clipboard.writeText(text).then(resolve, function () {
1681
+ // 权限被拒之类:退回老办法
1682
+ fallbackCopy(text) ? resolve() : reject(new Error('clipboard unavailable'))
1683
+ })
1684
+ return
1685
+ }
1686
+ if (fallbackCopy(text)) { resolve(); return }
1687
+ reject(new Error('clipboard unavailable'))
1688
+ })
1689
+ }
1690
+
1691
+ function fallbackCopy(text) {
1692
+ try {
1693
+ if (typeof document === 'undefined' || typeof document.createElement !== 'function') return false
1694
+ var area = document.createElement('textarea')
1695
+ area.value = text
1696
+ area.setAttribute('readonly', '')
1697
+ area.style.position = 'fixed'
1698
+ area.style.top = '-1000px'
1699
+ area.style.opacity = '0'
1700
+ var host = document.body || document.documentElement
1701
+ if (!host || typeof host.appendChild !== 'function') return false
1702
+ host.appendChild(area)
1703
+ area.select()
1704
+ if (typeof document.execCommand !== 'function') {
1705
+ host.removeChild(area)
1706
+ return false
1707
+ }
1708
+ var ok = document.execCommand('copy')
1709
+ host.removeChild(area)
1710
+ return ok === true
1711
+ } catch (failure) {
1712
+ logWarn('fallback copy failed', failure)
1713
+ return false
1714
+ }
1715
+ }
1716
+
1717
+ function copyItemPath(item) {
1718
+ var path = itemFullPath(item)
1719
+ setNotice(t('pathCopied', { path: path }))
1720
+ copyTextToClipboard(path).then(function () {
1721
+ setNotice(t('pathCopied', { path: path }))
1722
+ }, function (failure) {
1723
+ // 复制失败也别让用户抓空:把路径显示出来让他自己选中
1724
+ setListError(t('copyFailed', { path: path }))
1725
+ logWarn('copy path failed', failure)
1726
+ })
1727
+ }
1728
+
1729
+ function removeEntry(item) {
1730
+ if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
1731
+ if (!window.confirm(t('removeConfirm', { name: item.name }))) return
1732
+ }
1733
+ runMutation(function () {
1734
+ return api.remove([item.id])
1735
+ })
1736
+ }
1737
+
1738
+ // ------------------------------------------------------ 转移(移动)到别的文件夹
1739
+ /**
1740
+ * 转移面板:打开时从根目录开始浏览,点文件夹进去,底部「转移到这里」。
1741
+ * 目标不能是被移动项自己,也不能是它自己内部的子目录(服务端会拒绝,
1742
+ * 但在这里先挡掉,省得用户白等一次往返)。
1743
+ */
1744
+ function openMovePicker(item) {
1745
+ if (!item) return
1746
+ setMoveSource(item)
1747
+ setMoveOpen(true)
1748
+ setMoveStack([])
1749
+ setMoveError('')
1750
+ setMoveNote('')
1751
+ setMoveLoading(true)
1752
+ api.list('/', '')
1753
+ .then(function (doc) {
1754
+ setMoveItems(Array.isArray(doc.items) ? doc.items : [])
1755
+ setMoveCursor(doc.nextCursor || null)
1756
+ setMoveLoading(false)
1757
+ })
1758
+ .catch(function (failure) {
1759
+ setMoveLoading(false)
1760
+ setMoveError(t('listFailed', { detail: messageOf(failure) }))
1761
+ logWarn('move picker list failed', failure)
1762
+ })
1763
+ }
1764
+
1765
+ function closeMovePicker() {
1766
+ setMoveOpen(false)
1767
+ setMoveSource(null)
1768
+ setMoveStack([])
1769
+ setMoveItems([])
1770
+ setMoveCursor(null)
1771
+ setMoveError('')
1772
+ setMoveNote('')
1773
+ }
1774
+
1775
+ function movePickerParentId() {
1776
+ return moveStack.length > 0 ? moveStack[moveStack.length - 1].id : '/'
1777
+ }
1778
+
1779
+ function loadMoveDir(dirId) {
1780
+ setMoveLoading(true)
1781
+ setMoveError('')
1782
+ api.list(dirId, '')
1783
+ .then(function (doc) {
1784
+ setMoveItems(Array.isArray(doc.items) ? doc.items : [])
1785
+ setMoveCursor(doc.nextCursor || null)
1786
+ setMoveLoading(false)
1787
+ })
1788
+ .catch(function (failure) {
1789
+ setMoveLoading(false)
1790
+ setMoveError(t('listFailed', { detail: messageOf(failure) }))
1791
+ logWarn('move picker list failed', failure)
1792
+ })
1793
+ }
1794
+
1795
+ function moveInto(folder) {
1796
+ setMoveStack(moveStack.concat([{ id: folder.id, name: folder.name }]))
1797
+ loadMoveDir(folder.id)
1798
+ }
1799
+
1800
+ function moveUp() {
1801
+ var next = moveStack.slice(0, moveStack.length - 1)
1802
+ setMoveStack(next)
1803
+ loadMoveDir(next.length > 0 ? next[next.length - 1].id : '/')
1804
+ }
1805
+
1806
+ /** 目标合法性:不能是它自己,也不能是它自己的子目录。 */
1807
+ function moveTargetProblem(targetId) {
1808
+ var source = moveSource
1809
+ if (!source) return 'no-source'
1810
+ if (String(source.id) === String(targetId)) return 'self'
1811
+ // 如果源是文件夹,且目标就在它当前所在的子路径上,服务端会拒绝
1812
+ for (var i = 0; i < moveStack.length; i += 1) {
1813
+ if (String(moveStack[i].id) === String(source.id)) return 'inside'
1814
+ }
1815
+ return null
1816
+ }
1817
+
1818
+ function confirmMove() {
1819
+ var source = moveSource
1820
+ if (!source) return
1821
+ var targetId = movePickerParentId()
1822
+ var problem = moveTargetProblem(targetId)
1823
+ if (problem === 'self') { setMoveError(t('moveSelf')); return }
1824
+ if (problem === 'inside') { setMoveError(t('moveInside')); return }
1825
+ var targetName = moveStack.length > 0 ? moveStack[moveStack.length - 1].name : t('root')
1826
+ setMoveBusy(true)
1827
+ setMoveError('')
1828
+ api.move([source.id], targetId)
1829
+ .then(function () {
1830
+ setMoveBusy(false)
1831
+ closeMovePicker()
1832
+ // 移动后两边目录都变了:清缓存并刷新当前视图
1833
+ clearListingCache()
1834
+ return reload()
1835
+ })
1836
+ .catch(function (failure) {
1837
+ setMoveBusy(false)
1838
+ setMoveError(t('actFailed', { detail: messageOf(failure) }))
1839
+ logWarn('move failed', failure)
1840
+ })
1841
+ }
1842
+
1843
+ function renderMovePicker() {
1844
+ if (!moveOpen || !moveSource) return null
1845
+ var targetName = moveStack.length > 0 ? moveStack[moveStack.length - 1].name : t('root')
1846
+ var folders = moveItems.filter(function (i) { return i.type === 'folder' })
1847
+ var overlay = h('div', {
1848
+ className: 'mc-overlay',
1849
+ onMouseDown: function (event) {
1850
+ if (event && event.target !== event.currentTarget) return
1851
+ closeMovePicker()
1852
+ },
1853
+ },
1854
+ h('div', { className: 'mc-panel', role: 'dialog', 'aria-modal': 'true' },
1855
+ h('div', { className: 'mc-panelHead' },
1856
+ h('h2', { className: 'mc-panelTitle', title: t('moveTitle') }, t('moveTitle')),
1857
+ h('button', {
1858
+ type: 'button',
1859
+ className: 'mc-btn mc-panelClose',
1860
+ title: t('close'),
1861
+ 'aria-label': t('close'),
1862
+ onClick: closeMovePicker,
1863
+ }, '✕')
1864
+ ),
1865
+ h('p', { className: 'mc-hint' }, t('moveSource', { name: moveSource.name })),
1866
+ h('div', { className: 'mc-moveBar' },
1867
+ moveStack.length > 0 ? h('button', {
1868
+ className: 'mc-btn mc-btnSmall',
1869
+ onClick: moveUp,
1870
+ }, '↑ ' + t('up')) : null,
1871
+ h('span', { className: 'mc-movePath', title: '/' + moveStack.map(function (s) { return s.name }).join('/') },
1872
+ '/' + moveStack.map(function (s) { return s.name }).join('/'))
1873
+ ),
1874
+ moveError ? h('div', { className: 'mc-banner mc-bannerErr' }, moveError) : null,
1875
+ moveLoading
1876
+ ? h('div', { className: 'mc-loading' }, t('loading'))
1877
+ : (folders.length === 0
1878
+ ? h('div', { className: 'mc-empty' }, t('moveNoFolders'))
1879
+ : h('div', { className: 'mc-moveList' }, folders.map(function (folder) {
1880
+ var isSource = String(folder.id) === String(moveSource.id)
1881
+ return h('button', {
1882
+ key: folder.id,
1883
+ type: 'button',
1884
+ className: 'mc-moveRow' + (isSource ? ' mc-moveRowDisabled' : ''),
1885
+ disabled: isSource,
1886
+ title: isSource ? t('moveInside') : folder.name,
1887
+ onClick: function () { moveInto(folder) },
1888
+ }, '📁 ' + folder.name, isSource ? h('span', { className: 'mc-moveTag' }, t('moveInside')) : null)
1889
+ }))
1890
+ ),
1891
+ h('div', { className: 'mc-panelFoot' },
1892
+ h('button', {
1893
+ className: 'mc-btn mc-btnPrimary',
1894
+ disabled: moveBusy || moveLoading,
1895
+ onClick: confirmMove,
1896
+ }, moveBusy ? t('loading') : t('moveHere', { name: targetName })),
1897
+ h('button', { className: 'mc-btn', onClick: closeMovePicker }, t('cancel'))
1898
+ )
1899
+ )
1900
+ )
1901
+ return portalToBody(overlay)
1902
+ }
1903
+
1904
+
1905
+ function openLink(item) {
1906
+ setLinkBusy(item.id)
1907
+ setListError('')
1908
+ api.link(item.id)
1909
+ .then(function (doc) {
1910
+ setLinkBusy('')
1911
+ if (doc && doc.url && typeof window !== 'undefined' && typeof window.open === 'function') {
1912
+ window.open(doc.url, '_blank', 'noopener')
1913
+ return
1914
+ }
1915
+ throw new Error(t('error'))
1916
+ })
1917
+ .catch(function (failure) {
1918
+ setLinkBusy('')
1919
+ setListError(t('openFailed', { detail: messageOf(failure) }))
1920
+ logWarn('link failed', failure)
1921
+ })
1922
+ }
1923
+
1924
+ function onEnterKey(fn) {
1925
+ return function (event) {
1926
+ if (event.key === 'Enter') {
1927
+ event.preventDefault()
1928
+ fn()
1929
+ }
1930
+ }
1931
+ }
1932
+
1933
+ // ------------------------------------------------------ 传输任务
1934
+ function stopTransferPolling() {
1935
+ if (pollTimerRef.current !== null) {
1936
+ try {
1937
+ clearInterval(pollTimerRef.current)
1938
+ } catch (failure) {
1939
+ logWarn('clear transfer timer failed', failure)
1940
+ }
1941
+ pollTimerRef.current = null
1942
+ }
1943
+ pollingRef.current = false
1944
+ }
1945
+
1946
+ function applyJobs(list) {
1947
+ jobsRef.current = list
1948
+ setJobs(list)
1949
+ }
1950
+
1951
+ function hasRunningJob(list) {
1952
+ for (var i = 0; i < list.length; i += 1) {
1953
+ if (list[i] && list[i].state === 'running') return true
1954
+ }
1955
+ return false
1956
+ }
1957
+
1958
+ function ensureTransferPolling() {
1959
+ if (pollingRef.current) return
1960
+ pollingRef.current = true
1961
+ pollTimerRef.current = setInterval(function () {
1962
+ // 走 ref:轮询回调要用的永远是最新一版(parentId 可能已经变了)。
1963
+ if (typeof fetchRef.current === 'function') fetchRef.current()
1964
+ }, TRANSFER_POLL_MS)
1965
+ }
1966
+
1967
+ function jobById(list, id) {
1968
+ for (var i = 0; i < list.length; i += 1) {
1969
+ if (list[i] && list[i].id === id) return list[i]
1970
+ }
1971
+ return null
1972
+ }
1973
+
1974
+ /** 拉一次任务列表:有任务在跑就开表,没有就停表。失败不往外抛。 */
1975
+ function fetchTransfers() {
1976
+ return api.transfers()
1977
+ .then(function (doc) {
1978
+ var list = doc && Array.isArray(doc.jobs) ? doc.jobs : []
1979
+ var previous = jobsRef.current || []
1980
+ var uploadFinished = false
1981
+ for (var i = 0; i < list.length; i += 1) {
1982
+ var job = list[i]
1983
+ if (!job || job.kind !== 'upload' || job.state !== 'done') continue
1984
+ var before = jobById(previous, job.id)
1985
+ if (before && before.state === 'running') uploadFinished = true
1986
+ }
1987
+ applyJobs(list)
1988
+ if (hasRunningJob(list)) ensureTransferPolling()
1989
+ else stopTransferPolling()
1990
+ // 上传落地后当前目录里才会多出这个文件,刷新一次列表。
1991
+ if (uploadFinished) reload()
1992
+ return list
1993
+ })
1994
+ .catch(function (failure) {
1995
+ logWarn('transfers failed', failure)
1996
+ // 拉不到时:已知还有任务在跑就留着等重试,否则停表 —— 不留空转的 interval。
1997
+ if (!hasRunningJob(jobsRef.current || [])) stopTransferPolling()
1998
+ return []
1999
+ })
2000
+ }
2001
+
2002
+ /**
2003
+ * 起一个任务。POST 只会回 jobId(连没授权也是先 200),
2004
+ * 所以这里只负责提示「已开始」,进度和真正的失败都在传输面板里说。
2005
+ */
2006
+ function startJob(key, startedNotice, factory) {
2007
+ setJobBusy(key)
2008
+ setListError('')
2009
+ setNotice('')
2010
+ return factory()
2011
+ .then(function (doc) {
2012
+ setJobBusy('')
2013
+ setNotice(startedNotice)
2014
+ return fetchTransfers().then(function () { return doc })
2015
+ })
2016
+ .catch(function (failure) {
2017
+ setJobBusy('')
2018
+ setListError(t('actFailed', { detail: messageOf(failure) }))
2019
+ logWarn('transfer start failed', failure)
2020
+ return null
2021
+ })
2022
+ }
2023
+
2024
+ function downloadFile(item) {
2025
+ var localPath = joinPath(downloadDir, item.name)
2026
+ return startJob(item.id, t('downloadStarted', { name: item.name }), function () {
2027
+ return api.download(item.id, localPath)
2028
+ })
2029
+ }
2030
+
2031
+ function downloadFolder(item) {
2032
+ if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
2033
+ if (!window.confirm(t('confirmDownloadFolder', { name: item.name }))) return null
2034
+ }
2035
+ var localDir = joinPath(downloadDir, item.name)
2036
+ return startJob(item.id, t('downloadStarted', { name: item.name }), function () {
2037
+ return api.downloadFolder(item.id, localDir)
2038
+ })
2039
+ }
2040
+
2041
+ function startUpload() {
2042
+ var localPath = uploadPath.trim()
2043
+ if (!localPath) return null
2044
+ return startJob('upload', t('uploadStarted', { name: baseName(localPath) }), function () {
2045
+ return api.upload(localPath, parentId).then(function (doc) {
2046
+ setUploadPath('')
2047
+ return doc
2048
+ })
2049
+ }).then(function (doc) {
2050
+ // 起完任务就重拉列表 + 传输面板(真正落地后轮询还会再刷一次)。
2051
+ return refreshAfterUpload().then(function () { return doc })
2052
+ })
2053
+ }
2054
+
2055
+ function cancelJob(job) {
2056
+ if (!job || !job.id) return
2057
+ setCancelBusy(job.id)
2058
+ api.cancelTransfer(job.id)
2059
+ .then(function () {
2060
+ setCancelBusy('')
2061
+ setNotice(t('transferCanceled'))
2062
+ return fetchTransfers()
2063
+ })
2064
+ .catch(function (failure) {
2065
+ setCancelBusy('')
2066
+ setListError(t('actFailed', { detail: messageOf(failure) }))
2067
+ logWarn('cancel transfer failed', failure)
2068
+ })
2069
+ }
2070
+
2071
+ function updateDownloadDir(value) {
2072
+ downloadDirReadyRef.current = true
2073
+ setDownloadDir(value)
2074
+ writeDownloadDir(value)
2075
+ }
2076
+
2077
+ /** 真正把光标送进路径输入框(折叠区可能刚展开)。 */
2078
+ function focusUploadInput() {
2079
+ var probeNode = uploadInputRef.current
2080
+ var node = uploadInputRef.current
2081
+ if (!node) return
2082
+ try {
2083
+ if (typeof node.scrollIntoView === 'function') node.scrollIntoView({ block: 'center' })
2084
+ } catch (failure) {
2085
+ logWarn('scroll upload into view failed', failure)
2086
+ }
2087
+ try {
2088
+ if (typeof node.focus === 'function') node.focus()
2089
+ } catch (failure) {
2090
+ logWarn('focus upload failed', failure)
2091
+ }
2092
+ }
2093
+
2094
+ /**
2095
+ * 右键菜单里的「上传」:路径输入框现在是折叠起来的,先展开,
2096
+ * 等它真挂上去(pathOpen 的 effect)再聚焦 —— 收起状态下的 input 不存在,
2097
+ * 直接 focus 会静默失败。
2098
+ */
2099
+ function focusUpload() {
2100
+ if (pathOpen) {
2101
+ focusUploadInput()
2102
+ return
2103
+ }
2104
+ focusUploadRef.current = true
2105
+ setPathOpen(true)
2106
+ }
2107
+
2108
+ /**
2109
+ * 上传的目标目录**永远是「当前打开的这个目录」**,不跟着右键的那一行走。
2110
+ * (用户明确要求:右键上传传到页面所在的位置,不是被右键的那个文件夹。)
2111
+ */
2112
+ function parentIdOf() {
2113
+ return parentId
2114
+ }
2115
+
2116
+ /**
2117
+ * 浏览器原生文件选择器(<input type="file">)。
2118
+ *
2119
+ * 不再让 Host spawn 系统对话框:实测在 DSH 宿主里那段 PowerShell 会秒退,
2120
+ * 根本弹不出窗口,还带着 z-order / 抢焦点的问题。浏览器的 <input type="file">
2121
+ * 打开的本来就是系统的资源管理器对话框,字节也直接在浏览器里读、走 /upload-body。
2122
+ *
2123
+ * 取消时浏览器不发任何事件:window 重新获得焦点后若迟迟没有 change,就当取消清掉。
2124
+ * 万一连 focus 都收不到,超过 10 分钟也允许重新发起 —— UI 永远有退路,绝不永久锁死。
2125
+ */
2126
+ var pickStartedAtRef = React.useRef(0)
2127
+ var pickingRef = React.useRef(false)
2128
+ var pickerRef = React.useRef(null)
2129
+ var FOCUS_CANCEL_MS = 250
2130
+ var PICKER_STALE_MS = 10 * 60 * 1000
2131
+
2132
+ /** 点下入口那一刻的目录钉死;不传 = 此刻打开的目录。 */
2133
+ function resolveTarget(targetParentId) {
2134
+ return targetParentId === null || targetParentId === undefined || targetParentId === ''
2135
+ ? parentId
2136
+ : targetParentId
2137
+ }
2138
+
2139
+ /** 收掉还开着的选择器(卸载时也走这里,不留野监听 / 野节点)。 */
2140
+ function dropActivePicker() {
2141
+ var active = pickerRef.current
2142
+ if (!active) return
2143
+ pickerRef.current = null
2144
+ if (typeof active.cleanup === 'function') active.cleanup()
2145
+ }
2146
+
2147
+ /**
2148
+ * 开一个隐藏的 <input type="file">,把用户选中的 File 列表交给 onPicked。
2149
+ * folder = true 时带 webkitdirectory(选文件夹,File 上会有 webkitRelativePath)。
2150
+ */
2151
+ function openFilePicker(folder, targetParentId, onPicked) {
2152
+ if (typeof document === 'undefined' || !document || typeof document.createElement !== 'function') return null
2153
+ if (pickingRef.current) {
2154
+ // 上一次选择器还开着:没过 10 分钟就别再开(防连点);真卡住了就收掉重开。
2155
+ if (Date.now() - pickStartedAtRef.current < PICKER_STALE_MS) return null
2156
+ dropActivePicker()
2157
+ }
2158
+ var target = resolveTarget(targetParentId)
2159
+ var input = document.createElement('input')
2160
+ var settled = false
2161
+ var focusTimer = null
2162
+
2163
+ function removeFocusListener() {
2164
+ if (typeof window === 'undefined' || typeof window.removeEventListener !== 'function') return
2165
+ try {
2166
+ window.removeEventListener('focus', onWindowFocus)
2167
+ } catch (failure) {
2168
+ logWarn('remove picker focus listener failed', failure)
2169
+ }
2170
+ }
2171
+ function removeChangeListener() {
2172
+ if (input && typeof input.removeEventListener === 'function') {
2173
+ try {
2174
+ input.removeEventListener('change', onChange)
2175
+ } catch (failure) {
2176
+ logWarn('remove picker change listener failed', failure)
2177
+ }
2178
+ return
2179
+ }
2180
+ if (input && input.onchange === onChange) input.onchange = null
2181
+ }
2182
+ function cleanup() {
2183
+ if (settled) return
2184
+ settled = true
2185
+ if (focusTimer !== null) {
2186
+ try {
2187
+ clearTimeout(focusTimer)
2188
+ } catch (failure) {
2189
+ logWarn('clear picker focus timer failed', failure)
2190
+ }
2191
+ focusTimer = null
2192
+ }
2193
+ removeFocusListener()
2194
+ removeChangeListener()
2195
+ try {
2196
+ if (input && input.parentNode && typeof input.parentNode.removeChild === 'function') {
2197
+ input.parentNode.removeChild(input)
2198
+ }
2199
+ } catch (failure) {
2200
+ logWarn('remove picker input failed', failure)
2201
+ }
2202
+ if (pickerRef.current && pickerRef.current.cleanup === cleanup) pickerRef.current = null
2203
+ pickingRef.current = false
2204
+ setPicking(false)
2205
+ }
2206
+ function onWindowFocus() {
2207
+ if (settled || focusTimer !== null) return
2208
+ // 回到前台了:再给 change 一点时间(有的浏览器 focus 早于 change)。
2209
+ focusTimer = setTimeout(function () {
2210
+ focusTimer = null
2211
+ if (settled) return
2212
+ cleanup()
2213
+ setNotice(t('pickCancelled'))
2214
+ }, FOCUS_CANCEL_MS)
2215
+ }
2216
+ function onChange() {
2217
+ if (settled) return
2218
+ var files = []
2219
+ try {
2220
+ var list = input ? input.files : null
2221
+ if (list && typeof list.length === 'number') {
2222
+ for (var i = 0; i < list.length; i += 1) {
2223
+ if (list[i]) files.push(list[i])
2224
+ }
2225
+ }
2226
+ } catch (failure) {
2227
+ logWarn('read picked files failed', failure)
2228
+ }
2229
+ // picking 立刻放掉:按钮马上就能再点。
2230
+ cleanup()
2231
+ setListError('')
2232
+ if (files.length === 0) {
2233
+ setNotice(t('noFilesSelected'))
2234
+ return
2235
+ }
2236
+ setNotice('')
2237
+ setDropNote('')
2238
+ try {
2239
+ var started = onPicked(files, target)
2240
+ if (started && typeof started.catch === 'function') {
2241
+ started.catch(function (failure) {
2242
+ logWarn('picked upload failed', failure)
2243
+ return null
2244
+ })
2245
+ }
2246
+ } catch (failure) {
2247
+ logWarn('picked upload failed', failure)
2248
+ }
2249
+ }
2250
+
2251
+ try {
2252
+ input.type = 'file'
2253
+ input.multiple = true
2254
+ if (folder) input.webkitdirectory = true
2255
+ input.setAttribute('aria-hidden', 'true')
2256
+ input.tabIndex = -1
2257
+ if (input.style) input.style.display = 'none'
2258
+ } catch (failure) {
2259
+ logWarn('prepare picker input failed', failure)
2260
+ }
2261
+ if (input && typeof input.addEventListener === 'function') input.addEventListener('change', onChange)
2262
+ else if (input) input.onchange = onChange
2263
+
2264
+ pickerRef.current = { cleanup: cleanup }
2265
+
2266
+ try {
2267
+ var host = (typeof document.body !== 'undefined' && document.body) ||
2268
+ (typeof document.documentElement !== 'undefined' && document.documentElement) ||
2269
+ null
2270
+ if (host && typeof host.appendChild === 'function') host.appendChild(input)
2271
+ } catch (failure) {
2272
+ logWarn('attach picker input failed', failure)
2273
+ }
2274
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
2275
+ window.addEventListener('focus', onWindowFocus)
2276
+ }
2277
+
2278
+ // ⚠️ 顺序是关键:input.click() 必须**在**任何 setState 之前调用。
2279
+ // 浏览器的「用户手势」是有时效的,先 setState 会引发一次同步重渲染,
2280
+ // 把手势窗口耗掉,click() 就被静默拦截 —— 表现同样是「点了没反应」。
2281
+ // picking 相关状态用 ref 先记上就行,界面稍后自己会刷新。
2282
+ pickingRef.current = true
2283
+ pickStartedAtRef.current = Date.now()
2284
+ try {
2285
+ input.click()
2286
+ } catch (failure) {
2287
+ logWarn('open file picker failed', failure)
2288
+ cleanup()
2289
+ setListError(t('actFailed', { detail: messageOf(failure) }))
2290
+ return null
2291
+ }
2292
+ setPicking(true)
2293
+ setListError('')
2294
+ setNotice('')
2295
+ setDropNote('')
2296
+ return null
2297
+ }
2298
+
2299
+ /** 「上传文件」:开原生多选,选完逐个传到 targetParentId。 */
2300
+ function pickFiles(targetParentId) {
2301
+ return openFilePicker(false, targetParentId, function (files, target) {
2302
+ return uploadPickedFiles(files, target)
2303
+ })
2304
+ }
2305
+
2306
+ /** 「上传文件夹」:webkitdirectory 多选,先按相对路径建目录再逐个上传。 */
2307
+ function pickFolder(targetParentId) {
2308
+ return openFilePicker(true, targetParentId, function (files, target) {
2309
+ return uploadPickedFolder(files, target)
2310
+ })
2311
+ }
2312
+
2313
+ /** 逐个上传(串行):复用拖拽那条进度行 + XHR。 */
2314
+ function uploadPickedFiles(files, targetParentId) {
2315
+ var chain = Promise.resolve()
2316
+ files.forEach(function (file) {
2317
+ chain = chain.then(function () { return uploadDroppedFile(file, targetParentId) })
2318
+ })
2319
+ return chain
2320
+ }
2321
+
2322
+ /** webkitRelativePath 'F/sub/b.txt' → 'F/sub';没有斜杠 = 当前目录。 */
2323
+ function relativeDirOf(file) {
2324
+ var rel = file && typeof file.webkitRelativePath === 'string' ? file.webkitRelativePath : ''
2325
+ var text = rel.replace(/\\/g, '/')
2326
+ var cut = text.lastIndexOf('/')
2327
+ return cut > 0 ? text.slice(0, cut) : ''
2328
+ }
2329
+
2330
+ /** 选中的那个文件夹叫什么(webkitRelativePath 的第一段)。 */
2331
+ function pickedFolderName(files) {
2332
+ for (var i = 0; i < files.length; i += 1) {
2333
+ var rel = files[i] && typeof files[i].webkitRelativePath === 'string'
2334
+ ? files[i].webkitRelativePath.replace(/\\/g, '/')
2335
+ : ''
2336
+ if (rel) return rel.split('/')[0]
2337
+ }
2338
+ return t('upload')
2339
+ }
2340
+
2341
+ /**
2342
+ * 「上传文件夹」:webkitRelativePath 带着层级,先把远端目录按「父先子后」建出来
2343
+ * (每个目录只建一次;Host 的 /mkdir-path 会复用已存在的同名目录),
2344
+ * 再把每个文件塞进它自己那一层。
2345
+ */
2346
+ function uploadPickedFolder(files, targetParentId) {
2347
+ var folderName = pickedFolderName(files)
2348
+ setNotice(t('uploadFolderStarted', { name: folderName }))
2349
+ var dirs = []
2350
+ var seen = {}
2351
+ files.forEach(function (file) {
2352
+ var dir = relativeDirOf(file)
2353
+ if (!seen[dir]) {
2354
+ seen[dir] = true
2355
+ dirs.push(dir)
2356
+ }
2357
+ })
2358
+ // 前缀一定排在扩展之前('F' < 'F/sub');再按层数兜一次:父目录先建。
2359
+ dirs.sort(function (a, b) {
2360
+ var depthA = a ? a.split('/').length : 0
2361
+ var depthB = b ? b.split('/').length : 0
2362
+ if (depthA !== depthB) return depthA - depthB
2363
+ return a < b ? -1 : (a > b ? 1 : 0)
2364
+ })
2365
+ var dirIds = { '': targetParentId }
2366
+ var chain = Promise.resolve()
2367
+ dirs.forEach(function (dir) {
2368
+ if (dir === '') return
2369
+ chain = chain.then(function () {
2370
+ return api.mkdirPath(targetParentId, dir).then(function (doc) {
2371
+ var id = doc && doc.fileId !== undefined && doc.fileId !== null ? doc.fileId : null
2372
+ if (id === null) throw new Error('mkdir-path 没有返回 fileId')
2373
+ dirIds[dir] = id
2374
+ })
2375
+ })
2376
+ })
2377
+ chain = chain.then(function () {
2378
+ var uploads = Promise.resolve()
2379
+ files.forEach(function (file) {
2380
+ var dir = relativeDirOf(file)
2381
+ var parent = dirIds[dir] === undefined ? targetParentId : dirIds[dir]
2382
+ uploads = uploads.then(function () { return uploadDroppedFile(file, parent) })
2383
+ })
2384
+ return uploads
2385
+ })
2386
+ return chain
2387
+ .then(function () {
2388
+ setNotice(t('uploadFolderDone', { name: folderName, count: files.length }))
2389
+ return refreshAfterUpload()
2390
+ })
2391
+ .catch(function (failure) {
2392
+ setListError(t('uploadFolderFailed', { detail: messageOf(failure) }))
2393
+ logWarn('folder upload failed', failure)
2394
+ return null
2395
+ })
2396
+ }
2397
+
2398
+ // ------------------------------------------------------ 上传面板
2399
+ /** 打开面板:目标在「点下菜单项/按钮」这一刻钉住,之后翻目录也不改。 */
2400
+ function openUploadPanel() {
2401
+ setUploadTarget({ id: parentId, name: '' })
2402
+ setDropNote('')
2403
+ setDragOver(false)
2404
+ dragDepthRef.current = 0
2405
+ setUploadOpen(true)
2406
+ }
2407
+
2408
+ function closeUploadPanel() {
2409
+ setUploadOpen(false)
2410
+ setUploadTarget(null)
2411
+ setDragOver(false)
2412
+ dragDepthRef.current = 0
2413
+ }
2414
+
2415
+ /** 起完上传就重拉列表 + 任务面板:新任务要出现在传输面板里。 */
2416
+ function refreshAfterUpload() {
2417
+ return fetchTransfers()
2418
+ .then(function () { return reload() })
2419
+ .catch(function (failure) {
2420
+ logWarn('refresh after upload failed', failure)
2421
+ return null
2422
+ })
2423
+ }
2424
+
2425
+ /** 拖进来的「文件夹占位」长这样:没有大小、没有 MIME、名字里连点都没有。 */
2426
+ function looksLikeFolder(file) {
2427
+ if (!file) return false
2428
+ if (Number(file.size) !== 0) return false
2429
+ if (file.type) return false
2430
+ var name = typeof file.name === 'string' ? file.name : ''
2431
+ return name !== '' && name.indexOf('.') < 0
2432
+ }
2433
+
2434
+ /**
2435
+ * 从 dataTransfer 里挑出能上传的文件;文件夹一律礼貌拒绝。
2436
+ * 优先用 webkitGetAsEntry()(只有它认得出「拖进来的目录」),
2437
+ * 拿不到就退到「空文件 + 无 MIME + 名字没点」这个土办法。
2438
+ */
2439
+ function collectDroppedFiles(dataTransfer) {
2440
+ var files = []
2441
+ var refusedFolder = false
2442
+ var items = dataTransfer && dataTransfer.items ? dataTransfer.items : null
2443
+ if (items && items.length) {
2444
+ for (var i = 0; i < items.length; i += 1) {
2445
+ var item = items[i]
2446
+ if (!item || (item.kind && item.kind !== 'file')) continue
2447
+ var entry = null
2448
+ if (typeof item.webkitGetAsEntry === 'function') {
2449
+ try {
2450
+ entry = item.webkitGetAsEntry()
2451
+ } catch (failure) {
2452
+ entry = null
2453
+ }
2454
+ }
2455
+ if (entry && entry.isDirectory) {
2456
+ refusedFolder = true
2457
+ continue
2458
+ }
2459
+ var file = typeof item.getAsFile === 'function' ? item.getAsFile() : null
2460
+ if (!file) continue
2461
+ if (looksLikeFolder(file)) {
2462
+ refusedFolder = true
2463
+ continue
2464
+ }
2465
+ files.push(file)
2466
+ }
2467
+ }
2468
+ // items 不存在(或一个文件都没挑出来)时退回 files 列表。
2469
+ if (files.length === 0 && dataTransfer && dataTransfer.files && dataTransfer.files.length) {
2470
+ for (var j = 0; j < dataTransfer.files.length; j += 1) {
2471
+ var plain = dataTransfer.files[j]
2472
+ if (!plain) continue
2473
+ if (looksLikeFolder(plain)) {
2474
+ refusedFolder = true
2475
+ continue
2476
+ }
2477
+ files.push(plain)
2478
+ }
2479
+ }
2480
+ return { files: files, refusedFolder: refusedFolder }
2481
+ }
2482
+
2483
+ function pushUrlItem(entry) {
2484
+ setUrlItems(function (previous) {
2485
+ var next = previous.concat([entry])
2486
+ return next.length > URL_LINE_VISIBLE ? next.slice(next.length - URL_LINE_VISIBLE) : next
2487
+ })
2488
+ }
2489
+
2490
+ function updateUrlItem(id, patch) {
2491
+ setUrlItems(function (previous) {
2492
+ return previous.map(function (entry) {
2493
+ if (!entry || entry.id !== id) return entry
2494
+ var next = { id: entry.id, name: entry.name, percent: entry.percent, state: entry.state }
2495
+ if (patch.percent !== undefined) next.percent = patch.percent
2496
+ if (patch.state !== undefined) next.state = patch.state
2497
+ return next
2498
+ })
2499
+ })
2500
+ }
2501
+
2502
+ /** 进度行别当场消失:留几秒让用户看到「完成 / 失败」。 */
2503
+ function scheduleUrlItemDrop(id) {
2504
+ try {
2505
+ var timer = setTimeout(function () {
2506
+ setUrlItems(function (previous) {
2507
+ return previous.filter(function (entry) { return !entry || entry.id !== id })
2508
+ })
2509
+ }, URL_LINE_LINGER_MS)
2510
+ urlTimersRef.current.push(timer)
2511
+ } catch (failure) {
2512
+ logWarn('schedule url line removal failed', failure)
2513
+ }
2514
+ }
2515
+
2516
+ function clearUrlTimers() {
2517
+ var timers = urlTimersRef.current || []
2518
+ for (var i = 0; i < timers.length; i += 1) {
2519
+ try {
2520
+ clearTimeout(timers[i])
2521
+ } catch (failure) {
2522
+ logWarn('clear url line timer failed', failure)
2523
+ }
2524
+ }
2525
+ urlTimersRef.current = []
2526
+ }
2527
+
2528
+ /**
2529
+ * 一个文件的拖拽上传:进度行 + XHR(上行进度只有 XHR 给得了)。
2530
+ * 失败走文件区那条错误 banner,任务本身的成败仍然看传输面板。
2531
+ */
2532
+ function uploadDroppedFile(file, targetParentId) {
2533
+ var id = urlSeqRef.current + 1
2534
+ urlSeqRef.current = id
2535
+ var name = file && file.name ? String(file.name) : 'upload.bin'
2536
+ pushUrlItem({ id: id, name: name, percent: 0, state: 'uploading' })
2537
+ setJobBusy('drop')
2538
+ return api.uploadBody(file, targetParentId, function (percent) {
2539
+ updateUrlItem(id, { percent: percent })
2540
+ })
2541
+ .then(function () {
2542
+ setJobBusy('')
2543
+ updateUrlItem(id, { percent: 100, state: 'done' })
2544
+ setNotice(t('urlUploadDone', { name: name }))
2545
+ scheduleUrlItemDrop(id)
2546
+ return refreshAfterUpload()
2547
+ })
2548
+ .catch(function (failure) {
2549
+ setJobBusy('')
2550
+ updateUrlItem(id, { state: 'error' })
2551
+ setListError(t('urlUploadFailed', { detail: messageOf(failure) }))
2552
+ logWarn('drop upload failed', failure)
2553
+ return null
2554
+ })
2555
+ }
2556
+
2557
+ /**
2558
+ * 拖拽投放。HTML 默认不允许 drop:onDragOver/onDrop 都必须 preventDefault,
2559
+ * 否则浏览器只会「打开」那个文件,永远到不了这里。
2560
+ *
2561
+ * ⚠️ 还要 stopPropagation:DSH 的会话输入框也监听拖放,
2562
+ * 不拦住的话文件会同时被它接走(一边上传、一边在会话里生成附件)。
2563
+ * 注意**不要**用 stopImmediatePropagation —— 那是「同一元素上的其他监听也别跑」,
2564
+ * 在某些实现里会连自己的后续处理一起掐掉。stopPropagation 足够:
2565
+ * 事件已经到达我们的目标节点,阻止它继续往外冒就行。
2566
+ */
2567
+ function stopDragEvent(event) {
2568
+ if (!event) return
2569
+ if (typeof event.preventDefault === 'function') event.preventDefault()
2570
+ if (typeof event.stopPropagation === 'function') event.stopPropagation()
2571
+ }
2572
+
2573
+ function onDrop(event) {
2574
+ stopDragEvent(event)
2575
+ return handleDrop(event)
2576
+ }
2577
+
2578
+ /** 面板里的拖拽区:投到打开面板时钉住的那个目录(不是此刻浏览的目录)。 */
2579
+ function onPanelDrop(event) {
2580
+ stopDragEvent(event)
2581
+ var frozen = uploadTargetRef.current
2582
+ return handleDrop(event, frozen && frozen.id !== undefined && frozen.id !== null ? frozen.id : parentId)
2583
+ }
2584
+
2585
+ /** 拖进来的文件按顺序一个一个传(不并发,进度条也看得清)。 */
2586
+ function handleDrop(event, targetOverride) {
2587
+ dragDepthRef.current = 0
2588
+ setDragOver(false)
2589
+ var dataTransfer = event && event.dataTransfer ? event.dataTransfer : null
2590
+ var picked = collectDroppedFiles(dataTransfer)
2591
+ setDropNote(picked.refusedFolder ? t('dropFolderHint') : '')
2592
+ if (picked.files.length === 0) return null
2593
+ var targetParentId = targetOverride === null || targetOverride === undefined || targetOverride === ''
2594
+ ? parentId
2595
+ : targetOverride
2596
+ var chain = Promise.resolve()
2597
+ picked.files.forEach(function (file) {
2598
+ chain = chain.then(function () { return uploadDroppedFile(file, targetParentId) })
2599
+ })
2600
+ return chain
2601
+ }
2602
+
2603
+ function onDragEnter(event) {
2604
+ stopDragEvent(event)
2605
+ dragDepthRef.current += 1
2606
+ setDragOver(true)
2607
+ }
2608
+
2609
+ // HTML 默认不让 drop:不 preventDefault 就永远收不到。
2610
+ function onDragOver(event) {
2611
+ stopDragEvent(event)
2612
+ if (event && event.dataTransfer) {
2613
+ try {
2614
+ event.dataTransfer.dropEffect = 'copy'
2615
+ } catch (failure) {
2616
+ logWarn('set dropEffect failed', failure)
2617
+ }
2618
+ }
2619
+ if (!dragOver) setDragOver(true)
2620
+ }
2621
+
2622
+ function onDragLeave(event) {
2623
+ stopDragEvent(event)
2624
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
2625
+ if (dragDepthRef.current === 0) setDragOver(false)
2626
+ }
2627
+
2628
+ // ------------------------------------------------------ 右键菜单
2629
+ /** 在光标处开菜单;item 为空 = 列表空白处那一套。 */
2630
+ function openMenu(event, item) {
2631
+ if (event) {
2632
+ if (typeof event.preventDefault === 'function') event.preventDefault()
2633
+ if (typeof event.stopPropagation === 'function') event.stopPropagation()
2634
+ }
2635
+ var x = event ? Number(event.clientX) || 0 : 0
2636
+ var y = event ? Number(event.clientY) || 0 : 0
2637
+ // 键盘呼出(Shift+F10 / 菜单键)没有坐标:退到这一行的左下角。
2638
+ if (event && x === 0 && y === 0 && event.currentTarget &&
2639
+ typeof event.currentTarget.getBoundingClientRect === 'function') {
2640
+ var rect = event.currentTarget.getBoundingClientRect()
2641
+ x = Math.round(Number(rect.left) || 0) + 12
2642
+ y = Math.round(Number(rect.bottom) || 0)
2643
+ }
2644
+ setMenu({ x: x, y: y, item: item || null, placed: false })
2645
+ }
2646
+
2647
+ function closeMenu() {
2648
+ setMenu(null)
2649
+ }
2650
+
2651
+ function menuItem(key, label, run, extraClass) {
2652
+ // 用 onMouseDown 而不是 onClick:
2653
+ // 菜单外面的「点别处就关」监听挂在 document 的 mousedown 捕获阶段。
2654
+ // 如果只监听 click,按下鼠标的那一刻菜单就被卸载了,click 永远到不了按钮 ——
2655
+ // 表现就是「点菜单项完全没反应」。面板里的按钮没有这个机制,所以一直正常。
2656
+ var fired = false
2657
+ function trigger(event) {
2658
+ if (event && typeof event.preventDefault === 'function') event.preventDefault()
2659
+ if (event && typeof event.stopPropagation === 'function') event.stopPropagation()
2660
+ if (fired) return
2661
+ fired = true
2662
+ closeMenu()
2663
+ try {
2664
+ run()
2665
+ } catch (failure) {
2666
+ logWarn('menu item failed', failure)
2667
+ }
2668
+ }
2669
+ return h('button', {
2670
+ key: key,
2671
+ type: 'button',
2672
+ role: 'menuitem',
2673
+ className: 'mc-menuItem' + (extraClass ? ' ' + extraClass : ''),
2674
+ onMouseDown: trigger,
2675
+ // 键盘可达:回车/空格走 click,但 mousedown 已经处理过就不重复触发。
2676
+ onClick: function (event) {
2677
+ if (fired) return
2678
+ trigger(event)
2679
+ },
2680
+ onKeyDown: function (event) {
2681
+ if (event && (event.key === 'Enter' || event.key === ' ')) trigger(event)
2682
+ },
2683
+ }, label)
2684
+ }
2685
+
2686
+ /**
2687
+ * 菜单上的三个上传入口:目标在点下去这一刻定下来(文件夹行 = 进这个文件夹,
2688
+ * 文件行/空白处 = 此刻打开的这个目录)。之后用户翻目录也不改。
2689
+ */
2690
+ function pushUploadItems(items, item) {
2691
+ // 上传目标永远是「当前打开的这个目录」,与右键在哪一行无关。
2692
+ // 注意:这里必须调 pickFiles / pickFolder(浏览器的原生 <input type="file">),
2693
+ // 不能再调已经删掉的 runPick —— 那会抛 ReferenceError,
2694
+ // 在 React 事件处理器里抛出来就是「点了完全没反应」。
2695
+ items.push(menuItem('upload-file', t('menuUploadFile'), function () {
2696
+ pickFiles(parentId)
2697
+ }))
2698
+ items.push(menuItem('upload-folder', t('menuUploadFolder'), function () {
2699
+ pickFolder(parentId)
2700
+ }))
2701
+ items.push(menuItem('drag-upload', t('menuDragUpload'), function () {
2702
+ openUploadPanel()
2703
+ }))
2704
+ }
2705
+
2706
+ function renderMenu() {
2707
+ if (!menu) return null
2708
+ var items = []
2709
+ if (menu.item) {
2710
+ var item = menu.item
2711
+ if (item.type === 'folder') {
2712
+ items.push(menuItem('open', t('menuOpen'), function () { enterFolder(item) }))
2713
+ items.push(menuItem('download-folder', t('menuDownloadFolder'), function () { downloadFolder(item) }))
2714
+ } else {
2715
+ items.push(menuItem('link', t('menuDownloadLink'), function () { openLink(item) }))
2716
+ items.push(menuItem('download', t('menuDownloadLocal'), function () { downloadFile(item) }))
2717
+ }
2718
+ items.push(h('hr', { key: 'menu-sep-upload', className: 'mc-menuSep' }))
2719
+ pushUploadItems(items, item)
2720
+ items.push(h('hr', { key: 'menu-sep', className: 'mc-menuSep' }))
2721
+ items.push(menuItem('move', t('menuMove'), function () { openMovePicker(item) }))
2722
+ items.push(menuItem('copy-path', t('menuCopyPath'), function () { copyItemPath(item) }))
2723
+ items.push(menuItem('rename', t('menuRename'), function () { renameEntry(item) }))
2724
+ items.push(menuItem('remove', t('menuRemove'), function () { removeEntry(item) }, 'mc-menuItemDanger'))
2725
+ } else {
2726
+ items.push(menuItem('new-folder', t('menuNewFolder'), function () {
2727
+ setFolderOpen(true)
2728
+ setFolderName('')
2729
+ }))
2730
+ items.push(menuItem('refresh', t('menuRefresh'), function () { reload() }))
2731
+ items.push(menuItem('copy-dir-path', t('menuCopyPath'), function () { copyItemPath(null) }))
2732
+ items.push(h('hr', { key: 'menu-sep-upload', className: 'mc-menuSep' }))
2733
+ pushUploadItems(items, null)
2734
+ }
2735
+ return h('div', {
2736
+ className: 'mc-menu',
2737
+ role: 'menu',
2738
+ ref: menuRef,
2739
+ style: {
2740
+ left: menu.x + 'px',
2741
+ top: menu.y + 'px',
2742
+ visibility: menu.placed ? 'visible' : 'hidden',
2743
+ },
2744
+ onContextMenu: function (event) {
2745
+ if (event && typeof event.preventDefault === 'function') event.preventDefault()
2746
+ },
2747
+ },
2748
+ menu.item ? h('div', {
2749
+ key: 'menu-head',
2750
+ className: 'mc-menuHead',
2751
+ title: menu.item.name,
2752
+ }, (menu.item.type === 'folder' ? '📁 ' : '📄 ') + menu.item.name) : null,
2753
+ items
2754
+ )
2755
+ }
2756
+
2757
+ // ------------------------------------------------------ 授权卡
2758
+ /** 标签栏:账号密码 / 手动授权。当前那个有下划线 + 加粗。 */
2759
+ function renderAuthTabs() {
2760
+ function tab(key, id, label) {
2761
+ var active = authTab === id
2762
+ return h('button', {
2763
+ key: key,
2764
+ type: 'button',
2765
+ role: 'tab',
2766
+ 'aria-selected': active ? 'true' : 'false',
2767
+ className: 'mc-tab' + (active ? ' mc-tabActive' : ''),
2768
+ onClick: function () {
2769
+ if (authTab === id) return
2770
+ setAuthTab(id)
2771
+ // 切标签时把另一边的提示清掉,免得旧错误挂在新表单上。
2772
+ setLoginError('')
2773
+ setLoginNotice('')
2774
+ setAuthError('')
2775
+ setAuthNotice('')
2776
+ },
2777
+ }, label)
2778
+ }
2779
+ return h('div', { className: 'mc-tabs', role: 'tablist' },
2780
+ tab('tab-scan', 'scan', t('tabScan')),
2781
+ tab('tab-login', 'login', t('tabLogin'))
2782
+ )
2783
+ }
2784
+
2785
+ /**
2786
+ * 扫码登录面板。
2787
+ *
2788
+ * 流程:点「开始扫码」→ 宿主拉起一个独立 Edge → 你在里面扫码 →
2789
+ * 宿主抓 cookie 里的 authorization → 这里轮询到 done → 刷新整页。
2790
+ *
2791
+ * 关键卖点:抓到的令牌能自动续期,所以**扫这一次就不用再扫了**。
2792
+ */
2793
+ function renderScanForm() {
2794
+ var st = scanState || {}
2795
+ var running = st.state === 'running' || st.phase === 'launching' || st.phase === 'waiting'
2796
+ var phaseText = ''
2797
+ if (st.state === 'launching' || st.phase === 'launching') phaseText = t('scanLaunching')
2798
+ else if (st.state === 'running' || st.phase === 'waiting') phaseText = st.message || t('scanWaiting')
2799
+ else if (st.state === 'done') phaseText = t('scanDone')
2800
+ else if (st.state === 'cancelled') phaseText = t('scanCancelled')
2801
+ else if (st.state === 'error') phaseText = st.message || t('scanFailed')
2802
+
2803
+ return h('div', { className: 'mc-tabPanel', key: 'scanForm', role: 'tabpanel' },
2804
+ h('p', { className: 'mc-hint' }, t('scanIntro')),
2805
+
2806
+ running
2807
+ ? h('div', { className: 'mc-scanBox' },
2808
+ h('div', { className: 'mc-scanSpinner' }, '⟳'),
2809
+ h('div', { className: 'mc-scanText' }, phaseText),
2810
+ h('p', { className: 'mc-hint' }, t('scanBrowserHint')),
2811
+ h('div', { className: 'mc-cardActions' },
2812
+ h('button', {
2813
+ className: 'mc-btn',
2814
+ onClick: cancelScan,
2815
+ }, t('cancel'))
2816
+ )
2817
+ )
2818
+ : h('div', { className: 'mc-cardActions' },
2819
+ h('button', {
2820
+ className: 'mc-btn mc-btnPrimary',
2821
+ onClick: beginScan,
2822
+ }, st.state === 'error' || st.state === 'cancelled' ? t('scanRetry') : t('scanStart'))
2823
+ ),
2824
+
2825
+ st.state === 'done' ? h('div', { className: 'mc-banner mc-bannerOk' }, t('scanDoneHint')) : null,
2826
+ st.state === 'error' ? h('div', { className: 'mc-banner mc-bannerErr' }, st.message || t('scanFailed')) : null,
2827
+ loginError ? h('div', { className: 'mc-banner mc-bannerErr' }, loginError) : null,
2828
+
2829
+ h('p', { className: 'mc-securityNote' }, t('scanSecurityNote'))
2830
+ )
2831
+ }
2832
+
2833
+ /** 账号密码登录表单。 */
2834
+ function renderLoginForm() {
2835
+ return h('div', { className: 'mc-tabPanel', key: 'loginForm', role: 'tabpanel' },
2836
+ h('div', { className: 'mc-field' },
2837
+ h('label', { className: 'mc-label' }, t('loginAccount')),
2838
+ h('input', {
2839
+ className: 'mc-input',
2840
+ type: 'text',
2841
+ name: 'mc-login-account',
2842
+ autoComplete: 'username',
2843
+ spellCheck: false,
2844
+ value: loginAccount,
2845
+ placeholder: t('loginAccountPlaceholder'),
2846
+ onChange: function (event) { setLoginAccount(event.target.value) },
2847
+ onKeyDown: onEnterKey(submitLogin),
2848
+ })
2849
+ ),
2850
+ h('div', { className: 'mc-field' },
2851
+ h('label', { className: 'mc-label' }, t('loginPassword')),
2852
+ h('div', { className: 'mc-pwWrap' },
2853
+ h('input', {
2854
+ className: 'mc-input',
2855
+ // 👁 只切 type,值不动。
2856
+ type: showPassword ? 'text' : 'password',
2857
+ name: 'mc-login-password',
2858
+ autoComplete: 'current-password',
2859
+ spellCheck: false,
2860
+ value: loginPassword,
2861
+ placeholder: t('loginPasswordPlaceholder'),
2862
+ onChange: function (event) { setLoginPassword(event.target.value) },
2863
+ onKeyDown: onEnterKey(submitLogin),
2864
+ }),
2865
+ h('button', {
2866
+ key: 'pw-toggle',
2867
+ type: 'button',
2868
+ className: 'mc-pwToggle',
2869
+ title: showPassword ? t('hidePassword') : t('showPassword'),
2870
+ 'aria-label': showPassword ? t('hidePassword') : t('showPassword'),
2871
+ 'aria-pressed': showPassword ? 'true' : 'false',
2872
+ onClick: function () { setShowPassword(function (previous) { return !previous }) },
2873
+ }, showPassword ? '🙈' : '👁')
2874
+ )
2875
+ ),
2876
+ h('label', { className: 'mc-checkRow' },
2877
+ h('input', {
2878
+ type: 'checkbox',
2879
+ checked: rememberPassword === true,
2880
+ onChange: function (event) { setRememberPassword(event.target.checked === true) },
2881
+ }),
2882
+ h('span', { className: 'mc-checkText' }, t('rememberPassword'))
2883
+ ),
2884
+ h('p', { className: 'mc-formNote' }, t('rememberPasswordHint')),
2885
+ savedHint ? h('div', { className: 'mc-banner mc-bannerOk' }, savedHint) : null,
2886
+ loginError ? h('div', { className: 'mc-banner mc-bannerErr' }, loginError) : null,
2887
+ loginNotice ? h('div', { className: 'mc-banner mc-bannerOk' }, loginNotice) : null,
2888
+ h('div', { className: 'mc-submitRow' },
2889
+ h('button', {
2890
+ className: 'mc-btn mc-btnPrimary mc-submit',
2891
+ type: 'button',
2892
+ disabled: loginBusy,
2893
+ onClick: submitLogin,
2894
+ }, loginBusy ? t('loginBusy') : t('loginSubmit'))
2895
+ ),
2896
+ h('p', { className: 'mc-formNote' }, t('loginSecurityNote'))
2897
+ )
2898
+ }
2899
+
2900
+
2901
+ function renderAuthCard() {
2902
+ return h('section', { className: 'mc-card', key: 'auth' },
2903
+ h('h2', { className: 'mc-cardTitle' }, t('loginTitle')),
2904
+ renderAuthTabs(),
2905
+ authTab === 'scan' ? renderScanForm() : renderLoginForm()
2906
+ )
2907
+ }
2908
+
2909
+ // ------------------------------------------------------ 状态卡
2910
+ function renderQuota() {
2911
+ var quota = status ? status.quota : null
2912
+ var total = quota && isFinite(Number(quota.totalBytes)) ? Number(quota.totalBytes) : 0
2913
+ var used = quota && isFinite(Number(quota.usedBytes)) ? Number(quota.usedBytes) : 0
2914
+ var free = quota && isFinite(Number(quota.freeBytes)) ? Number(quota.freeBytes) : Math.max(0, total - used)
2915
+
2916
+ if (total <= 0) {
2917
+ return h('div', { className: 'mc-barWrap' },
2918
+ h('div', { className: 'mc-kv' },
2919
+ h('span', { className: 'mc-kvLabel' }, t('quota')),
2920
+ h('span', { className: 'mc-kvValue' }, t('quotaUnavailable'))
2921
+ ),
2922
+ h('div', { className: 'mc-cardActions' },
2923
+ h('button', {
2924
+ className: 'mc-btn mc-btnSmall',
2925
+ disabled: authBusy,
2926
+ onClick: openReauth,
2927
+ }, t('reauthorize'))
2928
+ )
2929
+ )
2930
+ }
2931
+
2932
+ var percent = Math.min(100, Math.max(0, (used / total) * 100))
2933
+ return h('div', { className: 'mc-barWrap' },
2934
+ h('div', { className: 'mc-kv' },
2935
+ h('span', { className: 'mc-kvLabel' }, t('quota')),
2936
+ h('span', { className: 'mc-kvValue' }, t('used') + ' ' + formatBytes(used) + ' / ' + formatBytes(total))
2937
+ ),
2938
+ h('div', { className: 'mc-bar' },
2939
+ h('div', { className: 'mc-barFill', style: { width: percent.toFixed(1) + '%' } })
2940
+ ),
2941
+ h('div', { className: 'mc-barText' }, formatBytes(used) + ' / ' + formatBytes(total) + ' · ' + percent.toFixed(1) + '%'),
2942
+ h('div', { className: 'mc-kv' },
2943
+ h('span', { className: 'mc-kvLabel' }, t('free')),
2944
+ h('span', { className: 'mc-kvValue' }, formatBytes(free))
2945
+ )
2946
+ )
2947
+ }
2948
+
2949
+ function renderExpires() {
2950
+ var expiresAt = status ? status.expiresAt : null
2951
+ if (!expiresAt) return '—'
2952
+ var text = formatDateTime(expiresAt)
2953
+ if (Number(expiresAt) < Date.now()) return t('expired') + ' · ' + text
2954
+ return text
2955
+ }
2956
+
2957
+ /** 打开显示名编辑:草稿 = 当前存储值(不是脱敏号码)。 */
2958
+ function openDisplayNameEdit() {
2959
+ setDisplayNameDraft(displayName)
2960
+ setDisplayNameError('')
2961
+ setDisplayNameEdit(true)
2962
+ }
2963
+
2964
+ /**
2965
+ * 保存显示名。只有「真想存的值写不进 localStorage」才算失败:
2966
+ * 空值存不存得进去都无所谓(清除本来就是回退到脱敏号码)。
2967
+ */
2968
+ function saveDisplayName() {
2969
+ var next = normalizeDisplayName(displayNameDraft)
2970
+ setDisplayName(next)
2971
+ var applied = writeDisplayName(next)
2972
+ if (applied !== next) {
2973
+ setDisplayNameError(t('actFailed', { detail: 'localStorage' }))
2974
+ return
2975
+ }
2976
+ setDisplayNameError('')
2977
+ setDisplayNameEdit(false)
2978
+ }
2979
+
2980
+ function clearDisplayName() {
2981
+ setDisplayName('')
2982
+ setDisplayNameDraft('')
2983
+ writeDisplayName('')
2984
+ setDisplayNameError('')
2985
+ setDisplayNameEdit(false)
2986
+ }
2987
+
2988
+ /** 取消:不落盘,直接回到卡片。 */
2989
+ function cancelDisplayNameEdit() {
2990
+ setDisplayNameDraft('')
2991
+ setDisplayNameError('')
2992
+ setDisplayNameEdit(false)
2993
+ }
2994
+
2995
+ /**
2996
+ * 账号行:显示名(有的话)或脱敏手机号 accountMasked。
2997
+ * ⚠️ 绝不渲染 status.account —— 那是完整手机号。
2998
+ * 有显示名时,脱敏号码退成下面一行小字(仍然不是完整号码)。
2999
+ */
3000
+ function renderAccountLine() {
3001
+ var text = null
3002
+ if (hasAccount) {
3003
+ text = h('span', { className: 'mc-acctText' }, accountLabel)
3004
+ if (displayName !== '') {
3005
+ text = h('span', { className: 'mc-acctLine' },
3006
+ h('span', { className: 'mc-acctText' }, accountLabel),
3007
+ h('span', { className: 'mc-acctSub' }, accountMasked)
3008
+ )
3009
+ }
3010
+ }
3011
+ return h('span', { className: 'mc-acctLine' },
3012
+ text,
3013
+ hasAccount ? h('button', {
3014
+ type: 'button',
3015
+ className: 'mc-btn mc-btnSmall',
3016
+ title: t('displayName'),
3017
+ 'aria-label': t('displayName'),
3018
+ onClick: openDisplayNameEdit,
3019
+ }, '✎') : null
3020
+ )
3021
+ }
3022
+
3023
+ function renderStatusCard() {
3024
+ return h('section', { className: 'mc-card', key: 'status' },
3025
+ h('div', { className: 'mc-cardHead' },
3026
+ h('h2', { className: 'mc-cardTitle' }, t('account')),
3027
+ h('div', { className: 'mc-cardActions' },
3028
+ h('button', { className: 'mc-btn mc-btnSmall', disabled: authBusy, onClick: openReauth }, t('reauthorize')),
3029
+ h('button', { className: 'mc-btn mc-btnSmall mc-btnDanger', disabled: authBusy, onClick: clearAuth }, t('clearAuth'))
3030
+ )
3031
+ ),
3032
+ h('div', {},
3033
+ KeyValue({ label: t('account'), value: renderAccountLine() }),
3034
+ KeyValue({ label: t('expires'), value: renderExpires() })
3035
+ ),
3036
+ hasAccount && displayNameEdit ? h('div', { className: 'mc-acctForm' },
3037
+ h('input', {
3038
+ className: 'mc-input',
3039
+ type: 'text',
3040
+ spellCheck: false,
3041
+ autoComplete: 'off',
3042
+ value: displayNameDraft,
3043
+ placeholder: t('displayNamePlaceholder'),
3044
+ 'aria-label': t('displayName'),
3045
+ onChange: function (event) { setDisplayNameDraft(event.target.value) },
3046
+ onKeyDown: onEnterKey(saveDisplayName),
3047
+ }),
3048
+ h('div', { className: 'mc-cardActions' },
3049
+ h('button', {
3050
+ className: 'mc-btn mc-btnSmall mc-btnPrimary',
3051
+ 'aria-label': t('displayNameSave'),
3052
+ onClick: saveDisplayName,
3053
+ }, t('displayNameSave')),
3054
+ h('button', {
3055
+ className: 'mc-btn mc-btnSmall',
3056
+ 'aria-label': t('displayNameClear'),
3057
+ onClick: clearDisplayName,
3058
+ }, t('displayNameClear')),
3059
+ h('button', {
3060
+ className: 'mc-btn mc-btnSmall',
3061
+ onClick: cancelDisplayNameEdit,
3062
+ }, t('cancel'))
3063
+ ),
3064
+ displayNameError ? h('span', { className: 'mc-acctWarn' }, displayNameError) : null
3065
+ ) : null,
3066
+ authError ? h('div', { className: 'mc-banner mc-bannerErr' }, authError) : null,
3067
+ authNotice ? h('div', { className: 'mc-banner mc-bannerOk' }, authNotice) : null,
3068
+ renderQuota()
3069
+ )
3070
+ }
3071
+
3072
+ // ------------------------------------------------------ 文件浏览
3073
+ function renderCrumbs() {
3074
+ // 根就是 "/",别再用 t('root')(那是「根目录」三个字,会拼成 //windows/实验)。
3075
+ // 分隔符只在两段之间出现,所以 "/" + "windows" + "/" + "实验"。
3076
+ var crumbs = [
3077
+ h('button', {
3078
+ key: 'root',
3079
+ className: 'mc-crumb',
3080
+ title: '/',
3081
+ onClick: goRoot,
3082
+ }, '/'),
3083
+ ]
3084
+ for (var i = 0; i < stack.length; i += 1) {
3085
+ crumbs.push(h('span', { key: 'sep' + i, className: 'mc-crumbSep' }, '/'))
3086
+ crumbs.push(h('button', {
3087
+ key: 'crumb' + i,
3088
+ className: 'mc-crumb',
3089
+ title: stack[i].name,
3090
+ onClick: (function (index) {
3091
+ return function () { goCrumb(index) }
3092
+ })(i),
3093
+ }, stack[i].name))
3094
+ }
3095
+ return h('nav', {
3096
+ className: 'mc-crumbs',
3097
+ title: '/' + stack.map(function (s) { return s.name }).join('/'),
3098
+ }, crumbs)
3099
+ }
3100
+
3101
+ function renderRow(item) {
3102
+ var isFolder = item && item.type === 'folder'
3103
+ var actions = []
3104
+ if (isFolder) {
3105
+ actions.push(h('button', {
3106
+ key: 'rename',
3107
+ className: 'mc-btn mc-btnSmall',
3108
+ disabled: busy,
3109
+ onClick: function () { renameEntry(item) },
3110
+ }, t('rename')))
3111
+ actions.push(h('button', {
3112
+ key: 'remove',
3113
+ className: 'mc-btn mc-btnSmall mc-btnDanger',
3114
+ disabled: busy,
3115
+ onClick: function () { removeEntry(item) },
3116
+ }, t('remove')))
3117
+ } else {
3118
+ actions.push(h('button', {
3119
+ key: 'link',
3120
+ className: 'mc-btn mc-btnSmall',
3121
+ disabled: busy || linkBusy === item.id,
3122
+ onClick: function () { openLink(item) },
3123
+ }, linkBusy === item.id ? t('loading') : t('downloadLink')))
3124
+ actions.push(h('button', {
3125
+ key: 'downloadLocal',
3126
+ className: 'mc-btn mc-btnSmall',
3127
+ disabled: busy || jobBusy === item.id,
3128
+ onClick: function () { downloadFile(item) },
3129
+ }, jobBusy === item.id ? t('loading') : t('menuDownloadLocal')))
3130
+ }
3131
+ return h('div', {
3132
+ className: 'mc-row',
3133
+ key: item.id,
3134
+ // 右键任意一行 = 这一行的菜单(文件夹和文件各一套)。
3135
+ onContextMenu: function (event) { openMenu(event, item) },
3136
+ },
3137
+ h('span', { className: 'mc-rowIcon' }, isFolder ? '📁' : '📄'),
3138
+ isFolder
3139
+ ? h('button', {
3140
+ className: 'mc-rowName mc-rowNameBtn',
3141
+ title: item.name,
3142
+ onClick: function () { enterFolder(item) },
3143
+ }, item.name)
3144
+ : h('span', { className: 'mc-rowName', title: item.name }, item.name),
3145
+ h('span', { className: 'mc-rowMeta mc-rowSize' }, isFolder ? '—' : formatBytes(item.size)),
3146
+ h('span', { className: 'mc-rowMeta mc-rowTime' }, formatDateTime(item.updatedAt)),
3147
+ h('span', { className: 'mc-rowActions' }, actions)
3148
+ )
3149
+ }
3150
+
3151
+ function renderBrowser() {
3152
+ var rows = items.map(renderRow)
3153
+ return h('section', { className: 'mc-card', key: 'browser' },
3154
+ h('div', { className: 'mc-cardHead' },
3155
+ renderCrumbs(),
3156
+ h('div', { className: 'mc-headActions' },
3157
+ busy ? h('span', { className: 'mc-notice' }, t('loading')) : null,
3158
+ stack.length > 0 ? h('button', {
3159
+ className: 'mc-btn mc-btnSmall',
3160
+ disabled: busy,
3161
+ onClick: goUp,
3162
+ }, t('up')) : null,
3163
+ h('button', {
3164
+ className: 'mc-btn mc-btnSmall',
3165
+ disabled: busy,
3166
+ onClick: function () {
3167
+ setFolderOpen(!folderOpen)
3168
+ setFolderName('')
3169
+ },
3170
+ }, t('newFolder'))
3171
+ )
3172
+ ),
3173
+ folderOpen ? h('div', { className: 'mc-inlineForm' },
3174
+ h('input', {
3175
+ className: 'mc-input',
3176
+ type: 'text',
3177
+ autoFocus: true,
3178
+ value: folderName,
3179
+ placeholder: t('folderName'),
3180
+ onChange: function (event) { setFolderName(event.target.value) },
3181
+ onKeyDown: onEnterKey(createFolder),
3182
+ }),
3183
+ h('button', {
3184
+ className: 'mc-btn mc-btnSmall mc-btnPrimary',
3185
+ disabled: busy || !folderName.trim(),
3186
+ onClick: createFolder,
3187
+ }, t('create')),
3188
+ h('button', {
3189
+ className: 'mc-btn mc-btnSmall',
3190
+ disabled: busy,
3191
+ onClick: function () { setFolderOpen(false); setFolderName('') },
3192
+ }, t('cancel'))
3193
+ ) : null,
3194
+ listError ? h('div', { className: 'mc-banner mc-bannerErr' }, listError) : null,
3195
+ notice ? h('div', { className: 'mc-banner mc-bannerOk' }, notice) : null,
3196
+ // 空白处右键 = 新建 / 刷新 / 上传;行上的右键会 stopPropagation,不会传到这里。
3197
+ h('div', {
3198
+ className: 'mc-listArea',
3199
+ onContextMenu: function (event) { openMenu(event, null) },
3200
+ },
3201
+ listLoading && items.length === 0 ? h('div', { className: 'mc-loading' }, t('loading')) : null,
3202
+ !listLoading && items.length === 0 ? h('div', { className: 'mc-empty' }, t('empty')) : null,
3203
+ h('div', { className: 'mc-rows' }, rows)
3204
+ ),
3205
+ nextCursor ? h('div', { className: 'mc-cardActions' },
3206
+ h('button', {
3207
+ className: 'mc-btn mc-btnSmall',
3208
+ disabled: listLoading || busy,
3209
+ onClick: function () { fetchListing(nextCursor, true) },
3210
+ }, listLoading ? t('loading') : t('more'))
3211
+ ) : null
3212
+ )
3213
+ }
3214
+
3215
+ // ------------------------------------------------------ 下载目录 / 上传 / 传输
3216
+ function renderDownloadDirCard() {
3217
+ return h('section', { className: 'mc-card', key: 'downloadDir' },
3218
+ h('div', { className: 'mc-cardHead' },
3219
+ h('h2', { className: 'mc-cardTitle' }, t('downloadDir'))
3220
+ ),
3221
+ h('div', { className: 'mc-inlineForm' },
3222
+ h('input', {
3223
+ className: 'mc-input',
3224
+ type: 'text',
3225
+ spellCheck: false,
3226
+ autoComplete: 'off',
3227
+ value: downloadDir,
3228
+ placeholder: 'D:\\Downloads',
3229
+ onChange: function (event) { updateDownloadDir(event.target.value) },
3230
+ })
3231
+ ),
3232
+ h('p', { className: 'mc-hint' }, t('downloadDirHint'))
3233
+ )
3234
+ }
3235
+
3236
+ /** 拖拽上传的逐文件进度行(浏览器 → Host 这一段)。 */
3237
+ function renderUrlLines() {
3238
+ if (!urlItems || urlItems.length === 0) return null
3239
+ return h('div', { className: 'mc-urlLines' }, urlItems.map(function (entry) {
3240
+ var percent = Math.min(100, Math.max(0, Number(entry.percent) || 0))
3241
+ var failed = entry.state === 'error'
3242
+ var label = entry.state === 'done'
3243
+ ? t('urlUploadDone', { name: entry.name })
3244
+ : failed
3245
+ ? entry.name
3246
+ : t('urlUploading', { name: entry.name, percent: percent.toFixed(0) })
3247
+ return h('div', { className: 'mc-urlLine', key: 'url' + entry.id },
3248
+ h('div', { className: 'mc-urlLineHead' },
3249
+ h('span', { className: 'mc-urlLineName', title: entry.name }, label),
3250
+ h('span', {
3251
+ className: 'mc-urlLinePct' + (failed ? ' mc-urlLineErr' : ''),
3252
+ }, failed ? '❌' : percent.toFixed(0) + '%')
3253
+ ),
3254
+ h('div', { className: 'mc-bar' },
3255
+ h('div', { className: 'mc-barFill', style: { width: percent.toFixed(0) + '%' } })
3256
+ )
3257
+ )
3258
+ }))
3259
+ }
3260
+
3261
+ /**
3262
+ * 上传面板里那块拖拽区 —— 现在只有这一个投放面(卡片里那个已经搬进来了)。
3263
+ * inPanel = true 时投放走 onPanelDrop(投到面板钉住的目标目录)。
3264
+ */
3265
+ function renderDropZone(inPanel) {
3266
+ return h('div', {
3267
+ className: 'mc-drop' + (dragOver ? ' mc-dropActive' : ''),
3268
+ onDragEnter: onDragEnter,
3269
+ onDragOver: onDragOver,
3270
+ onDragLeave: onDragLeave,
3271
+ onDrop: inPanel ? onPanelDrop : onDrop,
3272
+ },
3273
+ h('div', { className: 'mc-dropText' }, dragOver ? t('dropActive') : t('dropZone')),
3274
+ h('div', { className: 'mc-dropHint' }, t('dropHint'))
3275
+ )
3276
+ }
3277
+
3278
+ /** 面板的标题:右键在文件夹上就是「上传到「名字」」,否则是「上传到当前目录」。 */
3279
+ function uploadPanelTitleText() {
3280
+ var name = uploadTarget && uploadTarget.name ? String(uploadTarget.name) : ''
3281
+ return name
3282
+ ? t('uploadPanelTitle', { name: name })
3283
+ : t('uploadPanelHere')
3284
+ }
3285
+
3286
+ /**
3287
+ * 上传面板:fixed 居中的浮层 + 面板。
3288
+ * 打开它不发任何请求;只有面板自己的选择按钮和往拖拽区里投放才会起上传。
3289
+ * 关掉的方式:右上角 ✕ / Escape / 点浮层背景(点面板里不关)。
3290
+ */
3291
+ /**
3292
+ * 浮层挂到 document.body 上。
3293
+ * 插件根节点 `.mc-root` 是 overflow-y:auto 的滚动容器,直接在里面渲染 fixed 浮层
3294
+ * 会被祖先的层叠/裁剪上下文吃掉(实测:点「拖动上传」后面板不显示)。
3295
+ * 同仓库的 dsh-archive-manager / structured-prompt 都是这个写法;拿不到 react-dom 就退回内联。
3296
+ */
3297
+ var reactDom = null
3298
+ try {
3299
+ reactDom = require('react-dom')
3300
+ } catch (failure) {
3301
+ reactDom = null
3302
+ }
3303
+
3304
+ function portalToBody(node) {
3305
+ if (reactDom && typeof reactDom.createPortal === 'function' &&
3306
+ typeof document !== 'undefined' && document && document.body) {
3307
+ return reactDom.createPortal(node, document.body)
3308
+ }
3309
+ return node
3310
+ }
3311
+
3312
+ function renderUploadPanel() {
3313
+ if (!uploadOpen) return null
3314
+ // 面板里的选择按钮只钉住面板的目标目录,和卡片上那两个互不牵连。
3315
+ var panelTarget = uploadTarget && uploadTarget.id !== undefined && uploadTarget.id !== null
3316
+ ? uploadTarget.id
3317
+ : parentId
3318
+ var overlay = h('div', {
3319
+ className: 'mc-overlay',
3320
+ ref: dragGuardRef,
3321
+ // onMouseDown 而不是 onClick:合上菜单的那一下点击不会误关面板。
3322
+ onMouseDown: function (event) {
3323
+ if (event && event.target !== event.currentTarget) return
3324
+ closeUploadPanel()
3325
+ },
3326
+ onContextMenu: function (event) {
3327
+ if (event && typeof event.preventDefault === 'function') event.preventDefault()
3328
+ },
3329
+ },
3330
+ h('div', { className: 'mc-panel', role: 'dialog', 'aria-modal': 'true' },
3331
+ h('div', { className: 'mc-panelHead' },
3332
+ h('h2', {
3333
+ className: 'mc-panelTitle',
3334
+ title: uploadPanelTitleText(),
3335
+ }, uploadPanelTitleText()),
3336
+ h('button', {
3337
+ type: 'button',
3338
+ className: 'mc-btn mc-panelClose',
3339
+ title: t('close'),
3340
+ 'aria-label': t('close'),
3341
+ onClick: closeUploadPanel,
3342
+ }, '✕')
3343
+ ),
3344
+ h('p', { className: 'mc-hint' }, t('uploadPanelHint')),
3345
+ renderDropZone(true),
3346
+ dropNote ? h('div', { className: 'mc-banner mc-bannerOk' }, dropNote) : null,
3347
+ renderUrlLines(),
3348
+ h('div', { className: 'mc-panelFoot' },
3349
+ h('button', {
3350
+ className: 'mc-btn',
3351
+ disabled: picking,
3352
+ onClick: function () { pickFiles(panelTarget) },
3353
+ }, t('pickFile')),
3354
+ h('button', {
3355
+ className: 'mc-btn',
3356
+ disabled: picking,
3357
+ onClick: function () { pickFolder(panelTarget) },
3358
+ }, t('pickFolder')),
3359
+ // 对话框是模态的,可能开着好几分钟:明说在等谁。
3360
+ picking ? h('span', { className: 'mc-notice' }, t('pickWaiting')) : null
3361
+ )
3362
+ )
3363
+ )
3364
+ return portalToBody(overlay)
3365
+ }
3366
+
3367
+ /**
3368
+ * 上传卡:三个按钮(选文件 / 选文件夹 / 打开上传面板)→
3369
+ * 折叠起来的「或直接填路径」。拖拽区不在这里,在上传面板里。
3370
+ */
3371
+ function renderUploadCard() {
3372
+ // 只有「系统对话框还开着」才禁用选择按钮:起完任务后上传是 Host 那边的活,
3373
+ // 不该把选择按钮一直锁住(拖拽上传大文件时也是同理)。
3374
+ var pickerBusy = picking
3375
+ return h('section', { className: 'mc-card', key: 'upload' },
3376
+ h('div', { className: 'mc-cardHead' },
3377
+ h('h2', { className: 'mc-cardTitle' }, t('upload'))
3378
+ ),
3379
+ h('div', { className: 'mc-pickRow' },
3380
+ h('button', {
3381
+ className: 'mc-btn mc-btnPrimary',
3382
+ disabled: pickerBusy,
3383
+ onClick: function () { pickFiles(parentId) },
3384
+ }, t('pickFile')),
3385
+ h('button', {
3386
+ className: 'mc-btn',
3387
+ disabled: pickerBusy,
3388
+ onClick: function () { pickFolder(parentId) },
3389
+ }, t('pickFolder')),
3390
+ h('button', {
3391
+ className: 'mc-btn',
3392
+ onClick: function () { openUploadPanel(null) },
3393
+ }, t('dragUploadOpen')),
3394
+ // 对话框是模态的,可能开着好几分钟:明说在等谁。
3395
+ picking ? h('span', { className: 'mc-notice' }, t('pickWaiting')) : null
3396
+ ),
3397
+ h('p', { className: 'mc-hint' }, t('pickHint')),
3398
+ h('hr', { className: 'mc-sep' }),
3399
+ dropNote ? h('div', { className: 'mc-banner mc-bannerOk' }, dropNote) : null,
3400
+ renderUrlLines(),
3401
+ h('div', { className: 'mc-disclosure' },
3402
+ h('button', {
3403
+ type: 'button',
3404
+ className: 'mc-linkBtn',
3405
+ 'aria-expanded': pathOpen ? 'true' : 'false',
3406
+ onClick: function () { setPathOpen(!pathOpen) },
3407
+ }, (pathOpen ? '▾ ' : '▸ ') + t('orTypePath')),
3408
+ pathOpen ? h('div', { className: 'mc-inlineForm' },
3409
+ h('input', {
3410
+ className: 'mc-input',
3411
+ type: 'text',
3412
+ spellCheck: false,
3413
+ autoComplete: 'off',
3414
+ ref: uploadInputRef,
3415
+ value: uploadPath,
3416
+ placeholder: t('uploadPath'),
3417
+ onChange: function (event) { setUploadPath(event.target.value) },
3418
+ onKeyDown: onEnterKey(startUpload),
3419
+ }),
3420
+ h('button', {
3421
+ className: 'mc-btn mc-btnSmall mc-btnPrimary',
3422
+ disabled: jobBusy === 'upload' || jobBusy === 'drop' || !uploadPath.trim(),
3423
+ onClick: startUpload,
3424
+ }, jobBusy === 'upload' ? t('loading') : t('uploadToCurrent'))
3425
+ ) : null,
3426
+ pathOpen ? h('p', { className: 'mc-hint' }, t('uploadPathHint')) : null
3427
+ )
3428
+ )
3429
+ }
3430
+
3431
+ function renderJob(job) {
3432
+ var total = Number(job.total)
3433
+ var done = Number(job.done)
3434
+ if (!isFinite(total) || total < 0) total = 0
3435
+ if (!isFinite(done) || done < 0) done = 0
3436
+ var running = job.state === 'running'
3437
+ var failed = job.state === 'error'
3438
+ var finished = job.state === 'done'
3439
+ // 只有「还在跑」的时候才拿 done/total 算百分比:宿主偶尔会少记几个字节,
3440
+ // 已完成的任务被算成 54.9% 看着就像坏了;done > total 也夹到 100%。
3441
+ var known = running && total > 0
3442
+ var percent = 0
3443
+ if (known) {
3444
+ var capped = Math.min(done, total)
3445
+ percent = Math.min(100, Math.max(0, (capped / total) * 100))
3446
+ }
3447
+ var phase = PHASE_KEYS[job.phase] ? t(PHASE_KEYS[job.phase]) : ''
3448
+ var stateText = finished
3449
+ ? '✅ ' + t('transferDone')
3450
+ : failed
3451
+ ? '❌ ' + t('transferFailed')
3452
+ : t('transferRunning')
3453
+ var meta = []
3454
+ if (phase) meta.push(phase)
3455
+ if (job.currentFile) meta.push(String(job.currentFile))
3456
+ if (job.fileCount !== null && job.fileCount !== undefined && isFinite(Number(job.fileCount))) {
3457
+ meta.push(String(job.fileCount) + ' ' + t('filesUnit'))
3458
+ }
3459
+ // 已完成 = 100% 实心条;失败 = 明说一句,不给进度条(免得看着像卡住了);
3460
+ // 进行中但没有 total = 流动条。
3461
+ var bar = null
3462
+ var barText = ''
3463
+ if (finished) {
3464
+ bar = h('div', { className: 'mc-bar' }, h('div', { className: 'mc-barFill', style: { width: '100%' } }))
3465
+ barText = formatBytes(total > 0 ? total : done) + ' / ' + formatBytes(total > 0 ? total : done) + ' · 100.0%'
3466
+ } else if (failed) {
3467
+ barText = t('transferFailed')
3468
+ } else if (known) {
3469
+ bar = h('div', { className: 'mc-bar' }, h('div', { className: 'mc-barFill', style: { width: percent.toFixed(1) + '%' } }))
3470
+ barText = formatBytes(done) + ' / ' + formatBytes(total) + ' · ' + percent.toFixed(1) + '%'
3471
+ } else {
3472
+ bar = h('div', { className: 'mc-bar mc-barIndet' }, h('div', { className: 'mc-barFill' }))
3473
+ barText = formatBytes(done) + ' · ' + t('progressUnknown')
3474
+ }
3475
+ return h('div', { className: 'mc-job', key: job.id },
3476
+ h('div', { className: 'mc-jobHead' },
3477
+ h('span', { className: 'mc-jobIcon' }, JOB_ICONS[job.kind] || '⇄'),
3478
+ h('span', { className: 'mc-jobLabel', title: job.label || job.id }, job.label || job.id),
3479
+ h('span', { className: 'mc-jobState' }, stateText),
3480
+ running ? h('button', {
3481
+ className: 'mc-btn mc-btnSmall',
3482
+ disabled: cancelBusy === job.id,
3483
+ onClick: function () { cancelJob(job) },
3484
+ }, cancelBusy === job.id ? t('loading') : t('transferCancel')) : null
3485
+ ),
3486
+ h('div', { className: 'mc-barWrap' },
3487
+ bar,
3488
+ h('div', { className: 'mc-barText' }, barText)
3489
+ ),
3490
+ meta.length > 0 ? h('div', { className: 'mc-jobMeta' }, meta.join(' · ')) : null,
3491
+ // 任务失败(含没授权)只在这里出现,必须原样显示,不能吞。
3492
+ failed && job.error ? h('div', { className: 'mc-jobErr' }, String(job.error)) : null
3493
+ )
3494
+ }
3495
+
3496
+ /** 新的在前,最多 TRANSFER_VISIBLE 条。 */
3497
+ function sortedJobs(list) {
3498
+ var copy = (Array.isArray(list) ? list.slice() : []).filter(function (job) {
3499
+ return job && job.id
3500
+ })
3501
+ copy.sort(function (a, b) {
3502
+ return (Number(b.startedAt) || 0) - (Number(a.startedAt) || 0)
3503
+ })
3504
+ return copy.slice(0, TRANSFER_VISIBLE)
3505
+ }
3506
+
3507
+ function renderTransfersCard() {
3508
+ var list = sortedJobs(jobs)
3509
+ return h('section', { className: 'mc-card', key: 'transfers' },
3510
+ h('div', { className: 'mc-cardHead' },
3511
+ h('h2', { className: 'mc-cardTitle' }, t('transfers')),
3512
+ h('div', { className: 'mc-cardActions' },
3513
+ h('button', {
3514
+ className: 'mc-btn mc-btnSmall',
3515
+ onClick: function () { fetchTransfers() },
3516
+ }, t('refresh'))
3517
+ )
3518
+ ),
3519
+ list.length === 0
3520
+ ? h('div', { className: 'mc-empty' }, t('noTransfers'))
3521
+ : h('div', {}, list.map(renderJob))
3522
+ )
3523
+ }
3524
+
3525
+ // ------------------------------------------------------ 组装
3526
+ var body
3527
+ if (!status && statusLoading) {
3528
+ body = h('div', { className: 'mc-loading' }, t('loading'))
3529
+ } else if (!authorized) {
3530
+ body = h('div', { className: 'mc-stack' }, renderAuthCard())
3531
+ } else {
3532
+ body = h('div', { className: 'mc-stack' },
3533
+ renderStatusCard(),
3534
+ reauthOpen ? renderAuthCard() : null,
3535
+ renderBrowser(),
3536
+ renderDownloadDirCard(),
3537
+ renderTransfersCard(),
3538
+ h('div', { className: 'mc-footer' },
3539
+ t('host') + ': ' + ((status && status.host) || '—') +
3540
+ ' · ' + t('credentialPath') + ': ' + ((status && status.credentialPath) || '—')
3541
+ )
3542
+ )
3543
+ }
3544
+
3545
+ return h('div', { className: 'mc-root' },
3546
+ h('div', { className: 'mc-head' },
3547
+ h('div', {},
3548
+ h('h1', { className: 'mc-title' }, t('title')),
3549
+ h('p', { className: 'mc-subtitle' }, t('subtitle'))
3550
+ ),
3551
+ h('div', { className: 'mc-headActions' },
3552
+ authNotice && authorized ? h('span', { className: 'mc-notice' }, authNotice) : null,
3553
+ h('button', {
3554
+ className: 'mc-btn',
3555
+ disabled: statusLoading,
3556
+ onClick: function () { loadStatus() },
3557
+ }, statusLoading ? t('loading') : t('refresh'))
3558
+ )
3559
+ ),
3560
+ statusError ? h('div', { className: 'mc-banner mc-bannerErr' }, statusError) : null,
3561
+ body,
3562
+ // 浮层菜单挂在最后,fixed 定位,不会把面板挤变形。
3563
+ renderMenu(),
3564
+ // 上传面板再压在最后:它的浮层 z-index 比菜单高。
3565
+ renderUploadPanel(),
3566
+ // 转移面板同理(同一层级,同时只会开一个)。
3567
+ renderMovePicker()
3568
+ )
3569
+ }
3570
+ }
3571
+
3572
+ // ---------------------------------------------------------------- 入口
3573
+ function apply(ctx) {
3574
+ try {
3575
+ ensureStyle()
3576
+
3577
+ var t = translate(ZH)
3578
+ try {
3579
+ if (ctx && ctx.locale && typeof ctx.locale.register === 'function') {
3580
+ ctx.effect(function () {
3581
+ return ctx.locale.register(NS, { zh: ZH, en: EN })
3582
+ }, 'dsh-mcloud: dictionaries')
3583
+ }
3584
+ if (ctx && ctx.locale && typeof ctx.locale.bind === 'function') {
3585
+ t = ctx.locale.bind(NS)
3586
+ }
3587
+ } catch (failure) {
3588
+ // 字典是锦上添花:拿不到就退回内置中文。
3589
+ logWarn('locale unavailable', failure)
3590
+ }
3591
+
3592
+ var Panel = createPanel(t)
3593
+
3594
+ /**
3595
+ * 收起/还原主输入框。
3596
+ *
3597
+ * 这个槽位只会渲染当前选中的那个页签,组件挂载即代表「本页签被选中」;
3598
+ * 所以本组件一挂载就等于「移动云盘」这个 tab 被选中 —— 这一页不需要
3599
+ * 输入框,收起来而不是把它挡住。
3600
+ */
3601
+ function setComposerHidden(hidden) {
3602
+ try {
3603
+ if (typeof document === 'undefined') return
3604
+ var body = document.querySelector('[data-conversation-content]')
3605
+ if (body === null) return
3606
+ var seats = body.querySelectorAll('[data-composer-seat]')
3607
+ for (var i = 0; i < seats.length; i += 1) {
3608
+ var seat = seats[i]
3609
+ if (hidden) {
3610
+ if (seat.dataset.mcHidden !== '1') {
3611
+ seat.dataset.mcHiddenBefore = seat.style.display
3612
+ seat.style.display = 'none'
3613
+ seat.dataset.mcHidden = '1'
3614
+ }
3615
+ } else if (seat.dataset.mcHidden === '1') {
3616
+ seat.style.display = seat.dataset.mcHiddenBefore || ''
3617
+ delete seat.dataset.mcHidden
3618
+ delete seat.dataset.mcHiddenBefore
3619
+ }
3620
+ }
3621
+ } catch (failure) {
3622
+ logWarn('composer toggle failed', failure)
3623
+ }
3624
+ }
3625
+
3626
+ function clearComposerHidden() {
3627
+ setComposerHidden(false)
3628
+ }
3629
+
3630
+ /** View 标签的挂载点:挂载 = 这个 tab 被选中。 */
3631
+ function ViewMount() {
3632
+ React.useEffect(function () {
3633
+ setComposerHidden(true)
3634
+ return function () { setComposerHidden(false) }
3635
+ }, [])
3636
+ return h(Panel)
3637
+ }
3638
+
3639
+ // 会话窗口顶部的 View 标签栏:内置页签是 对话(0) / 轨迹(10) / 划过的视频(20),
3640
+ // 本插件用 order 30 排在它们后面;装了其他插件则按各自 order 插空。
3641
+ try {
3642
+ var registeredViews = []
3643
+ function registerView() {
3644
+ if (registeredViews.length > 0) return
3645
+ var dispose = ctx.slots.register({
3646
+ name: 'conversation.view',
3647
+ id: 'mobile-cloud-view',
3648
+ order: 30,
3649
+ scope: 'session',
3650
+ label: function () {
3651
+ var resolved = t('nav')
3652
+ return resolved === 'nav' ? '移动云盘' : resolved
3653
+ },
3654
+ locale: NS,
3655
+ }, ViewMount)
3656
+ registeredViews.push(typeof dispose === 'function' ? dispose : function () {})
3657
+ }
3658
+ function dropView() {
3659
+ for (var i = 0; i < registeredViews.length; i += 1) {
3660
+ try {
3661
+ registeredViews[i]()
3662
+ } catch (failure) {
3663
+ logWarn('view dispose failed', failure)
3664
+ }
3665
+ }
3666
+ registeredViews.length = 0
3667
+ clearComposerHidden()
3668
+ }
3669
+ ctx.effect(function () {
3670
+ return ctx.slots.inject('conversation.view', function () {
3671
+ registerView()
3672
+ return dropView
3673
+ })
3674
+ }, 'dsh-mcloud: conversation view tab')
3675
+ } catch (failure) {
3676
+ logError('conversation view failed', failure)
3677
+ }
3678
+ } catch (failure) {
3679
+ // apply 绝不往外抛。
3680
+ logError('apply failed', failure)
3681
+ }
3682
+ }
3683
+
3684
+ var module = { exports: {} }
3685
+ var exports = module.exports
3686
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
3687
+ exports.inject = ['slots', 'locale']
3688
+ exports.apply = apply
3689
+ exports.ZH = ZH
3690
+ exports.EN = EN
3691
+ return module.exports
3692
+ },
3693
+ })
3694
+ }