Files
Unlucky-Launcher/crates/app-iced/src/pages/download.rs
T
AskaEth 6b161227d8 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 行。
2026-11-05 03:20:21 +08:00

242 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! **版本管理页**(原下载页):三栏版面 —— 版本类型 / 主版本 / 列表 + 安装面板。
//!
//! 用户令(信息架构调整)把这一页从「下载」改称「版本管理」:下载页只负责整合包与 Mod/光影,
//! 而这一页管的是**游戏版本与加载器安装**。本文件是那次改版后的页面装配点。
//! **标识符不改**(用户令 2026-11-04 的「只改显示层」边界):`Route::Download` /
//! `Screen::Download` / 文件名 / 类型名一律保留,全仓重命名另开 finding 后置——所以本文件里
//! 「下载页」三个字到处都是,那是标识符的叫法,不是界面上出现的词。
//! **M102 版面订正**:旧版把整页块压到 424 并水平居中(1180 宽下左缘 422、页头标题在 112,
//! 左右各空 ~300px,视觉复审判为「内容缩在中间一条窄栏」)。现在页面块走 [`frame::body`]
//! ——左缘与页头标题同一条线(= 页边距 24),宽 = 块宽 `frame::body_width`(1180 下 1044,
//! 极宽窗封顶 **1440**)。424 只约束**成段散文**(本页没有),三栏的每一栏都吃块宽。
//!
//! ## 版面:三栏,宽度由布局系统裁决
//!
//! ```text
//! ┌─────────┬─────────┬──────────────────────────────┐
//! │ 版本类型 │ 主版本 │ 搜索条 · 命中数 · 计数头 │
//! │ 140/64 │ 140/64 │ 版本行(单行四项) │
//! │ │ │ … │
//! │ │ │ 安装面板(M105 的选项区在这里)│
//! └─────────┴─────────┴──────────────────────────────┘
//! ```
//!
//! 三个数字**没有一个是在本文件里拍的**:它们来自
//! [`VersionRails`](super::layout::VersionRails)(宽档裁决 + 数值表在 `pages/layout/rails.rs`)。
//! 页面只做一件事:把裁决结果交给 [`rails`] 画出来。
//!
//! ## 原顶部 4 个 tab 的去处(不是新造分类)
//!
//! 四个 chip(全部 / 正式版 / 预览版 / 远古版)**语义一字不改**,只是从顶部横排搬到第一栏竖排;
//! 窄到三栏不成立时(可用宽 < 600)它们**原样回到顶部**([`rails::stacked_chips`])——同一条
//! [`Category`] 谓词、同一条 `AppCommand::ListVersions`。
//!
//! ## 意图 → 消息 → 指令(页面只发意图,不做乐观回显)
//!
//! | 用户动作 | 页面消息 | app 层落点 |
//! |---|---|---|
//! | 点分类 tab | [`Message::SetCategory`] | `AppCommand::ListVersions(category)`(`All` 不派:画现有快照) |
//! | 点一行 | [`Message::SelectVersion`] | **只改视图模型**(选中不是业务命令) |
//! | 点「安装」 | [`Message::InstallVersion`] | `AppCommand::CreateInstance(草稿)`(M105:带加载器与实例名) |
//! | 点「取消安装」 | [`Message::CancelInstall`] | `AppCommand::CancelInstall` |
//! | 点「刷新」/「打开日志」 | `Message::Action(RefreshSources)` / `Message::Navigate(Logs)` | `ListVersions` / `AppState::navigate` |
//!
//! ## M105:安装选项(加载器维度)
//!
//! 选中版本**不再直接开工**。面板下半块变成「安装选项」区:五家加载器(原版 / Forge /
//! NeoForge / Fabric / Quilt)的 chip、该加载器的官方版本列表、实例名,点「安装」才派
//! `CreateInstance`。选项的决策与画法在 [`options`](子模块),本文件只负责把模型交给它。
//! 加载器版本列表来自 core 的 `LoaderSource::loader_versions`(经
//! `AppCommand::ListLoaderVersions` 回流到快照);取不到时页面给具名原因并**禁掉确认**——
//! 不许「选完才失败」。
//!
//! 进度条只读 `model.install`:它由 presenter 的 `InstallProgress` 事件经 `apply` 灌进来。
//! 于是「点了没变」只有一种解释——后端还没回事件,这正是要的(P3-2 的纪律)。
//!
//! ## M107:两级侧栏从哪来(第一级 = 版本类型,第二级 = 主版本桶)
//!
//! 第一级 = 版本类型(4 项,固定);第二级 = 主版本桶(由快照里的版本 id 按 [`group`] 的规则归纳,
//! 远古版这类「没有主版本概念」的只有「全部版本」一项)。
//!
//! ## 搜索(三层口径)
//!
//! | 情形 | 画面 |
//! |---|---|
//! | 有命中 | 列表照常;侧栏**位置不动**;搜索条右侧给「N 个匹配」(跨类型读数) |
//! | 一个都不命中 | [`states::no_match`]:没有匹配 + 换短片段 + 清空 |
//! | 有命中但不在当前类型 | [`states::elsewhere`]:N 个在别的类型 + 「看全部类型」 |
//! | 搜索中点第二级桶 | **退出搜索 + 按那个桶过滤**(点桶 = 「我就是要看这个桶」,见 `on_download`) |
//!
//! 查询词写在 presenter 快照的 `download_query` 里(页面无状态,见 [`model::Download::from_snapshot`]),
//! 所以「搜索 → 任何事件回来之后仍然在搜」这条不需要页面持有任何字段。
//!
//! ## 状态矩阵(加载 / 空 / 有数据 / 安装中 / 失败)
//!
//! 与 M94 时一致,唯一的改动是**空态与搜索态都留在三栏骨架里**:侧栏与搜索框照旧在,
//! 只有列表区换成占位内容——白屏会让人以为列表坏了。
//!
//! ## 两个纯视图态的字段(快照里没有,重建会丢)
//!
//! `selected_major`(第二级侧栏选中)与 `type_rail_folded`(类型栏手动折叠)**不在 presenter 快照里**
//! (`AppState` 没有这两个字段,加字段要动 `pages/mod.rs` 的偏离表与 presenter,见 M107 报告)。
//! 所以它们与既有的 `selected` 同一档处理:`on_download` 直接改视图模型,**不重建**;
//! 唯一会重建的路径是搜索(`rebuild_after_query`),那里把两个字段在重建前后各搬一次。
//! 其余事件(安装进度、版本列表刷新)回来时这两个字段会回到默认值——与 `selected` 的既有缺口同源,
//! 长期要靠 presenter 加字段,已登记在报告里。
//!
//! ## 版面判据(M102 口径:页面块 vs 散文列)
//!
//! 页面仍是 `PaneKit::Single`(页内自己切三栏)。整页内容走 [`frame::body`]:块左缘 =
//! 页头标题左缘 = `rail 88 + gutter 24 = 112`,块宽 = [`frame::body_width`](pane 宽封顶
//! **1440**)——视觉复审 ①(内容缩在中间一条窄栏、与页头错位)因此收口。
//! **三栏的列宽从「块宽」算**([`VersionRails::of_metrics`] 读 `frame::body_width`,不是
//! `metrics.available`):否则 1576+ 的超宽窗上三列之和会超出 1440 的块、右侧被裁。
//! 424([`super::layout::measure_cap_px`])在本页只对**成段散文**有效——这一页没有散文。
mod demo;
mod group;
mod install;
mod model;
mod options;
mod rails;
mod rows;
mod states;
#[cfg(test)]
mod tests;
pub use demo::demo;
pub use model::{cancelled_toast, Category, Download};
pub use options::{InstallDraft, Loader};
// M109:内容库页复用**同一块**安装面板(同一个函数、同一套文案与几何),不另写一套。
//
// 两个薄包装为什么必须在**这个文件**里:①`install::panel` 是 `pub(super)`(只在 `pages::download`
// 可见),再导出到 crate 会撞 `E0364`;②面板的入参是 `&Download`,而 `InstallView` 是页面私有
// 类型(M105 起不再从本模块导出),所以「快照 → 面板形状」的转换也留在这里——内容库只拿到
// `Option<Download>`,它不需要也不该认识 `InstallView`。
pub(crate) fn install_panel<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Download,
) -> Element<'a, Message> {
install::panel(ui, catalog, model)
}
/// 快照 → 面板要的 `Download` 形状(**只填 `install`**)。
///
/// 「装完 / 已取消」这类没有动作可给的终态返回 `None`(与下载页同一个判断
/// `InstallView::finished`):留着只会让面板停在一个点不动的状态上。
pub(crate) fn install_panel_model(
progress: &unlucky_presenter::dto::InstallProgressView,
catalog: Catalog,
) -> Option<Download> {
let view = model::InstallView::of(progress, catalog);
if view.finished() {
return None;
}
Some(Download {
install: Some(view),
..Download::loading()
})
}
use iced::widget::column;
use iced::{Element, Length};
use unlucky_design::spacing::Space;
use unlucky_i18n::{Catalog, Key, Lang};
use crate::app::components::buttons::{self, Button};
use crate::app::components::divider;
use crate::theme::Ui;
use super::content;
use super::frame::{self, Header};
use super::layout::{FrameMetrics, VersionRails};
use super::{Action, Message};
/// 整页(**唯一**的真数据入口:`model` 由 `app/view.rs` 从 presenter 快照建)。
///
/// `lang` 是 i18n 的入参,调用点传的是 `pages::Model.lang`(M106 起 = `App` 的生效语言,
/// 见 `ui::lang::LangState`);页面自己不读全局、不猜语言。
pub fn view<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
model: &'a Download,
lang: Lang,
) -> Element<'a, Message> {
let catalog = lang.catalog();
let header = Header::new(catalog.text(Key::DownloadTitle))
.subtitle(catalog.text(Key::DownloadSubtitle))
// M109:一级切换「版本管理 | 内容库」。两页共用同一条导航轨项(`NavSection::Download`),
// 所以这个切换是进内容库的唯一产品入口——少了它,内容库只能靠 `ULL_START_ROUTE` 到达。
.action(content::section_switcher(ui, catalog))
.action(buttons::text(
ui,
Button::new(
catalog.text(Key::DownloadRefresh),
Message::Action(Action::RefreshSources),
),
));
frame::page(ui, metrics, header, body(ui, metrics, catalog, model))
}
/// 正文:加载态仍是居中占位;其余状态都长在三栏骨架上。
fn body<'a>(
ui: &'a Ui,
metrics: FrameMetrics,
catalog: Catalog,
model: &'a Download,
) -> Element<'a, Message> {
if model.is_loading() {
// 还没拉到版本列表:三栏没有内容可填,给占位(安装面板照旧带着,见 M94 复审 p3)
let content: Element<'a, Message> = if model.install.is_some() {
column![
states::loading(ui, catalog),
divider::full(ui),
install::panel(ui, catalog, model)
]
.spacing(ui.space(Space::X3))
.width(Length::Fill)
.height(Length::Fill)
.into()
} else {
states::loading(ui, catalog)
};
return frame::body(metrics, content);
}
// 宽度裁决:档位来自布局系统,「用户手动折叠了类型栏」这一条覆盖叠在上面。
let rails = {
let rails = VersionRails::of_metrics(metrics);
if model.type_rail_folded {
rails.with_type_folded()
} else {
rails
}
};
let content: Element<'a, Message> = if rails.tier.is_three_column() {
rails::three_column(ui, catalog, model, rails)
} else {
// 三栏不成立(可用宽 < 600):类型与主版本原样退回顶部 chips,列表吃掉整条可用宽
column![
rails::stacked_chips(ui, catalog, model),
divider::full(ui),
rows::content(ui, catalog, model),
]
.spacing(ui.space(Space::X2))
.width(Length::Fill)
.height(Length::Fill)
.into()
};
// M102:整页块走 `frame::body`(贴左、块宽封顶 1440),与页头标题同一条左缘。
// 三栏都在这个块里切,所以 `VersionRails` 读的是块宽而不是 `metrics.available`。
frame::body(metrics, content)
}