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:
1 parent
5505ea0f91
commit
5a52f38f35
24 files changed
+1072
-24
No files matched your search
@@ -104,6 +104,15 @@ impl App {
|
||||
self.dispatch(AppCommand::InstallContent(Box::new(request)));
|
||||
}
|
||||
}
|
||||
// M191:「只重试上次失败的那几项」。请求构造在 `pages/content/model.rs`
|
||||
// (`retry_install_request`:载荷带 `retry: true`),这里只做转发。
|
||||
pages::content::Message::RetryFailed => {
|
||||
let catalog = unlucky_i18n::Lang::En.catalog();
|
||||
let model = Content::from_snapshot(&self.state, &self.content, catalog);
|
||||
if let Some(request) = model.retry_install_request() {
|
||||
self.dispatch(AppCommand::InstallContent(Box::new(request)));
|
||||
}
|
||||
}
|
||||
// M113: import/export —— 导入/导出区(细节在 `on_pack`)
|
||||
pages::content::Message::Pack(message) => self.on_pack(message),
|
||||
}
|
||||
@@ -222,6 +231,8 @@ impl App {
|
||||
instance: target.id.clone(),
|
||||
game_version: target.mc_version.clone(),
|
||||
loader: target.loader,
|
||||
// 普通装入:从根项目解起(「只重试这些」是另一条消息,`retry: true`)
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,16 @@
|
||||
//! | 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;
|
||||
@@ -61,7 +71,10 @@ mod view;
|
||||
mod tests;
|
||||
|
||||
pub use message::Message;
|
||||
pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target};
|
||||
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};
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! | 变量 | 取值 | 作用 |
|
||||
//! |---|---|---|
|
||||
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) |
|
||||
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` | 选状态(默认 `ok`;`offline` = M162 的未联网态) |
|
||||
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` / `retryfail` / `retryok` | 选状态(默认 `ok`;`offline` = M162 的未联网态;`retryfail` = M191 的「有失败项」,`retryok` = M191 的「全部成功」) |
|
||||
//!
|
||||
//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
|
||||
|
||||
@@ -19,10 +19,47 @@ use unlucky_i18n::{Catalog, Key, Lang};
|
||||
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
|
||||
|
||||
use super::model::{
|
||||
blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target, VersionRow,
|
||||
blockers_of, Content, ContentQueryView, Hit, HitFacts, RetryPanel, Status, Target, VersionRow,
|
||||
};
|
||||
use crate::pages::download::Download;
|
||||
|
||||
/// M191「有失败项」那一屏:两档各几个项目,面板按档分组。
|
||||
///
|
||||
/// **走真判据**([`RetryPanel::of`]),不手搓面板字段:出图与产品路径读同一个函数,
|
||||
/// 判据一改这条演示就跟着变(否则演示图能一直画着一个已经不存在的面板)。
|
||||
fn retry_failure(instance: &str, catalog: Catalog) -> Option<RetryPanel> {
|
||||
use unlucky_presenter::dto::{
|
||||
InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage,
|
||||
};
|
||||
let mut progress = InstallProgressView {
|
||||
version: "Sodium".to_string(),
|
||||
instance: instance.to_string(),
|
||||
loader: "Fabric".to_string(),
|
||||
stage: InstallStage::Failed,
|
||||
files_done: 1,
|
||||
files_total: 4,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
progress.retry = vec![
|
||||
InstallFailureView::new(
|
||||
"P7dR8mSH",
|
||||
InstallFailureKind::Incompatible,
|
||||
"没有匹配所选游戏版本/加载器(1.20.1 / fabric)的版本",
|
||||
),
|
||||
InstallFailureView::new(
|
||||
"1eAoo2KR",
|
||||
InstallFailureKind::Unreachable,
|
||||
"取版本失败(online_not_found):HTTP 404",
|
||||
),
|
||||
InstallFailureView::new(
|
||||
"AANobbMI",
|
||||
InstallFailureKind::Unreachable,
|
||||
"下载 fabric-api.jar 失败(online_not_connected):连不上 cdn.modrinth.com",
|
||||
),
|
||||
];
|
||||
RetryPanel::of(&progress, catalog)
|
||||
}
|
||||
|
||||
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
|
||||
fn targets() -> Vec<Target> {
|
||||
vec![
|
||||
@@ -254,6 +291,12 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
// M191:`retryfail` 画「有失败项」,`retryok` 画「全部成功」——后者走同一条判据,
|
||||
// 得到 `None`(连按钮都不渲染)。
|
||||
retry: match state.as_str() {
|
||||
"retryfail" => retry_failure("survival", catalog),
|
||||
_ => None,
|
||||
},
|
||||
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
|
||||
available_versions: demo_versions,
|
||||
versions_note: None,
|
||||
@@ -265,3 +308,20 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
|
||||
pack,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 「有失败项」那一屏的两档走**真判据**(`RetryPanel::of`),不是手搓的面板字段。
|
||||
#[test]
|
||||
fn the_failure_screen_shows_both_tiers() {
|
||||
let panel = retry_failure("survival", Lang::En.catalog()).expect("演示必须有面板");
|
||||
assert_eq!(panel.total, 3);
|
||||
assert!(panel.has_both_tiers(), "{:?}", panel.groups);
|
||||
assert_eq!(
|
||||
panel.items_of(unlucky_presenter::dto::InstallFailureKind::Unreachable),
|
||||
Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ pub enum Message {
|
||||
Retry,
|
||||
/// 装进目标实例
|
||||
Install,
|
||||
/// **只重试上次失败的那几项**(M191;不是「整单重装」)
|
||||
///
|
||||
/// app 层的落点:`AppCommand::InstallContent` 带 `retry: true`——host 拿上一轮的依赖计划
|
||||
/// 调 `DependencyPlan::retry_request()`,只重解失败项。
|
||||
RetryFailed,
|
||||
/// 导入/导出区(M113:整合包导入与导出;一切细节在 `packui::Message`)
|
||||
Pack(super::packui::PackMessage),
|
||||
}
|
||||
@@ -32,10 +32,12 @@ use unlucky_i18n::{Catalog, Key};
|
||||
use crate::pages::download::Download;
|
||||
|
||||
mod items;
|
||||
mod retry;
|
||||
|
||||
pub(crate) use items::HitFacts;
|
||||
pub(crate) use items::{capitalize, thousands};
|
||||
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow};
|
||||
pub use retry::{RetryGroup, RetryPanel};
|
||||
|
||||
/// 内容库页要画的东西。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -69,6 +71,11 @@ pub struct Content {
|
||||
/// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树
|
||||
/// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。
|
||||
pub panel: Option<Download>,
|
||||
/// 「只重试这些」面板(M191):`None` = 不渲染(全成功 / 没有失败项 / 还在跑)。
|
||||
///
|
||||
/// 与 `panel` 互斥地画:失败且有可重试项时,这块**顶替**下载页那块通用失败面板,
|
||||
/// 一屏只有一个失败块(两个会说同一件事,还各自给一个「关闭」)。
|
||||
pub retry: Option<RetryPanel>,
|
||||
/// 游戏版本筛选的候选(来自实例;去重、保持实例顺序)
|
||||
pub versions: Vec<String>,
|
||||
/// 关键词草稿
|
||||
@@ -187,6 +194,11 @@ impl Content {
|
||||
target_label,
|
||||
blockers,
|
||||
panel: content_install(state, catalog),
|
||||
// M191:失败且有可重试项时这块才存在(全成功 ⇒ `None` ⇒ 视图里连按钮都没有)
|
||||
retry: state
|
||||
.install
|
||||
.as_ref()
|
||||
.and_then(|progress| RetryPanel::of(progress, catalog)),
|
||||
versions,
|
||||
draft: page.draft.clone(),
|
||||
pack,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! 「只重试这些」面板的视图模型(M191)。
|
||||
//!
|
||||
//! ## 它画的是什么
|
||||
//!
|
||||
//! 安装失败之后,界面要说清「哪几项没装上、分别是哪种没装上」:
|
||||
//!
|
||||
//! ```text
|
||||
//! 安装未完成 —— 3 项没装上
|
||||
//! 不兼容所选版本/加载器
|
||||
//! P7dR8mSH
|
||||
//! 取不到(网络 / 项目不存在)
|
||||
//! 1eAoo2KR、AANobbMI
|
||||
//! [只重试这 3 个] [关闭]
|
||||
//! ```
|
||||
//!
|
||||
//! ## 三条判据(都落在 [`RetryPanel::of`],纯函数,用例直接钉)
|
||||
//!
|
||||
//! 1. **全成功不渲染**:`retry` 名单空 ⇒ `None` ⇒ 视图里连按钮都不存在(不是置灰);
|
||||
//! 2. **两档不混**:分组按 [`InstallFailureKind`] 分开,两档各有自己的标题 key;
|
||||
//! 3. **只列名单里的**:条目原样来自 DTO(`presenter` 从 `DependencyPlan::retry_request()`
|
||||
//! 的同一份失败名单产出),页面不自己挑、也不补「顺便重装根」。
|
||||
//!
|
||||
//! ## 为什么这里不拼文案
|
||||
//!
|
||||
//! 分组标题是 [`Key`](i18n),条目是项目键(站点数据,不是文案)。拼一句带量的中文句子
|
||||
//! 会把语序写死——英文与中文的量词位置不同(`Retry just these 3` / `只重试这 3 个`)。
|
||||
|
||||
use unlucky_i18n::{Catalog, Key};
|
||||
use unlucky_presenter::dto::{
|
||||
ContentInstallRequest, ContentSource, InstallFailureKind, InstallProgressView, InstallStage,
|
||||
LoaderKind,
|
||||
};
|
||||
|
||||
/// 面板上的一个档位分组。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RetryGroup {
|
||||
/// 哪一档(顺序固定,见 [`InstallFailureKind::ALL`])
|
||||
pub kind: InstallFailureKind,
|
||||
/// 这一档的标题 key(不硬编码中文)
|
||||
pub title: Key,
|
||||
/// 这一档的项目键(顺序 = 计划里的顺序)
|
||||
pub items: Vec<String>,
|
||||
}
|
||||
|
||||
/// 「只重试这些」面板(`None` = 不渲染:没有失败项、或已经全成功)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RetryPanel {
|
||||
/// 失败的那次装入的目标实例(重试只对这个实例生效)
|
||||
pub instance: String,
|
||||
/// 标题(已按词表渲染:带条数)
|
||||
pub title: String,
|
||||
/// 主按钮文字(已按词表渲染:`只重试这 N 个`)
|
||||
///
|
||||
/// 存在这里而不是画的时候现拼:`Button::new` 要的是 `&'a str`,而词表渲染出来的是
|
||||
/// `String`——存进模型(与 `Hit` / `VersionRow.line` 同一手法)就不必为了一个标签
|
||||
/// 去改组件库的入参类型。
|
||||
pub action: String,
|
||||
/// 按档分组(空组不出现)
|
||||
pub groups: Vec<RetryGroup>,
|
||||
/// 失败项总数(标题与按钮都用这个数)
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
impl RetryPanel {
|
||||
/// 进度快照 → 面板(**唯一**判据;`None` = 视图里不该出现这块)。
|
||||
///
|
||||
/// 只认 `Failed` 这一态:`Done` 没有可重试的东西,`Cancelled` 是用户自己停的
|
||||
/// (那条路上根本没有失败名单),`Planning`/`Downloading` 还在跑。
|
||||
#[must_use]
|
||||
pub fn of(progress: &InstallProgressView, catalog: Catalog) -> Option<Self> {
|
||||
if progress.stage != InstallStage::Failed || !progress.has_retry() {
|
||||
return None;
|
||||
}
|
||||
let total = progress.retry.len();
|
||||
let count = total.to_string();
|
||||
let groups = InstallFailureKind::ALL
|
||||
.into_iter()
|
||||
.map(|kind| RetryGroup {
|
||||
kind,
|
||||
title: title_of(kind),
|
||||
items: progress
|
||||
.retry_of(kind)
|
||||
.map(|item| item.project.clone())
|
||||
.collect(),
|
||||
})
|
||||
.filter(|group| !group.items.is_empty())
|
||||
.collect();
|
||||
Some(Self {
|
||||
instance: progress.instance.clone(),
|
||||
title: catalog.render(Key::ContentInstallFailedTitle, &[("count", count.as_str())]),
|
||||
action: catalog.render(Key::ContentInstallRetryOnly, &[("count", count.as_str())]),
|
||||
groups,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
/// 两档都在吗(「分组」这件事本身的判据:都出现时界面上是两段,不是一段)。
|
||||
#[must_use]
|
||||
pub fn has_both_tiers(&self) -> bool {
|
||||
self.groups.len() == 2
|
||||
}
|
||||
|
||||
/// 某一档的条目(用例断言用)。
|
||||
#[must_use]
|
||||
pub fn items_of(&self, kind: InstallFailureKind) -> Option<&[String]> {
|
||||
self.groups
|
||||
.iter()
|
||||
.find(|group| group.kind == kind)
|
||||
.map(|group| group.items.as_slice())
|
||||
}
|
||||
}
|
||||
|
||||
/// 档位 → 标题 key(**穷尽 match**:合并两档会少一条文案,用例会红)。
|
||||
#[must_use]
|
||||
pub const fn title_of(kind: InstallFailureKind) -> Key {
|
||||
match kind {
|
||||
InstallFailureKind::Incompatible => Key::ContentInstallRetryIncompatible,
|
||||
InstallFailureKind::Unreachable => Key::ContentInstallRetryUnreachable,
|
||||
}
|
||||
}
|
||||
|
||||
impl super::Content {
|
||||
/// 「只重试这些」那一次请求(M191;`None` = 没有可重试的面板 ⇒ 这个动作不存在)。
|
||||
///
|
||||
/// 载荷里 `retry: true` 是**唯一**的语义开关:host 收到它就不解根项目,而是拿上一轮的计划
|
||||
/// 调 `DependencyPlan::retry_request()`(只请求失败的那几个项目)。其余字段照旧填,
|
||||
/// 是因为 `ContentInstallRequest` 的判据仍要能读(`project` / `title` 也用于日志);
|
||||
/// 这些字段在重试路径上**不被 host 读取**——过滤条件与落盘目录都来自那份计划。
|
||||
///
|
||||
/// 实例取**失败那次的**实例,不是页面上此刻选中的那个:用户可能已经换了目标实例。
|
||||
pub fn retry_install_request(&self) -> Option<ContentInstallRequest> {
|
||||
let retry = self.retry.as_ref()?;
|
||||
let hit = self.selected_hit();
|
||||
let target = self
|
||||
.targets
|
||||
.iter()
|
||||
.find(|target| target.id == retry.instance);
|
||||
Some(ContentInstallRequest {
|
||||
project: hit.map(|hit| hit.id.clone()).unwrap_or_default(),
|
||||
title: hit.map(|hit| hit.title.clone()).unwrap_or_default(),
|
||||
kind: self.kind,
|
||||
source: hit.map_or(ContentSource::Modrinth, |hit| hit.source),
|
||||
instance: retry.instance.clone(),
|
||||
game_version: target.map_or_else(String::new, |target| target.mc_version.clone()),
|
||||
loader: target.map_or(LoaderKind::Vanilla, |target| target.loader),
|
||||
retry: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -355,3 +355,10 @@ fn loader_names_are_ascii_so_the_english_baseline_stays_english() {
|
||||
"不能画「原版」"
|
||||
);
|
||||
}
|
||||
|
||||
// M191:安装失败的两档分组与「只重试这些」(按主题分卷,父文件已贴 400 行硬线)
|
||||
//
|
||||
// `#[path]` 显式给出:本文件自己是 `#[path = "model/tests.rs"]` 挂上去的,子模块默认按
|
||||
// 「父模块所在目录」找,会指到 `model/retry.rs`(视图模型本体)而不是这里的 `tests/retry.rs`。
|
||||
#[path = "tests/retry.rs"]
|
||||
mod retry;
|
||||
@@ -0,0 +1,129 @@
|
||||
//! M191:安装失败的两档分组 + 「只重试这些」(`super` 按主题分卷,父文件已贴 400 行硬线)。
|
||||
//!
|
||||
//! 三条变异守门:两档不许合并 / 全成功不许渲染 / 重试只针对失败那次的实例。
|
||||
|
||||
use super::super::{Content, PageState, RetryPanel};
|
||||
use super::{hit, results, state_with};
|
||||
|
||||
use unlucky_i18n::{Key, Lang};
|
||||
use unlucky_presenter::dto::{
|
||||
InstallFailureKind, InstallFailureView, InstallProgressView, InstallStage,
|
||||
};
|
||||
|
||||
/// 一份「两档都有」的失败进度快照。
|
||||
fn failed_progress() -> unlucky_presenter::dto::InstallProgressView {
|
||||
let mut progress = InstallProgressView {
|
||||
version: "Sodium".to_string(),
|
||||
instance: "survival".to_string(),
|
||||
stage: InstallStage::Failed,
|
||||
files_done: 1,
|
||||
files_total: 4,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
progress.retry = vec![
|
||||
InstallFailureView::new(
|
||||
"P7dR8mSH",
|
||||
InstallFailureKind::Incompatible,
|
||||
"没有匹配的版本",
|
||||
),
|
||||
InstallFailureView::new("1eAoo2KR", InstallFailureKind::Unreachable, "HTTP 404"),
|
||||
InstallFailureView::new("AANobbMI", InstallFailureKind::Unreachable, "连不上 CDN"),
|
||||
];
|
||||
progress
|
||||
}
|
||||
|
||||
/// 两档**分组**画,各有自己的标题;条目按档分开,不混成一段。
|
||||
///
|
||||
/// 变异守门②:把两档合并(`kind_of` 都映成 `Unreachable`,或 `title_of` 都返回同一个 key)
|
||||
/// ⇒ `groups.len()` 变 1 / 两个 title 相同 ⇒ 这条红。
|
||||
#[test]
|
||||
fn the_two_tiers_are_two_groups_with_two_labels() {
|
||||
let panel = RetryPanel::of(&failed_progress(), Lang::En.catalog()).expect("有失败项就有面板");
|
||||
assert_eq!(panel.total, 3);
|
||||
assert!(panel.has_both_tiers(), "{:?}", panel.groups);
|
||||
assert_eq!(
|
||||
panel.items_of(InstallFailureKind::Incompatible),
|
||||
Some(["P7dR8mSH".to_string()].as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
panel.items_of(InstallFailureKind::Unreachable),
|
||||
Some(["1eAoo2KR".to_string(), "AANobbMI".to_string()].as_slice())
|
||||
);
|
||||
let titles: Vec<Key> = panel.groups.iter().map(|group| group.title).collect();
|
||||
assert_ne!(titles[0], titles[1], "两档的标题 key 不许是同一个");
|
||||
// 文案来自词表(不是硬编码中文):英文目录下这两句必须不同且不含中文
|
||||
let incompatible = Lang::En.catalog().text(titles[0]);
|
||||
let unreachable = Lang::En.catalog().text(titles[1]);
|
||||
assert_ne!(incompatible, unreachable);
|
||||
assert!(incompatible.is_ascii(), "{incompatible}");
|
||||
assert!(unreachable.is_ascii(), "{unreachable}");
|
||||
// 按钮文字带条数,也来自词表
|
||||
assert!(panel.action.contains('3'), "{}", panel.action);
|
||||
assert!(panel.action.is_ascii(), "{}", panel.action);
|
||||
}
|
||||
|
||||
/// 只有一档失败时只画一段(空组不出现——空的「取不到」标题会让用户以为那里也坏了)。
|
||||
#[test]
|
||||
fn an_empty_tier_does_not_get_a_group() {
|
||||
let mut progress = InstallProgressView {
|
||||
stage: InstallStage::Failed,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
progress.retry = vec![InstallFailureView::new(
|
||||
"P7dR8mSH",
|
||||
InstallFailureKind::Incompatible,
|
||||
"没有匹配的版本",
|
||||
)];
|
||||
let panel = RetryPanel::of(&progress, Lang::En.catalog()).expect("有失败项就有面板");
|
||||
assert!(!panel.has_both_tiers());
|
||||
assert!(panel.items_of(InstallFailureKind::Unreachable).is_none());
|
||||
}
|
||||
|
||||
/// **全成功时不渲染**:`Done` 且没有失败项 ⇒ 面板不存在(不是置灰,是连构造都没有)。
|
||||
///
|
||||
/// 变异守门③:把 `RetryPanel::of` 的判据去掉(恒返回 `Some`)⇒ 这条红。
|
||||
#[test]
|
||||
fn a_fully_successful_install_has_no_retry_surface_at_all() {
|
||||
let done = InstallProgressView {
|
||||
version: "Sodium".to_string(),
|
||||
instance: "survival".to_string(),
|
||||
stage: InstallStage::Done,
|
||||
files_done: 4,
|
||||
files_total: 4,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
assert!(RetryPanel::of(&done, Lang::En.catalog()).is_none());
|
||||
// 页面上也一样:状态是 Done ⇒ 页底那块既没有重试面板,也没有失败面板
|
||||
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
|
||||
state.install = Some(done);
|
||||
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert!(model.retry.is_none(), "全成功还在页面上画重试面板");
|
||||
assert!(model.panel.is_none());
|
||||
assert!(model.retry_install_request().is_none());
|
||||
|
||||
// 失败但没有名单(例如整合包那条本地失败)同样不画:没有可重试的东西
|
||||
let mut failed = InstallProgressView {
|
||||
stage: InstallStage::Failed,
|
||||
..InstallProgressView::new()
|
||||
};
|
||||
failed.retry.clear();
|
||||
assert!(RetryPanel::of(&failed, Lang::En.catalog()).is_none());
|
||||
}
|
||||
|
||||
/// 「只重试这些」发出去的是**失败那次的实例** + `retry: true`(不是页面此刻选中的实例,
|
||||
/// 也不是一次新的根项目安装)。
|
||||
#[test]
|
||||
fn the_retry_action_targets_the_failed_attempt() {
|
||||
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
|
||||
// 名单要有,否则这块入口根本不存在
|
||||
state.install = Some(failed_progress());
|
||||
|
||||
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
|
||||
assert!(model.retry.is_some());
|
||||
let request = model.retry_install_request().expect("有面板就该有请求");
|
||||
assert!(
|
||||
request.retry,
|
||||
"重试必须是 retry: true(否则 host 会从根解起)"
|
||||
);
|
||||
assert_eq!(request.instance, "survival");
|
||||
}
|
||||
@@ -100,6 +100,8 @@ pub fn panel_progress(progress: &ImportProgressView) -> InstallProgressView {
|
||||
eta_secs: 0,
|
||||
// 整合包导入的失败不产生内容库分类(它有自己的报告视图)。
|
||||
failure_kind: None,
|
||||
// 导入没有「只重试这些」这条路径(重试整个导入由导入区自己管)。
|
||||
retry: Vec::new(),
|
||||
message: progress.current.clone().unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,11 @@ use super::model::{Content, Hit};
|
||||
|
||||
mod detail;
|
||||
mod filters;
|
||||
mod retry;
|
||||
|
||||
pub(crate) use detail::detail;
|
||||
pub(crate) use filters::{filters, labelled};
|
||||
pub(crate) use retry::maybe as retry_panel;
|
||||
|
||||
use super::Message as ContentMessage;
|
||||
use crate::pages::frame;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -85,3 +85,16 @@ fn the_demo_selection_is_always_one_of_the_hits() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// M191:「全部成功」那一屏(默认态)**没有**重试面板——出图与判据同源,不是手搓的。
|
||||
#[test]
|
||||
fn the_default_demo_screen_has_no_retry_surface() {
|
||||
for kind in ContentKind::ALL {
|
||||
let model = demo(kind, Lang::En);
|
||||
assert!(
|
||||
model.retry.is_none(),
|
||||
"{} 默认态不该有重试面板",
|
||||
kind.slug()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,12 @@ fn body<'a>(
|
||||
.spacing(ui.space(Space::X3))
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill);
|
||||
if let Some(panel) = &model.panel {
|
||||
// M191:有可重试的失败项时,这块**顶替**下载页那块通用失败面板(一屏只有一个失败块);
|
||||
// 没有名单时它连构造都不存在(`None`),不是画一个灰按钮。
|
||||
let retry = parts::retry_panel(ui, catalog, model);
|
||||
if let Some(panel) = retry {
|
||||
stack = stack.push(divider::full(ui)).push(panel);
|
||||
} else if let Some(panel) = &model.panel {
|
||||
// **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。
|
||||
stack = stack
|
||||
.push(divider::full(ui))
|
||||
|
||||
@@ -297,6 +297,8 @@ fn install(stage: InstallStage, ratio: f32, catalog: Catalog) -> InstallView {
|
||||
message: String::new(),
|
||||
// 演示里这次失败不是内容库那条路来的,不给分类(面板照旧画「失败 + 文案」)。
|
||||
failure_kind: None,
|
||||
// 同理:没有失败名单 ⇒ 内容库那块「只重试这些」不出现(下载页也不画它)。
|
||||
retry: Vec::new(),
|
||||
},
|
||||
catalog,
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user