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 没有这一项,界面上也没有半成品入口。
This commit is contained in:
AskaEth committed 2026-11-09 05:06:04 +08:00
1 parent 5505ea0f91
commit 5a52f38f35
24 files changed
+1072 -24

No files matched your search

@@ -0,0 +1,139 @@
//! 安装失败后的「只重试这些」面板(M191)。
//!
//! ## 为什么这块画在内容库,而不是复用下载页那块失败面板
//!
//! 下载页的失败面板讲的是「停在哪一步 + 打开日志」,而这里要讲的是**哪几项没装上、属于哪种
//! 没装上、以及能不能只重试它们**。两件事混在一个块里会得到「失败 + 一堆条目 + 两个关闭」。
//! 所以:有可重试名单时([`RetryPanel::of`] 给了 `Some`)这块**顶替**通用失败面板,
//! 一屏只有一个失败块。
//!
//! ## 三个控件行为
//!
//! | 控件 | 消息 | 落点 |
//! |---|---|---|
//! | 只重试这 N 个 | [`ContentMessage::RetryFailed`] | `AppCommand::InstallContent{retry: true}`(host 只重解失败项) |
//! | 关闭 | [`PageMessage::CancelInstall`] | 清 install 快照(对没在跑的安装是安全 no-op,与下载页同一个出口) |
//!
//! 两个按钮都**按不动就不画**:没有名单时整块不存在(不是画一个灰按钮)。
use iced::widget::{column, container, row};
use iced::{Alignment, Background, Border, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::icons::IconKind;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key};
use crate::app::components::buttons::{self, Button};
use crate::theme::Ui;
use super::super::model::{Content, RetryPanel};
use super::intent;
use crate::pages::content::Message as ContentMessage;
use crate::pages::frame;
use crate::pages::Message as PageMessage;
/// 整块面板(`model.retry` 为 `None` 时调用方不该调它——判据在模型里,不在这里)。
pub(crate) fn retry_panel<'a>(
ui: &'a Ui,
catalog: Catalog,
panel: &'a RetryPanel,
) -> Element<'a, PageMessage> {
let head = row![
frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
panel.title.as_str(),
ui.color(Role::Error),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
let mut body = column![head]
.spacing(ui.space(Space::X2))
.width(Length::Fill);
// 两档分成两段:**各自的标题 + 各自的条目**(合并成一段就说不出「换版本还是检查网络」)。
// 条目之间用 ASCII 逗号:项目键是站点数据,句读不是文案,不该在这里挑中文的「、」。
for group in &panel.groups {
let items = group.items.join(", ");
body = body.push(
column![
frame::text_role(
ui,
TypeRole::LabelMedium,
true,
catalog.text(group.title),
ui.color(Role::OnErrorContainer),
),
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
items,
ui.color(Role::OnErrorContainer),
),
]
.spacing(ui.space(Space::X1)),
);
}
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentInstallRetryHint),
ui.color(Role::OnErrorContainer),
));
let actions = row![
// 主按钮:只重试这些(这个块存在的**唯一**理由)
buttons::filled(
ui,
Button::new(panel.action.as_str(), intent(ContentMessage::RetryFailed))
.leading(IconKind::Refresh),
),
buttons::text(
ui,
Button::new(
catalog.text(Key::ContentInstallDismiss),
PageMessage::CancelInstall
),
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
body = body.push(actions);
container(body)
.width(Length::Fill)
.padding(ui.space(Space::X4))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.color(Role::ErrorContainer))),
border: Border {
radius: ui.radius_dp(ShapeScale::Large),
..Border::default()
},
..container::Style::default()
})
.into()
}
/// 整页底部那块(判据在 [`Content::retry`];`None` ⇒ 一帧都不画)。
pub(crate) fn maybe<'a>(
ui: &'a Ui,
catalog: Catalog,
model: &'a Content,
) -> Option<Element<'a, PageMessage>> {
model
.retry
.as_ref()
.map(|panel| retry_panel(ui, catalog, panel))
}