Files
Unlucky-Launcher/crates/app-iced/src/pages/content.rs
T
AskaEth 5a52f38f35 feat(content): 安装失败的两档分组与「只重试这些」(M191)
M186 把「可只重试失败项」算到了 core/presenter(DependencyPlan::retry_request()),
但 DTO 没字段、界面没按钮,用户拿不到。这一轮补最后一公里:

- DTO:ContentFailureKind 加 Incompatible 一档(不再借 Unsupported,与「缺密钥」分开);
  InstallProgressView 加 retry: Vec<InstallFailureView>(project + 档位 + core 原话),
  档位是专门的 InstallFailureKind(Incompatible / Unreachable,穷尽映射 core 的 FailureKind);
  ContentInstallRequest 加 retry: bool(重试路径唯一读的字段是 instance)。
- presenter:plan.rs 把上一轮失败的计划按 host+实例记住(与 addon/pack.rs 同一手法,
  host/mod.rs 不在 scope);retry_request()/retry_plan() 就是「只重试这些」——
  调 core 的 retry_request()、只请求失败的那几个项目,present 重新扫目录。
  install.rs 的重试入口没有记录时回一条失败,不退化成整单重装;成功则 forget + 清名单。
- 界面:内容库页插入按档分组的失败面板(两段文案、各自条目)+「只重试这 N 个」实心按钮;
  判据只有一条且在模型里(stage==Failed && 名单非空),全成功时连同按钮一起不渲染。
- i18n:新增 6 条中英文案(不兼容 / 取不到 / 只重试这 N 个 / 标题 / 提示 / 关闭),金表同步。

守门(每条都做过变异验证,见报告):
① 把 retry_request() 换成根项目请求 ⇒ retrying_asks_for_the_failed_projects_only 红(roots 变 ["sodium"]);
② 两档合并 ⇒ the_retry_list_keeps_the_two_tiers_apart / the_two_tiers_are_two_groups_with_two_labels 红;
③ 恒渲染 ⇒ a_fully_successful_install_has_no_retry_surface_at_all 红。

数据包(datapack)本轮仍未做:ContentKind 没有这一项,界面上也没有半成品入口。
2026-11-09 05:06:04 +08:00

114 lines
5.7 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.
//! # 内容库页(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` / `Plus` / `Restore` / `Expand`,见 `parts::kind_icon`) | 设计令牌的 28 枚图标里没有这四类内容,且**不许**借用导航那五枚(同屏撞形)、不许用填充字形(门禁) | 图标集补「整合包 / Mod / 资源包 / 光影」时 |
//! | 2 | 本页自带一份栏几何(`content/layout.rs`) | M107 的 `pages/layout/rails.rs` 未并入本分支的 base | M107 合并后改为复用、删掉本文件 |
//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线 | 导入管线落地后接同一入口 |
//! | 5 | CurseForge 的「装入」同样是禁用态(`Blocker::SourceNotWired`) | 检索通了,**文件端点**还没接(CF 的文件列表要带 key) | 接上 CF 文件端点后删掉这条原因 |
//! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 |
//!
//! ## 安装失败之后:两档分组 + 「只重试这些」(M191)
//!
//! 装入失败且**有可重试项**时,页底那块失败面板换成 [`parts::retry_panel`]:按
//! `InstallFailureKind` 分两段(不兼容 / 取不到,各有自己的词表文案),并给一个「只重试这 N 个」
//! 的实心按钮。按钮发 [`Message::RetryFailed`],app 层翻成 `AppCommand::InstallContent{retry: true}`,
//! host 拿上一轮的计划调 `DependencyPlan::retry_request()` ⇒ **只请求失败的那几个项目**。
//!
//! 判据只有一条且在模型里(`RetryPanel::of`):`stage == Failed && !retry.is_empty()`。
//! 全成功、没有失败项、还在跑,三种情况都得到 `None`——**不渲染**,不是画一个灰按钮。
mod demo;
mod layout;
mod message;
mod model;
// M113: import/export —— 导入/导出区的界面(新代码全在 `packui*`)
mod packui;
mod parts;
mod view;
#[cfg(test)]
mod tests;
pub use message::Message;
pub use model::{
loader_name, Blocker, Content, ContentQueryView, Hit, PageState, RetryGroup, RetryPanel,
Status, Target,
};
// M113: import/export
pub use packui::{request_export, Pack, PackMessage, PackPageState};
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,
window_height: f32,
) -> Element<'a, PageMessage> {
view::view(ui, metrics, model, lang, window_height)
}
/// 演示数据(`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)
}