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

+69 -4
View File
@@ -27,7 +27,7 @@ use unlucky_presenter::dto::{
};
use unlucky_presenter::state::AppState;
use unlucky_i18n::Catalog;
use unlucky_i18n::{Catalog, Key};
use crate::pages::download::Download;
@@ -35,7 +35,7 @@ mod items;
pub(crate) use items::HitFacts;
pub(crate) use items::{capitalize, thousands};
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target};
pub use items::{loader_name, Blocker, Hit, PageState, Status, Target, VersionRow};
/// 内容库页要画的东西。
#[derive(Debug, Clone, PartialEq)]
@@ -75,6 +75,10 @@ pub struct Content {
pub draft: String,
/// 导入/导出区(M113):入口 + 进度 + 结果 + 导出预检
pub pack: super::packui::Pack,
/// 选中项目的可用版本(M162:详情通道;空 = 还没拉到 / 站点没给)
pub available_versions: Vec<VersionRow>,
/// 版本区的一句话说明(正在取 / 取不到:原因;`None` = 不必说)
pub versions_note: Option<String>,
}
/// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。
@@ -156,6 +160,9 @@ impl Content {
catalog,
);
let (available_versions, versions_note) =
detail_view(content, page.selected.as_deref(), catalog);
Self {
kind: page.kind,
query: ContentQueryView {
@@ -183,6 +190,8 @@ impl Content {
versions,
draft: page.draft.clone(),
pack,
available_versions,
versions_note,
}
}
@@ -213,8 +222,13 @@ impl Content {
/// 快照 → 四态。判据只有三个字段(请求在飞 / 失败原因 / 命中项),没有第四份真相。
fn status_of(content: &ContentState, hits: &[Hit]) -> Status {
if let Some(reason) = &content.failed {
return Status::Failed(reason.clone());
if let Some(failure) = &content.failed {
// M162:没联网单独一态(空列表不能冒充「没有结果」)
return if failure.is_offline() {
Status::Offline(failure.detail.clone())
} else {
Status::Failed(failure.detail.clone())
};
}
if content.pending || content.results.is_none() {
return Status::Loading;
@@ -237,6 +251,57 @@ fn content_install(state: &AppState, catalog: Catalog) -> Option<Download> {
.and_then(|progress| crate::pages::download::install_panel_model(progress, catalog))
}
/// 选中项目的版本列表 + 一句说明(详情通道;迟到结果按项目 id 丢)。
///
/// 三条优先级:**有结果就画结果** → 在飞就画「正在取」 → 失败才画原因。选中项变了而结果是
/// 上一个项目的,一律当「没有」(`detail.project != selected`)——否则会画着 A 的版本列表
/// 却高亮着 B。
fn detail_view(
content: &ContentState,
selected: Option<&str>,
catalog: Catalog,
) -> (Vec<VersionRow>, Option<String>) {
let Some(selected) = selected else {
return (Vec::new(), None);
};
if let Some(detail) = content.detail.as_ref().filter(|d| d.project == selected) {
let rows = detail
.versions
.iter()
.map(|version| VersionRow {
number: version.number.clone(),
kind: version.kind.clone(),
file_name: version.file_name.clone(),
line: catalog.render(
Key::ContentDetailVersionLine,
&[
("number", version.number.as_str()),
("kind", capitalize(&version.kind).as_str()),
("date", version.published.as_str()),
],
),
})
.collect();
return (rows, None);
}
if content.detail_pending {
return (
Vec::new(),
Some(catalog.text(Key::ContentDetailFetching).to_string()),
);
}
match &content.detail_failed {
Some(failure) => (
Vec::new(),
Some(catalog.render(
Key::ContentDetailFetchFailed,
&[("reason", failure.detail.as_str())],
)),
),
None => (Vec::new(), None),
}
}
/// 兼容性判定(**纯函数**,单测直接钉它;见模块文档第 2 节)。
///
/// `hit` 是「选中项的两个支持列表」:`None` = 没选中,`Some((版本, 加载器))` = 选中了。