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:
1 parent
0241f793b2
commit
81278f55af
50 files changed
+3794
-222
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>),
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in new issue
Block a user