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:
1 parent
5adac230fe
commit
6b161227d8
42 files changed
+4071
-25
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` 提前消化;显式列出而不是 `_ => {}`,见模块文档
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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",
|
||||
"不能画「原版」"
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
/// 页面动作清单(骨架期够用就行;接线时按业务语义重命名/扩充)。
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
{}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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"),
|
||||
];
|
||||
@@ -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;
|
||||
@@ -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(), "装满一页 = 还有下一页");
|
||||
}
|
||||
@@ -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,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::{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/// 进度快照
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
|
||||
Reference in new issue
Block a user