Files
Unlucky-Launcher/crates/presenter/src/dto/content/detail.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

176 lines
6.4 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.
//! 内容库的**失败分类**与**详情/版本列表** DTO(M162:在线内容源接上真数据)。
//!
//! 从 `dto/content.rs` 拆出(那个文件已贴 400 行硬线)。两件事放一起,因为它们都是
//! 「检索之后」的话:一次请求失败了要能说清是哪一类,选中一个项目要能拿到版本列表。
//!
//! # 为什么失败要分大类
//!
//! 无网时画「未联网,检查网络后重试」、站点 404 时画「换个关键词」是**两种界面**。
//! 只有字符串就只能靠 `contains("网络")` 猜文案,一改文案就失效;所以大类在 core 那一侧
//! (`OnlineErrorKind`)就分好了,这里只做一一映射,不重新判。
//!
//! # 详情为什么单独一条通道
//!
//! 检索结果里只有「支持哪些版本/加载器」(facets),拿不到「有哪些版本可以装」。
//! 版本列表是另一个端点(`/v2/project/{id}/version`),而且**只在选中某个项目时才需要**——
//! 跟着检索一起拉等于每页多 N 次请求。所以详情是一条独立命令/事件,页面选中才发。
use serde::{Deserialize, Serialize};
use super::ContentSource;
use crate::dto::LoaderKind;
/// 一次检索/详情失败的**大类**(M162:把「没联网」与「查无此项」分开)。
///
/// 为什么不是 `Option<String>`:无网时画「未联网,检查网络后重试」、站点 404 时画「换个关键词」
/// 是两件事;只有字符串就只能靠 `contains("网络")` 猜,一改文案就失效。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ContentFailureKind {
/// 没联网 / 连不上站点(core 的 `OnlineErrorKind::NotConnected`)
Offline,
/// 站点说没有(404)
NotFound,
/// 站点出错(5xx / 退避后仍失败 / 限速)
Server,
/// 这条路走不通(缺密钥 / 没有可用下载地址 / 摘要格式不对)
Unsupported,
/// **站点有这个项目,但目标版本/加载器下没有条目**(M191;core 的 `FailureKind::Incompatible`)
///
/// 与 [`Self::NotFound`] / [`Self::Offline`] 是**两种用户动作**:这里换版本或换加载器才有救,
/// 重试同样的条件只会得到同一条结论。以前它落在 `Unsupported`(与「缺密钥」共用一档),
/// 于是界面没法单独说「换个版本」——M191 把这一档单独拎出来。
Incompatible,
/// 其它(解析失败、本地 IO……)
Other,
}
impl ContentFailureKind {
/// 稳定字符串 id(日志 / 用例断言用,**不要改名**)。
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Offline => "offline",
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 {
matches!(self, Self::Offline)
}
}
/// 一次检索/详情失败(大类 + 一句人读原因)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContentFailure {
/// 大类
pub kind: ContentFailureKind,
/// 人读说明(原文来自 core / 站点;**不是**界面文案)
pub detail: String,
}
impl ContentFailure {
/// 构造。
#[must_use]
pub fn new(kind: ContentFailureKind, detail: impl Into<String>) -> Self {
Self {
kind,
detail: detail.into(),
}
}
/// 没联网。
#[must_use]
pub fn offline(detail: impl Into<String>) -> Self {
Self::new(ContentFailureKind::Offline, detail)
}
/// 这条路没通。
#[must_use]
pub fn unsupported(detail: impl Into<String>) -> Self {
Self::new(ContentFailureKind::Unsupported, detail)
}
/// 没联网吗。
#[must_use]
pub const fn is_offline(&self) -> bool {
self.kind.is_offline()
}
}
impl std::fmt::Display for ContentFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}:{}", self.kind.as_str(), self.detail)
}
}
/// 「取项目详情 + 版本列表」的请求(M162)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContentDetailRequest {
/// 项目 id
pub project: String,
/// 来源(详情端点目前只接了 Modrinth;CurseForge 会明确报「未接通」)
pub source: ContentSource,
/// 目标游戏版本(选版本用;`None` = 不限)
pub game_version: Option<String>,
/// 目标加载器(选版本用;`None` = 不限)
pub loader: Option<LoaderKind>,
}
/// 一个可用版本(详情页「版本列表」的一行)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContentVersionView {
/// 版本 id
pub id: String,
/// 版本名
pub name: String,
/// 版本号
pub number: String,
/// 版本类型(`release` / `beta` / `alpha`)
pub kind: String,
/// 支持的游戏版本
pub game_versions: Vec<String>,
/// 支持的加载器
pub loaders: Vec<String>,
/// 发布日期(站点原文)
pub published: String,
/// 主文件名(`None` = 站点没给)
pub file_name: Option<String>,
/// 主文件字节数(`None` = 站点没给)
pub file_size: Option<u64>,
}
/// 项目详情 + 版本列表(M162)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContentDetail {
/// 项目 id
pub project: String,
/// 标题
pub title: String,
/// 简介
pub description: String,
/// 长文(markdown 原文;页面只当纯文本画前几行)
pub body: String,
/// 可用版本(**最新在前**,已按请求的版本/加载器过滤)
pub versions: Vec<ContentVersionView>,
}
impl ContentDetail {
/// 版本数量(页面「N 个可用版本」直接用)。
#[must_use]
pub fn version_count(&self) -> usize {
self.versions.len()
}
}