dsh-mcloud 0.2.1 → 0.3.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/CHANGELOG.md +76 -1
- package/CONTRIBUTING.md +1 -1
- package/FAQ.md +6 -4
- package/README.md +271 -239
- package/lib/client.js +2650 -501
- package/lib/index.js +2505 -2321
- package/lib/request-guard.js +154 -0
- package/package.json +67 -64
package/lib/client.js
CHANGED
|
@@ -56,6 +56,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
56
56
|
refreshFailed: '读取状态失败:{detail}',
|
|
57
57
|
account: '账号',
|
|
58
58
|
expires: '授权有效期',
|
|
59
|
+
expiresShort: '有效期:',
|
|
59
60
|
expired: '已过期',
|
|
60
61
|
quota: '容量',
|
|
61
62
|
used: '已用',
|
|
@@ -98,7 +99,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
98
99
|
loginSecurityNote: '仅用于换取访问令牌;密码不会发送到除运营商外的任何地方',
|
|
99
100
|
loginEmpty: '请输入手机号/邮箱和密码',
|
|
100
101
|
reauthorize: '重新授权',
|
|
102
|
+
reauthorizeShort: '授权',
|
|
101
103
|
clearAuth: '清除授权',
|
|
104
|
+
clearAuthShort: '退出',
|
|
102
105
|
clearConfirm: '退出登录并清除本机保存的凭据?之后需要重新登录。',
|
|
103
106
|
root: '/',
|
|
104
107
|
up: '返回上级',
|
|
@@ -147,7 +150,56 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
147
150
|
menuTypePath: '直接填路径',
|
|
148
151
|
// 下载目录 / 上传
|
|
149
152
|
downloadDir: '下载目录',
|
|
153
|
+
downloadDirShort: '下载到',
|
|
150
154
|
downloadDirHint: '「下载到本地」的文件会存到这个目录;路径是运行 DSH 那台机器上的路径。',
|
|
155
|
+
downloadDirSaved: '下载目录已保存',
|
|
156
|
+
chooseDir: '选择目录',
|
|
157
|
+
resetDefault: '恢复默认',
|
|
158
|
+
save: '保存',
|
|
159
|
+
|
|
160
|
+
// ===== 0.3.0 资源管理器 =====
|
|
161
|
+
folders: '文件夹',
|
|
162
|
+
treeResizeHint: '拖动调整宽度(双击恢复默认)',
|
|
163
|
+
expand: '展开',
|
|
164
|
+
collapse: '收起',
|
|
165
|
+
toggleTree: '显示/隐藏文件夹树',
|
|
166
|
+
colName: '名称',
|
|
167
|
+
colSize: '大小',
|
|
168
|
+
colModified: '修改时间',
|
|
169
|
+
colResizeHint: '拖动调整列宽(双击恢复默认)',
|
|
170
|
+
search: '搜索',
|
|
171
|
+
searchPlaceholder: '搜索文件…',
|
|
172
|
+
searchClear: '清空搜索',
|
|
173
|
+
searching: '正在搜索…',
|
|
174
|
+
searchIn: '在 {path} 及其子目录中搜索',
|
|
175
|
+
searchNoResult: '没有找到匹配的文件',
|
|
176
|
+
searchCount: '找到 {count} 项',
|
|
177
|
+
searchTruncated: '找到 {count} 项(结果过多,已截断)',
|
|
178
|
+
searchExit: '退出搜索',
|
|
179
|
+
feedMoreHint: '继续向下滚动加载更多',
|
|
180
|
+
|
|
181
|
+
// 历史传输记录
|
|
182
|
+
history: '传输记录',
|
|
183
|
+
historyShort: '记录',
|
|
184
|
+
historyTitle: '传输记录',
|
|
185
|
+
historyClear: '清空记录',
|
|
186
|
+
historyClearConfirm: '清空全部传输记录?\n\n只删除记录列表,网盘和本地文件都不会受影响。正在传输的任务会保留。',
|
|
187
|
+
historyCleared: '已清空 {count} 条记录',
|
|
188
|
+
historyActive: '进行中',
|
|
189
|
+
historyDone: '已完成',
|
|
190
|
+
historyFailed: '失败',
|
|
191
|
+
historyCancelled: '已取消',
|
|
192
|
+
historyEmpty: '还没有传输记录。上传或下载一个文件就会出现在这里。',
|
|
193
|
+
jobDone: '已完成',
|
|
194
|
+
jobFailed: '失败',
|
|
195
|
+
jobCancelled: '已取消',
|
|
196
|
+
jobQueued: '排队中',
|
|
197
|
+
jobScanning: '扫描目录',
|
|
198
|
+
jobRunning: '传输中',
|
|
199
|
+
close: '关闭',
|
|
200
|
+
transfersMore: '还有 {count} 项',
|
|
201
|
+
transfersDone: '已完成 {count} 个传输任务',
|
|
202
|
+
transfersFailed: '{count} 个任务失败',
|
|
151
203
|
upload: '上传',
|
|
152
204
|
uploadPath: '本机文件绝对路径,例如 D:\\tmp\\a.zip',
|
|
153
205
|
uploadPathHint: '这个路径指向运行 DSH 的那台机器,不是浏览器所在的机器。',
|
|
@@ -210,6 +262,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
210
262
|
refreshFailed: 'Could not read status: {detail}',
|
|
211
263
|
account: 'Account',
|
|
212
264
|
expires: 'Authorized until',
|
|
265
|
+
expiresShort: 'Expires: ',
|
|
213
266
|
expired: 'Expired',
|
|
214
267
|
quota: 'Storage',
|
|
215
268
|
used: 'Used',
|
|
@@ -252,7 +305,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
252
305
|
loginSecurityNote: 'Used only to obtain an access token; the password is never sent anywhere but the carrier',
|
|
253
306
|
loginEmpty: 'Enter your phone/email and password',
|
|
254
307
|
reauthorize: 'Re-authorize',
|
|
308
|
+
reauthorizeShort: 'Auth',
|
|
255
309
|
clearAuth: 'Clear authorization',
|
|
310
|
+
clearAuthShort: 'Sign out',
|
|
256
311
|
clearConfirm: 'Clear the stored Mobile Cloud authorization? You will need to paste the Authorization value again.',
|
|
257
312
|
root: '/',
|
|
258
313
|
up: 'Up one level',
|
|
@@ -301,7 +356,56 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
301
356
|
menuTypePath: 'Type a path instead',
|
|
302
357
|
// Download dir / upload
|
|
303
358
|
downloadDir: 'Download folder',
|
|
359
|
+
downloadDirShort: 'Save to',
|
|
304
360
|
downloadDirHint: 'Files downloaded to this machine land in this folder; the path is on the machine running DSH.',
|
|
361
|
+
downloadDirSaved: 'Download folder saved',
|
|
362
|
+
chooseDir: 'Choose folder',
|
|
363
|
+
resetDefault: 'Reset to default',
|
|
364
|
+
save: 'Save',
|
|
365
|
+
|
|
366
|
+
// ===== 0.3.0 explorer =====
|
|
367
|
+
folders: 'Folders',
|
|
368
|
+
treeResizeHint: 'Drag to resize (double-click to reset)',
|
|
369
|
+
expand: 'Expand',
|
|
370
|
+
collapse: 'Collapse',
|
|
371
|
+
toggleTree: 'Show/hide folder tree',
|
|
372
|
+
colName: 'Name',
|
|
373
|
+
colSize: 'Size',
|
|
374
|
+
colModified: 'Modified',
|
|
375
|
+
colResizeHint: 'Drag to resize column (double-click to reset)',
|
|
376
|
+
search: 'Search',
|
|
377
|
+
searchPlaceholder: 'Search files…',
|
|
378
|
+
searchClear: 'Clear search',
|
|
379
|
+
searching: 'Searching…',
|
|
380
|
+
searchIn: 'Searching in {path} and its subfolders',
|
|
381
|
+
searchNoResult: 'No matching files',
|
|
382
|
+
searchCount: 'Found {count}',
|
|
383
|
+
searchTruncated: 'Found {count} (truncated)',
|
|
384
|
+
searchExit: 'Exit search',
|
|
385
|
+
feedMoreHint: 'Scroll down to load more',
|
|
386
|
+
|
|
387
|
+
// Transfer history
|
|
388
|
+
history: 'Transfers',
|
|
389
|
+
historyShort: 'Log',
|
|
390
|
+
historyTitle: 'Transfer history',
|
|
391
|
+
historyClear: 'Clear history',
|
|
392
|
+
historyClearConfirm: 'Clear all transfer records?\n\nOnly the list is removed — files on the drive and on disk are untouched. Transfers still running are kept.',
|
|
393
|
+
historyCleared: 'Cleared {count} record(s)',
|
|
394
|
+
historyActive: 'In progress',
|
|
395
|
+
historyDone: 'Completed',
|
|
396
|
+
historyFailed: 'Failed',
|
|
397
|
+
historyCancelled: 'Cancelled',
|
|
398
|
+
historyEmpty: 'No transfers yet. Upload or download a file and it will show up here.',
|
|
399
|
+
jobDone: 'Done',
|
|
400
|
+
jobFailed: 'Failed',
|
|
401
|
+
jobCancelled: 'Cancelled',
|
|
402
|
+
jobQueued: 'Queued',
|
|
403
|
+
jobScanning: 'Scanning',
|
|
404
|
+
jobRunning: 'Transferring',
|
|
405
|
+
close: 'Close',
|
|
406
|
+
transfersMore: '{count} more',
|
|
407
|
+
transfersDone: '{count} transfer(s) completed',
|
|
408
|
+
transfersFailed: '{count} transfer(s) failed',
|
|
305
409
|
upload: 'Upload',
|
|
306
410
|
uploadPath: 'Absolute path on this machine, e.g. D:\\tmp\\a.zip',
|
|
307
411
|
uploadPathHint: 'The path is on the machine running DSH, not the machine with the browser.',
|
|
@@ -517,6 +621,265 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
517
621
|
'.mc-formNote{font-size:11px;line-height:17px;color:' + GREY_2 + ';margin:0;word-break:break-word}',
|
|
518
622
|
'.mc-submitRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
519
623
|
'.mc-submit{justify-content:center;min-width:132px}',
|
|
624
|
+
|
|
625
|
+
// ================================================================
|
|
626
|
+
// 资源管理器布局(0.3.0)
|
|
627
|
+
//
|
|
628
|
+
// 目标:像 Windows 资源管理器那样「左边目录树 + 右边内容」,
|
|
629
|
+
// 并且整块区域要**撑满**插件面板(不再是一条条卡片往下堆)。
|
|
630
|
+
//
|
|
631
|
+
// 高度链条很重要:外层 .mc-explorer 必须是 flex 列 + min-height:0,
|
|
632
|
+
// 里面的滚动区才能正确收缩。任何一环忘了 min-height:0,
|
|
633
|
+
// 滚动区就会把父元素顶高、整页出现双滚动条。
|
|
634
|
+
// ================================================================
|
|
635
|
+
// 整页不再居中、不再滚动:由内部各区域自己滚。
|
|
636
|
+
// min-height 写 100% 做兜底:万一宿主给的外层没有确定高度,
|
|
637
|
+
// height:100% 会退化成 auto(塌成内容高),面板就「撑不满」了。
|
|
638
|
+
'.mc-root-ide{height:100%;min-height:100%;overflow:hidden;padding:0;align-items:stretch;gap:0}',
|
|
639
|
+
'.mc-root-ide>*{max-width:none}',
|
|
640
|
+
|
|
641
|
+
// 外壳:纵向 = 顶部栏 / 中间主体 / 底部传输条
|
|
642
|
+
// --mc-right-w 是「右上角容量条」和「搜索框」共用的宽度:
|
|
643
|
+
// 用同一个变量,两者永远同宽,右边缘也永远对齐;窗口变窄时一起缩,
|
|
644
|
+
// 这样按钮 + 容量条才能塞进一行。
|
|
645
|
+
// 🔴 flex:1 1 0 + min-height:0 是必须的:作为 .mc-root-ide(flex column)的子项,
|
|
646
|
+
// 只写 height:100% 会被 flex 算法忽略,结果被内容撑开十倍、列表永远不滚。
|
|
647
|
+
'.mc-explorer{position:relative;flex:1 1 0;display:flex;flex-direction:column;height:100%;min-height:0;width:100%;box-sizing:border-box;overflow:hidden;--mc-right-w:240px}',
|
|
648
|
+
'@media (max-width:1180px){.mc-explorer{--mc-right-w:190px}}',
|
|
649
|
+
'@media (max-width:980px){.mc-explorer{--mc-right-w:150px}}',
|
|
650
|
+
'.mc-root-ide .mc-explorer{max-width:none}',
|
|
651
|
+
|
|
652
|
+
// ---- 顶部工具条 ----
|
|
653
|
+
// 注意:这里**不要** border-bottom —— 底部那条线由容量条自己画。
|
|
654
|
+
'.mc-exTop{flex:none;display:flex;flex-direction:column;gap:0}',
|
|
655
|
+
|
|
656
|
+
// ---- 容量条:顶部栏底部那条贯通的粗进度线(替代原来的浅横线)----
|
|
657
|
+
// 文字压在条里居中,白字 + 深色描边:
|
|
658
|
+
// · 已用段是品牌色(较深),白字清楚;
|
|
659
|
+
// · 未用段是浅灰底,靠描边把白字托出来。
|
|
660
|
+
// 所以亮色 / 暗色主题下都能读,不会「白字压在浅色上消失」。
|
|
661
|
+
'.mc-exQuotaLine{position:relative;flex:none;height:15px;overflow:hidden;background:' + LAYER_2 + '}',
|
|
662
|
+
'.mc-exQuotaLineFill{position:absolute;left:0;top:0;bottom:0;background:var(--dsw-alias-brand-primary,#4f8cff);transition:width .35s ease}',
|
|
663
|
+
'.mc-exQuotaLineText{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:0 8px;font-size:10px;line-height:1;font-weight:600;letter-spacing:.2px;color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.72),0 0 3px rgba(0,0,0,.55);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none;font-variant-numeric:tabular-nums}',
|
|
664
|
+
// 第一行:账号 / 按钮 / 容量条 三者同一水平线(align-items:center = 同高对齐)。
|
|
665
|
+
// 塞得下的关键:左边账号区允许收缩(flex:1 1 0 + min-width:0),
|
|
666
|
+
// 右边按钮组和容量条都是 flex:none —— 空间不够时压账号区,绝不折行。
|
|
667
|
+
'.mc-exTopRow{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:9px clamp(10px,1.6vw,18px);flex-wrap:nowrap;min-width:0}',
|
|
668
|
+
// 账号区:纵向三行(手机号 / 有效期 / 下载目录),保持原来的高度
|
|
669
|
+
'.mc-exTopLeft{display:flex;flex-direction:column;gap:3px;flex:1 1 0;min-width:0;overflow:hidden}',
|
|
670
|
+
// 有效期:一行小字
|
|
671
|
+
'.mc-exExpiry{display:flex;align-items:baseline;gap:4px;min-width:0;font-size:11px;line-height:16px;color:' + GREY_2 + ';font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
672
|
+
// 右上:按钮 + 容量条横向一排,不换行、不收缩
|
|
673
|
+
'.mc-exTopRight{display:flex;align-items:center;gap:6px;flex:0 0 auto;flex-wrap:nowrap;min-width:0;align-self:flex-start}',
|
|
674
|
+
'.mc-exTopRight .mc-btn{flex:none;white-space:nowrap}',
|
|
675
|
+
// 真的挤不下时才折行(很窄的窗口)
|
|
676
|
+
'@media (max-width:720px){.mc-exTopRow{flex-wrap:wrap}.mc-exTopRight{width:100%;justify-content:flex-start}}',
|
|
677
|
+
// 容量条:和搜索框同宽(240px),不再居中、不再占满
|
|
678
|
+
|
|
679
|
+
// 账号行:名字 + 铅笔 + 有效期
|
|
680
|
+
'.mc-exAcct{display:flex;align-items:center;gap:6px;min-width:0;font-size:13px;line-height:19px;font-weight:600}',
|
|
681
|
+
'.mc-exAcctName{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
682
|
+
'.mc-exAcctMasked{font-size:11px;line-height:15px;font-weight:400;color:' + GREY_2 + '}',
|
|
683
|
+
'.mc-exAcctEdit{flex:none;appearance:none;border:0;background:transparent;color:' + GREY_2 + ';font-size:11px;line-height:1;padding:2px 3px;border-radius:4px;cursor:pointer}',
|
|
684
|
+
'.mc-exAcctEdit:hover{background:' + LAYER_2 + ';color:' + GREY_1 + '}',
|
|
685
|
+
'.mc-exExpiryWarn{color:' + DANGER + '}',
|
|
686
|
+
// 下载目录小按钮:带框、可点。
|
|
687
|
+
// ⚠️ 必须限宽:路径很长(C:\Users\xxx\Downloads\dsh-mcloud),
|
|
688
|
+
// 不限的话这个框会把左上角撑得很长,还挤压右边的按钮。
|
|
689
|
+
// self-start 让它按内容收缩,max-width 兜住上限,超出部分用省略号。
|
|
690
|
+
'.mc-exChip{appearance:none;font:inherit;display:inline-flex;align-items:center;gap:6px;box-sizing:border-box;border:0.5px solid ' + LINE + ';border-radius:6px;background:' + LAYER_1 + ';color:' + GREY_2 + ';font-size:11px;line-height:16px;padding:3px 8px;cursor:pointer;max-width:min(100%,230px);min-width:0;align-self:flex-start;transition:background-color .14s ease,color .14s ease,border-color .14s ease}',
|
|
691
|
+
'.mc-exChip:hover{background:' + LAYER_2 + ';color:' + GREY_1 + ';border-color:' + GREY_2 + '}',
|
|
692
|
+
'.mc-exChipIcon{flex:none;font-size:11px;line-height:1;opacity:.9}',
|
|
693
|
+
'.mc-exChipLabel{flex:none;font-weight:600}',
|
|
694
|
+
'.mc-exChipValue{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left;font-variant-numeric:tabular-nums}',
|
|
695
|
+
|
|
696
|
+
// 容量条(顶部居中)
|
|
697
|
+
|
|
698
|
+
// 第二行:工具条(返回 / 路径 / 搜索 / 新建)
|
|
699
|
+
// 第二行工具条:不换行(换行会把搜索框挤到第三行)
|
|
700
|
+
'.mc-exTools{display:flex;align-items:center;gap:6px;flex-wrap:nowrap;padding:0 clamp(10px,1.6vw,18px) 8px}',
|
|
701
|
+
'.mc-exToolsGrow{flex:1 1 0;min-width:0;overflow:hidden}',
|
|
702
|
+
|
|
703
|
+
// 搜索框
|
|
704
|
+
// 搜索框:固定 240px —— 和上一行的容量条严格同宽,
|
|
705
|
+
// 这样两者的右边缘能对齐(一个在上、一个在下)。
|
|
706
|
+
'.mc-exSearch{position:relative;display:flex;align-items:center;gap:0;flex:0 0 var(--mc-right-w);width:var(--mc-right-w);min-width:0}',
|
|
707
|
+
'@media (max-width:720px){.mc-exSearch{flex:1 1 100%;width:auto}}',
|
|
708
|
+
'.mc-exSearchInput{box-sizing:border-box;width:100%;font:inherit;font-size:12px;line-height:17px;color:' + GREY_1 + ';background:transparent;border:0.5px solid ' + LINE + ';border-radius:7px;padding:4px 26px 4px 8px}',
|
|
709
|
+
'.mc-exSearchInput::placeholder{color:' + GREY_2 + '}',
|
|
710
|
+
'.mc-exSearchClear{position:absolute;right:2px;top:50%;transform:translateY(-50%);appearance:none;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;padding:0;border:0;border-radius:5px;background:transparent;color:' + GREY_2 + ';font-size:13px;line-height:1;cursor:pointer}',
|
|
711
|
+
'.mc-exSearchClear:hover{background:' + LAYER_2 + ';color:' + GREY_1 + '}',
|
|
712
|
+
|
|
713
|
+
// ---- 中部主体:左树 + 右列表 ----
|
|
714
|
+
'.mc-exBody{flex:1 1 0;display:flex;min-height:0;overflow:hidden}',
|
|
715
|
+
// 左树
|
|
716
|
+
// 左树:宽度由 CSS 自适应,拖过之后由 inline style 固定
|
|
717
|
+
'.mc-exTree{flex:none;display:flex;flex-direction:column;min-height:0;width:clamp(150px,19vw,240px);border-right:0.5px solid ' + LINE + '}',
|
|
718
|
+
// 拖动中的分隔条:6px 热区 + 中间 2px 竖线
|
|
719
|
+
// 热区给到 14px:6px 太窄,鼠标很难精确落在上面(用户反馈要拖到边上去才生效)。
|
|
720
|
+
// 视觉上仍然是中间那条 1px 的线,只是可抓取范围宽了。
|
|
721
|
+
'.mc-exSplitter{flex:none;position:relative;width:14px;margin-left:-7px;margin-right:-7px;cursor:col-resize;z-index:6;display:flex;align-items:center;justify-content:center;touch-action:none}',
|
|
722
|
+
'.mc-exSplitterBar{width:1px;height:100%;background:' + LINE + ';opacity:0;border-radius:1px;transition:opacity .12s ease,background-color .12s ease,width .12s ease}',
|
|
723
|
+
// 悬停时才显形,平时不打扰
|
|
724
|
+
'.mc-exSplitter:hover .mc-exSplitterBar{opacity:1;width:2px;background:' + GREY_2 + '}',
|
|
725
|
+
'.mc-exSplitterActive .mc-exSplitterBar{opacity:1;width:2px;background:' + GREY_1 + '}',
|
|
726
|
+
// 拖动时禁掉整页选中(否则会拖出一片蓝色选区)
|
|
727
|
+
'.mc-exResizing,.mc-exResizing *{user-select:none !important;cursor:col-resize !important}',
|
|
728
|
+
// 窄屏时收起左树(靠 localStorage 记住用户选择)
|
|
729
|
+
'.mc-exTreeHidden{display:none}',
|
|
730
|
+
'.mc-exTreeHead{flex:none;display:flex;align-items:center;justify-content:space-between;gap:6px;height:29px;box-sizing:border-box;padding:0 10px;font-size:11px;line-height:16px;color:' + GREY_2 + ';border-bottom:0.5px solid ' + LINE + '}',
|
|
731
|
+
// overscroll-behavior:contain —— 滚到底别把滚动传给外层对话页
|
|
732
|
+
'.mc-exTreeScroll{flex:1 1 0;min-height:0;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding:4px 4px 10px}',
|
|
733
|
+
'.mc-exTreeNode{display:flex;align-items:center;gap:3px;min-width:0;border-radius:6px}',
|
|
734
|
+
'.mc-exTreeNode:hover{background:' + LAYER_1 + '}',
|
|
735
|
+
'.mc-exTreeNodeActive{background:' + LAYER_2 + '}',
|
|
736
|
+
// 箭头格子:宽度恒定 14px,任何状态都不变,所以行不会跳
|
|
737
|
+
'.mc-exTreeToggle{flex:none;appearance:none;display:inline-flex;align-items:center;justify-content:center;width:14px;min-width:14px;height:20px;padding:0;border:0;background:transparent;color:' + GREY_2 + ';cursor:pointer;border-radius:4px}',
|
|
738
|
+
'.mc-exTreeToggle:hover:not(:disabled){color:' + GREY_1 + ';background:' + LAYER_2 + '}',
|
|
739
|
+
'.mc-exTreeToggle:disabled{cursor:default;opacity:.5}',
|
|
740
|
+
'.mc-exCaretSlot{display:inline-flex;align-items:center;justify-content:center;width:10px;height:10px;flex:none}',
|
|
741
|
+
// SVG 折角:收起指向右,展开转 90°,带过渡。
|
|
742
|
+
// 颜色比普通文字淡一档,但悬停整行会变亮 —— 平时不抢戏,需要时看得见。
|
|
743
|
+
'.mc-exCaret{display:block;color:' + GREY_2 + ';opacity:1;transition:transform .16s cubic-bezier(.4,0,.2,1),color .12s ease}',
|
|
744
|
+
'.mc-exCaretOpen{transform:rotate(90deg)}',
|
|
745
|
+
'.mc-exTreeNode:hover .mc-exCaret,.mc-exTreeToggle:hover .mc-exCaret{color:' + GREY_1 + '}',
|
|
746
|
+
// 加载中的小转圈(和箭头同尺寸,不撑开布局)
|
|
747
|
+
'.mc-exTreeSpinner{display:block;width:9px;height:9px;box-sizing:border-box;border:1.2px solid currentColor;border-top-color:transparent;border-radius:50%;opacity:.6;animation:mc-exSpin .7s linear infinite}',
|
|
748
|
+
'@keyframes mc-exSpin{to{transform:rotate(360deg)}}',
|
|
749
|
+
// 子节点容器:展开时淡入,避免"啪"一下出现
|
|
750
|
+
'.mc-exTreeKids{animation:mc-exTreeIn .14s ease-out}',
|
|
751
|
+
'@keyframes mc-exTreeIn{from{opacity:0}to{opacity:1}}',
|
|
752
|
+
'.mc-exTreeLabel{flex:1 1 auto;min-width:0;appearance:none;display:flex;align-items:center;gap:5px;border:0;background:transparent;font:inherit;font-size:12px;line-height:18px;color:' + GREY_1 + ';text-align:left;padding:3px 4px;border-radius:5px;cursor:pointer;overflow:hidden}',
|
|
753
|
+
'.mc-exTreeIcon{flex:none;font-size:12px;line-height:1}',
|
|
754
|
+
'.mc-exTreeName{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
755
|
+
'.mc-exTreeLoading{flex:none;font-size:10px;color:' + GREY_2 + '}',
|
|
756
|
+
// 树里的「继续加载」:比正常节点淡,明确是占位而不是真目录
|
|
757
|
+
|
|
758
|
+
// 右列表
|
|
759
|
+
'.mc-exMain{flex:1 1 0;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}',
|
|
760
|
+
// 列头(像资源管理器那样)
|
|
761
|
+
'.mc-exCols{position:relative;flex:none;display:grid;grid-template-columns:minmax(0,1fr) var(--mc-col-size,84px) var(--mc-col-time,132px);align-items:stretch;gap:0;height:29px;padding:0;box-sizing:border-box;border-bottom:0.5px solid ' + LINE + ';font-size:11px;line-height:16px;color:' + GREY_2 + ';user-select:none}',
|
|
762
|
+
// ⚠️ 用「面板自身宽度」而不是视口宽度判断:对话区可能很窄,但整个浏览器窗口很宽,
|
|
763
|
+
// 媒体查询(基于视口)在这种布局下会失效,三列直接挤成一团。
|
|
764
|
+
// 这里由 JS 量 .mc-exMain 的宽度挂上 mc-narrow 类。
|
|
765
|
+
'.mc-exMain.mc-narrow .mc-exCols{grid-template-columns:minmax(0,1fr) var(--mc-col-size,72px)}',
|
|
766
|
+
'.mc-exMain.mc-narrow .mc-exColsTime{display:none}',
|
|
767
|
+
'.mc-exMain.mc-narrow .mc-exColHandleTime{display:none}',
|
|
768
|
+
'.mc-exColsName{display:flex;align-items:center;min-width:0;padding:0 8px 0 clamp(8px,1.2vw,14px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
769
|
+
// 「大小」左右两条竖线(用户要求),同时也是拖动把手的视觉落点
|
|
770
|
+
'.mc-exColsSize{display:flex;align-items:center;justify-content:flex-end;padding:0 8px;border-left:0.5px solid ' + LINE + ';border-right:0.5px solid ' + LINE + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
771
|
+
'.mc-exColsTime{display:flex;align-items:center;justify-content:flex-end;padding:0 clamp(8px,1.2vw,14px) 0 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
772
|
+
// 滚动列表:撑满剩余高度,自己滚
|
|
773
|
+
'.mc-exScroll{flex:1 1 0;min-height:0;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding:0 0 6px}',
|
|
774
|
+
'.mc-exRows{display:flex;flex-direction:column}',
|
|
775
|
+
// 行:网格列对齐列头。
|
|
776
|
+
// 不要边框 —— 资源管理器里行是「无框」的,靠整行背景色区分 hover / 选中,
|
|
777
|
+
// 加边框会让列表看着像一堆小方块,又碎又脏。
|
|
778
|
+
// 列宽拖动把手:热区 12px,视觉上是一条悬停才亮起来的竖线
|
|
779
|
+
'.mc-exColHandle{position:absolute;top:0;bottom:0;width:12px;margin-left:-6px;cursor:col-resize;z-index:4;display:flex;align-items:center;justify-content:center;touch-action:none}',
|
|
780
|
+
'.mc-exColHandleBar{width:1px;height:55%;background:transparent;border-radius:1px;transition:background-color .12s ease,height .12s ease,width .12s ease}',
|
|
781
|
+
'.mc-exColHandle:hover .mc-exColHandleBar{width:2px;height:100%;background:' + GREY_2 + '}',
|
|
782
|
+
'.mc-exColHandleActive .mc-exColHandleBar{width:2px;height:100%;background:' + GREY_1 + '}',
|
|
783
|
+
// 拖动时禁掉列头的文字选中,避免拖出一片蓝
|
|
784
|
+
'.mc-exColsResizing{user-select:none;cursor:col-resize}',
|
|
785
|
+
'.mc-exRow{display:grid;grid-template-columns:minmax(0,1fr) var(--mc-col-size,84px) var(--mc-col-time,132px);align-items:stretch;gap:0;min-height:29px;padding:0;box-sizing:border-box;font-size:13px;line-height:19px;cursor:default;position:relative}',
|
|
786
|
+
'.mc-exMain.mc-narrow .mc-exRow{grid-template-columns:minmax(0,1fr) var(--mc-col-size,72px)}',
|
|
787
|
+
'.mc-exMain.mc-narrow .mc-exRowTime{display:none}',
|
|
788
|
+
'.mc-exRow:hover{background:' + LAYER_1 + '}',
|
|
789
|
+
// 选中:只把背景加深一档,左侧留一条短竖线做焦点提示(资源管理器的做法)
|
|
790
|
+
'.mc-exRowSelected{background:' + LAYER_2 + '}',
|
|
791
|
+
'.mc-exRowSelected::before{content:"";position:absolute;left:2px;top:50%;transform:translateY(-50%);width:2px;height:14px;border-radius:1px;background:' + GREY_2 + '}',
|
|
792
|
+
'.mc-exRowName{display:flex;align-items:center;gap:7px;min-width:0;padding:0 8px 0 calc(clamp(8px,1.2vw,14px) + 3px);overflow:hidden}',
|
|
793
|
+
'.mc-exRowIcon{flex:none;font-size:13px;line-height:1}',
|
|
794
|
+
'.mc-exRowText{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
795
|
+
'.mc-exRowTextBtn{appearance:none;border:0;background:transparent;font:inherit;color:inherit;text-align:left;padding:0;cursor:pointer;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
796
|
+
// 左右竖线要贯穿整列:列头有,每一行也必须有,否则只有表头那一段有线
|
|
797
|
+
'.mc-exRowSize{display:flex;align-items:center;justify-content:flex-end;font-size:12px;color:' + GREY_2 + ';font-variant-numeric:tabular-nums;padding:0 8px;border-left:0.5px solid ' + LINE + ';border-right:0.5px solid ' + LINE + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
798
|
+
'.mc-exRowTime{display:flex;align-items:center;justify-content:flex-end;font-size:12px;color:' + GREY_2 + ';font-variant-numeric:tabular-nums;padding:0 clamp(8px,1.2vw,14px) 0 8px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
799
|
+
// 提示条 / 空态 / 加载
|
|
800
|
+
'.mc-exBanner{flex:none;margin:6px clamp(8px,1.2vw,14px) 0;border-radius:8px;padding:6px 10px;font-size:12px;line-height:17px;background:' + LAYER_1 + ';word-break:break-word}',
|
|
801
|
+
'.mc-exBannerErr{color:' + DANGER + '}',
|
|
802
|
+
'.mc-exEmpty{padding:22px 12px;font-size:12px;line-height:18px;color:' + GREY_2 + ';text-align:center}',
|
|
803
|
+
'.mc-exSearchHint{flex:none;display:flex;align-items:center;justify-content:space-between;gap:8px;padding:5px clamp(8px,1.2vw,14px);font-size:11px;line-height:16px;color:' + GREY_2 + '}',
|
|
804
|
+
'.mc-exSearchHit{font-weight:600;color:' + GREY_1 + '}',
|
|
805
|
+
// 触底自动加载:底部只给一行极小的小字,不用按钮
|
|
806
|
+
'.mc-exFeedFoot{display:flex;align-items:center;justify-content:center;gap:6px;padding:10px 0 4px;font-size:11px;line-height:16px;color:' + GREY_2 + ';opacity:.75}',
|
|
807
|
+
'.mc-exFeedDot{width:4px;height:4px;border-radius:50%;background:currentColor;animation:mc-exFeedPulse 1.1s ease-in-out infinite}',
|
|
808
|
+
'@keyframes mc-exFeedPulse{0%,100%{opacity:.25}50%{opacity:1}}',
|
|
809
|
+
|
|
810
|
+
// ---- 底部传输条 ----
|
|
811
|
+
// 平时完全隐形(就是一条和背景同色的细条);
|
|
812
|
+
// 有任务时进度填充出现颜色,跑满后停留 5 秒再淡出。
|
|
813
|
+
// 高度会随「有没有任务」在 3px / 5px 之间切换:只改 CSS 变量不影响布局链条。
|
|
814
|
+
// 传输条浮层:绝对定位钉在面板底部,任何视图/滚动位置都看得见。
|
|
815
|
+
// 空闲时 pointer-events:none,不挡下面的点击。
|
|
816
|
+
'.mc-exTransDock{position:absolute;left:0;right:0;bottom:0;z-index:20;pointer-events:none}',
|
|
817
|
+
'.mc-exTransDockActive{pointer-events:auto}',
|
|
818
|
+
// 空闲时也留一条极淡的底线 —— 不然用户根本不知道进度条在哪儿
|
|
819
|
+
'.mc-exTrans{flex:none;position:relative;height:6px;background:' + LINE + ';transition:height .18s ease,background-color .25s ease}',
|
|
820
|
+
'.mc-exTransActive{height:8px;background:transparent}',
|
|
821
|
+
'.mc-exTransTrack{position:absolute;inset:0;overflow:hidden;background:transparent}',
|
|
822
|
+
// 褪色要慢一点:1.5s 让颜色缓缓溶进背景,而不是「唰」一下没了
|
|
823
|
+
'.mc-exTransFill{position:absolute;left:0;top:0;bottom:0;width:0;border-radius:0 3px 3px 0;background:transparent;transition:width .28s ease,background-color .3s ease,opacity 1.5s ease}',
|
|
824
|
+
'.mc-exTransDraw{background:var(--dsw-alias-brand-primary,#4f8cff)}',
|
|
825
|
+
'.mc-exTransDone{background:var(--dsw-alias-state-success-primary,#2ea043)}',
|
|
826
|
+
'.mc-exTransErr{background:' + DANGER + '}',
|
|
827
|
+
// 不确定进度(还不知道总量):一小段来回跑,别让用户以为卡住了
|
|
828
|
+
'.mc-exTransIndet{animation:mc-exTransIndet 1.2s ease-in-out infinite}',
|
|
829
|
+
'@keyframes mc-exTransIndet{0%{width:8%;margin-left:0}50%{width:26%;margin-left:37%}100%{width:8%;margin-left:92%}}',
|
|
830
|
+
'.mc-exTransBar{flex:none;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:5px clamp(10px,1.6vw,18px);border-top:0.5px solid ' + LINE + ';background:var(--dsw-alias-bg-layer-3,#ffffff);box-shadow:0 -4px 14px rgba(0,0,0,.10);font-size:11px;line-height:16px;color:' + GREY_2 + ';font-variant-numeric:tabular-nums;transition:opacity 1.5s ease}',
|
|
831
|
+
'.mc-exTransBarFade{opacity:0}',
|
|
832
|
+
'.mc-exTransSummary{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
833
|
+
'.mc-exTransActions{flex:none;display:flex;align-items:center;gap:6px}',
|
|
834
|
+
|
|
835
|
+
// ---- 弹层(下载目录 / 选择目录)----
|
|
836
|
+
'.mc-exModalHead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}',
|
|
837
|
+
'.mc-exModalTitle{font-size:14px;font-weight:600;line-height:21px;margin:0}',
|
|
838
|
+
'.mc-exModalRow{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
839
|
+
'.mc-exModalRow .mc-input{flex:1 1 200px;min-width:0}',
|
|
840
|
+
'.mc-exModalNote{font-size:11px;line-height:17px;color:' + GREY_2 + ';margin:0;word-break:break-word}',
|
|
841
|
+
|
|
842
|
+
// ---- 传输记录 ----
|
|
843
|
+
// 按钮上只有文字(不再放时钟图标),所以**任何宽度都不能把文字藏掉**,
|
|
844
|
+
// 否则窄屏会变成一个空白按钮。窄屏时改成更短的词。
|
|
845
|
+
'.mc-exHistoryBtn{display:inline-flex;align-items:center;gap:5px;position:relative}',
|
|
846
|
+
'.mc-exHistoryLabel{display:inline}',
|
|
847
|
+
'.mc-exHistoryLabelShort{display:none}',
|
|
848
|
+
'@media (max-width:960px){.mc-exHistoryLabel{display:none}.mc-exHistoryLabelShort{display:inline}}',
|
|
849
|
+
// 有任务在跑时按钮上一个小圆点
|
|
850
|
+
'.mc-exHistoryDot{width:5px;height:5px;border-radius:50%;background:var(--dsw-alias-brand-primary,#4f8cff);animation:mc-exFeedPulse 1.1s ease-in-out infinite}',
|
|
851
|
+
// 弹层自身不滚,让里面的列表滚 —— 否则内外两层都能滚,滚动手感很怪
|
|
852
|
+
'.mc-exHistPanel{width:min(560px,100%);overflow:hidden}',
|
|
853
|
+
// 列表区自己滚:flex:1 + min-height:0 才能在高面板里正确收缩
|
|
854
|
+
'.mc-exHistBody{display:flex;flex-direction:column;gap:14px;flex:1 1 auto;min-height:0;max-height:min(62vh,470px);overflow-y:auto;overscroll-behavior:contain;padding-right:4px}',
|
|
855
|
+
// 细一点的滚动条,别抢视觉
|
|
856
|
+
'.mc-exHistBody::-webkit-scrollbar{width:8px}',
|
|
857
|
+
'.mc-exHistBody::-webkit-scrollbar-thumb{background:' + LAYER_2 + ';border-radius:4px}',
|
|
858
|
+
'.mc-exHistBody::-webkit-scrollbar-thumb:hover{background:' + GREY_2 + '}',
|
|
859
|
+
'.mc-exHistBody::-webkit-scrollbar-track{background:transparent}',
|
|
860
|
+
'.mc-exHistEmpty{padding:26px 12px;font-size:12px;line-height:18px;color:' + GREY_2 + ';text-align:center}',
|
|
861
|
+
'.mc-exHistSection{display:flex;flex-direction:column;gap:2px}',
|
|
862
|
+
'.mc-exHistSectionHead{font-size:11px;line-height:16px;color:' + GREY_2 + ';font-weight:600;padding:0 0 4px}',
|
|
863
|
+
'.mc-exHistItem{display:flex;flex-direction:column;gap:3px;padding:7px 8px;border-radius:7px}',
|
|
864
|
+
'.mc-exHistItem:hover{background:' + LAYER_1 + '}',
|
|
865
|
+
'.mc-exHistRow{display:flex;align-items:center;gap:7px;min-width:0}',
|
|
866
|
+
// 图标:上传绿、下载蓝,一眼分清方向
|
|
867
|
+
'.mc-exHistIcon{flex:none;display:inline-flex;align-items:center;justify-content:center;width:12px;height:14px;font-size:11px;line-height:1;color:' + GREY_2 + '}',
|
|
868
|
+
'.mc-exHistIconUp{color:var(--dsw-alias-state-success-primary,#2ea043)}',
|
|
869
|
+
'.mc-exHistIconDown{color:var(--dsw-alias-brand-primary,#4f8cff)}',
|
|
870
|
+
'.mc-exHistName{flex:1 1 auto;min-width:0;font-size:12px;line-height:18px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
871
|
+
'.mc-exHistState{flex:none;font-size:11px;line-height:16px;color:' + GREY_2 + '}',
|
|
872
|
+
'.mc-exHistStateOk{color:var(--dsw-alias-state-success-primary,#2ea043)}',
|
|
873
|
+
'.mc-exHistStateErr{color:' + DANGER + '}',
|
|
874
|
+
'.mc-exHistMeta{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:11px;line-height:15px;color:' + GREY_2 + ';font-variant-numeric:tabular-nums}',
|
|
875
|
+
'.mc-exHistBar{position:relative;height:3px;border-radius:999px;overflow:hidden;background:' + LAYER_2 + '}',
|
|
876
|
+
'.mc-exHistBarFill{height:100%;border-radius:999px;background:var(--dsw-alias-brand-primary,#4f8cff);transition:width .25s ease}',
|
|
877
|
+
'.mc-exHistErr{font-size:11px;line-height:15px;color:' + DANGER + ';overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
878
|
+
|
|
879
|
+
// 极窄屏:左树会让列表没地方放,直接收起来(用户可用顶栏 ◀ 手动开)
|
|
880
|
+
'@media (max-width:520px){.mc-exTree{width:132px}}',
|
|
881
|
+
// 高 DPI / 小高度:顶部栏压缩一点,给列表让高度
|
|
882
|
+
'@media (max-height:520px){.mc-exTopRow{padding:5px clamp(8px,1.2vw,12px)}.mc-exTools{padding-bottom:5px}.mc-exTransBar{padding:2px clamp(8px,1.2vw,12px)}}',
|
|
520
883
|
].join('')
|
|
521
884
|
var CSS_ID = 'dsh-mcloud/panel.css'
|
|
522
885
|
function ensureStyle() {
|
|
@@ -670,7 +1033,6 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
670
1033
|
complete: 'phaseComplete',
|
|
671
1034
|
}
|
|
672
1035
|
|
|
673
|
-
var JOB_ICONS = { upload: '⬆', download: '⬇', 'download-folder': '🗂', 'upload-folder': '🗂' }
|
|
674
1036
|
|
|
675
1037
|
// ---------------------------------------------------------------- HTTP
|
|
676
1038
|
/** 非 2xx 与 { ok:false, error } 都转成 Error 抛出去;响应体不是 JSON 也当失败。 */
|
|
@@ -695,12 +1057,29 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
695
1057
|
* 统一出口:GET 不带自定义头,POST 一律 content-type + x-dsh-mcloud。
|
|
696
1058
|
* 非 2xx 与 { ok:false, error } 都转成 Error 抛出去。
|
|
697
1059
|
*/
|
|
1060
|
+
/**
|
|
1061
|
+
* 客户端请求超时(比宿主的 25s 稍长,让宿主先报错)。
|
|
1062
|
+
*
|
|
1063
|
+
* 没有超时的话,一旦某个请求挂住(浏览器层面卡死、代理断了),
|
|
1064
|
+
* `listLoading` 会永久停在 true —— 而「加载更多」的守卫是
|
|
1065
|
+
* `if (listLoading) return`,于是列表从此再也不会续加载,
|
|
1066
|
+
* 用户滚到底就彻底卡住。大目录最容易撞上这个。
|
|
1067
|
+
*/
|
|
1068
|
+
var CLIENT_TIMEOUT_MS = 30 * 1000
|
|
1069
|
+
|
|
698
1070
|
function requestJson(method, path, body) {
|
|
699
1071
|
var init = { method: method, credentials: 'same-origin', cache: 'no-store' }
|
|
700
1072
|
if (method === 'POST') {
|
|
701
1073
|
init.headers = { 'content-type': 'application/json', 'x-dsh-mcloud': '1' }
|
|
702
1074
|
init.body = JSON.stringify(body || {})
|
|
703
1075
|
}
|
|
1076
|
+
try {
|
|
1077
|
+
if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
|
|
1078
|
+
init.signal = AbortSignal.timeout(CLIENT_TIMEOUT_MS)
|
|
1079
|
+
}
|
|
1080
|
+
} catch (failure) {
|
|
1081
|
+
/* 老浏览器没有 AbortSignal.timeout,退化成不超时 */
|
|
1082
|
+
}
|
|
704
1083
|
return fetch(BASE + path, init).then(readJsonResponse)
|
|
705
1084
|
}
|
|
706
1085
|
|
|
@@ -736,13 +1115,31 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
736
1115
|
clearAuth: function () {
|
|
737
1116
|
return requestJson('POST', '/auth/clear', {})
|
|
738
1117
|
},
|
|
739
|
-
list: function (parentFileId, cursor) {
|
|
1118
|
+
list: function (parentFileId, cursor, pageSize) {
|
|
1119
|
+
var query =
|
|
1120
|
+
'/list?parentFileId=' + encodeURIComponent(parentFileId || '/') +
|
|
1121
|
+
'&cursor=' + encodeURIComponent(cursor || '')
|
|
1122
|
+
// 探测「这个目录有没有东西」时用 pageSize=1,比默认 10 更轻。
|
|
1123
|
+
if (pageSize) query += '&pageSize=' + encodeURIComponent(String(pageSize))
|
|
1124
|
+
return requestJson('GET', query)
|
|
1125
|
+
},
|
|
1126
|
+
/** 按文件名搜索:Host 端会从 parentFileId 往下走若干层。 */
|
|
1127
|
+
search: function (options) {
|
|
1128
|
+
var opts = options || {}
|
|
740
1129
|
return requestJson(
|
|
741
1130
|
'GET',
|
|
742
|
-
'/
|
|
743
|
-
'&
|
|
1131
|
+
'/search?keyword=' + encodeURIComponent(opts.keyword || '') +
|
|
1132
|
+
'&parentFileId=' + encodeURIComponent(opts.parentFileId || '/')
|
|
744
1133
|
)
|
|
745
1134
|
},
|
|
1135
|
+
/** 弹宿主机的系统「选择文件夹」对话框(选下载目录用)。 */
|
|
1136
|
+
pickDir: function () {
|
|
1137
|
+
return requestJson('POST', '/pick-dir', {})
|
|
1138
|
+
},
|
|
1139
|
+
/** 清空已结束的传输记录(只删记录,不删文件)。 */
|
|
1140
|
+
clearTransfers: function () {
|
|
1141
|
+
return requestJson('POST', '/transfers/clear', {})
|
|
1142
|
+
},
|
|
746
1143
|
mkdir: function (parentFileId, name) {
|
|
747
1144
|
return requestJson('POST', '/mkdir', { parentFileId: parentFileId, name: name })
|
|
748
1145
|
},
|
|
@@ -1106,6 +1503,343 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
1106
1503
|
var uploadTarget = uploadTargetState[0]
|
|
1107
1504
|
var setUploadTarget = uploadTargetState[1]
|
|
1108
1505
|
|
|
1506
|
+
// ================================================================
|
|
1507
|
+
// 0.3.0 资源管理器:目录树 / 搜索 / 下载目录弹层 / 传输条动画
|
|
1508
|
+
// ================================================================
|
|
1509
|
+
/**
|
|
1510
|
+
* 左侧目录树。
|
|
1511
|
+
*
|
|
1512
|
+
* 结构:{ id, name, open, loading, loaded, children: [] }
|
|
1513
|
+
* 只有「展开过」的节点才去请求子目录 —— 网盘目录可能很多,
|
|
1514
|
+
* 一上来就把整棵树拉下来会让打开面板慢好几秒。
|
|
1515
|
+
*/
|
|
1516
|
+
var treeRootState = React.useState({
|
|
1517
|
+
id: '/',
|
|
1518
|
+
name: '/',
|
|
1519
|
+
open: true,
|
|
1520
|
+
loading: false,
|
|
1521
|
+
loaded: false,
|
|
1522
|
+
children: [],
|
|
1523
|
+
})
|
|
1524
|
+
var treeRoot = treeRootState[0]
|
|
1525
|
+
var setTreeRoot = treeRootState[1]
|
|
1526
|
+
|
|
1527
|
+
/** 左树是否显示(窄屏可手动收起)。 */
|
|
1528
|
+
var treeShownState = React.useState(true)
|
|
1529
|
+
var treeShown = treeShownState[0]
|
|
1530
|
+
var setTreeShown = treeShownState[1]
|
|
1531
|
+
|
|
1532
|
+
/** 当前列表所对应的目录 id(右树的「焦点」)。 */
|
|
1533
|
+
var treeFocusIdState = React.useState('/')
|
|
1534
|
+
var treeFocusId = treeFocusIdState[0]
|
|
1535
|
+
var setTreeFocusId = treeFocusIdState[1]
|
|
1536
|
+
|
|
1537
|
+
// 搜索
|
|
1538
|
+
var searchOpenState = React.useState(false)
|
|
1539
|
+
var searchOpen = searchOpenState[0]
|
|
1540
|
+
var setSearchOpen = searchOpenState[1]
|
|
1541
|
+
var searchKeywordState = React.useState('')
|
|
1542
|
+
var searchKeyword = searchKeywordState[0]
|
|
1543
|
+
var setSearchKeyword = searchKeywordState[1]
|
|
1544
|
+
var searchBusyState = React.useState(false)
|
|
1545
|
+
var searchBusy = searchBusyState[0]
|
|
1546
|
+
var setSearchBusy = searchBusyState[1]
|
|
1547
|
+
var searchItemsState = React.useState([])
|
|
1548
|
+
var searchItems = searchItemsState[0]
|
|
1549
|
+
var setSearchItems = searchItemsState[1]
|
|
1550
|
+
var searchNoteState = React.useState('')
|
|
1551
|
+
var searchNote = searchNoteState[0]
|
|
1552
|
+
var setSearchNote = searchNoteState[1]
|
|
1553
|
+
var searchErrorState = React.useState('')
|
|
1554
|
+
var searchError = searchErrorState[0]
|
|
1555
|
+
var setSearchError = searchErrorState[1]
|
|
1556
|
+
var searchSeqRef = React.useRef(0)
|
|
1557
|
+
|
|
1558
|
+
// 下载目录弹层
|
|
1559
|
+
var dirModalState = React.useState(false)
|
|
1560
|
+
var dirModal = dirModalState[0]
|
|
1561
|
+
var setDirModal = dirModalState[1]
|
|
1562
|
+
var dirDraftState = React.useState('')
|
|
1563
|
+
var dirDraft = dirDraftState[0]
|
|
1564
|
+
var setDirDraft = dirDraftState[1]
|
|
1565
|
+
var dirPickBusyState = React.useState(false)
|
|
1566
|
+
var dirPickBusy = dirPickBusyState[0]
|
|
1567
|
+
var setDirPickBusy = dirPickBusyState[1]
|
|
1568
|
+
var dirErrorState = React.useState('')
|
|
1569
|
+
var dirError = dirErrorState[0]
|
|
1570
|
+
var setDirError = dirErrorState[1]
|
|
1571
|
+
|
|
1572
|
+
/** 底部传输条:'idle' | 'draw' | 'done' | 'error'。 */
|
|
1573
|
+
var transPhaseState = React.useState('idle')
|
|
1574
|
+
var transPhase = transPhaseState[0]
|
|
1575
|
+
var setTransPhase = transPhaseState[1]
|
|
1576
|
+
/** 刚刚完成时停留 5 秒再渐变回透明。 */
|
|
1577
|
+
var transFadeRef = React.useRef(null)
|
|
1578
|
+
/** 上一次的整体进度,用来推动画。 */
|
|
1579
|
+
var transPercentState = React.useState(0)
|
|
1580
|
+
var transPercent = transPercentState[0]
|
|
1581
|
+
var setTransPercent = transPercentState[1]
|
|
1582
|
+
|
|
1583
|
+
/** 当前选中的行 id(选中态只影响视觉)。 */
|
|
1584
|
+
var selectedIdState = React.useState('')
|
|
1585
|
+
var selectedId = selectedIdState[0]
|
|
1586
|
+
var setSelectedId = selectedIdState[1]
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* 列表滚动容器。
|
|
1590
|
+
*
|
|
1591
|
+
* 不用「加载更多」按钮:滚到底部就把下一批接上,
|
|
1592
|
+
* 这才是资源管理器的样子。用 ref 是为了在滚动回调里读到最新值。
|
|
1593
|
+
*/
|
|
1594
|
+
var listScrollRef = React.useRef(null)
|
|
1595
|
+
/** 防重入:一次只允许一个「加载下一批」在飞。 */
|
|
1596
|
+
var loadMoreBusyRef = React.useRef(false)
|
|
1597
|
+
/** 滚到底部附近多少像素时开始预加载。 */
|
|
1598
|
+
var LOAD_MORE_THRESHOLD = 240
|
|
1599
|
+
|
|
1600
|
+
/** 历史传输记录弹层。 */
|
|
1601
|
+
var historyOpenState = React.useState(false)
|
|
1602
|
+
var historyOpen = historyOpenState[0]
|
|
1603
|
+
var setHistoryOpen = historyOpenState[1]
|
|
1604
|
+
/** 清空记录进行中(防连点)。 */
|
|
1605
|
+
var historyBusyState = React.useState(false)
|
|
1606
|
+
var historyBusy = historyBusyState[0]
|
|
1607
|
+
var setHistoryBusy = historyBusyState[1]
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* 左树宽度(px)。
|
|
1611
|
+
*
|
|
1612
|
+
* null = 还没拖过,用 CSS 的自适应宽度(clamp)。
|
|
1613
|
+
* 用户拖动过之后就固定成他选的像素值,并记到 localStorage,
|
|
1614
|
+
* 下次打开还是这个宽度。
|
|
1615
|
+
*/
|
|
1616
|
+
var treeWidthState = React.useState(null)
|
|
1617
|
+
var treeWidth = treeWidthState[0]
|
|
1618
|
+
var setTreeWidth = treeWidthState[1]
|
|
1619
|
+
/** 拖动中(用来加 cursor 和防止选中文字)。 */
|
|
1620
|
+
var resizingState = React.useState(false)
|
|
1621
|
+
var resizing = resizingState[0]
|
|
1622
|
+
var setResizing = resizingState[1]
|
|
1623
|
+
|
|
1624
|
+
/**
|
|
1625
|
+
* 资源管理器根节点的实测高度(px)。
|
|
1626
|
+
*
|
|
1627
|
+
* 不用 CSS 的 height:100% —— 它在父容器没有确定高度时会静默失效,
|
|
1628
|
+
* 导致整个面板被内容撑开、列表滚不动、进度条被顶到最底下。
|
|
1629
|
+
* 这里直接量父容器的像素高度写死,最可靠。
|
|
1630
|
+
*/
|
|
1631
|
+
var ideRootRef = React.useRef(null)
|
|
1632
|
+
var ideHeightState = React.useState(0)
|
|
1633
|
+
var ideHeight = ideHeightState[0]
|
|
1634
|
+
var setIdeHeight = ideHeightState[1]
|
|
1635
|
+
|
|
1636
|
+
/**
|
|
1637
|
+
* 右侧列表的列宽(px)。
|
|
1638
|
+
*
|
|
1639
|
+
* 名称列是弹性的(占剩下的空间),大小 / 时间两列固定。
|
|
1640
|
+
* 列头里「大小」左右各有竖线,都能拖:拖左线改大小列宽,拖右线改时间列宽。
|
|
1641
|
+
* 下限见 COL_MIN_*,上限保证左边(名称列)至少还剩 NAME_MIN 宽 ——
|
|
1642
|
+
* 否则能把某一列拖到只剩一条线。
|
|
1643
|
+
*/
|
|
1644
|
+
var COL_SIZE_DEFAULT = 84
|
|
1645
|
+
var COL_TIME_DEFAULT = 132
|
|
1646
|
+
var COL_SIZE_MIN = 64
|
|
1647
|
+
var COL_TIME_MIN = 88
|
|
1648
|
+
var COL_NAME_MIN = 120
|
|
1649
|
+
var colSizeState = React.useState(COL_SIZE_DEFAULT)
|
|
1650
|
+
var colSize = colSizeState[0]
|
|
1651
|
+
var setColSize = colSizeState[1]
|
|
1652
|
+
var colTimeState = React.useState(COL_TIME_DEFAULT)
|
|
1653
|
+
var colTime = colTimeState[0]
|
|
1654
|
+
var setColTime = colTimeState[1]
|
|
1655
|
+
/** 列头容器,用来量总宽算拖动边界。 */
|
|
1656
|
+
var colsRef = React.useRef(null)
|
|
1657
|
+
/**
|
|
1658
|
+
* 右侧主栏是否太窄(决定要不要把「修改时间」列藏掉)。
|
|
1659
|
+
*
|
|
1660
|
+
* 必须按**面板自身宽度**判断,不能用 @media —— 对话区可能只占窗口一小半,
|
|
1661
|
+
* 窗口很宽的时候媒体查询不会触发,三列就会挤在一起。
|
|
1662
|
+
*/
|
|
1663
|
+
var mainNarrowState = React.useState(false)
|
|
1664
|
+
var mainNarrow = mainNarrowState[0]
|
|
1665
|
+
var setMainNarrow = mainNarrowState[1]
|
|
1666
|
+
var mainRef = React.useRef(null)
|
|
1667
|
+
/**
|
|
1668
|
+
* 列表滚动条的宽度(px)。
|
|
1669
|
+
*
|
|
1670
|
+
* 列头在滚动容器**外面**,行在**里面** —— 列表一旦出现滚动条,
|
|
1671
|
+
* 行的可用宽度就比列头少十几像素,名称列又是弹性的,于是两个固定列
|
|
1672
|
+
* 整体左移,竖线和表头错位。这里量出实际滚动条宽度补给列头。
|
|
1673
|
+
*/
|
|
1674
|
+
var scrollbarWState = React.useState(0)
|
|
1675
|
+
var scrollbarW = scrollbarWState[0]
|
|
1676
|
+
var setScrollbarW = scrollbarWState[1]
|
|
1677
|
+
var colResizingState = React.useState('')
|
|
1678
|
+
var colResizing = colResizingState[0]
|
|
1679
|
+
var setColResizing = colResizingState[1]
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* 拖列头的竖线调整列宽。
|
|
1683
|
+
*
|
|
1684
|
+
* which: 'size' = 「大小」左侧那条线(拖它改大小列宽)
|
|
1685
|
+
* 'time' = 「大小」右侧那条线(拖它改时间列宽)
|
|
1686
|
+
*
|
|
1687
|
+
* 边界:每列都有自己的下限,并且永远给名称列留够 COL_NAME_MIN ——
|
|
1688
|
+
* 这样怎么拖都不会出现「某一列被压成一条线」。
|
|
1689
|
+
*/
|
|
1690
|
+
function beginColResize(which, event) {
|
|
1691
|
+
if (event && typeof event.preventDefault === 'function') event.preventDefault()
|
|
1692
|
+
if (event && typeof event.stopPropagation === 'function') event.stopPropagation()
|
|
1693
|
+
var startX = event && event.clientX ? event.clientX : 0
|
|
1694
|
+
var startSize = colSize
|
|
1695
|
+
var startTime = colTime
|
|
1696
|
+
var total = 0
|
|
1697
|
+
var node = colsRef.current
|
|
1698
|
+
if (node) {
|
|
1699
|
+
total = node.clientWidth || 0
|
|
1700
|
+
}
|
|
1701
|
+
if (!total) total = 600
|
|
1702
|
+
setColResizing(which)
|
|
1703
|
+
if (typeof document !== 'undefined' && document.body) {
|
|
1704
|
+
document.body.style.cursor = 'col-resize'
|
|
1705
|
+
document.body.style.userSelect = 'none'
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function onMove(moveEvent) {
|
|
1709
|
+
if (moveEvent && typeof moveEvent.preventDefault === 'function') moveEvent.preventDefault()
|
|
1710
|
+
var delta = (moveEvent && moveEvent.clientX ? moveEvent.clientX : startX) - startX
|
|
1711
|
+
if (which === 'size') {
|
|
1712
|
+
// 往右拖 → 大小列变窄
|
|
1713
|
+
var next = startSize - delta
|
|
1714
|
+
var maxSize = total - COL_NAME_MIN - startTime
|
|
1715
|
+
if (maxSize < COL_SIZE_MIN) maxSize = COL_SIZE_MIN
|
|
1716
|
+
next = Math.max(COL_SIZE_MIN, Math.min(maxSize, next))
|
|
1717
|
+
setColSize(Math.round(next))
|
|
1718
|
+
} else {
|
|
1719
|
+
var nextTime = startTime - delta
|
|
1720
|
+
var maxTime = total - COL_NAME_MIN - startSize
|
|
1721
|
+
if (maxTime < COL_TIME_MIN) maxTime = COL_TIME_MIN
|
|
1722
|
+
nextTime = Math.max(COL_TIME_MIN, Math.min(maxTime, nextTime))
|
|
1723
|
+
setColTime(Math.round(nextTime))
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
function onUp() {
|
|
1728
|
+
setColResizing('')
|
|
1729
|
+
if (typeof document !== 'undefined' && document.body) {
|
|
1730
|
+
document.body.style.cursor = ''
|
|
1731
|
+
document.body.style.userSelect = ''
|
|
1732
|
+
}
|
|
1733
|
+
if (typeof document !== 'undefined') {
|
|
1734
|
+
document.removeEventListener('mousemove', onMove, true)
|
|
1735
|
+
document.removeEventListener('mouseup', onUp, true)
|
|
1736
|
+
}
|
|
1737
|
+
if (typeof window !== 'undefined') {
|
|
1738
|
+
window.removeEventListener('mouseup', onUp, true)
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
if (typeof document !== 'undefined') {
|
|
1743
|
+
document.addEventListener('mousemove', onMove, true)
|
|
1744
|
+
document.addEventListener('mouseup', onUp, true)
|
|
1745
|
+
}
|
|
1746
|
+
if (typeof window !== 'undefined') {
|
|
1747
|
+
window.addEventListener('mouseup', onUp, true)
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/** 双击竖线:两列都回到默认宽度。 */
|
|
1752
|
+
function resetColWidth() {
|
|
1753
|
+
setColSize(COL_SIZE_DEFAULT)
|
|
1754
|
+
setColTime(COL_TIME_DEFAULT)
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/** 量主栏宽度,窄到放不下三列时收起「修改时间」列。 */
|
|
1758
|
+
React.useEffect(function () {
|
|
1759
|
+
if (!authorized) return
|
|
1760
|
+
var node = mainRef.current
|
|
1761
|
+
if (!node || typeof window === 'undefined') return
|
|
1762
|
+
|
|
1763
|
+
function measureWidth() {
|
|
1764
|
+
var el = mainRef.current
|
|
1765
|
+
if (!el) return
|
|
1766
|
+
var w = el.clientWidth || 0
|
|
1767
|
+
var narrow = w > 0 && w < 620
|
|
1768
|
+
setMainNarrow(function (previous) {
|
|
1769
|
+
return previous === narrow ? previous : narrow
|
|
1770
|
+
})
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
measureWidth()
|
|
1774
|
+
var observer = null
|
|
1775
|
+
try {
|
|
1776
|
+
if (typeof ResizeObserver === 'function') {
|
|
1777
|
+
observer = new ResizeObserver(measureWidth)
|
|
1778
|
+
observer.observe(node)
|
|
1779
|
+
}
|
|
1780
|
+
} catch (failure) {
|
|
1781
|
+
observer = null
|
|
1782
|
+
}
|
|
1783
|
+
window.addEventListener('resize', measureWidth)
|
|
1784
|
+
var timers = [80, 300, 900].map(function (delay) {
|
|
1785
|
+
return setTimeout(measureWidth, delay)
|
|
1786
|
+
})
|
|
1787
|
+
return function () {
|
|
1788
|
+
for (var i = 0; i < timers.length; i += 1) clearTimeout(timers[i])
|
|
1789
|
+
window.removeEventListener('resize', measureWidth)
|
|
1790
|
+
if (observer) {
|
|
1791
|
+
try {
|
|
1792
|
+
observer.disconnect()
|
|
1793
|
+
} catch (failure) {
|
|
1794
|
+
/* 已断开 */
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
}, [authorized])
|
|
1799
|
+
|
|
1800
|
+
/** 量滚动条宽度,补偿给列头。 */
|
|
1801
|
+
React.useEffect(function () {
|
|
1802
|
+
if (!authorized) return
|
|
1803
|
+
if (typeof window === 'undefined') return
|
|
1804
|
+
|
|
1805
|
+
function measureScrollbar() {
|
|
1806
|
+
var el = listScrollRef.current
|
|
1807
|
+
if (!el) return
|
|
1808
|
+
var w = (el.offsetWidth || 0) - (el.clientWidth || 0)
|
|
1809
|
+
if (w < 0) w = 0
|
|
1810
|
+
if (w > 40) w = 40
|
|
1811
|
+
setScrollbarW(function (previous) {
|
|
1812
|
+
return previous === w ? previous : w
|
|
1813
|
+
})
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
measureScrollbar()
|
|
1817
|
+
var observer = null
|
|
1818
|
+
try {
|
|
1819
|
+
if (typeof ResizeObserver === 'function' && scrollRef.current) {
|
|
1820
|
+
observer = new ResizeObserver(measureScrollbar)
|
|
1821
|
+
observer.observe(listScrollRef.current)
|
|
1822
|
+
}
|
|
1823
|
+
} catch (failure) {
|
|
1824
|
+
observer = null
|
|
1825
|
+
}
|
|
1826
|
+
window.addEventListener('resize', measureScrollbar)
|
|
1827
|
+
var timers = [80, 300, 900].map(function (delay) {
|
|
1828
|
+
return setTimeout(measureScrollbar, delay)
|
|
1829
|
+
})
|
|
1830
|
+
return function () {
|
|
1831
|
+
for (var i = 0; i < timers.length; i += 1) clearTimeout(timers[i])
|
|
1832
|
+
window.removeEventListener('resize', measureScrollbar)
|
|
1833
|
+
if (observer) {
|
|
1834
|
+
try {
|
|
1835
|
+
observer.disconnect()
|
|
1836
|
+
} catch (failure) {
|
|
1837
|
+
/* 已断开 */
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}, [authorized, items, nextCursor])
|
|
1842
|
+
|
|
1109
1843
|
// 请求序号:快速切目录时把旧响应丢掉。
|
|
1110
1844
|
var seqRef = React.useRef(0)
|
|
1111
1845
|
var pollTimerRef = React.useRef(null)
|
|
@@ -1238,9 +1972,62 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
1238
1972
|
})
|
|
1239
1973
|
}
|
|
1240
1974
|
|
|
1975
|
+
/**
|
|
1976
|
+
* 滚到底部就接上下一批。
|
|
1977
|
+
*
|
|
1978
|
+
* 判断「到底」用的是「滚动位置 + 可视高度 >= 内容高度 - 阈值」,
|
|
1979
|
+
* 留 240px 提前量,滚动手感更连续(不会先看到空白再补上)。
|
|
1980
|
+
*/
|
|
1981
|
+
function maybeLoadMore() {
|
|
1982
|
+
if (loadMoreBusyRef.current) return
|
|
1983
|
+
if (!nextCursor) return
|
|
1984
|
+
if (listLoading || busy) return
|
|
1985
|
+
if (searchOpen && searchKeyword.trim() !== '') return
|
|
1986
|
+
var el = listScrollRef.current
|
|
1987
|
+
if (!el) return
|
|
1988
|
+
var remaining = el.scrollHeight - el.scrollTop - el.clientHeight
|
|
1989
|
+
if (remaining > LOAD_MORE_THRESHOLD) return
|
|
1990
|
+
loadMoreBusyRef.current = true
|
|
1991
|
+
var cursor = nextCursor
|
|
1992
|
+
/** 兜底:万一 promise 链路没走完,40 秒后强制放行,别永久锁死。 */
|
|
1993
|
+
var releaseTimer = setTimeout(function () {
|
|
1994
|
+
loadMoreBusyRef.current = false
|
|
1995
|
+
}, 40000)
|
|
1996
|
+
fetchListing(cursor, true)
|
|
1997
|
+
.catch(function () {
|
|
1998
|
+
/* fetchListing 内部已经处理错误展示 */
|
|
1999
|
+
})
|
|
2000
|
+
.then(function () {
|
|
2001
|
+
clearTimeout(releaseTimer)
|
|
2002
|
+
loadMoreBusyRef.current = false
|
|
2003
|
+
})
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
/**
|
|
2007
|
+
* 内容不够一屏时自动接着拉。
|
|
2008
|
+
*
|
|
2009
|
+
* 场景:目录里只有 3 项,但还有下一批 —— 这时列表根本滚不动,
|
|
2010
|
+
* 用户看不到也滚不到底部,就永远触发不了 maybeLoadMore。
|
|
2011
|
+
* 所以每次列表变化后量一下:没填满就再要一批。
|
|
2012
|
+
*
|
|
2013
|
+
* 用 requestAnimationFrame 让浏览器先完成布局,量到的 scrollHeight 才是真的。
|
|
2014
|
+
*/
|
|
2015
|
+
React.useEffect(function () {
|
|
2016
|
+
if (!nextCursor || listLoading) return
|
|
2017
|
+
var raf =
|
|
2018
|
+
typeof requestAnimationFrame === 'function'
|
|
2019
|
+
? requestAnimationFrame
|
|
2020
|
+
: function (fn) { return setTimeout(fn, 16) }
|
|
2021
|
+
raf(function () {
|
|
2022
|
+
var el = listScrollRef.current
|
|
2023
|
+
if (!el) return
|
|
2024
|
+
if (listLoading || !nextCursor) return
|
|
2025
|
+
if (el.scrollHeight - el.clientHeight <= 4) maybeLoadMore()
|
|
2026
|
+
})
|
|
2027
|
+
}, [items, nextCursor, listLoading])
|
|
2028
|
+
|
|
1241
2029
|
function reload() {
|
|
1242
|
-
// 手动刷新:先清缓存,再强制拉一次
|
|
1243
|
-
clearListingCache()
|
|
2030
|
+
// 手动刷新:先清缓存,再强制拉一次 clearListingCache()
|
|
1244
2031
|
return fetchListing('', false)
|
|
1245
2032
|
}
|
|
1246
2033
|
|
|
@@ -1479,6 +2266,9 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
1479
2266
|
.then(function () {
|
|
1480
2267
|
setBusy(false)
|
|
1481
2268
|
loadStatus()
|
|
2269
|
+
// 增删改之后,「哪个文件夹还有下一级」的结论也可能变了 ——
|
|
2270
|
+
// 不清掉的话,删空的目录会一直挂着箭头。
|
|
2271
|
+
invalidateTreeKnowledge()
|
|
1482
2272
|
return reload()
|
|
1483
2273
|
})
|
|
1484
2274
|
.catch(function (failure) {
|
|
@@ -1628,130 +2418,890 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
1628
2418
|
setStack([])
|
|
1629
2419
|
}
|
|
1630
2420
|
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
2421
|
+
// ================================================================
|
|
2422
|
+
// 0.3.0 目录树
|
|
2423
|
+
// ================================================================
|
|
1634
2424
|
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
2425
|
+
/**
|
|
2426
|
+
* 🔴 网盘接口**不返回「这个文件夹有几个子项」**(folder 的 size 恒为 null,
|
|
2427
|
+
* 也没有 fileCount / childCount 之类字段,实测确认过)。
|
|
2428
|
+
*
|
|
2429
|
+
* 所以「有没有下级」只能问一次。这里做的是:
|
|
2430
|
+
* · 展开父节点后,并发探测它的每个子文件夹(pageSize=1,最轻);
|
|
2431
|
+
* · 结果进内存缓存,同一次会话里不重复问;
|
|
2432
|
+
* · 探测到「有」才给箭头 —— 所以永远不会出现「先有箭头、点一下没了」。
|
|
2433
|
+
*/
|
|
2434
|
+
/**
|
|
2435
|
+
* 「这个文件夹有没有下级」的缓存。
|
|
2436
|
+
*
|
|
2437
|
+
* ⚠️ 必须有有效期:以前是永久缓存,结果用户把某个目录的子文件夹删光之后,
|
|
2438
|
+
* 箭头还一直挂着(缓存里还是「有」),看着像坏了。
|
|
2439
|
+
* 现在 45 秒过期,并且任何增删改都会整体作废(invalidateTreeKnowledge)。
|
|
2440
|
+
*/
|
|
2441
|
+
var kidProbeCache = Object.create(null)
|
|
2442
|
+
/** 正在探测中的 folderId,避免同一目录被并发探测两次。 */
|
|
2443
|
+
var kidProbeInflight = Object.create(null)
|
|
2444
|
+
var KID_PROBE_TTL_MS = 45 * 1000
|
|
1646
2445
|
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
2446
|
+
/**
|
|
2447
|
+
* 把「树的结构知识」全部作废:探测缓存、展开状态、已加载的子节点。
|
|
2448
|
+
* 增删改、手动刷新之后都该调一次 —— 目录内容变了,树上的箭头和层级就得重算。
|
|
2449
|
+
*
|
|
2450
|
+
* 保留根节点展开(用户正看着它),只是让它重新拉一次子目录。
|
|
2451
|
+
*/
|
|
2452
|
+
function invalidateTreeKnowledge() {
|
|
2453
|
+
kidProbeCache = Object.create(null)
|
|
2454
|
+
kidProbeInflight = Object.create(null)
|
|
2455
|
+
setTreeRoot({
|
|
2456
|
+
id: '/',
|
|
2457
|
+
name: '/',
|
|
2458
|
+
open: true,
|
|
2459
|
+
loading: false,
|
|
2460
|
+
loaded: false,
|
|
2461
|
+
children: [],
|
|
1655
2462
|
})
|
|
1656
2463
|
}
|
|
1657
2464
|
|
|
1658
|
-
// ------------------------------------------------------ 复制路径
|
|
1659
|
-
/** 当前所在目录的完整路径,例如 "/windows/实验"。 */
|
|
1660
|
-
function currentDirPath() {
|
|
1661
|
-
return '/' + stack.map(function (s) { return s.name }).join('/')
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
2465
|
/**
|
|
1665
|
-
*
|
|
1666
|
-
*
|
|
1667
|
-
*
|
|
2466
|
+
* 拿「这个目录里有没有**子文件夹**」。
|
|
2467
|
+
*
|
|
2468
|
+
* 🔴 这里踩过坑:一开始判的是 `items.length > 0`(这个目录里有没有任何东西),
|
|
2469
|
+
* 结果「手机视频」这种只放了一堆视频文件、没有子文件夹的目录,
|
|
2470
|
+
* 也被判成「有下级」→ 树里挂了个箭头,但点开什么都没有。
|
|
2471
|
+
*
|
|
2472
|
+
* 正确判据是 **至少有一个 type === 'folder'**。
|
|
2473
|
+
* 麻烦在于列表按修改时间排序、文件和文件夹混在一起,
|
|
2474
|
+
* 第一页可能全是文件 —— 所以「这一页没有文件夹」还得往后翻几页确认,
|
|
2475
|
+
* 最多翻 3 页(30 项);一旦翻到文件夹就立刻返回 true(大多数目录第一页就命中)。
|
|
1668
2476
|
*/
|
|
1669
|
-
function
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
2477
|
+
function probeKid(folderId) {
|
|
2478
|
+
var cached = kidProbeCache[folderId]
|
|
2479
|
+
if (cached !== undefined && Date.now() - cached.at < KID_PROBE_TTL_MS) {
|
|
2480
|
+
return Promise.resolve(cached.has)
|
|
2481
|
+
}
|
|
2482
|
+
if (kidProbeInflight[folderId]) return kidProbeInflight[folderId]
|
|
2483
|
+
|
|
2484
|
+
var MAX_PROBE_PAGES = 3
|
|
2485
|
+
function scan(cursor, page) {
|
|
2486
|
+
return api.list(folderId, cursor, 10).then(function (doc) {
|
|
2487
|
+
var items = doc && Array.isArray(doc.items) ? doc.items : []
|
|
2488
|
+
for (var i = 0; i < items.length; i += 1) {
|
|
2489
|
+
if (items[i] && items[i].type === 'folder') return true
|
|
2490
|
+
}
|
|
2491
|
+
var next = doc && doc.nextCursor ? String(doc.nextCursor) : ''
|
|
2492
|
+
if (next && page < MAX_PROBE_PAGES) return scan(next, page + 1)
|
|
2493
|
+
// 翻完了都没有文件夹 → 确实没有下级
|
|
2494
|
+
return false
|
|
2495
|
+
})
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
var p = scan('', 1)
|
|
2499
|
+
.then(function (has) {
|
|
2500
|
+
kidProbeCache[folderId] = { has: has, at: Date.now() }
|
|
2501
|
+
delete kidProbeInflight[folderId]
|
|
2502
|
+
return has
|
|
2503
|
+
})
|
|
2504
|
+
.catch(function () {
|
|
2505
|
+
// 探测失败就先当作「未知」,不要缓存成 false(否则会永久少一个箭头)
|
|
2506
|
+
delete kidProbeInflight[folderId]
|
|
2507
|
+
return null
|
|
2508
|
+
})
|
|
2509
|
+
kidProbeInflight[folderId] = p
|
|
2510
|
+
return p
|
|
1673
2511
|
}
|
|
1674
2512
|
|
|
1675
|
-
/**
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
2513
|
+
/**
|
|
2514
|
+
* 批量探测一组子文件夹,探到一个就更新一个。
|
|
2515
|
+
*
|
|
2516
|
+
* 全并发:树第一页最多 10 个文件夹,10 个请求同时发 ≈ 一次网络往返
|
|
2517
|
+
* (实测单次约 200ms,原来并发 4 要 480ms,现在一次就都回来了)。
|
|
2518
|
+
* 探到一个立刻更新一个,所以用户看到的是箭头「一个个跳出来」,
|
|
2519
|
+
* 不用等全部探完。
|
|
2520
|
+
*/
|
|
2521
|
+
function probeTreeKids(parentId, folders) {
|
|
2522
|
+
var now = Date.now()
|
|
2523
|
+
var targets = folders.slice(0, 20).filter(function (folder) {
|
|
2524
|
+
if (!folder || !folder.id) return false
|
|
2525
|
+
var cached = kidProbeCache[folder.id]
|
|
2526
|
+
// 没探过、或者缓存过期了,都要重新探
|
|
2527
|
+
return cached === undefined || now - cached.at >= KID_PROBE_TTL_MS
|
|
2528
|
+
})
|
|
2529
|
+
if (!targets.length) return
|
|
2530
|
+
function applyOne(folderId, has) {
|
|
2531
|
+
if (has === null || has === undefined) return
|
|
2532
|
+
setTreeRoot(function (current) {
|
|
2533
|
+
return replaceTreeNode(current, parentId, function (node) {
|
|
2534
|
+
var kids = node.children || []
|
|
2535
|
+
var changed = false
|
|
2536
|
+
var next = kids.map(function (kid) {
|
|
2537
|
+
if (kid.id !== folderId) return kid
|
|
2538
|
+
changed = true
|
|
2539
|
+
return Object.assign({}, kid, { hasKids: has })
|
|
2540
|
+
})
|
|
2541
|
+
if (!changed) return node
|
|
2542
|
+
return Object.assign({}, node, { children: next })
|
|
1683
2543
|
})
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
2544
|
+
})
|
|
2545
|
+
}
|
|
2546
|
+
// 全部并发,不再排队
|
|
2547
|
+
var tasks = targets.map(function (folder) {
|
|
2548
|
+
return probeKid(folder.id).then(function (has) {
|
|
2549
|
+
applyOne(folder.id, has)
|
|
2550
|
+
})
|
|
2551
|
+
})
|
|
2552
|
+
Promise.all(tasks).catch(function () {
|
|
2553
|
+
/* 探测失败不影响树本身 */
|
|
1688
2554
|
})
|
|
1689
2555
|
}
|
|
1690
2556
|
|
|
1691
|
-
|
|
2557
|
+
/**
|
|
2558
|
+
* 拖动「左树 / 右列表」中间那条分隔线来调宽度。
|
|
2559
|
+
*
|
|
2560
|
+
* 范围按用户要求卡死:
|
|
2561
|
+
* · 最左:让左树刚好还能看清一级目录名(≈ 130px);
|
|
2562
|
+
* · 最右:不超过整个面板的一半 —— 再往右列表就没地方了。
|
|
2563
|
+
* 拖的时候给 body 加一个全局 cursor,避免鼠标移出分隔线后光标乱跳。
|
|
2564
|
+
*/
|
|
2565
|
+
var TREE_MIN_WIDTH = 130
|
|
2566
|
+
|
|
2567
|
+
function beginTreeResize(event) {
|
|
2568
|
+
// 只响应左键
|
|
2569
|
+
if (event.button !== undefined && event.button !== 0) return
|
|
2570
|
+
event.preventDefault()
|
|
2571
|
+
event.stopPropagation()
|
|
2572
|
+
var handle = event.currentTarget
|
|
2573
|
+
var treeEl = handle && handle.parentNode ? handle.parentNode.querySelector('.mc-exTree') : null
|
|
2574
|
+
var startX = event.clientX
|
|
2575
|
+
var startWidth = treeEl ? treeEl.getBoundingClientRect().width : 200
|
|
2576
|
+
setResizing(true)
|
|
2577
|
+
|
|
2578
|
+
function maxWidth() {
|
|
2579
|
+
// 「页面中间」= 面板宽度的一半(面板宽度取 .mc-exBody 的宽度)
|
|
2580
|
+
var body = treeEl && treeEl.parentNode ? treeEl.parentNode : null
|
|
2581
|
+
var total = body ? body.getBoundingClientRect().width : (typeof window !== 'undefined' ? window.innerWidth : 1200)
|
|
2582
|
+
return Math.max(TREE_MIN_WIDTH + 40, Math.floor(total / 2))
|
|
2583
|
+
}
|
|
2584
|
+
|
|
2585
|
+
function onMove(moveEvent) {
|
|
2586
|
+
// 拖动时吃掉默认行为:否则浏览器可能把它当文本选择/页面拖动
|
|
2587
|
+
if (typeof moveEvent.preventDefault === 'function') moveEvent.preventDefault()
|
|
2588
|
+
if (typeof moveEvent.stopPropagation === 'function') moveEvent.stopPropagation()
|
|
2589
|
+
var delta = moveEvent.clientX - startX
|
|
2590
|
+
var next = startWidth + delta
|
|
2591
|
+
var limit = maxWidth()
|
|
2592
|
+
if (next < TREE_MIN_WIDTH) next = TREE_MIN_WIDTH
|
|
2593
|
+
if (next > limit) next = limit
|
|
2594
|
+
setTreeWidth(Math.round(next))
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
function onUp() {
|
|
2598
|
+
setResizing(false)
|
|
2599
|
+
try {
|
|
2600
|
+
if (typeof document !== 'undefined') {
|
|
2601
|
+
document.removeEventListener('mousemove', onMove, true)
|
|
2602
|
+
document.removeEventListener('mouseup', onUp, true)
|
|
2603
|
+
document.body.style.cursor = ''
|
|
2604
|
+
document.body.style.userSelect = ''
|
|
2605
|
+
}
|
|
2606
|
+
} catch (failure) {
|
|
2607
|
+
/* 清理失败无所谓 */
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
|
|
1692
2611
|
try {
|
|
1693
|
-
if (typeof document
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
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
|
|
2612
|
+
if (typeof document !== 'undefined') {
|
|
2613
|
+
document.addEventListener('mousemove', onMove, true)
|
|
2614
|
+
document.addEventListener('mouseup', onUp, true)
|
|
2615
|
+
document.body.style.cursor = 'col-resize'
|
|
2616
|
+
document.body.style.userSelect = 'none'
|
|
1707
2617
|
}
|
|
1708
|
-
var ok = document.execCommand('copy')
|
|
1709
|
-
host.removeChild(area)
|
|
1710
|
-
return ok === true
|
|
1711
2618
|
} catch (failure) {
|
|
1712
|
-
|
|
1713
|
-
return false
|
|
2619
|
+
onUp()
|
|
1714
2620
|
}
|
|
1715
2621
|
}
|
|
1716
2622
|
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
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
|
-
})
|
|
2623
|
+
/** 双击分隔线 = 恢复自适应宽度。 */
|
|
2624
|
+
function resetTreeWidth() {
|
|
2625
|
+
setTreeWidth(null)
|
|
1727
2626
|
}
|
|
1728
2627
|
|
|
1729
|
-
function
|
|
1730
|
-
|
|
1731
|
-
|
|
2628
|
+
/** 在树里按 id 找节点,返回 { node, parentList, index }。 */ function findTreeNode(id, node, parentList, index) {
|
|
2629
|
+
node = node || treeRoot
|
|
2630
|
+
parentList = parentList || null
|
|
2631
|
+
index = index === undefined ? -1 : index
|
|
2632
|
+
if (node.id === id) return { node: node, parentList: parentList, index: index }
|
|
2633
|
+
var kids = node.children || []
|
|
2634
|
+
for (var i = 0; i < kids.length; i += 1) {
|
|
2635
|
+
var hit = findTreeNode(id, kids[i], kids, i)
|
|
2636
|
+
if (hit) return hit
|
|
1732
2637
|
}
|
|
1733
|
-
|
|
1734
|
-
|
|
2638
|
+
return null
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
/** 不可变地替换树里某个节点(React 要新对象才会重渲染)。 */
|
|
2642
|
+
function replaceTreeNode(root, id, updater) {
|
|
2643
|
+
if (root.id === id) return updater(root)
|
|
2644
|
+
var kids = root.children || []
|
|
2645
|
+
var changed = false
|
|
2646
|
+
var nextKids = kids.map(function (kid) {
|
|
2647
|
+
var next = replaceTreeNode(kid, id, updater)
|
|
2648
|
+
if (next !== kid) changed = true
|
|
2649
|
+
return next
|
|
1735
2650
|
})
|
|
2651
|
+
if (!changed) return root
|
|
2652
|
+
var copy = Object.assign({}, root)
|
|
2653
|
+
copy.children = nextKids
|
|
2654
|
+
return copy
|
|
1736
2655
|
}
|
|
1737
2656
|
|
|
1738
|
-
// ------------------------------------------------------ 转移(移动)到别的文件夹
|
|
1739
2657
|
/**
|
|
1740
|
-
*
|
|
1741
|
-
*
|
|
1742
|
-
*
|
|
2658
|
+
* 加载某个树节点的子目录。
|
|
2659
|
+
*
|
|
2660
|
+
* 只取**文件夹**,文件不进树(资源管理器左树也不列文件)。
|
|
2661
|
+
* 一页 10 条的限制还在,这里循环翻页直到拿完 —— 树节点通常不多,
|
|
2662
|
+
* 但如果真遇到几百个子目录,最多翻 8 页就停,避免卡死。
|
|
1743
2663
|
*/
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
2664
|
+
/**
|
|
2665
|
+
* 加载某个树节点的子目录。
|
|
2666
|
+
*
|
|
2667
|
+
* ⚠️ 性能要点:**只取第一页**。
|
|
2668
|
+
* 以前是串行翻页最多 8 页,按实测 pageSize=10 一次约 1.3 秒算,
|
|
2669
|
+
* 一个放 30 个子文件夹的目录要翻 3 页 ≈ 4 秒 —— 点开树要等这么久,
|
|
2670
|
+
* 这就是「目录打开慢」的主要来源。
|
|
2671
|
+
*
|
|
2672
|
+
* 树只是导航,第一页(10 项)已经够用;真还有更多,末尾会多出一个
|
|
2673
|
+
* 「继续加载」的伪节点,用户点了再接下一页。右侧列表才是看全量的地方。
|
|
2674
|
+
*/
|
|
2675
|
+
function loadTreeChildren(id, cursor) {
|
|
2676
|
+
var hit = findTreeNode(id)
|
|
2677
|
+
if (!hit || hit.node.loading) return
|
|
2678
|
+
var appending = !!cursor
|
|
2679
|
+
setTreeRoot(function (current) {
|
|
2680
|
+
return replaceTreeNode(current, id, function (node) {
|
|
2681
|
+
return Object.assign({}, node, { loading: true })
|
|
2682
|
+
})
|
|
2683
|
+
})
|
|
2684
|
+
api.list(id, cursor || '')
|
|
2685
|
+
.then(function (doc) {
|
|
2686
|
+
if (!doc || doc.ok === false) throw new Error((doc && doc.error) || 'load failed')
|
|
2687
|
+
var rows = Array.isArray(doc.items) ? doc.items : []
|
|
2688
|
+
var folders = rows.filter(function (row) {
|
|
2689
|
+
return row.type === 'folder'
|
|
2690
|
+
})
|
|
2691
|
+
var more = doc.nextCursor ? String(doc.nextCursor) : ''
|
|
2692
|
+
// 空目录:把 open 收回 false,并且记下「确实没有下级」,
|
|
2693
|
+
// 这样用户点了一下之后箭头不会留着、也不会再显示。
|
|
2694
|
+
var isEmpty = folders.length === 0 && !more
|
|
2695
|
+
if (isEmpty) kidProbeCache[id] = { has: false, at: Date.now() }
|
|
2696
|
+
setTreeRoot(function (current) {
|
|
2697
|
+
return replaceTreeNode(current, id, function (node) {
|
|
2698
|
+
var previous = node.children || []
|
|
2699
|
+
var mapped = folders.map(function (folder) {
|
|
2700
|
+
// 已经展开过的节点保留展开状态与已缓存的子节点
|
|
2701
|
+
var old = previous.filter(function (kid) {
|
|
2702
|
+
return kid.id === folder.id
|
|
2703
|
+
})[0]
|
|
2704
|
+
return {
|
|
2705
|
+
id: folder.id,
|
|
2706
|
+
name: folder.name,
|
|
2707
|
+
open: old ? old.open : false,
|
|
2708
|
+
loading: false,
|
|
2709
|
+
loaded: old ? old.loaded : false,
|
|
2710
|
+
children: old ? old.children : [],
|
|
2711
|
+
// 已知有没有子目录(探测后填),未知就是 null
|
|
2712
|
+
hasKids: old ? old.hasKids : null,
|
|
2713
|
+
}
|
|
2714
|
+
})
|
|
2715
|
+
var nextChildren = appending
|
|
2716
|
+
? previous.concat(mapped)
|
|
2717
|
+
: mapped
|
|
2718
|
+
return Object.assign({}, node, {
|
|
2719
|
+
loading: false,
|
|
2720
|
+
loaded: true,
|
|
2721
|
+
// 空目录:子节点一并清空,别留着上次的
|
|
2722
|
+
children: isEmpty ? [] : nextChildren,
|
|
2723
|
+
// 空目录:收回展开态 + 明确标记没有下级
|
|
2724
|
+
open: isEmpty ? false : node.open,
|
|
2725
|
+
hasKids: isEmpty ? false : node.hasKids,
|
|
2726
|
+
})
|
|
2727
|
+
})
|
|
2728
|
+
})
|
|
2729
|
+
// 子列表到位后,顺手探测「哪些子目录还有下一级」,
|
|
2730
|
+
// 这样箭头从一开始就是准的,不会先显示再消失。
|
|
2731
|
+
if (!appending) probeTreeKids(id, folders)
|
|
2732
|
+
})
|
|
2733
|
+
.catch(function (failure) {
|
|
2734
|
+
logWarn('tree load failed', failure)
|
|
2735
|
+
setTreeRoot(function (current) {
|
|
2736
|
+
return replaceTreeNode(current, id, function (node) {
|
|
2737
|
+
return Object.assign({}, node, { loading: false })
|
|
2738
|
+
})
|
|
2739
|
+
})
|
|
2740
|
+
})
|
|
2741
|
+
}
|
|
2742
|
+
|
|
2743
|
+
/** 点树的箭头:展开 / 收起。第一次展开时去拉子目录。 */
|
|
2744
|
+
function toggleTreeNode(id) {
|
|
2745
|
+
var hit = findTreeNode(id)
|
|
2746
|
+
if (!hit) return
|
|
2747
|
+
var willOpen = !hit.node.open
|
|
2748
|
+
setTreeRoot(function (current) {
|
|
2749
|
+
return replaceTreeNode(current, id, function (node) {
|
|
2750
|
+
return Object.assign({}, node, { open: willOpen })
|
|
2751
|
+
})
|
|
2752
|
+
})
|
|
2753
|
+
if (willOpen && !hit.node.loaded) loadTreeChildren(id)
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
/**
|
|
2757
|
+
* 点树的名字:把右侧切到那个目录。
|
|
2758
|
+
*
|
|
2759
|
+
* 关键:右树的目录栈要和点击的节点对齐 ——
|
|
2760
|
+
* 这样面包屑、右键「上传到当前目录」、新建文件夹都落在对的地方。
|
|
2761
|
+
* 所以先把树上从根到该节点的名字路径算出来,再整体 setStack。
|
|
2762
|
+
*/
|
|
2763
|
+
function selectTreeNode(id) {
|
|
2764
|
+
if (id === '/') {
|
|
2765
|
+
setStack([])
|
|
2766
|
+
setTreeFocusId('/')
|
|
2767
|
+
return
|
|
2768
|
+
}
|
|
2769
|
+
var path = []
|
|
2770
|
+
function walk(node, trail) {
|
|
2771
|
+
if (node.id === id) {
|
|
2772
|
+
path = trail.concat([{ id: node.id, name: node.name }])
|
|
2773
|
+
return true
|
|
2774
|
+
}
|
|
2775
|
+
var kids = node.children || []
|
|
2776
|
+
for (var i = 0; i < kids.length; i += 1) {
|
|
2777
|
+
if (walk(kids[i], trail.concat([{ id: node.id, name: node.name }]))) return true
|
|
2778
|
+
}
|
|
2779
|
+
return false
|
|
2780
|
+
}
|
|
2781
|
+
walk(treeRoot, [])
|
|
2782
|
+
// 去掉根(根是 "/",栈里不存它)
|
|
2783
|
+
var next = path.filter(function (entry) {
|
|
2784
|
+
return entry.id !== '/'
|
|
2785
|
+
})
|
|
2786
|
+
setStack(next)
|
|
2787
|
+
setTreeFocusId(id)
|
|
2788
|
+
// 顺手复核一次「这目录还有没有下级」:
|
|
2789
|
+
// 用户可能刚在别处把它的子项删空/搬走,箭头得跟着变。
|
|
2790
|
+
recheckNodeHasKids(id)
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
/** 重新问一次某个节点「还有没有下级」,并更新树上的箭头。 */
|
|
2794
|
+
function recheckNodeHasKids(id) {
|
|
2795
|
+
if (!id || id === '/') return
|
|
2796
|
+
delete kidProbeCache[id]
|
|
2797
|
+
probeKid(id).then(function (has) {
|
|
2798
|
+
if (has === null || has === undefined) return
|
|
2799
|
+
setTreeRoot(function (current) {
|
|
2800
|
+
return replaceTreeNode(current, id, function (node) {
|
|
2801
|
+
if (node.hasKids === has) return node
|
|
2802
|
+
return Object.assign({}, node, { hasKids: has })
|
|
2803
|
+
})
|
|
2804
|
+
})
|
|
2805
|
+
})
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
/** 右侧切目录时,把左树同步展开到该目录(让高亮看得见)。 */
|
|
2809
|
+
function syncTreeToStack() {
|
|
2810
|
+
setTreeFocusId(parentId)
|
|
2811
|
+
if (parentId === '/') return
|
|
2812
|
+
// 从根开始逐级展开路径上的节点
|
|
2813
|
+
var trail = stack.map(function (entry) {
|
|
2814
|
+
return entry.id
|
|
2815
|
+
})
|
|
2816
|
+
function ensure(index) {
|
|
2817
|
+
if (index >= trail.length) return
|
|
2818
|
+
var id = trail[index]
|
|
2819
|
+
var hit = findTreeNode(id)
|
|
2820
|
+
if (!hit) return
|
|
2821
|
+
if (!hit.node.open) {
|
|
2822
|
+
setTreeRoot(function (current) {
|
|
2823
|
+
return replaceTreeNode(current, id, function (node) {
|
|
2824
|
+
return Object.assign({}, node, { open: true })
|
|
2825
|
+
})
|
|
2826
|
+
})
|
|
2827
|
+
}
|
|
2828
|
+
if (!hit.node.loaded) loadTreeChildren(id)
|
|
2829
|
+
ensure(index + 1)
|
|
2830
|
+
}
|
|
2831
|
+
ensure(0)
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// 目录一变就同步左树(第一层先展开,用户能看到自己点到了哪)
|
|
2835
|
+
React.useEffect(function () {
|
|
2836
|
+
if (!authorized) return
|
|
2837
|
+
syncTreeToStack()
|
|
2838
|
+
// 根节点第一次展开时把它的子目录拉出来
|
|
2839
|
+
if (!treeRoot.loaded && !treeRoot.loading) loadTreeChildren('/')
|
|
2840
|
+
}, [parentId, authorized])
|
|
2841
|
+
|
|
2842
|
+
/**
|
|
2843
|
+
* 量出父容器的真实像素高度,写到根节点上。
|
|
2844
|
+
*
|
|
2845
|
+
* 为什么要这么干:`.mc-root-ide{height:100%}` 要求父元素有**确定高度**,
|
|
2846
|
+
* 而 DSH 的 view 容器不保证给。一旦失效,面板会被内容撑成一条长页面:
|
|
2847
|
+
* · 列表内部的 overflow:auto 永远不触发(它没被压缩过);
|
|
2848
|
+
* · 底部进度条被推到内容最底下,得拖外层滚动条到底才看得到。
|
|
2849
|
+
* 这条现象和用户反馈完全一致,所以改用量出来的像素值。
|
|
2850
|
+
*
|
|
2851
|
+
* 同时用 ResizeObserver 跟随侧栏折叠、窗口缩放、对话区高度变化。
|
|
2852
|
+
*/
|
|
2853
|
+
React.useEffect(function () {
|
|
2854
|
+
if (!authorized) return
|
|
2855
|
+
var el = ideRootRef.current
|
|
2856
|
+
if (!el || typeof window === 'undefined') return
|
|
2857
|
+
|
|
2858
|
+
/**
|
|
2859
|
+
* 量高度要跨浏览器稳妥,所以按「由近到远」分四级兜底:
|
|
2860
|
+
*
|
|
2861
|
+
* 1. 直接父容器的 clientHeight(最准,含 padding 不含 border)
|
|
2862
|
+
* 2. 父容器的 getBoundingClientRect().height(clientHeight 报 0 时用,
|
|
2863
|
+
* 某些 WebView / 旧 Safari 上 clientHeight 会晚一拍才有值)
|
|
2864
|
+
* 3. 再往上一层的同样两种量法(父容器是 display:contents 或零高包裹时)
|
|
2865
|
+
* 4. 实在量不到:用视觉视口估(移动端地址栏收起/展开时
|
|
2866
|
+
* innerHeight 会变,visualViewport.height 更贴近真实可视区)
|
|
2867
|
+
*
|
|
2868
|
+
* 高度只用来「撑满」,量小一点只是留白,量大才会出滚动条;
|
|
2869
|
+
* 所以宁可偏保守。
|
|
2870
|
+
*/
|
|
2871
|
+
function readHeight(node) {
|
|
2872
|
+
if (!node) return 0
|
|
2873
|
+
var h = 0
|
|
2874
|
+
try {
|
|
2875
|
+
h = node.clientHeight || 0
|
|
2876
|
+
} catch (failure) {
|
|
2877
|
+
h = 0
|
|
2878
|
+
}
|
|
2879
|
+
if (h <= 0) {
|
|
2880
|
+
try {
|
|
2881
|
+
var rect = node.getBoundingClientRect()
|
|
2882
|
+
h = rect && rect.height ? Math.round(rect.height) : 0
|
|
2883
|
+
} catch (failure) {
|
|
2884
|
+
h = 0
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
return h > 0 ? h : 0
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
function viewportHint() {
|
|
2891
|
+
// 移动端地址栏会伸缩:visualViewport 比 innerHeight 准
|
|
2892
|
+
var vv = 0
|
|
2893
|
+
try {
|
|
2894
|
+
if (window.visualViewport && window.visualViewport.height) {
|
|
2895
|
+
vv = Math.round(window.visualViewport.height)
|
|
2896
|
+
}
|
|
2897
|
+
} catch (failure) {
|
|
2898
|
+
vv = 0
|
|
2899
|
+
}
|
|
2900
|
+
if (vv <= 0) vv = Math.round(window.innerHeight || 0)
|
|
2901
|
+
if (vv <= 0) return 0
|
|
2902
|
+
// 面板在对话区里,大致占视口的 3/4;留一点余量避免顶出滚动条
|
|
2903
|
+
return Math.max(320, Math.floor(vv * 0.72))
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
function measure() {
|
|
2907
|
+
var node = ideRootRef.current
|
|
2908
|
+
if (!node) return
|
|
2909
|
+
var parent = node.parentElement
|
|
2910
|
+
var h = readHeight(parent)
|
|
2911
|
+
if (h <= 0 && parent) h = readHeight(parent.parentElement)
|
|
2912
|
+
if (h <= 0) h = viewportHint()
|
|
2913
|
+
if (h > 0) {
|
|
2914
|
+
setIdeHeight(function (previous) {
|
|
2915
|
+
return previous === h ? previous : h
|
|
2916
|
+
})
|
|
2917
|
+
}
|
|
2918
|
+
}
|
|
2919
|
+
|
|
2920
|
+
// 首帧 + 下一帧各量一次:有些浏览器首帧还没算完布局
|
|
2921
|
+
measure()
|
|
2922
|
+
var raf1 = 0
|
|
2923
|
+
var raf2 = 0
|
|
2924
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
2925
|
+
raf1 = requestAnimationFrame(function () {
|
|
2926
|
+
measure()
|
|
2927
|
+
raf2 = requestAnimationFrame(measure)
|
|
2928
|
+
})
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
var observer = null
|
|
2932
|
+
try {
|
|
2933
|
+
if (typeof ResizeObserver === 'function' && el.parentElement) {
|
|
2934
|
+
observer = new ResizeObserver(measure)
|
|
2935
|
+
observer.observe(el.parentElement)
|
|
2936
|
+
// 父容器可能是零高包裹,再往上一层也盯一下
|
|
2937
|
+
if (el.parentElement.parentElement) observer.observe(el.parentElement.parentElement)
|
|
2938
|
+
}
|
|
2939
|
+
} catch (failure) {
|
|
2940
|
+
observer = null
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
window.addEventListener('resize', measure)
|
|
2944
|
+
window.addEventListener('orientationchange', measure)
|
|
2945
|
+
// 移动端软键盘 / 地址栏伸缩
|
|
2946
|
+
try {
|
|
2947
|
+
if (window.visualViewport && typeof window.visualViewport.addEventListener === 'function') {
|
|
2948
|
+
window.visualViewport.addEventListener('resize', measure)
|
|
2949
|
+
window.visualViewport.addEventListener('scroll', measure)
|
|
2950
|
+
}
|
|
2951
|
+
} catch (failure) {
|
|
2952
|
+
/* 没有 visualViewport */
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
// 窗口布局可能异步完成,补几次延迟测量
|
|
2956
|
+
var timers = [60, 200, 600, 1200, 2400].map(function (delay) {
|
|
2957
|
+
return setTimeout(measure, delay)
|
|
2958
|
+
})
|
|
2959
|
+
|
|
2960
|
+
return function () {
|
|
2961
|
+
for (var i = 0; i < timers.length; i += 1) clearTimeout(timers[i])
|
|
2962
|
+
if (raf1 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf1)
|
|
2963
|
+
if (raf2 && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf2)
|
|
2964
|
+
window.removeEventListener('resize', measure)
|
|
2965
|
+
window.removeEventListener('orientationchange', measure)
|
|
2966
|
+
try {
|
|
2967
|
+
if (window.visualViewport && typeof window.visualViewport.removeEventListener === 'function') {
|
|
2968
|
+
window.visualViewport.removeEventListener('resize', measure)
|
|
2969
|
+
window.visualViewport.removeEventListener('scroll', measure)
|
|
2970
|
+
}
|
|
2971
|
+
} catch (failure) {
|
|
2972
|
+
/* 没有 visualViewport */
|
|
2973
|
+
}
|
|
2974
|
+
if (observer) {
|
|
2975
|
+
try {
|
|
2976
|
+
observer.disconnect()
|
|
2977
|
+
} catch (failure) {
|
|
2978
|
+
/* 已经断开 */
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
}, [authorized])
|
|
2983
|
+
|
|
2984
|
+
// ================================================================
|
|
2985
|
+
// 0.3.0 搜索
|
|
2986
|
+
// ================================================================
|
|
2987
|
+
function clearSearch() {
|
|
2988
|
+
searchSeqRef.current += 1
|
|
2989
|
+
setSearchKeyword('')
|
|
2990
|
+
setSearchItems([])
|
|
2991
|
+
setSearchNote('')
|
|
2992
|
+
setSearchError('')
|
|
2993
|
+
setSearchBusy(false)
|
|
2994
|
+
setSearchOpen(false)
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
/**
|
|
2998
|
+
* 执行搜索。带请求序号,快速输入时只认最后一次的结果。
|
|
2999
|
+
* 搜索范围:当前目录 + 若干层子目录(宿主端控制上限)。
|
|
3000
|
+
*/
|
|
3001
|
+
function runSearch(keyword) {
|
|
3002
|
+
var needle = String(keyword === undefined ? searchKeyword : keyword).trim()
|
|
3003
|
+
if (!needle) {
|
|
3004
|
+
clearSearch()
|
|
3005
|
+
return
|
|
3006
|
+
}
|
|
3007
|
+
searchSeqRef.current += 1
|
|
3008
|
+
var seq = searchSeqRef.current
|
|
3009
|
+
setSearchBusy(true)
|
|
3010
|
+
setSearchError('')
|
|
3011
|
+
setSearchNote('')
|
|
3012
|
+
setSearchOpen(true)
|
|
3013
|
+
api
|
|
3014
|
+
.search({ keyword: needle, parentFileId: parentId })
|
|
3015
|
+
.then(function (doc) {
|
|
3016
|
+
if (seq !== searchSeqRef.current) return
|
|
3017
|
+
if (!doc || doc.ok === false) {
|
|
3018
|
+
setSearchError((doc && doc.error) || t('actFailed', { detail: '' }))
|
|
3019
|
+
setSearchItems([])
|
|
3020
|
+
setSearchBusy(false)
|
|
3021
|
+
return
|
|
3022
|
+
}
|
|
3023
|
+
var rows = Array.isArray(doc.items) ? doc.items : []
|
|
3024
|
+
setSearchItems(rows)
|
|
3025
|
+
setSearchBusy(false)
|
|
3026
|
+
if (rows.length === 0) {
|
|
3027
|
+
setSearchNote(t('searchNoResult'))
|
|
3028
|
+
} else if (doc.truncated) {
|
|
3029
|
+
setSearchNote(t('searchTruncated', { count: rows.length }))
|
|
3030
|
+
} else {
|
|
3031
|
+
setSearchNote(t('searchCount', { count: rows.length }))
|
|
3032
|
+
}
|
|
3033
|
+
})
|
|
3034
|
+
.catch(function (failure) {
|
|
3035
|
+
if (seq !== searchSeqRef.current) return
|
|
3036
|
+
setSearchBusy(false)
|
|
3037
|
+
setSearchError(messageOf(failure))
|
|
3038
|
+
setSearchItems([])
|
|
3039
|
+
})
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
// 输入防抖:350ms 内不再输入就发请求
|
|
3043
|
+
React.useEffect(function () {
|
|
3044
|
+
if (!searchOpen) return
|
|
3045
|
+
var kw = searchKeyword.trim()
|
|
3046
|
+
if (!kw) {
|
|
3047
|
+
setSearchItems([])
|
|
3048
|
+
setSearchNote('')
|
|
3049
|
+
setSearchError('')
|
|
3050
|
+
return
|
|
3051
|
+
}
|
|
3052
|
+
var timer = setTimeout(function () {
|
|
3053
|
+
runSearch(kw)
|
|
3054
|
+
}, 350)
|
|
3055
|
+
return function () {
|
|
3056
|
+
clearTimeout(timer)
|
|
3057
|
+
}
|
|
3058
|
+
}, [searchKeyword])
|
|
3059
|
+
|
|
3060
|
+
// ================================================================
|
|
3061
|
+
// 0.3.0 下载目录弹层
|
|
3062
|
+
// ================================================================
|
|
3063
|
+
function openDirModal() {
|
|
3064
|
+
setDirDraft(downloadDir)
|
|
3065
|
+
setDirError('')
|
|
3066
|
+
setDirPickBusy(false)
|
|
3067
|
+
setDirModal(true)
|
|
3068
|
+
}
|
|
3069
|
+
|
|
3070
|
+
function closeDirModal() {
|
|
3071
|
+
setDirModal(false)
|
|
3072
|
+
setDirError('')
|
|
3073
|
+
setDirPickBusy(false)
|
|
3074
|
+
}
|
|
3075
|
+
|
|
3076
|
+
/** 调宿主的系统「选择文件夹」对话框,选中后填进草稿。 */
|
|
3077
|
+
function pickDownloadDir() {
|
|
3078
|
+
if (dirPickBusy) return
|
|
3079
|
+
setDirPickBusy(true)
|
|
3080
|
+
setDirError('')
|
|
3081
|
+
api
|
|
3082
|
+
.pickDir()
|
|
3083
|
+
.then(function (doc) {
|
|
3084
|
+
setDirPickBusy(false)
|
|
3085
|
+
if (!doc || doc.ok === false) {
|
|
3086
|
+
setDirError((doc && doc.error) || t('actFailed', { detail: '' }))
|
|
3087
|
+
return
|
|
3088
|
+
}
|
|
3089
|
+
if (doc.cancelled) return
|
|
3090
|
+
if (doc.path) setDirDraft(String(doc.path))
|
|
3091
|
+
})
|
|
3092
|
+
.catch(function (failure) {
|
|
3093
|
+
setDirPickBusy(false)
|
|
3094
|
+
setDirError(messageOf(failure))
|
|
3095
|
+
})
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
function saveDownloadDir() {
|
|
3099
|
+
var next = dirDraft.trim()
|
|
3100
|
+
updateDownloadDir(next)
|
|
3101
|
+
setDirModal(false)
|
|
3102
|
+
setNotice(t('downloadDirSaved'))
|
|
3103
|
+
setTimeout(function () { setNotice('') }, 2200)
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
// ================================================================
|
|
3107
|
+
// 0.3.0 底部传输条:把 jobs 折算成一条整体进度
|
|
3108
|
+
// ================================================================
|
|
3109
|
+
/**
|
|
3110
|
+
* 进度条「至少亮这么久」。
|
|
3111
|
+
*
|
|
3112
|
+
* 小图片下载可能 50ms 就完事,如果严格按真实进度显示,
|
|
3113
|
+
* 进度条会闪一下就没 —— 用户根本看不到,以为没在传。
|
|
3114
|
+
* 所以强行让它至少亮 1.2 秒再进入完成态。
|
|
3115
|
+
*/
|
|
3116
|
+
var TRANS_MIN_DRAW_MS = 1200
|
|
3117
|
+
/** 这一轮传输是什么时候开始的(0 = 当前没有在亮)。 */
|
|
3118
|
+
var transBeganAtRef = React.useRef(0)
|
|
3119
|
+
|
|
3120
|
+
React.useEffect(function () {
|
|
3121
|
+
var list = Array.isArray(jobs) ? jobs : []
|
|
3122
|
+
// 和轮询用同一套「活跃」判断,别两处逻辑对不上
|
|
3123
|
+
var running = list.filter(isJobActive)
|
|
3124
|
+
var failed = list.filter(function (job) {
|
|
3125
|
+
return job && job.state === 'error'
|
|
3126
|
+
})
|
|
3127
|
+
function clearTimer() {
|
|
3128
|
+
if (transFadeRef.current !== null) {
|
|
3129
|
+
clearTimeout(transFadeRef.current)
|
|
3130
|
+
transFadeRef.current = null
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
if (running.length > 0) {
|
|
3134
|
+
clearTimer()
|
|
3135
|
+
if (transBeganAtRef.current === 0) transBeganAtRef.current = Date.now()
|
|
3136
|
+
var total = 0
|
|
3137
|
+
var done = 0
|
|
3138
|
+
for (var i = 0; i < running.length; i += 1) {
|
|
3139
|
+
total += Math.max(0, Number(running[i].total) || 0)
|
|
3140
|
+
done += Math.max(0, Number(running[i].done) || 0)
|
|
3141
|
+
}
|
|
3142
|
+
setTransPercent(total > 0 ? Math.min(100, (done / total) * 100) : 0)
|
|
3143
|
+
setTransPhase('draw')
|
|
3144
|
+
return
|
|
3145
|
+
}
|
|
3146
|
+
// 没有运行中的任务:如果刚从「有任务」变成没有,就走
|
|
3147
|
+
// 「完成(停留 5 秒)→ 缓缓褪色 → 归位」。
|
|
3148
|
+
if (transPhase !== 'draw') return
|
|
3149
|
+
var hasError = failed.length > 0
|
|
3150
|
+
var began = transBeganAtRef.current
|
|
3151
|
+
var elapsed = began ? Date.now() - began : TRANS_MIN_DRAW_MS
|
|
3152
|
+
// 还没亮够时间就等一等,别让进度条闪一下就消失
|
|
3153
|
+
var wait = Math.max(0, TRANS_MIN_DRAW_MS - elapsed)
|
|
3154
|
+
clearTimer()
|
|
3155
|
+
transFadeRef.current = setTimeout(function () {
|
|
3156
|
+
transBeganAtRef.current = 0
|
|
3157
|
+
setTransPercent(100)
|
|
3158
|
+
setTransPhase(hasError ? 'error' : 'done')
|
|
3159
|
+
// 完成态停留 5 秒,再花 1.5 秒褪色
|
|
3160
|
+
transFadeRef.current = setTimeout(function () {
|
|
3161
|
+
setTransPhase('fade')
|
|
3162
|
+
transFadeRef.current = setTimeout(function () {
|
|
3163
|
+
setTransPhase('idle')
|
|
3164
|
+
setTransPercent(0)
|
|
3165
|
+
transFadeRef.current = null
|
|
3166
|
+
}, 1600)
|
|
3167
|
+
}, 5000)
|
|
3168
|
+
}, wait)
|
|
3169
|
+
}, [jobs])
|
|
3170
|
+
|
|
3171
|
+
// 卸载时清掉淡出定时器
|
|
3172
|
+
React.useEffect(function () {
|
|
3173
|
+
return function () {
|
|
3174
|
+
if (transFadeRef.current !== null) {
|
|
3175
|
+
clearTimeout(transFadeRef.current)
|
|
3176
|
+
transFadeRef.current = null
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
}, [])
|
|
3180
|
+
|
|
3181
|
+
function goUp() {
|
|
3182
|
+
setStack(stack.slice(0, stack.length - 1))
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
function createFolder() {
|
|
3186
|
+
var name = folderName.trim()
|
|
3187
|
+
if (!name) return
|
|
3188
|
+
runMutation(function () {
|
|
3189
|
+
return api.mkdir(parentId, name).then(function (doc) {
|
|
3190
|
+
setFolderName('')
|
|
3191
|
+
setFolderOpen(false)
|
|
3192
|
+
return doc
|
|
3193
|
+
})
|
|
3194
|
+
})
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
function renameEntry(item) {
|
|
3198
|
+
if (typeof window === 'undefined' || typeof window.prompt !== 'function') return
|
|
3199
|
+
var next = window.prompt(t('renamePrompt'), item.name)
|
|
3200
|
+
if (next === null) return
|
|
3201
|
+
next = String(next).trim()
|
|
3202
|
+
if (!next || next === item.name) return
|
|
3203
|
+
runMutation(function () {
|
|
3204
|
+
return api.rename(item.id, next)
|
|
3205
|
+
})
|
|
3206
|
+
}
|
|
3207
|
+
|
|
3208
|
+
// ------------------------------------------------------ 复制路径
|
|
3209
|
+
/** 当前所在目录的完整路径,例如 "/windows/实验"。 */
|
|
3210
|
+
function currentDirPath() {
|
|
3211
|
+
return '/' + stack.map(function (s) { return s.name }).join('/')
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
/**
|
|
3215
|
+
* 把一个项的完整路径拼出来(基于当前目录栈 + 名字)。
|
|
3216
|
+
* 注意:这是**展示用的路径**,不是网盘的 fileId;移动云盘按 id 定位,
|
|
3217
|
+
* 所以这个路径只适合给人看 / 粘到笔记里。
|
|
3218
|
+
*/
|
|
3219
|
+
function itemFullPath(item) {
|
|
3220
|
+
if (!item) return currentDirPath()
|
|
3221
|
+
var base = currentDirPath()
|
|
3222
|
+
return (base === '/' ? '' : base) + '/' + String(item.name || '')
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3225
|
+
/** 优先用剪贴板 API;不支持时退回 textarea + execCommand。 */
|
|
3226
|
+
function copyTextToClipboard(text) {
|
|
3227
|
+
return new Promise(function (resolve, reject) {
|
|
3228
|
+
if (typeof navigator !== 'undefined' && navigator.clipboard &&
|
|
3229
|
+
typeof navigator.clipboard.writeText === 'function') {
|
|
3230
|
+
navigator.clipboard.writeText(text).then(resolve, function () {
|
|
3231
|
+
// 权限被拒之类:退回老办法
|
|
3232
|
+
fallbackCopy(text) ? resolve() : reject(new Error('clipboard unavailable'))
|
|
3233
|
+
})
|
|
3234
|
+
return
|
|
3235
|
+
}
|
|
3236
|
+
if (fallbackCopy(text)) { resolve(); return }
|
|
3237
|
+
reject(new Error('clipboard unavailable'))
|
|
3238
|
+
})
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
function fallbackCopy(text) {
|
|
3242
|
+
try {
|
|
3243
|
+
if (typeof document === 'undefined' || typeof document.createElement !== 'function') return false
|
|
3244
|
+
var area = document.createElement('textarea')
|
|
3245
|
+
area.value = text
|
|
3246
|
+
area.setAttribute('readonly', '')
|
|
3247
|
+
area.style.position = 'fixed'
|
|
3248
|
+
area.style.top = '-1000px'
|
|
3249
|
+
area.style.opacity = '0'
|
|
3250
|
+
var host = document.body || document.documentElement
|
|
3251
|
+
if (!host || typeof host.appendChild !== 'function') return false
|
|
3252
|
+
host.appendChild(area)
|
|
3253
|
+
area.select()
|
|
3254
|
+
if (typeof document.execCommand !== 'function') {
|
|
3255
|
+
host.removeChild(area)
|
|
3256
|
+
return false
|
|
3257
|
+
}
|
|
3258
|
+
var ok = document.execCommand('copy')
|
|
3259
|
+
host.removeChild(area)
|
|
3260
|
+
return ok === true
|
|
3261
|
+
} catch (failure) {
|
|
3262
|
+
logWarn('fallback copy failed', failure)
|
|
3263
|
+
return false
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
function copyItemPath(item) {
|
|
3268
|
+
var path = itemFullPath(item)
|
|
3269
|
+
setNotice(t('pathCopied', { path: path }))
|
|
3270
|
+
copyTextToClipboard(path).then(function () {
|
|
3271
|
+
setNotice(t('pathCopied', { path: path }))
|
|
3272
|
+
}, function (failure) {
|
|
3273
|
+
// 复制失败也别让用户抓空:把路径显示出来让他自己选中
|
|
3274
|
+
setListError(t('copyFailed', { path: path }))
|
|
3275
|
+
logWarn('copy path failed', failure)
|
|
3276
|
+
})
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
function removeEntry(item) {
|
|
3280
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
|
3281
|
+
if (!window.confirm(t('removeConfirm', { name: item.name }))) return
|
|
3282
|
+
}
|
|
3283
|
+
runMutation(function () {
|
|
3284
|
+
return api.remove([item.id])
|
|
3285
|
+
})
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
// ------------------------------------------------------ 转移(移动)到别的文件夹
|
|
3289
|
+
/**
|
|
3290
|
+
* 转移面板:打开时从根目录开始浏览,点文件夹进去,底部「转移到这里」。
|
|
3291
|
+
* 目标不能是被移动项自己,也不能是它自己内部的子目录(服务端会拒绝,
|
|
3292
|
+
* 但在这里先挡掉,省得用户白等一次往返)。
|
|
3293
|
+
*/
|
|
3294
|
+
function openMovePicker(item) {
|
|
3295
|
+
if (!item) return
|
|
3296
|
+
setMoveSource(item)
|
|
3297
|
+
setMoveOpen(true)
|
|
3298
|
+
setMoveStack([])
|
|
3299
|
+
setMoveError('')
|
|
3300
|
+
setMoveNote('')
|
|
3301
|
+
setMoveLoading(true)
|
|
3302
|
+
api.list('/', '')
|
|
3303
|
+
.then(function (doc) {
|
|
3304
|
+
setMoveItems(Array.isArray(doc.items) ? doc.items : [])
|
|
1755
3305
|
setMoveCursor(doc.nextCursor || null)
|
|
1756
3306
|
setMoveLoading(false)
|
|
1757
3307
|
})
|
|
@@ -1948,9 +3498,28 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
1948
3498
|
setJobs(list)
|
|
1949
3499
|
}
|
|
1950
3500
|
|
|
3501
|
+
/**
|
|
3502
|
+
* 这个任务是不是「还在动」。
|
|
3503
|
+
*
|
|
3504
|
+
* 🔴 这里踩过一个坑:一开始只认 state === 'running',
|
|
3505
|
+
* 但任务刚建出来时是 queued / scan(甚至 pending),
|
|
3506
|
+
* 于是 startJob 之后第一次 fetchTransfers 判定「没有在跑的」→
|
|
3507
|
+
* 直接把轮询停了,进度条永远等不到更新,用户就「看不见进度条」。
|
|
3508
|
+
*
|
|
3509
|
+
* 所以凡是「还没结束」的状态都算活跃。终态只有 done / error / cancelled。
|
|
3510
|
+
*/
|
|
3511
|
+
function isJobActive(job) {
|
|
3512
|
+
if (!job) return false
|
|
3513
|
+
var state = String(job.state || '')
|
|
3514
|
+
if (state === 'done' || state === 'error' || state === 'cancelled' || state === 'canceled') {
|
|
3515
|
+
return false
|
|
3516
|
+
}
|
|
3517
|
+
return true
|
|
3518
|
+
}
|
|
3519
|
+
|
|
1951
3520
|
function hasRunningJob(list) {
|
|
1952
3521
|
for (var i = 0; i < list.length; i += 1) {
|
|
1953
|
-
if (list[i]
|
|
3522
|
+
if (isJobActive(list[i])) return true
|
|
1954
3523
|
}
|
|
1955
3524
|
return false
|
|
1956
3525
|
}
|
|
@@ -2007,6 +3576,19 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
2007
3576
|
setJobBusy(key)
|
|
2008
3577
|
setListError('')
|
|
2009
3578
|
setNotice('')
|
|
3579
|
+
/**
|
|
3580
|
+
* 乐观地把进度条点亮。
|
|
3581
|
+
*
|
|
3582
|
+
* 🔴 这里踩过坑:小图片下载可能 50ms 就完事,等 fetchTransfers
|
|
3583
|
+
* 回来时任务已经是 done —— 而进度条的状态机只在「当前是 draw」
|
|
3584
|
+
* 时才肯转 done,于是整条进度条从头到尾都没出现过,用户以为没在传。
|
|
3585
|
+
*
|
|
3586
|
+
* 所以起任务的动作本身就先把 transPhase 设成 'draw' 并记下开始时间,
|
|
3587
|
+
* 这样即使任务瞬间完成,也能走完「亮起 → 完成 → 停留 5 秒 → 褪色」。
|
|
3588
|
+
*/
|
|
3589
|
+
transBeganAtRef.current = Date.now()
|
|
3590
|
+
setTransPercent(0)
|
|
3591
|
+
setTransPhase('draw')
|
|
2010
3592
|
return factory()
|
|
2011
3593
|
.then(function (doc) {
|
|
2012
3594
|
setJobBusy('')
|
|
@@ -2017,6 +3599,10 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
2017
3599
|
setJobBusy('')
|
|
2018
3600
|
setListError(t('actFailed', { detail: messageOf(failure) }))
|
|
2019
3601
|
logWarn('transfer start failed', failure)
|
|
3602
|
+
// 任务没起来:把乐观点亮的进度条收回去
|
|
3603
|
+
transBeganAtRef.current = 0
|
|
3604
|
+
setTransPhase('idle')
|
|
3605
|
+
setTransPercent(0)
|
|
2020
3606
|
return null
|
|
2021
3607
|
})
|
|
2022
3608
|
}
|
|
@@ -2709,19 +4295,21 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
2709
4295
|
if (menu.item) {
|
|
2710
4296
|
var item = menu.item
|
|
2711
4297
|
if (item.type === 'folder') {
|
|
2712
|
-
|
|
2713
|
-
|
|
4298
|
+
// 文件夹右键只留这两项(用户要求):
|
|
4299
|
+
// 进入文件夹靠点名字/双击,增删改移走其他入口。
|
|
4300
|
+
items.push(menuItem('copy-path', t('menuCopyPath'), function () { copyItemPath(item) }))
|
|
4301
|
+
items.push(menuItem('rename', t('menuRename'), function () { renameEntry(item) }))
|
|
2714
4302
|
} else {
|
|
2715
4303
|
items.push(menuItem('link', t('menuDownloadLink'), function () { openLink(item) }))
|
|
2716
4304
|
items.push(menuItem('download', t('menuDownloadLocal'), function () { downloadFile(item) }))
|
|
4305
|
+
items.push(h('hr', { key: 'menu-sep-upload', className: 'mc-menuSep' }))
|
|
4306
|
+
pushUploadItems(items, item)
|
|
4307
|
+
items.push(h('hr', { key: 'menu-sep', className: 'mc-menuSep' }))
|
|
4308
|
+
items.push(menuItem('move', t('menuMove'), function () { openMovePicker(item) }))
|
|
4309
|
+
items.push(menuItem('copy-path', t('menuCopyPath'), function () { copyItemPath(item) }))
|
|
4310
|
+
items.push(menuItem('rename', t('menuRename'), function () { renameEntry(item) }))
|
|
4311
|
+
items.push(menuItem('remove', t('menuRemove'), function () { removeEntry(item) }, 'mc-menuItemDanger'))
|
|
2717
4312
|
}
|
|
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
4313
|
} else {
|
|
2726
4314
|
items.push(menuItem('new-folder', t('menuNewFolder'), function () {
|
|
2727
4315
|
setFolderOpen(true)
|
|
@@ -2897,176 +4485,905 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
2897
4485
|
)
|
|
2898
4486
|
}
|
|
2899
4487
|
|
|
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()
|
|
4488
|
+
|
|
4489
|
+
function renderAuthCard() {
|
|
4490
|
+
return h('section', { className: 'mc-card', key: 'auth' },
|
|
4491
|
+
h('h2', { className: 'mc-cardTitle' }, t('loginTitle')),
|
|
4492
|
+
renderAuthTabs(),
|
|
4493
|
+
authTab === 'scan' ? renderScanForm() : renderLoginForm()
|
|
4494
|
+
)
|
|
4495
|
+
}
|
|
4496
|
+
|
|
4497
|
+
// ------------------------------------------------------ 状态卡
|
|
4498
|
+
function renderExpires() {
|
|
4499
|
+
var expiresAt = status ? status.expiresAt : null
|
|
4500
|
+
if (!expiresAt) return '—'
|
|
4501
|
+
var text = formatDateTime(expiresAt)
|
|
4502
|
+
if (Number(expiresAt) < Date.now()) return t('expired') + ' · ' + text
|
|
4503
|
+
return text
|
|
4504
|
+
}
|
|
4505
|
+
|
|
4506
|
+
/** 打开显示名编辑:草稿 = 当前存储值(不是脱敏号码)。 */
|
|
4507
|
+
function openDisplayNameEdit() {
|
|
4508
|
+
setDisplayNameDraft(displayName)
|
|
4509
|
+
setDisplayNameError('')
|
|
4510
|
+
setDisplayNameEdit(true)
|
|
4511
|
+
}
|
|
4512
|
+
|
|
4513
|
+
/**
|
|
4514
|
+
* 保存显示名。只有「真想存的值写不进 localStorage」才算失败:
|
|
4515
|
+
* 空值存不存得进去都无所谓(清除本来就是回退到脱敏号码)。
|
|
4516
|
+
*/
|
|
4517
|
+
function saveDisplayName() {
|
|
4518
|
+
var next = normalizeDisplayName(displayNameDraft)
|
|
4519
|
+
setDisplayName(next)
|
|
4520
|
+
var applied = writeDisplayName(next)
|
|
4521
|
+
if (applied !== next) {
|
|
4522
|
+
setDisplayNameError(t('actFailed', { detail: 'localStorage' }))
|
|
4523
|
+
return
|
|
4524
|
+
}
|
|
4525
|
+
setDisplayNameError('')
|
|
4526
|
+
setDisplayNameEdit(false)
|
|
4527
|
+
}
|
|
4528
|
+
|
|
4529
|
+
function clearDisplayName() {
|
|
4530
|
+
setDisplayName('')
|
|
4531
|
+
setDisplayNameDraft('')
|
|
4532
|
+
writeDisplayName('')
|
|
4533
|
+
setDisplayNameError('')
|
|
4534
|
+
setDisplayNameEdit(false)
|
|
4535
|
+
}
|
|
4536
|
+
|
|
4537
|
+
/** 取消:不落盘,直接回到卡片。 */
|
|
4538
|
+
function cancelDisplayNameEdit() {
|
|
4539
|
+
setDisplayNameDraft('')
|
|
4540
|
+
setDisplayNameError('')
|
|
4541
|
+
setDisplayNameEdit(false)
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4544
|
+
/**
|
|
4545
|
+
* 账号行:显示名(有的话)或脱敏手机号 accountMasked。
|
|
4546
|
+
* ⚠️ 绝不渲染 status.account —— 那是完整手机号。
|
|
4547
|
+
* 有显示名时,脱敏号码退成下面一行小字(仍然不是完整号码)。
|
|
4548
|
+
*/
|
|
4549
|
+
// ------------------------------------------------------ 文件浏览
|
|
4550
|
+
// ================================================================
|
|
4551
|
+
// 0.3.0 资源管理器渲染
|
|
4552
|
+
// ================================================================
|
|
4553
|
+
|
|
4554
|
+
/**
|
|
4555
|
+
* 顶部工具条。
|
|
4556
|
+
*
|
|
4557
|
+
* 布局按用户要求:
|
|
4558
|
+
* · 左上 = 账号 + 授权有效期(+ 下载目录小按钮)
|
|
4559
|
+
* · 顶部居中 = 容量
|
|
4560
|
+
* · 右上 = 操作按钮
|
|
4561
|
+
*/
|
|
4562
|
+
function renderExTop() {
|
|
4563
|
+
var quota = status ? status.quota : null
|
|
4564
|
+
var total = quota && isFinite(Number(quota.totalBytes)) ? Number(quota.totalBytes) : 0
|
|
4565
|
+
var used = quota && isFinite(Number(quota.usedBytes)) ? Number(quota.usedBytes) : 0
|
|
4566
|
+
var percent = total > 0 ? Math.min(100, Math.max(0, (used / total) * 100)) : 0
|
|
4567
|
+
|
|
4568
|
+
// ---- 左上:账号 + 有效期 ----
|
|
4569
|
+
// 账号区:纵向三行(手机号 / 有效期 / 下载目录),保持原来的高度
|
|
4570
|
+
var accountBlock = h('div', { className: 'mc-exTopLeft' },
|
|
4571
|
+
h('div', { className: 'mc-exAcct' },
|
|
4572
|
+
h('span', { className: 'mc-exAcctName', title: accountMasked || '' },
|
|
4573
|
+
hasAccount ? accountLabel : t('notAuthorized')
|
|
4574
|
+
),
|
|
4575
|
+
hasAccount ? h('button', {
|
|
4576
|
+
type: 'button',
|
|
4577
|
+
className: 'mc-exAcctEdit',
|
|
4578
|
+
title: t('displayName'),
|
|
4579
|
+
'aria-label': t('displayName'),
|
|
4580
|
+
onClick: openDisplayNameEdit,
|
|
4581
|
+
}, '✎') : null
|
|
4582
|
+
),
|
|
4583
|
+
displayName !== '' && hasAccount
|
|
4584
|
+
? h('span', { className: 'mc-exAcctMasked' }, accountMasked)
|
|
4585
|
+
: null,
|
|
4586
|
+
h('div', { className: 'mc-exExpiry' },
|
|
4587
|
+
h('span', {}, t('expiresShort')),
|
|
4588
|
+
h('span', { className: expiresSoon() ? 'mc-exExpiryWarn' : '' }, renderExpires())
|
|
4589
|
+
),
|
|
4590
|
+
// 下载目录:小按钮(带框,限宽)
|
|
4591
|
+
h('button', {
|
|
4592
|
+
type: 'button',
|
|
4593
|
+
className: 'mc-exChip',
|
|
4594
|
+
title: (t('downloadDir') + ':' + (downloadDir || '—')),
|
|
4595
|
+
onClick: openDirModal,
|
|
4596
|
+
},
|
|
4597
|
+
h('span', { className: 'mc-exChipIcon' },
|
|
4598
|
+
h('svg', { viewBox: '0 0 10 12', width: 9, height: 11, 'aria-hidden': 'true' },
|
|
4599
|
+
h('path', {
|
|
4600
|
+
d: 'M5 1.5 L5 10',
|
|
4601
|
+
fill: 'none',
|
|
4602
|
+
stroke: 'currentColor',
|
|
4603
|
+
strokeWidth: '1.5',
|
|
4604
|
+
strokeLinecap: 'round',
|
|
4605
|
+
}),
|
|
4606
|
+
h('path', {
|
|
4607
|
+
d: 'M2 7.4 L5 10.4 L8 7.4',
|
|
4608
|
+
fill: 'none',
|
|
4609
|
+
stroke: 'currentColor',
|
|
4610
|
+
strokeWidth: '1.5',
|
|
4611
|
+
strokeLinecap: 'round',
|
|
4612
|
+
strokeLinejoin: 'round',
|
|
4613
|
+
})
|
|
4614
|
+
)
|
|
4615
|
+
),
|
|
4616
|
+
h('span', { className: 'mc-exChipValue' }, downloadDir || '—')
|
|
4617
|
+
)
|
|
4618
|
+
)
|
|
4619
|
+
|
|
4620
|
+
// ---- 容量条:第一行右侧,紧挨操作按钮 ----
|
|
4621
|
+
// ---- 容量条:顶部栏底部那条贯通的进度线 ----
|
|
4622
|
+
// 文字直接压在条里居中;白字 + 深色描边,所以不管底下是浅色未用区
|
|
4623
|
+
// 还是品牌色已用区,都看得清(亮色/暗色主题都适配)。
|
|
4624
|
+
var quotaBlock = h('div', {
|
|
4625
|
+
className: 'mc-exQuotaLine',
|
|
4626
|
+
title: total > 0
|
|
4627
|
+
? t('quota') + ':' + formatBytes(used) + ' / ' + formatBytes(total) + ' · ' + percent.toFixed(1) + '%'
|
|
4628
|
+
: t('quotaUnavailable'),
|
|
4629
|
+
},
|
|
4630
|
+
h('div', {
|
|
4631
|
+
className: 'mc-exQuotaLineFill',
|
|
4632
|
+
style: { width: (total > 0 ? percent : 0).toFixed(1) + '%' },
|
|
4633
|
+
}),
|
|
4634
|
+
h('div', { className: 'mc-exQuotaLineText' },
|
|
4635
|
+
total > 0
|
|
4636
|
+
? formatBytes(used) + ' / ' + formatBytes(total) + ' · ' + percent.toFixed(1) + '%'
|
|
4637
|
+
: t('quotaUnavailable')
|
|
4638
|
+
)
|
|
4639
|
+
)
|
|
4640
|
+
|
|
4641
|
+
// ---- 右上:操作 ----
|
|
4642
|
+
var activeCount = (Array.isArray(jobs) ? jobs : []).filter(isJobActive).length
|
|
4643
|
+
// 右上:按钮 + 容量条横着排成一行(不换行)
|
|
4644
|
+
var actionBlock = h('div', { className: 'mc-exTopRight' },
|
|
4645
|
+
// 传输记录
|
|
4646
|
+
h('button', {
|
|
4647
|
+
type: 'button',
|
|
4648
|
+
className: 'mc-btn mc-btnSmall mc-exHistoryBtn',
|
|
4649
|
+
title: t('historyTitle'),
|
|
4650
|
+
onClick: function () {
|
|
4651
|
+
fetchTransfers()
|
|
4652
|
+
setHistoryOpen(true)
|
|
4653
|
+
},
|
|
4654
|
+
},
|
|
4655
|
+
h('span', { className: 'mc-exHistoryLabel' }, t('history')),
|
|
4656
|
+
h('span', { className: 'mc-exHistoryLabelShort' }, t('historyShort')),
|
|
4657
|
+
activeCount > 0 ? h('span', { className: 'mc-exHistoryDot' }) : null
|
|
4658
|
+
),
|
|
4659
|
+
h('button', {
|
|
4660
|
+
type: 'button',
|
|
4661
|
+
className: 'mc-btn mc-btnSmall',
|
|
4662
|
+
title: t('reauthorize'),
|
|
4663
|
+
disabled: authBusy,
|
|
4664
|
+
onClick: openReauth,
|
|
4665
|
+
}, t('reauthorizeShort')),
|
|
4666
|
+
h('button', {
|
|
4667
|
+
type: 'button',
|
|
4668
|
+
className: 'mc-btn mc-btnSmall mc-btnDanger',
|
|
4669
|
+
title: t('clearAuth'),
|
|
4670
|
+
disabled: authBusy,
|
|
4671
|
+
onClick: clearAuth,
|
|
4672
|
+
}, t('clearAuthShort')),
|
|
4673
|
+
h('button', {
|
|
4674
|
+
type: 'button',
|
|
4675
|
+
className: 'mc-btn mc-btnSmall',
|
|
4676
|
+
title: t('refresh'),
|
|
4677
|
+
'aria-label': t('refresh'),
|
|
4678
|
+
disabled: statusLoading,
|
|
4679
|
+
onClick: function () {
|
|
4680
|
+
// 刷新不仅是刷容量和列表,树的结构也可能变了
|
|
4681
|
+
invalidateTreeKnowledge()
|
|
4682
|
+
loadStatus()
|
|
4683
|
+
reload()
|
|
4684
|
+
},
|
|
4685
|
+
}, '↻'),
|
|
4686
|
+
)
|
|
4687
|
+
|
|
4688
|
+
return h('div', { className: 'mc-exTop' },
|
|
4689
|
+
// 第一行:账号 + 操作按钮(容量条不在这儿了,所以永远塞得下)
|
|
4690
|
+
h('div', { className: 'mc-exTopRow' }, accountBlock, actionBlock),
|
|
4691
|
+
// 第二行:新建 + 面包屑 + 搜索
|
|
4692
|
+
renderExTools(),
|
|
4693
|
+
// 底部:贯通的容量条(它就是原来那条浅横线)
|
|
4694
|
+
quotaBlock
|
|
4695
|
+
)
|
|
4696
|
+
}
|
|
4697
|
+
|
|
4698
|
+
/** 授权剩余天数是否已不足 7 天(用于把有效期标红)。 */
|
|
4699
|
+
function expiresSoon() {
|
|
4700
|
+
var expiresAt = status ? Number(status.expiresAt) : 0
|
|
4701
|
+
if (!expiresAt) return false
|
|
4702
|
+
return expiresAt - Date.now() < 7 * 24 * 3600 * 1000
|
|
4703
|
+
}
|
|
4704
|
+
|
|
4705
|
+
/** 第二行:新建 / 路径 / 搜索。 */
|
|
4706
|
+
function renderExTools() {
|
|
4707
|
+
return h('div', { className: 'mc-exTools' },
|
|
4708
|
+
h('button', {
|
|
4709
|
+
type: 'button',
|
|
4710
|
+
className: 'mc-btn mc-btnSmall',
|
|
4711
|
+
disabled: busy,
|
|
4712
|
+
title: t('newFolder'),
|
|
4713
|
+
onClick: function () {
|
|
4714
|
+
setFolderOpen(!folderOpen)
|
|
4715
|
+
setFolderName('')
|
|
4716
|
+
},
|
|
4717
|
+
}, '+ ' + t('newFolder')),
|
|
4718
|
+
h('div', { className: 'mc-exToolsGrow' }, renderCrumbs()),
|
|
4719
|
+
h('div', { className: 'mc-exSearch' },
|
|
4720
|
+
h('input', {
|
|
4721
|
+
className: 'mc-exSearchInput',
|
|
4722
|
+
type: 'search',
|
|
4723
|
+
spellCheck: false,
|
|
4724
|
+
autoComplete: 'off',
|
|
4725
|
+
placeholder: t('searchPlaceholder'),
|
|
4726
|
+
'aria-label': t('search'),
|
|
4727
|
+
value: searchKeyword,
|
|
4728
|
+
onChange: function (event) { setSearchKeyword(event.target.value) },
|
|
4729
|
+
onKeyDown: function (event) {
|
|
4730
|
+
if (event.key === 'Enter') runSearch(searchKeyword)
|
|
4731
|
+
if (event.key === 'Escape') clearSearch()
|
|
4732
|
+
},
|
|
4733
|
+
}),
|
|
4734
|
+
searchKeyword
|
|
4735
|
+
? h('button', {
|
|
4736
|
+
type: 'button',
|
|
4737
|
+
className: 'mc-exSearchClear',
|
|
4738
|
+
title: t('searchClear'),
|
|
4739
|
+
onClick: clearSearch,
|
|
4740
|
+
}, '×')
|
|
4741
|
+
: null
|
|
4742
|
+
)
|
|
4743
|
+
)
|
|
4744
|
+
}
|
|
4745
|
+
|
|
4746
|
+
/** 左树的一个节点(含子节点,递归)。 */
|
|
4747
|
+
/**
|
|
4748
|
+
* 左树的一个节点。
|
|
4749
|
+
*
|
|
4750
|
+
* 箭头这块踩过坑:一开始用 ▼ / ▶ / · 三个字符表示「展开 / 收起 / 加载中」,
|
|
4751
|
+
* 结果是
|
|
4752
|
+
* · 三个字符宽度不同,切换时整行左右跳;
|
|
4753
|
+
* · 没子目录时用 visibility:hidden 把字藏了,用户就看见箭头「点了消失」;
|
|
4754
|
+
* · 字符切换没有过渡,观感很卡。
|
|
4755
|
+
*
|
|
4756
|
+
* 现在改成:固定 9px 的格子 + 一个 SVG 折角,展开靠 rotate 过渡。
|
|
4757
|
+
* 任何状态都不改宽度,所以永远不跳。没有子目录时格子留白(不是消失)。
|
|
4758
|
+
*/
|
|
4759
|
+
function renderCaret(node, hasKids) {
|
|
4760
|
+
// 加载中:小转圈(占同样宽度)
|
|
4761
|
+
if (node.loading) {
|
|
4762
|
+
return h('span', { className: 'mc-exCaretSlot' },
|
|
4763
|
+
h('span', { className: 'mc-exTreeSpinner' })
|
|
4764
|
+
)
|
|
4765
|
+
}
|
|
4766
|
+
// 确定没有子目录:留白占位,不画箭头(但位置永远在,不会跳)
|
|
4767
|
+
if (!hasKids) {
|
|
4768
|
+
return h('span', { className: 'mc-exCaretSlot' })
|
|
4769
|
+
}
|
|
4770
|
+
return h('span', { className: 'mc-exCaretSlot' },
|
|
4771
|
+
h('svg', {
|
|
4772
|
+
className: 'mc-exCaret' + (node.open ? ' mc-exCaretOpen' : ''),
|
|
4773
|
+
viewBox: '0 0 10 10',
|
|
4774
|
+
width: 10,
|
|
4775
|
+
height: 10,
|
|
4776
|
+
'aria-hidden': 'true',
|
|
4777
|
+
},
|
|
4778
|
+
h('path', {
|
|
4779
|
+
d: 'M3.5 2 L7 5 L3.5 8',
|
|
4780
|
+
fill: 'none',
|
|
4781
|
+
stroke: 'currentColor',
|
|
4782
|
+
strokeWidth: '1.6',
|
|
4783
|
+
strokeLinecap: 'round',
|
|
4784
|
+
strokeLinejoin: 'round',
|
|
4785
|
+
})
|
|
4786
|
+
)
|
|
4787
|
+
)
|
|
4788
|
+
}
|
|
4789
|
+
|
|
4790
|
+
function renderTreeNode(node, depth) {
|
|
4791
|
+
var isActive = treeFocusId === node.id
|
|
4792
|
+
var kids = node.children || []
|
|
4793
|
+
|
|
4794
|
+
// 「有没有下一级」是三态:
|
|
4795
|
+
// true → 画箭头,可点
|
|
4796
|
+
// false → 确实没有下级,**不画箭头也不可点**
|
|
4797
|
+
// null → 还不知道(正在探测),不画箭头但可点(点了就去加载)
|
|
4798
|
+
var known = node.hasKids
|
|
4799
|
+
var hasKids = known === true
|
|
4800
|
+
var canToggle = known !== false
|
|
4801
|
+
// 展开过一次并且拿到了子节点,就一定是有下级的
|
|
4802
|
+
if (node.loaded && kids.length > 0) {
|
|
4803
|
+
hasKids = true
|
|
4804
|
+
canToggle = true
|
|
4805
|
+
}
|
|
4806
|
+
var showKids = node.open && kids.length > 0
|
|
4807
|
+
|
|
4808
|
+
return h('div', { key: 'tn' + node.id },
|
|
4809
|
+
h('div', {
|
|
4810
|
+
className: 'mc-exTreeNode' + (isActive ? ' mc-exTreeNodeActive' : ''),
|
|
4811
|
+
style: { paddingLeft: String(depth * 14 + 2) + 'px' },
|
|
4812
|
+
},
|
|
4813
|
+
h('button', {
|
|
4814
|
+
type: 'button',
|
|
4815
|
+
className: 'mc-exTreeToggle',
|
|
4816
|
+
tabIndex: -1,
|
|
4817
|
+
title: hasKids ? (node.open ? t('collapse') : t('expand')) : '',
|
|
4818
|
+
'aria-label': hasKids ? (node.open ? t('collapse') : t('expand')) : '',
|
|
4819
|
+
disabled: !canToggle,
|
|
4820
|
+
onClick: function (event) {
|
|
4821
|
+
event.stopPropagation()
|
|
4822
|
+
if (!canToggle) return
|
|
4823
|
+
toggleTreeNode(node.id)
|
|
4824
|
+
},
|
|
4825
|
+
}, renderCaret(node, hasKids)),
|
|
4826
|
+
h('button', {
|
|
4827
|
+
type: 'button',
|
|
4828
|
+
className: 'mc-exTreeLabel',
|
|
4829
|
+
title: node.id === '/' ? '/' : node.name,
|
|
4830
|
+
onClick: function () { selectTreeNode(node.id) },
|
|
4831
|
+
// 左树里也能右键:把它当成一个文件夹项交给同一套菜单,
|
|
4832
|
+
// 这样「上传到这儿 / 新建文件夹 / 复制路径 / 改名 / 删除」在树里也能用。
|
|
4833
|
+
onContextMenu: function (event) {
|
|
4834
|
+
if (node.id === '/' || node.id === '') return
|
|
4835
|
+
event.preventDefault()
|
|
4836
|
+
event.stopPropagation()
|
|
4837
|
+
openMenu(event, { id: node.id, name: node.name, type: 'folder' })
|
|
4838
|
+
},
|
|
4839
|
+
onDoubleClick: function () {
|
|
4840
|
+
// 双击 = 展开(资源管理器里双击目录就是展开)
|
|
4841
|
+
if (canToggle && !node.open) toggleTreeNode(node.id)
|
|
4842
|
+
},
|
|
4843
|
+
},
|
|
4844
|
+
h('span', { className: 'mc-exTreeIcon' }, node.open && kids.length ? '📂' : '📁'),
|
|
4845
|
+
h('span', { className: 'mc-exTreeName' }, node.id === '/' ? '/' : node.name)
|
|
4846
|
+
)
|
|
4847
|
+
),
|
|
4848
|
+
node.open && kids.length
|
|
4849
|
+
? h('div', { className: 'mc-exTreeKids' },
|
|
4850
|
+
kids.map(function (kid) { return renderTreeNode(kid, depth + 1) })
|
|
4851
|
+
)
|
|
4852
|
+
: null
|
|
4853
|
+
)
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
function renderExTree() {
|
|
4857
|
+
// 拖过就用用户选的固定宽度,没拖过就让 CSS 自适应
|
|
4858
|
+
var treeStyle = treeWidth !== null ? { width: treeWidth + 'px', flex: '0 0 ' + treeWidth + 'px' } : undefined
|
|
4859
|
+
return h('aside', {
|
|
4860
|
+
className: 'mc-exTree' + (treeShown ? '' : ' mc-exTreeHidden'),
|
|
4861
|
+
style: treeStyle,
|
|
4862
|
+
},
|
|
4863
|
+
h('div', { className: 'mc-exTreeHead' },
|
|
4864
|
+
h('span', {}, t('folders'))
|
|
4865
|
+
),
|
|
4866
|
+
h('div', { className: 'mc-exTreeScroll' },
|
|
4867
|
+
renderTreeNode(treeRoot, 0)
|
|
4868
|
+
)
|
|
4869
|
+
)
|
|
4870
|
+
}
|
|
4871
|
+
|
|
4872
|
+
/**
|
|
4873
|
+
* 可拖动的分隔线。
|
|
4874
|
+
*
|
|
4875
|
+
* 单独成一个 6px 宽的窄条:中间画 2px 的竖线,
|
|
4876
|
+
* 平时几乎看不见,hover / 拖动时才明显 —— 这样既有可拖区域又不占视觉。
|
|
4877
|
+
*/
|
|
4878
|
+
function renderTreeSplitter() {
|
|
4879
|
+
if (!treeShown) return null
|
|
4880
|
+
return h('div', {
|
|
4881
|
+
className: 'mc-exSplitter' + (resizing ? ' mc-exSplitterActive' : ''),
|
|
4882
|
+
role: 'separator',
|
|
4883
|
+
'aria-orientation': 'vertical',
|
|
4884
|
+
title: t('treeResizeHint'),
|
|
4885
|
+
onMouseDown: beginTreeResize,
|
|
4886
|
+
onDoubleClick: resetTreeWidth,
|
|
4887
|
+
},
|
|
4888
|
+
h('span', { className: 'mc-exSplitterBar' })
|
|
4889
|
+
)
|
|
4890
|
+
}
|
|
4891
|
+
|
|
4892
|
+
/** 资源管理器风格的一行。 */
|
|
4893
|
+
function renderExRow(item) {
|
|
4894
|
+
var isFolder = item && item.type === 'folder'
|
|
4895
|
+
var selected = selectedId === item.id
|
|
4896
|
+
// 搜索结果多带 parentPath,用于告诉用户它在哪
|
|
4897
|
+
var where = item.parentPath && item.parentPath !== '/' ? item.parentPath : ''
|
|
4898
|
+
return h('div', {
|
|
4899
|
+
className: 'mc-exRow' + (selected ? ' mc-exRowSelected' : ''),
|
|
4900
|
+
key: item.id,
|
|
4901
|
+
title: item.name + (where ? '\n' + where : ''),
|
|
4902
|
+
onContextMenu: function (event) { openMenu(event, item) },
|
|
4903
|
+
onClick: function () { setSelectedId(item.id) },
|
|
4904
|
+
onDoubleClick: isFolder
|
|
4905
|
+
? function () { enterFolder(item) }
|
|
4906
|
+
: function () { downloadFile(item) },
|
|
4907
|
+
},
|
|
4908
|
+
h('div', { className: 'mc-exRowName' },
|
|
4909
|
+
h('span', { className: 'mc-exRowIcon' }, isFolder ? '📁' : '📄'),
|
|
4910
|
+
isFolder
|
|
4911
|
+
? h('button', {
|
|
4912
|
+
type: 'button',
|
|
4913
|
+
className: 'mc-exRowTextBtn',
|
|
4914
|
+
onClick: function (event) {
|
|
4915
|
+
event.stopPropagation()
|
|
4916
|
+
enterFolder(item)
|
|
4917
|
+
},
|
|
4918
|
+
}, item.name)
|
|
4919
|
+
: h('span', { className: 'mc-exRowText' }, item.name)
|
|
4920
|
+
),
|
|
4921
|
+
h('span', { className: 'mc-exRowSize' }, isFolder ? '—' : formatBytes(item.size)),
|
|
4922
|
+
h('span', { className: 'mc-exRowTime' }, formatDateTime(item.updatedAt))
|
|
2906
4923
|
)
|
|
2907
4924
|
}
|
|
2908
4925
|
|
|
2909
|
-
|
|
2910
|
-
function
|
|
2911
|
-
var
|
|
2912
|
-
var
|
|
2913
|
-
var
|
|
2914
|
-
var free = quota && isFinite(Number(quota.freeBytes)) ? Number(quota.freeBytes) : Math.max(0, total - used)
|
|
4926
|
+
/** 右侧内容区:搜索结果 / 当前目录 / 空态。 */
|
|
4927
|
+
function renderExMain() {
|
|
4928
|
+
var searching = searchOpen && searchKeyword.trim() !== ''
|
|
4929
|
+
var rows = searching ? searchItems : items
|
|
4930
|
+
var rowNodes = rows.map(renderExRow)
|
|
2915
4931
|
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
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
|
-
)
|
|
4932
|
+
// 列宽走 CSS 变量,行和列头共用同一套,保证永远对齐
|
|
4933
|
+
var colVars = {
|
|
4934
|
+
'--mc-col-size': colSize + 'px',
|
|
4935
|
+
'--mc-col-time': colTime + 'px',
|
|
2930
4936
|
}
|
|
2931
4937
|
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
4938
|
+
return h('main', {
|
|
4939
|
+
className: 'mc-exMain' + (mainNarrow ? ' mc-narrow' : ''),
|
|
4940
|
+
style: colVars,
|
|
4941
|
+
ref: mainRef,
|
|
4942
|
+
},
|
|
4943
|
+
h('div', {
|
|
4944
|
+
className: 'mc-exCols' + (colResizing ? ' mc-exColsResizing' : ''),
|
|
4945
|
+
ref: colsRef,
|
|
4946
|
+
// 和列表滚动条同宽,保证列头与行的可用宽度一致(否则固定列会错位)
|
|
4947
|
+
style: scrollbarW > 0 ? { paddingRight: scrollbarW + 'px' } : undefined,
|
|
4948
|
+
},
|
|
4949
|
+
h('span', { className: 'mc-exColsName' }, t('colName')),
|
|
4950
|
+
h('span', { className: 'mc-exColsSize' }, t('colSize')),
|
|
4951
|
+
h('span', { className: 'mc-exColsTime' }, t('colModified')),
|
|
4952
|
+
// 「大小」左边界:拖它改大小列宽
|
|
4953
|
+
h('div', {
|
|
4954
|
+
className: 'mc-exColHandle' + (colResizing === 'size' ? ' mc-exColHandleActive' : ''),
|
|
4955
|
+
style: { left: 'calc(100% - var(--mc-col-size) - var(--mc-col-time))' },
|
|
4956
|
+
role: 'separator',
|
|
4957
|
+
'aria-orientation': 'vertical',
|
|
4958
|
+
title: t('colResizeHint'),
|
|
4959
|
+
onMouseDown: function (event) { beginColResize('size', event) },
|
|
4960
|
+
onDoubleClick: resetColWidth,
|
|
4961
|
+
}, h('span', { className: 'mc-exColHandleBar' })),
|
|
4962
|
+
// 「大小」右边界:拖它改时间列宽
|
|
4963
|
+
h('div', {
|
|
4964
|
+
className: 'mc-exColHandle mc-exColHandleTime' + (colResizing === 'time' ? ' mc-exColHandleActive' : ''),
|
|
4965
|
+
style: { left: 'calc(100% - var(--mc-col-time))' },
|
|
4966
|
+
role: 'separator',
|
|
4967
|
+
'aria-orientation': 'vertical',
|
|
4968
|
+
title: t('colResizeHint'),
|
|
4969
|
+
onMouseDown: function (event) { beginColResize('time', event) },
|
|
4970
|
+
onDoubleClick: resetColWidth,
|
|
4971
|
+
}, h('span', { className: 'mc-exColHandleBar' }))
|
|
2940
4972
|
),
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
4973
|
+
searching
|
|
4974
|
+
? h('div', { className: 'mc-exSearchHint' },
|
|
4975
|
+
h('span', {},
|
|
4976
|
+
searchBusy
|
|
4977
|
+
? t('searching')
|
|
4978
|
+
: h('span', {}, t('searchIn', { path: currentDirPath() })),
|
|
4979
|
+
searchNote ? h('span', { className: 'mc-exSearchHit' }, ' · ' + searchNote) : null
|
|
4980
|
+
),
|
|
4981
|
+
h('button', {
|
|
4982
|
+
type: 'button',
|
|
4983
|
+
className: 'mc-btn mc-btnSmall',
|
|
4984
|
+
onClick: clearSearch,
|
|
4985
|
+
}, t('searchExit'))
|
|
4986
|
+
)
|
|
4987
|
+
: null,
|
|
4988
|
+
listError ? h('div', { className: 'mc-exBanner mc-exBannerErr' }, listError) : null,
|
|
4989
|
+
notice ? h('div', { className: 'mc-exBanner' }, notice) : null,
|
|
4990
|
+
searchError ? h('div', { className: 'mc-exBanner mc-exBannerErr' }, searchError) : null,
|
|
4991
|
+
folderOpen
|
|
4992
|
+
? h('div', { className: 'mc-exBanner' },
|
|
4993
|
+
h('div', { className: 'mc-inlineForm' },
|
|
4994
|
+
h('input', {
|
|
4995
|
+
className: 'mc-input',
|
|
4996
|
+
type: 'text',
|
|
4997
|
+
autoFocus: true,
|
|
4998
|
+
value: folderName,
|
|
4999
|
+
placeholder: t('folderName'),
|
|
5000
|
+
onChange: function (event) { setFolderName(event.target.value) },
|
|
5001
|
+
onKeyDown: onEnterKey(createFolder),
|
|
5002
|
+
}),
|
|
5003
|
+
h('button', {
|
|
5004
|
+
type: 'button',
|
|
5005
|
+
className: 'mc-btn mc-btnSmall mc-btnPrimary',
|
|
5006
|
+
disabled: busy || !folderName.trim(),
|
|
5007
|
+
onClick: createFolder,
|
|
5008
|
+
}, t('create')),
|
|
5009
|
+
h('button', {
|
|
5010
|
+
type: 'button',
|
|
5011
|
+
className: 'mc-btn mc-btnSmall',
|
|
5012
|
+
disabled: busy,
|
|
5013
|
+
onClick: function () { setFolderOpen(false); setFolderName('') },
|
|
5014
|
+
}, t('cancel'))
|
|
5015
|
+
)
|
|
5016
|
+
)
|
|
5017
|
+
: null,
|
|
5018
|
+
h('div', {
|
|
5019
|
+
className: 'mc-exScroll',
|
|
5020
|
+
ref: listScrollRef,
|
|
5021
|
+
onScroll: maybeLoadMore,
|
|
5022
|
+
onContextMenu: function (event) { openMenu(event, null) },
|
|
5023
|
+
},
|
|
5024
|
+
(!searching && listLoading && items.length === 0) || (searching && searchBusy && searchItems.length === 0)
|
|
5025
|
+
? h('div', { className: 'mc-exEmpty' }, t('loading'))
|
|
5026
|
+
: null,
|
|
5027
|
+
!searching && !listLoading && items.length === 0
|
|
5028
|
+
? h('div', { className: 'mc-exEmpty' }, t('empty'))
|
|
5029
|
+
: null,
|
|
5030
|
+
searching && !searchBusy && searchItems.length === 0
|
|
5031
|
+
? h('div', { className: 'mc-exEmpty' }, searchNote || t('searchNoResult'))
|
|
5032
|
+
: null,
|
|
5033
|
+
h('div', { className: 'mc-exRows' }, rowNodes),
|
|
5034
|
+
// 触底自动加载的提示:只有真的还有下一批时才出现,且是一行小字,不是按钮
|
|
5035
|
+
!searching && nextCursor
|
|
5036
|
+
? h('div', { className: 'mc-exFeedFoot' },
|
|
5037
|
+
h('span', { className: 'mc-exFeedDot' }),
|
|
5038
|
+
h('span', {}, listLoading ? t('loading') : t('feedMoreHint'))
|
|
5039
|
+
)
|
|
5040
|
+
: null
|
|
2945
5041
|
)
|
|
2946
5042
|
)
|
|
2947
5043
|
}
|
|
2948
5044
|
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
5045
|
+
/**
|
|
5046
|
+
* 底部传输条。
|
|
5047
|
+
*
|
|
5048
|
+
* 设计要求:平时**看不出来**(和背景同色 / 全透明);
|
|
5049
|
+
* 传文件时出现颜色并随进度推进;传完后保持颜色 5 秒再渐隐。
|
|
5050
|
+
*/
|
|
5051
|
+
function renderExTransfers() {
|
|
5052
|
+
var list = sortedJobs(jobs)
|
|
5053
|
+
var running = list.filter(isJobActive)
|
|
5054
|
+
var failed = list.filter(function (job) { return job && job.state === 'error' })
|
|
5055
|
+
var doneCount = list.filter(function (job) { return job && job.state === 'done' }).length
|
|
5056
|
+
|
|
5057
|
+
var fading = transPhase === 'fade'
|
|
5058
|
+
var visible = transPhase !== 'idle'
|
|
5059
|
+
var indeterminate = running.length > 0 && totalOf(running) === 0
|
|
5060
|
+
var percent = Math.min(100, Math.max(0, Number(transPercent) || 0))
|
|
5061
|
+
|
|
5062
|
+
var fillClass = 'mc-exTransFill'
|
|
5063
|
+
// 褪色阶段保留最后一次的颜色(成功/失败),只是透明度降下去
|
|
5064
|
+
if (transPhase === 'draw') fillClass += ' mc-exTransDraw'
|
|
5065
|
+
else if (transPhase === 'done' || (fading && failed.length === 0)) fillClass += ' mc-exTransDone'
|
|
5066
|
+
else if (transPhase === 'error' || (fading && failed.length > 0)) fillClass += ' mc-exTransErr'
|
|
5067
|
+
if (indeterminate) fillClass += ' mc-exTransIndet'
|
|
5068
|
+
|
|
5069
|
+
// 摘要文字:只在有内容时显示,避免平时占地方。
|
|
5070
|
+
// 褪色阶段**保留文字**,让它跟着进度条一起淡出 ——
|
|
5071
|
+
// 之前是直接把文字置空,看起来就像「闪一下就没了」,很突兀。
|
|
5072
|
+
var summary = ''
|
|
5073
|
+
if (running.length > 0) {
|
|
5074
|
+
var first = running[0]
|
|
5075
|
+
summary = (first.label || t('transfers')) + ' · ' + percent.toFixed(0) + '%'
|
|
5076
|
+
if (running.length > 1) summary += ' · ' + t('transfersMore', { count: running.length - 1 })
|
|
5077
|
+
} else if (transPhase === 'done' || (fading && failed.length === 0)) {
|
|
5078
|
+
summary = t('transfersDone', { count: doneCount })
|
|
5079
|
+
} else if ((transPhase === 'error' || fading) && failed.length) {
|
|
5080
|
+
summary = t('transfersFailed', { count: failed.length })
|
|
5081
|
+
}
|
|
5082
|
+
|
|
5083
|
+
// 褪色时宽度**不收缩**,保持满格,只让颜色融进背景
|
|
5084
|
+
var widthText = fading
|
|
5085
|
+
? '100%'
|
|
5086
|
+
: indeterminate
|
|
5087
|
+
? '26%'
|
|
5088
|
+
: percent.toFixed(1) + '%'
|
|
5089
|
+
|
|
5090
|
+
return h('div', {
|
|
5091
|
+
// 浮在面板底部:不管当前在哪个目录、列表滚到哪里,都一定看得见。
|
|
5092
|
+
// 之前它只是 flex 流里的最后一块,很容易被挤出可视区或被内容盖住。
|
|
5093
|
+
className: 'mc-exTransDock' + (visible ? ' mc-exTransDockActive' : ''),
|
|
5094
|
+
},
|
|
5095
|
+
h('div', { className: 'mc-exTrans' + (visible ? ' mc-exTransActive' : '') },
|
|
5096
|
+
h('div', { className: 'mc-exTransTrack' },
|
|
5097
|
+
h('div', {
|
|
5098
|
+
className: fillClass,
|
|
5099
|
+
style: {
|
|
5100
|
+
width: widthText,
|
|
5101
|
+
opacity: fading ? 0 : visible ? 1 : 0,
|
|
5102
|
+
},
|
|
5103
|
+
})
|
|
5104
|
+
)
|
|
5105
|
+
),
|
|
5106
|
+
summary
|
|
5107
|
+
? h('div', { className: 'mc-exTransBar' + (fading ? ' mc-exTransBarFade' : '') },
|
|
5108
|
+
h('span', { className: 'mc-exTransSummary', title: summary }, summary),
|
|
5109
|
+
h('span', { className: 'mc-exTransActions' },
|
|
5110
|
+
running.length > 0
|
|
5111
|
+
? h('button', {
|
|
5112
|
+
type: 'button',
|
|
5113
|
+
className: 'mc-btn mc-btnSmall',
|
|
5114
|
+
onClick: function () { fetchTransfers() },
|
|
5115
|
+
}, t('refresh'))
|
|
5116
|
+
: null
|
|
5117
|
+
)
|
|
5118
|
+
)
|
|
5119
|
+
: null
|
|
5120
|
+
)
|
|
2955
5121
|
}
|
|
2956
5122
|
|
|
2957
|
-
/**
|
|
2958
|
-
function
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
5123
|
+
/** 汇总一组任务的 total(进度条不确定态判断用)。 */
|
|
5124
|
+
function totalOf(list) {
|
|
5125
|
+
var sum = 0
|
|
5126
|
+
for (var i = 0; i < list.length; i += 1) sum += Math.max(0, Number(list[i].total) || 0)
|
|
5127
|
+
return sum
|
|
2962
5128
|
}
|
|
2963
5129
|
|
|
2964
5130
|
/**
|
|
2965
|
-
*
|
|
2966
|
-
*
|
|
5131
|
+
* 历史传输记录弹层。
|
|
5132
|
+
*
|
|
5133
|
+
* 数据就是 `jobs`(宿主保留最近 30 分钟的任务),按开始时间倒序。
|
|
5134
|
+
* 分三组显示:正在进行 / 已完成 / 失败,这样一眼能看出「还有没有在跑」。
|
|
2967
5135
|
*/
|
|
2968
|
-
function
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
5136
|
+
function renderHistoryModal() {
|
|
5137
|
+
if (!historyOpen) return null
|
|
5138
|
+
var all = (Array.isArray(jobs) ? jobs : []).slice()
|
|
5139
|
+
all.sort(function (a, b) {
|
|
5140
|
+
return (Number(b.startedAt) || 0) - (Number(a.startedAt) || 0)
|
|
5141
|
+
})
|
|
5142
|
+
var active = all.filter(isJobActive)
|
|
5143
|
+
var done = all.filter(function (job) { return job && job.state === 'done' })
|
|
5144
|
+
var failed = all.filter(function (job) { return job && job.state === 'error' })
|
|
5145
|
+
var cancelled = all.filter(function (job) {
|
|
5146
|
+
return job && (job.state === 'cancelled' || job.state === 'canceled')
|
|
5147
|
+
})
|
|
5148
|
+
|
|
5149
|
+
/**
|
|
5150
|
+
* 任务类型图标:上传=向上箭头,下载=向下箭头。
|
|
5151
|
+
*
|
|
5152
|
+
* 用 SVG 而不是 emoji —— 箭头这类字形在不同字体下大小和粗细差别很大,
|
|
5153
|
+
* 而这两个图标在列表里是**对齐参照**,必须稳定。
|
|
5154
|
+
*/
|
|
5155
|
+
function kindIcon(kind) {
|
|
5156
|
+
var k = String(kind || '')
|
|
5157
|
+
var isUpload = k.indexOf('upload') === 0
|
|
5158
|
+
var isDownload = k.indexOf('download') === 0
|
|
5159
|
+
if (!isUpload && !isDownload) return h('span', { className: 'mc-exHistIcon' }, '⇄')
|
|
5160
|
+
return h('span', {
|
|
5161
|
+
className: 'mc-exHistIcon' + (isUpload ? ' mc-exHistIconUp' : ' mc-exHistIconDown'),
|
|
5162
|
+
title: isUpload ? t('upload') : t('menuDownloadLocal'),
|
|
5163
|
+
},
|
|
5164
|
+
h('svg', { viewBox: '0 0 10 12', width: 9, height: 11, 'aria-hidden': 'true' },
|
|
5165
|
+
// 竖线
|
|
5166
|
+
h('path', {
|
|
5167
|
+
d: 'M5 10.5 L5 2',
|
|
5168
|
+
fill: 'none',
|
|
5169
|
+
stroke: 'currentColor',
|
|
5170
|
+
strokeWidth: '1.5',
|
|
5171
|
+
strokeLinecap: 'round',
|
|
5172
|
+
}),
|
|
5173
|
+
// 箭头(上传朝上,下载朝下)
|
|
5174
|
+
h('path', {
|
|
5175
|
+
d: isUpload ? 'M2 4.6 L5 1.6 L8 4.6' : 'M2 7.4 L5 10.4 L8 7.4',
|
|
5176
|
+
fill: 'none',
|
|
5177
|
+
stroke: 'currentColor',
|
|
5178
|
+
strokeWidth: '1.5',
|
|
5179
|
+
strokeLinecap: 'round',
|
|
5180
|
+
strokeLinejoin: 'round',
|
|
5181
|
+
})
|
|
5182
|
+
)
|
|
5183
|
+
)
|
|
2975
5184
|
}
|
|
2976
|
-
setDisplayNameError('')
|
|
2977
|
-
setDisplayNameEdit(false)
|
|
2978
|
-
}
|
|
2979
5185
|
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
5186
|
+
function stateText(job) {
|
|
5187
|
+
var s = String(job.state || '')
|
|
5188
|
+
if (s === 'done') return t('jobDone')
|
|
5189
|
+
if (s === 'error') return t('jobFailed')
|
|
5190
|
+
if (s === 'cancelled' || s === 'canceled') return t('jobCancelled')
|
|
5191
|
+
if (s === 'queued') return t('jobQueued')
|
|
5192
|
+
if (s === 'scan') return t('jobScanning')
|
|
5193
|
+
return t('jobRunning')
|
|
5194
|
+
}
|
|
2987
5195
|
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
5196
|
+
function progressText(job) {
|
|
5197
|
+
var total = Number(job.total) || 0
|
|
5198
|
+
var doneBytes = Number(job.done) || 0
|
|
5199
|
+
if (total > 0) {
|
|
5200
|
+
return formatBytes(doneBytes) + ' / ' + formatBytes(total) +
|
|
5201
|
+
' · ' + Math.min(100, Math.round((doneBytes / total) * 100)) + '%'
|
|
5202
|
+
}
|
|
5203
|
+
return formatBytes(doneBytes)
|
|
5204
|
+
}
|
|
5205
|
+
|
|
5206
|
+
function renderItem(job) {
|
|
5207
|
+
var isErr = job.state === 'error'
|
|
5208
|
+
var isDone = job.state === 'done'
|
|
5209
|
+
var running = isJobActive(job)
|
|
5210
|
+
var total = Number(job.total) || 0
|
|
5211
|
+
var pct = total > 0 ? Math.min(100, ((Number(job.done) || 0) / total) * 100) : 0
|
|
5212
|
+
return h('div', { className: 'mc-exHistItem', key: job.id },
|
|
5213
|
+
h('div', { className: 'mc-exHistRow' },
|
|
5214
|
+
h('span', { className: 'mc-exHistIcon' }, kindIcon(job.kind)),
|
|
5215
|
+
h('span', { className: 'mc-exHistName', title: job.label || '' }, job.label || '—'),
|
|
5216
|
+
h('span', {
|
|
5217
|
+
className: 'mc-exHistState' + (isErr ? ' mc-exHistStateErr' : isDone ? ' mc-exHistStateOk' : ''),
|
|
5218
|
+
}, stateText(job))
|
|
5219
|
+
),
|
|
5220
|
+
h('div', { className: 'mc-exHistMeta' },
|
|
5221
|
+
h('span', {}, progressText(job)),
|
|
5222
|
+
h('span', {}, formatDateTime(job.finishedAt || job.startedAt))
|
|
5223
|
+
),
|
|
5224
|
+
running
|
|
5225
|
+
? h('div', { className: 'mc-exHistBar' },
|
|
5226
|
+
h('div', { className: 'mc-exHistBarFill', style: { width: pct.toFixed(1) + '%' } })
|
|
5227
|
+
)
|
|
5228
|
+
: null,
|
|
5229
|
+
isErr && job.error
|
|
5230
|
+
? h('div', { className: 'mc-exHistErr', title: String(job.error) }, String(job.error))
|
|
5231
|
+
: null
|
|
5232
|
+
)
|
|
5233
|
+
}
|
|
5234
|
+
|
|
5235
|
+
function section(title, list, emptyHint) {
|
|
5236
|
+
if (!list.length) return null
|
|
5237
|
+
return h('div', { className: 'mc-exHistSection' },
|
|
5238
|
+
h('div', { className: 'mc-exHistSectionHead' }, title + ' · ' + list.length),
|
|
5239
|
+
list.map(renderItem)
|
|
5240
|
+
)
|
|
5241
|
+
}
|
|
5242
|
+
|
|
5243
|
+
var body
|
|
5244
|
+
if (!all.length) {
|
|
5245
|
+
body = h('div', { className: 'mc-exHistEmpty' }, t('historyEmpty'))
|
|
5246
|
+
} else {
|
|
5247
|
+
body = h('div', { className: 'mc-exHistBody' },
|
|
5248
|
+
section(t('historyActive'), active),
|
|
5249
|
+
section(t('historyDone'), done),
|
|
5250
|
+
section(t('historyFailed'), failed),
|
|
5251
|
+
section(t('historyCancelled'), cancelled)
|
|
5252
|
+
)
|
|
5253
|
+
}
|
|
5254
|
+
|
|
5255
|
+
return h('div', {
|
|
5256
|
+
className: 'mc-overlay',
|
|
5257
|
+
onMouseDown: function (event) {
|
|
5258
|
+
if (event.target === event.currentTarget) setHistoryOpen(false)
|
|
5259
|
+
},
|
|
5260
|
+
}, h('div', { className: 'mc-panel mc-exHistPanel' },
|
|
5261
|
+
h('div', { className: 'mc-exModalHead' },
|
|
5262
|
+
h('h2', { className: 'mc-exModalTitle' }, t('historyTitle')),
|
|
5263
|
+
h('button', {
|
|
5264
|
+
type: 'button',
|
|
5265
|
+
className: 'mc-btn mc-btnSmall mc-panelClose',
|
|
5266
|
+
title: t('cancel'),
|
|
5267
|
+
onClick: function () { setHistoryOpen(false) },
|
|
5268
|
+
}, '×')
|
|
5269
|
+
),
|
|
5270
|
+
body,
|
|
5271
|
+
// 右上角已经有 × 关闭了,这里不再放「关闭」按钮(重复)
|
|
5272
|
+
h('div', { className: 'mc-panelFoot' },
|
|
5273
|
+
// 一键清空:只删记录,不动文件。正在传的任务会保留,所以要先说清楚。
|
|
5274
|
+
h('button', {
|
|
5275
|
+
type: 'button',
|
|
5276
|
+
className: 'mc-btn mc-btnSmall mc-btnDanger',
|
|
5277
|
+
disabled: historyBusy || done.length + failed.length + cancelled.length === 0,
|
|
5278
|
+
title: t('historyClear'),
|
|
5279
|
+
onClick: clearHistory,
|
|
5280
|
+
}, historyBusy ? t('loading') : t('historyClear')),
|
|
5281
|
+
h('button', {
|
|
5282
|
+
type: 'button',
|
|
5283
|
+
className: 'mc-btn mc-btnSmall',
|
|
5284
|
+
disabled: historyBusy,
|
|
5285
|
+
onClick: function () { fetchTransfers() },
|
|
5286
|
+
}, t('refresh'))
|
|
5287
|
+
)
|
|
5288
|
+
))
|
|
2993
5289
|
}
|
|
2994
5290
|
|
|
2995
5291
|
/**
|
|
2996
|
-
*
|
|
2997
|
-
*
|
|
2998
|
-
*
|
|
5292
|
+
* 清空传输记录。
|
|
5293
|
+
*
|
|
5294
|
+
* ⚠️ 只清「记录」,不删网盘/本地文件 —— 宿主那边也只动内存里的任务表。
|
|
5295
|
+
* 正在传输的任务不会被动,所以这个操作随时可以做,不会打断下载。
|
|
5296
|
+
* 二次确认是因为「清空」听起来像删东西,用户需要知道它不删文件。
|
|
2999
5297
|
*/
|
|
3000
|
-
function
|
|
3001
|
-
|
|
3002
|
-
if (
|
|
3003
|
-
|
|
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
|
-
}
|
|
5298
|
+
function clearHistory() {
|
|
5299
|
+
if (historyBusy) return
|
|
5300
|
+
if (typeof window !== 'undefined' && typeof window.confirm === 'function') {
|
|
5301
|
+
if (!window.confirm(t('historyClearConfirm'))) return
|
|
3010
5302
|
}
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
5303
|
+
setHistoryBusy(true)
|
|
5304
|
+
api
|
|
5305
|
+
.clearTransfers()
|
|
5306
|
+
.then(function (doc) {
|
|
5307
|
+
setHistoryBusy(false)
|
|
5308
|
+
if (!doc || doc.ok === false) {
|
|
5309
|
+
setListError((doc && doc.error) || t('actFailed', { detail: '' }))
|
|
5310
|
+
return
|
|
5311
|
+
}
|
|
5312
|
+
// 立刻重拉一次,让列表反映真实的剩余记录(正在跑的还在)
|
|
5313
|
+
return fetchTransfers().then(function () {
|
|
5314
|
+
var removed = Number(doc.removed) || 0
|
|
5315
|
+
if (removed > 0) {
|
|
5316
|
+
setNotice(t('historyCleared', { count: removed }))
|
|
5317
|
+
setTimeout(function () { setNotice('') }, 2400)
|
|
5318
|
+
}
|
|
5319
|
+
})
|
|
5320
|
+
})
|
|
5321
|
+
.catch(function (failure) {
|
|
5322
|
+
setHistoryBusy(false)
|
|
5323
|
+
setListError(messageOf(failure))
|
|
5324
|
+
})
|
|
3021
5325
|
}
|
|
3022
5326
|
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
5327
|
+
/** 下载目录弹层:手填 + 系统选择目录。 */
|
|
5328
|
+
function renderDirModal() {
|
|
5329
|
+
if (!dirModal) return null
|
|
5330
|
+
var body = h('div', { className: 'mc-panel' },
|
|
5331
|
+
h('div', { className: 'mc-exModalHead' },
|
|
5332
|
+
h('h2', { className: 'mc-exModalTitle' }, t('downloadDir')),
|
|
5333
|
+
h('button', {
|
|
5334
|
+
type: 'button',
|
|
5335
|
+
className: 'mc-btn mc-btnSmall mc-panelClose',
|
|
5336
|
+
title: t('cancel'),
|
|
5337
|
+
onClick: closeDirModal,
|
|
5338
|
+
}, '×')
|
|
3035
5339
|
),
|
|
3036
|
-
|
|
5340
|
+
h('div', { className: 'mc-exModalRow' },
|
|
3037
5341
|
h('input', {
|
|
3038
5342
|
className: 'mc-input',
|
|
3039
5343
|
type: 'text',
|
|
3040
5344
|
spellCheck: false,
|
|
3041
5345
|
autoComplete: 'off',
|
|
3042
|
-
value:
|
|
3043
|
-
placeholder:
|
|
3044
|
-
'aria-label': t('
|
|
3045
|
-
onChange: function (event) {
|
|
3046
|
-
onKeyDown: onEnterKey(
|
|
5346
|
+
value: dirDraft,
|
|
5347
|
+
placeholder: 'D:\\Downloads',
|
|
5348
|
+
'aria-label': t('downloadDir'),
|
|
5349
|
+
onChange: function (event) { setDirDraft(event.target.value) },
|
|
5350
|
+
onKeyDown: onEnterKey(saveDownloadDir),
|
|
3047
5351
|
}),
|
|
3048
|
-
h('
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
5352
|
+
h('button', {
|
|
5353
|
+
type: 'button',
|
|
5354
|
+
className: 'mc-btn mc-btnSmall',
|
|
5355
|
+
disabled: dirPickBusy,
|
|
5356
|
+
onClick: pickDownloadDir,
|
|
5357
|
+
}, dirPickBusy ? t('loading') : t('chooseDir'))
|
|
5358
|
+
),
|
|
5359
|
+
h('p', { className: 'mc-exModalNote' }, t('downloadDirHint')),
|
|
5360
|
+
dirError ? h('div', { className: 'mc-banner mc-bannerErr' }, dirError) : null,
|
|
5361
|
+
h('div', { className: 'mc-panelFoot' },
|
|
5362
|
+
h('button', {
|
|
5363
|
+
type: 'button',
|
|
5364
|
+
className: 'mc-btn mc-btnPrimary',
|
|
5365
|
+
onClick: saveDownloadDir,
|
|
5366
|
+
}, t('save')),
|
|
5367
|
+
h('button', {
|
|
5368
|
+
type: 'button',
|
|
5369
|
+
className: 'mc-btn',
|
|
5370
|
+
onClick: function () {
|
|
5371
|
+
setDirDraft(status && status.defaultDownloadDir ? status.defaultDownloadDir : '')
|
|
5372
|
+
},
|
|
5373
|
+
}, t('resetDefault')),
|
|
5374
|
+
h('button', {
|
|
5375
|
+
type: 'button',
|
|
5376
|
+
className: 'mc-btn',
|
|
5377
|
+
onClick: closeDirModal,
|
|
5378
|
+
}, t('cancel'))
|
|
5379
|
+
)
|
|
3069
5380
|
)
|
|
5381
|
+
return h('div', {
|
|
5382
|
+
className: 'mc-overlay',
|
|
5383
|
+
onMouseDown: function (event) {
|
|
5384
|
+
if (event.target === event.currentTarget) closeDirModal()
|
|
5385
|
+
},
|
|
5386
|
+
}, body)
|
|
3070
5387
|
}
|
|
3071
5388
|
|
|
3072
5389
|
// ------------------------------------------------------ 文件浏览
|
|
@@ -3098,142 +5415,8 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
3098
5415
|
}, crumbs)
|
|
3099
5416
|
}
|
|
3100
5417
|
|
|
3101
|
-
|
|
3102
|
-
|
|
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 这一段)。 */
|
|
5418
|
+
// ------------------------------------------------------ 下载目录 / 上传 / 传输
|
|
5419
|
+
/** 拖拽上传的逐文件进度行(浏览器 → Host 这一段)。 */
|
|
3237
5420
|
function renderUrlLines() {
|
|
3238
5421
|
if (!urlItems || urlItems.length === 0) return null
|
|
3239
5422
|
return h('div', { className: 'mc-urlLines' }, urlItems.map(function (entry) {
|
|
@@ -3428,72 +5611,7 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
3428
5611
|
)
|
|
3429
5612
|
}
|
|
3430
5613
|
|
|
3431
|
-
|
|
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 条。 */
|
|
5614
|
+
/** 新的在前,最多 TRANSFER_VISIBLE 条。 */
|
|
3497
5615
|
function sortedJobs(list) {
|
|
3498
5616
|
var copy = (Array.isArray(list) ? list.slice() : []).filter(function (job) {
|
|
3499
5617
|
return job && job.id
|
|
@@ -3504,67 +5622,98 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__ && typeof window.__
|
|
|
3504
5622
|
return copy.slice(0, TRANSFER_VISIBLE)
|
|
3505
5623
|
}
|
|
3506
5624
|
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
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))
|
|
5625
|
+
// ------------------------------------------------------ 组装
|
|
5626
|
+
//
|
|
5627
|
+
// 两条完全不同的形态:
|
|
5628
|
+
// · 未登录 / 首屏加载 → 原来的居中卡片流(登录页按用户要求**不改**)
|
|
5629
|
+
// · 已登录 → 资源管理器(顶部栏 + 左树 + 右列表 + 底部传输条)
|
|
5630
|
+
if (!status && statusLoading) {
|
|
5631
|
+
return h('div', { className: 'mc-root' },
|
|
5632
|
+
h('div', { className: 'mc-loading' }, t('loading'))
|
|
3522
5633
|
)
|
|
3523
5634
|
}
|
|
3524
5635
|
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
h('div', { className: 'mc-footer' },
|
|
3539
|
-
t('host') + ': ' + ((status && status.host) || '—') +
|
|
3540
|
-
' · ' + t('credentialPath') + ': ' + ((status && status.credentialPath) || '—')
|
|
3541
|
-
)
|
|
5636
|
+
if (!authorized) {
|
|
5637
|
+
return h('div', { className: 'mc-root' },
|
|
5638
|
+
h('div', { className: 'mc-head' },
|
|
5639
|
+
h('div', {},
|
|
5640
|
+
h('h1', { className: 'mc-title' }, t('title')),
|
|
5641
|
+
h('p', { className: 'mc-subtitle' }, t('subtitle'))
|
|
5642
|
+
)
|
|
5643
|
+
),
|
|
5644
|
+
statusError ? h('div', { className: 'mc-banner mc-bannerErr' }, statusError) : null,
|
|
5645
|
+
h('div', { className: 'mc-stack' }, renderAuthCard()),
|
|
5646
|
+
renderMenu(),
|
|
5647
|
+
renderUploadPanel(),
|
|
5648
|
+
renderMovePicker()
|
|
3542
5649
|
)
|
|
3543
5650
|
}
|
|
3544
5651
|
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
5652
|
+
// ---- 已登录:资源管理器 ----
|
|
5653
|
+
/**
|
|
5654
|
+
* 根节点高度。
|
|
5655
|
+
*
|
|
5656
|
+
* 🔴 这里踩过大坑:CSS 的 `height:100%` 只有在**父元素有确定高度**时才生效。
|
|
5657
|
+
* DSH 的 view 容器不一定给高度,于是 `height:100%` 静默失效,
|
|
5658
|
+
* 面板按内容高度撑开 → 整个面板变成一条长页面 →
|
|
5659
|
+
* · 列表内部永远滚不起来(它根本没被压缩);
|
|
5660
|
+
* · 底部进度条被推到内容最底下,要拖外层滚动条到底才看得见。
|
|
5661
|
+
* 这完全对得上用户看到的现象。
|
|
5662
|
+
*
|
|
5663
|
+
* 所以这里改成:挂载后**实测父容器的像素高度**,直接写死到根节点上,
|
|
5664
|
+
* 并用 ResizeObserver 跟随侧栏折叠/窗口缩放变化。CSS 的 100% 只当兜底。
|
|
5665
|
+
*/
|
|
5666
|
+
return h('div', {
|
|
5667
|
+
className: 'mc-root mc-root-ide',
|
|
5668
|
+
ref: ideRootRef,
|
|
5669
|
+
style: ideHeight ? { height: ideHeight + 'px', minHeight: ideHeight + 'px' } : undefined,
|
|
5670
|
+
/**
|
|
5671
|
+
* ⚠️ 这里**不要**挂 onWheel 去拦滚轮。
|
|
5672
|
+
*
|
|
5673
|
+
* 之前试过:为了不让滚动链把外层对话页带跑,在根节点上
|
|
5674
|
+
* stopPropagation 掐掉滚轮。结果列表直接滚不动了 ——
|
|
5675
|
+
* 滚动虽然主要是浏览器默认行为,但一旦中途 stopPropagation,
|
|
5676
|
+
* 依赖冒泡的滚动逻辑(以及某些输入法/手势路径)就断了。
|
|
5677
|
+
*
|
|
5678
|
+
* 现在改成纯 CSS 方案:所有滚动容器加 `overscroll-behavior: contain`,
|
|
5679
|
+
* 既能断开滚动链,又完全不干预原生滚动。
|
|
5680
|
+
*/
|
|
5681
|
+
},
|
|
5682
|
+
h('div', { className: 'mc-explorer' },
|
|
5683
|
+
renderExTop(),
|
|
5684
|
+
// 重新授权时把登录卡以浮层形式盖上来,不打乱布局
|
|
5685
|
+
reauthOpen ? h('div', {
|
|
5686
|
+
className: 'mc-overlay',
|
|
5687
|
+
onMouseDown: function (event) {
|
|
5688
|
+
if (event.target === event.currentTarget) setReauthOpen(false)
|
|
5689
|
+
},
|
|
5690
|
+
}, h('div', { className: 'mc-panel' },
|
|
5691
|
+
h('div', { className: 'mc-exModalHead' },
|
|
5692
|
+
h('h2', { className: 'mc-exModalTitle' }, t('reauthorize')),
|
|
5693
|
+
h('button', {
|
|
5694
|
+
type: 'button',
|
|
5695
|
+
className: 'mc-btn mc-btnSmall mc-panelClose',
|
|
5696
|
+
title: t('cancel'),
|
|
5697
|
+
onClick: function () { setReauthOpen(false) },
|
|
5698
|
+
}, '×')
|
|
5699
|
+
),
|
|
5700
|
+
renderAuthCard()
|
|
5701
|
+
)) : null,
|
|
5702
|
+
statusError ? h('div', { className: 'mc-exBanner mc-exBannerErr' }, statusError) : null,
|
|
5703
|
+
authError ? h('div', { className: 'mc-exBanner mc-exBannerErr' }, authError) : null,
|
|
5704
|
+
h('div', { className: 'mc-exBody' },
|
|
5705
|
+
renderExTree(),
|
|
5706
|
+
renderTreeSplitter(),
|
|
5707
|
+
renderExMain()
|
|
3550
5708
|
),
|
|
3551
|
-
|
|
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
|
-
)
|
|
5709
|
+
renderExTransfers()
|
|
3559
5710
|
),
|
|
3560
|
-
|
|
3561
|
-
body,
|
|
3562
|
-
// 浮层菜单挂在最后,fixed 定位,不会把面板挤变形。
|
|
5711
|
+
// 浮层:菜单 / 上传 / 转移 / 下载目录 / 历史记录
|
|
3563
5712
|
renderMenu(),
|
|
3564
|
-
// 上传面板再压在最后:它的浮层 z-index 比菜单高。
|
|
3565
5713
|
renderUploadPanel(),
|
|
3566
|
-
|
|
3567
|
-
|
|
5714
|
+
renderMovePicker(),
|
|
5715
|
+
renderDirModal(),
|
|
5716
|
+
renderHistoryModal()
|
|
3568
5717
|
)
|
|
3569
5718
|
}
|
|
3570
5719
|
}
|