deepclean-mydisk 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -6,7 +6,7 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  const React = require("react");
8
8
  const h = React.createElement;
9
- const { useState, useEffect, useCallback, useRef } = React;
9
+ const { useState, useEffect, useCallback, useRef, useId } = React;
10
10
 
11
11
  const PANEL_ID = "deepclean";
12
12
  const API = "/dsh-deepclean/api";
@@ -111,6 +111,17 @@ window.__ModuleLoader__.load({
111
111
  "文件在{trash};永久删除后可释放(约 {size})": "Files are in {trash}; deleting them permanently frees about {size}",
112
112
  "恢复此批次": "Restore this batch", "已恢复/已清空": "Restored/emptied",
113
113
  "已永久删除": "Permanently deleted", "已清空": "Emptied",
114
+ "已恢复": "Restored", "仍可恢复": "Still restorable", "去向未记录": "Outcome unrecorded",
115
+ "文件流向": "File flow", "清理过的文件,后来去了哪里?": "See where your cleaned files went.",
116
+ "移入{trash}": "Moved to {trash}", "查看批次内容": "View batch contents",
117
+ "当前没有可恢复的批次": "No batches to restore", "全部清理记录": "All cleanup records",
118
+ "显示最近 20 个批次,以及更早仍可恢复的批次。": "Shows the latest 20 batches and any older batches you can still restore.",
119
+ "暂无清理记录。清理后,可在这里查看文件去向并恢复。": "No cleanup records yet. After a cleanup, come here to see where files went and restore them.",
120
+ "{n} 项可恢复 · {batches} 批": "{n} restorable items · {batches} batches",
121
+ "约 {size}": "About {size}", "大小未记录": "Size unrecorded", "时间未记录": "Time unrecorded",
122
+ "蓝色延伸到今天的流带表示文件仍可恢复。容量为清理时的估算。": "Blue ribbons reaching today represent files you can still restore. Sizes are estimates from cleanup.",
123
+ "缺少大小的批次用虚线表示。": "Dashed ribbons indicate batches with missing size data.",
124
+ "清理批次": "Cleanup batch", "今天": "Today",
114
125
  // 空间
115
126
  "空间分析": "Storage analysis", "找到占用来源,按需整理或移动。": "Find what uses storage. Organize or move what you choose.",
116
127
  "空间视图": "Storage view", "目录分布": "Folders", "大文件": "Large files", "目录导航": "Folder navigation",
@@ -119,10 +130,10 @@ window.__ModuleLoader__.load({
119
130
  "按文件占用统计;共享文件和系统保留空间可能造成差异。": "Allocated file storage; shared files and system reserves can cause differences.",
120
131
  "正在统计目录,请稍候…": "Counting folder usage, please wait…", "停止加载": "Stop loading", "已停止加载,可重试。": "Stopped. You can retry.",
121
132
  "未能读取 {n} 项,跳过链接 {s} 项;统计可能不完整。": "Could not read {n} items; skipped {s} links. Totals may be incomplete.",
122
- "已跳过云盘目录 {n} 个:云盘文件不参与搬家或去重。": "Skipped {n} cloud folders: cloud files are excluded from moving and dedupe.",
123
- "当前目录分布": "Current folder usage", "当前已统计": "Counted in this folder", "其他": "Other",
133
+ "已跳过云盘目录 {n} 个:云盘文件不参与搬家或去重。": "Skipped cloud folders: {n} (cloud files are excluded from moving and dedupe).",
134
+ "当前目录分布": "Current folder usage", "其他": "Other", "仅个人文件夹": "Home folder only",
135
+ "已统计 {size}": "Counted {size}", "进入目录": "Open folder", "查看其他项目": "View other items", "目录明细": "Folder details",
124
136
  "其中可清理 {size}": "{size} cleanable", "去清理页处理": "Clean these from the Clean page",
125
- "点击目录或色块进入下一级": "Select a folder or chart segment to explore",
126
137
  "查看此目录的大文件": "Large files in this folder", "打开位置": "Show location", "重新统计": "Refresh totals",
127
138
  "此目录没有可显示的项目": "No items to display in this folder", "部分统计": "Partial total", "显示更多": "Show more",
128
139
  "无法打开位置": "Could not open location", "打开目标文件夹": "Open destination",
@@ -183,8 +194,14 @@ window.__ModuleLoader__.load({
183
194
  "环境": "Environment", "平台": "Platform", "权限": "Permission",
184
195
  "完全磁盘访问已授权": "Full Disk Access granted", "完全磁盘访问未授权": "Full Disk Access not granted",
185
196
  "无法确认": "Unknown", "未知": "Unknown",
186
- "未授权完全磁盘访问,扫描结果会严重漏报。": "Full Disk Access not granted — scan results will be severely incomplete.",
187
- "打开系统设置": "Open System Settings", "查看详细步骤": "Show detailed steps", "收起详细步骤": "Hide detailed steps",
197
+ "扫描前需要开启完全磁盘访问权限": "Full Disk Access is required to scan",
198
+ "扫描已停止,缺少完全磁盘访问权限": "Scan stopped because Full Disk Access is off",
199
+ "macOS 限制了部分文件夹的访问。请为 DeepSeek Harness 开启权限,然后退出并重新打开应用。": "macOS restricts access to some folders. Turn on Full Disk Access for DeepSeek Harness, then quit and reopen the app.",
200
+ "打开系统设置": "Open System Settings", "查看开启步骤": "Show setup steps", "收起步骤": "Hide setup steps",
201
+ "请前往「系统设置」→「隐私与安全」→「完全磁盘访问权限」。": "Go to System Settings → Privacy & Security → Full Disk Access.",
202
+ "打开 DeepSeek Harness 的开关。如果列表里没有它,点击「+」,在「应用程序」中选中 DeepSeek Harness,再点「打开」。": "Turn on DeepSeek Harness. If it is missing from the list, click +, select DeepSeek Harness in Applications, then click Open.",
203
+ "按 ⌘Q 退出 DeepSeek Harness,再重新打开。回到「磁盘体检」,点击「开始扫描」。": "Press ⌘Q to quit DeepSeek Harness, then reopen it. Return to Disk checkup and click Start scan.",
204
+ "未能打开系统设置,请按下方路径手动打开。": "Could not open System Settings. Follow the path below to open it manually.",
188
205
  "重新检查": "Recheck",
189
206
  "审计日志": "Audit log", "历史文件": "History file", "保护名单文件": "Protected-paths file", "立即扫描": "Scan now",
190
207
  // 提示与通知(客户端生成,创建时已按当前语言翻译)
@@ -226,6 +243,10 @@ window.__ModuleLoader__.load({
226
243
  "已复制提示词,打开对话后粘贴发送": "Prompt copied — paste it into a conversation to send",
227
244
  "AI 入口不可用(当前环境没有会话服务)": "AI entry unavailable (no session service in this environment)",
228
245
  "AI 发送失败,请重试": "Sending to the conversation failed. Try again.",
246
+ "无法复制提示词,请打开 DSH 对话后重试。": "Could not copy the prompt. Open a DSH conversation and try again.",
247
+ "找不到可用的 Python 3": "No usable Python 3 was found",
248
+ "Windows 扫描需要 Python 3.9 或更新版本。可以把安装请求发送到 DSH,在对话中确认安装。安装后重启 DSH,再试一次。": "Scanning on Windows requires Python 3.9 or later. Send an installation request to DSH and confirm it in the conversation. After installation, restart DSH and try again.",
249
+ "让 DSH 帮我安装 Python": "Ask DSH to install Python",
229
250
  "解释这一项": "Explain this item", "AI 证据": "AI evidence",
230
251
  "AI 将看到以下脱敏证据,不会收到完整路径:": "AI will see the following redacted evidence; full paths are not sent:",
231
252
  "AI 只做只读解释,不改变分档与勾选。": "AI only explains; it cannot change risk tiers or selections.",
@@ -242,6 +263,26 @@ window.__ModuleLoader__.load({
242
263
  "当前体检里再次出现,说明清理后会重新生成,或上次清理不彻底。": "Present again in the current checkup: it either regenerates after cleaning or the last clean was partial.",
243
264
  "更早的清理批次没有记录大小": "Older clean batches did not record sizes",
244
265
  "历史趋势数据来自本机的扫描与清理审计;大小由程序统计。": "Trends come from local scan and clean audit records; sizes are computed by the app.",
266
+ // 空间页:目录统计的跳过项、硬链接口径与错误码
267
+ "未能读取 {n} 项": "Unreadable items: {n}",
268
+ "跳过链接或系统别名 {n} 项": "Skipped links or system aliases: {n}",
269
+ "统计可能不完整。": "Totals may be incomplete.",
270
+ "跳过云占位文件 {n} 个:未下载到本地,不计入大小。": "Skipped cloud placeholder files: {n} (not downloaded locally, so not counted).",
271
+ "硬链接 {n} 个;重复部分 {size} 只计一次。": "Hard links: {n}; the duplicated part ({size}) is counted once.",
272
+ "硬链接 {n} 个;移动只减少引用,不一定释放空间。": "Hard links: {n}; moving one only drops a reference and may not free space.",
273
+ "硬链接": "Hard link",
274
+ "硬链接(大小已计入其他位置)": "Hard link (size already counted elsewhere)",
275
+ "含硬链接文件:移动后原位置仍可能有其他链接,释放的空间可能少于已选大小。": "Selected files include hard links: other links may remain, so less space may be freed than selected.",
276
+ "最多 {n} 个": "up to {n}",
277
+ "仅 ≥1 MB": "≥1 MB only",
278
+ "显示 {n} 个文件": "{n} files shown",
279
+ "查看明细": "View details",
280
+ "目录不存在或已被删除,请返回上一级后重试。": "The folder no longer exists. Go back one level and retry.",
281
+ "无权读取该目录,请检查权限后重试。": "Cannot read this folder. Check permissions and retry.",
282
+ "只允许浏览个人文件夹。": "Only your home folder can be browsed.",
283
+ "链接或个人文件夹之外的路径不可浏览。": "Links or paths outside your home folder cannot be browsed.",
284
+ "目录统计超时,已停止;请重试或缩小范围。": "Counting the folder timed out. Retry or pick a smaller folder.",
285
+ "目录统计失败或无权读取,请检查权限后重试。": "Counting the folder failed or access was denied. Check permissions and retry.",
245
286
  };
246
287
 
247
288
  function tr(lang, s) { return lang === "en" ? (EN[s] || s) : s; }
@@ -324,6 +365,23 @@ window.__ModuleLoader__.load({
324
365
  return out;
325
366
  }
326
367
  function trNote(lang, s) { return lang === "en" ? noteEn(EN[s] || s) : s; }
368
+ // 目录统计错误:宿主返回稳定 errorCode,客户端按语言本地化;没有码时退回原文
369
+ // (英文再走一次 noteEn,兼容旧宿主的中文原文)。
370
+ function treeErrorText(lang, err) {
371
+ const map = {
372
+ tree_missing: "目录不存在或已被删除,请返回上一级后重试。",
373
+ tree_denied: "无权读取该目录,请检查权限后重试。",
374
+ tree_outside: "只允许浏览个人文件夹。",
375
+ tree_symlink: "链接或个人文件夹之外的路径不可浏览。",
376
+ tree_timeout: "目录统计超时,已停止;请重试或缩小范围。",
377
+ tree_failed: "目录统计失败或无权读取,请检查权限后重试。",
378
+ reveal: "无法打开位置",
379
+ };
380
+ const code = err && err.code;
381
+ if (code && map[code]) return tr(lang, map[code]);
382
+ const message = String((err && err.message) || "");
383
+ return lang === "en" ? noteEn(message) : message;
384
+ }
327
385
  // 清理目的地的平台词:macOS 为废纸篓,Windows 与其他平台为隔离区(见 docs/copy-terms.md)。
328
386
  function trashWord(lang, platform) {
329
387
  if (lang === "en") return platform === "win32" ? "quarantine" : "Trash";
@@ -433,6 +491,13 @@ window.__ModuleLoader__.load({
433
491
  .dc .dc-notice{display:flex;align-items:flex-start;gap:10px;white-space:pre-wrap;overflow-wrap:anywhere;padding:12px 16px;background:var(--dc-tint);border:1px solid var(--dc-line);border-radius:8px;font-size:13px;margin:0 0 20px}
434
492
  .dc .dc-notice span{flex:1;min-width:0}
435
493
  .dc .dc-notice button{padding:0;background:none;border:0}
494
+ .dc .dc-permission{display:flex;align-items:flex-start;gap:10px;padding:12px 16px;background:var(--dc-tint);border:1px solid var(--dc-line);border-radius:8px;font-size:13px;margin:0 0 20px;scroll-margin-top:108px}
495
+ .dc .dc-permission-body{flex:1;min-width:0}
496
+ .dc .dc-permission-body p{margin-top:4px}
497
+ .dc .dc-permission-actions{display:flex;align-items:center;gap:4px 14px;flex-wrap:wrap;margin-top:10px}
498
+ .dc .dc-permission .dc-button{min-height:34px;padding:6px 12px}
499
+ .dc .dc-permission ol{margin:10px 0 0;padding-inline-start:20px}
500
+ .dc .dc-permission li+li{margin-top:4px}
436
501
  .dc .dc-progress{margin:20px 0}
437
502
  .dc .dc-progress-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;gap:16px}
438
503
  .dc .dc-progress-track{height:6px;border-radius:4px;background:var(--dc-line);overflow:hidden}
@@ -498,6 +563,49 @@ window.__ModuleLoader__.load({
498
563
  .dc .dc-confirm strong{display:block;margin-bottom:6px}
499
564
  .dc .dc-confirm-actions{display:flex;gap:12px;margin-top:12px}
500
565
  .dc .dc-legacy-page{padding-bottom:32px}
566
+ .dc .dc-history{container-type:inline-size}
567
+ .dc .dc-history .dc-page-head{align-items:center;margin-bottom:24px}
568
+ .dc .dc-flow-total{text-align:end;display:grid;justify-items:end}
569
+ .dc .dc-flow-total strong{color:var(--dc-blue);font-size:clamp(24px,4.5cqw,46px);line-height:1.12;letter-spacing:-.04em}
570
+ .dc .dc-flow-axis,.dc .dc-flow-row{display:grid;grid-template-columns:minmax(100px,1fr) minmax(0,4.5fr) minmax(122px,1.2fr);gap:14px;align-items:center}
571
+ .dc .dc-flow-axis{margin-bottom:8px}
572
+ .dc .dc-flow-dates{position:relative;height:26px;color:var(--dc-muted);font-size:12px;white-space:nowrap}
573
+ .dc .dc-flow-dates time{position:absolute;transform:translateX(-50%)}
574
+ .dc .dc-flow-dates time:first-child{transform:none}
575
+ .dc .dc-flow-dates time:last-child{transform:translateX(-100%)}
576
+ .dc .dc-flow-row{width:100%;text-align:start;border:1px solid transparent;border-radius:12px;background:transparent;color:var(--dc-text);padding:0;min-height:88px}
577
+ .dc .dc-flow-row[aria-pressed=true]{border-color:var(--dc-line);background:var(--dc-tint)}
578
+ .dc .dc-flow-row svg{display:block;width:100%;height:clamp(88px,11cqw,152px);overflow:visible}
579
+ .dc .dc-flow-source{padding-inline-start:12px;overflow-wrap:anywhere}
580
+ .dc .dc-flow-source strong,.dc .dc-flow-source time{display:block}
581
+ .dc .dc-flow-value{font-size:15px;font-weight:650}
582
+ .dc .dc-flow-ends{display:grid;gap:5px;padding-inline-end:9px}
583
+ .dc .dc-flow-status{display:block;overflow-wrap:anywhere;font-size:12px;padding:5px 9px;border-radius:8px;background:var(--dc-tint)}
584
+ .dc .dc-flow-ends .dc-flow-live{color:var(--dc-blue);font-weight:650;background:var(--dc-select)}
585
+ .dc .dc-flow-branch{transition:opacity .2s ease}
586
+ .dc .dc-flow-row[aria-pressed=true] .dc-flow-branch{filter:drop-shadow(0 1px 4px color-mix(in srgb,var(--dc-blue) 24%,transparent))}
587
+ .dc .dc-flow-note{display:grid;gap:4px;margin-top:10px}
588
+ .dc .dc-flow-detail{background:var(--dc-select);border:1px solid var(--dc-line);border-radius:14px;padding:16px 18px;margin:20px 0}
589
+ .dc .dc-flow-detail-head{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
590
+ .dc .dc-flow-detail-head h2{margin-bottom:2px}
591
+ .dc .dc-flow-events,.dc .dc-flow-controls{display:flex;align-items:baseline;flex-wrap:wrap;gap:6px 16px;margin:8px 0}
592
+ .dc .dc-flow-controls:empty{display:none}
593
+ .dc .dc-flow-delete{color:var(--dc-muted)}
594
+ .dc .dc-flow-detail summary,.dc .dc-history > details > summary{width:fit-content;color:var(--dc-blue)}
595
+ .dc .dc-history > details{border-top:1px solid var(--dc-line);padding:13px 0}
596
+ .dc .dc-flow-items,.dc .dc-flow-archive{list-style:none;padding:0;margin:8px 0}
597
+ .dc .dc-flow-items li{overflow-wrap:anywhere;padding:4px 0}
598
+ .dc .dc-flow-archive button{width:100%;display:flex;justify-content:space-between;align-items:baseline;gap:4px 16px;flex-wrap:wrap;border:0;border-bottom:1px solid var(--dc-line);background:transparent;color:var(--dc-text);padding:9px 0;text-align:start}
599
+ .dc .dc-flow-archive button[aria-pressed=true]{color:var(--dc-blue);font-weight:600}
600
+ .dc .dc-flow-empty{padding:28px 18px;background:var(--dc-tint);border-radius:12px}
601
+ @container (max-width:520px){
602
+ .dc .dc-flow-total{text-align:start;justify-items:start}
603
+ .dc .dc-flow-axis,.dc .dc-flow-row svg{display:none}
604
+ .dc .dc-flow-row{grid-template-columns:minmax(0,1fr) minmax(0,1.2fr);gap:12px;min-height:66px;margin-bottom:4px;padding:8px 2px}
605
+ .dc .dc-flow-source{border-inline-start:2px solid var(--dc-line);margin-inline-start:10px;padding-inline-start:8px}
606
+ .dc .dc-flow-row[aria-pressed=true] .dc-flow-source{border-color:var(--dc-blue)}
607
+ }
608
+ @media(prefers-reduced-motion:reduce){.dc .dc-flow-branch{transition:none}}
501
609
  .dc .dc-wizard{margin-bottom:26px}
502
610
  .dc .dc-wizard button{font-size:13px;min-height:34px}
503
611
  .dc .dc-wizard label{min-height:39px}
@@ -563,11 +671,8 @@ window.__ModuleLoader__.load({
563
671
  .dc .dc-button,.dc .dc-field,.dc .dc-select{border-color:var(--dc-line)}}
564
672
 
565
673
 
566
- .dc .dc-space-grid{display:grid;grid-template-columns:250px minmax(0,1fr);gap:32px;align-items:start;margin-top:22px}
567
674
  .dc .dc-space-crumbs,.dc .dc-space-tools{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin-bottom:14px}
568
675
  .dc .dc-space-entry{display:flex;align-items:center;gap:8px;background:none;border:0;color:inherit;text-align:left;padding:8px 0;overflow-wrap:anywhere;cursor:pointer;min-height:40px}
569
- .dc .dc-space-legend{display:flex;align-items:center;gap:8px;font-size:13px;padding:7px 0;overflow-wrap:anywhere}
570
- .dc .dc-space-legend>span{width:10px;height:10px;border-radius:3px;flex-shrink:0}.dc .dc-space-legend strong{margin-left:auto}
571
676
  .dc .dc-space-bar{height:3px;background:var(--dc-line);margin-top:7px;min-width:55px}.dc .dc-space-bar span{display:block;height:100%;background:var(--dc-blue)}
572
677
  .dc .dc-space-cleanable{display:block;background:none;border:0;color:var(--dc-green);font-size:11px;padding:2px 0 0;cursor:pointer;text-align:left}
573
678
  .dc .dc-space-actions{position:sticky;bottom:0;background:var(--dc-bg);border-top:1px solid var(--dc-line);padding:16px 0;display:flex;gap:12px;flex-wrap:wrap;align-items:center}
@@ -576,7 +681,54 @@ window.__ModuleLoader__.load({
576
681
  .dc .dc-space-filters input{min-width:180px;flex:1}
577
682
  .dc .dc-space-filters input,.dc .dc-space-filters select,.dc .dc-space-actions input,.dc .dc-space-actions select{font:inherit;font-size:13px;padding:9px;border:1px solid var(--dc-line);border-radius:7px;background:var(--dc-bg);color:var(--dc-text)}
578
683
  .dc button:focus-visible,.dc input:focus-visible,.dc select:focus-visible{outline:2px solid var(--dc-blue);outline-offset:3px}
579
- @media(max-width:760px){.dc .dc-space-grid{grid-template-columns:minmax(0,1fr)}.dc .dc-space-grid aside{max-width:300px}.dc .dc-space-filters>*{max-width:100%}}
684
+ @media(max-width:760px){.dc .dc-space-filters>*{max-width:100%}}
685
+
686
+ .dc.dc-tide-view{background:#071c38}
687
+ .dc-tide-view .dc-content{--dc-tide-inset:48px;--dc-bg:#071c38;--dc-text:#f4f8ff;--dc-muted:#adbed7;--dc-line:#294768;--dc-tint:#112b4c;--dc-blue:#7cdbff;--dc-green:#99e8d1;color:var(--dc-text)}
688
+ .dc-tide-view .dc-page-head{align-items:baseline;margin-bottom:16px}
689
+ .dc-tide-view .dc-page-head h1{margin-bottom:0}
690
+ .dc .dc-tide-scope{display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:4px 16px;color:var(--dc-muted)}
691
+ .dc .dc-tide-scope .dc-space-crumbs{margin:0;gap:8px;min-width:0;scroll-margin-top:108px}
692
+ .dc .dc-tide-scope .dc-link{color:inherit;white-space:normal;overflow-wrap:anywhere;text-align:left}
693
+ .dc .dc-tide-total{color:var(--dc-text);white-space:nowrap;font-variant-numeric:tabular-nums}
694
+ .dc .dc-tide-loading-status{flex:1 1 180px;min-width:0;overflow-wrap:anywhere}
695
+ .dc .dc-tide-loading-wave{display:block;width:100%;height:158px;margin:8px auto 2px}
696
+ .dc .dc-tide-loading-crest{animation:dc-tide-loading-swell 3.8s ease-in-out infinite alternate}
697
+ .dc .dc-tide-loading-current{stroke-dasharray:19 81;animation:dc-tide-loading-flow 3.1s linear infinite}
698
+ .dc .dc-tide-map{position:relative;height:clamp(280px,52vh,450px);margin-inline:calc(-1 * var(--dc-tide-inset));overflow:hidden;animation:dc-tide-enter .23s ease-out}
699
+ .dc .dc-tide-map svg{display:block;width:100%;height:100%}
700
+ .dc .dc-tide-band{cursor:pointer}
701
+ .dc .dc-tide-band:hover{filter:brightness(1.1)}
702
+ .dc .dc-tide-crest{filter:drop-shadow(0 0 5px #54dcff99)}
703
+ .dc .dc-tide-label{position:absolute;left:var(--dc-tide-inset);max-width:calc(100% - 2 * var(--dc-tide-inset));transform:translateY(-50%);text-align:left}
704
+ .dc .dc-tide-label,.dc .dc-tide-small button{border:0;background:none;color:#fff;padding:2px 4px;border-radius:4px;line-height:1.15}
705
+ .dc .dc-tide-label{border-left:3px solid #b6eaff80;padding-left:8px}
706
+ .dc .dc-tide-label[aria-pressed=true]{border-left-color:#9aefff}
707
+ .dc .dc-tide-label strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:clamp(16px,4vw,21px)}
708
+ .dc .dc-tide-label span{white-space:nowrap;font-size:13px;font-variant-numeric:tabular-nums}
709
+ .dc .dc-tide-label.dc-tide-hero strong{font-size:clamp(23px,6vw,36px)}
710
+ .dc .dc-tide-label.dc-tide-hero span{font-size:clamp(14px,4vw,19px);font-weight:600}
711
+ .dc .dc-tide-label.dc-tide-compact{display:flex;align-items:baseline;gap:8px}
712
+ .dc .dc-tide-label.dc-tide-compact strong{font-size:13px}
713
+ .dc .dc-tide-small{display:flex;gap:8px 12px;flex-wrap:wrap;margin:0 0 10px}
714
+ .dc .dc-tide-small button{display:inline-flex;align-items:center;gap:6px;min-height:30px;font-size:12px;color:var(--dc-muted)}
715
+ .dc .dc-tide-small button span{width:8px;height:8px;border-radius:50%;flex-shrink:0}
716
+ .dc .dc-tide-small button[aria-pressed=true]{color:#fff;text-decoration:underline;text-underline-offset:3px}
717
+ .dc .dc-tide-selection{display:flex;align-items:center;gap:10px;padding:10px 0;border-top:1px solid var(--dc-line)}
718
+ .dc .dc-tide-selection-text{min-width:0;flex:1;overflow-wrap:anywhere}
719
+ .dc .dc-tide-selection-text strong{font-size:17px}
720
+ .dc .dc-tide-selection-text small{color:var(--dc-muted);font-size:12px}
721
+ .dc .dc-tide-selection .dc-space-cleanable{font-size:12px}
722
+ .dc .dc-tide-selection .dc-primary{color:#fff;background:#1658d3;border-color:#1658d3;border-radius:22px;white-space:normal;max-width:46%}
723
+ .dc .dc-tide-details{scroll-margin-top:108px;padding:12px 0 26px}
724
+ .dc .dc-tide-details .dc-space-tools{margin-top:10px}
725
+ @keyframes dc-tide-enter{from{opacity:.55;transform:scaleY(.97)}to{opacity:1;transform:scaleY(1)}}
726
+ @keyframes dc-tide-loading-swell{from{transform:translate(-14px,3px)}to{transform:translate(14px,-4px)}}
727
+ @keyframes dc-tide-loading-flow{from{stroke-dashoffset:19;opacity:.45}50%{opacity:1}to{stroke-dashoffset:-81;opacity:.45}}
728
+ @media(prefers-reduced-motion:reduce){.dc .dc-tide-map,.dc .dc-tide-loading-crest,.dc .dc-tide-loading-current{animation:none}}
729
+ @media(max-width:1100px){.dc-tide-view .dc-content{--dc-tide-inset:28px}}
730
+ @media(max-width:760px){.dc-tide-view .dc-content{--dc-tide-inset:20px;padding-top:16px}
731
+ .dc-tide-view .dc-page-head{margin-bottom:8px}}
580
732
  `;
581
733
  function Icon({ name, size = 20, color = "currentColor", ...props }) {
582
734
  const svg = (ICONS[name] || ICONS["folder"]).replace(/currentColor/g, color === "currentColor" ? "#667085" : color);
@@ -691,6 +843,9 @@ window.__ModuleLoader__.load({
691
843
  }
692
844
  const seen = new Set();
693
845
  const q = search.trim().toLowerCase();
846
+ // 绝对路径搜索(空间页「其中可清理」会带来目录路径)按路径边界匹配,
847
+ // 否则 /demo/Library 会连带匹配 /demo/LibraryOld。
848
+ const pathQuery = q.length > 1 && /^(\/|[a-z]:[\\/])/.test(q) ? q.replace(/[\\/]+$/, "") : "";
694
849
  const filtered = rows.filter((r) => {
695
850
  const key = r.paths.map(normalizedPath).sort().join("\n");
696
851
  if (seen.has(key)) return false;
@@ -700,7 +855,10 @@ window.__ModuleLoader__.load({
700
855
  if (tab === "cache" && r.bucket !== "cache" && r.bucket !== "dev") return false;
701
856
  if (tab === "keep" && r.bucket !== "system") return false;
702
857
  if (["dev", "browser", "leftover", "app", "special", "other", "system"].includes(tab) && r.category !== tab) return false;
703
- if (q && !(r.name + " " + r.paths.join(" ") + " " + r.note).toLowerCase().includes(q)) return false;
858
+ if (q && pathQuery) {
859
+ const target = pathQuery.replace(/[\\/]+$/, "");
860
+ if (!r.paths.some((p) => insidePath(String(p).toLowerCase(), target))) return false;
861
+ } else if (q && !(r.name + " " + r.paths.join(" ") + " " + r.note).toLowerCase().includes(q)) return false;
704
862
  return true;
705
863
  });
706
864
  filtered.sort((a, b) => {
@@ -847,6 +1005,28 @@ window.__ModuleLoader__.load({
847
1005
  };
848
1006
  }
849
1007
 
1008
+ function PythonSetupNotice({ lang, onSend, onRetry, busy }) {
1009
+ const [sending, setSending] = useState(false);
1010
+ // 环境设置请求走同一会话通道,只发送固定文字,不带扫描结果、路径或错误原文。
1011
+ const send = async () => {
1012
+ setSending(true);
1013
+ const prompt = lang === "en"
1014
+ ? "DeepClean MyDisk could not find a usable Python 3.9 or later on this Windows PC. Please check whether a compatible Python is already installed and reachable through py -3, python, or python3. If it is installed, help me make it available to DSH. If an installation or system change is needed, explain what you will change and wait for my confirmation. Use an official Python distribution. If you cannot act on this Windows PC, give me manual steps. Once a compatible version is available, remind me to restart DSH and retry the scan. Keep the work limited to Python setup."
1015
+ : "DeepClean MyDisk 在这台 Windows 电脑上找不到可用的 Python 3.9 或更新版本。请先检查是否已经安装兼容的 Python,以及 py -3、python 或 python3 能否运行。如果已安装,请帮我让 DSH 找到它。如果确实需要安装或修改系统设置,请先说明会改什么,等我确认后再操作。请使用官方 Python 发行版。如果你无法操作这台 Windows 电脑,请给我手动步骤。确认兼容版本可用后,请提醒我重启 DSH,再回到面板重试扫描。请把操作范围限于 Python 环境设置。";
1016
+ try { await onSend(prompt); }
1017
+ finally { setSending(false); }
1018
+ };
1019
+ return h("section", { className: "dc-permission" },
1020
+ h(Icon, { name: "warning-circle", size: 18 }),
1021
+ h("div", { className: "dc-permission-body" },
1022
+ h("div", { role: "status" },
1023
+ h("strong", null, tr(lang, "找不到可用的 Python 3")),
1024
+ h("p", null, tr(lang, "Windows 扫描需要 Python 3.9 或更新版本。可以把安装请求发送到 DSH,在对话中确认安装。安装后重启 DSH,再试一次。"))),
1025
+ h("div", { className: "dc-permission-actions" },
1026
+ h("button", { type: "button", className: "dc-button dc-primary", disabled: busy || sending, onClick: send }, tr(lang, "让 DSH 帮我安装 Python")),
1027
+ h("button", { type: "button", className: "dc-link", disabled: busy || sending, onClick: onRetry }, tr(lang, "重试")))));
1028
+ }
1029
+
850
1030
  function aiRules(lang) {
851
1031
  return (lang === "en" ? [
852
1032
  "Answer rules:",
@@ -1183,6 +1363,101 @@ window.__ModuleLoader__.load({
1183
1363
  return Number.isFinite(n) ? n : 0;
1184
1364
  }
1185
1365
 
1366
+ const FLOW_STATUS = {
1367
+ restorable: "仍可恢复",
1368
+ restored: "已恢复",
1369
+ deleted: "已永久删除",
1370
+ unknown: "去向未记录",
1371
+ };
1372
+
1373
+ function flowItemStatus(item) {
1374
+ if (item.restored) return "restored";
1375
+ if (item.exists) return "restorable";
1376
+ return item.deleted ? "deleted" : "unknown";
1377
+ }
1378
+
1379
+ function flowRecord(batch, lang) {
1380
+ const items = batch.items || [];
1381
+ const restorable = batch.restorable ?? batch.count ?? 0;
1382
+ const groups = new Map();
1383
+ function add(status, at, kb, count = 1) {
1384
+ const key = status + ":" + at;
1385
+ if (!groups.has(key)) groups.set(key, { key, status, at, count: 0, kb: 0, sized: true });
1386
+ const group = groups.get(key);
1387
+ group.count += count;
1388
+ if (Number.isFinite(kb) && kb >= 0) group.kb += kb;
1389
+ else group.sized = false;
1390
+ }
1391
+ for (const item of items) {
1392
+ const status = flowItemStatus(item);
1393
+ const at = status === "restored" ? item.restoredAt : status === "deleted" ? item.deletedAt : null;
1394
+ add(status, toMs(at), item.kb);
1395
+ }
1396
+ const counted = (status) => [...groups.values()].filter((g) => g.status === status).reduce((n, g) => n + g.count, 0);
1397
+ const unlisted = (batch.count ?? items.length) - items.length;
1398
+ if (unlisted > 0) {
1399
+ const live = Math.min(unlisted, Math.max(0, restorable - counted("restorable")));
1400
+ const restored = Math.min(unlisted - live, Math.max(0, (batch.restored || 0) - counted("restored")));
1401
+ if (live) add("restorable", 0, null, live);
1402
+ if (restored) add("restored", 0, null, restored);
1403
+ if (unlisted > live + restored) add("unknown", 0, null, unlisted - live - restored);
1404
+ }
1405
+ const category = items[0]?.category;
1406
+ const label = category && items.every((item) => item.category === category)
1407
+ ? (category === "photo" ? "相似照片" : AI_CATEGORY_LABEL[category]) : null;
1408
+ const branches = [...groups.values()].sort((a, b) => (a.at || Infinity) - (b.at || Infinity));
1409
+ return { batch, items, restorable, label: tr(lang, label || "清理批次"), at: toMs(batch.at), branches,
1410
+ sized: branches.every((g) => g.sized), kb: branches.reduce((n, g) => n + g.kb, 0) };
1411
+ }
1412
+
1413
+ function flowSize(lang, kb) {
1414
+ return tf(lang, "约 {size}", { size: displaySize(kb) });
1415
+ }
1416
+
1417
+ function flowEvent(lang, group) {
1418
+ const size = group.sized ? " · " + flowSize(lang, group.kb) : "";
1419
+ const date = group.at ? " · " + timeStr(group.at)
1420
+ : ["restored", "deleted"].includes(group.status) ? " · " + tr(lang, "时间未记录") : "";
1421
+ return tr(lang, FLOW_STATUS[group.status]) + " · " + tf(lang, "{n} 项", { n: group.count }) + size + date;
1422
+ }
1423
+
1424
+ function flowRibbon(record, x, ticks, maxKb, index) {
1425
+ const { branches, sized, kb } = record;
1426
+ const weights = branches.map((g) => sized ? g.kb : 1);
1427
+ const total = weights.reduce((n, w) => n + w, 0);
1428
+ const span = sized ? Math.max(4, 44 * kb / maxKb) : 18;
1429
+ let cursor = 44 - span / 2;
1430
+ const start = x(record.at);
1431
+ const gradient = "dc-flow-gradient-" + index;
1432
+ return h("svg", { viewBox: "0 0 1000 88", preserveAspectRatio: "none", "aria-hidden": true },
1433
+ h("defs", null, h("linearGradient", { id: gradient, gradientUnits: "userSpaceOnUse", x1: start, y1: 0, x2: 992, y2: 0 },
1434
+ h("stop", { stopColor: "var(--dc-blue)", stopOpacity: .46 }),
1435
+ h("stop", { offset: "42%", stopColor: "var(--dc-blue)", stopOpacity: .88 }),
1436
+ h("stop", { offset: "100%", stopColor: "var(--dc-blue)" }))),
1437
+ ticks.map((t) => h("line", { key: t, x1: x(t), x2: x(t), y1: 0, y2: 88, stroke: "var(--dc-line)", strokeDasharray: "3 5", vectorEffect: "non-scaling-stroke" })),
1438
+ record.at ? branches.map((g, i) => {
1439
+ const knownEnd = g.status === "restorable" || g.at >= record.at;
1440
+ const end = g.status === "restorable" ? 992 : knownEnd ? x(g.at) : Math.min(992, start + 32);
1441
+ const width = span * (total ? weights[i] / total : 1 / branches.length);
1442
+ const y = cursor + width / 2;
1443
+ cursor += width;
1444
+ const target = branches.length === 1 ? 44 : 17 + 54 * i / (branches.length - 1);
1445
+ const bend = start + (end - start) * .62;
1446
+ const color = g.status === "restorable" ? "url(#" + gradient + ")"
1447
+ : g.status === "restored" ? "var(--dc-blue)" : "var(--dc-muted)";
1448
+ const isLive = g.status === "restorable";
1449
+ const path = isLive && branches.length === 1
1450
+ ? "M" + start + " " + y + " C" + (start + (end - start) * .28) + " " + (y + 16) + " "
1451
+ + (start + (end - start) * .72) + " " + (y - 16) + " " + end + " " + y
1452
+ : "M" + start + " " + y + " C" + bend + " " + y + " " + bend + " " + target + " " + end + " " + target;
1453
+ return h("g", { key: g.key, className: "dc-flow-branch" },
1454
+ h("path", { fill: "none", stroke: color, strokeWidth: Math.max(1, width), opacity: isLive ? 1 : .36,
1455
+ strokeDasharray: sized && knownEnd ? undefined : "11 6", d: path }),
1456
+ isLive && sized && width >= 5 ? h("path", { fill: "none", stroke: "var(--dc-bg)", strokeOpacity: .38,
1457
+ strokeWidth: 1, transform: "translate(0 " + (-width * .39) + ")", d: path }) : null);
1458
+ }) : null);
1459
+ }
1460
+
1186
1461
  function computeFreeTrend(history) {
1187
1462
  const scans = (history || []).filter((h) => h && h.at && Number.isFinite(h.free)).slice(-30);
1188
1463
  if (scans.length < 2) return null;
@@ -1261,53 +1536,108 @@ window.__ModuleLoader__.load({
1261
1536
  return L.join("\n");
1262
1537
  }
1263
1538
 
1264
- const spaceColors = ["#5087eb", "#769e76", "#bc9958", "#967cb5", "#59a3ad", "#a88471", "#8b949e"];
1539
+ const tideColors = [["#078dd5", "#0756af"], ["#555bc1", "#343f91"], ["#147783", "#125663"], ["#23598b", "#163b68"]];
1265
1540
  const percent = (kb, total) => !kb || !total ? "0%" : kb / total < .01 ? "<1%" : `${Math.round(kb / total * 100)}%`;
1266
1541
  function spaceSegments(items) {
1267
1542
  const positive = items.filter(it => it.kb > 0);
1268
- return positive.length <= 7 ? positive : [...positive.slice(0, 6), { name: "其他", tail: true, kb: positive.slice(6).reduce((n, it) => n + it.kb, 0) }];
1543
+ return positive.length <= 4 ? positive : [...positive.slice(0, 3), { name: "其他", tail: true, kb: positive.slice(3).reduce((n, it) => n + it.kb, 0) }];
1544
+ }
1545
+
1546
+ // Both edges use the same curve at different offsets, so a band has its exact share of the plotted area.
1547
+ function tideEdge(y) {
1548
+ return `M0 ${y+8} C85 ${y-12} 165 ${y-18} 250 ${y+3} C372 ${y+33} 448 ${y+38} 580 ${y+3} C729 ${y-37} 864 ${y-39} 1000 ${y-13}`;
1269
1549
  }
1270
- function Sunburst({ items, lang, onPick, active, onActive, onTail }) {
1271
- const total = items.reduce((n, it) => n + it.kb, 0);
1272
- let angle = -Math.PI / 2;
1273
- const point = (r, a) => `${95 + r * Math.cos(a)} ${95 + r * Math.sin(a)}`;
1274
- return h("svg", { width: 230, height: 230, viewBox: "0 0 190 190", role: "img", "aria-label": tr(lang, "当前目录分布") },
1275
- spaceSegments(items).map((it, i) => {
1276
- const start = angle, end = angle += it.kb / (total || 1) * Math.PI * 2;
1277
- // Two half arcs also render correctly for a directory containing one item.
1278
- const mid = (start + end) / 2;
1279
- const arc = (r, x, y) => `A${r} ${r} 0 0 1 ${point(r, y)}`;
1280
- const d = `M${point(84, start)} ${arc(84,start,mid)} ${arc(84,mid,end)} L${point(55,end)} A55 55 0 0 0 ${point(55,mid)} A55 55 0 0 0 ${point(55,start)} Z`;
1281
- return h("path", { key: it.path || "other", d, fill: spaceColors[i], opacity: active && items.slice(0,6).some(c=>c.path===active) && active !== it.path ? .5 : 1,
1282
- onMouseEnter: () => onActive(it.path), onMouseLeave: () => onActive(""), onClick: () => it.tail ? onTail && onTail() : it.isDir && onPick(it), style: { cursor: it.isDir || it.tail ? "pointer" : "default" } }, h("title", null, `${tr(lang,it.name)} · ${displaySize(it.kb)} · ${percent(it.kb,total)}`));
1283
- }),
1284
- h("text", { x:95, y:91, textAnchor:"middle", fill:"currentColor", fontSize:17, fontWeight:600 }, displaySize(total)),
1285
- h("text", { x:95, y:109, textAnchor:"middle", fill:"currentColor", fontSize:10 }, tr(lang,"当前已统计")));
1550
+ function tideBand(top, bottom) {
1551
+ return `${tideEdge(top)} L1000 ${bottom-13} C864 ${bottom-39} 729 ${bottom-37} 580 ${bottom+3} C448 ${bottom+38} 372 ${bottom+33} 250 ${bottom+3} C165 ${bottom-18} 85 ${bottom-12} 0 ${bottom+8} Z`;
1286
1552
  }
1287
- function SpaceView({ lang, onLarge, onClean, report, fresh, whitelist = [], refreshToken = 0 }) {
1553
+ function TideLoadingWave() {
1554
+ const fillId = `${useId()}-loading-tide`;
1555
+ const crest = "M-20 90 C49 91 69 44 131 58 S219 122 286 91 S388 32 442 56 S492 85 540 60";
1556
+ return h("svg", { className: "dc-tide-loading-wave", viewBox: "0 0 520 178", "aria-hidden": true, focusable: "false" },
1557
+ h("defs", null, h("linearGradient", { id: fillId, x2: "0%", y2: "100%" },
1558
+ h("stop", { stopColor: "#148bd4", stopOpacity: .55 }),
1559
+ h("stop", { offset: "100%", stopColor: "#148bd4", stopOpacity: 0 }))),
1560
+ h("g", { className: "dc-tide-loading-crest" },
1561
+ h("path", { d: `${crest} V166 H-20 Z`, fill: `url(#${fillId})` }),
1562
+ h("path", { d: crest, fill: "none", stroke: "#278fce", strokeWidth: 1.4 }),
1563
+ h("path", { className: "dc-tide-loading-current", d: crest, pathLength: 100, fill: "none",
1564
+ stroke: "#91f0ff", strokeWidth: 2.6, strokeLinecap: "round" }),
1565
+ [22, 45].map(y => h("path", { key: y, d: crest, transform: `translate(0 ${y})`,
1566
+ fill: "none", stroke: "#4398d5", opacity: .38 }))));
1567
+ }
1568
+ function TideMap({ segments, total, lang, selected, onSelect }) {
1569
+ const id = useId();
1570
+ let edge = 36;
1571
+ const bands = segments.map((item, index) => {
1572
+ const share = item.kb / total;
1573
+ const top = edge;
1574
+ edge += share * 330;
1575
+ return { item, index, top, bottom: edge, share };
1576
+ });
1577
+ const label = ({ item, index, top, bottom, share }) => {
1578
+ const name = item.tail ? tr(lang, "其他") : item.name;
1579
+ const size = `${displaySize(item.kb)} · ${percent(item.kb, total)}`;
1580
+ const thin = share < .1;
1581
+ const className = thin ? undefined : `dc-tide-label ${share >= .4 ? "dc-tide-hero" : share < .14 ? "dc-tide-compact" : ""}`.trim();
1582
+ return h("button", { key: item.path || "other", type: "button", className,
1583
+ "aria-label": `${name} · ${size}`, "aria-pressed": selected === item,
1584
+ style: thin ? undefined : { top: `${(top + bottom) / 8}%` }, onClick: () => onSelect(item) },
1585
+ thin ? h("span", { "aria-hidden": true, style: { background: tideColors[index][0] } }) : null,
1586
+ h("strong", null, name), thin ? size : h("span", null, size));
1587
+ };
1588
+ return h("div", { role: "group", "aria-label": tr(lang, "当前目录分布") },
1589
+ h("div", { className: "dc-tide-map" },
1590
+ h("svg", { viewBox: "0 0 1000 400", preserveAspectRatio: "none", "aria-hidden": true },
1591
+ h("defs", null, tideColors.map(([start, end], i) => h("linearGradient", { key: i, id: `${id}-tide-${i}`, x2: "78%", y2: "100%" },
1592
+ h("stop", { stopColor: start }), h("stop", { offset: "100%", stopColor: end })))),
1593
+ bands.map(({ item, index, top, bottom, share }) => h("g", { key: item.path || "other" },
1594
+ h("path", { className: "dc-tide-band", d: tideBand(top, bottom), fill: `url(#${id}-tide-${index})`,
1595
+ onClick: () => onSelect(item) }),
1596
+ selected === item && share >= .18 ? [.34, .63, .79].map(fraction => h("path", { key: fraction,
1597
+ d: tideEdge(top + (bottom - top) * fraction), fill: "none", stroke: "#83deff", opacity: .18, vectorEffect: "non-scaling-stroke", pointerEvents: "none" })) : null,
1598
+ h("path", { className: selected === item ? "dc-tide-crest" : undefined, d: tideEdge(top), fill: "none", stroke: selected === item ? "#9aefff" : "#a3c8f4", opacity: selected === item ? 1 : .38,
1599
+ strokeWidth: selected === item ? 2 : 1, vectorEffect: "non-scaling-stroke", pointerEvents: "none" })))),
1600
+ bands.filter(band => band.share >= .1).map(label)),
1601
+ bands.some(band => band.share < .1) ? h("div", { className: "dc-tide-small" }, bands.filter(band => band.share < .1).map(label)) : null);
1602
+ }
1603
+ function SpaceView({ lang, onLarge, onClean, onSetupPython, report, fresh, whitelist = [], refreshToken = 0 }) {
1288
1604
  const [data, setData] = useState(null);
1289
1605
  const [stack, setStack] = useState([]);
1290
1606
  const [loading, setLoading] = useState(false);
1291
- const [error, setError] = useState("");
1607
+ const [error, setError] = useState(null);
1608
+ const [pythonUnavailable, setPythonUnavailable] = useState(false);
1292
1609
  const [limit, setLimit] = useState(20);
1293
- const [active, setActive] = useState("");
1610
+ const [selectedPath, setSelectedPath] = useState("");
1294
1611
  const [pending, setPending] = useState("");
1295
1612
  const request = useRef(null);
1296
1613
  const pendingStack = useRef([]);
1614
+ const scopeRef = useRef(null);
1615
+ const detailsRef = useRef(null);
1616
+ const previousPath = useRef(null);
1297
1617
  const load = useCallback(async (p = "", next = [], refresh = false) => {
1298
1618
  request.current?.abort();
1299
1619
  const controller = new AbortController(); request.current = controller;
1300
- setLoading(true); setPending(p); pendingStack.current = next; setError("");
1620
+ setLoading(true); setPending(p); pendingStack.current = next; setError(null); setPythonUnavailable(false);
1301
1621
  try {
1302
1622
  const res = await fetch(`${API}/tree?path=${encodeURIComponent(p)}${refresh ? "&refresh=1" : ""}`, { signal: controller.signal });
1303
1623
  const body = await res.json();
1304
- if (!res.ok) throw Error(body.error || `HTTP ${res.status}`);
1305
1624
  if (controller.signal.aborted) return;
1306
- setData(body); setStack(next); setLimit(20); setActive("");
1307
- } catch (err) { if (!controller.signal.aborted) setError(err.message); }
1625
+ if (body.errorCode === "python_unavailable") { setPythonUnavailable(true); return; }
1626
+ if (!res.ok) { const e = new Error(body.error || `HTTP ${res.status}`); e.code = body.errorCode || ""; throw e; }
1627
+ setData(body); setStack(next);
1628
+ // 刷新当前目录时保留「显示更多」的展开进度;只有切换目录才回到 20 行。
1629
+ if (!refresh) { setLimit(20); setSelectedPath(""); }
1630
+ } catch (err) { if (!controller.signal.aborted) setError({ code: err.code || "", message: err.message }); }
1308
1631
  finally { if (request.current === controller) setLoading(false); }
1309
1632
  }, []);
1310
1633
  useEffect(() => { load(); return () => request.current?.abort(); }, [load]);
1634
+ useEffect(() => {
1635
+ if (previousPath.current && previousPath.current !== data?.path) {
1636
+ scopeRef.current?.focus({ preventScroll: true });
1637
+ scopeRef.current?.scrollIntoView({ block: "nearest" });
1638
+ }
1639
+ previousPath.current = data?.path;
1640
+ }, [data?.path]);
1311
1641
  // 搬家完成后由父组件递增 refreshToken:绕过 60s 缓存重统当前目录,
1312
1642
  // 消除「文件已搬走但目录大小没变」的困惑。
1313
1643
  const lastToken = useRef(refreshToken);
@@ -1317,8 +1647,18 @@ window.__ModuleLoader__.load({
1317
1647
  if (data) load(data.path, stack, true);
1318
1648
  }, [refreshToken]);
1319
1649
  const children = data?.children || [], total = children.reduce((n,c) => n+c.kb,0);
1650
+ const segments = spaceSegments(children);
1651
+ const selected = segments.find(it => (it.path ?? null) === selectedPath) || segments[0];
1652
+ const partial = data?.partial || data?.unavailable || data?.skipped;
1653
+ const skippedBits = [];
1654
+ if (data?.unavailable) skippedBits.push(tf(lang, "未能读取 {n} 项", { n: data.unavailable }));
1655
+ if (data?.skipped) skippedBits.push(tf(lang, "跳过链接或系统别名 {n} 项", { n: data.skipped }));
1320
1656
  const drill = it => { if (!loading) load(it.path, [...stack, {path:it.path,name:it.name}]); };
1321
- const expandTail = () => setLimit(children.length);
1657
+ const expandTail = () => {
1658
+ setLimit(children.length);
1659
+ detailsRef.current?.focus();
1660
+ detailsRef.current?.scrollIntoView({ block: "start" });
1661
+ };
1322
1662
  // 「其中可清理」角标:仅基于新鲜体检报告的信息性估算;已被更上层条目覆盖的路径不重复计。
1323
1663
  const cleanableRows = onClean && fresh && report ? buildRows(report, whitelist, "all", "", "sizeDesc", lang).filter(r => r.checkable) : [];
1324
1664
  const cleanableKb = {};
@@ -1335,28 +1675,51 @@ window.__ModuleLoader__.load({
1335
1675
  }
1336
1676
  const reveal = async p => {
1337
1677
  try { const res = await fetch(`${API}/reveal`, { method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({path:p}) }); if (!res.ok) throw Error(tr(lang,"无法打开位置")); }
1338
- catch(err) { setError(err.message); }
1678
+ catch(err) { setError({ code:"reveal", message: err.message, path:p }); }
1339
1679
  };
1340
- return h("section", { "aria-label":tr(lang,"目录分布"), "aria-busy":loading },
1341
- h("nav", {className:"dc-space-crumbs", "aria-label":tr(lang,"目录导航")},
1342
- h("button", {className:"dc-link",disabled:loading,onClick:()=>load()},tr(lang,"个人文件夹")),
1343
- stack.map((it,i)=>h(React.Fragment,{key:it.path},h("span",null,"/"),h("button",{className:"dc-link",disabled:loading,onClick:()=>load(it.path,stack.slice(0,i+1))},it.name)))),
1344
- h("div",{className:"dc-page-meta"},tr(lang,"仅统计个人文件夹,包含隐藏项;不代表整盘已用容量。"),data ? ` · ${timeStr(data.at)}` : ""),
1345
- data ? h("p",{className:"dc-caption"},tr(lang,data.metric === "logical" ? "按文件大小统计;压缩、共享文件和系统保留空间可能造成差异。" : "按文件占用统计;共享文件和系统保留空间可能造成差异。")) : null,
1346
- loading ? h("div",{className:"dc-notice",role:"status"},tr(lang,"正在统计目录,请稍候…"),pending,h("button",{className:"dc-link",onClick:()=>{request.current?.abort();setLoading(false);setError(tr(lang,"已停止加载,可重试。"));}},tr(lang,"停止加载"))) : null,
1347
- error ? h("div",{className:"dc-notice",role:"alert"},error,h("button",{className:"dc-link",onClick:()=>load(pending,pendingStack.current)},tr(lang,"重试"))) : null,
1348
- data?.unavailable || data?.skipped ? h("p",{className:"dc-caption",role:"status"},tf(lang,"未能读取 {n} 项,跳过链接 {s} 项;统计可能不完整。",{n:data.unavailable || 0,s:data.skipped || 0})) : null,
1349
- data?.skippedCloud ? h("p",{className:"dc-caption",role:"status"},tf(lang,"已跳过云盘目录 {n} 个:云盘文件不参与搬家或去重。",{n:data.skippedCloud})) : null,
1350
- data ? h("div",{className:"dc-space-grid",style:{opacity:loading ? .55 : 1}},
1351
- h("aside",null,h(Sunburst,{items:children,lang,onPick:drill,active,onActive:setActive,onTail:expandTail}),
1352
- h("p",{className:"dc-caption"},tr(lang,"点击目录或色块进入下一级")),
1353
- spaceSegments(children).map((it,i)=>h("button",{key:it.path || "other",type:"button",className:"dc-space-legend",disabled:loading,style:{background:"none",border:0,color:"inherit",font:"inherit",width:"100%",textAlign:"left",cursor:"pointer"},onClick:()=>{if(it.tail)expandTail();else{const target=children.find(c=>c.path===it.path);if(target&&target.isDir)drill(target);}}},h("span",{style:{background:spaceColors[i]}}),tr(lang,it.name),h("strong",null,percent(it.kb,total))))),
1354
- h("div",{style:{minWidth:0}},
1355
- h("div",{className:"dc-space-tools"},h("button",{className:"dc-link",onClick:()=>onLarge(data.path,data.bigFiles || [],data.at)},tr(lang,"查看此目录的大文件")),h("button",{className:"dc-link",onClick:()=>reveal(data.path)},tr(lang,"打开位置")),h("button",{className:"dc-link",disabled:loading,onClick:()=>load(data.path,stack,true)},tr(lang,"重新统计"))),
1680
+ return h("section", { "aria-label":tr(lang,"目录分布") },
1681
+ h("div", {className:"dc-tide-scope"},
1682
+ h("nav", {ref:scopeRef,tabIndex:-1,className:"dc-space-crumbs", "aria-label":tr(lang,"目录导航")},
1683
+ h("button", {className:"dc-link",disabled:loading,"aria-current":stack.length === 0 ? "location" : undefined,onClick:()=>load()},tr(lang,"个人文件夹")),
1684
+ stack.map((it,i)=>h(React.Fragment,{key:it.path},h("span",{"aria-hidden":true},"›"),h("button",{className:"dc-link",disabled:loading,
1685
+ "aria-current":i === stack.length - 1 ? "location" : undefined,onClick:()=>load(it.path,stack.slice(0,i+1))},it.name)))),
1686
+ data ? h("strong", {className:"dc-tide-total"}, tf(lang,"已统计 {size}",{size:displaySize(total)})) : null),
1687
+ loading ? h(React.Fragment, null,
1688
+ h("div", {className:"dc-notice"},
1689
+ h("span", {className:"dc-tide-loading-status",role:"status"}, tr(lang,"正在统计目录,请稍候…"), pending ? ` ${pending}` : ""),
1690
+ h("button", {className:"dc-link",onClick:()=>{request.current?.abort();setLoading(false);setError({code:"",message:tr(lang,"已停止加载,可重试。")});}},tr(lang,"停止加载"))),
1691
+ !data ? h(TideLoadingWave) : null) : null,
1692
+ error ? h("div",{className:"dc-notice",role:"alert"},treeErrorText(lang,error),
1693
+ h("button",{className:"dc-link",onClick:()=> error.code==="reveal" ? reveal(error.path) : load(pending,pendingStack.current)},tr(lang,"重试"))) : null,
1694
+ pythonUnavailable ? h(PythonSetupNotice, { lang, onSend: onSetupPython, onRetry: () => load(pending, pendingStack.current, true), busy: loading }) : null,
1695
+ data ? h(React.Fragment,null,
1696
+ // 刷新时保留环形图(只降透明度、禁用点击),避免 450px 图突然消失造成布局跳动。
1697
+ selected ? h("div",{className:"dc-tide-map-wrap","aria-busy":loading ? "true" : undefined,style:loading ? {opacity:0.55,pointerEvents:"none"} : null},
1698
+ h(TideMap,{key:data.path,segments,total,lang,selected,onSelect:it=>setSelectedPath(it.path ?? null)})) : null,
1699
+ selected ? h("div",{className:"dc-tide-selection"},
1700
+ h(Icon,{name:selected.isDir || selected.tail ? "folder" : "app-window",size:24,color:"#8bdcff"}),
1701
+ h("div",{className:"dc-tide-selection-text"},h("strong",null,selected.tail ? tr(lang,"其他") : selected.name),
1702
+ h("div",null,h("small",null,`${displaySize(selected.kb)} · ${percent(selected.kb,total)}`)),
1703
+ cleanableKb[selected.path] ? h("button",{className:"dc-space-cleanable",disabled:loading,onClick:()=>onClean(selected.path)},tf(lang,"其中可清理 {size}",{size:displaySize(cleanableKb[selected.path])})) : null),
1704
+ // 「其他」已全部展开时不再是「查看其他项目」,而是滚动到明细。
1705
+ h("button",{className:"dc-button dc-primary",disabled:loading,
1706
+ onClick:()=>selected.tail ? expandTail() : selected.isDir ? drill(selected) : reveal(selected.path)},
1707
+ tr(lang,selected.tail ? (children.length > limit ? "查看其他项目" : "查看明细") : selected.isDir ? "进入目录" : "打开位置"))) : null,
1708
+ h("p",{className:"dc-caption"},[partial ? tr(lang,"部分统计") : null,tr(lang,"仅个人文件夹")].filter(Boolean).join(" · ")),
1709
+ h("section",{ref:detailsRef,tabIndex:-1,className:"dc-tide-details","aria-label":tr(lang,"目录明细")},
1710
+ h("div",{className:"dc-page-meta"},tr(lang,"仅统计个人文件夹,包含隐藏项;不代表整盘已用容量。"),` · ${timeStr(data.at)}`),
1711
+ h("p",{className:"dc-caption"},tr(lang,data.metric === "logical" ? "按文件大小统计;压缩、共享文件和系统保留空间可能造成差异。" : "按文件占用统计;共享文件和系统保留空间可能造成差异。")),
1712
+ skippedBits.length ? h("p",{className:"dc-caption",role:"status"},skippedBits.join(" · ")," · ",tr(lang,"统计可能不完整。")) : null,
1713
+ data.skippedCloud ? h("p",{className:"dc-caption",role:"status"},tf(lang,"已跳过云盘目录 {n} 个:云盘文件不参与搬家或去重。",{n:data.skippedCloud})) : null,
1714
+ data.cloudFiles ? h("p",{className:"dc-caption",role:"status"},tf(lang,"跳过云占位文件 {n} 个:未下载到本地,不计入大小。",{n:data.cloudFiles})) : null,
1715
+ data.sharedFiles ? h("p",{className:"dc-caption",role:"status"},data.sharedDupKb > 0
1716
+ ? tf(lang,"硬链接 {n} 个;重复部分 {size} 只计一次。",{n:data.sharedFiles,size:displaySize(data.sharedDupKb)})
1717
+ : tf(lang,"硬链接 {n} 个;移动只减少引用,不一定释放空间。",{n:data.sharedFiles})) : null,
1718
+ h("div",{className:"dc-space-tools"},h("button",{className:"dc-link",onClick:()=>onLarge(data.path,data.bigFiles || [],data.at,data.bigFilesCap || 0)},tr(lang,"查看此目录的大文件")),h("button",{className:"dc-link",onClick:()=>reveal(data.path)},tr(lang,"打开位置")),h("button",{className:"dc-link",disabled:loading,onClick:()=>load(data.path,stack,true)},tr(lang,"重新统计"))),
1356
1719
  !children.length ? h("p",{className:"dc-notice"},tr(lang,"此目录没有可显示的项目")) :
1357
1720
  h("table",{className:"dc-file-table"},h("colgroup",null,h("col",{style:{width:"55%"}}),h("col",{style:{width:"20%"}}),h("col",{style:{width:"25%"}})),h("thead",null,h("tr",null,["名称","大小","占比"].map(t=>h("th",{key:t,scope:"col"},tr(lang,t))))),
1358
- h("tbody",null,children.slice(0,limit).map(c=>h("tr",{key:c.path,onMouseEnter:()=>setActive(c.path),onMouseLeave:()=>setActive(""),style:{background:active===c.path?"var(--dc-tint)":undefined}},
1359
- h("td",null,h("button",{className:"dc-space-entry",disabled:loading,onFocus:()=>setActive(c.path),onBlur:()=>setActive(""),onClick:()=>c.isDir?drill(c):reveal(c.path)},h(Icon,{name:c.isDir?"folder":"app-window",size:18}),c.name,c.isDir?" ›":""),c.partial?h("span",{className:"dc-caption"},tr(lang,"部分统计")):null,cleanableKb[c.path]?h("button",{className:"dc-space-cleanable",title:tr(lang,"去清理页处理"),onClick:()=>onClean(c.path)},tf(lang,"其中可清理 {size}",{size:displaySize(cleanableKb[c.path])})):null),
1721
+ h("tbody",null,children.slice(0,limit).map(c=>h("tr",{key:c.path,style:{background:selected?.path===c.path?"var(--dc-tint)":undefined}},
1722
+ h("td",null,h("button",{className:"dc-space-entry",disabled:loading,onClick:()=>c.isDir?drill(c):reveal(c.path)},h(Icon,{name:c.isDir?"folder":"app-window",size:18}),c.name,c.isDir?" ›":""),c.shared?h("span",{className:"dc-caption",title:tr(lang,"硬链接:移动只减少引用,不一定释放空间")},c.dedup?tr(lang,"硬链接(大小已计入其他位置)"):tr(lang,"硬链接")):null,c.partial?h("span",{className:"dc-caption"},tr(lang,"部分统计")):null,cleanableKb[c.path]?h("button",{className:"dc-space-cleanable",title:tr(lang,"去清理页处理"),onClick:()=>onClean(c.path)},tf(lang,"其中可清理 {size}",{size:displaySize(cleanableKb[c.path])})):null),
1360
1723
  h("td",{className:"dc-size-col"},displaySize(c.kb)),h("td",{className:"dc-size-col"},percent(c.kb,total),h("div",{className:"dc-space-bar"},h("span",{style:{width:`${total?c.kb/total*100:0}%`}}))))))),
1361
1724
  children.length>limit?h("button",{className:"dc-link",onClick:()=>setLimit(limit+30)},tr(lang,"显示更多")):null)) : null);
1362
1725
  }
@@ -1536,6 +1899,7 @@ window.__ModuleLoader__.load({
1536
1899
  scan.status === "running" ? h("div", { style: { height: 6, borderRadius: 3, background: "rgba(127,127,127,.2)", overflow: "hidden", marginBottom: 8 } },
1537
1900
  h("div", { style: { height: "100%", width: `${Math.max(3, scan.progress || 0)}%`, background: "rgba(80,140,255,.9)" } })) : null,
1538
1901
  scan.status === "running" ? h("div", { style: { fontSize: 11, opacity: 0.7, marginBottom: 8 } }, `${trNote(lang, scan.stage || "扫描中…")} · ${scan.elapsed || 0}s`) : null,
1902
+ scan.status === "error" && scan.errorCode === "python_unavailable" ? h(PythonSetupNotice, { lang, onSend: props.onSetupPython, onRetry: start, busy }) : null,
1539
1903
  note ? h("pre", { style: { whiteSpace: "pre-wrap", fontSize: 11.5, opacity: 0.85, background: "rgba(127,127,127,.08)", padding: 8, borderRadius: 6, marginBottom: 8 } }, note) : null,
1540
1904
  result ? h("div", { style: { fontSize: 12, opacity: 0.7, marginBottom: 8 } }, tf(lang, "扫描 {n} 张,比较 {m} 张,找到 {g} 组相似;预计可释放 {size}", { n: result.scanned, m: result.compared, g: result.groups.length, size: fmt(wasted) }) + (result.skippedCloud ? " · " + tf(lang, "已跳过云盘目录 {n} 个:云盘文件不参与搬家或去重。", { n: result.skippedCloud }) : "")) : null,
1541
1905
  groups,
@@ -1587,7 +1951,7 @@ window.__ModuleLoader__.load({
1587
1951
  }
1588
1952
 
1589
1953
  function LargeView(props) {
1590
- const { lang, report, scan, fresh, scope, onScope, onScan, onCancel, at, whitelist = [], local = null, onLocal, ai = null, onAiFailure = null } = props;
1954
+ const { lang, report, scan, fresh, scope, onScope, onScan, onCancel, at, whitelist = [], local = null, onLocal, ai = null, onAiFailure = null, onSetupPython } = props;
1591
1955
  const [sort,setSort] = useState("size");
1592
1956
  const [age,setAge] = useState(0);
1593
1957
  const [ext,setExt] = useState("");
@@ -1600,6 +1964,7 @@ window.__ModuleLoader__.load({
1600
1964
  const [confirming,setConfirming] = useState(false);
1601
1965
  const [busy,setBusy] = useState(false);
1602
1966
  const [note,setNote] = useState("");
1967
+ const [pythonUnavailableScope, setPythonUnavailableScope] = useState(null);
1603
1968
  const [move,setMove] = useState({status:"idle",done:0,total:0});
1604
1969
  const [lastDest,setLastDest] = useState("");
1605
1970
  const callback = useRef(props.onMoved); callback.current = props.onMoved;
@@ -1673,8 +2038,14 @@ window.__ModuleLoader__.load({
1673
2038
  catch(err){setNote(err.message);}finally{setBusy(false);}
1674
2039
  };
1675
2040
  const rescanScope=async()=>{
1676
- setBusy(true);setNote("");
1677
- try{const r=await fetch(`${API}/tree?path=${encodeURIComponent(scope)}&refresh=1`),b=await r.json();if(!r.ok)throw Error(b.error||`HTTP ${r.status}`);onLocal&&onLocal({scope,files:b.bigFiles || [],at:b.at});}
2041
+ setBusy(true);setNote("");setPythonUnavailableScope(null);
2042
+ try {
2043
+ const res = await fetch(`${API}/tree?path=${encodeURIComponent(scope)}&refresh=1`);
2044
+ const body = await res.json();
2045
+ if (body.errorCode === "python_unavailable") { setPythonUnavailableScope(scope); return; }
2046
+ if (!res.ok) throw Error(body.error || `HTTP ${res.status}`);
2047
+ onLocal?.({ scope, files: body.bigFiles || [], at: body.at, cap: body.bigFilesCap || 0 });
2048
+ }
1678
2049
  catch(err){setNote(err.message);}finally{setBusy(false);}
1679
2050
  };
1680
2051
  const scanFull=scan?.full===true || (report?.bigFiles || []).length>0;
@@ -1682,7 +2053,7 @@ window.__ModuleLoader__.load({
1682
2053
  const filteredByUser=query || ext || age || minSize;
1683
2054
  const emptyMessage=tr(lang,scoped?(filteredByUser?"没有符合筛选条件的大文件":"该目录没有大文件"):scanning?"正在扫描大文件…":scan?.status==="error"?"大文件扫描失败,请重试":scan?.status==="canceled"?"扫描已取消":!report?"先扫描,查找占用较大的文件":!scanFull?"当前扫描未覆盖大文件,请进行完整扫描":filtered?"没有符合筛选条件的大文件":"没有发现大文件");
1684
2055
  return h("section",{"aria-label":tr(lang,"大文件")},
1685
- h("div",{className:"dc-space-tools"},h("span",{className:"dc-page-meta"},scoped?tf(lang,"来源:目录统计 · {path}",{path:scope}):report?.bigFilesTop?tf(lang,"扫描范围:个人文件夹 · ≥300 MB · 仅显示最大的 {n} 个",{n:report.bigFilesTop}):tr(lang,"扫描范围:个人文件夹 · 仅列出 ≥300 MB 的大文件"),(scoped?local.at:at)?` · ${timeStr(scoped?local.at:at)}`:""),
2056
+ h("div",{className:"dc-space-tools"},h("span",{className:"dc-page-meta"},scoped?tf(lang,"来源:目录统计 · {path}",{path:scope})+(local?.cap?` · ${tf(lang,"最多 {n} 个",{n:local.cap})}`:""):report?.bigFilesTop?tf(lang,"扫描范围:个人文件夹 · ≥300 MB · 仅显示最大的 {n} 个",{n:report.bigFilesTop}):tr(lang,"扫描范围:个人文件夹 · 仅列出 ≥300 MB 的大文件"),(scoped?local.at:at)?` · ${timeStr(scoped?local.at:at)}`:""),
1686
2057
  scoped?h("button",{className:"dc-link",disabled:moving || busy,onClick:rescanScope},tr(lang,"重新统计")):h("button",{className:"dc-link",disabled:scanning || moving || busy,onClick:onScan},tr(lang,"完整扫描"))),
1687
2058
  !scoped && report?.truncated?h("p",{className:"dc-caption",role:"status"},tr(lang,"扫描时间预算有限,大文件结果可能不全。"),ai&&onAiFailure?h("button",{className:"dc-link",disabled:busy,onClick:()=>onAiFailure({kind:"truncated",elapsed:report.elapsed,truncated:true})},tr(lang,"为什么结果不完整")):null):null,
1688
2059
  scoped?!localFresh?h("div",{className:"dc-notice",role:"status"},tr(lang,"目录统计已过期,请重新统计后移动。")):null
@@ -1696,16 +2067,17 @@ window.__ModuleLoader__.load({
1696
2067
  h("select",{"aria-label":tr(lang,"最小大小"),value:minSize,onChange:e=>setMinSize(Number(e.target.value))},(scoped?[0,50,100,500]:[0,500,1024,5120]).map(n=>h("option",{value:n,key:n},n?`≥ ${displaySize(n*1024)}`:tr(lang,"全部大小"))))),
1697
2068
  scope?h("p",{className:"dc-caption"},scope,h("button",{className:"dc-link",onClick:()=>onScope("")},tr(lang,"全部文件夹"))):null,
1698
2069
  note?h("div",{className:"dc-notice",role:"status"},note):null,
2070
+ scoped && pythonUnavailableScope === scope ? h(PythonSetupNotice, { lang, onSend: onSetupPython, onRetry: rescanScope, busy }) : null,
1699
2071
  lastDest?h("button",{className:"dc-link",onClick:()=>reveal(lastDest)},tr(lang,"打开目标文件夹")):null,
1700
2072
  moving?h("div",{className:"dc-notice",role:"status"},tf(lang,"正在移动 {done}/{total}",{done:move.done,total:move.total})," · ",move.current,h("button",{className:"dc-link",onClick:async()=>{try{const r=await fetch(`${API}/move/cancel`,{method:"POST"});if(!r.ok)throw Error(tr(lang,"无法取消当前任务"));setNote(tr(lang,"正在取消;当前文件处理完成后停止。"));}catch(err){setNote(err.message);}}},tr(lang,"取消"))):null,
1701
2073
  move.error?h("p",{role:"alert"},move.error):null,
1702
2074
  (move.results || []).filter(r=>!r.ok).map(r=>h("p",{key:r.path,role:"status"},r.path," · ",r.error)),
1703
2075
  !rows.length?h("div",{className:"dc-empty"},h("p",null,tr(lang,emptyMessage)),!scanning?h("button",{className:"dc-button",disabled:moving || busy,onClick:scoped?(filteredByUser?()=>{setQuery("");setExt("");setAge(0);setMinSize(0);}:rescanScope):(filtered && scanFull?()=>{setQuery("");setExt("");setAge(0);setMinSize(0);onScope("");}:onScan)},tr(lang,scoped?(filteredByUser?"清除筛选":"重新统计"):(filtered && scanFull?"清除筛选":"完整扫描"))):null):
1704
- h("div",null,h("div",{className:"dc-space-tools"},h("span",null,tf(lang,"共 {n} 个文件",{n:rows.length})),
2076
+ h("div",null,h("div",{className:"dc-space-tools"},h("span",null,tf(lang,"显示 {n} 个文件",{n:rows.length}),scoped?h("span",{className:"dc-caption"},` · ${local?.cap?tf(lang,"最多 {n} 个",{n:local.cap}):tr(lang,"仅 ≥1 MB")}`):null),
1705
2077
  h("button",{className:"dc-link",disabled:!effectiveFresh || moving || scanning,onClick:()=>setSel(Object.fromEntries(rows.filter(f=>!protectedFile(f)).map(f=>[f.path,true])))},tr(lang,"选择筛选结果")),h("button",{className:"dc-link",disabled:moving,onClick:()=>setSel({})},tr(lang,"清空选择"))),
1706
2078
  h("table",{className:"dc-file-table"},h("colgroup",null,h("col",{style:{width:44}}),h("col",null),h("col",{style:{width:90}}),h("col",{style:{width:90}})),h("thead",null,h("tr",null,["选择","名称","大小","操作"].map(t=>h("th",{scope:"col",key:t},tr(lang,t))))),
1707
2079
  h("tbody",null,rows.map(f=>h("tr",{key:f.path},h("td",null,h("input",{type:"checkbox","aria-label":tf(lang,"选择 {name}",{name:f.path}),checked:!!sel[f.path]&&!protectedFile(f),disabled:!effectiveFresh||moving||scanning||protectedFile(f),onChange:()=>setSel(prev=>({...prev,[f.path]:!prev[f.path]}))})),
1708
- h("td",null,h("div",{style:{overflowWrap:"anywhere"}},normalizedPath(f.path).split("/").pop()),h("div",{className:"dc-file-path",title:f.path},f.path),h("div",{className:"dc-caption"},protectedFile(f)?tr(lang,"受保护"):f.mtime?tf(lang,"超过 {n} 天未修改",{n:Math.max(0,Math.floor((Date.now()/1000-f.mtime)/86400))}):tr(lang,"修改时间未知"))),
2080
+ h("td",null,h("div",{style:{overflowWrap:"anywhere"}},normalizedPath(f.path).split("/").pop()),h("div",{className:"dc-file-path",title:f.path},f.path),f.shared?h("div",{className:"dc-caption"},tr(lang,"硬链接:移动只减少引用,不一定释放空间")):null,h("div",{className:"dc-caption"},protectedFile(f)?tr(lang,"受保护"):f.mtime?tf(lang,"超过 {n} 天未修改",{n:Math.max(0,Math.floor((Date.now()/1000-f.mtime)/86400))}):tr(lang,"修改时间未知"))),
1709
2081
  h("td",{className:"dc-size-col"},displaySize(f.kb)),h("td",null,h("button",{className:"dc-link",onClick:()=>reveal(f.path)},tr(lang,"打开位置")),ai?h("button",{className:"dc-link",onClick:()=>askFile(f)},tr(lang,"这是什么")):null,h("button",{className:"dc-link",onClick:async()=>{try{await navigator.clipboard.writeText(f.path);setNote(tr(lang,"已复制路径"));}catch{setNote(tr(lang,"无法复制路径"));}}},tr(lang,"复制路径")))))))),
1710
2082
  selected.length>0?h("div",{className:"dc-space-actions"},h("strong",null,tf(lang,"已选 {n} 个 · {size}",{n:selected.length,size:displaySize(selectedKb)})),
1711
2083
  h("select",{"aria-label":tr(lang,"目标磁盘"),disabled:moving||busy,value:volume?.path || "",onChange:e=>{setDestination(e.target.value);setFolder(null);}},h("option",{value:""},tr(lang,"选择目标磁盘…")),volumes.map(v=>h("option",{key:v.path,value:v.path},`${v.label} · ${tr(lang,"可用")} ${displaySize(v.free/1024)}`))),
@@ -1718,6 +2090,7 @@ window.__ModuleLoader__.load({
1718
2090
  folder.parent?h("button",{className:"dc-link",disabled:busy||moving,onClick:()=>browse(folder.parent)},tr(lang,"上一级")):null):null,
1719
2091
  !confirmationValid?h("button",{className:"dc-button dc-primary",disabled:busy||moving||!effectiveFresh||scanning||!destination.trim()||insufficient,onClick:prepare},tr(lang,"移动到…")):
1720
2092
  h("div",{role:"group","aria-label":tr(lang,"确认移动"),style:{flexBasis:"100%"}},h("p",null,tf(lang,"确认移动 {n} 个文件到 {dest}?",{n:selected.length,dest:destination})),h("p",{className:"dc-caption"},tr(lang,"移动到其他磁盘时会先复制并逐字节校验,确认一致后才移除原文件。")),h("p",{className:"dc-caption"},tr(lang,"成功后原位置不再保留文件;移动不进入清理历史,无法通过「历史」恢复。")),
2093
+ selected.some(f=>f.shared)?h("p",{className:"dc-caption",role:"status"},tr(lang,"含硬链接文件:移动后原位置仍可能有其他链接,释放的空间可能少于已选大小。")):null,
1721
2094
  h("button",{className:"dc-button dc-primary",disabled:busy||moving||!effectiveFresh||scanning||insufficient,onClick:startMove},tr(lang,"确认移动")),h("button",{className:"dc-link",disabled:busy,onClick:()=>setConfirming(false)},tr(lang,"取消")))):null);
1722
2095
  }
1723
2096
 
@@ -1729,6 +2102,11 @@ window.__ModuleLoader__.load({
1729
2102
  const [overview, setOverview] = useState(null);
1730
2103
  const [liveDisk, setLiveDisk] = useState(null);
1731
2104
  const [scan, setScan] = useState({ status: "idle" });
2105
+ const [perm, setPerm] = useState({ status: "", text: "" });
2106
+ const [permSteps, setPermSteps] = useState(false);
2107
+ const [privacySettingsFailed, setPrivacySettingsFailed] = useState(false);
2108
+ const permRequest = useRef(0);
2109
+ const permNotice = useRef(null);
1732
2110
  const [tab, setTab] = useState("all");
1733
2111
  const [risk, setRisk] = useState("all");
1734
2112
  const [moreOpen, setMoreOpen] = useState(false);
@@ -1751,6 +2129,8 @@ window.__ModuleLoader__.load({
1751
2129
  const [reclaim, setReclaim] = useState(null);
1752
2130
  const [reclaimBusy, setReclaimBusy] = useState(false);
1753
2131
  const [purgeConfirm, setPurgeConfirm] = useState("");
2132
+ const [historyBatchId, setHistoryBatchId] = useState("");
2133
+ const historyDetail = useRef(null);
1754
2134
  const [newWhitelist, setNewWhitelist] = useState("");
1755
2135
  const [spaceMode, setSpaceMode] = useState("tree");
1756
2136
  const [spaceScope, setSpaceScope] = useState("");
@@ -1831,18 +2211,27 @@ window.__ModuleLoader__.load({
1831
2211
  if (s.status === "done") {
1832
2212
  setClock(Date.now());
1833
2213
  const ov = await loadOverview();
2214
+ if (ov?.report?.fda?.status === "granted") {
2215
+ permRequest.current++;
2216
+ setPerm({ status: "ok", text: "" });
2217
+ }
1834
2218
  if (wizard.open) { setWizard((w) => ({ ...w, phase: "review" })); setWizardSel(wizardInitSel(ov && ov.report, (ov && ov.whitelist) || [])); }
1835
2219
  else setView(scanOrigin.current === "space" ? "space" : "clean");
1836
2220
  }
1837
2221
  if (s.status === "error") {
1838
- setNote(tr(lang, "扫描失败:") + (s.error || tr(lang, "未知错误")));
1839
- setFailure({
1840
- kind: "scan", status: "error", error: s.error || "",
1841
- fda: (overview && overview.report && overview.report.fda) || null,
1842
- elapsed: overview && overview.report && overview.report.elapsed,
1843
- truncated: !!(overview && overview.report && overview.report.truncated),
1844
- at: Date.now(),
1845
- });
2222
+ if (s.errorCode === "full_disk_access_required") {
2223
+ permRequest.current++;
2224
+ setPerm({ status: "denied", text: s.error || "" });
2225
+ } else if (s.errorCode !== "python_unavailable") {
2226
+ setNote(tr(lang, "扫描失败:") + (s.error || tr(lang, "未知错误")));
2227
+ setFailure({
2228
+ kind: "scan", status: "error", error: s.error || "",
2229
+ fda: (overview && overview.report && overview.report.fda) || null,
2230
+ elapsed: overview && overview.report && overview.report.elapsed,
2231
+ truncated: !!(overview && overview.report && overview.report.truncated),
2232
+ at: Date.now(),
2233
+ });
2234
+ }
1846
2235
  setWizard((w) => ({ ...w, open: false, phase: "idle" }));
1847
2236
  }
1848
2237
  if (s.status === "canceled") { setNote(tr(lang, "扫描已取消")); setWizard((w) => ({ ...w, open: false, phase: "idle" })); }
@@ -1860,6 +2249,7 @@ window.__ModuleLoader__.load({
1860
2249
  const body = await res.json();
1861
2250
  if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
1862
2251
  setScan(body.scan || { status: "running", progress: 0 });
2252
+ setPerm((prev) => prev.status === "python_unavailable" ? { status: "", text: "" } : prev);
1863
2253
  } catch (err) {
1864
2254
  const msg = String(err && err.message ? err.message : err);
1865
2255
  setNote(msg);
@@ -1879,6 +2269,12 @@ window.__ModuleLoader__.load({
1879
2269
  const report = overview && overview.report ? overview.report : null;
1880
2270
  const whitelist = (overview && overview.whitelist) || [];
1881
2271
  const platform = (overview && overview.platform) || "";
2272
+ const scanPermissionDenied = scan.status === "error" && scan.errorCode === "full_disk_access_required";
2273
+ const scanPythonUnavailable = scan.status === "error" && scan.errorCode === "python_unavailable";
2274
+ const permissionDenied = platform === "darwin" && (perm.status === "denied" || (!perm.status && scanPermissionDenied));
2275
+ useEffect(() => {
2276
+ if (scanPermissionDenied && permissionDenied) permNotice.current?.scrollIntoView({ block: "start" });
2277
+ }, [scanPermissionDenied, permissionDenied]);
1882
2278
  const trash = trashWord(lang, platform);
1883
2279
  const disk = liveDisk || (report && report.disk) || null;
1884
2280
  const allRows = buildRows(report, whitelist, "all", "", "sizeDesc", lang);
@@ -1902,39 +2298,63 @@ window.__ModuleLoader__.load({
1902
2298
  });
1903
2299
  }, []);
1904
2300
 
1905
- const checkPermissions = useCallback(async () => {
1906
- try {
1907
- const res = await fetch(`${API}/permissions`);
1908
- const body = await res.json();
1909
- setNote((body.text || body.error || "").trim());
1910
- } catch (err) { setNote(String(err && err.message ? err.message : err)); }
1911
- }, []);
1912
-
1913
- // macOS 未授权完全磁盘访问时,首页/清理页常驻引导卡(区别于一次性 note)。
1914
- const [perm, setPerm] = useState({ status: "ok", text: "" });
1915
- const [permSteps, setPermSteps] = useState(false);
1916
- const recheckPerm = useCallback(async () => {
2301
+ const checkPermissions = useCallback(async (showResult = false) => {
2302
+ const request = ++permRequest.current;
1917
2303
  try {
1918
2304
  const res = await fetch(`${API}/permissions`);
1919
2305
  const body = await res.json();
1920
- setPerm({ status: body.status || "ok", text: (body.text || "").trim() });
1921
- } catch { /* 探测失败按无异常处理,不出卡片 */ }
1922
- }, []);
1923
- useEffect(() => { if (platform === "darwin") recheckPerm(); }, [platform, recheckPerm]);
2306
+ if (request !== permRequest.current) return;
2307
+ if (body.errorCode === "python_unavailable") {
2308
+ setPerm({ status: "python_unavailable", text: "" });
2309
+ if (showResult) setNote("");
2310
+ return;
2311
+ }
2312
+ setPerm((prev) => prev.status === "python_unavailable" ? { status: "", text: "" } : prev);
2313
+ const detail = (body.text || body.error || "").trim();
2314
+ if (platform === "darwin" && (body.code === 0 || body.status === "denied")) {
2315
+ setPerm({ status: body.status, text: detail });
2316
+ if (body.status === "denied") {
2317
+ if (showResult) setNote("");
2318
+ return;
2319
+ }
2320
+ }
2321
+ if (showResult) setNote(detail);
2322
+ } catch (err) {
2323
+ if (showResult && request === permRequest.current) setNote(String(err && err.message ? err.message : err));
2324
+ }
2325
+ }, [platform]);
2326
+ useEffect(() => { if (platform === "darwin") checkPermissions(); }, [platform, checkPermissions]);
1924
2327
  const openPrivacySettings = useCallback(async () => {
1925
- try { await fetch(`${API}/open-privacy-settings`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" }); }
1926
- catch { /* 打不开设置页时详细步骤仍可手动跟随 */ }
2328
+ try {
2329
+ const res = await fetch(`${API}/open-privacy-settings`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
2330
+ if (!res.ok) throw new Error();
2331
+ setPrivacySettingsFailed(false);
2332
+ } catch {
2333
+ setPrivacySettingsFailed(true);
2334
+ setPermSteps(true);
2335
+ }
1927
2336
  }, []);
1928
2337
 
1929
- function PermBanner() {
1930
- if (perm.status !== "denied") return null;
1931
- return h("div", { className: "dc-notice", role: "alert" },
2338
+ function permissionBanner() {
2339
+ if (!permissionDenied) return null;
2340
+ return h("section", { className: "dc-permission", ref: permNotice },
1932
2341
  h(Icon, { name: "warning-circle", size: 18 }),
1933
- h("span", null, h("strong", null, tr(lang, "未授权完全磁盘访问,扫描结果会严重漏报。")), permSteps && perm.text ? "\n" + perm.text : ""),
1934
- h("button", { className: "dc-link", onClick: openPrivacySettings }, tr(lang, "打开系统设置")),
1935
- h("button", { className: "dc-link", onClick: () => setPermSteps((v) => !v) }, tr(lang, permSteps ? "收起详细步骤" : "查看详细步骤")),
1936
- h("button", { className: "dc-link", onClick: recheckPerm }, tr(lang, "重新检查")),
1937
- ai ? h("button", { className: "dc-link", disabled: busy, onClick: () => askAiFailure({ kind: "permission", fda: (report && report.fda) || null, detail: perm.text }) }, tr(lang, "问 AI 为什么扫不到")) : null);
2342
+ h("div", { className: "dc-permission-body" },
2343
+ h("div", { role: "status" },
2344
+ h("strong", null, tr(lang, scanPermissionDenied ? "扫描已停止,缺少完全磁盘访问权限" : "扫描前需要开启完全磁盘访问权限")),
2345
+ h("p", null, tr(lang, "macOS 限制了部分文件夹的访问。请为 DeepSeek Harness 开启权限,然后退出并重新打开应用。"))),
2346
+ h("div", { className: "dc-permission-actions" },
2347
+ h("button", { className: "dc-button dc-primary", onClick: openPrivacySettings }, tr(lang, "打开系统设置")),
2348
+ h("button", { className: "dc-link", "aria-expanded": permSteps, onClick: () => setPermSteps((v) => !v) }, tr(lang, permSteps ? "收起步骤" : "查看开启步骤"))),
2349
+ permSteps ? h(React.Fragment, null,
2350
+ privacySettingsFailed ? h("p", { role: "status" }, tr(lang, "未能打开系统设置,请按下方路径手动打开。")) : null,
2351
+ h("ol", null,
2352
+ h("li", null, tr(lang, "请前往「系统设置」→「隐私与安全」→「完全磁盘访问权限」。")),
2353
+ h("li", null, tr(lang, "打开 DeepSeek Harness 的开关。如果列表里没有它,点击「+」,在「应用程序」中选中 DeepSeek Harness,再点「打开」。")),
2354
+ h("li", null, tr(lang, "按 ⌘Q 退出 DeepSeek Harness,再重新打开。回到「磁盘体检」,点击「开始扫描」。"))),
2355
+ h("div", { className: "dc-permission-actions" },
2356
+ h("button", { className: "dc-link", onClick: () => checkPermissions() }, tr(lang, "重新检查")),
2357
+ ai ? h("button", { className: "dc-link", disabled: busy, onClick: () => askAiFailure({ kind: "permission", fda: { status: "denied" }, detail: perm.text || scan.error }) }, tr(lang, "问 AI 为什么扫不到")) : null)) : null));
1938
2358
  }
1939
2359
 
1940
2360
  const fetchReclaim = useCallback(async (paths) => {
@@ -2219,15 +2639,15 @@ window.__ModuleLoader__.load({
2219
2639
 
2220
2640
  // AI 入口:只把已算好的数据拼成提示词注入当前会话;服务不可用或没有会话时降级为复制提示词。
2221
2641
  const askAi = useCallback(async (prompt) => {
2222
- if (!ai || !prompt) return;
2223
- const status = await ai.ask(prompt);
2224
- if (status === "no-session" || status === "unavailable") {
2642
+ if (!prompt) return;
2643
+ const status = ai ? await ai.ask(prompt) : "unavailable";
2644
+ if (status === "sent") setNote(tr(lang, "已发送到对话"));
2645
+ else if (status === "no-session" || status === "unavailable") {
2225
2646
  try {
2226
- if (typeof navigator !== "undefined" && navigator.clipboard) await navigator.clipboard.writeText(prompt);
2227
- } catch { /* 复制失败仍然给出提示 */ }
2647
+ await navigator.clipboard.writeText(prompt);
2648
+ setNote(tr(lang, "已复制提示词,打开对话后粘贴发送"));
2649
+ } catch { setNote(tr(lang, "无法复制提示词,请打开 DSH 对话后重试。")); }
2228
2650
  }
2229
- if (status === "sent") setNote(tr(lang, "已发送到对话"));
2230
- else if (status === "no-session" || status === "unavailable") setNote(tr(lang, "已复制提示词,打开对话后粘贴发送"));
2231
2651
  else setNote(tr(lang, "AI 发送失败,请重试"));
2232
2652
  }, [ai, lang]);
2233
2653
  const askAiReport = useCallback(() => {
@@ -2333,7 +2753,7 @@ window.__ModuleLoader__.load({
2333
2753
  const fresh = reportFresh && !scanning;
2334
2754
  return h(React.Fragment, null,
2335
2755
  h("div", null, h("h1", null, tr(lang, "磁盘体检")), h("p", { className: "dc-subtitle" }, tr(lang, "了解空间占用,按需整理文件。"))),
2336
- h(PermBanner),
2756
+ permissionBanner(),
2337
2757
  h("section", { className: "dc-disk-summary", "aria-label": tr(lang, "磁盘容量") },
2338
2758
  h("div", { className: "dc-disk-data" }, h("img", { className: "dc-drive-image", src: "/dsh-deepclean/assets/disk.png", alt: "", "aria-hidden": true }),
2339
2759
  h("div", null, h("div", { className: "dc-drive-name" }, tr(lang, "系统磁盘"), disk ? h("span", { className: "dc-live", title: timeStr(disk.at) }, tr(lang, diskFresh ? "实时" : "上次扫描")) : null),
@@ -2363,9 +2783,9 @@ window.__ModuleLoader__.load({
2363
2783
  showAdvanced && !scanning ? h("div", { className: "dc-scan-options" },
2364
2784
  h("button", { className: "dc-button", disabled: busy, onClick: () => wizardStart(false) }, tr(lang, "快速扫描")),
2365
2785
  h("button", { className: "dc-button", disabled: busy, onClick: () => { wizardClose(); startScan(true); } }, tr(lang, "完整扫描")),
2366
- h("button", { className: "dc-link", onClick: checkPermissions }, tr(lang, "权限检查"))) : null)),
2786
+ h("button", { className: "dc-link", onClick: () => checkPermissions(true) }, tr(lang, "权限检查"))) : null)),
2367
2787
  wizard.open && wizard.phase !== "scanning" ? h("div", { className: "dc-wizard" }, WizardPanel()) : null,
2368
- scan.status === "error" ? h("div", { className: "dc-notice", role: "alert" }, h(Icon, { name: "warning-circle" }), h("span", null, tr(lang, "扫描失败,请重试。"), " ", untrustedText(scan.error || "", 240)), h("button", { className: "dc-link", onClick: () => wizardStart(true) }, tr(lang, "重试")), ai ? h("button", { className: "dc-link", disabled: busy, onClick: () => askAiFailure({ kind: "scan", status: "error", error: scan.error || "", fda: report && report.fda, elapsed: report && report.elapsed, truncated: !!(report && report.truncated) }) }, tr(lang, "AI 解释这次失败")) : null) : null,
2788
+ scan.status === "error" && !scanPermissionDenied && !scanPythonUnavailable ? h("div", { className: "dc-notice", role: "alert" }, h(Icon, { name: "warning-circle" }), h("span", null, tr(lang, "扫描失败,请重试。"), " ", untrustedText(scan.error || "", 240)), h("button", { className: "dc-link", onClick: () => wizardStart(true) }, tr(lang, "重试")), ai ? h("button", { className: "dc-link", disabled: busy, onClick: () => askAiFailure({ kind: "scan", status: "error", error: scan.error || "", fda: report && report.fda, elapsed: report && report.elapsed, truncated: !!(report && report.truncated) }) }, tr(lang, "AI 解释这次失败")) : null) : null,
2369
2789
  report && report.truncated && scan.status !== "error" ? h("div", { className: "dc-notice", role: "status" }, h(Icon, { name: "info" }), h("span", null, tr(lang, "扫描受时间预算限制,结果可能不完整。")), ai ? h("button", { className: "dc-link", disabled: busy, onClick: () => askAiFailure({ kind: "truncated", elapsed: report.elapsed, truncated: true }) }, tr(lang, "为什么结果不完整")) : null) : null,
2370
2790
  h("section", { className: "dc-recent" },
2371
2791
  h("div", { className: "dc-icon-disc" }, h(Icon, { name: "clock", size: 25 })),
@@ -2462,20 +2882,121 @@ window.__ModuleLoader__.load({
2462
2882
  }
2463
2883
 
2464
2884
  function History() {
2885
+ const records = cleans.map((batch) => flowRecord(batch, lang)).sort((a, b) => b.at - a.at);
2886
+ const live = records.filter((r) => r.restorable > 0);
2887
+ const current = records.find((r) => r.batch.batchId === historyBatchId) || live[0] || records[0];
2888
+ const shown = (current && !records.slice(0, 3).includes(current) ? [current, ...records.slice(0, 2)] : records.slice(0, 3))
2889
+ .sort((a, b) => a.at - b.at);
2890
+ const liveCount = live.reduce((n, r) => n + r.restorable, 0);
2891
+ const liveGroups = live.flatMap((r) => r.branches.filter((g) => g.status === "restorable"));
2892
+ const liveKb = liveGroups.reduce((n, g) => n + g.kb, 0);
2893
+ const liveSized = liveGroups.every((g) => g.sized) && liveGroups.reduce((n, g) => n + g.count, 0) === liveCount;
2894
+ const maxKb = Math.max(1, ...shown.map((r) => r.sized ? r.kb : 0));
2895
+ const now = Math.max(Date.now(), ...shown.map((r) => r.at));
2896
+ const start = Math.min(now - 86400000, ...shown.map((r) => r.at).filter(Boolean));
2897
+ const x = (time) => 8 + 984 * Math.max(0, Math.min(1, (time - start) / (now - start)));
2898
+ const dateLabel = (at) => at ? new Date(at).toLocaleDateString(lang === "en" ? "en" : "zh-CN", { month: "numeric", day: "numeric" }) : tr(lang, "时间未记录");
2899
+ const midpoint = (start + now) / 2;
2900
+ const ticks = dateLabel(midpoint) === dateLabel(start) || dateLabel(midpoint) === dateLabel(now)
2901
+ ? [start, now] : [start, midpoint, now];
2902
+ const choose = (record, focus) => {
2903
+ setHistoryBatchId(record.batch.batchId);
2904
+ setPurgeConfirm("");
2905
+ if (focus) historyDetail.current?.focus();
2906
+ };
2465
2907
  const trend = computeFreeTrend(history);
2466
- const regrowth = computeRegrowth(allRows, cleans);
2467
- const cleanedKb = cleans.reduce((n, b) => n + (b.items || []).reduce((m, it) => m + (Number(it.kb) || 0), 0), 0);
2468
- return h("div", null,
2908
+ const regrowth = reportFresh ? computeRegrowth(allRows, cleans) : [];
2909
+ const batch = current?.batch;
2910
+ const id = batch?.batchId;
2911
+ return h("div", { className: "dc-history" },
2912
+ h("header", { className: "dc-page-head" },
2913
+ h("div", null, h("h1", null, tr(lang, "历史")), h("p", { className: "dc-subtitle" }, tr(lang, "清理过的文件,后来去了哪里?"))),
2914
+ liveCount ? h("div", { className: "dc-flow-total" },
2915
+ h("span", null, tr(lang, "仍可恢复")),
2916
+ liveSized ? h("strong", null, flowSize(lang, liveKb)) : null,
2917
+ h("span", { className: "dc-caption" }, tf(lang, "{n} 项可恢复 · {batches} 批", { n: liveCount, batches: live.length }))) :
2918
+ batch ? h("span", { className: "dc-caption" }, tr(lang, "当前没有可恢复的批次")) : null),
2919
+ batch ? h("section", { "aria-label": tr(lang, "文件流向") },
2920
+ h("div", { className: "dc-flow-axis", "aria-hidden": true }, h("span", null), h("div", { className: "dc-flow-dates" },
2921
+ ticks.map((t) => h("time", { key: t, style: { left: x(t) / 10 + "%" } }, t === now ? tr(lang, "今天") : dateLabel(t))))),
2922
+ shown.map((r, i) => {
2923
+ const statuses = [...new Set(r.branches.map((g) => g.status))];
2924
+ return h("button", { key: r.batch.batchId, type: "button", className: "dc-flow-row", "aria-pressed": r === current,
2925
+ "aria-controls": "dc-history-batch", onClick: () => choose(r, false) },
2926
+ h("span", { className: "dc-flow-source" },
2927
+ h("strong", null, r.label),
2928
+ h("time", { className: "dc-caption", title: r.at ? timeStr(r.at) : undefined }, dateLabel(r.at)),
2929
+ h("span", { className: r.sized ? "dc-flow-value" : "dc-caption" },
2930
+ r.sized ? flowSize(lang, r.kb) : tf(lang, "{n} 项", { n: r.batch.count }))),
2931
+ flowRibbon(r, x, ticks, maxKb, i),
2932
+ h("span", { className: "dc-flow-ends" }, statuses.map((status) => {
2933
+ const groups = r.branches.filter((g) => g.status === status);
2934
+ const size = groups.every((g) => g.sized) ? " · " + displaySize(groups.reduce((n, g) => n + g.kb, 0)) : "";
2935
+ const date = groups.length === 1 && groups[0].at ? dateLabel(groups[0].at) : "";
2936
+ return h("span", { key: status, className: "dc-flow-status" + (status === "restorable" ? " dc-flow-live" : "") },
2937
+ tr(lang, FLOW_STATUS[status]) + size,
2938
+ date ? h("time", { className: "dc-caption" }, " · ", date) : null);
2939
+ })));
2940
+ }),
2941
+ h("div", { className: "dc-flow-note dc-caption" },
2942
+ h("p", null, tr(lang, "蓝色延伸到今天的流带表示文件仍可恢复。容量为清理时的估算。")),
2943
+ shown.some((r) => !r.sized) ? h("p", null, tr(lang, "缺少大小的批次用虚线表示。")) : null))
2944
+ : h("p", { className: "dc-flow-empty" }, tr(lang, "暂无清理记录。清理后,可在这里查看文件去向并恢复。")),
2945
+ batch ? h("section", { id: "dc-history-batch", className: "dc-flow-detail", ref: historyDetail, tabIndex: -1,
2946
+ "aria-label": tr(lang, "查看批次内容") },
2947
+ h("div", { className: "dc-flow-detail-head", "aria-live": "polite" },
2948
+ h("div", null, h("h2", null, current.label),
2949
+ h("p", { className: "dc-caption" }, tf(lang, "移入{trash}", { trash }), " · ",
2950
+ current.at ? timeStr(current.at) : tr(lang, "时间未记录"), " · ", tf(lang, "{n} 项", { n: batch.count }),
2951
+ current.sized ? " · " + flowSize(lang, current.kb) : " · " + tr(lang, "大小未记录"))),
2952
+ current.restorable > 0 ? h("button", { className: "dc-button dc-primary", disabled: busy, onClick: () => restoreBatch(id) },
2953
+ tr(lang, "恢复此批次")) : null),
2954
+ h("div", { className: "dc-flow-events dc-caption" },
2955
+ current.branches.map((g) => h("span", { key: g.key }, flowEvent(lang, g)))),
2956
+ current.items.length > 0 ? h("details", { key: id },
2957
+ h("summary", null, tr(lang, "查看批次内容")),
2958
+ h("ul", { className: "dc-flow-items" }, current.items.map((item, i) =>
2959
+ h("li", { key: i }, item.from, " · ", tr(lang, FLOW_STATUS[flowItemStatus(item)]))))) : null,
2960
+ h("div", { className: "dc-flow-controls" },
2961
+ batch.lastRestore?.failed > 0 ? h("span", { className: "dc-caption", role: "status" },
2962
+ tf(lang, "上次恢复失败 {n} 项", { n: batch.lastRestore.failed })) : null,
2963
+ current.restorable > 0 && batch.lastRestore?.codes?.exists > 0
2964
+ ? h("button", { style: btn, disabled: busy, onClick: () => restoreBatch(id, "rename") }, tr(lang, "另存为副本恢复")) : null,
2965
+ current.restorable > 0 ? h("button", { className: "dc-link dc-flow-delete", disabled: busy, "aria-expanded": purgeConfirm === id,
2966
+ onClick: () => setPurgeConfirm(purgeConfirm === id ? "" : id) }, tr(lang, "永久删除此批次")) : null),
2967
+ purgeConfirm === id && current.restorable > 0 ? h("div", { className: "dc-flow-controls" },
2968
+ h("span", null, tr(lang, "确认永久删除本批次?删除后无法撤销。")),
2969
+ h("button", { style: btnDanger, disabled: busy, onClick: () => doPurge(id) }, tr(lang, "确认永久删除")),
2970
+ h("button", { style: btn, disabled: busy, onClick: () => setPurgeConfirm("") }, tr(lang, "取消"))) : null) : null,
2971
+ batch ? h("details", null,
2972
+ h("summary", null, tr(lang, "全部清理记录")),
2973
+ h("p", { className: "dc-caption" }, tr(lang, "显示最近 20 个批次,以及更早仍可恢复的批次。")),
2974
+ h("ul", { className: "dc-flow-archive" }, records.map((r) => h("li", { key: r.batch.batchId },
2975
+ h("button", { type: "button", "aria-pressed": r === current, "aria-controls": "dc-history-batch", onClick: () => choose(r, true) },
2976
+ h("span", null, r.label, " · ", r.at ? timeStr(r.at) : tr(lang, "时间未记录")),
2977
+ h("span", { className: "dc-caption" }, r.branches.map((g) => flowEvent(lang, g)).join(" / "))))))) : null,
2978
+ quarantine && quarantine.kb > 0 ? h("details", null,
2979
+ h("summary", null, tr(lang, "隔离区/废纸篓")),
2980
+ h("div", { style: { fontSize: 12, marginBottom: 4 } }, tf(lang, "{trash}里还有 {size},永久删除后可释放", { trash, size: fmt(quarantine.kb) })),
2981
+ h("div", { style: { fontSize: 11, opacity: 0.6, marginBottom: 6, wordBreak: "break-all" } }, quarantine.root),
2982
+ h("div", { style: { display: "flex", gap: 8, flexWrap: "wrap" } },
2983
+ h("button", { style: btn, disabled: busy, onClick: () => setPurgeAllConfirm(!purgeAllConfirm) }, tf(lang, "永久删除全部({n} 批)", { n: quarantine.batches ? quarantine.batches.length : 0 })),
2984
+ h("button", { style: btn, disabled: busy, onClick: async () => {
2985
+ try {
2986
+ const r = await fetch(`${API}/reveal`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: quarantine.root }) });
2987
+ if (!r.ok) throw Error(tr(lang, "无法打开位置"));
2988
+ } catch (err) { setNote(String(err && err.message ? err.message : err)); }
2989
+ } }, tr(lang, "打开位置"))),
2990
+ purgeAllConfirm ? h("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap", marginTop: 6 } },
2991
+ h("span", { style: { fontSize: 12, color: "#e5534b" } }, tr(lang, "确认永久删除全部?删除后无法撤销。")),
2992
+ h("button", { style: btnDanger, disabled: busy, onClick: () => doPurgeAll(quarantine.batches ? quarantine.batches.map((b) => b.batchId) : []) }, tr(lang, "确认永久删除全部")),
2993
+ h("button", { style: btn, disabled: busy, onClick: () => setPurgeAllConfirm(false) }, tr(lang, "取消"))) : null) : null,
2994
+ h("details", null, h("summary", null, tr(lang, "趋势与复清")),
2469
2995
  h("div", { style: { display: "flex", alignItems: "baseline", gap: 10, flexWrap: "wrap" } },
2470
- h("div", { style: { fontSize: 13, fontWeight: 600, marginBottom: 6 } }, tr(lang, "趋势与复清")),
2471
2996
  ai ? h("button", { className: "dc-link", disabled: busy, onClick: askAiHistory }, tr(lang, "让 AI 分析趋势")) : null),
2472
2997
  trend ? h("div", { style: { fontSize: 12, marginBottom: 6 } },
2473
2998
  tf(lang, "过去 {days} 天:可用 {from} → {to}({delta})", { days: trend.days, from: fmtBytes(trend.from.free), to: fmtBytes(trend.to.free), delta: (trend.delta >= 0 ? "+" : "-") + fmtBytes(Math.abs(trend.delta)) })) :
2474
2999
  h("div", { style: { fontSize: 12, opacity: 0.6, marginBottom: 6 } }, tr(lang, "还没有足够的历史数据")),
2475
- cleans.length > 0 ? h("div", { style: { fontSize: 12, marginBottom: 6 } },
2476
- cleanedKb > 0
2477
- ? tf(lang, "累计清理 {n} 批 · 约 {size}", { n: cleans.length, size: fmt(cleanedKb) })
2478
- : tf(lang, "累计清理 {n} 批", { n: cleans.length }) + " · " + tr(lang, "更早的清理批次没有记录大小")) : null,
2479
3000
  regrowth.length > 0 ? h("div", { style: { marginBottom: 6 } },
2480
3001
  h("div", { style: { fontSize: 12, fontWeight: 600, marginBottom: 2 } }, tr(lang, "清了又长回来")),
2481
3002
  regrowth.map((g) => h("div", { key: g.row.id, style: { fontSize: 12, padding: "2px 0" } },
@@ -2491,54 +3012,17 @@ window.__ModuleLoader__.load({
2491
3012
  h("td", { style: td }, tf(lang, "可用 {size}", { size: fmtBytes(s.free) })),
2492
3013
  h("td", { style: td }, tf(lang, "可释放 {size}", { size: fmt(s.totalKb) })),
2493
3014
  h("td", { style: { ...td, opacity: 0.6 } }, `${s.elapsed}s`))))),
2494
- quarantine && quarantine.kb > 0 ? h("div", { style: { borderTop: "1px solid rgba(127,127,127,.18)", padding: "10px 0" } },
2495
- h("div", { style: { fontSize: 13, fontWeight: 600, marginBottom: 4 } }, tr(lang, "隔离区/废纸篓")),
2496
- h("div", { style: { fontSize: 12, marginBottom: 4 } }, tf(lang, "{trash}里还有 {size},永久删除后可释放", { trash, size: fmt(quarantine.kb) })),
2497
- h("div", { style: { fontSize: 11, opacity: 0.6, marginBottom: 6, wordBreak: "break-all" } }, quarantine.root),
2498
- h("div", { style: { display: "flex", gap: 8, flexWrap: "wrap" } },
2499
- h("button", { style: btn, disabled: busy, onClick: () => setPurgeAllConfirm(!purgeAllConfirm) }, tf(lang, "永久删除全部({n} 批)", { n: quarantine.batches ? quarantine.batches.length : 0 })),
2500
- h("button", { style: btn, disabled: busy, onClick: async () => {
2501
- try {
2502
- const r = await fetch(`${API}/reveal`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: quarantine.root }) });
2503
- if (!r.ok) throw Error(tr(lang, "无法打开位置"));
2504
- } catch (err) { setNote(String(err && err.message ? err.message : err)); }
2505
- } }, tr(lang, "打开位置"))),
2506
- purgeAllConfirm ? h("div", { style: { display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap", marginTop: 6 } },
2507
- h("span", { style: { fontSize: 12, color: "#e5534b" } }, tr(lang, "确认永久删除全部?删除后无法撤销。")),
2508
- h("button", { style: btnDanger, disabled: busy, onClick: () => doPurgeAll(quarantine.batches ? quarantine.batches.map((b) => b.batchId) : []) }, tr(lang, "确认永久删除全部")),
2509
- h("button", { style: btn, disabled: busy, onClick: () => setPurgeAllConfirm(false) }, tr(lang, "取消"))) : null) : null,
2510
- h("div", { style: { fontSize: 13, fontWeight: 600, margin: "14px 0 6px" } }, tr(lang, "清理批次(可恢复)")),
2511
- cleans.length === 0 ? h("div", { style: { fontSize: 12, opacity: 0.6 } }, tr(lang, "(暂无)")) :
2512
- cleans.map((c) => {
2513
- const ok = c.restorable === undefined ? c.count : c.restorable;
2514
- return h("div", { key: c.batchId, style: { borderTop: "1px solid rgba(127,127,127,.18)", padding: "8px 0", display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" } },
2515
- h("span", { style: { fontSize: 12 } }, timeStr(c.at)),
2516
- h("span", { style: { fontSize: 12, opacity: 0.7 } }, tf(lang, "{n} 项", { n: c.count }) + (ok !== c.count ? tf(lang, ",其中 {n} 项可恢复", { n: ok }) : "")),
2517
- h("button", { style: btn, disabled: busy || ok === 0, onClick: () => restoreBatch(c.batchId) },
2518
- ok > 0 ? tr(lang, "恢复此批次")
2519
- : c.purged ? tr(lang, "已永久删除")
2520
- : (c.restored > 0 ? tr(lang, "已恢复") : tr(lang, "已清空"))),
2521
- c.lastRestore && c.lastRestore.codes && c.lastRestore.codes.exists > 0
2522
- ? h("button", { style: btn, disabled: busy || ok === 0, onClick: () => restoreBatch(c.batchId, "rename") }, tr(lang, "另存为副本恢复"))
2523
- : null,
2524
- c.lastRestore && c.lastRestore.failed > 0
2525
- ? h("span", { style: { fontSize: 11, color: "#e5534b" } }, tf(lang, "上次恢复失败 {n} 项", { n: c.lastRestore.failed }))
2526
- : null,
2527
- ok > 0 ? h("button", { style: btnDanger, disabled: busy, onClick: () => setPurgeConfirm(purgeConfirm === c.batchId ? "" : c.batchId) }, tr(lang, "永久删除此批次")) : null,
2528
- purgeConfirm === c.batchId ? h("div", { style: { flexBasis: "100%", display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap", marginTop: 4 } },
2529
- h("span", { style: { fontSize: 12, color: "#e5534b" } }, tr(lang, "确认永久删除本批次?删除后无法撤销。")),
2530
- h("button", { style: btnDanger, disabled: busy, onClick: () => doPurge(c.batchId) }, tr(lang, "确认永久删除")),
2531
- h("button", { style: btn, disabled: busy, onClick: () => setPurgeConfirm("") }, tr(lang, "取消"))) : null);
2532
- }),
3015
+ )
2533
3016
  );
2534
3017
  }
2535
3018
 
2536
3019
  function SpacePage() {
2537
3020
  return h("div",null,
2538
- h("div",{className:"dc-page-head"},h("div",null,h("h1",null,tr(lang,"空间分析")),h("p",{className:"dc-subtitle"},tr(lang,"找到占用来源,按需整理或移动。"))),
3021
+ h("div",{className:"dc-page-head"},h("div",null,h("h1",null,tr(lang,"空间分析")),spaceMode === "large" ? h("p",{className:"dc-subtitle"},tr(lang,"找到占用来源,按需整理或移动。")) : null),
2539
3022
  h("div",{className:"dc-mode-switch","aria-label":tr(lang,"空间视图")},[["tree","目录分布"],["large","大文件"]].map(([id,label])=>h("button",{key:id,"aria-pressed":spaceMode===id,onClick:()=>setSpaceMode(id)},tr(lang,label))))),
2540
- h("div",{hidden:spaceMode!=="tree"},h(SpaceView,{lang,report,fresh:reportFresh,whitelist,refreshToken:spaceRefresh,onClean:goClean,onLarge:(p,files,at)=>{setSpaceScope(p);setSpaceFiles({scope:p,files,at});setSpaceMode("large");}})),
2541
- h("div",{hidden:spaceMode!=="large"},h(LargeView,{report,lang,scan,fresh:reportFresh,scope:spaceScope,ai,onAiFailure:askAiFailure,onScope:(p)=>{setSpaceScope(p);if(!p)setSpaceFiles(null);},local:spaceFiles&&spaceScope&&spaceFiles.scope===spaceScope?spaceFiles:null,onLocal:setSpaceFiles,onScan:()=>startScan(true),onCancel:cancelScan,at:overview?.at,whitelist,onMoved:()=>{loadOverview();refreshDisk();setSpaceRefresh(n=>n+1);}})));
3023
+ view === "space" && spaceMode === "large" && scanPermissionDenied ? permissionBanner() : null,
3024
+ h("div",{hidden:spaceMode!=="tree"},h(SpaceView,{lang,report,fresh:reportFresh,whitelist,refreshToken:spaceRefresh,onClean:goClean,onSetupPython:askAi,onLarge:(p,files,at,cap)=>{setSpaceScope(p);setSpaceFiles({scope:p,files,at,cap});setSpaceMode("large");}})),
3025
+ h("div",{hidden:spaceMode!=="large"},h(LargeView,{report,lang,scan,fresh:reportFresh,scope:spaceScope,ai,onAiFailure:askAiFailure,onSetupPython:askAi,onScope:(p)=>{setSpaceScope(p);if(!p)setSpaceFiles(null);},local:spaceFiles&&spaceScope&&spaceFiles.scope===spaceScope?spaceFiles:null,onLocal:setSpaceFiles,onScan:()=>startScan(true),onCancel:cancelScan,at:overview?.at,whitelist,onMoved:()=>{loadOverview();refreshDisk();setSpaceRefresh(n=>n+1);}})));
2542
3026
  }
2543
3027
 
2544
3028
  function CleanPage() {
@@ -2547,8 +3031,8 @@ window.__ModuleLoader__.load({
2547
3031
  h("div", { className: "dc-page-meta" }, h("span", null, tr(lang, scanning ? "扫描中…" : !report ? "等待首次扫描" : reportFresh ? "扫描完成" : "扫描结果已过期"), report && !scanning ? ` · ${recentTime(overview.at, lang)}` : ""),
2548
3032
  h("button", { className: "dc-link", disabled: scanning || busy, onClick: () => startScan(false) }, h(Icon, { name: "arrow-clockwise", size: 17, color: "#356ae6" }), tr(lang, "重新扫描")))),
2549
3033
  h("div", { className: "dc-mode-switch", "aria-label": tr(lang, "清理类型") }, [["junk", "垃圾清理"], ["photos", "相似照片"]].map(([id, label]) => h("button", { key: id, "aria-pressed": cleanMode === id, onClick: () => setCleanMode(id) }, tr(lang, label))))),
2550
- h(PermBanner),
2551
- cleanMode === "junk" ? Clean() : h(PhotoView, { lang, platform: (overview && overview.platform) || "" }));
3034
+ permissionBanner(),
3035
+ cleanMode === "junk" ? Clean() : h(PhotoView, { lang, platform, onSetupPython: askAi }));
2552
3036
  }
2553
3037
 
2554
3038
  function Settings() {
@@ -2575,22 +3059,30 @@ window.__ModuleLoader__.load({
2575
3059
  h("input", { style: { ...input, flex: 1 }, placeholder: tr(lang, "输入要保护的绝对路径"), value: newWhitelist, onChange: (e) => setNewWhitelist(e.target.value) }),
2576
3060
  h("button", { style: btn, onClick: addWhitelist }, tr(lang, "添加"))),
2577
3061
  h("div", { style: { fontSize: 13, fontWeight: 600, marginBottom: 6 } }, tr(lang, "环境")),
3062
+ permissionBanner(),
2578
3063
  h("div", { style: { fontSize: 12, opacity: 0.75, lineHeight: 1.8 } },
2579
3064
  h("div", null, `${tr(lang, "平台")}: ${overview ? overview.platform : "?"}`),
2580
- h("div", null, `${tr(lang, "权限")}: ${report && report.fda ? (report.fda.status === "granted" ? tr(lang, "完全磁盘访问已授权") : report.fda.status === "denied" ? tr(lang, "完全磁盘访问未授权") : tr(lang, "无法确认")) : tr(lang, "未知")}`),
3065
+ h("div", null, `${tr(lang, "权限")}: ${permissionDenied ? tr(lang, "完全磁盘访问未授权") : report && report.fda ? (report.fda.status === "granted" ? tr(lang, "完全磁盘访问已授权") : report.fda.status === "denied" ? tr(lang, "完全磁盘访问未授权") : tr(lang, "无法确认")) : tr(lang, "未知")}`),
2581
3066
  h("div", null, `${tr(lang, "隔离区/废纸篓")}: ${quarantine ? quarantine.root : "—"}${quarantine ? ` · ${fmt(quarantine.kb)}` : ""}`),
2582
3067
  h("div", null, `${tr(lang, "审计日志")}: ${(overview && overview.paths && overview.paths.audit) || "~/.dsh/deepclean-audit.log"}`),
2583
3068
  h("div", null, `${tr(lang, "历史文件")}: ${(overview && overview.paths && overview.paths.history) || "~/.dsh/deepclean-history.json"}`),
2584
3069
  h("div", null, `${tr(lang, "保护名单文件")}: ${(overview && overview.paths && overview.paths.whitelist) || "~/.dsh/deepclean-whitelist.json"}`)),
2585
3070
  h("div", { style: { display: "flex", gap: 8, marginTop: 10 } },
2586
- h("button", { style: btn, onClick: checkPermissions }, tr(lang, "权限检查")),
3071
+ h("button", { style: btn, onClick: () => checkPermissions(true) }, tr(lang, "权限检查")),
2587
3072
  h("button", { style: btn, disabled: busy || scan.status === "running", onClick: () => startScan(false) }, tr(lang, "立即扫描"))));
2588
3073
  }
2589
3074
 
2590
- return h("div", { className: "dc", ref: rootRef, lang: lang === "en" ? "en" : "zh-CN" },
3075
+ const permissionCheckNeedsPython = perm.status === "python_unavailable" && (view === "settings" || view === "home");
3076
+ const showScanPythonNotice = scanPythonUnavailable && (view === "home" || view === "settings" ||
3077
+ (view === "clean" && cleanMode === "junk") || (view === "space" && spaceMode === "large" && !spaceScope));
3078
+ return h("div", { className: view === "space" && spaceMode === "tree" ? "dc dc-tide-view" : "dc", ref: rootRef, lang: lang === "en" ? "en" : "zh-CN" },
2591
3079
  h("style", null, STYLES), Header(),
2592
3080
  h("main", { className: "dc-content" },
2593
3081
  note ? h("div", { className: "dc-notice", role: "status" }, h(Icon, { name: "info", size: 18 }), h("span", null, trNote(lang, note)), h("button", { "aria-label": tr(lang, "关闭提示"), onClick: () => setNote("") }, h(Icon, { name: "x", size: 17 }))) : null,
3082
+ showScanPythonNotice || permissionCheckNeedsPython ? h(PythonSetupNotice, {
3083
+ lang, onSend: askAi, busy: busy || scanning,
3084
+ onRetry: permissionCheckNeedsPython ? () => checkPermissions(true) : () => startScan(scan.full),
3085
+ }) : null,
2594
3086
  // 失败/降级诊断入口:只读解释,不触发任何重试或写操作;首页的扫描错误卡自带入口,避免重复。
2595
3087
  failure && ai && !(failure.kind === "scan" && scan.status === "error" && view === "home") ? h("div", { className: "dc-notice", role: "status" },
2596
3088
  h(Icon, { name: "warning-circle", size: 18 }),