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

+94
View File
@@ -0,0 +1,94 @@
//! # 内容库页(M109 `CONTENTDL`):整合包 / Mod / 资源包 / 光影
//!
//! 用户令:「下载页只负责下载整合包和Mod/光影」。于是版本管理页(M107/M108 改名)只做版本,
//! **这一页**做内容:四条内容类型(页内一级侧栏)+ 关键词/来源/版本/加载器/排序筛选 +
//! 结果列表与详情面板 + **一键装入指定实例**。
//!
//! ## 入口(B1 裁定:不新增导航项)
//!
//! `Route::Content` 是新顶层路由,但 `Route::nav()` 返回 `NavSection::Download`:导航轨仍是
//! 五项,页头右上是「版本管理 | 内容库」一级切换([`parts::section_switcher`])。
//! 为什么不是第六个图标:轨宽 88 塞六项会挤,且用户要的是「同一入口下两块内容」。
//!
//! ## 分页 vs 无限滚动:**选分页**
//!
//! 三条理由,都是这一页的实际约束:
//!
//! 1. **快照只有一个槽**(`AppState::content.results`):无限滚动要求「滚到底就自动续」,而
//! 自动续在没有虚拟化列表的 iced 里会一次挂上几百个控件;
//! 2. **翻页要有明确出口**:站点限流 / 失败重试都发生在「下一批」上,用户得能看见自己正在
//! 等第 3 页,而不是画面莫名一卡;
//! 3. **可截图复现**:分页的每一次状态都是一份确定的快照,D30 的矩阵能钉住「还有更多 / 没有更多了」。
//!
//! 所以尾部是一个「加载更多」按钮 + 一句「没有更多了」([`parts::more`]),追加逻辑在
//! presenter 的 `AppState::apply_content`(同条件换页 ⇒ 追加;换条件 ⇒ 整批替换;迟到 ⇒ 丢掉)。
//!
//! ## 四态(都必须能画出来)
//!
//! | 态 | 判据 | 画什么 |
//! |---|---|---|
//! | 加载 | `pending` 或还没有结果 | 一句「正在加载」+ 说明 + 重试 |
//! | 空 | 拉到了、零命中 | 真 `EmptyState` + 重试 CTA |
//! | 失败 | `failed` 有原因 | `Error` 图标 + 原因原文 + 重试(实心) |
//! | 有数据 | 有命中项 | 计数头 + 列表 +(还有更多 ⇒ 加载更多 / 否则 ⇒ 没有更多了) |
//!
//! ## 不在这里做的事
//!
//! - **不发 HTTP**:检索与装入都在 presenter 的 `host/addon`(任务书第 4 条)。
//! - **不做乐观回显**:点「装入」不改本地状态,进度只由 `InstallProgress` 事件回流。
//! - **不自己画安装面板**:复用 `pages::download::install_panel`(同一个函数、同一套文案)。
//!
//! ## 偏离登记
//!
//! | # | 偏离 | 为什么 | 何时收口 |
//! |---|---|---|---|
//! | 1 | 四种内容类型借用现有字形(`Folder`/`Instances`/`Download`/`Play`) | 设计令牌的图标集还没有这四枚 | 图标集补「整合包 / Mod / 资源包 / 光影」时 |
//! | 2 | 本页自带一份栏几何(`content/layout.rs`) | M107 的 `pages/layout/rails.rs` 未并入本分支的 base | M107 合并后改为复用、删掉本文件 |
//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线(M111) | M111 落地后接同一入口 |
//! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 |
mod demo;
mod layout;
mod message;
mod model;
mod parts;
mod view;
#[cfg(test)]
mod tests;
pub use message::Message;
pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target};
use iced::Element;
use unlucky_i18n::Lang;
pub use unlucky_presenter::dto::ContentKind;
use crate::pages::layout::FrameMetrics;
use crate::pages::Message as PageMessage;
use crate::theme::Ui;
/// 整页入口(`pages/mod.rs` 的 `render` 调它;页面层不认识 presenter 的 DTO)。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
model: &'a Content,
lang: Lang,
) -> Element<'a, PageMessage> {
view::view(ui, metrics, model, lang)
}
/// 演示数据(`pages_demo` 出图与单测用;产品路径走 `Content::from_snapshot`)。
pub fn demo(kind: ContentKind, lang: Lang) -> Content {
demo::demo(kind, lang)
}
/// 页头那条一级切换(版本管理页也要用同一个函数:两页共用一个入口才不会各画一套)。
pub(crate) fn section_switcher<'a>(
ui: &'a Ui,
catalog: unlucky_i18n::Catalog,
) -> Element<'a, PageMessage> {
parts::section_switcher(ui, catalog, true)
}