feat(app-iced): M109 CONTENTDL —— 内容库页(整合包/Mod/资源包/光影)

用户令「下载页只负责下载整合包和Mod/光影」:新开一页做**内容发现与装入**,
检索走 Modrinth(优先,无需 key)/ CurseForge(`ULL_CF_API_KEY` 环境变量,日志只打
set/unset),装入走 core 的下载引擎真下到实例目录,进度/取消/失败复用下载页那块安装面板。

- **入口(B1)**:`Route::Content` 是新顶层路由,但 `Route::nav()` 复用 `NavSection::Download`
  ——导航轨仍五项,页头右上是「版本管理 | 内容库」一级切换(版本管理页也加了同一个切换,
  否则内容库只有 `ULL_START_ROUTE` 能到)
- **页内四条一级侧栏**(`pages/content/layout.rs` 四档几何:992/856/648 三个门槛都是
  「Σ栏宽 + 间距」算出来的):整合包 / Mod / 资源包 / 光影;标签栏 200(`Resource packs`
  比 M107 的版本类型名长,140 会切字,已登记偏离)
- **筛选**:关键词(草稿 + 显式「搜索」按钮,不逐字打网络)、来源、游戏版本、加载器
  (Fabric/Forge/NeoForge/Quilt)、排序;**分页**(「加载更多」)而不是无限滚动——快照只有一个槽、
  翻页要看得见、状态要可截图复现
- **四态**:加载 / 空 / 失败 / 有数据 + 「没有更多了」;筛选条在任何状态下都在(加载中藏筛选条
  等于把入口收走)
- **一键装入**:`Content::blockers()` 是纯函数——版本不匹配、加载器不匹配、没有目标实例、
  整合包缺管线**四条原因逐条**给出,按钮点之前就禁用;判定与画法分开(model/tests 直接钉)
- **整合包今天不可装**:`ContentKind::Modpack.dir()` 是 `None`,UI 禁用 + 说明,host 侧再挡
  一次(绕过 UI 也只会回一条 Failed)。真流程是 M111 的导入管线
- **零 UI 网络**:`presenter/host/addon.rs` + `addon/{sources,http,install}.rs`;无 header 的请求走
  core 的 `download_bytes`(信号量限流 + 健康度 + 退避全现成),带 `X-API-KEY` 的只有 CF 一条,
  自带 429/5xx 退避(优先吃 `Retry-After`,封顶 30s);密钥不进 DTO/日志/仓库
- 文案 41 条中英进 `crates/i18n`(新域接在装配链末尾 ⇒ `Key::ALL` 与金表只追加)
- 演示数据 `ContentHit → Hit::of` 与产品路径同源(格式化不会两套),`ULL_CONTENT_KIND` /
  `ULL_CONTENT_STATE` 供出图矩阵选类型与状态

取证:`DevDocs/artifacts/m109/`(10 张:四类内容 × 浅/深 + 搜索无结果 + 加载器不兼容禁用)

门禁:fmt ✓ / clippy `-D warnings`(三 crate)✓ / app-iced 450+6、presenter 170、i18n 24 全绿;
`cargo tree` 两条零 UI 门禁仍空;全部改动文件 ≤400 行。
This commit is contained in:
AskaEth committed 2026-11-05 03:20:21 +08:00
1 parent 5adac230fe
commit 6b161227d8
42 files changed
+4071 -25

No files matched your search

@@ -0,0 +1,87 @@
//! 内容库页的守门(M109):演示数据、四类内容、页面自留状态的默认值。
//!
//! 视图模型的判定在 `model/tests.rs`,栏几何在 `layout.rs`,这里只管「演示数据真的装得满」——
//! 出图靠它,装不满就说明 `demo()` 与 `Content` 的字段对不上。
use unlucky_i18n::Lang;
use unlucky_presenter::dto::ContentKind;
use super::model::{PageState, Status};
use super::{demo, Content};
#[test]
fn the_page_state_starts_on_mods_with_nothing_selected() {
let page = PageState::default();
assert_eq!(page.kind, ContentKind::Mod);
assert!(page.selected.is_none());
assert!(page.target.is_none());
assert!(page.draft.is_empty());
}
#[test]
fn every_kind_gets_a_full_demo_screen() {
// 四种内容各出一屏(状态矩阵的前 8 张靠这一条)
for kind in ContentKind::ALL {
let model: Content = demo(kind, Lang::En);
assert_eq!(model.kind, kind, "{} 的演示数据换了类型", kind.slug());
assert_eq!(model.status, Status::Ready, "{}", kind.slug());
assert_eq!(model.hits.len(), 4, "{} 的命中项不足", kind.slug());
assert!(
model.selected.is_some(),
"演示数据要选中一条(详情面板才有内容)"
);
assert!(model.total > 0);
assert!(model.has_more, "演示数据要能画「加载更多」");
// 整合包**不**在这一条里断(它天生不可装:`ContentKind::Modpack.dir()` 是 `None`),
// 由下一条用例专钉。
if kind != ContentKind::Modpack {
assert!(
model.can_install(),
"默认那一屏应当是「能装」的({})",
kind.slug()
);
}
}
}
#[test]
fn the_modpack_screen_is_browsable_but_never_installable() {
// 整合包:检索能看,装入一律禁用(M111 未落地)——演示图上必须看得到原因
let model = demo(ContentKind::Modpack, Lang::En);
assert!(model.selected.is_some());
assert!(!model.can_install());
assert_eq!(
model.blocker_reasons(Lang::En.catalog()).len(),
1,
"整合包只有一条原因(导入管线未落地)"
);
}
#[test]
fn the_installable_kinds_block_only_when_the_target_disagrees() {
for kind in [
ContentKind::Mod,
ContentKind::Resourcepack,
ContentKind::Shader,
] {
let model = demo(kind, Lang::En);
assert!(
model.blocker_reasons(Lang::En.catalog()).is_empty(),
"{} 的默认演示项应当可装",
kind.slug()
);
}
}
#[test]
fn the_demo_selection_is_always_one_of_the_hits() {
for kind in ContentKind::ALL {
let model = demo(kind, Lang::En);
let selected = model.selected.clone().expect("有选中项");
assert!(
model.hits.iter().any(|hit| hit.id == selected),
"{} 的选中项不在命中列表里(详情面板会是空的)",
kind.slug()
);
}
}