feat(m162): Modrinth 在线内容源(匿名)+ 远端条目按 sha512 补全 + 离线态

core:新增 addon::online —— 按 Modrinth v2 真实形状建模(search facets / project /
project versions / version_file by hash),网络出口只有 Transport 一个 trait,生产实现
EngineTransport 走既有 DownloadEngine::download_bytes(零新依赖);错误分大类,网络类
失败映射成 NotConnected(= 未联网),不再用空列表冒充「没有结果」。

core:online::download::download_verified —— sha1 交给引擎流式校验、sha512 落盘后复核
(download::HashAlgo 没有 Sha512,改它超出 scope,已登记 finding),不符/失败删文件重试,
最多 3 次尝试且不留半截文件。

core:远端条目补全 —— PackFetcher::modrinth_file_by_sha512(默认 Unsupported = 补不出),
import 管线与新增的 scan_pack_online 都会在「有 path 但缺 hashes.sha1」时按 sha512 反查;
补出的 sha1 走 M160 同一套 judge 与合并判定,补不出仍进 unknown_remote 计数(口径未改)。
DownloadEngineFetcher 改用 download_verified,FetchRequest 增 sha512。

presenter:Modrinth 检索/详情改由 core 的 online 客户端提供(不再自己拼 URL、不再读错
date_modified);ContentFailure{kind} 取代裸字符串,无网 = Offline;新增
ContentDetailRequest/ContentDetail + LoadContentDetail 命令与 CoreEvent::ContentDetail 通道;
预检改走 scan_pack_online;内容库安装也走 download_verified(sha1+sha512)。

app-iced:Status::Offline 独立一态(未联网 ≠ 空列表 ≠ 普通失败),详情面板消费新 DTO
显示可用版本;选中项目即拉详情。i18n 新增 6 个键并同步金表。

测试:online 模块 24 个离线用例(录制 JSON + 假字节:搜索/详情/版本/按摘要反查/校验失败
即删/重试/离线分类/补全),import 侧 6 个(离线计数不变、补全后判冲突、补错摘要 ⇒ Failed),
presenter/app-iced 各补失败分类与详情状态机用例。
This commit is contained in:
AskaEth committed 2026-11-07 17:28:36 +08:00
1 parent 0241f793b2
commit 81278f55af
50 files changed
+3794 -222

No files matched your search

+46 -46
View File
@@ -24,6 +24,15 @@ use serde::{Deserialize, Serialize};
use crate::dto::{InstanceId, LoaderKind};
pub use query::ContentSort;
mod detail;
mod query;
pub use detail::{
ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind, ContentVersionView,
};
/// 内容类型(内容库页左侧那四条一级侧栏)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ContentKind {
@@ -117,50 +126,6 @@ impl ContentSource {
}
}
/// 排序方式(两个站点的官方排序项,口径对齐后再映射到各自参数)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ContentSort {
/// 相关度(Modrinth `relevance`;CF 没有对应项,退回热度)
Relevance,
/// 下载量
Downloads,
/// 最新发布
Newest,
/// 最近更新
Updated,
}
impl ContentSort {
/// 四种排序的声明顺序。
pub const ALL: [ContentSort; 4] = [
ContentSort::Relevance,
ContentSort::Downloads,
ContentSort::Newest,
ContentSort::Updated,
];
/// Modrinth `/v2/search` 的 `index` 参数值。
#[must_use]
pub const fn modrinth_index(self) -> &'static str {
match self {
Self::Relevance => "relevance",
Self::Downloads => "downloads",
Self::Newest => "newest",
Self::Updated => "updated",
}
}
/// CurseForge 的 `sortField`(1 = 热度,2 = 更新,3 = 发布,4 = 名称)。
#[must_use]
pub const fn curseforge_field(self) -> u32 {
match self {
Self::Relevance | Self::Downloads => 1,
Self::Newest => 3,
Self::Updated => 2,
}
}
}
/// 加载器 → Modrinth 的 loader facet 值;`None` = 该加载器没有对应 facet(原版等)。
#[must_use]
pub const fn loader_facet(loader: LoaderKind) -> Option<&'static str> {
@@ -333,14 +298,20 @@ pub struct ContentState {
pub results: Option<ContentResults>,
/// 有请求在飞(页面画加载态的依据;命中项只由事件写进来,不做乐观回显)
pub pending: bool,
/// 最近一次失败的原文(`None` = 没失败)
pub failed: Option<String>,
/// 最近一次失败(`None` = 没失败)。M162 起带**大类**,「没联网」不再是普通失败。
pub failed: Option<ContentFailure>,
/// **最近一页**是否装满(`hits.len() >= limit`)。
///
/// 为什么不是「累计条数够一页」:分页是**累加**到 `results.hits` 里的,累计值在第 2 页之后
/// 必然 ≥ limit ⇒ 用累计判会得到「has_more 恒真」:加载更多永不消失、「没有更多了」在真实
/// 翻页路径上不可达,而且每点一次白发一次网络请求(复审 P1-2)。所以这一位单独记。
pub last_page_filled: bool,
/// 选中项目的详情(M162:`LoadContentDetail` 的结果;`None` = 还没拉过)
pub detail: Option<Box<ContentDetail>>,
/// 详情请求在飞
pub detail_pending: bool,
/// 最近一次详情请求的失败
pub detail_failed: Option<ContentFailure>,
}
impl Default for ContentState {
@@ -351,11 +322,40 @@ impl Default for ContentState {
pending: false,
failed: None,
last_page_filled: false,
detail: None,
detail_pending: false,
detail_failed: None,
}
}
}
impl ContentState {
/// 进「详情请求在飞」态并清掉上一条(换选中项时调)。
pub fn request_detail(&mut self, project: &str) {
if self
.detail
.as_ref()
.is_some_and(|detail| detail.project != project)
{
self.detail = None;
}
self.detail_pending = true;
self.detail_failed = None;
}
/// 详情落地(迟到的结果(换了选中项)由调用方按 `project` 判)。
pub fn apply_detail(&mut self, detail: ContentDetail) {
self.detail_pending = false;
self.detail_failed = None;
self.detail = Some(Box::new(detail));
}
/// 详情失败。
pub fn fail_detail(&mut self, failure: ContentFailure) {
self.detail_pending = false;
self.detail_failed = Some(failure);
}
/// 这批结果还算当前条件吗(`false` = 迟到的事件,丢掉,不许覆盖新的筛选结果)。
#[must_use]
pub fn accepts(&self, results: &ContentResults) -> bool {
+162
View File
@@ -0,0 +1,162 @@
//! 内容库的**失败分类**与**详情/版本列表** 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,
/// 其它(解析失败、本地 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::Other => "other",
}
}
/// 是不是「没联网」(页面据此画独立的离线态,而不是通用失败态)。
#[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()
}
}
+50
View File
@@ -0,0 +1,50 @@
//! 排序方式(M109 起在 `dto/content.rs`,M162 拆出来给内容源抽象让位)。
//!
//! 四种排序是**两个站点都有的公共面**,各自映射到自己的参数(Modrinth 的 `index`、
//! CurseForge 的 `sortField`)。映射函数放在各自的来源实现里,这里只有声明与稳定 slug。
use serde::{Deserialize, Serialize};
/// 排序方式(两个站点的官方排序项,口径对齐后再映射到各自参数)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ContentSort {
/// 相关度(Modrinth `relevance`;CF 没有对应项,退回热度)
Relevance,
/// 下载量
Downloads,
/// 最新发布
Newest,
/// 最近更新
Updated,
}
impl ContentSort {
/// 四种排序的声明顺序。
pub const ALL: [ContentSort; 4] = [
ContentSort::Relevance,
ContentSort::Downloads,
ContentSort::Newest,
ContentSort::Updated,
];
/// Modrinth `/v2/search` 的 `index` 参数值。
#[must_use]
pub const fn modrinth_index(self) -> &'static str {
match self {
Self::Relevance => "relevance",
Self::Downloads => "downloads",
Self::Newest => "newest",
Self::Updated => "updated",
}
}
/// CurseForge 的 `sortField`(1 = 热度,2 = 更新,3 = 发布,4 = 名称)。
#[must_use]
pub const fn curseforge_field(self) -> u32 {
match self {
Self::Relevance | Self::Downloads => 1,
Self::Newest => 3,
Self::Updated => 2,
}
}
}
+6 -2
View File
@@ -137,8 +137,10 @@ pub enum CoreEvent {
NoticesChanged(Vec<NoticeView>),
/// 内容库检索结果(M109):**整批替换**(同一份 `query` 的命中项,含分页游标)
ContentResults(Box<ContentResults>),
/// 内容库检索失败(M109):一句人读原因(没网 / 站点限速 / 缺密钥)
ContentFailed(String),
/// 内容库检索失败(M109;M162 起带大类):没网 / 站点 5xx / 缺密钥要能分开画
ContentFailed(Box<crate::dto::ContentFailure>),
/// 内容库项目详情 + 版本列表(M162):选中项目后单独一条通道
ContentDetail(Box<Result<crate::dto::ContentDetail, crate::dto::ContentFailure>>),
// M113: import/export
/// 导入/导出(M113):进度、结果、探测诊断、导出预检(子枚举见 [`PackEvent`])
Pack(Box<PackEvent>),
@@ -251,6 +253,8 @@ pub enum AppCommand {
SearchContent(ContentQuery),
/// 把某个内容项目装进指定实例(M109;整合包不支持,host 会拒绝并给原因)
InstallContent(Box<ContentInstallRequest>),
/// 取某个内容项目的详情与版本列表(M162;结果经 [`CoreEvent::ContentDetail`] 回来)
LoadContentDetail(Box<crate::dto::ContentDetailRequest>),
// M113: import/export
/// 整合包导入/导出(M113):导入 / 预检 / 确认写盘 / 关面板(子枚举见 [`PackCommand`])
Pack(Box<PackCommand>),
+3 -2
View File
@@ -40,8 +40,9 @@ pub use accounts::{
};
pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView};
pub use content::{
loader_facet, ContentHit, ContentInstallRequest, ContentKind, ContentQuery, ContentResults,
ContentSort, ContentSource, ContentState,
loader_facet, ContentDetail, ContentDetailRequest, ContentFailure, ContentFailureKind,
ContentHit, ContentInstallRequest, ContentKind, ContentQuery, ContentResults, ContentSort,
ContentSource, ContentState, ContentVersionView,
};
pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch};
pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage};