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 没有这一项,界面上也没有半成品入口。
176 lines
6.4 KiB
Rust
176 lines
6.4 KiB
Rust
//! 内容库的**失败分类**与**详情/版本列表** 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()
|
||
}
|
||
}
|