feat(mods): 本地 Mod 列表与启停(M224 MODTOGGLE)

三层一次做完:core 读写目录 → presenter DTO/host → 实例详情页 MODS 区。
r1 复审后 rebase 到 main 71144df(M223 已合入),并修掉四条文档疵。

core(crates/core/src/addon/mods/)
- suffix:`.disabled` / `.old` 的**唯一判据**(四态枚举 + 展示名 + 双向改名目标名),
  收敛原先两份同构实现——`export/scan.rs::is_disabled_path` 与 `import/plan/paths.rs::suffix_variant`
  改为委派/再导出,签名与行为不变。
- list:单根语义(入口是 derive_game_dir 派生出的运行目录)列 `<run>/mods/`,不按扩展名过滤,
  符号链接/目录/怪名字逐条登记原因;`mods/` 本身是符号链接时拒(不跟随)。
- toggle:启停 = 在 `mods/` 里改名。五条守卫(裸文件名 / 目录可用 / 普通文件 / 不覆盖 / 不做
  copy+delete 兜底)+ 幂等;`.disabled` 只插在 `.old` 之前。

presenter
- dto/mods:ModsCommand / ModListView / ModFailureView(不拼文案,只给结构 + OS 原文)。
- host/mods:后台线程列目录与启停,运行目录走与启动同一份 D20 派生、不写任何设置;
  游戏在跑时 host 侧直接挡。
- state/mods:快照整体替换、迟到结果丢弃、在飞标记;`AppState::mods`。

app-iced(实例详情页)
- MODS 区(`pages/instances/mods.rs`):名字 + 大小 + `.old` 徽标 + Switch;运行中/在飞只读。
- 进入详情页即后台列一次(不走 3 s 观察窗,避免打红 M192 的「空闲不烧帧」守卫)。
- MODS 卡排在启动参数**之前**(详情列是滚动区,排在后面 1180×1000 下永远看不见)。
- i18n:19 条中英文案(`catalog/instances.rs` + 金表)。

rebase 冲突解法(三个文件,全是"两侧同段新增",都保留):
- `i18n/catalog/instances.rs` + `i18n/catalog/golden.rs`:M223 的 Adopt* 段在前、M224 的
  InstancesMods* 段在后(= `Key::ALL` 顺序 = 金表顺序)。
- `presenter/src/state.rs`:`adopt` 与 `mods` 字段/初始化/apply_core 分支都留;`tiles()` 归
  `state/tiles.rs`(本分支搬的),`apply_agent`/`agent_rail_visible` 归 `state/agent.rs`
  (M223 搬的)——两处搬移不重叠,state.rs 里都不再出现。

r1 四条文档疵:
1. `pages/instances/mods.rs` 模块文档:挂载位置写反("启动参数卡之后"→实际之前),并补上为什么;
2. `state/mods.rs`:引用不存在的 `CoreEvent::ModsChanged/ModsFailed` → 改成 `CoreEvent::Mods`
   + 文字说明 Ok 落快照 / Err 落具名失败;
3. `app/state/mods_shot.rs`:文档说"目录串取真实派生结果"但代码留空 → 改成"故意留空";
4. `app/state/refresh.rs::note_mods_failure`:文档说"连点两次看见两次红字"与去重机制不符 →
   改成按 `(实例, 文件, 大类)` 三元组去重(防刷屏),换文件/换实例/换大类仍会再弹。

顺带(r1 已确认的 main 红):`pages/download/options/view.rs:88-89` 两条
`clippy::doc_lazy_continuation` 只缩进修掉,文字未动。
This commit is contained in:
tower-worker committed 2026-11-10 21:09:37 +08:00
1 parent 71144dfca8
commit a0f61d8bb5
40 files changed
+2762 -91

No files matched your search

+18
View File
@@ -87,6 +87,24 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
("AdoptInstanceName", "Instance name", "实例名"),
("AdoptDoneTitle", "Imported", "已导入"),
("AdoptDoneHint", "It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"),
("InstancesModsTitle", "MODS", "模组"),
("InstancesModsCount", "{enabled} enabled · {disabled} disabled", "{enabled} 启用 · {disabled} 禁用"),
("InstancesModsLoading", "Reading mods/…", "正在读取 mods/…"),
("InstancesModsEmpty", "No mods here yet — put jars into mods/ and they show up.", "这里还没有模组——把模组文件放进 mods/ 就会出现在这里。"),
("InstancesModsMissingDir", "This instance has no mods/ directory yet.", "这个实例还没有 mods/ 目录。"),
("InstancesModsRunning", "The game is running — toggles are read-only until it exits.", "游戏正在运行——退出前开关只读。"),
("InstancesModsOldBadge", "legacy file", "旧版本残留"),
("InstancesModsSkipped", "{count} entries skipped", "跳过 {count} 项"),
("InstancesModsFailed", "Could not read mods/: {reason}", "读取 mods/ 失败:{reason}"),
("InstancesModsToggleFailed", "Could not toggle {file}: {reason}", "启停 {file} 失败:{reason}"),
("InstancesModsReasonRunning", "the game is running", "游戏正在运行"),
("InstancesModsReasonInstance", "the instance is gone", "实例已不存在"),
("InstancesModsReasonName", "the file name is not usable", "文件名不可用"),
("InstancesModsReasonDirectory", "there is no usable mods/ directory", "没有可用的 mods/ 目录"),
("InstancesModsReasonMissing", "the file is gone", "文件已不在"),
("InstancesModsReasonNotFile", "that is not a regular file", "那不是普通文件"),
("InstancesModsReasonCollision", "a file with that name already exists", "同名文件已存在"),
("InstancesModsReasonRename", "the rename failed", "改名失败"),
];
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。