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

+10
View File
@@ -284,6 +284,16 @@ pub struct ContentInstallRequest {
pub game_version: String,
/// 目标实例的加载器(选文件用)
pub loader: LoaderKind,
/// **只重试上次失败的那些项目**(M191)。
///
/// `true` 时 host 不再解根项目:它拿上一次装入留下的依赖计划调
/// `DependencyPlan::retry_request()`,只重解失败项(`present` 重新扫一遍目录),于是
/// 「已装好的不重下、没失败的不重发」。这种模式下本结构里**只有 `instance` 会被读**——
/// 过滤条件与落盘目录都来自那份计划。
///
/// 没有可重试的计划时(换进程 / 那一次是别的实例)一律回一条失败,**不**退化成「整单重装」:
/// 那正是本字段要避免的事。
pub retry: bool,
}
/// 内容库页的状态(M109):**检索条件 + 最近一批结果 + 请求态**。
@@ -34,6 +34,12 @@ pub enum ContentFailureKind {
Server,
/// 这条路走不通(缺密钥 / 没有可用下载地址 / 摘要格式不对)
Unsupported,
/// **站点有这个项目,但目标版本/加载器下没有条目**(M191;core 的 `FailureKind::Incompatible`)
///
/// 与 [`Self::NotFound`] / [`Self::Offline`] 是**两种用户动作**:这里换版本或换加载器才有救,
/// 重试同样的条件只会得到同一条结论。以前它落在 `Unsupported`(与「缺密钥」共用一档),
/// 于是界面没法单独说「换个版本」——M191 把这一档单独拎出来。
Incompatible,
/// 其它(解析失败、本地 IO……)
Other,
}
@@ -47,10 +53,17 @@ impl ContentFailureKind {
Self::NotFound => "not_found",
Self::Server => "server",
Self::Unsupported => "unsupported",
Self::Incompatible => "incompatible",
Self::Other => "other",
}
}
/// 是不是「所选版本/加载器下没有条目」(换版本/加载器才有救,重试没用)。
#[must_use]
pub const fn is_incompatible(self) -> bool {
matches!(self, Self::Incompatible)
}
/// 是不是「没联网」(页面据此画独立的离线态,而不是通用失败态)。
#[must_use]
pub const fn is_offline(self) -> bool {
+83
View File
@@ -47,6 +47,65 @@ impl InstallStage {
}
}
/// 一条可只重试的失败的**档位**(M191;与 core 的 `FailureKind` 一一对应)。
///
/// ## 为什么不是一个 `bool` / 一句话
///
/// 「不兼容所选版本/加载器」与「取不到」要用户做的事不同:前者换版本或换加载器,后者检查网络
/// 再试。**两档在类型上分开**,界面才能分组画两段、给两条不同的话;合并成一档之后
/// 「重试」按钮会把一条重试必然失败的项也算进去(M191 的变异守门就钉这一条)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum InstallFailureKind {
/// 站点有这个项目,但目标版本/加载器下没有条目(换版本/加载器才有救)
Incompatible,
/// 取不到(没联网 / 项目不存在 / 站点出错 / 下载失败)
Unreachable,
}
impl InstallFailureKind {
/// 稳定字符串 id(日志 / 用例断言用,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Incompatible => "incompatible",
Self::Unreachable => "unreachable",
}
}
/// 两档的固定顺序(界面分组按它排:先「换版本能救」,再「检查网络」)。
pub const ALL: [Self; 2] = [Self::Incompatible, Self::Unreachable];
}
/// 一条「可只重试」的失败项(M191):项目键 + 档位 + core 给的原话。
///
/// 这是界面画「可只重试这 N 个」和分组所需的**全部**信息:项目键就是重试时发出去的那个键
/// (`DependencyPlan::retry_request()` 用的同一份)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallFailureView {
/// 请求它用的项目键(slug 或 id;重试也用它)
pub project: String,
/// 哪一档
pub kind: InstallFailureKind,
/// core 给的人读说明(引擎原话 + 上下文,**不是**界面文案)
pub detail: String,
}
impl InstallFailureView {
/// 构造。
#[must_use]
pub fn new(
project: impl Into<String>,
kind: InstallFailureKind,
detail: impl Into<String>,
) -> Self {
Self {
project: project.into(),
kind,
detail: detail.into(),
}
}
}
/// 一次安装的进度快照。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InstallProgressView {
@@ -77,6 +136,12 @@ pub struct InstallProgressView {
/// 放在进度快照里而不是只塞进文案:`message` 是给用户看的一句话,"没联网" 与
/// "这个项目不存在" 该画两种提示,判据不能靠那句话里有没有"网络"两个字。
pub failure_kind: Option<ContentFailureKind>,
/// 可只重试的失败项(M191):空 = 没有失败项 ⇒ 界面**不渲染**那条入口。
///
/// 每条自带档位([`InstallFailureKind`]),界面按档分组;`project` 就是
/// `DependencyPlan::retry_request()` 会发出去的那几个项目键——名单由 plan 产出,
/// 界面不自己拼(拼一份就会与重试实际请求的那几个不一致)。
pub retry: Vec<InstallFailureView>,
}
impl Default for InstallProgressView {
@@ -101,6 +166,7 @@ impl InstallProgressView {
eta_secs: 0,
message: String::new(),
failure_kind: None,
retry: Vec::new(),
}
}
@@ -159,6 +225,23 @@ impl InstallProgressView {
}
format!("{}/{} 文件", self.files_done, self.files_total)
}
/// 有可只重试的失败项吗(`false` ⇒ 界面**不渲染**那条入口,不是置灰)。
#[must_use]
pub fn has_retry(&self) -> bool {
!self.retry.is_empty()
}
/// 某一档的可重试项(两档分开画时用;顺序沿用计划里的顺序)。
pub fn retry_of(&self, kind: InstallFailureKind) -> impl Iterator<Item = &InstallFailureView> {
self.retry.iter().filter(move |item| item.kind == kind)
}
/// 某一档有几项。
#[must_use]
pub fn retry_count(&self, kind: InstallFailureKind) -> usize {
self.retry_of(kind).count()
}
}
/// 字节数 → 人读(`1.2 MB`)。
+4 -1
View File
@@ -45,7 +45,10 @@ pub use content::{
ContentSource, ContentState, ContentVersionView,
};
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};
pub use install::{
derive_speed, human_bytes, InstallFailureKind, InstallFailureView, InstallProgressView,
InstallStage,
};
pub use instances::{
CreateRequest, InstanceId, InstanceRuntime, InstanceSummary, LaunchFailureCause,
LaunchFailureStage, LaunchFailureView, LaunchStage, LoaderInfo, LoaderKind, Progress,