feat(app-iced): M109 CONTENTDL —— 内容库页(整合包/Mod/资源包/光影)

用户令「下载页只负责下载整合包和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 行。
This commit is contained in:
AskaEth committed 2026-11-05 03:20:21 +08:00
1 parent 5adac230fe
commit 6b161227d8
42 files changed
+4071 -25

No files matched your search

+2 -1
View File
@@ -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,
+8
View File
@@ -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;
+6 -1
View File
@@ -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<Message> {
})
}
/// 取证开关 `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<Route> {
"settings" => Some(Route::Settings),
// M104:AI 独立页也要能直达(三态矩阵的 Detached 那张)
"agent" => Some(Route::Agent),
// M109:内容库(截图矩阵要能直达;页面内的四条侧栏不在路由里)
"content" => Some(Route::Content),
_ => None,
}
}
+163
View File
@@ -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<Message> {
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<ContentInstallRequest> {
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,
})
}
}
@@ -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` 提前消化;显式列出而不是 `_ => {}`,见模块文档
+13 -1
View File
@@ -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<crate::cli::ForcedState>) -> 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]
+94
View File
@@ -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)
}
+208
View File
@@ -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<Target> {
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<Hit> {
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()
},
}
}
+194
View File
@@ -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());
}
}
@@ -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<String>),
/// 换加载器过滤(`None` = 不限)
SetLoader(Option<LoaderKind>),
/// 选中一个项目(**页面内选中**,不 dispatch)
Select(String),
/// 选目标实例(一键装入的对象)
SetTarget(String),
/// 加载下一页
LoadMore,
/// 同条件重试(失败态的出口)
Retry,
/// 装进目标实例
Install,
}
+281
View File
@@ -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<Hit>,
/// 选中项
pub selected: Option<String>,
/// 当前状态
pub status: Status,
/// 命中总数(`0` = 站点没给)
pub total: u32,
/// 还有下一页
pub has_more: bool,
/// CurseForge 源可用(缺 key 时筛选条里那条禁用并说明)
pub curseforge: bool,
/// 目标实例候选
pub targets: Vec<Target>,
/// 选中的目标实例
pub target: Option<String>,
/// 选中目标实例的可读标签(详情面板「装入到 …」)
pub target_label: Option<String>,
/// 装入不上的原因(空 = 可以装)
pub blockers: Vec<Blocker>,
/// 安装面板的视图模型(**复用下载页那块**:`pages::download::install_panel`)
///
/// 存整个 `Download` 而不是只存一帧视图:面板的入参就是 `&Download`,而 iced 的控件树
/// 借用它 ⇒ 被借的东西必须活得比这一帧长(局部临时值不行)。
pub panel: Option<Download>,
/// 游戏版本筛选的候选(来自实例;去重、保持实例顺序)
pub versions: Vec<String>,
/// 关键词草稿
pub draft: String,
}
/// 筛选条的展示态(数字/枚举都在这里,页面不再从 `ContentQuery` 里挖字段)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentQueryView {
/// 来源
pub source: ContentSource,
/// 排序
pub sort: ContentSort,
/// 游戏版本过滤
pub version: Option<String>,
/// 加载器过滤
pub loader: Option<LoaderKind>,
/// 已提交的关键词(输入框显示的是 `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<Hit> = 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<Target> = 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<String> = 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<String> {
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<Download>`。
fn content_install(state: &AppState, catalog: Catalog) -> Option<Download> {
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<Blocker> {
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::<Vec<_>>();
if versions.len() > shown.len() {
format!("{}…", shown.join(", "))
} else {
shown.join(", ")
}
}
#[cfg(test)]
#[path = "model/tests.rs"]
mod tests;
@@ -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<String>,
/// 目标实例 id(`None` = 没选 → 装入按钮禁用并说明)
pub target: Option<String>,
/// 关键词输入框里的草稿(**未提交**;提交后才进 `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<String>,
/// 支持的加载器(**站点原样的小写 facet**:兼容性判定要用它跟 `loader_facet` 比)
pub loaders: Vec<String>,
/// 来源(行上标来源徽标)
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::<String>() + 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(),
}
}
}
@@ -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<Target> {
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<Blocker> {
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<ContentResults>, 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<ContentHit>, 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",
"不能画「原版」"
);
}
+333
View File
@@ -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::<Vec<_>>(),
)
.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>>) -> 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<Element<'a, PageMessage>> {
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,
}
}
@@ -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::<Vec<_>>()
.join(", ")
};
frame::text_role_owned(
ui,
TypeRole::BodySmall,
false,
catalog.render(key, &[("list", &list)]),
ui.color(Role::OnSurfaceVariant),
)
}
@@ -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>>,
) -> 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<Element>` → 一行(顺序就是声明顺序)。
pub(crate) fn wrapped_row<'a>(
ui: &'a Ui,
chips: Vec<Element<'a, PageMessage>>,
) -> Element<'a, PageMessage> {
row(chips)
.spacing(ui.space(Space::X1))
.align_y(Alignment::Center)
.into()
}
@@ -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()
);
}
}
+222
View File
@@ -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),
}
}
+36
View File
@@ -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<Download>`,它不需要也不该认识 `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<Download> {
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(
+6
View File
@@ -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),
}
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
+4
View File
@@ -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 => {}
}
+5
View File
@@ -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,
}
+11 -3
View File
@@ -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,
}
+9 -3
View File
@@ -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);
}
+5
View File
@@ -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,
{}
}
+86
View File
@@ -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;
+42
View File
@@ -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"),
];
+359
View File
@@ -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<String>,
/// 加载器过滤(`None` = 不过滤;对资源包 / 光影也照发——静默丢掉会让筛选条撒谎)
pub loader: Option<LoaderKind>,
/// 排序
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<String>,
/// 支持的加载器(`fabric` / `forge` / `neoforge` / `quilt`)
pub loaders: Vec<String>,
/// 图标 URL(`None` = 没有;页面不拉图,只把主机名画成占位标记)
pub icon: Option<String>,
}
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<ContentHit>,
/// 命中总数(站点给的分页总数;`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<ContentResults>,
/// 有请求在飞(页面画加载态的依据;命中项只由事件写进来,不做乐观回显)
pub pending: bool,
/// 最近一次失败的原文(`None` = 没失败)
pub failed: Option<String>,
}
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;
+87
View File
@@ -0,0 +1,87 @@
//! 内容库 DTO 的守门测试(M109)。从 `dto/content.rs` 拆出:那个文件已贴 400 行硬上限。
use super::*;
fn hit(game_versions: Vec<String>, loaders: Vec<String>) -> 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(), "装满一页 = 还有下一页");
}
+12 -3
View File
@@ -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<SettingGroupView>),
/// 横幅通知刷新(隔离切换提示/启动失败等)
NoticesChanged(Vec<NoticeView>),
/// 内容库检索结果(M109):**整批替换**(同一份 `query` 的命中项,含分页游标)
ContentResults(Box<ContentResults>),
/// 内容库检索失败(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<ContentInstallRequest>),
}
impl AppCommand {
+6 -1
View File
@@ -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::{
+102
View File
@@ -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<String> {
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<ContentResults, String> = 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<AtomicBool>,
epoch: u64,
) {
spawn("content-install", move || {
install::install(&env, &tx, &request, &cancel, epoch);
});
}
+123
View File
@@ -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<String, String> {
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<String, String> {
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<String, String> {
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::<u64>().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::<u64>().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
}
+252
View File
@@ -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<String, String> {
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<InstanceSummary, String> {
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<PathBuf, String> {
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<String>,
}
/// 解析「这个项目下最适合目标实例的那个文件」。
fn resolve_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
match request.source {
ContentSource::Modrinth => modrinth_file(env, request),
ContentSource::Curseforge => Err(
"CurseForge 的文件解析未接通(要带 key 的文件端点;见报告「没验的部分」)".to_string(),
),
}
}
fn modrinth_file(env: &HostEnv, request: &ContentInstallRequest) -> Result<RemoteFile, String> {
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
}
}
+262
View File
@@ -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<ContentResults, String> {
match query.source {
ContentSource::Modrinth => modrinth(env, query),
ContentSource::Curseforge => curseforge(env, query),
}
}
// --------------------------------------------------------------- Modrinth
fn modrinth(env: &HostEnv, query: &ContentQuery) -> Result<ContentResults, String> {
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::<Vec<_>>();
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<ContentResults, String> {
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::<Vec<_>>();
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<String> = Vec::new();
let mut loaders: Vec<String> = 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<String> {
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<String> {
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
}
+85
View File
@@ -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"]
);
}
+17 -1
View File
@@ -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);
+9 -3
View File
@@ -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;
}
+3 -1
View File
@@ -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<Vec<String>, String>,
},
/// 内容库检索(M109;`Err` = 一句人读原因)
Content(Box<Result<ContentResults, String>>),
/// 安装进度(高频,host 只做聚合与转发)
Install {
/// 进度快照
+3
View File
@@ -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
}
+9 -4
View File
@@ -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<NavSection> {
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,
+45 -3
View File
@@ -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<String>,
/// 内容库(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`;