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

+25 -2
View File
@@ -17,7 +17,8 @@
//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。
use unlucky_presenter::dto::{
AppCommand, ContentInstallRequest, ContentQuery, ContentSource, ExportRequestView,
AppCommand, ContentDetailRequest, ContentInstallRequest, ContentQuery, ContentSource,
ExportRequestView,
};
use crate::pages::content::{Blocker, Content};
@@ -76,7 +77,12 @@ impl App {
self.with_query(|query| query.loader = loader)
}
pages::content::Message::Select(id) => {
self.content.selected = Some(id);
// M162:选中就拉这个项目的详情/版本列表(一条独立通道,失败只影响详情面板)
self.content.selected = Some(id.clone());
self.state.content.request_detail(&id);
if let Some(request) = self.content_detail_request() {
self.dispatch(AppCommand::LoadContentDetail(Box::new(request)));
}
self.view_dirty = true;
}
pages::content::Message::SetTarget(id) => {
@@ -167,6 +173,23 @@ impl App {
///
/// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来,
/// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。
/// 详情的请求载荷(选中项 + 目标实例的版本/加载器;没有选中项就没有请求)。
fn content_detail_request(&self) -> Option<ContentDetailRequest> {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
let hit = model.selected_hit()?;
let target = model
.targets
.iter()
.find(|target| Some(&target.id) == model.target.as_ref());
Some(ContentDetailRequest {
project: hit.id.clone(),
source: hit.source,
game_version: target.map(|target| target.mc_version.clone()),
loader: target.map(|target| target.loader),
})
}
fn content_install_request(&self) -> Option<ContentInstallRequest> {
let catalog = unlucky_i18n::Lang::En.catalog();
let model = Content::from_snapshot(&self.state, &self.content, catalog);
+51 -5
View File
@@ -11,14 +11,16 @@
//! | 变量 | 取值 | 作用 |
//! |---|---|---|
//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) |
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` | 选状态(默认 `ok`) |
//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` / `install` / `cf` / `offline` | 选状态(默认 `ok`;`offline` = M162 的未联网态) |
//!
//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。
use unlucky_i18n::{Catalog, Lang};
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind};
use super::model::{blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target};
use super::model::{
blockers_of, Content, ContentQueryView, Hit, HitFacts, Status, Target, VersionRow,
};
use crate::pages::download::Download;
/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。
@@ -147,9 +149,45 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
let targets = targets();
let target = Some("survival".to_string());
let empty = state == "empty";
// M162:「未联网」那一屏(与空态分开画)
let offline = state == "offline";
// M162:详情通道的版本列表(真实路径来自 presenter 的 `ContentDetail`)
let demo_versions = if empty || offline {
Vec::new()
} else {
vec![
VersionRow {
number: "mc1.20.1-0.5.11".to_string(),
kind: "release".to_string(),
file_name: Some("sodium-fabric-0.5.11.jar".to_string()),
line: catalog.render(
Key::ContentDetailVersionLine,
&[
("number", "mc1.20.1-0.5.11"),
("kind", "Release"),
("date", "2025-06-01"),
],
),
},
VersionRow {
number: "mc1.20.1-0.5.10".to_string(),
kind: "beta".to_string(),
file_name: Some("sodium-fabric-0.5.10.jar".to_string()),
line: catalog.render(
Key::ContentDetailVersionLine,
&[
("number", "mc1.20.1-0.5.10"),
("kind", "Beta"),
("date", "2025-05-20"),
],
),
},
]
};
// 「装入中」那一屏:面板就是下载页那块(同一个函数),用来证明它在内容库语义下也可读
let installing = state == "install";
let list = if empty {
// 空态与未联网态都**没有命中项**:离线时画出列表就是自相矛盾(列表本身就来自网络)
let list = if empty || offline {
Vec::new()
} else {
hits(kind, catalog, state != "incompatible")
@@ -196,7 +234,13 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
},
hits: list,
selected,
status: if empty { Status::Empty } else { Status::Ready },
status: if offline {
Status::Offline("connect timed out after 10s".to_string())
} else if empty {
Status::Empty
} else {
Status::Ready
},
total: if empty { 0 } else { 1_284 },
// 演示数据故意把 `has_more` 置真(真实判据是「本页装满」):出图要能看到「加载更多」
has_more: !empty,
@@ -211,6 +255,8 @@ pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content {
None
},
versions: vec!["1.20.1".to_string(), "1.21.1".to_string()],
available_versions: demo_versions,
versions_note: None,
draft: if empty {
"quarkonium".to_string()
} else {
+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((版本, 加载器))` = 选中了。
@@ -45,13 +45,34 @@ pub enum Status {
Empty,
/// 失败(带一句人读原因)
Failed(String),
/// 未联网(M162):**不是**「没有匹配的内容」,也不是普通失败——单独一屏,
/// 说清「要么联网要么没结果」这件事不可能靠空列表表达。
Offline(String),
}
impl Status {
/// 失败了吗(视图用它决定画错误面板还是列表)。
pub const fn failed(&self) -> bool {
matches!(self, Self::Failed(_))
matches!(self, Self::Failed(_) | Self::Offline(_))
}
/// 未联网吗(视图据此画离线态而不是通用失败态)。
pub const fn offline(&self) -> bool {
matches!(self, Self::Offline(_))
}
}
/// 版本列表的一行(M162:详情通道拉回来的 `ContentVersionView`)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VersionRow {
/// 版本号(`mc1.20.1-0.5.11`)
pub number: String,
/// 版本类型(`release` / `beta` / `alpha`,站点原文)
pub kind: String,
/// 文件名(`None` = 站点没给)
pub file_name: Option<String>,
/// 已经求好文案的一行(`版本号 · 类型 · 日期`)
pub line: String,
}
/// 一条命中项的展示态(文案在这一层求好:iced 的文本控件借用内容)。
@@ -6,8 +6,8 @@
use super::*;
use unlucky_i18n::Lang;
use unlucky_presenter::dto::{
ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, InstanceRuntime,
LoaderKind,
ContentDetail, ContentFailure, ContentFailureKind, ContentHit, ContentKind, ContentQuery,
ContentResults, ContentSource, ContentVersionView, InstanceRuntime, LoaderKind,
};
fn hit(versions: &[&str], loaders: &[&str]) -> ContentHit {
@@ -200,10 +200,69 @@ fn the_four_states_are_derived_from_three_fields() {
#[test]
fn a_failure_outranks_the_loading_state() {
let mut state = state_with(None, true);
state.content.failed = Some("网络不通".to_string());
state.content.failed = Some(ContentFailure::new(ContentFailureKind::Server, "站点 503"));
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
assert_eq!(model.status, Status::Failed("网络不通".to_string()));
assert_eq!(model.status, Status::Failed("站点 503".to_string()));
assert!(model.status.failed());
assert!(!model.status.offline());
}
#[test]
fn no_network_is_its_own_state_not_an_empty_list() {
// M162:无网必须画「未联网」,不能与「没有匹配的内容」共用一个空态。
let mut state = state_with(None, false);
state.content.results = Some(results(Vec::new(), 0));
state.content.failed = Some(ContentFailure::offline("connect timed out"));
let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog());
assert_eq!(
model.status,
Status::Offline("connect timed out".to_string())
);
assert!(model.status.offline());
assert!(model.status.failed());
assert_ne!(model.status, Status::Empty);
}
#[test]
fn versions_come_from_the_detail_channel_of_the_selected_project() {
let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false);
let mut page = PageState {
selected: Some("p1".to_string()),
..PageState::default()
};
// 详情还没到:说明「正在取」,不是「一个版本都没有」
state.content.request_detail("p1");
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
assert!(model.available_versions.is_empty());
assert!(model.versions_note.is_some());
state.content.apply_detail(ContentDetail {
project: "p1".to_string(),
title: "Sodium".to_string(),
description: "fast".to_string(),
body: String::new(),
versions: vec![ContentVersionView {
id: "v1".to_string(),
name: "Sodium 0.6.0".to_string(),
number: "mc1.20.1-0.6.0".to_string(),
kind: "release".to_string(),
game_versions: vec!["1.20.1".to_string()],
loaders: vec!["fabric".to_string()],
published: "2026-02-01T00:00:00Z".to_string(),
file_name: Some("sodium.jar".to_string()),
file_size: Some(2048),
}],
});
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
assert_eq!(model.available_versions.len(), 1);
assert_eq!(model.available_versions[0].number, "mc1.20.1-0.6.0");
assert!(model.available_versions[0].line.contains("mc1.20.1-0.6.0"));
assert!(model.versions_note.is_none());
// 换了选中项:上一个项目的版本列表不许画在这个项目下面
page.selected = Some("p2".to_string());
let model = Content::from_snapshot(&state, &page, Lang::En.catalog());
assert!(model.available_versions.is_empty());
}
#[test]
@@ -341,6 +341,58 @@ pub(super) fn sort_label(sort: ContentSort, catalog: Catalog) -> &'static str {
}
}
/// 未联网态(M162):与失败态同一套骨架,只是说清「要么联网、要么真的没有」这件事。
pub(super) fn offline<'a>(
ui: &'a Ui,
catalog: Catalog,
reason: &'a str,
) -> Element<'a, PageMessage> {
centered(
column![
row![
frame::glyph(
IconKind::Warn,
ui.color(Role::OnSurfaceVariant),
frame::ICON_PX
),
frame::text_role(
ui,
TypeRole::TitleSmall,
true,
catalog.text(Key::ContentOfflineTitle),
ui.color(Role::OnSurface)
),
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
catalog.text(Key::ContentOfflineBody),
ui.color(Role::OnSurfaceVariant)
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
reason,
ui.color(Role::OnSurfaceVariant)
),
buttons::outlined(
ui,
Button::new(
catalog.text(Key::ContentRetry),
intent(ContentMessage::Retry)
)
.leading(IconKind::Refresh),
),
]
.spacing(ui.space(Space::X2))
.align_x(Alignment::Center),
)
}
/// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。
pub(super) fn status_body<'a>(
ui: &'a Ui,
@@ -351,6 +403,8 @@ pub(super) fn status_body<'a>(
Status::Loading => Some(loading(ui, catalog)),
Status::Empty => Some(empty(ui, catalog)),
Status::Failed(reason) => Some(failed(ui, catalog, reason.as_str())),
// M162:未联网不是「没有结果」,也不是普通失败——单独一屏(标题/正文/图标都不同)
Status::Offline(reason) => Some(offline(ui, catalog, reason.as_str())),
Status::Ready => None,
}
}
@@ -93,6 +93,39 @@ pub(crate) fn detail<'a>(
let mut body = column![head, divider::full(ui), meta].spacing(ui.space(Space::X3));
// M162:可用版本(详情通道拉的;最多画 5 行——列表页负责「多」,这里只负责「有哪些」)
if model.available_versions.is_empty() {
if let Some(note) = model.versions_note.as_deref() {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
note,
ui.color(Role::OnSurfaceVariant),
));
}
} else {
let count = model.available_versions.len().to_string();
let mut versions = column![frame::text_role_owned(
ui,
TypeRole::LabelMedium,
true,
catalog.render(Key::ContentDetailVersionCount, &[("count", count.as_str())]),
ui.color(Role::OnSurfaceVariant),
)]
.spacing(ui.space(Space::X1));
for version in model.available_versions.iter().take(5) {
versions = versions.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
version.line.as_str(),
ui.color(Role::OnSurfaceVariant),
));
}
body = body.push(versions);
}
// 目标实例:一行 chip(选中的那个高亮)
if model.targets.is_empty() {
body = body.push(frame::text_role(