From 6b161227d8eea6c77c2efc7e49baa7a324814294 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 4 Nov 2026 19:06:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(app-iced):=20M109=20CONTENTDL=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=86=85=E5=AE=B9=E5=BA=93=E9=A1=B5=EF=BC=88?= =?UTF-8?q?=E6=95=B4=E5=90=88=E5=8C=85/Mod/=E8=B5=84=E6=BA=90=E5=8C=85/?= =?UTF-8?q?=E5=85=89=E5=BD=B1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户令「下载页只负责下载整合包和Mod/光影」:新开一页做**内容发现与装入**, 检索走 Modrinth(优先,无需 key)/ CurseForge(`ULL_CF_API_KEY` 环境变量,日志只打 set/unset),装入走 core 的下载引擎真下到实例目录,进度/取消/失败复用下载页那块安装面板。 - **入口(B1)**:`Route::Content` 是新顶层路由,但 `Route::nav()` 复用 `NavSection::Download` ——导航轨仍五项,页头右上是「版本管理 | 内容库」一级切换(版本管理页也加了同一个切换, 否则内容库只有 `ULL_START_ROUTE` 能到) - **页内四条一级侧栏**(`pages/content/layout.rs` 四档几何:992/856/648 三个门槛都是 「Σ栏宽 + 间距」算出来的):整合包 / Mod / 资源包 / 光影;标签栏 200(`Resource packs` 比 M107 的版本类型名长,140 会切字,已登记偏离) - **筛选**:关键词(草稿 + 显式「搜索」按钮,不逐字打网络)、来源、游戏版本、加载器 (Fabric/Forge/NeoForge/Quilt)、排序;**分页**(「加载更多」)而不是无限滚动——快照只有一个槽、 翻页要看得见、状态要可截图复现 - **四态**:加载 / 空 / 失败 / 有数据 + 「没有更多了」;筛选条在任何状态下都在(加载中藏筛选条 等于把入口收走) - **一键装入**:`Content::blockers()` 是纯函数——版本不匹配、加载器不匹配、没有目标实例、 整合包缺管线**四条原因逐条**给出,按钮点之前就禁用;判定与画法分开(model/tests 直接钉) - **整合包今天不可装**:`ContentKind::Modpack.dir()` 是 `None`,UI 禁用 + 说明,host 侧再挡 一次(绕过 UI 也只会回一条 Failed)。真流程是 M111 的导入管线 - **零 UI 网络**:`presenter/host/addon.rs` + `addon/{sources,http,install}.rs`;无 header 的请求走 core 的 `download_bytes`(信号量限流 + 健康度 + 退避全现成),带 `X-API-KEY` 的只有 CF 一条, 自带 429/5xx 退避(优先吃 `Retry-After`,封顶 30s);密钥不进 DTO/日志/仓库 - 文案 41 条中英进 `crates/i18n`(新域接在装配链末尾 ⇒ `Key::ALL` 与金表只追加) - 演示数据 `ContentHit → Hit::of` 与产品路径同源(格式化不会两套),`ULL_CONTENT_KIND` / `ULL_CONTENT_STATE` 供出图矩阵选类型与状态 取证:`DevDocs/artifacts/m109/`(10 张:四类内容 × 浅/深 + 搜索无结果 + 加载器不兼容禁用) 门禁:fmt ✓ / clippy `-D warnings`(三 crate)✓ / app-iced 450+6、presenter 170、i18n 24 全绿; `cargo tree` 两条零 UI 门禁仍空;全部改动文件 ≤400 行。 --- crates/app-iced/src/app/message.rs | 3 +- crates/app-iced/src/app/state.rs | 8 + crates/app-iced/src/app/update.rs | 7 +- crates/app-iced/src/app/update/content.rs | 163 ++++++++ .../app-iced/src/app/update/page_messages.rs | 8 + crates/app-iced/src/app/view.rs | 14 +- crates/app-iced/src/pages/content.rs | 94 +++++ crates/app-iced/src/pages/content/demo.rs | 208 ++++++++++ crates/app-iced/src/pages/content/layout.rs | 194 ++++++++++ crates/app-iced/src/pages/content/message.rs | 40 ++ crates/app-iced/src/pages/content/model.rs | 281 ++++++++++++++ .../app-iced/src/pages/content/model/items.rs | 218 +++++++++++ .../app-iced/src/pages/content/model/tests.rs | 274 +++++++++++++ crates/app-iced/src/pages/content/parts.rs | 333 ++++++++++++++++ .../src/pages/content/parts/detail.rs | 179 +++++++++ .../src/pages/content/parts/filters.rs | 163 ++++++++ crates/app-iced/src/pages/content/tests.rs | 87 +++++ crates/app-iced/src/pages/content/view.rs | 222 +++++++++++ crates/app-iced/src/pages/download.rs | 36 ++ crates/app-iced/src/pages/message.rs | 6 + crates/app-iced/src/pages/mod.rs | 4 + crates/app-iced/src/pages/model.rs | 5 + crates/app-iced/src/pages/screen.rs | 14 +- crates/app-iced/src/pages/tests.rs | 12 +- crates/i18n/src/catalog.rs | 5 + crates/i18n/src/catalog/content.rs | 86 +++++ crates/i18n/src/catalog/golden.rs | 42 ++ crates/presenter/src/dto/content.rs | 359 ++++++++++++++++++ crates/presenter/src/dto/content/tests.rs | 87 +++++ crates/presenter/src/dto/events.rs | 15 +- crates/presenter/src/dto/mod.rs | 7 +- crates/presenter/src/host/addon.rs | 102 +++++ crates/presenter/src/host/addon/http.rs | 123 ++++++ crates/presenter/src/host/addon/install.rs | 252 ++++++++++++ crates/presenter/src/host/addon/sources.rs | 262 +++++++++++++ crates/presenter/src/host/addon/tests.rs | 85 +++++ crates/presenter/src/host/dispatch.rs | 18 +- crates/presenter/src/host/handle.rs | 12 +- crates/presenter/src/host/job.rs | 4 +- crates/presenter/src/host/mod.rs | 3 + crates/presenter/src/router.rs | 13 +- crates/presenter/src/state.rs | 48 ++- 42 files changed, 4071 insertions(+), 25 deletions(-) create mode 100644 crates/app-iced/src/app/update/content.rs create mode 100644 crates/app-iced/src/pages/content.rs create mode 100644 crates/app-iced/src/pages/content/demo.rs create mode 100644 crates/app-iced/src/pages/content/layout.rs create mode 100644 crates/app-iced/src/pages/content/message.rs create mode 100644 crates/app-iced/src/pages/content/model.rs create mode 100644 crates/app-iced/src/pages/content/model/items.rs create mode 100644 crates/app-iced/src/pages/content/model/tests.rs create mode 100644 crates/app-iced/src/pages/content/parts.rs create mode 100644 crates/app-iced/src/pages/content/parts/detail.rs create mode 100644 crates/app-iced/src/pages/content/parts/filters.rs create mode 100644 crates/app-iced/src/pages/content/tests.rs create mode 100644 crates/app-iced/src/pages/content/view.rs create mode 100644 crates/i18n/src/catalog/content.rs create mode 100644 crates/presenter/src/dto/content.rs create mode 100644 crates/presenter/src/dto/content/tests.rs create mode 100644 crates/presenter/src/host/addon.rs create mode 100644 crates/presenter/src/host/addon/http.rs create mode 100644 crates/presenter/src/host/addon/install.rs create mode 100644 crates/presenter/src/host/addon/sources.rs create mode 100644 crates/presenter/src/host/addon/tests.rs diff --git a/crates/app-iced/src/app/message.rs b/crates/app-iced/src/app/message.rs index ebe4259..a0dcdf6 100644 --- a/crates/app-iced/src/app/message.rs +++ b/crates/app-iced/src/app/message.rs @@ -89,7 +89,8 @@ pub(super) fn route_of(nav: NavItem) -> Route { pub(super) fn nav_item_of(route: &Route) -> NavItem { match route { Route::Instances | Route::Detail(_) => NavItem::Instances, - Route::Download => NavItem::Download, + // M109:内容库与版本管理共用「下载」那一个导航项(B1 裁定) + Route::Download | Route::Content => NavItem::Download, Route::Logs => NavItem::Logs, Route::Settings => NavItem::Settings, Route::Agent => NavItem::Ai, diff --git a/crates/app-iced/src/app/state.rs b/crates/app-iced/src/app/state.rs index d50a12c..a4397bb 100644 --- a/crates/app-iced/src/app/state.rs +++ b/crates/app-iced/src/app/state.rs @@ -146,6 +146,12 @@ pub struct App { /// 已经提示过的取消次数(M105):`AppState::install_cancelled` 是「发生过几次取消」, /// 这里是「其中几次已经弹过提示」。两者不等 ⇒ 弹一次 i18n 提示。 pub(super) install_cancelled_seen: u64, + /// 内容库页的页面内选择(M109):内容类型 / 选中项目 / 目标实例 / 关键词草稿。 + /// + /// 为什么在 `App` 而不是 presenter:这四样是「用户在这一屏上点了什么」,重建快照不该冲掉; + /// 而检索条件与结果(会跨页存活、要被事件写)在 `AppState::content`。放进 `view` 模型也 + /// 不行——`refresh_view` 每次都用快照重建模型,选择会被冲回默认值。 + pub(super) content: pages::content::PageState, } impl App { @@ -246,6 +252,7 @@ impl App { // 下载页安装草稿(M105):出厂态 = 没选中、原版、两个空串 download_draft: pages::InstallDraft::default(), install_cancelled_seen: 0, + content: pages::content::PageState::default(), }; // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 app.rebuild_ui(); @@ -338,6 +345,7 @@ impl App { category, &draft, self.lang(), + &self.content, ); self.view_dirty = false; self.refreshes += 1; diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index 96feb8c..a2f1bd0 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -43,6 +43,7 @@ use super::state::{App, IDLE_TICK, TICK, TICK_MS, WATCH_WINDOW}; use super::CAPTURE_AT_FRAME; mod agent; +mod content; mod download; mod page_messages; mod settings; @@ -173,6 +174,8 @@ impl App { self.state.navigate(route); // 直达某一页时不要再被「自动选中第一条实例」拉回详情页。 self.auto_selected = true; + // M109:直达内容库时补一次检索(与页面内切换同一个入口) + self.ensure_content_loaded(); } self.auto_select(); @@ -364,7 +367,7 @@ fn heartbeat() -> Task { }) } -/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `logs`)。 +/// 取证开关 `ULL_START_ROUTE` 的取值解析(`settings` / `instances` / `download` / `content` / `logs`)。 /// /// 为什么是环境变量:`crates/app-iced/src/cli.rs` 不在 M93 的 scope,而外观矩阵必须在**设置页**上 /// 出图。语义与 `ULL_APPEARANCE_*` / `ULL_TICK_TRACE` 一致:只影响这一次进程。 @@ -376,6 +379,8 @@ fn env_start_route() -> Option { "settings" => Some(Route::Settings), // M104:AI 独立页也要能直达(三态矩阵的 Detached 那张) "agent" => Some(Route::Agent), + // M109:内容库(截图矩阵要能直达;页面内的四条侧栏不在路由里) + "content" => Some(Route::Content), _ => None, } } diff --git a/crates/app-iced/src/app/update/content.rs b/crates/app-iced/src/app/update/content.rs new file mode 100644 index 0000000..910501a --- /dev/null +++ b/crates/app-iced/src/app/update/content.rs @@ -0,0 +1,163 @@ +//! 内容库页的消息落点(M109):`pages::Message::Content(..)` 的穷尽分发。 +//! +//! ## 两件事分开(与 `update/download.rs` 同一条纪律) +//! +//! | 意图 | 谁改状态 | 派指令吗 | +//! |---|---|---| +//! | 关键词草稿 / 选中项目 / 目标实例 | `App::content`(页面内选择) | 不派 | +//! | 检索条件(类型/来源/排序/版本/加载器/关键词提交/翻页/重试) | `AppState::content`(presenter) | 派 `SearchContent` | +//! | 装入 | 谁都不改 | 派 `InstallContent` | +//! +//! 「装入」不改本地状态是关键:装上的证据是 `InstallProgress` 事件回流到安装面板 +//! (`pages/download::install_panel`),页面自己不做乐观回显。 +//! +//! ## 为什么条件写进 presenter 再派 +//! +//! `AppState::content.query` 是「当前结果对应哪份条件」的唯一真相:结果回来时 +//! `apply_content` 用它判「迟到的事件要不要丢」。页面自己存一份就会有两份真相。 + +use unlucky_presenter::dto::{AppCommand, ContentInstallRequest, ContentQuery, ContentSource}; + +use crate::pages::content::{Blocker, Content}; + +use super::*; + +impl App { + /// 进入内容库页时补一次检索(首个用户动作不该是「再点一下搜索」)。 + /// + /// 判据是 `AppState::content` 里还没有结果、也没有请求在飞:重复进出不重复打网络。 + pub(super) fn ensure_content_loaded(&mut self) { + if self.state.content.results.is_some() || self.state.content.pending { + return; + } + let query = ContentQuery::first(self.content.kind); + self.search_content(query); + } + + /// 页面消息(穷尽分发:漏一支报 E0004)。 + pub(super) fn on_content(&mut self, message: pages::content::Message) -> Task { + match message { + pages::content::Message::SetKind(kind) => { + // 换类型 = 换一份条件(facet 全变):选中项作废,条件回到该类型的首页 + self.content.kind = kind; + self.content.selected = None; + let mut query = ContentQuery::first(kind); + // 保留用户已经选好的来源/排序/版本/加载器:换成默认值会让筛选条自己跳回去 + query.source = self.state.content.query.source; + query.sort = self.state.content.query.sort; + query.game_version = self.state.content.query.game_version.clone(); + query.loader = self.state.content.query.loader; + self.search_content(query); + } + pages::content::Message::QueryChanged(text) => { + self.content.draft = text; + self.view_dirty = true; + } + pages::content::Message::Search => { + let mut query = self.state.content.query.clone(); + query.text = self.content.draft.trim().to_string(); + query.offset = 0; + self.search_content(query); + } + pages::content::Message::SetSource(source) => { + if source == ContentSource::Curseforge && !self.curseforge_available() { + // 筛选条那条本来就是禁用的;这里再挡一次(防绕过 UI 的消息) + return Task::none(); + } + self.with_query(|query| query.source = source); + } + pages::content::Message::SetSort(sort) => self.with_query(|query| query.sort = sort), + pages::content::Message::SetVersion(version) => { + self.with_query(|query| query.game_version = version); + } + pages::content::Message::SetLoader(loader) => { + self.with_query(|query| query.loader = loader) + } + pages::content::Message::Select(id) => { + self.content.selected = Some(id); + self.view_dirty = true; + } + pages::content::Message::SetTarget(id) => { + self.content.target = Some(id); + self.view_dirty = true; + } + pages::content::Message::LoadMore => { + let next = self.state.content.query.next_page(); + self.search_content(next); + } + pages::content::Message::Retry => { + let mut query = self.state.content.query.clone(); + query.offset = 0; + self.search_content(query); + } + pages::content::Message::Install => { + if let Some(request) = self.content_install_request() { + self.dispatch(AppCommand::InstallContent(Box::new(request))); + } + } + } + Task::none() + } + + /// 改一个筛选字段并**立刻重查**(第一页)。 + fn with_query(&mut self, change: impl FnOnce(&mut ContentQuery)) { + let mut query = self.state.content.query.clone(); + change(&mut query); + query.offset = 0; + self.search_content(query); + } + + /// 提交一次检索:条件先落进 presenter(迟到事件的判据),再派指令。 + fn search_content(&mut self, query: ContentQuery) { + self.state.set_content_query(query.clone()); + self.view_dirty = true; + self.dispatch(AppCommand::SearchContent(query)); + } + + /// CF 源可用吗(结果里带回的那一位;还没拉过 ⇒ `false`)。 + fn curseforge_available(&self) -> bool { + self.state + .content + .results + .as_ref() + .is_some_and(|results| results.curseforge) + } + + /// 组一份内容库的「装入」请求;闸门与页面上的按钮**同一个判据**(`Content::can_install`)。 + /// + /// 名字带 `content_`:M105 的版本安装已经占了 `install_request`(版本 → `CreateRequest`), + /// 两者语义完全不同,撞名会被编译器拦下(本轮 rebase 撞过)。 + /// + /// 这里再判一次不是重复劳动:页面禁用的只是那一个按钮,消息仍然可能从别的路径进来, + /// 而 host 侧对整合包会直接回失败——三道闸门同一个结论,才不会出现「有的路能过」。 + fn content_install_request(&self) -> Option { + let catalog = unlucky_i18n::Lang::En.catalog(); + let model = Content::from_snapshot(&self.state, &self.content, catalog); + if !model.can_install() { + // 有原因就打印出来(取证用);没有选中项时什么都不做 + for reason in model.blocker_reasons(catalog) { + eprintln!("[content] 装入被挡下:{reason}"); + } + if model.blockers.contains(&Blocker::NeedsPipeline) { + eprintln!("[content] 整合包装入未实现(M111)"); + } + return None; + } + let hit = model.selected_hit()?; + let target = model + .targets + .iter() + .find(|target| Some(&target.id) == model.target.as_ref())?; + Some(ContentInstallRequest { + project: hit.id.clone(), + title: hit.title.clone(), + // 整合包走不到这里:`ContentKind::Modpack` 的 `dir()` 是 `None` ⇒ 必然带 + // `Blocker::NeedsPipeline` ⇒ `can_install()` 为假。所以这里就是「单文件」那一类。 + kind: model.kind, + source: hit.source, + instance: target.id.clone(), + game_version: target.mc_version.clone(), + loader: target.loader, + }) + } +} diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index ee4f6c3..75b9c11 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -43,9 +43,15 @@ impl App { match message { pages::Message::Navigate(route) => { + // M109:进内容库时补一次检索(首个动作不该是「再点一下搜索」)。 + // 判据在 `ensure_content_loaded` 里(已有结果/请求在飞就不重复打网络)。 + let entering_content = route == Route::Content; self.state.navigate(route); self.view_dirty = true; self.refresh_view(); + if entering_content { + self.ensure_content_loaded(); + } } pages::Message::Launch(id) => self.dispatch(AppCommand::LaunchInstance(id)), pages::Message::Stop(id) => self.dispatch(AppCommand::StopInstance(id)), @@ -57,6 +63,8 @@ impl App { // 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」, // 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。 pages::Message::Language(choice) => self.set_language(choice), + // M109:内容库那一套意图在自己的文件里穷尽分发(`update/content.rs`) + pages::Message::Content(message) => return self.on_content(message), // M95:摘要落剪贴板(文本由页面造,这里只搬) pages::Message::CopySummary(text) => return iced::clipboard::write(text), // 下面四支已由 `on_download` 提前消化;显式列出而不是 `_ => {}`,见模块文档 diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index 72e2981..003395a 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -111,6 +111,7 @@ pub(super) fn model( category: pages::Category, draft: &pages::InstallDraft, lang: Lang, + content: &pages::content::PageState, ) -> pages::Model { let route = state.route().clone(); let screen = pages::Screen::of(&route).unwrap_or(pages::Screen::Instances); @@ -141,6 +142,8 @@ pub(super) fn model( active: route.nav(), instances: pages::Instances { rows, detail }, download: pages::Download::from_snapshot(state, category, draft, lang.catalog()), + // M109:检索条件/结果来自快照,页面内选择(类型/选中/目标/草稿)来自 `App::content` + content: pages::content::Content::from_snapshot(state, content, lang.catalog()), logs: logs_model(state).with_status(lang), lang, forced: forced.map(|state| match state { @@ -280,7 +283,16 @@ mod tests { /// 测试用的薄包装:默认宽度 / 默认分类 / 空草稿。 fn at(state: &AppState, forced: Option) -> pages::Model { let (draft, lang) = (pages::InstallDraft::default(), Lang::En); - model(state, 1180.0, forced, pages::Category::All, &draft, lang) + let page = pages::content::PageState::default(); + model( + state, + 1180.0, + forced, + pages::Category::All, + &draft, + lang, + &page, + ) } #[test] diff --git a/crates/app-iced/src/pages/content.rs b/crates/app-iced/src/pages/content.rs new file mode 100644 index 0000000..e43c1fa --- /dev/null +++ b/crates/app-iced/src/pages/content.rs @@ -0,0 +1,94 @@ +//! # 内容库页(M109 `CONTENTDL`):整合包 / Mod / 资源包 / 光影 +//! +//! 用户令:「下载页只负责下载整合包和Mod/光影」。于是版本管理页(M107/M108 改名)只做版本, +//! **这一页**做内容:四条内容类型(页内一级侧栏)+ 关键词/来源/版本/加载器/排序筛选 + +//! 结果列表与详情面板 + **一键装入指定实例**。 +//! +//! ## 入口(B1 裁定:不新增导航项) +//! +//! `Route::Content` 是新顶层路由,但 `Route::nav()` 返回 `NavSection::Download`:导航轨仍是 +//! 五项,页头右上是「版本管理 | 内容库」一级切换([`parts::section_switcher`])。 +//! 为什么不是第六个图标:轨宽 88 塞六项会挤,且用户要的是「同一入口下两块内容」。 +//! +//! ## 分页 vs 无限滚动:**选分页** +//! +//! 三条理由,都是这一页的实际约束: +//! +//! 1. **快照只有一个槽**(`AppState::content.results`):无限滚动要求「滚到底就自动续」,而 +//! 自动续在没有虚拟化列表的 iced 里会一次挂上几百个控件; +//! 2. **翻页要有明确出口**:站点限流 / 失败重试都发生在「下一批」上,用户得能看见自己正在 +//! 等第 3 页,而不是画面莫名一卡; +//! 3. **可截图复现**:分页的每一次状态都是一份确定的快照,D30 的矩阵能钉住「还有更多 / 没有更多了」。 +//! +//! 所以尾部是一个「加载更多」按钮 + 一句「没有更多了」([`parts::more`]),追加逻辑在 +//! presenter 的 `AppState::apply_content`(同条件换页 ⇒ 追加;换条件 ⇒ 整批替换;迟到 ⇒ 丢掉)。 +//! +//! ## 四态(都必须能画出来) +//! +//! | 态 | 判据 | 画什么 | +//! |---|---|---| +//! | 加载 | `pending` 或还没有结果 | 一句「正在加载」+ 说明 + 重试 | +//! | 空 | 拉到了、零命中 | 真 `EmptyState` + 重试 CTA | +//! | 失败 | `failed` 有原因 | `Error` 图标 + 原因原文 + 重试(实心) | +//! | 有数据 | 有命中项 | 计数头 + 列表 +(还有更多 ⇒ 加载更多 / 否则 ⇒ 没有更多了) | +//! +//! ## 不在这里做的事 +//! +//! - **不发 HTTP**:检索与装入都在 presenter 的 `host/addon`(任务书第 4 条)。 +//! - **不做乐观回显**:点「装入」不改本地状态,进度只由 `InstallProgress` 事件回流。 +//! - **不自己画安装面板**:复用 `pages::download::install_panel`(同一个函数、同一套文案)。 +//! +//! ## 偏离登记 +//! +//! | # | 偏离 | 为什么 | 何时收口 | +//! |---|---|---|---| +//! | 1 | 四种内容类型借用现有字形(`Folder`/`Instances`/`Download`/`Play`) | 设计令牌的图标集还没有这四枚 | 图标集补「整合包 / Mod / 资源包 / 光影」时 | +//! | 2 | 本页自带一份栏几何(`content/layout.rs`) | M107 的 `pages/layout/rails.rs` 未并入本分支的 base | M107 合并后改为复用、删掉本文件 | +//! | 3 | 整合包的「装入」是禁用态 | core 没有远程整合包的安装管线(M111) | M111 落地后接同一入口 | +//! | 4 | 安装进度只有阶段级(无字节条) | presenter 依赖表里没有 tokio/futures,拿不到 `ProgressEmitter` 的接收端 | presenter 补依赖后换成字节级 | + +mod demo; +mod layout; +mod message; +mod model; +mod parts; +mod view; + +#[cfg(test)] +mod tests; + +pub use message::Message; +pub use model::{loader_name, Blocker, Content, ContentQueryView, Hit, PageState, Status, Target}; + +use iced::Element; + +use unlucky_i18n::Lang; + +pub use unlucky_presenter::dto::ContentKind; + +use crate::pages::layout::FrameMetrics; +use crate::pages::Message as PageMessage; +use crate::theme::Ui; + +/// 整页入口(`pages/mod.rs` 的 `render` 调它;页面层不认识 presenter 的 DTO)。 +pub fn view<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + model: &'a Content, + lang: Lang, +) -> Element<'a, PageMessage> { + view::view(ui, metrics, model, lang) +} + +/// 演示数据(`pages_demo` 出图与单测用;产品路径走 `Content::from_snapshot`)。 +pub fn demo(kind: ContentKind, lang: Lang) -> Content { + demo::demo(kind, lang) +} + +/// 页头那条一级切换(版本管理页也要用同一个函数:两页共用一个入口才不会各画一套)。 +pub(crate) fn section_switcher<'a>( + ui: &'a Ui, + catalog: unlucky_i18n::Catalog, +) -> Element<'a, PageMessage> { + parts::section_switcher(ui, catalog, true) +} diff --git a/crates/app-iced/src/pages/content/demo.rs b/crates/app-iced/src/pages/content/demo.rs new file mode 100644 index 0000000..b6a263f --- /dev/null +++ b/crates/app-iced/src/pages/content/demo.rs @@ -0,0 +1,208 @@ +//! 内容库的演示数据(`pages_demo` 出图与单测用;产品路径永远走 `Content::from_snapshot`)。 +//! +//! ## 为什么要环境变量选「哪一屏」 +//! +//! D30 的验收矩阵要求「四类内容 × 浅/深」加「搜索无结果」「加载器不兼容禁用」两种状态, +//! 共 10 张。`pages_demo` 的 `--screen=` 只够选到**哪一页**,选不到「同一页的哪一态」; +//! 而 `pages/harness.rs` 与 `cli.rs` 不在本 mission 的 scope。 +//! +//! 所以沿用本仓既有的取证开关范式(`ULL_START_ROUTE` / `ULL_APPEARANCE_*` / `ULL_TICK_TRACE`): +//! +//! | 变量 | 取值 | 作用 | +//! |---|---|---| +//! | `ULL_CONTENT_KIND` | `modpack` / `mod` / `resourcepack` / `shader` | 选内容类型(默认 `mod`) | +//! | `ULL_CONTENT_STATE` | `ok` / `empty` / `incompatible` | 选状态(默认 `ok`) | +//! +//! 只影响本来就用演示数据的 harness 路径;产品二进制里 `demo()` 根本不会被调用。 + +use unlucky_i18n::{Catalog, Lang}; +use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSort, ContentSource, LoaderKind}; + +use super::model::{blockers_of, Content, ContentQueryView, Hit, Status, Target}; +use crate::pages::download::Download; + +/// 一个固定的演示环境:两个实例(一个 Fabric / 一个 Forge)。 +fn targets() -> Vec { + vec![ + Target { + id: "survival".to_string(), + name: "Survival".to_string(), + mc_version: "1.20.1".to_string(), + loader: LoaderKind::Fabric, + }, + Target { + id: "modded".to_string(), + name: "Modded".to_string(), + mc_version: "1.21.1".to_string(), + loader: LoaderKind::Forge, + }, + ] +} + +/// 四条命中项:**先造 `ContentHit` 再走 `Hit::of`**,保证演示数据与产品路径逐字段同源 +/// (自己拼 `Hit` 的那一版会把「1,200,000 下载量」「Fabric 首字母大写」这类格式化漂开)。 +fn hits(kind: ContentKind, catalog: Catalog, compatible_only: bool) -> Vec { + let rows: &[(&str, &str, &str)] = match kind { + ContentKind::Modpack => &[ + ( + "Fabulously Optimized", + "robotkoer", + "Performance-focused vanilla+ pack", + ), + ("Adrenaline", "Someone", "Lightweight kitchen-sink pack"), + ("Better MC", "Luna", "Adventure pack with quests"), + ("All the Mods 9", "ATMTeam", "Kitchen sink, 400+ mods"), + ], + ContentKind::Mod => &[ + ("Sodium", "jellysquid3", "Modern rendering engine"), + ("Lithium", "jellysquid3", "Game logic optimizations"), + ("Iris Shaders", "coderbot", "Shader support for Fabric"), + ("JEI", "mezz", "Item and recipe viewer"), + ], + ContentKind::Resourcepack => &[ + ( + "Faithful 32x", + "Faithful", + "Double resolution vanilla textures", + ), + ("Stay True", "Fred", "Warm, hand-drawn look"), + ("Classic 3D", "Someone", "3D blocks and items"), + ("Bare Bones", "Someone", "Flat, minimalist textures"), + ], + ContentKind::Shader => &[ + ("Complementary Reimagined", "Emin", "Balanced, high quality"), + ("BSL Shaders", "capttatsu", "Soft colors, strong weather"), + ("Photon", "SixthSurge", "Path-traced look"), + ("MakeUp Ultra Fast", "Someone", "Lightweight shader"), + ], + }; + + rows.iter() + .enumerate() + .map(|(index, (title, author, summary))| { + // 前两条只支持 1.20.1 + Fabric(能装进 Survival);后两条是 1.21.1 + Forge + let compatible = compatible_only || index < 2; + Hit::of( + &ContentHit { + id: format!("{}-{index}", kind.slug()), + slug: format!("{}-{index}", kind.slug()), + title: (*title).to_string(), + summary: (*summary).to_string(), + author: (*author).to_string(), + downloads: 1_200_000u64.saturating_sub(index as u64 * 111_111), + updated: format!("2026-0{}-1{index}", index + 1), + source: if index % 2 == 0 { + ContentSource::Modrinth + } else { + ContentSource::Curseforge + }, + game_versions: if compatible { + vec!["1.20.1".to_string()] + } else { + vec!["1.21.1".to_string()] + }, + loaders: if compatible { + vec!["fabric".to_string()] + } else { + vec!["forge".to_string()] + }, + icon: None, + }, + catalog, + ) + }) + .collect() +} + +/// 装入中的面板(**复用下载页那块**:`pages::download::install_panel_model` + `install_panel`)。 +fn install_panel(catalog: Catalog) -> Download { + use unlucky_presenter::dto::{InstallProgressView, InstallStage}; + let progress = InstallProgressView { + version: "Sodium".to_string(), + instance: "survival".to_string(), + loader: "Fabric".to_string(), + stage: InstallStage::Downloading, + files_done: 0, + files_total: 1, + message: "sodium-fabric-0.6.0.jar".to_string(), + ..InstallProgressView::new() + }; + crate::pages::download::install_panel_model(&progress, catalog) + .expect("演示数据的进度是「下载中」,面板必然存在") +} + +/// 内建模型(默认 `ok` + 从环境变量读类型/状态)。 +pub(super) fn demo(fallback_kind: ContentKind, lang: Lang) -> Content { + let catalog = lang.catalog(); + let kind = std::env::var("ULL_CONTENT_KIND") + .ok() + .and_then(|value| { + ContentKind::ALL + .into_iter() + .find(|item| item.slug() == value.trim()) + }) + .unwrap_or(fallback_kind); + let state = std::env::var("ULL_CONTENT_STATE").unwrap_or_else(|_| "ok".to_string()); + + let targets = targets(); + let target = Some("survival".to_string()); + let empty = state == "empty"; + // 「装入中」那一屏:面板就是下载页那块(同一个函数),用来证明它在内容库语义下也可读 + let installing = state == "install"; + let list = if empty { + Vec::new() + } else { + hits(kind, catalog, state != "incompatible") + }; + // 「不兼容」那一屏:选中的那条是 1.21.1 + Forge 的项目,而目标是 1.20.1 + Fabric + let selected_index = if state == "incompatible" { 2 } else { 0 }; + let selected = list.get(selected_index).map(|hit| hit.id.clone()); + let blockers = blockers_of( + list.get(selected_index) + .map(|hit| (hit.versions.as_slice(), hit.loaders.as_slice())), + kind, + &targets, + target.as_deref(), + ); + let target_label = targets + .iter() + .find(|item| Some(&item.id) == target.as_ref()) + .map(|item| item.label()); + + Content { + kind, + query: ContentQueryView { + source: ContentSource::Modrinth, + sort: ContentSort::Relevance, + version: Some("1.20.1".to_string()), + loader: Some(LoaderKind::Fabric), + text: if empty { + "quarkonium".to_string() + } else { + String::new() + }, + }, + hits: list, + selected, + status: if empty { Status::Empty } else { Status::Ready }, + total: if empty { 0 } else { 1_284 }, + // 演示数据故意把 `has_more` 置真(真实判据是「本页装满」):出图要能看到「加载更多」 + has_more: !empty, + curseforge: false, + targets, + target: target.clone(), + target_label, + blockers, + panel: if installing { + Some(install_panel(catalog)) + } else { + None + }, + versions: vec!["1.20.1".to_string(), "1.21.1".to_string()], + draft: if empty { + "quarkonium".to_string() + } else { + String::new() + }, + } +} diff --git a/crates/app-iced/src/pages/content/layout.rs b/crates/app-iced/src/pages/content/layout.rs new file mode 100644 index 0000000..b2a253a --- /dev/null +++ b/crates/app-iced/src/pages/content/layout.rs @@ -0,0 +1,194 @@ +//! 内容库页的栏几何(纯函数 + 数值表)。 +//! +//! ## 为什么这一页自带一份几何 +//! +//! 任务书要求「与 M107 同一套视觉语言」:类型栏 140 / 图标栏 64、间距 24、列表列下限 424 +//! (行宽红线)、详情 320。M107 的那份几何在 `pages/layout/rails.rs`(M107 分支,尚未并入 main), +//! 本 mission 的分支基于 main,所以这里按**同一组数值**先落一份,并在报告里登记 +//! 「M107 合并后应改为复用 `pages/layout/rails.rs`,删掉本文件」。 +//! +//! ## 四档的判据是**算出来的**,不是拍的 +//! +//! | 档 | 条件(可用宽,已扣导航轨与页边距) | 版面 | +//! |---|---|---| +//! | `Wide` | ≥ 140 + 24 + 424 + 24 + 320 = **932** | 标签栏 + 列表 + 详情 | +//! | `Narrow` | ≥ 64 + 24 + 424 + 24 + 320 = **856**(类型栏收成图标栏) | 图标栏 + 列表 + 详情 | +//! | `Compact` | ≥ 140 + 24 + 424 = **588** | 标签栏 + 单列(详情整页替换列表) | +//! | `Tight` | 其余 | 类型条变成顶部一行 chip + 单列 | +//! +//! 三个门槛都是「Σ栏宽 + 间距」算出来的,改动任何一档都会让数值表用例红。 + +use unlucky_design::spacing::Space; + +use crate::pages::layout; + +/// 类型栏宽(标签形态)。 +/// +/// M107 的版本类型栏是 140;这里取 200:四个**内容类型**的名字(`Resource packs`)比 +/// 版本类型(`Release` / `Snapshot`)长,140 会把标签切掉。登记在 `pages/content.rs` 的偏离表。 +pub const RAIL_LABEL: f32 = 200.0; +/// 类型栏宽(图标形态)。 +pub const RAIL_ICON: f32 = 64.0; +/// 详情面板宽。 +pub const DETAIL: f32 = 320.0; +/// 栏间距(`Space::X6` = 24,与 `pages::layout::PANES_GAP` 同源)。 +pub const GAP: f32 = Space::X6.dp(); + +/// 一屏三栏各自多宽。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rails { + /// 档位 + pub tier: Tier, + /// 类型栏宽(`Tight` 档是 0:类型条在顶部) + pub rail: f32, + /// 列表列宽 + pub list: f32, + /// 详情面板宽(0 = 这一档没有独立详情列) + pub detail: f32, + /// 栏间距 + pub gap: f32, +} + +/// 版面档位。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tier { + /// 标签栏 + 列表 + 详情 + Wide, + /// 图标栏 + 列表 + 详情 + Narrow, + /// 标签栏 + 单列 + Compact, + /// 顶部 chip 条 + 单列 + Tight, +} + +impl Tier { + /// 这一档有独立详情列吗。 + pub const fn has_detail(self) -> bool { + matches!(self, Self::Wide | Self::Narrow) + } + + /// 这一档的类型栏是标签形态吗(`Tight` 没有栏)。 + pub const fn rail_has_labels(self) -> bool { + matches!(self, Self::Wide | Self::Compact) + } +} + +/// `Wide` 的门槛:标签栏 + 间距 + 列表下限 + 间距 + 详情。 +pub fn wide_min() -> f32 { + RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL +} + +/// `Narrow` 的门槛:图标栏 + 间距 + 列表下限 + 间距 + 详情。 +pub fn narrow_min() -> f32 { + RAIL_ICON + GAP + layout::LINE_SAFE_WIDTH_PX + GAP + DETAIL +} + +/// `Compact` 的门槛:标签栏 + 间距 + 列表下限。 +pub fn compact_min() -> f32 { + RAIL_LABEL + GAP + layout::LINE_SAFE_WIDTH_PX +} + +/// 可用宽 → 三栏几何。 +pub fn resolve(available: f32) -> Rails { + let cap = layout::measure_cap_px(); + + if available >= wide_min() { + let rail = RAIL_LABEL; + return Rails { + tier: Tier::Wide, + rail, + gap: GAP, + detail: DETAIL, + list: (available - rail - GAP - DETAIL - GAP).max(layout::LINE_SAFE_WIDTH_PX), + }; + } + if available >= narrow_min() { + let rail = RAIL_ICON; + return Rails { + tier: Tier::Narrow, + rail, + gap: GAP, + detail: DETAIL, + list: (available - rail - GAP - DETAIL - GAP).max(layout::LINE_SAFE_WIDTH_PX), + }; + } + if available >= compact_min() { + let rail = RAIL_LABEL; + return Rails { + tier: Tier::Compact, + rail, + gap: GAP, + detail: 0.0, + list: (available - rail - GAP).min(cap.max(layout::LINE_SAFE_WIDTH_PX)), + }; + } + Rails { + tier: Tier::Tight, + rail: 0.0, + gap: GAP, + detail: 0.0, + list: available.min(cap.max(layout::LINE_SAFE_WIDTH_PX)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 门槛必须由「Σ栏宽 + 间距」算出来(抓得住:谁把 932 抄成 900 这种拍值)。 + #[test] + fn the_three_thresholds_are_sums_not_magic_numbers() { + assert_eq!(wide_min(), 200.0 + 24.0 + 424.0 + 24.0 + 320.0); + assert_eq!(narrow_min(), 64.0 + 24.0 + 424.0 + 24.0 + 320.0); + assert_eq!(compact_min(), 200.0 + 24.0 + 424.0); + assert_eq!(wide_min(), 992.0); + assert_eq!(narrow_min(), 856.0); + assert_eq!(compact_min(), 648.0); + } + + #[test] + fn each_tier_takes_effect_at_its_own_threshold() { + assert_eq!(resolve(wide_min()).tier, Tier::Wide); + assert_eq!(resolve(wide_min() - 0.5).tier, Tier::Narrow); + assert_eq!(resolve(narrow_min()).tier, Tier::Narrow); + assert_eq!(resolve(narrow_min() - 0.5).tier, Tier::Compact); + assert_eq!(resolve(compact_min()).tier, Tier::Compact); + assert_eq!(resolve(compact_min() - 0.5).tier, Tier::Tight); + } + + #[test] + fn a_wide_page_never_gives_the_list_less_than_the_row_width_red_line() { + // 1180 窗宽(右栗收起):1044 可用 → Wide,列表 536 + let rails = resolve(1044.0); + assert_eq!(rails.tier, Tier::Wide); + assert!(rails.list >= layout::LINE_SAFE_WIDTH_PX, "{}", rails.list); + // 逐档扫一遍:只要还有独立列表列,就不许低于 424(行宽红线) + for available in 500..=1600 { + let rails = resolve(available as f32); + if rails.rail > 0.0 { + assert!( + rails.list >= layout::LINE_SAFE_WIDTH_PX, + "available={available} 时列表只剩 {}", + rails.list + ); + } + } + } + + #[test] + fn the_detail_column_only_exists_in_the_two_wide_tiers() { + assert!(Tier::Wide.has_detail()); + assert!(Tier::Narrow.has_detail()); + assert!(!Tier::Compact.has_detail()); + assert!(!Tier::Tight.has_detail()); + assert_eq!(resolve(1200.0).detail, DETAIL); + assert_eq!(resolve(700.0).detail, 0.0); + // 图标形态只在这两档出现,且窄档的标签栏不存在 + assert_eq!(resolve(900.0).rail, RAIL_ICON); + assert_eq!(resolve(700.0).rail, RAIL_LABEL); + assert_eq!(resolve(400.0).rail, 0.0); + assert!(Tier::Wide.rail_has_labels()); + assert!(!Tier::Narrow.rail_has_labels()); + } +} diff --git a/crates/app-iced/src/pages/content/message.rs b/crates/app-iced/src/pages/content/message.rs new file mode 100644 index 0000000..0cead41 --- /dev/null +++ b/crates/app-iced/src/pages/content/message.rs @@ -0,0 +1,40 @@ +//! 内容库页的**自有**消息(M109)。 +//! +//! 与设置页同一个范式(`pages/settings/message.rs` + `pages::Message::Settings`):页面自己的一套 +//! 意图在这个文件里声明,`pages::Message` 只留一个 `Content(..)` 投递口,于是「加一条内容库消息」 +//! 不碰共享枚举的正文,也不碰 `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。 +//! +//! **一条纪律**:这里的每个变体都是**意图**,不是状态变更。改了条件 ≠ 已经发请求 +//! (`QueryChanged` 只改输入框里的字,`Search` 才发指令);点了「装入」≠ 已装上 +//! (装上的证据是 `InstallProgress` 事件回流到面板)。 + +use unlucky_presenter::dto::{ContentKind, ContentSort, ContentSource, LoaderKind}; + +/// 内容库页的消息。 +#[derive(Debug, Clone)] +pub enum Message { + /// 换内容类型(左侧四条一级侧栏) + SetKind(ContentKind), + /// 关键词输入框变了(**只改草稿**,不 dispatch) + QueryChanged(String), + /// 提交搜索(零偏移重来) + Search, + /// 换来源(Modrinth / CurseForge) + SetSource(ContentSource), + /// 换排序 + SetSort(ContentSort), + /// 换游戏版本过滤(`None` = 不限) + SetVersion(Option), + /// 换加载器过滤(`None` = 不限) + SetLoader(Option), + /// 选中一个项目(**页面内选中**,不 dispatch) + Select(String), + /// 选目标实例(一键装入的对象) + SetTarget(String), + /// 加载下一页 + LoadMore, + /// 同条件重试(失败态的出口) + Retry, + /// 装进目标实例 + Install, +} diff --git a/crates/app-iced/src/pages/content/model.rs b/crates/app-iced/src/pages/content/model.rs new file mode 100644 index 0000000..3d48429 --- /dev/null +++ b/crates/app-iced/src/pages/content/model.rs @@ -0,0 +1,281 @@ +//! 内容库页的视图模型:**presenter 快照 + 页面选中项 → 要画的东西**。 +//! +//! ## 分层(与实例页 / 下载页同一条) +//! +//! | 数据源 | 谁转 | 用途 | +//! |---|---|---| +//! | `AppState::content`(检索条件 + 结果 + 请求态) | [`Content::from_snapshot`] | 产品运行时的真页面 | +//! | [`super::demo`] 的固定夹具 | 同上 | `pages_demo` 出图与单测 | +//! +//! ## 「点之前就知道装不装得上」(任务书第 3 条) +//! +//! 兼容性判定在 [`Content::blockers`]:**纯函数**,输入是「选中的项目 + 目标实例」, +//! 输出是 [`Blocker`] 列表。视图拿它决定按钮 `disabled` 与旁边那几句原因——于是「点完才报错」 +//! 在结构上不可能发生(按钮根本发不出消息)。 +//! +//! 两条判据各自独立、**都能同时成立**(版本对不上 + 加载器也不对):把两条合成一条会在 +//! 「都不兼容」时只说一半原因。 +//! +//! ## 为什么要 `PageState`(页面自留的那点状态) +//! +//! `kind` / `selected` / `target` / `draft` 都是**页面内**的选择:检索条件与结果在 presenter +//! (`AppState::content`),这四样是「用户在这一屏上点了什么」,重建快照不该把它们冲掉。 +//! 它们的所有者是 `app/state.rs` 的 `App::content`,本文件只借用。 + +use unlucky_presenter::dto::{ + loader_facet, ContentKind, ContentResults, ContentSort, ContentSource, ContentState, LoaderKind, +}; +use unlucky_presenter::state::AppState; + +use unlucky_i18n::Catalog; + +use crate::pages::download::Download; + +mod items; + +pub(crate) use items::{capitalize, thousands}; +pub use items::{loader_name, Blocker, Hit, PageState, Status, Target}; + +/// 内容库页要画的东西。 +#[derive(Debug, Clone, PartialEq)] +pub struct Content { + /// 当前内容类型(左栏四条侧栏的选中项) + pub kind: ContentKind, + /// 当前检索条件(筛选条 + 分页游标) + pub query: ContentQueryView, + /// 命中项 + pub hits: Vec, + /// 选中项 + pub selected: Option, + /// 当前状态 + pub status: Status, + /// 命中总数(`0` = 站点没给) + pub total: u32, + /// 还有下一页 + pub has_more: bool, + /// CurseForge 源可用(缺 key 时筛选条里那条禁用并说明) + pub curseforge: bool, + /// 目标实例候选 + pub targets: Vec, + /// 选中的目标实例 + pub target: Option, + /// 选中目标实例的可读标签(详情面板「装入到 …」) + pub target_label: Option, + /// 装入不上的原因(空 = 可以装) + pub blockers: Vec, + /// 安装面板的视图模型(**复用下载页那块**:`pages::download::install_panel`) + /// + /// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树 + /// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。 + pub panel: Option, + /// 游戏版本筛选的候选(来自实例;去重、保持实例顺序) + pub versions: Vec, + /// 关键词草稿 + pub draft: String, +} + +/// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContentQueryView { + /// 来源 + pub source: ContentSource, + /// 排序 + pub sort: ContentSort, + /// 游戏版本过滤 + pub version: Option, + /// 加载器过滤 + pub loader: Option, + /// 已提交的关键词(输入框显示的是 `draft`) + pub text: String, +} + +impl Content { + /// presenter 快照 + 页面选中项 → 视图模型(**唯一的真数据入口**)。 + pub fn from_snapshot(state: &AppState, page: &PageState, catalog: Catalog) -> Self { + let content: &ContentState = &state.content; + let hits: Vec = content + .results + .as_ref() + .map(|results| { + results + .hits + .iter() + .map(|hit| Hit::of(hit, catalog)) + .collect() + }) + .unwrap_or_default(); + + let status = status_of(content, &hits); + let targets: Vec = state.instances.iter().map(Target::of).collect(); + let target = page + .target + .clone() + .filter(|id| targets.iter().any(|target| &target.id == id)) + .or_else(|| targets.first().map(|target| target.id.clone())); + let target_label = target + .as_deref() + .and_then(|id| targets.iter().find(|item| item.id == id)) + .map(Target::label); + + let mut versions: Vec = Vec::new(); + for instance in &targets { + if !versions.iter().any(|item| item == &instance.mc_version) { + versions.push(instance.mc_version.clone()); + } + } + + let selected_hit = page.selected.as_deref().and_then(|id| { + content + .results + .as_ref()? + .hits + .iter() + .find(|hit| hit.id == id) + }); + let blockers = blockers_of( + selected_hit.map(|hit| (hit.game_versions.as_slice(), hit.loaders.as_slice())), + page.kind, + &targets, + target.as_deref(), + ); + + Self { + kind: page.kind, + query: ContentQueryView { + source: content.query.source, + sort: content.query.sort, + version: content.query.game_version.clone(), + loader: content.query.loader, + text: content.query.text.clone(), + }, + hits, + selected: page.selected.clone(), + status, + total: content.results.as_ref().map_or(0, |results| results.total), + has_more: content + .results + .as_ref() + .is_some_and(ContentResults::has_more) + && !content.pending, + curseforge: content + .results + .as_ref() + .is_some_and(|results| results.curseforge), + targets, + target: target.clone(), + target_label, + blockers, + panel: content_install(state, catalog), + versions, + draft: page.draft.clone(), + } + } + + /// 选中项在命中列表里的那一行(详情面板读它)。 + pub fn selected_hit(&self) -> Option<&Hit> { + let id = self.selected.as_deref()?; + self.hits.iter().find(|hit| hit.id == id) + } + + /// 「装入」按钮此刻能不能按(`false` 时按钮禁用,`blockers()` 给原因)。 + pub fn can_install(&self) -> bool { + self.selected_hit().is_some() && self.blockers.is_empty() + } + + /// 原因的人读文案(顺序 = 判定顺序;空 = 没有原因)。 + pub fn blocker_reasons(&self, catalog: Catalog) -> Vec { + self.blockers + .iter() + .map(|blocker| blocker.reason(catalog)) + .collect() + } + + /// 加载中(首屏 / 换条件)。 + pub const fn is_loading(&self) -> bool { + matches!(self.status, Status::Loading) + } +} + +/// 快照 → 四态。判据只有三个字段(请求在飞 / 失败原因 / 命中项),没有第四份真相。 +fn status_of(content: &ContentState, hits: &[Hit]) -> Status { + if let Some(reason) = &content.failed { + return Status::Failed(reason.clone()); + } + if content.pending || content.results.is_none() { + return Status::Loading; + } + if hits.is_empty() { + Status::Empty + } else { + Status::Ready + } +} + +/// 安装面板(**只借用下载页那块面板**,不另写一套画法)。 +/// +/// 转换与「成功/取消这类终态不占面板」的判断都在 `pages::download::install_panel_model` 里 +/// (M105 起 `InstallView` 不再从那个模块导出):内容库只拿 `Option`。 +fn content_install(state: &AppState, catalog: Catalog) -> Option { + state + .install + .as_ref() + .and_then(|progress| crate::pages::download::install_panel_model(progress, catalog)) +} + +/// 兼容性判定(**纯函数**,单测直接钉它;见模块文档第 2 节)。 +/// +/// `hit` 是「选中项的两个支持列表」:`None` = 没选中,`Some((版本, 加载器))` = 选中了。 +/// **空列表 ≠ 不兼容**:站点没给数据时按「未知」处理,不许把「不知道」画成「不支持」。 +pub(super) fn blockers_of( + hit: Option<(&[String], &[String])>, + kind: ContentKind, + targets: &[Target], + target: Option<&str>, +) -> Vec { + if targets.is_empty() { + return vec![Blocker::NoTarget]; + } + let Some(target) = target.and_then(|id| targets.iter().find(|item| item.id == id)) else { + return vec![Blocker::NoTarget]; + }; + let mut blockers = Vec::new(); + // 整合包:不是「兼容不兼容」的问题,是这条路根本没有(M111) + if kind.dir().is_none() { + blockers.push(Blocker::NeedsPipeline); + } + let Some((versions, loaders)) = hit else { + return blockers; + }; + if !versions.is_empty() && !versions.iter().any(|item| item == &target.mc_version) { + blockers.push(Blocker::Version { + needs: join_or_unknown(versions), + actual: target.mc_version.clone(), + }); + } + let facet = loader_facet(target.loader); + if let Some(facet) = facet { + if !loaders.is_empty() && !loaders.iter().any(|item| item == facet) { + blockers.push(Blocker::Loader { + loader: target.loader, + }); + } + } + blockers +} + +/// 版本列表 → 一行人读串(最多三个,其余用 `…`;空 = 「未知」)。 +fn join_or_unknown(versions: &[String]) -> String { + if versions.is_empty() { + return "?".to_string(); + } + let shown = versions.iter().take(3).cloned().collect::>(); + if versions.len() > shown.len() { + format!("{}…", shown.join(", ")) + } else { + shown.join(", ") + } +} + +#[cfg(test)] +#[path = "model/tests.rs"] +mod tests; diff --git a/crates/app-iced/src/pages/content/model/items.rs b/crates/app-iced/src/pages/content/model/items.rs new file mode 100644 index 0000000..977b5f2 --- /dev/null +++ b/crates/app-iced/src/pages/content/model/items.rs @@ -0,0 +1,218 @@ +//! 内容库页的类型:页面自留状态 / 四态 / 展示行 / 目标实例 / 装不上的原因。 +//! +//! 从 `pages/content/model.rs` 拆出(那个文件已贴 400 行硬上限)。纯数据类型与纯函数, +//! 不认识 iced:`model/tests.rs` 直接钉它们的判定。 + +use unlucky_presenter::dto::{ContentHit, ContentKind, ContentSource, InstanceSummary, LoaderKind}; + +use unlucky_i18n::{Catalog, Key}; + +/// 页面自留的选择(检索条件与结果在 presenter 的 `AppState::content`)。 +#[derive(Debug, Clone, PartialEq)] +pub struct PageState { + /// 当前内容类型 + pub kind: ContentKind, + /// 选中的项目 id(`None` = 没选 → 详情面板空态) + pub selected: Option, + /// 目标实例 id(`None` = 没选 → 装入按钮禁用并说明) + pub target: Option, + /// 关键词输入框里的草稿(**未提交**;提交后才进 `AppState::content.query`) + pub draft: String, +} + +impl Default for PageState { + fn default() -> Self { + Self { + kind: ContentKind::Mod, + selected: None, + target: None, + draft: String::new(), + } + } +} + +/// 页面此刻画哪一态(四态 + 分页尾)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Status { + /// 加载中(首屏 / 换条件 / 换页) + Loading, + /// 有命中项 + Ready, + /// 拉到了但一条都没有 + Empty, + /// 失败(带一句人读原因) + Failed(String), +} + +impl Status { + /// 失败了吗(视图用它决定画错误面板还是列表)。 + pub const fn failed(&self) -> bool { + matches!(self, Self::Failed(_)) + } +} + +/// 一条命中项的展示态(文案在这一层求好:iced 的文本控件借用内容)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Hit { + /// 项目 id(选中 / 装入指令的载荷) + pub id: String, + /// 标题 + pub title: String, + /// 一行说明 + pub summary: String, + /// 来源 + 作者(`Modrinth · jelly`) + pub supporting: String, + /// 下载量(已加千分位) + pub downloads: String, + /// 更新日期的人读串 + pub updated: String, + /// 支持的游戏版本(详情面板用;空 = 站点没给) + pub versions: Vec, + /// 支持的加载器(**站点原样的小写 facet**:兼容性判定要用它跟 `loader_facet` 比) + pub loaders: Vec, + /// 来源(行上标来源徽标) + pub source: ContentSource, +} + +impl Hit { + /// 命中项 DTO → 展示态(**演示数据也走这条**,于是 fixture 与产品路径逐字段同源)。 + pub(crate) fn of(hit: &ContentHit, catalog: Catalog) -> Self { + let source = match hit.source { + ContentSource::Modrinth => catalog.text(Key::ContentSourceModrinth), + ContentSource::Curseforge => catalog.text(Key::ContentSourceCurseforge), + }; + let supporting = if hit.author.is_empty() { + source.to_string() + } else { + format!("{source} · {}", hit.author) + }; + Self { + id: hit.id.clone(), + title: hit.title.clone(), + summary: hit.summary.clone(), + supporting, + downloads: catalog.render( + Key::ContentDetailDownloads, + &[("count", &thousands(hit.downloads))], + ), + updated: catalog.render(Key::ContentDetailUpdated, &[("date", &hit.updated)]), + versions: hit.game_versions.clone(), + loaders: hit.loaders.clone(), + source: hit.source, + } + } +} + +/// 目标实例(一键装入的对象)的展示态。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Target { + /// 实例 id + pub id: String, + /// 显示名 + pub name: String, + /// 游戏版本 + pub mc_version: String, + /// 加载器 + pub loader: LoaderKind, +} + +impl Target { + pub(super) fn of(instance: &InstanceSummary) -> Self { + Self { + id: instance.id.clone(), + name: instance.name.clone(), + mc_version: instance.mc_version.clone(), + loader: instance.loader.kind, + } + } + + /// 详情里那一行(`Survival · 1.20.4 · Fabric`)。 + pub(crate) fn label(&self) -> String { + format!( + "{} · {} · {}", + self.name, + self.mc_version, + loader_name(self.loader) + ) + } +} + +/// 下载量加千分位(`1200000` → `1,200,000`):raw 数字在详情面板上读不出量级。 +pub(crate) fn thousands(value: u64) -> String { + let digits = value.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (index, ch) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index).is_multiple_of(3) { + out.push(','); + } + out.push(ch); + } + out +} + +/// 首字母大写(`fabric` → `Fabric`;空串原样)。 +/// +/// **只在画的时候用**:`Hit::loaders` 与兼容性判定都保持站点原样的小写 facet, +/// 否则「Fabric」与 facet「fabric」一比就成了不兼容(本轮踩过)。 +pub(crate) fn capitalize(raw: &str) -> String { + let mut chars = raw.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +/// 加载器的**界面名**(ASCII)。 +/// +/// 不复用 `LoaderKind::label()`:它对原版返回「原版」——中文业务串,而本仓的界面文案在 i18n +/// 之前是英文基线(干净 CI 上没有中文字体,见 `pages/instances/data.rs` 的同一条理由)。 +pub const fn loader_name(loader: LoaderKind) -> &'static str { + match loader { + LoaderKind::Vanilla => "Vanilla", + LoaderKind::Fabric => "Fabric", + LoaderKind::Quilt => "Quilt", + LoaderKind::Forge => "Forge", + LoaderKind::NeoForge => "NeoForge", + LoaderKind::OptiFine => "OptiFine", + LoaderKind::LiteLoader => "LiteLoader", + } +} + +/// 装不上的原因(**一条一个原因**,可以有多条同时成立)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Blocker { + /// 整包类内容不走「单文件进目录」这条路(M111 才有导入管线) + NeedsPipeline, + /// 项目不支持目标实例的游戏版本 + Version { + /// 项目支持的游戏版本(人读,最多取三个) + needs: String, + /// 目标实例的版本 + actual: String, + }, + /// 项目不支持目标实例的加载器 + Loader { + /// 目标实例的加载器 + loader: LoaderKind, + }, + /// 没有目标实例 + NoTarget, +} + +impl Blocker { + /// 一句话原因(i18n)。 + pub(super) fn reason(&self, catalog: Catalog) -> String { + match self { + Self::NeedsPipeline => catalog.text(Key::ContentModpackNotReady).to_string(), + Self::Version { needs, actual } => catalog.render( + Key::ContentIncompatibleVersion, + &[("version", needs), ("actual", actual)], + ), + Self::Loader { loader } => catalog.render( + Key::ContentIncompatibleLoader, + &[("loader", loader_name(*loader))], + ), + Self::NoTarget => catalog.text(Key::ContentNoInstances).to_string(), + } + } +} diff --git a/crates/app-iced/src/pages/content/model/tests.rs b/crates/app-iced/src/pages/content/model/tests.rs new file mode 100644 index 0000000..cd9ab1b --- /dev/null +++ b/crates/app-iced/src/pages/content/model/tests.rs @@ -0,0 +1,274 @@ +//! 内容库视图模型的守门测试(M109):四态判定、兼容性判定、装入闸门。 +//! +//! 全部是纯函数级断言(不开图形环境):`status_of` / `blockers_of` / `Content::can_install` +//! 是页面「该画什么、按钮能不能按」的唯一判据,钉住它们比截图更早期。 + +use super::*; +use unlucky_i18n::Lang; +use unlucky_presenter::dto::{ + ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, InstanceRuntime, + LoaderKind, +}; + +fn hit(versions: &[&str], loaders: &[&str]) -> ContentHit { + ContentHit { + id: "p1".to_string(), + slug: "p".to_string(), + title: "Sodium".to_string(), + summary: "fast".to_string(), + author: "jelly".to_string(), + downloads: 10, + updated: "2026-01-01".to_string(), + source: ContentSource::Modrinth, + game_versions: versions.iter().map(|item| (*item).to_string()).collect(), + loaders: loaders.iter().map(|item| (*item).to_string()).collect(), + icon: None, + } +} + +fn targets() -> Vec { + vec![ + Target { + id: "fabric-1.20.1".to_string(), + name: "Survival".to_string(), + mc_version: "1.20.1".to_string(), + loader: LoaderKind::Fabric, + }, + Target { + id: "forge-1.21.1".to_string(), + name: "Modded".to_string(), + mc_version: "1.21.1".to_string(), + loader: LoaderKind::Forge, + }, + ] +} + +fn blockers_of_hit(hit: &ContentHit, kind: ContentKind, target: Option<&str>) -> Vec { + blockers_of( + Some((hit.game_versions.as_slice(), hit.loaders.as_slice())), + kind, + &targets(), + target, + ) +} + +#[test] +fn a_matching_project_has_no_blockers() { + let hit = hit(&["1.20.1"], &["fabric"]); + assert!(blockers_of_hit(&hit, ContentKind::Mod, Some("fabric-1.20.1")).is_empty()); +} + +#[test] +fn a_version_mismatch_is_reported_before_the_user_clicks() { + // 抓得住:把「版本对不上」留到点了才报错(任务书第 3 条禁止的正是这个)。 + let hit = hit(&["1.20.1"], &["fabric"]); + let blockers = blockers_of_hit(&hit, ContentKind::Mod, Some("forge-1.21.1")); + assert!(blockers.contains(&Blocker::Version { + needs: "1.20.1".to_string(), + actual: "1.21.1".to_string() + })); + assert!(blockers.contains(&Blocker::Loader { + loader: LoaderKind::Forge + })); +} + +#[test] +fn both_mismatches_are_reported_separately() { + // 合成一条会在「都不兼容」时只说一半原因 + let hit = hit(&["1.20.1"], &["fabric"]); + assert_eq!( + blockers_of_hit(&hit, ContentKind::Mod, Some("forge-1.21.1")).len(), + 2 + ); +} + +#[test] +fn site_data_that_is_missing_never_blocks() { + // 站点没给版本/加载器 = 未知,不是「不支持」 + let hit = hit(&[], &[]); + assert!(blockers_of_hit(&hit, ContentKind::Mod, Some("forge-1.21.1")).is_empty()); +} + +#[test] +fn modpacks_are_blocked_by_the_missing_pipeline_not_by_compatibility() { + let hit = hit(&["1.20.1"], &["fabric"]); + assert_eq!( + blockers_of_hit(&hit, ContentKind::Modpack, Some("fabric-1.20.1")), + vec![Blocker::NeedsPipeline] + ); + // 资源包 / 光影 / Mod 都没有这一条 + for kind in [ + ContentKind::Mod, + ContentKind::Resourcepack, + ContentKind::Shader, + ] { + assert!(blockers_of_hit(&hit, kind, Some("fabric-1.20.1")).is_empty()); + } +} + +#[test] +fn no_target_instance_disables_the_button_with_a_reason() { + assert_eq!( + blockers_of(None, ContentKind::Mod, &[], None), + vec![Blocker::NoTarget] + ); + // 有实例但选中项不在名单里(被删了):同样不许静默通过 + assert_eq!( + blockers_of(None, ContentKind::Mod, &targets(), Some("gone")), + vec![Blocker::NoTarget] + ); + // 没选中项目:只剩「目标」与「整包」这两类先天原因 + assert!(blockers_of(None, ContentKind::Mod, &targets(), Some("fabric-1.20.1")).is_empty()); +} + +fn state_with(results: Option, pending: bool) -> AppState { + let mut state = AppState::new(); + state.instances = Vec::new(); + state.content.pending = pending; + state.content.results = results; + state +} + +fn results(hits: Vec, offset: u32) -> ContentResults { + let mut query = ContentQuery::first(ContentKind::Mod); + query.offset = offset; + ContentResults { + query, + hits, + total: 100, + curseforge: false, + } +} + +#[test] +fn the_four_states_are_derived_from_three_fields() { + let page = PageState::default(); + let catalog = Lang::En.catalog(); + + // 还没有结果 ⇒ 加载 + let loading = Content::from_snapshot(&state_with(None, false), &page, catalog); + assert!(loading.is_loading()); + assert_eq!(loading.status, Status::Loading); + + // 请求在飞 ⇒ 加载(即使手里还有上一批) + let pending = Content::from_snapshot( + &state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), true), + &page, + catalog, + ); + assert_eq!(pending.status, Status::Loading); + + // 拉到了但零命中 ⇒ 空 + let empty = Content::from_snapshot( + &state_with(Some(results(Vec::new(), 0)), false), + &page, + catalog, + ); + assert_eq!(empty.status, Status::Empty); + + // 有命中 ⇒ 有数据 + let ready = Content::from_snapshot( + &state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false), + &page, + catalog, + ); + assert_eq!(ready.status, Status::Ready); + assert_eq!(ready.hits.len(), 1); + assert_eq!(ready.hits[0].title, "Sodium"); + assert!(!ready.has_more, "只有 1 条、不够一页 ⇒ 没有下一页"); +} + +#[test] +fn a_failure_outranks_the_loading_state() { + let mut state = state_with(None, true); + state.content.failed = Some("网络不通".to_string()); + let model = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog()); + assert_eq!(model.status, Status::Failed("网络不通".to_string())); + assert!(model.status.failed()); +} + +#[test] +fn can_install_needs_a_selection_and_no_blockers() { + let mut state = state_with(Some(results(vec![hit(&["1.20.1"], &["fabric"])], 0)), false); + state.instances = vec![unlucky_presenter::dto::InstanceSummary { + id: "fabric-1.20.1".to_string(), + name: "Survival".to_string(), + mc_version: "1.20.1".to_string(), + loader: unlucky_presenter::dto::LoaderInfo { + kind: LoaderKind::Fabric, + version: "0.16.5".to_string(), + }, + last_played: None, + runtime: InstanceRuntime::Idle, + icon: None, + isolation: "isolated".to_string(), + version_id: "1.20.1".to_string(), + version_present: true, + }]; + let catalog = Lang::En.catalog(); + let page = PageState { + kind: ContentKind::Mod, + selected: None, + target: None, + draft: String::new(), + }; + + // 没选中项目 ⇒ 不能装(也不该说「成功」) + let no_selection = Content::from_snapshot(&state, &page, catalog); + assert!(!no_selection.can_install()); + assert_eq!( + no_selection.target.as_deref(), + Some("fabric-1.20.1"), + "只有一个实例就默认选它" + ); + + // 选中兼容项 ⇒ 能装 + let page = PageState { + selected: Some("p1".to_string()), + ..page + }; + let compatible = Content::from_snapshot(&state, &page, catalog); + assert!(compatible.can_install()); + assert!(compatible.blocker_reasons(catalog).is_empty()); + assert!(compatible.target_label.is_some()); +} + +#[test] +fn the_panel_only_shows_while_an_install_is_unfinished() { + use unlucky_presenter::dto::{InstallProgressView, InstallStage}; + + let mut state = state_with(None, false); + state.install = Some(InstallProgressView { + version: "Sodium".to_string(), + stage: InstallStage::Downloading, + files_total: 1, + ..InstallProgressView::new() + }); + let running = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog()); + assert!(running.panel.is_some(), "下载中要有面板"); + + state.install = Some(InstallProgressView { + stage: InstallStage::Done, + ..InstallProgressView::new() + }); + let done = Content::from_snapshot(&state, &PageState::default(), Lang::En.catalog()); + assert!(done.panel.is_none(), "装完的面板不该钉在页面上"); +} + +#[test] +fn loader_names_are_ascii_so_the_english_baseline_stays_english() { + for loader in [ + LoaderKind::Vanilla, + LoaderKind::Fabric, + LoaderKind::Forge, + LoaderKind::NeoForge, + LoaderKind::Quilt, + ] { + assert!(loader_name(loader).is_ascii(), "{loader:?}"); + } + assert_eq!( + loader_name(LoaderKind::Vanilla), + "Vanilla", + "不能画「原版」" + ); +} diff --git a/crates/app-iced/src/pages/content/parts.rs b/crates/app-iced/src/pages/content/parts.rs new file mode 100644 index 0000000..512cb92 --- /dev/null +++ b/crates/app-iced/src/pages/content/parts.rs @@ -0,0 +1,333 @@ +//! 内容库页的零件:筛选条 / 结果列表 / 详情面板 / 四态(加载 / 空 / 失败 / 无更多)/ 一级切换。 +//! +//! 全是既有真组件(`chip` / `list_item` / `buttons` / `empty_state`),页面不自己画状态层。 +//! +//! ## 消息口径 +//! +//! 本文件的每个控件都直接发 [`crate::pages::Message`]:内容库自己的意图包一层 +//! `PageMessage::Content(..)`([`intent`]),而「取消安装 / 打开日志」用的是**下载页就有的** +//! 那两个变体——因为对用户来说它们是同一件事(同一个 `AppCommand::CancelInstall`)。 + +use iced::widget::{column, container, row, scrollable}; +use iced::{Alignment, Element, Length, Padding}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::ContentSort; +use unlucky_presenter::router::Route; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::feedback::{empty_state, EmptyState}; +use crate::app::components::navigation::list_item::{list_item, ListItem}; +use crate::app::components::selection::{chip, Chip}; +use crate::theme::Ui; + +use super::layout::Rails; +use super::model::{Content, Hit, Status}; + +mod detail; +mod filters; + +pub(crate) use detail::detail; +pub(crate) use filters::{filters, labelled}; + +use super::Message as ContentMessage; +use crate::pages::frame; +use crate::pages::Message as PageMessage; + +/// 内容库意图 → 页面消息(唯一的一层包装)。 +pub(super) fn intent(message: ContentMessage) -> PageMessage { + PageMessage::Content(message) +} + +/// 结果行与行之间的间距(与下载页同值)。 +const ROW_GAP: Space = Space::X1; + +/// 一级切换:版本管理 | 内容库(M109 按 B1 裁定:共用同一条导航轨项,页内切换)。 +pub(super) fn section_switcher<'a>( + ui: &'a Ui, + catalog: Catalog, + on_content: bool, +) -> Element<'a, PageMessage> { + let versions = Chip::filter( + catalog.text(Key::ContentSectionVersions), + !on_content, + PageMessage::Navigate(Route::Download), + ); + let content = Chip::filter( + catalog.text(Key::ContentSectionContent), + on_content, + PageMessage::Navigate(Route::Content), + ); + row![chip(ui, versions), chip(ui, content)] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .into() +} + +/// 关键词搜索条(输入只改草稿;回车/按钮才发检索指令)。 +pub(super) fn search<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Content, +) -> Element<'a, PageMessage> { + // 输入只改草稿,**提交**才发检索(每敲一个字就打一次网络既没必要也会被限流)。 + // 搜索条自身没有提交回调,所以旁边给一个显式的「搜索」按钮——这也是键盘外的唯一出口。 + let bar = crate::app::components::inputs::search_bar( + ui, + crate::app::components::inputs::SearchBar::new(model.draft.as_str()) + .placeholder(catalog.text(Key::ContentSearchPlaceholder)), + move |text| intent(ContentMessage::QueryChanged(text)), + ); + let submit = crate::app::components::buttons::text( + ui, + crate::app::components::buttons::Button::new( + catalog.text(Key::ContentSearch), + intent(ContentMessage::Search), + ) + .leading(IconKind::Search), + ); + row![bar, submit] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .width(Length::Fill) + .into() +} + +/// 结果列表(独立滚动;底部「加载更多」钉在列表之外,见 [`more`])。 +pub(super) fn list<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Content, + rails: Rails, +) -> Element<'a, PageMessage> { + let rows = column( + model + .hits + .iter() + .map(|hit| hit_row(ui, model, hit)) + .collect::>(), + ) + .spacing(ui.space(ROW_GAP)) + .padding(Padding::from([0.0, ui.space(Space::X2)])) + .width(Length::Fill); + + let _ = catalog; + container( + scrollable(rows) + .direction(scrollable::Direction::Vertical( + scrollable::Scrollbar::new() + .width(frame::SCROLLBAR_WIDTH) + .scroller_width(frame::SCROLLBAR_WIDTH) + .margin(frame::SCROLLBAR_MARGIN), + )) + .height(Length::Fill) + .style(frame::scroll_style(ui)), + ) + .width(Length::Fixed(rails.list)) + .height(Length::Fill) + .into() +} + +fn hit_row<'a>(ui: &'a Ui, model: &Content, hit: &'a Hit) -> Element<'a, PageMessage> { + let mut item = ListItem::new( + hit.title.as_str(), + intent(ContentMessage::Select(hit.id.clone())), + ) + .overline(hit.supporting.as_str()) + .supporting(hit.summary.as_str()) + .leading(kind_icon(model.kind)); + if model.selected.as_deref() == Some(hit.id.as_str()) { + item = item.selected(); + } + list_item(ui, item) +} + +/// 计数头 + 翻页尾(分页而非无限滚动:见 `pages/content.rs` 的模块文档)。 +pub(super) fn count_head<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &Content, +) -> Element<'a, PageMessage> { + let count = if model.total > 0 { + model.total + } else { + u32::try_from(model.hits.len()).unwrap_or(u32::MAX) + }; + frame::text_role_owned( + ui, + TypeRole::LabelMedium, + false, + catalog.render( + Key::ContentCount, + &[("count", &super::model::thousands(u64::from(count)))], + ), + ui.color(Role::OnSurfaceVariant), + ) +} + +/// 列表尾部:还有更多 ⇒「加载更多」;没有了 ⇒ 一句「没有更多了」。 +pub(super) fn more<'a>(ui: &'a Ui, catalog: Catalog, model: &Content) -> Element<'a, PageMessage> { + if model.has_more { + return buttons::text( + ui, + Button::new( + catalog.text(Key::ContentLoadMore), + intent(ContentMessage::LoadMore), + ) + .leading(IconKind::ChevronDown), + ); + } + frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentNoMore), + ui.color(Role::OnSurfaceVariant), + ) +} + +pub(super) fn loading<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { + centered( + column![ + frame::text_role_centered( + ui, + TypeRole::TitleMedium, + true, + catalog.text(Key::ContentLoading), + ui.color(Role::OnSurface) + ), + frame::text_role_centered( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentLoadingHint), + ui.color(Role::OnSurfaceVariant) + ), + buttons::text( + ui, + Button::new( + catalog.text(Key::ContentRetry), + intent(ContentMessage::Retry) + ) + ), + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .align_x(Alignment::Center), + ) +} + +/// 空态(真 `EmptyState`:CTA 是标准 Filled 按钮)。 +pub(super) fn empty<'a>(ui: &'a Ui, catalog: Catalog) -> Element<'a, PageMessage> { + centered(empty_state( + ui, + EmptyState::new( + IconKind::Search, + catalog.text(Key::ContentEmptyTitle), + catalog.text(Key::ContentEmptyBody), + ) + .action( + catalog.text(Key::ContentRetry), + intent(ContentMessage::Retry), + ), + )) +} + +/// 失败态(带原因与重试出口;原因来自 host,不打码不打折)。 +pub(super) fn failed<'a>( + ui: &'a Ui, + catalog: Catalog, + reason: &'a str, +) -> Element<'a, PageMessage> { + centered( + column![ + row![ + frame::glyph(IconKind::Error, ui.color(Role::Error), frame::ICON_PX), + frame::text_role( + ui, + TypeRole::TitleSmall, + true, + catalog.text(Key::ContentFailedTitle), + ui.color(Role::Error) + ), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + reason, + ui.color(Role::OnSurfaceVariant) + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentFailedBody), + ui.color(Role::OnSurfaceVariant) + ), + buttons::filled( + ui, + Button::new( + catalog.text(Key::ContentRetry), + intent(ContentMessage::Retry) + ) + .leading(IconKind::Refresh) + ), + ] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .align_x(Alignment::Center), + ) +} + +fn centered<'a>(content: impl Into>) -> Element<'a, PageMessage> { + container(content) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .into() +} + +/// 内容类型 → 图标(**借用**现有字形:图标集里还没有「整合包/Mod/资源包/光影」四枚专用图标, +/// 已在报告里登记为偏离)。 +pub(super) const fn kind_icon(kind: unlucky_presenter::dto::ContentKind) -> IconKind { + match kind { + unlucky_presenter::dto::ContentKind::Modpack => IconKind::Folder, + unlucky_presenter::dto::ContentKind::Mod => IconKind::Instances, + unlucky_presenter::dto::ContentKind::Resourcepack => IconKind::Download, + // `Play`/`Stop`/`More` 是**填充**字形,pages 里禁用(tests/no_hardcoded_tokens): + // 光影借用 `Expand`(描边的四散光线),语义最近又不撞语汇 + unlucky_presenter::dto::ContentKind::Shader => IconKind::Expand, + } +} + +pub(super) fn sort_label(sort: ContentSort, catalog: Catalog) -> &'static str { + match sort { + ContentSort::Relevance => catalog.text(Key::ContentSortRelevance), + ContentSort::Downloads => catalog.text(Key::ContentSortDownloads), + ContentSort::Newest => catalog.text(Key::ContentSortNewest), + ContentSort::Updated => catalog.text(Key::ContentSortUpdated), + } +} + +/// 状态分派(四态各一支;`Ready` 由调用方画列表 + 详情)。 +pub(super) fn status_body<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Content, +) -> Option> { + match &model.status { + Status::Loading => Some(loading(ui, catalog)), + Status::Empty => Some(empty(ui, catalog)), + Status::Failed(reason) => Some(failed(ui, catalog, reason.as_str())), + Status::Ready => None, + } +} diff --git a/crates/app-iced/src/pages/content/parts/detail.rs b/crates/app-iced/src/pages/content/parts/detail.rs new file mode 100644 index 0000000..5d616a1 --- /dev/null +++ b/crates/app-iced/src/pages/content/parts/detail.rs @@ -0,0 +1,179 @@ +//! 详情面板:选中项的支持范围 + 目标实例 + 装入(不兼容时**禁用并逐条给原因**)。 +//! +//! 从 `pages/content/parts.rs` 拆出(那个文件已贴 400 行硬上限)。 +//! +//! 这块是「一键装入指定实例」的**唯一**入口:按钮能不能按由 [`Content::can_install`] 决定, +//! 按不动时旁边逐条列出原因——判定与画法分开,`model/tests.rs` 才能不开图形环境就钉住它。 + +use iced::widget::{column, container}; +use iced::{Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::{Catalog, Key}; + +use crate::app::components::buttons::{self, Button}; +use crate::app::components::divider; +use crate::app::components::selection::{chip, Chip}; +use crate::theme::Ui; + +use super::super::model::{capitalize, Content}; +use super::super::Message as ContentMessage; +use super::{intent, labelled}; +use crate::pages::frame; +use crate::pages::Message as PageMessage; + +/// 详情面板:选中项的支持范围 + 目标实例 + 装入(不兼容时**禁用并给原因**)。 +pub(crate) fn detail<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Content, +) -> Element<'a, PageMessage> { + let Some(hit) = model.selected_hit() else { + return container(frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentDetailEmpty), + ui.color(Role::OnSurfaceVariant), + )) + .width(Length::Fill) + .into(); + }; + + let head = column![ + frame::text_role( + ui, + TypeRole::TitleMedium, + true, + hit.title.as_str(), + ui.color(Role::OnSurface) + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + hit.supporting.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + frame::text_role( + ui, + TypeRole::BodySmall, + false, + hit.summary.as_str(), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + let meta = column![ + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + hit.downloads.as_str(), + ui.color(Role::OnSurfaceVariant) + ), + frame::text_role( + ui, + TypeRole::LabelMedium, + false, + hit.updated.as_str(), + ui.color(Role::OnSurfaceVariant) + ), + support_line(ui, catalog, Key::ContentDetailVersions, &hit.versions), + support_line(ui, catalog, Key::ContentDetailLoaders, &hit.loaders), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + + let mut body = column![head, divider::full(ui), meta].spacing(ui.space(Space::X3)); + + // 目标实例:一行 chip(选中的那个高亮) + if model.targets.is_empty() { + body = body.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentNoInstances), + ui.color(Role::OnSurfaceVariant), + )); + } else { + let mut targets = Vec::new(); + for target in &model.targets { + targets.push(chip( + ui, + Chip::filter( + target.name.as_str(), + model.target.as_deref() == Some(target.id.as_str()), + intent(ContentMessage::SetTarget(target.id.clone())), + ), + )); + } + body = body.push(labelled( + ui, + catalog.text(Key::ContentTargetInstance), + targets, + )); + } + + // 装入:能装 ⇒ 实心按钮;不能 ⇒ 禁用 + 逐条原因(**点之前**就说清楚) + if model.can_install() { + body = body.push(buttons::filled( + ui, + Button::new( + catalog.text(Key::ContentInstall), + intent(ContentMessage::Install), + ) + .leading(IconKind::Download), + )); + } else { + body = body.push(buttons::outlined( + ui, + Button::disabled(catalog.text(Key::ContentInstall)), + )); + for reason in model.blocker_reasons(catalog) { + body = body.push(frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + reason, + ui.color(Role::OnSurfaceVariant), + )); + } + } + + container(body.spacing(ui.space(Space::X3))) + .width(Length::Fill) + .into() +} + +/// 「游戏版本:1.20.1, 1.20.4」一行(空列表给 `—`,不编造)。 +fn support_line<'a>( + ui: &'a Ui, + catalog: Catalog, + key: Key, + values: &[String], +) -> Element<'a, PageMessage> { + let list = if values.is_empty() { + "—".to_string() + } else { + // 加载器是站点给的小写 facet(`fabric`):这一层只管怎么显示 + values + .iter() + .map(|value| capitalize(value)) + .collect::>() + .join(", ") + }; + frame::text_role_owned( + ui, + TypeRole::BodySmall, + false, + catalog.render(key, &[("list", &list)]), + ui.color(Role::OnSurfaceVariant), + ) +} diff --git a/crates/app-iced/src/pages/content/parts/filters.rs b/crates/app-iced/src/pages/content/parts/filters.rs new file mode 100644 index 0000000..6e1a9f7 --- /dev/null +++ b/crates/app-iced/src/pages/content/parts/filters.rs @@ -0,0 +1,163 @@ +//! 筛选条:来源 / 排序 / 游戏版本 / 加载器四组 chip,外加分组标签与换行容器。 +//! +//! 从 `pages/content/parts.rs` 拆出(那个文件已贴 400 行硬上限)。 +//! +//! 缺 key 的源(CurseForge)画成**禁用**而不是消失:筛选条要能被发现,也要能解释自己。 + +use iced::widget::{column, container, row}; +use iced::{Alignment, Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::{loader_facet, ContentSort, ContentSource, LoaderKind}; + +use crate::app::components::selection::{chip, Chip}; +use crate::theme::Ui; + +use super::super::model::{loader_name, Content}; +use super::super::Message as ContentMessage; +use super::{intent, sort_label}; +use crate::pages::frame; +use crate::pages::Message as PageMessage; + +/// 来源 + 排序 + 版本 + 加载器四组 chip(一行放不下就换行,`row` 变 `column`)。 +pub(crate) fn filters<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Content, +) -> Element<'a, PageMessage> { + let mut source = Vec::new(); + for item in ContentSource::ALL { + let available = match item { + ContentSource::Modrinth => true, + ContentSource::Curseforge => model.curseforge, + }; + let label = match item { + ContentSource::Modrinth => catalog.text(Key::ContentSourceModrinth), + ContentSource::Curseforge => catalog.text(Key::ContentSourceCurseforge), + }; + let mut spec = Chip::filter( + label, + model.query.source == item, + intent(ContentMessage::SetSource(item)), + ); + if !available { + // 缺 key 的源:**禁用并说明**,不是静默消失(否则用户找不到它) + spec = spec.disabled(); + } + source.push(chip(ui, spec)); + } + + let mut sort = Vec::new(); + for item in ContentSort::ALL { + sort.push(chip( + ui, + Chip::filter( + sort_label(item, catalog), + model.query.sort == item, + intent(ContentMessage::SetSort(item)), + ), + )); + } + + let mut versions = vec![chip( + ui, + Chip::filter( + catalog.text(Key::ContentFilterAny), + model.query.version.is_none(), + intent(ContentMessage::SetVersion(None)), + ), + )]; + for version in &model.versions { + versions.push(chip( + ui, + Chip::filter( + version.as_str(), + model.query.version.as_deref() == Some(version.as_str()), + intent(ContentMessage::SetVersion(Some(version.clone()))), + ), + )); + } + + let mut loaders = vec![chip( + ui, + Chip::filter( + catalog.text(Key::ContentFilterAny), + model.query.loader.is_none(), + intent(ContentMessage::SetLoader(None)), + ), + )]; + for loader in [ + LoaderKind::Fabric, + LoaderKind::Forge, + LoaderKind::NeoForge, + LoaderKind::Quilt, + ] { + let usable = loader_facet(loader).is_some(); + let mut spec = Chip::filter( + loader_name(loader), + model.query.loader == Some(loader), + intent(ContentMessage::SetLoader(Some(loader))), + ); + if !usable { + spec = spec.disabled(); + } + loaders.push(chip(ui, spec)); + } + + let mut column = column![ + labelled(ui, catalog.text(Key::ContentFilterVersion), versions), + labelled(ui, catalog.text(Key::ContentFilterLoader), loaders), + wrapped_row(ui, source), + wrapped_row(ui, sort), + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill); + + if !model.curseforge { + column = column.push(frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::ContentSourceUnavailable), + ui.color(Role::OnSurfaceVariant), + )); + } + column.into() +} + +/// 分组标签 + 一行 chip(标签左、chip 右,纵向居中对齐)。 +pub(crate) fn labelled<'a>( + ui: &'a Ui, + label: &'a str, + chips: Vec>, +) -> Element<'a, PageMessage> { + row![ + container(frame::text_role( + ui, + TypeRole::LabelMedium, + false, + label, + ui.color(Role::OnSurfaceVariant), + )), + wrapped_row(ui, chips), + ] + .spacing(ui.space(Space::X2)) + .align_y(Alignment::Center) + .width(Length::Fill) + .into() +} + +/// `Vec` → 一行(顺序就是声明顺序)。 +pub(crate) fn wrapped_row<'a>( + ui: &'a Ui, + chips: Vec>, +) -> Element<'a, PageMessage> { + row(chips) + .spacing(ui.space(Space::X1)) + .align_y(Alignment::Center) + .into() +} diff --git a/crates/app-iced/src/pages/content/tests.rs b/crates/app-iced/src/pages/content/tests.rs new file mode 100644 index 0000000..e4dcb08 --- /dev/null +++ b/crates/app-iced/src/pages/content/tests.rs @@ -0,0 +1,87 @@ +//! 内容库页的守门(M109):演示数据、四类内容、页面自留状态的默认值。 +//! +//! 视图模型的判定在 `model/tests.rs`,栏几何在 `layout.rs`,这里只管「演示数据真的装得满」—— +//! 出图靠它,装不满就说明 `demo()` 与 `Content` 的字段对不上。 + +use unlucky_i18n::Lang; +use unlucky_presenter::dto::ContentKind; + +use super::model::{PageState, Status}; +use super::{demo, Content}; + +#[test] +fn the_page_state_starts_on_mods_with_nothing_selected() { + let page = PageState::default(); + assert_eq!(page.kind, ContentKind::Mod); + assert!(page.selected.is_none()); + assert!(page.target.is_none()); + assert!(page.draft.is_empty()); +} + +#[test] +fn every_kind_gets_a_full_demo_screen() { + // 四种内容各出一屏(状态矩阵的前 8 张靠这一条) + for kind in ContentKind::ALL { + let model: Content = demo(kind, Lang::En); + assert_eq!(model.kind, kind, "{} 的演示数据换了类型", kind.slug()); + assert_eq!(model.status, Status::Ready, "{}", kind.slug()); + assert_eq!(model.hits.len(), 4, "{} 的命中项不足", kind.slug()); + assert!( + model.selected.is_some(), + "演示数据要选中一条(详情面板才有内容)" + ); + assert!(model.total > 0); + assert!(model.has_more, "演示数据要能画「加载更多」"); + // 整合包**不**在这一条里断(它天生不可装:`ContentKind::Modpack.dir()` 是 `None`), + // 由下一条用例专钉。 + if kind != ContentKind::Modpack { + assert!( + model.can_install(), + "默认那一屏应当是「能装」的({})", + kind.slug() + ); + } + } +} + +#[test] +fn the_modpack_screen_is_browsable_but_never_installable() { + // 整合包:检索能看,装入一律禁用(M111 未落地)——演示图上必须看得到原因 + let model = demo(ContentKind::Modpack, Lang::En); + assert!(model.selected.is_some()); + assert!(!model.can_install()); + assert_eq!( + model.blocker_reasons(Lang::En.catalog()).len(), + 1, + "整合包只有一条原因(导入管线未落地)" + ); +} + +#[test] +fn the_installable_kinds_block_only_when_the_target_disagrees() { + for kind in [ + ContentKind::Mod, + ContentKind::Resourcepack, + ContentKind::Shader, + ] { + let model = demo(kind, Lang::En); + assert!( + model.blocker_reasons(Lang::En.catalog()).is_empty(), + "{} 的默认演示项应当可装", + kind.slug() + ); + } +} + +#[test] +fn the_demo_selection_is_always_one_of_the_hits() { + for kind in ContentKind::ALL { + let model = demo(kind, Lang::En); + let selected = model.selected.clone().expect("有选中项"); + assert!( + model.hits.iter().any(|hit| hit.id == selected), + "{} 的选中项不在命中列表里(详情面板会是空的)", + kind.slug() + ); + } +} diff --git a/crates/app-iced/src/pages/content/view.rs b/crates/app-iced/src/pages/content/view.rs new file mode 100644 index 0000000..490d67b --- /dev/null +++ b/crates/app-iced/src/pages/content/view.rs @@ -0,0 +1,222 @@ +//! 内容库页的整页装配:页头(含一级切换)+ 左侧类型栏 + 主列(筛选/结果)+ 详情 + 安装面板。 +//! +//! ## 版面(几何在 [`super::layout`],这里只消费结果) +//! +//! ```text +//! ┌─ 页头:Content [版本管理 | 内容库] ─┐ +//! ├──────┬────────────────────────┬──────────────┤ +//! │ 类型 │ 搜索 + 四组 chip │ 详情 / 装入 │ +//! │ 四条 │ 计数头 │ 原因逐条列出 │ +//! │ 侧栏 │ 结果列表(独立滚动) │ │ +//! │ │ 加载更多 / 没有更多了 │ │ +//! ├──────┴────────────────────────┴──────────────┤ +//! │ 安装面板(**下载页那块**,只在有安装时出现) │ +//! └──────────────────────────────────────────────┘ +//! ``` +//! +//! ## 为什么类型栏用 `list_item`(与 M107 的版本类型栏同一套) +//! +//! 四条内容类型是**一级侧栏**(页面内导航),几何与状态层必须与版本管理页一致:`list_item` +//! 自带 60 高两行档与选中态,页面不自己写 hover/pressed。窄档(`Tight`)退成一行 chip—— +//! 栏宽没了就不该硬塞一条 140px 的栏。 + +use iced::widget::{column, container, row}; +use iced::{Element, Length}; + +use unlucky_design::spacing::Space; + +use unlucky_i18n::{Catalog, Key}; +use unlucky_presenter::dto::ContentKind; + +use crate::app::components::divider; +use crate::app::components::navigation::list_item::{list_item, ListItem}; +use crate::app::components::selection::{chip, Chip}; +use crate::theme::Ui; + +use crate::pages::download; +use crate::pages::frame::{self, Header}; +use crate::pages::layout::FrameMetrics; +use crate::pages::Message as PageMessage; + +use super::layout::{self as content_layout, Rails, Tier}; +use super::model::{Content, Status}; +use super::parts; +use super::Message as ContentMessage; + +/// 整页入口(由 `pages/mod.rs` 的 `render` 调;页面层不认识 presenter 类型)。 +pub(super) fn view<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + model: &'a Content, + lang: unlucky_i18n::Lang, +) -> Element<'a, PageMessage> { + let catalog = lang.catalog(); + let header = Header::new(catalog.text(Key::ContentTitle)) + .subtitle(catalog.text(Key::ContentSubtitle)) + .action(parts::section_switcher(ui, catalog, true)); + frame::page(ui, metrics, header, body(ui, metrics, catalog, model)) +} + +fn body<'a>( + ui: &'a Ui, + metrics: FrameMetrics, + catalog: Catalog, + model: &'a Content, +) -> Element<'a, PageMessage> { + let rails = content_layout::resolve(metrics.available); + + let mut columns = row![kind_rail(ui, catalog, model, rails)] + .spacing(rails.gap) + .width(Length::Fill) + .height(Length::Fill); + + if rails.tier == Tier::Tight { + // 窄到放不下侧栏:类型条跑到主列上方(同一个 `kind_rail`,chip 形态) + let main = column![ + kind_rail(ui, catalog, model, rails), + divider::full(ui), + main_column(ui, catalog, model, rails) + ] + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .height(Length::Fill); + columns = row![main].width(Length::Fill).height(Length::Fill); + } else { + columns = columns.push(main_column(ui, catalog, model, rails)); + if rails.tier.has_detail() { + columns = columns.push(detail_column(ui, catalog, model, rails)); + } + } + + let mut stack = column![columns] + .spacing(ui.space(Space::X3)) + .width(Length::Fill) + .height(Length::Fill); + if let Some(panel) = &model.panel { + // **同一块安装面板**(下载页那个函数):内容库不另画一套进度/失败/取消。 + stack = stack + .push(divider::full(ui)) + .push(download::install_panel(ui, catalog, panel)); + } + stack.into() +} + +/// 左侧四条内容类型(`Wide`/`Compact` 是列表项,`Narrow` 是图标项,`Tight` 是一行 chip)。 +fn kind_rail<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Content, + rails: Rails, +) -> Element<'a, PageMessage> { + if rails.tier == Tier::Tight { + let mut chips = Vec::new(); + for kind in ContentKind::ALL { + chips.push(chip( + ui, + Chip::filter( + kind_label(kind, catalog), + model.kind == kind, + PageMessage::Content(ContentMessage::SetKind(kind)), + ), + )); + } + return row(chips) + .spacing(ui.space(Space::X2)) + .width(Length::Fill) + .into(); + } + + let labels = rails.tier.rail_has_labels(); + let mut items = column(Vec::new()) + .spacing(ui.space(Space::X1)) + .width(Length::Fill); + for kind in ContentKind::ALL { + let label = kind_label(kind, catalog); + // 图标档没有文字位:标签仍传(无障碍/日志用),宽度由几何裁掉 + let mut item = ListItem::new( + if labels { label } else { " " }, + PageMessage::Content(ContentMessage::SetKind(kind)), + ) + .leading(parts::kind_icon(kind)); + if model.kind == kind { + item = item.selected(); + } + items = items.push(list_item(ui, item)); + } + + container(items) + .width(Length::Fixed(rails.rail)) + .height(Length::Fill) + .into() +} + +/// 主列:搜索条 + 筛选 + 计数 + 结果(或四态之一)。 +fn main_column<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Content, + rails: Rails, +) -> Element<'a, PageMessage> { + // 主列的横向内边距不在这里加:`rails.list` 已经把可用宽算干了,再加 2×X2 会让整排 + // 溢出 32px(详情列被切掉一半,复审截图上正是这条) + let mut head = column![parts::search(ui, catalog, model)] + .spacing(ui.space(Space::X2)) + .width(Length::Fill); + + // 筛选条**永远在**:它是控制面,不是数据。加载中把筛选条藏起来,用户就没法在慢网络下 + // 换条件或换类型(只能干等),这是「加载态顺手把入口收走」的经典坑。 + head = head.push(parts::filters(ui, catalog, model)); + head = head.push(divider::full(ui)); + if model.status == Status::Ready { + // 计数头只在有数据时出现:0 条的「0 results」和空态说的是同一件事,画两遍是噪音 + head = head.push(parts::count_head(ui, catalog, model)); + } + + let body: Element<'a, PageMessage> = if let Some(state) = parts::status_body(ui, catalog, model) + { + state + } else { + { + let mut list = column![parts::list(ui, catalog, model, rails)] + .width(Length::Fill) + .height(Length::Fill); + // 紧凑档没有独立详情列:详情压在列表下面(整页替换的成本比「多一块」高) + if !rails.tier.has_detail() { + list = list + .push(divider::full(ui)) + .push(parts::detail(ui, catalog, model)); + } + list.push(parts::more(ui, catalog, model)).into() + } + }; + + column![head, body] + .spacing(ui.space(Space::X2)) + .width(Length::Fixed(rails.list)) + .height(Length::Fill) + .into() +} + +/// 详情列(只在 `Wide` / `Narrow` 存在)。 +fn detail_column<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Content, + rails: Rails, +) -> Element<'a, PageMessage> { + container(parts::detail(ui, catalog, model)) + .width(Length::Fixed(rails.detail)) + .height(Length::Fill) + .style(move |_theme| container::Style::default()) + .into() +} + +/// 四种内容类型的界面名。 +fn kind_label(kind: ContentKind, catalog: Catalog) -> &'static str { + match kind { + ContentKind::Modpack => catalog.text(Key::ContentKindModpack), + ContentKind::Mod => catalog.text(Key::ContentKindMod), + ContentKind::Resourcepack => catalog.text(Key::ContentKindResourcepack), + ContentKind::Shader => catalog.text(Key::ContentKindShader), + } +} diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index aa19d9e..d0e85dd 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -109,6 +109,38 @@ pub use demo::demo; pub use model::{cancelled_toast, Category, Download}; pub use options::{InstallDraft, Loader}; +// M109:内容库页复用**同一块**安装面板(同一个函数、同一套文案与几何),不另写一套。 +// +// 两个薄包装为什么必须在**这个文件**里:①`install::panel` 是 `pub(super)`(只在 `pages::download` +// 可见),再导出到 crate 会撞 `E0364`;②面板的入参是 `&Download`,而 `InstallView` 是页面私有 +// 类型(M105 起不再从本模块导出),所以「快照 → 面板形状」的转换也留在这里——内容库只拿到 +// `Option`,它不需要也不该认识 `InstallView`。 +pub(crate) fn install_panel<'a>( + ui: &'a Ui, + catalog: Catalog, + model: &'a Download, +) -> Element<'a, Message> { + install::panel(ui, catalog, model) +} + +/// 快照 → 面板要的 `Download` 形状(**只填 `install`**)。 +/// +/// 「装完 / 已取消」这类没有动作可给的终态返回 `None`(与下载页同一个判断 +/// `InstallView::finished`):留着只会让面板停在一个点不动的状态上。 +pub(crate) fn install_panel_model( + progress: &unlucky_presenter::dto::InstallProgressView, + catalog: Catalog, +) -> Option { + let view = model::InstallView::of(progress, catalog); + if view.finished() { + return None; + } + Some(Download { + install: Some(view), + ..Download::loading() + }) +} + use iced::widget::column; use iced::{Element, Length}; @@ -120,6 +152,7 @@ use crate::app::components::buttons::{self, Button}; use crate::app::components::divider; use crate::theme::Ui; +use super::content; use super::frame::{self, Header}; use super::layout::{FrameMetrics, VersionRails}; use super::{Action, Message}; @@ -138,6 +171,9 @@ pub fn view<'a>( let header = Header::new(catalog.text(Key::DownloadTitle)) .subtitle(catalog.text(Key::DownloadSubtitle)) + // M109:一级切换「版本管理 | 内容库」。两页共用同一条导航轨项(`NavSection::Download`), + // 所以这个切换是进内容库的唯一产品入口——少了它,内容库只能靠 `ULL_START_ROUTE` 到达。 + .action(content::section_switcher(ui, catalog)) .action(buttons::text( ui, Button::new( diff --git a/crates/app-iced/src/pages/message.rs b/crates/app-iced/src/pages/message.rs index a8d0df4..f33f5a7 100644 --- a/crates/app-iced/src/pages/message.rs +++ b/crates/app-iced/src/pages/message.rs @@ -11,6 +11,7 @@ use unlucky_i18n::LangChoice; use unlucky_presenter::router::Route; use super::appearance; +use super::content; use super::download::{Category, Loader}; use super::settings; @@ -85,6 +86,11 @@ pub enum Message { /// (`app/update/settings/language_appearance.rs`)是 language+appearance 线的文件。 /// 代价是本文件多一个变体;收口方向写在 `settings/general.rs` 的文档里。 Language(LangChoice), + /// 内容库页的一组消息(**M109**):与设置页同一个范式(页面自己的枚举 + 一个投递口)。 + /// + /// 加一条内容库消息只动 `pages/content/message.rs`,不动本枚举的正文,也不动 + /// `app/update/page_messages.rs` 那条不许 `_ =>` 的 match。 + Content(content::Message), } /// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。 diff --git a/crates/app-iced/src/pages/mod.rs b/crates/app-iced/src/pages/mod.rs index c03ce8c..5fc809d 100644 --- a/crates/app-iced/src/pages/mod.rs +++ b/crates/app-iced/src/pages/mod.rs @@ -109,6 +109,7 @@ pub mod harness; pub mod layout; pub mod model; +pub mod content; mod download; mod instances; mod logs; @@ -213,6 +214,8 @@ fn render<'a>( Screen::Instances => instances::view(ui, metrics, model), // M106:下载页此前写死成 `Lang::En`,现在跟 `model.lang`(页面文案跟着界面语言走)。 Screen::Download => download::view(ui, metrics, &model.download, model.lang), + // M109:内容库页同样跟 `model.lang` + Screen::Content => content::view(ui, metrics, &model.content, model.lang), Screen::Settings => settings::view(ui, metrics, states, language), Screen::Logs => logs::view(ui, metrics, &model.logs, model.lang), } @@ -272,6 +275,7 @@ pub fn demo_model(screen: Screen, window_width: f32) -> Model { match screen { Screen::Instances => model.instances = instances::demo(), Screen::Download => model.download = download::demo(unlucky_i18n::Lang::En.catalog()), + Screen::Content => model.content = content::demo(content::ContentKind::Mod, model.lang), Screen::Logs => model.logs = logs::demo(model.lang), Screen::Settings => {} } diff --git a/crates/app-iced/src/pages/model.rs b/crates/app-iced/src/pages/model.rs index 293d824..fdde68d 100644 --- a/crates/app-iced/src/pages/model.rs +++ b/crates/app-iced/src/pages/model.rs @@ -19,6 +19,7 @@ use unlucky_presenter::router::NavSection; use crate::app::components::interaction::Interaction; +use super::content::Content; use super::logs::Logs; use super::{Download, Screen}; @@ -89,6 +90,8 @@ pub struct Model { pub download: Download, /// 日志页的数据(M95:presenter 快照 → 日志行 + 诊断结论,见 [`super::logs`])。 pub logs: Logs, + /// 内容库页的数据(M109:检索条件 / 命中项 / 装入闸门,见 [`super::content`])。 + pub content: Content, /// 界面语言(M106):页面文案走 i18n key,语言由 app 层决定后灌进来。 /// /// 谁填:`app/state.rs` 的 `App::refresh_view`(`LangState::lang()` → `view::model(..)`), @@ -112,6 +115,8 @@ impl Model { instances: Instances::default(), download: Download::loading(), logs: Logs::default(), + // 演示模型也从这个空壳起步(`demo_model` 再按页面填数据) + content: super::content::demo(unlucky_presenter::dto::ContentKind::Mod, Lang::En), lang: Lang::En, forced: None, } diff --git a/crates/app-iced/src/pages/screen.rs b/crates/app-iced/src/pages/screen.rs index 34b8e5d..aa3d440 100644 --- a/crates/app-iced/src/pages/screen.rs +++ b/crates/app-iced/src/pages/screen.rs @@ -14,6 +14,8 @@ pub enum Screen { Instances, /// **版本管理**(原下载页;单列页,但正文自己切三栏:类型 / 主版本 / 列表)。 Download, + /// 内容库(M109):整合包 / Mod / 资源包 / 光影。 + Content, /// 设置(supporting-pane)。 Settings, /// 日志(单列,两行页头)。 @@ -22,9 +24,10 @@ pub enum Screen { impl Screen { /// 全部页面(截图矩阵按这个顺序铺)。 - pub const ALL: [Screen; 4] = [ + pub const ALL: [Screen; 5] = [ Screen::Instances, Screen::Download, + Screen::Content, Screen::Settings, Screen::Logs, ]; @@ -34,6 +37,7 @@ impl Screen { match self { Screen::Instances => "instances", Screen::Download => "download", + Screen::Content => "content", Screen::Settings => "settings", Screen::Logs => "logs", } @@ -49,7 +53,9 @@ impl Screen { match self { Screen::Instances => PaneKit::ListDetail, Screen::Settings => PaneKit::Supporting, - Screen::Download | Screen::Logs => PaneKit::Single, + // 内容库自带三栏(`pages/content/layout.rs`),版面基座仍是单列: + // 它的三栏是「页内栏」而不是 layout 模块那三种整页版面。 + Screen::Download | Screen::Content | Screen::Logs => PaneKit::Single, } } @@ -60,6 +66,7 @@ impl Screen { Some(match route { Route::Instances | Route::Detail(_) => Screen::Instances, Route::Download => Screen::Download, + Route::Content => Screen::Content, Route::Logs => Screen::Logs, Route::Settings => Screen::Settings, Route::Agent => return None, @@ -70,7 +77,8 @@ impl Screen { pub const fn nav(self) -> NavSection { match self { Screen::Instances => NavSection::Instances, - Screen::Download => NavSection::Download, + // M109(B1 裁定):内容库与版本管理共用同一条导航轨项 + Screen::Download | Screen::Content => NavSection::Download, Screen::Logs => NavSection::Logs, Screen::Settings => NavSection::Settings, } diff --git a/crates/app-iced/src/pages/tests.rs b/crates/app-iced/src/pages/tests.rs index c14ef65..469b772 100644 --- a/crates/app-iced/src/pages/tests.rs +++ b/crates/app-iced/src/pages/tests.rs @@ -23,10 +23,14 @@ fn ui() -> Ui { } #[test] -fn four_screens_answer_the_mission_list() { - assert_eq!(Screen::ALL.len(), 4); +fn five_screens_answer_the_mission_list() { + // M109:内容库是第五页(`download` 与 `content` 共用导航轨的「下载」项) + assert_eq!(Screen::ALL.len(), 5); let slugs: Vec<&str> = Screen::ALL.iter().map(|screen| screen.slug()).collect(); - assert_eq!(slugs, vec!["instances", "download", "settings", "logs"]); + assert_eq!( + slugs, + vec!["instances", "download", "content", "settings", "logs"] + ); for screen in Screen::ALL { assert_eq!(Screen::from_slug(screen.slug()), Some(screen)); } @@ -38,6 +42,8 @@ fn screens_declare_the_layout_their_page_needs() { assert_eq!(Screen::Instances.kit(), PaneKit::ListDetail); assert_eq!(Screen::Settings.kit(), PaneKit::Supporting); assert_eq!(Screen::Download.kit(), PaneKit::Single); + // 内容库自带页内三栏,版面基座仍是单列(见 `pages/content/layout.rs`) + assert_eq!(Screen::Content.kit(), PaneKit::Single); assert_eq!(Screen::Logs.kit(), PaneKit::Single); } diff --git a/crates/i18n/src/catalog.rs b/crates/i18n/src/catalog.rs index 2406264..0a274d3 100644 --- a/crates/i18n/src/catalog.rs +++ b/crates/i18n/src/catalog.rs @@ -113,6 +113,7 @@ macro_rules! finish { } mod agent; +mod content; mod core_text; mod download; mod font; @@ -131,6 +132,7 @@ mod golden; mod tests; use agent::agent_entries; +use content::content_entries; use core_text::core_text_entries; use download::{download_entries, version_entries}; use font::font_entries; @@ -167,6 +169,9 @@ nav_entries! { // `general_entries` 已经在 main 上占了链尾,所以本域排在它们**之后**: // `Key::ALL` 顺序 = 金表顺序 = 先 main 的全部域,再 version。 version_entries, + // M109:内容库页。与 M107 同一条纪律——本域排在 main 已占住的链尾**之后**, + // 于是 `Key::ALL` 顺序 = 金表顺序 = 先 main 的全部域,再 version,最后 content。 + content_entries, finish, {} } diff --git a/crates/i18n/src/catalog/content.rs b/crates/i18n/src/catalog/content.rs new file mode 100644 index 0000000..f9140ab --- /dev/null +++ b/crates/i18n/src/catalog/content.rs @@ -0,0 +1,86 @@ +//! 内容库文案(M109 `CONTENTDL`)。 +//! +//! 一句话一个 key:带变量的句子用 `{name}`,页面只做 [`crate::Catalog::render`],不拼片段。 +//! +//! ## 三条口径 +//! +//! 1. **只说能做的事**:整合包那条写的是「要等导入管线」,不是「稍后自动支持」; +//! 2. **站名也进词表**:`Modrinth` / `CurseForge` 的**中文译文必须与英文不同** +//! (`catalog/tests.rs` 的 `chinese_catalog_is_actually_chinese` 会红),所以中文写成「Modrinth 源」; +//! 3. **禁用态要能解释自己**:`ContentIncompatible*` / `ContentModpackNotReady` 就是禁用按钮旁边那几句话—— +//! 没有它们,禁用就等于「点了没反应」。 +//! +//! 域文件的形状见 `crate::catalog` 的「域装配」一节。 + +macro_rules! content_entries { + ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => { + $next! { $($rest,)* { + $($acc)* + // --- content library page (M109) --- + ContentTitle => ("Content", "内容库"), + ContentSubtitle => ( + "Modpacks, mods, resource packs and shaders", + "整合包、Mod、资源包与光影" + ), + ContentSectionVersions => ("Version management", "版本管理"), + ContentSectionContent => ("Content library", "内容库"), + ContentKindModpack => ("Modpacks", "整合包"), + ContentKindMod => ("Mods", "Mod"), + ContentKindResourcepack => ("Resource packs", "资源包"), + ContentKindShader => ("Shaders", "光影"), + ContentSourceModrinth => ("Modrinth", "Modrinth 源"), + ContentSourceCurseforge => ("CurseForge", "CurseForge 源"), + ContentSortRelevance => ("Relevance", "相关度"), + ContentSortDownloads => ("Downloads", "下载量"), + ContentSortNewest => ("Newest", "最新发布"), + ContentSortUpdated => ("Recently updated", "最近更新"), + ContentSearchPlaceholder => ("Search content…", "搜索内容…"), + ContentSearch => ("Search", "搜索"), + ContentFilterVersion => ("Game version", "游戏版本"), + ContentFilterLoader => ("Loader", "加载器"), + ContentFilterAny => ("Any", "不限"), + ContentCount => ("{count} results", "{count} 个结果"), + ContentLoading => ("Loading content…", "正在加载内容…"), + ContentLoadingHint => ( + "Results come from the network; nothing is cached offline yet.", + "结果来自网络,暂时没有离线缓存。" + ), + ContentEmptyTitle => ("Nothing matched", "没有匹配的内容"), + ContentEmptyBody => ( + "Try another keyword, or clear a filter.", + "换个关键词,或清掉一个筛选条件。" + ), + ContentFailedTitle => ("Could not load content", "内容加载失败"), + ContentFailedBody => ("Check the network, then retry.", "检查网络后重试。"), + ContentRetry => ("Retry", "重试"), + ContentLoadMore => ("Load more", "加载更多"), + ContentNoMore => ("No more results", "没有更多了"), + ContentDetailEmpty => ( + "Pick an item to see what it supports.", + "选一个项目看它支持什么。" + ), + ContentDetailDownloads => ("{count} downloads", "下载量 {count}"), + ContentDetailUpdated => ("Updated {date}", "更新于 {date}"), + ContentDetailLoaders => ("Loaders: {list}", "加载器:{list}"), + ContentDetailVersions => ("Game versions: {list}", "游戏版本:{list}"), + ContentInstall => ("Install into instance", "装入实例"), + ContentTargetInstance => ("Target instance", "目标实例"), + ContentNoInstances => ("No instance to install into", "没有可装入的实例"), + ContentIncompatibleVersion => ( + "Needs {version}; this instance runs {actual}", + "需要 {version},该实例运行的是 {actual}" + ), + ContentIncompatibleLoader => ("Not built for {loader}", "不支持 {loader}"), + ContentModpackNotReady => ( + "Modpack install comes with the M111 import pipeline", + "整合包安装要等 M111 的导入管线" + ), + ContentSourceUnavailable => ( + "Set ULL_CF_API_KEY to search CurseForge", + "要搜 CurseForge 请先设置 ULL_CF_API_KEY" + ), + } } + }; +} + +pub(crate) use content_entries; diff --git a/crates/i18n/src/catalog/golden.rs b/crates/i18n/src/catalog/golden.rs index d305dc8..8c55fb3 100644 --- a/crates/i18n/src/catalog/golden.rs +++ b/crates/i18n/src/catalog/golden.rs @@ -176,4 +176,46 @@ pub(super) const GOLDEN: &[(&str, &str, &str)] = &[ ("VersionSearchHitsOne", "1 match", "1 个匹配"), ("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"), ("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"), + // --- M109 内容库页(同样追加在链尾:`Key::ALL` 顺序 = 金表顺序) --- + ("ContentTitle", "Content", "内容库"), + ("ContentSubtitle", "Modpacks, mods, resource packs and shaders", "整合包、Mod、资源包与光影"), + ("ContentSectionVersions", "Version management", "版本管理"), + ("ContentSectionContent", "Content library", "内容库"), + ("ContentKindModpack", "Modpacks", "整合包"), + ("ContentKindMod", "Mods", "Mod"), + ("ContentKindResourcepack", "Resource packs", "资源包"), + ("ContentKindShader", "Shaders", "光影"), + ("ContentSourceModrinth", "Modrinth", "Modrinth 源"), + ("ContentSourceCurseforge", "CurseForge", "CurseForge 源"), + ("ContentSortRelevance", "Relevance", "相关度"), + ("ContentSortDownloads", "Downloads", "下载量"), + ("ContentSortNewest", "Newest", "最新发布"), + ("ContentSortUpdated", "Recently updated", "最近更新"), + ("ContentSearchPlaceholder", "Search content…", "搜索内容…"), + ("ContentSearch", "Search", "搜索"), + ("ContentFilterVersion", "Game version", "游戏版本"), + ("ContentFilterLoader", "Loader", "加载器"), + ("ContentFilterAny", "Any", "不限"), + ("ContentCount", "{count} results", "{count} 个结果"), + ("ContentLoading", "Loading content…", "正在加载内容…"), + ("ContentLoadingHint", "Results come from the network; nothing is cached offline yet.", "结果来自网络,暂时没有离线缓存。"), + ("ContentEmptyTitle", "Nothing matched", "没有匹配的内容"), + ("ContentEmptyBody", "Try another keyword, or clear a filter.", "换个关键词,或清掉一个筛选条件。"), + ("ContentFailedTitle", "Could not load content", "内容加载失败"), + ("ContentFailedBody", "Check the network, then retry.", "检查网络后重试。"), + ("ContentRetry", "Retry", "重试"), + ("ContentLoadMore", "Load more", "加载更多"), + ("ContentNoMore", "No more results", "没有更多了"), + ("ContentDetailEmpty", "Pick an item to see what it supports.", "选一个项目看它支持什么。"), + ("ContentDetailDownloads", "{count} downloads", "下载量 {count}"), + ("ContentDetailUpdated", "Updated {date}", "更新于 {date}"), + ("ContentDetailLoaders", "Loaders: {list}", "加载器:{list}"), + ("ContentDetailVersions", "Game versions: {list}", "游戏版本:{list}"), + ("ContentInstall", "Install into instance", "装入实例"), + ("ContentTargetInstance", "Target instance", "目标实例"), + ("ContentNoInstances", "No instance to install into", "没有可装入的实例"), + ("ContentIncompatibleVersion", "Needs {version}; this instance runs {actual}", "需要 {version},该实例运行的是 {actual}"), + ("ContentIncompatibleLoader", "Not built for {loader}", "不支持 {loader}"), + ("ContentModpackNotReady", "Modpack install comes with the M111 import pipeline", "整合包安装要等 M111 的导入管线"), + ("ContentSourceUnavailable", "Set ULL_CF_API_KEY to search CurseForge", "要搜 CurseForge 请先设置 ULL_CF_API_KEY"), ]; diff --git a/crates/presenter/src/dto/content.rs b/crates/presenter/src/dto/content.rs new file mode 100644 index 0000000..882c01f --- /dev/null +++ b/crates/presenter/src/dto/content.rs @@ -0,0 +1,359 @@ +//! 内容库 DTO(M109 `CONTENTDL`):Modrinth / CurseForge 上「整合包 / Mod / 资源包 / 光影」的 +//! **检索**与**装入指定实例**。 +//! +//! ## 为什么这些类型在 presenter 而不是 core +//! +//! D3 / 01c §5:UI 只经 `crate::dto` 消费后端,跨边界类型必须可序列化。而**检索本身** +//! (HTTP + 两个站点的 JSON 方言)今天落在 `crate::host::addon`:`crates/core` 的 `addon` 模块 +//! 只有加载器安装与整合包**格式**解析,没有 CF / Modrinth 的 API 客户端(M111 才做导入管线)。 +//! 所以这是「宿主层临时落点」的契约面——长期应下沉到 `core::addon`,届时这里只留薄 DTO。 +//! +//! 两个形状是**故意**的: +//! +//! - [`ContentHit::game_versions`] / [`ContentHit::loaders`] 来自检索结果的 facets ⇒ 页面在用户 +//! **点之前**就能判「装不装得上」(任务书第 3 条),不用点完才报错; +//! - [`ContentKind::dir`] 的 `None`(整合包)是「按钮禁用」的**唯一**判据,不是一个文案开关。 +//! +//! ## 密钥纪律(任务书第 5 条) +//! +//! CurseForge 的 `X-API-KEY` 从环境变量 `ULL_CF_API_KEY` 读,**不进仓库、不进 DTO、不进日志**。 +//! 这里只有 [`ContentResults::curseforge`] 一个 `bool`:源可用与否。 +//! 于是「密钥泄漏」在类型层就不可能——本 crate 没有任何字段能装下它。 + +use serde::{Deserialize, Serialize}; + +use crate::dto::{InstanceId, LoaderKind}; + +/// 内容类型(内容库页左侧那四条一级侧栏)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ContentKind { + /// 整合包(Modpack) + Modpack, + /// Mod + Mod, + /// 资源包(Resource pack) + Resourcepack, + /// 光影(Shader pack) + Shader, +} + +impl ContentKind { + /// 四条侧栏的声明顺序(= 画出来的顺序)。 + pub const ALL: [ContentKind; 4] = [ + ContentKind::Modpack, + ContentKind::Mod, + ContentKind::Resourcepack, + ContentKind::Shader, + ]; + + /// 稳定 slug(日志 / 文件名 / 命令行参数用,**不要改名**)。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Modpack => "modpack", + Self::Mod => "mod", + Self::Resourcepack => "resourcepack", + Self::Shader => "shader", + } + } + + /// Modrinth 的 `project_type` facet 值。 + #[must_use] + pub const fn modrinth_type(self) -> &'static str { + match self { + Self::Modpack => "modpack", + Self::Mod => "mod", + Self::Resourcepack => "resourcepack", + Self::Shader => "shader", + } + } + + /// 装入实例时的落盘目录(相对运行目录);`None` = 不是「一个文件进一个目录」这种语义。 + /// + /// 整合包没有目录:它要走 core 的导入管线(解压 + 补全 + 登记实例),那是 M111 的活, + /// 所以这里是 `None`——**按钮禁用由这个 `None` 驱动**,不是靠一句文案。 + #[must_use] + pub const fn dir(self) -> Option<&'static str> { + match self { + Self::Modpack => None, + Self::Mod => Some("mods"), + Self::Resourcepack => Some("resourcepacks"), + Self::Shader => Some("shaderpacks"), + } + } + + /// CurseForge 的 `classId`(CF API 的类目字段;数值是官方文档里的固定值)。 + #[must_use] + pub const fn curseforge_class(self) -> u32 { + match self { + Self::Modpack => 4471, + Self::Mod => 6, + Self::Resourcepack => 12, + Self::Shader => 6552, + } + } +} + +/// 内容来源。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ContentSource { + /// Modrinth(无需 API key,默认源) + Modrinth, + /// CurseForge(需要 `ULL_CF_API_KEY`) + Curseforge, +} + +impl ContentSource { + /// 两个来源的声明顺序(= 筛选条顺序)。 + pub const ALL: [ContentSource; 2] = [ContentSource::Modrinth, ContentSource::Curseforge]; + + /// 稳定 slug。 + #[must_use] + pub const fn slug(self) -> &'static str { + match self { + Self::Modrinth => "modrinth", + Self::Curseforge => "curseforge", + } + } +} + +/// 排序方式(两个站点的官方排序项,口径对齐后再映射到各自参数)。 +#[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> { + match loader { + LoaderKind::Fabric => Some("fabric"), + LoaderKind::Quilt => Some("quilt"), + LoaderKind::Forge => Some("forge"), + LoaderKind::NeoForge => Some("neoforge"), + LoaderKind::Vanilla | LoaderKind::OptiFine | LoaderKind::LiteLoader => None, + } +} + +/// 一次检索请求(**页面把它整份发出去**,不做本地过滤之外的乐观处理)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContentQuery { + /// 内容类型 + pub kind: ContentKind, + /// 来源 + pub source: ContentSource, + /// 关键词(空串 = 不带 `query` 参数,即「按热度浏览」) + pub text: String, + /// 游戏版本过滤(`None` = 不过滤) + pub game_version: Option, + /// 加载器过滤(`None` = 不过滤;对资源包 / 光影也照发——静默丢掉会让筛选条撒谎) + pub loader: Option, + /// 排序 + pub sort: ContentSort, + /// 偏移(分页游标) + pub offset: u32, + /// 每页条数 + pub limit: u32, +} + +impl ContentQuery { + /// 每页条数(20 = Modrinth 默认上限的一半,翻页手感与请求量折中)。 + pub const PAGE: u32 = 20; + + /// 某个类型的首页(来源 Modrinth、无关键词、无过滤、按相关度)。 + #[must_use] + pub fn first(kind: ContentKind) -> Self { + Self { + kind, + source: ContentSource::Modrinth, + text: String::new(), + game_version: None, + loader: None, + sort: ContentSort::Relevance, + offset: 0, + limit: Self::PAGE, + } + } + + /// 换一批(保留全部筛选条件,只把游标往后挪一页)。 + #[must_use] + pub fn next_page(&self) -> Self { + Self { + offset: self.offset.saturating_add(self.limit), + ..self.clone() + } + } + + /// 筛选条件是否与另一份相同(**不含游标**:判「结果是不是同一批条件拉回来的」用它)。 + #[must_use] + pub fn same_filters(&self, other: &Self) -> bool { + self.kind == other.kind + && self.source == other.source + && self.text == other.text + && self.game_version == other.game_version + && self.loader == other.loader + && self.sort == other.sort + } +} + +/// 一条检索结果(一个项目,不是文件)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContentHit { + /// 项目 id(Modrinth 的 base62 id / CF 的 modId;装入指令的载荷) + pub id: String, + /// 人读 slug(`sodium`;链接与日志里更可读) + pub slug: String, + /// 标题 + pub title: String, + /// 一句话说明(站点原文,不翻译:它是用户内容,不是界面文案) + pub summary: String, + /// 作者(Modrinth 只有团队 id 时给空串) + pub author: String, + /// 下载量 + pub downloads: u64, + /// 最近更新时间(ISO-8601 的日期部分;未知给空串) + pub updated: String, + /// 来源 + pub source: ContentSource, + /// 支持的游戏版本(升序由站点给;页面只做「包不包含」判断) + pub game_versions: Vec, + /// 支持的加载器(`fabric` / `forge` / `neoforge` / `quilt`) + pub loaders: Vec, + /// 图标 URL(`None` = 没有;页面不拉图,只把主机名画成占位标记) + pub icon: Option, +} + +impl ContentHit { + /// 这个项目支持某个游戏版本吗(空列表 = 站点没给,按「未知」处理 ⇒ 不拦)。 + #[must_use] + pub fn supports_version(&self, version: &str) -> bool { + self.game_versions.is_empty() || self.game_versions.iter().any(|item| item == version) + } + + /// 这个项目支持某个加载器吗(空列表 = 站点没给,按「未知」处理 ⇒ 不拦)。 + #[must_use] + pub fn supports_loader(&self, loader: LoaderKind) -> bool { + let Some(facet) = loader_facet(loader) else { + return true; + }; + self.loaders.is_empty() || self.loaders.iter().any(|item| item == facet) + } +} + +/// 一批检索结果。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContentResults { + /// 这份结果对应的请求(页面据此判断「这批还算不算当前条件」) + pub query: ContentQuery, + /// 命中项(顺序 = 站点顺序) + pub hits: Vec, + /// 命中总数(站点给的分页总数;`0` = 站点没给) + pub total: u32, + /// CurseForge 源此刻是否可用(`false` = 没有 `ULL_CF_API_KEY`) + pub curseforge: bool, +} + +impl ContentResults { + /// 还有下一页吗。 + /// + /// 判据是「本页装满了」而不是 `offset + len < total`:站点给的 `total` 有时是估算值, + /// 用「装满」判定最多多请求一次空页,不会出现「明明还有却翻不到」。 + #[must_use] + pub fn has_more(&self) -> bool { + self.hits.len() as u32 >= self.query.limit + } +} + +/// 「装入指定实例」的请求(UI 侧只发意图;选哪个文件由 host 按版本 + 加载器选)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContentInstallRequest { + /// 项目 id + pub project: String, + /// 项目标题(进度面板上显示的那一行;**不是** i18n 文案) + pub title: String, + /// 内容类型(决定落盘目录与「能不能装」) + pub kind: ContentKind, + /// 来源 + pub source: ContentSource, + /// 目标实例 + pub instance: InstanceId, + /// 目标实例的游戏版本(选文件用) + pub game_version: String, + /// 目标实例的加载器(选文件用) + pub loader: LoaderKind, +} + +/// 内容库页的状态(M109):**检索条件 + 最近一批结果 + 请求态**。 +/// +/// 条件为什么也存在这里:页面层不持有业务状态是老纪律(同 `download_query`);换页只挪游标、 +/// 条件只有一份,才不会出现「筛选条说的是 A、列表画的是 B」。 +#[derive(Debug, Clone, PartialEq)] +pub struct ContentState { + /// 当前检索条件(游标也在里面:换页就是换一份 `query`) + pub query: ContentQuery, + /// 最近一批结果(`None` = 还没拉过) + pub results: Option, + /// 有请求在飞(页面画加载态的依据;命中项只由事件写进来,不做乐观回显) + pub pending: bool, + /// 最近一次失败的原文(`None` = 没失败) + pub failed: Option, +} + +impl Default for ContentState { + fn default() -> Self { + Self { + query: ContentQuery::first(ContentKind::Mod), + results: None, + pending: false, + failed: None, + } + } +} + +impl ContentState { + /// 这批结果还算当前条件吗(`false` = 迟到的事件,丢掉,不许覆盖新的筛选结果)。 + #[must_use] + pub fn accepts(&self, results: &ContentResults) -> bool { + results.query.same_filters(&self.query) + } +} +#[cfg(test)] +mod tests; diff --git a/crates/presenter/src/dto/content/tests.rs b/crates/presenter/src/dto/content/tests.rs new file mode 100644 index 0000000..daed624 --- /dev/null +++ b/crates/presenter/src/dto/content/tests.rs @@ -0,0 +1,87 @@ +//! 内容库 DTO 的守门测试(M109)。从 `dto/content.rs` 拆出:那个文件已贴 400 行硬上限。 + +use super::*; + +fn hit(game_versions: Vec, loaders: Vec) -> ContentHit { + ContentHit { + id: "1".to_string(), + slug: "s".to_string(), + title: "t".to_string(), + summary: String::new(), + author: String::new(), + downloads: 0, + updated: String::new(), + source: ContentSource::Modrinth, + game_versions, + loaders, + icon: None, + } +} + +#[test] +fn only_file_kinds_have_install_directories() { + // 抓得住:把整合包也塞一个目录(那等于把「解压+补全+登记实例」偷偷降级成「拷一个文件进 mods」, + // 正是任务书第 3 条禁止的事)。 + assert_eq!(ContentKind::Modpack.dir(), None); + assert_eq!(ContentKind::Mod.dir(), Some("mods")); + assert_eq!(ContentKind::Resourcepack.dir(), Some("resourcepacks")); + assert_eq!(ContentKind::Shader.dir(), Some("shaderpacks")); +} + +#[test] +fn kinds_map_to_the_official_facets_and_class_ids() { + assert_eq!(ContentKind::Modpack.modrinth_type(), "modpack"); + assert_eq!(ContentKind::Shader.modrinth_type(), "shader"); + // CF 的四个 classId 是官方固定值,抄错就搜不到东西(而且不会报错,只会空列表) + assert_eq!(ContentKind::Mod.curseforge_class(), 6); + assert_eq!(ContentKind::Resourcepack.curseforge_class(), 12); + assert_eq!(ContentKind::Modpack.curseforge_class(), 4471); + assert_eq!(ContentKind::Shader.curseforge_class(), 6552); + for kind in ContentKind::ALL { + assert!(!kind.slug().is_empty()); + } +} + +#[test] +fn a_page_keeps_its_filters_and_only_moves_the_cursor() { + let mut query = ContentQuery::first(ContentKind::Mod); + query.game_version = Some("1.20.1".to_string()); + query.text = "sodium".to_string(); + + let next = query.next_page(); + assert_eq!(next.offset, ContentQuery::PAGE, "游标按一页走"); + assert!(next.same_filters(&query), "换页不改筛选条件"); + + let mut other = next.clone(); + other.text = "lithium".to_string(); + assert!(!other.same_filters(&query), "关键词变了就不是同一批条件"); +} + +#[test] +fn compatibility_checks_treat_missing_site_data_as_unknown_not_incompatible() { + let empty = hit(Vec::new(), Vec::new()); + // 站点没给数据时不许拦人(否则「不知道」会被画成「不兼容」) + assert!(empty.supports_version("1.20.1")); + assert!(empty.supports_loader(LoaderKind::Fabric)); + + let filled = hit(vec!["1.20.1".to_string()], vec!["fabric".to_string()]); + assert!(filled.supports_version("1.20.1")); + assert!(!filled.supports_version("1.21.9")); + assert!(filled.supports_loader(LoaderKind::Fabric)); + assert!(!filled.supports_loader(LoaderKind::Forge)); + // 原版没有 facet:不该被加载器过滤误伤 + assert!(filled.supports_loader(LoaderKind::Vanilla)); +} + +#[test] +fn has_more_is_driven_by_a_full_page() { + let mut results = ContentResults { + query: ContentQuery::first(ContentKind::Mod), + hits: Vec::new(), + total: 999, + curseforge: false, + }; + assert!(!results.has_more(), "空页 = 没有更多了"); + results.hits = vec![hit(Vec::new(), Vec::new()); ContentQuery::PAGE as usize]; + assert!(results.has_more(), "装满一页 = 还有下一页"); +} diff --git a/crates/presenter/src/dto/events.rs b/crates/presenter/src/dto/events.rs index e22bc0a..a515c83 100644 --- a/crates/presenter/src/dto/events.rs +++ b/crates/presenter/src/dto/events.rs @@ -9,9 +9,10 @@ use serde::{Deserialize, Serialize}; use crate::dialogs::{DialogId, DialogRequest}; use crate::dto::CreateRequest; use crate::dto::{ - AccountView, AgentEvent, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId, - InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine, LogOutcome, LogSession, - NoticeView, SessionId, SettingGroupView, VersionCategory, VersionSummary, + AccountView, AgentEvent, ContentInstallRequest, ContentQuery, ContentResults, DeviceLoginView, + DiagnosisReport, InstallProgressView, InstanceId, InstanceRuntime, InstanceSummary, + JavaPageView, LoaderKind, LogLine, LogOutcome, LogSession, NoticeView, SessionId, + SettingGroupView, VersionCategory, VersionSummary, }; use crate::toast::Toast; @@ -93,6 +94,10 @@ pub enum CoreEvent { SettingsChanged(Vec), /// 横幅通知刷新(隔离切换提示/启动失败等) NoticesChanged(Vec), + /// 内容库检索结果(M109):**整批替换**(同一份 `query` 的命中项,含分页游标) + ContentResults(Box), + /// 内容库检索失败(M109):一句人读原因(没网 / 站点限速 / 缺密钥) + ContentFailed(String), } /// 统一事件队列的元素:core / agent / UI 自产(对话框、Toast)都走这一条路, @@ -177,6 +182,10 @@ pub enum AppCommand { StopAgent, /// YOLO 开关(跳过确认,审计照记) SetYolo(bool), + /// 检索内容库(M109;结果经 [`CoreEvent::ContentResults`] 回来) + SearchContent(ContentQuery), + /// 把某个内容项目装进指定实例(M109;整合包不支持,host 会拒绝并给原因) + InstallContent(Box), } impl AppCommand { diff --git a/crates/presenter/src/dto/mod.rs b/crates/presenter/src/dto/mod.rs index 1d58ce4..810e0ca 100644 --- a/crates/presenter/src/dto/mod.rs +++ b/crates/presenter/src/dto/mod.rs @@ -6,7 +6,7 @@ //! P3 起由 [`crate::host`] 的真实后端驱动全部页面。 //! //! 结构:**按领域拆子模块**(instances / versions / logs / agent / accounts / java / -//! settings / install / notices / events),全量 `pub use` 出去,调用方依旧写 +//! settings / install / notices / content / events),全量 `pub use` 出去,调用方依旧写 //! `crate::dto::InstanceSummary` 这类路径。 //! 契约说明(字段口径、事件顺序、错误语义)见 `DevDocs/SubAgentsTalk/app-DTO通道约定.md`; //! UI 侧的唯一入口与 iced 接线用法见 crate 文档(`src/lib.rs`)。 @@ -16,6 +16,7 @@ mod accounts; mod agent; +mod content; mod events; mod install; mod instances; @@ -27,6 +28,10 @@ mod versions; pub use accounts::{AccountKind, AccountView, DeviceLoginPhase, DeviceLoginView}; pub use agent::{AgentEvent, ChatMessage, ChatRole, ConfirmRequest, Sensitivity, ToolCallView}; +pub use content::{ + loader_facet, ContentHit, ContentInstallRequest, ContentKind, ContentQuery, ContentResults, + ContentSort, ContentSource, ContentState, +}; pub use events::{AppCommand, AppEvent, Backend, CoreEvent, LaunchFinished, LiveLogBatch}; pub use install::{derive_speed, human_bytes, InstallProgressView, InstallStage}; pub use instances::{ diff --git a/crates/presenter/src/host/addon.rs b/crates/presenter/src/host/addon.rs new file mode 100644 index 0000000..2734124 --- /dev/null +++ b/crates/presenter/src/host/addon.rs @@ -0,0 +1,102 @@ +//! 内容库的宿主实现(M109 `CONTENTDL`):**检索** + **装进指定实例**。 +//! +//! ## 为什么这些代码在 presenter 而不是 core(临时落点,必须写清) +//! +//! `crates/core` 的 `addon` 模块今天只有两件事:加载器安装(`addon::loader`)与整合包**格式** +//! 解析(`addon::pack`,M110)。它**没有** CurseForge / Modrinth 的 API 客户端,也没有 +//! 「远程整合包一键装进实例」的管线(M111/M112 才做)。而任务书的硬约束是「UI 层不许自己发 +//! HTTP,新增能力一律经 presenter 的 host 转发」——所以本轮把客户端放在这里。 +//! +//! **长期应下沉到 `core::addon`**(已报 finding):那时本模块只剩「DTO ↔ 领域类型」的翻译, +//! 与 `host/versions.rs` 现在的角色一样。 +//! +//! ## 三条纪律 +//! +//! | 纪律 | 落点 | +//! |---|---| +//! | 网络只在后台线程 | [`spawn_search`] / [`spawn_install`] 走 `job::spawn`,UI 线程只收事件 | +//! | Modrinth 走 core 的下载引擎 | `addon/http.rs` 的 `download_bytes` 路径带**信号量限流 + 源健康度 + 指数退避**,不另写重试循环 | +//! | 密钥不进仓库 / 日志 | CF 的 `X-API-KEY` 只从 `ULL_CF_API_KEY` 读,日志只打 `set`/`unset`,DTO 里只有一个 `bool` | +//! +//! ## 模块 +//! +//! | 文件 | 职责 | +//! |---|---| +//! | [`sources`] | 两个站点的 JSON 方言 → [`crate::dto::ContentHit`] | +//! | `http` | 取文本:无 header 走引擎、带 header 走 client + 退避、百分号编码 | +//! | `install` | 一键装入:解析文件 → 下载到实例目录 → 进度回流 | +//! +//! ## 整合包与 CF 的边界(都写进报告) +//! +//! - 整合包**不做**「一键装入」:`ContentKind::Modpack.dir()` 是 `None`,UI 侧按钮禁用带原因, +//! host 侧再挡一次(就算绕过 UI 发指令也只回一条 `Failed`)。真流程是 M111 的导入管线。 +//! - CurseForge 本轮只做**检索**(需要用户配 `ULL_CF_API_KEY`),文件解析未接通。 + +mod http; +mod install; +mod sources; + +#[cfg(test)] +mod tests; + +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use crate::dto::{ContentQuery, ContentResults}; + +use super::env::HostEnv; +use super::job::{spawn, HostTask, JobSender}; + +/// 日志里只出现这两种状态,**永远不打密钥本身**。 +const KEY_UNSET: &str = "ULL_CF_API_KEY unset(CurseForge 源不可用)"; +const KEY_SET: &str = "ULL_CF_API_KEY set(CurseForge 源可用)"; + +/// CurseForge 的密钥(环境变量;空串 / 空白按「没配」处理)。 +/// +/// 为什么不放设置项:core 的 `setting` schema 不在本 mission 的 scope(已登记 finding)。 +/// 环境变量这条路至少满足「不进仓库、不进日志」两条硬要求。 +fn curseforge_key() -> Option { + std::env::var("ULL_CF_API_KEY") + .ok() + .map(|key| key.trim().to_string()) + .filter(|key| !key.is_empty()) +} + +/// CF 源此刻可用吗(页面据此禁用那条来源)。 +#[must_use] +pub fn curseforge_ready() -> bool { + curseforge_key().is_some() +} + +/// 打一行「配没配」的日志(**不含**密钥内容;host 装配时调一次)。 +pub fn log_curseforge_state() { + eprintln!( + "[content] {}", + if curseforge_ready() { + KEY_SET + } else { + KEY_UNSET + } + ); +} + +/// 后台检索(网络在后台线程,UI 不卡)。 +pub fn spawn_search(env: HostEnv, tx: JobSender, query: ContentQuery) { + spawn("content", move || { + let result: Result = sources::search(&env, &query); + let _ = tx.send(HostTask::Content(Box::new(result))); + }); +} + +/// 后台「装进指定实例」。 +pub fn spawn_install( + env: HostEnv, + tx: JobSender, + request: crate::dto::ContentInstallRequest, + cancel: Arc, + epoch: u64, +) { + spawn("content-install", move || { + install::install(&env, &tx, &request, &cancel, epoch); + }); +} diff --git a/crates/presenter/src/host/addon/http.rs b/crates/presenter/src/host/addon/http.rs new file mode 100644 index 0000000..07d73ec --- /dev/null +++ b/crates/presenter/src/host/addon/http.rs @@ -0,0 +1,123 @@ +//! HTTP 取文本(内容库唯一的网络出口)。 +//! +//! ## 两条路径,为什么不是一条 +//! +//! | 请求 | 走哪 | 拿到什么 | +//! |---|---|---| +//! | 无自定义 header(Modrinth 的全部请求) | [`DownloadEngine::download_bytes`] | **信号量限流 + 源健康度 + 指数退避 + 分类错误**,全是 core 现成的 | +//! | 带 header(只有 CurseForge 的 `X-API-KEY`) | `engine.client()` 直发 | 引擎的请求体没有 header 字段,只能自己发;代价是这条路径自己带一个 429/5xx 退避 | +//! +//! ## 密钥纪律 +//! +//! 带 header 的那条路径**只把 header 值喂给请求**:不进日志、不进错误串。错误串里只有 URL +//! (CF 的 URL 不含密钥)与状态码。 + +use std::time::Duration; + +use unlucky_core::download::blocking::block_on; +use unlucky_core::download::{DownloadRequest, ResourceKind}; + +use super::super::env::HostEnv; + +/// 站点要求的 User-Agent(Modrinth 明文要求带联系方式,否则限流)。 +pub(super) const USER_AGENT: &str = + "Unlucky-Launcher/0.1 (+https://github.com/AskaEth/Unlucky-Launcher)"; + +/// 最多重试次数(含首次)。 +const ATTEMPTS: u32 = 3; + +/// 取一段文本(JSON 原文)。 +pub(super) fn get(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Result { + if headers.is_empty() { + get_via_engine(env, url) + } else { + get_with_headers(env, url, headers) + } +} + +/// core 的下载引擎:限流与退避都在里面,这里只负责「URL → 文本」。 +fn get_via_engine(env: &HostEnv, url: &str) -> Result { + let engine = super::super::engine::engine(env); + let dir = env.layout.cache().join("content"); + std::fs::create_dir_all(&dir).map_err(|error| format!("建缓存目录失败:{error}"))?; + let request = DownloadRequest::new(ResourceKind::Other, url, dir.join("response.json")); + let bytes = block_on(engine.download_bytes(request)) + .map_err(|error| format!("请求 {url} 失败:{error}"))?; + String::from_utf8(bytes).map_err(|error| format!("响应不是 UTF-8:{error}")) +} + +/// 带 header 的请求(只有 CF 走这条):429 / 5xx / 连接类失败按退避重试。 +fn get_with_headers(env: &HostEnv, url: &str, headers: &[(&str, &str)]) -> Result { + let engine = super::super::engine::engine(env); + let owned: Vec<(String, String)> = headers + .iter() + .map(|(name, value)| ((*name).to_string(), (*value).to_string())) + .collect(); + + for attempt in 0..ATTEMPTS { + let result = block_on(async { + let mut builder = engine.client().get(url).header("User-Agent", USER_AGENT); + for (name, value) in &owned { + builder = builder.header(name.as_str(), value.as_str()); + } + let response = builder.send().await.map_err(|error| error.to_string())?; + let status = response.status(); + if status.is_success() { + return response.text().await.map_err(|error| error.to_string()); + } + let retry = response + .headers() + .get("retry-after") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + Err(format!("HTTP {} (retry_after={retry:?})", status.as_u16())) + }); + + match result { + Ok(body) => return Ok(body), + Err(reason) if attempt + 1 < ATTEMPTS && retryable(&reason) => { + std::thread::sleep(retry_after(&reason, attempt)); + } + Err(reason) => return Err(format!("请求 {url} 失败:{reason}")), + } + } + Err(format!("请求 {url} 失败:重试用尽")) +} + +/// 这个失败值得再试一次吗(429 / 5xx / 连接类;**401/403/404 一律不重试**,重试只会更糟)。 +pub(super) fn retryable(reason: &str) -> bool { + reason.contains("HTTP 429") + || reason.contains("HTTP 500") + || reason.contains("HTTP 502") + || reason.contains("HTTP 503") + || reason.contains("HTTP 504") + || reason.contains("error sending request") + || reason.contains("timed out") +} + +/// 退避时长:优先吃服务端给的 `Retry-After`(封顶 30s),否则指数退避(1s、2s、4s)。 +pub(super) fn retry_after(reason: &str, attempt: u32) -> Duration { + let parsed = reason + .split("retry_after=Some(") + .nth(1) + .and_then(|rest| rest.split(')').next()) + .and_then(|value| value.parse::().ok()); + match parsed { + Some(secs) => Duration::from_secs(secs.min(30)), + None => Duration::from_secs(1u64 << attempt.min(4)), + } +} + +/// 最小百分号编码(query 串用;不为一件小事引第三方 crate)。 +pub(super) fn encode(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + for byte in raw.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + out.push(char::from(byte)); + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} diff --git a/crates/presenter/src/host/addon/install.rs b/crates/presenter/src/host/addon/install.rs new file mode 100644 index 0000000..5aea786 --- /dev/null +++ b/crates/presenter/src/host/addon/install.rs @@ -0,0 +1,252 @@ +//! 「一键装入指定实例」(M109)。 +//! +//! ## 它到底做了什么(诚实版) +//! +//! | 内容类型 | 做什么 | 状态 | +//! |---|---|---| +//! | Mod / 资源包 / 光影 | 解析「该项目下匹配目标实例(版本 + 加载器)的那个文件」→ core 下载引擎下载到实例运行目录的 `mods/` `resourcepacks/` `shaderpacks/` → 进度经 `InstallProgressView` 回流(**复用下载页那块面板**) | 已接 | +//! | 整合包 | 一律 `Failed` + 一句原因 | **未做**:core 没有「远程整合包装进实例」的管线(M111) | +//! | CurseForge 文件解析 | 一律 `Failed` + 一句原因 | **未做**:需要带 key 的文件端点,本轮只做了 CF 的**检索** | +//! +//! ## 为什么进度只有阶段级,没有字节条 +//! +//! 字节级进度要走 `ProgressEmitter`(core 的 tokio 广播),而 presenter 的依赖表里没有 +//! `tokio` / `futures-util`(`Cargo.toml` 不在本 mission 的 scope),拿不到接收端的 `recv()`。 +//! 所以这里给的是**阶段 + 文件名**(`Planning` → `Downloading` → `Done`/`Failed`), +//! 面板在「进度未知」时本来就只画一行说明、不画条(`download/install.rs` 的既定口径)。 +//! 已登记为后续项:presenter 补 `futures-util` 之后换成字节级。 + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; + +use unlucky_core::download::blocking::block_on; +use unlucky_core::download::{DownloadRequest, ResourceKind}; +use unlucky_core::game::install::instance::read_instance_config; +use unlucky_core::game::isolation::derive_game_dir; + +use crate::dto::{ + loader_facet, ContentInstallRequest, ContentSource, InstallProgressView, InstallStage, + InstanceSummary, +}; + +use super::super::env::HostEnv; +use super::super::job::{HostTask, JobSender}; +use super::http::{encode, get}; +use super::sources::MODRINTH; + +/// 跑一次装入(同步;由 [`super::spawn_install`] 放进后台线程)。 +pub(super) fn install( + env: &HostEnv, + tx: &JobSender, + request: &ContentInstallRequest, + cancel: &AtomicBool, + epoch: u64, +) { + let mut progress = InstallProgressView { + version: request.title.clone(), + instance: request.instance.clone(), + loader: request.loader.label().to_string(), + files_total: 1, + ..InstallProgressView::new() + }; + + // 整合包:core 没有这条管线(M111),**不**假装能装。UI 侧按钮本来就是禁用的, + // 这里再挡一次是为了「绕过 UI 直接发指令」也不会静默做半件事。 + if request.kind.dir().is_none() { + fail( + tx, + progress, + "整合包安装管线未落地(M111);内容库对整合包只做检索", + epoch, + ); + return; + } + + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + match run(env, request, cancel, &mut progress, tx, epoch) { + Ok(done) => { + progress.stage = InstallStage::Done; + progress.files_done = 1; + progress.message = done; + let _ = tx.send(HostTask::InstallDone { + progress: Box::new(progress), + result: Ok(request.title.clone()), + epoch, + }); + } + Err(reason) => { + progress.stage = if cancel.load(Ordering::SeqCst) { + InstallStage::Cancelled + } else { + InstallStage::Failed + }; + let _ = tx.send(HostTask::InstallDone { + progress: Box::new(progress), + result: Err(reason), + epoch, + }); + } + } +} + +fn fail(tx: &JobSender, mut progress: InstallProgressView, reason: &str, epoch: u64) { + progress.stage = InstallStage::Failed; + progress.message = reason.to_string(); + let _ = tx.send(HostTask::InstallDone { + progress: Box::new(progress), + result: Err(reason.to_string()), + epoch, + }); +} + +/// 真正干活的那一段,返回一句人读的落盘描述。 +fn run( + env: &HostEnv, + request: &ContentInstallRequest, + cancel: &AtomicBool, + progress: &mut InstallProgressView, + tx: &JobSender, + epoch: u64, +) -> Result { + let instance = find_instance(env, &request.instance)?; + let game_dir = game_dir_of(env, &instance)?; + let dir = request + .kind + .dir() + .ok_or_else(|| "该内容类型不支持单文件装入".to_string())?; + let target_dir = game_dir.join(dir); + std::fs::create_dir_all(&target_dir) + .map_err(|error| format!("建目录 {} 失败:{error}", target_dir.display()))?; + + let file = resolve_file(env, request)?; + if cancel.load(Ordering::SeqCst) { + return Err("已取消(下载开始前)".to_string()); + } + progress.stage = InstallStage::Downloading; + progress.message = file.name.clone(); + let _ = tx.send(HostTask::Install { + progress: Box::new(progress.clone()), + epoch, + }); + + let dest = target_dir.join(&file.name); + let mut download = DownloadRequest::new(ResourceKind::ModFile, file.url, dest.clone()); + if let Some(sha1) = file.sha1.as_deref().filter(|hex| !hex.is_empty()) { + download = download.with_sha1(sha1); + } + block_on(super::super::engine::engine(env).download(download)) + .map_err(|error| format!("下载失败:{error}"))?; + Ok(format!("{}({})", file.name, dest.display())) +} + +fn find_instance(env: &HostEnv, id: &str) -> Result { + super::super::game::instances(env) + .into_iter() + .find(|instance| instance.id == id) + .ok_or_else(|| format!("找不到实例 {id}")) +} + +/// 实例的运行目录(D20 覆盖链:实例 `isolation` > 全局 `instances.mode`)。 +fn game_dir_of(env: &HostEnv, instance: &InstanceSummary) -> Result { + let global = super::super::game::global_mode(env); + let config = read_instance_config(&env.layout, &instance.id) + .map_err(|error| format!("读实例设置失败:{error}"))?; + derive_game_dir( + &env.game, + &instance.version_id, + global, + config.as_ref(), + None, + ) + .map(|derivation| derivation.game_dir) + .map_err(|error| format!("隔离目录派生失败:{error}")) +} + +/// 要下载的那个文件。 +struct RemoteFile { + url: String, + name: String, + sha1: Option, +} + +/// 解析「这个项目下最适合目标实例的那个文件」。 +fn resolve_file(env: &HostEnv, request: &ContentInstallRequest) -> Result { + match request.source { + ContentSource::Modrinth => modrinth_file(env, request), + ContentSource::Curseforge => Err( + "CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)".to_string(), + ), + } +} + +fn modrinth_file(env: &HostEnv, request: &ContentInstallRequest) -> Result { + let mut url = format!( + "{MODRINTH}/project/{}/version?game_versions={}", + request.project, + encode(&format!("[\"{}\"]", request.game_version)) + ); + if let Some(facet) = loader_facet(request.loader) { + url.push_str(&format!("&loaders={}", encode(&format!("[\"{facet}\"]")))); + } + let body = get(env, &url, &[])?; + let json: serde_json::Value = serde_json::from_str(&body) + .map_err(|error| format!("Modrinth 版本列表不是合法 JSON:{error}"))?; + let versions = json + .as_array() + .ok_or_else(|| "Modrinth 版本列表不是数组".to_string())?; + let first = versions + .first() + .ok_or_else(|| "这个项目没有匹配目标实例(版本 / 加载器)的文件".to_string())?; + let files = first + .get("files") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "版本里没有 files".to_string())?; + // 显式选 `primary: true`(Modrinth 官方口径):取第一个会拿到 sources jar 这类坑 + let file = files + .iter() + .find(|file| { + file.get("primary") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + }) + .or_else(|| files.first()) + .ok_or_else(|| "版本里没有可用文件".to_string())?; + + let url = file + .get("url") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "文件没有 url".to_string())?; + let name = file + .get("filename") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "文件没有 filename".to_string())?; + let sha1 = file + .get("hashes") + .and_then(|hashes| hashes.get("sha1")) + .and_then(serde_json::Value::as_str) + .map(ToString::to_string); + Ok(RemoteFile { + url: url.to_string(), + name: safe_file_name(name), + sha1, + }) +} + +/// 只留文件名本身:站点给的名字可能带路径分隔符,落盘前必须砍掉 +/// (与 `core::addon::pack::path` 的穿越双检同一条纪律)。 +pub(super) fn safe_file_name(raw: &str) -> String { + let name = raw + .rsplit(['/', '\\']) + .next() + .unwrap_or(raw) + .trim() + .to_string(); + if name.is_empty() || name == "." || name == ".." { + "download.bin".to_string() + } else { + name + } +} diff --git a/crates/presenter/src/host/addon/sources.rs b/crates/presenter/src/host/addon/sources.rs new file mode 100644 index 0000000..19bfd40 --- /dev/null +++ b/crates/presenter/src/host/addon/sources.rs @@ -0,0 +1,262 @@ +//! 两个站点的检索方言(M109):把各自的 JSON 翻成 [`ContentHit`]。 +//! +//! 两个站点在**同一件事上用的字段名完全不同**,所以这里每个站点一段、各自带注释: +//! +//! | 语义 | Modrinth `/v2/search` | CurseForge `/v1/mods/search` | +//! |---|---|---| +//! | 项目 id | `project_id`(base62) | `id`(数字) | +//! | 标题 / 简介 | `title` / `description` | `name` / `summary` | +//! | 下载量 | `downloads` | `downloadCount` | +//! | 游戏版本 | `versions` | `latestFilesIndexes[].gameVersion` | +//! | 加载器 | `categories` 里混着的 `fabric`/`forge`/… | `latestFilesIndexes[].modLoader`(数字枚举) | +//! +//! 加载器那一行是最容易抄错的地方:Modrinth 的 `categories` 同时装着 `optimization` 这种**分类标签**, +//! 直接整份当加载器用,画面上就会多出「optimization」这种假加载器(`sources/tests` 钉住了这一条)。 + +use serde_json::Value; + +use crate::dto::{ + loader_facet, ContentHit, ContentKind, ContentQuery, ContentResults, ContentSource, +}; + +use super::super::env::HostEnv; +use super::curseforge_ready; +use super::http::{encode, get}; + +/// Modrinth v2 的根(无需 key)。 +pub(super) const MODRINTH: &str = "https://api.modrinth.com/v2"; +/// CurseForge v1 的根(需要 `X-API-KEY`)。 +pub(super) const CURSEFORGE: &str = "https://api.curseforge.com/v1"; +/// CurseForge 的「Minecraft」gameId(官方固定值)。 +const CF_MINECRAFT: u32 = 432; + +/// 检索分派。 +pub(super) fn search(env: &HostEnv, query: &ContentQuery) -> Result { + match query.source { + ContentSource::Modrinth => modrinth(env, query), + ContentSource::Curseforge => curseforge(env, query), + } +} + +// --------------------------------------------------------------- Modrinth + +fn modrinth(env: &HostEnv, query: &ContentQuery) -> Result { + let mut facets = vec![format!("[\"project_type:{}\"]", query.kind.modrinth_type())]; + if let Some(version) = &query.game_version { + facets.push(format!("[\"versions:{version}\"]")); + } + if let Some(facet) = query.loader.and_then(loader_facet) { + facets.push(format!("[\"categories:{facet}\"]")); + } + + let mut url = format!( + "{MODRINTH}/search?limit={}&offset={}&index={}&facets={}", + query.limit.min(100), + query.offset, + query.sort.modrinth_index(), + encode(&format!("[{}]", facets.join(","))) + ); + if !query.text.trim().is_empty() { + url.push_str(&format!("&query={}", encode(query.text.trim()))); + } + + let body = get(env, &url, &[])?; + let json: Value = serde_json::from_str(&body) + .map_err(|error| format!("Modrinth 返回的不是合法 JSON:{error}"))?; + let hits = json + .get("hits") + .and_then(Value::as_array) + .ok_or_else(|| "Modrinth 返回里没有 hits 字段".to_string())? + .iter() + .map(modrinth_hit) + .collect::>(); + + Ok(ContentResults { + query: query.clone(), + hits, + total: u32_of(&json, "total_hits"), + curseforge: curseforge_ready(), + }) +} + +/// 一条 Modrinth 命中项 → DTO(缺字段一律给空/零,**不**编造)。 +fn modrinth_hit(value: &Value) -> ContentHit { + let loaders = strings(value, "categories") + .into_iter() + // `categories` 混着加载器与分类标签:只留已知的四个加载器 + .filter(|tag| matches!(tag.as_str(), "fabric" | "forge" | "neoforge" | "quilt")) + .collect(); + + ContentHit { + id: text(value, "project_id"), + slug: text(value, "slug"), + title: text(value, "title"), + summary: text(value, "description"), + author: text(value, "author"), + downloads: u64_of(value, "downloads"), + updated: date_part(&text(value, "updated")), + source: ContentSource::Modrinth, + game_versions: strings(value, "versions"), + loaders, + icon: optional_text(value, "icon_url"), + } +} + +// --------------------------------------------------------------- CurseForge + +fn curseforge(env: &HostEnv, query: &ContentQuery) -> Result { + let Some(key) = super::curseforge_key() else { + return Err("CurseForge 需要 API key(ULL_CF_API_KEY 未设置)".to_string()); + }; + let mut url = format!( + "{CURSEFORGE}/mods/search?gameId={CF_MINECRAFT}&classId={}&index={}&pageSize={}&sortField={}&sortOrder=desc", + query.kind.curseforge_class(), + query.offset, + query.limit.min(50), + query.sort.curseforge_field() + ); + if !query.text.trim().is_empty() { + url.push_str(&format!("&searchFilter={}", encode(query.text.trim()))); + } + if let Some(version) = &query.game_version { + url.push_str(&format!("&gameVersion={}", encode(version))); + } + + let body = get(env, &url, &[("x-api-key", key.as_str())])?; + let json: Value = serde_json::from_str(&body) + .map_err(|error| format!("CurseForge 返回的不是合法 JSON:{error}"))?; + let hits = json + .get("data") + .and_then(Value::as_array) + .ok_or_else(|| "CurseForge 返回里没有 data 字段".to_string())? + .iter() + .map(curseforge_hit) + .collect::>(); + + Ok(ContentResults { + query: query.clone(), + hits, + total: json + .get("pagination") + .map(|pagination| u64_of(pagination, "totalCount")) + .and_then(|value| u32::try_from(value).ok()) + .unwrap_or(0), + curseforge: true, + }) +} + +/// 一条 CF 命中项 → DTO。加载器只能从 `latestFilesIndexes[].modLoader` 的数字枚举反推。 +fn curseforge_hit(value: &Value) -> ContentHit { + let mut game_versions: Vec = Vec::new(); + let mut loaders: Vec = Vec::new(); + if let Some(indexes) = value.get("latestFilesIndexes").and_then(Value::as_array) { + for entry in indexes { + if let Some(version) = entry.get("gameVersion").and_then(Value::as_str) { + if !game_versions.iter().any(|item| item == version) { + game_versions.push(version.to_string()); + } + } + if let Some(loader) = entry + .get("modLoader") + .and_then(Value::as_u64) + .and_then(curseforge_loader) + { + if !loaders.iter().any(|item| item == loader) { + loaders.push(loader.to_string()); + } + } + } + } + let author = value + .get("authors") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .map(|entry| text(entry, "name")) + .unwrap_or_default(); + + ContentHit { + id: value + .get("id") + .and_then(Value::as_u64) + .map(|id| id.to_string()) + .unwrap_or_default(), + slug: text(value, "slug"), + title: text(value, "name"), + summary: text(value, "summary"), + author, + downloads: u64_of(value, "downloadCount"), + updated: date_part(&text(value, "dateModified")), + source: ContentSource::Curseforge, + game_versions, + loaders, + icon: value + .get("logo") + .and_then(|logo| logo.get("url")) + .and_then(Value::as_str) + .map(ToString::to_string), + } +} + +/// CF 的 `modLoader` 枚举 → Modrinth 口径的 loader 名(1 Forge / 4 Fabric / 5 Quilt / 6 NeoForge)。 +pub(super) fn curseforge_loader(raw: u64) -> Option<&'static str> { + match raw { + 1 => Some("forge"), + 4 => Some("fabric"), + 5 => Some("quilt"), + 6 => Some("neoforge"), + _ => None, + } +} + +// --------------------------------------------------------------- 小工具 + +/// 取字符串字段(缺 / 类型不对 ⇒ 空串)。 +fn text(value: &Value, key: &str) -> String { + value + .get(key) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() +} + +/// 取可选字符串字段(空串也当没有——站点会给 `""` 表示没图)。 +fn optional_text(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(ToString::to_string) +} + +/// 取字符串数组(缺 / 类型不对 ⇒ 空 vec)。 +fn strings(value: &Value, key: &str) -> Vec { + value + .get(key) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(ToString::to_string)) + .collect() + }) + .unwrap_or_default() +} + +fn u64_of(value: &Value, key: &str) -> u64 { + value.get(key).and_then(Value::as_u64).unwrap_or(0) +} + +fn u32_of(value: &Value, key: &str) -> u32 { + u32::try_from(u64_of(value, key)).unwrap_or(0) +} + +/// ISO-8601 → 日期部分(`2024-05-01T…` → `2024-05-01`;短串原样返回)。 +pub(super) fn date_part(raw: &str) -> String { + raw.get(..10).unwrap_or(raw).to_string() +} + +/// 类型名只用来钉住「四条侧栏都在」的常量没被改乱(检索本身按 `kind` 走 facet)。 +#[must_use] +pub(super) fn all_kinds() -> [ContentKind; 4] { + ContentKind::ALL +} diff --git a/crates/presenter/src/host/addon/tests.rs b/crates/presenter/src/host/addon/tests.rs new file mode 100644 index 0000000..0d4a135 --- /dev/null +++ b/crates/presenter/src/host/addon/tests.rs @@ -0,0 +1,85 @@ +//! 内容库宿主的守门测试(M109):编码、文件名穿越、退避判定、两个站点的方言映射。 + +use std::time::Duration; + +use super::http::{encode, retry_after, retryable}; +use super::install::safe_file_name; +use super::sources::{all_kinds, curseforge_loader, date_part}; +use crate::dto::ContentKind; + +#[test] +fn percent_encoding_keeps_the_query_readable_and_escapes_the_rest() { + assert_eq!(encode("1.20.1"), "1.20.1"); + assert_eq!( + encode("[\"versions:1.20.1\"]"), + "%5B%22versions%3A1.20.1%22%5D" + ); + assert_eq!(encode("中文"), "%E4%B8%AD%E6%96%87"); + assert_eq!(encode("a b"), "a%20b"); +} + +#[test] +fn file_names_never_carry_a_path() { + // 抓得住:站点给的 `../../evil.jar` 落到实例目录之外(穿越)。 + assert_eq!(safe_file_name("sodium.jar"), "sodium.jar"); + assert_eq!(safe_file_name("../../evil.jar"), "evil.jar"); + assert_eq!(safe_file_name("mods\\jei.jar"), "jei.jar"); + assert_eq!(safe_file_name(".."), "download.bin"); + assert_eq!(safe_file_name(""), "download.bin"); +} + +#[test] +fn dates_are_sliced_not_guessed() { + assert_eq!(date_part("2024-05-01T12:00:00Z"), "2024-05-01"); + assert_eq!(date_part("2024-05"), "2024-05"); + assert_eq!(date_part(""), ""); +} + +#[test] +fn only_rate_limited_and_transient_failures_are_retried() { + assert!(retryable("HTTP 429 (retry_after=Some(3))")); + assert!(retryable("HTTP 503 (retry_after=None)")); + assert!(!retryable("HTTP 404 (retry_after=None)")); + assert!( + !retryable("HTTP 401 (retry_after=None)"), + "密钥错重试只会更糟" + ); +} + +#[test] +fn retry_after_prefers_the_server_and_falls_back_to_exponential() { + assert_eq!( + retry_after("HTTP 429 (retry_after=Some(7))", 0), + Duration::from_secs(7) + ); + assert_eq!( + retry_after("HTTP 429 (retry_after=None)", 0), + Duration::from_secs(1) + ); + assert_eq!( + retry_after("HTTP 429 (retry_after=None)", 2), + Duration::from_secs(4) + ); + // 服务端要得再多也不等超过 30s(交互与截图不能被一次退避挂死) + assert_eq!( + retry_after("HTTP 429 (retry_after=Some(900))", 0), + Duration::from_secs(30) + ); +} + +#[test] +fn curseforge_loader_ids_follow_the_official_enum() { + assert_eq!(curseforge_loader(1), Some("forge")); + assert_eq!(curseforge_loader(4), Some("fabric")); + assert_eq!(curseforge_loader(5), Some("quilt")); + assert_eq!(curseforge_loader(6), Some("neoforge")); + assert_eq!(curseforge_loader(99), None); +} + +#[test] +fn the_four_content_kinds_are_the_four_side_entries() { + assert_eq!( + all_kinds().map(ContentKind::slug), + ["modpack", "mod", "resourcepack", "shader"] + ); +} diff --git a/crates/presenter/src/host/dispatch.rs b/crates/presenter/src/host/dispatch.rs index 1d1b525..09af427 100644 --- a/crates/presenter/src/host/dispatch.rs +++ b/crates/presenter/src/host/dispatch.rs @@ -6,7 +6,9 @@ use crate::dto::{AppCommand, Backend, CoreEvent, CreateRequest, LoaderKind}; use crate::toast::ToastKind; use super::accounts::{self, LoginJob}; -use super::{game, install, java, launch, loader_versions, logs, settings, versions, CoreHost}; +use super::{ + addon, game, install, java, launch, loader_versions, logs, settings, versions, CoreHost, +}; impl CoreHost { /// 下发一条用户意图(`Backend::dispatch` 的实现体)。 @@ -168,6 +170,20 @@ impl CoreHost { self.notices.retain(|notice| notice.id != id); self.send(CoreEvent::NoticesChanged(self.notices.clone())); } + // M109:内容库检索(网络在后台线程,结果经 `HostTask::Content` 回来) + AppCommand::SearchContent(query) => { + addon::spawn_search(self.env.clone(), self.tx.clone(), query); + } + // M109:把某个内容项目装进指定实例。取消标记与安装面板共用同一条路 + // (`CancelInstall` 会把标记置位,下载在阶段边界收尾)。 + AppCommand::InstallContent(request) => { + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + self.install_cancel = Some(std::sync::Arc::clone(&cancel)); + // 代次与版本安装共用同一条路(M105 的形状):取消/被顶掉之后,旧任务带回来的 + // 进度事件一律被 `handle` 丢掉,内容库种的这次安装不会把面板重新点亮。 + let epoch = self.next_install_epoch(); + addon::spawn_install(self.env.clone(), self.tx.clone(), *request, cancel, epoch); + } // AI 对话:agent 的 GUI host 未落地前用脚本回复(不接真实模型) other @ (AppCommand::AskAgent(_) | AppCommand::StopAgent | AppCommand::SetYolo(_)) => { self.chat.dispatch(other); diff --git a/crates/presenter/src/host/handle.rs b/crates/presenter/src/host/handle.rs index 0585505..5ac4743 100644 --- a/crates/presenter/src/host/handle.rs +++ b/crates/presenter/src/host/handle.rs @@ -34,10 +34,16 @@ impl CoreHost { result, }); } + // M109:内容库检索。`Err` 不弹 toast——页面自己有失败态(四态之一), + // toast 只做「顺手提一句」会把可重试的错误变成一次性噪音。 + HostTask::Content(result) => match *result { + Ok(results) => self.send(CoreEvent::ContentResults(Box::new(results))), + Err(reason) => self.send(CoreEvent::ContentFailed(reason)), + }, + // M105 的形状(代次丢包)为准:旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉。 + // 这是「点了取消没反应」的根因:取消时 `InstallProgress(None)` 刚把面板收回, + // 旧任务的下一条「下载中」事件立刻又把它点亮。 HostTask::Install { progress, epoch } => { - // 旧任务(已取消 / 被下一轮顶掉)的进度一律丢掉:这是「点了取消没反应」的根因。 - // 取消时 `InstallProgress(None)` 刚把面板收回,旧任务的下一条「下载中」事件 - // 立刻又把它点亮,用户看到的就永远是那块还在涨的进度条。 if epoch != self.install_epoch { return; } diff --git a/crates/presenter/src/host/job.rs b/crates/presenter/src/host/job.rs index 39ec444..09a3399 100644 --- a/crates/presenter/src/host/job.rs +++ b/crates/presenter/src/host/job.rs @@ -7,7 +7,7 @@ use std::sync::mpsc::{Receiver, Sender}; use crate::dto::{ - AccountView, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId, + AccountView, ContentResults, DeviceLoginView, DiagnosisReport, InstallProgressView, InstanceId, InstanceRuntime, JavaPageView, LaunchFinished, LiveLogBatch, LoaderKind, LogLine, LogOutcome, LogSession, NoticeView, SessionId, VersionSummary, }; @@ -27,6 +27,8 @@ pub enum HostTask { /// `Ok(最新在前)` / `Err(人读原因)` result: Result, String>, }, + /// 内容库检索(M109;`Err` = 一句人读原因) + Content(Box>), /// 安装进度(高频,host 只做聚合与转发) Install { /// 进度快照 diff --git a/crates/presenter/src/host/mod.rs b/crates/presenter/src/host/mod.rs index b707942..eaa1154 100644 --- a/crates/presenter/src/host/mod.rs +++ b/crates/presenter/src/host/mod.rs @@ -13,6 +13,7 @@ //! 其余页面全部走真实 core。 mod accounts; +mod addon; mod dispatch; mod engine; pub mod env; @@ -141,6 +142,8 @@ impl CoreHost { if refresh { host.refresh_all(); } + // M109:CF 源可用与否只在装配时打一行(**不含密钥内容**,见 `addon` 模块文档)。 + addon::log_curseforge_state(); host } diff --git a/crates/presenter/src/router.rs b/crates/presenter/src/router.rs index b65f6d7..7b2f46d 100644 --- a/crates/presenter/src/router.rs +++ b/crates/presenter/src/router.rs @@ -11,7 +11,7 @@ use crate::dto::InstanceId; pub enum NavSection { /// 实例 Instances, - /// 下载 + /// 下载 / 版本管理 + 内容库(M109:内容库复用这一项,不新增第 6 个图标) Download, /// 日志 Logs, @@ -24,8 +24,13 @@ pub enum NavSection { pub enum Route { /// G1 实例页(主页) Instances, - /// G2 下载页 + /// G2 下载页(M107/M108 起是「版本管理」) Download, + /// 内容库(M109):整合包 / Mod / 资源包 / 光影,来源 Modrinth / CurseForge + /// + /// 它是**顶层页**(跟版本管理一样整栈替换),但高亮复用 [`NavSection::Download`]: + /// 用户令「下载页只负责下载整合包和Mod/光影」⇒ 同一入口下两块内容,不是一个新图标。 + Content, /// G3 日志页 Logs, /// G6 设置页 @@ -41,7 +46,7 @@ impl Route { pub fn is_top_level(&self) -> bool { matches!( self, - Self::Instances | Self::Download | Self::Logs | Self::Settings + Self::Instances | Self::Download | Self::Content | Self::Logs | Self::Settings ) } @@ -49,7 +54,7 @@ impl Route { pub fn nav(&self) -> Option { match self { Self::Instances | Self::Detail(_) => Some(NavSection::Instances), - Self::Download => Some(NavSection::Download), + Self::Download | Self::Content => Some(NavSection::Download), Self::Logs => Some(NavSection::Logs), Self::Settings => Some(NavSection::Settings), Self::Agent => None, diff --git a/crates/presenter/src/state.rs b/crates/presenter/src/state.rs index de73a69..2079ef5 100644 --- a/crates/presenter/src/state.rs +++ b/crates/presenter/src/state.rs @@ -6,11 +6,13 @@ use crate::agent::AgentRail; use crate::dialogs::DialogQueue; use crate::dto::{ - AccountView, AgentEvent, AppEvent, CoreEvent, DeviceLoginView, DiagnosisReport, - InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, LoaderKind, LogLine, - LogSession, NoticeView, Progress, SettingGroupView, VersionSummary, + AccountView, AgentEvent, AppEvent, ContentQuery, ContentResults, CoreEvent, DeviceLoginView, + DiagnosisReport, InstallProgressView, InstanceRuntime, InstanceSummary, JavaPageView, + LoaderKind, LogLine, LogSession, NoticeView, Progress, SettingGroupView, VersionSummary, }; use crate::prefs::UiPrefs; +// 内容库的视图态与其 DTO 放一起(`dto/content.rs`),路径 `state::ContentState` 不变。 +pub use crate::dto::ContentState; use crate::router::{AiPanelMode, Route, Router}; use crate::toast::{ToastCenter, ToastKind}; use crate::wizard::WizardState; @@ -106,6 +108,8 @@ pub struct AppState { pub wizard: WizardState, /// 正在实时尾随的会话 pub live_session: Option, + /// 内容库(M109):条件 / 结果 / 请求态 + pub content: ContentState, } impl Default for AppState { @@ -143,6 +147,7 @@ impl AppState { notices: Vec::new(), wizard: WizardState::new(), live_session: None, + content: ContentState::default(), } } @@ -266,9 +271,46 @@ impl AppState { .insert(mc_version, result); } CoreEvent::NoticesChanged(notices) => self.notices = notices, + CoreEvent::ContentResults(results) => self.apply_content(*results), + CoreEvent::ContentFailed(reason) => { + self.content.pending = false; + self.content.failed = Some(reason); + } } } + /// 内容库结果落地(M109):同条件换页 ⇒ **追加**;换条件 ⇒ **整批替换**;迟到 ⇒ 丢掉。 + /// + /// 这三条就是「列表画的永远是当前筛选条件」的全部保证——页面自己没有过滤逻辑,也没有第二份真相。 + fn apply_content(&mut self, results: ContentResults) { + self.content.pending = false; + self.content.failed = None; + if !self.content.accepts(&results) { + // 迟到的一批:用户已经改了条件,覆盖上去就会出现「筛选条说 A、列表是 B」。 + return; + } + match &mut self.content.results { + Some(current) if results.query.offset > 0 => { + current.hits.extend(results.hits); + current.total = results.total; + current.curseforge = results.curseforge; + current.query = results.query; + } + _ => self.content.results = Some(results), + } + } + + /// 记下新的检索条件并进入「请求在飞」态(指令由调用方随后 dispatch)。 + pub fn set_content_query(&mut self, query: ContentQuery) { + self.content.pending = true; + self.content.failed = None; + if query.offset == 0 { + // 换条件(不是换页):先把旧结果清掉,否则加载态会画出上一批命中项。 + self.content.results = None; + } + self.content.query = query; + } + /// agent 事件 → AI 栏视图模型(投影规则见 [`crate::agent::rail`])。 /// /// 未读规则:栏不可见时,助手侧新内容(助手/工具消息、错误、待确认请求)逐条累加 `unread`;