zipcalm 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -2
  3. package/bin/zipcalm.js +191 -0
  4. package/package.json +64 -4
  5. package/server/app.js +309 -0
  6. package/server/boot.js +145 -0
  7. package/server/config/defaults.js +153 -0
  8. package/server/config/index.js +140 -0
  9. package/server/config/schema.js +244 -0
  10. package/server/core/auth/password.js +89 -0
  11. package/server/core/auth/store.js +261 -0
  12. package/server/core/compress/blacklist.js +236 -0
  13. package/server/core/compress/capRegistry.js +171 -0
  14. package/server/core/compress/deleter.js +285 -0
  15. package/server/core/compress/hardlinkScan.js +67 -0
  16. package/server/core/compress/orchestrator.js +783 -0
  17. package/server/core/compress/preflight.js +376 -0
  18. package/server/core/compress/registry.js +240 -0
  19. package/server/core/compress/sourceWalk.js +84 -0
  20. package/server/core/compress/verify.js +47 -0
  21. package/server/core/engineRunner.js +369 -0
  22. package/server/core/fs/list.js +164 -0
  23. package/server/core/fs/repairNames.js +150 -0
  24. package/server/core/fs/roots.js +185 -0
  25. package/server/core/fs/safePath.js +173 -0
  26. package/server/core/fs/search.js +108 -0
  27. package/server/core/fs/stat.js +110 -0
  28. package/server/core/fs/systemTrash.js +212 -0
  29. package/server/core/fs/writeOps.js +257 -0
  30. package/server/core/hardlink/executor.js +106 -0
  31. package/server/core/hardlink/scanner.js +193 -0
  32. package/server/core/metrics/collector.js +279 -0
  33. package/server/core/metrics/procfs.js +107 -0
  34. package/server/core/task/events.js +82 -0
  35. package/server/core/task/manager.js +987 -0
  36. package/server/core/task/recovery.js +116 -0
  37. package/server/core/task/store.js +240 -0
  38. package/server/http/middleware/auth.js +130 -0
  39. package/server/http/middleware/error.js +54 -0
  40. package/server/http/middleware/logger.js +27 -0
  41. package/server/http/middleware/pathGuard.js +119 -0
  42. package/server/http/router.js +29 -0
  43. package/server/http/routes/auth.js +115 -0
  44. package/server/http/routes/blacklist.js +97 -0
  45. package/server/http/routes/capabilities.js +21 -0
  46. package/server/http/routes/compress.js +339 -0
  47. package/server/http/routes/fs.js +109 -0
  48. package/server/http/routes/hardlink.js +160 -0
  49. package/server/http/routes/health.js +52 -0
  50. package/server/http/routes/metrics.js +36 -0
  51. package/server/http/routes/tasks.js +304 -0
  52. package/server/lib/atomic-json.js +118 -0
  53. package/server/lib/errors.js +145 -0
  54. package/server/lib/fs-probe.js +25 -0
  55. package/server/lib/logger.js +68 -0
  56. package/server/lib/runtime-guard.js +49 -0
  57. package/server/lib/throttle.js +42 -0
  58. package/server/plugins/capabilities/password/index.js +300 -0
  59. package/server/plugins/capabilities/password/methods/aes256.js +31 -0
  60. package/server/plugins/capabilities/password/methods/zip20.js +29 -0
  61. package/server/plugins/formats/_shared/createGuards.js +26 -0
  62. package/server/plugins/formats/_shared/entrySafety.js +174 -0
  63. package/server/plugins/formats/_shared/nameRepair.js +123 -0
  64. package/server/plugins/formats/_shared/streams.js +18 -0
  65. package/server/plugins/formats/_shared/tar/compression.js +114 -0
  66. package/server/plugins/formats/_shared/tar/reader.js +74 -0
  67. package/server/plugins/formats/_shared/tar/writer.js +206 -0
  68. package/server/plugins/formats/_shared/tarFamily.js +207 -0
  69. package/server/plugins/formats/rar/index.js +354 -0
  70. package/server/plugins/formats/rar/js-engine.js +122 -0
  71. package/server/plugins/formats/rar/js-runner.mjs +129 -0
  72. package/server/plugins/formats/rar/unrar.js +266 -0
  73. package/server/plugins/formats/sevenzip/engine.js +355 -0
  74. package/server/plugins/formats/sevenzip/index.js +461 -0
  75. package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
  76. package/server/plugins/formats/tar/index.js +19 -0
  77. package/server/plugins/formats/targz/index.js +19 -0
  78. package/server/plugins/formats/tarzst/index.js +26 -0
  79. package/server/plugins/formats/zip/index.js +587 -0
  80. package/shared/app-version.js +7 -0
  81. package/shared/capabilities.js +21 -0
  82. package/shared/compress-modes.js +38 -0
  83. package/shared/error-codes.js +117 -0
  84. package/shared/format-list.js +101 -0
  85. package/shared/hardlink-hint.js +21 -0
  86. package/shared/task-phase.js +62 -0
  87. package/shared/task-status.js +84 -0
  88. package/web/dist/assets/index-D9Vdmrb9.css +1 -0
  89. package/web/dist/assets/index-DiDB4ClR.js +131 -0
  90. package/web/dist/index.html +18 -0
@@ -0,0 +1,117 @@
1
+ // ZipCalm 错误码枚举(前后端共用的唯一来源)
2
+ //
3
+ // 依据:docs/M0/API规范.md §八「错误码枚举」——逐项对齐,不新增、不改语义。
4
+ // 为什么要集中在 shared/:契约要求「所有 code 同时写入 server/lib/errors.js 与
5
+ // shared/error-codes.js,两端 import 同一份」,否则会出现「后端返回 DISK_FULL、
6
+ // 前端不认识」的漂移(docs/12 §四)。
7
+ //
8
+ // 特例:INTERRUPTED / ABORTED 是「任务内专用码」,只出现在任务对象的 error.code
9
+ // 里,HTTP 层不返回(见 API 规范 §八「任务内专用码」)。
10
+
11
+ // 错误码字面量枚举:使用处一律 ERROR_CODES.XXX,禁止手写裸字符串
12
+ export const ERROR_CODES = Object.freeze({
13
+ BAD_REQUEST: 'BAD_REQUEST',
14
+ VALIDATION_FAILED: 'VALIDATION_FAILED',
15
+ INVALID_PATH: 'INVALID_PATH',
16
+ PATH_OUT_OF_ROOT: 'PATH_OUT_OF_ROOT',
17
+ PATH_NOT_FOUND: 'PATH_NOT_FOUND',
18
+ NOT_A_DIRECTORY: 'NOT_A_DIRECTORY',
19
+ PERMISSION_DENIED: 'PERMISSION_DENIED',
20
+ AUTH_REQUIRED: 'AUTH_REQUIRED',
21
+ AUTH_INVALID: 'AUTH_INVALID',
22
+ AUTH_EXPIRED: 'AUTH_EXPIRED',
23
+ FORBIDDEN: 'FORBIDDEN',
24
+ TASK_NOT_FOUND: 'TASK_NOT_FOUND',
25
+ TASK_CONFLICT: 'TASK_CONFLICT',
26
+ TASK_NOT_CANCELLABLE: 'TASK_NOT_CANCELLABLE',
27
+ INVALID_STATE: 'INVALID_STATE',
28
+ TARGET_EXISTS: 'TARGET_EXISTS',
29
+ CROSS_DEVICE_UNSUPPORTED: 'CROSS_DEVICE_UNSUPPORTED',
30
+ UNSUPPORTED_FORMAT: 'UNSUPPORTED_FORMAT',
31
+ FORMAT_UNAVAILABLE: 'FORMAT_UNAVAILABLE',
32
+ DISK_FULL: 'DISK_FULL',
33
+ QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
34
+ VERIFY_FAILED: 'VERIFY_FAILED',
35
+ ARCHIVE_CORRUPTED: 'ARCHIVE_CORRUPTED',
36
+ ALL_SOURCES_SKIPPED: 'ALL_SOURCES_SKIPPED',
37
+ SCAN_LIMIT_EXCEEDED: 'SCAN_LIMIT_EXCEEDED',
38
+ RANGE_NOT_SATISFIABLE: 'RANGE_NOT_SATISFIABLE',
39
+ NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
40
+ SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
41
+ RATE_LIMITED: 'RATE_LIMITED',
42
+ INTERNAL_ERROR: 'INTERNAL_ERROR',
43
+ INTERRUPTED: 'INTERRUPTED',
44
+ ABORTED: 'ABORTED',
45
+ // 加密压缩包解压(docs/19 §七 P4):三态必须区分,密码错 ≠ 文件损坏
46
+ MISSING_PASSWORD: 'MISSING_PASSWORD', // dsh-skip-sensitive
47
+ BAD_PASSWORD: 'BAD_PASSWORD', // dsh-skip-sensitive
48
+ SEVENZIP_MISSING: 'SEVENZIP_MISSING',
49
+ });
50
+
51
+ // 数组形态:便于做「码是否在全集内」的校验与遍历
52
+ export const ERROR_CODE_VALUES = Object.freeze(Object.values(ERROR_CODES));
53
+
54
+ // 每个码的元信息:HTTP 状态 + 中文提示 + 是否建议重试(http=null 表示不落到 HTTP 层)
55
+ export const ERROR_CODE_META = Object.freeze({
56
+ [ERROR_CODES.BAD_REQUEST]: { http: 400, label: '请求格式错误', retryable: false },
57
+ [ERROR_CODES.VALIDATION_FAILED]: { http: 400, label: '字段校验失败', retryable: false },
58
+ [ERROR_CODES.INVALID_PATH]: { http: 400, label: '路径格式非法', retryable: false },
59
+ [ERROR_CODES.PATH_OUT_OF_ROOT]: { http: 403, label: '路径超出可访问范围', retryable: false },
60
+ [ERROR_CODES.PATH_NOT_FOUND]: { http: 404, label: '路径不存在', retryable: false },
61
+ [ERROR_CODES.NOT_A_DIRECTORY]: { http: 400, label: '目标不是目录', retryable: false },
62
+ [ERROR_CODES.PERMISSION_DENIED]: { http: 403, label: '没有访问权限', retryable: false },
63
+ [ERROR_CODES.AUTH_REQUIRED]: { http: 401, label: '需要登录', retryable: false },
64
+ [ERROR_CODES.AUTH_INVALID]: { http: 401, label: '凭据无效', retryable: false },
65
+ [ERROR_CODES.AUTH_EXPIRED]: { http: 401, label: '登录已过期', retryable: false },
66
+ [ERROR_CODES.FORBIDDEN]: { http: 403, label: '策略拒绝', retryable: false },
67
+ [ERROR_CODES.TASK_NOT_FOUND]: { http: 404, label: '任务不存在', retryable: false },
68
+ [ERROR_CODES.TASK_CONFLICT]: { http: 409, label: '路径正被其他任务使用', retryable: false },
69
+ [ERROR_CODES.TASK_NOT_CANCELLABLE]: { http: 409, label: '当前状态不可取消', retryable: false },
70
+ [ERROR_CODES.INVALID_STATE]: { http: 409, label: '当前状态不允许该操作', retryable: false },
71
+ [ERROR_CODES.TARGET_EXISTS]: { http: 409, label: '目标已存在(不会覆盖)', retryable: false },
72
+ [ERROR_CODES.CROSS_DEVICE_UNSUPPORTED]: { http: 409, label: '不支持跨分区移动', retryable: false },
73
+ [ERROR_CODES.UNSUPPORTED_FORMAT]: { http: 400, label: '不支持的压缩格式', retryable: false },
74
+ [ERROR_CODES.FORMAT_UNAVAILABLE]: { http: 503, label: '该格式当前不可用', retryable: false },
75
+ [ERROR_CODES.DISK_FULL]: { http: 507, label: '磁盘空间不足', retryable: true },
76
+ [ERROR_CODES.QUOTA_EXCEEDED]: { http: 507, label: '已达到存储配额', retryable: true },
77
+ [ERROR_CODES.VERIFY_FAILED]: { http: 422, label: '压缩包校验未通过', retryable: false },
78
+ [ERROR_CODES.ARCHIVE_CORRUPTED]: { http: 422, label: '压缩包无法解析', retryable: false },
79
+ [ERROR_CODES.ALL_SOURCES_SKIPPED]: { http: 422, label: '没有可压缩的文件', retryable: false },
80
+ [ERROR_CODES.SCAN_LIMIT_EXCEEDED]: { http: 400, label: '扫描条目超出上限', retryable: false },
81
+ [ERROR_CODES.RANGE_NOT_SATISFIABLE]: { http: 416, label: '请求区间超出文件大小', retryable: false },
82
+ [ERROR_CODES.NOT_IMPLEMENTED]: { http: 501, label: '该功能尚未实现', retryable: false },
83
+ [ERROR_CODES.SERVICE_UNAVAILABLE]: { http: 503, label: '服务暂不可用', retryable: true },
84
+ [ERROR_CODES.RATE_LIMITED]: { http: 429, label: '请求过于频繁', retryable: true },
85
+ [ERROR_CODES.INTERNAL_ERROR]: { http: 500, label: '服务内部错误', retryable: false },
86
+ [ERROR_CODES.INTERRUPTED]: { http: null, label: '服务重启导致任务中断', retryable: true, taskOnly: true },
87
+ [ERROR_CODES.ABORTED]: { http: null, label: '任务已取消', retryable: false, taskOnly: true },
88
+ [ERROR_CODES.MISSING_PASSWORD]: { http: 422, label: '该压缩包已加密,请输入密码', retryable: true },
89
+ [ERROR_CODES.BAD_PASSWORD]: { http: 422, label: '密码错误', retryable: true },
90
+ [ERROR_CODES.SEVENZIP_MISSING]: { http: 503, label: '当前环境缺少 7z,无法解压 AES-256 加密包', retryable: false },
91
+ });
92
+
93
+ // 任务内专用码:UI 只在任务对象上遇见它们,不要拿去和 HTTP 状态做映射
94
+ export const TASK_ONLY_ERROR_CODES = Object.freeze([
95
+ ERROR_CODES.INTERRUPTED,
96
+ ERROR_CODES.ABORTED,
97
+ ]);
98
+
99
+ // 判断一个字符串是否是契约内的错误码
100
+ export function isErrorCode(code) {
101
+ return typeof code === 'string' && ERROR_CODE_VALUES.includes(code);
102
+ }
103
+
104
+ // 取错误码元信息;未知码回落 INTERNAL_ERROR,保证调用方永远拿到完整结构
105
+ export function errorCodeMeta(code) {
106
+ return ERROR_CODE_META[code] || ERROR_CODE_META[ERROR_CODES.INTERNAL_ERROR];
107
+ }
108
+
109
+ // 错误码 → HTTP 状态(任务内专用码返回 null)
110
+ export function httpStatusOf(code) {
111
+ return errorCodeMeta(code).http;
112
+ }
113
+
114
+ // 该错误是否建议用户重试(4xx 多数不可修正,5xx 中的三类可重试)
115
+ export function isRetryable(code) {
116
+ return errorCodeMeta(code).retryable === true;
117
+ }
@@ -0,0 +1,101 @@
1
+ // 压缩格式清单(前后端共用的唯一来源)
2
+ //
3
+ // 依据:docs/M0/插件契约.md §六「四个格式对号入座」+ docs/M0/数据模型.md §一。
4
+ // 这里只放「契约级」的静态元数据(id / 扩展名 / MIME / 显示名 / 级别范围);
5
+ // 运行期可用性(available)必须由后端 GET /api/v1/formats 返回,前端不猜。
6
+
7
+ export const FORMAT_IDS = Object.freeze({
8
+ ZIP: 'zip',
9
+ TAR: 'tar',
10
+ TARGZ: 'targz',
11
+ TARZST: 'tarzst',
12
+ });
13
+
14
+ /** zstd 支持的压缩级别上限(zstd 规范值;与格式包 levelRange 保持一致) */
15
+ const ZSTD_MAX_LEVEL = 19;
16
+
17
+ export const FORMATS = Object.freeze([
18
+ {
19
+ id: FORMAT_IDS.ZIP,
20
+ name: 'ZIP',
21
+ ext: '.zip',
22
+ extAliases: [],
23
+ mime: 'application/zip',
24
+ defaultLevel: 6,
25
+ levelRange: [0, 9],
26
+ },
27
+ {
28
+ id: FORMAT_IDS.TAR,
29
+ name: 'TAR',
30
+ ext: '.tar',
31
+ extAliases: [],
32
+ mime: 'application/x-tar',
33
+ defaultLevel: null, // tar 本身不压缩,无级别概念
34
+ levelRange: null,
35
+ },
36
+ {
37
+ id: FORMAT_IDS.TARGZ,
38
+ name: 'TAR.GZ',
39
+ ext: '.tar.gz',
40
+ extAliases: ['.tgz'],
41
+ mime: 'application/gzip',
42
+ defaultLevel: 6,
43
+ levelRange: [0, 9],
44
+ },
45
+ {
46
+ id: FORMAT_IDS.TARZST,
47
+ name: 'TAR.ZST',
48
+ ext: '.tar.zst',
49
+ extAliases: ['.tzst'],
50
+ mime: 'application/zstd',
51
+ defaultLevel: 3,
52
+ levelRange: [1, ZSTD_MAX_LEVEL],
53
+ },
54
+ ]);
55
+
56
+ export const FORMAT_VALUES = Object.freeze(FORMATS.map((item) => item.id));
57
+
58
+ // 默认格式:docs/08 第八节「默认值预设合理:格式 zip」
59
+ export const FORMAT_DEFAULT = FORMAT_IDS.ZIP;
60
+
61
+ // 按 id 索引
62
+ const FORMAT_BY_ID = new Map(FORMATS.map((item) => [item.id, item]));
63
+
64
+ // 扩展名索引:复合扩展名整体入表(.tar.gz 不拆成 .gz),保证「最长匹配优先」
65
+ const FORMAT_BY_EXT = new Map();
66
+ for (const format of FORMATS) {
67
+ for (const ext of [format.ext, ...format.extAliases]) {
68
+ FORMAT_BY_EXT.set(ext.toLowerCase(), format);
69
+ }
70
+ }
71
+
72
+ export function isFormatId(value) {
73
+ return FORMAT_VALUES.includes(value);
74
+ }
75
+
76
+ export function getFormat(id) {
77
+ return FORMAT_BY_ID.get(id) || null;
78
+ }
79
+
80
+ export function getFormatExt(id) {
81
+ const format = getFormat(id);
82
+ return format ? format.ext : null;
83
+ }
84
+
85
+ export function getFormatMime(id) {
86
+ const format = getFormat(id);
87
+ return format ? format.mime : 'application/octet-stream';
88
+ }
89
+
90
+ // 扩展名 → 格式 id:按「扩展名长度降序」匹配,避免 .gz 抢走 .tar.gz(对齐插件契约 §五 匹配优先级)
91
+ export function getFormatIdByExt(ext) {
92
+ if (typeof ext !== 'string' || ext === '') return null;
93
+ const lower = ext.toLowerCase();
94
+ const candidates = [...FORMAT_BY_EXT.keys()].sort((a, b) => b.length - a.length);
95
+ for (const candidate of candidates) {
96
+ if (lower === candidate || lower.endsWith(candidate)) {
97
+ return FORMAT_BY_EXT.get(candidate).id;
98
+ }
99
+ }
100
+ return null;
101
+ }
@@ -0,0 +1,21 @@
1
+ // 硬链接预检提示的共享契约:阈值与格式名单只在这里定义,前后端同源、避免各写一套漂移。
2
+ // 提示不改任何空间判定(INV-07 原样):zip 逐条累加恰好准确、tar 偏保守但不会漏拦。
3
+ // 判定依据只有一条 —— 同 dev 且同 ino 才是硬链接(跨分区相同 ino 是巧合)。
4
+
5
+ /** 提示阈值:当前格式会"多存"的字节占逻辑大小 ≥ 该比例时才提示(低于它属噪声) */
6
+ export const HARDLINK_HINT_MIN_RATIO = 0.1;
7
+
8
+ /** 保留硬链接的格式(tar 家族;zip 不保留) */
9
+ export const HARDLINK_PRESERVING_FORMATS = Object.freeze(['tar', 'targz', 'tarzst']);
10
+
11
+ /** 未知格式按"不保留"处理:宁可多提示一次,也不让用户以为省了空间。 */
12
+ export function formatPreservesHardlinks(formatId) {
13
+ return HARDLINK_PRESERVING_FORMATS.includes(String(formatId || '').toLowerCase());
14
+ }
15
+
16
+ /** 是否值得提示(仅格式不保留硬链接时使用):差异太小就不打扰。 */
17
+ export function shouldHintHardlink({ logicalBytes, duplicatedBytes }) {
18
+ if (!Number.isFinite(logicalBytes) || logicalBytes <= 0) return false;
19
+ if (!Number.isFinite(duplicatedBytes) || duplicatedBytes <= 0) return false;
20
+ return duplicatedBytes / logicalBytes >= HARDLINK_HINT_MIN_RATIO;
21
+ }
@@ -0,0 +1,62 @@
1
+ // 任务阶段枚举(前后端共用的唯一来源)
2
+ //
3
+ // 依据:docs/M0/数据模型.md §3.3「phase 与删除子状态机」——6 个值,按执行顺序排列。
4
+ // 为什么单独于 status:status 只回答「任务处于什么大状态」,phase 回答「卡在哪一步」;
5
+ // M5 的二次确认 UI(waiting_confirm)与 M6 的预检提示都依赖 phase。
6
+
7
+ export const TASK_PHASE = Object.freeze({
8
+ PRECHECK: 'precheck',
9
+ SCANNING: 'scanning',
10
+ COMPRESSING: 'compressing',
11
+ VERIFYING: 'verifying',
12
+ WAITING_CONFIRM: 'waiting_confirm',
13
+ DELETING: 'deleting',
14
+ });
15
+
16
+ // 顺序表:进度条与步骤条按此顺序渲染
17
+ export const TASK_PHASE_ORDER = Object.freeze([
18
+ TASK_PHASE.PRECHECK,
19
+ TASK_PHASE.SCANNING,
20
+ TASK_PHASE.COMPRESSING,
21
+ TASK_PHASE.VERIFYING,
22
+ TASK_PHASE.WAITING_CONFIRM,
23
+ TASK_PHASE.DELETING,
24
+ ]);
25
+
26
+ export const TASK_PHASE_VALUES = Object.freeze(Object.values(TASK_PHASE));
27
+
28
+ // 中文文案(用户能看懂的措辞,不要直接暴露英文枚举)
29
+ export const TASK_PHASE_LABELS = Object.freeze({
30
+ [TASK_PHASE.PRECHECK]: '检查权限与空间',
31
+ [TASK_PHASE.SCANNING]: '统计文件',
32
+ [TASK_PHASE.COMPRESSING]: '压缩中',
33
+ [TASK_PHASE.VERIFYING]: '校验压缩包',
34
+ [TASK_PHASE.WAITING_CONFIRM]: '待确认删除原文件',
35
+ [TASK_PHASE.DELETING]: '删除原文件',
36
+ });
37
+
38
+ // 可被取消的阶段:删除阶段不允许取消(删除是安全敏感动作,中途停止语义不清)
39
+ export const TASK_CANCELLABLE_PHASES = Object.freeze([
40
+ TASK_PHASE.PRECHECK,
41
+ TASK_PHASE.SCANNING,
42
+ TASK_PHASE.COMPRESSING,
43
+ TASK_PHASE.VERIFYING,
44
+ ]);
45
+
46
+ export function isTaskPhase(value) {
47
+ return TASK_PHASE_VALUES.includes(value);
48
+ }
49
+
50
+ export function taskPhaseLabel(phase) {
51
+ return TASK_PHASE_LABELS[phase] || '处理中';
52
+ }
53
+
54
+ export function isCancellablePhase(phase) {
55
+ return TASK_CANCELLABLE_PHASES.includes(phase);
56
+ }
57
+
58
+ // 阶段进度序号(1 起):UI 显示「第 3/6 步」
59
+ export function taskPhaseIndex(phase) {
60
+ const index = TASK_PHASE_ORDER.indexOf(phase);
61
+ return index === -1 ? 0 : index + 1;
62
+ }
@@ -0,0 +1,84 @@
1
+ // 任务状态枚举(前后端共用的唯一来源)
2
+ //
3
+ // 依据:docs/M0/数据模型.md §三「状态机」——7 个值。
4
+ // 为什么是 7 个而不是最初的 5 个:docs/11 M3 要求「服务重启后未完成的任务标记为中断」
5
+ // 且「能取消正在执行的任务」——「取消完成」(aborted) 与「重启中断」(interrupted) 是两种
6
+ // 不同终态;cancelling 是取消的过渡态(对齐参考实现 taskManager.ts 的 cancelTask)。
7
+
8
+ export const TASK_STATUS = Object.freeze({
9
+ QUEUED: 'queued',
10
+ RUNNING: 'running',
11
+ CANCELLING: 'cancelling',
12
+ DONE: 'done',
13
+ FAILED: 'failed',
14
+ ABORTED: 'aborted',
15
+ INTERRUPTED: 'interrupted',
16
+ });
17
+
18
+ export const TASK_STATUS_VALUES = Object.freeze(Object.values(TASK_STATUS));
19
+
20
+ // 终态:进入后不可再迁移(重试 = 新建任务,见数据模型 §3.2 禁止迁移)
21
+ export const TASK_TERMINAL_STATUSES = Object.freeze([
22
+ TASK_STATUS.DONE,
23
+ TASK_STATUS.FAILED,
24
+ TASK_STATUS.ABORTED,
25
+ TASK_STATUS.INTERRUPTED,
26
+ ]);
27
+
28
+ // 可取消的状态集合(后端还有 phase 守卫,这里只做 UI 置灰判断)
29
+ export const TASK_CANCELLABLE_STATUSES = Object.freeze([
30
+ TASK_STATUS.QUEUED,
31
+ TASK_STATUS.RUNNING,
32
+ ]);
33
+
34
+ // 可重试的状态集合(失败/中断可重试;重试会生成新 UUID)
35
+ export const TASK_RETRYABLE_STATUSES = Object.freeze([
36
+ TASK_STATUS.FAILED,
37
+ TASK_STATUS.INTERRUPTED,
38
+ ]);
39
+
40
+ // 中文文案:UI 直接取用,避免各处手写不同措辞
41
+ export const TASK_STATUS_LABELS = Object.freeze({
42
+ [TASK_STATUS.QUEUED]: '排队中',
43
+ [TASK_STATUS.RUNNING]: '压缩中',
44
+ [TASK_STATUS.CANCELLING]: '正在取消',
45
+ [TASK_STATUS.DONE]: '已完成',
46
+ [TASK_STATUS.FAILED]: '失败',
47
+ [TASK_STATUS.ABORTED]: '已取消',
48
+ [TASK_STATUS.INTERRUPTED]: '已中断',
49
+ });
50
+
51
+ // CSS 修饰类后缀:样式按状态着色(如 .task--failed)
52
+ export const TASK_STATUS_TONES = Object.freeze({
53
+ [TASK_STATUS.QUEUED]: 'muted',
54
+ [TASK_STATUS.RUNNING]: 'active',
55
+ [TASK_STATUS.CANCELLING]: 'warn',
56
+ [TASK_STATUS.DONE]: 'ok',
57
+ [TASK_STATUS.FAILED]: 'error',
58
+ [TASK_STATUS.ABORTED]: 'muted',
59
+ [TASK_STATUS.INTERRUPTED]: 'warn',
60
+ });
61
+
62
+ export function isTaskStatus(value) {
63
+ return TASK_STATUS_VALUES.includes(value);
64
+ }
65
+
66
+ export function isTerminalStatus(status) {
67
+ return TASK_TERMINAL_STATUSES.includes(status);
68
+ }
69
+
70
+ export function isCancellableStatus(status) {
71
+ return TASK_CANCELLABLE_STATUSES.includes(status);
72
+ }
73
+
74
+ export function isRetryableStatus(status) {
75
+ return TASK_RETRYABLE_STATUSES.includes(status);
76
+ }
77
+
78
+ export function taskStatusLabel(status) {
79
+ return TASK_STATUS_LABELS[status] || '未知状态';
80
+ }
81
+
82
+ export function taskStatusTone(status) {
83
+ return TASK_STATUS_TONES[status] || 'muted';
84
+ }
@@ -0,0 +1 @@
1
+ :root{--color-bg: #f4f6f8;--color-surface: #ffffff;--color-surface-muted: #f8fafc;--color-border: #e2e8f0;--color-border-strong: #cbd5e1;--color-text: #1f2733;--color-text-muted: #64748b;--color-text-inverse: #ffffff;--color-primary: #2f6fed;--color-primary-soft: #e8f0fe;--color-ok: #16a34a;--color-warn: #d97706;--color-error: #dc2626;--color-hover: rgba(47, 111, 237, .08);--color-active: rgba(47, 111, 237, .14);--radius-sm: 4px;--radius-md: 8px;--radius-lg: 12px;--shadow-card: 0 1px 2px rgba(15, 23, 42, .06), 0 1px 3px rgba(15, 23, 42, .08);--shadow-pop: 0 8px 24px rgba(15, 23, 42, .12);--space-1: 4px;--space-2: 8px;--space-3: 12px;--space-4: 16px;--space-5: 20px;--space-6: 24px;--topbar-height: 56px;--bottombar-height: 52px;--sidebar-width: 232px;--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Courier New", monospace;--font-size-xs: 12px;--font-size-sm: 13px;--font-size-md: 14px;--font-size-lg: 16px;--font-size-xl: 20px;--hit-target-min: 32px}*,*:before,*:after{box-sizing:border-box}html,body{height:100%}body{margin:0;font-family:var(--font-sans);font-size:var(--font-size-md);line-height:1.5;color:var(--color-text);background:var(--color-bg);-webkit-font-smoothing:antialiased}#app{height:100%}h1,h2,h3,h4,p{margin:0}ul{margin:0;padding:0;list-style:none}a{color:var(--color-primary);text-decoration:none}a:hover{text-decoration:underline}button{font:inherit;color:inherit;background:none;border:1px solid var(--color-border-strong);border-radius:var(--radius-sm);padding:var(--space-1) var(--space-3);min-height:var(--hit-target-min);cursor:pointer}button:hover:not(:disabled){background:var(--color-hover)}button:disabled{cursor:not-allowed;opacity:.5}input,select,textarea{font:inherit;color:inherit;background:var(--color-surface);border:1px solid var(--color-border-strong);border-radius:var(--radius-sm);padding:var(--space-1) var(--space-2);min-height:var(--hit-target-min)}input:focus-visible,button:focus-visible,a:focus-visible{outline:2px solid var(--color-primary);outline-offset:1px}.main-area,.sidebar{touch-action:manipulation}.scroll-area{overflow:auto;scrollbar-width:thin}.scroll-area::-webkit-scrollbar{width:8px;height:8px}.scroll-area::-webkit-scrollbar-thumb{background:var(--color-border-strong);border-radius:var(--radius-sm)}.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.app-shell{display:grid;height:100%;grid-template-columns:var(--sidebar-width) 1fr;grid-template-rows:var(--topbar-height) 1fr var(--bottombar-height);grid-template-areas:"topbar topbar" "sidebar main" "bottombar bottombar"}.topbar{grid-area:topbar;display:flex;align-items:center;gap:var(--space-4);padding:0 var(--space-4);background:var(--color-surface);border-bottom:1px solid var(--color-border)}.sidebar{grid-area:sidebar;display:flex;flex-direction:column;gap:var(--space-4);padding:var(--space-4) var(--space-3);background:var(--color-surface);border-right:1px solid var(--color-border);overflow:auto}.main-area{grid-area:main;display:flex;flex-direction:column;min-width:0;background:var(--color-bg);overflow:hidden}.bottom-task-bar{grid-area:bottombar;display:flex;align-items:center;gap:var(--space-4);padding:0 var(--space-4);background:var(--color-surface);border-top:1px solid var(--color-border)}.topbar__brand{display:flex;align-items:baseline;gap:var(--space-2);font-weight:600;white-space:nowrap}.topbar__brand-sub{font-size:var(--font-size-xs);font-weight:400;color:var(--color-text-muted)}.topbar__crumbs{display:flex;align-items:center;gap:var(--space-1);font-size:var(--font-size-sm);color:var(--color-text-muted);min-width:0;overflow:hidden;white-space:nowrap}.search-results{position:absolute;top:calc(100% + var(--space-1));left:0;z-index:2400;width:min(420px,80vw);max-height:320px;overflow:auto;padding:var(--space-1);background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 10px 28px #00000029}.search-results ul{margin:0;padding:0;list-style:none}.search-results__item{display:flex;flex-direction:column;align-items:flex-start;width:100%;min-height:40px;padding:var(--space-1) var(--space-2);border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text);text-align:left;cursor:pointer}.search-results__item:hover{background:var(--color-hover)}.search-results__name{font-size:var(--font-size-sm, .875rem)}.search-results__path{color:var(--color-text-muted);font-size:var(--font-size-xs);word-break:break-all}.search-results__hint{margin:var(--space-1) var(--space-2);color:var(--color-text-muted);font-size:var(--font-size-xs)}.file-table__row,.file-card{-webkit-user-select:none;user-select:none;-webkit-touch-callout:none}.topbar__search{flex:1 1 auto;max-width:320px}.topbar__search input{width:100%}.topbar__spacer{flex:1 1 auto}.topbar__views{display:flex;gap:var(--space-1)}.view-toggle{padding:var(--space-1) var(--space-3);font-size:var(--font-size-sm);color:var(--color-text-muted)}.view-toggle[aria-pressed=true]{color:var(--color-primary);border-color:var(--color-primary);background:var(--color-primary-soft)}.topbar__user{display:flex;align-items:center;gap:var(--space-2);font-size:var(--font-size-sm);color:var(--color-text-muted);white-space:nowrap}.sidebar__section{display:flex;flex-direction:column;gap:var(--space-2)}.sidebar__title{display:flex;align-items:center;gap:var(--space-1);font-size:var(--font-size-xs);font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--color-text-muted);padding:0 var(--space-2)}.nav-list{display:flex;flex-direction:column;gap:2px}.nav-item{display:flex;align-items:center;gap:var(--space-2);min-height:var(--hit-target-min);padding:var(--space-1) var(--space-2);border-radius:var(--radius-sm);color:var(--color-text);font-size:var(--font-size-sm)}.nav-item:hover{background:var(--color-hover);text-decoration:none}.nav-item[aria-current=page]{background:var(--color-primary-soft);color:var(--color-primary);font-weight:600}.nav-item__icon{display:inline-flex;width:16px;height:16px;color:currentColor}.tree{display:flex;flex-direction:column;gap:2px;font-size:var(--font-size-sm)}.tree__item{display:flex;align-items:center;gap:var(--space-2);padding:var(--space-1) var(--space-2);border-radius:var(--radius-sm);color:var(--color-text-muted)}.tree__item--root{color:var(--color-text);font-weight:600}.main-area__toolbar{display:flex;align-items:center;gap:var(--space-3);padding:var(--space-3) var(--space-4);background:var(--color-surface);border-bottom:1px solid var(--color-border)}.main-area__toolbar-title{font-size:var(--font-size-lg);font-weight:600}.main-area__toolbar-hint{font-size:var(--font-size-xs);color:var(--color-text-muted)}.main-area__body{flex:1 1 auto;padding:var(--space-4);overflow:auto}.page-outlet{display:flex;flex-direction:column;gap:var(--space-4);min-height:100%}.card{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:var(--shadow-card);padding:var(--space-5)}.card__title{font-size:var(--font-size-lg);font-weight:600;margin-bottom:var(--space-2)}.card__desc{color:var(--color-text-muted);font-size:var(--font-size-sm)}.chart-legend{display:flex;gap:var(--space-4);margin-top:var(--space-1);font-size:var(--font-size-xs)}.chart-legend__item{display:inline-flex;align-items:center;gap:var(--space-1);color:var(--color-text-muted)}.chart-legend__dot{width:8px;height:8px;border-radius:999px;display:inline-block}.metric__value{font-size:var(--font-size-2xl, 1.5rem);font-weight:600;font-variant-numeric:tabular-nums;margin:var(--space-1) 0;line-height:1.2}.context-menu{position:fixed;z-index:3000;min-width:180px;padding:var(--space-1) 0;background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 8px 24px #0000002e}.context-menu__item{display:flex;align-items:center;justify-content:space-between;gap:var(--space-4);width:100%;min-height:40px;padding:0 var(--space-3);border:0;background:transparent;color:var(--color-text);font-size:var(--font-size-sm, .875rem);text-align:left;cursor:pointer}.context-menu__item:hover:not(:disabled){background:var(--color-hover)}.context-menu__item:disabled{color:var(--color-text-muted);cursor:not-allowed}.context-menu__item--danger{color:var(--color-error, #dc2626)}.context-menu__shortcut{color:var(--color-text-muted);font-size:var(--font-size-xs)}.context-menu__divider{height:1px;margin:var(--space-1) 0;background:var(--color-border)}.dialog-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:3100;display:flex;align-items:center;justify-content:center;padding:var(--space-4);background:#0f172a73}.dialog{width:min(420px,100%);padding:var(--space-4);background:var(--color-surface);border-radius:var(--radius-md);box-shadow:0 16px 40px #00000040}.dialog__title{margin:0 0 var(--space-3);font-size:var(--font-size-lg, 1.125rem)}.dialog__field{display:block;margin-bottom:var(--space-3)}.dialog__field span{display:block;margin-bottom:var(--space-1);color:var(--color-text-muted);font-size:var(--font-size-xs)}.dialog__field input{width:100%;min-height:40px;padding:0 var(--space-3);border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-surface);color:var(--color-text)}.dialog__text{margin:0 0 var(--space-3);color:var(--color-text-muted)}.dialog__footer{display:flex;justify-content:flex-end;gap:var(--space-2)}.dialog__footer button{min-height:40px;padding:0 var(--space-4)}.dialog__primary{border-color:var(--color-primary, #2f6fed);background:var(--color-primary, #2f6fed);color:#fff}.dialog__primary--danger{border-color:var(--color-error, #dc2626);background:var(--color-error, #dc2626)}.file-browser__split{display:grid;grid-template-columns:minmax(160px,220px) minmax(0,1fr);gap:var(--space-4);margin-top:var(--space-3)}.file-browser__tree-pane{overflow:auto;max-height:62vh;border-right:1px solid var(--color-border);padding-right:var(--space-3)}.root-item{display:flex;flex-direction:column;align-items:stretch;gap:2px;width:100%;min-height:44px;padding:var(--space-1) var(--space-2);border:1px solid transparent;border-radius:var(--radius-sm);background:transparent;color:var(--color-text);text-align:left;cursor:pointer}.root-item:hover{background:var(--color-hover)}.root-item--active{background:color-mix(in srgb,var(--color-primary) 12%,transparent);border-color:color-mix(in srgb,var(--color-primary) 35%,transparent);font-weight:600}.root-item__name{font-size:var(--font-size-sm, .875rem);word-break:break-all}.root-item__size{color:var(--color-text-muted);font-size:var(--font-size-xs)}.root-item__bar{display:block;width:100%;height:3px;margin-top:2px;border-radius:999px;background:var(--color-surface-muted);overflow:hidden;pointer-events:none}.root-item__bar-fill{display:block;height:100%;background:var(--color-text-muted);border-radius:inherit;transition:width .2s ease-out}.root-item__bar-fill--warn{background:var(--color-warn)}.root-item__bar-fill--danger{background:var(--color-error)}.dir-tree__caption{margin:0 0 var(--space-2);color:var(--color-text-muted);font-size:var(--font-size-xs)}.dir-tree__node{display:flex;align-items:center;gap:var(--space-1);width:100%;min-height:40px;padding:0 var(--space-2);border:0;border-radius:var(--radius-sm);background:transparent;color:var(--color-text);text-align:left;cursor:pointer}.dir-tree__node:hover{background:var(--color-hover)}.dir-tree__node--active{background:var(--color-hover);font-weight:600}.dir-tree__node[data-drop-active=true]{outline:2px dashed var(--color-primary, #2f6fed);outline-offset:-2px;background:color-mix(in srgb,var(--color-primary, #2f6fed) 12%,transparent)}.dir-tree__list{margin:0;padding:0;list-style:none}.dir-tree__row{display:flex;align-items:center;gap:var(--space-1)}.dir-tree__toggle{width:28px;min-height:40px;border:0;background:transparent;color:var(--color-text-muted);cursor:pointer}.file-table__row--focused{outline:2px solid var(--color-primary, #2f6fed);outline-offset:-2px}.file-table__row--selected{background:color-mix(in srgb,var(--color-primary, #2f6fed) 10%,transparent)}.drop-target{outline:2px dashed var(--color-primary, #2f6fed);outline-offset:2px}@media (max-width: 760px){.file-browser__split{grid-template-columns:minmax(0,1fr)}.file-browser__tree-pane{max-height:30vh;border-right:0;border-bottom:1px solid var(--color-border);padding-right:0;padding-bottom:var(--space-2)}}.topbar__menu,.sidebar__close{display:none}@media (max-width: 760px){.app-shell{grid-template-columns:minmax(0,1fr);grid-template-areas:"topbar" "main" "bottombar"}.topbar__menu{display:inline-flex;align-items:center;justify-content:center;min-width:40px;min-height:40px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-surface);color:var(--color-text)}.sidebar{position:fixed;left:0;top:0;bottom:0;z-index:2500;width:min(78vw,300px);transform:translate(-100%);transition:transform .18s ease-out;box-shadow:8px 0 24px #0000002e}.sidebar--open{transform:none}.sidebar__close{display:inline-flex;align-items:center;justify-content:center;align-self:flex-end;min-width:40px;min-height:40px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-surface);color:var(--color-text)}}@media (max-width: 760px){.topbar__search,.topbar__crumbs,.topbar__brand-sub{display:none}.topbar{gap:var(--space-2);padding:0 var(--space-2);overflow:hidden}.topbar__brand{flex-shrink:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-browser__permission{max-width:62ch;padding:var(--space-2) var(--space-3);border:1px solid var(--color-border);border-left:3px solid var(--color-warn);border-radius:var(--radius-sm);background:var(--color-surface-muted)}.file-browser__permission p{margin:var(--space-1) 0}.file-browser__permission ul{margin:var(--space-1) 0;padding-left:1.25rem;line-height:1.6}.file-browser__permission code{padding:0 .25em;border-radius:3px;background:var(--color-hover);font-size:.92em}.file-browser__toolbar button:disabled{opacity:1;color:var(--color-text-muted);border-color:var(--color-border);background:var(--color-surface-muted);cursor:not-allowed}.file-browser__toolbar{flex-wrap:wrap;row-gap:var(--space-2)}.file-browser__toolbar button,.file-browser__crumb{flex-shrink:0}.bottom-task-bar{flex-wrap:wrap;gap:var(--space-1);padding:var(--space-1) var(--space-2);overflow:hidden}.bottom-task-bar__metrics{display:none}.bottom-task-bar__actions{flex-wrap:wrap;margin-left:auto}.file-browser__toolbar button,.file-browser__crumb,.bottom-task-bar__actions button,.dir-tree__node,.dir-tree__toggle{min-height:44px}.file-browser__toolbar [data-testid=open-compress],.file-browser__toolbar [data-testid=open-extract],.file-browser__toolbar [data-testid=reload],.file-browser__toolbar [data-testid=selection-count]{display:none}}.file-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:var(--space-3);margin-top:var(--space-3)}.file-card{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;gap:var(--space-1);min-height:96px;padding:var(--space-3) var(--space-2);border:1px solid var(--color-border);border-radius:var(--radius-md);background:var(--color-surface);cursor:pointer;text-align:center;overflow:hidden}.file-card--selected{background:color-mix(in srgb,var(--color-primary, #2f6fed) 12%,transparent);outline:2px solid var(--color-primary, #2f6fed);outline-offset:-2px}.file-card--focused{outline:2px dashed var(--color-primary, #2f6fed);outline-offset:-2px}.file-card__icon{color:var(--color-text-muted)}.file-card__name{width:100%;font-size:var(--font-size-xs);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-all}.file-card__meta{display:flex;flex-wrap:wrap;justify-content:center;gap:var(--space-1);color:var(--color-text-muted);font-size:var(--font-size-xs)}.bottom-bar{position:fixed;left:0;right:0;bottom:0;z-index:2000;display:flex;flex-direction:column;gap:var(--space-2);padding:var(--space-2) var(--space-3) calc(var(--space-2) + env(safe-area-inset-bottom,0px));background:var(--color-surface);border-top:1px solid var(--color-border);box-shadow:0 -6px 20px #0000001a}.bottom-bar__count{color:var(--color-text-muted);font-size:var(--font-size-xs)}.bottom-bar__actions{display:flex;gap:var(--space-2)}.bottom-bar__actions button{flex:1;min-height:44px;padding:0 var(--space-2)}.bottom-bar__danger{color:var(--color-error, #dc2626);border-color:currentcolor}@media (pointer: coarse){.file-table__row{height:44px}.file-table__row td{padding-top:0;padding-bottom:0}button,.file-browser__crumb,.dir-tree__node{min-height:44px}}@media (max-width: 760px){.file-grid{grid-template-columns:repeat(auto-fill,minmax(104px,1fr))}.file-browser{padding-bottom:96px}.drawer button,.drawer input,.drawer select,.dialog button,.dialog input{min-height:44px}}.placeholder-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:var(--space-4)}.placeholder-block{border:1px dashed var(--color-border-strong);border-radius:var(--radius-md);padding:var(--space-4);color:var(--color-text-muted);font-size:var(--font-size-sm);background:var(--color-surface-muted)}.badge{display:inline-flex;align-items:center;gap:var(--space-1);padding:1px var(--space-2);border-radius:var(--radius-sm);font-size:var(--font-size-xs);background:var(--color-surface-muted);border:1px solid var(--color-border);color:var(--color-text-muted)}.badge--ok{color:var(--color-ok);border-color:color-mix(in srgb,var(--color-ok) 35%,transparent)}.badge--active{color:var(--color-primary);border-color:color-mix(in srgb,var(--color-primary) 35%,transparent)}.badge--warn{color:var(--color-warn);border-color:color-mix(in srgb,var(--color-warn) 35%,transparent)}.badge--error{color:var(--color-error);border-color:color-mix(in srgb,var(--color-error) 35%,transparent)}.bottom-task-bar__summary{display:flex;align-items:center;gap:var(--space-3);font-size:var(--font-size-sm);white-space:nowrap}.bottom-task-bar__current{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--color-text-muted)}.bottom-task-bar__metrics{display:flex;gap:var(--space-3);font-family:var(--font-mono);font-size:var(--font-size-xs);color:var(--color-text-muted);white-space:nowrap}.bottom-task-bar__actions{display:flex;gap:var(--space-2)}.login-page{display:flex;align-items:center;justify-content:center;min-height:60vh}.login-card{width:min(380px,100%)}.login-card__brand{font-size:var(--font-size-xl);font-weight:600;margin-bottom:var(--space-2)}.login-form{display:flex;flex-direction:column;gap:var(--space-2);margin-top:var(--space-4)}.login-form__label{font-size:var(--font-size-sm);color:var(--color-text-muted)}.login-form__error{color:var(--color-error);font-size:var(--font-size-sm)}.login-form__submit{margin-top:var(--space-2);color:var(--color-text-inverse);background:var(--color-primary);border-color:var(--color-primary)}.login-form__submit:hover:not(:disabled){background:color-mix(in srgb,var(--color-primary) 88%,black)}.file-browser{padding:0;overflow:hidden}.file-browser__toolbar{display:flex;align-items:center;gap:var(--space-2);padding:var(--space-3);border-bottom:1px solid var(--color-border);background:var(--color-surface-muted)}.file-browser__crumbs{display:flex;align-items:center;gap:var(--space-1);flex-wrap:wrap;min-width:0}.file-browser__crumb{border:none;min-height:auto;padding:2px var(--space-1);color:var(--color-primary);background:none}.file-browser__crumb[aria-current=page]{color:var(--color-text);font-weight:600;cursor:default}.file-browser__crumb:hover:not([aria-current=page]){text-decoration:underline;background:none}.file-browser__hint{padding:var(--space-3);color:var(--color-text-muted);font-size:var(--font-size-sm)}.file-browser__empty,.file-browser__error{padding:var(--space-5);display:flex;flex-direction:column;gap:var(--space-2);align-items:flex-start}.file-browser__error{color:var(--color-error)}.file-browser__error p{color:var(--color-text)}.file-table{width:100%;border-collapse:collapse;font-size:var(--font-size-sm)}.file-table th,.file-table td{text-align:left;padding:var(--space-2) var(--space-3);border-bottom:1px solid var(--color-border);white-space:nowrap}.file-table thead th{font-weight:600;color:var(--color-text-muted);background:var(--color-surface)}.file-table__sort{border:none;padding:0;min-height:auto;color:inherit;background:none;font-weight:600}.file-table__row{cursor:default}.file-table__row--dir{cursor:pointer}.file-table__row:hover{background:var(--color-hover)}.file-table__name{display:inline-flex;align-items:center;gap:var(--space-2);min-width:0}.file-table__col-type{width:88px;color:var(--color-text-muted)}.file-table__size,.file-table__mtime{color:var(--color-text-muted);font-variant-numeric:tabular-nums}.file-table__col-check{width:32px;text-align:center}.file-table__col-check input{min-height:auto;cursor:pointer}.drawer-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;background:#0f172a59;display:flex;justify-content:flex-end;z-index:3000}.drawer{width:min(420px,100%);height:100%;background:var(--color-surface);box-shadow:var(--shadow-pop);display:flex;flex-direction:column;padding-bottom:env(safe-area-inset-bottom,0px)}.drawer__header,.drawer__footer{display:flex;align-items:center;gap:var(--space-2);padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border)}.drawer__footer{border-bottom:none;border-top:1px solid var(--color-border);justify-content:flex-end}.drawer__close{margin-left:auto}.drawer__body{padding:var(--space-4);display:flex;flex-direction:column;gap:var(--space-4);overflow:auto;flex:1 1 auto}.field{border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);display:flex;flex-direction:column;gap:var(--space-2)}.field legend{font-size:var(--font-size-xs);color:var(--color-text-muted);padding:0 var(--space-1)}.field__option{display:flex;align-items:center;gap:var(--space-2);font-size:var(--font-size-sm)}.field__option input{min-height:auto}.field__label{font-size:var(--font-size-sm);color:var(--color-text-muted)}.drawer__hardlink-hint{margin:0;padding:var(--space-2) var(--space-3);border:1px solid color-mix(in srgb,var(--color-primary) 32%,var(--color-border));border-left:3px solid var(--color-primary);border-radius:var(--radius-sm);background:color-mix(in srgb,var(--color-primary) 8%,var(--color-surface));color:var(--color-text);font-size:var(--font-size-sm);line-height:1.55;overflow-wrap:anywhere}.drawer__hardlink-hint b{font-weight:600}.drawer__hardlink-hint--ok{border-color:color-mix(in srgb,var(--color-ok) 32%,var(--color-border));border-left-color:var(--color-ok);background:color-mix(in srgb,var(--color-ok) 8%,var(--color-surface))}.field__hint{font-size:var(--font-size-xs);color:var(--color-text-muted)}.progress{height:8px;border-radius:999px;background:var(--color-surface-muted);border:1px solid var(--color-border);overflow:hidden;margin:var(--space-2) 0}.progress__bar{height:100%;background:var(--color-primary);transition:width .2s ease}.task-panel__head{display:flex;align-items:center;gap:var(--space-2)}.task-panel__confirm{display:flex;align-items:center;gap:var(--space-3);margin-top:var(--space-2);padding:var(--space-2) var(--space-3);border:1px solid var(--color-border-strong);border-radius:var(--radius-md);background:var(--color-surface-muted)}.bottom-task-bar__progress{display:inline-block;width:120px;height:6px;border-radius:999px;background:var(--color-surface-muted);border:1px solid var(--color-border);overflow:hidden;vertical-align:middle}.bottom-task-bar__file{color:var(--color-text-muted);max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.drawer--wide{width:min(760px,100%)}.task-drawer__body{display:grid;grid-template-columns:minmax(220px,1fr) minmax(280px,1.4fr);gap:var(--space-4);align-items:start}.task-drawer__list{display:flex;flex-direction:column;gap:var(--space-1);max-height:70vh;overflow:auto}.task-drawer__row{display:flex;align-items:center;gap:var(--space-2);justify-content:flex-start;text-align:left;width:100%}.task-drawer__row--active{outline:2px solid var(--color-primary)}.task-drawer__detail{border-left:1px solid var(--color-border);padding-left:var(--space-4);display:flex;flex-direction:column;gap:var(--space-2)}.task-drawer__kv{display:grid;grid-template-columns:72px 1fr;gap:var(--space-1) var(--space-2);font-size:var(--font-size-sm)}.task-drawer__kv dt{color:var(--color-text-muted)}.task-drawer__actions{display:flex;gap:var(--space-2);flex-wrap:wrap;margin-top:var(--space-2)}.modal-mask[data-v-b8c79d1b]{position:fixed;top:0;right:0;bottom:0;left:0;background:#00000073;display:flex;align-items:center;justify-content:center;z-index:60;padding:16px}.modal-card[data-v-b8c79d1b]{width:min(560px,100%);max-height:82vh;overflow:auto;background:var(--color-surface, #fff);border-radius:12px;padding:18px 20px;box-shadow:0 18px 48px #0000003d}.modal-card__head[data-v-b8c79d1b]{display:flex;align-items:center;justify-content:space-between;gap:12px}.modal-card__head h3[data-v-b8c79d1b]{margin:0;font-size:16px}.modal-card__close[data-v-b8c79d1b]{min-height:40px;min-width:64px;border:1px solid var(--color-border, #d8dde6);border-radius:8px;background:transparent;cursor:pointer}.modal-card__hint[data-v-b8c79d1b]{margin:10px 0;font-size:13px;line-height:1.6;color:var(--color-text-muted, #5b6472)}.modal-card__error[data-v-b8c79d1b]{margin:10px 0;color:var(--color-danger, #d33);font-size:13px}.modal-card__stats[data-v-b8c79d1b]{display:flex;flex-wrap:wrap;gap:8px 16px;padding:0;margin:10px 0;list-style:none;font-size:13px}.modal-card__list[data-v-b8c79d1b]{margin:8px 0;padding:0;list-style:none;font-size:12px;display:grid;gap:6px}.modal-card__list li[data-v-b8c79d1b]{display:flex;flex-wrap:wrap;gap:6px;padding:6px 8px;border-radius:6px;background:var(--color-surface-alt, #f5f7fa);word-break:break-all}.modal-card__list .arrow[data-v-b8c79d1b]{color:var(--color-text-muted, #5b6472)}.modal-card__list .to[data-v-b8c79d1b]{color:var(--color-primary, #2f6fed);font-weight:600}.modal-card__foot[data-v-b8c79d1b]{display:flex;justify-content:flex-end;gap:10px;margin-top:14px}.modal-card__foot .btn[data-v-b8c79d1b]{min-height:40px;min-width:88px;border-radius:8px;border:1px solid var(--color-border, #d8dde6);background:transparent;cursor:pointer}.modal-card__foot .btn--primary[data-v-b8c79d1b]{background:var(--color-primary, #2f6fed);border-color:var(--color-primary, #2f6fed);color:#fff}.modal-card__foot .btn[data-v-b8c79d1b]:disabled{opacity:.5;cursor:not-allowed}.hardlink-form[data-v-a34ec66d]{display:flex;flex-direction:column;gap:10px;margin:12px 0}.hardlink-form__row[data-v-a34ec66d]{display:flex;flex-direction:column;gap:4px}.hardlink-form__row input[data-v-a34ec66d]{padding:8px 10px;border:1px solid var(--color-border, #d0d3d9);border-radius:6px}.hardlink-summary[data-v-a34ec66d]{display:flex;gap:16px;flex-wrap:wrap;padding:8px 0;font-weight:600}.hardlink-groups[data-v-a34ec66d]{display:flex;flex-direction:column;gap:10px}.hardlink-groups__all[data-v-a34ec66d]{display:flex;align-items:center;gap:8px;font-weight:600}.hardlink-group[data-v-a34ec66d]{border:1px solid var(--color-border, #d0d3d9);border-radius:8px;padding:10px}.hardlink-group--selected[data-v-a34ec66d]{border-color:var(--color-primary, #2f6fed)}.hardlink-group__head[data-v-a34ec66d]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.hardlink-group__paths[data-v-a34ec66d]{margin:8px 0 0;padding-left:28px;word-break:break-all}.hardlink-group__master[data-v-a34ec66d]{font-weight:600}.uplot,.uplot *,.uplot *:before,.uplot *:after{box-sizing:border-box}.uplot{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";line-height:1.5;width:min-content}.u-title{text-align:center;font-size:18px;font-weight:700}.u-wrap{position:relative;-webkit-user-select:none;user-select:none}.u-over,.u-under{position:absolute}.u-under{overflow:hidden}.uplot canvas{display:block;position:relative;width:100%;height:100%}.u-axis{position:absolute}.u-legend{font-size:14px;margin:auto;text-align:center}.u-inline{display:block}.u-inline *{display:inline-block}.u-inline tr{margin-right:16px}.u-legend th{font-weight:600}.u-legend th>*{vertical-align:middle;display:inline-block}.u-legend .u-marker{width:1em;height:1em;margin-right:4px;background-clip:padding-box!important}.u-inline.u-live th:after{content:":";vertical-align:middle}.u-inline:not(.u-live) .u-value{display:none}.u-series>*{padding:4px}.u-series th{cursor:pointer}.u-legend .u-off>*{opacity:.3}.u-select{background:#00000012;position:absolute;pointer-events:none}.u-cursor-x,.u-cursor-y{position:absolute;left:0;top:0;pointer-events:none;will-change:transform}.u-hz .u-cursor-x,.u-vt .u-cursor-y{height:100%;border-right:1px dashed #607D8B}.u-hz .u-cursor-y,.u-vt .u-cursor-x{width:100%;border-bottom:1px dashed #607D8B}.u-cursor-pt{position:absolute;top:0;left:0;border-radius:50%;border:0 solid;pointer-events:none;will-change:transform;background-clip:padding-box!important}.u-axis.u-off,.u-select.u-off,.u-cursor-x.u-off,.u-cursor-y.u-off,.u-cursor-pt.u-off{display:none}.trend-chart[data-v-1e8d3936]{width:100%;overflow:hidden}